mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
9b6053365a
commit
e3b93f652c
3 changed files with 585 additions and 3 deletions
28
work/meshai/env/nws.py
vendored
28
work/meshai/env/nws.py
vendored
|
|
@ -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
188
work/meshai/env/nws_zones.py
vendored
Normal 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}
|
||||
372
work/tests/test_nws_zones.py
Normal file
372
work/tests/test_nws_zones.py
Normal 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"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue