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) <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-27 23:33:48 +00:00
commit c111211850
2 changed files with 251 additions and 1 deletions

76
meshai/env/fires.py vendored
View file

@ -3,11 +3,13 @@
import json import json
import logging import logging
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Optional
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from urllib.parse import urlencode from urllib.parse import urlencode
from meshai.notifications.events import Event, make_event
if TYPE_CHECKING: if TYPE_CHECKING:
from ..config import NICFFiresConfig from ..config import NICFFiresConfig
@ -231,6 +233,78 @@ class NICFFiresAdapter:
return (None, None) 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: def get_events(self) -> list:
"""Get current fire events.""" """Get current fire events."""
return self._events return self._events

176
tests/test_adapter_fires.py Normal file
View file

@ -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