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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7bee87b147
commit
fb1b6956c2
@@ -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)
|
||||
Reference in New Issue
Block a user