firms-client

Roadmap ยท updated 4 November 2026

From hotspots to answers

firms-client turns FIRMS into clean, typed detections. The next step is turning detections into answers: what is burning, is it unusual, who should know. Some of that is plain engineering, some of it is machine learning, and some of it is an LLM explaining numbers that code has already computed.

Shipped: the foundations (v0.3), the AI-ready layer (v0.4) and the static-source mask and anomalies (v0.5). Everything marked โœ“ shipped below is on main, with tests and measured on the real Italian archives. Also shipped (v1.0): grounded alert bulletins, crop burning vs wildfire by land cover. Next: training sets for fire-risk models.

Principles

What does not change

๐ŸชถThe core stays light

firms-client keeps one dependency. ML and LLM features live in optional extras (firms-client[ml]) or sibling packages (firms-mcp), never in the import path of someone who only wants a CSV.

๐ŸงฎCode counts, models narrate

Every number an LLM shows comes from a function with a test. The model chooses tools and writes prose; it never sums detections in its head.

๐Ÿ”Honest about the data

FIRMS is presence-only, and type is missing in near-real-time data. Every feature says what it cannot see, as the README already does.

๐Ÿ“Measured, not vibes

Every model ships with its evaluation: held-out years, per-class precision and recall, and the Italian archive as a fixed benchmark.

Phases

Four releases

v0.3

Foundations

  • โœ… opt-in retries for 429 / 5xx
  • โœ… area API over long date ranges
  • โœ… keep every CSV column
  • โœ… firms command line
v0.4

AI-ready

  • โœ… summarize(): compact stats for LLMs
  • โœ… fire events (spatio-temporal clusters)
  • โœ… firms-mcp server
  • โœ… llms.txt (on this site)
v0.5

Learned filters

  • โœ… static-source mask from the archives
  • โœ… anomaly vs the historical baseline
  • type classifier for new sources
v1.0

Answers

  • โœ… grounded alert bulletins
  • โœ… crop burning vs wildfire, with land cover
  • training sets for fire-risk models

The gap that matters

Near-real-time data has no type

The whole library is built on one insight: only the type column tells a wildfire from a steelworks. But that column comes with standard processing, in the yearly archives. The area API, the one you use for alerts, does not carry it.

select() keeps a row without type, because it cannot judge it. So the wildfire trigger recipe, run over Italy, can alert on the ILVA steelworks in Taranto on any clear night.

The archives already know where the factories are: 3,308 fire-days on one square kilometre is not a wildfire. Learning that from history, and applying it to live data, is the highest-value feature on this page. See static-source mask and type classifier.

Foundations

What the AI features stand on

None of these is AI. All of them are needed before AI is worth doing.

๐Ÿ” Opt-in retries and rate limits

v0.3smallโœ“ shipped

An MCP server or an agent calls the area API far more often than a person does. A RetryPolicy for 429 and 5xx with backoff and Retry-After, as in egms-client, opt-in so that your client's retries still win.

๐Ÿ—บ๏ธ Area API over history

v0.3smallโœ“ shipped

The *_SP sources and date ranges longer than 5 days, split into windows and merged. This is how an agent asks for "last month over this valley" without knowing the API's limit.

rows = await fetch_area_range(client, key, "VIIRS_SNPP_SP", bbox, date(2025, 7, 1), date(2025, 7, 31))

๐Ÿงพ Keep every column

v0.3smallโœ“ shipped

parse_csv drops bright_ti5 / bright_t31, scan, track and version. The difference between the 4 ยตm and 11 ยตm brightness, and the pixel size, are among the best features to tell a gas flare from a grass fire. Add them to Hotspot as optional fields.

๐Ÿ–ฅ๏ธ A command line

v0.3mediumโœ“ shipped

firms archive Italy 2025 --select --geojson, firms area --bbox 6,36,19,47.6 --days 3. Also the simplest tool an agent with a shell can use.

๐Ÿงญ Fire events, not pixels

v0.4mediumโœ“ shipped

Cluster neighbouring hotspots in space and time (a few hundred metres, a day or two) into FireEvents: start, end, footprint, peak and total FRP, number of overpasses. Pure Python on a grid index, no scikit-learn in the core.

People ask about fires, not pixels. Events are what an LLM should talk about, what an alert should be about, and the unit a classifier should judge.

events = cluster(select(rows), radius_m=750, gap_days=1)
# Italy 2025: 14,880 VIIRS fires โ†’ 8,250 events, largest by total FRP first

The largest "vegetation fire" event of 2025 in Italy, 16-21 February, turned out to sit on the flank of Etna: a lava flow outside the volcano mask is type 0 too.

AI features

Machine learning and LLMs, where they earn their place

๐Ÿ“‹ summarize(): hotspots an LLM can read

LLM-readyv0.4smallโœ“ shipped

A national VIIRS year is 23,000 rows: pasting it into a prompt is expensive and the model will miscount it. summarize() returns a small, deterministic, JSON-serialisable digest: counts by type and month, top events by FRP, the hottest cells, what was filtered out and why, and the data's caveats.

digest = summarize(rows, top_events=10, top_cells=5)
json.dumps(digest)  # 3.9 KB instead of a 1.8 MB CSV, for Italy 2025: the context for any LLM call

It is the building block of the MCP server and the bulletins, and it is useful without any AI at all.

๐Ÿค– firms-mcp: FIRMS as tools for agents

agentsv0.4mediumโœ“ shipped

A Model Context Protocol server, as a sibling package, so Claude Desktop, Claude Code or any MCP client can answer "how many vegetation fires were there in Sicily last July, and how does that compare with 2017?" without writing code.

pip install 'firms-client[mcp]'
claude mcp add firms -e FIRMS_MAP_KEY=your-key -- firms-mcp
  • fire_history(country, product, first_year, last_year, bbox): detections and fires per year, from the cached archives;
  • archive_digest(country, year, product, bbox): one year, by type and month, largest events, hottest cells;
  • area_digest(bbox, source, days | start+end, mask_country): near-real-time data, with the static-source mask applied;
  • season_anomaly(country, year, month, bbox): rank, percentile and robust z against the other years;
  • export_geojson(country, year, filename, โ€ฆ): a file the user can open in QGIS;
  • resources: firms://guide (the traps) and firms://attribution.

Every tool returns summarize()-sized output, never raw rows. The MAP_KEY stays in the server's environment; the model never sees it.

๐Ÿญ Static-source mask, learned from 26 years

statistical learningv0.5mediumโœ“ shipped

Close the gap with the archives the library already caches. For each ~375 m cell, count fire-days per year, the share of type 2 / 3 detections, the share of night detections and the seasonality. A cell hot in January and July, night and day, for ten years, is a factory.

mask = await fetch_static_mask(client, "Italy")  # built once from the archives, cached as JSON

live = await fetch_area(client, key, "VIIRS_NOAA21_NRT", ITALY)
fires = select(mask.label(live))  # NRT rows get the type of their cell; ILVA drops out

Pure Python, no model weights, explainable: mask.verdict(h) says which rule fired and the history behind it. Evaluated by hiding the type column of 2025 and predicting it from 2012-2024 (docs/evaluate_mask.py):

static detections caught99.3%
precision91.6%
FIRMS type 0 masked (part of it: the Etna lava flow)3.0%
static sources among select() fires5,725 โ†’ 42

๐ŸŽฏ A type classifier for new sources

MLnextlargeextra: [ml]

The mask only knows places that were hot before. A new biogas plant or a new flare needs a model. A gradient-boosted classifier trained on the archives' labelled rows (millions across countries) with features from a single detection and its neighbourhood: FRP, the brightness bands, day/night, scan and track, fire-days of the cell so far, distance to the nearest known static source.

  • trained offline per region, shipped as a small model file with a model card;
  • validated on years and countries it never saw;
  • outputs a probability, so select() can take a threshold instead of a hard rule.

๐Ÿ“ˆ Is this season unusual?

anomaly detectionv0.5smallโœ“ shipped

With 14 years of VIIRS and 26 of MODIS on disk, compare any month or region with its own history: rank, percentile, robust z-score (median and MAD). It is the question everyone asks in August, and the one an LLM should never answer from memory.

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

๐Ÿ“ฐ Grounded alert bulletins

LLMv1.0mediumโœ“ shipped

The wildfire trigger, one step further: when a new event appears, an LLM writes a short bulletin for a civil-protection team, in their language, from structured inputs only: the event, the anomaly score, the static-mask verdict, nearby places from a gazetteer.

  • structured output (JSON schema), with every number copied from the input and checked after generation;
  • human in the loop: a draft for an operator, never an automatic public alert;
  • provider-agnostic: a writer is any async callable; ClaudeWriter (extra [claude]) is the reference.
facts = bulletin_facts(event, places=gazetteer, mask=mask, anomaly=season, region="Sicily")
draft = await draft_bulletin(facts, ClaudeWriter(), language="it")
draft.grounded  # False if a number or a place is not in the facts: draft.problems says which

๐ŸŒพ Crop burning vs wildfire

land coverv1.0largeโœ“ shipped

The one thing type cannot do. Join each event with land cover (ESA WorldCover, CORINE in Europe) and the calendar: small, short, low-FRP events on cropland in the weeks after harvest are stubble burning; events reaching forest or shrubland are not. A rule set first, a model on the event features if the rules plateau.

with WorldCover() as land:  # ESA WorldCover 10 m, read in place: extra [landcover]
    verdict = classify_burning(event, land)
# crop_burning: cropland 92%, natural vegetation 8%, 11 hotspots over 1 day

Italy 2025: of the 100 largest events, 76% burn in natural vegetation and 9% are crop burning; crop burning comes from June to October, mostly on the Apulian plain. The Etna lava flow reads as vegetation: use it with the static-source mask.

๐Ÿงช Training sets for fire-risk models

datasetv1.0medium

Researchers train fire-risk and burnt-area models on FIRMS. build_training_set(country, years, grid_km) produces a gridded, daily table of fire / no-fire with static sources removed, ready to join with weather (ERA5) and vegetation (NDVI). It handles the presence-only trap explicitly: absences are flagged as "not observed" when the overpass was cloudy or missing, not silently set to zero.

๐Ÿ“š Docs that agents can read

LLM-readyโœ“ shipped

This site publishes llms.txt: the API, the type table and the traps in one plain-text file, so a coding assistant writes correct firms-client code on the first try (and knows that a steelworks comes through at high confidence).

Priorities

Impact against effort

FeatureImpactEffortNeedsShips as
Static-source maskโ˜…โ˜…โ˜…mediumarchives, columnscore โœ…
summarize()โ˜…โ˜…โ˜…smalleventscore โœ…
firms-mcpโ˜…โ˜…โ˜…mediumsummarize, retries, windowsextra [mcp] โœ…
Fire eventsโ˜…โ˜…โ˜…mediumโ€”core โœ…
Anomaly vs baselineโ˜…โ˜…smallarchivescore โœ…
Retries, windows, columnsโ˜…โ˜…smallโ€”core โœ…
CLIโ˜…โ˜…mediumโ€”core (script) โœ…
type classifierโ˜…โ˜…largecolumns, mask[ml] extra
Bulletinsโ˜…โ˜…mediumevents, mask, anomalycore + extra [claude] โœ…
Training setsโ˜…โ˜…mediummask, events[ml] extra
Crop vs wildfireโ˜…largeevents, land coverextra [landcover] โœ…

Done in this order: columns โ†’ events โ†’ static mask โ†’ summarize โ†’ firms-mcp. firms-client now answers the question it was built for, "is something burning in my area right now?", without the steelworks, and an agent can ask it in plain words. Then the bulletins. Next: training sets โ†’ classifier.

Risks

What could go wrong, and the guardrail

RiskGuardrail
An LLM invents a number or a placeNumbers only from tools; bulletins checked against their input; places from a gazetteer, never from the model.
"No fire detected" read as "no fire"Every summary and tool result carries the presence-only caveat and, when known, cloud or overpass gaps.
A learned mask hides a real fire next to a factoryThe mask lowers a score, it does not delete; events with high FRP or growth near a masked cell are kept and flagged.
A model trained on Italy fails elsewherePer-region model cards, cross-country validation, and the rule-based mask as the fallback.
Agents exhaust the MAP_KEY's transaction limitRetries with backoff, a short-lived cache in the MCP server, and long ranges served from the SP archives.
The core grows heavyExtras and sibling packages; CI checks that import firms_client pulls in only httpx.
NASA attribution lost downstreamThe acknowledgement text as an MCP resource and in every generated bulletin and export.

Have a use case, or want to pick one up? Open an issue.