"""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 from datetime import datetime, timezone import httpx import nats from config import FIRMS_MAP_KEY, FIRMS_DATASET, FIRMS_BBOX, FIRMS_TIMEOUT, NATS_URL 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. """ if not FIRMS_MAP_KEY: logger.warning( "FIRMS_MAP_KEY not set — fire ingest disabled. " "Get a free key at https://firms.modaps.eosdis.nasa.gov/api/map_key_info/" ) return 0 area = bbox or FIRMS_BBOX url = FIRMS_AREA_CSV.format( key=FIRMS_MAP_KEY, dataset=FIRMS_DATASET, bbox=area ) 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