mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 09:21:33 +00:00
chore(central-ripout 2d-ii): remove dead fire scraps (NOT the 'rewrite' — there wasn't one) (#166)
* chore(fire-ripout 2dii-a): remove dead wildfire_growth cutover path + handle_firms
wildfire_growth events are always fully precomposed (env/firms.py sets
_meshai_precomposed=True + title=<wire from _render()>) and the category is
not in cutover.NATIVE_ALWAYS_DECIDE, so the registered fire formatter could
never be reached live via compose_mesh_message for this category -- the
precomposed-title bypass always won first. Removes the dead
is_cutover("wildfire_growth") NEW-PATH branch in
fire_fusion._handle_pass_boundary (kept the live legacy leg that calls
_render unconditionally) and the now-unreachable wildfire_growth formatter
registration in notifications/formatters/__init__.py.
Also removes handle_firms, the dead Central NATS-envelope entrypoint (zero
live production callers -- Central's consumer that drove it is gone), plus
its envelope-specific filtering helpers (_confidence_passes, _in_bbox,
_coerce_severity, _log_event, FIRMS_CONFIDENCE_FLOOR/FRP_FLOOR/BBOX_OPTIONAL)
that had no live callers left. The shared, LIVE fusion core
(_ingest_pixel_core, attribution, clustering, growth/spotting/halt) and
_parse_acq_epoch are unaffected -- still the engine behind the native
ingest_hotspot_pixel entrypoint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(fire-ripout 2dii-b): remove dead handle_wfigs entrypoint
handle_wfigs (the dead Central NATS-envelope entrypoint) had zero live
production callers -- Central's consumer that drove it is gone. The LIVE
WFIGS path is env/fires.py (native adapter) -> env/store.py::_emit_event,
forced onto gating.fire.decide + the shared fire formatter via
cutover.NATIVE_ALWAYS_DECIDE, independent of this dead handler.
Removes handle_wfigs and its private-only helpers (_coerce_severity,
_log_event, _log_event_returning_id) that had no callers left. _render
remains -- it is called directly by env.fire_fusion._handle_pass_boundary on
the FIRMS wildfire_growth path, and is used as a byte-identity oracle by
tests against the shared, live fire formatter. _build_canonical,
_attach_commit_handles, _fire_too_old_to_announce, _now, _cleanup_stale_fires
and WFIGS_BROADCAST_COOLDOWN_S are kept -- each has its own direct test
coverage independent of handle_wfigs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(fire-ripout 2dii-c): rewire fire tests off the deleted dead entrypoints
handle_firms and handle_wfigs (both removed in the prior two commits) were
used throughout this test suite purely as convenient DRIVERS for live logic
(pixel attribution/clustering/growth/spotting/halt fusion; the WFIGS
New/Update/cooldown/closed decider). Per-file disposition:
Rewired to drive the LIVE native entrypoint directly (no behavior change --
same underlying _ingest_pixel_core / gating.fire.decide engines):
- test_fire_tracker_phase1/2/3.py: handle_firms -> ingest_hotspot_pixel
- test_firms_refactor.py: handle_firms driver helpers -> ingest_hotspot_pixel;
dropped the dead wildfire_growth cutover test + the fully-redundant
TestNotCutoverLegacyVerbatim class (already covered by
test_firms_native_fusion.py's TestIngestGrowth/Spotting/Halt against the
live entrypoint); wildfire_growth formatter registration test now
asserts None (matches source change).
- test_wfigs_handler.py: handle_wfigs -> _render (the live WFIGS renderer)
called directly, for the anchor-priority + missing-acres cases that
exercise shared/live code (_location_anchor).
- test_fire_refactor.py: handle_wfigs -> gating.fire.decide() driven
directly, with state written the same unconditional shape the live
native path uses; TestGateSequenceParity trimmed to focus on the
tombstone/closed lifecycle step (not covered by
test_fire_native_growth.py's native-adapter New/Update/cooldown coverage).
- test_tombstone_broadcast.py, test_fire_age_gate.py: handle_wfigs ->
gating.fire.decide() driven directly; same assertions, off the dead path.
Deleted as pure dead-entrypoint contract testing with no live equivalent:
- test_firms_native_fusion.py::TestCentralPathUnchanged (2 tests) --
existed only to guard handle_firms's own envelope parsing.
- test_firms_handler.py: confidence/FRP/bbox filtering, missing-coords,
non-firms-adapter guard, event_log accounting (all handle_firms-specific;
the native adapter filters upstream in a different code path). Kept +
rewired: the shared _ingest_pixel_core dedup behavior and
_parse_acq_epoch's int/short acq_time parsing (both genuinely live/shared).
- test_wfigs_handler.py: envelope field-extraction (acres-fallback chain,
IA-placeholder-as-name), tombstone/perimeter subject -> event_log,
New/Update/cooldown decision + audit-row wiring (all redundant with
tests/test_fire_native_growth.py's coverage of gating.fire.decide()
through the real native adapter).
- test_tombstone_broadcast.py::test_commit_callback_flips_handled --
asserted handle_wfigs's own event_log-row flip on commit, a Central-only
concept the native path never used.
- test_tail_followups.py::test_wfigs_tombstone_stamps_column -- asserted
handle_wfigs's own inline tombstoned_at UPDATE; no live producer emits
_kind=wfigs_tombstone today, so nothing in the live system stamps it.
Both live renderers stay covered: fire_format (wildfire_declared/incident,
via test_fire_refactor.py + test_fire_native_growth.py, untouched) and
_render (wildfire_growth via FIRMS, via test_fire_tracker_phase2.py +
test_firms_native_fusion.py's TestIngestGrowth, both driving the live
ingest_hotspot_pixel entrypoint).
Collection: 2010 -> 1974 tests (net -36: dead-only tests deleted, live
behavior rewired 1:1 or consolidated onto already-existing native-path
coverage). Full suite: 1974 passed, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
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
6050392d09
commit
67176d66d5
14 changed files with 629 additions and 2026 deletions
333
work/meshai/env/fire_fusion.py
vendored
333
work/meshai/env/fire_fusion.py
vendored
|
|
@ -1,73 +1,29 @@
|
|||
"""v0.7-fire-tracker-3 FIRMS handler -- storage + attribution + cluster + growth/halt/spotting.
|
||||
"""v0.7-fire-tracker-3 FIRMS handler -- attribution + cluster + growth/halt/spotting.
|
||||
|
||||
Relocated from `meshai.central.firms_handler` during the Central ripout
|
||||
(central/ handler retirement, chore/ripout-2d). ``ingest_hotspot_pixel`` is
|
||||
the LIVE fire-fusion engine with one consumer: the native FIRMS adapter
|
||||
(`meshai.env.firms` -> `_run_fusion`). ``handle_firms`` (the Central
|
||||
NATS-envelope entrypoint) has no live production caller -- Central's
|
||||
consumer that drove it is gone -- but it remains the parity-tested legacy
|
||||
contract (see `tests/test_firms_handler.py`, `tests/test_firms_refactor.py`,
|
||||
`tests/test_fire_tracker_phase1/2/3.py`) -- kept verbatim, not deleted, per
|
||||
that coverage.
|
||||
(`meshai.env.firms` -> `_run_fusion`).
|
||||
|
||||
Pre-v0.6-1 the v0.5.13 default-deny gate at consumer._normalize() silently
|
||||
dropped every `central.fire.hotspot.>` envelope because no per-adapter handler
|
||||
existed (audit doc v0.6-phase1-audit.md finding #2). The `firms_pixels`
|
||||
table was created in v0.5.8b (v1.sql:98-111) and has been empty ever since.
|
||||
chore/ripout-2dii: the dead Central NATS-envelope entrypoint ``handle_firms``
|
||||
(envelope field-extraction, confidence/FRP/bbox filtering, event_log
|
||||
accounting) has been REMOVED -- it had zero live production callers (Central's
|
||||
consumer that drove it is gone) and its own parity tests (`test_firms_handler.py`,
|
||||
`TestCentralPathUnchanged` in `test_firms_native_fusion.py`). The shared,
|
||||
LIVE core (`_ingest_pixel_core`, attribution, clustering, growth/spotting/halt
|
||||
fusion) is unaffected and remains fully covered via `ingest_hotspot_pixel`
|
||||
(see `test_firms_native_fusion.py`, `test_fire_tracker_phase1/2/3.py`).
|
||||
|
||||
This handler closes that gap: every passing FIRMS pixel lands in
|
||||
`firms_pixels`. No mesh broadcasts are emitted -- FIRMS data is for the
|
||||
LLM context (commit #5: env_reporter) and for the v0.6 fire-tracker
|
||||
fusion (per v0.6-design-fire-tracker.md). Returning None from the handler
|
||||
tells the consumer's default-deny clause "no broadcast", which is exactly
|
||||
the v0.6-1 contract (memory rule 19).
|
||||
|
||||
Subject pattern (Central v0.10.0):
|
||||
Subject pattern (Central v0.10.0, historical -- kept for context):
|
||||
central.fire.hotspot.<satellite>.<confidence>.<region>
|
||||
where <region> is `us.<state>` or `unknown`.
|
||||
|
||||
Envelope shape (from firms-investigation.md, 250 envelopes 2026-05-28..06-04):
|
||||
envelope["data"]["adapter"] == "firms"
|
||||
envelope["data"]["data"]:
|
||||
latitude (REAL)
|
||||
longitude (REAL)
|
||||
frp (REAL, MW -- fire radiative power)
|
||||
bright_ti4 (REAL, K -- VIIRS brightness temperature)
|
||||
bright_ti5 (REAL, K, optional)
|
||||
satellite ("N" Suomi-NPP | "N20" NOAA-20)
|
||||
instrument ("VIIRS" so far; MODIS would extend this)
|
||||
confidence ("nominal" | "high" | "low")
|
||||
acq_date ("YYYY-MM-DD" UTC)
|
||||
acq_time ("HHMM" UTC, 4-digit)
|
||||
daynight ("D" | "N")
|
||||
version (str)
|
||||
_enriched.geocoder.city/state/county/landclass/elevation_m
|
||||
|
||||
Filtering (hardcoded defaults; commit #3 migrates these to adapter_config
|
||||
GUI rows per Rule 17. Per Matt's lock: defaults become GUI default values
|
||||
with no behavior change on first deploy.):
|
||||
|
||||
FIRMS_CONFIDENCE_FLOOR = "low" -- rank-based; "low" = store every conf
|
||||
FIRMS_FRP_FLOOR = 0.0 -- 0 = store every FRP value
|
||||
FIRMS_BBOX_OPTIONAL = None -- None = no spatial filter
|
||||
|
||||
Permissive defaults are intentional: storage is cheap and v0.6 fire-tracker
|
||||
fusion (FIRMS + WFIGS) needs the full pixel stream to detect unattributed
|
||||
clusters early. Query-time filtering happens in env_reporter (commit #5).
|
||||
|
||||
Dedup:
|
||||
Unique partial index added in v4.sql on
|
||||
(round(lat,5), round(lon,5), acq_time, satellite)
|
||||
Same satellite pixel observation re-published via NATS reconnect /
|
||||
JetStream replay is a no-op INSERT OR IGNORE. 5 decimals on lat/lon
|
||||
is ~1.1 m precision -- well inside VIIRS' 375 m pixel.
|
||||
|
||||
event_log accounting:
|
||||
handled=1 -> row inserted into firms_pixels (or dedup-hit -- still
|
||||
"successfully handled" semantically: we know about it)
|
||||
handled=0 -> dropped (missing coords / outside bbox / below conf
|
||||
floor / below FRP floor / missing acq timestamp).
|
||||
Category is suffixed with "|<reason>" for grep.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from meshai.adapter_config import adapter_config
|
||||
|
|
@ -75,7 +31,6 @@ from meshai.adapter_config import adapter_config
|
|||
import json
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
|
|
@ -86,175 +41,8 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
# ============================================================================
|
||||
# v0.6-3b: all four settings now live in adapter_config.firms. Module-level
|
||||
# names retained as backward-compat aliases for test monkeypatches; the
|
||||
# handler reads via adapter_config so a GUI edit takes effect on the next
|
||||
# envelope without restart.
|
||||
# ============================================================================
|
||||
|
||||
# VIIRS-FIRMS confidence rank table (CODE -- NOAA-defined vocabulary).
|
||||
_CONFIDENCE_RANK = {"low": 0, "nominal": 1, "high": 2}
|
||||
|
||||
# Back-compat aliases for tests that import these names. New code should
|
||||
# read via adapter_config.firms.<key>.
|
||||
FIRMS_CONFIDENCE_FLOOR = "low"
|
||||
FIRMS_FRP_FLOOR = 0.0
|
||||
FIRMS_BBOX_OPTIONAL: Optional[tuple[float, float, float, float]] = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Public entry point
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def handle_firms(envelope: dict, subject: str,
|
||||
data: Optional[dict] = None,
|
||||
now: Optional[int] = None) -> Optional[str]:
|
||||
"""Storage-only FIRMS handler. ALWAYS returns None.
|
||||
|
||||
Args:
|
||||
envelope: CloudEvents envelope from the Central consumer.
|
||||
subject: NATS subject (`central.fire.hotspot.<sat>.<conf>.<region>`).
|
||||
data: Mutable Event.data dict (unused -- no broadcast attached).
|
||||
now: Override current epoch for tests.
|
||||
|
||||
Returns:
|
||||
None unconditionally. The v0.5.13 default-deny clause at
|
||||
consumer._normalize() interprets None as "no broadcast", which is
|
||||
the desired contract for storage-only adapters.
|
||||
"""
|
||||
if not isinstance(envelope, dict):
|
||||
return None
|
||||
inner = envelope.get("data") or {}
|
||||
if (inner.get("adapter") or "") != "firms":
|
||||
return None
|
||||
|
||||
d = inner.get("data") or {}
|
||||
now = now if now is not None else int(time.time())
|
||||
category_raw = inner.get("category") or ""
|
||||
severity_word = _coerce_severity(inner.get("severity"))
|
||||
event_id_external = inner.get("id")
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
except Exception:
|
||||
logger.exception("firms_handler: persistence unavailable; dropping")
|
||||
return None
|
||||
|
||||
# ---- field extraction + validation -----------------------------------
|
||||
|
||||
lat = d.get("latitude")
|
||||
lon = d.get("longitude")
|
||||
if not (isinstance(lat, (int, float)) and isinstance(lon, (int, float))):
|
||||
_log_event(conn, now=now, source="firms",
|
||||
category=category_raw + "|missing_coords",
|
||||
severity_word=severity_word,
|
||||
event_id_external=event_id_external,
|
||||
subject=subject, handled=0,
|
||||
table_name=None, table_pk=None)
|
||||
return None
|
||||
lat = float(lat); lon = float(lon)
|
||||
|
||||
# ---- filter: bbox (optional) -----------------------------------------
|
||||
|
||||
if not _in_bbox(lat, lon):
|
||||
_log_event(conn, now=now, source="firms",
|
||||
category=category_raw + "|outside_bbox",
|
||||
severity_word=severity_word,
|
||||
event_id_external=event_id_external,
|
||||
subject=subject, handled=0,
|
||||
table_name=None, table_pk=None)
|
||||
return None
|
||||
|
||||
# ---- filter: confidence floor ----------------------------------------
|
||||
|
||||
conf = d.get("confidence")
|
||||
if not _confidence_passes(conf):
|
||||
_log_event(conn, now=now, source="firms",
|
||||
category=category_raw + "|below_confidence_floor",
|
||||
severity_word=severity_word,
|
||||
event_id_external=event_id_external,
|
||||
subject=subject, handled=0,
|
||||
table_name=None, table_pk=None)
|
||||
return None
|
||||
|
||||
# ---- filter: FRP floor (no-op when FIRMS_FRP_FLOOR <= 0) -------------
|
||||
|
||||
frp_raw = d.get("frp")
|
||||
try:
|
||||
frp = float(frp_raw) if frp_raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
frp = None
|
||||
import sys as _sys
|
||||
_this = _sys.modules[__name__]
|
||||
frp_floor = float(_this.FIRMS_FRP_FLOOR) if _this.FIRMS_FRP_FLOOR > 0 \
|
||||
else float(adapter_config.firms.frp_floor)
|
||||
if frp_floor > 0:
|
||||
if frp is None or frp < frp_floor:
|
||||
_log_event(conn, now=now, source="firms",
|
||||
category=category_raw + "|below_frp_floor",
|
||||
severity_word=severity_word,
|
||||
event_id_external=event_id_external,
|
||||
subject=subject, handled=0,
|
||||
table_name=None, table_pk=None)
|
||||
return None
|
||||
|
||||
# ---- acquisition timestamp (required for dedup key) ------------------
|
||||
|
||||
acq_epoch = _parse_acq_epoch(d.get("acq_date"), d.get("acq_time"))
|
||||
if acq_epoch is None:
|
||||
_log_event(conn, now=now, source="firms",
|
||||
category=category_raw + "|missing_acq_time",
|
||||
severity_word=severity_word,
|
||||
event_id_external=event_id_external,
|
||||
subject=subject, handled=0,
|
||||
table_name=None, table_pk=None)
|
||||
return None
|
||||
|
||||
# ---- persist + attribute + fuse (source-agnostic core) --------------
|
||||
|
||||
satellite = d.get("satellite") or ""
|
||||
brightness_raw = d.get("bright_ti4") if d.get("bright_ti4") is not None \
|
||||
else d.get("brightness")
|
||||
try:
|
||||
brightness = float(brightness_raw) if brightness_raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
brightness = None
|
||||
|
||||
# The INSERT-OR-IGNORE into firms_pixels + attribution + growth/spotting/
|
||||
# halt fusion now live in the shared _ingest_pixel_core so the native
|
||||
# env/firms.py adapter feeds the SAME engine. handle_firms keeps the
|
||||
# envelope-specific concerns (field extraction, filtering, event_log
|
||||
# accounting) here so the Central path stays byte-identical. `data` is the
|
||||
# consumer's mutable Event.data dict; core stamps it in place on a fusion
|
||||
# broadcast exactly as the old inline path did.
|
||||
stored, rowid, wire = _ingest_pixel_core(
|
||||
conn, lat=lat, lon=lon, acq_epoch=acq_epoch, frp=frp,
|
||||
confidence=conf, brightness=brightness, satellite=satellite,
|
||||
data=data, now=now,
|
||||
)
|
||||
|
||||
# event_log row regardless of dedup outcome -- both "stored" and
|
||||
# "dedup-hit" count as "handled" for accounting; the suffix tells them
|
||||
# apart for ops grep. (Written after the core call: event_log is a
|
||||
# distinct table with its own rowid sequence, so ordering it after the
|
||||
# attribution INSERTs leaves the row content + relative order identical.)
|
||||
cat_tag = category_raw if stored else category_raw + "|dedup_hit"
|
||||
_log_event(conn, now=now, source="firms", category=cat_tag,
|
||||
severity_word=severity_word,
|
||||
event_id_external=event_id_external,
|
||||
subject=subject, handled=1,
|
||||
table_name="firms_pixels" if stored else None,
|
||||
table_pk=(str(rowid) if stored else None))
|
||||
|
||||
# Dedup hits skip broadcast -- the original insert already had its chance.
|
||||
if not stored:
|
||||
return None
|
||||
return wire
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Source-agnostic pixel ingest (Central + native env/firms.py share this)
|
||||
# Source-agnostic pixel ingest (native env/firms.py; formerly also shared
|
||||
# with the now-deleted Central handle_firms entrypoint)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
|
|
@ -401,41 +189,6 @@ def ingest_hotspot_pixel(pixel: dict, *, now, seed=False) -> list[tuple[str, dic
|
|||
# ============================================================================
|
||||
|
||||
|
||||
def _confidence_passes(conf: Optional[str]) -> bool:
|
||||
"""Return True iff `conf` is at or above the configured floor.
|
||||
|
||||
v0.6-3b: floor read from adapter_config.firms.confidence_floor; the
|
||||
module-level FIRMS_CONFIDENCE_FLOOR still wins when explicitly
|
||||
monkeypatched (so existing tests stay one-line).
|
||||
"""
|
||||
if conf is None:
|
||||
return False
|
||||
rank = _CONFIDENCE_RANK.get(str(conf).lower())
|
||||
if rank is None:
|
||||
return False
|
||||
import sys
|
||||
_this = sys.modules[__name__]
|
||||
if _this.FIRMS_CONFIDENCE_FLOOR != "low":
|
||||
floor_str = _this.FIRMS_CONFIDENCE_FLOOR
|
||||
else:
|
||||
floor_str = str(adapter_config.firms.confidence_floor)
|
||||
floor = _CONFIDENCE_RANK.get(str(floor_str).lower(), 0)
|
||||
return rank >= floor
|
||||
|
||||
|
||||
def _in_bbox(lat: float, lon: float) -> bool:
|
||||
import sys
|
||||
_this = sys.modules[__name__]
|
||||
if _this.FIRMS_BBOX_OPTIONAL is not None:
|
||||
bbox = _this.FIRMS_BBOX_OPTIONAL
|
||||
else:
|
||||
bbox = adapter_config.firms.bbox
|
||||
if bbox is None:
|
||||
return True
|
||||
min_lat, min_lon, max_lat, max_lon = bbox
|
||||
return (min_lat <= lat <= max_lat) and (min_lon <= lon <= max_lon)
|
||||
|
||||
|
||||
def _parse_acq_epoch(date_s: Optional[str],
|
||||
time_s: Optional[Any]) -> Optional[int]:
|
||||
"""FIRMS publishes acq_date 'YYYY-MM-DD' + acq_time HHMM (UTC).
|
||||
|
|
@ -454,27 +207,6 @@ def _parse_acq_epoch(date_s: Optional[str],
|
|||
return None
|
||||
|
||||
|
||||
def _coerce_severity(sev: Any) -> Optional[str]:
|
||||
if sev is None: return None
|
||||
if isinstance(sev, str): return sev or None
|
||||
try: return str(int(sev))
|
||||
except (TypeError, ValueError): return str(sev)
|
||||
|
||||
|
||||
def _log_event(conn, *, now, source, category, severity_word,
|
||||
event_id_external, subject, handled,
|
||||
table_name, table_pk) -> None:
|
||||
"""event_log writer -- shape matches sibling handlers exactly."""
|
||||
conn.execute(
|
||||
"INSERT INTO event_log(received_at, source, category, severity_word, "
|
||||
"event_id_external, nats_subject, handled, table_name, table_pk) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(now, source, category, severity_word, event_id_external, subject,
|
||||
int(bool(handled)), table_name, table_pk),
|
||||
)
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# v0.7-fire-tracker-1: attribution + unattributed-cluster detection
|
||||
# ============================================================================
|
||||
|
|
@ -892,12 +624,12 @@ def _handle_pass_boundary(conn, *, irwin_id, pass_id, lat, lon,
|
|||
|
||||
# Phase-3c: the growth broadcast DECISION (pass boundary + drift threshold)
|
||||
# is delegated to gating.firms.decide; the wire is still rendered inline via
|
||||
# the WFIGS _render for byte-identity. The boundary predicate reproduces the
|
||||
# legacy guard (`last_pass_id == pass_id or last_pass_id is None or prev is
|
||||
# None` -> suppress) exactly. Growth has no latch, so there is nothing to
|
||||
# defer -- only the data stamping differs between the cutover and legacy
|
||||
# paths.
|
||||
from meshai.notifications.cutover import is_cutover
|
||||
# the WFIGS _render (the live renderer for wildfire_growth -- see
|
||||
# notifications/renderers/composer.py's `_meshai_precomposed` bypass;
|
||||
# wildfire_growth is not in NATIVE_ALWAYS_DECIDE and the cutover formatter
|
||||
# path was never invoked live, so it was removed -- chore/ripout-2dii).
|
||||
# The boundary predicate reproduces the legacy guard (`last_pass_id ==
|
||||
# pass_id or last_pass_id is None or prev is None` -> suppress) exactly.
|
||||
from meshai.notifications.gating.firms import decide as _firms_decide
|
||||
|
||||
boundary_growth = ((last_pass_id != pass_id) and (last_pass_id is not None)
|
||||
|
|
@ -934,25 +666,14 @@ def _handle_pass_boundary(conn, *, irwin_id, pass_id, lat, lon,
|
|||
normalized = dict(fire)
|
||||
normalized["irwin_id"] = irwin_id
|
||||
if isinstance(data, dict):
|
||||
if is_cutover("wildfire_growth"):
|
||||
# NEW PATH: fire formatter re-renders from these hints. Feed ONLY the
|
||||
# fields _render actually reads on the growth path (incident_name,
|
||||
# lat/lon for the anchor, movement, is_update) so the wire matches.
|
||||
data.update(gate.data_patch)
|
||||
data["incident_name"] = fire["incident_name"]
|
||||
data["lat"] = fire["lat"]
|
||||
data["lon"] = fire["lon"]
|
||||
data["_broadcast_audit"] = {"table": "fires", "pk": irwin_id}
|
||||
_raw_commit = gate.commit
|
||||
if _raw_commit is not None:
|
||||
def _on_commit(committed_at: float) -> None:
|
||||
_raw_commit(committed_at)
|
||||
data["_on_broadcast_committed"] = _on_commit
|
||||
else:
|
||||
# LEGACY verbatim (byte-for-byte identical live output).
|
||||
data["category"] = "wildfire_growth"
|
||||
data["_severity_override"] = "immediate"
|
||||
data["_cooldown_suffix"] = irwin_id
|
||||
# wildfire_growth's only live render path (byte-for-byte identical
|
||||
# live output). The cutover NEW-PATH (fire formatter re-render) was
|
||||
# removed -- wildfire_growth is not in NATIVE_ALWAYS_DECIDE and events
|
||||
# are precomposed, so the formatter was never invoked live for this
|
||||
# category (chore/ripout-2dii).
|
||||
data["category"] = "wildfire_growth"
|
||||
data["_severity_override"] = "immediate"
|
||||
data["_cooldown_suffix"] = irwin_id
|
||||
return _render(normalized, prefix="Update", movement=movement)
|
||||
|
||||
|
||||
|
|
|
|||
341
work/meshai/env/fire_render.py
vendored
341
work/meshai/env/fire_render.py
vendored
|
|
@ -1,36 +1,21 @@
|
|||
"""WFIGS handler: persistence-backed change-detection + wire renderer.
|
||||
"""WFIGS wire renderer + shared fire helpers.
|
||||
|
||||
Relocated from `meshai.central.wfigs_handler` during the Central ripout
|
||||
(central/ handler retirement, chore/ripout-2d). ``handle_wfigs`` has no live
|
||||
production caller (Central's NATS consumer that drove it is gone), but it
|
||||
remains the parity-tested legacy contract for the WFIGS wildfire wire format
|
||||
(see `tests/test_fire_refactor.py`, `tests/test_wfigs_handler.py`) -- kept
|
||||
verbatim, not deleted, per that oracle. ``_render`` IS live: it is imported
|
||||
by `meshai.env.fire_fusion._handle_pass_boundary` on the FIRMS growth path
|
||||
(central/ handler retirement, chore/ripout-2d).
|
||||
|
||||
chore/ripout-2dii: the dead Central NATS-envelope entrypoint ``handle_wfigs``
|
||||
(the New:/Update:/tombstone dispatch + event_log accounting + the
|
||||
`is_cutover` legacy-vs-new branch) has been REMOVED -- it had zero live
|
||||
production callers (Central's consumer that drove it is gone). ``_render``
|
||||
IS live: it is called directly by
|
||||
`meshai.env.fire_fusion._handle_pass_boundary` on the FIRMS growth path
|
||||
(`env/firms.py` -> `ingest_hotspot_pixel` -> ... -> `_handle_pass_boundary`
|
||||
-> `_render`) to render the `wildfire_growth` wire.
|
||||
|
||||
v0.5.8b refactor: New: vs Update: decision now keys on `last_broadcast_at`,
|
||||
not on row existence. Cold-start scenarios where the dispatcher drops the
|
||||
broadcast (cold-start grace, stale filter, cooldown, dedup) leave the fires
|
||||
row with NULL last_broadcast_at, so the NEXT successful broadcast still
|
||||
gets the "New:" prefix -- it really is the first delivery for that fire.
|
||||
|
||||
Cases (resolved at handler entry):
|
||||
(i) row missing -> INSERT, prefix="New", return wire
|
||||
(ii) row exists, last_broadcast_at IS NULL
|
||||
-> UPDATE current_*, prefix="New",
|
||||
return wire (never broadcast yet)
|
||||
(iii) row exists, last_broadcast_at NOT NULL
|
||||
-> UPDATE current_*, gate on change +
|
||||
8h cooldown. If pass: prefix="Update",
|
||||
return wire; else return None.
|
||||
|
||||
The last_broadcast_* UPDATE has moved OUT of the handler and INTO a callback
|
||||
attached to event.data["_on_broadcast_committed"]. The dispatcher calls it
|
||||
ONLY after a successful broadcast. The mesh_broadcasts_out audit row is now
|
||||
inserted by the dispatcher (via event.data["_broadcast_audit"]) for the same
|
||||
reason -- it should only exist for actually-delivered broadcasts.
|
||||
-> `_render`) to render the `wildfire_growth` wire, and is used as a
|
||||
byte-identity oracle by `tests/test_fire_refactor.py` /
|
||||
`tests/test_wfigs_handler.py` against the shared, LIVE fire formatter
|
||||
(`notifications/formatters/fire.py`, reached for `wildfire_declared` /
|
||||
`wildfire_incident` via the native WFIGS adapter `env/fires.py` ->
|
||||
`env/store.py::_emit_event` -> `gating.fire.decide`).
|
||||
|
||||
Concurrency: each consumer thread gets its own SQLite connection via
|
||||
meshai.persistence.get_db() (threading.local pool). Writes are serial
|
||||
|
|
@ -44,7 +29,7 @@ from meshai.notifications.formatters._budget import budget_for, fit_to_budget
|
|||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from typing import Optional
|
||||
|
||||
from meshai.notifications import clock
|
||||
|
||||
|
|
@ -120,247 +105,18 @@ def _build_canonical(normalized: dict, kind: str) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def handle_wfigs(normalized: dict, envelope: dict, subject: str,
|
||||
data: Optional[dict] = None,
|
||||
now: Optional[int] = None) -> Optional[str]:
|
||||
"""Route a normalized WFIGS dict through persistence + change-detection.
|
||||
|
||||
Phase-3b refactor: the broadcast DECISION (New/Update/suppress + cooldown +
|
||||
age-gate + all-clear eligibility) now lives in
|
||||
``meshai.notifications.gating.fire.decide``. This handler keeps ownership
|
||||
of the inline ``fires`` INSERT/UPDATE of ``current_*`` (unconditional state
|
||||
write), the ``tombstoned_at`` stamp, the ``event_log`` row + handled flip,
|
||||
and the wire it returns (mirror quake/nwis).
|
||||
|
||||
`data` is the mutable dict the caller (consumer._normalize) is composing
|
||||
into the Event. When a broadcast should fire, the handler attaches an
|
||||
`_on_broadcast_committed` callback and `_broadcast_audit` descriptor to
|
||||
it; the dispatcher invokes both AFTER a successful deliver().
|
||||
|
||||
Cutover gate: when the emitted category is explicitly cut over, the handler
|
||||
writes ``gate.data_patch`` + wraps ``gate.commit`` (new path live);
|
||||
otherwise it keeps the legacy ``_attach_commit_handles`` / all-clear
|
||||
stamping VERBATIM so the live broadcast stays byte-for-byte identical while
|
||||
the new formatter+decider bake in shadow.
|
||||
|
||||
Returns a wire string when a broadcast should fire, None otherwise.
|
||||
"""
|
||||
if not isinstance(normalized, dict):
|
||||
return None
|
||||
kind = normalized.get("_kind")
|
||||
if kind not in ("wfigs_incident", "wfigs_tombstone", "wfigs_perimeter"):
|
||||
return None
|
||||
|
||||
now = now if now is not None else _now()
|
||||
inner = envelope.get("data") or {} if isinstance(envelope, dict) else {}
|
||||
category = inner.get("category") or ""
|
||||
severity_word = _coerce_severity(inner.get("severity"))
|
||||
irwin_id = normalized.get("irwin_id")
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
except Exception:
|
||||
logger.exception("wfigs_handler: persistence unavailable; "
|
||||
"deferring to default pipeline")
|
||||
return None
|
||||
|
||||
from meshai.notifications.cutover import is_cutover
|
||||
from meshai.notifications.gating.fire import decide as _gate_decide
|
||||
|
||||
canonical = _build_canonical(normalized, kind)
|
||||
|
||||
if kind in ("wfigs_tombstone", "wfigs_perimeter"):
|
||||
source = "wfigs_incidents" if kind == "wfigs_tombstone" else "wfigs_perimeters"
|
||||
log_id = _log_event_returning_id(
|
||||
conn, now=now, source=source, category=category,
|
||||
severity_word=severity_word, irwin_id=irwin_id,
|
||||
subject=subject, handled=0,
|
||||
table_name=None, table_pk=irwin_id)
|
||||
# v0.6-tail item 4: tombstone branch stamps fires.tombstoned_at so
|
||||
# the ReminderScheduler stops re-broadcasting the closed fire.
|
||||
# Only the tombstone kind closes the fire; perimeter polls don t.
|
||||
# UNCONDITIONAL state write — stays inline, mirror the original.
|
||||
if kind == "wfigs_tombstone" and irwin_id:
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE fires SET tombstoned_at=COALESCE(tombstoned_at, ?) "
|
||||
"WHERE irwin_id=?",
|
||||
(now, irwin_id),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("wfigs: tombstoned_at stamp failed irwin=%s", irwin_id)
|
||||
|
||||
# All-clear broadcast: only fires that previously made it to mesh
|
||||
# get a closure message. Silent for fires that were never broadcast.
|
||||
# The DECISION (row exists AND last_broadcast_at NOT NULL) is delegated
|
||||
# to the decider; the WIRE is still rendered here from the fire row for
|
||||
# byte-identity, and the not-cutover data stamping stays verbatim.
|
||||
if kind == "wfigs_tombstone" and irwin_id:
|
||||
gate = _gate_decide(canonical, source="wfigs", now=float(now))
|
||||
if gate.broadcast:
|
||||
fire_row = conn.execute(
|
||||
"SELECT incident_name, current_acres, current_contained_pct, "
|
||||
"last_broadcast_at, county, state, lat, lon "
|
||||
"FROM fires WHERE irwin_id = ?", (irwin_id,)
|
||||
).fetchone()
|
||||
name = fire_row["incident_name"] or "(unnamed fire)"
|
||||
# Build line 2 parts
|
||||
parts = []
|
||||
if fire_row["current_acres"] is not None:
|
||||
parts.append(f"{int(fire_row['current_acres']):,} ac")
|
||||
if fire_row["current_contained_pct"] is not None:
|
||||
parts.append(f"{int(fire_row['current_contained_pct'])}% contained")
|
||||
# Location via _location_anchor with a minimal normalized dict
|
||||
loc_dict = {
|
||||
"lat": fire_row["lat"], "lon": fire_row["lon"],
|
||||
"county": fire_row["county"], "state": fire_row["state"],
|
||||
}
|
||||
anchor = _location_anchor(loc_dict)
|
||||
if anchor and anchor != "(location unknown)":
|
||||
parts.append(anchor)
|
||||
lines = [f"✅ {name} — contained & closed"]
|
||||
if parts:
|
||||
lines.append(" | ".join(parts))
|
||||
wire = "\n".join(lines)
|
||||
if isinstance(data, dict):
|
||||
if is_cutover("wildfire_closed"):
|
||||
# NEW PATH: formatter re-renders from data_patch fields.
|
||||
data.update(gate.data_patch)
|
||||
data["_broadcast_audit"] = {"table": "fires", "pk": irwin_id}
|
||||
_raw_commit = gate.commit
|
||||
_log_row_id = log_id
|
||||
|
||||
def _on_commit(committed_at: float) -> None:
|
||||
if _raw_commit is not None:
|
||||
_raw_commit(committed_at)
|
||||
if _log_row_id is not None:
|
||||
try:
|
||||
get_db().execute(
|
||||
"UPDATE event_log SET handled=1 WHERE id=?",
|
||||
(int(_log_row_id),))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"wfigs closed commit: event_log update failed")
|
||||
|
||||
data["_on_broadcast_committed"] = _on_commit
|
||||
else:
|
||||
# LEGACY verbatim (byte-for-byte identical live output).
|
||||
data["category"] = "wildfire_closed"
|
||||
data["_severity_override"] = "priority"
|
||||
_attach_commit_handles(
|
||||
data, irwin_id=irwin_id,
|
||||
acres=fire_row["current_acres"],
|
||||
contained_pct=fire_row["current_contained_pct"],
|
||||
event_log_row_id=log_id)
|
||||
data["_dedup_suffix"] = "closed"
|
||||
return wire
|
||||
|
||||
return None
|
||||
|
||||
# ---- active incident ----
|
||||
# v0.5.8b: log handled=0 initially. The commit callback UPDATEs this
|
||||
# row to handled=1 if/when the dispatcher actually broadcasts -- if it
|
||||
# drops (cold-start grace, staleness, cooldown, dedup), the row stays
|
||||
# handled=0 and we can grep the event_log to find the suppressed events.
|
||||
log_id = _log_event_returning_id(
|
||||
conn, now=now, source="wfigs_incidents", category=category,
|
||||
severity_word=severity_word, irwin_id=irwin_id,
|
||||
subject=subject, handled=0,
|
||||
table_name="fires", table_pk=irwin_id)
|
||||
|
||||
acres = normalized.get("acres")
|
||||
contained_pct = normalized.get("contained_pct")
|
||||
|
||||
# Delegate the New/Update/suppress + age-gate + cooldown decision. The
|
||||
# decider READS the fires row (pre-write) exactly as the original inline
|
||||
# branch did; the inline INSERT/UPDATE below stays handler-owned.
|
||||
gate = _gate_decide(canonical, source="wfigs", now=float(now))
|
||||
|
||||
# ---- inline state write (UNCONDITIONAL) -- INSERT or UPDATE current_*.
|
||||
# Re-read the row (same pre-write state the decider saw) to pick the write.
|
||||
row = conn.execute(
|
||||
"SELECT last_broadcast_at FROM fires WHERE irwin_id = ?",
|
||||
(irwin_id,)).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"INSERT INTO fires(irwin_id, incident_name, incident_type, "
|
||||
"current_acres, current_contained_pct, status, lat, lon, "
|
||||
"county, state, landclass, declared_at, last_event_at, "
|
||||
"last_broadcast_at, last_broadcast_acres, last_broadcast_contained) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
irwin_id,
|
||||
normalized.get("incident_name"),
|
||||
normalized.get("incident_type"),
|
||||
acres, contained_pct,
|
||||
None, # status reserved
|
||||
normalized.get("lat"), normalized.get("lon"),
|
||||
normalized.get("county"), normalized.get("state"),
|
||||
normalized.get("landclass"),
|
||||
normalized.get("declared_at_epoch"),
|
||||
now, # last_event_at
|
||||
None, None, None, # last_broadcast_* explicitly NULL
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE fires SET current_acres=?, current_contained_pct=?, "
|
||||
"lat=COALESCE(?, lat), lon=COALESCE(?, lon), last_event_at=? "
|
||||
"WHERE irwin_id=?",
|
||||
(acres, contained_pct, normalized.get("lat"),
|
||||
normalized.get("lon"), now, irwin_id),
|
||||
)
|
||||
|
||||
if not gate.broadcast:
|
||||
# Case-(iii) suppress (no forward change OR inside cooldown) ran the
|
||||
# stale-fire cleanup in the original; the age-gate suppress did not.
|
||||
if gate.lifecycle == "cooldown":
|
||||
_cleanup_stale_fires(conn)
|
||||
return None
|
||||
|
||||
# ---- broadcast: render the wire (back-compat) + stamp data.
|
||||
prefix = "Update" if gate.lifecycle == "update" else "New"
|
||||
wire = _render(normalized, prefix=prefix,
|
||||
last_bcast_acres=gate.data_patch.get("last_bcast_acres"),
|
||||
last_bcast_contained=gate.data_patch.get("last_bcast_contained"))
|
||||
|
||||
# Cutover gate keys on the category THIS broadcast carries: New first-sight
|
||||
# is wildfire_declared, growth Update stays wildfire_incident.
|
||||
_cut = (is_cutover("wildfire_incident") if gate.lifecycle == "update"
|
||||
else is_cutover("wildfire_declared"))
|
||||
if isinstance(data, dict):
|
||||
if _cut:
|
||||
# NEW PATH: formatter re-renders from canonical + data_patch.
|
||||
data.update(canonical)
|
||||
data.update(gate.data_patch)
|
||||
data["_broadcast_audit"] = {"table": "fires", "pk": irwin_id}
|
||||
_raw_commit = gate.commit
|
||||
_log_row_id = log_id
|
||||
|
||||
def _on_commit(committed_at: float) -> None:
|
||||
if _raw_commit is not None:
|
||||
_raw_commit(committed_at)
|
||||
if _log_row_id is not None:
|
||||
try:
|
||||
get_db().execute(
|
||||
"UPDATE event_log SET handled=1 WHERE id=?",
|
||||
(int(_log_row_id),))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"wfigs commit: event_log update failed")
|
||||
|
||||
data["_on_broadcast_committed"] = _on_commit
|
||||
else:
|
||||
# LEGACY verbatim (byte-for-byte identical live output).
|
||||
# New first-sight tags wildfire_declared; Update keeps the
|
||||
# envelope-derived wildfire_incident (no category override).
|
||||
if gate.lifecycle != "update":
|
||||
data["category"] = "wildfire_declared"
|
||||
data["_severity_override"] = "priority"
|
||||
_attach_commit_handles(data, irwin_id=irwin_id,
|
||||
acres=acres, contained_pct=contained_pct,
|
||||
event_log_row_id=log_id)
|
||||
return wire
|
||||
# chore/ripout-2dii: the dead Central NATS-envelope entrypoint ``handle_wfigs``
|
||||
# (envelope-driven New/Update/tombstone dispatch, event_log accounting, the
|
||||
# `is_cutover` legacy-vs-new branch) has been REMOVED -- it had zero live
|
||||
# production callers (Central's consumer that drove it is gone). The LIVE
|
||||
# WFIGS path is `env/fires.py` (native adapter) -> `env/store.py::_emit_event`
|
||||
# (forced onto `gating.fire.decide` + the shared fire formatter via
|
||||
# `cutover.NATIVE_ALWAYS_DECIDE`, independent of this dead handler) -- see
|
||||
# `tests/test_fire_native_growth.py`. `_render` (below) remains LIVE: it is
|
||||
# called directly by `env.fire_fusion._handle_pass_boundary` on the FIRMS
|
||||
# `wildfire_growth` path, and by `tests/test_fire_refactor.py` /
|
||||
# `tests/test_wfigs_handler.py` as a byte-identity oracle for the shared fire
|
||||
# formatter (`notifications/formatters/fire.py`).
|
||||
|
||||
|
||||
# ---------- commit-callback factory ---------------------------------------
|
||||
|
|
@ -419,47 +175,6 @@ def _attach_commit_handles(data: Optional[dict], *, irwin_id: str,
|
|||
data["_dedup_suffix"] = f"{acres}|{contained_pct}"
|
||||
|
||||
|
||||
# ---------- helpers -------------------------------------------------------
|
||||
|
||||
|
||||
def _coerce_severity(sev: Any) -> Optional[str]:
|
||||
if sev is None: return None
|
||||
if isinstance(sev, str): return sev or None
|
||||
try: return str(int(sev))
|
||||
except (TypeError, ValueError): return str(sev)
|
||||
|
||||
|
||||
def _log_event(conn, *, now, source, category, severity_word, irwin_id,
|
||||
subject, handled, table_name, table_pk) -> None:
|
||||
"""Insert an event_log row; void return (used for tombstones/perimeters
|
||||
where the handled flag is fixed at write-time)."""
|
||||
conn.execute(
|
||||
"INSERT INTO event_log(received_at, source, category, severity_word, "
|
||||
"event_id_external, nats_subject, handled, table_name, table_pk) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(now, source, category, severity_word, irwin_id, subject,
|
||||
int(bool(handled)), table_name, table_pk),
|
||||
)
|
||||
|
||||
|
||||
def _log_event_returning_id(conn, *, now, source, category, severity_word,
|
||||
irwin_id, subject, handled, table_name,
|
||||
table_pk) -> int:
|
||||
"""Insert an event_log row and return its primary key id.
|
||||
|
||||
Used for active-incident logging where the commit callback updates
|
||||
the same row to handled=1 once a broadcast actually goes out.
|
||||
"""
|
||||
cur = conn.execute(
|
||||
"INSERT INTO event_log(received_at, source, category, severity_word, "
|
||||
"event_id_external, nats_subject, handled, table_name, table_pk) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(now, source, category, severity_word, irwin_id, subject,
|
||||
int(bool(handled)), table_name, table_pk),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
# ---------- renderer ------------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -122,19 +122,19 @@ register("wildfire_declared", _fire_fmt_mod.format)
|
|||
register("wildfire_incident", _fire_fmt_mod.format)
|
||||
register("wildfire_closed", _fire_fmt_mod.format)
|
||||
|
||||
# Phase-3c: FIRMS fusion broadcasts. Three categories the firms_handler emits,
|
||||
# all under toggle "fire". `wildfire_growth` REUSES the fire formatter — its
|
||||
# wire is the WFIGS incident render with a movement dict, and feeding the fire
|
||||
# formatter only {incident_name, is_update, movement, lat, lon} reproduces the
|
||||
# legacy _render() call byte-for-byte (the growth SELECT's current_* / declared_at
|
||||
# columns are NOT read by _render, so size/containment/date render as unknown/
|
||||
# absent — matched here by omitting those fields). `wildfire_spotting` and
|
||||
# `wildfire_halted` have their own terse wires -> formatters/firms.py. Registered
|
||||
# under the explicit strings (not the "fire" toggle) so the still-deferred FIRMS
|
||||
# native categories (wildfire_hotspot / new_ignition / unattributed_hotspot_cluster)
|
||||
# are NOT captured by the family fallback. NOT cut over this phase.
|
||||
register("wildfire_growth", _fire_fmt_mod.format)
|
||||
|
||||
# Phase-3c: FIRMS fusion broadcasts. `wildfire_spotting` / `wildfire_halted`
|
||||
# have their own terse wires -> formatters/firms.py, registered below.
|
||||
# `wildfire_growth` is NOT registered here (chore/ripout-2dii): its events are
|
||||
# always fully precomposed (env/firms.py sets `_meshai_precomposed=True` +
|
||||
# `title=<wire>`), and it is not in `cutover.NATIVE_ALWAYS_DECIDE`, so
|
||||
# compose_mesh_message's formatter-invocation branch
|
||||
# (notifications/renderers/composer.py:343-347) can never reach a registered
|
||||
# formatter for this category live -- the precomposed-title bypass always wins
|
||||
# first. wildfire_growth's live wire comes from `env.fire_render._render`,
|
||||
# called directly by `env.fire_fusion._handle_pass_boundary`. A prior
|
||||
# registration here (reusing the fire formatter) was dead code kept from the
|
||||
# Phase-3c migration bake and has been removed; see TestRegistration in
|
||||
# tests/test_firms_refactor.py for the regression guard.
|
||||
from meshai.notifications.formatters import firms as _firms_fmt_mod # noqa: E402,F401
|
||||
register("wildfire_spotting", _firms_fmt_mod.format)
|
||||
register("wildfire_halted", _firms_fmt_mod.format)
|
||||
|
|
|
|||
|
|
@ -99,14 +99,21 @@ def test_custom_knob_respected():
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler-path: New is suppressed, Update still emits.
|
||||
# Decider-path: New is suppressed, Update still emits.
|
||||
#
|
||||
# These exercise the real Case (i)/(ii)/(iii) paths in handle_wfigs against
|
||||
# the isolated tmp DB seeded by conftest.
|
||||
# These exercise the real Case (i)/(ii)/(iii) paths in gating.fire.decide()
|
||||
# (the LIVE decider) against the isolated tmp DB seeded by conftest.
|
||||
#
|
||||
# chore/ripout-2dii: previously drove these through handle_wfigs (the dead
|
||||
# Central NATS-envelope entrypoint); that entrypoint has been removed from
|
||||
# meshai.env.fire_render (zero live production callers). decide() is the
|
||||
# SAME decision logic the native WFIGS adapter (env/fires.py ->
|
||||
# env/store.py::_emit_event) uses live -- see tests/test_fire_native_growth.py
|
||||
# for full end-to-end coverage of the age-gate through that real entrypoint.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _normalized(*, irwin_id, declared_at_epoch, acres=250.0, contained=0):
|
||||
def _canonical(*, irwin_id, declared_at_epoch, acres=250.0, contained=0):
|
||||
return {
|
||||
"_kind": "wfigs_incident",
|
||||
"irwin_id": irwin_id,
|
||||
|
|
@ -120,58 +127,39 @@ def _normalized(*, irwin_id, declared_at_epoch, acres=250.0, contained=0):
|
|||
}
|
||||
|
||||
|
||||
def _envelope():
|
||||
return {
|
||||
"data": {"adapter": "wfigs", "category": "wildfire_incident",
|
||||
"severity": "priority"}
|
||||
}
|
||||
|
||||
|
||||
def test_handler_new_path_suppresses_old_fire():
|
||||
"""Case (i): a brand-new fire whose declared_at is ~45d old is INSERTed
|
||||
but the 'New' broadcast is suppressed (wire is None), and the New-path
|
||||
category tag is NOT applied."""
|
||||
from meshai.env.fire_render import handle_wfigs
|
||||
from meshai.persistence import get_db
|
||||
def test_decider_new_path_suppresses_old_fire():
|
||||
"""Case (i): a brand-new fire whose declared_at is ~45d old suppresses
|
||||
the 'New' broadcast (gate.broadcast is False), and the New-path category
|
||||
tag is NOT applied."""
|
||||
from meshai.notifications.gating.fire import decide as fire_decide
|
||||
|
||||
now = int(time.time())
|
||||
declared = now - _45D # OTR 11 style
|
||||
data = {}
|
||||
wire = handle_wfigs(_normalized(irwin_id="ID-OTR-11", declared_at_epoch=declared),
|
||||
_envelope(),
|
||||
subject="central.fire.incident.id",
|
||||
data=data, now=now)
|
||||
assert wire is None, f"old fire should be silenced, got wire={wire!r}"
|
||||
assert data.get("category") != "wildfire_declared"
|
||||
# The row is still INSERTed (so genuine future Updates work).
|
||||
row = get_db().execute(
|
||||
"SELECT irwin_id, last_broadcast_at FROM fires WHERE irwin_id=?",
|
||||
("ID-OTR-11",)).fetchone()
|
||||
assert row is not None
|
||||
assert row["last_broadcast_at"] is None
|
||||
gate = fire_decide(_canonical(irwin_id="ID-OTR-11", declared_at_epoch=declared),
|
||||
source="wfigs", now=float(now))
|
||||
assert gate.broadcast is False, f"old fire should be silenced, got {gate!r}"
|
||||
assert "category" not in gate.data_patch
|
||||
|
||||
|
||||
def test_handler_new_path_announces_recent_fire():
|
||||
def test_decider_new_path_announces_recent_fire():
|
||||
"""Case (i): a recent fire (~5d) DOES broadcast 'New' and tags
|
||||
wildfire_declared."""
|
||||
from meshai.env.fire_render import handle_wfigs
|
||||
from meshai.notifications.gating.fire import decide as fire_decide
|
||||
|
||||
now = int(time.time())
|
||||
declared = now - _5D
|
||||
data = {}
|
||||
wire = handle_wfigs(_normalized(irwin_id="ID-RECENT-1", declared_at_epoch=declared),
|
||||
_envelope(),
|
||||
subject="central.fire.incident.id",
|
||||
data=data, now=now)
|
||||
assert wire is not None and "New" in wire
|
||||
assert data.get("category") == "wildfire_declared"
|
||||
gate = fire_decide(_canonical(irwin_id="ID-RECENT-1", declared_at_epoch=declared),
|
||||
source="wfigs", now=float(now))
|
||||
assert gate.broadcast is True
|
||||
assert gate.lifecycle == "new"
|
||||
assert gate.data_patch.get("category") == "wildfire_declared"
|
||||
|
||||
|
||||
def test_handler_update_path_still_emits_for_old_fire():
|
||||
def test_decider_update_path_still_emits_for_old_fire():
|
||||
"""An already-broadcast OLD fire that grows acreage still emits an
|
||||
'Update' (Case (iii) is NOT gated -- genuine old-but-active fires keep
|
||||
getting containment/acreage updates)."""
|
||||
from meshai.env.fire_render import handle_wfigs
|
||||
from meshai.notifications.gating.fire import decide as fire_decide
|
||||
from meshai.persistence import get_db
|
||||
|
||||
now = int(time.time())
|
||||
|
|
@ -185,12 +173,10 @@ def test_handler_update_path_still_emits_for_old_fire():
|
|||
("ID-OLD-ACTIVE", "Old Town Road", 250.0, 0, 42.93, -114.45,
|
||||
declared, now - 30000, now - 30000, 250.0, 0),
|
||||
)
|
||||
data = {}
|
||||
wire = handle_wfigs(
|
||||
_normalized(irwin_id="ID-OLD-ACTIVE", declared_at_epoch=declared,
|
||||
acres=900.0, contained=20),
|
||||
_envelope(), subject="central.fire.incident.id",
|
||||
data=data, now=now)
|
||||
assert wire is not None and "Update" in wire, \
|
||||
f"old-but-active fire Update must still emit, got {wire!r}"
|
||||
assert data.get("category") != "wildfire_declared"
|
||||
gate = fire_decide(
|
||||
_canonical(irwin_id="ID-OLD-ACTIVE", declared_at_epoch=declared,
|
||||
acres=900.0, contained=20),
|
||||
source="wfigs", now=float(now))
|
||||
assert gate.broadcast is True and gate.lifecycle == "update", \
|
||||
f"old-but-active fire Update must still emit, got {gate!r}"
|
||||
assert "category" not in gate.data_patch
|
||||
|
|
|
|||
|
|
@ -8,14 +8,28 @@ hazard, mirroring test_hydro_refactor.py / test_quake_refactor.py:
|
|||
growth ((+delta) size line), a movement-line case, an anchor-line case, and
|
||||
the wildfire_closed all-clear.
|
||||
|
||||
2. Gate-sequence parity: an explicit `now`-timeline of WFIGS events driven
|
||||
through the NEW gating.fire.decide() matches the OLD handle_wfigs
|
||||
broadcast/suppress behavior AND the data-dict stamps
|
||||
(category / _severity_override / _dedup_suffix / _cooldown_suffix).
|
||||
2. Gate-sequence: an explicit `now`-timeline of WFIGS events driven through
|
||||
`gating.fire.decide()` (the LIVE decider -- see
|
||||
tests/test_fire_native_growth.py for its full end-to-end native-adapter
|
||||
coverage) exercises the New/cooldown/Update/closed lifecycle and the
|
||||
data-dict stamps (category / _severity_override / _dedup_suffix /
|
||||
_cooldown_suffix) `decide()` hands the dispatcher.
|
||||
|
||||
3. Registration: the three explicit categories the wfigs_handler emits
|
||||
(wildfire_declared / wildfire_incident / wildfire_closed) resolve to the
|
||||
fire formatter + decider, and FIRMS categories do NOT.
|
||||
3. Registration: the three explicit categories the WFIGS decider/formatter
|
||||
pair emits (wildfire_declared / wildfire_incident / wildfire_closed)
|
||||
resolve to the fire formatter + decider, and FIRMS categories do NOT.
|
||||
|
||||
chore/ripout-2dii: `handle_wfigs` (the dead Central NATS-envelope entrypoint
|
||||
this file used purely as a byte-identity driver) has been REMOVED from
|
||||
`meshai.env.fire_render` -- zero live production callers. `fire_format` IS
|
||||
live (reached for wildfire_declared/wildfire_incident via the native WFIGS
|
||||
adapter `env/fires.py` -> `env/store.py::_emit_event`, forced onto
|
||||
`gating.fire.decide` + this formatter via `cutover.NATIVE_ALWAYS_DECIDE`
|
||||
independent of any env var -- see `notifications/renderers/composer.py:343-347`
|
||||
and `tests/test_fire_native_growth.py`), so this file's fire_format coverage
|
||||
stays green; only the handle_wfigs-as-oracle plumbing was replaced with
|
||||
direct `_wfigs_render` calls (also live -- see env/fire_fusion.py's FIRMS
|
||||
growth path) and direct `fire_decide()` driving.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -25,7 +39,6 @@ from meshai.notifications.formatters._budget import budget_for
|
|||
from meshai.env.fire_render import (
|
||||
_build_canonical,
|
||||
_render as _wfigs_render,
|
||||
handle_wfigs,
|
||||
)
|
||||
from meshai.notifications.formatters.fire import format as fire_format
|
||||
from meshai.notifications.gating.fire import decide as fire_decide
|
||||
|
|
@ -70,6 +83,33 @@ class _FakeEvent:
|
|||
self.category = category
|
||||
|
||||
|
||||
def _write_fire_state(conn, *, irwin_id, name, acres, contained_pct,
|
||||
lat, lon, county, state, declared_at_epoch=None,
|
||||
now):
|
||||
"""Unconditional current_* state write, mirroring the LIVE native path's
|
||||
own upsert (env/store.py::_ingest_fires INSERT/UPDATE current_acres /
|
||||
current_contained_pct) -- the same shape handle_wfigs used to do inline
|
||||
before it was deleted (chore/ripout-2dii)."""
|
||||
row = conn.execute(
|
||||
"SELECT irwin_id FROM fires WHERE irwin_id=?", (irwin_id,)).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"INSERT INTO fires(irwin_id, incident_name, current_acres, "
|
||||
"current_contained_pct, lat, lon, county, state, declared_at, "
|
||||
"last_event_at, last_broadcast_at, last_broadcast_acres, "
|
||||
"last_broadcast_contained) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(irwin_id, name, acres, contained_pct, lat, lon, county, state,
|
||||
declared_at_epoch, now, None, None, None),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE fires SET current_acres=?, current_contained_pct=?, "
|
||||
"lat=COALESCE(?, lat), lon=COALESCE(?, lon), last_event_at=? "
|
||||
"WHERE irwin_id=?",
|
||||
(acres, contained_pct, lat, lon, now, irwin_id),
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1. Golden byte-identical — formatter reproduces _render()/all-clear exactly
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -145,92 +185,63 @@ class TestFormatterGolden:
|
|||
assert "Moving" not in new
|
||||
|
||||
def test_wildfire_closed_all_clear(self, mem_db):
|
||||
# Drive the real handler to produce the legacy all-clear wire, then
|
||||
# assert the formatter reproduces it byte-for-byte from event.data.
|
||||
env = _make_active_envelope(geocoder_city="Burley")
|
||||
n0 = _normalize_wfigs(env)
|
||||
data0 = {}
|
||||
handle_wfigs(n0, env, env["subject"], data=data0, now=1_000_000)
|
||||
data0["_on_broadcast_committed"](float(1_000_000)) # arm last_broadcast_*
|
||||
# Drive the LIVE tombstone decision (gating.fire.decide) directly --
|
||||
# handle_wfigs (the dead Central entrypoint that used to wrap this) is
|
||||
# gone (chore/ripout-2dii). State is written the same unconditional
|
||||
# shape the live native path uses (_write_fire_state).
|
||||
irwin_id = "IRWIN-CLOSED-1"
|
||||
_write_fire_state(
|
||||
mem_db, irwin_id=irwin_id, name="Cache Peak Fire",
|
||||
acres=1847.0, contained_pct=23, lat=42.197, lon=-113.710,
|
||||
county="Cassia", state="ID", now=1_000_000)
|
||||
gate0 = fire_decide(
|
||||
{"_kind": "wfigs_incident", "irwin_id": irwin_id,
|
||||
"incident_name": "Cache Peak Fire", "acres": 1847.0,
|
||||
"contained_pct": 23, "declared_at_epoch": None,
|
||||
"lat": 42.197, "lon": -113.710, "county": "Cassia", "state": "ID"},
|
||||
source="wfigs", now=1_000_000.0)
|
||||
assert gate0.broadcast is True
|
||||
gate0.commit(1_000_000.0) # arm last_broadcast_* (fire "reached mesh")
|
||||
|
||||
tomb = _make_tombstone()
|
||||
data_t = {}
|
||||
old_wire = handle_wfigs(_normalize_wfigs(tomb), tomb, tomb["subject"],
|
||||
data=data_t, now=2_000_000)
|
||||
assert old_wire is not None
|
||||
assert old_wire.startswith("✅ Cache Peak Fire — contained & closed")
|
||||
assert data_t["category"] == "wildfire_closed"
|
||||
gate_t = fire_decide(
|
||||
{"_kind": "wfigs_tombstone", "irwin_id": irwin_id},
|
||||
source="wfigs", now=2_000_000.0)
|
||||
assert gate_t.broadcast is True
|
||||
assert gate_t.data_patch["category"] == "wildfire_closed"
|
||||
assert gate_t.data_patch["incident_name"] == "Cache Peak Fire"
|
||||
|
||||
# Reconstruct the canonical fields the decider's data_patch supplies to
|
||||
# the formatter for the closed wire, and render.
|
||||
closed_data = {
|
||||
"category": "wildfire_closed",
|
||||
"incident_name": "Cache Peak Fire",
|
||||
"acres": 1847.0,
|
||||
"contained_pct": 23,
|
||||
"lat": env["data"]["data"]["latitude"],
|
||||
"lon": env["data"]["data"]["longitude"],
|
||||
"county": "Cassia",
|
||||
"state": "ID",
|
||||
}
|
||||
new_wire = fire_format(_FakeEvent(closed_data, category="wildfire_closed"),
|
||||
now=_AT, budget=budget_for("wfigs"))
|
||||
assert_byte_identical(new_wire, old_wire)
|
||||
new_wire = fire_format(
|
||||
_FakeEvent(gate_t.data_patch, category="wildfire_closed"),
|
||||
now=_AT, budget=budget_for("wfigs"))
|
||||
assert new_wire.startswith("✅ Cache Peak Fire — contained & closed")
|
||||
# Golden literal (captured from the live fire_format all-clear branch;
|
||||
# this is the SAME format string handle_wfigs used to build inline
|
||||
# before its removal -- see notifications/formatters/fire.py::_render_allclear).
|
||||
assert new_wire == "✅ Cache Peak Fire — contained & closed\n1,847 ac | 23% contained | 24 mi S of Burley"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2. Gate-sequence parity — new decide() vs old handle_wfigs across a lifecycle
|
||||
# 2. Gate-sequence — decide() drives New/cooldown/Update/closed
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestGateSequenceParity:
|
||||
"""A full fire lifecycle agrees between decide() and handle_wfigs()."""
|
||||
"""decide() correctly negotiates a full fire lifecycle end to end.
|
||||
|
||||
New/Update/cooldown/suppress behavior through the REAL native entrypoint
|
||||
(env/fires.py -> env/store.py -> compose_mesh_message) is covered more
|
||||
faithfully by tests/test_fire_native_growth.py; this class focuses on the
|
||||
parts that file doesn't reach -- the tombstone/closed lifecycle step (no
|
||||
live producer currently emits `_kind=wfigs_tombstone`, but the decider +
|
||||
formatter branch is live/shipped code and deserves regression coverage)
|
||||
-- plus a defense-in-depth pass over the whole sequence via decide()
|
||||
directly (no handle_wfigs; that entrypoint is gone).
|
||||
"""
|
||||
|
||||
def _decide(self, env, now):
|
||||
n = _normalize_wfigs(env)
|
||||
canonical = _build_canonical(n, n["_kind"])
|
||||
return fire_decide(canonical, source="wfigs", now=float(now))
|
||||
|
||||
def _step(self, env, now, *, expect_broadcast, expect_lifecycle):
|
||||
"""Assert decide() and handle_wfigs() agree at one timeline step.
|
||||
|
||||
decide() (called first) only READS state, so it sees the same pre-write
|
||||
row the handler's internal decide() sees. On broadcast the handler's
|
||||
legacy (not-cutover) stamps must equal decide()'s data_patch, and we arm
|
||||
last_broadcast_* via the commit callback (simulating the dispatcher).
|
||||
"""
|
||||
n = _normalize_wfigs(env)
|
||||
gate = self._decide(env, now)
|
||||
assert gate.broadcast is expect_broadcast, (
|
||||
f"decide broadcast {gate.broadcast} != {expect_broadcast} "
|
||||
f"@ {now} ({expect_lifecycle})")
|
||||
assert gate.lifecycle == expect_lifecycle, (
|
||||
f"decide lifecycle {gate.lifecycle} != {expect_lifecycle} @ {now}")
|
||||
|
||||
data = {}
|
||||
wire = handle_wfigs(n, env, env["subject"], data=data, now=now)
|
||||
assert (wire is not None) is expect_broadcast
|
||||
|
||||
if expect_broadcast:
|
||||
# Handler's stamped keys (legacy path) match decide()'s data_patch.
|
||||
for k in ("_severity_override", "_dedup_suffix", "_cooldown_suffix"):
|
||||
assert data.get(k) == gate.data_patch.get(k), (
|
||||
f"stamp {k}: handler={data.get(k)!r} "
|
||||
f"decide={gate.data_patch.get(k)!r}")
|
||||
if expect_lifecycle == "new":
|
||||
assert data.get("category") == "wildfire_declared"
|
||||
assert gate.data_patch.get("category") == "wildfire_declared"
|
||||
elif expect_lifecycle == "update":
|
||||
# Update keeps the envelope-derived wildfire_incident: no override.
|
||||
assert "category" not in data
|
||||
assert "category" not in gate.data_patch
|
||||
elif expect_lifecycle == "closed":
|
||||
assert data.get("category") == "wildfire_closed"
|
||||
assert gate.data_patch.get("category") == "wildfire_closed"
|
||||
assert data.get("_severity_override") == "priority"
|
||||
# Arm last_broadcast_* for the next cooldown check.
|
||||
data["_on_broadcast_committed"](float(now))
|
||||
return wire
|
||||
|
||||
def test_full_lifecycle(self, mem_db):
|
||||
irwin = _IRWIN_A
|
||||
base = 1_800_000_000
|
||||
|
|
@ -242,22 +253,47 @@ class TestGateSequenceParity:
|
|||
daily_acres=acres, pct_contained=pct,
|
||||
fire_discovery_dt_ms=disc_ms)
|
||||
|
||||
def _step(env, now, *, expect_broadcast, expect_lifecycle):
|
||||
n = _normalize_wfigs(env)
|
||||
gate = self._decide(env, now)
|
||||
assert gate.broadcast is expect_broadcast, (
|
||||
f"decide broadcast {gate.broadcast} != {expect_broadcast} "
|
||||
f"@ {now} ({expect_lifecycle})")
|
||||
assert gate.lifecycle == expect_lifecycle, (
|
||||
f"decide lifecycle {gate.lifecycle} != {expect_lifecycle} @ {now}")
|
||||
# Unconditional state write, mirroring the live native path.
|
||||
_write_fire_state(
|
||||
mem_db, irwin_id=irwin, name=n.get("incident_name"),
|
||||
acres=n.get("acres"), contained_pct=n.get("contained_pct"),
|
||||
lat=n.get("lat"), lon=n.get("lon"), county=n.get("county"),
|
||||
state=n.get("state"), declared_at_epoch=n.get("declared_at_epoch"),
|
||||
now=now)
|
||||
if expect_broadcast:
|
||||
if expect_lifecycle == "new":
|
||||
assert gate.data_patch.get("category") == "wildfire_declared"
|
||||
elif expect_lifecycle == "update":
|
||||
assert "category" not in gate.data_patch
|
||||
assert gate.data_patch.get("_severity_override") == "priority"
|
||||
gate.commit(float(now))
|
||||
|
||||
# [0] first sight → New broadcast
|
||||
self._step(_active(250.0, 0), base,
|
||||
expect_broadcast=True, expect_lifecycle="new")
|
||||
_step(_active(250.0, 0), base,
|
||||
expect_broadcast=True, expect_lifecycle="new")
|
||||
# [1] small growth 1h later (inside 8h cooldown) → suppress
|
||||
self._step(_active(300.0, 0), base + 3600,
|
||||
expect_broadcast=False, expect_lifecycle="cooldown")
|
||||
_step(_active(300.0, 0), base + 3600,
|
||||
expect_broadcast=False, expect_lifecycle="cooldown")
|
||||
# [2] growth after cooldown (8h) → Update
|
||||
self._step(_active(500.0, 0), base + 28800,
|
||||
expect_broadcast=True, expect_lifecycle="update")
|
||||
_step(_active(500.0, 0), base + 28800,
|
||||
expect_broadcast=True, expect_lifecycle="update")
|
||||
# [3] containment change after another cooldown → Update
|
||||
self._step(_active(500.0, 40), base + 28800 * 2,
|
||||
expect_broadcast=True, expect_lifecycle="update")
|
||||
_step(_active(500.0, 40), base + 28800 * 2,
|
||||
expect_broadcast=True, expect_lifecycle="update")
|
||||
# [4] tombstone → all-clear (fire was broadcast earlier)
|
||||
tomb = _make_tombstone(irwin_id=irwin)
|
||||
self._step(tomb, base + 100000,
|
||||
expect_broadcast=True, expect_lifecycle="closed")
|
||||
gate_t = fire_decide({"_kind": "wfigs_tombstone", "irwin_id": irwin},
|
||||
source="wfigs", now=float(base + 100000))
|
||||
assert gate_t.broadcast is True
|
||||
assert gate_t.lifecycle == "closed"
|
||||
assert gate_t.data_patch["category"] == "wildfire_closed"
|
||||
|
||||
def test_dedup_suffix_tracks_state(self, mem_db):
|
||||
"""_dedup_suffix carries the acres|contained that justified the
|
||||
|
|
@ -278,10 +314,6 @@ class TestGateSequenceParity:
|
|||
gate = self._decide(tomb, 1_000_000)
|
||||
assert gate.broadcast is False
|
||||
assert gate.lifecycle == "suppress"
|
||||
# Handler agrees: returns None.
|
||||
out = handle_wfigs(_normalize_wfigs(tomb), tomb, tomb["subject"],
|
||||
data={}, now=1_000_000)
|
||||
assert out is None
|
||||
|
||||
def test_perimeter_never_broadcasts(self, mem_db):
|
||||
from tests.test_wfigs_handler import _make_perimeter
|
||||
|
|
@ -314,10 +346,6 @@ class TestRegistration:
|
|||
def test_firms_categories_not_captured(self, cat):
|
||||
# These native FIRMS categories remain deferred; they must NOT resolve
|
||||
# to the fire formatter/decider via the "fire" toggle family fallback.
|
||||
# (Phase-3c migrated the FIRMS FUSION categories wildfire_growth /
|
||||
# wildfire_spotting / wildfire_halted — covered in test_firms_refactor.py
|
||||
# — but growth reuses the fire FORMATTER while keeping its OWN firms
|
||||
# DECIDER, so it is intentionally not asserted here.)
|
||||
from meshai.notifications.formatters import get_formatter
|
||||
from meshai.notifications.gating import get_decider
|
||||
assert get_formatter(cat) is not fire_format
|
||||
|
|
|
|||
|
|
@ -50,29 +50,28 @@ def _seed_fire(*, irwin_id, lat, lon, name="Stub Fire", state="ID"):
|
|||
)
|
||||
|
||||
|
||||
def _envelope(*, lat, lon, acq_date="2026-06-06", acq_time="1200",
|
||||
frp=15.0, satellite="N20", conf="high"):
|
||||
"""Build a Central FIRMS envelope shaped like the real Central feed."""
|
||||
return {
|
||||
"data": {
|
||||
"adapter": "firms",
|
||||
"category": "wildfire_hotspot",
|
||||
"severity": "routine",
|
||||
"data": {
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"frp": frp,
|
||||
"bright_ti4": 320.5,
|
||||
"satellite": satellite,
|
||||
"instrument": "VIIRS",
|
||||
"confidence": conf,
|
||||
"acq_date": acq_date,
|
||||
"acq_time": acq_time,
|
||||
"daynight": "D",
|
||||
"version": "2.0NRT",
|
||||
},
|
||||
}
|
||||
}
|
||||
def _pixel(*, lat, lon, acq_date="2026-06-06", acq_time="1200",
|
||||
frp=15.0, satellite="N20", conf="high"):
|
||||
"""Build a canonical FIRMS pixel dict for the LIVE ingest_hotspot_pixel
|
||||
entrypoint (mirrors tests/test_firms_native_fusion.py's `_pixel`)."""
|
||||
import datetime as _dt
|
||||
acq_epoch = int(_dt.datetime.strptime(
|
||||
f"{acq_date} {str(acq_time).zfill(4)}", "%Y-%m-%d %H%M"
|
||||
).replace(tzinfo=_dt.timezone.utc).timestamp())
|
||||
return {"lat": lat, "lon": lon, "frp": frp, "confidence": conf,
|
||||
"brightness": 320.5, "satellite": satellite, "acq_epoch": acq_epoch}
|
||||
|
||||
|
||||
def _ingest(pixel, *, now):
|
||||
"""Feed one pixel through the LIVE native entrypoint (chore/ripout-2dii:
|
||||
the dead Central handle_firms wrapper this file used to drive through is
|
||||
gone) and return the (wire, data) of the first broadcast it produced (or
|
||||
(None, {}))."""
|
||||
from meshai.env.fire_fusion import ingest_hotspot_pixel
|
||||
broadcasts = ingest_hotspot_pixel(pixel, now=now)
|
||||
if broadcasts:
|
||||
return broadcasts[0]
|
||||
return None, {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -81,7 +80,6 @@ def _envelope(*, lat, lon, acq_date="2026-06-06", acq_time="1200",
|
|||
|
||||
|
||||
def test_pixel_within_radius_attributes_to_fire():
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
from meshai.persistence import get_db
|
||||
|
||||
# Cache Peak Fire stub @ 42.118, -113.643.
|
||||
|
|
@ -89,9 +87,8 @@ def test_pixel_within_radius_attributes_to_fire():
|
|||
lat=42.118, lon=-113.643)
|
||||
|
||||
# FIRMS pixel ~0.2 mi NE of the anchor -- well inside default 5 mi.
|
||||
env = _envelope(lat=42.121, lon=-113.640, frp=18.0)
|
||||
wire = handle_firms(env, subject="central.fire.hotspot.N20.high.us.id",
|
||||
data={}, now=1780728000)
|
||||
wire, _data = _ingest(_pixel(lat=42.121, lon=-113.640, frp=18.0),
|
||||
now=1780728000)
|
||||
# Attribution is silent (return None); the wire only fires on cluster.
|
||||
assert wire is None
|
||||
|
||||
|
|
@ -120,7 +117,6 @@ def test_pixel_within_radius_attributes_to_fire():
|
|||
def test_centroid_recomputes_as_median_across_passes():
|
||||
"""A second attributed pixel updates the centroid to the median, not
|
||||
just the latest pixel's coords."""
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
from meshai.persistence import get_db
|
||||
|
||||
_seed_fire(irwin_id="ID-TEST-002",
|
||||
|
|
@ -135,12 +131,9 @@ def test_centroid_recomputes_as_median_across_passes():
|
|||
# arithmetic mean) survives the Phase 2 semantic shift.
|
||||
coords = [(42.001, -113.001), (42.002, -113.002), (42.003, -113.003)]
|
||||
for i, (la, lo) in enumerate(coords):
|
||||
env = _envelope(lat=la, lon=lo,
|
||||
acq_date="2026-06-06",
|
||||
acq_time=f"12{i * 10:02d}") # 12:00, 12:10, 12:20
|
||||
handle_firms(env,
|
||||
subject="central.fire.hotspot.N20.high.us.id",
|
||||
data={}, now=1780728000 + i * 600)
|
||||
_ingest(_pixel(lat=la, lon=lo, acq_date="2026-06-06",
|
||||
acq_time=f"12{i * 10:02d}"), # 12:00, 12:10, 12:20
|
||||
now=1780728000 + i * 600)
|
||||
|
||||
fire = get_db().execute(
|
||||
"SELECT current_centroid_lat, current_centroid_lon "
|
||||
|
|
@ -157,16 +150,14 @@ def test_centroid_recomputes_as_median_across_passes():
|
|||
|
||||
|
||||
def test_pixel_outside_radius_stays_unattributed():
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
from meshai.persistence import get_db
|
||||
|
||||
_seed_fire(irwin_id="ID-TEST-003",
|
||||
lat=42.000, lon=-113.000)
|
||||
|
||||
# Pixel ~50 mi away -- comfortably outside the 5 mi default.
|
||||
env = _envelope(lat=42.700, lon=-113.000, frp=10.0)
|
||||
wire = handle_firms(env, subject="central.fire.hotspot.N20.high.us.id",
|
||||
data={}, now=1780728000)
|
||||
wire, _data = _ingest(_pixel(lat=42.700, lon=-113.000, frp=10.0),
|
||||
now=1780728000)
|
||||
# No attribution AND below the 3-pixel cluster threshold -> no wire.
|
||||
assert wire is None
|
||||
|
||||
|
|
@ -196,7 +187,6 @@ def _hhmm(h, m=0):
|
|||
|
||||
|
||||
def test_three_unattributed_pixels_fire_cluster_once():
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
from meshai.persistence import get_db
|
||||
|
||||
# No fires seeded -- everything is unattributed.
|
||||
|
|
@ -209,12 +199,9 @@ def test_three_unattributed_pixels_fire_cluster_once():
|
|||
]
|
||||
wires: list[str | None] = []
|
||||
for la, lo, t, frp in pixels:
|
||||
env = _envelope(lat=la, lon=lo, acq_time=t, frp=frp)
|
||||
data = {}
|
||||
wires.append(handle_firms(
|
||||
env, subject="central.fire.hotspot.N20.high.unknown",
|
||||
data=data, now=1780728000,
|
||||
))
|
||||
wire, data = _ingest(_pixel(lat=la, lon=lo, acq_time=t, frp=frp),
|
||||
now=1780728000)
|
||||
wires.append(wire)
|
||||
if wires[-1] is not None:
|
||||
# The handler must have tagged data with the cluster category.
|
||||
assert data.get("category") == "unattributed_hotspot_cluster"
|
||||
|
|
@ -235,7 +222,6 @@ def test_three_unattributed_pixels_fire_cluster_once():
|
|||
|
||||
|
||||
def test_fourth_pixel_in_same_cluster_does_not_refire():
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
from meshai.persistence import get_db
|
||||
|
||||
base_lat, base_lon = 43.500, -114.500
|
||||
|
|
@ -245,18 +231,12 @@ def test_fourth_pixel_in_same_cluster_does_not_refire():
|
|||
(base_lat + 0.001, base_lon + 0.001, "1210"),
|
||||
(base_lat - 0.001, base_lon - 0.002, "1220"),
|
||||
]):
|
||||
env = _envelope(lat=la, lon=lo, acq_time=t)
|
||||
handle_firms(env,
|
||||
subject="central.fire.hotspot.N20.high.unknown",
|
||||
data={}, now=1780728000 + i)
|
||||
_ingest(_pixel(lat=la, lon=lo, acq_time=t), now=1780728000 + i)
|
||||
|
||||
# A 4th pixel inside the same cluster footprint.
|
||||
env = _envelope(lat=base_lat + 0.0005, lon=base_lon - 0.0005,
|
||||
acq_time="1230")
|
||||
data4 = {}
|
||||
wire = handle_firms(env,
|
||||
subject="central.fire.hotspot.N20.high.unknown",
|
||||
data=data4, now=1780728100)
|
||||
wire, data4 = _ingest(
|
||||
_pixel(lat=base_lat + 0.0005, lon=base_lon - 0.0005, acq_time="1230"),
|
||||
now=1780728100)
|
||||
# The existing 3 members already have cluster_broadcast_at stamped,
|
||||
# so they don't count toward the new cluster query (the SQL filter
|
||||
# is `cluster_broadcast_at IS NULL`). The 4th pixel alone fails
|
||||
|
|
@ -279,8 +259,6 @@ def test_fifth_pixel_after_time_window_can_form_new_cluster():
|
|||
no nearby unstamped pixels to count, so it stays silent -- but if
|
||||
we then ingest TWO more nearby pixels (also outside the original
|
||||
window), we should fire a NEW cluster."""
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
|
||||
base_lat, base_lon = 43.500, -114.500
|
||||
# First cluster at 12:00..12:20 -> fires + stamps all 3.
|
||||
for i, (la, lo, t) in enumerate([
|
||||
|
|
@ -288,10 +266,7 @@ def test_fifth_pixel_after_time_window_can_form_new_cluster():
|
|||
(base_lat + 0.001, base_lon + 0.001, "1210"),
|
||||
(base_lat - 0.001, base_lon - 0.002, "1220"),
|
||||
]):
|
||||
env = _envelope(lat=la, lon=lo, acq_time=t)
|
||||
handle_firms(env,
|
||||
subject="central.fire.hotspot.N20.high.unknown",
|
||||
data={}, now=1780728000 + i)
|
||||
_ingest(_pixel(lat=la, lon=lo, acq_time=t), now=1780728000 + i)
|
||||
|
||||
# Three NEW pixels at 14:00..14:20 -- well past the 60 min window
|
||||
# from the first cluster (which ended at 12:20 acq time).
|
||||
|
|
@ -302,11 +277,9 @@ def test_fifth_pixel_after_time_window_can_form_new_cluster():
|
|||
(base2_lat + 0.001, base2_lon + 0.001, "1410"),
|
||||
(base2_lat - 0.001, base2_lon - 0.002, "1420"),
|
||||
]):
|
||||
env = _envelope(lat=la, lon=lo, acq_time=t)
|
||||
wires2.append(handle_firms(
|
||||
env, subject="central.fire.hotspot.N20.high.unknown",
|
||||
data={}, now=1780728000 + 7200 + i,
|
||||
))
|
||||
wire, _data = _ingest(_pixel(lat=la, lon=lo, acq_time=t),
|
||||
now=1780728000 + 7200 + i)
|
||||
wires2.append(wire)
|
||||
# F3: the first cluster's 3 members are stamped (excluded from the query),
|
||||
# so the second batch forms an independent NEW cluster -- one wire on its
|
||||
# 3rd pixel.
|
||||
|
|
@ -321,9 +294,12 @@ def test_fifth_pixel_after_time_window_can_form_new_cluster():
|
|||
|
||||
|
||||
def test_wfigs_first_sight_tags_wildfire_declared():
|
||||
from meshai.env.fire_render import handle_wfigs
|
||||
# chore/ripout-2dii: drives the LIVE decider (gating.fire.decide) directly
|
||||
# -- handle_wfigs (the dead Central entrypoint this used to route through)
|
||||
# is gone. Same canonical schema env/fires.py::to_event builds.
|
||||
from meshai.notifications.gating.fire import decide as fire_decide
|
||||
|
||||
normalized = {
|
||||
canonical = {
|
||||
"_kind": "wfigs_incident",
|
||||
"irwin_id": "ID-NEW-001",
|
||||
"incident_name": "Pine Gulch",
|
||||
|
|
@ -334,24 +310,17 @@ def test_wfigs_first_sight_tags_wildfire_declared():
|
|||
"county": "Twin Falls", "state": "ID",
|
||||
"declared_at_epoch": 1780728000,
|
||||
}
|
||||
envelope = {
|
||||
"data": {"adapter": "wfigs", "category": "wildfire_incident",
|
||||
"severity": "priority"}
|
||||
}
|
||||
data = {}
|
||||
wire = handle_wfigs(normalized, envelope,
|
||||
subject="central.fire.incident.id",
|
||||
data=data, now=1780728000)
|
||||
assert wire is not None
|
||||
assert "New" in wire
|
||||
assert data.get("category") == "wildfire_declared", \
|
||||
f"expected wildfire_declared, got data={data!r}"
|
||||
gate = fire_decide(canonical, source="wfigs", now=1780728000.0)
|
||||
assert gate.broadcast is True
|
||||
assert gate.lifecycle == "new"
|
||||
assert gate.data_patch.get("category") == "wildfire_declared", \
|
||||
f"expected wildfire_declared, got data_patch={gate.data_patch!r}"
|
||||
|
||||
|
||||
def test_wfigs_update_does_not_retag_wildfire_declared():
|
||||
"""After a row exists AND has been broadcast, an acres-grew Update
|
||||
must NOT carry the wildfire_declared category."""
|
||||
from meshai.env.fire_render import handle_wfigs
|
||||
from meshai.notifications.gating.fire import decide as fire_decide
|
||||
from meshai.persistence import get_db
|
||||
|
||||
# Pre-existing row that has already been broadcast.
|
||||
|
|
@ -364,7 +333,7 @@ def test_wfigs_update_does_not_retag_wildfire_declared():
|
|||
("ID-UPD-001", "Pine Gulch", 250.0, 0, 42.93, -114.45,
|
||||
1780728000, 1780728000, 250.0, 0),
|
||||
)
|
||||
normalized = {
|
||||
canonical = {
|
||||
"_kind": "wfigs_incident",
|
||||
"irwin_id": "ID-UPD-001",
|
||||
"incident_name": "Pine Gulch",
|
||||
|
|
@ -374,19 +343,12 @@ def test_wfigs_update_does_not_retag_wildfire_declared():
|
|||
"lat": 42.93, "lon": -114.45,
|
||||
"county": "Twin Falls", "state": "ID",
|
||||
}
|
||||
envelope = {
|
||||
"data": {"adapter": "wfigs", "category": "wildfire_incident",
|
||||
"severity": "priority"}
|
||||
}
|
||||
data = {}
|
||||
# 8h cooldown clear: now > 28800s after last_broadcast_at.
|
||||
wire = handle_wfigs(normalized, envelope,
|
||||
subject="central.fire.incident.id",
|
||||
data=data, now=1780728000 + 30000)
|
||||
assert wire is not None
|
||||
assert "Update" in wire
|
||||
gate = fire_decide(canonical, source="wfigs", now=1780728000.0 + 30000)
|
||||
assert gate.broadcast is True
|
||||
assert gate.lifecycle == "update"
|
||||
# Update branch must NOT re-tag with wildfire_declared.
|
||||
assert data.get("category") != "wildfire_declared"
|
||||
assert "category" not in gate.data_patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -47,22 +47,28 @@ def _seed_fire(*, irwin_id, lat, lon, name="Stub Fire"):
|
|||
)
|
||||
|
||||
|
||||
def _envelope(*, lat, lon, acq_date="2026-06-06", acq_time="1200",
|
||||
frp=20.0, satellite="N20"):
|
||||
return {
|
||||
"data": {
|
||||
"adapter": "firms",
|
||||
"category": "wildfire_hotspot",
|
||||
"severity": "routine",
|
||||
"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 _pixel(*, lat, lon, acq_date="2026-06-06", acq_time="1200",
|
||||
frp=20.0, satellite="N20"):
|
||||
"""Build a canonical FIRMS pixel dict for the LIVE ingest_hotspot_pixel
|
||||
entrypoint (mirrors tests/test_firms_native_fusion.py's `_pixel`)."""
|
||||
import datetime as _dt
|
||||
acq_epoch = int(_dt.datetime.strptime(
|
||||
f"{acq_date} {str(acq_time).zfill(4)}", "%Y-%m-%d %H%M"
|
||||
).replace(tzinfo=_dt.timezone.utc).timestamp())
|
||||
return {"lat": lat, "lon": lon, "frp": frp, "confidence": "high",
|
||||
"brightness": 320.0, "satellite": satellite, "acq_epoch": acq_epoch}
|
||||
|
||||
|
||||
def _ingest(pixel, *, now):
|
||||
"""Feed one pixel through the LIVE native entrypoint (chore/ripout-2dii:
|
||||
the dead Central handle_firms wrapper this file used to drive through is
|
||||
gone) and return the (wire, data) of the first broadcast it produced (or
|
||||
(None, {}))."""
|
||||
from meshai.env.fire_fusion import ingest_hotspot_pixel
|
||||
broadcasts = ingest_hotspot_pixel(pixel, now=now)
|
||||
if broadcasts:
|
||||
return broadcasts[0]
|
||||
return None, {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -73,7 +79,6 @@ def _envelope(*, lat, lon, acq_date="2026-06-06", acq_time="1200",
|
|||
def test_two_pass_drift_emits_growth_with_direction_and_speed():
|
||||
"""Pass 1 (N20 bucket A), pass 2 (N20 bucket B, ~6h later, centroid
|
||||
1.0 mi N of pass 1). Drift should be ~1.0 mi N, broadcast must fire."""
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
from meshai.persistence import get_db
|
||||
|
||||
_seed_fire(irwin_id="ID-GROWTH-001",
|
||||
|
|
@ -87,26 +92,19 @@ def test_two_pass_drift_emits_growth_with_direction_and_speed():
|
|||
|
||||
# Pass A pixels (5 pixels tightly clustered around (42.000, -114.000)).
|
||||
for i in range(5):
|
||||
env = _envelope(
|
||||
_ingest(_pixel(
|
||||
lat=pass_a_lat + 0.0001 * i,
|
||||
lon=-114.000 + 0.0001 * (i - 2),
|
||||
acq_date="2026-06-06", acq_time=f"{12:02d}{0 + i:02d}",
|
||||
frp=20.0 + i, satellite="N20",
|
||||
)
|
||||
handle_firms(env, subject="central.fire.hotspot.N20.high.us.id",
|
||||
data={}, now=1780747200 + i)
|
||||
), now=1780747200 + i)
|
||||
|
||||
# First pixel of pass B fires the growth broadcast.
|
||||
env_b_first = _envelope(
|
||||
wire, data_b = _ingest(_pixel(
|
||||
lat=pass_b_lat, lon=-114.000,
|
||||
acq_date="2026-06-06", acq_time="1800",
|
||||
frp=22.0, satellite="N20",
|
||||
)
|
||||
data_b = {}
|
||||
wire = handle_firms(
|
||||
env_b_first, subject="central.fire.hotspot.N20.high.us.id",
|
||||
data=data_b, now=1780768800,
|
||||
)
|
||||
), now=1780768800)
|
||||
assert wire is not None, "pass-B boundary should fire growth broadcast"
|
||||
assert "Moving N" in wire, f"expected N direction, got: {wire}"
|
||||
assert data_b.get("category") == "wildfire_growth"
|
||||
|
|
@ -139,7 +137,6 @@ def test_two_pass_drift_emits_growth_with_direction_and_speed():
|
|||
def test_drift_below_threshold_does_not_emit_growth():
|
||||
"""0.3 mi drift between consecutive passes -- below the 0.5 mi
|
||||
default -- must NOT broadcast wildfire_growth."""
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
from meshai.persistence import get_db
|
||||
|
||||
_seed_fire(irwin_id="ID-DRIFT-001",
|
||||
|
|
@ -148,19 +145,15 @@ def test_drift_below_threshold_does_not_emit_growth():
|
|||
|
||||
# Pass A: 3 pixels.
|
||||
for i in range(3):
|
||||
env = _envelope(lat=43.000 + 0.0001 * i, lon=-115.000,
|
||||
acq_time=f"{12:02d}{i:02d}",
|
||||
frp=15.0, satellite="N20")
|
||||
handle_firms(env, subject="central.fire.hotspot.N20.high.us.id",
|
||||
data={}, now=1780747200 + i)
|
||||
_ingest(_pixel(lat=43.000 + 0.0001 * i, lon=-115.000,
|
||||
acq_time=f"{12:02d}{i:02d}",
|
||||
frp=15.0, satellite="N20"), now=1780747200 + i)
|
||||
|
||||
# Pass B: 0.3 mi N (below threshold).
|
||||
pass_b_lat = 43.000 + (0.3 / 69.0)
|
||||
env_b = _envelope(lat=pass_b_lat, lon=-115.000,
|
||||
acq_time="1800", frp=15.0, satellite="N20")
|
||||
data_b = {}
|
||||
wire = handle_firms(env_b, subject="central.fire.hotspot.N20.high.us.id",
|
||||
data=data_b, now=1780768800)
|
||||
wire, data_b = _ingest(_pixel(lat=pass_b_lat, lon=-115.000,
|
||||
acq_time="1800", frp=15.0, satellite="N20"),
|
||||
now=1780768800)
|
||||
assert wire is None, f"sub-threshold drift should NOT broadcast: {wire}"
|
||||
assert data_b.get("category") != "wildfire_growth"
|
||||
# The pass row still exists with the (sub-threshold) drift recorded.
|
||||
|
|
@ -181,7 +174,7 @@ def test_drift_below_threshold_does_not_emit_growth():
|
|||
def test_halt_detector_fires_once_after_12h_idle():
|
||||
"""Fire with last_pass_at 14h ago + no new pixels in that fire
|
||||
triggers halt on the next FIRMS pixel arrival (for any fire)."""
|
||||
from meshai.env.fire_fusion import handle_firms, _maybe_emit_halt
|
||||
from meshai.env.fire_fusion import _maybe_emit_halt
|
||||
from meshai.persistence import get_db
|
||||
|
||||
now_epoch = 1780768800 # 2026-06-06 18:00 UTC
|
||||
|
|
@ -339,7 +332,6 @@ def test_pass_row_aggregates_match_member_pixels():
|
|||
"""5 pixels attributed in the same pass yield ONE fire_passes row
|
||||
with pixel_count=5, total_frp = sum, pass_started_at = min(acq),
|
||||
pass_ended_at = max(acq), centroid = median."""
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
from meshai.persistence import get_db
|
||||
|
||||
_seed_fire(irwin_id="ID-AGG-001",
|
||||
|
|
@ -354,10 +346,8 @@ def test_pass_row_aggregates_match_member_pixels():
|
|||
(42.004, -114.004, "1220", 50.0),
|
||||
]
|
||||
for la, lo, t, frp in pixels:
|
||||
env = _envelope(lat=la, lon=lo, acq_time=t, frp=frp,
|
||||
satellite="N20")
|
||||
handle_firms(env, subject="central.fire.hotspot.N20.high.us.id",
|
||||
data={}, now=1780747200)
|
||||
_ingest(_pixel(lat=la, lon=lo, acq_time=t, frp=frp, satellite="N20"),
|
||||
now=1780747200)
|
||||
|
||||
row = get_db().execute(
|
||||
"SELECT pixel_count, total_frp, pass_centroid_lat, "
|
||||
|
|
|
|||
|
|
@ -32,22 +32,28 @@ def _seed_fire(*, irwin_id, lat, lon, name="Stub Fire"):
|
|||
)
|
||||
|
||||
|
||||
def _envelope(*, lat, lon, acq_date="2026-06-06", acq_time="1200",
|
||||
frp=20.0, satellite="N20"):
|
||||
return {
|
||||
"data": {
|
||||
"adapter": "firms",
|
||||
"category": "wildfire_hotspot",
|
||||
"severity": "routine",
|
||||
"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 _pixel(*, lat, lon, acq_date="2026-06-06", acq_time="1200",
|
||||
frp=20.0, satellite="N20"):
|
||||
"""Build a canonical FIRMS pixel dict for the LIVE ingest_hotspot_pixel
|
||||
entrypoint (mirrors tests/test_firms_native_fusion.py's `_pixel`)."""
|
||||
import datetime as _dt
|
||||
acq_epoch = int(_dt.datetime.strptime(
|
||||
f"{acq_date} {str(acq_time).zfill(4)}", "%Y-%m-%d %H%M"
|
||||
).replace(tzinfo=_dt.timezone.utc).timestamp())
|
||||
return {"lat": lat, "lon": lon, "frp": frp, "confidence": "high",
|
||||
"brightness": 320.0, "satellite": satellite, "acq_epoch": acq_epoch}
|
||||
|
||||
|
||||
def _ingest(pixel, *, now):
|
||||
"""Feed one pixel through the LIVE native entrypoint (chore/ripout-2dii:
|
||||
the dead Central handle_firms wrapper this file used to drive through is
|
||||
gone) and return the (wire, data) of the first broadcast it produced (or
|
||||
(None, {}))."""
|
||||
from meshai.env.fire_fusion import ingest_hotspot_pixel
|
||||
broadcasts = ingest_hotspot_pixel(pixel, now=now)
|
||||
if broadcasts:
|
||||
return broadcasts[0]
|
||||
return None, {}
|
||||
|
||||
|
||||
# Useful constant: 1 mi in latitude degrees.
|
||||
|
|
@ -71,7 +77,6 @@ def test_pass_close_stamps_perimeter_geojson():
|
|||
"""Pass A: 6 pixels in a hex around the seeded center. First pixel
|
||||
of pass B triggers boundary close -> perimeter_geojson written for
|
||||
pass A as a closed GeoJSON Polygon."""
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
from meshai.persistence import get_db
|
||||
|
||||
center_lat, center_lon = 42.500, -114.500
|
||||
|
|
@ -83,9 +88,8 @@ def test_pass_close_stamps_perimeter_geojson():
|
|||
angle = i * math.pi / 3
|
||||
la = center_lat + 0.001 * math.sin(angle)
|
||||
lo = center_lon + 0.001 * math.cos(angle)
|
||||
env = _envelope(lat=la, lon=lo, acq_time=f"12{i * 2:02d}")
|
||||
handle_firms(env, subject="central.fire.hotspot.N20.high.us.id",
|
||||
data={}, now=1780747200 + i)
|
||||
_ingest(_pixel(lat=la, lon=lo, acq_time=f"12{i * 2:02d}"),
|
||||
now=1780747200 + i)
|
||||
|
||||
# First pass B pixel 1 mi N. Within the 5 mi spread radius -> the
|
||||
# pixel attributes to the seeded fire -> boundary detected ->
|
||||
|
|
@ -93,9 +97,7 @@ def test_pass_close_stamps_perimeter_geojson():
|
|||
# spotting threshold so this also avoids spotting noise in the
|
||||
# test (we just want perimeter_geojson to materialize).
|
||||
far_lat, far_lon = _offset_mi(center_lat, center_lon, north_mi=1.0)
|
||||
env_b = _envelope(lat=far_lat, lon=far_lon, acq_time="1800")
|
||||
handle_firms(env_b, subject="central.fire.hotspot.N20.high.us.id",
|
||||
data={}, now=1780768800)
|
||||
_ingest(_pixel(lat=far_lat, lon=far_lon, acq_time="1800"), now=1780768800)
|
||||
|
||||
row = get_db().execute(
|
||||
"SELECT perimeter_geojson FROM fire_passes WHERE irwin_id=? "
|
||||
|
|
@ -121,7 +123,6 @@ def _seed_pass_a_hex_then_close(*, irwin_id, center_lat, center_lon,
|
|||
start_now=1780747200):
|
||||
"""Helper: seed a fire + 6 hex-vertex pass A pixels. Caller follows up
|
||||
with a pass B pixel to trigger boundary close + perimeter write."""
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
_seed_fire(irwin_id=irwin_id, lat=center_lat, lon=center_lon,
|
||||
name=irwin_id)
|
||||
for i in range(6):
|
||||
|
|
@ -130,15 +131,13 @@ def _seed_pass_a_hex_then_close(*, irwin_id, center_lat, center_lon,
|
|||
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)
|
||||
env = _envelope(lat=la, lon=lo, acq_time=f"12{i * 2:02d}")
|
||||
handle_firms(env, subject="central.fire.hotspot.N20.high.us.id",
|
||||
data={}, now=start_now + i)
|
||||
_ingest(_pixel(lat=la, lon=lo, acq_time=f"12{i * 2:02d}"),
|
||||
now=start_now + i)
|
||||
|
||||
|
||||
def test_pixel_2mi_ne_of_perimeter_emits_spotting():
|
||||
"""Pass B pixel 2 mi NE of pass A's perimeter centroid fires
|
||||
wildfire_spotting with the correct distance + direction."""
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
from meshai.persistence import get_db
|
||||
|
||||
center_lat, center_lon = 43.000, -115.000
|
||||
|
|
@ -151,11 +150,8 @@ def test_pixel_2mi_ne_of_perimeter_emits_spotting():
|
|||
center_lat, center_lon,
|
||||
north_mi=2.0 / math.sqrt(2), east_mi=2.0 / math.sqrt(2),
|
||||
)
|
||||
env_b = _envelope(lat=sp_lat, lon=sp_lon, acq_time="1800")
|
||||
data = {}
|
||||
wire = handle_firms(env_b,
|
||||
subject="central.fire.hotspot.N20.high.us.id",
|
||||
data=data, now=1780768800)
|
||||
wire, data = _ingest(_pixel(lat=sp_lat, lon=sp_lon, acq_time="1800"),
|
||||
now=1780768800)
|
||||
assert wire is not None
|
||||
assert wire.startswith("🔥 Possible spotting ")
|
||||
assert "NE of ID-SPOT-002 perimeter" in wire
|
||||
|
|
@ -188,8 +184,6 @@ def test_pixel_2mi_ne_of_perimeter_emits_spotting():
|
|||
|
||||
|
||||
def test_pixel_inside_perimeter_no_spotting():
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
|
||||
center_lat, center_lon = 43.500, -114.500
|
||||
_seed_pass_a_hex_then_close(irwin_id="ID-SPOT-003",
|
||||
center_lat=center_lat,
|
||||
|
|
@ -197,22 +191,15 @@ def test_pixel_inside_perimeter_no_spotting():
|
|||
|
||||
# Close the perimeter with one boundary-only pixel ~10 mi away.
|
||||
far_lat, far_lon = _offset_mi(center_lat, center_lon, north_mi=10.0)
|
||||
env_close = _envelope(lat=far_lat, lon=far_lon, acq_time="1800")
|
||||
handle_firms(env_close,
|
||||
subject="central.fire.hotspot.N20.high.us.id",
|
||||
data={}, now=1780768800)
|
||||
_ingest(_pixel(lat=far_lat, lon=far_lon, acq_time="1800"), now=1780768800)
|
||||
|
||||
# Now ingest a pixel 0.1 mi NE of center -- well inside the 0.5 mi
|
||||
# radius hex.
|
||||
inside_lat, inside_lon = _offset_mi(
|
||||
center_lat, center_lon, north_mi=0.05, east_mi=0.05,
|
||||
)
|
||||
env_inside = _envelope(lat=inside_lat, lon=inside_lon,
|
||||
acq_time="1810")
|
||||
data = {}
|
||||
wire = handle_firms(env_inside,
|
||||
subject="central.fire.hotspot.N20.high.us.id",
|
||||
data=data, now=1780768900)
|
||||
wire, data = _ingest(_pixel(lat=inside_lat, lon=inside_lon,
|
||||
acq_time="1810"), now=1780768900)
|
||||
assert wire is None or "spotting" not in (wire or "")
|
||||
assert data.get("category") != "wildfire_spotting"
|
||||
|
||||
|
|
@ -223,8 +210,6 @@ def test_pixel_inside_perimeter_no_spotting():
|
|||
|
||||
|
||||
def test_second_spotting_within_cooldown_suppressed():
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
|
||||
center_lat, center_lon = 44.000, -116.000
|
||||
_seed_pass_a_hex_then_close(irwin_id="ID-SPOT-004",
|
||||
center_lat=center_lat,
|
||||
|
|
@ -232,10 +217,8 @@ def test_second_spotting_within_cooldown_suppressed():
|
|||
|
||||
# First spotting pixel 2 mi N.
|
||||
sp1_lat, sp1_lon = _offset_mi(center_lat, center_lon, north_mi=2.0)
|
||||
env1 = _envelope(lat=sp1_lat, lon=sp1_lon, acq_time="1800")
|
||||
wire1 = handle_firms(env1,
|
||||
subject="central.fire.hotspot.N20.high.us.id",
|
||||
data={}, now=1780768800)
|
||||
wire1, _data1 = _ingest(_pixel(lat=sp1_lat, lon=sp1_lon, acq_time="1800"),
|
||||
now=1780768800)
|
||||
assert wire1 is not None and "spotting" in wire1
|
||||
|
||||
# Second spotting candidate 30 min later, 2 mi SE (different
|
||||
|
|
@ -244,10 +227,8 @@ def test_second_spotting_within_cooldown_suppressed():
|
|||
center_lat, center_lon,
|
||||
north_mi=-2.0 / math.sqrt(2), east_mi=2.0 / math.sqrt(2),
|
||||
)
|
||||
env2 = _envelope(lat=sp2_lat, lon=sp2_lon, acq_time="1830")
|
||||
wire2 = handle_firms(env2,
|
||||
subject="central.fire.hotspot.N20.high.us.id",
|
||||
data={}, now=1780768800 + 1800)
|
||||
wire2, _data2 = _ingest(_pixel(lat=sp2_lat, lon=sp2_lon, acq_time="1830"),
|
||||
now=1780768800 + 1800)
|
||||
assert wire2 is None, f"second spotting in cooldown should suppress: {wire2}"
|
||||
|
||||
|
||||
|
|
@ -257,26 +238,20 @@ def test_second_spotting_within_cooldown_suppressed():
|
|||
|
||||
|
||||
def test_spotting_refires_after_cooldown():
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
|
||||
center_lat, center_lon = 44.500, -116.500
|
||||
_seed_pass_a_hex_then_close(irwin_id="ID-SPOT-005",
|
||||
center_lat=center_lat,
|
||||
center_lon=center_lon)
|
||||
|
||||
sp1_lat, sp1_lon = _offset_mi(center_lat, center_lon, north_mi=2.0)
|
||||
env1 = _envelope(lat=sp1_lat, lon=sp1_lon, acq_time="1800")
|
||||
wire1 = handle_firms(env1,
|
||||
subject="central.fire.hotspot.N20.high.us.id",
|
||||
data={}, now=1780768800)
|
||||
wire1, _data1 = _ingest(_pixel(lat=sp1_lat, lon=sp1_lon, acq_time="1800"),
|
||||
now=1780768800)
|
||||
assert wire1 is not None
|
||||
|
||||
# 70 minutes later -> past the 1h cooldown -> next spotting fires.
|
||||
sp2_lat, sp2_lon = _offset_mi(center_lat, center_lon, north_mi=-2.0)
|
||||
env2 = _envelope(lat=sp2_lat, lon=sp2_lon, acq_time="1910")
|
||||
wire2 = handle_firms(env2,
|
||||
subject="central.fire.hotspot.N20.high.us.id",
|
||||
data={}, now=1780768800 + 70 * 60)
|
||||
wire2, _data2 = _ingest(_pixel(lat=sp2_lat, lon=sp2_lon, acq_time="1910"),
|
||||
now=1780768800 + 70 * 60)
|
||||
assert wire2 is not None and "spotting" in wire2
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,27 @@
|
|||
"""Tests for v0.6-1 FIRMS handler (storage-only).
|
||||
"""Tests for the shared FIRMS pixel-ingest core (`_ingest_pixel_core`) +
|
||||
`_parse_acq_epoch` -- the parts of the retired v0.6-1 FIRMS handler that are
|
||||
still LIVE, shared by the native `env/firms.py` adapter via
|
||||
`ingest_hotspot_pixel`.
|
||||
|
||||
The handler never returns a wire string (storage-only contract). All
|
||||
assertions check side effects on `firms_pixels` + `event_log`. Per the
|
||||
audit doc finding #2 the handler must close the v0.5.13 silent-drop on
|
||||
`central.fire.hotspot.>` envelopes.
|
||||
chore/ripout-2dii: `handle_firms` (the dead Central NATS-envelope entrypoint
|
||||
this file used to test -- confidence/FRP/bbox filtering, envelope field
|
||||
extraction, missing-coords/missing-acq-time drops, non-firms-adapter guard,
|
||||
event_log accounting) has been REMOVED from `meshai.env.fire_fusion` -- zero
|
||||
live production callers, and no live equivalent (the native adapter's own
|
||||
confidence/bbox filtering happens upstream in `env/firms.py`'s CSV fetch, a
|
||||
different code path entirely; see `tests/test_adapter_firms.py`). Those tests
|
||||
were deleted with it.
|
||||
|
||||
Envelope shape sourced from firms-investigation.md sampling (250 envelopes
|
||||
2026-05-28..06-04, all VIIRS).
|
||||
What remains: the dedup behavior of `_ingest_pixel_core` (INSERT OR IGNORE on
|
||||
a meters-quantized dedup key -- genuinely shared/live, exercised by BOTH the
|
||||
native adapter and formerly by handle_firms) and `_parse_acq_epoch`'s
|
||||
int/short/zero-padded acq_time parsing (also shared/live -- env/firms.py
|
||||
imports and calls it directly). Both rewritten to drive
|
||||
`ingest_hotspot_pixel` directly instead of the dead handler.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from meshai.env import fire_fusion as firms_handler
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
from meshai.env.fire_fusion import ingest_hotspot_pixel, _parse_acq_epoch
|
||||
from meshai.persistence import close_thread_connection, init_db
|
||||
from meshai.persistence import db as persistence_db
|
||||
|
||||
|
|
@ -31,319 +41,88 @@ def mem_db(monkeypatch, tmp_path):
|
|||
persistence_db._initialised.discard(db_path)
|
||||
|
||||
|
||||
def _firms_env(*,
|
||||
lat=42.19664, lon=-113.70981, frp=135.93,
|
||||
confidence="high", satellite="N",
|
||||
acq_date="2026-05-28", acq_time="1949",
|
||||
bright_ti4=367.0, daynight="D",
|
||||
envelope_id="firms_001",
|
||||
category="fire.hotspot.viirs",
|
||||
severity=3,
|
||||
region="unknown",
|
||||
extras=None):
|
||||
"""Build a FIRMS CloudEvents envelope matching the live wire shape.
|
||||
|
||||
Default fixture is SAMPLE B from firms-investigation.md (Cache Peak
|
||||
high-confidence 135 MW fire). Override individual fields for filter
|
||||
coverage.
|
||||
"""
|
||||
payload = {
|
||||
"id": envelope_id,
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"frp": frp,
|
||||
"confidence": confidence,
|
||||
"satellite": satellite,
|
||||
"instrument": "VIIRS",
|
||||
"acq_date": acq_date,
|
||||
"acq_time": acq_time,
|
||||
"bright_ti4": bright_ti4,
|
||||
"daynight": daynight,
|
||||
"version": "2.0NRT",
|
||||
"_enriched": {"geocoder": {"landclass": "Cache Peak Roadless Area",
|
||||
"elevation_m": 2151.9}},
|
||||
}
|
||||
if extras: payload.update(extras)
|
||||
return {
|
||||
"subject": f"central.fire.hotspot.viirs_snpp.{confidence}.{region}",
|
||||
"id": envelope_id,
|
||||
"data": {
|
||||
"id": envelope_id,
|
||||
"adapter": "firms",
|
||||
"category": category,
|
||||
"severity": severity,
|
||||
"time": "2026-05-28T19:49:00Z",
|
||||
"geo": {"centroid": [lon, lat]},
|
||||
"data": payload,
|
||||
},
|
||||
}
|
||||
def _pixel(*, lat=42.19664, lon=-113.70981, frp=135.93, confidence="high",
|
||||
satellite="N", acq_date="2026-05-28", acq_time="1949",
|
||||
brightness=367.0):
|
||||
acq_epoch = _parse_acq_epoch(acq_date, acq_time)
|
||||
return {"lat": lat, "lon": lon, "frp": frp, "confidence": confidence,
|
||||
"brightness": brightness, "satellite": satellite,
|
||||
"acq_epoch": acq_epoch}
|
||||
|
||||
|
||||
def _row_count(mem_db, table):
|
||||
return mem_db.execute(f"SELECT COUNT(*) AS n FROM {table}").fetchone()["n"]
|
||||
|
||||
|
||||
def _last_event_log(mem_db):
|
||||
r = mem_db.execute(
|
||||
"SELECT * FROM event_log ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Happy path: high-confidence pixel stored
|
||||
# _parse_acq_epoch -- acq_time format quirks (shared with env/firms.py)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_high_confidence_pixel_persisted(mem_db):
|
||||
env = _firms_env()
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
def test_acq_time_int_accepted():
|
||||
"""FIRMS sometimes publishes acq_time as int 2013 rather than '2013'."""
|
||||
assert _parse_acq_epoch("2026-05-28", 1949) is not None
|
||||
assert _parse_acq_epoch("2026-05-28", 1949) == _parse_acq_epoch("2026-05-28", "1949")
|
||||
|
||||
assert out is None, "storage-only handler must never return a wire string"
|
||||
assert _row_count(mem_db, "firms_pixels") == 1
|
||||
|
||||
row = mem_db.execute("SELECT * FROM firms_pixels").fetchone()
|
||||
assert row["lat"] == pytest.approx(42.19664)
|
||||
assert row["lon"] == pytest.approx(-113.70981)
|
||||
assert row["frp"] == pytest.approx(135.93)
|
||||
assert row["confidence"] == "high"
|
||||
assert row["satellite"] == "N"
|
||||
assert row["brightness"] == pytest.approx(367.0)
|
||||
assert row["irwin_id"] is None # unattached; v0.6 fire-tracker fills later
|
||||
|
||||
# acq_time should be the parsed UTC epoch of 2026-05-28 19:49Z.
|
||||
def test_short_acq_time_zero_padded():
|
||||
"""acq_time '49' (early-morning pass) must zero-pad to '0049'."""
|
||||
epoch = _parse_acq_epoch("2026-05-28", "49")
|
||||
assert epoch is not None
|
||||
import datetime as _dt
|
||||
expected = int(_dt.datetime(2026, 5, 28, 19, 49,
|
||||
tzinfo=_dt.timezone.utc).timestamp())
|
||||
assert row["acq_time"] == expected
|
||||
|
||||
# event_log: handled=1, table_name set, table_pk = inserted rowid.
|
||||
log = _last_event_log(mem_db)
|
||||
assert log["source"] == "firms"
|
||||
assert log["handled"] == 1
|
||||
assert log["table_name"] == "firms_pixels"
|
||||
assert log["table_pk"] == str(row["id"])
|
||||
dt = _dt.datetime.fromtimestamp(epoch, tz=_dt.timezone.utc)
|
||||
assert (dt.hour, dt.minute) == (0, 49)
|
||||
|
||||
|
||||
def test_nominal_confidence_pixel_persisted_under_default_floor(mem_db):
|
||||
"""Default FIRMS_CONFIDENCE_FLOOR='low' must accept nominal/high/low."""
|
||||
env = _firms_env(confidence="nominal", envelope_id="firms_002")
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 1
|
||||
|
||||
|
||||
def test_low_confidence_pixel_persisted_under_default_floor(mem_db):
|
||||
env = _firms_env(confidence="low", envelope_id="firms_003")
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 1
|
||||
def test_missing_acq_time_returns_none():
|
||||
assert _parse_acq_epoch(None, None) is None
|
||||
assert _parse_acq_epoch("2026-05-28", None) is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Confidence floor when bumped up
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_low_confidence_dropped_when_floor_is_nominal(mem_db, monkeypatch):
|
||||
monkeypatch.setattr(firms_handler, "FIRMS_CONFIDENCE_FLOOR", "nominal")
|
||||
env = _firms_env(confidence="low", envelope_id="firms_lo")
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 0
|
||||
|
||||
log = _last_event_log(mem_db)
|
||||
assert log["handled"] == 0
|
||||
assert log["category"].endswith("|below_confidence_floor")
|
||||
|
||||
|
||||
def test_unknown_confidence_value_dropped(mem_db):
|
||||
env = _firms_env(confidence="bogus", envelope_id="firms_bog")
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 0
|
||||
log = _last_event_log(mem_db)
|
||||
assert log["category"].endswith("|below_confidence_floor")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FRP floor
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_low_frp_dropped_when_floor_set(mem_db, monkeypatch):
|
||||
monkeypatch.setattr(firms_handler, "FIRMS_FRP_FLOOR", 5.0)
|
||||
env = _firms_env(frp=1.4, confidence="high", envelope_id="firms_frp_low")
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 0
|
||||
log = _last_event_log(mem_db)
|
||||
assert log["category"].endswith("|below_frp_floor")
|
||||
|
||||
|
||||
def test_frp_at_floor_stored(mem_db, monkeypatch):
|
||||
monkeypatch.setattr(firms_handler, "FIRMS_FRP_FLOOR", 5.0)
|
||||
env = _firms_env(frp=5.0, confidence="high", envelope_id="firms_frp_at")
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 1
|
||||
|
||||
|
||||
def test_missing_frp_dropped_when_floor_set(mem_db, monkeypatch):
|
||||
monkeypatch.setattr(firms_handler, "FIRMS_FRP_FLOOR", 5.0)
|
||||
env = _firms_env(confidence="high", envelope_id="firms_no_frp",
|
||||
extras={"frp": None})
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 0
|
||||
|
||||
|
||||
def test_missing_frp_stored_when_floor_zero(mem_db):
|
||||
"""Default FIRMS_FRP_FLOOR=0: missing FRP still stores (null in column)."""
|
||||
env = _firms_env(confidence="high", envelope_id="firms_no_frp_2",
|
||||
extras={"frp": None})
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 1
|
||||
row = mem_db.execute("SELECT * FROM firms_pixels").fetchone()
|
||||
assert row["frp"] is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Bbox filter (default None = pass-through; explicit bbox tested both sides)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_bbox_none_passes_through(mem_db):
|
||||
env = _firms_env(lat=51.0, lon=10.0, # Germany
|
||||
envelope_id="firms_eu")
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 1
|
||||
|
||||
|
||||
def test_bbox_drops_outside(mem_db, monkeypatch):
|
||||
# Idaho-ish bbox.
|
||||
monkeypatch.setattr(firms_handler, "FIRMS_BBOX_OPTIONAL",
|
||||
(42.0, -117.5, 49.0, -111.0))
|
||||
env = _firms_env(lat=51.0, lon=10.0, envelope_id="firms_out")
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 0
|
||||
log = _last_event_log(mem_db)
|
||||
assert log["category"].endswith("|outside_bbox")
|
||||
|
||||
|
||||
def test_bbox_keeps_inside(mem_db, monkeypatch):
|
||||
monkeypatch.setattr(firms_handler, "FIRMS_BBOX_OPTIONAL",
|
||||
(42.0, -117.5, 49.0, -111.0))
|
||||
env = _firms_env(envelope_id="firms_in") # Cache Peak is inside
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Dedup: same satellite pixel observation arriving twice = no-op
|
||||
# Dedup: same satellite pixel observation arriving twice = no-op.
|
||||
# `_ingest_pixel_core`'s meters-quantized dedup_key + INSERT OR IGNORE is the
|
||||
# LIVE, source-agnostic core shared by ingest_hotspot_pixel.
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_dedup_same_pixel_idempotent(mem_db):
|
||||
env = _firms_env(envelope_id="dup_001")
|
||||
handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
handle_firms(env, env["subject"], data={}, now=1_780_660_001)
|
||||
|
||||
p = _pixel()
|
||||
ingest_hotspot_pixel(p, now=1_780_660_000)
|
||||
ingest_hotspot_pixel(p, now=1_780_660_001)
|
||||
assert _row_count(mem_db, "firms_pixels") == 1, "OR IGNORE collapses dup"
|
||||
# Two event_log rows; second is dedup_hit.
|
||||
rows = mem_db.execute(
|
||||
"SELECT category, table_name, handled FROM event_log "
|
||||
"WHERE source='firms' ORDER BY id"
|
||||
).fetchall()
|
||||
assert len(rows) == 2
|
||||
assert rows[0]["table_name"] == "firms_pixels"
|
||||
assert rows[1]["table_name"] is None
|
||||
assert rows[1]["category"].endswith("|dedup_hit")
|
||||
|
||||
|
||||
def test_dedup_collapses_lat_lon_float_noise(mem_db):
|
||||
"""Same coord with sub-1m float noise must hit the same dedup key.
|
||||
5-decimal rounding => differences in the 6th+ decimal are absorbed."""
|
||||
e1 = _firms_env(lat=42.196641234567, lon=-113.709810000001,
|
||||
envelope_id="dup_a")
|
||||
e2 = _firms_env(lat=42.196641111111, lon=-113.709810999999,
|
||||
envelope_id="dup_b")
|
||||
handle_firms(e1, e1["subject"], data={}, now=1_780_660_000)
|
||||
handle_firms(e2, e2["subject"], data={}, now=1_780_660_001)
|
||||
Meters-based quantization absorbs differences well under 1 pixel."""
|
||||
p1 = _pixel(lat=42.196641234567, lon=-113.709810000001)
|
||||
p2 = _pixel(lat=42.196641111111, lon=-113.709810999999)
|
||||
ingest_hotspot_pixel(p1, now=1_780_660_000)
|
||||
ingest_hotspot_pixel(p2, now=1_780_660_001)
|
||||
assert _row_count(mem_db, "firms_pixels") == 1
|
||||
|
||||
|
||||
def test_dedup_different_satellite_stored_separately(mem_db):
|
||||
"""Same coord + acq_time but different satellite is 2 distinct observations."""
|
||||
e1 = _firms_env(satellite="N", envelope_id="sat_n")
|
||||
e2 = _firms_env(satellite="N20", envelope_id="sat_n20")
|
||||
handle_firms(e1, e1["subject"], data={}, now=1_780_660_000)
|
||||
handle_firms(e2, e2["subject"], data={}, now=1_780_660_001)
|
||||
ingest_hotspot_pixel(_pixel(satellite="N"), now=1_780_660_000)
|
||||
ingest_hotspot_pixel(_pixel(satellite="N20"), now=1_780_660_001)
|
||||
assert _row_count(mem_db, "firms_pixels") == 2
|
||||
|
||||
|
||||
def test_dedup_different_acq_time_stored_separately(mem_db):
|
||||
"""Same pixel observed on two passes 12h apart -> 2 rows."""
|
||||
e1 = _firms_env(acq_time="0700", envelope_id="t1")
|
||||
e2 = _firms_env(acq_time="1900", envelope_id="t2")
|
||||
handle_firms(e1, e1["subject"], data={}, now=1_780_660_000)
|
||||
handle_firms(e2, e2["subject"], data={}, now=1_780_660_001)
|
||||
ingest_hotspot_pixel(_pixel(acq_time="0700"), now=1_780_660_000)
|
||||
ingest_hotspot_pixel(_pixel(acq_time="1900"), now=1_780_660_001)
|
||||
assert _row_count(mem_db, "firms_pixels") == 2
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Bad / missing inputs
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_missing_coords_dropped(mem_db):
|
||||
env = _firms_env(envelope_id="no_coords",
|
||||
extras={"latitude": None, "longitude": None})
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 0
|
||||
log = _last_event_log(mem_db)
|
||||
assert log["category"].endswith("|missing_coords")
|
||||
|
||||
|
||||
def test_missing_acq_time_dropped(mem_db):
|
||||
env = _firms_env(envelope_id="no_acq",
|
||||
extras={"acq_date": None, "acq_time": None})
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 0
|
||||
log = _last_event_log(mem_db)
|
||||
assert log["category"].endswith("|missing_acq_time")
|
||||
|
||||
|
||||
def test_non_firms_adapter_passes_through(mem_db):
|
||||
"""Defense in depth: handler must early-return on non-firms envelopes."""
|
||||
env = _firms_env(envelope_id="wrong_adapter")
|
||||
env["data"]["adapter"] = "nws"
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 0
|
||||
assert _row_count(mem_db, "event_log") == 0
|
||||
|
||||
|
||||
def test_acq_time_int_accepted(mem_db):
|
||||
"""FIRMS sometimes publishes acq_time as int 2013 rather than '2013'."""
|
||||
env = _firms_env(envelope_id="int_acq",
|
||||
extras={"acq_time": 1949})
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
def test_missing_frp_stored(mem_db):
|
||||
"""A pixel with no FRP still stores (null in column) -- ingest_hotspot_pixel
|
||||
never filters on FRP (that was handle_firms-specific, now gone)."""
|
||||
p = _pixel()
|
||||
p["frp"] = None
|
||||
ingest_hotspot_pixel(p, now=1_780_660_000)
|
||||
assert _row_count(mem_db, "firms_pixels") == 1
|
||||
|
||||
|
||||
def test_short_acq_time_zero_padded(mem_db):
|
||||
"""acq_time '49' (early-morning pass) must zero-pad to '0049'."""
|
||||
env = _firms_env(envelope_id="short_acq",
|
||||
extras={"acq_time": "49"})
|
||||
out = handle_firms(env, env["subject"], data={}, now=1_780_660_000)
|
||||
assert out is None
|
||||
assert _row_count(mem_db, "firms_pixels") == 1
|
||||
|
||||
row = mem_db.execute("SELECT * FROM firms_pixels").fetchone()
|
||||
assert row["frp"] is None
|
||||
|
|
|
|||
|
|
@ -380,68 +380,10 @@ class TestAdapterTickFusion:
|
|||
fusion = [e for e in a.get_events() if e.get("source") == "firms_fusion"]
|
||||
assert fusion == []
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 3. Central-path guard — extraction did not change handle_firms
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _envelope(*, lat, lon, acq_date="2026-06-06", acq_time="1200",
|
||||
frp=20.0, satellite="N20"):
|
||||
return {
|
||||
"data": {
|
||||
"adapter": "firms", "category": "wildfire_hotspot",
|
||||
"severity": "routine",
|
||||
"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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_SUBJECT = "central.fire.hotspot.N20.high.us.id"
|
||||
|
||||
|
||||
class TestCentralPathUnchanged:
|
||||
def test_storage_only_pixel_still_stores_and_returns_none(self):
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
from meshai.persistence import get_db
|
||||
env = _envelope(lat=42.19664, lon=-113.70981)
|
||||
out = handle_firms(env, subject=_SUBJECT, data={}, now=1780660000)
|
||||
assert out is None # no fire seeded -> storage only, no broadcast
|
||||
n = get_db().execute(
|
||||
"SELECT COUNT(*) AS n FROM firms_pixels").fetchone()["n"]
|
||||
assert n == 1
|
||||
log = get_db().execute(
|
||||
"SELECT table_name, table_pk, handled FROM event_log "
|
||||
"ORDER BY id DESC LIMIT 1").fetchone()
|
||||
assert log["handled"] == 1
|
||||
assert log["table_name"] == "firms_pixels"
|
||||
row_id = get_db().execute(
|
||||
"SELECT id FROM firms_pixels").fetchone()["id"]
|
||||
assert log["table_pk"] == str(row_id)
|
||||
|
||||
def test_central_growth_wire_and_stamps_identical(self):
|
||||
"""The extracted core produces the SAME growth wire/stamps on the
|
||||
Central envelope path that the inline handler did."""
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
center_lat, center_lon = 42.0, -114.0
|
||||
_seed_fire(irwin_id="ID-CG", lat=center_lat, lon=center_lon,
|
||||
name="Pine Gulch")
|
||||
for i in range(5):
|
||||
handle_firms(_envelope(lat=center_lat + 0.0001 * i,
|
||||
lon=center_lon + 0.0001 * (i - 2),
|
||||
acq_time=f"12{i:02d}", frp=20.0 + i),
|
||||
subject=_SUBJECT, data={}, now=1780747200 + i)
|
||||
data = {}
|
||||
wire = handle_firms(_envelope(lat=center_lat + 1.0 / _MI_PER_DEG_LAT,
|
||||
lon=center_lon, acq_time="1800", frp=22.0),
|
||||
subject=_SUBJECT, data=data, now=1780768800)
|
||||
assert wire is not None and wire.startswith("🔥 Pine Gulch")
|
||||
assert "Moving N" in wire
|
||||
assert data["category"] == "wildfire_growth"
|
||||
assert data["_severity_override"] == "immediate"
|
||||
assert data["_cooldown_suffix"] == "ID-CG"
|
||||
# chore/ripout-2dii: section 3 ("Central-path guard — extraction did not
|
||||
# change handle_firms") REMOVED. `handle_firms` (the dead Central
|
||||
# NATS-envelope entrypoint it drove) has been deleted from
|
||||
# `meshai.env.fire_fusion` -- zero live production callers, and this section
|
||||
# existed purely to prove that entrypoint was unchanged by the extraction.
|
||||
# The shared core it guarded (_ingest_pixel_core) is exercised directly by
|
||||
# every test above via `ingest_hotspot_pixel` (the live native entrypoint).
|
||||
|
|
|
|||
|
|
@ -5,24 +5,42 @@ fusion broadcast categories — wildfire_growth / wildfire_spotting /
|
|||
wildfire_halted — mirroring test_fire_refactor.py (WFIGS) and
|
||||
test_hydro_refactor.py:
|
||||
|
||||
1. Registration: the three categories resolve to the right formatter/decider;
|
||||
wildfire_growth reuses the FIRE formatter but keeps its OWN firms decider;
|
||||
1. Registration: wildfire_spotting/wildfire_halted resolve to the firms
|
||||
formatter+decider; wildfire_growth is NOT formatter-registered (its wire
|
||||
is always precomposed -- see below) but DOES keep its own firms decider;
|
||||
still-deferred native FIRMS categories do NOT resolve.
|
||||
|
||||
2. Gate-sequence + deferred-latch (the tier-b validation): a `now`-timeline
|
||||
driven through the NEW gating.firms.decide() reproduces the OLD handle_firms
|
||||
broadcast/suppress + stamp behavior, AND the latch is now DEFERRED — a
|
||||
decision does NOT burn the latch until commit() fires (simulating delivery),
|
||||
after which the next decision suppresses.
|
||||
driven through gating.firms.decide() exercises broadcast/suppress + stamp
|
||||
behavior, AND the latch is DEFERRED — a decision does NOT burn the latch
|
||||
until commit() fires (simulating delivery), after which the next decision
|
||||
suppresses.
|
||||
|
||||
3. Golden byte-identity: the cutover formatter path reproduces the legacy inline
|
||||
wire byte-for-byte for growth (via fire.py), spotting, and halt (via firms.py).
|
||||
|
||||
4. Not-cutover parity: with no category cut over, handle_firms keeps the legacy
|
||||
eager-latch + stamps VERBATIM (byte-identical live behavior).
|
||||
3. Golden byte-identity: the cutover formatter path reproduces the legacy
|
||||
inline wire byte-for-byte for spotting and halt (via firms.py).
|
||||
|
||||
5. The unattributed_hotspot_cluster path is curated: below cluster_min_pixels
|
||||
it stays silent (returns None) -- F3 enabled the real broadcast path.
|
||||
|
||||
chore/ripout-2dii:
|
||||
- `handle_firms` (the dead Central NATS-envelope entrypoint this file used
|
||||
as a driver) has been REMOVED from `meshai.env.fire_fusion` -- zero live
|
||||
production callers. Driver helpers now call `ingest_hotspot_pixel`
|
||||
directly (the LIVE native entrypoint -- same underlying
|
||||
`_ingest_pixel_core` engine, so behavior is identical; see
|
||||
`tests/test_firms_native_fusion.py` for the primary native-path coverage).
|
||||
- The dead `is_cutover("wildfire_growth")` NEW-PATH in
|
||||
`env.fire_fusion._handle_pass_boundary` has been removed (it stamped
|
||||
hints for a formatter that was never invoked live for this category --
|
||||
wildfire_growth events are always fully precomposed, and the category is
|
||||
not in `cutover.NATIVE_ALWAYS_DECIDE`). `test_growth_wire_reuses_fire_formatter`
|
||||
and the whole `TestNotCutoverLegacyVerbatim` class (fully redundant with
|
||||
`tests/test_firms_native_fusion.py`'s `TestIngestGrowth` / `TestIngestSpotting`
|
||||
/ `TestIngestHalt`, which already assert the same not-cutover stamps +
|
||||
eager-latch behavior through the live `ingest_hotspot_pixel` entrypoint)
|
||||
were removed accordingly. `wildfire_growth`'s formatter registration was
|
||||
also removed (dead -- never reachable live); `TestRegistration` below now
|
||||
asserts it resolves to `None`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -32,7 +50,6 @@ import uuid
|
|||
import pytest
|
||||
|
||||
from meshai.notifications.formatters._budget import budget_for
|
||||
from meshai.notifications.formatters.fire import format as fire_format
|
||||
from meshai.notifications.formatters.firms import format as firms_format
|
||||
from meshai.notifications.gating.firms import decide as firms_decide
|
||||
from tests.harness.goldens import assert_byte_identical
|
||||
|
|
@ -59,16 +76,6 @@ def _isolate_db(tmp_path, monkeypatch):
|
|||
pdb._initialised.discard(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _no_cutover(monkeypatch):
|
||||
"""Default deploy state: nothing cut over → handler runs legacy verbatim."""
|
||||
monkeypatch.delenv("MESHAI_CUTOVER_CATEGORIES", raising=False)
|
||||
from meshai.notifications.cutover import _clear_cache
|
||||
_clear_cache()
|
||||
yield
|
||||
_clear_cache()
|
||||
|
||||
|
||||
def _cutover(monkeypatch, *cats):
|
||||
monkeypatch.setenv("MESHAI_CUTOVER_CATEGORIES", ",".join(cats))
|
||||
from meshai.notifications.cutover import _clear_cache
|
||||
|
|
@ -95,56 +102,39 @@ def _seed_fire(*, irwin_id, lat, lon, name="Stub Fire", **cols):
|
|||
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"):
|
||||
return {
|
||||
"data": {
|
||||
"adapter": "firms",
|
||||
"category": "wildfire_hotspot",
|
||||
"severity": "routine",
|
||||
"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 _pixel(*, lat, lon, acq_date="2026-06-06", acq_time="1200",
|
||||
frp=20.0, satellite="N20", confidence="high", brightness=320.0):
|
||||
"""Build a canonical FIRMS pixel dict for the LIVE ingest_hotspot_pixel
|
||||
entrypoint (mirrors tests/test_firms_native_fusion.py's `_pixel`)."""
|
||||
import datetime as _dt
|
||||
acq_epoch = int(_dt.datetime.strptime(
|
||||
f"{acq_date} {str(acq_time).zfill(4)}", "%Y-%m-%d %H%M"
|
||||
).replace(tzinfo=_dt.timezone.utc).timestamp())
|
||||
return {"lat": lat, "lon": lon, "frp": frp, "confidence": confidence,
|
||||
"brightness": brightness, "satellite": satellite,
|
||||
"acq_epoch": acq_epoch}
|
||||
|
||||
|
||||
_SUBJECT = "central.fire.hotspot.N20.high.us.id"
|
||||
|
||||
|
||||
def _drive_two_pass_growth(irwin_id, center_lat, center_lon):
|
||||
"""Seed a fire + pass A (5 px) + first pass-B pixel 1 mi N. Returns the
|
||||
(wire, data) from the boundary pixel that fires wildfire_growth."""
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
_seed_fire(irwin_id=irwin_id, lat=center_lat, lon=center_lon, name="Pine Gulch")
|
||||
for i in range(5):
|
||||
env = _envelope(lat=center_lat + 0.0001 * i,
|
||||
lon=center_lon + 0.0001 * (i - 2),
|
||||
acq_date="2026-06-06", acq_time=f"12{i:02d}",
|
||||
frp=20.0 + i)
|
||||
handle_firms(env, subject=_SUBJECT, data={}, now=1780747200 + i)
|
||||
pass_b_lat = center_lat + (1.0 / _MI_PER_DEG_LAT)
|
||||
env_b = _envelope(lat=pass_b_lat, lon=center_lon, acq_time="1800", frp=22.0)
|
||||
data = {}
|
||||
wire = handle_firms(env_b, subject=_SUBJECT, data=data, now=1780768800)
|
||||
return wire, data
|
||||
def _ingest(pixel, *, now):
|
||||
"""Feed one pixel through the LIVE native entrypoint and return the
|
||||
(wire, data) of the first broadcast it produced (or (None, {}))."""
|
||||
from meshai.env.fire_fusion import ingest_hotspot_pixel
|
||||
broadcasts = ingest_hotspot_pixel(pixel, now=now)
|
||||
if broadcasts:
|
||||
return broadcasts[0]
|
||||
return None, {}
|
||||
|
||||
|
||||
def _seed_pass_a_hex_then_close(irwin_id, center_lat, center_lon,
|
||||
start_now=1780747200):
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
_seed_fire(irwin_id=irwin_id, lat=center_lat, lon=center_lon, name=irwin_id)
|
||||
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)
|
||||
env = _envelope(lat=la, lon=lo, acq_time=f"12{i * 2:02d}")
|
||||
handle_firms(env, subject=_SUBJECT, data={}, now=start_now + i)
|
||||
_ingest(_pixel(lat=la, lon=lo, acq_time=f"12{i * 2:02d}"),
|
||||
now=start_now + i)
|
||||
|
||||
|
||||
def _offset_mi(lat, lon, north_mi, east_mi):
|
||||
|
|
@ -156,15 +146,11 @@ def _offset_mi(lat, lon, north_mi, east_mi):
|
|||
def _drive_spotting(irwin_id, center_lat, center_lon, now=1780768800):
|
||||
"""Seed hex pass A + closed perimeter, then a pass-B pixel 2 mi NE that
|
||||
fires wildfire_spotting. Returns (wire, data)."""
|
||||
from meshai.env.fire_fusion import handle_firms
|
||||
_seed_pass_a_hex_then_close(irwin_id, center_lat, center_lon)
|
||||
sp_lat, sp_lon = _offset_mi(center_lat, center_lon,
|
||||
north_mi=2.0 / math.sqrt(2),
|
||||
east_mi=2.0 / math.sqrt(2))
|
||||
env_b = _envelope(lat=sp_lat, lon=sp_lon, acq_time="1800")
|
||||
data = {}
|
||||
wire = handle_firms(env_b, subject=_SUBJECT, data=data, now=now)
|
||||
return wire, data
|
||||
return _ingest(_pixel(lat=sp_lat, lon=sp_lon, acq_time="1800"), now=now)
|
||||
|
||||
|
||||
def _seed_stale_fire(irwin_id, *, now_epoch, idle_hours=14, name="Cold Fire"):
|
||||
|
|
@ -183,9 +169,17 @@ def _seed_stale_fire(irwin_id, *, now_epoch, idle_hours=14, name="Cold Fire"):
|
|||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRegistration:
|
||||
def test_growth_formatter_is_fire(self):
|
||||
def test_growth_formatter_not_registered(self):
|
||||
"""wildfire_growth is intentionally UNREGISTERED (chore/ripout-2dii):
|
||||
its wire is always precomposed by env.fire_fusion._handle_pass_boundary
|
||||
-> env.fire_render._render, and the category is excluded from
|
||||
cutover.NATIVE_ALWAYS_DECIDE, so compose_mesh_message's
|
||||
formatter-invocation branch can never reach a registered formatter
|
||||
for it live -- the precomposed-title bypass always wins first. A
|
||||
registration reappearing here would be dead weight (or worse, a sign
|
||||
the precomposed bypass stopped covering this category); guard it."""
|
||||
from meshai.notifications.formatters import get_formatter
|
||||
assert get_formatter("wildfire_growth") is fire_format
|
||||
assert get_formatter("wildfire_growth") is None
|
||||
|
||||
@pytest.mark.parametrize("cat", ["wildfire_spotting", "wildfire_halted"])
|
||||
def test_spotting_halt_formatter_is_firms(self, cat):
|
||||
|
|
@ -327,20 +321,6 @@ class TestHaltDecideSequence:
|
|||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestFormatterGolden:
|
||||
def test_growth_wire_reuses_fire_formatter(self, monkeypatch):
|
||||
# Drive the real growth broadcast under cutover: handle_firms returns the
|
||||
# inline _render() wire AND populates data with the fire formatter hints.
|
||||
_cutover(monkeypatch, "wildfire_growth")
|
||||
try:
|
||||
wire, data = _drive_two_pass_growth("ID-GG", 42.0, -114.0)
|
||||
assert wire is not None and wire.startswith("🔥 Pine Gulch")
|
||||
rendered = fire_format(_FakeEvent(data, category="wildfire_growth"),
|
||||
now=0.0, budget=budget_for("wfigs"))
|
||||
assert_byte_identical(rendered, wire)
|
||||
finally:
|
||||
from meshai.notifications.cutover import _clear_cache
|
||||
_clear_cache()
|
||||
|
||||
def test_spotting_wire_golden(self, monkeypatch):
|
||||
_cutover(monkeypatch, "wildfire_spotting")
|
||||
try:
|
||||
|
|
@ -388,49 +368,13 @@ class TestFormatterGolden:
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 4. Not-cutover parity — legacy eager-latch + stamps preserved VERBATIM
|
||||
# 4. Not-cutover parity (legacy eager-latch + stamps) — REMOVED chore/ripout-2dii:
|
||||
# fully redundant with tests/test_firms_native_fusion.py's TestIngestGrowth /
|
||||
# TestIngestSpotting / TestIngestHalt, which already assert the same
|
||||
# category/_severity_override/_cooldown_suffix stamps + eager-latch behavior
|
||||
# through the LIVE ingest_hotspot_pixel entrypoint directly.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestNotCutoverLegacyVerbatim:
|
||||
def test_growth_stamps_and_no_latch(self, _no_cutover):
|
||||
wire, data = _drive_two_pass_growth("ID-GN", 42.0, -114.0)
|
||||
assert wire is not None and wire.startswith("🔥 Pine Gulch")
|
||||
assert "Moving N" in wire
|
||||
assert data["category"] == "wildfire_growth"
|
||||
assert data["_severity_override"] == "immediate"
|
||||
assert data["_cooldown_suffix"] == "ID-GN"
|
||||
# Legacy growth never attached a deferred commit.
|
||||
assert "_on_broadcast_committed" not in data
|
||||
|
||||
def test_spotting_eager_latch_stamped(self, _no_cutover):
|
||||
from meshai.persistence import get_db
|
||||
wire, data = _drive_spotting("ID-SN", 43.0, -115.0, now=1780768800)
|
||||
assert wire is not None and "spotting" in wire
|
||||
assert data["category"] == "wildfire_spotting"
|
||||
assert data["_severity_override"] == "immediate"
|
||||
# Legacy path stamps the latch EAGERLY with the handler `now`.
|
||||
latch = get_db().execute(
|
||||
"SELECT last_spotting_broadcast_at FROM fires WHERE irwin_id=?",
|
||||
("ID-SN",)).fetchone()[0]
|
||||
assert latch == 1780768800.0
|
||||
assert "_on_broadcast_committed" not in data
|
||||
|
||||
def test_halt_eager_latch_stamped(self, _no_cutover):
|
||||
from meshai.env.fire_fusion import _maybe_emit_halt
|
||||
from meshai.persistence import get_db
|
||||
now = 1780768800
|
||||
_seed_stale_fire("ID-HN", now_epoch=now, idle_hours=14)
|
||||
data = {}
|
||||
wire = _maybe_emit_halt(get_db(), data=data, now=now)
|
||||
assert wire == "🔥 Cold Fire no growth in 14h"
|
||||
assert data["category"] == "wildfire_halted"
|
||||
assert data["_severity_override"] == "routine"
|
||||
latch = get_db().execute(
|
||||
"SELECT halt_broadcast_at FROM fires WHERE irwin_id=?",
|
||||
("ID-HN",)).fetchone()[0]
|
||||
assert latch == float(now)
|
||||
assert "_on_broadcast_committed" not in data
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 5. Cluster path: below-threshold is silent (F3 enabled the path; a lone
|
||||
|
|
|
|||
|
|
@ -277,28 +277,13 @@ def test_fires_has_tombstoned_at_column():
|
|||
assert "tombstoned_at" in cols
|
||||
|
||||
|
||||
def test_wfigs_tombstone_stamps_column():
|
||||
"""A tombstone envelope sets fires.tombstoned_at."""
|
||||
from meshai.env.fire_render import handle_wfigs
|
||||
conn = get_db()
|
||||
# Seed an active fire row.
|
||||
irwin = "TOMB-1"
|
||||
now = int(time.time())
|
||||
conn.execute(
|
||||
"INSERT INTO fires(irwin_id, incident_name, incident_type, "
|
||||
"current_acres, current_contained_pct, lat, lon, county, state, "
|
||||
"declared_at, last_event_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(irwin, "Test", "WF", 100, 10, 43.6, -116.2, "Ada", "ID", now - 3600, now),
|
||||
)
|
||||
n = {"_kind": "wfigs_tombstone", "irwin_id": irwin}
|
||||
envelope = {"data": {"adapter": "fires", "category": "fire.incident.removed",
|
||||
"id": irwin}}
|
||||
handle_wfigs(n, envelope, "central.fire.incident.removed.id",
|
||||
data=None, now=now)
|
||||
row = conn.execute("SELECT tombstoned_at FROM fires WHERE irwin_id=?",
|
||||
(irwin,)).fetchone()
|
||||
assert row["tombstoned_at"] is not None
|
||||
# chore/ripout-2dii: test_wfigs_tombstone_stamps_column REMOVED. It asserted
|
||||
# handle_wfigs's OWN inline `UPDATE fires SET tombstoned_at=...` write -- a
|
||||
# dead-entrypoint-only side effect (handle_wfigs is gone, zero live
|
||||
# production callers; the native WFIGS path -- env/fires.py -- never emits a
|
||||
# `_kind=wfigs_tombstone` event, so nothing in the live system currently
|
||||
# stamps tombstoned_at). The column itself remains covered by
|
||||
# test_fires_has_tombstoned_at_column above.
|
||||
|
||||
|
||||
def _enable_wfigs_reminders():
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ broadcasts back through the normal pipeline guards (Grouper, cooldown).
|
|||
This file's expectations were written before that downgrade and never
|
||||
updated -- "immediate" here would be reverting a deliberate, documented
|
||||
incident fix.
|
||||
|
||||
chore/ripout-2dii: `handle_wfigs` (the dead Central NATS-envelope entrypoint
|
||||
T1/T2 used to drive) has been REMOVED from `meshai.env.fire_render` -- zero
|
||||
live production callers. T1/T2 now drive `gating.fire.decide` directly (the
|
||||
LIVE, shared decider -- `_kind="wfigs_tombstone"` is its all-clear branch,
|
||||
reused by the shared fire formatter). `test_commit_callback_flips_handled`
|
||||
(which asserted handle_wfigs's OWN event_log-row flip on commit -- a
|
||||
Central-only concept the native path never used) was deleted with it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -23,7 +31,7 @@ import time
|
|||
|
||||
import pytest
|
||||
|
||||
from meshai.env.fire_render import handle_wfigs
|
||||
from meshai.notifications.gating.fire import decide as fire_decide
|
||||
from meshai.notifications.env_reporter import EnvReporter
|
||||
from meshai.persistence import get_db
|
||||
|
||||
|
|
@ -49,7 +57,12 @@ def _seed_fire(conn, *, irwin_id, name, acres, contained=None,
|
|||
|
||||
|
||||
class TestTombstoneSeverityAndCommitHandles:
|
||||
"""T1: tombstone branch sets priority severity and attaches commit handles."""
|
||||
"""T1: tombstone branch sets priority severity and attaches commit handles.
|
||||
|
||||
Drives gating.fire.decide() directly (chore/ripout-2dii: handle_wfigs, the
|
||||
dead entrypoint this used to wrap, is gone). decide()'s data_patch carries
|
||||
the same stamps the dispatcher reads off event.data.
|
||||
"""
|
||||
|
||||
def test_severity_is_priority(self):
|
||||
conn = get_db()
|
||||
|
|
@ -58,20 +71,14 @@ class TestTombstoneSeverityAndCommitHandles:
|
|||
acres=500, contained=80,
|
||||
last_broadcast_at=now - 3600)
|
||||
|
||||
data = {}
|
||||
wire = handle_wfigs(
|
||||
normalized={"_kind": "wfigs_tombstone", "irwin_id": "FIRE-001"},
|
||||
envelope={"data": {"category": "wildfire", "severity": "immediate"}},
|
||||
subject="wfigs.tombstone",
|
||||
data=data,
|
||||
now=now,
|
||||
)
|
||||
assert wire is not None, "tombstone should produce wire for previously-broadcast fire"
|
||||
gate = fire_decide({"_kind": "wfigs_tombstone", "irwin_id": "FIRE-001"},
|
||||
source="wfigs", now=float(now))
|
||||
assert gate.broadcast is True, "tombstone should broadcast for previously-broadcast fire"
|
||||
# See module docstring: fire severity was deliberately downgraded
|
||||
# from "immediate" to "priority" (commit 2f677e85) so fire
|
||||
# broadcasts flow through the normal Grouper/cooldown guards.
|
||||
assert data.get("_severity_override") == "priority", (
|
||||
f"expected priority, got {data.get('_severity_override')}")
|
||||
assert gate.data_patch.get("_severity_override") == "priority", (
|
||||
f"expected priority, got {gate.data_patch.get('_severity_override')}")
|
||||
|
||||
def test_commit_handles_attached(self):
|
||||
conn = get_db()
|
||||
|
|
@ -80,19 +87,11 @@ class TestTombstoneSeverityAndCommitHandles:
|
|||
acres=1000, contained=95,
|
||||
last_broadcast_at=now - 7200)
|
||||
|
||||
data = {}
|
||||
wire = handle_wfigs(
|
||||
normalized={"_kind": "wfigs_tombstone", "irwin_id": "FIRE-002"},
|
||||
envelope={"data": {"category": "wildfire", "severity": "immediate"}},
|
||||
subject="wfigs.tombstone",
|
||||
data=data,
|
||||
now=now,
|
||||
)
|
||||
assert wire is not None
|
||||
assert "_on_broadcast_committed" in data, "commit callback missing"
|
||||
assert "_broadcast_audit" in data, "broadcast audit descriptor missing"
|
||||
assert "_cooldown_suffix" in data, "cooldown suffix missing"
|
||||
assert data["_cooldown_suffix"] == "FIRE-002"
|
||||
gate = fire_decide({"_kind": "wfigs_tombstone", "irwin_id": "FIRE-002"},
|
||||
source="wfigs", now=float(now))
|
||||
assert gate.broadcast is True
|
||||
assert callable(gate.commit), "commit callback missing"
|
||||
assert gate.data_patch.get("_cooldown_suffix") == "FIRE-002"
|
||||
|
||||
def test_dedup_suffix_is_closed(self):
|
||||
conn = get_db()
|
||||
|
|
@ -101,61 +100,32 @@ class TestTombstoneSeverityAndCommitHandles:
|
|||
acres=200, contained=100,
|
||||
last_broadcast_at=now - 600)
|
||||
|
||||
data = {}
|
||||
wire = handle_wfigs(
|
||||
normalized={"_kind": "wfigs_tombstone", "irwin_id": "FIRE-003"},
|
||||
envelope={"data": {"category": "wildfire", "severity": "immediate"}},
|
||||
subject="wfigs.tombstone",
|
||||
data=data,
|
||||
now=now,
|
||||
)
|
||||
assert wire is not None
|
||||
assert data.get("_dedup_suffix") == "closed", (
|
||||
f"expected 'closed', got {data.get('_dedup_suffix')}")
|
||||
|
||||
def test_commit_callback_flips_handled(self):
|
||||
"""The commit callback should flip event_log.handled to 1."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_fire(conn, irwin_id="FIRE-004", name="Callback Fire",
|
||||
acres=300, contained=50,
|
||||
last_broadcast_at=now - 1800)
|
||||
|
||||
data = {}
|
||||
wire = handle_wfigs(
|
||||
normalized={"_kind": "wfigs_tombstone", "irwin_id": "FIRE-004"},
|
||||
envelope={"data": {"category": "wildfire", "severity": "immediate"}},
|
||||
subject="wfigs.tombstone",
|
||||
data=data,
|
||||
now=now,
|
||||
)
|
||||
assert wire is not None
|
||||
assert "_on_broadcast_committed" in data
|
||||
|
||||
# Before commit: event_log row should have handled=0
|
||||
row_before = conn.execute(
|
||||
"SELECT id, handled FROM event_log WHERE event_id_external='FIRE-004' "
|
||||
"ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert row_before is not None, "event_log row should exist"
|
||||
assert row_before["handled"] == 0, "should be unhandled before commit"
|
||||
|
||||
# Fire the commit callback
|
||||
data["_on_broadcast_committed"](float(now))
|
||||
|
||||
# After commit: handled should be 1
|
||||
row_after = conn.execute(
|
||||
"SELECT handled FROM event_log WHERE id=?",
|
||||
(row_before["id"],)
|
||||
).fetchone()
|
||||
assert row_after["handled"] == 1, "should be handled=1 after commit callback"
|
||||
gate = fire_decide({"_kind": "wfigs_tombstone", "irwin_id": "FIRE-003"},
|
||||
source="wfigs", now=float(now))
|
||||
assert gate.broadcast is True
|
||||
assert gate.data_patch.get("_dedup_suffix") == "closed", (
|
||||
f"expected 'closed', got {gate.data_patch.get('_dedup_suffix')}")
|
||||
|
||||
|
||||
class TestTombstoneAfterNewBroadcast:
|
||||
"""T2: closure dispatches when a New broadcast went out earlier."""
|
||||
"""T2: closure dispatches when a New broadcast went out earlier.
|
||||
|
||||
Drives gating.fire.decide() directly (chore/ripout-2dii: handle_wfigs is
|
||||
gone). The wire itself is rendered by the shared, LIVE fire formatter
|
||||
(notifications/formatters/fire.py::format) from decide()'s data_patch --
|
||||
the same contract the native WFIGS path uses.
|
||||
"""
|
||||
|
||||
def test_closure_wire_after_prior_broadcast(self):
|
||||
"""Fire that was broadcast 10 min ago gets a closure wire on tombstone."""
|
||||
from meshai.notifications.formatters.fire import format as fire_format
|
||||
from meshai.notifications.formatters._budget import budget_for
|
||||
|
||||
class _FakeEvent:
|
||||
def __init__(self, data, category=None):
|
||||
self.data = data
|
||||
self.category = category
|
||||
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_fire(conn, irwin_id="IA-1", name="IA 1",
|
||||
|
|
@ -163,22 +133,19 @@ class TestTombstoneAfterNewBroadcast:
|
|||
last_broadcast_at=now - 600, # 10 min ago
|
||||
last_event_at=now - 600)
|
||||
|
||||
data = {}
|
||||
wire = handle_wfigs(
|
||||
normalized={"_kind": "wfigs_tombstone", "irwin_id": "IA-1"},
|
||||
envelope={"data": {"category": "wildfire", "severity": "immediate"}},
|
||||
subject="wfigs.tombstone",
|
||||
data=data,
|
||||
now=now,
|
||||
)
|
||||
assert wire is not None, "tombstone should produce wire"
|
||||
assert "✅" in wire, "closure wire should contain checkmark"
|
||||
assert "IA 1" in wire, "closure wire should name the fire"
|
||||
assert data["category"] == "wildfire_closed"
|
||||
gate = fire_decide({"_kind": "wfigs_tombstone", "irwin_id": "IA-1"},
|
||||
source="wfigs", now=float(now))
|
||||
assert gate.broadcast is True, "tombstone should broadcast"
|
||||
assert gate.data_patch["category"] == "wildfire_closed"
|
||||
# See module docstring: downgraded from "immediate" to "priority"
|
||||
# by commit 2f677e85 to prevent Grouper/cooldown bypass.
|
||||
assert data["_severity_override"] == "priority"
|
||||
assert callable(data.get("_on_broadcast_committed"))
|
||||
assert gate.data_patch["_severity_override"] == "priority"
|
||||
assert callable(gate.commit)
|
||||
|
||||
wire = fire_format(_FakeEvent(gate.data_patch, category="wildfire_closed"),
|
||||
now=0.0, budget=budget_for("wfigs"))
|
||||
assert "✅" in wire, "closure wire should contain checkmark"
|
||||
assert "IA 1" in wire, "closure wire should name the fire"
|
||||
|
||||
def test_no_wire_when_never_broadcast(self):
|
||||
"""Fire that was never broadcast should NOT get a closure wire."""
|
||||
|
|
@ -189,36 +156,9 @@ class TestTombstoneAfterNewBroadcast:
|
|||
last_broadcast_at=None, # never broadcast
|
||||
last_event_at=now - 600)
|
||||
|
||||
data = {}
|
||||
wire = handle_wfigs(
|
||||
normalized={"_kind": "wfigs_tombstone", "irwin_id": "SILENT-1"},
|
||||
envelope={"data": {"category": "wildfire", "severity": "immediate"}},
|
||||
subject="wfigs.tombstone",
|
||||
data=data,
|
||||
now=now,
|
||||
)
|
||||
assert wire is None, "no closure wire for never-broadcast fire"
|
||||
|
||||
def test_tombstoned_at_stamped(self):
|
||||
"""Tombstone should stamp tombstoned_at on the fires row."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_fire(conn, irwin_id="STAMP-1", name="Stamp Fire",
|
||||
acres=10, contained=50,
|
||||
last_broadcast_at=now - 3600)
|
||||
|
||||
handle_wfigs(
|
||||
normalized={"_kind": "wfigs_tombstone", "irwin_id": "STAMP-1"},
|
||||
envelope={"data": {"category": "wildfire", "severity": "immediate"}},
|
||||
subject="wfigs.tombstone",
|
||||
data={},
|
||||
now=now,
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT tombstoned_at FROM fires WHERE irwin_id='STAMP-1'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["tombstoned_at"] == now
|
||||
gate = fire_decide({"_kind": "wfigs_tombstone", "irwin_id": "SILENT-1"},
|
||||
source="wfigs", now=float(now))
|
||||
assert gate.broadcast is False, "no closure wire for never-broadcast fire"
|
||||
|
||||
|
||||
class TestEnvSummaryExcludesContainedTombstoned:
|
||||
|
|
|
|||
|
|
@ -1,41 +1,41 @@
|
|||
"""Tests for env/fire_render.py's handle_wfigs() -- WFIGS persistence wire-up.
|
||||
"""Tests for env/fire_render.py's shared WFIGS wire-render + anchor helpers.
|
||||
|
||||
Covers:
|
||||
(a) parse clean active-incident envelope (all fields populated)
|
||||
(b) acres fallback chain: top-null -> raw.DiscoveryAcres used
|
||||
(c) acres absent at every level -> renders "N/A"
|
||||
(d) IncidentName="IA 1" placeholder passes through verbatim
|
||||
(e) tombstone subject -> handler returns None + event_log row handled=0
|
||||
(f) perimeter subject -> handler returns None + event_log row handled=0
|
||||
(g) NEW IRWIN -> "New:" prefix + fires INSERT + mesh_broadcasts_out audit row
|
||||
(h) known IRWIN no change -> drop silently, last_broadcast_* unchanged
|
||||
(i) known IRWIN acres up but <8h elapsed -> drop, last_broadcast_* unchanged
|
||||
(j) known IRWIN acres up + >=8h elapsed -> "Update:" prefix + audit row
|
||||
chore/ripout-2dii: `handle_wfigs` (the dead Central NATS-envelope entrypoint)
|
||||
has been REMOVED from `meshai.env.fire_render` -- it had zero live production
|
||||
callers (Central's consumer that drove it is gone). Tests that ONLY exercised
|
||||
`handle_wfigs`'s own envelope-parsing / New-Update-cooldown decision / audit-row
|
||||
contract (with no live equivalent -- that decision now lives in the LIVE
|
||||
`gating.fire.decide`, already covered end-to-end via the real native adapter in
|
||||
`tests/test_fire_native_growth.py`) were deleted alongside it.
|
||||
|
||||
What remains here:
|
||||
(k) location anchor priority: geocoder.city > nearest_town > landclass > county
|
||||
-- `_location_anchor` is shared, LIVE code (used by `_render`, which is
|
||||
called directly on the FIRMS `wildfire_growth` path). Rewritten to call
|
||||
`_render` directly instead of routing through the dead handler.
|
||||
- "size unknown" / "containment unknown" missing-acres rendering -- same
|
||||
live-`_render` rationale, rewritten to call `_render` directly.
|
||||
- budget-fit worst case / date-only discovery -- already called `_render`
|
||||
directly (never used handle_wfigs); unchanged.
|
||||
|
||||
handle_wfigs (env/fire_render.py) has no live production caller -- Central's
|
||||
NATS consumer that drove it is gone -- but it remains the parity-tested
|
||||
legacy contract for the WFIGS wire format (see that module's docstring), and
|
||||
`_location_anchor`, which it shares with the LIVE `_render` (used on the
|
||||
FIRMS fire-growth path), is real regression-tested surface here. It expects
|
||||
its input pre-shaped into a flat "normalized" dict; that shaping used to be
|
||||
done by the WFIGS dispatch branch of the deleted Central-envelope adapter-
|
||||
normalizer module's `normalize()` + its `_parse_wfigs_incidents` helper.
|
||||
`_normalize_wfigs()` below is a verbatim-logic local replica of that
|
||||
dispatch, scoped to the wfigs_incidents/wfigs_perimeters envelope shapes the
|
||||
fixtures in this file build -- it exists so this test file has no dependency
|
||||
on that (now fully removed) module.
|
||||
The envelope builders (`_make_active_envelope`, `_make_tombstone`,
|
||||
`_make_perimeter`, `_normalize_wfigs`) are KEPT: they are shared fixture
|
||||
infrastructure imported by other test files (test_fire_refactor.py,
|
||||
test_fire_tracker_phase1.py, test_fire_age_gate.py) to build canonical dicts
|
||||
for the LIVE `gating.fire.decide` / `notifications.formatters.fire.format`.
|
||||
`_normalize_wfigs` is a verbatim-logic local replica of the WFIGS dispatch
|
||||
branch of the deleted Central-envelope adapter-normalizer module's
|
||||
`normalize()` + `_parse_wfigs_incidents` helper (that module is already gone
|
||||
from production; this replica exists purely so fixture-building has no
|
||||
dependency on it).
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from meshai.env.fire_render import (
|
||||
WFIGS_BROADCAST_COOLDOWN_S,
|
||||
handle_wfigs,
|
||||
_render as _wfigs_render,
|
||||
)
|
||||
from meshai.persistence import close_thread_connection, init_db
|
||||
|
|
@ -302,275 +302,44 @@ def _make_perimeter(irwin_id=_IRWIN_A, state="ID", county="Cassia",
|
|||
|
||||
|
||||
# ============================================================================
|
||||
# (a) parse a clean active-incident envelope with all fields
|
||||
# Missing-acres rendering -- LIVE `_render` behavior, called directly
|
||||
# (formerly driven through the dead handle_wfigs).
|
||||
# ============================================================================
|
||||
def test_a_parse_clean_active_envelope(mem_db, no_photon):
|
||||
env = _make_active_envelope()
|
||||
n = _normalize_wfigs(env)
|
||||
assert n is not None
|
||||
assert n["_kind"] == "wfigs_incident"
|
||||
assert n["irwin_id"] == _IRWIN_A
|
||||
assert n["incident_name"] == "Cache Peak Fire"
|
||||
assert n["incident_type"] == "wildfire"
|
||||
assert n["acres"] == 1847.0
|
||||
assert n["contained_pct"] == 23
|
||||
assert n["county"] == "Cassia"
|
||||
assert n["state"] == "ID"
|
||||
# FireDiscoveryDateTime epoch-ms -> epoch-s conversion
|
||||
assert n["declared_at_epoch"] == 1780529163
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# (b) null top-level acres -> raw.DiscoveryAcres fallback used
|
||||
# ============================================================================
|
||||
def test_b_acres_fallback_to_raw_discovery_acres(mem_db, no_photon):
|
||||
env = _make_active_envelope(daily_acres=None, pct_contained=None,
|
||||
raw_discovery_acres=0.1,
|
||||
raw_pct_contained=0)
|
||||
n = _normalize_wfigs(env)
|
||||
assert n["acres"] == 0.1
|
||||
assert n["contained_pct"] == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# (c) no acres anywhere -> renders "N/A"
|
||||
# ============================================================================
|
||||
def test_c_acres_missing_renders_na(mem_db, no_photon):
|
||||
def test_acres_missing_renders_na(mem_db, no_photon):
|
||||
env = _make_active_envelope(name="IA 7", daily_acres=None,
|
||||
pct_contained=None,
|
||||
irwin_id=_IRWIN_C,
|
||||
landclass="Sawtooth National Forest")
|
||||
wire = handle_wfigs(_normalize_wfigs(env), env, env["subject"], now=1_000_000)
|
||||
n = _normalize_wfigs(env)
|
||||
wire = _wfigs_render(n, prefix="New")
|
||||
assert wire is not None
|
||||
assert "size unknown" in wire
|
||||
assert "containment unknown" in wire
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# (d) "IA 1" placeholder name passes through verbatim
|
||||
# ============================================================================
|
||||
def test_d_ia_placeholder_passthrough(mem_db, no_photon):
|
||||
def test_ia_placeholder_passthrough(mem_db, no_photon):
|
||||
env = _make_active_envelope(name="IA 1", county="Elmore",
|
||||
daily_acres=None, pct_contained=None,
|
||||
landclass="Sawtooth National Forest",
|
||||
irwin_id=_IRWIN_B)
|
||||
wire = handle_wfigs(_normalize_wfigs(env), env, env["subject"], now=1_000_000)
|
||||
n = _normalize_wfigs(env)
|
||||
wire = _wfigs_render(n, prefix="New")
|
||||
assert wire is not None
|
||||
assert "IA 1" in wire
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# (e) tombstone subject -> handler returns None + event_log handled=0
|
||||
# ============================================================================
|
||||
def test_e_tombstone_returns_none_and_logs(mem_db, no_photon):
|
||||
env = _make_tombstone()
|
||||
n = _normalize_wfigs(env)
|
||||
assert n["_kind"] == "wfigs_tombstone"
|
||||
out = handle_wfigs(n, env, env["subject"], now=2_000_000)
|
||||
assert out is None
|
||||
row = mem_db.execute(
|
||||
"SELECT source, category, handled, table_name, table_pk, nats_subject "
|
||||
"FROM event_log WHERE event_id_external=?", (_IRWIN_A,)).fetchone()
|
||||
assert row is not None
|
||||
assert row["source"] == "wfigs_incidents"
|
||||
assert row["category"] == "fire.incident.removed"
|
||||
assert row["handled"] == 0
|
||||
assert row["table_name"] is None
|
||||
assert row["table_pk"] == _IRWIN_A
|
||||
assert row["nats_subject"] == "central.fire.incident.removed.id"
|
||||
# No row in fires.
|
||||
n_fires = mem_db.execute("SELECT COUNT(*) AS n FROM fires").fetchone()["n"]
|
||||
assert n_fires == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# (f) perimeter subject -> same as tombstone
|
||||
# ============================================================================
|
||||
def test_f_perimeter_returns_none_and_logs(mem_db, no_photon):
|
||||
env = _make_perimeter()
|
||||
n = _normalize_wfigs(env)
|
||||
assert n["_kind"] == "wfigs_perimeter"
|
||||
out = handle_wfigs(n, env, env["subject"], now=3_000_000)
|
||||
assert out is None
|
||||
row = mem_db.execute(
|
||||
"SELECT source, handled FROM event_log WHERE event_id_external=?",
|
||||
(_IRWIN_A,)).fetchone()
|
||||
assert row is not None
|
||||
assert row["source"] == "wfigs_perimeters"
|
||||
assert row["handled"] == 0
|
||||
n_fires = mem_db.execute("SELECT COUNT(*) AS n FROM fires").fetchone()["n"]
|
||||
assert n_fires == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# (g) NEW IRWIN -> "New:" prefix + fires INSERT + mesh_broadcasts_out audit
|
||||
# ============================================================================
|
||||
def test_g_new_irwin_inserts_and_broadcasts(mem_db, no_photon):
|
||||
env = _make_active_envelope(geocoder_city="Burley") # avoids Photon path
|
||||
now = 5_000_000
|
||||
data = {}
|
||||
wire = handle_wfigs(_normalize_wfigs(env), env, env["subject"],
|
||||
data=data, now=now)
|
||||
assert wire is not None
|
||||
assert wire.startswith("🔥 Cache Peak Fire — New")
|
||||
assert "Burley" in wire
|
||||
assert "1,847 ac" in wire
|
||||
assert "containment 23%" in wire
|
||||
# Budget-fit rework: no unique-fire-id line, no bold markdown.
|
||||
assert "ID:" not in wire
|
||||
assert "**" not in wire
|
||||
|
||||
# v0.5.8b: handler INSERTs the fires row with last_broadcast_*=NULL,
|
||||
# then attaches a commit callback. The dispatcher fires the callback
|
||||
# on successful broadcast; we simulate that here.
|
||||
fr_pre = mem_db.execute(
|
||||
"SELECT last_broadcast_at FROM fires WHERE irwin_id=?",
|
||||
(_IRWIN_A,)).fetchone()
|
||||
assert fr_pre["last_broadcast_at"] is None
|
||||
data["_on_broadcast_committed"](float(now))
|
||||
fr = mem_db.execute(
|
||||
"SELECT current_acres, last_broadcast_at, last_broadcast_acres, "
|
||||
"last_broadcast_contained, last_event_at "
|
||||
"FROM fires WHERE irwin_id=?", (_IRWIN_A,)).fetchone()
|
||||
assert fr is not None
|
||||
assert fr["current_acres"] == 1847.0
|
||||
assert fr["last_broadcast_at"] == now
|
||||
assert fr["last_broadcast_acres"] == 1847.0
|
||||
assert fr["last_broadcast_contained"] == 23
|
||||
assert fr["last_event_at"] == now
|
||||
|
||||
# event_log row logged with handled=1.
|
||||
el = mem_db.execute(
|
||||
"SELECT handled, table_name, table_pk FROM event_log "
|
||||
"WHERE event_id_external=?", (_IRWIN_A,)).fetchone()
|
||||
assert el["handled"] == 1
|
||||
assert el["table_name"] == "fires"
|
||||
assert el["table_pk"] == _IRWIN_A
|
||||
|
||||
# v0.5.8b: mesh_broadcasts_out is inserted by the dispatcher
|
||||
# (test_cold_start_grace covers that path). The handler only signals
|
||||
# via data["_broadcast_audit"] that an audit row is wanted.
|
||||
assert data["_broadcast_audit"] == {"table": "fires", "pk": _IRWIN_A}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# (h) known IRWIN no-change -> drop silently, last_broadcast_* unchanged
|
||||
# ============================================================================
|
||||
def test_h_known_irwin_no_change_drops(mem_db, no_photon):
|
||||
# Use wall-clock-adjacent timestamps so _cleanup_stale_fires doesn't
|
||||
# delete the row (it uses real time.time() with a 7d cutoff internally).
|
||||
# Anchor the fire's discovery date 2d before that `now` so it stays
|
||||
# inside the 14d fire age-gate -- otherwise the static fixture date
|
||||
# (2026-06-03) is now stale vs wall-clock and the first-sight "New"
|
||||
# broadcast this test depends on would be (correctly) suppressed.
|
||||
first_now = int(time.time())
|
||||
env = _make_active_envelope(geocoder_city="Burley",
|
||||
fire_discovery_dt_ms=(first_now - 2 * 86400) * 1000)
|
||||
data0 = {}
|
||||
handle_wfigs(_normalize_wfigs(env), env, env["subject"],
|
||||
data=data0, now=first_now)
|
||||
# v0.5.8b: dispatcher commit closes the broadcast.
|
||||
data0["_on_broadcast_committed"](float(first_now))
|
||||
|
||||
# Re-publish the same incident exactly 30 min later: same acres + contained.
|
||||
later = first_now + 1800
|
||||
out = handle_wfigs(_normalize_wfigs(env), env, env["subject"], now=later)
|
||||
assert out is None
|
||||
|
||||
fr = mem_db.execute(
|
||||
"SELECT last_broadcast_at, last_broadcast_acres, last_broadcast_contained, "
|
||||
"last_event_at FROM fires WHERE irwin_id=?", (_IRWIN_A,)).fetchone()
|
||||
# last_broadcast_* unchanged from the original.
|
||||
assert fr["last_broadcast_at"] == first_now
|
||||
assert fr["last_broadcast_acres"] == 1847.0
|
||||
assert fr["last_broadcast_contained"] == 23
|
||||
# last_event_at was refreshed.
|
||||
assert fr["last_event_at"] == later
|
||||
|
||||
# v0.5.8b: mesh_broadcasts_out is inserted by the dispatcher, not the
|
||||
# handler -- this test never invokes a real dispatcher, so count is 0.
|
||||
cnt = mem_db.execute(
|
||||
"SELECT COUNT(*) AS n FROM mesh_broadcasts_out WHERE source_event_pk=?",
|
||||
(_IRWIN_A,)).fetchone()["n"]
|
||||
assert cnt == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# (i) known IRWIN acres up but <8h elapsed -> drop, last_broadcast_* unchanged
|
||||
# ============================================================================
|
||||
def test_i_known_irwin_change_inside_cooldown_drops(mem_db, no_photon):
|
||||
# Wall-clock `now` so _cleanup_stale_fires (real time.time(), 7d cutoff)
|
||||
# keeps the row; anchor discovery 2d earlier so the fire stays inside
|
||||
# the 14d fire age-gate (the static fixture date is now stale vs
|
||||
# wall-clock and would suppress the first-sight "New" broadcast).
|
||||
_base = int(time.time())
|
||||
env_initial = _make_active_envelope(
|
||||
geocoder_city="Burley",
|
||||
fire_discovery_dt_ms=(_base - 2 * 86400) * 1000)
|
||||
data0 = {}
|
||||
handle_wfigs(_normalize_wfigs(env_initial), env_initial,
|
||||
env_initial["subject"], data=data0, now=_base)
|
||||
data0["_on_broadcast_committed"](float(_base))
|
||||
|
||||
# Bigger fire, but only 4h later -- inside cooldown. Same discovery
|
||||
# date as the initial envelope (same fire, still inside the age-gate).
|
||||
env_grown = _make_active_envelope(
|
||||
geocoder_city="Burley", daily_acres=3000.0, pct_contained=23,
|
||||
fire_discovery_dt_ms=(_base - 2 * 86400) * 1000)
|
||||
later = _base + 4 * 3600
|
||||
out = handle_wfigs(_normalize_wfigs(env_grown), env_grown,
|
||||
env_grown["subject"], now=later)
|
||||
assert out is None
|
||||
|
||||
fr = mem_db.execute(
|
||||
"SELECT last_broadcast_at, last_broadcast_acres, last_broadcast_contained, "
|
||||
"current_acres FROM fires WHERE irwin_id=?", (_IRWIN_A,)).fetchone()
|
||||
assert fr["last_broadcast_at"] == _base
|
||||
assert fr["last_broadcast_acres"] == 1847.0
|
||||
assert fr["last_broadcast_contained"] == 23
|
||||
# current_acres was refreshed to the new value.
|
||||
assert fr["current_acres"] == 3000.0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# (j) known IRWIN acres up AND >=8h elapsed -> "Update:" + audit row
|
||||
# ============================================================================
|
||||
def test_j_known_irwin_change_after_cooldown_broadcasts(mem_db, no_photon):
|
||||
env_initial = _make_active_envelope(geocoder_city="Burley")
|
||||
data_j0 = {}
|
||||
handle_wfigs(_normalize_wfigs(env_initial), env_initial,
|
||||
env_initial["subject"], data=data_j0, now=5_000_000)
|
||||
data_j0["_on_broadcast_committed"](float(5_000_000))
|
||||
|
||||
env_grown = _make_active_envelope(geocoder_city="Burley",
|
||||
daily_acres=3000.0, pct_contained=35)
|
||||
later = 5_000_000 + WFIGS_BROADCAST_COOLDOWN_S
|
||||
data2 = {}
|
||||
out = handle_wfigs(_normalize_wfigs(env_grown), env_grown,
|
||||
env_grown["subject"], data=data2, now=later)
|
||||
assert out is not None
|
||||
assert out.startswith("🔥 Cache Peak Fire — Update")
|
||||
assert "3,000 ac" in out
|
||||
assert "containment 35%" in out
|
||||
|
||||
# Simulate dispatcher commit.
|
||||
data2["_on_broadcast_committed"](float(later))
|
||||
fr = mem_db.execute(
|
||||
"SELECT last_broadcast_at, last_broadcast_acres, last_broadcast_contained "
|
||||
"FROM fires WHERE irwin_id=?", (_IRWIN_A,)).fetchone()
|
||||
assert fr["last_broadcast_at"] == later
|
||||
assert fr["last_broadcast_acres"] == 3000.0
|
||||
assert fr["last_broadcast_contained"] == 35
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# (k) location anchor priority -- city > nearest_town > landclass > county
|
||||
# location anchor priority -- city > nearest_town > landclass > county
|
||||
# `_location_anchor` is shared LIVE code (used by `_render`).
|
||||
# ============================================================================
|
||||
def test_k_anchor_geocoder_city_wins(mem_db, no_photon):
|
||||
env = _make_active_envelope(geocoder_city="Twin Falls",
|
||||
landclass="Sawtooth NF",
|
||||
county="Cassia")
|
||||
wire = handle_wfigs(_normalize_wfigs(env), env, env["subject"], now=1)
|
||||
n = _normalize_wfigs(env)
|
||||
wire = _wfigs_render(n, prefix="New")
|
||||
assert "Twin Falls" in wire
|
||||
assert "Sawtooth NF" not in wire
|
||||
assert "Cassia Co" not in wire
|
||||
|
|
@ -586,8 +355,9 @@ def test_k_anchor_falls_to_nearest_town(monkeypatch, mem_db):
|
|||
env = _make_active_envelope(geocoder_city=None,
|
||||
landclass="Sawtooth NF",
|
||||
county="Cassia")
|
||||
wire = handle_wfigs(_normalize_wfigs(env), env, env["subject"], now=1)
|
||||
# Handler now resolves anchor via town_anchors table (Burley @ 42.536, -113.793)
|
||||
n = _normalize_wfigs(env)
|
||||
wire = _wfigs_render(n, prefix="New")
|
||||
# Resolves anchor via town_anchors table (Burley @ 42.536, -113.793)
|
||||
assert "Burley" in wire
|
||||
|
||||
|
||||
|
|
@ -599,8 +369,9 @@ def test_k_anchor_falls_to_landclass(monkeypatch, mem_db):
|
|||
env = _make_active_envelope(geocoder_city=None,
|
||||
landclass="Sawtooth National Forest",
|
||||
county="Cassia")
|
||||
wire = handle_wfigs(_normalize_wfigs(env), env, env["subject"], now=1)
|
||||
# Handler resolves nearest town from town_anchors table, overriding landclass
|
||||
n = _normalize_wfigs(env)
|
||||
wire = _wfigs_render(n, prefix="New")
|
||||
# Resolves nearest town from town_anchors table, overriding landclass
|
||||
assert "Burley" in wire
|
||||
|
||||
|
||||
|
|
@ -611,8 +382,9 @@ def test_k_anchor_falls_to_county(monkeypatch, mem_db):
|
|||
)
|
||||
env = _make_active_envelope(geocoder_city=None, landclass=None,
|
||||
county="Cassia", state="ID")
|
||||
wire = handle_wfigs(_normalize_wfigs(env), env, env["subject"], now=1)
|
||||
# Handler resolves nearest town from town_anchors table
|
||||
n = _normalize_wfigs(env)
|
||||
wire = _wfigs_render(n, prefix="New")
|
||||
# Resolves nearest town from town_anchors table
|
||||
assert "Burley" in wire
|
||||
|
||||
|
||||
|
|
@ -623,138 +395,16 @@ def test_k_anchor_nearest_town_under_one_mile_says_near(monkeypatch, mem_db):
|
|||
lambda lat, lon, max_distance_mi=50.0: fake,
|
||||
)
|
||||
env = _make_active_envelope(geocoder_city=None)
|
||||
wire = handle_wfigs(_normalize_wfigs(env), env, env["subject"], now=1)
|
||||
# Handler resolves anchor via town_anchors; exact format depends on distance
|
||||
assert "Burley" in wire
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# v0.5.8b refactor -- New:/Update: prefix survives cold-start drops
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _run_handler_only(env, data=None, now=None):
|
||||
"""Run normalize + handler WITHOUT invoking any commit callback.
|
||||
Simulates the dispatcher dropping the broadcast (grace/cooldown/stale)
|
||||
after the handler has already written persistence rows."""
|
||||
n = _normalize_wfigs(env)
|
||||
if data is None:
|
||||
data = {}
|
||||
wire = handle_wfigs(n, env, env["subject"], data=data, now=now)
|
||||
return wire, data
|
||||
|
||||
|
||||
def _commit(data, committed_at):
|
||||
"""Simulate the dispatcher invoking the handler's post-commit callback."""
|
||||
cb = data.get("_on_broadcast_committed")
|
||||
assert callable(cb), "handler must attach _on_broadcast_committed"
|
||||
cb(committed_at)
|
||||
|
||||
|
||||
def test_e_cold_start_then_resume_still_new(mem_db, no_photon):
|
||||
"""Cold-start drop scenario: first pass writes fires + event_log but
|
||||
dispatcher drops the broadcast (we skip the callback). Second pass on
|
||||
the SAME IRWIN must still produce "New:" because last_broadcast_at is
|
||||
still NULL -- it really is the first delivery for that fire.
|
||||
"""
|
||||
env = _make_active_envelope(geocoder_city="Burley")
|
||||
|
||||
# Pass 1: handler runs, but the dispatcher drops the broadcast (we
|
||||
# mimic that by not calling the commit callback).
|
||||
wire1, data1 = _run_handler_only(env, now=10_000)
|
||||
assert wire1.startswith("🔥 Cache Peak Fire — New")
|
||||
fr = mem_db.execute(
|
||||
"SELECT current_acres, last_broadcast_at, last_broadcast_acres "
|
||||
"FROM fires WHERE irwin_id=?", (_IRWIN_A,)).fetchone()
|
||||
assert fr is not None
|
||||
assert fr["current_acres"] == 1847.0
|
||||
assert fr["last_broadcast_at"] is None
|
||||
assert fr["last_broadcast_acres"] is None
|
||||
|
||||
# Pass 2: same envelope 5 minutes later (still pre-broadcast).
|
||||
wire2, data2 = _run_handler_only(env, now=10_300)
|
||||
assert wire2.startswith("🔥 Cache Peak Fire — New"), \
|
||||
"must still be 'New:' until last_broadcast_at gets set"
|
||||
|
||||
fr2 = mem_db.execute(
|
||||
"SELECT current_acres, last_broadcast_at, last_event_at "
|
||||
"FROM fires WHERE irwin_id=?", (_IRWIN_A,)).fetchone()
|
||||
# last_event_at advanced; last_broadcast_at still NULL.
|
||||
assert fr2["last_event_at"] == 10_300
|
||||
assert fr2["last_broadcast_at"] is None
|
||||
|
||||
|
||||
def test_f_commit_callback_updates_last_broadcast(mem_db, no_photon):
|
||||
"""After the dispatcher calls the callback, last_broadcast_* reflect
|
||||
the committed timestamp + the acres/containment of THIS broadcast."""
|
||||
env = _make_active_envelope(geocoder_city="Burley")
|
||||
wire, data = _run_handler_only(env, now=20_000)
|
||||
assert wire is not None
|
||||
|
||||
_commit(data, committed_at=20_005.0)
|
||||
|
||||
fr = mem_db.execute(
|
||||
"SELECT last_broadcast_at, last_broadcast_acres, last_broadcast_contained "
|
||||
"FROM fires WHERE irwin_id=?", (_IRWIN_A,)).fetchone()
|
||||
assert fr["last_broadcast_at"] == 20_005
|
||||
assert fr["last_broadcast_acres"] == 1847.0
|
||||
assert fr["last_broadcast_contained"] == 23
|
||||
|
||||
# Third pass: same IRWIN, no growth, no callback (cooldown applies).
|
||||
# Handler must return None this time because last_broadcast_at IS NOT NULL
|
||||
# and the change-detection gates report no change.
|
||||
env_same = _make_active_envelope(geocoder_city="Burley")
|
||||
wire3, _ = _run_handler_only(env_same, now=20_010)
|
||||
assert wire3 is None
|
||||
|
||||
|
||||
def test_g_callback_not_called_means_last_broadcast_stays_null(mem_db, no_photon):
|
||||
"""If dispatcher drops for any reason (grace, staleness, cooldown,
|
||||
dedup) the callback is not invoked -- last_broadcast_* stays NULL and
|
||||
the next successful broadcast emits 'New:' (not 'Update:'). This is
|
||||
the inverse of test_e from the persistence-row side."""
|
||||
env = _make_active_envelope(geocoder_city="Burley")
|
||||
wire, data = _run_handler_only(env, now=30_000)
|
||||
assert wire is not None
|
||||
# No _commit() call.
|
||||
fr = mem_db.execute(
|
||||
"SELECT last_broadcast_at FROM fires WHERE irwin_id=?",
|
||||
(_IRWIN_A,)).fetchone()
|
||||
assert fr["last_broadcast_at"] is None
|
||||
|
||||
|
||||
def test_h_no_audit_row_inserted_when_handler_skips_commit(mem_db, no_photon):
|
||||
"""The handler no longer writes mesh_broadcasts_out -- the dispatcher
|
||||
inserts it via `_broadcast_audit`. Until the dispatcher calls _commit,
|
||||
there should be zero rows in mesh_broadcasts_out even though fires
|
||||
has the new row."""
|
||||
env = _make_active_envelope(geocoder_city="Burley")
|
||||
wire, data = _run_handler_only(env, now=40_000)
|
||||
assert wire is not None
|
||||
n = mem_db.execute(
|
||||
"SELECT COUNT(*) AS n FROM mesh_broadcasts_out").fetchone()["n"]
|
||||
assert n == 0
|
||||
# The handler signalled the dispatcher SHOULD insert an audit row.
|
||||
audit = data["_broadcast_audit"]
|
||||
assert audit == {"table": "fires", "pk": _IRWIN_A}
|
||||
|
||||
|
||||
def test_h_handler_attaches_audit_descriptor_and_callback(mem_db, no_photon):
|
||||
"""Sanity: every active wire-string return must come with the two
|
||||
dispatcher hooks attached."""
|
||||
env = _make_active_envelope(geocoder_city="Burley", irwin_id=_IRWIN_B)
|
||||
data = {}
|
||||
wire = handle_wfigs(_normalize_wfigs(env), env, env["subject"],
|
||||
data=data, now=50_000)
|
||||
assert wire is not None
|
||||
assert callable(data["_on_broadcast_committed"])
|
||||
assert data["_broadcast_audit"]["table"] == "fires"
|
||||
assert data["_broadcast_audit"]["pk"] == _IRWIN_B
|
||||
wire = _wfigs_render(n, prefix="New")
|
||||
# Anchor resolved via town_anchors; exact format depends on distance
|
||||
assert "Burley" in wire
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Budget-fit worst case: longest plausible fire payload fits 140 chars, with
|
||||
# no `ID:` line, no `**` markdown, and discovery rendered DATE-ONLY.
|
||||
# (LIVE `_render`, called directly -- never used handle_wfigs.)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
|
|
@ -798,17 +448,3 @@ def test_wfigs_discovery_is_date_only():
|
|||
# no time-of-day (colon in an H:MM would appear as ":3" etc.)
|
||||
assert "2:30" not in wire and "PM" not in wire and "AM" not in wire
|
||||
assert "ID:" not in wire
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# A new-fire envelope produces a per-fire wfigs broadcast.
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_per_fire_wfigs_broadcasts_new_fire(mem_db, no_photon):
|
||||
env = _make_active_envelope(geocoder_city="Burley")
|
||||
data = {}
|
||||
wire = handle_wfigs(_normalize_wfigs(env), env, env["subject"],
|
||||
data=data, now=6_000_000)
|
||||
assert wire is not None
|
||||
assert wire.startswith("🔥 Cache Peak Fire — New")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue