diff --git a/work/meshai/env/nws.py b/work/meshai/env/nws.py index 6d46efd..935859f 100644 --- a/work/meshai/env/nws.py +++ b/work/meshai/env/nws.py @@ -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 diff --git a/work/meshai/env/nws_zones.py b/work/meshai/env/nws_zones.py new file mode 100644 index 0000000..173251a --- /dev/null +++ b/work/meshai/env/nws_zones.py @@ -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} diff --git a/work/tests/test_nws_zones.py b/work/tests/test_nws_zones.py new file mode 100644 index 0000000..753172b --- /dev/null +++ b/work/tests/test_nws_zones.py @@ -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" + )