diff --git a/meshai/adapter_config/defaults.py b/meshai/adapter_config/defaults.py index ee9e0f7..c05455d 100644 --- a/meshai/adapter_config/defaults.py +++ b/meshai/adapter_config/defaults.py @@ -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) diff --git a/meshai/central/firms_handler.py b/meshai/central/firms_handler.py index 9a7b23c..b1540b5 100644 --- a/meshai/central/firms_handler.py +++ b/meshai/central/firms_handler.py @@ -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 diff --git a/meshai/notifications/categories.py b/meshai/notifications/categories.py index 4c1bade..faf5b76 100644 --- a/meshai/notifications/categories.py +++ b/meshai/notifications/categories.py @@ -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", diff --git a/meshai/persistence/db.py b/meshai/persistence/db.py index 65e15db..3109932 100644 --- a/meshai/persistence/db.py +++ b/meshai/persistence/db.py @@ -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" diff --git a/meshai/persistence/migrations/v15.sql b/meshai/persistence/migrations/v15.sql new file mode 100644 index 0000000..2cb2768 --- /dev/null +++ b/meshai/persistence/migrations/v15.sql @@ -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); diff --git a/tests/test_fire_tracker_phase3.py b/tests/test_fire_tracker_phase3.py new file mode 100644 index 0000000..25b1122 --- /dev/null +++ b/tests/test_fire_tracker_phase3.py @@ -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"