mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 09:24:44 +02:00
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>
322 lines
11 KiB
Python
322 lines
11 KiB
Python
"""NIFC/WFIGS Wildfire perimeter adapter."""
|
|
|
|
import json
|
|
import logging
|
|
import time
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class NICFFiresAdapter:
|
|
"""WFIGS ArcGIS fire perimeter polling."""
|
|
|
|
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):
|
|
self._state = config.state
|
|
self._tick_interval = config.tick_seconds or 600
|
|
self._last_tick = 0.0
|
|
self._events = []
|
|
self._consecutive_errors = 0
|
|
self._last_error = None
|
|
self._is_loaded = False
|
|
# Region anchors for proximity calculation
|
|
self._region_anchors = region_anchors or []
|
|
|
|
def tick(self) -> bool:
|
|
"""Execute one polling tick.
|
|
|
|
Returns:
|
|
True if data changed
|
|
"""
|
|
now = time.time()
|
|
|
|
if now - self._last_tick < self._tick_interval:
|
|
return False
|
|
|
|
self._last_tick = now
|
|
return self._fetch()
|
|
|
|
def _fetch(self) -> bool:
|
|
"""Fetch fire perimeters from WFIGS.
|
|
|
|
Returns:
|
|
True if data changed
|
|
"""
|
|
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)}"
|
|
|
|
headers = {
|
|
"User-Agent": "MeshAI/1.0",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
try:
|
|
req = Request(url, headers=headers)
|
|
with urlopen(req, timeout=30) as resp:
|
|
data = json.loads(resp.read().decode("utf-8"))
|
|
|
|
except HTTPError as e:
|
|
logger.warning(f"NIFC HTTP error: {e.code}")
|
|
self._last_error = f"HTTP {e.code}"
|
|
self._consecutive_errors += 1
|
|
return False
|
|
|
|
except URLError as e:
|
|
logger.warning(f"NIFC connection error: {e.reason}")
|
|
self._last_error = str(e.reason)
|
|
self._consecutive_errors += 1
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.warning(f"NIFC fetch error: {e}")
|
|
self._last_error = str(e)
|
|
self._consecutive_errors += 1
|
|
return False
|
|
|
|
# Parse response
|
|
features = data.get("features", [])
|
|
new_events = []
|
|
now = time.time()
|
|
|
|
for feature in features:
|
|
props = feature.get("properties", {})
|
|
geom = feature.get("geometry")
|
|
|
|
name = props.get("attr_IncidentName", "Unknown Fire")
|
|
acres = props.get("attr_IncidentSize") or props.get("poly_GISAcres") or 0
|
|
pct_contained = props.get("attr_PercentContained") or 0
|
|
|
|
# Compute centroid from polygon
|
|
lat, lon = self._compute_centroid(geom)
|
|
|
|
# Compute proximity to nearest anchor
|
|
distance_km, nearest_anchor = self._nearest_anchor_distance(lat, lon)
|
|
|
|
# Severity based on distance
|
|
if distance_km is not None:
|
|
if distance_km < 25:
|
|
severity = "priority"
|
|
elif distance_km < 50:
|
|
severity = "routine"
|
|
else:
|
|
severity = "routine"
|
|
else:
|
|
severity = "routine"
|
|
|
|
# Format headline
|
|
headline = f"{name} -- {int(acres):,} ac, {int(pct_contained)}% contained"
|
|
if distance_km is not None and nearest_anchor:
|
|
headline += f" ({int(distance_km)} km from {nearest_anchor})"
|
|
|
|
event = {
|
|
"source": "nifc",
|
|
"event_id": f"nifc_{name.replace(' ', '_').lower()}_{self._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": self._state,
|
|
"expires": now + 21600, # 6 hour TTL
|
|
"fetched_at": now,
|
|
}
|
|
|
|
# Store polygon for map overlay
|
|
if geom and geom.get("type") == "Polygon":
|
|
event["polygon"] = geom.get("coordinates", [])
|
|
|
|
new_events.append(event)
|
|
|
|
# Check if data changed
|
|
old_ids = {e["event_id"] for e in self._events}
|
|
new_ids = {e["event_id"] for e in new_events}
|
|
changed = old_ids != new_ids
|
|
|
|
self._events = new_events
|
|
self._consecutive_errors = 0
|
|
self._last_error = None
|
|
self._is_loaded = True
|
|
|
|
if changed:
|
|
logger.info(f"NIFC fires updated: {len(new_events)} active in {self._state}")
|
|
|
|
return changed
|
|
|
|
def _compute_centroid(self, geom) -> tuple:
|
|
"""Compute centroid from GeoJSON geometry."""
|
|
if not geom:
|
|
return (None, None)
|
|
|
|
try:
|
|
coords = geom.get("coordinates", [])
|
|
geom_type = geom.get("type")
|
|
|
|
if geom_type == "Polygon" and coords:
|
|
# Use first ring
|
|
ring = coords[0]
|
|
if ring:
|
|
lat_sum = sum(c[1] for c in ring)
|
|
lon_sum = sum(c[0] for c in ring)
|
|
return (lat_sum / len(ring), lon_sum / len(ring))
|
|
|
|
elif geom_type == "MultiPolygon" and coords:
|
|
# Average all polygon centroids
|
|
all_lats = []
|
|
all_lons = []
|
|
for polygon in coords:
|
|
if polygon:
|
|
ring = polygon[0]
|
|
if ring:
|
|
all_lats.append(sum(c[1] for c in ring) / len(ring))
|
|
all_lons.append(sum(c[0] for c in ring) / len(ring))
|
|
if all_lats and all_lons:
|
|
return (sum(all_lats) / len(all_lats), sum(all_lons) / len(all_lons))
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
return (None, None)
|
|
|
|
def _nearest_anchor_distance(self, lat, lon) -> tuple:
|
|
"""Find distance to nearest region anchor.
|
|
|
|
Returns:
|
|
(distance_km, anchor_name) or (None, None)
|
|
"""
|
|
if lat is None or lon is None or not self._region_anchors:
|
|
return (None, None)
|
|
|
|
from ..geo import haversine_distance
|
|
|
|
min_dist = float("inf")
|
|
nearest_name = None
|
|
|
|
for anchor in self._region_anchors:
|
|
anchor_lat = anchor.get("lat") if isinstance(anchor, dict) else getattr(anchor, "lat", None)
|
|
anchor_lon = anchor.get("lon") if isinstance(anchor, dict) else getattr(anchor, "lon", None)
|
|
anchor_name = anchor.get("name") if isinstance(anchor, dict) else getattr(anchor, "name", "Unknown")
|
|
|
|
if anchor_lat is None or anchor_lon is None:
|
|
continue
|
|
|
|
# haversine_distance returns miles, convert to km
|
|
dist_miles = haversine_distance(lat, lon, anchor_lat, anchor_lon)
|
|
dist_km = dist_miles * 1.60934
|
|
|
|
if dist_km < min_dist:
|
|
min_dist = dist_km
|
|
nearest_name = anchor_name
|
|
|
|
if min_dist < float("inf"):
|
|
return (min_dist, nearest_name)
|
|
|
|
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
|
|
|
|
@property
|
|
def health_status(self) -> dict:
|
|
"""Get adapter health status."""
|
|
return {
|
|
"source": "nifc",
|
|
"is_loaded": self._is_loaded,
|
|
"last_error": str(self._last_error) if self._last_error else None,
|
|
"consecutive_errors": self._consecutive_errors,
|
|
"event_count": len(self._events),
|
|
"last_fetch": self._last_tick,
|
|
}
|