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.
This commit is contained in:
parent
47c726d68d
commit
150cc5fdc4
3 changed files with 286 additions and 1 deletions
|
|
@ -150,6 +150,12 @@ def overlay_catalog() -> dict:
|
||||||
"endpoint": "/api/map/gpsjam",
|
"endpoint": "/api/map/gpsjam",
|
||||||
"attribution": "GPSJAM / John Wiseman / ADS-B Exchange",
|
"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 gpsjam_csv_to_geojson(resp.text)
|
||||||
|
|
||||||
return await _ttl_get(f"gpsjam:{date}", GPSJAM_TTL, _load)
|
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)
|
||||||
|
|
|
||||||
35
app/main.py
35
app/main.py
|
|
@ -57,7 +57,7 @@ from live_layers import (
|
||||||
fetch_aircraft, fetch_fire_incidents, fetch_fire_perimeters,
|
fetch_aircraft, fetch_fire_incidents, fetch_fire_perimeters,
|
||||||
fetch_gpsjam, fetch_planespotters_photo, fetch_radar_meta, fetch_sentinel1,
|
fetch_gpsjam, fetch_planespotters_photo, fetch_radar_meta, fetch_sentinel1,
|
||||||
fetch_storms, fetch_trains, fetch_vessels, fetch_weather_alerts,
|
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)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
@ -1764,6 +1764,39 @@ async def map_gpsjam(
|
||||||
return overlay_json(fc, 3600)
|
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")
|
@app.get("/api/map/times")
|
||||||
async def map_layer_times(
|
async def map_layer_times(
|
||||||
layer: str = Query(..., description="GIBS layer identifier, e.g. VIIRS_SNPP_CorrectedReflectance_TrueColor"),
|
layer: str = Query(..., description="GIBS layer identifier, e.g. VIIRS_SNPP_CorrectedReflectance_TrueColor"),
|
||||||
|
|
|
||||||
145
tests/test_infrastructure.py
Normal file
145
tests/test_infrastructure.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Add table
Reference in a new issue