Roadmap¶
Built as a sequence of vertical slices. Each slice is independently reviewable, testable, and useful on its own. We complete and merge one before starting the next. (Flexible — if collapsing two makes sense, we'll discuss it.)
Slice 1 — Ingestion¶
Pull the latest assembled Level 2 volume for a given site code (default KFTG)
from unidata-nexrad-level2, store the raw file, record it in a SQLite index.
Dedupe on the volume's scan timestamp.
- CLI: backscatter pull KFTG
- Done when: running it lands a real _V06 file on disk + a matching index
row; re-running doesn't duplicate; tests cover newest-key selection, dedupe, and
the UTC-midnight fallback (mock S3). Plus one real end-to-end pull.
- No decoding, no rendering yet.
Slice 2 — Site selection (any US location)¶
Bundle the static NEXRAD site table (~160 sites, ICAO + lat/lon). Resolve the
active radar from a configured lat/lon by great-circle distance; expose the full
ranked list for later failover.
- CLI: backscatter site --near "<lat>,<lon>" (and config-driven default)
- Done when: any CONUS lat/lon resolves to the correct nearest site, with a
sane ranked list behind it; tested against a handful of known points. Ingestion
now feeds on the resolved site code.
Slice 3 — Decode + single-frame render¶
Read a stored volume with Py-ART, extract lowest-tilt (0.5°) reflectivity at
super-res, reproject to web mercator, render one georeferenced PNG with a real
color table.
- CLI: backscatter render <volume>
- Done when: a stored volume produces a correctly georeferenced image whose
bounds and orientation are verified against a reference for the same timestamp.
Geometry + color mapping have value-based tests.
- Still no web server.
Slice 4 — Serve + map¶
FastAPI serves the rendered frame(s); a minimal MapLibre page overlays the latest frame on a no-token basemap, centered on the configured location. - Done when: you can open a browser on the LAN and see the latest radar frame correctly placed on the map.
Slice 5 — Continuous collection¶
A scheduler loop: resolve site → pull → decode → render → index, on a sane
interval (~60s poll, deduped). Runs as a long-lived service. Fails over to the
next-nearest site when the primary has no recent data.
- CLI: backscatter collect
- Done when: left running, it steadily accumulates frames with no dupes,
survives transient S3/network errors, and fails over cleanly.
Slice 6 — Playback¶
Timeline API returns available frame timestamps for a range; the frontend gets a scrubber / play control that cycles cached frames. - Done when: you can scrub and play back across the full collected archive in the browser. This is the feature subscription apps don't give us.
Slice 7 — Archive navigation¶
Slice 6's timeline only reaches the most recent ~500 frames. Make the whole
archive navigable: a UTC date/time range picker bounded to what exists, plus
cursor pagination so a window deeper than the per-request cap is reachable without
silently truncating history.
- API: /api/frames gains a cursor (exclusive scan_time lower bound) and
returns next_cursor for forward paging; /api/frames/range reports a site's
min/max/count.
- Frontend: range picker + presets driving the timeline; playback pages through
a long window transparently (fetch the next page near the end) so it never
dead-ends. Default load stays the recent rolling window.
- Done when: you can pick any historical window the archive covers and scrub /
play across it, paging through spans larger than one request without gaps or dupes.
Slice 8 — Multiple locations + collect-all¶
Generalize the single configured location into a named list (one flagged
Home/default) and make collect archive all of them continuously.
- Config: BACKSCATTER_LOCATIONS JSON list (back-compat: the old single
lat/lon = a one-entry "Home"); validate ≥1, exactly-one-default, unique names; an
explicit SITE override pins Home only. Frames stay per-radar (no index change).
- Collector: each cycle iterates every location, resolving its nearest site and
pull→render→indexing — deduped on (site, scan_time), so co-located locations
converge on one frame. Per-location failover + resilience.
- API: /api/locations; /api/frames, /api/frames/range, /api/latest take
an optional location (defaults to Home).
- Data model + collector + API only; UI (active-location switching) is Slice 9.
- Done when: collect archives several locations at once, two co-located ones
produce a single shared frame (no double pull/store), and the API resolves any
configured location to its site.
Slice 9 — Location switcher UI¶
The runtime piece deferred from Slice 8: an in-UI location selector (frontend only —
no API/collector changes).
- A dropdown populated from /api/locations (name + resolved site), Home preselected.
- Selecting a location re-fetches its frames via the location param, re-points the
timeline/scrubber at that location's archive, and re-centers the map on its lat/lon;
per-frame bounds keep a different radar's frames correctly placed. The active
location persists across reload (localStorage). Single-location hides the selector.
- Done when: you can switch from Home (KFTG, Elizabeth) to e.g. OKC (KTLX) and the
map re-centers and shows that radar's frames, correctly georeferenced.
Slice 10 — Location management (CRUD, persisted, live)¶
Turn locations from read-only env config into mutable, persisted state managed from
the UI (ADR-0008).
- Persistence: locations move to a SQLite locations table; env JSON only seeds
an empty store, then the DB wins. Site stays derived (re-resolves on edit).
- Write API: POST/PUT/DELETE /api/locations with the invariants enforced on
every write (≥1, exactly one default, unique names); deleting the last or the
default is rejected.
- Collector live-reload: collect re-reads the store each cycle — an added
location archives next cycle, a deleted one stops, no restart.
- UI: a manage-locations panel (add / edit / delete / set-default, click-map to
set coords) that refreshes the Slice-9 switcher; backend validation shown inline.
- Done when: you can add/edit/delete locations in the browser, the running
collector picks them up within a cycle, and a restart preserves them.
Slice 11 — Retention / pruning¶
There is no retention: collect keeps every volume + frame forever and collect-all
makes the archive grow unbounded. Bound it with configurable retention (ADR-0009).
- Policy: an age limit (default 30 days, ON) and a size cap (default
OFF/unlimited — opt-in; no surprise GB default). Both can be active; a frame is
pruned if it violates either (older than the age limit, or in the oldest-first
overflow above the size cap). Global across the whole archive; size accounting is
real on-disk bytes.
- Prune: removes the raw volume, its rendered PNG + sidecar, and the index row
together (files-first, then row; idempotent on missing files; a file error skips
that frame, never orphaning the index). A pruned frame disappears from the timeline
cleanly — no dangling row, no 404.
- Where: a throttled pass in the collect loop (first cycle, then ≤ once per
BACKSCATTER_PRUNE_INTERVAL, default 1h) — self-bounds with no cron — plus a manual
backscatter prune. --dry-run previews (count / oldest-newest / bytes / by-reason)
and deletes nothing; the live command prompts [y/N] unless --yes.
- Config-driven, no UI this slice. New env: BACKSCATTER_RETENTION_DAYS (0 = off),
BACKSCATTER_RETENTION_MAX_GB, BACKSCATTER_PRUNE_INTERVAL.
- Done when: an over-age / over-cap archive prunes to within policy (oldest-first,
exact bytes), a pruned frame is gone from /api/frames with its files removed, and
--dry-run reports the same set without deleting anything.
Slice 12 — First-class backfill command¶
Replace the throwaway demo script with a real command that fills the archive with
historical data on demand — the same per-volume pipeline as collect, over a past
range instead of "latest".
- Command: backfill [target] --start <UTC> --end <UTC> [--dry-run] [--yes].
target is a location name or site code (defaults to the configured site). Lists
assembled volumes for the site across the range, then per volume:
dedupe → download → render → index, reusing the existing pipeline
(pull.fetch_key + collect.render_and_index). No failover (one site).
- Dedupe / idempotent: skips (site, scan_time) already indexed; re-running a
range adds nothing. Composes with the archive — fills holes, leaves frames alone.
- Dry-run: reports volume count / span / exact listed bytes and fetches nothing.
The live run prints the same plan, then prompts [y/N] on a TTY unless --yes.
- Retention interaction (ADR-0009): if the range falls (partly) older than the
active age window, it WARNS that those frames will be pruned on the next prune pass
(raise/disable BACKSCATTER_RETENTION_DAYS to keep them) — does not refuse.
- Resilience: a bad/un-decodable volume is marked render-failed (kept); a fetch
error is skipped — a long backfill never aborts on one volume. End-of-run summary.
- Done when: a dry-run reports the range without fetching, a real backfill of a
past range lands frames that scrub in the timeline, a re-run adds nothing, and the
retention warning fires on a range older than the configured window.
Slice 13 — Timeline gap indicator¶
The scrubber is index-spaced, so a large time hole between consecutive frames
(collect was down, backfill hasn't filled it) looks continuous. Make holes visible —
frontend-only, derived from the scan_times /api/frames already returns (no backend
change, no new endpoint).
- Detection (the testable core): a gap is any consecutive interval longer than
GAP_FACTOR × median(interval) over the loaded window (median so the gaps don't
inflate the baseline; factor 3). Derived, not a hardcoded 5 min, so normal clear-air
spacing isn't flagged but a real ≥30-min hole is. Pure detectGaps in web/gaps.js,
unit-tested with node --test web/gaps.test.js.
- Display: amber hatched segments on the scrubber track at each gap step; a
"⚠ gap before · 1h 32m" trailing-edge indicator when the current frame sits just
after a gap. Markers recompute as paginated windows fill in.
- Playback: marker-only — playback runs across gaps, never auto-pauses or skips.
- Done when: a real gap is visibly marked and normal cadence isn't (screenshot
gate), and the detection rule passes its unit tests.
Slice 14 — Docker packaging¶
Package the whole app as one self-hosted container so docker compose up is the entire
install. Net-new infra — no app behavior change, no features.
- One container, both processes: a lean bash entrypoint runs the FastAPI server and
the collect loop; if either exits the container exits (compose restart brings it
back — never half-up). init: true reaps/forwards signals; collect's existing
SIGTERM handling shuts down cleanly. Server starts first and seeds the DB before
collect starts (avoids the empty-DB seed race on a fresh volume).
- Base image (the build risk): python:3.12-slim-bookworm (glibc) — Py-ART's stack
(numpy/scipy/netCDF4/cartopy/matplotlib) is manylinux-wheels-only, so not Alpine.
An ldd of the wheels needs only libstdc++6/libgomp1/libz/ca-certificates from
the system; everything heavy is bundled. Multi-stage with uv; no build tools in the
runtime image.
- Persistence: raw volumes + renders + SQLite DB live on a host bind mount
(./data → /data), so down/up/rebuild never lose the archive. Container runs as
the host UID (PUID/PGID) so the mount is writable non-root.
- Config via env (maps to existing Config, no code change): BACKSCATTER_LOCATIONS
seeds the store on first run, then the DB wins (Slice-10 flow); retention/poll/site
pass through .env.
- Deliverables: Dockerfile, docker-compose.yml, .env.example, .dockerignore,
README "Run with Docker".
- Done when: the image builds clean (pyart imports + reads a volume in-container),
docker compose up serves the UI on the LAN with collect cycling, and the archive +
seeded locations survive down/up/rebuild.
Slice 15 — Banner image + repo polish¶
Public-facing polish ahead of the docs site — no app behavior change.
- Banner (docs/assets/banner.svg + rendered banner.png): on-theme radar-sweep
motif in the real NWS dBZ palette (storm cells, range rings, amber sweep beam) +
wordmark; PNG rendered from the SVG via headless Chrome.
- Hero screenshot: live reflectivity on the map + the playback timeline with gap
markers (later superseded by the pinned app-overview.png in Slice 17).
- LICENSE (MIT, matching pyproject); README License section fixed from "TBD" so
GitHub detects the license.
- README top rebuilt: banner → tagline → honest static badges (license / python /
docker — no fake CI badge) → what-is-this → screenshot → quick links.
- CONTRIBUTING.md stub (dev setup + checks + conventions; expanded by Slice 16).
- GitHub metadata: repo description + topics set via gh.
- No cargo-cult community files (no CoC / issue templates / FUNDING / CI yet).
Slice 16 — Documentation site¶
A full docs site for a zero-assumptions, weather-curious audience — plus a developer
branch. MkDocs Material in docs/, deployed to GitHub Pages by a build workflow.
- For everyone: a plain-language Home,
three click-by-click platform install guides (Windows / macOS / Linux, Docker path,
nothing assumed), a Configure guide (location + retention in plain words), a Using tour
(radar colors, timeline, playback, gaps — with GIFs), and a Help/FAQ.
- For developers: the openness pitch, a fast uv local-run path, an architecture
page (mermaid pipeline diagram + module map), testing, the CONTRIBUTING expansion, and
the ADR index — with ROADMAP + ADRs surfaced in the nav.
- Capture automation: scripts/capture_docs.py drives the live app with Playwright
and builds GIFs via ffmpeg → real, repeatable screenshots/GIFs (not hand-grabbed).
- Tooling isolation: docs deps are a uv docs group — never in the app runtime or
Docker image. Win/Mac Docker-Desktop install is text + official link (can't authentically
capture those installers); everything else is captured live.
- Done when: mkdocs build --strict is clean, the Pages workflow publishes the live
site, and a non-technical reader can get from "what is this" to a running app.
Slice 17 — Location pins + configurable port + README GIF¶
Three batched changes; no new product behavior beyond the pins.
- Labelled location pins: a marker for every configured location, drawn from the
existing /api/locations (no backend change) as a MapLibre circle + text layer. The
active location is amber/larger, the others muted white; labels collide-hide so close
names stay readable; pins sit above the radar and are click-through. They update live
on switch and on the location CRUD path. Pure GeoJSON builder (web/markers.js)
unit-tested with node --test, like gaps.js.
- Single configurable port: BACKSCATTER_PORT (default 8085) replaces hardcoded
8000 everywhere — one env value drives the in-container and published port and the
healthcheck; the CLI serve default is 8085. Docs/README updated with a plain-language
"changing the port" note.
- README: hero repointed to the re-captured (pinned) app-overview.png; playback GIF
embedded; capture script re-run so all map imagery shows the pins.
Maintenance fixes (post-Slice-17)¶
Docker robustness, no app behavior change:
- Build perf: removed a redundant chmod -R a+rX /app that recursed the ~36k-file
scientific-stack venv (~150s on a home server) to set perms uv already produces; the
entrypoint exec bit is set with COPY --chmod instead.
- Fresh-deploy crash: a missing ./data was auto-created by Docker as root, so the
non-root container couldn't write the DB ("unable to open database file" crash-loop).
Fixed with a tracked empty data/.gitkeep (the dir exists owned by the cloning user)
+ an entrypoint writability check that prints an actionable chown message + a Help
& FAQ entry.
Slice 18 — First-run honesty + smart default view¶
Make a brand-new (empty) app and a returning (populated) one both immediately legible —
never a blank, baffling map. Frontend only; reuses the existing read-only frame APIs.
- Three distinct states (were one collapsed "no frames" dead end): empty archive →
a friendly "collecting now, first frame in ~5 min, updates on its own" card + a
load-history link; wrong time-window → a visibly different "no radar in this window;
you have data from X to Y (local)" card + Jump to latest; has data → radar.
Pure chooseView in web/firstrun.js, unit-tested like gaps.js/markers.js.
- Default view = latest (never a back-dated window that may be empty).
- Live status cue near the readout, freshness-honest: "Collecting · last frame 12:56 PM"
when fresh, "Last frame … · checking for new radar…" when stale, "waiting for the first
frame…" when empty.
- Considerate auto-update: polls /api/frames/range (~30s) only on the empty/latest
views (and only while visible); the empty card auto-dismisses when the first frame
lands; a newer frame on the live view shows a non-intrusive "New radar available" nudge.
- Local time in the readout/frame time/messages (the time picker stays UTC — that
overhaul is a later clarity slice).
Slice 19 — One-click "Load recent radar now" (web-triggered backfill)¶
Put the existing backfill pipeline (Slice 12) behind a one-click web button so a
first-run user gets recent history without the CLI — the load-bearing other half of
Slice 18's empty state. (ADR-0010.)
- Async job, not a blocked request: a backfill is minutes of download + render, so
POST /api/backfill starts a daemon-thread job and returns immediately; the UI polls
GET /api/backfill/{id}. An in-memory JobManager runs one job at a time (a second
start → 409). No external queue/broker.
- Two writers, one DB, two processes: the job writes the index while the live
collector also writes it. serve and collect are separate processes, so safety rests
on SQLite WAL + busy_timeout (raised to 15s) + UNIQUE(site, scan_time), not an
app-level lock — proven by a concurrency test (two threads, overlapping keys → no lock
error, no dupes, integrity_check ok).
- Bounded: a click loads the last 6 hours, hard-capped at 24h server-side (well
inside retention, so no prune warning). Reuses run_backfill unchanged but for an
additive progress_cb; same dedupe / skip-on-bad-volume / idempotency.
- Frontend: the empty card's docs link becomes a primary "Load recent radar now"
button with a live progress bar ("Loading radar… 12 of 48 frames"); on success the
timeline auto-populates, on failure a plain "try again." Also offered as a secondary
action on the wrong-window card. Pure web/backfill.js, unit-tested like firstrun.js.
Slice 20 — Bilinear interpolation in the radar render¶
Smooth the nearest-neighbour blockiness so single-site reflectivity approaches the
RadarScope look. Investigation first confirmed we already decode + paint full native
super-res (720×1832 @ 250 m, 1:1 in range) — the blockiness was purely sampling: NN
stamped each 0.5° ray (wider than a 250 m pixel past ~29 km) across many pixels → fan-
wedges + per-ray speckle.
- Bilinear across the 4 nearest gates (2 rays × 2 gates) in (azimuth, range), on dBZ
before the palette. Pure helpers _bracket_rays/_bracket_gates/_bilinear_sample in
render/raster.py, fully vectorized.
- Conservative masked-edge rule (the correctness crux): a pixel is blended only when
all 4 corners are valid; otherwise it keeps the nearest sample (NaN where no-data). So
the valid/no-data boundary is pixel-identical to NN — interpolation never invents
data or moves a feature, it only smooths the interior of real returns. Verified: non-NaN
coverage on a real KFTG render is identical to NN (only interior values changed); visual
check vs the NN render confirmed cells/edges unmoved.
- Cost: ~1.22× render time (4.83s → 5.90s on a real volume) — fine for collect/backfill.
- Note: existing cached PNGs stay NN until re-rendered; new renders/backfills use bilinear.
Slice 21 — Light/dark mode toggle¶
A ☀/☾ button in the control bar switches the app between light and dark. UI theming only —
the NWS dBZ radar palette is byte-identical in both (it's baked into the PNGs; the theme
path only touches the basemap, CSS chrome, and pin paint).
- Basemap swap: keyless OpenFreeMap liberty ↔ dark (no key/credit card, same
attribution). setStyle wipes custom layers, so the radar (current frame) + location pins
are re-added on the map's next idle (with diff:false) — robust where style.load /
isStyleLoaded() fail headless when a basemap sprite 404s.
- Chrome via CSS variables: :root = light, [data-theme="dark"] = dark; --accent
amber identical in both. Pins (white + dark halo, amber active) read on both basemaps.
- Default + persistence: follows the OS prefers-color-scheme on first load (default
light), then the explicit choice persists (localStorage); a <head> shim sets the theme
before first paint (no flash).
- Pure web/theme.js (resolveInitialTheme/nextTheme/basemapFor), node --test'd like
firstrun.js. One dark-mode screenshot added to the docs.
Slice 22 — Mobile UX overhaul¶
Make the app genuinely usable on a phone (portrait, 360–430px) without changing the desktop
layout. Pure responsive layout/CSS — no features, no rendering/data/backend change.
- One breakpoint: a single @media (max-width: 600px) block carries the whole mobile
treatment; desktop (≥601px) is untouched.
- Window-controls drawer: the time-window controls (label, start/end, Load, 6h/24h,
Latest, extent) move into a #windowctl group that is display:contents (inline) on
desktop and a dropdown drawer on mobile behind a 🕘 Window toggle. The top bar goes
slim/full-width (Locations collapses to its ⚙ icon); readout + status drop below it.
- Touch + fit: finger-sized targets (≥40px), enlarged MapLibre zoom moved clear of the
bar, full-width timeline with the gap-flag on its own row, first-run/Locations panels fit
(the latter scrolls). Button clarity folded in (labeled, headed drawer).
- Pure web/layout.js (isMobile/BREAKPOINT) node --test'd; app.js wires the drawer
toggle + resize auto-close. Verified on a 390×844 phone viewport and a real device.
Slice 23 — Small UX wins (display-only)¶
Four self-contained frontend improvements; no backend/render/data change. (Two siblings —
clear-air hide + palette toggle — were split out to Slice 24, see below.)
- Local time in the picker + extent. The datetime-local conversions were treating the
value as UTC; fixed via the Date object in a pure web/timefmt.js so a picked local time
maps to the correct UTC instant for the API (storage/queries stay UTC — verified round-trip
in a non-UTC tz: 12:00 MDT → 18:00Z → right frames). Label "(UTC)" dropped to "window:".
Uses the browser tz (= the location's for a user in that zone).
- Auto-advance replaces the "● New radar available" nudge: on the live view a new frame
just shows; if you've scrubbed back, the list refreshes but your frame is kept (no yank).
- Radar opacity slider (0.1–1.0, default 0.8) in a new Display settings section, live +
persisted.
- Keyless basemap switcher — liberty / bright / positron / dark / fiord (all keyless
OpenFreeMap); chrome derived from the style; ☀/☾ kept as a shortcut; old theme pref
migrated. No satellite/terrain (none keyless). theme.js generalized to a STYLES registry.
Slice 24 — Client-side recolor: clear-air hide + palette toggle (DONE — 24a/24b/24c)¶
The radar palette is 15 discrete buckets, so the colored PNG is losslessly invertible to a
bucket index — both features are done client-side, no re-render, no backend, by recoloring
the source PNG in a canvas (RGB→bucket→drop-low-buckets for clear-air / remap-to-other-ramp for
a RadarScope-style palette). Shared canvas-LUT pipeline (web/recolor.js) + a visual-correctness
pass.
- Slice 24a — clear-air hide (f101c83). Lossless bucket inversion drops the low-dBZ
buckets (bugs, dust, fine-gradient clutter) so real weather stands out; off by default,
persisted.
- Slice 24b — palette toggle (700d1e1). Palette selector swaps NWS classic ↔
RadarScope-style ramp, same data, client-side remap.
- Slice 24c — coverage-framed initial view (02bb064). Open framed on the active
location's radar coverage instead of a fixed wide zoom.
Slice 25 — Freshness UX + tighter polling (the cheap lag fixes)¶
From a lag investigation: vs RadarScope we're ~5–10 min behind, but that's ~90% the
assembled-bucket choice by design (measured: an assembled _V06 lands in S3 a median ~309 s
after the volume's start — the whole volume must finish/assemble/upload). Only ~1 min was
avoidable, and the "no Latest / feels stuck" report was two UX gaps, not a data bug (the
frame queries are consistent; Latest works). This slice does the cheap fixes:
- Tighter polling: collect 60 s → 30 s, frontend POLL_MS 30 s → 10 s — a freshly-rendered
frame surfaces in ~10–40 s (measured 6.1 s in the proof) vs ~30–150 s.
- Reachable Latest on mobile: the freshness cue (always on-screen) is now a tappable
"go to latest" control — on a phone the #latest button is otherwise buried in the Window
drawer. Desktop unchanged (Latest stays inline).
- Honest data-age: the cue shows "● Live · last frame N min ago" (pure relativeAge,
floored), dropping the badge when scrubbed back — so a normal 5–9-min-old frame reads as the
source lag, not a freeze. Deliberately does NOT claim the latency is solved.
Near-real-time via the chunks bucket (DONE — 26a + 26b)¶
Beat the ~5-min assembled-bucket floor (to ~1–2 min, RadarScope-class). ADR-0001 deferred it; ADR-0011 is the live-frame design. Hybrid: the assembled bucket stays the archive/backfill source of truth, the chunks path is additive for the live frame only. Measured win: a live frame 0.6 min old while the freshest assembled volume was 6.0 min old.
- Slice 26a — chunks reader + partial assembler (done,
63ab449).ingest/chunks.pyparses/orders theunidata-nexrad-level2-chunksrotating volume dirs andassemble_lowest_ sweep()fetches chunks in order, concatenating until the 0.5° cut is complete. The decode is Py-ART, no MetPy: concatenated raw chunk bytes are a partial AR2V stream thatpyart.io.read_ nexrad_archive(BytesIO)reads directly. Completeness ruletry_decode_lowest(min_sweeps=2)— the 0.5° surveillance cut is the FIRST sweep, so a 2nd cut appearing means it's frozen; we never render a half-swept frame. Hermetic correctness gate (committed clear-air KEMX chunk fixture): chunk-decoded 0.5° == assembled volume's tilt, max abs diff 0.0 dBZ, masks equal. Debuglive-frameCLI (--compare-assembled) for the visual check — live PNG is byte-identical to the assembled render. NOT wired into collect/serve. - Slice 26b — live wiring + reconciliation (done, ADR-0011). The collect loop assembles the
live 0.5° frame each cycle and indexes it as a normal
volumesrow with a newsourcecolumn (assembleddefault |live, added by idempotent migration). A dedicated per-cycle reconcile sweep upgrades each live row in place toassembledonce the complete volume lands (6-min delay; deterministic key check; artifact overwritten, no re-render — PNG identical, so the swap is invisible), guaranteeing every scan_time ends as the complete assembled volume — no partials, no duplicate rows. Bounded cost: a per-site cursor rides the active dir cheaply and the O(dirs) active-dir scan is parallelized (~45s→~2s) and run only at cold start/rollover.BACKSCATTER_LIVE_CHUNKS(default on) toggles it; off = exactly the assembled-only path. Serve - frontend unchanged (the live frame surfaces through the existing UI; the freshness cue just reads a much smaller age).
Intra-volume SAILS cuts (27a + 27b DONE) — ADR-0012¶
One frame per volume left backscatter ~5 min stale between volumes during precip, while RadarScope showed the mid-volume SAILS cuts. Confirmed live: backscatter correctly served its newest live frame, but that frame was the volume's base cut; a fresher SAILS cut was decoded and dropped. Surface every 0.5° surveillance cut (base + SAILS/MRLE) as its own frame, live-only.
- Slice 27a — multi-cut decode (done, hermetic, not wired).
decode/volume.pygainssurveillance_sweeps(radar)andtry_decode_all_lowest(bytes). Surveillance vs Doppler is decided by "first sweep of each visit to the minimum elevation" (both split-cut halves carry reflectivity in super-res, so presence can't distinguish them); each cut is stamped with its own sweep start time (time['units']epoch + first-ray offset). The base cut stays byte-identical to the priorsweep_from_radar(max abs diff 0.0 dBZ), so 26a/26b are untouched. Correctness gate: value-based tests on a real captured KFTG SAILS layout (tests/fixtures/sails_KFTG_layout.npz) + synthetic split-cut/SAILS layouts — selection[0, 9], Doppler twins excluded, times00:24:20/00:26:44, base == volume start. Real-bytes spot-check confirmed 2 distinct cuts (71 dBZ apart) and the freeze progression. - Slice 27b — live wiring (done,
d679ca6).chunks.ride_volumerides one active volume dir, accumulates its chunks across polls, and surfaces each newly-frozen cut; the collect loop indexes the base assource='live'(reconciles as today) and each SAILS cut assource='live-sails', permanent (no assembled object at their timestamp; reconcile'sWHERE source='live'skips them). No schema migration. Hermetic tests cover the dual-source wiring + reconcile-skip.
Storm cell tracking (28a–28f DONE) — ADR-0012¶
RadarScope-style storm tracks. Library survey (TINT/tintX/tobac/Py-ART) concluded none fit
our streaming, polar-sweep, small-image loop cleanly — each is batch-shaped, wants a grid type
we don't hold, and is either fragile (TINT: alpha, git-only) or heavy (tintX pins numpy<2.0;
tobac drags iris/xarray). Decision: port the documented TITAN/SCIT method ourselves on the
grid we already render, reusing scipy.ndimage (already present via pyart — zero image cost).
Tracking is estimation, framed in-UI as estimated motion, never a nowcast (not-for-life-safety).
- Slice 28a — identify + store (done).
track/detect.py::detect_cellsthresholds the rasterized dBZ grid (RasterResult.dbz), labels connected components (scipy.ndimage), and reduces each to an intensity-weighted centroid + peak dBZ +cos(lat)-corrected ground area. Centroid px→lon/lat reuses the renderer'sPIXEL_SIZE_Morigin andmercator_to_lonlat(no new geometry). Newcellstable keyed to(site, scan_time)withtrack_id/u_ms/v_msnullable (staged for 28b, no later migration); computed incollect(assembled + live paths) behindBACKSCATTER_TRACK_CELLS, best-effort. Value-based tests (known centroid/area/lon-lat, flip guard, threshold/area filters) + DB round-trip; real-data smoke (correctly finds 0 storm cells in a weak fixture — no false positives). - Slice 28b — association + motion (done).
track/associate.py::associatecarriestrack_idforward by predicting each prior track's position (its motion × Δt) and matching to this frame's detections withscipy.optimize.linear_sum_assignment(Hungarian, optimal) gated by a speed-based search radius; motion = EMA-smoothed ground velocity (ueast,vnorth) from the matched step, measured with the newgeometry.geodesic_between(true ground m/s, not Mercator). Newtrackstable (AUTOINCREMENT) gives race-free ids;latest_tracked_cells_beforefeeds the previous frame;record_cellsnow stores id+motion. Wired into the collect seam (assembled + live) with a 20-min max-gap guard; works in steady-state and oldest-first backfill. Simple split/merge (a split spawns a new track); full TITAN lineage deferred. Value tests cover continuity, new/lost/no-resurrection, crossing cells (predict-forward beats nearest-centroid), beyond-radius teleport guard, motion smoothing. Real-data proof: 7 consecutive KDMX volumes over the 2024-05-21 Greenfield EF4 supercell → stable ids across 6–7 frames, 44–50 kt toward ~30–44° (NE), 55–61 dBZ cores — matches the documented ~45 mph NE storm motion (NWS SCIT family). On-map RadarScope visual comparison lands in 28c. - Slice 28c — API + map overlay (done).
GET /api/cells?site=&scan_time=(query params — avoids encoding the+00:00; lazy per-frame, doesn't bloat/api/frames) →{site, scan_time, tracks:[{track_id, lon, lat, max_dbz, area_km2, speed_kmh, bearing_deg, proj_lon, proj_lat}]}. The projected endpoint is computed server-side via the testedgeometry.ground_destination(30-min horizon → arrow length encodes speed); near-stationary cells getproj_*=None(no zero-length arrow). Newdb.cells_for_frameread helper +frames.cell_payload/frame_cells. Frontend: pureweb/stormtracks.js(trackFeatures→ GeoJSON Points + LineStrings, node-tested) feeds one MapLibre source + three layers (dark casing + cyan vector + cyan cell markers — cyan is outside the dBZ palette and the white pins; legible over radar/light/dark). Off-by-defaultStorm trackscheckbox in#displaysettings(localStoragebackscatter.stormtracks), re-added on basemapsetStylelike radar/pins, refreshed ongoTowith a per-fetch stale-guard so scrubbing never flickers a wrong frame. In-UI disclaimer "Estimated cell motion — not a nowcast, not for life safety". The on-deploy RadarScope comparison (operator's phone — markers on the right cells, vectors pointing the right way at sensible length, sane while scrubbing) drove the 28f vector-drawing gates. Tests:tests/test_api.py(endpoint returns the frame's cells, projection for a mover / None for a stationary cell, 400 on bad timestamp) +web/stormtracks.test.js. - Slice 28d — RadarScope-style time-tick cross-lines + arrowhead (done). Frontend-only polish
on the 28c overlay; no backend/algorithm change (
/api/cellsalready returns each cell'slon/lat/speed_kmh/bearing_deg, and the existing MapLibre layers filter by geometry-type, so new LineStrings render through them —web/stormtracks.js+ its test are the only files touched).trackFeaturesnow draws, per moving cell: the main vector, perpendicular cross-line ticks at 15/30/45/60 min (4 ticks, 60-min horizon — RadarScope's usual cadence; faster storm → ticks farther apart = the time scale on the track), and a small arrowhead at the tip. Geometry uses a small-distance equirectangularforward(lon,lat,bearing,dist)(cos-lat scaled; <1 km error at ≤100 km, negligible vs the steady-velocity assumption), JS value-tested (due-E/N/wrapped bearings, equal-time→equal-spacing, ⟂ orientation, 7-feature inventory). Same cyan + dark-casing styling; stationary cells stay marker-only. Tunable consts (TICK_INTERVAL_MIN/TICK_COUNT/TICK_HALF_LEN_M/arrow). Note: the renderer now recomputes the vector from speed+bearing (server's geodesicproj_*retained in the API but unused for drawing). Honesty unchanged (estimated, steady-motion, not a nowcast). 50 node tests. (The on-phone RadarScope tick comparison fed back into the 28f horizon/gating fixes.) - Slice 28e — track grace period / coast (done). Fixes flicker-and-restart: a cell that briefly
dips under the 40 dBZ / 10 km² detection floor used to end its track and restart under a new
track_id(detection is a hard, memoryless per-frame gate; association had no grace). Now a track may coast up to_TRACK_COAST_FRAMES=2missed frames and resume the same id + motion when the cell returns within its motion-scaled search radius. Coast state is derived from the cells history — no ghost rows, no new table, no phantom markers; the gap frame stays empty, only id continuity is preserved.db.active_tracks_for_coastreturns each track's latest detection within the last K+1 frames (+ last-seen); replaceslatest_tracked_cells_before.associategains a per-candidate-age coreassociate_candidates(each candidate predicted/radius/velocity by its own age); the oldassociate(prev, curr, dt_s)stays as a uniform-age wrapper so all 28b tests are untouched. Collect feeds coast candidates (20-min hard gap still caps it). Backend-only — no API/frontend/schema change. Value tests: coast resume within radius keeps id, beyond-radius gets a new id;active_tracks_for_coastwindow/latest-per-track/K-bound. End-to-end smoke: present→ present→absent→present resumes the same id. ruff/mypy clean, 239 pytest + 50 node. Honesty unchanged (estimated, steady-velocity; K small so it never coasts across real dissipation). - Slice 28f — vector-drawing gates + horizon fix (done,
d88b217,6dcae6b). Frontend polish from the on-deploy comparison: draw the backend's true 30-min projection horizon (not 60), enforce a real speed ceiling, and require an N-frame track history before drawing a vector — so a just-detected or noisy cell shows a marker but no premature/overlong arrow.
Slice 29 — Runtime-editable retention (DONE — 29a/29b) — ADR-0013¶
Retention was immutable env config (read once into a frozen Config); changing a limit meant
editing .env and recreating the container, and the serve/collect split meant an
in-memory change in one process never reached the other. Moved to the same shape as locations
(ADR-0008): DB-backed runtime state, env seeds an empty store on first run.
- Slice 29a — DB-backed policy + API (4454241, ADR-0013). Retention lives in the SQLite
settings table; GET/PUT /api/retention read/write it; the prune loop reads the live
policy each pass instead of a startup snapshot. Env seeds first run only.
- Slice 29b — Settings-menu form (8cd67f2). Archive retention form in the ⚙ panel
(web/retention.js) edits days + GB live; applies on the next cleanup pass, no restart.
Maintenance¶
- Asset cache revalidation (
1b4fbdf).serverevalidates frontend assets so a deploy is never masked by a stale browser cache (the index/static bundle re-checks; immutable renders stay long-cached).
Later (not scheduled yet)¶
- Velocity and dual-pol products; product switcher
- MRMS national composite at low zoom (wide-area context — the right way to use multiple radars; see ADR-0005)
- Higher-fidelity client-side / WebGL radial-sweep rendering (the "real RadarScope look")