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:
malice 2026-07-08 15:58:48 -06:00 committed by GitHub
commit d988868257
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 685 additions and 113 deletions

View file

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

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