mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
fix(firms): native adapter attribution-only — never broadcast raw hotspots (#36)
env/firms.py to_event() now unconditionally returns None. The native FIRMS path was broadcasting raw single-pixel hotspots (new_ignition/wildfire_hotspot, both live "fire"-toggle categories) straight to the mesh — no decider gated them (none registered for those categories) — violating the absolute "we do NOT broadcast hotspots" rule and diverging from the Central handler's storage-only contract. Finding: native FIRMS has NO fusion wiring — it never wrote firms_pixels or did attribution; the growth/spotting/halt fire-tracker lives entirely in central/firms_handler.py driven by NATS. So neutralization loses no fusion (there was none natively). Full native fire-tracking standalone would require feeding native pixels into that attribution engine — a known, deferred gap. Only env/firms.py + tests/test_adapter_firms.py touched; firms_handler.py, gating/firms.py, store.py untouched. Suite at the 10-failure baseline. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a756143284
commit
8cf0851964
2 changed files with 76 additions and 155 deletions
89
work/meshai/env/firms.py
vendored
89
work/meshai/env/firms.py
vendored
|
|
@ -345,64 +345,43 @@ class FIRMSAdapter:
|
|||
return (None, None)
|
||||
|
||||
def to_event(self, evt: dict) -> Optional["Event"]:
|
||||
"""Translate a stored FIRMS event dict into a pipeline Event.
|
||||
"""Attribution-only: the native FIRMS adapter NEVER broadcasts hotspots.
|
||||
|
||||
Args:
|
||||
evt: Internal event dict from get_events()
|
||||
Firm rule (Matt): "we do NOT broadcast hotspots." A raw satellite
|
||||
thermal pixel -- a single-pixel ``wildfire_hotspot`` or
|
||||
``new_ignition`` detection -- is noisy and not actionable on its own;
|
||||
broadcasting it would flood the mesh with unattributed heat. This
|
||||
mirrors the Central path (``central/firms_handler.py``), which is
|
||||
storage-only and returns None for every raw pixel. The ONLY FIRMS
|
||||
signals that ever reach the mesh are the fire-tracker FUSION outputs
|
||||
(``wildfire_growth`` / ``wildfire_spotting`` / ``wildfire_halted``),
|
||||
produced by the Central handler's attribution engine -- NOT here.
|
||||
|
||||
Returns:
|
||||
Event instance ready for EventBus emission, or None if
|
||||
the dict is missing required fields (lat/lon).
|
||||
This method therefore ALWAYS returns None. The neutralization is
|
||||
UNCONDITIONAL (not behind any config flag): the no-hotspots rule is
|
||||
absolute. Previously this emitted ``make_event(category="new_ignition"
|
||||
if new_ignition else "wildfire_hotspot", ...)``, and because
|
||||
``store._emit_event`` only consults a gating decider for cut-over
|
||||
categories -- and NO decider is registered for the raw hotspot
|
||||
categories (see ``notifications/gating/__init__.py``: "native
|
||||
env/fires.py hotspot broadcasts ... are NOT migrated") -- those Events
|
||||
went straight to the bus and out to the mesh. Returning None removes
|
||||
that broadcast on the native path entirely.
|
||||
|
||||
The raw hotspot dicts remain available in-memory via ``get_events()``
|
||||
/ ``get_new_ignitions()`` for LLM context and health reporting; they
|
||||
just never become a broadcastable Event.
|
||||
|
||||
STANDALONE GAP (not fixed here): unlike the Central handler, the native
|
||||
adapter has NO fusion wiring -- it does not persist ``firms_pixels`` or
|
||||
run attribution / clustering / pass-boundary growth. Native FIRMS
|
||||
therefore feeds NOTHING into the fire tracker today; the cross-ref to
|
||||
known NIFC fires only sets the (now unused for broadcast)
|
||||
``new_ignition`` flag on the in-memory dict. Wiring native pixels into
|
||||
the attribution engine so growth/spotting/halt can eventually fire is a
|
||||
separate, larger effort.
|
||||
"""
|
||||
try:
|
||||
lat = evt.get("lat")
|
||||
lon = evt.get("lon")
|
||||
if lat is None or lon is None:
|
||||
return None # Can't make a useful Event without coords
|
||||
|
||||
props = evt.get("properties", {}) or {}
|
||||
is_new_ignition = bool(props.get("new_ignition", False))
|
||||
# v0.5.7-fire: 'wildfire_proximity' was removed from ALERT_CATEGORIES
|
||||
# (parametric: distance threshold isn't configurable on rules until
|
||||
# v0.5.8). Emit 'wildfire_hotspot' to align with the central FIRMS
|
||||
# path -- both native and central FIRMS now produce the same category.
|
||||
category = "new_ignition" if is_new_ignition else "wildfire_hotspot"
|
||||
|
||||
severity = evt.get("severity", "routine")
|
||||
|
||||
title = evt.get("headline", "") or "Fire Hotspot"
|
||||
|
||||
# Build a richer summary including FRP, confidence, distance
|
||||
summary_parts = [title]
|
||||
if props.get("frp") is not None:
|
||||
summary_parts.append(f"FRP {int(props['frp'])} MW")
|
||||
if props.get("confidence"):
|
||||
summary_parts.append(f"conf {props['confidence']}")
|
||||
if props.get("distance_km") is not None and props.get("nearest_anchor"):
|
||||
summary_parts.append(
|
||||
f"{int(props['distance_km'])} km from {props['nearest_anchor']}"
|
||||
)
|
||||
summary = " | ".join(summary_parts)[:300]
|
||||
|
||||
spatial_key = f"firms:{round(lat, 2):.2f}:{round(lon, 2):.2f}"
|
||||
|
||||
return make_event(
|
||||
source="firms",
|
||||
category=category,
|
||||
severity=severity,
|
||||
title=title,
|
||||
summary=summary,
|
||||
timestamp=evt.get("fetched_at"),
|
||||
expires=evt.get("expires"),
|
||||
region=props.get("nearest_anchor"),
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
group_key=spatial_key,
|
||||
inhibit_keys=[spatial_key],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"FIRMS to_event failed for evt: {evt.get('event_id')}")
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_events(self) -> list:
|
||||
"""Get current hotspot events."""
|
||||
|
|
|
|||
|
|
@ -73,105 +73,48 @@ def make_firms_event(
|
|||
|
||||
|
||||
# ============================================================
|
||||
# CATEGORY DECISION TESTS
|
||||
# ATTRIBUTION-ONLY CONTRACT
|
||||
# ============================================================
|
||||
#
|
||||
# Firm rule (Matt): "we do NOT broadcast hotspots." The native FIRMS adapter
|
||||
# must NEVER produce a broadcastable Event from a raw satellite pixel --
|
||||
# neither a near-known-fire `wildfire_hotspot` nor a standalone `new_ignition`.
|
||||
# `to_event()` is now attribution-only and ALWAYS returns None, mirroring the
|
||||
# storage-only Central path (central/firms_handler.py). The only FIRMS signals
|
||||
# that ever reach the mesh are the fusion outputs (wildfire_growth /
|
||||
# wildfire_spotting / wildfire_halted) produced by the Central handler.
|
||||
#
|
||||
# These tests lock that contract: NOTHING the adapter emits reaches the bus.
|
||||
|
||||
def test_to_event_new_ignition(adapter):
|
||||
"""New ignition maps to new_ignition category."""
|
||||
evt = make_firms_event(new_ignition=True)
|
||||
event = adapter.to_event(evt)
|
||||
assert event is not None
|
||||
assert event.category == "new_ignition"
|
||||
def test_to_event_hotspot_returns_none(adapter):
|
||||
"""A representative hotspot (near a known fire) never broadcasts."""
|
||||
evt = make_firms_event(new_ignition=False, near_fire="Snake River Fire",
|
||||
frp=85.5, confidence="h")
|
||||
assert adapter.to_event(evt) is None
|
||||
|
||||
|
||||
def test_to_event_near_known_fire(adapter):
|
||||
"""Hotspot near known fire maps to wildfire_hotspot."""
|
||||
evt = make_firms_event(new_ignition=False, near_fire="Snake River Fire")
|
||||
event = adapter.to_event(evt)
|
||||
assert event is not None
|
||||
assert event.category == "wildfire_hotspot"
|
||||
def test_to_event_new_ignition_returns_none(adapter):
|
||||
"""A representative new-ignition hotspot never broadcasts."""
|
||||
evt = make_firms_event(new_ignition=True, frp=120.0, confidence="h",
|
||||
distance_km=12, nearest_anchor="TFL")
|
||||
assert adapter.to_event(evt) is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SEVERITY PASS-THROUGH TESTS
|
||||
# ============================================================
|
||||
|
||||
def test_to_event_severity_passes_through(adapter):
|
||||
"""Severity from FIRMS event passes through unchanged."""
|
||||
def test_to_event_returns_none_across_severities(adapter):
|
||||
"""No severity tier re-opens the hotspot broadcast path."""
|
||||
for sev in ["routine", "priority", "immediate"]:
|
||||
evt = make_firms_event(severity=sev)
|
||||
event = adapter.to_event(evt)
|
||||
assert event is not None
|
||||
assert event.severity == sev
|
||||
assert adapter.to_event(evt) is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CONTENT TESTS
|
||||
# ============================================================
|
||||
|
||||
def test_to_event_summary_includes_frp(adapter):
|
||||
"""Summary includes FRP when present."""
|
||||
evt = make_firms_event(frp=85.5)
|
||||
event = adapter.to_event(evt)
|
||||
assert event is not None
|
||||
assert "FRP 85" in event.summary
|
||||
|
||||
|
||||
def test_to_event_summary_handles_missing_frp(adapter):
|
||||
"""Missing FRP doesn't break to_event."""
|
||||
evt = make_firms_event(frp=None)
|
||||
event = adapter.to_event(evt)
|
||||
assert event is not None
|
||||
assert "FRP" not in event.summary
|
||||
|
||||
|
||||
def test_to_event_summary_includes_distance_when_present(adapter):
|
||||
"""Summary includes distance and anchor when present."""
|
||||
evt = make_firms_event(distance_km=12, nearest_anchor="TFL")
|
||||
event = adapter.to_event(evt)
|
||||
assert event is not None
|
||||
assert "12 km" in event.summary
|
||||
assert "TFL" in event.summary
|
||||
|
||||
|
||||
def test_to_event_region_uses_nearest_anchor(adapter):
|
||||
"""Region is set from nearest_anchor."""
|
||||
evt = make_firms_event(nearest_anchor="MHR")
|
||||
event = adapter.to_event(evt)
|
||||
assert event is not None
|
||||
assert event.region == "MHR"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SPATIAL KEY TESTS
|
||||
# ============================================================
|
||||
|
||||
def test_to_event_group_key_is_spatial_grid(adapter):
|
||||
"""Group key is spatial grid based on rounded lat/lon."""
|
||||
evt = make_firms_event(lat=42.5678, lon=-114.3456)
|
||||
event = adapter.to_event(evt)
|
||||
assert event is not None
|
||||
assert event.group_key == "firms:42.57:-114.35"
|
||||
|
||||
|
||||
def test_to_event_inhibit_keys_match_group_key(adapter):
|
||||
"""Inhibit keys contain the same spatial key as group_key."""
|
||||
evt = make_firms_event(lat=42.5678, lon=-114.3456)
|
||||
event = adapter.to_event(evt)
|
||||
assert event is not None
|
||||
assert event.group_key in event.inhibit_keys
|
||||
|
||||
|
||||
def test_two_nearby_detections_share_group_key(adapter):
|
||||
"""Two detections in same grid cell share group_key."""
|
||||
# Both round to 42.57:-114.35
|
||||
evt1 = make_firms_event(lat=42.571, lon=-114.351)
|
||||
evt2 = make_firms_event(lat=42.572, lon=-114.352)
|
||||
event1 = adapter.to_event(evt1)
|
||||
event2 = adapter.to_event(evt2)
|
||||
assert event1 is not None
|
||||
assert event2 is not None
|
||||
assert event1.group_key == event2.group_key
|
||||
def test_to_event_never_broadcasts_even_with_full_payload(adapter):
|
||||
"""A fully-populated hotspot dict still produces no Event."""
|
||||
evt = make_firms_event(
|
||||
lat=42.5678, lon=-114.3456, new_ignition=True, severity="immediate",
|
||||
headline="NEW HOTSPOT detected", frp=250.0, confidence="h",
|
||||
distance_km=3, nearest_anchor="MHR",
|
||||
)
|
||||
assert adapter.to_event(evt) is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
|
@ -186,8 +129,8 @@ def test_to_event_missing_coords_returns_none(adapter):
|
|||
assert event is None
|
||||
|
||||
|
||||
def test_to_event_missing_properties_returns_event(adapter):
|
||||
"""Missing properties dict defaults to wildfire_hotspot."""
|
||||
def test_to_event_missing_properties_returns_none(adapter):
|
||||
"""Missing properties dict still produces no broadcast."""
|
||||
evt = {
|
||||
"source": "firms",
|
||||
"event_id": "test",
|
||||
|
|
@ -200,8 +143,7 @@ def test_to_event_missing_properties_returns_event(adapter):
|
|||
}
|
||||
# No "properties" key at all
|
||||
event = adapter.to_event(evt)
|
||||
assert event is not None
|
||||
assert event.category == "wildfire_hotspot"
|
||||
assert event is None
|
||||
|
||||
|
||||
def test_to_event_does_not_raise_on_corrupted_dict(adapter):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue