diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index b5b5da9..028c71b 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -20,7 +20,7 @@ logger = logging.getLogger("central.gui.routes") 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.gui.csrf import ( 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") async def events_json(request: Request): """ diff --git a/src/central/gui/static/css/central.css b/src/central/gui/static/css/central.css index 5f7af6c..735ec39 100644 --- a/src/central/gui/static/css/central.css +++ b/src/central/gui/static/css/central.css @@ -29,6 +29,9 @@ --shadow-pop: 0 4px 16px rgba(0,0,0,0.08); --radius: 6px; --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 ──────────────────────────────────────────────────────── */ @@ -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-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); } +/* 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 */ .paginator { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 16px; font-size: 13px; } diff --git a/src/central/gui/templates/events_list.html b/src/central/gui/templates/events_list.html index 7532b0a..d5102cc 100644 --- a/src/central/gui/templates/events_list.html +++ b/src/central/gui/templates/events_list.html @@ -96,6 +96,10 @@ Filter table by map view + @@ -203,6 +207,7 @@ spiderfyOnMaxZoom: true }).addTo(map); var polyGroup = L.featureGroup().addTo(map); + var fireFusedGroup = L.featureGroup().addTo(map); // v0.9.14 fused fire view var highlightedRow = null; var highlightedLayer = null; var isInitialLoad = true; @@ -299,15 +304,112 @@ 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 = "🔥 " + name + "
Confirmed fire (perimeter + " + + n + " hotspot" + (n === 1 ? "" : "s") + ")"; + if (fire.acres != null) html += "
" + Math.round(fire.acres).toLocaleString() + " acres"; + if (fire.cause) html += "
Cause: " + fire.cause; + if (fire.max_frp != null) html += "
Max FRP: " + fire.max_frp; + if (fire.irwin_id) html += "
" + fire.irwin_id + ""; + return html; + } + function unconfirmedPopup(h) { + var html = "⚠ Unconfirmed hotspot
Satellite detection, no perimeter yet"; + if (h.satellite) html += "
Satellite: " + h.satellite; + if (h.frp) html += "
FRP: " + h.frp; + if (h.confidence) html += "
Confidence: " + h.confidence; + var t = h.time ? new Date(h.time).toLocaleString() : ""; + if (t) html += "
" + t + ""; + 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: '
', + 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() { markerCluster.clearLayers(); polyGroup.clearLayers(); + var fused = fuseFireOn(); + if (fused) { renderFusedFires(); } else { fireFusedGroup.clearLayers(); } var rows = document.querySelectorAll("#events-rows tr.event-row[data-geometry]"); rows.forEach(function(row) { var geomStr = row.dataset.geometry; if (!geomStr || geomStr === "") return; + if (fused && FIRE_ADAPTERS[row.dataset.adapter || ""]) return; try { var geom = JSON.parse(geomStr); diff --git a/tests/test_fire_fused.py b/tests/test_fire_fused.py new file mode 100644 index 0000000..057d5fb --- /dev/null +++ b/tests/test_fire_fused.py @@ -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): + # east90 + 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]