NASA FIRMS · Python 3.12+ · Apache-2.0
Every active fire seen from space since 2000, in a few lines of Python.
firms-client downloads the open per-country FIRMS archives (no account, no key),
queries the near-real-time area API, and does the parsing that tells a
vegetation fire from a steelworks. One dependency: httpx.
What it does
Small, typed, honest about the data
Built for researchers, data engineers and civil-protection teams who want FIRMS data in a notebook or a pipeline, without the e-mail order form.
📦Open yearly archives
MODIS from 2000 and VIIRS S-NPP from 2012, per country, with no Earthdata login and no key.
💾Never download twice
Archives are saved in a persistent data directory (FIRMS_DATA_DIR) and read from disk next time.
⚡Near-real-time API
The last 1-5 days over any bounding box, about 3 hours after the overpass, with a free MAP_KEY.
🔥Fire or factory?
Every detection carries its FIRMS type: vegetation, volcano, industry, offshore.
🎚️One confidence scale
MODIS percentages and VIIRS l/n/h judged by one rule.
🛡️Errors that look like errors
A missing year is None; a bad key or an error page served with HTTP 200 raises FirmsError.
🧪Pure functions too
URLs and parsing are plain functions, if you bring your own HTTP stack.
🏭No steelworks in live data
A static-source mask learned from the archives types near-real-time rows: 99.3% of static sources removed on Italy 2025.
🧭Events and digests
cluster() turns pixels into fire events; summarize() turns a year into 4 KB an LLM can read.
🤖MCP server
firms-mcp lets Claude and other agents ask FIRMS questions, with numbers computed by code.
📈Is this season unusual?
Rank, percentile and robust z of any year or month against the archive: July 2017 is the highest in Italy since 2012.
🪶Light
One dependency, mypy --strict, 100% test coverage, tests that never touch the network.
Quick start
A country's year, no key needed
pip install firms-client # the library and the firms command
pip install 'firms-client[mcp]' # plus the firms-mcp server
import asyncio
from collections import Counter
import httpx
from firms_client import ARCHIVE_PRODUCTS, fetch_archive, select
async def main() -> None:
async with httpx.AsyncClient(timeout=120) as client:
for product in ARCHIVE_PRODUCTS:
rows = await fetch_archive(client, product, 2025, "Italy")
if rows is None: # that year is not published (yet)
continue
fires = select(rows) # vegetation fires, nominal confidence or better
types = dict(sorted(Counter(r.detection_type for r in rows).items()))
print(product.slug, len(rows), "detections", types, "->", len(fires), "fires")
asyncio.run(main())
modis 5076 detections {0: 3414, 1: 21, 2: 1635, 3: 6} -> 3315 fires
viirs-snpp 23349 detections {0: 17533, 1: 633, 2: 4277, 3: 906} -> 14880 fires
The CSVs are now in ~/.cache/firms-client/: run it again and nothing is downloaded.
From a shell
firms archive Italy 2025 --fires --format geojson > fires.geojson
firms anomaly Italy 2017 --month 7
FIRMS_MAP_KEY=… firms area --bbox 6,36,19,47.6 --days 2 --mask Italy --fires
From Claude, or any MCP client
claude mcp add firms -e FIRMS_MAP_KEY=your-key -- firms-mcp
Then ask: "how did the 2021 fire season in Sicily compare with 2025?" The tools answer with digests computed by firms-client, never raw rows.
Or the last days, with a MAP_KEY
The archive runs up to the previous year. For the last days, get a free MAP_KEY and ask the area API:
from firms_client import fetch_area, select
ITALY = (6.0, 36.0, 19.0, 47.6) # west, south, east, north
rows = await fetch_area(client, MAP_KEY, "VIIRS_SNPP_NRT", ITALY, days=3)
mask = await fetch_static_mask(client, "Italy") # area rows have no type: learn it from the archives
fires = select(mask.label(rows), min_frp_mw=1.0)
Fire or factory?
Not every hotspot is a fire
A satellite sees heat, not fire. Steelworks, refineries, gas flares, volcanoes and offshore platforms are hot every day of the year, and they come through at high confidence, because they really are hot.
type | Constant | Meaning |
|---|---|---|
0 | VEGETATION_FIRE | presumed vegetation fire |
1 | ACTIVE_VOLCANO | active volcano |
2 | STATIC_LAND_SOURCE | other static land source: industry, gas flares |
3 | OFFSHORE | offshore |
On Italy, about a quarter of the VIIRS detections are not vegetation fires, and 98% of those pass a nominal-confidence filter. Without type == 0, the most "burnt" square kilometre in the country over the whole archive is the ILVA steelworks in Taranto, with 3,308 fire-days.
Two things no filter fixes: crop-residue burning is a real vegetation fire (type 0), and only land cover can set it apart; and FIRMS is presence-only, so no detection does not mean nothing burnt.
How it works
Two doors in: archives and area API
| Per-country archive | Area API | |
|---|---|---|
| Function | fetch_archive, download_archive | fetch_area |
| What | one country, one whole year | any bounding box, 1-5 days per query |
| Key | none | free MAP_KEY |
| Latency | up to the previous year | about 3 hours |
| Processing | standard (SP), with type | near-real-time |
| Cached on disk | yes | no |
Reference
API at a glance
Every async function takes your httpx.AsyncClient: timeouts, proxies, retries and pooling stay yours.
| Name | Kind | What it does |
|---|---|---|
fetch_archive(client, product, year, country, directory=None) | async | one year of a country, parsed; None if not published |
fetch_archive_csv(…) | async | the same, as raw CSV text |
download_archive(…) | async | the CSV's Path in the data directory, downloaded if missing |
fetch_area(client, map_key, source, bbox, *, days=1, on_date=None) | async | near-real-time detections over a bbox |
select(hotspots, *, vegetation_only, min_confidence, min_confidence_pct, min_frp_mw) | pure | keep what looks like a fire |
parse_csv(text) | pure | CSV text → list[Hotspot] |
confidence_level(raw) | pure | MODIS % or VIIRS level → "low" / "nominal" / "high" |
is_hotspot_csv(text) | pure | is this a hotspot CSV, or an error page? |
archive_url, archive_filename, area_url, archive_years | pure | URLs, file names, year ranges |
default_directory() | pure | FIRMS_DATA_DIR, else ~/.cache/firms-client |
fetch_area_range(client, map_key, source, bbox, start, end) | async | any date range, in 5-day requests |
cluster(hotspots, *, radius_m, gap_days) | pure | hotspots → FireEvents, largest first |
summarize(hotspots) | pure | a JSON digest of a few KB, for people and LLMs |
fetch_static_mask(client, country), mask.label(rows) | async / pure | the type of static sources, for rows that have none |
season_anomaly(client, country, year, *, month, bbox) | async | a year or month against the other archive years |
RetryPolicy(), retry= | type | opt-in retries for 429, 5xx and dropped connections |
FirmsError | exception | FIRMS answered, but not with a hotspot CSV |
Full details, recipes (fire history, fire-days per km², GeoJSON, a wildfire trigger) and the traps we already fell into are in the README.
Roadmap
From hotspots to answers
Shipped: fire events, digests for LLMs, the MCP server, the static-source mask and season anomalies. Next: alert bulletins grounded in computed numbers, crop burning told apart from wildfires, and training sets for fire-risk models.
Read the roadmap →