mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
fix(ipaws): route county-only CAP alerts + auto-refresh toggles live (#159)
County-only civil CAP alerts (SAME geocode, no <polygon>) had no geometry, so the geometry-based region tagger could not place them: no region -> no region_routes match -> silently not broadcast. Many CEMs / 911 outages / some AMBER alerts are county-only. - Bundle work/meshai/county_centroids.py: Census 2023 national county gazetteer internal points (3,222 counties + DC/territories), FIPS->(lat,lon), with SAME PSSCCC -> 5-digit FIPS helpers. - env/ipaws.py: when a CAP alert has SAME geocode(s) but NO polygon, set a Point (single county) or MultiPoint (multi-county) geometry from the county centroid(s) so the EXISTING coverage/region tagger locates it and tags ALL matching regions. Real polygon geometry always wins (never overridden). - dashboard/server.py: call register_config_routes_hooks(app) in create_app so the toggle auto-refresh middleware is actually wired in prod (was test-only); saving a family toggle now takes effect live without POST /api/notifications/refresh-toggles. Tests: county-only alert tags SW Idaho + matches emergency route cell; multi-county tags all regions; polygon path unchanged; create_app wires the refresh middleware; panhandle coverage-gap documented. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e244405230
commit
8460ab50e0
5 changed files with 3574 additions and 2 deletions
3278
work/meshai/county_centroids.py
Normal file
3278
work/meshai/county_centroids.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -78,6 +78,15 @@ def create_app() -> FastAPI:
|
||||||
# WebSocket router (no prefix, path is /ws/live)
|
# WebSocket router (no prefix, path is /ws/live)
|
||||||
app.include_router(ws_router)
|
app.include_router(ws_router)
|
||||||
|
|
||||||
|
# Auto-refresh the live ToggleFilter after any successful notifications/
|
||||||
|
# config PUT so enabling a family toggle takes effect WITHOUT a manual
|
||||||
|
# POST /api/notifications/refresh-toggles. The middleware is defined in
|
||||||
|
# config_routes.register_config_routes_hooks(); it was previously only wired
|
||||||
|
# up in tests, so in prod a saved toggle read "enabled" in config while the
|
||||||
|
# running filter's enabled-set was stale until the manual poke.
|
||||||
|
from .api.config_routes import register_config_routes_hooks
|
||||||
|
register_config_routes_hooks(app)
|
||||||
|
|
||||||
# Static files setup for SPA
|
# Static files setup for SPA
|
||||||
static_dir = Path(__file__).parent / "static"
|
static_dir = Path(__file__).parent / "static"
|
||||||
index_html = static_dir / "index.html"
|
index_html = static_dir / "index.html"
|
||||||
|
|
|
||||||
51
work/meshai/env/ipaws.py
vendored
51
work/meshai/env/ipaws.py
vendored
|
|
@ -34,6 +34,7 @@ from typing import TYPE_CHECKING, Optional
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from meshai.county_centroids import centroid_for_same
|
||||||
from meshai.env.nws import _cfg_str, map_cap_severity
|
from meshai.env.nws import _cfg_str, map_cap_severity
|
||||||
from meshai.notifications.events import Event, make_event
|
from meshai.notifications.events import Event, make_event
|
||||||
|
|
||||||
|
|
@ -330,6 +331,33 @@ class IPAWSAlertsAdapter:
|
||||||
if not any(c in self._same_codes for c in area_same_codes):
|
if not any(c in self._same_codes for c in area_same_codes):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# ── County-centroid fallback for county-only (no-polygon) alerts ─────
|
||||||
|
# A civil CAP alert that carries only SAME county geocode(s) and NO
|
||||||
|
# <polygon> has no geometry, so the geometry-based coverage/region
|
||||||
|
# tagger (CoverageFilter -> event_region_names -> matching_area_names)
|
||||||
|
# cannot place it. With no region it never matches the region-routing
|
||||||
|
# matrix and is SILENTLY not broadcast — the exact gap this closes.
|
||||||
|
# Resolve each SAME county to its Census internal point so the EXISTING
|
||||||
|
# geometry tagger locates it. Multi-county alerts become a MultiPoint so
|
||||||
|
# EVERY county's region is tagged (Shapely intersects any point). A real
|
||||||
|
# <polygon> always wins and is never overridden.
|
||||||
|
if geometry is None and area_same_codes:
|
||||||
|
county_points = [] # [(lat, lon), ...] in resolution order
|
||||||
|
seen_points = set()
|
||||||
|
for code in area_same_codes:
|
||||||
|
pt = centroid_for_same(code)
|
||||||
|
if pt and pt not in seen_points:
|
||||||
|
seen_points.add(pt)
|
||||||
|
county_points.append(pt)
|
||||||
|
if len(county_points) == 1:
|
||||||
|
lat, lon = county_points[0]
|
||||||
|
geometry = {"type": "Point", "coordinates": [lon, lat]}
|
||||||
|
elif len(county_points) > 1:
|
||||||
|
geometry = {
|
||||||
|
"type": "MultiPoint",
|
||||||
|
"coordinates": [[lon, lat] for (lat, lon) in county_points],
|
||||||
|
}
|
||||||
|
|
||||||
# Stable per-alert id (also the dedup / group key).
|
# Stable per-alert id (also the dedup / group key).
|
||||||
event_id = identifier or f"ipaws:{same_value}:{sent}"
|
event_id = identifier or f"ipaws:{same_value}:{sent}"
|
||||||
|
|
||||||
|
|
@ -395,10 +423,29 @@ class IPAWSAlertsAdapter:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _centroid(geometry: Optional[dict]) -> Optional[tuple]:
|
def _centroid(geometry: Optional[dict]) -> Optional[tuple]:
|
||||||
"""Best-effort (lat, lon) centroid of a GeoJSON Polygon (or None)."""
|
"""Best-effort (lat, lon) centroid of a GeoJSON Polygon / Point /
|
||||||
if not geometry or geometry.get("type") != "Polygon":
|
MultiPoint (or None). Point/MultiPoint arise from the county-centroid
|
||||||
|
fallback for county-only alerts; MultiPoint returns the mean point (a
|
||||||
|
reasonable display centroid — region tagging uses the full geometry, not
|
||||||
|
this value)."""
|
||||||
|
if not geometry:
|
||||||
return None
|
return None
|
||||||
|
gtype = geometry.get("type")
|
||||||
coords = geometry.get("coordinates") or []
|
coords = geometry.get("coordinates") or []
|
||||||
|
if gtype == "Point":
|
||||||
|
# GeoJSON Point coordinates are [lon, lat].
|
||||||
|
if len(coords) >= 2:
|
||||||
|
return (coords[1], coords[0])
|
||||||
|
return None
|
||||||
|
if gtype == "MultiPoint":
|
||||||
|
pts = [c for c in coords if len(c) >= 2]
|
||||||
|
if not pts:
|
||||||
|
return None
|
||||||
|
lats = [c[1] for c in pts]
|
||||||
|
lons = [c[0] for c in pts]
|
||||||
|
return (sum(lats) / len(lats), sum(lons) / len(lons))
|
||||||
|
if gtype != "Polygon":
|
||||||
|
return None
|
||||||
if not coords or not coords[0]:
|
if not coords or not coords[0]:
|
||||||
return None
|
return None
|
||||||
ring = coords[0]
|
ring = coords[0]
|
||||||
|
|
|
||||||
220
work/tests/test_ipaws_county_only_routing.py
Normal file
220
work/tests/test_ipaws_county_only_routing.py
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
"""County-only IPAWS routing gap — regression + fix proof (NO transmit).
|
||||||
|
|
||||||
|
The bug this proves fixed: a civil CAP alert carrying only SAME county
|
||||||
|
geocode(s) and NO <polygon> had no geometry, so the geometry-based region
|
||||||
|
tagger (CoverageFilter -> event_region_names -> matching_area_names) could not
|
||||||
|
place it. With no region it never matched the region-routing matrix and was
|
||||||
|
SILENTLY not broadcast. Many CEMs / 911 outages / some AMBER alerts are
|
||||||
|
county-only.
|
||||||
|
|
||||||
|
The fix gives such alerts a Census county internal-point (Point, or MultiPoint
|
||||||
|
for multi-county) so the EXISTING tagger locates them. These tests assert the
|
||||||
|
county-only alert goes from "no region / dropped" to "tagged + routable" against
|
||||||
|
the prod-shaped SW/SC/East Idaho coverage areas + emergency route cells.
|
||||||
|
|
||||||
|
Everything here is a pure in-process harness. Nothing is sent to any mesh.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from meshai.config import IPAWSConfig
|
||||||
|
from meshai.env.ipaws import IPAWSAlertsAdapter
|
||||||
|
from meshai.coverage_area import MonitoringArea, event_region_names, classify_event_areas
|
||||||
|
from meshai.notifications.pipeline.coverage_filter import CoverageFilter
|
||||||
|
|
||||||
|
|
||||||
|
# Prod coverage areas (from the live CT108 config, 2026-07-16): three named
|
||||||
|
# Idaho region boxes. Names match the emergency region_routes cell keys exactly.
|
||||||
|
PROD_AREAS = [
|
||||||
|
MonitoringArea(name="SW Idaho", west=-117.993408, south=41.9, east=-115.389404, north=44.331707),
|
||||||
|
MonitoringArea(name="SC Idaho", west=-115.389404, south=41.9, east=-112.8, north=44.331707),
|
||||||
|
MonitoringArea(name="East Idaho", west=-112.8, south=41.9, east=-110.9, north=45.331707),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Prod emergency region_routes cells (live CT108 config): every named region has
|
||||||
|
# an enabled cell on both transports. A tagged region that is a key here WOULD
|
||||||
|
# route (matrix Section 1.5 owns delivery); tagging is the gap we're closing.
|
||||||
|
EMERGENCY_CELLS = {
|
||||||
|
"SW Idaho": {"enabled": True, "mt": 3, "mc": "#sw-id-aida", "min_severity": "routine"},
|
||||||
|
"SC Idaho": {"enabled": True, "mt": 2, "mc": "#sc-id-aida", "min_severity": "routine"},
|
||||||
|
"East Idaho": {"enabled": True, "mt": 5, "mc": "#e-id-aida", "min_severity": "routine"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cap(same_areas, *, polygon: str | None = None) -> bytes:
|
||||||
|
"""Build a minimal CAP 1.2 civil alert.
|
||||||
|
|
||||||
|
same_areas: list of (areaDesc, SAME_code) tuples -> one <area> each.
|
||||||
|
polygon: optional CAP polygon string ('lat,lon lat,lon ...'); when given
|
||||||
|
it is attached to the FIRST area (real-geometry regression case).
|
||||||
|
"""
|
||||||
|
areas_xml = []
|
||||||
|
for i, (desc, same) in enumerate(same_areas):
|
||||||
|
poly = f"<polygon>{polygon}</polygon>" if (polygon and i == 0) else ""
|
||||||
|
areas_xml.append(
|
||||||
|
f"<area><areaDesc>{desc}</areaDesc>"
|
||||||
|
f"<geocode><valueName>SAME</valueName><value>{same}</value></geocode>"
|
||||||
|
f"{poly}</area>"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">'
|
||||||
|
"<identifier>ID-COUNTY-ONLY-TEST</identifier>"
|
||||||
|
"<sender>oem@example-county.id.gov</sender>"
|
||||||
|
"<sent>2026-07-16T12:00:00-06:00</sent>"
|
||||||
|
"<status>Actual</status><msgType>Alert</msgType>"
|
||||||
|
"<info>"
|
||||||
|
"<language>en-US</language>"
|
||||||
|
"<event>Civil Emergency Message</event>"
|
||||||
|
"<urgency>Immediate</urgency><severity>Extreme</severity>"
|
||||||
|
"<certainty>Observed</certainty>"
|
||||||
|
"<eventCode><valueName>SAME</valueName><value>CEM</value></eventCode>"
|
||||||
|
"<headline>County-only civil emergency</headline>"
|
||||||
|
"<description>Shelter in place until further notice.</description>"
|
||||||
|
+ "".join(areas_xml) +
|
||||||
|
"</info></alert>"
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _adapter() -> IPAWSAlertsAdapter:
|
||||||
|
return IPAWSAlertsAdapter(IPAWSConfig())
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_via_pipeline(event):
|
||||||
|
"""Run an event through CoverageFilter exactly as the live pipeline does and
|
||||||
|
return (kept, event). Region tags are stamped onto the event in place."""
|
||||||
|
received = []
|
||||||
|
flt = CoverageFilter(next_handler=received.append, areas=PROD_AREAS, enabled=True)
|
||||||
|
flt.handle(event)
|
||||||
|
return (len(received) == 1), event
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 1. County-only Idaho alert (the exact silent-drop case) -> tagged + routable
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
def test_county_only_alert_resolves_to_point_geometry():
|
||||||
|
"""A SAME-only (no polygon) Ada County alert gets a Point geometry + lat/lon
|
||||||
|
from the Census internal point instead of None."""
|
||||||
|
a = _adapter()
|
||||||
|
raw = a._parse_cap(_cap([("Ada County", "016001")]), "16")
|
||||||
|
assert raw is not None
|
||||||
|
assert raw["geometry"] is not None
|
||||||
|
assert raw["geometry"]["type"] == "Point"
|
||||||
|
lon, lat = raw["geometry"]["coordinates"]
|
||||||
|
assert raw["lat"] == pytest.approx(lat) and raw["lon"] == pytest.approx(lon)
|
||||||
|
# Ada County internal point is in the Boise area.
|
||||||
|
assert 43.0 < raw["lat"] < 44.0 and -117.0 < raw["lon"] < -116.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_county_only_alert_tags_region_and_matches_route_cell():
|
||||||
|
"""THE proof: the county-only alert that silently dropped now tags SW Idaho
|
||||||
|
(via the existing geometry tagger) AND that region is an emergency route
|
||||||
|
cell -> it WOULD route. No send is performed."""
|
||||||
|
a = _adapter()
|
||||||
|
raw = a._parse_cap(_cap([("Ada County", "016001")]), "16")
|
||||||
|
ev = a.to_event(raw)
|
||||||
|
|
||||||
|
# Direct tagger check (what CoverageFilter calls).
|
||||||
|
names = event_region_names(ev, PROD_AREAS)
|
||||||
|
assert names == ["SW Idaho"]
|
||||||
|
|
||||||
|
# Full pipeline gate: kept (in-bounds) and region stamped on the event.
|
||||||
|
kept, ev = _tag_via_pipeline(ev)
|
||||||
|
assert kept is True
|
||||||
|
assert ev.regions == ["SW Idaho"]
|
||||||
|
assert ev.region == "SW Idaho"
|
||||||
|
|
||||||
|
# Routable: the tagged region resolves to an ENABLED emergency route cell.
|
||||||
|
matched = [r for r in ([ev.region, *ev.regions]) if r in EMERGENCY_CELLS]
|
||||||
|
assert matched, "county-only alert must now match an emergency route cell"
|
||||||
|
assert EMERGENCY_CELLS[matched[0]]["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_county_only_alert_was_dropped_before_fix():
|
||||||
|
"""Regression anchor: WITHOUT the centroid (simulating pre-fix state — no
|
||||||
|
geometry, no lat/lon) the same event tags NO region, i.e. it had nowhere to
|
||||||
|
route. This is the behaviour the fix changes."""
|
||||||
|
a = _adapter()
|
||||||
|
raw = a._parse_cap(_cap([("Ada County", "016001")]), "16")
|
||||||
|
ev = a.to_event(raw)
|
||||||
|
# Strip the fix's contribution to reproduce the old no-geometry event.
|
||||||
|
ev.data["geometry"] = None
|
||||||
|
ev.lat = None
|
||||||
|
ev.lon = None
|
||||||
|
assert event_region_names(ev, PROD_AREAS) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 2. Multi-county alert tags ALL its regions
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
def test_multi_county_alert_tags_all_regions():
|
||||||
|
"""Ada County (SW Idaho) + Bannock County (East Idaho) in one alert -> a
|
||||||
|
MultiPoint geometry -> BOTH regions tagged -> both route cells reachable."""
|
||||||
|
a = _adapter()
|
||||||
|
raw = a._parse_cap(
|
||||||
|
_cap([("Ada County", "016001"), ("Bannock County", "016005")]), "16"
|
||||||
|
)
|
||||||
|
assert raw["geometry"]["type"] == "MultiPoint"
|
||||||
|
assert len(raw["geometry"]["coordinates"]) == 2
|
||||||
|
|
||||||
|
ev = a.to_event(raw)
|
||||||
|
names = event_region_names(ev, PROD_AREAS)
|
||||||
|
assert set(names) == {"SW Idaho", "East Idaho"}
|
||||||
|
|
||||||
|
kept, ev = _tag_via_pipeline(ev)
|
||||||
|
assert kept is True
|
||||||
|
assert set(ev.regions) == {"SW Idaho", "East Idaho"}
|
||||||
|
# Both tagged regions are enabled emergency route cells.
|
||||||
|
assert all(r in EMERGENCY_CELLS for r in ev.regions)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 3. Polygon alert is UNCHANGED (regression guard)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
def test_polygon_alert_geometry_not_overridden():
|
||||||
|
"""When a real <polygon> is present the county-centroid fallback must NOT
|
||||||
|
fire — the Polygon geometry wins unchanged."""
|
||||||
|
a = _adapter()
|
||||||
|
# A small polygon roughly over Ada County; SAME geocode also present.
|
||||||
|
poly = "43.7,-116.4 43.7,-116.1 43.5,-116.1 43.5,-116.4"
|
||||||
|
raw = a._parse_cap(_cap([("Ada County", "016001")], polygon=poly), "16")
|
||||||
|
assert raw["geometry"]["type"] == "Polygon"
|
||||||
|
ev = a.to_event(raw)
|
||||||
|
assert ev.data["geometry"]["type"] == "Polygon"
|
||||||
|
assert classify_event_areas(ev, PROD_AREAS) == "in-bounds"
|
||||||
|
|
||||||
|
|
||||||
|
def test_polygon_alert_matches_real_fixture(_ipaws_fixture_bytes):
|
||||||
|
"""The real captured Idaho CEM fixture (has a Boundary County polygon) still
|
||||||
|
parses to a Polygon — the fallback path leaves it alone."""
|
||||||
|
a = _adapter()
|
||||||
|
raw = a._parse_cap(_ipaws_fixture_bytes, "16")
|
||||||
|
assert raw["geometry"]["type"] == "Polygon"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _ipaws_fixture_bytes():
|
||||||
|
import pathlib
|
||||||
|
fx = pathlib.Path(__file__).parent / "fixtures" / "ipaws" / "eas_idaho_cem.xml"
|
||||||
|
return fx.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 4. Unresolvable county (northern panhandle) — honest coverage-gap guard
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
def test_panhandle_county_gets_geometry_but_no_region():
|
||||||
|
"""Boundary County (northern panhandle) DOES get a valid Point, but the prod
|
||||||
|
coverage boxes stop at ~lat 44.3 (SW/SC), so it tags NO region. That is a
|
||||||
|
coverage-DEFINITION gap (region boxes don't cover north Idaho), not a bug in
|
||||||
|
this fix — documented so it isn't mistaken for a regression."""
|
||||||
|
a = _adapter()
|
||||||
|
raw = a._parse_cap(_cap([("Boundary County", "016021")]), "16")
|
||||||
|
assert raw["geometry"]["type"] == "Point" # located...
|
||||||
|
assert raw["lat"] > 48.0 # ...in the panhandle
|
||||||
|
ev = a.to_event(raw)
|
||||||
|
assert event_region_names(ev, PROD_AREAS) == [] # outside all region boxes
|
||||||
|
|
@ -71,6 +71,24 @@ def test_auto_refresh_does_not_fire_on_other_section():
|
||||||
assert refreshed["n"] == 0
|
assert refreshed["n"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_app_registers_auto_refresh_middleware():
|
||||||
|
"""Regression: create_app() must actually WIRE the auto-refresh middleware.
|
||||||
|
It was defined in register_config_routes_hooks() but never called from
|
||||||
|
create_app(), so in prod a saved toggle never refreshed the live filter and
|
||||||
|
had to be poked with POST /api/notifications/refresh-toggles by hand."""
|
||||||
|
from meshai.dashboard.server import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
dispatches = []
|
||||||
|
for mw in app.user_middleware:
|
||||||
|
fn = (getattr(mw, "kwargs", {}) or {}).get("dispatch")
|
||||||
|
if fn is not None:
|
||||||
|
dispatches.append(getattr(fn, "__qualname__", ""))
|
||||||
|
assert any("_auto_refresh_toggle_filter" in q for q in dispatches), (
|
||||||
|
"create_app() did not register the toggle auto-refresh middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Item 2 -- env_reporter cap from adapter_config
|
# Item 2 -- env_reporter cap from adapter_config
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue