Weather backend: NWS active alerts + nearest-METAR wind, cached (Refs #237) #263

Merged
logan merged 2 commits from feat/237-weather-api into main 2026-10-05 03:03:33 -04:00
Owner

Server-side weather backend for #237 (part 1 of 2; map layers land in batch 5). This PR does not close #237.

What it adds

  • GET /weather/alerts?south&west&north&east (bbox optional): NWS active alerts for NWS_AREA (default NY), returning {alerts, zone_only, fetched_at, error, credit: "National Weather Service"}. Each alert is {id, event, severity, urgency, headline, onset, expires, ends, sender_name, area_desc, geometry, geometry_source, bbox, zones}.
  • GET /weather/wind?lat&lon: nearest METAR within 75 km → {available, reason, wind: {station, station_name, distance_km, obs_time, wind_dir_deg, variable, wind_kt, gust_kt, stale, raw}, fetched_at, error, credit}.
  • Both are mounted with require_service_or_firebase_token, the same as /traffic.
  • Config: nws_user_agent (a placeholder that must be overridden in prod; it logs a warning once if left as is), nws_area, weather_alerts_ttl_s=90. .env.example has the two env keys next to the 511 keys.

Acceptance (backend half)

  • When the upstream fails, the response returns the last good data with error and fetched_at. It never returns a silently empty list. It retries after 30 s.
  • If NWS returns features and none of them parse, the canary turns that into an error.
  • Cache: a lock means concurrent requests share a single fetch. NWS gets one upstream call per TTL. METAR is cached for 5 min per 0.5° cell.
  • The NWS-required User-Agent goes on every NWS and AWC request.
  • Zone-based alerts (geometry: null) get their polygons from affectedZones. Zone shapes are cached for 24 h, failed zone fetches for 15 min, and only https://api.weather.gov/zones/ URLs are ever fetched. Any alert still unresolved goes to zone_only, which is never bbox-filtered or dropped.

Upstreams checked by hand on 2026-10-05: NWS /alerts/active?area= returns GeoJSON, and a large share of alerts have null geometry. /zones/forecast/X returns a Polygon. AWC /api/data/metar?bbox=minLat,minLon,maxLat,maxLon&format=json returns a JSON list, 204 with an empty body when no station is in the box, and wdir can be "VRB". A live smoke test against CA, FL and AK worked: 21 mapped and 10 zone_only, the latter only because of the 80-zone-fetch-per-refresh cap, so they resolve over the next refreshes. White Plains wind resolved to KHPN at 5.9 km.

Brief deviation: the wind cache is per 0.5° area cell, not per station. One bbox query serves every point in the cell, so it is still one upstream call per 5 min per area.

Heads-up for the batch-5 map layer: zone-resolved polygons are heavy. The live CA/FL/AK payload was about 2.1 MB unfiltered, so the frontend should always pass the viewport bbox. Follow-up filed as #264 (simplification).

Tests: tests/test_weather.py, 9 tests (happy path + UA, null-geometry/zone_only, 500 keeps last-good, canary, empty list OK, single-flight cache, bbox filter/validation, wind nearest/VRB/dedupe/cache, wind unavailable). Full c2-core suite: 863 passed.

Refs #237

🤖 Generated with Claude Code

Server-side weather backend for #237 (part 1 of 2; map layers land in batch 5). This PR does **not** close #237. **What it adds** - `GET /weather/alerts?south&west&north&east` (bbox optional): NWS active alerts for `NWS_AREA` (default `NY`), returning `{alerts, zone_only, fetched_at, error, credit: "National Weather Service"}`. Each alert is `{id, event, severity, urgency, headline, onset, expires, ends, sender_name, area_desc, geometry, geometry_source, bbox, zones}`. - `GET /weather/wind?lat&lon`: nearest METAR within 75 km → `{available, reason, wind: {station, station_name, distance_km, obs_time, wind_dir_deg, variable, wind_kt, gust_kt, stale, raw}, fetched_at, error, credit}`. - Both are mounted with `require_service_or_firebase_token`, the same as `/traffic`. - Config: `nws_user_agent` (a placeholder that **must** be overridden in prod; it logs a warning once if left as is), `nws_area`, `weather_alerts_ttl_s=90`. `.env.example` has the two env keys next to the 511 keys. **Acceptance (backend half)** - When the upstream fails, the response returns the last good data with `error` and `fetched_at`. It never returns a silently empty list. It retries after 30 s. - If NWS returns features and none of them parse, the canary turns that into an error. - Cache: a lock means concurrent requests share a single fetch. NWS gets one upstream call per TTL. METAR is cached for 5 min per 0.5° cell. - The NWS-required User-Agent goes on every NWS and AWC request. - Zone-based alerts (`geometry: null`) get their polygons from `affectedZones`. Zone shapes are cached for 24 h, failed zone fetches for 15 min, and only `https://api.weather.gov/zones/` URLs are ever fetched. Any alert still unresolved goes to `zone_only`, which is never bbox-filtered or dropped. **Upstreams checked by hand on 2026-10-05:** NWS `/alerts/active?area=` returns GeoJSON, and a large share of alerts have null geometry. `/zones/forecast/X` returns a Polygon. AWC `/api/data/metar?bbox=minLat,minLon,maxLat,maxLon&format=json` returns a JSON list, 204 with an empty body when no station is in the box, and `wdir` can be `"VRB"`. A live smoke test against CA, FL and AK worked: 21 mapped and 10 zone_only, the latter only because of the 80-zone-fetch-per-refresh cap, so they resolve over the next refreshes. White Plains wind resolved to KHPN at 5.9 km. **Brief deviation:** the wind cache is per 0.5° area cell, not per station. One bbox query serves every point in the cell, so it is still one upstream call per 5 min per area. **Heads-up for the batch-5 map layer:** zone-resolved polygons are heavy. The live CA/FL/AK payload was about 2.1 MB unfiltered, so the frontend should always pass the viewport bbox. Follow-up filed as #264 (simplification). **Tests:** `tests/test_weather.py`, 9 tests (happy path + UA, null-geometry/zone_only, 500 keeps last-good, canary, empty list OK, single-flight cache, bbox filter/validation, wind nearest/VRB/dedupe/cache, wind unavailable). Full c2-core suite: 863 passed. Refs #237 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 1 commit 2026-10-05 02:55:10 -04:00
GET /weather/alerts — api.weather.gov/alerts/active for settings.nws_area,
sent with the NWS-required User-Agent, cached weather_alerts_ttl_s (90 s)
with single-flight refresh. Zone-based alerts (geometry null) get their
polygons from affectedZones (24 h zone cache, api.weather.gov/zones/ only);
any still unresolved are returned in zone_only, never dropped. Failed
refresh keeps last-good data + error + fetched_at; a payload where no
feature parses is a schema-change error. Optional bbox filter.

GET /weather/wind — nearest METAR (aviationweather.gov /api/data/metar,
bbox query) within 75 km, cached 5 min per 0.5-degree cell; VRB wind and
stale reports flagged; available:false with a reason otherwise.

Both behind require_service_or_firebase_token like /traffic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
logan added 1 commit 2026-10-05 02:58:50 -04:00
- Cap one refresh's NWS zone fetches at 15 s wall clock. They run under the
  feed lock, so a hung zone endpoint could otherwise stall every
  /weather/alerts request for minutes; unfinished zones retry next refresh.
- Prod c2-core.env.j2 never set NWS_USER_AGENT, so the VM would have sent the
  contact-less placeholder. Default it to the site URL (overridable via
  vault_nws_user_agent); warn on the .env.example example.com placeholder too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Review (bdbde35): pushed two fixes. (1) Zone fetches now have a 15 s wall-clock budget, because they run under the feed lock and a hung zone endpoint could stall every /weather/alerts request for minutes. New test covers it. (2) The prod c2-core.env.j2 never set NWS_USER_AGENT, so the VM would have sent the placeholder with no contact. It now defaults to the site URL and can be overridden with vault_nws_user_agent. The full c2-core suite passes: 864. Not merged.

Review (bdbde35): pushed two fixes. (1) Zone fetches now have a 15 s wall-clock budget, because they run under the feed lock and a hung zone endpoint could stall every /weather/alerts request for minutes. New test covers it. (2) The prod c2-core.env.j2 never set NWS_USER_AGENT, so the VM would have sent the placeholder with no contact. It now defaults to the site URL and can be overridden with vault_nws_user_agent. The full c2-core suite passes: 864. Not merged.
logan merged commit f3b21cd432 into main 2026-10-05 03:03:33 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#263