Capture periodic radar frames for active alerts (#130) #144

Merged
claude-bot merged 1 commit from feat/radar-frames into main 2026-07-28 00:00:34 +00:00
Contributor

Closes #130. Second capture issue of the v2.0.0 Historical Explorer epic (#22).

Problem

Radar-at-alert-time capture (#85) produced exactly one image per alert lifecycle — _radar_snapshot_filename is deterministic per (lifecycle_id, location_id) so re-fires overwrite, and SentAlert carries a single radar_snapshot_path. A radar loop (#140) needs many frames. Since uncaptured history is unrecoverable, capture had to become periodic before #140 is built, or every event predating it would be a single still.

It was also off by default (ALERT_RADAR_SNAPSHOT_ENABLED = False), and image lifetime was only transitively tied to sent_alerts — a never-cleared row kept its image forever.

Cadence decision

Periodic frames with a hard per-event cap. Sizing at ~120 KB/PNG and ~150 alerts/location/year:

Cadence Per location/year 10 locations, 13 months
One frame per alert ~18 MB ~200 MB
Frames every 5 min ~165 MB ~1.8 GB

~10x, judged affordable. The cap is the guardrail that matters: it bounds disk growth and outbound load on NOAA's MapServer during an outbreak, when many alerts are active at once. Captures within a cycle also run sequentially rather than fanned out, for the same reason.

Change

  • New alert_radar_frames table (migration 0031) keyed to the dashboard sent_alerts row, with a timestamped filename so frames accumulate.
  • New poll_alert_radar_frames scheduler job appending a frame to each active dashboard alert, gated by ALERT_RADAR_FRAME_INTERVAL_MINUTES (5) and capped by ALERT_RADAR_FRAME_MAX_PER_EVENT (24 → ~2h coverage). Excludes synthetic SPC rows, cleared and expired alerts.
  • ALERT_RADAR_SNAPSHOT_ENABLED now defaults to true; the first-fire capture additionally records frame #1, reusing the bytes already fetched rather than a second upstream call.
  • Frames get an owned retention window (RETENTION_ALERT_RADAR_FRAMES_DAYS, 400) instead of a transitive lifetime.
  • GET /media/alert-radar/frames/{frame_id}.png, mirroring the existing route's validation and cache posture.

SentAlert.radar_snapshot_path / radar_snapshot_at are unchanged as the representative first frame, so /media/alert-radar/{id}.png and the public alert page keep working. Frames are purely additive.

Two file-ownership hazards, both fixed and tested

The first frame deliberately shares its filename with radar_snapshot_path (one file on disk, not two), and frames and sent_alerts have independent retention windows. So each sweep could delete a file the other still owns:

  • the orphan sweep in _cleanup_alert_radar_snapshots now treats any filename referenced by a live AlertRadarFrame.path as referenced;
  • _cleanup_alert_radar_frames drops the row but leaves the file when it is still pointed at by a surviving SentAlert.radar_snapshot_path, which would otherwise 404 /media/alert-radar/{id}.png for a still-retained alert.

Both directions have a regression test.

Test-suite guard

With capture enabled by default, any test dispatching a dashboard alert would schedule a live NOAA fetch. A new autouse conftest fixture patches radar._fetch_radar to the "no image" path — exercised fail-soft, no network. Tests asserting on capture override it.

Verification

Run locally in Docker against every CI gate:

Step Result
ruff check . passed
python -m compileall app OK
SQLite bulk tier 791 passed
alembic upgrade head on fresh Postgres 16 clean through 0031
0031 downgrade → re-upgrade round trip clean
Postgres integration tier 4 passed

Deferred

Routing writes through the media storage abstraction is left to #131, which moves every media call site at once — doing it here would mean writing the same code twice.

🤖 Generated with Claude Code

Closes #130. Second capture issue of the v2.0.0 Historical Explorer epic (#22). ## Problem Radar-at-alert-time capture (#85) produced exactly **one** image per alert lifecycle — `_radar_snapshot_filename` is deterministic per `(lifecycle_id, location_id)` so re-fires overwrite, and `SentAlert` carries a single `radar_snapshot_path`. A radar loop (#140) needs many frames. Since uncaptured history is unrecoverable, capture had to become periodic *before* #140 is built, or every event predating it would be a single still. It was also **off by default** (`ALERT_RADAR_SNAPSHOT_ENABLED = False`), and image lifetime was only transitively tied to `sent_alerts` — a never-cleared row kept its image forever. ## Cadence decision Periodic frames with a hard per-event cap. Sizing at ~120 KB/PNG and ~150 alerts/location/year: | Cadence | Per location/year | 10 locations, 13 months | |---|---|---| | One frame per alert | ~18 MB | ~200 MB | | Frames every 5 min | ~165 MB | ~1.8 GB | ~10x, judged affordable. The **cap** is the guardrail that matters: it bounds disk growth *and* outbound load on NOAA's MapServer during an outbreak, when many alerts are active at once. Captures within a cycle also run sequentially rather than fanned out, for the same reason. ## Change - New `alert_radar_frames` table (migration `0031`) keyed to the dashboard `sent_alerts` row, with a **timestamped** filename so frames accumulate. - New `poll_alert_radar_frames` scheduler job appending a frame to each active dashboard alert, gated by `ALERT_RADAR_FRAME_INTERVAL_MINUTES` (5) and capped by `ALERT_RADAR_FRAME_MAX_PER_EVENT` (24 → ~2h coverage). Excludes synthetic SPC rows, cleared and expired alerts. - `ALERT_RADAR_SNAPSHOT_ENABLED` now defaults to **true**; the first-fire capture additionally records frame #1, reusing the bytes already fetched rather than a second upstream call. - Frames get an owned retention window (`RETENTION_ALERT_RADAR_FRAMES_DAYS`, 400) instead of a transitive lifetime. - `GET /media/alert-radar/frames/{frame_id}.png`, mirroring the existing route's validation and cache posture. `SentAlert.radar_snapshot_path` / `radar_snapshot_at` are unchanged as the representative first frame, so `/media/alert-radar/{id}.png` and the public alert page keep working. Frames are purely additive. ## Two file-ownership hazards, both fixed and tested The first frame **deliberately shares its filename** with `radar_snapshot_path` (one file on disk, not two), and frames and `sent_alerts` have independent retention windows. So each sweep could delete a file the other still owns: - the orphan sweep in `_cleanup_alert_radar_snapshots` now treats any filename referenced by a live `AlertRadarFrame.path` as referenced; - `_cleanup_alert_radar_frames` drops the row but **leaves the file** when it is still pointed at by a surviving `SentAlert.radar_snapshot_path`, which would otherwise 404 `/media/alert-radar/{id}.png` for a still-retained alert. Both directions have a regression test. ## Test-suite guard With capture enabled by default, any test dispatching a dashboard alert would schedule a live NOAA fetch. A new autouse conftest fixture patches `radar._fetch_radar` to the "no image" path — exercised fail-soft, no network. Tests asserting on capture override it. ## Verification Run locally in Docker against every CI gate: | Step | Result | |---|---| | `ruff check .` | passed | | `python -m compileall app` | OK | | SQLite bulk tier | 791 passed | | `alembic upgrade head` on fresh Postgres 16 | clean through `0031` | | `0031` downgrade → re-upgrade round trip | clean | | Postgres integration tier | 4 passed | ## Deferred Routing writes through the media storage abstraction is left to #131, which moves every media call site at once — doing it here would mean writing the same code twice. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Capture periodic radar frames for active alerts (#130)
All checks were successful
CI / test (pull_request) Successful in 3m8s
167aaa94be
Radar-at-alert-time capture (#85) produced exactly one image per lifecycle:
_radar_snapshot_filename is deterministic per (lifecycle, location) so re-fires
overwrite, and SentAlert carries a single radar_snapshot_path. A radar loop
(#140) needs many frames, so capture had to become periodic before the Explorer
could ever show one -- uncaptured history is unrecoverable.

- New alert_radar_frames table (migration 0031) keyed to the dashboard
  sent_alerts row, with a timestamped filename so frames accumulate.
- New poll_alert_radar_frames scheduler job appends a frame to each active
  dashboard alert, gated by ALERT_RADAR_FRAME_INTERVAL_MINUTES and capped by
  ALERT_RADAR_FRAME_MAX_PER_EVENT. Captures run sequentially: an outbreak can
  mean many simultaneously-active alerts and the cap bounds both disk growth
  and outbound load on NOAA's MapServer.
- ALERT_RADAR_SNAPSHOT_ENABLED now defaults to true; the first-fire capture
  additionally records frame #1, reusing bytes already fetched.
- Frames get an owned retention window (RETENTION_ALERT_RADAR_FRAMES_DAYS)
  instead of a lifetime transitively tied to sent_alerts. Both sweeps now
  refuse to delete files the other still owns: the orphan sweep treats live
  AlertRadarFrame paths as referenced, and frame cleanup leaves files still
  pointed at by SentAlert.radar_snapshot_path, since the first frame shares
  that filename by design.
- Serve frames at /media/alert-radar/frames/{frame_id}.png, mirroring the
  existing route's validation and cache posture.

SentAlert.radar_snapshot_path/_at are unchanged as the representative first
frame, so /media/alert-radar/{id}.png and the public alert page still work.

Tests add a conftest guard patching radar._fetch_radar for every test: with
capture enabled by default, any test dispatching a dashboard alert would
otherwise schedule live NOAA requests.

Deferred to #131: routing writes through the media storage abstraction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/WeatherBot!144
No description provided.