Prerequisites: SkyWatch #1: Polling Live Skies — the ingestion pipeline, the quota budget, and the snapshot store (the first post in this series). This post joins those flight snapshots with live weather. Background: Pagination, Rate Limits & Retries.

"What was that plane?" is more interesting with "…in 25-knot winds." This post enriches flight snapshots with Open-Meteo weather: per-grid-cell caching, joining two sources with different freshness guarantees, and a dashboard that labels what "live" actually means. The one principle for this post: join on freshness, not just keys.

Priya (Wednesday, 2:40 PM): "Readers love the overhead page. Now they're asking why — why is that plane so low, why so many go-arounds today? Half the answers are weather. Can we put wind and conditions next to each flight?"

Dev: "We can. But you're about to marry two APIs with very different ideas of 'current.' Flights change by the second; weather changes by the quarter-hour. The join has to be honest about that gap."

The customer problem

A flight's story is incomplete without its air. A low approach makes sense in a 30-knot crosswind; a holding pattern makes sense under a storm cell. Priya's readers don't need a meteorology degree — they need one line next to each flight: wind 18 kt from the west, 14°C, raining. The data is free — Open-Meteo needs no API key and answers a single HTTP GET with current conditions for any latitude/longitude. The engineering problem isn't fetching weather. It's joining two live sources that were never designed to agree.

Clarify the ask

  • Weather where? Per-flight coordinates would mean one weather call per aircraft — hundreds of calls per poll cycle, burning quota for precision nobody needs. Weather doesn't change meaningfully over 500 meters. So: quantize the map into grid cells (say, 0.5° × 0.5°) and fetch weather per cell, not per flight.
  • How fresh? Open-Meteo updates its current conditions roughly every 15 minutes. Fetching more often than that buys nothing but quota burn. The cache TTL should mirror the source's own rhythm.
  • What if weather is missing? The flight still shows. Weather is enrichment, not a gate — a dashboard that hides planes because the weather call failed has the dependency backwards.

The minimum concept

One cell, one call, one join. Take the bounding box's center, fetch current weather, attach it to every flight in the snapshot:

import requests

def get_weather(lat, lon):
    # Units are a contract decision, not a default: the dashboard displays
    # knots, so request knots. A reader must never guess the unit.
    r = requests.get("https://api.open-meteo.com/v1/forecast",
                     params={"latitude": lat, "longitude": lon,
                             "current": "temperature_2m,wind_speed_10m,"
                                        "wind_direction_10m,weather_code",
                             "wind_speed_unit": "kn"},
                     timeout=15)
    r.raise_for_status()
    return r.json()["current"]

wx = get_weather(37.6, -122.4)
print(wx["temperature_2m"], wx["wind_speed_10m"], wx["weather_code"])
# 20.1  7.6  2  — degrees C, KNOTS (requested), and a WMO weather code

That works for a demo. It does not work for a dashboard: with 200 aircraft in the box, per-flight weather calls would mean 200 API calls per poll cycle. The grid-cell cache is the whole design:

flowchart LR subgraph Cells[Grid cells — 0.5° × 0.5°] C1[Cell A
37.5, -122.5] C2[Cell B
37.5, -122.0] C3[Cell C
38.0, -122.5] end C1 --> W[(Weather cache
1 row per cell
TTL 15 min)] C2 --> W C3 --> W F[Flight snapshot
200 aircraft] --> J{Join} W --> J J --> D[Enriched dashboard rows]

Build: the enrichment join

# 0.5° at ~38° latitude ≈ 56 km N-S × 44 km E-W. That's a regional
# approximation, not local truth: fine for "what's the weather over
# the bay," wrong for "why did this aircraft go around." For approach
# conditions, airport METAR observations beat a model grid — and surface
# conditions never explain what an aircraft felt at altitude. The grid
# resolution is a use-case decision, and weather enrichment must never
# claim to explain a maneuver.
CELL = 0.5  # degrees; coarse by design — documented, not assumed

def cell_key(lat, lon):
    return (round(lat / CELL) * CELL, round(lon / CELL) * CELL)

def enrich(snapshot, wx_cache):
    """Attach cached weather to every flight. Never block on weather —
    and never let a missing position delete an aircraft."""
    rows = []
    for ac in snapshot.values():
        lat, lon = ac.get("latitude"), ac.get("longitude")
        if lat is None or lon is None or not (-90 <= lat <= 90 and -180 <= lon <= 180):
            # No valid position: keep the aircraft, mark weather unavailable.
            rows.append({**ac, "wx": None, "wx_status": "unavailable",
                         "wx_reason": "no_position"})
            continue
        key = cell_key(lat, lon)
        wx = wx_cache.get(key)  # may be None — that's fine
        rows.append({
            **ac,
            "wx": wx["data"] if wx else None,
            # Two different clocks: the weather SOURCE's observation time
            # (from Open-Meteo's `time` field) vs when WE fetched it.
            # Readers see the observation; operators monitor the fetch.
            "wx_observed_at": wx["data"]["time"] if wx else None,
            "wx_fetched_at": wx["fetched_at"] if wx else None,
            "wx_status": wx_status(wx),
        })
    return rows

# Simplified teaching implementation — production needs: a shared
# requests.Session, single-flight refresh per cell, retry/backoff on 429/5xx,
# a per-cycle cell cap, and schema validation before replacing the cache.
# The invariant that matters: a failed refresh NEVER overwrites a valid entry.
def refresh_weather(cells, wx_cache, ttl=900):
    """Fetch only stale cells. Weather changes slowly; act like it."""
    now = time.time()
    for key in cells:
        entry = wx_cache.get(key)
        if entry and now - entry["fetched_at"] < ttl:
            continue
        lat, lon = key
        try:
            data = get_weather(lat, lon)  # schema-checked before it lands
            validate_weather_schema(data)
            wx_cache[key] = {"data": data, "fetched_at": now}
        except (requests.RequestException, SchemaError) as e:
            log(f"weather fetch failed for {key}: {e}; keeping previous entry")
            # previous entry stays untouched — stale is displayed, not hidden

def wx_status(wx, max_display_age=7200):
    """Display policy: fresh, stale-but-displayable, or unavailable.
    A 45-minute-old observation must never look equivalent to a fresh one."""
    if not wx:
        return "unavailable"
    age = time.time() - wx["fetched_at"]
    if age <= 900:
        return "fresh"
    if age <= max_display_age:
        return "stale"
    return "unavailable"  # too old to show next to a live flight

Two design decisions worth naming:

  1. Weather failure never removes flights. The wx field is nullable by design. A dashboard row with wx: null renders as "weather unavailable" — the plane is still there. Enrichment is additive; the day weather is down is not the day the dashboard goes dark.
  2. Every enriched row carries two clocks. observed_at (when we saw the flight) and wx_observed_at (when we fetched the weather). The dashboard needs both, because they disagree — and the disagreement is the honest part.

Break: when "live" disagrees with itself

flowchart LR subgraph T[One dashboard row, one moment] direction TB F[Flight position
observed 40s ago
fresh] Wx[Wind 18 kt
fetched 11 min ago
aging] end T --> Q{Reader asks:
“why so low?”} Q --> A[Answer uses both:
position is now,
wind is recent]

The freshness table for the whole system:

SourceUpdate rhythmOur poll/cached rhythmHonest label
OpenSky flight vectorsSeconds (receiver-dependent)Every ~3.6 min (quota)"position as of HH:MM"
Open-Meteo current~15 min15-min cell cache"weather as of HH:MM"

The failure mode this prevents: a reader sees "wind 8 kt" next to a go-around and concludes the pilot erred — when the wind reading is 40 minutes old and the actual gust front arrived 10 minutes ago. Stale enrichment presented as live is misinformation with a dashboard. The fix isn't fresher weather (the source doesn't offer it); it's labeled weather. Every number on the page carries its age, and the page teaches the reader to read the ages.

The trap: one "last updated" timestamp for the whole page. A single timestamp implies everything on the page shares a freshness. It doesn't. Flights are minutes old; weather is quarter-hours old. One timestamp lets the freshest source launder the stalest one. Per-source ages, or don't claim "live" at all.

Productionize: the dashboard contract

The dashboard's data contract, stated the way the validation lesson taught — what the consumer is promised, in writing:

  • Every row carries: callsign (nullable), baro_altitude_m (nullable), velocity_ms (nullable), position_observed_at (the flight's source observation time), wx (nullable object), wx_observed_at (the weather source's observation time — from the provider's time field), wx_fetched_at (when we retrieved it), wx_status (fresh / stale / unavailable), wx_grid_cell (the cell used for lookup), wx_source ("open-meteo/forecast"). Three different clocks, each labeled — never one timestamp doing three jobs.
  • Rows are never removed because weather is missing. Rows are removed when the aircraft leaves the bounding box or goes stale past a threshold (say, no contact for 15 minutes) — with the threshold documented, not magic.
  • WMO weather codes map to plain words ("3" → "overcast") in a versioned lookup table, not inline conditionals — the same "vocabulary is validated, never guessed" rule, applied to a weather API.
sequenceDiagram participant D as Dashboard participant S as Snapshot store participant W as Weather cache D->>S: latest flight snapshot S-->>D: 200 aircraft + observed_at D->>W: weather for cells in view W-->>D: cached cells (ages included) D->>D: join on cell_key
render per-source ages

Communicate: the reader's mental model

Priya's page gets a legend, not a footnote. Three lines, above the fold:

"Positions update every few minutes from OpenSky's community receivers. Weather refreshes every 15 minutes from Open-Meteo. Each row shows when its data was observed — check the ages before drawing conclusions."

That legend does something most dashboards don't: it teaches the reader how to distrust the page correctly. A reader who knows the wind is 11 minutes old won't blame a pilot for a gust the page couldn't see. Trustworthy software sometimes means teaching the user the limits of what you built.

Prove it: the three-aircraft test

The chapter's promise — every aircraft stays visible, every freshness label honest — is testable. Three fixtures:

AircraftFlight position ageWeather observation ageExpected display
A40 seconds11 minutesFlight + recent weather
B2 minutes45 minutesFlight + stale-weather warning
C1 minuteUnavailableFlight, no weather
def test_freshness_labels():
    rows = enrich(snapshot_three_aircraft(), wx_cache_three_states())
    by_id = {r["icao24"]: r for r in rows}
    assert len(by_id) == 3, "no aircraft may disappear"
    assert by_id["A"]["wx_status"] == "fresh"
    assert by_id["B"]["wx_status"] == "stale"
    assert by_id["C"]["wx"] is None and by_id["C"]["wx_status"] == "unavailable"

All three visible, all three labeled correctly. That's the join done truthfully, not just successfully.

Field check

  1. Your bounding box holds 300 aircraft across 6 grid cells. How many Open-Meteo calls per 15-minute cycle — 300 or 6? Why?
  2. The weather cache holds a cell fetched 45 minutes ago; the TTL is 15 minutes; the refresh just failed with a timeout. What does the dashboard show for flights in that cell?
  3. A reader complains: "your site showed wind 8 kt during yesterday's go-around, but the METAR said 28 kt gusts." The weather timestamp on that row was 50 minutes old. What went wrong, and what would you change?
  4. Why is per-source age labeling better than a single "last updated" timestamp for the page?
  5. Open-Meteo is free with generous limits. Why cache at all instead of fetching per request?
Answers

1. 6 — one per grid cell, not one per flight. Weather is smooth at 0.5° scale; per-flight calls would burn 300 calls per cycle for precision nobody can use. The cell is the join key between "where the plane is" and "what the air is doing there."

2. The flights still show, with the weather labeled 45 minutes old (or "weather unavailable" if you set a max-staleness display threshold). The stale entry is kept, not deleted — deleting it would turn "old weather" into "no weather," which is a different claim. Enrichment never gates the primary data.

3. Nothing in the pipeline malfunctioned — the source's own rhythm (15-min updates) plus a missed refresh meant the reader saw a stale number presented next to a live event. The fix is presentation and expectation: the row already carried the weather's age, so the deeper fix is the legend teaching readers to check ages, plus alerting (Post 3) when weather staleness exceeds a threshold so you notice before the reader does.

4. A single timestamp lets the freshest source launder the stalest one — "updated 1 min ago" next to a weather reading from 40 minutes ago. Per-source ages keep each claim attached to its own evidence. It's the dashboard version of the reconciliation rule: every number carries its provenance.

5. Three reasons: the source itself only updates ~15 minutes, so faster fetching buys nothing; "generous" is not "infinite" — a dashboard with real traffic multiplies every per-request call; and the cache is what keeps the dashboard alive when Open-Meteo has a bad 10 minutes. Cache for correctness of the design, not just economy.

Next: SkyWatch #3 — operating the dashboard. Quotas exhaust, APIs go down, data goes stale, and the 2 AM page doesn't care that the API is free.