mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-11 12:24: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.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):
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue