feat(nws): resolve zone-only alerts to geometry via affectedZones (cached) so in-coverage zone alerts are placed + region-tagged instead of dropped (#91)

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-07 22:23:19 -06:00 committed by GitHub
commit e3b93f652c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 585 additions and 3 deletions

View file

@ -269,11 +269,21 @@ class NWSAlertsAdapter:
}
# Attach the RAW GeoJSON alert geometry (Polygon / MultiPolygon /
# None) to the event. This is the authoritative field the pipeline
# coverage gate intersects against configured areas. Zone-only NWS
# alerts have geometry=None; the fail-closed gate drops those.
# None) — the authoritative field the coverage gate intersects
# against configured areas. Zone-only NWS alerts have geometry=None;
# resolve those from affectedZones (cached zone shapes) so
# in-coverage zone alerts get placed + region-tagged instead of
# dropped by the fail-closed gate.
geom = feature.get("geometry")
if geom is None:
zurls = props.get("affectedZones") or []
if zurls:
from meshai.env.nws_zones import resolve_zones_geometry
resolved = resolve_zones_geometry(zurls)
if resolved:
geom = resolved
event["geometry"] = geom
event["affected_zones"] = props.get("affectedZones", [])
# Compute a best-effort centroid (fallback / nice-to-have; the
# geometry above is authoritative). Handle Polygon and MultiPolygon.
@ -293,6 +303,18 @@ class NWSAlertsAdapter:
lon_sum = sum(c[0] for c in ring)
event["lat"] = lat_sum / len(ring)
event["lon"] = lon_sum / len(ring)
elif geom.get("type") == "MultiPolygon" and coords:
# Average all outer-ring vertices across all polygons
all_lats: list[float] = []
all_lons: list[float] = []
for poly_coords in coords:
if poly_coords:
ring = poly_coords[0]
all_lats.extend(c[1] for c in ring)
all_lons.extend(c[0] for c in ring)
if all_lats:
event["lat"] = sum(all_lats) / len(all_lats)
event["lon"] = sum(all_lons) / len(all_lons)
except Exception:
pass

188
work/meshai/env/nws_zones.py vendored Normal file
View file

@ -0,0 +1,188 @@
"""NWS zone-geometry resolver with persistent SQLite cache.
Zones at api.weather.gov/zones/* are static (published zone boundaries that
change only when NWS redraws them rare, O(years)). We cache resolved
geometries in the shared meshai DB so repeated polls for the same alert don't
hit the NWS API on every tick. A process-level in-memory dict provides a
hot-path shortcut inside a single run.
Public API
----------
resolve_zones_geometry(zone_urls) -> dict | None
Combine a list of NWS zone URLs into ONE GeoJSON MultiPolygon.
Internal helper exposed for monkeypatching in tests
----------------------------------------------------
_fetch_zone(url) -> dict | None
Single-zone HTTP fetch. Factor it out so tests can inject geometry
without touching the network at all.
"""
from __future__ import annotations
import json
import logging
import time
from typing import Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from meshai.persistence import get_db
logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------
# In-memory hot cache: url -> geometry dict or None
# (None = confirmed "this zone has no geometry"; still authoritative.)
# --------------------------------------------------------------------------
_mem_cache: dict[str, Optional[dict]] = {}
_NWS_USER_AGENT = "(meshai, ops@echo6.co)"
_FETCH_TIMEOUT = 8 # seconds
_CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS nws_zone_cache (
url TEXT PRIMARY KEY,
geometry_json TEXT,
fetched_at REAL
)
"""
# --------------------------------------------------------------------------
# Internal helpers
# --------------------------------------------------------------------------
def _ensure_table(conn) -> None:
"""Create the cache table if it doesn't already exist (idempotent)."""
conn.execute(_CREATE_TABLE_SQL)
def _fetch_zone(url: str) -> Optional[dict]:
"""Fetch a single NWS zone URL and return its .geometry (or None).
This function is intentionally thin so tests can monkeypatch it without
needing to stub urllib internals. Any network/parse error returns None
and is logged at DEBUG level (not ERROR zone miss is non-fatal).
"""
req = Request(
url,
headers={
"User-Agent": _NWS_USER_AGENT,
"Accept": "application/geo+json",
},
)
try:
with urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
data = json.loads(resp.read().decode("utf-8"))
return data.get("geometry") # may legitimately be None
except (HTTPError, URLError, OSError) as exc:
logger.debug("nws_zones: fetch failed for %s: %s", url, exc)
return None
except Exception as exc:
logger.debug("nws_zones: unexpected error fetching %s: %s", url, exc)
return None
def _load_cached(conn, url: str) -> tuple[bool, Optional[dict]]:
"""Return (cache_hit, geometry). geometry may be None for known-null zones."""
_ensure_table(conn)
row = conn.execute(
"SELECT geometry_json FROM nws_zone_cache WHERE url = ?",
(url,),
).fetchone()
if row is None:
return False, None
raw_json = row[0]
if raw_json is None:
return True, None # cached null marker — zone has no geometry
try:
return True, json.loads(raw_json)
except Exception:
return True, None # corrupt cache row — treat as null
def _store_cached(conn, url: str, geometry: Optional[dict]) -> None:
"""Persist a resolved geometry (or a null marker) for the given URL."""
_ensure_table(conn)
raw_json = json.dumps(geometry) if geometry is not None else None
conn.execute(
"INSERT OR REPLACE INTO nws_zone_cache(url, geometry_json, fetched_at)"
" VALUES(?, ?, ?)",
(url, raw_json, time.time()),
)
def _get_zone_geometry(url: str) -> Optional[dict]:
"""Return the GeoJSON geometry for a single zone URL (multi-level cached).
Resolution order:
1. In-memory dict (hot path, no I/O)
2. SQLite nws_zone_cache table
3. HTTP fetch via NWS API write-through to both caches
"""
# 1. In-memory hit
if url in _mem_cache:
return _mem_cache[url]
# 2. Persistent cache
conn = get_db()
found, geom = _load_cached(conn, url)
if found:
_mem_cache[url] = geom
return geom
# 3. Fetch from NWS (delegates to _fetch_zone — monkeypatchable)
geom = _fetch_zone(url)
_mem_cache[url] = geom
_store_cached(conn, url, geom)
return geom
# --------------------------------------------------------------------------
# Public API
# --------------------------------------------------------------------------
def resolve_zones_geometry(zone_urls: list) -> Optional[dict]:
"""Resolve a list of NWS zone URLs to ONE combined GeoJSON MultiPolygon.
Fetches each zone's geometry (cached persistently — zones are static), and
concatenates every zone's polygon(s) into a single MultiPolygon. Returns
None when the list is empty or NONE of the zones resolve (caller then keeps
the alert's geometry as None → existing fail-closed behaviour).
Fully resilient: any per-zone fetch/parse error skips that zone.
Combination rules
-----------------
- Polygon its single coordinate ring-list appended as one MP entry
- MultiPolygon each of its coordinate ring-lists appended as MP entries
- Anything else (e.g. Point, GeometryCollection) skipped
"""
if not zone_urls:
return None
combined: list = []
for url in zone_urls:
try:
geom = _get_zone_geometry(url)
if not geom:
continue
gtype = geom.get("type")
coords = geom.get("coordinates")
if not coords:
continue
if gtype == "Polygon":
# A Polygon's coordinates is [outer_ring, *holes].
# In a MultiPolygon each entry IS [outer_ring, *holes].
combined.append(coords)
elif gtype == "MultiPolygon":
# A MultiPolygon's coordinates is [[ring_list], [ring_list], …]
combined.extend(coords)
# Unsupported geometry type → skip silently
except Exception as exc:
logger.debug("nws_zones: error resolving zone %s: %s", url, exc)
if not combined:
return None
return {"type": "MultiPolygon", "coordinates": combined}

View file

@ -0,0 +1,372 @@
"""Tests for meshai.env.nws_zones — network-free, cache-verified.
All tests monkeypatch `meshai.env.nws_zones._fetch_zone` so no HTTP calls
are ever made. The persistent SQLite cache uses the per-test temp DB that
`conftest._isolate_meshai_db` (autouse) sets up automatically.
The process-level in-memory cache (_mem_cache) is cleared by the
`_clear_zone_mem_cache` fixture (autouse within this module) so tests are
independent of execution order.
"""
import io
import json
from unittest.mock import MagicMock, patch
import pytest
import meshai.env.nws_zones as nws_zones
from meshai.env.nws_zones import resolve_zones_geometry
from meshai.persistence import get_db
# ---------------------------------------------------------------------------
# Module-local autouse: clear the in-memory hot cache around every test
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _clear_zone_mem_cache():
"""Ensure the module-level _mem_cache is empty before and after each test."""
nws_zones._mem_cache.clear()
yield
nws_zones._mem_cache.clear()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _polygon(ring):
"""Build a minimal GeoJSON Polygon geometry from a single outer ring."""
return {"type": "Polygon", "coordinates": [ring]}
def _multipolygon(rings):
"""Build a GeoJSON MultiPolygon from a list of outer rings."""
return {"type": "MultiPolygon", "coordinates": [[r] for r in rings]}
# Sample rings (lon, lat order as per GeoJSON)
RING_A = [[-116.5, 42.5], [-116.0, 42.5], [-116.0, 43.0], [-116.5, 43.0], [-116.5, 42.5]]
RING_B = [[-115.5, 43.0], [-115.0, 43.0], [-115.0, 43.5], [-115.5, 43.5], [-115.5, 43.0]]
RING_C = [[-114.5, 42.0], [-114.0, 42.0], [-114.0, 42.5], [-114.5, 42.5], [-114.5, 42.0]]
RING_D = [[-113.5, 41.5], [-113.0, 41.5], [-113.0, 42.0], [-113.5, 42.0], [-113.5, 41.5]]
URL_A = "https://api.weather.gov/zones/fire/IDZ001"
URL_B = "https://api.weather.gov/zones/fire/IDZ002"
URL_C = "https://api.weather.gov/zones/fire/IDZ003"
# ---------------------------------------------------------------------------
# 1. Two Polygon zones → combined MultiPolygon with both polygons' coordinates
# ---------------------------------------------------------------------------
def test_two_polygon_zones_combine(monkeypatch):
"""Two Polygon zones produce a MultiPolygon with both polygons."""
geoms = {
URL_A: _polygon(RING_A),
URL_B: _polygon(RING_B),
}
monkeypatch.setattr(nws_zones, "_fetch_zone", lambda url: geoms.get(url))
result = resolve_zones_geometry([URL_A, URL_B])
assert result is not None
assert result["type"] == "MultiPolygon"
coords = result["coordinates"]
assert len(coords) == 2
# Each entry in a MultiPolygon is [outer_ring, *holes] — a list of rings.
assert coords[0] == [RING_A]
assert coords[1] == [RING_B]
# ---------------------------------------------------------------------------
# 2. Polygon + MultiPolygon → all rings folded into one MultiPolygon
# ---------------------------------------------------------------------------
def test_polygon_and_multipolygon_combine(monkeypatch):
"""A Polygon zone and a MultiPolygon zone are concatenated correctly."""
geoms = {
URL_A: _polygon(RING_A),
URL_B: _multipolygon([RING_B, RING_C]), # 2-polygon MultiPolygon
}
monkeypatch.setattr(nws_zones, "_fetch_zone", lambda url: geoms.get(url))
result = resolve_zones_geometry([URL_A, URL_B])
assert result is not None
assert result["type"] == "MultiPolygon"
coords = result["coordinates"]
# RING_A from Polygon → 1 entry; RING_B and RING_C from MultiPolygon → 2
assert len(coords) == 3
assert coords[0] == [RING_A]
assert coords[1] == [RING_B]
assert coords[2] == [RING_C]
# ---------------------------------------------------------------------------
# 3. Cache hit: fetcher is NOT called again for a URL already resolved
# ---------------------------------------------------------------------------
def test_cache_hit_no_refetch(monkeypatch):
"""Once a zone is cached, _fetch_zone is not called again for that URL."""
call_count = {"n": 0}
def fake_fetch(url):
call_count["n"] += 1
return _polygon(RING_A)
monkeypatch.setattr(nws_zones, "_fetch_zone", fake_fetch)
# First call → cache miss → fetcher invoked
r1 = resolve_zones_geometry([URL_A])
assert r1 is not None
assert call_count["n"] == 1
# Clear only the in-memory cache to force SQLite lookup path
nws_zones._mem_cache.clear()
# Second call → SQLite cache hit → fetcher NOT called again
r2 = resolve_zones_geometry([URL_A])
assert r2 is not None
assert call_count["n"] == 1, (
f"_fetch_zone called {call_count['n']} times; expected exactly 1"
)
assert r2["type"] == "MultiPolygon"
# ---------------------------------------------------------------------------
# 4. All zones fail / return None → resolve_zones_geometry returns None
# ---------------------------------------------------------------------------
def test_all_zones_fail_returns_none(monkeypatch):
"""When every zone fetch fails, the resolver returns None."""
monkeypatch.setattr(nws_zones, "_fetch_zone", lambda url: None)
result = resolve_zones_geometry([URL_A, URL_B])
assert result is None
def test_all_zones_none_geometry_returns_none(monkeypatch):
"""Zones that exist but have no geometry also yield None."""
# Simulate NWS returning {geometry: null} for every zone
monkeypatch.setattr(nws_zones, "_fetch_zone", lambda url: None)
result = resolve_zones_geometry([URL_A])
assert result is None
# ---------------------------------------------------------------------------
# 5. Empty list → None immediately (no fetch, no DB)
# ---------------------------------------------------------------------------
def test_empty_zone_list_returns_none(monkeypatch):
"""An empty zone list returns None without touching the fetcher."""
called = {"n": 0}
def fake_fetch(url):
called["n"] += 1
return _polygon(RING_A)
monkeypatch.setattr(nws_zones, "_fetch_zone", fake_fetch)
result = resolve_zones_geometry([])
assert result is None
assert called["n"] == 0
# ---------------------------------------------------------------------------
# 6. Null-marker caching: a zone with no geometry is cached as NULL so we
# don't refetch it on the next call.
# ---------------------------------------------------------------------------
def test_null_geometry_zone_cached(monkeypatch):
"""A zone that resolves to None is stored as NULL; not re-fetched."""
call_count = {"n": 0}
def fake_fetch(url):
call_count["n"] += 1
return None # zone exists but has no geometry
monkeypatch.setattr(nws_zones, "_fetch_zone", fake_fetch)
# First call: fetch + cache null
resolve_zones_geometry([URL_A])
assert call_count["n"] == 1
# Evict in-memory cache to force SQLite path
nws_zones._mem_cache.clear()
# Second call: hit SQLite null marker — should NOT re-fetch
resolve_zones_geometry([URL_A])
assert call_count["n"] == 1, "null-marker zone was re-fetched (should be cached)"
# ---------------------------------------------------------------------------
# 7. SQLite table is created on first use (idempotent CREATE TABLE IF NOT EXISTS)
# ---------------------------------------------------------------------------
def test_db_table_created(monkeypatch):
"""The nws_zone_cache table is auto-created on first call."""
monkeypatch.setattr(nws_zones, "_fetch_zone", lambda url: _polygon(RING_A))
resolve_zones_geometry([URL_A])
conn = get_db()
row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='nws_zone_cache'"
).fetchone()
assert row is not None, "nws_zone_cache table was not created"
# ---------------------------------------------------------------------------
# 8. Partial failure: one zone fails, others succeed → partial MultiPolygon
# ---------------------------------------------------------------------------
def test_partial_zone_failure_skipped(monkeypatch):
"""A failing zone is skipped; the resolver still combines the rest."""
geoms = {
URL_A: _polygon(RING_A),
URL_B: None, # this one fails
URL_C: _polygon(RING_C),
}
monkeypatch.setattr(nws_zones, "_fetch_zone", lambda url: geoms.get(url))
result = resolve_zones_geometry([URL_A, URL_B, URL_C])
assert result is not None
assert result["type"] == "MultiPolygon"
assert len(result["coordinates"]) == 2
# ---------------------------------------------------------------------------
# 9. (Optional) NWS _fetch parse path: zone-only feature → event has geometry
# Tests the wiring in nws.py without hitting any network.
# ---------------------------------------------------------------------------
def test_nws_parse_zone_only_alert_gets_geometry(monkeypatch):
"""A feature with geometry=null + affectedZones produces an event with
a non-None geometry after zone resolution (exercises the nws.py wire-up)."""
import json as _json
from io import BytesIO
from unittest.mock import patch
# Fake zone returns a simple polygon
monkeypatch.setattr(nws_zones, "_fetch_zone", lambda url: _polygon(RING_A))
# Build a minimal NWS API response with geometry=null
fake_response_body = _json.dumps({
"features": [
{
"geometry": None,
"properties": {
"id": "urn:oid:test.zone.only.001",
"event": "Red Flag Warning",
"severity": "Severe",
"headline": "Red Flag Warning for Owyhee Mountains",
"description": "Dry and windy conditions expected.",
"onset": "2026-07-08T14:00:00Z",
"expires": "2026-07-08T22:00:00Z",
"areaDesc": "Owyhee Mountains",
"geocode": {"UGC": ["IDZ423"]},
"affectedZones": [URL_A],
},
}
]
}).encode()
class _FakeResp:
def read(self): return fake_response_body
def __enter__(self): return self
def __exit__(self, *a): pass
from meshai.env.nws import NWSAlertsAdapter
config = MagicMock()
config.areas = ["ID"]
config.user_agent = "(meshai-test, test@example.com)"
config.severity_min = "moderate"
config.tick_seconds = 60
adapter = NWSAlertsAdapter(config)
with patch("meshai.env.nws.urlopen", return_value=_FakeResp()):
adapter._fetch()
events = adapter.get_events()
assert len(events) == 1, f"Expected 1 event, got {len(events)}"
ev = events[0]
assert ev["geometry"] is not None, "zone-only alert has no geometry after resolution"
assert ev["geometry"]["type"] == "MultiPolygon"
assert ev["affected_zones"] == [URL_A]
# lat/lon should be computed from the resolved MultiPolygon
assert "lat" in ev, "lat not computed from resolved zone geometry"
assert "lon" in ev, "lon not computed from resolved zone geometry"
def test_nws_parse_polygon_alert_unaffected(monkeypatch):
"""An alert that already has a polygon geometry is NOT touched by zone resolution."""
# The fetcher should never be called for alerts that already have geometry
called = {"n": 0}
def should_not_be_called(url):
called["n"] += 1
return _polygon(RING_A)
monkeypatch.setattr(nws_zones, "_fetch_zone", should_not_be_called)
import json as _json
from unittest.mock import patch
polygon_geom = {
"type": "Polygon",
"coordinates": [RING_B],
}
fake_response_body = _json.dumps({
"features": [
{
"geometry": polygon_geom,
"properties": {
"id": "urn:oid:test.polygon.001",
"event": "Tornado Warning",
"severity": "Extreme",
"headline": "Tornado Warning",
"description": "A tornado warning.",
"onset": "2026-07-08T14:00:00Z",
"expires": "2026-07-08T15:00:00Z",
"areaDesc": "Ada County",
"geocode": {"UGC": ["IDZ016"]},
"affectedZones": [URL_B],
},
}
]
}).encode()
class _FakeResp:
def read(self): return fake_response_body
def __enter__(self): return self
def __exit__(self, *a): pass
from meshai.env.nws import NWSAlertsAdapter
config = MagicMock()
config.areas = ["ID"]
config.user_agent = "(meshai-test, test@example.com)"
config.severity_min = "moderate"
config.tick_seconds = 60
adapter = NWSAlertsAdapter(config)
with patch("meshai.env.nws.urlopen", return_value=_FakeResp()):
adapter._fetch()
events = adapter.get_events()
assert len(events) == 1
ev = events[0]
# The original polygon geometry must be preserved as-is
assert ev["geometry"] == polygon_geom
# Zone fetcher must NOT have been called
assert called["n"] == 0, (
"_fetch_zone was called even though alert already had polygon geometry"
)