From c11121185082655fc94c92f3e07052af0d26383b Mon Sep 17 00:00:00 2001 From: K7ZVX Date: Wed, 27 May 2026 23:33:48 +0000 Subject: [PATCH] feat(notifications): Phase 2.11 NIFC fires adapter pipeline integration Adds NICFFiresAdapter.to_event(), wiring the NIFC/WFIGS wildfire perimeter adapter into the notification EventBus, following the Phase 2.7 traffic / 2.9 USGS / 2.10 avalanche pattern. to_event() design: - Category: every active perimeter with a reported size maps to a single wildfire_incident category (the adapter's WFIGS query already filters to active WF incidents in the configured state). - Severity: PASSED THROUGH unchanged. The adapter computes severity by proximity to region anchors (< 25 km -> priority, else routine), which is a richer, more actionable signal for a mesh-notification use case than raw acreage. I deliberately did NOT invent acreage breakpoints -- pass-through matches the 2.9/2.10 pattern and defers tiering to the pipeline Inhibitor. (Flagged for review: if acreage-based or containment-based severity is preferred, it belongs in the adapter's _fetch severity logic, not to_event.) - Summary: incident name + acreage + % contained + distance to nearest anchor. - group_key/inhibit_keys: the adapter's stable "nifc_{name}_{state}" event_id as both. Re-polls of the same incident coalesce; single inhibit key lets the Inhibitor suppress lower-severity re-emissions. - Defensive: missing centroid (lat/lon), missing event_id, or missing/zero acreage returns None; try/except-guarded. No store.py change: the Phase 2.9 _emit_event None-guard already handles to_event() returning None, and store gates emission on hasattr(adapter, "to_event"). Rule 17: no new tunable. fires enabled / state / tick_seconds already exist in env_feeds.yaml (GUI-editable). Rule 18 N/A -- the WFIGS Interagency Perimeters ArcGIS FeatureServer is keyless (no .env entry; the .ref credentials store has no NIFC/ArcGIS/wildfire key, confirming none is needed). Rule 16: standalone fetch path validated in-container. FIRMS side-investigation (flagged in the 2.10 report): firms is disabled because it needs a NASA FIRMS map key that is not provisioned -- env_feeds.yaml has firms.enabled=false with map_key='' (not even a ${FIRMS_MAP_KEY} reference), and /data/secrets/.env has no FIRMS key. Intentional/blocked-on-key, not a bug. No action this phase. Config note: fires was already enabled (state US-ID) and already one of the 7 live adapters (store key "nifc"), so this phase keeps the count at 7 (no 7->8 change) and required no env_feeds.yaml edit. No seasonal short-circuit, so no temp config wiggling was needed (unlike 2.10). Tests: tests/test_adapter_fires.py (12 tests) mirrors test_adapter_usgs / test_adapter_avalanche -- category (always wildfire_incident, independent of severity), severity pass-through, group_key/inhibit_keys, distinct-incident keys, field population, summary content, and the defensive cases (zero acreage -> None, missing centroid/event_id -> None, corrupted -> None). Full suite: 200 passed. Live smoke test (prod container, Phase 2.11 code rebuilt in): clean startup, 7 env adapters loaded, no traceback. There IS an active Idaho incident today, so this produced a real end-to-end emission rather than the empty-result cases of 2.9/2.10: the running store logged "NIFC fires updated: 1 active in US-ID" and "Emitted nifc event cc4bd340be7fd57e (wildfire_incident) to pipeline bus". An in-container standalone fetch confirmed health is_loaded=true, last_error=null, consecutive_errors=0, event_count=1 -- the WFIGS ArcGIS endpoint was reached with no DNS/auth errors (Phase 2.6.6 DNS fix). The Summit Creek incident (1,500 ac, 0% contained, ~72 km from the Twin Falls anchor) mapped to wildfire_incident / routine as designed. Co-Authored-By: Claude Opus 4.7 (1M context) --- meshai/env/fires.py | 76 +++++++++++++++- tests/test_adapter_fires.py | 176 ++++++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 tests/test_adapter_fires.py diff --git a/meshai/env/fires.py b/meshai/env/fires.py index b5f600d..0379cf3 100644 --- a/meshai/env/fires.py +++ b/meshai/env/fires.py @@ -3,11 +3,13 @@ import json import logging import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen from urllib.parse import urlencode +from meshai.notifications.events import Event, make_event + if TYPE_CHECKING: from ..config import NICFFiresConfig @@ -231,6 +233,78 @@ class NICFFiresAdapter: return (None, None) + def to_event(self, evt: dict) -> Optional["Event"]: + """Translate a stored NIFC/WFIGS fire perimeter into a pipeline Event. + + Every active perimeter with a reported size maps to a single + wildfire_incident category; the adapter's proximity-based severity + (priority when near a region anchor, else routine) is passed through + unchanged. Severity tiering is delegated to the pipeline Inhibitor. + + Args: + evt: Internal event dict from get_events() + + Returns: + Event instance ready for EventBus emission, or None if the dict is + missing its centroid, event_id, or a reported acreage. + """ + try: + lat = evt.get("lat") + lon = evt.get("lon") + if lat is None or lon is None: + return None # No centroid -- can't make a useful Event + + event_id = evt.get("event_id") + if not event_id: + return None # No stable identity to group/inhibit on + + acres = evt.get("acres") + if not acres: + return None # No reported size -- low-signal, do not emit + + severity = evt.get("severity", "routine") + name = evt.get("name") or "Wildfire" + + # Summary: size, containment, and proximity to nearest anchor. + summary_parts = [name] + try: + summary_parts.append(f"{int(acres):,} ac") + except (TypeError, ValueError): + pass + pct = evt.get("pct_contained") + if pct is not None: + try: + summary_parts.append(f"{int(pct)}% contained") + except (TypeError, ValueError): + pass + anchor = evt.get("nearest_anchor") + dist = evt.get("distance_km") + if anchor and dist is not None: + summary_parts.append(f"{int(dist)} km from {anchor}") + summary = " | ".join(summary_parts)[:300] + + # event_id is already the stable "nifc_{name}_{state}" key. Re-polls + # of the same incident coalesce on this group_key; using it as the + # sole inhibit_key lets the pipeline Inhibitor suppress lower-severity + # re-emissions while a higher-severity one is active (severity tiering + # delegated to the Inhibitor). + return make_event( + source="nifc", + category="wildfire_incident", + severity=severity, + title=name, + summary=summary, + timestamp=evt.get("fetched_at"), + expires=evt.get("expires"), + lat=lat, + lon=lon, + group_key=event_id, + inhibit_keys=[event_id], + ) + except Exception: + logger.exception(f"NIFC to_event failed for evt: {evt.get('event_id')}") + return None + def get_events(self) -> list: """Get current fire events.""" return self._events diff --git a/tests/test_adapter_fires.py b/tests/test_adapter_fires.py new file mode 100644 index 0000000..cf3972f --- /dev/null +++ b/tests/test_adapter_fires.py @@ -0,0 +1,176 @@ +"""Tests for NIFC fires adapter Phase 2.11 — to_event() method.""" + +import time +from unittest.mock import MagicMock + +import pytest + +from meshai.env.fires import NICFFiresAdapter +from meshai.notifications.events import Event + + +# ============================================================ +# FIXTURES +# ============================================================ + +@pytest.fixture +def mock_config(): + """Create a mock NICFFiresConfig with real scalar fields.""" + config = MagicMock() + config.state = "US-ID" + config.tick_seconds = 600 + return config + + +@pytest.fixture +def adapter(mock_config): + """Create a NICFFiresAdapter with mocked config.""" + return NICFFiresAdapter(mock_config) + + +def make_fire_event( + name="Ross Fork Fire", + state="US-ID", + acres=12500, + pct_contained=40, + severity="priority", + lat=43.6, + lon=-114.9, + distance_km=18.0, + nearest_anchor="Twin Falls", + headline=None, +): + """Helper to create a stored NIFC event dict (mirrors _fetch).""" + now = time.time() + if headline is None: + headline = f"{name} -- {int(acres):,} ac, {int(pct_contained)}% contained" + evt = { + "source": "nifc", + "event_id": f"nifc_{name.replace(' ', '_').lower()}_{state}", + "event_type": "Wildfire", + "severity": severity, + "headline": headline, + "name": name, + "acres": acres, + "pct_contained": pct_contained, + "lat": lat, + "lon": lon, + "distance_km": distance_km, + "nearest_anchor": nearest_anchor, + "state": state, + "expires": now + 21600, + "fetched_at": now, + } + return evt + + +# ============================================================ +# CATEGORY TESTS +# ============================================================ + +def test_active_perimeter_is_wildfire_incident(adapter): + """Any active perimeter with a size maps to wildfire_incident.""" + event = adapter.to_event(make_fire_event()) + assert event is not None + assert event.category == "wildfire_incident" + + +def test_category_independent_of_severity(adapter): + """Category stays wildfire_incident regardless of severity.""" + for sev in ["routine", "priority"]: + event = adapter.to_event(make_fire_event(severity=sev)) + assert event is not None + assert event.category == "wildfire_incident" + + +# ============================================================ +# SEVERITY PASS-THROUGH TESTS +# ============================================================ + +def test_severity_passes_through(adapter): + """The adapter's proximity-based severity passes through unchanged.""" + for sev in ["routine", "priority", "immediate"]: + event = adapter.to_event(make_fire_event(severity=sev)) + assert event is not None + assert event.severity == sev + + +# ============================================================ +# GROUP KEY / INHIBIT KEY TESTS +# ============================================================ + +def test_group_key_is_event_id(adapter): + """Group key is the stable nifc_{name}_{state} key.""" + event = adapter.to_event(make_fire_event(name="Ross Fork Fire", state="US-ID")) + assert event is not None + assert event.group_key == "nifc_ross_fork_fire_US-ID" + + +def test_inhibit_keys_match_group_key(adapter): + """The sole inhibit key equals the group key (Inhibitor does severity tiering).""" + event = adapter.to_event(make_fire_event()) + assert event is not None + assert event.inhibit_keys == [event.group_key] + + +def test_distinct_fires_get_distinct_keys(adapter): + """Two different incidents get distinct group keys.""" + e1 = adapter.to_event(make_fire_event(name="Ross Fork Fire")) + e2 = adapter.to_event(make_fire_event(name="Wapiti Fire")) + assert e1.group_key != e2.group_key + + +# ============================================================ +# CONTENT / FIELD POPULATION TESTS +# ============================================================ + +def test_populates_core_fields(adapter): + """Core Event fields are populated from the stored dict.""" + evt = make_fire_event(lat=43.61, lon=-114.92) + event = adapter.to_event(evt) + assert event is not None + assert event.source == "nifc" + assert event.lat == 43.61 + assert event.lon == -114.92 + assert event.expires == evt["expires"] + assert event.timestamp == evt["fetched_at"] + assert event.id # auto-computed + + +def test_summary_includes_size_containment_proximity(adapter): + """Summary includes acreage, containment, and nearest-anchor proximity.""" + event = adapter.to_event( + make_fire_event(acres=12500, pct_contained=40, distance_km=18.0, nearest_anchor="Twin Falls") + ) + assert event is not None + assert "12,500 ac" in event.summary + assert "40% contained" in event.summary + assert "18 km from Twin Falls" in event.summary + + +# ============================================================ +# DEFENSIVE / NON-EMIT TESTS +# ============================================================ + +def test_missing_acres_returns_none(adapter): + """A perimeter with no reported size (0 acres) is not emitted.""" + assert adapter.to_event(make_fire_event(acres=0)) is None + + +def test_missing_centroid_returns_none(adapter): + """Missing centroid (lat/lon) returns None.""" + evt = make_fire_event() + evt["lat"] = None + assert adapter.to_event(evt) is None + + +def test_missing_event_id_returns_none(adapter): + """Missing event_id returns None (no stable group key).""" + evt = make_fire_event() + evt["event_id"] = None + assert adapter.to_event(evt) is None + + +def test_does_not_raise_on_corrupted_dict(adapter): + """Corrupted dict returns None without raising.""" + assert adapter.to_event({"garbage": True}) is None