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)