diff --git a/work/meshai/env/fires.py b/work/meshai/env/fires.py index b92102e..231977b 100644 --- a/work/meshai/env/fires.py +++ b/work/meshai/env/fires.py @@ -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 logging @@ -17,9 +27,10 @@ logger = logging.getLogger(__name__) 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" + 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): self._state = config.state @@ -49,7 +60,7 @@ class NICFFiresAdapter: return self._fetch() 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 the full coverage bbox (which may cross multiple states); the single-state @@ -79,140 +90,247 @@ class NICFFiresAdapter: } 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: - """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: True if data changed """ - params = self._build_query_params() - - url = f"{self.BASE_URL}?{urlencode(params)}" - headers = { "User-Agent": "MeshAI/1.0", "Accept": "application/json", } + # ── (a) Perimeter layer fetch ──────────────────────────────────────── + perim_features = [] + perim_ok = False try: + params = self._build_query_params() + url = f"{self.BASE_URL}?{urlencode(params)}" req = Request(url, headers=headers) with urlopen(req, timeout=30) as resp: data = json.loads(resp.read().decode("utf-8")) - + perim_features = data.get("features", []) + perim_ok = True 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 - + logger.warning(f"NIFC perimeter HTTP error: {e.code}") except URLError as e: - logger.warning(f"NIFC connection error: {e.reason}") - self._last_error = str(e.reason) - self._consecutive_errors += 1 - return False - + logger.warning(f"NIFC perimeter connection error: {e.reason}") except Exception as e: - logger.warning(f"NIFC fetch error: {e}") - self._last_error = str(e) + logger.warning(f"NIFC perimeter fetch error: {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 return False - # Parse response - features = data.get("features", []) + # ── (c) Merge: points are the authoritative superset ───────────────── + # 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 = [] now = time.time() - for feature in features: - props = feature.get("properties", {}) - geom = feature.get("geometry") + for feature in point_features: + try: + # 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") - acres = props.get("attr_IncidentSize") or props.get("poly_GISAcres") or 0 - pct_contained = props.get("attr_PercentContained") or 0 + name = attrs.get("IncidentName") or "Unknown Fire" - # Compute centroid from polygon - lat, lon = self._compute_centroid(geom) + # Derive irwin_id: prefer real IRWIN GUID, then unique fire id, + # then fall through to event_id (computed below). + irwin_id_raw = ( + attrs.get("IrwinID") + or attrs.get("UniqueFireIdentifier") + ) - # Compute proximity to nearest anchor - distance_km, nearest_anchor = self._nearest_anchor_distance(lat, lon) + # State for event_id construction (coverage mode uses fire's own state) + 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 - if distance_km is not None: - if distance_km < 25: - severity = "priority" - elif distance_km < 50: - severity = "routine" + event_id = f"nifc_{name.replace(' ', '_').lower()}_{event_id_state}" + + irwin_id = irwin_id_raw or event_id + + # ── Merge with perimeter data ──────────────────────────────── + 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: 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})" + # 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})" - # In coverage mode fires can span multiple states, so use the fire's own - # POOState for the event_id suffix. This keeps Idaho fire keys identical to - # 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 + declared_at_epoch = self._parse_discovery_epoch( + attrs.get("FireDiscoveryDateTime")) - 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 - # (notifications/gating/fire.py::decide) reads off Event.data. Prefer - # the real IRWIN GUID; fall back to the unique fire id, then to the - # stable adapter event_id so a fire is always gate-able by the fires - # table even when WFIGS omits the id fields. - irwin_id = ( - props.get("attr_IrwinID") - or props.get("attr_UniqueFireIdentifier") - or event_id - ) - declared_at_epoch = self._parse_discovery_epoch( - props.get("attr_FireDiscoveryDateTime")) + event = { + "source": "nifc", + "event_id": event_id, + "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": 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 = { - "source": "nifc", - "event_id": event_id, - "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 (only when perimeter geometry present) + if polygon: + event["polygon"] = polygon - # Store polygon for map overlay - if geom and geom.get("type") == "Polygon": - event["polygon"] = geom.get("coordinates", []) + new_events.append(event) - 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 - # 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. + # Change detection — include acres + containment so fire growth is visible def _change_sig(e): try: acres = int(round(float(e.get("acres") or 0))) @@ -235,7 +353,12 @@ class NICFFiresAdapter: if changed: 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 @@ -331,9 +454,9 @@ class NICFFiresAdapter: return (None, None) 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 (priority when near a region anchor, else routine) is passed through unchanged. Severity tiering is delegated to the pipeline Inhibitor. @@ -435,3 +558,37 @@ class NICFFiresAdapter: "event_count": len(self._events), "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 diff --git a/work/tests/test_fire_point_fusion.py b/work/tests/test_fire_point_fusion.py new file mode 100644 index 0000000..c8bd336 --- /dev/null +++ b/work/tests/test_fire_point_fusion.py @@ -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)