meshai/tests/test_adapter_fires.py

176 lines
5.6 KiB
Python
Raw Normal View History

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>
2026-05-27 23:33:48 +00:00
"""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