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>
52 lines
1.9 KiB
Python
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)
|