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
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: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e244405230
commit
553bba0925
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)
|
||||
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_dir = Path(__file__).parent / "static"
|
||||
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.request import Request, urlopen
|
||||
|
||||
from meshai.county_centroids import centroid_for_same
|
||||
from meshai.env.nws import _cfg_str, map_cap_severity
|
||||
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):
|
||||
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).
|
||||
event_id = identifier or f"ipaws:{same_value}:{sent}"
|
||||
|
||||
|
|
@ -395,10 +423,29 @@ class IPAWSAlertsAdapter:
|
|||
|
||||
@staticmethod
|
||||
def _centroid(geometry: Optional[dict]) -> Optional[tuple]:
|
||||
"""Best-effort (lat, lon) centroid of a GeoJSON Polygon (or None)."""
|
||||
if not geometry or geometry.get("type") != "Polygon":
|
||||
"""Best-effort (lat, lon) centroid of a GeoJSON Polygon / Point /
|
||||
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
|
||||
gtype = geometry.get("type")
|
||||
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]:
|
||||
return None
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
# ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue