Files
server-26/drb-c2-core/app/routers/weather.py
T
Logan CusanoandClaude Opus 5.5 fb1b6956c2 Weather backend: NWS active alerts + nearest-METAR wind, cached (Refs #237)
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>
2026-10-05 02:54:33 -04:00

52 lines
1.9 KiB
Python

"""Weather context for the map (server-26#237): NWS alerts + nearest-METAR wind.
Public-domain US Government data, cached server-side in app/internal/weather.py;
no licence gate (unlike 511NY / outage providers).
"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from app.internal import weather
router = APIRouter(prefix="/weather", tags=["weather"])
@router.get("/alerts")
async def get_alerts(
south: Optional[float] = Query(None, ge=-90, le=90),
west: Optional[float] = Query(None, ge=-180, le=180),
north: Optional[float] = Query(None, ge=-90, le=90),
east: Optional[float] = Query(None, ge=-180, le=180),
):
"""Active NWS alerts with polygons, optionally filtered to those intersecting a bbox.
``zone_only`` holds alerts whose zones could not be resolved to a polygon; it
is never bbox-filtered (there is no shape to test) and never dropped.
"""
box = (south, west, north, east)
if any(v is not None for v in box) and any(v is None for v in box):
raise HTTPException(400, "give all of south, west, north, east or none")
if south is not None and (south > north or west > east):
raise HTTPException(400, "bbox must satisfy south<=north and west<=east")
feed = await weather.alerts.get()
items = feed.alerts
if south is not None:
items = [a for a in items if weather.intersects(a.get("bbox"), south, west, north, east)]
return {
"alerts": items,
"zone_only": feed.zone_only,
"fetched_at": feed.fetched_at,
"error": feed.error,
"credit": weather.CREDIT,
}
@router.get("/wind")
async def get_wind(
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
):
"""Wind from the nearest METAR station, or ``available: false`` with a reason."""
return await weather.nearest_wind(lat, lon)