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