v0.9.14: fused FIRMS+WFIGS fire view

Pairs each live WFIGS perimeter with its nearby/contemporaneous FIRMS hotspots
into a single "fire" on the /events map. FIRMS hotspots carry no IrwinID, so the
link is spatial+temporal: a hotspot is confirmed (part of a known fire) when it
lies within 1km of a perimeter AND within 72h of it; hotspots matching no
perimeter render amber as "unconfirmed" -- a possible new fire detected by
satellite before an official perimeter exists (early-warning signal).

- routes.py: read-only /events/fire-fused.json (PostGIS ST_DWithin geography join)
- events_list.html: "Fuse fire layers" toggle (default on); centroid fire glyph
  that expands to polygon + hotspot dots on click; amber unconfirmed hotspots
- central.css: --fire-confirmed / --fire-unconfirmed vars (retune without code)
- 11 tests (shaping, bbox parse, R/T + bbox param wiring); spatial correctness
  verified on prod (Summit Creek: perimeter + 90 hotspots; 191 unconfirmed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-05-27 03:49:30 +00:00
commit d5367ff55e
4 changed files with 374 additions and 1 deletions

View file

@ -20,7 +20,7 @@ logger = logging.getLogger("central.gui.routes")
from fastapi import APIRouter, Depends, Form, Request from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from central.bootstrap_config import get_settings from central.bootstrap_config import get_settings
from central.gui.csrf import ( from central.gui.csrf import (
reuse_or_generate_pre_auth_csrf, reuse_or_generate_pre_auth_csrf,
@ -3457,6 +3457,126 @@ def _decorate_table_events(events: list[dict]) -> None:
# --- Fused fire view (v0.9.14): WFIGS perimeters + nearby FIRMS hotspots -----
# FIRMS hotspots carry no IrwinID, so the link is spatial+temporal: a hotspot is
# "confirmed" (part of a known fire) when it lies within FIRE_FUSE_RADIUS_M of a
# live perimeter AND within FIRE_FUSE_WINDOW_H of it. Hotspots matching no
# perimeter are "unconfirmed" (possible new fire ahead of an official perimeter).
# Read-only: SELECT-only, no DML.
FIRE_FUSE_RADIUS_M = 1000
FIRE_FUSE_WINDOW_H = 72
def _fused_bbox(params: dict) -> tuple[float, float, float, float] | None:
"""Parse optional north/south/east/west into (west, south, east, north), or
None if absent/degenerate/out-of-range (mirrors _parse_events_params)."""
try:
n = float(params["north"]); so = float(params["south"])
e = float(params["east"]); w = float(params["west"])
except (KeyError, TypeError, ValueError):
return None
if -90 <= so < n <= 90 and -180 <= w < e <= 180:
return (w, so, e, n)
return None
def _shape_fused_fire(row) -> dict[str, Any]:
"""Shape a confirmed-fire row (perimeter + aggregated hotspots) for JSON."""
return {
"id": row["id"],
"time": row["time"].isoformat(),
"incident_name": row["incident_name"],
"irwin_id": row["irwin_id"],
"acres": row["acres"],
"cause": row["cause"],
"geometry": row["geometry"],
"hotspot_count": row["hotspot_count"],
"max_frp": row["max_frp"],
"hotspots": row["hotspots"] or [],
}
def _shape_unconfirmed_hotspot(row) -> dict[str, Any]:
"""Shape an unconfirmed FIRMS hotspot (no nearby perimeter) for JSON."""
return {
"id": row["id"],
"time": row["time"].isoformat(),
"geometry": row["geometry"],
"frp": row["frp"],
"confidence": row["confidence"],
"satellite": row["satellite"],
}
@router.get("/events/fire-fused.json")
async def fire_fused(request: Request) -> Response:
"""Fused fire view: each live WFIGS perimeter with its nearby/contemporaneous
FIRMS hotspots, plus standalone ("unconfirmed") hotspots. Honors an optional
north/south/east/west viewport bbox. Read-only."""
pool = get_pool()
bbox = _fused_bbox(dict(request.query_params))
confirmed_sql = """
SELECT p.id, p.time,
p.payload->'data'->'data'->'raw'->>'poly_IncidentName' AS incident_name,
p.payload->'data'->'data'->'raw'->>'attr_IrwinID' AS irwin_id,
(p.payload->'data'->'data'->'raw'->>'poly_GISAcres')::float AS acres,
p.payload->'data'->'data'->'raw'->>'attr_FireCause' AS cause,
ST_AsGeoJSON(p.geom)::jsonb AS geometry,
count(h.id) AS hotspot_count,
max((h.payload->'data'->'data'->>'frp')::float) AS max_frp,
COALESCE(jsonb_agg(jsonb_build_object(
'geometry', ST_AsGeoJSON(h.geom)::jsonb,
'frp', h.payload->'data'->'data'->>'frp',
'confidence', h.payload->'data'->'data'->>'confidence',
'satellite', h.payload->'data'->'data'->>'satellite',
'time', h.time
) ORDER BY h.time DESC) FILTER (WHERE h.id IS NOT NULL), '[]'::jsonb) AS hotspots
FROM events p
LEFT JOIN events h
ON h.adapter = 'firms' AND h.category NOT LIKE '%.removed' AND h.geom IS NOT NULL
AND ST_DWithin(p.geom::geography, h.geom::geography, $1)
AND h.time > p.time - ($2 * interval '1 hour')
WHERE p.adapter = 'wfigs_perimeters' AND p.category NOT LIKE '%.removed'
AND p.geom IS NOT NULL{p_bbox}
GROUP BY p.id, p.time
ORDER BY hotspot_count DESC
"""
unconfirmed_sql = """
SELECT h.id, h.time,
ST_AsGeoJSON(h.geom)::jsonb AS geometry,
h.payload->'data'->'data'->>'frp' AS frp,
h.payload->'data'->'data'->>'confidence' AS confidence,
h.payload->'data'->'data'->>'satellite' AS satellite
FROM events h
WHERE h.adapter = 'firms' AND h.category NOT LIKE '%.removed'
AND h.geom IS NOT NULL{h_bbox}
AND NOT EXISTS (
SELECT 1 FROM events p
WHERE p.adapter = 'wfigs_perimeters' AND p.category NOT LIKE '%.removed'
AND p.geom IS NOT NULL
AND ST_DWithin(p.geom::geography, h.geom::geography, $1)
AND h.time > p.time - ($2 * interval '1 hour')
)
ORDER BY h.time DESC
"""
args: list[Any] = [FIRE_FUSE_RADIUS_M, FIRE_FUSE_WINDOW_H]
p_bbox = h_bbox = ""
if bbox:
p_bbox = "\n AND ST_Intersects(p.geom, ST_MakeEnvelope($3, $4, $5, $6, 4326))"
h_bbox = "\n AND ST_Intersects(h.geom, ST_MakeEnvelope($3, $4, $5, $6, 4326))"
args.extend(bbox)
async with pool.acquire() as conn:
confirmed = await conn.fetch(confirmed_sql.format(p_bbox=p_bbox), *args)
unconfirmed = await conn.fetch(unconfirmed_sql.format(h_bbox=h_bbox), *args)
return JSONResponse({
"fires": [_shape_fused_fire(r) for r in confirmed],
"unconfirmed": [_shape_unconfirmed_hotspot(r) for r in unconfirmed],
})
@router.get("/events.json") @router.get("/events.json")
async def events_json(request: Request): async def events_json(request: Request):
""" """

View file

@ -29,6 +29,9 @@
--shadow-pop: 0 4px 16px rgba(0,0,0,0.08); --shadow-pop: 0 4px 16px rgba(0,0,0,0.08);
--radius: 6px; --radius: 6px;
--radius-sm: 4px; --radius-sm: 4px;
/* Fused fire view (v0.9.14): retune here without touching JS. */
--fire-confirmed: #d7261e; /* perimeter + hotspots */
--fire-unconfirmed: #f59e0b; /* hotspots only -- possible new fire */
} }
/* ─── element base ──────────────────────────────────────────────────────── */ /* ─── element base ──────────────────────────────────────────────────────── */
@ -578,6 +581,9 @@ table tbody tr:hover { background: var(--bg-soft); }
.evt-triangle { border: none; clip-path: polygon(50% 0%, 100% 100%, 0% 100%); } .evt-triangle { border: none; clip-path: polygon(50% 0%, 100% 100%, 0% 100%); }
.evt-star { border: none; clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%); } .evt-star { border: none; clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%); }
.evt-marker.evt-hl { filter: drop-shadow(0 0 4px #ff3333); transform: scale(1.35); } .evt-marker.evt-hl { filter: drop-shadow(0 0 4px #ff3333); transform: scale(1.35); }
/* Fused-fire centroid glyph: larger, with a soft glow to read as 'a fire'. */
.evt-marker.fire-glyph { width: 17px; height: 17px; border-color: rgba(0,0,0,0.7);
box-shadow: 0 0 6px 1px rgba(215,38,30,0.55); }
/* pagination */ /* pagination */
.paginator { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 16px; font-size: 13px; } .paginator { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 16px; font-size: 13px; }

View file

@ -96,6 +96,10 @@
<input type="checkbox" id="map-filter-toggle" {{ 'checked' if filter_state.map_filter else '' }}> <input type="checkbox" id="map-filter-toggle" {{ 'checked' if filter_state.map_filter else '' }}>
Filter table by map view Filter table by map view
</label> </label>
<label class="map-filter-toggle tb" title="Pair WFIGS perimeters with nearby FIRMS hotspots into a single fire; lone hotspots show amber as possible new fires.">
<input type="checkbox" id="fuse-fire-toggle" checked>
Fuse fire layers
</label>
</div> </div>
</div> </div>
@ -203,6 +207,7 @@
spiderfyOnMaxZoom: true spiderfyOnMaxZoom: true
}).addTo(map); }).addTo(map);
var polyGroup = L.featureGroup().addTo(map); var polyGroup = L.featureGroup().addTo(map);
var fireFusedGroup = L.featureGroup().addTo(map); // v0.9.14 fused fire view
var highlightedRow = null; var highlightedRow = null;
var highlightedLayer = null; var highlightedLayer = null;
var isInitialLoad = true; var isInitialLoad = true;
@ -299,15 +304,112 @@
return html; return html;
} }
// --- Fused fire view (v0.9.14) -----------------------------------------
// Fire-domain adapters whose rows are replaced by the fused renderer.
var FIRE_ADAPTERS = { firms: 1, wfigs_perimeters: 1, wfigs_incidents: 1 };
var fuseFireToggle = document.getElementById("fuse-fire-toggle");
if (fuseFireToggle) fuseFireToggle.addEventListener("change", rebindEventLayers);
function fuseFireOn() { return fuseFireToggle && fuseFireToggle.checked; }
// Colors come from CSS vars so they can be retuned without code.
function cssVar(name, fallback) {
var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
function fireConfirmedColor() { return cssVar("--fire-confirmed", "#d7261e"); }
function fireUnconfirmedColor() { return cssVar("--fire-unconfirmed", "#f59e0b"); }
function fireFusedPopup(fire) {
var name = fire.incident_name || "(unnamed fire)";
var n = fire.hotspot_count || 0;
var html = "<strong>&#128293; " + name + "</strong><br>Confirmed fire (perimeter + " +
n + " hotspot" + (n === 1 ? "" : "s") + ")";
if (fire.acres != null) html += "<br>" + Math.round(fire.acres).toLocaleString() + " acres";
if (fire.cause) html += "<br>Cause: " + fire.cause;
if (fire.max_frp != null) html += "<br>Max FRP: " + fire.max_frp;
if (fire.irwin_id) html += "<br><small>" + fire.irwin_id + "</small>";
return html;
}
function unconfirmedPopup(h) {
var html = "<strong>&#9888; Unconfirmed hotspot</strong><br>Satellite detection, no perimeter yet";
if (h.satellite) html += "<br>Satellite: " + h.satellite;
if (h.frp) html += "<br>FRP: " + h.frp;
if (h.confidence) html += "<br>Confidence: " + h.confidence;
var t = h.time ? new Date(h.time).toLocaleString() : "";
if (t) html += "<br><small>" + t + "</small>";
return html;
}
function drawConfirmedFire(fire, color) {
if (fire.geometry) {
var poly = L.geoJSON(fire.geometry, { style: { color: color, weight: 2,
fillColor: color, opacity: 0.9, fillOpacity: 0.25 } });
poly.bindPopup(fireFusedPopup(fire));
poly.addTo(fireFusedGroup);
}
(fire.hotspots || []).forEach(function(h) {
if (!h.geometry) return;
L.geoJSON(h.geometry, { pointToLayer: function(f, ll) {
return L.circleMarker(ll, { radius: 3, color: color, weight: 1,
fillColor: color, fillOpacity: 0.8 }); },
style: { color: color, weight: 1, fillColor: color, fillOpacity: 0.55 }
}).addTo(fireFusedGroup);
});
// Collapsed glyph at the perimeter centroid; click expands (zoom to bounds).
var center = centroidLatLng(fire.geometry);
if (center) {
var glyph = L.marker(center, { icon: L.divIcon({ className: "",
html: '<div class="evt-marker evt-square fire-glyph" style="background:' + color + '"></div>',
iconSize: [17, 17], iconAnchor: [9, 9] }) });
glyph.bindPopup(fireFusedPopup(fire));
glyph.on("click", function() {
try { var b = L.geoJSON(fire.geometry).getBounds();
if (b.isValid()) map.fitBounds(b.pad(0.3)); } catch (e) {}
});
glyph.addTo(fireFusedGroup);
}
}
function drawUnconfirmedHotspot(h, color) {
if (!h.geometry) return;
var layer = L.geoJSON(h.geometry, { pointToLayer: function(f, ll) {
return L.circleMarker(ll, { radius: 4, color: color, weight: 1,
fillColor: color, fillOpacity: 0.7 }); },
style: { color: color, weight: 1, fillColor: color, fillOpacity: 0.5 } });
layer.bindPopup(unconfirmedPopup(h));
layer.addTo(fireFusedGroup);
}
function renderFusedFires() {
fireFusedGroup.clearLayers();
var url = "/events/fire-fused.json";
if (mapFilterOn()) {
var b = map.getBounds();
url += "?north=" + Math.min(90, b.getNorth()).toFixed(4) +
"&south=" + Math.max(-90, b.getSouth()).toFixed(4) +
"&east=" + wrapLon(b.getEast()).toFixed(4) +
"&west=" + wrapLon(b.getWest()).toFixed(4);
}
fetch(url, { headers: { "Accept": "application/json" } })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(data) {
if (!data) return;
var cc = fireConfirmedColor(), uc = fireUnconfirmedColor();
(data.fires || []).forEach(function(fire) { drawConfirmedFire(fire, cc); });
(data.unconfirmed || []).forEach(function(h) { drawUnconfirmedHotspot(h, uc); });
})
.catch(function(e) { console.error("fused fire fetch failed", e); });
}
function rebindEventLayers() { function rebindEventLayers() {
markerCluster.clearLayers(); markerCluster.clearLayers();
polyGroup.clearLayers(); polyGroup.clearLayers();
var fused = fuseFireOn();
if (fused) { renderFusedFires(); } else { fireFusedGroup.clearLayers(); }
var rows = document.querySelectorAll("#events-rows tr.event-row[data-geometry]"); var rows = document.querySelectorAll("#events-rows tr.event-row[data-geometry]");
rows.forEach(function(row) { rows.forEach(function(row) {
var geomStr = row.dataset.geometry; var geomStr = row.dataset.geometry;
if (!geomStr || geomStr === "") return; if (!geomStr || geomStr === "") return;
if (fused && FIRE_ADAPTERS[row.dataset.adapter || ""]) return;
try { try {
var geom = JSON.parse(geomStr); var geom = JSON.parse(geomStr);

145
tests/test_fire_fused.py Normal file
View file

@ -0,0 +1,145 @@
"""Fused fire view (v0.9.14): WFIGS perimeter + nearby FIRMS hotspots.
The spatial join itself (ST_DWithin / time window) is PostGIS and is verified on
real data, not here -- the suite has no PostGIS test DB (mock_conn only). These
tests cover the Python response-shaping, the bbox parse, the R/T constants, and
that the query is parameterized with R, T, and the optional bbox.
"""
import json
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from central.gui.routes import (
FIRE_FUSE_RADIUS_M,
FIRE_FUSE_WINDOW_H,
_fused_bbox,
_shape_fused_fire,
_shape_unconfirmed_hotspot,
fire_fused,
)
T = datetime(2026, 5, 27, 0, 0, tzinfo=timezone.utc)
def test_constants():
assert FIRE_FUSE_RADIUS_M == 1000
assert FIRE_FUSE_WINDOW_H == 72
class TestFusedBbox:
def test_valid_returns_w_s_e_n(self):
assert _fused_bbox({"north": "44.5", "south": "41.8",
"east": "-111.0", "west": "-117.5"}) == (-117.5, 41.8, -111.0, 44.5)
def test_missing_returns_none(self):
assert _fused_bbox({"north": "44.5"}) is None
assert _fused_bbox({}) is None
def test_out_of_range_returns_none(self):
# east<west (degenerate) and lat>90
assert _fused_bbox({"north": "44", "south": "42", "east": "-118", "west": "-111"}) is None
assert _fused_bbox({"north": "99", "south": "42", "east": "-111", "west": "-118"}) is None
def test_nonnumeric_returns_none(self):
assert _fused_bbox({"north": "x", "south": "1", "east": "2", "west": "0"}) is None
def test_shape_fused_fire():
row = {
"id": "perim1", "time": T, "incident_name": "Summit Creek",
"irwin_id": "{ABC}", "acres": 1234.8, "cause": "Natural",
"geometry": {"type": "Polygon", "coordinates": []},
"hotspot_count": 90, "max_frp": 12.5,
"hotspots": [{"geometry": {"type": "Polygon"}, "frp": "1.2",
"confidence": "nominal", "satellite": "N", "time": "2026-05-26T..."}],
}
out = _shape_fused_fire(row)
assert out["incident_name"] == "Summit Creek"
assert out["hotspot_count"] == 90
assert out["max_frp"] == 12.5
assert out["geometry"]["type"] == "Polygon"
assert len(out["hotspots"]) == 1
assert out["time"] == T.isoformat()
def test_shape_fused_fire_null_hotspots():
row = {"id": "p", "time": T, "incident_name": None, "irwin_id": None,
"acres": None, "cause": None, "geometry": None,
"hotspot_count": 0, "max_frp": None, "hotspots": None}
out = _shape_fused_fire(row)
assert out["hotspots"] == []
assert out["hotspot_count"] == 0
def test_shape_unconfirmed_hotspot():
row = {"id": "h1", "time": T, "geometry": {"type": "Polygon"},
"frp": "0.7", "confidence": "nominal", "satellite": "N"}
out = _shape_unconfirmed_hotspot(row)
assert out["id"] == "h1"
assert out["satellite"] == "N"
assert out["geometry"]["type"] == "Polygon"
def _mock_pool(conn):
pool = MagicMock()
pool.acquire.return_value.__aenter__ = AsyncMock(return_value=conn)
pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
return pool
def _confirmed_row():
return {"id": "p1", "time": T, "incident_name": "Summit Creek", "irwin_id": "{X}",
"acres": 1234.8, "cause": "Natural", "geometry": {"type": "Polygon"},
"hotspot_count": 2, "max_frp": 9.9,
"hotspots": [{"geometry": {"type": "Polygon"}, "frp": "1", "confidence": "n",
"satellite": "N", "time": "2026-05-26T00:00:00+00:00"}]}
def _unconfirmed_row():
return {"id": "h9", "time": T, "geometry": {"type": "Polygon"},
"frp": "2.0", "confidence": "high", "satellite": "1"}
@pytest.mark.asyncio
async def test_endpoint_returns_fires_and_unconfirmed():
req = MagicMock()
req.state.operator = MagicMock(id=1, username="admin")
req.query_params = {}
conn = AsyncMock()
conn.fetch.side_effect = [[_confirmed_row()], [_unconfirmed_row(), _unconfirmed_row()]]
with patch("central.gui.routes.get_pool", return_value=_mock_pool(conn)):
resp = await fire_fused(req)
assert resp.status_code == 200
body = json.loads(resp.body)
assert len(body["fires"]) == 1
assert body["fires"][0]["incident_name"] == "Summit Creek"
assert len(body["unconfirmed"]) == 2
@pytest.mark.asyncio
async def test_endpoint_binds_R_T_and_no_bbox_params():
req = MagicMock()
req.query_params = {}
conn = AsyncMock()
conn.fetch.side_effect = [[], []]
with patch("central.gui.routes.get_pool", return_value=_mock_pool(conn)):
await fire_fused(req)
# Both queries bound with exactly (R, T) and no bbox.
first = conn.fetch.call_args_list[0].args
assert first[1] == FIRE_FUSE_RADIUS_M and first[2] == FIRE_FUSE_WINDOW_H
assert len(first) == 3 # sql, R, T
@pytest.mark.asyncio
async def test_endpoint_appends_bbox_params_when_valid():
req = MagicMock()
req.query_params = {"north": "44.5", "south": "41.8", "east": "-111.0", "west": "-117.5"}
conn = AsyncMock()
conn.fetch.side_effect = [[], []]
with patch("central.gui.routes.get_pool", return_value=_mock_pool(conn)):
await fire_fused(req)
first = conn.fetch.call_args_list[0].args
assert first[1:] == (FIRE_FUSE_RADIUS_M, FIRE_FUSE_WINDOW_H, -117.5, 41.8, -111.0, 44.5)
assert "ST_MakeEnvelope" in first[0]