Skip to main content

Weather timeline

Dynamic weather layers for the map — rain (radar, our own nowcast, the forecast), wind gusts and temperature as frames a client can scrub through and animate — served from our own service, not from a third-party tile API. Phones never talk to a weather upstream: the worker ingests each source on a schedule into immutable frames on disk and the API serves, per variable, a manifest plus per-frame tiles.

EndpointWhat it answers
GET /weather/layers.jsonWhich variables this server serves, with units, legend and the bands each has
GET /weather/{variable}/frames.jsonThat variable's frames, in order, and how fresh each source is
GET /tiles/{variable}/{frame}/{z}/{x}/{y}.pngOne 512-px RGBA tile of one frame (paths registered per variable: /tiles/rain/…, /tiles/gust/…, /tiles/temp/…)
POST /weather/rain/refresh?source=operaRun one source's ingest early, if the cap allows (auth; per source, any variable)
POST /weather/along-route/framesThe layers sampled at (point, ETA) pairs — rain, gusts and temperature at each stop's arrival time (auth)

variable is rain, gust, temp or vis (visibility, WP-4). The rain URLs and the rain manifest's shape are the ones the first rider-app release reads and do not change; the other variables answer the same shape in their own units.

VariableUnits shownFrames holdPalette
rainmm/hmm/h (forecast frames: the hour's amount, numerically the mean rate)logarithmic, transparent below 0.1
gustkm/hm/srider thresholds 30 / 50 / 70 / 90 / 120 km/h, transparent below 30 — a gust under that is weather, not a hazard
temp°C°C (NaN = no coverage, since −1 °C is a real value)diverging −15…40 with a hard edge at 0 °C (frost is a threshold, not a gradient); never transparent inside coverage

A frame under the wrong variable's tile path is 404, so the on-disk tile cache stays keyed by frame id alone.

Both answer 404 while RAIN_SOURCES is empty: the layer is off, not broken. The design, the source survey and the roadmap (nowcast, forecast, the capped user refresh) are in PLANNING/TODO_rain_timeline.md.

Sources​

Source idWhatCoverageStepLicence
operaEUMETNET OPERA instantaneous rain-rate composite (RATE, mm/h, 2 km) via the MeteoGate Open Radar Data API and its 24-hour S3 cache — the past bandEurope (−10.4…57.8 E, 31.7…67.6 N)15 min, ~20 min latencyCC BY 4.0 — exceptions noted in the product metadata
nowcastOur own extrapolation of the newest OPERA frames — the nowcast bandas opera15 min to +2 h, re-issued after every radar ingestderived work; credit EUMETNET OPERA
metnoMET Norway's 5-minute Nordic radar mosaic with its model-corrected +2 h nowcast, composited into the nowcast frames inside the mosaic — 1 km detail where it existsNordic (−8…41 E, 53…73 N), 1 kmread with each nowcast issueNLOD 2.0 / CC BY 4.0 — attribution to MET Norway
iconeuDWD ICON-EU TOT_PREC from opendata.dwd.de, de-accumulated to hourly amounts — the forecast bandEurope (−23.5…62.5 E, 29.5…70.5 N), 0.0625°hourly to RAIN_FORECAST_HOURS (24 by default, 78 max); model runs 00/06/12/18 UTC, published ~3 h laterGeoNutzV — attribution to DWD
gustDWD ICON-EU VMAX_10M — the maximum gust over the previous hour, hours 0…H as published — the gust variable's forecast bandas iconeuas iconeuas iconeu
tempDWD ICON-EU T_2M, 2 m temperature, hours 0…H — the temp variable's forecast bandas iconeuas iconeuas iconeu
visDWD ICON-EU VIS, visibility in metres, hours 0…H — the vis variable's forecast band. Drawn only where it limits the view: the palette is grey from 200 m, fading to nothing at 5 km (clear air is not drawn)as iconeuas iconeuas iconeu
nordicvisMET Norway's MEPS 2.5 km deterministic run (visibility_in_air, with fog_area_fraction ≥ 0.5 capping the visibility at 200 m so a foggy cell reads as fog) as overlay frames of the vis variable. The 1 km product has no visibility, and MEPS exists on thredds only as an .ncml aggregation, so this is read over OpenDAP (DAP2, one ~4 MB step per request, brackets URL-encoded) with the standard library — no netCDF client in the imageNordic (MEPS Lambert domain), 2.5 kmhourly to RAIN_FORECAST_HOURS; a new MEPS run at most every 3 h, polled every half hourNLOD 2.0 / CC BY 4.0 — attribution to MET Norway
metfcMET Norway's post-processed 1 km hourly forecast (precipitation_amount, wind_speed_of_gust, air_temperature_2m), pasted into the ICON-EU frames of all three variables inside its footprint, refreshed with each ICON run; a run ingested while thredds was down is re-issued with the paste on the next tick MET answersNordic (−8…41 E, 53…73 N), 1 kmhourly to +58 h; read at ICON ingestNLOD 2.0 / CC BY 4.0 — attribution to MET Norway
gfsNOAA GFS 0.25° (public S3 bucket, one .idx per file so a field is a byte range: rain APCP differenced from the run total, GUST, TMP 2 m, VIS, the categorical type CRAIN/CSNOW/CFRZR/CICEP, UGRD/VGRD for the direction) filling the ICON-EU frames where ICON has no data: ICON-EU's grid ends at 29.5° N, so Dakhla, Mauritania, Senegal and the Sahara had no weather. While an ICON run's frames are warped the frame grows to hold the GFS box and GFS's values go in the pixels ICON leaves empty; inside ICON's grid (and the Nordic 1 km paste) nothing changes. All four variables, plus the rain frames' phase band and the gust frames' direction band (the rain hazard band stays 0 there: no thunder information is read from GFS). No frames of its own; the GFS cycle used is the newest at or before the ICON run that has published the last needed hour (~4.5 h after its nominal time). Advisory: a GFS outage leaves the run as ICON alone (marked on gfs). A run ingested before gfs was switched on, or while GFS was not yet published, is re-issued once as the next revision (-v1) at the next tick that finds a published GFS cycle; the old revision and its tile cache go last. Radar has no free African source, so rain keeps its past and nowcast bands in Europe onlyRAIN_GFS_BOX, default −30…62.5 E, −36…31 N (all Africa and Arabia), 0.25° (~28 km)hourly (the ICON run's hours); GFS runs 00/06/12/18 UTCUS Government work, public domain
nordicrain / nordicgust / nordictempMET Norway's 1 km hourly forecast as frames of its own at ~1 km on the ground (2 000 Mercator m, RAIN_NORDIC_RES_M), drawn on top of the forecast band inside MET's footprint. The paste (metfc) gives the 8 km ICON frames MET's values; this layer keeps MET's resolution. Each forecast entry names its overlay frame (overlay)Nordic (−8…41 E, 53…73 N), ~1 kmhourly to RAIN_FORECAST_HOURS; a new MET run at most every 3 h (RAIN_NORDICTEMP_CADENCE_S …), polled every half hourNLOD 2.0 / CC BY 4.0 — attribution to MET Norway

How the nowcast is made​

No machine learning and no third party: block-wise phase correlation between the two newest radar frames (and the pair before, averaged) gives a displacement field — each 128-px block's cross-power-spectrum peak, Hann windowed, blocks with too little rain filled from the median of the rest, smoothed, anything faster than ~150 km/h discarded as noise. The newest frame is then advected semi-Lagrangian backward (k steps ahead = the frame sampled k × displacement upstream) so rain moves without spreading or doubling. Extrapolation says nothing about growth or decay and is mostly wrong about position past ~90 min, so intensity is scaled towards "no rain" from 90 to 120 min, each step is blurred a little more, and every entry carries lead_min so a client can fade the band. A nowcast is issued only from three consecutive 15-minute frames on one grid; fewer, and the band is empty rather than stale. About 15 s of CPU per issue.

With metno enabled, MET Norway's own nowcast is pasted over ours inside the Nordic mosaic for each valid time (their nearest 5-minute step, ≤ 2.5 min away): theirs is model-corrected and 1 km, ours is 2 km advection. Only the timesteps we need are range-read from the 90 MB NetCDF (GDAL's HDF5 driver over /vsicurl, ~0.6 s for eight steps); a MET outage just leaves our own field in place and flags freshness.metno. The nowcast tiles' credit line names MET when the overlay is on. The result is still one frame per valid time — the phone and the tile route know nothing about the seam.

The manifest​

GET /weather/layers.json first: it lists the served variables with manifest and tiles paths, units, legend and which bands currently have frames, so a client builds its layer picker from the server. Then, per variable:

{
"generated": "2026-09-20T03:51:04Z",
"host": "https://api.fastgis.eu",
"tiles": "/tiles/rain/{frame}/{z}/{x}/{y}.png",
"minzoom": 3, "maxzoom": 10, "tile_size": 512,
"units": "mm/h", "step_s": 900,
"legend": [{"mmh": 0.1, "rgba": "#6eaaff6e"}, …, {"mmh": 100.0, "rgba": "#960082f0"}],
"coverage": {"opera": [-10.4, 31.7, 57.8, 67.6]},
"attribution": {"opera": {"text": "Rain radar (c) EUMETNET OPERA via MeteoGate, CC BY 4.0", "licence": "…", "licence_url": "…"}},
"past": [{"id": "opera-20260920T0200Z", "time": "2026-09-20T02:00:00Z", "source": "opera"}, …],
"nowcast": [],
"forecast": [],
"freshness": {"opera": {"newest": "2026-09-20T03:30:00Z", "stale": false, "cadence_s": 900,
"last_ok_at": "2026-09-20T03:52:00Z", "last_error": null}}
}
  • past is oldest-first and covers the last 2 h by default; ?past_hours=24 gives the whole OPERA window (0.25–24 accepted).
  • A frame id is <source>-<YYYYMMDDTHHMM>Z, the product time in UTC; a forecast frame also carries its model run: iconeu-r20260920T0600Z-20260920T0900Z. An id names one product of one source (and one run) and is never rewritten, which is why the tiles may be cached forever (below). Substitute it for {frame} in tiles. When a frame has to be re-issued — the MET 1 km paste retried after a thredds outage — it takes a revision suffix (…-20260920T0900Z-v1) and the superseded id disappears from the manifest and from disk: a client that cached the old tiles simply loads the new ids on its next manifest poll. Treat ids as opaque; only one revision per time is ever listed.
  • A forecast entry may carry overlay: the id of the Nordic 1 km frame for the same hour (nordictemp-r20260921T0600Z-20260921T0900Z), served under the same tiles template and transparent outside MET's domain. A client draws it above the entry's own frame and steps both together; a client that ignores the key still gets the full ICON picture (with MET's values pasted in at 8 km). The overlay source's freshness, coverage and attribution ride in the manifest's blocks like any other source.
  • nowcast is the newest issue only: 15-minute steps from the first step after the newest observation to +2 h, each with lead_min and run (the base observation time).
  • forecast is the newest complete run only, hourly, starting at the first hour after the nowcast ends (after the newest observation when there is no nowcast, or from now with no observations) so the three bands never overlap. Each frame is that hour's amount in mm — numerically the hour's mean mm/h, so the same legend applies. run says which model run it came from; when a newer run lands the whole band changes at once, never hour by hour.
  • freshness.<source>.stale is judged from the newest frame on disk against three times the source's cadence — what a rider would actually get, not what the ingest last claimed. For a forecast source newest is the model run time and stale means no run for 12 h (two missed runs). A stale source's frames are still listed: stale is a flag for the UI to show, not a filter.
  • ETag + Cache-Control: public, max-age=60; the tag changes only when the frame list or a stale flag does, so a client polling every minute mostly sees 304.
  • refresh.allowed_after is when the capped user refresh (below) may next fire — null means right now — and min_age_s the age a source's newest frame must have before a refresh is worth asking for. The phone greys its ⟳ and says next check in 6 min from this, instead of failing quietly — for a wait of an hour or less. A longer wait is not one a rider acts on, so the phone leaves ⟳ usable and says nothing; the server still gates the press. (As of 2026-10-29 the overlay sources report a frame time ~22 h ahead here; seq-S44-rain-refresh-overlay-wait fixes the server.)

The capped refresh​

POST /weather/rain/refresh?source=opera (authenticated: X-API-Key or the session cookie) runs that source's ingest now — the very same function the worker's beat runs — subject to four gates, in order, so that a tap can never make us hit an upstream more often than the schedule would:

  1. age — the source's newest frame is older than RAIN_REFRESH_MIN_AGE_S (600 s); otherwise fresh;
  2. cadence — no manual refresh of that source within its cadence; otherwise capped. A manual run is an early tick that stands in for the next scheduled one: the beat skips its tick when a manual run happened inside the cadence, so on average the upstream sees one run per cadence from us however hard the button is pressed;
  3. single flight — one run per source at a time, cluster-wide; otherwise running;
  4. per user — one request per user per minute; otherwise rate_limited.

The answer is always 200 with status (ok, fresh, capped, running, rate_limited, error), newest, next_allowed_at, cadence_s, min_age_s and, after a run, fetched. 404 only when the layer is off or the source is not served; 422 for an id that is not a source id.

The layers along a route​

POST /weather/along-route/frames (authenticated) answers the plan sheet's per-stop question — what will it be like when I get there? — from the very frames the map shows, so the caption under a stop and the tile the rider scrubs to never disagree. The body is {points: [{lon, lat, eta}], variables?} with 1–50 points; eta is ISO-8601 (a naive time is read as UTC) and variables defaults to every variable the server serves (422 for an unknown one, 404 when the layer is off).

Per point and variable the answer is {value, units, frame, band, time, offset_min} — value in display units (mm/h, km/h, °C), frame the id the tile URL takes, band where it came from (past, nowcast, forecast), offset_min how far the frame's valid time is from the ETA — or null when no frame lies within 90 min of the ETA or the point is outside coverage (a null is "we do not know", never zero). The frame chosen per ETA is the nearest by time among the observed frames, the newest nowcast issue and the newest forecast run; on a tie the nowcast wins over the forecast. summary carries {min, max} per variable across the stops and generated the answer time. One request samples one pixel per (stop, variable) from the COGs on disk — no upstream call, no rendering (but see the fallback below).

Inside MET's footprint the value comes from the Nordic 1 km overlay frame of that hour when one is served (source says which frame answered), so the caption matches what the map draws on top rather than the pixel beneath.

Where the frames say nothing (a stop outside every source's footprint, east of 62.5° E for instance, or no frame within 90 min), MET's point forecast (locationforecast, the provider the storm-avoidance check already uses) answers rain, temperature and — only where MET sends one for that point — gust: source is locationforecast, frame is locationforecast-<step time>, band is forecast. One request per ~1 km cell, cached on MET's Expires, in parallel inside RAIN_FALLBACK_BUDGET_S (10 s); a stop it cannot answer stays null. Visibility has no point forecast and stays null. RAIN_ALONG_ROUTE_FALLBACK=0 switches it off. The one-pixel-from-disk guarantee above holds for every stop a frame covers: those never call MET.

This is not the storm-avoidance check: /grass/routing/weather/along-route scores a whole trip against a point forecast and suggests departures; this endpoint reads our own layers at the stops the sheet already lists.

Band 2: the precipitation phase​

Since weather hazards WP-1 the rain forecast frames (iconeu-r… and the Nordic nordicrain-r… overlays) carry a second band — the precipitation phase — so a rider can tell whether the 3 mm/h on the pass falls as rain or snow. Nothing changes for a client: same ids, same manifest shape, same tile URLs; the phase is drawn on the rain tile as a tint plus a hatch and read by the stop sampler. Frames written before the band existed have one band and render exactly as before.

CodeMeaningOn the tileStop caption
0unknown / no precipitationnothing🌧
1rainnothing — the rain colours as they are🌧
2mixed (sleet)quarter tint toward pale blue-white, sparse diagonal hatch🌨
3snowhalf tint, fine diagonal hatch❄
4freezing rain / drizzlecolours unchanged, red diagonal hatch🧊

Where it comes from: pan-Europe, ICON-EU's own split of TOT_PREC into SNOW_GSP + SNOW_CON and RAIN_GSP + RAIN_CON (accumulated like TOT_PREC, differenced hour by hour; their sum equals TOT_PREC) — snow fraction ≥ 0.7 = snow, 0.3–0.7 = mixed, below = rain — with WW, the WMO present-weather code, marking freezing precipitation (56/57/66/67) and standing in for the split where it says nothing. In the Nordics MET's 1 km probability_of_snow_phase replaces the split wherever the paste (metfc) and the overlay frames have data, with the same thresholds. The phase is judged on the warped grid against the frame's own amount, from 0.1 mm/h (the first legend stop) up; below that there is nothing to give a phase to. The companion downloads are advisory: a missing ww or snow_gsp hour means an unknown phase for that hour, never a failed run. Thresholds: RAIN_PHASE_SNOW_MIN (0.7), RAIN_PHASE_MIXED_MIN (0.3).

The manifests carry the swatches the hatch means as phases (rain only, null for the other variables): [{code, label, glyph, rgba}] — the legend list itself is unchanged. The along-route answer carries phase: {code, label} (or null) on every rain value.

Band 3: hazard bits​

Since weather hazards WP-2 the same rain frames carry a third band, hazard bits: 1 thunder, 2 hail (always with thunder), 4 fog (WW 45/48, WP-4). Independent of the amount — a thunderstorm cell is a hazard whether or not the rain band shows anything there — and, like the phase, invisible to a client that does not know it: same ids, manifests and tile URLs.

Sources: pan-Europe from ICON-EU's WW present-weather code (95 thunderstorm, 96/99 thunderstorm with hail); in the Nordics MET's 1 km probability_of_thunder sets or clears the thunder bit wherever it has data (≥ RAIN_THUNDER_MIN, default 0.3), inside the paste and the nordicrain overlay frames.

WhereWhat
Rain tilea ⚡ bolt at every point of a fixed 40-px lattice whose bits say thunder — yellow, orange-red with hail, dark outline; drawn even where the rain colours are transparent
Manifestshazards swatches (rain only, null otherwise): [{bit, label, glyph, rgba}]
Along-route answerhazards: ["thunder", "hail"] on every rain value (empty list when clear)
Rider app⚡ / 🌩 (and 🌫 for fog) after the rain value in the stop captions; on the timeline, a yellow tick at every forecast hour at which the rider's own position sees thunder — the app asks /weather/along-route/frames for its position at each forecast hour (≤ 50 points, thinned evenly beyond that) whenever the rain manifest loads, and ⚡ before the time label while such an hour is shown

Band 2 of the gust frames: wind direction​

Since weather hazards WP-3 the gust frames (gust-r… and the Nordic nordicgust-r… overlays) carry a second band, the wind's from direction in 2° steps (0–179; 0 = from north, 45 = from east; 255 = unknown). Same rule as the rain frames' bands: ids, manifests and tile URLs unchanged; a client that does not know the band sees the gust colours as before.

Sources: pan-Europe the 10 m components ICON-EU publishes (U_10M, V_10M — fetched with each gust hour, advisory: a missing one leaves that hour's direction unknown); in the Nordics MET's 1 km wind_direction_10m inside the paste and the nordicgust overlay frames. Directions are never interpolated as angles: components (unit vectors for MET) are warped and the angle recomputed, so 359° and 1° never average to 180°.

WhereWhat
Gust tileone arrow per point of a fixed 40-px lattice, pointing where the wind blows, longer with the gust (short at the 30 km/h floor, longest at 120); dark on the light end of the ramp, light on its dark end; under the floor a short grey barb so calm areas still show direction
Along-route answerdirection_deg (from-direction, degrees, or null) on every gust value
Rider appa compass arrow after the gust value in the stop captions — 💨 31 km/h ↗ — pointing where the wind blows, like the tiles

Visibility​

The fourth variable (WP-4) works like the others — GET /weather/vis/frames.json, /tiles/vis/…, vis in layers.json, vis in the along-route answer (km) — with one inversion: its palette is transparent above 5 km, so the layer shows fog and haze and nothing else. Frames hold metres; legend, captions and the along-route answer speak km. The rider app mentions visibility in a stop caption only under 5 km (🌫 0.8 km), and its picker gains a 🌫.

The tiles​

/tiles/rain/{frame}/{z}/{x}/{y}.png, zoom 3–10, 512-px RGBA PNG, Cache-Control: public, max-age=31536000, immutable, X-Attribution per source, CORS *. Rendered on demand from the frame's EPSG:3857 COG (windowed read → palette lookup → quantised PNG) and cached on disk under the frame; the first tile of a view costs ~15–35 ms, the rest are file reads.

  • Outside the frame's footprint the response is a 1×1 transparent PNG, status 200, not a 204 — raster clients treat an empty body as an error.
  • 404 for an unknown or malformed frame id, a zoom outside 3–10, an x/y outside the zoom's range, or a source not in RAIN_SOURCES. The frame id is validated before anything touches the filesystem.
  • One palette for every source (mm/h, logarithmic, transparent below 0.1 mm/h, purple at 100) so the animation does not change look at the observation → nowcast → forecast seam. Forecast frames, when they come, are hourly amounts rendered as the equivalent mean rate under the same legend.
  • Two kinds of "nothing" are kept apart at ingest: a cell no radar covers is NoData; a cell a radar observed as dry is 0.0. Both render transparent, but the manifest's coverage and the frame's own footprint tell a client which is which.

Operating it​

  • RAIN_SOURCES=opera,nowcast,metno,iconeu turns it on. The worker's beat then runs rain.ingest_opera every RAIN_OPERA_CADENCE_S (900 s): probe the last two hours' quarter-hour paths on the S3 cache (RAIN_OPERA_LISTING=s3, eight HEAD requests, no quota — the MeteoGate listing API's 200/hour cap turned out to be shared more widely than one IP and was exhausted on the EU box on day one; ord keeps that path for a deployment with a key), download each new composite (~3 MB), warp it once to web-mercator (0.45 s), write it atomically to DATA_ROOT/rain/frames/<frame-id>.tif. rain.ingest_iconeu (every RAIN_ICONEU_CADENCE_S, 1800 s) reads one DWD directory page per recent run and, when every hour up to RAIN_FORECAST_HOURS is there for a run we do not have, downloads them (~25 × 0.5 MB), de-accumulates and warps them in a scratch directory and moves the whole run into the store at once. The OPERA tick that fetched new frames re-issues the nowcast inline (rain.nowcast is only a backstop). rain.prune (hourly) enforces the 24-hour radar retention, drops a nowcast issue or forecast run the moment a newer one is complete, and removes the tile caches with the frames.
  • Freshness marks live in Redis at rain:src:<source> (last_attempt_at, last_ok_at, last_error, newest_frame) and surface in the manifest. Neither task ever raises — a dead upstream is a stale flag, never a failed map load.
  • The daily freshness watchdog reads the public manifest (FRESHNESS_RAIN_URL) and fails when a served source is stale; a 404 passes as "disabled".
  • Storage: ~1.5 MB per 3857 radar frame, 96 a day → ~150 MB rolling for OPERA; ~2.3 MB per precipitation forecast frame and ~4–4.5 MB per gust or temperature frame (smooth fields deflate worse) × 24 h → ~270 MB for the three fields' current runs; plus a bounded tile cache, all on the /data volume the API and worker share.