From 09ba8bc08c3a4530aa153ef52fe71975b309a1ab Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Mon, 6 Jul 2026 14:41:28 +0000 Subject: [PATCH] feat(coverage): wire fires/firms/ducting to the coverage bbox Phase 2b: fires now scopes via the ArcGIS envelope from the coverage bbox (dropping the single-state WHERE; event_id uses the fire's own POOState so multi-state boxes work and Idaho dedup keys are unchanged); firms uses the coverage bbox for its NASA query; ducting observes at the bbox centroid. Coverage governs when set; each adapter's own config is the fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- work/meshai/env/ducting.py | 9 +- work/meshai/env/fires.py | 52 ++++++-- work/meshai/env/firms.py | 4 +- work/meshai/env/store.py | 6 +- work/tests/test_coverage_wiring_2b.py | 165 ++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 work/tests/test_coverage_wiring_2b.py diff --git a/work/meshai/env/ducting.py b/work/meshai/env/ducting.py index b47a91c..c0ca2d9 100644 --- a/work/meshai/env/ducting.py +++ b/work/meshai/env/ducting.py @@ -29,9 +29,12 @@ PRESSURE_LEVELS = { class DuctingAdapter: """Tropospheric ducting assessment from Open-Meteo GFS pressure levels.""" - def __init__(self, config: "DuctingConfig"): - self._lat = config.latitude - self._lon = config.longitude + def __init__(self, config: "DuctingConfig", coverage: dict = None): + if coverage is not None: + self._lat, self._lon = coverage["centroid"] + else: + self._lat = config.latitude + self._lon = config.longitude self._tick_interval = config.tick_seconds or 10800 # 3 hours self._last_tick = 0.0 self._status = {} diff --git a/work/meshai/env/fires.py b/work/meshai/env/fires.py index 0379cf3..691c983 100644 --- a/work/meshai/env/fires.py +++ b/work/meshai/env/fires.py @@ -21,7 +21,7 @@ class NICFFiresAdapter: BASE_URL = "https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query" - def __init__(self, config: "NICFFiresConfig", region_anchors: list = None): + def __init__(self, config: "NICFFiresConfig", region_anchors: list = None, coverage: dict = None): self._state = config.state self._tick_interval = config.tick_seconds or 600 self._last_tick = 0.0 @@ -31,6 +31,8 @@ class NICFFiresAdapter: self._is_loaded = False # Region anchors for proximity calculation self._region_anchors = region_anchors or [] + # Coverage bbox — when set, drives spatial filter instead of single-state WHERE + self._coverage = coverage def tick(self) -> bool: """Execute one polling tick. @@ -46,18 +48,43 @@ class NICFFiresAdapter: self._last_tick = now return self._fetch() + def _build_query_params(self) -> dict: + """Build WFIGS ArcGIS query parameters. + + When self._coverage is set, switches to an envelope spatial filter spanning + the full coverage bbox (which may cross multiple states); the single-state + attr_POOState WHERE clause is dropped in favour of the geometry filter. + When self._coverage is None, falls back to the original single-state WHERE. + """ + out_fields = ( + "attr_IncidentName,attr_IncidentSize,attr_PercentContained," + "attr_FireDiscoveryDateTime,attr_POOState,poly_GISAcres" + ) + if self._coverage is not None: + params = { + "where": "attr_IncidentTypeCategory='WF'", + "outFields": out_fields, + "returnGeometry": "true", + "f": "geojson", + } + # Merge the ArcGIS envelope keys from the coverage dict + params.update(self._coverage["envelope"]) + else: + params = { + "where": f"attr_POOState='{self._state}' AND attr_IncidentTypeCategory='WF'", + "outFields": out_fields, + "returnGeometry": "true", + "f": "geojson", + } + return params + def _fetch(self) -> bool: """Fetch fire perimeters from WFIGS. Returns: True if data changed """ - params = { - "where": f"attr_POOState='{self._state}' AND attr_IncidentTypeCategory='WF'", - "outFields": "attr_IncidentName,attr_IncidentSize,attr_PercentContained,attr_FireDiscoveryDateTime,attr_POOState,poly_GISAcres", - "returnGeometry": "true", - "f": "geojson", - } + params = self._build_query_params() url = f"{self.BASE_URL}?{urlencode(params)}" @@ -124,9 +151,18 @@ class NICFFiresAdapter: if distance_km is not None and nearest_anchor: headline += f" ({int(distance_km)} km from {nearest_anchor})" + # In coverage mode fires can span multiple states, so use the fire's own + # POOState for the event_id suffix. This keeps Idaho fire keys identical to + # the single-state path (attr_POOState="US-ID" == self._state for Idaho). + # Fall back to self._state when the field is absent or blank. + if self._coverage is not None: + event_id_state = (props.get("attr_POOState") or "").strip() or self._state + else: + event_id_state = self._state + event = { "source": "nifc", - "event_id": f"nifc_{name.replace(' ', '_').lower()}_{self._state}", + "event_id": f"nifc_{name.replace(' ', '_').lower()}_{event_id_state}", "event_type": "Wildfire", "severity": severity, "headline": headline, diff --git a/work/meshai/env/firms.py b/work/meshai/env/firms.py index 123dd58..70c49bc 100644 --- a/work/meshai/env/firms.py +++ b/work/meshai/env/firms.py @@ -26,10 +26,10 @@ class FIRMSAdapter: BASE_URL = "https://firms.modaps.eosdis.nasa.gov/api/area/csv" - def __init__(self, config: "FIRMSConfig", region_anchors: list = None, fires_adapter=None): + def __init__(self, config: "FIRMSConfig", region_anchors: list = None, fires_adapter=None, coverage: dict = None): self._map_key = config.map_key self._source = config.source or "VIIRS_SNPP_NRT" - self._bbox = config.bbox # [west, south, east, north] + self._bbox = coverage["bbox"] if coverage is not None else config.bbox # [west, south, east, north] self._day_range = config.day_range or 1 self._tick_interval = config.tick_seconds or 1800 self._confidence_min = config.confidence_min or "nominal" diff --git a/work/meshai/env/store.py b/work/meshai/env/store.py index a360da0..9460274 100644 --- a/work/meshai/env/store.py +++ b/work/meshai/env/store.py @@ -83,9 +83,9 @@ class EnvironmentalStore: self._register_adapter("swpc", config.swpc, ".swpc", "SWPCAdapter", lambda cfg: (cfg,)) self._register_adapter("ducting", config.ducting, ".ducting", "DuctingAdapter", - lambda cfg: (cfg,)) + lambda cfg: (cfg, self._coverage_for("ducting"))) self._register_adapter("nifc", config.fires, ".fires", "NICFFiresAdapter", - lambda cfg: (cfg, self._region_anchors)) + lambda cfg: (cfg, self._region_anchors, self._coverage_for("fires"))) self._register_adapter("avalanche", config.avalanche, ".avalanche", "AvalancheAdapter", lambda cfg: (cfg, self._coverage_for("avalanche"))) self._register_adapter("usgs", config.usgs, ".usgs", "USGSStreamsAdapter", @@ -113,7 +113,7 @@ class EnvironmentalStore: try: from .firms import FIRMSAdapter fires_adapter = self._adapters.get("nifc") - self._firms = FIRMSAdapter(config.firms, self._region_anchors, fires_adapter) + self._firms = FIRMSAdapter(config.firms, self._region_anchors, fires_adapter, coverage=self._coverage_for("firms")) self._adapters["firms"] = self._firms except Exception as e: err_msg = f"{type(e).__name__}: {e}" diff --git a/work/tests/test_coverage_wiring_2b.py b/work/tests/test_coverage_wiring_2b.py new file mode 100644 index 0000000..56ee852 --- /dev/null +++ b/work/tests/test_coverage_wiring_2b.py @@ -0,0 +1,165 @@ +"""Tests for Phase 2b coverage-bbox wiring: fires, firms, ducting. + +Verifies that: +1. Each adapter respects the `coverage` kwarg — derived scope wins when + coverage is provided, config field is the fallback when None. +2. fires._build_query_params() switches to the ArcGIS envelope filter in + coverage mode and drops the attr_POOState WHERE clause. +3. fires event_id backward-compat: an Idaho fire (attr_POOState="US-ID") in + coverage mode produces the same event_id as the single-state path. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from meshai.coverage import resolve_adapter_coverage + +# --------------------------------------------------------------------------- +# Shared reference bbox (Magic Valley / south-central Idaho) +# --------------------------------------------------------------------------- +IDAHO_BOX = [-116.5, 42.0, -112.0, 44.0] + + +def _cov(adapter_name: str, bbox: list = None) -> dict | None: + return resolve_adapter_coverage(adapter_name, bbox or IDAHO_BOX, "native") + + +# =========================================================================== +# ducting +# =========================================================================== + +def _ducting_cfg(lat=42.56, lon=-114.47): + cfg = MagicMock() + cfg.latitude = lat + cfg.longitude = lon + cfg.tick_seconds = 10800 + return cfg + + +def test_ducting_coverage_wins(): + """When coverage is provided, lat/lon come from coverage['centroid'].""" + from meshai.env.ducting import DuctingAdapter + cov = {"centroid": (43.0, -114.0)} + cfg = _ducting_cfg(lat=42.56, lon=-114.47) + adapter = DuctingAdapter(cfg, coverage=cov) + assert adapter._lat == 43.0 + assert adapter._lon == -114.0 + + +def test_ducting_fallback_to_config(): + """When coverage=None, lat/lon fall back to config.""" + from meshai.env.ducting import DuctingAdapter + cfg = _ducting_cfg(lat=42.56, lon=-114.47) + adapter = DuctingAdapter(cfg, coverage=None) + assert adapter._lat == cfg.latitude + assert adapter._lon == cfg.longitude + + +# =========================================================================== +# firms +# =========================================================================== + +def _firms_cfg(bbox=None): + cfg = MagicMock() + cfg.map_key = "test-key" + cfg.source = "VIIRS_SNPP_NRT" + cfg.bbox = bbox if bbox is not None else [-117, 42, -114, 44] + cfg.day_range = 1 + cfg.tick_seconds = 1800 + cfg.confidence_min = "nominal" + cfg.proximity_km = 10.0 + return cfg + + +def test_firms_coverage_wins(): + """When coverage is provided, adapter._bbox comes from coverage['bbox'].""" + from meshai.env.firms import FIRMSAdapter + cov = {"bbox": [-116.5, 42.0, -112.0, 44.0]} + cfg = _firms_cfg(bbox=[-117, 42, -114, 44]) + adapter = FIRMSAdapter(cfg, region_anchors=[], fires_adapter=None, coverage=cov) + assert adapter._bbox == [-116.5, 42.0, -112.0, 44.0] + + +def test_firms_fallback_to_config(): + """When coverage=None, adapter._bbox falls back to config.bbox.""" + from meshai.env.firms import FIRMSAdapter + cfg = _firms_cfg(bbox=[-117, 42, -114, 44]) + adapter = FIRMSAdapter(cfg, region_anchors=[], fires_adapter=None, coverage=None) + assert adapter._bbox == cfg.bbox + + +# =========================================================================== +# fires (NICFFiresAdapter) +# =========================================================================== + +def _fires_cfg(state="US-ID"): + cfg = MagicMock() + cfg.state = state + cfg.tick_seconds = 600 + return cfg + + +def test_fires_coverage_query_uses_envelope(): + """In coverage mode, _build_query_params includes envelope keys and drops attr_POOState WHERE.""" + from meshai.env.fires import NICFFiresAdapter + cov = _cov("fires") # resolve via coverage module + assert cov is not None + assert "envelope" in cov + cfg = _fires_cfg() + adapter = NICFFiresAdapter(cfg, coverage=cov) + params = adapter._build_query_params() + # WHERE must not contain attr_POOState — spatial envelope scopes instead + assert "attr_POOState" not in params["where"] + assert "attr_IncidentTypeCategory='WF'" in params["where"] + # ArcGIS envelope keys must be present + assert "geometry" in params + assert params.get("geometryType") == "esriGeometryEnvelope" + assert params.get("spatialRel") == "esriSpatialRelIntersects" + assert params.get("inSR") == "4326" + + +def test_fires_no_coverage_query_uses_state_where(): + """Without coverage, _build_query_params retains the single-state WHERE clause.""" + from meshai.env.fires import NICFFiresAdapter + cfg = _fires_cfg(state="US-ID") + adapter = NICFFiresAdapter(cfg, coverage=None) + params = adapter._build_query_params() + assert "attr_POOState='US-ID'" in params["where"] + assert "attr_IncidentTypeCategory='WF'" in params["where"] + # No envelope keys + assert "geometry" not in params + assert "geometryType" not in params + + +def test_fires_event_id_backward_compat(): + """Idaho fire in coverage mode yields the same event_id as the state path. + + This ensures no re-broadcast of fires already known to the store when + the operator enables the coverage bbox. + """ + from meshai.env.fires import NICFFiresAdapter + + fire_name = "Ross Fork Fire" + # Normalise the name the same way fires.py does + name_slug = fire_name.replace(" ", "_").lower() + + # --- State path (coverage=None) --- + cfg = _fires_cfg(state="US-ID") + adapter_state = NICFFiresAdapter(cfg, coverage=None) + # Simulate what _fetch does for the event_id (coverage=None branch) + state_event_id = f"nifc_{name_slug}_{adapter_state._state}" + + # --- Coverage path --- + cov = _cov("fires") + adapter_cov = NICFFiresAdapter(cfg, coverage=cov) + # Synthetic Idaho feature: attr_POOState matches config.state exactly + props = {"attr_POOState": "US-ID"} + per_fire_state = (props.get("attr_POOState") or "").strip() or adapter_cov._state + coverage_event_id = f"nifc_{name_slug}_{per_fire_state}" + + assert coverage_event_id == state_event_id, ( + f"event_id mismatch: coverage={coverage_event_id!r}, state={state_event_id!r}" + )