Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
"""NASA FIRMS active fire / hotspot ingestor.
|
|
|
|
|
|
|
|
|
|
Fetches the VIIRS active-fire detection CSV for an area from NASA FIRMS and
|
|
|
|
|
publishes each hotspot to NATS JetStream on the ``events.fire`` subject. The
|
|
|
|
|
NATS consumer (app/ingestor.py) routes those messages to the ``fires``
|
|
|
|
|
TimescaleDB hypertable, deduped by the natural key (lat, lon, acq_time,
|
|
|
|
|
satellite).
|
|
|
|
|
|
|
|
|
|
Zero-cost data: the FIRMS MAP_KEY is free (https://firms.modaps.eosdis.nasa.gov/api/map_key_info/)
|
|
|
|
|
and the CSV area endpoint is unlimited for personal/research use.
|
|
|
|
|
|
|
|
|
|
Endpoint (documented at https://firms.modaps.eosdis.nasa.gov/api/area/csv/):
|
|
|
|
|
https://firms.modaps.eosdis.nasa.gov/api/area/csv/{MAP_KEY}/{DATASET}/{bbox}
|
|
|
|
|
bbox = "minlon,minlat,maxlon,maxlat" (e.g. "-125,24,-66,50")
|
|
|
|
|
|
|
|
|
|
CSV columns (VIIRS): latitude, longitude, bright_ti4, scan, track, acq_date,
|
|
|
|
|
acq_time, satellite, instrument, confidence, version, bright_ti5, frp, daynight.
|
|
|
|
|
``acq_time`` is an integer HHMM in UTC (seconds are 0); ``acq_date`` is the
|
|
|
|
|
UTC date (YYYY-MM-DD).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import csv
|
|
|
|
|
import io
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
2026-08-24 21:21:15 -04:00
|
|
|
import os
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
import nats
|
|
|
|
|
|
2026-08-24 21:21:15 -04:00
|
|
|
from config import FIRMS_DATASET, FIRMS_BBOX, FIRMS_TIMEOUT, NATS_URL
|
|
|
|
|
from keystore import get_api_key
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
|
|
|
|
|
logger = logging.getLogger("osint.firms")
|
|
|
|
|
|
|
|
|
|
# ── FIRMS API ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
FIRMS_AREA_CSV = (
|
|
|
|
|
"https://firms.modaps.eosdis.nasa.gov/api/area/csv/{key}/{dataset}/{bbox}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# The canonical VIIRS CSV header FIRMS returns. Used to (a) locate the real
|
|
|
|
|
# header row if FIRMS ever prepends a legend line and (b) validate a download.
|
|
|
|
|
FIRMS_CSV_COLUMNS = (
|
|
|
|
|
"latitude", "longitude", "bright_ti4", "scan", "track", "acq_date",
|
|
|
|
|
"acq_time", "satellite", "instrument", "confidence", "version",
|
|
|
|
|
"bright_ti5", "frp", "daynight",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _to_float(value: object) -> float | None:
|
|
|
|
|
"""Best-effort float conversion; returns None on empty/garbage values."""
|
|
|
|
|
if value is None:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
return float(value)
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_acq_time(acq_date: object, acq_time: object) -> datetime | None:
|
|
|
|
|
"""Combine FIRMS acq_date + acq_time into a timezone-aware UTC datetime.
|
|
|
|
|
|
|
|
|
|
acq_date is 'YYYY-MM-DD'; acq_time is an integer HHMM in UTC with seconds
|
|
|
|
|
truncated. Returns None when the values can't be parsed (dropped).
|
|
|
|
|
"""
|
|
|
|
|
if not acq_date or acq_time is None or acq_time == "":
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
hhmm = str(int(acq_time)).zfill(4)
|
|
|
|
|
return datetime.strptime(
|
|
|
|
|
f"{acq_date} {hhmm}", "%Y-%m-%d %H%M"
|
|
|
|
|
).replace(tzinfo=timezone.utc)
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_firms_csv(text: str) -> list[dict]:
|
|
|
|
|
"""Parse a FIRMS area CSV payload into normalized fire messages.
|
|
|
|
|
|
|
|
|
|
Returns one dict per hotspot with the fields stored in the ``fires`` table
|
|
|
|
|
(acq_time already combined into a UTC ISO timestamp). Rows that don't look
|
|
|
|
|
like valid VIIRS detections are skipped rather than failing the whole poll.
|
|
|
|
|
"""
|
|
|
|
|
rows = list(csv.reader(io.StringIO(text)))
|
|
|
|
|
if not rows:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
# Locate the real header row. FIRMS normally returns the CSV header first,
|
|
|
|
|
# but occasionally prepends a legend/info line, so scan until we see the
|
|
|
|
|
# canonical header.
|
|
|
|
|
header_idx = 0
|
|
|
|
|
for i, row in enumerate(rows):
|
|
|
|
|
if row and row[0].strip().lower() == "latitude" and len(row) >= 4:
|
|
|
|
|
header_idx = i
|
|
|
|
|
break
|
|
|
|
|
header = [c.strip().lower() for c in rows[header_idx]]
|
|
|
|
|
# Guard against a header that isn't actually the FIRMS one.
|
|
|
|
|
if "latitude" not in header or "longitude" not in header:
|
|
|
|
|
logger.warning("FIRMS payload does not look like a hotspot CSV (first row: %r)", header[:6])
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
points: list[dict] = []
|
|
|
|
|
for row in rows[header_idx + 1:]:
|
|
|
|
|
if len(row) < len(header):
|
|
|
|
|
continue
|
|
|
|
|
rec = dict(zip(header, row))
|
|
|
|
|
lat = _to_float(rec.get("latitude"))
|
|
|
|
|
lon = _to_float(rec.get("longitude"))
|
|
|
|
|
if lat is None or lon is None:
|
|
|
|
|
continue
|
|
|
|
|
acq_time = normalize_acq_time(rec.get("acq_date"), rec.get("acq_time"))
|
|
|
|
|
if acq_time is None:
|
|
|
|
|
continue
|
|
|
|
|
points.append({
|
|
|
|
|
"latitude": lat,
|
|
|
|
|
"longitude": lon,
|
|
|
|
|
"brightness": _to_float(rec.get("bright_ti4")),
|
|
|
|
|
"confidence": str(rec.get("confidence") or "").strip(),
|
|
|
|
|
"acq_time": acq_time.isoformat(),
|
|
|
|
|
"satellite": str(rec.get("satellite") or "").strip(),
|
|
|
|
|
"instrument": str(rec.get("instrument") or "").strip(),
|
|
|
|
|
"bright_ti5": _to_float(rec.get("bright_ti5")),
|
|
|
|
|
"frp": _to_float(rec.get("frp")),
|
|
|
|
|
"daynight": str(rec.get("daynight") or "").strip(),
|
|
|
|
|
"scan": _to_float(rec.get("scan")),
|
|
|
|
|
"track": _to_float(rec.get("track")),
|
|
|
|
|
"version": str(rec.get("version") or "").strip(),
|
|
|
|
|
})
|
|
|
|
|
return points
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def publish_fire_batch(points: list[dict]) -> int:
|
|
|
|
|
"""Publish a batch of hotspot messages to NATS JetStream in one connection.
|
|
|
|
|
|
|
|
|
|
Fires are bulk data (thousands of detections per poll), so opening a NATS
|
|
|
|
|
connection per message — as the RSS/GDELT path does — would be wasteful.
|
|
|
|
|
One connection, one JetStream context, one close, with the batch flushed
|
|
|
|
|
before returning so a crash mid-write can't silently drop half a poll.
|
|
|
|
|
"""
|
|
|
|
|
if not points:
|
|
|
|
|
return 0
|
|
|
|
|
nc = await nats.connect(NATS_URL)
|
|
|
|
|
js = nc.jetstream()
|
|
|
|
|
try:
|
|
|
|
|
for pt in points:
|
|
|
|
|
msg = {"source_type": "fire", **pt}
|
|
|
|
|
await js.publish("events.fire", json.dumps(msg).encode())
|
|
|
|
|
finally:
|
|
|
|
|
await nc.close()
|
|
|
|
|
return len(points)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def ingest_fires(bbox: str | None = None) -> int:
|
|
|
|
|
"""Fetch the FIRMS hotspot CSV for an area and publish it to NATS.
|
|
|
|
|
|
|
|
|
|
Returns the number of hotspot messages published. Idle (0) and a logged
|
|
|
|
|
warning when FIRMS_MAP_KEY is not set, so the ingester keeps running for
|
|
|
|
|
the other sources.
|
|
|
|
|
"""
|
2026-08-24 21:21:15 -04:00
|
|
|
# Key resolution: env var first (12-factor), then the shared Postgres
|
|
|
|
|
# api_keys table so a key saved via the dashboard Keys page is picked up
|
|
|
|
|
# on the next poll without a restart.
|
|
|
|
|
map_key = os.getenv("FIRMS_MAP_KEY", "") or await get_api_key("FIRMS_MAP_KEY")
|
|
|
|
|
if not map_key:
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
logger.warning(
|
|
|
|
|
"FIRMS_MAP_KEY not set — fire ingest disabled. "
|
2026-08-24 21:21:15 -04:00
|
|
|
"Set it in the dashboard Keys page or .env. "
|
|
|
|
|
"Free key: https://firms.modaps.eosdis.nasa.gov/api/map_key_info/"
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
area = bbox or FIRMS_BBOX
|
|
|
|
|
url = FIRMS_AREA_CSV.format(
|
2026-08-24 21:21:15 -04:00
|
|
|
key=map_key, dataset=FIRMS_DATASET, bbox=area
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=FIRMS_TIMEOUT) as client:
|
|
|
|
|
resp = await client.get(url)
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
text = resp.text
|
|
|
|
|
|
|
|
|
|
# FIRMS returns HTTP 200 with a plain-text error for some failure modes
|
|
|
|
|
# (bad key, invalid bbox); surface the first line for debuggability.
|
|
|
|
|
if "latitude" not in text.lower()[:4096]:
|
|
|
|
|
first_line = text.strip().splitlines()[0][:200] if text.strip() else "(empty)"
|
|
|
|
|
logger.warning("FIRMS CSV download returned no hotspot data (%s)", first_line)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
points = parse_firms_csv(text)
|
|
|
|
|
published = await publish_fire_batch(points)
|
|
|
|
|
logger.info(
|
|
|
|
|
"FIRMS: fetched %d hotspot(s) for bbox=%s (%s), published %d",
|
|
|
|
|
len(points), area, FIRMS_DATASET, published,
|
|
|
|
|
)
|
|
|
|
|
return published
|