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
Foundations
- โ opt-in retries for 429 / 5xx
- โ area API over long date ranges
- โ keep every CSV column
- โ
firmscommand line
AI-ready
- โ
summarize(): compact stats for LLMs - โ fire events (spatio-temporal clusters)
- โ
firms-mcpserver - โ
llms.txt(on this site)
Learned filters
- โ static-source mask from the archives
- โ anomaly vs the historical baseline
typeclassifier for new sources
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
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
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
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
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
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
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
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) andfirms://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
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 caught | 99.3% |
| precision | 91.6% |
| FIRMS type 0 masked (part of it: the Etna lava flow) | 3.0% |
static sources among select() fires | 5,725 โ 42 |
๐ฏ A type classifier for new sources
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?
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
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
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
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
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
| Feature | Impact | Effort | Needs | Ships as |
|---|---|---|---|---|
| Static-source mask | โ โ โ | medium | archives, columns | core โ |
summarize() | โ โ โ | small | events | core โ |
firms-mcp | โ โ โ | medium | summarize, retries, windows | extra [mcp] โ
|
| Fire events | โ โ โ | medium | โ | core โ |
| Anomaly vs baseline | โ โ | small | archives | core โ |
| Retries, windows, columns | โ โ | small | โ | core โ |
| CLI | โ โ | medium | โ | core (script) โ |
type classifier | โ โ | large | columns, mask | [ml] extra |
| Bulletins | โ โ | medium | events, mask, anomaly | core + extra [claude] โ
|
| Training sets | โ โ | medium | mask, events | [ml] extra |
| Crop vs wildfire | โ | large | events, land cover | extra [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
| Risk | Guardrail |
|---|---|
| An LLM invents a number or a place | Numbers 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 factory | The 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 elsewhere | Per-region model cards, cross-country validation, and the rule-based mask as the fallback. |
| Agents exhaust the MAP_KEY's transaction limit | Retries with backoff, a short-lived cache in the MCP server, and long ranges served from the SP archives. |
| The core grows heavy | Extras and sibling packages; CI checks that import firms_client pulls in only httpx. |
| NASA attribution lost downstream | The 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.