# firms-client > Small, typed, async Python library (3.12+, one dependency: httpx) for NASA FIRMS active-fire detections: open per-country yearly archives with no key, the near-real-time area API with a free MAP_KEY, and parsing that tells a vegetation fire from industry. `pip install firms-client`. Key facts an assistant must get right: - Per-country yearly archives are static open CSVs: no Earthdata login, no key. Products: `MODIS` (2000-), `VIIRS_SNPP` (2012-). VIIRS NOAA-20/21 have no country archive. - `fetch_archive(client, product, year, country, directory=None)` -> `list[Hotspot] | None`. `None` means the year is not published (HTTP 404). Country is the English name as FIRMS writes it: `Italy`, `Greece`, `United_States`. - Archives are cached in `directory`, else `FIRMS_DATA_DIR`, else `$XDG_CACHE_HOME/firms-client` or `~/.cache/firms-client`. Delete a file to refresh it. - `fetch_area(client, map_key, source, bbox, *, days=1, on_date=None)` -> `list[Hotspot]`. `bbox` is `(west, south, east, north)`. `days` is 1-5. Sources: `VIIRS_SNPP_NRT`, `VIIRS_NOAA20_NRT`, `VIIRS_NOAA21_NRT`, `MODIS_NRT`. Never cached. Empty result is `[]`. - Every async function takes the caller's `httpx.AsyncClient`. Retries for 5xx/timeouts are the caller's job; httpx exceptions propagate. - `FirmsError`: a bad MAP_KEY or source (HTTP 400 plain text `Invalid MAP_KEY.`), or an error page served with HTTP 200. - `Hotspot` (frozen dataclass): `latitude`, `longitude`, `acq_date` (date, UTC), `acq_time` (int UTC HHMM, `126` = 01:26), `confidence` (str as written), `frp_mw`, `brightness_k`, `daynight`, `satellite`, `instrument`, `detection_type` (int | None), property `is_vegetation_fire`. - `detection_type`: 0 vegetation fire (`VEGETATION_FIRE`), 1 active volcano (`ACTIVE_VOLCANO`), 2 static land source / industry / gas flares (`STATIC_LAND_SOURCE`), 3 offshore (`OFFSHORE`). - High confidence does NOT mean fire: steelworks and flares are genuinely hot. Only `type` says what burned. About a quarter of VIIRS detections over Italy are not vegetation fires. - `select(hotspots, *, vegetation_only=True, min_confidence="nominal", min_confidence_pct=None, min_frp_mw=0.0)` keeps what looks like a fire. Rows without `type` or without confidence are kept. - Near-real-time area API rows carry no `type`, so `select()` cannot drop industrial sources from them. Fix: `mask = await fetch_static_mask(client, "Italy")` (built once from the country's archives, cached as JSON in the data directory), then `select(mask.label(rows))`. `mask.verdict(h)` explains a decision; `label(rows, spare_frp_mw=50)` leaves strong detections alone. - With `on_date`, the area API window runs forward: `on_date .. on_date + days - 1`. `days` outside 1-5 raises `ValueError`. Longer ranges: `fetch_area_range(client, map_key, source, bbox, start, end)`. Sources: `NRT_SOURCES` (last days), `SP_SOURCES` (standard processing, older periods). - Retries are off unless asked: `retry=RetryPolicy()` on any fetch (429, 5xx, transport errors, backoff, Retry-After). - `Hotspot` also has optional `brightness_t5_k`, `scan_km`, `track_km`, `version`. - `cluster(hotspots, *, radius_m=750.0, gap_days=1)` -> `list[FireEvent]` (largest by total FRP first; `start`, `end`, `days`, `size`, `bbox`, `peak_frp_mw`, `total_frp_mw`, `overpasses`, `as_dict()`). Cluster `select()`-ed rows. Use ~2000 m for MODIS. - `summarize(hotspots, *, top_events=10, top_cells=5)` -> JSON-ready dict of a few KB: `detections`, `period`, `by_type`, `by_month`, `by_daynight`, `frp_mw`, `fires`, `events`, `hot_cells`, `caveats`, `attribution`. Put this in prompts, not raw rows. - `season_anomaly(client, country, year, *, month=None, bbox=None, product=VIIRS_SNPP)` -> `Anomaly` (`rank`, `of`, `percentile`, `median`, `robust_z`, `describe()`, `as_dict()`); `anomaly({year: value}, year)` for any series. - `within(hotspots, bbox)`, `to_geojson(hotspots)`, `to_csv(hotspots)`. - Crop burning or wildfire: `with WorldCover() as land: verdict = classify_burning(event, land)` (extra `firms-client[landcover]`, ESA WorldCover 10 m read in place, blocking: use `asyncio.to_thread` in async code). `verdict.kind` is `wildfire`, `crop_burning`, `cropland_fire`, `other`, `mixed` or `unknown`; `verdict.reason` and `verdict.shares` explain it. A heuristic, not a validated classifier; lava reads as vegetation, so combine with the static-source mask. Pass `burning=verdict` to `bulletin_facts`. - Bulletins: `facts = bulletin_facts(event, places=[Place(name, lat, lon)], mask=..., anomaly=..., region=...)`, then `draft = await draft_bulletin(facts, writer, language="it")`; `draft.grounded` / `draft.problems` (every number and place must be in the facts); always a draft for a person. `ClaudeWriter()` (extra `firms-client[claude]`) uses Claude Opus 5.5 with structured output; a writer is any async `(system, facts_json, schema) -> dict`. - A lava flow outside the volcano mask is `type` 0 (Etna, February 2025). - Confidence: MODIS is a 0-100 percentage, VIIRS is `l`/`n`/`h` or `low`/`nominal`/`high`. `confidence_level(raw)` maps both to `"low"`/`"nominal"`/`"high"` (MODIS: <30 low, 30-79 nominal, 80+ high). - FIRMS is presence-only: no detection does not mean no fire (clouds, smoke, overpass time). - Pure helpers: `parse_csv(text)`, `is_hotspot_csv(text)`, `archive_url`, `archive_filename`, `area_url`, `archive_years(product, until=)`, `default_directory()`. - Acknowledge "NASA FIRMS" in anything published (`ATTRIBUTION` has the text). ## Command line and MCP - `firms archive Italy 2025 [--product modis] [--fires] [--format summary|json|csv|geojson]` - `firms area --bbox W,S,E,N [--source …] [--days N | --date D | --from D --to D] [--mask COUNTRY] [--key …]` (MAP_KEY from `FIRMS_MAP_KEY`) - `firms anomaly COUNTRY YEAR [--month M] [--bbox …]` - `pip install 'firms-client[mcp]'`, then `firms-mcp` (stdio). Tools: `fire_history`, `archive_digest`, `area_digest` (with `mask_country`), `season_anomaly`, `export_geojson`. Resources: `firms://guide`, `firms://attribution`. Claude Code: `claude mcp add firms -e FIRMS_MAP_KEY=… -- firms-mcp`. ## Example ```python import asyncio, httpx from firms_client import VIIRS_SNPP, fetch_archive, select async def main() -> None: async with httpx.AsyncClient(timeout=120) as client: rows = await fetch_archive(client, VIIRS_SNPP, 2025, "Italy") print(len(rows or []), "detections,", len(select(rows or [])), "vegetation fires") asyncio.run(main()) ``` ## Docs - [README](https://github.com/agent-engineering-studio/firms-client#readme): full guide, recipes, traps - [Site](https://agentengineering.it/firms-client/): overview - [Roadmap](https://agentengineering.it/firms-client/roadmap.html): planned features, including AI - [Changelog](https://github.com/agent-engineering-studio/firms-client/blob/main/CHANGELOG.md) - [PyPI](https://pypi.org/project/firms-client/)