firms-client is a small, typed, async Python library for NASA FIRMS, the Fire Information for Resource Management System: every active fire seen from space since 2000. It downloads the open per-country archives (no account, no key), queries the near-real-time area API, and does the parsing that tells a vegetation fire from a steelworks.
rows = await fetch_archive(client, VIIRS_SNPP, 2025, "Italy") # 23,349 detections, no key
fires = select(rows) # 14,880 vegetation fires
One dependency: httpx.
Contents
- What is FIRMS, in 30 seconds
- What this library does
- Who is it for
- Quick start
- Where downloads are saved
- Not every hotspot is a fire
- How it works
- Recipes
- The traps we already fell into
- API at a glance
- Contributing · Ideas to work on
- Data licence and credits
What is FIRMS, in 30 seconds
Several satellites look at the whole Earth a few times a day with thermal infrared sensors. When a pixel is much hotter than its surroundings, it is reported as a hotspot: a point with a position, a time, a brightness temperature, a fire radiative power (FRP, in MW) and a confidence. NASA collects them in FIRMS, a few hours after the overpass.
flowchart LR
S["🛰️ MODIS (Terra, Aqua)<br/>🛰️ VIIRS (S-NPP, NOAA-20, NOAA-21)"] --> F["NASA LANCE / FIRMS<br/>hotspot detection"]
F --> A["📦 Per-country yearly CSVs<br/>open, 2000 →"]
F --> N["⚡ Area API<br/>1-5 days per query, MAP_KEY"]
A -->|fetch_archive| C["firms-client<br/>parse · classify · select"]
N -->|fetch_area| C
C --> U["Your analysis<br/>fire history · risk models · alerts"]
What people use it for:
- a fire history of a country or region, year by year, for 26 years;
- a wildfire trigger: something is burning in my area right now;
- training data for fire-risk and burnt-area models;
- air-quality and emissions work, from the radiative power of each fire.
What this library does
| Feature | In plain words | |
|---|---|---|
| 📦 | 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 and read from disk next time. |
| ⚡ | Near-real-time area API | The last days over any bounding box, 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 (or low/nominal/high) judged by one rule. |
| 🛡️ | Errors that look like errors | A missing year is None, a bad key or an error page with HTTP 200 raises FirmsError. |
| 🧪 | Pure functions too | URLs and parsing are plain functions, if you have your own HTTP stack. |
| 🪶 | Light | One dependency (httpx), fully typed (mypy --strict), 100% test coverage. |
Who is it for
- Researchers and students in remote sensing, ecology and climate who want FIRMS data in a notebook or pipeline, without the e-mail order form.
- Data engineers who need a repeatable national fire history.
- Civil protection and risk teams who want a wildfire trigger over their area, with industrial sources already filtered out.
It started inside Limen, which uses the archives as the fire history of Italy and the area API as a wildfire trigger, and was extracted so that anyone can use it.
Quick start
1. Install
pip install firms-client
Python 3.12 or newer.
2. Download a country's year, no key needed
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. The country is its English name, as FIRMS writes it (Italy,
Greece, United_States…).
3. 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 (an e-mail address is enough) 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)
fires = select(rows, min_frp_mw=1.0)
days goes from 1 to 5 (the API's limit), on_date=date(2026, 8, 1) moves the
window back.
The area API is near-real-time and is never cached.
Where downloads are saved
Yearly archives go to the directory you pass (directory= on
fetch_archive, fetch_archive_csv and download_archive). If you pass
none:
FIRMS_DATA_DIR, if set;- otherwise
~/.cache/firms-client(or$XDG_CACHE_HOME/firms-client).
~/.cache/firms-client/
├── modis_2025_Italy.csv 400 KB
├── viirs-snpp_2025_Italy.csv 1.8 MB
└── viirs-snpp_2024_Italy.csv …
flowchart TD
Q["fetch_archive(client, VIIRS_SNPP, 2025, 'Italy')"] --> E{"viirs-snpp_2025_Italy.csv<br/>in the data directory?"}
E -->|yes| R["read from disk<br/>no request"]
E -->|no| G["GET the yearly CSV"]
G -->|404| N["None<br/>(not cached: it may be published later)"]
G -->|200| P["stream to …csv.part"]
P --> V{"is it a hotspot CSV?"}
V -->|no| X["delete .part<br/>raise FirmsError"]
V -->|yes| M["rename to .csv"] --> R
A file that exists is whole: downloads are streamed to a .part file and
renamed only when complete. To refresh a year, delete its file.
In Docker, point it at a volume so the data survives the container:
services:
app:
environment:
FIRMS_DATA_DIR: /data/firms
volumes:
- firms-data:/data/firms
volumes:
firms-data:
It is the same convention as
egms-client
(EGMS_DATA_DIR), so one volume can hold both under /data.
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. FIRMS
labels every detection with a type:
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.
| Italy, 2025 | Detections | Vegetation (0) | Volcano (1) | Industry (2) | Offshore (3) | select() keeps |
|---|---|---|---|---|---|---|
| MODIS | 5,076 | 3,414 | 21 | 1,635 | 6 | 3,315 |
| VIIRS S-NPP | 23,349 | 17,533 | 633 | 4,277 | 906 | 14,880 |
The difference is starkest in winter, when there are few real fires and the factories keep burning:
The numbers behind the chart
| VIIRS S-NPP, Italy 2025 | Jan | Feb | Mar | Apr | May | Jun | Jul | Aug | Sep | Oct | Nov | Dec |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Vegetation fire | 76 | 117 | 357 | 322 | 590 | 3,018 | 4,859 | 4,214 | 2,751 | 1,034 | 118 | 77 |
| Everything else | 263 | 292 | 411 | 538 | 509 | 875 | 648 | 609 | 628 | 422 | 366 | 255 |
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 (clouds,
smoke and the time of the overpass all hide fires).
All the images above are drawn from the real archives by
docs/make_images.py, with this library.
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 |
| Products | MODIS (2000-), VIIRS_SNPP (2012-) |
VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, VIIRS_NOAA21_NRT, MODIS_NRT (NRT_SOURCES) |
| 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 |
| URL | …/data/country/{product}/{year}/{product}_{year}_{country}.csv |
…/api/area/csv/{key}/{source}/{w,s,e,n}/{days}[/{date}] |
From a CSV row to a Hotspot
MODIS and VIIRS share one layout, with two differences the parser absorbs:
confidence (a percentage for MODIS, a level for VIIRS) and the 4 µm
brightness column (brightness vs bright_ti4). Every row becomes a frozen
dataclass:
| Field | Type | Example | Notes |
|---|---|---|---|
latitude, longitude |
float |
40.52, 17.21 |
degrees, WGS84 |
acq_date |
date |
2025-07-24 |
UTC |
acq_time |
int |
126 |
UTC HHMM: 126 is 01:26 |
confidence |
str | None |
"n", "high", "87" |
as written; see confidence_level |
frp_mw |
float | None |
12.4 |
fire radiative power, MW |
brightness_k |
float | None |
331.2 |
4 µm brightness temperature, K |
daynight |
str | None |
"D" |
day or night overpass |
satellite, instrument |
str | None |
"N", "VIIRS" |
|
detection_type |
int | None |
0 |
the FIRMS type; is_vegetation_fire reads it |
Rows without coordinates, date or a valid time are skipped.
What select() keeps
flowchart TD
H["Hotspot"] --> T{"vegetation_only and<br/>type is 1, 2 or 3?"}
T -->|yes| D1["❌ dropped<br/>volcano · industry · offshore"]
T -->|"no (type 0, or no type)"| C{"confidence"}
C -->|missing| F
C -->|"VIIRS level"| L{"≥ min_confidence?<br/>(nominal)"}
C -->|"MODIS %"| P{"≥ min_confidence_pct?<br/>else mapped: <30 low,<br/>30-79 nominal, 80+ high"}
L -->|no| D2["❌ dropped"]
P -->|no| D2
L -->|yes| F{"min_frp_mw > 0?"}
P -->|yes| F
F -->|"no"| K["✅ kept"]
F -->|"yes: FRP ≥ min"| K
F -->|"yes: FRP lower or missing"| D3["❌ dropped"]
select(rows, vegetation_only=True, min_confidence="nominal", min_confidence_pct=None, min_frp_mw=0.0):
- a row without
typeis kept, since it cannot be judged; - a missing confidence is kept: losing a real fire is worse than keeping a doubtful pixel;
confidence_level("87")→"high",confidence_level("n")→"nominal": use it to put both instruments on one scale yourself.
What happens on errors
| FIRMS answers | You get |
|---|---|
| archive year not published (404 with a JSON body) | None |
| an error page with HTTP 200 instead of a CSV | FirmsError |
bad MAP_KEY or source (HTTP 400, plain text Invalid MAP_KEY.) |
FirmsError |
| area API, nothing detected (empty body) | [] |
| 5xx, timeouts, network errors | httpx exceptions, untouched: retries are your client's job |
Recipes
The fire history of a country
from firms_client import VIIRS_SNPP, archive_years, fetch_archive, select
async def history(client: httpx.AsyncClient, country: str) -> dict[int, int]:
"""Vegetation fires per year; the first run downloads, the next ones read from disk."""
per_year = {}
for year in archive_years(VIIRS_SNPP, until=2025):
rows = await fetch_archive(client, VIIRS_SNPP, year, country)
if rows is not None:
per_year[year] = len(select(rows))
return per_year
Fire-days per square kilometre
Run it with and without select() to find your country's steelworks:
from collections import defaultdict
days: dict[tuple[float, float], set[date]] = defaultdict(set)
for year in archive_years(VIIRS_SNPP, until=2025):
for r in await fetch_archive(client, VIIRS_SNPP, year, "Italy") or []:
days[round(r.latitude, 2), round(r.longitude, 2)].add(r.acq_date)
hottest = sorted(days.items(), key=lambda kv: len(kv[1]), reverse=True)[:5]
for (lat, lon), d in hottest:
print(f"{lat:.2f}, {lon:.2f}: {len(d)} fire-days")
A monthly chart
With pip install matplotlib:
import matplotlib.pyplot as plt
rows = await fetch_archive(client, VIIRS_SNPP, 2025, "Italy")
fires = [0] * 12
other = [0] * 12
for r in rows:
(fires if r.is_vegetation_fire is not False else other)[r.acq_date.month - 1] += 1
months = range(1, 13)
plt.bar(months, other, label="industry, volcano, offshore")
plt.bar(months, fires, bottom=other, label="vegetation fire")
plt.legend()
plt.title("VIIRS detections over Italy, 2025")
plt.show()
GeoJSON for QGIS or a web map
import json
features = [
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [h.longitude, h.latitude]},
"properties": {
"date": h.acq_date.isoformat(),
"time": f"{h.acq_time:04d}",
"frp_mw": h.frp_mw,
"confidence": h.confidence,
"type": h.detection_type,
},
}
for h in select(rows)
]
Path("fires.geojson").write_text(json.dumps({"type": "FeatureCollection", "features": features}))
A wildfire trigger
async def watch(client: httpx.AsyncClient, map_key: str, bbox: BBox) -> None:
seen: set[tuple[float, float, date, int]] = set()
while True:
for source in ("VIIRS_SNPP_NRT", "VIIRS_NOAA20_NRT", "VIIRS_NOAA21_NRT"):
for h in select(await fetch_area(client, map_key, source, bbox), min_frp_mw=5.0):
key = (h.latitude, h.longitude, h.acq_date, h.acq_time)
if key not in seen:
seen.add(key)
print(f"🔥 {h.latitude:.3f}, {h.longitude:.3f} {h.frp_mw} MW {source}")
await asyncio.sleep(15 * 60)
Bring your own HTTP
archive_url, archive_filename, area_url, is_hotspot_csv and
parse_csv are pure: download with anything, parse with this.
from firms_client import VIIRS_SNPP, archive_url, parse_csv
url = archive_url(VIIRS_SNPP, 2025, "Greece")
rows = parse_csv(requests.get(url, timeout=120).text)
The traps we already fell into
| You might think | What is true |
|---|---|
| the archive needs an Earthdata login and arrives by e-mail | yearly CSVs per country are static and open: no account, no key |
| every satellite has a country archive | modis (2000-) and viirs-snpp (2012-) do; viirs-noaa20 / noaa21 do not |
| high confidence means fire | a steelworks is hot for real and comes through at high confidence |
| a confidence filter is enough | only the type column says what burned: 0 vegetation, 1 volcano, 2 industry, 3 offshore |
| confidence is one format | MODIS writes a 0-100 percentage, VIIRS l/n/h or low/nominal/high |
| a missing year is an empty file | it is a 404 with a JSON body; an error with status 200 is caught too |
| a bad MAP_KEY returns JSON | it is HTTP 400 with plain text Invalid MAP_KEY. |
| no hotspot means no fire | FIRMS is presence-only: clouds, smoke and the overpass time hide fires |
All checked against the live service on 3 November 2026.
API at a glance
| 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 |
MODIS, VIIRS_SNPP, ARCHIVE_PRODUCTS, NRT_SOURCES |
constants | products and sources |
VEGETATION_FIRE, ACTIVE_VOLCANO, STATIC_LAND_SOURCE, OFFSHORE |
constants | the type values |
Hotspot, ArchiveProduct, BBox, ConfidenceLevel |
types | |
FirmsError |
exception | FIRMS answered, but not with a hotspot CSV |
Every async function takes your httpx.AsyncClient: timeouts, proxies,
retries and pooling stay yours.
Contributing
Issues and pull requests are welcome; see CONTRIBUTING.md.
git clone https://github.com/agent-engineering-studio/firms-client
cd firms-client
uv sync
make check # ruff, mypy --strict, pytest with coverage: the same gate as CI
Tests never touch the network: they run against small fixtures cut from the 2025 Italian archives.
Ideas to work on
- 🌍 More archives: per-country files for other products, if FIRMS publishes them; a list of the country names FIRMS accepts.
- 🗺️ Area API over history: the
*_SPsources and date ranges longer than 5 days, split into windows. - 🔁 Retries for 429/5xx, as in egms-client, opt-in.
- 🖥️ A command line:
firms archive Italy 2025,firms area --bbox …. - 🧭 Clustering neighbouring hotspots into fire events.
- 📓 Notebooks: a national fire history, a burnt-area comparison.
If you use firms-client in a project or a paper, open an issue and tell us: we will link it here.
Data licence and credits
FIRMS data are provided by NASA LANCE / FIRMS and are free to use; please acknowledge "NASA FIRMS" in anything you publish, for example:
We acknowledge the use of data and imagery from LANCE FIRMS operated by NASA's Earth Science Data and Information System (ESDIS) with funding provided by NASA Headquarters.
The test fixtures are a few rows of the 2025 Italian archives, and the images in this README are drawn from the full 2025 archives.
Extracted from Limen. See also egms-client for the ground motion of Europe from Sentinel-1.
License
Code: Apache-2.0.
Metadata
Release files for firms-client 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| firms_client-0.2.0.tar.gz | 27.7 kB | With provenance | Details for firms_client-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| firms_client-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for firms_client-0.2.0-py3-none-any.whl |
Total release size: 49.3 kB
Release files / firms_client-0.2.0.tar.gz
| Download URL | firms_client-0.2.0.tar.gz |
|---|---|
| Size | 27.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7b6cd7a7416cb25df68c90aef4ff52beef334b01d33861d6dbcc587c51e42256
|
|
BLAKE2b-256 checksum How to use checksums |
46ca3782fc6cc0ef79db3d73b8ae1dc72a8f16489447d04a44127b65a09f4ecf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.
Transparency logRelease files / firms_client-0.2.0-py3-none-any.whl
| Download URL | firms_client-0.2.0-py3-none-any.whl |
|---|---|
| Size | 21.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0e87418c9505c6a1f3ae6befb0004df68b08d59d0e00619af65db37a33813fdc
|
|
BLAKE2b-256 checksum How to use checksums |
2ad30e35d51b20beecf69c00119649246fcd679c580e4c391c791df672417771
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.
Transparency logSecurity for firms-client 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for firms_client-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.