mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 11:54:37 +02:00
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:
parent
05b89df3a6
commit
d5367ff55e
4 changed files with 374 additions and 1 deletions
|
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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>🔥 " + 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>⚠ 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
145
tests/test_fire_fused.py
Normal 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]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue