osint-dashboard/docs/firms.md
Sirius DevOps 627990efde 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

6.2 KiB
Raw Blame History

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.py fetches the VIIRS active-fire CSV for a bounding box, normalises each row (combining acq_date + acq_time into a UTC timestamp), and publishes one message per hotspot to NATS JetStream subject events.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_event routes source_type == "fire" messages to ingest_fire_row, which writes to the fires table.
  • Idempotency: the fires primary key IS the natural key (latitude, longitude, acq_time, satellite). Inserts use ON CONFLICT DO NOTHING, so a hotspot re-delivered on a later poll is silently ignored — no duplicates, no upsert churn.
  • The fires table is a TimescaleDB hypertable partitioned on acq_time (1-day chunks), so retention is one drop_chunks call 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 ~510 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 like 2026-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 its DimensionValue).

  • 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").