feat(v0.7-fire-tracker-3): spotting detection -- pixels beyond perimeter trigger immediate broadcast

Phase 3 of FIRMS+WFIGS fusion. v15.sql adds perimeter_geojson to
fire_passes + last_spotting_broadcast_at to fires. FIRMS handler
computes convex hull of each pass on pass-boundary close; attributed
pixels >= 1.5 mi (configurable) from previous-pass perimeter emit
wildfire_spotting broadcast. Cooldown 1h between spotting broadcasts
per fire so rapid embers do not spam. wildfire_spotting category at
immediate severity -- spotting is the highest-actionable fire signal
(spread beyond perimeter). All thresholds GUI-editable. Phase 4 (LLM
summaries + on-demand queries) deferred.

Schema (v15.sql):
- fire_passes gains perimeter_geojson TEXT (nullable; populated by
  _close_prev_perimeter at boundary). GeoJSON Polygon, single outer
  ring in (lon, lat) order per RFC 7946, closed (first == last).
- fires gains last_spotting_broadcast_at REAL (per-fire cooldown
  latch). Index (irwin_id, last_spotting_broadcast_at) for the
  cooldown probe.

adapter_config (defaults.py REGISTRY):
- fires.spotting_distance_threshold_mi = 1.5 (float). Matches design
  doc Phase 3 spec; design doc open question #6 lists this as TBD
  pending real spotting observation data.
- fires.spotting_cooldown_seconds = 3600 (int, 1h). Suppresses
  rapid-ember spam from a single satellite pass.

ALERT_CATEGORIES (notifications/categories.py):
- wildfire_spotting: immediate / fire. Highest fire severity --
  spotting represents fire spread BEYOND the existing perimeter, the
  most actionable detection signal.

FIRMS handler (central/firms_handler.py):
- _handle_pass_boundary now closes the prior pass's perimeter (convex
  hull of fire_pixels via Andrew's monotone chain) on the first
  boundary; subsequent in-pass pixels reuse the stored hull.
- _check_spotting runs for every attributed pixel: looks up the most
  recent CLOSED pass (perimeter_geojson NOT NULL AND pass_id !=
  current), point-in-polygon test, vertex-distance approximation
  per design doc Q (sparse pixels make edge projection overkill at
  VIIRS 375 m resolution), per-fire cooldown gate.
- Priority order: spotting (immediate) > growth (priority) > cluster
  (priority) > halt (routine). Spotting preempts growth at the same
  pixel because immediate > priority.
- Helpers: _convex_hull (Andrew's monotone chain), _hull_to_geojson
  (RFC 7946 Polygon), _point_in_polygon (ray casting),
  _close_prev_perimeter, _check_spotting, _prev_has_perimeter.

Wire string:
- wildfire_spotting: "🔥 Possible spotting <dist:.1f> mi <dir> of
  <incident_name> perimeter" -- direction is 8-way bearing from the
  previous pass's centroid to the spotting pixel.

Tests (tests/test_fire_tracker_phase3.py, 11 cases all green):
- Pass close stamps perimeter_geojson as a closed Polygon (6 hex
  vertices -> 7-entry closed ring).
- Pixel 2 mi NE of perimeter fires spotting with distance in the
  1.0..2.5 mi band (vertex-distance approximation) and direction NE.
- Pixel inside perimeter -> NO spotting wire.
- Second spotting candidate within 1h cooldown -> suppressed.
- Past-cooldown spotting fires again.
- Convex hull / point-in-polygon / GeoJSON round-trip helper tests.
- adapter_config seed for both new fires.* keys.
- wildfire_spotting category registered with immediate severity.
- 49 tests green across phase1/phase2/phase3/or-arch/include-roundtrip.

Live verification on CT108 after rebuild:
- v15 migration applied (schema_meta=15, no Traceback in 3 min).
- Container healthy.

Synthetic 25-pixel probe (PROBE-V07P3-*, cleaned up after):
- Pass A: 20 pixels in a ~0.3 mi circle. Perimeter stored on boundary.
- Pass B: 5 pixels at distances 0.5/1.0/2.0/5.0/7.0 mi from center.
  Observed wires:
    "🔥 Possible spotting 1.7 mi NE of Probe Spotting Fire perimeter"
    "🔥 Possible spotting 4.7 mi NW of Probe Spotting Fire perimeter"
  (Plus a Phase 2 growth wire on the first pass B pixel -- documented
  side effect: single-pixel pass B centroid shows 0.5 mi drift from
  pass A.)
- 7.0 mi E pixel: outside 5 mi spread, no broadcast (cluster check
  found no co-located unattributed pixels). Cleanup confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson (via Claude) 2026-06-06 06:43:22 +00:00
commit 31e543ca04
6 changed files with 634 additions and 4 deletions

View file

@ -277,7 +277,7 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
},
# =================================================================
# FIRES -- 4 settings (Phase 1 radius + Phase 2 growth/halt thresholds)
# FIRES -- 6 settings (Phase 1 radius + Phase 2 growth/halt + Phase 3 spotting)
# =================================================================
# Per-fire spread radius override lives in fires.spread_radius_mi;
# the value below is the fallback. v0.7-fire-1 shipped 5 mi based on
@ -319,6 +319,28 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
"type": "int",
"description": "Minimum elapsed seconds since the most recent attributed pixel before wildfire_halted can fire.",
},
# v0.7-fire-3 -- spotting detection.
# spotting_distance_threshold_mi: an attributed pixel this far or
# more from the previous-pass perimeter (convex hull, vertex-
# distance approximation) fires wildfire_spotting. 1.5 mi matches
# the design doc Phase 3 spec ("Hotspot >=1.5 mi from perimeter").
# Treat as an initial-guess default -- the design doc lists this
# as an open question pending real spotting-fire observation data.
("fires", "spotting_distance_threshold_mi"): {
"default": 1.5,
"type": "float",
"description": "Distance (miles) from previous-pass perimeter that fires wildfire_spotting. Tune from observed spotting events; design doc open question #6 marks this as TBD.",
},
# spotting_cooldown_seconds: per-fire latch so a burst of pixels
# in the same general spotting area doesn't spam the mesh. 1h is
# short enough that real follow-on spotting (different ember,
# different sector) re-fires, long enough that a single satellite
# pass with N nearby ember hits broadcasts at most once.
("fires", "spotting_cooldown_seconds"): {
"default": 3600,
"type": "int",
"description": "Minimum seconds between consecutive wildfire_spotting broadcasts for the same fire; suppresses rapid-ember spam.",
},
# =================================================================
# FIRMS -- 7 settings (storage floors + dedup + 3 v0.7 cluster knobs)

View file

@ -1,4 +1,4 @@
"""v0.7-fire-tracker-2 FIRMS handler -- storage + attribution + cluster + growth/halt.
"""v0.7-fire-tracker-3 FIRMS handler -- storage + attribution + cluster + growth/halt/spotting.
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
@ -62,6 +62,7 @@ event_log accounting:
from __future__ import annotations
from meshai.adapter_config import adapter_config
import json
import logging
import math
import time
@ -692,9 +693,29 @@ def _handle_pass_boundary(conn, *, irwin_id, pass_id, lat, lon,
pass_centroid_lon, irwin_id),
)
# v0.7-fire-tracker-3: on the boundary path, close the prior pass's
# perimeter (convex hull of its pixels) so this and subsequent
# in-pass pixels can spotting-check against it.
boundary = (last_pass_id != pass_id) and (last_pass_id is not None)
if boundary and prev is not None and not _prev_has_perimeter(conn, irwin_id, prev["pass_id"]):
_close_prev_perimeter(conn, irwin_id, prev["pass_id"])
# v0.7-fire-tracker-3: spotting check. Runs for every attributed
# pixel (not just boundary) because pixels 2..N of the new pass may
# also be far from the prior perimeter. Spotting has IMMEDIATE
# severity and preempts growth in the priority order.
spotting_wire = _check_spotting(
conn, irwin_id=irwin_id, pixel_lat=lat, pixel_lon=lon,
current_pass_id=pass_id,
incident_name=fires_row["incident_name"] or "(unnamed fire)",
data=data, now=now,
)
if spotting_wire is not None:
return spotting_wire
if last_pass_id == pass_id or last_pass_id is None or prev is None:
# No boundary (same pass), or this is the fire's first pass --
# nothing to compare drift against. Phase 2 silent in these cases.
# nothing to compare drift against.
return None
threshold = float(adapter_config.fires.growth_drift_threshold_mi)
@ -794,3 +815,203 @@ def _direction_8(bearing_deg: float) -> str:
"""
idx = int(((float(bearing_deg) + 22.5) % 360.0) // 45)
return _COMPASS_8[idx]
# ============================================================================
# v0.7-fire-tracker-3: spotting detection (convex-hull perimeter + cooldown)
# ============================================================================
#
# Pass close: when a boundary is detected (the first pixel of a new
# pass arrives), the prior pass's perimeter is computed as the convex
# hull of its fire_pixels rows and stored as GeoJSON in fire_passes.
# Hulls with <3 distinct vertices are stored verbatim (point or line)
# and skipped during spotting checks -- a "perimeter" needs area.
#
# Spotting check: every attributed pixel looks up the most recent
# fire_passes row for this fire with a non-NULL perimeter (i.e. the
# previous closed pass). If the pixel is outside that hull AND the
# vertex-nearest distance is >= spotting_distance_threshold_mi AND
# the per-fire cooldown is clear, fire wildfire_spotting and stamp
# fires.last_spotting_broadcast_at.
def _prev_has_perimeter(conn, irwin_id: str, prev_pass_id: str) -> bool:
row = conn.execute(
"SELECT 1 FROM fire_passes WHERE irwin_id=? AND pass_id=? "
"AND perimeter_geojson IS NOT NULL",
(irwin_id, prev_pass_id),
).fetchone()
return row is not None
def _close_prev_perimeter(conn, irwin_id: str, prev_pass_id: str) -> None:
"""Compute the convex hull of fire_pixels for (irwin_id, prev_pass_id)
and write it into fire_passes.perimeter_geojson. A no-op if the pass
has zero pixels (defensive; should not happen)."""
pts = conn.execute(
"SELECT lat, lon FROM fire_pixels WHERE irwin_id=? AND pass_id=?",
(irwin_id, prev_pass_id),
).fetchall()
coords = [(float(r["lat"]), float(r["lon"])) for r in pts]
if not coords:
return
hull = _convex_hull(coords)
geojson = _hull_to_geojson(hull)
conn.execute(
"UPDATE fire_passes SET perimeter_geojson=? "
"WHERE irwin_id=? AND pass_id=?",
(geojson, irwin_id, prev_pass_id),
)
def _check_spotting(conn, *, irwin_id, pixel_lat, pixel_lon,
current_pass_id, incident_name, data, now):
"""Return spotting wire if criteria met, else None."""
threshold_mi = float(adapter_config.fires.spotting_distance_threshold_mi)
cooldown_s = int(adapter_config.fires.spotting_cooldown_seconds)
# Most recent CLOSED pass with a perimeter (i.e. not the current pass).
prev = conn.execute(
"SELECT pass_id, pass_centroid_lat, pass_centroid_lon, "
"perimeter_geojson FROM fire_passes "
"WHERE irwin_id=? AND pass_id != ? "
"AND perimeter_geojson IS NOT NULL "
"ORDER BY pass_ended_at DESC LIMIT 1",
(irwin_id, current_pass_id),
).fetchone()
if prev is None:
return None
try:
poly = json.loads(prev["perimeter_geojson"])
ring = poly["coordinates"][0] # GeoJSON: outer ring, (lon,lat)
except (KeyError, ValueError, TypeError):
logger.exception("spotting: malformed perimeter geojson for %s",
irwin_id)
return None
# Need at least 3 distinct vertices for a real perimeter. Hulls
# with <3 vertices represent degenerate point / line passes and
# don't support a meaningful inside/outside test.
# A closed GeoJSON ring repeats its first vertex at the end, so a
# 3-vertex triangle has 4 entries; treat <4 as degenerate.
if len(ring) < 4:
return None
# ring is [[lon, lat], ...]; convert to [(lat, lon), ...] for our
# local Haversine + point-in-polygon math.
vertices_lat_lon = [(c[1], c[0]) for c in ring[:-1]]
inside = _point_in_polygon((pixel_lat, pixel_lon), vertices_lat_lon)
if inside:
return None
# Vertex-distance approximation: closest vertex haversine distance.
# Design doc accepts this -- exact edge projection is overkill at
# VIIRS's 375 m pixel resolution.
dist_mi = min(
_haversine_mi(pixel_lat, pixel_lon, v_lat, v_lon)
for v_lat, v_lon in vertices_lat_lon
)
if dist_mi < threshold_mi:
return None
# Cooldown gate.
fires_row = conn.execute(
"SELECT last_spotting_broadcast_at FROM fires WHERE irwin_id=?",
(irwin_id,),
).fetchone()
if fires_row is not None:
last_ts = fires_row["last_spotting_broadcast_at"]
if last_ts is not None and (float(now) - float(last_ts)) < cooldown_s:
return None
# Direction is FROM the perimeter centroid (the previous pass's
# pass_centroid_lat/lon, already on the row) TO this pixel.
direction = _direction_8(_bearing(
prev["pass_centroid_lat"], prev["pass_centroid_lon"],
pixel_lat, pixel_lon,
))
# Stamp the latch + tag data.
conn.execute(
"UPDATE fires SET last_spotting_broadcast_at=? WHERE irwin_id=?",
(float(now), irwin_id),
)
if isinstance(data, dict):
data["category"] = "wildfire_spotting"
data["severity"] = "immediate"
return (
f"🔥 Possible spotting {dist_mi:.1f} mi {direction} of "
f"{incident_name} perimeter"
)
def _convex_hull(points):
"""Andrew's monotone-chain convex hull. Returns the hull as a list of
(lat, lon) tuples in CCW order. Treats lat/lon as planar (small-area
approximation -- adequate for fires <10 mi diameter).
Pass through with <3 unique points: returned as-is (caller decides
whether the perimeter is meaningful)."""
pts = sorted(set(points))
if len(pts) <= 1:
return list(pts)
def cross(o, a, b):
return ((a[0] - o[0]) * (b[1] - o[1])
- (a[1] - o[1]) * (b[0] - o[0]))
lower = []
for p in pts:
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
lower.pop()
lower.append(p)
upper = []
for p in reversed(pts):
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
upper.pop()
upper.append(p)
return lower[:-1] + upper[:-1]
def _hull_to_geojson(hull) -> str:
"""Encode a list of (lat, lon) as a GeoJSON Polygon string. RFC 7946
requires (lon, lat) order and a closed outer ring (first == last)."""
if not hull:
return json.dumps({"type": "Polygon", "coordinates": [[]]})
ring = [[lon, lat] for lat, lon in hull]
# Close the ring per spec.
if ring[0] != ring[-1]:
ring.append(ring[0])
return json.dumps({"type": "Polygon", "coordinates": [ring]})
def _point_in_polygon(point, polygon_lat_lon) -> bool:
"""Ray-casting point-in-polygon. polygon_lat_lon is a list of
(lat, lon) tuples (the ring; do NOT repeat the first vertex).
Returns True for points strictly inside the polygon; the boundary
case is implementation-defined and is fine for our needs (a pixel
sitting exactly on a vertex isn't a spotting candidate either way).
"""
n = len(polygon_lat_lon)
if n < 3:
return False
px_lat, px_lon = point
inside = False
j = n - 1
for i in range(n):
lat_i, lon_i = polygon_lat_lon[i]
lat_j, lon_j = polygon_lat_lon[j]
if ((lat_i > px_lat) != (lat_j > px_lat)):
# x = (lon_j - lon_i) * (px_lat - lat_i) / (lat_j - lat_i) + lon_i
try:
x_intersect = (lon_j - lon_i) * (px_lat - lat_i) / (lat_j - lat_i) + lon_i
except ZeroDivisionError:
x_intersect = lon_i
if px_lon < x_intersect:
inside = not inside
j = i
return inside

View file

@ -365,6 +365,20 @@ ALERT_CATEGORIES = {
"example_message": "🔥 Cache Peak Fire no growth in 14h",
"toggle": "fire",
},
# v0.7-fire-tracker-3: a FIRMS pixel attributed to a tracked fire
# is at least fires.spotting_distance_threshold_mi from that fire's
# previous-pass convex-hull perimeter. Spotting is the highest-
# actionable signal -- fire spread BEYOND the existing perimeter
# is what kills people during a major event -- hence immediate
# severity. Cooldown via fires.last_spotting_broadcast_at keeps
# rapid embers in the same area from spamming the mesh.
"wildfire_spotting": {
"name": "Wildfire Spotting (beyond perimeter)",
"description": "FIRMS pixel attributed to a tracked fire but located outside the previous-pass perimeter by at least fires.spotting_distance_threshold_mi. Indicates ember spread or new ignition forward of the main fire.",
"default_severity": "immediate",
"example_message": "🔥 Possible spotting 2.1 mi NE of Cache Peak Fire perimeter",
"toggle": "fire",
},
"wildfire_hotspot": {
"name": "Wildfire Hotspot",
"description": "Satellite thermal-anomaly detection (NASA FIRMS VIIRS/MODIS pixel) — not necessarily a new ignition",

View file

@ -30,7 +30,7 @@ logger = logging.getLogger(__name__)
DEFAULT_DB_PATH = "/data/meshai.sqlite"
MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH"
SCHEMA_VERSION = 14
SCHEMA_VERSION = 15
SCHEMA_META_TABLE = "schema_meta"
MIGRATIONS_DIR = Path(__file__).parent / "migrations"

View file

@ -0,0 +1,31 @@
-- v0.7-fire-tracker-3 -- spotting detection (per-pass perimeter + spotting latch).
--
-- Phase 3 of the FIRMS+WFIGS fusion. Phase 2 made every closed pass
-- show up as a fire_passes row; Phase 3 adds the perimeter (convex hull
-- of pass pixels) stored as GeoJSON, plus a per-fire cooldown latch
-- that prevents rapid embers from spamming the mesh.
-- ---- fire_passes: perimeter_geojson -----------------------------------
-- Computed at pass close (when a new pass first attributes a pixel for
-- this fire). NULL until then. GeoJSON Polygon, single outer ring in
-- (lon, lat) order per RFC 7946; first coordinate repeated at the end
-- so the ring is closed. Convex hull (Andrew's monotone chain) -- not
-- a true alpha-shape perimeter, but the design doc accepts vertex-
-- distance approximation for spotting checks.
ALTER TABLE fire_passes ADD COLUMN perimeter_geojson TEXT;
-- ---- fires: per-fire spotting cooldown latch ---------------------------
-- last_spotting_broadcast_at:
-- Stamped each time a wildfire_spotting broadcast fires for this
-- fire. The next spotting candidate within the cooldown window
-- (adapter_config.fires.spotting_cooldown_seconds) is silently
-- suppressed -- the broadcast already went out, repeating it would
-- just spam. Cleared (implicitly) when NOW - this >= cooldown.
ALTER TABLE fires ADD COLUMN last_spotting_broadcast_at REAL;
-- Index for the cooldown probe (read every attributed pixel arrives).
CREATE INDEX IF NOT EXISTS idx_fires_last_spotting
ON fires(irwin_id, last_spotting_broadcast_at);

View file

@ -0,0 +1,342 @@
"""v0.7-fire-tracker-3 tests."""
from __future__ import annotations
import json
import math
import time
import uuid
import pytest
@pytest.fixture(autouse=True)
def _isolate_db(tmp_path, monkeypatch):
db_path = str(tmp_path / f"meshai-{uuid.uuid4().hex}.sqlite")
monkeypatch.setenv("MESHAI_DB_PATH", db_path)
from meshai.persistence import db as pdb
pdb.close_thread_connection()
pdb._initialised.discard(db_path)
from meshai.persistence import init_db
init_db(db_path)
yield db_path
pdb.close_thread_connection()
pdb._initialised.discard(db_path)
def _seed_fire(*, irwin_id, lat, lon, name="Stub Fire"):
from meshai.persistence import get_db
get_db().execute(
"INSERT INTO fires(irwin_id, incident_name, lat, lon, last_event_at) "
"VALUES (?,?,?,?,?)",
(irwin_id, name, lat, lon, int(time.time())),
)
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",
},
}
}
# Useful constant: 1 mi in latitude degrees.
_MI_PER_DEG_LAT = 69.0
def _offset_mi(lat, lon, *, north_mi=0.0, east_mi=0.0):
"""Return (lat, lon) offset by north_mi north and east_mi east."""
dlat = north_mi / _MI_PER_DEG_LAT
cos_lat = math.cos(math.radians(lat))
dlon = east_mi / (_MI_PER_DEG_LAT * max(0.01, cos_lat))
return lat + dlat, lon + dlon
# ---------------------------------------------------------------------------
# (a) Pass-close stamps perimeter_geojson.
# ---------------------------------------------------------------------------
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.central.firms_handler import handle_firms
from meshai.persistence import get_db
center_lat, center_lon = 42.500, -114.500
_seed_fire(irwin_id="ID-SPOT-001",
lat=center_lat, lon=center_lon, name="Hex Fire")
# Pass A: 6 hex vertices ~0.05 mi from center; bucket 12:00 N20.
for i in range(6):
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)
# First pass B pixel 1 mi N. Within the 5 mi spread radius -> the
# pixel attributes to the seeded fire -> boundary detected ->
# _close_prev_perimeter runs. 1 mi puts us below the 1.5 mi
# 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)
row = get_db().execute(
"SELECT perimeter_geojson FROM fire_passes WHERE irwin_id=? "
"ORDER BY pass_ended_at ASC LIMIT 1",
("ID-SPOT-001",),
).fetchone()
assert row["perimeter_geojson"] is not None
poly = json.loads(row["perimeter_geojson"])
assert poly["type"] == "Polygon"
ring = poly["coordinates"][0]
# Ring is closed: first == last.
assert ring[0] == ring[-1]
# Convex hull of a hex has 6 vertices; the closed ring has 7 entries.
assert len(ring) == 7
# ---------------------------------------------------------------------------
# (b) Spotting fires for pixels outside perimeter beyond threshold.
# ---------------------------------------------------------------------------
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.central.firms_handler 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
# Hex ~0.5 mi radius (well inside the 5 mi spread radius).
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)
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.central.firms_handler import handle_firms
from meshai.persistence import get_db
center_lat, center_lon = 43.000, -115.000
_seed_pass_a_hex_then_close(irwin_id="ID-SPOT-002",
center_lat=center_lat,
center_lon=center_lon)
# First pass B pixel 2 mi NE.
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="central.fire.hotspot.N20.high.us.id",
data=data, now=1780768800)
assert wire is not None
assert wire.startswith("🔥 Possible spotting ")
assert "NE of ID-SPOT-002 perimeter" in wire
# Distance should be ~2 mi from perimeter -- vertex closest is ~1.5
# mi from center (2.0 - 0.5 hex radius).
# Allow a generous window: 1.0..2.5 mi.
import re
m = re.search(r"spotting (\d+\.\d+) mi", wire)
assert m, f"distance not found in wire: {wire!r}"
dist = float(m.group(1))
assert 1.0 <= dist <= 2.5, f"distance {dist} out of expected band"
# The data dict is tagged (category/severity) for the dispatcher.
# This is verified here so future regressions don\'t silently break
# routing; verification REPORTS only quote the wire (per the
# feedback-no-event-metadata-in-reports memory rule).
assert data["category"] == "wildfire_spotting"
assert data["severity"] == "immediate"
# Latch stamped.
fire = get_db().execute(
"SELECT last_spotting_broadcast_at FROM fires WHERE irwin_id=?",
("ID-SPOT-002",),
).fetchone()
assert fire["last_spotting_broadcast_at"] == 1780768800.0
# ---------------------------------------------------------------------------
# (c) Pixel inside perimeter does not fire spotting.
# ---------------------------------------------------------------------------
def test_pixel_inside_perimeter_no_spotting():
from meshai.central.firms_handler 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,
center_lon=center_lon)
# 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)
# 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)
assert wire is None or "spotting" not in (wire or "")
assert data.get("category") != "wildfire_spotting"
# ---------------------------------------------------------------------------
# (d) Cooldown semantics: second spotting within 1h is suppressed.
# ---------------------------------------------------------------------------
def test_second_spotting_within_cooldown_suppressed():
from meshai.central.firms_handler 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,
center_lon=center_lon)
# 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)
assert wire1 is not None and "spotting" in wire1
# Second spotting candidate 30 min later, 2 mi SE (different
# direction, still beyond perimeter). Within 1h cooldown -> suppressed.
sp2_lat, sp2_lon = _offset_mi(
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)
assert wire2 is None, f"second spotting in cooldown should suppress: {wire2}"
# ---------------------------------------------------------------------------
# (e) Past-cooldown spotting fires again.
# ---------------------------------------------------------------------------
def test_spotting_refires_after_cooldown():
from meshai.central.firms_handler 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)
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)
assert wire2 is not None and "spotting" in wire2
# ---------------------------------------------------------------------------
# (f) Helper sanity.
# ---------------------------------------------------------------------------
def test_convex_hull_basic():
from meshai.central.firms_handler import _convex_hull
pts = [(0, 0), (1, 0), (1, 1), (0, 1), (0.5, 0.5)]
hull = _convex_hull(pts)
assert (0.5, 0.5) not in hull
assert (0, 0) in hull and (1, 1) in hull
def test_point_in_polygon_basic():
from meshai.central.firms_handler import _point_in_polygon
square = [(0, 0), (0, 10), (10, 10), (10, 0)] # (lat, lon)
assert _point_in_polygon((5, 5), square) is True
assert _point_in_polygon((15, 5), square) is False
assert _point_in_polygon((-1, 5), square) is False
def test_geojson_round_trip_via_hull():
"""Hull -> GeoJSON -> parse -> ring shape sane."""
from meshai.central.firms_handler import _convex_hull, _hull_to_geojson
hull = _convex_hull([(0, 0), (1, 0), (0, 1), (1, 1)])
raw = _hull_to_geojson(hull)
parsed = json.loads(raw)
assert parsed["type"] == "Polygon"
ring = parsed["coordinates"][0]
assert ring[0] == ring[-1]
# Vertices stored as [lon, lat] per GeoJSON RFC 7946.
for entry in ring:
assert isinstance(entry, list) and len(entry) == 2
# ---------------------------------------------------------------------------
# (g) Adapter_config + category registration.
# ---------------------------------------------------------------------------
def test_adapter_config_seeds_spotting_keys():
from meshai.persistence import get_db
rows = {
(r["adapter"], r["key"]): r["default_json"]
for r in get_db().execute(
"SELECT adapter, key, default_json FROM adapter_config "
"WHERE (adapter, key) IN ( "
" ('fires','spotting_distance_threshold_mi'), "
" ('fires','spotting_cooldown_seconds') )"
)
}
assert rows[("fires", "spotting_distance_threshold_mi")] == "1.5"
assert rows[("fires", "spotting_cooldown_seconds")] == "3600"
def test_wildfire_spotting_category_registered():
from meshai.notifications.categories import ALERT_CATEGORIES
e = ALERT_CATEGORIES["wildfire_spotting"]
assert e["default_severity"] == "immediate"
assert e["toggle"] == "fire"