Skip to main content

firms-client: NASA active fires in Python, with a map of 2025 VIIRS detections over Italy

ci PyPI Python 3.12 | 3.13 mypy strict coverage 100% Apache-2.0

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. Website: agentengineering.it/firms-client, with the roadmap.


Contents


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). Without a date it is the last days days up to today; on_date=date(2026, 8, 1) asks for the days days starting on that date. For a longer range, fetch_area_range splits it into 5-day requests:

from datetime import date

from firms_client import RetryPolicy, fetch_area_range

july = await fetch_area_range(
    client,
    MAP_KEY,
    "VIIRS_SNPP_SP",
    ITALY,
    date(2025, 7, 1),
    date(2025, 7, 31),
    retry=RetryPolicy(),  # 429, 5xx and dropped connections, with backoff
)

The area API is near-real-time and is never cached. Its rows carry no type, so select() cannot drop industrial sources from them: see the roadmap.

4. Or from a shell

firms archive Italy 2025                      # a summary, by type
firms archive Italy 2025 --fires --format geojson > fires.geojson
firms archive Greece 2024 --product modis --format csv
FIRMS_MAP_KEY=… firms area --bbox 6,36,19,47.6 --days 3 --fires
FIRMS_MAP_KEY=… firms area --bbox 6,36,19,47.6 --source VIIRS_SNPP_SP --from 2025-07-01 --to 2025-07-31
FIRMS_MAP_KEY=… firms area --bbox 6,36,19,47.6 --days 2 --mask Italy --fires   # without the steelworks
firms anomaly Italy 2017 --month 7
23349 detections
  vegetation fire: 17533
  active volcano: 633
  static land source: 4277
  offshore: 906
from 2025-01-01 to 2025-12-31, busiest day 2025-07-22
FRP: max 420.84 MW, total 148141 MW

The command line retries on 429, 5xx and dropped connections.

5. Or ask an agent: the MCP server

firms-mcp exposes FIRMS as tools over the Model Context Protocol, for Claude Code, Claude Desktop or any MCP client:

pip install 'firms-client[mcp]'

# Claude Code
claude mcp add firms -e FIRMS_MAP_KEY=your-key -- firms-mcp
{
  "mcpServers": {
    "firms": {
      "command": "uvx",
      "args": ["--from", "firms-client[mcp]", "firms-mcp"],
      "env": { "FIRMS_MAP_KEY": "your-key" }
    }
  }
}

That is Claude Desktop's claude_desktop_config.json. The MAP_KEY is only needed for the area API, and stays on the server: the model never sees it.

Tool Answers
fire_history(country, product, first_year, last_year, bbox) detections and fires per year, from the open archives
archive_digest(country, year, product, bbox, top_events) one year: by type and month, largest events, hottest cells
area_digest(bbox, source, days | start+end, top_events, mask_country) the area API, last days or any range; with mask_country, without static sources
season_anomaly(country, year, month, product, bbox, first_year) was this year or month unusual? rank, percentile, robust z
export_geojson(country, year, filename, product, bbox, fires_only) a GeoJSON file in FIRMS_EXPORT_DIR (default: exports/ in the data directory)

Every tool answers with a summarize() digest of a few kilobytes, never raw rows, so the numbers an agent quotes are computed here. The server's instructions and the firms://guide resource carry the traps (type, presence-only, no type in near-real-time data) and firms://attribution carries the NASA acknowledgement. Ask it "how did the 2021 fire season in Sicily compare with 2025?": it calls fire_history with Sicily's bbox.


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:

  1. FIRMS_DATA_DIR, if set;
  2. 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

Map of Italy with 2025 VIIRS detections coloured by type: vegetation fires across the south and the islands, industrial sources in the Po valley and Taranto

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.

Share of detection types over Italy in 2025: MODIS 67% vegetation, 32% industry; VIIRS S-NPP 75% vegetation, 18% industry, 4% offshore, 3% volcano

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:

Monthly VIIRS detections over Italy in 2025, stacked: vegetation fires peak in July (4,859); the other types stay between 255 and 875 a month

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

Timeline: MODIS archive from 2000 and VIIRS S-NPP from 2012, both open with no key; VIIRS NOAA-20 and NOAA-21 only through the near-real-time area API with a MAP_KEY

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
brightness_t5_k float | None 274.9 11 µm brightness, K (bright_ti5 / bright_t31)
scan_km, track_km float | None 0.4, 0.37 pixel size along scan and track
version str | None "2", "2.0NRT" collection and processing

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: &lt;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 type is 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, unless you pass retry=RetryPolicy()

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

from firms_client import to_csv, to_geojson

Path("fires.geojson").write_text(json.dumps(to_geojson(select(rows))))
Path("fires.csv").write_text(to_csv(select(rows)))  # every field, one row per hotspot

Fire events, not pixels

One fire is often dozens of hotspots, seen by several satellites over several days. cluster links hotspots within radius_m and gap_days of each other into FireEvents, the largest by total FRP first:

from firms_client import cluster

events = cluster(select(rows), radius_m=750, gap_days=1)  # VIIRS; ~2000 m for MODIS
for e in events[:3]:
    print(e.start, e.days, "days", e.size, "hotspots", f"{e.total_frp_mw:.0f} MW", e.bbox)

On Italy in 2025 the 14,880 VIIRS fires make 8,250 events.

A digest for people and LLMs

summarize does the counting and returns a JSON-ready dict of a few kilobytes: counts by type, month and day/night, the fires select() keeps, the largest events, the cells hot on the most days, the caveats and the NASA attribution. Put it in a prompt instead of 23,000 rows: every number in it comes from code.

from firms_client import summarize

digest = summarize(rows, top_events=10, top_cells=5)
digest["hot_cells"][0]
# {'latitude': 45.15, 'longitude': 9.94, 'fire_days': 212, 'detections': 430,
#  'not_vegetation_share': 0.991}

From a shell: firms archive Italy 2025 --format json.

Near-real-time data without the steelworks

Area API rows have no type, so select() keeps every steelworks and volcano. A static-source mask learns from a country's archives where they are, cell by cell (0.01°), and gives live rows the type of the place they fall in:

from firms_client import fetch_static_mask

mask = await fetch_static_mask(client, "Italy")  # built once from 2012-last year, then cached
live = await fetch_area(client, MAP_KEY, "VIIRS_NOAA21_NRT", ITALY, days=1)
fires = select(mask.label(live))  # industry, volcanoes and platforms drop out

A cell is static when most of its archive detections are FIRMS type 1-3 (labelled), or when it burned on at least 30 days, in 6 years and in 8 different months (persistent: a source FIRMS calls vegetation). mask.verdict(h) says which, with the history behind it, and label(..., spare_frp_mw=50) keeps strong detections untouched.

How well does it work? The mask was learned on Italy 2012-2024, then the 2025 archive was stripped of its type, as live data are, and labelled (docs/evaluate_mask.py):

Italy 2025, type hidden
static detections (FIRMS type 1-3) caught 99.3% (5,773 / 5,816)
of those, with the right type 97.1%
precision 91.6%
FIRMS type 0 masked 3.0% (530 / 17,533)
select() without the mask 20,605 fires, 5,725 of them static
select() with the mask 14,430 fires, 42 of them static

Part of the "type 0 masked" are not mistakes: the Etna lava flow of February 2025, and type-0 detections inside the Taranto steelworks.

Is this season unusual?

season_anomaly puts a year, or a month of it, against every other archive year: rank, percentile and a robust z-score (median and MAD).

from firms_client import season_anomaly

a = await season_anomaly(client, "Italy", 2017, month=7)
a.describe()  # '2017: 7,552, the highest of 14 years (robust z +3.8)'

From a shell: firms anomaly Italy 2017 --month 7, or firms anomaly Italy 2021 --bbox 12.3,36.6,15.7,38.35 for Sicily.

Crop burning or wildfire?

type 0 says vegetation, not which: stubble burnt after the harvest and a forest fire look the same to the satellite. The land cover under an event tells them apart. With pip install 'firms-client[landcover]', WorldCover reads ESA WorldCover 10 m (open data, no account) straight from its public bucket, fetching only the blocks under the hotspots:

from firms_client import WorldCover, classify_burning, cluster

with WorldCover() as land:
    for event in cluster(select(rows))[:20]:
        verdict = classify_burning(event, land)
        print(event.start, verdict.kind, verdict.reason)
# 2025-08-09 wildfire natural vegetation 100%
# 2025-07-19 crop_burning cropland 92%, natural vegetation 8%, 11 hotspots over 1 day
kind When
wildfire natural vegetation (trees, shrubs, grassland, wetland) ≥ 50%
crop_burning cropland ≥ 70%, natural ≤ 15%, at most 15 hotspots over 2 days
cropland_fire the same land, but a larger or longer event
other built-up or bare ground ≥ 50%
mixed, unknown none of the above; no land cover (the sea)

The thresholds are in BurningRules. It is a heuristic that says what the map says under the pixels, not a validated classifier.

On Italy 2025 (docs/landcover_italy.py, 129 s for 400 events read from the bucket):

VIIRS events, Italy 2025 wildfire mixed crop burning cropland fire other
the 100 largest 76% 14% 9% 1% –
300 at random 48% 23% 13% – 16%

Crop burning comes from June to October, after the harvest; the clearest cases are on the Apulian plain, on 92-96% cropland. One limit shows up too: the Etna lava flow of February 2025 reads as wildfire, since the map sees the vegetation on the volcano's flanks. Use the land cover together with the static-source mask, which catches it.

Reading from the bucket costs about half a second per new 1024-pixel block: fine for alerts, slow for a country's whole year. For bulk work, copy the tiles you need and pass their directory: WorldCover(source="…").

Grounded alert bulletins

When the trigger finds a new event, an LLM can draft the few lines an operator needs, in their language. Code gathers the facts; the model only writes; every number and place in the draft is then checked against the facts. With pip install 'firms-client[claude]':

from firms_client import ClaudeWriter, Place, bulletin_facts, draft_bulletin

places = [
    Place("Caltanissetta", 37.4901, 14.0629),
    Place("Enna", 37.5670, 14.2795),
]  # your gazetteer
facts = bulletin_facts(event, places=places, mask=mask, anomaly=season, region="Sicily")
draft = await draft_bulletin(facts, ClaudeWriter(), language="it")

if draft.grounded:
    print(draft.as_text())  # a draft for the operator, never a public alert
else:
    print(draft.problems)  # e.g. ["number '300' is not in the facts", "place 'Corleone' ..."]
  • bulletin_facts has the event (first and last time seen, peak and total FRP, overpasses, satellites, day and night detections), the nearest places from your gazetteer with distance and direction, the static-source verdict when the mask knows the place, the season's anomaly, and the caveats. The model is never asked where places are.
  • draft_bulletin asks for structured output (BULLETIN_SCHEMA) and runs check_grounding: numbers must be facts or facts rounded, places must be in the facts. A draft that strays is sent back once with the list of problems; if it still strays, grounded is false and problems says why.
  • ClaudeWriter calls Claude Opus 5.5 through the Anthropic API, with refusals retried server-side on Anthropic's recommended fallback (fallbacks="default"). Credentials come from the environment (ANTHROPIC_API_KEY). Any other model works: a writer is just an async (system, facts_json, schema) -> dict.

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.
the date in the area API ends the window it starts it: DATE .. DATE + days - 1; without a date, the window ends today
type 0 is always vegetation a lava flow outside the volcano mask is type 0 too: Italy's largest 2025 "vegetation fire" event, 16-21 February, is on the flank of Etna
near-real-time rows say what burned the area API has no type column: a steelworks looks like a fire; use a static-source mask
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, retry=None) async detections over a bbox, 1-5 days
fetch_area_range(client, map_key, source, bbox, start, end, *, retry=None) async any date range, in 5-day requests
RetryPolicy(attempts=3, backoff_s=1.0, max_backoff_s=30.0, …) type opt-in retries for every fetch (retry=)
cluster(hotspots, *, radius_m=750.0, gap_days=1) pure hotspots → list[FireEvent], largest first
summarize(hotspots, *, top_events=10, top_cells=5, …) pure a JSON-ready digest, a few KB, for people and LLMs
fetch_static_mask(client, country, *, product, first_year, last_year, rules) async the static-source mask of a country, built once and cached
build_static_mask(hotspots, rules), StaticMask.label/verdict/save/load, MaskRules pure the same from rows you have
season_anomaly(client, country, year, *, month, bbox, product) async a year or month against the other archive years
anomaly(series, year) → Anomaly pure the same for any {year: value} series
bulletin_facts(event, *, places, mask, anomaly, burning, region), Place, nearest_places pure the facts a bulletin may use
draft_bulletin(facts, writer, *, language, retries) → Bulletin, check_grounding async / pure an LLM draft, checked against the facts
ClaudeWriter(client=None, *, model="claude-opus-5-5", effort="medium") extra [claude] a writer on the Anthropic API
classify_burning(event, land, rules) → BurningVerdict, event_land_cover, BurningRules pure crop burning, wildfire or other, by land cover
WorldCover(source=…, year=2021) extra [landcover] ESA WorldCover 10 m, read in place
to_geojson(hotspots), to_csv(hotspots) pure a GeoJSON FeatureCollection, a CSV of every field
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, area_windows, archive_years pure URLs, file names, 5-day windows, year ranges
default_directory() pure FIRMS_DATA_DIR, else ~/.cache/firms-client
MODIS, VIIRS_SNPP, ARCHIVE_PRODUCTS, NRT_SOURCES, SP_SOURCES, MAX_AREA_DAYS constants products and sources
VEGETATION_FIRE, ACTIVE_VOLCANO, STATIC_LAND_SOURCE, OFFSHORE constants the type values
Hotspot, FireEvent, ArchiveProduct, BBox, ConfidenceLevel types
ATTRIBUTION, TYPE_NAMES constants the NASA acknowledgement, names for type
within(hotspots, bbox) pure the hotspots inside a bbox: one region of a country
firms_client.mcp_server extra [mcp] firms-mcp, build_server(), and the tool logic as plain async functions
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
uv sync --all-extras  # the mcp, claude and landcover extras too, for their tests
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.
  • 🌋 Volcano proximity in the static mask, and masks for more countries, measured with docs/evaluate_mask.py.
  • 📓 Notebooks: a national fire history, a burnt-area comparison.

The full plan, including the AI features (an MCP server, a learned static-source mask for near-real-time data, fire events, grounded alert bulletins), is on the roadmap.

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.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for firms-client 0.5.0
File Size Uploaded Provenance Actions
firms_client-0.5.0.tar.gz 67.0 kB With provenance Details for firms_client-0.5.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for firms-client 0.5.0
File Interpreter ABI Platform Actions
firms_client-0.5.0-py3-none-any.whl Python 3 none any Details for firms_client-0.5.0-py3-none-any.whl

Total release size: 128.6 kB

Release files / firms_client-0.5.0.tar.gz

Download URL firms_client-0.5.0.tar.gz
Size 67.0 kB
Tags Source
SHA-256 checksum
How to use checksums
3cf49516ae7fbd1cad9e699b572c2c56648b52a40d5cd0ce5527f154dc0bc0c7
BLAKE2b-256 checksum
How to use checksums
d53440ae221a45cc83407b0ecc911fdfd13feb7f932588a4c8b0575f889f94e8
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 4, 2026.

Transparency log

Release files / firms_client-0.5.0-py3-none-any.whl

Download URL firms_client-0.5.0-py3-none-any.whl
Size 61.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8049ee4ed2298c2686821dbd9578ef300124920f4dc821ac4b9f60050e4fe467
BLAKE2b-256 checksum
How to use checksums
47a8d72ae1d43d39305733844e39832676bf3348c3e7bdc2d415015afb6ccd03
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 4, 2026.

Transparency log

Security for firms-client 0.5.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.

Select from 2 files

Attestation for firms_client-0.5.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 4, 2026.

Transparency log

A 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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page