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.
6.2 KiB
FIRMS active fire / hotspot heatmap — data source
The OSINT map's fire overlay is fed by NASA FIRMS (Fire Information for Resource Management System). Two zero-cost options exist; this repo implements option A (ingested vector points served as JSON), and option B (GIBS raster tiles) is documented below for a no-storage frontend-only alternative.
A. FIRMS area CSV → Postgres/TimescaleDB → GET /api/fires
Data flow
NASA FIRMS area CSV ──► app/fire_sources.py ──► NATS events.fire
│
app/ingestor.py (ingest_fire_row)
│
fires hypertable (idempotent PK)
│
GET /api/fires?bbox=&since= (JSON)
app/fire_sources.pyfetches the VIIRS active-fire CSV for a bounding box, normalises each row (combiningacq_date+acq_timeinto a UTC timestamp), and publishes one message per hotspot to NATS JetStream subjectevents.fire.- The long-running ingester (
app/run_ingester.py) polls FIRMS on its own ~15-minute cadence (FIRMS_INTERVAL, default 900 s) and shares the existing NATS consumer.ingest_eventroutessource_type == "fire"messages toingest_fire_row, which writes to thefirestable. - Idempotency: the
firesprimary key IS the natural key(latitude, longitude, acq_time, satellite). Inserts useON CONFLICT DO NOTHING, so a hotspot re-delivered on a later poll is silently ignored — no duplicates, no upsert churn. - The
firestable is a TimescaleDB hypertable partitioned onacq_time(1-day chunks), so retention is onedrop_chunkscall away.
Endpoint
GET /api/fires?bbox=<minlon,minlat,maxlon,maxlat>&since=<ISO-8601-UTC>&limit=<N>
| Query param | Meaning | Default |
|---|---|---|
bbox |
"minlon,minlat,maxlon,maxlat" to bound the result (e.g. -125,24,-66,50). |
all stored detections |
since |
only hotspots acquired at/after this UTC instant (e.g. 2026-08-24T12:00:00Z). |
none |
limit |
max rows returned. | 2000 (max 10000) |
Response is a plain JSON array (frontend renders it as the heatmap overlay):
[
{
"latitude": 39.45678,
"longitude": -121.12345,
"brightness": 341.4, // bright_ti4, Kelvin
"confidence": "h", // VIIRS: n (nominal) / l (low) / h (high)
"acq_time": "2026-08-24T18:10:00Z",
"satellite": "N", // N (S-NPP), N20, N21
"instrument": "VIIRS",
"bright_ti5": 310.2, // 12µm brightness, Kelvin
"frp": 12.4, // fire radiative power, MW
"daynight": "D" // D / N
}
]
A manual poll can also be triggered with POST /api/ingest/fires?bbox=....
Configuration (all via env / .env)
| Var | Default | Notes |
|---|---|---|
FIRMS_MAP_KEY |
(blank) | Required for live data. Free key: https://firms.modaps.eosdis.nasa.gov/api/map_key_info/ (1-minute signup). Until set, the fire loop logs a warning and stays idle — it never crashes the ingester. |
FIRMS_DATASET |
VIIRS_SNPP_NRT |
NRT VIIRS S-NPP 375 m active fire detection. |
FIRMS_BBOX |
-180,-60,180,75 |
Poll area "minlon,minlat,maxlon,maxlat". Narrow it (e.g. -125,24,-66,50) to cut payload and write volume. |
FIRMS_INTERVAL |
900 |
Poll cadence in seconds (~15 min; FIRMS NRT refreshes every ~5–10 min). |
INGEST_FIRES |
1 |
Set 0 to disable the fire loop entirely. |
FIRMS_TIMEOUT |
60 |
Outbound HTTP timeout for the CSV download. |
Tests
tests/ — parser/normalisation/mapping unit tests run anywhere; DB-backed
idempotency + API contract tests are marked integration and auto-skip without
a reachable TimescaleDB+PostGIS (the repo's localhost/osint-dashboard-pg
image or a local postgis/postgis):
DB_HOST=... DB_PORT=... DB_USER=osint DB_PASSWORD=... DB_NAME=osint_data \
pytest tests/ -v
DB_NULL_POOL=1 is set by the test suite (fresh connection per event loop).
Live verification
Live end-to-end verification (real FIRMS fetch → NATS → Postgres → API) is
blocked until FIRMS_MAP_KEY is set in .env. Everything else — CSV
parsing, idempotent storage, the API contract — is verified against a real
TimescaleDB instance in the test suite.
B. GIBS thermal-anomaly tiles — zero-cost, no key, no storage
If you want a fire layer with zero backend work (raster tiles rendered by the map library directly, no ingest, no DB, no API key), NASA GIBS serves the same VIIRS S-NPP detections as WMTS tiles:
-
Layer:
VIIRS_SNPP_Thermal_Anomalies_375m_All(375 m VIIRS S-NPP thermal anomalies / active fires). Sibling layers exist for day/night-only views (..._Day,..._Night). -
REST tile URL (Web Mercator, EPSG:3857 — what Leaflet/MapLibre use):
https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_SNPP_Thermal_Anomalies_375m_All/default/{Time}/GoogleMapsCompatible_Level{Z}/{Y}/{X}.png -
{Time}is a date like2026-08-24(or a time-of-day string); the list of available times comes from the WMTS capabilities:https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/1.0.0/WMTSCapabilities.xml(search for the layer, read itsDimension→Value). -
Leaflet/MapLibre example:
L.tileLayer( 'https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_SNPP_Thermal_Anomalies_375m_All/default/{time}/GoogleMapsCompatible_Level{z}/{y}/{x}.png', { attribution: 'NASA GIBS / FIRMS', maxZoom: 9 } ).addTo(map);
Trade-offs vs. option A:
| A — FIRMS CSV ingest | B — GIBS WMTS tiles | |
|---|---|---|
| Data in our DB | yes (queryable, filterable) | no (pixels only) |
| Per-hotspot attributes (brightness, FRP, confidence) | yes | no (colour-coded only) |
Time range / since filtering server-side |
yes | tile {Time} per snapshot |
| Backend cost | ingest service + DB rows | none |
| API key | free FIRMS_MAP_KEY | none |
GIBS is the right choice when the map only needs "where are fires right now". The FIRMS ingest is right when you want to query, aggregate, or persist the detections (e.g. "fires near X in the last 24 h").