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.
| Endpoint | What it answers |
|---|---|
GET /weather/layers.json | Which variables this server serves, with units, legend and the bands each has |
GET /weather/{variable}/frames.json | That variable's frames, in order, and how fresh each source is |
GET /tiles/{variable}/{frame}/{z}/{x}/{y}.png | One 512-px RGBA tile of one frame (paths registered per variable: /tiles/rain/…, /tiles/gust/…, /tiles/temp/…) |
POST /weather/rain/refresh?source=opera | Run one source's ingest early, if the cap allows (auth; per source, any variable) |
POST /weather/along-route/frames | The 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.
| Variable | Units shown | Frames hold | Palette |
|---|---|---|---|
rain | mm/h | mm/h (forecast frames: the hour's amount, numerically the mean rate) | logarithmic, transparent below 0.1 |
gust | km/h | m/s | rider 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 id | What | Coverage | Step | Licence |
|---|---|---|---|---|
opera | EUMETNET 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 band | Europe (−10.4…57.8 E, 31.7…67.6 N) | 15 min, ~20 min latency | CC BY 4.0 — exceptions noted in the product metadata |
nowcast | Our own extrapolation of the newest OPERA frames — the nowcast band | as opera | 15 min to +2 h, re-issued after every radar ingest | derived work; credit EUMETNET OPERA |
metno | MET 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 exists | Nordic (−8…41 E, 53…73 N), 1 km | read with each nowcast issue | NLOD 2.0 / CC BY 4.0 — attribution to MET Norway |
iconeu | DWD ICON-EU TOT_PREC from opendata.dwd.de, de-accumulated to hourly amounts — the forecast band | Europe (−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 later | GeoNutzV — attribution to DWD |
gust | DWD ICON-EU VMAX_10M — the maximum gust over the previous hour, hours 0…H as published — the gust variable's forecast band | as iconeu | as iconeu | as iconeu |
temp | DWD ICON-EU T_2M, 2 m temperature, hours 0…H — the temp variable's forecast band | as iconeu | as iconeu | as iconeu |
vis | DWD 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 iconeu | as iconeu | as iconeu |
nordicvis | MET 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 image | Nordic (MEPS Lambert domain), 2.5 km | hourly to RAIN_FORECAST_HOURS; a new MEPS run at most every 3 h, polled every half hour | NLOD 2.0 / CC BY 4.0 — attribution to MET Norway |
metfc | MET 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 answers | Nordic (−8…41 E, 53…73 N), 1 km | hourly to +58 h; read at ICON ingest | NLOD 2.0 / CC BY 4.0 — attribution to MET Norway |
gfs | NOAA 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 only | RAIN_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 UTC | US Government work, public domain |
nordicrain / nordicgust / nordictemp | MET 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 km | hourly to RAIN_FORECAST_HOURS; a new MET run at most every 3 h (RAIN_NORDICTEMP_CADENCE_S …), polled every half hour | NLOD 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}}
}
pastis oldest-first and covers the last 2 h by default;?past_hours=24gives 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}intiles. 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 sametilestemplate 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. nowcastis the newest issue only: 15-minute steps from the first step after the newest observation to +2 h, each withlead_minandrun(the base observation time).forecastis 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.runsays which model run it came from; when a newer run lands the whole band changes at once, never hour by hour.freshness.<source>.staleis 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 sourcenewestis 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 sees304.refresh.allowed_afteris when the capped user refresh (below) may next fire —nullmeans right now — andmin_age_sthe 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 theoverlaysources report a frame time ~22 h ahead here;seq-S44-rain-refresh-overlay-waitfixes 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:
- age — the source's newest frame is older than
RAIN_REFRESH_MIN_AGE_S(600 s); otherwisefresh; - 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; - single flight — one run per source at a time, cluster-wide;
otherwise
running; - 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.
| Code | Meaning | On the tile | Stop caption |
|---|---|---|---|
| 0 | unknown / no precipitation | nothing | 🌧 |
| 1 | rain | nothing — the rain colours as they are | 🌧 |
| 2 | mixed (sleet) | quarter tint toward pale blue-white, sparse diagonal hatch | 🌨 |
| 3 | snow | half tint, fine diagonal hatch | ❄ |
| 4 | freezing rain / drizzle | colours 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.
| Where | What |
|---|---|
| Rain tile | a ⚡ 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 |
| Manifests | hazards swatches (rain only, null otherwise): [{bit, label, glyph, rgba}] |
| Along-route answer | hazards: ["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°.
| Where | What |
|---|---|
| Gust tile | one 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 answer | direction_deg (from-direction, degrees, or null) on every gust value |
| Rider app | a 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/youtside the zoom's range, or a source not inRAIN_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
coverageand the frame's own footprint tell a client which is which.
Operating it
RAIN_SOURCES=opera,nowcast,metno,iconeuturns it on. The worker's beat then runsrain.ingest_operaeveryRAIN_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;ordkeeps 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 toDATA_ROOT/rain/frames/<frame-id>.tif.rain.ingest_iconeu(everyRAIN_ICONEU_CADENCE_S, 1800 s) reads one DWD directory page per recent run and, when every hour up toRAIN_FORECAST_HOURSis 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.nowcastis 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
/datavolume the API and worker share.