diff --git a/work/meshai/env/nws.py b/work/meshai/env/nws.py index 5c443b0..0dfd8ff 100644 --- a/work/meshai/env/nws.py +++ b/work/meshai/env/nws.py @@ -19,8 +19,23 @@ logger = logging.getLogger(__name__) class NWSAlertsAdapter: """NWS Active Alerts -- polls api.weather.gov""" - def __init__(self, config: "NWSConfig"): - self._areas = config.areas or ["ID"] + def __init__(self, config: "NWSConfig", coverage: dict = None): + if coverage is not None: + derived_areas = coverage["areas"] + if not derived_areas: + # bbox overlaps no state — keep config areas so the API call + # remains well-formed, but retain the bbox for geometry filter. + logger.debug( + "NWS coverage: derived area list is empty (bbox outside all states); " + "falling back to config areas for API query" + ) + self._areas = config.areas or ["ID"] + else: + self._areas = derived_areas + self._coverage_bbox = coverage["bbox"] + else: + self._areas = config.areas or ["ID"] + self._coverage_bbox = None self._user_agent = config.user_agent or "(meshai, ops@example.com)" self._severity_min = config.severity_min or "moderate" self._tick_interval = config.tick_seconds or 60 @@ -157,6 +172,24 @@ class NWSAlertsAdapter: self._last_tick = now return self._fetch() + def _in_coverage(self, event: dict) -> bool: + """Return True iff the event should be kept under the coverage bbox filter. + + Rules: + - If no coverage bbox is set, always keep. + - If the event has a numeric lat/lon centroid, drop it when outside the box. + - If the event has no centroid (lat/lon absent or None), keep it — the + state area= filter already scopes it; we can't box-filter without coords. + """ + if not self._coverage_bbox: + return True + lat = event.get("lat") + lon = event.get("lon") + if lat is None or lon is None: + return True # no geometry — pass through + from meshai.coverage import point_in_bbox + return point_in_bbox(lat, lon, self._coverage_bbox) + def _fetch(self) -> bool: """Fetch alerts from NWS API. @@ -266,6 +299,12 @@ class NWSAlertsAdapter: except Exception: pass + # Geometry filter: drop alerts whose centroid falls outside the + # coverage bbox. Alerts with no centroid are kept (state area= + # filter already scopes them; we can't box-filter without coords). + if not self._in_coverage(event): + continue + new_events.append(event) # Check if data changed diff --git a/work/meshai/env/store.py b/work/meshai/env/store.py index 9460274..42c860c 100644 --- a/work/meshai/env/store.py +++ b/work/meshai/env/store.py @@ -79,7 +79,7 @@ class EnvironmentalStore: # Create adapter instances with error isolation self._register_adapter("nws", config.nws, ".nws", "NWSAlertsAdapter", - lambda cfg: (cfg,)) + lambda cfg: (cfg, self._coverage_for("nws"))) self._register_adapter("swpc", config.swpc, ".swpc", "SWPCAdapter", lambda cfg: (cfg,)) self._register_adapter("ducting", config.ducting, ".ducting", "DuctingAdapter", @@ -93,7 +93,7 @@ class EnvironmentalStore: self._register_adapter("usgs_quake", config.usgs_quake, ".usgs_quake", "USGSQuakeAdapter", lambda cfg: (cfg, self._coverage_for("usgs_quake"))) self._register_adapter("traffic", config.traffic, ".traffic", "TomTomTrafficAdapter", - lambda cfg: (cfg,)) + lambda cfg: (cfg, self._coverage_for("traffic"))) self._register_adapter("roads511", config.roads511, ".roads511", "Roads511Adapter", lambda cfg: (cfg, self._coverage_for("roads511"))) self._register_adapter("wzdx", config.wzdx, ".wzdx", "WZDxAdapter", diff --git a/work/meshai/env/traffic.py b/work/meshai/env/traffic.py index b6b6947..8f55829 100644 --- a/work/meshai/env/traffic.py +++ b/work/meshai/env/traffic.py @@ -22,9 +22,15 @@ class TomTomTrafficAdapter: BASE_URL = "https://api.tomtom.com/traffic/services/4/flowSegmentData/relative0/10/json" - def __init__(self, config: "TomTomConfig"): + def __init__(self, config: "TomTomConfig", coverage: dict = None): self._api_key = self._resolve_env(config.api_key or "") - self._corridors = config.corridors or [] + if coverage is not None: + self._corridors = [ + {"name": f"grid_{i}", "lat": la, "lon": lo} + for i, (la, lo) in enumerate(coverage["points"]) + ] + else: + self._corridors = config.corridors or [] self._tick_interval = config.tick_seconds or 300 self._last_tick = 0.0 self._events = [] diff --git a/work/tests/test_coverage_wiring_2c.py b/work/tests/test_coverage_wiring_2c.py new file mode 100644 index 0000000..ea9d3b9 --- /dev/null +++ b/work/tests/test_coverage_wiring_2c.py @@ -0,0 +1,205 @@ +"""Tests for Phase 2c coverage-bbox wiring: nws + traffic. + +Verifies that: +1. traffic: coverage wins (9 grid corridors derived from bbox); None falls back + to config.corridors. +2. nws: coverage wins for areas + coverage_bbox; None falls back to config.areas + and leaves _coverage_bbox as None. +3. nws geometry filter: alerts inside bbox are kept; outside are dropped; alerts + with no centroid (lat/lon None) are always kept. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from meshai.coverage import resolve_adapter_coverage + +# --------------------------------------------------------------------------- +# Shared reference bboxes +# --------------------------------------------------------------------------- +IDAHO_BOX = [-116.5, 42.0, -112.0, 44.0] # Magic Valley / south-central Idaho + + +def _cov(adapter_name: str, bbox: list = None) -> dict | None: + return resolve_adapter_coverage(adapter_name, bbox or IDAHO_BOX, "native") + + +# =========================================================================== +# traffic (TomTomTrafficAdapter) +# =========================================================================== + +def _traffic_cfg(corridors=None): + cfg = MagicMock() + cfg.api_key = "test-key" + cfg.corridors = corridors if corridors is not None else [] + cfg.tick_seconds = 300 + return cfg + + +def test_traffic_coverage_wins(): + """When coverage is provided, _corridors is a 3x3 grid from the bbox.""" + from meshai.env.traffic import TomTomTrafficAdapter + cov = _cov("traffic") + assert cov is not None + assert "points" in cov + cfg = _traffic_cfg(corridors=[]) + adapter = TomTomTrafficAdapter(cfg, coverage=cov) + # Must have exactly 9 corridors (3x3 grid) + assert len(adapter._corridors) == 9 + # Each corridor must be a dict with name, lat, lon + for i, corridor in enumerate(adapter._corridors): + assert isinstance(corridor, dict), f"corridor {i} is not a dict" + assert "name" in corridor, f"corridor {i} missing 'name'" + assert "lat" in corridor, f"corridor {i} missing 'lat'" + assert "lon" in corridor, f"corridor {i} missing 'lon'" + # All names must follow grid_N pattern + names = [c["name"] for c in adapter._corridors] + assert names == [f"grid_{i}" for i in range(9)] + + +def test_traffic_coverage_corridors_inside_bbox(): + """All grid corridor points must fall inside the coverage bbox.""" + from meshai.env.traffic import TomTomTrafficAdapter + from meshai.coverage import point_in_bbox + cov = _cov("traffic") + assert cov is not None + cfg = _traffic_cfg() + adapter = TomTomTrafficAdapter(cfg, coverage=cov) + west, south, east, north = IDAHO_BOX + for corridor in adapter._corridors: + lat, lon = corridor["lat"], corridor["lon"] + assert point_in_bbox(lat, lon, IDAHO_BOX), ( + f"corridor {corridor['name']} ({lat},{lon}) outside bbox" + ) + + +def test_traffic_fallback_to_config(): + """When coverage=None, _corridors falls back to config.corridors.""" + from meshai.env.traffic import TomTomTrafficAdapter + config_corridors = [ + {"name": "US-93 Twin Falls", "lat": 42.56, "lon": -114.47}, + {"name": "I-84 Bliss", "lat": 42.93, "lon": -114.98}, + ] + cfg = _traffic_cfg(corridors=config_corridors) + adapter = TomTomTrafficAdapter(cfg, coverage=None) + assert adapter._corridors == config_corridors + + +# =========================================================================== +# nws (NWSAlertsAdapter) +# =========================================================================== + +def _nws_cfg(areas=None): + cfg = MagicMock() + cfg.areas = areas if areas is not None else ["ID"] + cfg.user_agent = "(meshai-test, test@example.com)" + cfg.severity_min = "moderate" + cfg.tick_seconds = 60 + return cfg + + +def test_nws_coverage_areas(): + """When coverage is provided, _areas comes from coverage['areas'].""" + from meshai.env.nws import NWSAlertsAdapter + cov = _cov("nws") + assert cov is not None + assert "areas" in cov + assert "ID" in cov["areas"], "Idaho box should overlap ID state" + cfg = _nws_cfg(areas=["WY"]) # deliberately different config default + adapter = NWSAlertsAdapter(cfg, coverage=cov) + assert adapter._areas == cov["areas"] + + +def test_nws_coverage_bbox_set(): + """When coverage is provided, _coverage_bbox is set from coverage['bbox'].""" + from meshai.env.nws import NWSAlertsAdapter + cov = _cov("nws") + assert cov is not None + cfg = _nws_cfg() + adapter = NWSAlertsAdapter(cfg, coverage=cov) + assert adapter._coverage_bbox == cov["bbox"] + assert adapter._coverage_bbox == IDAHO_BOX + + +def test_nws_fallback_to_config(): + """When coverage=None, _areas falls back to config.areas and _coverage_bbox is None.""" + from meshai.env.nws import NWSAlertsAdapter + cfg = _nws_cfg(areas=["ID", "OR"]) + adapter = NWSAlertsAdapter(cfg, coverage=None) + assert adapter._areas == ["ID", "OR"] + assert adapter._coverage_bbox is None + + +def test_nws_fallback_config_areas_default(): + """When coverage=None and config.areas is empty/None, falls back to ['ID'].""" + from meshai.env.nws import NWSAlertsAdapter + cfg = _nws_cfg(areas=None) + cfg.areas = None # force the falsy case + adapter = NWSAlertsAdapter(cfg, coverage=None) + assert adapter._areas == ["ID"] + assert adapter._coverage_bbox is None + + +def test_nws_empty_areas_fallback_for_api(): + """When derived areas is empty, config areas are used for the API call but bbox is kept.""" + from meshai.env.nws import NWSAlertsAdapter + # Synthesise a coverage dict whose areas list is empty (bbox outside all states) + empty_areas_cov = {"areas": [], "bbox": [-1.0, 0.0, 1.0, 1.0]} + cfg = _nws_cfg(areas=["ID"]) + adapter = NWSAlertsAdapter(cfg, coverage=empty_areas_cov) + # API area= query falls back to config areas + assert adapter._areas == ["ID"] + # but the geometry filter bbox is still set + assert adapter._coverage_bbox == [-1.0, 0.0, 1.0, 1.0] + + +# =========================================================================== +# nws geometry filter (_in_coverage helper) +# =========================================================================== + +def _make_nws_with_bbox(bbox=None): + """Create a NWSAlertsAdapter with a specific coverage_bbox, no live config needed.""" + from meshai.env.nws import NWSAlertsAdapter + cfg = _nws_cfg() + if bbox is not None: + cov = {"areas": ["ID"], "bbox": bbox} + return NWSAlertsAdapter(cfg, coverage=cov) + return NWSAlertsAdapter(cfg, coverage=None) + + +def test_nws_in_coverage_inside_bbox_kept(): + """Alert whose centroid is inside the bbox passes the filter.""" + adapter = _make_nws_with_bbox(IDAHO_BOX) + # Twin Falls is inside the Idaho box + event = {"lat": 42.56, "lon": -114.47} + assert adapter._in_coverage(event) is True + + +def test_nws_in_coverage_outside_bbox_dropped(): + """Alert whose centroid is outside the bbox is filtered out.""" + adapter = _make_nws_with_bbox(IDAHO_BOX) + # Seattle (47.6, -122.3) is well outside south-central Idaho box + event = {"lat": 47.6, "lon": -122.3} + assert adapter._in_coverage(event) is False + + +def test_nws_in_coverage_no_coords_kept(): + """Alert with no lat/lon is always kept (can't filter without coords).""" + adapter = _make_nws_with_bbox(IDAHO_BOX) + event = {} # no lat/lon keys at all + assert adapter._in_coverage(event) is True + + event_none = {"lat": None, "lon": None} + assert adapter._in_coverage(event_none) is True + + +def test_nws_in_coverage_no_bbox_always_passes(): + """When no coverage bbox is set, _in_coverage always returns True.""" + adapter = _make_nws_with_bbox(bbox=None) + assert adapter._coverage_bbox is None + # Even an "outside" coord passes when there is no bbox filter + event = {"lat": 47.6, "lon": -122.3} + assert adapter._in_coverage(event) is True