mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
Three independent bugs kept firms_handler's growth/spotting/halt/cluster fusion decisions from reaching a correct mesh Event: - #117: consumer._normalize() computed `category` from the raw Central category BEFORE the per-adapter handler ran and never re-read data["category"] afterward, so every firms_handler category stamp was a silent no-op. Now re-read post-dispatch, validated against the known category registry (unrecognized overrides are logged and ignored). - #118: consumer.py only ever honors data["_severity_override"], but firms_handler's halt/spotting/cluster sites stamped the plain data["severity"] key instead (only growth used the right key). Switched all three sites to `_severity_override` for one consistent contract. This is severity plumbing only -- it does not change which events fire. - #119: FirePacer's gate only matched source in ("fires","wfigs") at severity=="priority", so FIRMS fusion broadcasts (source="firms", growth/spotting at "immediate") never reached the pacer. Broadened the gate to cover "firms" + {"priority","immediate"}, and gave FirePacer head-of-line insertion so an "immediate" event is never stuck behind already-queued "priority" events. Still unbounded/never-drops. Cluster detection is left exactly as main ships it: live, always on, no toggle (PR #73's curated new-fire cluster broadcasts with cold-start silent-seeding). Only its severity-override key changes, under #118. Updated existing tests that asserted the old (buggy) data["severity"] contract, and added tests/test_firms_fusion_event_contract.py covering all three fixes end-to-end through consumer._normalize()/_handle() and FirePacer directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
383 lines
17 KiB
Python
383 lines
17 KiB
Python
"""Regression tests for the FIRMS fire-fusion Event contract (issues #117-#119).
|
|
|
|
Three independent bugs in the path from firms_handler's growth / spotting /
|
|
halt / cluster fusion decisions to the actual meshai Event that reaches the
|
|
dispatcher + pacer:
|
|
|
|
#117 category overrides dead: consumer._normalize() computed `category`
|
|
from the raw Central category BEFORE the per-adapter handler ran, and
|
|
the final make_event() call never re-read data["category"], so every
|
|
firms_handler category stamp (wildfire_growth / wildfire_halted /
|
|
wildfire_spotting / unattributed_hotspot_cluster) was a silent no-op.
|
|
|
|
#118 severity overrides dead for 3 of 4 fusion kinds: consumer.py only ever
|
|
honored data["_severity_override"], but firms_handler's halt / spotting
|
|
/ cluster sites stamped the plain data["severity"] key instead (only
|
|
growth used the correct key), so those events fell back to whatever
|
|
map_severity(inner.get("severity")) produced from the raw envelope.
|
|
|
|
#119 FirePacer didn't cover FIRMS: the pacer gate only matched
|
|
source in ("fires", "wfigs") and severity == "priority", but FIRMS
|
|
fusion broadcasts carry source="firms" and growth/spotting are
|
|
severity="immediate" -- so none of them were ever paced. The fix
|
|
broadens the gate to source="firms" + {"priority","immediate"}, and
|
|
adds head-of-line insertion so an "immediate" event is never stuck
|
|
behind already-queued "priority" events.
|
|
|
|
Cluster detection itself is a deliberate, always-on feature of main (PR #73:
|
|
curated new-fire cluster broadcasts, cold-start silent-seeded). Nothing here
|
|
enables or disables it -- the cluster case below only asserts that its
|
|
severity override reaches the Event (the #118 fix).
|
|
|
|
Sections:
|
|
A. Category overrides survive to the emitted Event (growth/halt/spotting).
|
|
B. Severity overrides survive to the emitted Event, using the shared
|
|
`_severity_override` contract (spotting=immediate, halt=routine,
|
|
cluster=priority).
|
|
C. FirePacer routes FIRMS broadcasts; immediate jumps the queue; nothing
|
|
is ever dropped.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import math
|
|
import time
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
from meshai.config import Config
|
|
from meshai.central.consumer import CentralConsumer
|
|
from meshai.notifications.events import make_event
|
|
from meshai.notifications.pipeline.pacer import FirePacer
|
|
from meshai.persistence import close_thread_connection, init_db
|
|
from meshai.persistence import db as persistence_db
|
|
|
|
_MI_PER_DEG_LAT = 69.0
|
|
_SUBJECT = "central.fire.hotspot.N20.high.us.id"
|
|
|
|
|
|
# ── isolation ────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_db(tmp_path, monkeypatch):
|
|
db_path = str(tmp_path / f"meshai-{uuid.uuid4().hex}.sqlite")
|
|
monkeypatch.setenv("MESHAI_DB_PATH", db_path)
|
|
persistence_db._initialised.clear()
|
|
close_thread_connection()
|
|
init_db()
|
|
try:
|
|
from meshai.adapter_config import adapter_config as _ac
|
|
_ac.invalidate()
|
|
except Exception:
|
|
pass
|
|
yield db_path
|
|
close_thread_connection()
|
|
persistence_db._initialised.discard(db_path)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_cutover(monkeypatch):
|
|
"""Default deploy state: nothing cut over -> firms_handler's legacy
|
|
stamps are what actually reach `data`."""
|
|
monkeypatch.delenv("MESHAI_CUTOVER_CATEGORIES", raising=False)
|
|
from meshai.notifications.cutover import _clear_cache
|
|
_clear_cache()
|
|
yield
|
|
_clear_cache()
|
|
|
|
|
|
# firms_handler.handle_firms defaults `now` to real wall-clock time
|
|
# (int(time.time())) whenever it is called without an explicit `now`
|
|
# kwarg -- which is exactly how consumer._normalize()/_handle() call it (no
|
|
# `now` is threaded through from the envelope). The growth/spotting/halt
|
|
# fixtures below use fixed 2026-06-06 acq_date/acq_time values (matching the
|
|
# rest of the FIRMS test suite), so pin the wall clock to a fixed reference
|
|
# in the same window; otherwise the halt detector opportunistically fires on
|
|
# every pixel once the real host clock is weeks past the canned acq_times.
|
|
_FIXED_NOW = 1780768800.0 # 2026-06-06 18:00 UTC
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fixed_clock(monkeypatch):
|
|
monkeypatch.setattr("time.time", lambda: _FIXED_NOW)
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def consumer():
|
|
"""CentralConsumer with a mocked bus, mirroring
|
|
test_consumer_default_deny.py's `consumer` fixture."""
|
|
from unittest.mock import MagicMock
|
|
cfg = Config()
|
|
cfg.notifications.cold_start_grace_seconds = 0
|
|
bus = MagicMock()
|
|
c = CentralConsumer(cfg.environmental, bus)
|
|
return c, bus
|
|
|
|
|
|
# ── envelope + fire-seeding helpers (mirror test_firms_refactor.py) ─────────
|
|
|
|
def _seed_fire(*, irwin_id, lat, lon, name="Stub Fire", **cols):
|
|
from meshai.persistence import get_db
|
|
conn = get_db()
|
|
base = {"irwin_id": irwin_id, "incident_name": name, "lat": lat, "lon": lon,
|
|
"last_event_at": int(time.time())}
|
|
base.update(cols)
|
|
keys = ",".join(base)
|
|
ph = ",".join("?" * len(base))
|
|
conn.execute(f"INSERT INTO fires({keys}) VALUES ({ph})", tuple(base.values()))
|
|
|
|
|
|
def _envelope(*, lat, lon, acq_date="2026-06-06", acq_time="1200",
|
|
frp=20.0, satellite="N20", eid=None):
|
|
eid = eid or f"firms-{lat}-{lon}-{acq_time}"
|
|
return {
|
|
"id": f"env_{eid}",
|
|
"data": {
|
|
"id": eid,
|
|
"adapter": "firms",
|
|
"category": "wildfire_hotspot",
|
|
"severity": "routine",
|
|
"geo": {"primary_region": "US-ID"},
|
|
"data": {
|
|
"latitude": lat, "longitude": lon, "frp": frp,
|
|
"bright_ti4": 320.0, "satellite": satellite,
|
|
"instrument": "VIIRS", "confidence": "high",
|
|
"acq_date": acq_date, "acq_time": acq_time,
|
|
"daynight": "D", "version": "2.0NRT",
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _offset_mi(lat, lon, north_mi, east_mi):
|
|
dlat = north_mi / _MI_PER_DEG_LAT
|
|
dlon = east_mi / (_MI_PER_DEG_LAT * math.cos(math.radians(lat)))
|
|
return lat + dlat, lon + dlon
|
|
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
# A. Category overrides reach the emitted Event (issue #117)
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
class TestCategoryOverrideReachesEvent:
|
|
def test_growth_event_category_is_wildfire_growth(self, consumer):
|
|
c, _bus = consumer
|
|
center_lat, center_lon = 42.0, -114.0
|
|
_seed_fire(irwin_id="ID-CAT-G", lat=center_lat, lon=center_lon,
|
|
name="Pine Gulch")
|
|
# Pass A: 5 pixels build the baseline (no broadcast yet).
|
|
for i in range(5):
|
|
evt = c._normalize(_SUBJECT, _envelope(
|
|
lat=center_lat + 0.0001 * i, lon=center_lon + 0.0001 * (i - 2),
|
|
acq_time=f"12{i:02d}", frp=20.0 + i, eid=f"ga{i}"))
|
|
assert evt is None, "pass-A pixels must not broadcast"
|
|
# Pass B: 1 mi N, later pass bucket -> growth boundary.
|
|
pass_b_lat = center_lat + (1.0 / _MI_PER_DEG_LAT)
|
|
evt = c._normalize(_SUBJECT, _envelope(
|
|
lat=pass_b_lat, lon=center_lon, acq_time="1800", frp=22.0, eid="gb"))
|
|
assert evt is not None
|
|
assert evt.category == "wildfire_growth", (
|
|
"category override must survive to the Event, not the generic "
|
|
"wildfire_hotspot/wildfire_incident fallback")
|
|
assert evt.source == "firms"
|
|
|
|
def test_halt_event_category_is_wildfire_halted(self, consumer):
|
|
c, _bus = consumer
|
|
now = 1780768800
|
|
idle_at = now - 14 * 3600
|
|
from meshai.persistence import get_db
|
|
get_db().execute(
|
|
"INSERT INTO fires(irwin_id, incident_name, lat, lon, "
|
|
"last_event_at, last_pass_id, last_pass_at) VALUES (?,?,?,?,?,?,?)",
|
|
("ID-CAT-H", "Cold Fire", 42.5, -114.5, int(idle_at),
|
|
"N20-329627", float(idle_at)))
|
|
# A fresh unattributed pixel far away triggers the opportunistic
|
|
# halt detector for the idle fire.
|
|
evt = c._normalize(_SUBJECT, _envelope(
|
|
lat=45.0, lon=-118.0, acq_time="1800", eid="halt1"))
|
|
assert evt is not None
|
|
assert evt.category == "wildfire_halted"
|
|
|
|
def test_spotting_event_category_is_wildfire_spotting(self, consumer):
|
|
c, _bus = consumer
|
|
center_lat, center_lon = 43.0, -115.0
|
|
_seed_fire(irwin_id="ID-CAT-S", lat=center_lat, lon=center_lon,
|
|
name="Spot Fire")
|
|
for i in range(6):
|
|
angle = i * math.pi / 3
|
|
la = center_lat + (0.5 / _MI_PER_DEG_LAT) * math.sin(angle)
|
|
cos_lat = math.cos(math.radians(center_lat))
|
|
lo = center_lon + (0.5 / (_MI_PER_DEG_LAT * cos_lat)) * math.cos(angle)
|
|
evt = c._normalize(_SUBJECT, _envelope(
|
|
lat=la, lon=lo, acq_time=f"12{i * 2:02d}", eid=f"sa{i}"))
|
|
assert evt is None
|
|
sp_lat, sp_lon = _offset_mi(center_lat, center_lon,
|
|
north_mi=2.0 / math.sqrt(2),
|
|
east_mi=2.0 / math.sqrt(2))
|
|
evt = c._normalize(_SUBJECT, _envelope(
|
|
lat=sp_lat, lon=sp_lon, acq_time="1800", eid="sb"))
|
|
assert evt is not None
|
|
assert evt.category == "wildfire_spotting"
|
|
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
# B. Severity overrides reach the emitted Event via `_severity_override`
|
|
# (issue #118)
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
class TestSeverityOverrideReachesEvent:
|
|
def test_spotting_event_severity_is_immediate(self, consumer):
|
|
c, _bus = consumer
|
|
center_lat, center_lon = 44.0, -116.0
|
|
_seed_fire(irwin_id="ID-SEV-S", lat=center_lat, lon=center_lon)
|
|
for i in range(6):
|
|
angle = i * math.pi / 3
|
|
la = center_lat + (0.5 / _MI_PER_DEG_LAT) * math.sin(angle)
|
|
cos_lat = math.cos(math.radians(center_lat))
|
|
lo = center_lon + (0.5 / (_MI_PER_DEG_LAT * cos_lat)) * math.cos(angle)
|
|
c._normalize(_SUBJECT, _envelope(
|
|
lat=la, lon=lo, acq_time=f"12{i * 2:02d}", eid=f"ssa{i}"))
|
|
sp_lat, sp_lon = _offset_mi(center_lat, center_lon,
|
|
north_mi=2.0 / math.sqrt(2),
|
|
east_mi=2.0 / math.sqrt(2))
|
|
evt = c._normalize(_SUBJECT, _envelope(
|
|
lat=sp_lat, lon=sp_lon, acq_time="1800", eid="ssb"))
|
|
assert evt is not None
|
|
assert evt.severity == "immediate", (
|
|
"spotting must reach the pacer/dispatcher as immediate severity, "
|
|
"not fall back to map_severity() of the raw envelope")
|
|
|
|
def test_halt_event_severity_is_routine(self, consumer):
|
|
c, _bus = consumer
|
|
now = 1780768800
|
|
idle_at = now - 14 * 3600
|
|
from meshai.persistence import get_db
|
|
get_db().execute(
|
|
"INSERT INTO fires(irwin_id, incident_name, lat, lon, "
|
|
"last_event_at, last_pass_id, last_pass_at) VALUES (?,?,?,?,?,?,?)",
|
|
("ID-SEV-H", "Cold Fire", 42.5, -114.5, int(idle_at),
|
|
"N20-329627", float(idle_at)))
|
|
evt = c._normalize(_SUBJECT, _envelope(
|
|
lat=45.0, lon=-118.0, acq_time="1800", eid="sevhalt"))
|
|
assert evt is not None
|
|
assert evt.severity == "routine"
|
|
|
|
def test_cluster_event_severity_is_priority(self, consumer):
|
|
c, _bus = consumer
|
|
base_lat, base_lon = 43.500, -114.500
|
|
pixels = [
|
|
(base_lat, base_lon, "1200"),
|
|
(base_lat + 0.001, base_lon + 0.001, "1210"),
|
|
(base_lat - 0.001, base_lon - 0.002, "1220"),
|
|
]
|
|
events = []
|
|
for i, (la, lo, t) in enumerate(pixels):
|
|
evt = c._normalize("central.fire.hotspot.N20.high.unknown", _envelope(
|
|
lat=la, lon=lo, acq_time=t, eid=f"clu{i}"))
|
|
if evt is not None:
|
|
events.append(evt)
|
|
assert len(events) == 1, f"expected exactly one cluster event: {events}"
|
|
assert events[0].category == "unattributed_hotspot_cluster"
|
|
assert events[0].severity == "priority"
|
|
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
# C. FirePacer covers FIRMS; immediate jumps the queue; nothing is dropped
|
|
# (issue #119)
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
class TestPacerCoversFirms:
|
|
def test_firms_growth_broadcast_routes_through_pacer(self, consumer):
|
|
"""A real FIRMS growth broadcast (source=firms, severity=immediate)
|
|
must be handed to the pacer, not emitted straight to the bus."""
|
|
from unittest.mock import MagicMock
|
|
c, bus = consumer
|
|
pacer = MagicMock()
|
|
c._pacer = pacer
|
|
|
|
center_lat, center_lon = 42.0, -114.0
|
|
_seed_fire(irwin_id="ID-PACE-G", lat=center_lat, lon=center_lon,
|
|
name="Pine Gulch")
|
|
for i in range(5):
|
|
c._handle(_SUBJECT, _raw(_envelope(
|
|
lat=center_lat + 0.0001 * i, lon=center_lon + 0.0001 * (i - 2),
|
|
acq_time=f"12{i:02d}", frp=20.0 + i, eid=f"pga{i}")))
|
|
pass_b_lat = center_lat + (1.0 / _MI_PER_DEG_LAT)
|
|
event = c._handle(_SUBJECT, _raw(_envelope(
|
|
lat=pass_b_lat, lon=center_lon, acq_time="1800", frp=22.0,
|
|
eid="pgb")))
|
|
|
|
assert event is not None
|
|
assert event.category == "wildfire_growth"
|
|
pacer.enqueue.assert_called_once_with(event)
|
|
bus.emit.assert_not_called()
|
|
|
|
def test_immediate_event_emitted_before_already_queued_priority_events(self):
|
|
"""Two 'priority' events are queued first; a later 'immediate' event
|
|
must still be emitted BEFORE them (head-of-line), not after."""
|
|
emitted = []
|
|
|
|
class _FakeBus:
|
|
def emit(self, event):
|
|
emitted.append(event)
|
|
|
|
pacer = FirePacer(_FakeBus(), interval_seconds=0.01)
|
|
|
|
p1 = make_event(source="fires", category="wildfire_incident",
|
|
severity="priority", title="priority-1")
|
|
p2 = make_event(source="fires", category="wildfire_incident",
|
|
severity="priority", title="priority-2")
|
|
imm = make_event(source="firms", category="wildfire_spotting",
|
|
severity="immediate", title="immediate-1")
|
|
|
|
pacer.enqueue(p1)
|
|
pacer.enqueue(p2)
|
|
pacer.enqueue(imm) # must jump ahead of p1/p2
|
|
|
|
async def _drive():
|
|
await pacer.start()
|
|
await asyncio.sleep(0.2)
|
|
await pacer.stop()
|
|
|
|
asyncio.run(_drive())
|
|
|
|
assert [e.title for e in emitted] == [
|
|
"immediate-1", "priority-1", "priority-2"]
|
|
|
|
def test_pacer_never_drops_events(self):
|
|
"""Rapid-fire enqueue of many events (mixed severities) -- the
|
|
unbounded FIFO must eventually deliver every single one."""
|
|
emitted = []
|
|
|
|
class _FakeBus:
|
|
def emit(self, event):
|
|
emitted.append(event)
|
|
|
|
pacer = FirePacer(_FakeBus(), interval_seconds=0.001)
|
|
|
|
total = 25
|
|
for i in range(total):
|
|
sev = "immediate" if i % 5 == 0 else "priority"
|
|
pacer.enqueue(make_event(
|
|
source="firms", category="wildfire_growth",
|
|
severity=sev, title=f"evt-{i}"))
|
|
assert pacer.pending_count() == total
|
|
|
|
async def _drive():
|
|
await pacer.start()
|
|
# Generous wait: interval is 1ms, 25 events, allow real margin.
|
|
await asyncio.sleep(1.0)
|
|
await pacer.stop()
|
|
|
|
asyncio.run(_drive())
|
|
|
|
assert len(emitted) == total, (
|
|
f"pacer must never drop events: expected {total}, got {len(emitted)}")
|
|
assert pacer.pending_count() == 0
|
|
|
|
|
|
def _raw(envelope: dict) -> bytes:
|
|
import json
|
|
return json.dumps(envelope).encode()
|