mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(coverage): wire fires/firms/ducting to the coverage bbox (#61)
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: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dd90f4a961
commit
ef8977b172
5 changed files with 220 additions and 16 deletions
9
work/meshai/env/ducting.py
vendored
9
work/meshai/env/ducting.py
vendored
|
|
@ -29,9 +29,12 @@ PRESSURE_LEVELS = {
|
||||||
class DuctingAdapter:
|
class DuctingAdapter:
|
||||||
"""Tropospheric ducting assessment from Open-Meteo GFS pressure levels."""
|
"""Tropospheric ducting assessment from Open-Meteo GFS pressure levels."""
|
||||||
|
|
||||||
def __init__(self, config: "DuctingConfig"):
|
def __init__(self, config: "DuctingConfig", coverage: dict = None):
|
||||||
self._lat = config.latitude
|
if coverage is not None:
|
||||||
self._lon = config.longitude
|
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._tick_interval = config.tick_seconds or 10800 # 3 hours
|
||||||
self._last_tick = 0.0
|
self._last_tick = 0.0
|
||||||
self._status = {}
|
self._status = {}
|
||||||
|
|
|
||||||
52
work/meshai/env/fires.py
vendored
52
work/meshai/env/fires.py
vendored
|
|
@ -21,7 +21,7 @@ class NICFFiresAdapter:
|
||||||
|
|
||||||
BASE_URL = "https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query"
|
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._state = config.state
|
||||||
self._tick_interval = config.tick_seconds or 600
|
self._tick_interval = config.tick_seconds or 600
|
||||||
self._last_tick = 0.0
|
self._last_tick = 0.0
|
||||||
|
|
@ -31,6 +31,8 @@ class NICFFiresAdapter:
|
||||||
self._is_loaded = False
|
self._is_loaded = False
|
||||||
# Region anchors for proximity calculation
|
# Region anchors for proximity calculation
|
||||||
self._region_anchors = region_anchors or []
|
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:
|
def tick(self) -> bool:
|
||||||
"""Execute one polling tick.
|
"""Execute one polling tick.
|
||||||
|
|
@ -46,18 +48,43 @@ class NICFFiresAdapter:
|
||||||
self._last_tick = now
|
self._last_tick = now
|
||||||
return self._fetch()
|
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:
|
def _fetch(self) -> bool:
|
||||||
"""Fetch fire perimeters from WFIGS.
|
"""Fetch fire perimeters from WFIGS.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if data changed
|
True if data changed
|
||||||
"""
|
"""
|
||||||
params = {
|
params = self._build_query_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",
|
|
||||||
}
|
|
||||||
|
|
||||||
url = f"{self.BASE_URL}?{urlencode(params)}"
|
url = f"{self.BASE_URL}?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
@ -124,9 +151,18 @@ class NICFFiresAdapter:
|
||||||
if distance_km is not None and nearest_anchor:
|
if distance_km is not None and nearest_anchor:
|
||||||
headline += f" ({int(distance_km)} km from {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 = {
|
event = {
|
||||||
"source": "nifc",
|
"source": "nifc",
|
||||||
"event_id": f"nifc_{name.replace(' ', '_').lower()}_{self._state}",
|
"event_id": f"nifc_{name.replace(' ', '_').lower()}_{event_id_state}",
|
||||||
"event_type": "Wildfire",
|
"event_type": "Wildfire",
|
||||||
"severity": severity,
|
"severity": severity,
|
||||||
"headline": headline,
|
"headline": headline,
|
||||||
|
|
|
||||||
4
work/meshai/env/firms.py
vendored
4
work/meshai/env/firms.py
vendored
|
|
@ -26,10 +26,10 @@ class FIRMSAdapter:
|
||||||
|
|
||||||
BASE_URL = "https://firms.modaps.eosdis.nasa.gov/api/area/csv"
|
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._map_key = config.map_key
|
||||||
self._source = config.source or "VIIRS_SNPP_NRT"
|
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._day_range = config.day_range or 1
|
||||||
self._tick_interval = config.tick_seconds or 1800
|
self._tick_interval = config.tick_seconds or 1800
|
||||||
self._confidence_min = config.confidence_min or "nominal"
|
self._confidence_min = config.confidence_min or "nominal"
|
||||||
|
|
|
||||||
6
work/meshai/env/store.py
vendored
6
work/meshai/env/store.py
vendored
|
|
@ -83,9 +83,9 @@ class EnvironmentalStore:
|
||||||
self._register_adapter("swpc", config.swpc, ".swpc", "SWPCAdapter",
|
self._register_adapter("swpc", config.swpc, ".swpc", "SWPCAdapter",
|
||||||
lambda cfg: (cfg,))
|
lambda cfg: (cfg,))
|
||||||
self._register_adapter("ducting", config.ducting, ".ducting", "DuctingAdapter",
|
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",
|
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",
|
self._register_adapter("avalanche", config.avalanche, ".avalanche", "AvalancheAdapter",
|
||||||
lambda cfg: (cfg, self._coverage_for("avalanche")))
|
lambda cfg: (cfg, self._coverage_for("avalanche")))
|
||||||
self._register_adapter("usgs", config.usgs, ".usgs", "USGSStreamsAdapter",
|
self._register_adapter("usgs", config.usgs, ".usgs", "USGSStreamsAdapter",
|
||||||
|
|
@ -113,7 +113,7 @@ class EnvironmentalStore:
|
||||||
try:
|
try:
|
||||||
from .firms import FIRMSAdapter
|
from .firms import FIRMSAdapter
|
||||||
fires_adapter = self._adapters.get("nifc")
|
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
|
self._adapters["firms"] = self._firms
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
err_msg = f"{type(e).__name__}: {e}"
|
err_msg = f"{type(e).__name__}: {e}"
|
||||||
|
|
|
||||||
165
work/tests/test_coverage_wiring_2b.py
Normal file
165
work/tests/test_coverage_wiring_2b.py
Normal file
|
|
@ -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}"
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue