From 150cc5fdc45dba10eb3b5ca1a82436b33071aebf Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Mon, 31 Aug 2026 21:31:31 -0400 Subject: [PATCH] feat(infra): GET /api/infrastructure Overpass nuclear markers (bbox) Overpass nwr power=plant + plant:source=nuclear, bbox-required, one in-flight per quantized bbox, 25s server query with identifying UA, 24h TTL cache. Normalized to {id,name,lat,lon,type,extra}. overlay_catalog entry infra_nuclear. Missing bbox 400; unknown type 422. --- app/live_layers.py | 107 ++++++++++++++++++++++++++ app/main.py | 35 ++++++++- tests/test_infrastructure.py | 145 +++++++++++++++++++++++++++++++++++ 3 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 tests/test_infrastructure.py diff --git a/app/live_layers.py b/app/live_layers.py index fa632b1..36b4bb5 100644 --- a/app/live_layers.py +++ b/app/live_layers.py @@ -150,6 +150,12 @@ def overlay_catalog() -> dict: "endpoint": "/api/map/gpsjam", "attribution": "GPSJAM / John Wiseman / ADS-B Exchange", }, + "infra_nuclear": { + "id": "infra_nuclear", + "kind": "points", + "endpoint": "/api/infrastructure?types=nuclear", + "attribution": "OpenStreetMap contributors / Overpass API", + }, } @@ -1429,3 +1435,104 @@ async def fetch_gpsjam(date: str) -> dict: return gpsjam_csv_to_geojson(resp.text) return await _ttl_get(f"gpsjam:{date}", GPSJAM_TTL, _load) +# ── Infrastructure (Overpass) ─────────────────────────────────────────────── + + +OVERPASS_INTERPRETER = "https://overpass-api.de/api/interpreter" +# One in-flight query per quantized bbox (the per-key lock in _ttl_get). Overpass +# asks for a 25s server timeout in-band; the client gives it 30s of headroom. +OVERPASS_TIMEOUT = httpx.Timeout(30.0, connect=5.0) +INFRA_TTL = 24 * 3600 # 24h per quantized bbox — static infrastructure + +# `types=` enum. Nuclear ships first; military/hospital slot in behind the same +# query template without touching the transport. Overpass bbox is +# (south, west, north, east), i.e. (minlat, minlon, maxlat, maxlon). +_INFRA_QUERIES: dict[str, str] = { + "nuclear": ( + '[out:json][timeout:25];\n' + 'nwr["power"="plant"]["plant:source"="nuclear"]({bbox});\n' + 'out center;' + ), +} + + +def infra_query(type_: str, minlon: float, minlat: float, maxlon: float, maxlat: float) -> str: + """Render one Overpass query with the bbox substituted in south,west,north,east.""" + bbox = f"{minlat},{minlon},{maxlat},{maxlon}" + return _INFRA_QUERIES[type_].replace("{bbox}", bbox) + + +def normalize_infra_element(elem: dict, type_: str) -> dict | None: + """Map one Overpass element to ``{id, name, lat, lon, type, extra}``. + + ``out center`` gives nodes their own ``lat``/``lon`` and ways/relations a + ``center``. Elements with no usable coordinate are dropped. + """ + etype = elem.get("type") + eid = elem.get("id") + if eid is None: + return None + if etype == "node": + lat, lon = elem.get("lat"), elem.get("lon") + else: + center = elem.get("center") or {} + lat, lon = center.get("lat"), center.get("lon") + if lat is None or lon is None: + return None + tags = elem.get("tags") or {} + name = tags.get("name") or tags.get("ref") or f"{etype}/{eid}" + extra = {k: v for k, v in tags.items() if k != "name"} + return { + "id": f"{etype}/{eid}", + "name": name, + "lat": lat, + "lon": lon, + "type": type_, + "extra": extra, + } + + +def overpass_nuclear_to_markers(data: dict) -> list[dict]: + """Convert an Overpass JSON response to normalized nuclear markers.""" + markers = [] + for elem in data.get("elements") or []: + marker = normalize_infra_element(elem, "nuclear") + if marker is not None: + markers.append(marker) + return markers + + +async def fetch_infrastructure(types: str, bbox: str) -> list[dict]: + """Fetch Overpass infrastructure markers, cached 24h per quantized bbox. + + ``types`` is a single supported enum value (``nuclear`` for now). ``bbox`` + is ``minlon,minlat,maxlon,maxlat``. + """ + requested = [t.strip() for t in types.split(",") if t.strip()] + minlon, minlat, maxlon, maxlat = parse_bbox(bbox) + key = f"infra:{','.join(requested)}:{bbox_cell_key(bbox)}" + + async def _load() -> list[dict]: + # One query per requested type, concatenated. Nuclear is the only type + # today; the loop keeps the shape ready for military/hospital. + out: list[dict] = [] + for type_ in requested: + query = infra_query(type_, minlon, minlat, maxlon, maxlat) + if _http is None: + async with httpx.AsyncClient( + timeout=OVERPASS_TIMEOUT, follow_redirects=True, + headers=_headers(), + ) as client: + resp = await client.post(OVERPASS_INTERPRETER, data={"data": query}) + resp.raise_for_status() + out.extend(overpass_nuclear_to_markers(resp.json())) + else: + resp = await _http.post( + OVERPASS_INTERPRETER, data={"data": query}, + timeout=OVERPASS_TIMEOUT, + ) + resp.raise_for_status() + out.extend(overpass_nuclear_to_markers(resp.json())) + return out + + return await _ttl_get(key, float(INFRA_TTL), _load) diff --git a/app/main.py b/app/main.py index 2d12e25..b4fe5bd 100644 --- a/app/main.py +++ b/app/main.py @@ -57,7 +57,7 @@ from live_layers import ( fetch_aircraft, fetch_fire_incidents, fetch_fire_perimeters, fetch_gpsjam, fetch_planespotters_photo, fetch_radar_meta, fetch_sentinel1, fetch_storms, fetch_trains, fetch_vessels, fetch_weather_alerts, - overlay_catalog, parse_bbox, UpstreamRateLimited, + fetch_infrastructure, overlay_catalog, parse_bbox, UpstreamRateLimited, ) logging.basicConfig(level=logging.INFO) @@ -1764,6 +1764,39 @@ async def map_gpsjam( return overlay_json(fc, 3600) +_INFRA_TYPES = frozenset({"nuclear"}) + + +@app.get("/api/infrastructure") +async def api_infrastructure( + types: str = Query(..., description="comma-separated enum (nuclear)"), + bbox: str | None = Query(None, description="minlon,minlat,maxlon,maxlat"), +): + """Overpass-derived static infrastructure markers (nuclear power plants). + + ``bbox`` is required; ``types`` is a comma-separated subset of ``nuclear``. + Fetched from Overpass (identifying UA, 25s query) and cached 24h per + quantized bbox. Markers are ``{id, name, lat, lon, type, extra}``. + """ + if not bbox: + raise HTTPException(400, "bbox required (minlon,minlat,maxlon,maxlat)") + requested = [t.strip() for t in (types or "").split(",") if t.strip()] + if not requested: + raise HTTPException(422, "types required (e.g. nuclear)") + unknown = [t for t in requested if t not in _INFRA_TYPES] + if unknown: + raise HTTPException( + 422, f"unsupported types: {', '.join(unknown)} (supported: nuclear)" + ) + try: + markers = await fetch_infrastructure(",".join(requested), bbox) + except ValueError as exc: + raise HTTPException(422, str(exc)) from exc + except Exception as exc: + _upstream_or_502(exc, "infrastructure") + return overlay_json(markers, 86400) + + @app.get("/api/map/times") async def map_layer_times( layer: str = Query(..., description="GIBS layer identifier, e.g. VIIRS_SNPP_CorrectedReflectance_TrueColor"), diff --git a/tests/test_infrastructure.py b/tests/test_infrastructure.py new file mode 100644 index 0000000..27dd80f --- /dev/null +++ b/tests/test_infrastructure.py @@ -0,0 +1,145 @@ +"""GET /api/infrastructure — Overpass nuclear markers.""" + +import asyncio + +import httpx + +from live_layers import ( + normalize_infra_element, + overlay_catalog, + overpass_nuclear_to_markers, + _cache, +) +from main import app + +BASE = "http://test" + +OVERPASS = { + "version": 0.6, + "generator": "Overpass API", + "elements": [ + { + "type": "node", + "id": 12345, + "lat": 44.0, + "lon": -1.5, + "tags": {"name": "Test NPP", "operator": "EDF", "plant:source": "nuclear"}, + }, + { + "type": "way", + "id": 67890, + "center": {"lat": 43.5, "lon": -1.25}, + "tags": {"name": "Test Plant Way", "plant:source": "nuclear"}, + }, + { + "type": "relation", + "id": 999, + "center": {"lat": 43.0, "lon": -1.0}, + "tags": {}, + }, + ], +} + + +async def _get(path: str) -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url=BASE) as client: + return await client.get(path) + + +def test_normalize_node_to_marker(): + m = normalize_infra_element(OVERPASS["elements"][0], "nuclear") + assert m["id"] == "node/12345" + assert m["name"] == "Test NPP" + assert m["lat"] == 44.0 + assert m["lon"] == -1.5 + assert m["type"] == "nuclear" + assert m["extra"]["operator"] == "EDF" + assert "name" not in m["extra"] + + +def test_way_center_and_unnamed_fallback(): + way = normalize_infra_element(OVERPASS["elements"][1], "nuclear") + assert way["lat"] == 43.5 + assert way["lon"] == -1.25 + rel = normalize_infra_element(OVERPASS["elements"][2], "nuclear") + assert rel["name"] == "relation/999" + + +def test_overpass_json_to_markers(): + markers = overpass_nuclear_to_markers(OVERPASS) + assert len(markers) == 3 + assert markers[0]["id"] == "node/12345" + + +def test_missing_bbox_400(): + resp = asyncio.run(_get("/api/infrastructure?types=nuclear")) + assert resp.status_code == 400 + + +def test_unknown_type_422(): + resp = asyncio.run(_get("/api/infrastructure?types=military&bbox=-2,43,-1,44")) + assert resp.status_code == 422 + + +def test_map_infrastructure_returns_markers(monkeypatch): + async def fake_fetch(types, bbox): + return [ + {"id": "node/1", "name": "X", "lat": 1.0, "lon": 2.0, + "type": "nuclear", "extra": {}} + ] + + monkeypatch.setattr("main.fetch_infrastructure", fake_fetch) + resp = asyncio.run(_get("/api/infrastructure?types=nuclear&bbox=-2,43,-1,44")) + assert resp.status_code == 200 + body = resp.json() + assert body[0]["name"] == "X" + assert body[0]["type"] == "nuclear" + assert "max-age" in (resp.headers.get("cache-control") or "").lower() + + +def test_overlay_catalog_has_infra_nuclear(): + entry = overlay_catalog()["infra_nuclear"] + assert entry["kind"] == "points" + assert "nuclear" in entry["endpoint"] + + +def test_fetch_infrastructure_cache_hit_no_refetch(monkeypatch): + _cache.clear() + hits = {"n": 0} + + class FakeResp: + def raise_for_status(self): + pass + + def json(self): + return OVERPASS + + class FakeClient: + def __init__(self, **kw): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def post(self, url, data=None, timeout=None): + hits["n"] += 1 + assert "overpass-api.de" in url + assert "plant:source" in data["data"] + assert "nuclear" in data["data"] + return FakeResp() + + monkeypatch.setattr("live_layers.httpx.AsyncClient", FakeClient) + monkeypatch.setattr("live_layers._http", None) + + from live_layers import fetch_infrastructure + + m1 = asyncio.run(fetch_infrastructure("nuclear", "-2,43,-1,44")) + m2 = asyncio.run(fetch_infrastructure("nuclear", "-2,43,-1,44")) + assert len(m1) == 3 + assert m2 == m1 + assert hits["n"] == 1 + _cache.clear()