mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(fires): fuse WFIGS incident-point layer with perimeter layer so non-perimeter fires surface (dedup by IrwinID, cold-start silent-seed) (#98)
Add WFIGS_Incident_Locations_Current point layer (IRWIN superset, ~6 ID fires) alongside the existing perimeter layer (~2 ID fires). Fires are merged by IrwinID: point layer is the authoritative superset, perimeter layer supplies polygon geometry and validated acreage when available. Point-only fires surface with lat/lon from the point geometry and no polygon. cold-start silent-seed path is unchanged (first-poll batch is always silent regardless of source). Perimeter fetch failure falls back to perimeter-only stubs; point fetch failure falls back to perimeter-only; both failing bumps the consecutive error counter. county is now populated from POOCounty on the point layer. Five off-air unit tests cover: T1 merge-dedup, T2 point-only-new, T3 perimeter-geom-preferred, T4 cold-start silent-seed 6 fires, T5 FIRMS _get_known_fires attribution. No changes to store.py, gating/fire.py, schema, or coverage. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
099cec783c
commit
d988868257
2 changed files with 685 additions and 113 deletions
363
work/meshai/env/fires.py
vendored
363
work/meshai/env/fires.py
vendored
|
|
@ -1,4 +1,14 @@
|
||||||
"""NIFC/WFIGS Wildfire perimeter adapter."""
|
"""NIFC/WFIGS Wildfire adapter — perimeter + incident-point fusion.
|
||||||
|
|
||||||
|
Fetches both the WFIGS perimeter layer (fires with mapped perimeters) and the
|
||||||
|
WFIGS incident-locations point layer (the IRWIN superset, includes fires without
|
||||||
|
a perimeter yet) and merges them by IrwinID. The point layer is the authoritative
|
||||||
|
superset; the perimeter layer supplies geometry (polygon + centroid) and validated
|
||||||
|
acreage when a perimeter exists.
|
||||||
|
|
||||||
|
Result: non-perimeter fires surface without double-broadcasting (deduplicated by
|
||||||
|
IrwinID through the same gating.fire.decide + fires table path as before).
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -17,9 +27,10 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class NICFFiresAdapter:
|
class NICFFiresAdapter:
|
||||||
"""WFIGS ArcGIS fire perimeter polling."""
|
"""WFIGS ArcGIS fire perimeter + incident-point polling (merged by IrwinID)."""
|
||||||
|
|
||||||
BASE_URL = "https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query"
|
BASE_URL = "https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query"
|
||||||
|
POINTS_URL = "https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/WFIGS_Incident_Locations_Current/FeatureServer/0/query"
|
||||||
|
|
||||||
def __init__(self, config: "NICFFiresConfig", region_anchors: list = None, coverage: dict = None):
|
def __init__(self, config: "NICFFiresConfig", region_anchors: list = None, coverage: dict = None):
|
||||||
self._state = config.state
|
self._state = config.state
|
||||||
|
|
@ -49,7 +60,7 @@ class NICFFiresAdapter:
|
||||||
return self._fetch()
|
return self._fetch()
|
||||||
|
|
||||||
def _build_query_params(self) -> dict:
|
def _build_query_params(self) -> dict:
|
||||||
"""Build WFIGS ArcGIS query parameters.
|
"""Build WFIGS ArcGIS perimeter query parameters (GeoJSON).
|
||||||
|
|
||||||
When self._coverage is set, switches to an envelope spatial filter spanning
|
When self._coverage is set, switches to an envelope spatial filter spanning
|
||||||
the full coverage bbox (which may cross multiple states); the single-state
|
the full coverage bbox (which may cross multiple states); the single-state
|
||||||
|
|
@ -79,140 +90,247 @@ class NICFFiresAdapter:
|
||||||
}
|
}
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
def _build_points_query_params(self) -> dict:
|
||||||
|
"""Build WFIGS ArcGIS incident-locations point query parameters (ArcGIS JSON).
|
||||||
|
|
||||||
|
Point-layer field names carry NO attr_ prefix (different service schema).
|
||||||
|
Uses the same coverage envelope as the perimeter query when coverage is set,
|
||||||
|
falling back to a state WHERE clause otherwise.
|
||||||
|
"""
|
||||||
|
out_fields = (
|
||||||
|
"IrwinID,IncidentName,IncidentSize,PercentContained,"
|
||||||
|
"FireDiscoveryDateTime,POOState,POOCounty,InitialLatitude,"
|
||||||
|
"InitialLongitude,UniqueFireIdentifier,IncidentTypeCategory"
|
||||||
|
)
|
||||||
|
if self._coverage is not None:
|
||||||
|
params = {
|
||||||
|
"where": "IncidentTypeCategory='WF'",
|
||||||
|
"outFields": out_fields,
|
||||||
|
"returnGeometry": "true",
|
||||||
|
"f": "json",
|
||||||
|
}
|
||||||
|
params.update(self._coverage["envelope"])
|
||||||
|
else:
|
||||||
|
params = {
|
||||||
|
"where": f"POOState='{self._state}' AND IncidentTypeCategory='WF'",
|
||||||
|
"outFields": out_fields,
|
||||||
|
"returnGeometry": "true",
|
||||||
|
"f": "json",
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
|
||||||
def _fetch(self) -> bool:
|
def _fetch(self) -> bool:
|
||||||
"""Fetch fire perimeters from WFIGS.
|
"""Fetch and merge fire perimeters + incident-point locations from WFIGS.
|
||||||
|
|
||||||
|
(a) Fetches the perimeter layer (GeoJSON) — builds perimeters_by_irwin.
|
||||||
|
(b) Fetches the point layer (ArcGIS JSON) — the authoritative superset.
|
||||||
|
(c) Merges: iterates the point set; per-fire uses perimeter geometry
|
||||||
|
(polygon + centroid) when available, point coordinates otherwise.
|
||||||
|
|
||||||
|
Resilience: each HTTP call is independently try/except'd. If the point
|
||||||
|
layer fails, falls back to perimeter-only (today's behaviour). If the
|
||||||
|
perimeter layer fails, uses point-only (no polygon). Consecutive error
|
||||||
|
counter is bumped only when BOTH layers fail (nothing new to return).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if data changed
|
True if data changed
|
||||||
"""
|
"""
|
||||||
params = self._build_query_params()
|
|
||||||
|
|
||||||
url = f"{self.BASE_URL}?{urlencode(params)}"
|
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": "MeshAI/1.0",
|
"User-Agent": "MeshAI/1.0",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── (a) Perimeter layer fetch ────────────────────────────────────────
|
||||||
|
perim_features = []
|
||||||
|
perim_ok = False
|
||||||
try:
|
try:
|
||||||
|
params = self._build_query_params()
|
||||||
|
url = f"{self.BASE_URL}?{urlencode(params)}"
|
||||||
req = Request(url, headers=headers)
|
req = Request(url, headers=headers)
|
||||||
with urlopen(req, timeout=30) as resp:
|
with urlopen(req, timeout=30) as resp:
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
perim_features = data.get("features", [])
|
||||||
|
perim_ok = True
|
||||||
except HTTPError as e:
|
except HTTPError as e:
|
||||||
logger.warning(f"NIFC HTTP error: {e.code}")
|
logger.warning(f"NIFC perimeter HTTP error: {e.code}")
|
||||||
self._last_error = f"HTTP {e.code}"
|
|
||||||
self._consecutive_errors += 1
|
|
||||||
return False
|
|
||||||
|
|
||||||
except URLError as e:
|
except URLError as e:
|
||||||
logger.warning(f"NIFC connection error: {e.reason}")
|
logger.warning(f"NIFC perimeter connection error: {e.reason}")
|
||||||
self._last_error = str(e.reason)
|
|
||||||
self._consecutive_errors += 1
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"NIFC fetch error: {e}")
|
logger.warning(f"NIFC perimeter fetch error: {e}")
|
||||||
self._last_error = str(e)
|
|
||||||
|
# Build perimeters_by_irwin: irwin_id -> {lat, lon, polygon, acres, pct_contained}
|
||||||
|
perimeters_by_irwin: dict = {}
|
||||||
|
for feature in perim_features:
|
||||||
|
try:
|
||||||
|
props = feature.get("properties", {})
|
||||||
|
geom = feature.get("geometry")
|
||||||
|
|
||||||
|
irwin_id = (
|
||||||
|
props.get("attr_IrwinID")
|
||||||
|
or props.get("attr_UniqueFireIdentifier")
|
||||||
|
)
|
||||||
|
if not irwin_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lat, lon = self._compute_centroid(geom)
|
||||||
|
|
||||||
|
# Store polygon for map overlay (Polygon type only, same as original)
|
||||||
|
polygon = None
|
||||||
|
if geom and geom.get("type") == "Polygon":
|
||||||
|
polygon = geom.get("coordinates", [])
|
||||||
|
|
||||||
|
acres = props.get("attr_IncidentSize") or props.get("poly_GISAcres") or 0
|
||||||
|
pct_contained = props.get("attr_PercentContained") or 0
|
||||||
|
|
||||||
|
perimeters_by_irwin[irwin_id] = {
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"polygon": polygon,
|
||||||
|
"acres": acres,
|
||||||
|
"pct_contained": pct_contained,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"NIFC perimeter parse error for feature: {e}")
|
||||||
|
|
||||||
|
# ── (b) Points layer fetch ───────────────────────────────────────────
|
||||||
|
point_features = []
|
||||||
|
points_ok = False
|
||||||
|
try:
|
||||||
|
params = self._build_points_query_params()
|
||||||
|
url = f"{self.POINTS_URL}?{urlencode(params)}"
|
||||||
|
req = Request(url, headers=headers)
|
||||||
|
with urlopen(req, timeout=30) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
# ArcGIS JSON format: data["features"][].attributes + .geometry.x/.y
|
||||||
|
point_features = data.get("features", [])
|
||||||
|
points_ok = True
|
||||||
|
except HTTPError as e:
|
||||||
|
logger.warning(f"NIFC points HTTP error: {e.code}")
|
||||||
|
except URLError as e:
|
||||||
|
logger.warning(f"NIFC points connection error: {e.reason}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"NIFC points fetch error: {e}")
|
||||||
|
|
||||||
|
# If BOTH layers failed, bump error counter and bail unchanged
|
||||||
|
if not perim_ok and not points_ok:
|
||||||
|
self._last_error = "both perimeter and points fetch failed"
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Parse response
|
# ── (c) Merge: points are the authoritative superset ─────────────────
|
||||||
features = data.get("features", [])
|
# If point layer failed, fall back to perimeter-only by synthesising
|
||||||
|
# point-style entries from the perimeter features already parsed.
|
||||||
|
if not points_ok:
|
||||||
|
logger.warning("NIFC points fetch failed — falling back to perimeter-only")
|
||||||
|
point_features = _perim_features_as_point_stubs(perim_features)
|
||||||
|
|
||||||
new_events = []
|
new_events = []
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
for feature in features:
|
for feature in point_features:
|
||||||
props = feature.get("properties", {})
|
try:
|
||||||
geom = feature.get("geometry")
|
# ArcGIS JSON: attrs in .attributes, geometry is {x: lon, y: lat}
|
||||||
|
attrs = feature.get("attributes", {})
|
||||||
|
pt_geom = feature.get("geometry") # {"x": lon, "y": lat} or None
|
||||||
|
|
||||||
name = props.get("attr_IncidentName", "Unknown Fire")
|
name = attrs.get("IncidentName") or "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
|
# Derive irwin_id: prefer real IRWIN GUID, then unique fire id,
|
||||||
lat, lon = self._compute_centroid(geom)
|
# then fall through to event_id (computed below).
|
||||||
|
irwin_id_raw = (
|
||||||
|
attrs.get("IrwinID")
|
||||||
|
or attrs.get("UniqueFireIdentifier")
|
||||||
|
)
|
||||||
|
|
||||||
# Compute proximity to nearest anchor
|
# State for event_id construction (coverage mode uses fire's own state)
|
||||||
distance_km, nearest_anchor = self._nearest_anchor_distance(lat, lon)
|
if self._coverage is not None:
|
||||||
|
event_id_state = (attrs.get("POOState") or "").strip() or self._state
|
||||||
|
else:
|
||||||
|
event_id_state = self._state
|
||||||
|
|
||||||
# Severity based on distance
|
event_id = f"nifc_{name.replace(' ', '_').lower()}_{event_id_state}"
|
||||||
if distance_km is not None:
|
|
||||||
if distance_km < 25:
|
irwin_id = irwin_id_raw or event_id
|
||||||
severity = "priority"
|
|
||||||
elif distance_km < 50:
|
# ── Merge with perimeter data ────────────────────────────────
|
||||||
severity = "routine"
|
perim = perimeters_by_irwin.get(irwin_id)
|
||||||
|
if perim:
|
||||||
|
# Perimeter geometry preferred: use centroid + polygon
|
||||||
|
lat = perim["lat"]
|
||||||
|
lon = perim["lon"]
|
||||||
|
polygon = perim["polygon"]
|
||||||
|
# Perimeter acres/pct preferred; fall back to point values
|
||||||
|
acres = perim["acres"] or attrs.get("IncidentSize") or 0
|
||||||
|
pct_contained = (
|
||||||
|
perim["pct_contained"]
|
||||||
|
if perim["pct_contained"] is not None
|
||||||
|
else (attrs.get("PercentContained") or 0)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Point-only: coordinates from geometry, fallback to InitialLat/Lon
|
||||||
|
if pt_geom:
|
||||||
|
lat = pt_geom.get("y") or attrs.get("InitialLatitude")
|
||||||
|
lon = pt_geom.get("x") or attrs.get("InitialLongitude")
|
||||||
|
else:
|
||||||
|
lat = attrs.get("InitialLatitude")
|
||||||
|
lon = attrs.get("InitialLongitude")
|
||||||
|
polygon = None
|
||||||
|
acres = attrs.get("IncidentSize") or 0
|
||||||
|
pct_contained = attrs.get("PercentContained") or 0
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
severity = "priority" if distance_km < 25 else "routine"
|
||||||
else:
|
else:
|
||||||
severity = "routine"
|
severity = "routine"
|
||||||
else:
|
|
||||||
severity = "routine"
|
|
||||||
|
|
||||||
# Format headline
|
# Format headline
|
||||||
headline = f"{name} -- {int(acres):,} ac, {int(pct_contained)}% contained"
|
headline = f"{name} -- {int(acres):,} ac, {int(pct_contained)}% contained"
|
||||||
if distance_km is not None and nearest_anchor:
|
if distance_km is not None and nearest_anchor:
|
||||||
headline += f" ({int(distance_km)} km from {nearest_anchor})"
|
headline += f" ({int(distance_km)} km from {nearest_anchor})"
|
||||||
|
|
||||||
# In coverage mode fires can span multiple states, so use the fire's own
|
declared_at_epoch = self._parse_discovery_epoch(
|
||||||
# POOState for the event_id suffix. This keeps Idaho fire keys identical to
|
attrs.get("FireDiscoveryDateTime"))
|
||||||
# the single-state path (attr_POOState="US-ID" == self._state for Idaho).
|
|
||||||
# Fall back to self._state when the field is absent or blank.
|
|
||||||
if self._coverage is not None:
|
|
||||||
event_id_state = (props.get("attr_POOState") or "").strip() or self._state
|
|
||||||
else:
|
|
||||||
event_id_state = self._state
|
|
||||||
|
|
||||||
event_id = f"nifc_{name.replace(' ', '_').lower()}_{event_id_state}"
|
county = attrs.get("POOCounty") or None
|
||||||
|
|
||||||
# Canonical WFIGS identity + discovery date the Phase-3 fire decider
|
event = {
|
||||||
# (notifications/gating/fire.py::decide) reads off Event.data. Prefer
|
"source": "nifc",
|
||||||
# the real IRWIN GUID; fall back to the unique fire id, then to the
|
"event_id": event_id,
|
||||||
# stable adapter event_id so a fire is always gate-able by the fires
|
"event_type": "Wildfire",
|
||||||
# table even when WFIGS omits the id fields.
|
"severity": severity,
|
||||||
irwin_id = (
|
"headline": headline,
|
||||||
props.get("attr_IrwinID")
|
"name": name,
|
||||||
or props.get("attr_UniqueFireIdentifier")
|
"acres": acres,
|
||||||
or event_id
|
"pct_contained": pct_contained,
|
||||||
)
|
# Canonical keys the decider consumes (mirrored into Event.data
|
||||||
declared_at_epoch = self._parse_discovery_epoch(
|
# by to_event) + reused by store cold-start seeding.
|
||||||
props.get("attr_FireDiscoveryDateTime"))
|
"irwin_id": irwin_id,
|
||||||
|
"contained_pct": pct_contained,
|
||||||
|
"declared_at_epoch": declared_at_epoch,
|
||||||
|
"county": county,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"distance_km": distance_km,
|
||||||
|
"nearest_anchor": nearest_anchor,
|
||||||
|
"state": self._state,
|
||||||
|
"expires": now + 21600, # 6 hour TTL
|
||||||
|
"fetched_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
event = {
|
# Store polygon for map overlay (only when perimeter geometry present)
|
||||||
"source": "nifc",
|
if polygon:
|
||||||
"event_id": event_id,
|
event["polygon"] = polygon
|
||||||
"event_type": "Wildfire",
|
|
||||||
"severity": severity,
|
|
||||||
"headline": headline,
|
|
||||||
"name": name,
|
|
||||||
"acres": acres,
|
|
||||||
"pct_contained": pct_contained,
|
|
||||||
# Canonical keys the decider consumes (mirrored into Event.data
|
|
||||||
# by to_event) + reused by store cold-start seeding.
|
|
||||||
"irwin_id": irwin_id,
|
|
||||||
"contained_pct": pct_contained,
|
|
||||||
"declared_at_epoch": declared_at_epoch,
|
|
||||||
"county": None, # WFIGS perimeter layer carries no county field
|
|
||||||
"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
|
new_events.append(event)
|
||||||
if geom and geom.get("type") == "Polygon":
|
|
||||||
event["polygon"] = geom.get("coordinates", [])
|
|
||||||
|
|
||||||
new_events.append(event)
|
except Exception as e:
|
||||||
|
logger.warning(f"NIFC merge error for feature: {e}")
|
||||||
|
|
||||||
# Change detection must reflect each fire's GROWTH, not just the set of
|
# Change detection — include acres + containment so fire growth is visible
|
||||||
# fire names. Comparing event_id sets alone made acreage/containment growth
|
|
||||||
# of an already-known fire invisible: tick() returned False, so the store
|
|
||||||
# never re-ran _ingest_fires and the Phase-3 fire decider never saw the
|
|
||||||
# growth. Include acres + containment in the signature so a growing fire
|
|
||||||
# flips changed=True; the decider (forward-only + cooldown) stays the
|
|
||||||
# broadcast gate, so no backlog is dumped.
|
|
||||||
def _change_sig(e):
|
def _change_sig(e):
|
||||||
try:
|
try:
|
||||||
acres = int(round(float(e.get("acres") or 0)))
|
acres = int(round(float(e.get("acres") or 0)))
|
||||||
|
|
@ -235,7 +353,12 @@ class NICFFiresAdapter:
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
loc = "the coverage area" if self._coverage is not None else self._state
|
loc = "the coverage area" if self._coverage is not None else self._state
|
||||||
logger.info(f"NIFC fires updated: {len(new_events)} active in {loc}")
|
perim_count = len(perimeters_by_irwin)
|
||||||
|
point_count = len(point_features)
|
||||||
|
logger.info(
|
||||||
|
f"NIFC fires updated: {len(new_events)} active in {loc} "
|
||||||
|
f"(merged {point_count} point(s) + {perim_count} perimeter(s))"
|
||||||
|
)
|
||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
@ -331,9 +454,9 @@ class NICFFiresAdapter:
|
||||||
return (None, None)
|
return (None, None)
|
||||||
|
|
||||||
def to_event(self, evt: dict) -> Optional["Event"]:
|
def to_event(self, evt: dict) -> Optional["Event"]:
|
||||||
"""Translate a stored NIFC/WFIGS fire perimeter into a pipeline Event.
|
"""Translate a stored NIFC/WFIGS fire into a pipeline Event.
|
||||||
|
|
||||||
Every active perimeter with a reported size maps to a single
|
Every active fire with a reported size maps to a single
|
||||||
wildfire_incident category; the adapter's proximity-based severity
|
wildfire_incident category; the adapter's proximity-based severity
|
||||||
(priority when near a region anchor, else routine) is passed through
|
(priority when near a region anchor, else routine) is passed through
|
||||||
unchanged. Severity tiering is delegated to the pipeline Inhibitor.
|
unchanged. Severity tiering is delegated to the pipeline Inhibitor.
|
||||||
|
|
@ -435,3 +558,37 @@ class NICFFiresAdapter:
|
||||||
"event_count": len(self._events),
|
"event_count": len(self._events),
|
||||||
"last_fetch": self._last_tick,
|
"last_fetch": self._last_tick,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fallback: synthesise point-stub dicts from perimeter GeoJSON features ──────
|
||||||
|
# Used when the points fetch fails; lets the perimeter-only path continue to
|
||||||
|
# work through the unified merge loop without a separate code path.
|
||||||
|
|
||||||
|
def _perim_features_as_point_stubs(perim_features: list) -> list:
|
||||||
|
"""Convert perimeter GeoJSON features to minimal ArcGIS-point-style dicts.
|
||||||
|
|
||||||
|
Used ONLY when the points fetch fails; keeps the merge loop unified.
|
||||||
|
The stub carries attrs under ``attributes`` and no ``geometry`` (lat/lon
|
||||||
|
come from the perimeter dict via perimeters_by_irwin lookup in the caller).
|
||||||
|
"""
|
||||||
|
stubs = []
|
||||||
|
for f in perim_features:
|
||||||
|
props = f.get("properties", {})
|
||||||
|
stubs.append({
|
||||||
|
"attributes": {
|
||||||
|
"IrwinID": props.get("attr_IrwinID"),
|
||||||
|
"UniqueFireIdentifier": props.get("attr_UniqueFireIdentifier"),
|
||||||
|
"IncidentName": props.get("attr_IncidentName"),
|
||||||
|
"IncidentSize": props.get("attr_IncidentSize") or props.get("poly_GISAcres"),
|
||||||
|
"PercentContained": props.get("attr_PercentContained"),
|
||||||
|
"FireDiscoveryDateTime": props.get("attr_FireDiscoveryDateTime"),
|
||||||
|
"POOState": props.get("attr_POOState"),
|
||||||
|
"POOCounty": None,
|
||||||
|
"InitialLatitude": None,
|
||||||
|
"InitialLongitude": None,
|
||||||
|
"UniqueFireIdentifier": props.get("attr_UniqueFireIdentifier"),
|
||||||
|
"IncidentTypeCategory": "WF",
|
||||||
|
},
|
||||||
|
"geometry": None,
|
||||||
|
})
|
||||||
|
return stubs
|
||||||
|
|
|
||||||
415
work/tests/test_fire_point_fusion.py
Normal file
415
work/tests/test_fire_point_fusion.py
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
"""Off-air tests for WFIGS incident-point + perimeter fusion (feat/wfigs-incident-point-fusion).
|
||||||
|
|
||||||
|
T1 — merge dedup by IrwinID:
|
||||||
|
Points return A+B+C, perimeters return A+B → merged has 3;
|
||||||
|
A and B carry polygon + perimeter-derived lat/lon; C has no polygon.
|
||||||
|
|
||||||
|
T2 — point-only fire surfaces post-seed:
|
||||||
|
_fires_seeded=True, empty fires table, 25-ac point-only fire →
|
||||||
|
decider emits "new", _kind="wfigs_incident", correct irwin_id/lat/lon.
|
||||||
|
|
||||||
|
T3 — perimeter geometry preferred over point geometry for same IrwinID.
|
||||||
|
|
||||||
|
T4 — cold-start silent-seed:
|
||||||
|
_fires_seeded=False, empty table, 6-fire merged batch →
|
||||||
|
all 6 rows inserted with last_broadcast_at NOT NULL, ZERO EventBus emits,
|
||||||
|
flag flips True.
|
||||||
|
|
||||||
|
T5 — FIRMS attribution:
|
||||||
|
FIRMSAdapter._get_known_fires() sees point-only fires in the merged set
|
||||||
|
(proximity match works on their lat/lon).
|
||||||
|
|
||||||
|
No mesh wiring, no hand-written fires rows, no network calls.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import List, Tuple
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from meshai.config import EnvironmentalConfig, NICFFiresConfig
|
||||||
|
from meshai.env.fires import NICFFiresAdapter
|
||||||
|
from meshai.env.store import EnvironmentalStore
|
||||||
|
from meshai.notifications.pipeline.bus import EventBus
|
||||||
|
from meshai.persistence import close_thread_connection, init_db
|
||||||
|
from meshai.persistence import db as persistence_db
|
||||||
|
|
||||||
|
_NOW = 1_800_000_000.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Time seam ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
def __init__(self, t: float = _NOW):
|
||||||
|
self.t = t
|
||||||
|
|
||||||
|
def now(self) -> float:
|
||||||
|
return self.t
|
||||||
|
|
||||||
|
|
||||||
|
# ── Response builders ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _perim_geojson(fires: List[Tuple]) -> bytes:
|
||||||
|
"""GeoJSON perimeter response.
|
||||||
|
|
||||||
|
fires = list of (irwin_id, name, acres, lat, lon)
|
||||||
|
Each entry gets a triangular Polygon so _compute_centroid yields ~(lat, lon).
|
||||||
|
"""
|
||||||
|
features = []
|
||||||
|
for irwin_id, name, acres, lat, lon in fires:
|
||||||
|
features.append({
|
||||||
|
"properties": {
|
||||||
|
"attr_IrwinID": irwin_id,
|
||||||
|
"attr_IncidentName": name,
|
||||||
|
"attr_IncidentSize": acres,
|
||||||
|
"attr_PercentContained": 10,
|
||||||
|
"attr_FireDiscoveryDateTime": None,
|
||||||
|
"attr_POOState": "US-ID",
|
||||||
|
"poly_GISAcres": acres,
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
# Triangle whose centroid averages to (lat, lon)
|
||||||
|
"coordinates": [[
|
||||||
|
[lon - 0.1, lat - 0.1],
|
||||||
|
[lon + 0.2, lat - 0.1],
|
||||||
|
[lon - 0.1, lat + 0.2],
|
||||||
|
[lon - 0.1, lat - 0.1],
|
||||||
|
]],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return json.dumps({"features": features}).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _points_arcgis(fires: List[Tuple]) -> bytes:
|
||||||
|
"""ArcGIS JSON (f=json) points response.
|
||||||
|
|
||||||
|
fires = list of (irwin_id, name, acres, lat, lon)
|
||||||
|
"""
|
||||||
|
features = []
|
||||||
|
for irwin_id, name, acres, lat, lon in fires:
|
||||||
|
features.append({
|
||||||
|
"attributes": {
|
||||||
|
"IrwinID": irwin_id,
|
||||||
|
"UniqueFireIdentifier": None,
|
||||||
|
"IncidentName": name,
|
||||||
|
"IncidentSize": acres,
|
||||||
|
"PercentContained": 10,
|
||||||
|
"FireDiscoveryDateTime": None,
|
||||||
|
"POOState": "US-ID",
|
||||||
|
"POOCounty": "Test County",
|
||||||
|
"InitialLatitude": lat,
|
||||||
|
"InitialLongitude": lon,
|
||||||
|
"IncidentTypeCategory": "WF",
|
||||||
|
},
|
||||||
|
"geometry": {"x": lon, "y": lat},
|
||||||
|
})
|
||||||
|
return json.dumps({"features": features}).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(body: bytes):
|
||||||
|
"""Minimal context-manager mock for urlopen(...) with body bytes."""
|
||||||
|
m = MagicMock()
|
||||||
|
m.__enter__ = lambda self: self
|
||||||
|
m.__exit__ = MagicMock(return_value=False)
|
||||||
|
m.read.return_value = body
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
# ── Adapter fixture ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def adapter():
|
||||||
|
cfg = MagicMock()
|
||||||
|
cfg.state = "US-ID"
|
||||||
|
cfg.tick_seconds = 600
|
||||||
|
return NICFFiresAdapter(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Store + fake fires helper (mirrors test_fire_native_growth.py) ─────────────
|
||||||
|
|
||||||
|
class _FakeFires:
|
||||||
|
"""Controllable batch that uses the REAL to_event from NICFFiresAdapter."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._batch: list = []
|
||||||
|
self._real = NICFFiresAdapter(NICFFiresConfig())
|
||||||
|
|
||||||
|
def set_batch(self, evts: list) -> None:
|
||||||
|
self._batch = evts
|
||||||
|
|
||||||
|
def tick(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_events(self) -> list:
|
||||||
|
return list(self._batch)
|
||||||
|
|
||||||
|
def to_event(self, evt: dict):
|
||||||
|
return self._real.to_event(evt)
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_fire(
|
||||||
|
*,
|
||||||
|
name="TESTFIRE",
|
||||||
|
irwin="IRWIN-TEST-001",
|
||||||
|
acres=100,
|
||||||
|
contained=0,
|
||||||
|
declared=None,
|
||||||
|
lat=44.0,
|
||||||
|
lon=-115.0,
|
||||||
|
state="US-ID",
|
||||||
|
county="Test County",
|
||||||
|
polygon=None,
|
||||||
|
) -> dict:
|
||||||
|
"""Raw internal event dict as produced by the merged _fetch path."""
|
||||||
|
eid = f"nifc_{name.replace(' ', '_').lower()}_{state}"
|
||||||
|
evt = {
|
||||||
|
"source": "nifc",
|
||||||
|
"event_id": eid,
|
||||||
|
"event_type": "Wildfire",
|
||||||
|
"name": name,
|
||||||
|
"irwin_id": irwin,
|
||||||
|
"acres": acres,
|
||||||
|
"pct_contained": contained,
|
||||||
|
"contained_pct": contained,
|
||||||
|
"declared_at_epoch": declared,
|
||||||
|
"county": county,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"distance_km": None,
|
||||||
|
"nearest_anchor": None,
|
||||||
|
"severity": "routine",
|
||||||
|
"state": state,
|
||||||
|
"fetched_at": _NOW,
|
||||||
|
"expires": _NOW + 21600,
|
||||||
|
}
|
||||||
|
if polygon is not None:
|
||||||
|
evt["polygon"] = polygon
|
||||||
|
return evt
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env(monkeypatch, tmp_path):
|
||||||
|
db_path = str(tmp_path / "fire-pts-test.sqlite")
|
||||||
|
monkeypatch.setenv("MESHAI_DB_PATH", db_path)
|
||||||
|
persistence_db._initialised.clear()
|
||||||
|
close_thread_connection()
|
||||||
|
conn = init_db()
|
||||||
|
from meshai.adapter_config import adapter_config as _ac
|
||||||
|
_ac.invalidate()
|
||||||
|
clk = _Clock(_NOW)
|
||||||
|
monkeypatch.setattr("meshai.notifications.clock.now", clk.now)
|
||||||
|
yield conn, clk
|
||||||
|
close_thread_connection()
|
||||||
|
persistence_db._initialised.discard(db_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_store():
|
||||||
|
bus = EventBus()
|
||||||
|
captured: list = []
|
||||||
|
bus.subscribe(lambda e: captured.append(e))
|
||||||
|
store = EnvironmentalStore(EnvironmentalConfig(), event_bus=bus)
|
||||||
|
adapter = _FakeFires()
|
||||||
|
store._adapters["nifc"] = adapter
|
||||||
|
return store, adapter, captured
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# T1 — merge dedup by IrwinID: A+B in perimeter, A+B+C in points → 3 merged
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@patch("meshai.env.fires.urlopen")
|
||||||
|
def test_t1_merge_dedup_by_irwin(mock_urlopen, adapter):
|
||||||
|
"""Points A+B+C merged with perimeters A+B → 3 events; A/B have polygon; C does not."""
|
||||||
|
fires_ab = [
|
||||||
|
("IRWIN-A", "Alpha Fire", 500, 43.0, -115.0),
|
||||||
|
("IRWIN-B", "Beta Fire", 300, 43.5, -115.5),
|
||||||
|
]
|
||||||
|
fires_abc = [
|
||||||
|
("IRWIN-A", "Alpha Fire", 500, 43.01, -115.01), # point coords differ from perim
|
||||||
|
("IRWIN-B", "Beta Fire", 300, 43.51, -115.51),
|
||||||
|
("IRWIN-C", "Gamma Fire", 250, 44.0, -116.0), # point-only
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_urlopen.side_effect = [
|
||||||
|
_ctx(_perim_geojson(fires_ab)),
|
||||||
|
_ctx(_points_arcgis(fires_abc)),
|
||||||
|
]
|
||||||
|
|
||||||
|
adapter._fetch()
|
||||||
|
|
||||||
|
events = adapter.get_events()
|
||||||
|
assert len(events) == 3, f"Expected 3 merged fires, got {len(events)}: {[e['name'] for e in events]}"
|
||||||
|
|
||||||
|
by_irwin = {e["irwin_id"]: e for e in events}
|
||||||
|
|
||||||
|
# A and B: must have polygon (perimeter-backed)
|
||||||
|
assert "polygon" in by_irwin["IRWIN-A"], "Alpha Fire (perimeter-backed) must carry polygon"
|
||||||
|
assert "polygon" in by_irwin["IRWIN-B"], "Beta Fire (perimeter-backed) must carry polygon"
|
||||||
|
|
||||||
|
# C: must NOT have polygon (point-only)
|
||||||
|
assert "polygon" not in by_irwin["IRWIN-C"], "Gamma Fire (point-only) must not carry polygon"
|
||||||
|
|
||||||
|
# C must still have valid lat/lon from point geometry
|
||||||
|
assert by_irwin["IRWIN-C"]["lat"] == pytest.approx(44.0, abs=0.01)
|
||||||
|
assert by_irwin["IRWIN-C"]["lon"] == pytest.approx(-116.0, abs=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# T2 — point-only fire surfaces post-seed (decider emits "new")
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def test_t2_point_only_fire_surfaces(env):
|
||||||
|
"""Post-seed: a point-only fire (25 ac) triggers decider 'new' → one emit."""
|
||||||
|
_conn, _clk = env
|
||||||
|
store, fires_adapter, captured = _make_store()
|
||||||
|
|
||||||
|
# Mark already seeded so this is NOT cold-start
|
||||||
|
store._fires_seeded = True
|
||||||
|
|
||||||
|
fires_adapter.set_batch([
|
||||||
|
_raw_fire(
|
||||||
|
name="Elk Point Fire",
|
||||||
|
irwin="IRWIN-PT-ONLY-001",
|
||||||
|
acres=25,
|
||||||
|
contained=0,
|
||||||
|
lat=43.8,
|
||||||
|
lon=-115.2,
|
||||||
|
polygon=None, # point-only — no polygon
|
||||||
|
)
|
||||||
|
])
|
||||||
|
store._ingest("nifc", fires_adapter)
|
||||||
|
|
||||||
|
assert len(captured) == 1, (
|
||||||
|
f"Expected 1 broadcast for new point-only fire, got {len(captured)}"
|
||||||
|
)
|
||||||
|
ev = captured[0]
|
||||||
|
assert ev.data.get("_kind") == "wfigs_incident"
|
||||||
|
assert ev.data.get("irwin_id") == "IRWIN-PT-ONLY-001"
|
||||||
|
assert ev.lat == pytest.approx(43.8, abs=0.001)
|
||||||
|
assert ev.lon == pytest.approx(-115.2, abs=0.001)
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# T3 — perimeter geometry preferred over point geometry for same IrwinID
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@patch("meshai.env.fires.urlopen")
|
||||||
|
def test_t3_perimeter_geometry_preferred(mock_urlopen, adapter):
|
||||||
|
"""When both layers carry the same IrwinID, the perimeter centroid wins."""
|
||||||
|
# Perimeter: centroid will average to roughly (43.0, -115.0) from the triangle
|
||||||
|
perim = [("IRWIN-GEOM", "Geom Fire", 400, 43.0, -115.0)]
|
||||||
|
# Points: different initial coords
|
||||||
|
pts = [("IRWIN-GEOM", "Geom Fire", 400, 44.9, -119.9)]
|
||||||
|
|
||||||
|
mock_urlopen.side_effect = [
|
||||||
|
_ctx(_perim_geojson(perim)),
|
||||||
|
_ctx(_points_arcgis(pts)),
|
||||||
|
]
|
||||||
|
|
||||||
|
adapter._fetch()
|
||||||
|
|
||||||
|
events = adapter.get_events()
|
||||||
|
assert len(events) == 1
|
||||||
|
evt = events[0]
|
||||||
|
|
||||||
|
# Centroid of the triangle [(-115.1, 42.9), (-114.8, 42.9), (-115.1, 43.2), (-115.1, 42.9)]
|
||||||
|
# average x = (-115.1 + -114.8 + -115.1 + -115.1) / 4 = (-460.1)/4 ≈ -115.025
|
||||||
|
# average y = (42.9 + 42.9 + 43.2 + 42.9) / 4 = 171.9/4 ≈ 42.975
|
||||||
|
# Point coords were (44.9, -119.9) — clearly different
|
||||||
|
assert evt["lat"] != pytest.approx(44.9, abs=0.5), (
|
||||||
|
"perimeter centroid must be used, not point lat"
|
||||||
|
)
|
||||||
|
assert evt["lon"] != pytest.approx(-119.9, abs=0.5), (
|
||||||
|
"perimeter centroid must be used, not point lon"
|
||||||
|
)
|
||||||
|
# Must be near the perimeter centroid (43.0, -115.0)
|
||||||
|
assert evt["lat"] == pytest.approx(43.0, abs=0.2)
|
||||||
|
assert evt["lon"] == pytest.approx(-115.0, abs=0.2)
|
||||||
|
|
||||||
|
# Must carry a polygon (from perimeter)
|
||||||
|
assert "polygon" in evt
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# T4 — cold-start silent-seed: 6-fire merged batch → 0 broadcasts, 6 rows
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def test_t4_cold_start_silent_seed_six_fires(env):
|
||||||
|
"""Cold-start: 6 fires (mix of perimeter-backed + point-only) → zero broadcasts,
|
||||||
|
all 6 inserted with last_broadcast_at NOT NULL, flag flips True."""
|
||||||
|
conn, _clk = env
|
||||||
|
store, fires_adapter, captured = _make_store()
|
||||||
|
|
||||||
|
assert store._fires_seeded is False, "store must start unseeded"
|
||||||
|
|
||||||
|
batch = [
|
||||||
|
_raw_fire(name=f"Fire {i}", irwin=f"IRWIN-SEED-{i:03d}",
|
||||||
|
acres=100 + i * 50, lat=43.0 + i * 0.1, lon=-115.0 + i * 0.1,
|
||||||
|
polygon=([[[-115.0, 43.0], [-114.9, 43.0], [-115.0, 43.1], [-115.0, 43.0]]]
|
||||||
|
if i % 2 == 0 else None))
|
||||||
|
for i in range(6)
|
||||||
|
]
|
||||||
|
fires_adapter.set_batch(batch)
|
||||||
|
store._ingest("nifc", fires_adapter)
|
||||||
|
|
||||||
|
assert captured == [], f"cold-start must produce ZERO broadcasts, got {len(captured)}"
|
||||||
|
assert store._fires_seeded is True, "flag must flip after non-empty ingest"
|
||||||
|
|
||||||
|
rows = conn.execute("SELECT irwin_id, last_broadcast_at FROM fires").fetchall()
|
||||||
|
assert len(rows) == 6, f"Expected 6 fires rows, got {len(rows)}"
|
||||||
|
for row in rows:
|
||||||
|
assert row["last_broadcast_at"] is not None, (
|
||||||
|
f"cold-start row for {row['irwin_id']} must have last_broadcast_at set"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# T5 — FIRMS attribution: _get_known_fires() sees point-only fires
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def test_t5_firms_attribution_sees_point_only_fires():
|
||||||
|
"""FIRMSAdapter._get_known_fires() returns point-only fires from the merged set."""
|
||||||
|
from meshai.env.firms import FIRMSAdapter
|
||||||
|
|
||||||
|
# Fires adapter with a point-only fire and a perimeter-backed fire
|
||||||
|
fires_cfg = MagicMock()
|
||||||
|
fires_cfg.state = "US-ID"
|
||||||
|
fires_cfg.tick_seconds = 600
|
||||||
|
fires_adapter = NICFFiresAdapter(fires_cfg)
|
||||||
|
fires_adapter._events = [
|
||||||
|
# Perimeter-backed fire (has polygon)
|
||||||
|
_raw_fire(name="Perim Fire", irwin="IRWIN-PERIM-001", acres=500,
|
||||||
|
lat=43.5, lon=-115.5,
|
||||||
|
polygon=[[[-115.5, 43.4], [-115.4, 43.4], [-115.5, 43.6], [-115.5, 43.4]]]),
|
||||||
|
# Point-only fire (no polygon)
|
||||||
|
_raw_fire(name="Point Only Fire", irwin="IRWIN-PTONLY-001", acres=80,
|
||||||
|
lat=44.1, lon=-116.3, polygon=None),
|
||||||
|
]
|
||||||
|
|
||||||
|
firms_cfg = MagicMock()
|
||||||
|
firms_cfg.map_key = "test-key"
|
||||||
|
firms_cfg.source = "VIIRS_SNPP_NRT"
|
||||||
|
firms_cfg.bbox = [-117, 42, -114, 44]
|
||||||
|
firms_cfg.day_range = 1
|
||||||
|
firms_cfg.tick_seconds = 1800
|
||||||
|
firms_cfg.confidence_min = "nominal"
|
||||||
|
firms_cfg.proximity_km = 10.0
|
||||||
|
|
||||||
|
firms = FIRMSAdapter(firms_cfg, region_anchors=[], fires_adapter=fires_adapter)
|
||||||
|
known = firms._get_known_fires()
|
||||||
|
|
||||||
|
assert len(known) == 2, f"Expected 2 known fires (perim + point-only), got {len(known)}"
|
||||||
|
|
||||||
|
names = {f["name"] for f in known}
|
||||||
|
assert "Perim Fire" in names
|
||||||
|
assert "Point Only Fire" in names
|
||||||
|
|
||||||
|
# Point-only fire must have correct coordinates for proximity matching
|
||||||
|
pt = next(f for f in known if f["name"] == "Point Only Fire")
|
||||||
|
assert pt["lat"] == pytest.approx(44.1, abs=0.001)
|
||||||
|
assert pt["lon"] == pytest.approx(-116.3, abs=0.001)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue