Merge pull request 'Weather backend: NWS active alerts + nearest-METAR wind, cached (Refs #237)' (#263) from feat/237-weather-api into main
Build & Deploy / Build & push images (push) Successful in 5m21s
Build & Deploy / Deploy to VM (push) Canceled after 0s
Build & Deploy / Deploy Firestore rules & indexes (push) Canceled after 0s
Build & Deploy / Report a failed deploy (push) Skipped

This commit was merged in pull request #263.
This commit is contained in:
2026-10-05 03:03:32 -04:00
8 changed files with 719 additions and 1 deletions
+51
View File
@@ -0,0 +1,51 @@
"""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)