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:
malice 2026-07-17 12:08:29 -06:00 committed by GitHub
commit 8460ab50e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 3574 additions and 2 deletions

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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]

View 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

View file

@ -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
# ============================================================================ # ============================================================================