meshai/work/tests/test_fire_point_fusion.py

415 lines
16 KiB
Python
Raw Normal View History

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