Merge pull request #1650 from bilawalsidhu/sameh/firms-window-label

fix(firms): label a snapshot that ends at a set time with that time, not LIVE
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-10 10:44:53 -08:00
commit 66a8ff06cb
22 files changed
+190 -17

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
- A FIRMS snapshot that ends at a set time (`at`, epoch ms, from a source that
serves one) is no longer labelled live: the Active Fires row reads
`NASA FIRMS · 24 h to 10-21 15:00 UTC`, and `get_active_fires` says "in the
24 hours to" that time and reports it as `window_end` (samehkhamis, #1650).
+8 -1
View File
@@ -767,7 +767,7 @@ Consult the linked official advisory for safety decisions.
Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state.
Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance. Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h, or for FIRMS the 24 hours to a snapshot's `at`), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance.
Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches
`terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no `terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no
@@ -2913,6 +2913,13 @@ reporting are unchanged.
The FIRMS proxy requests VIIRS NOAA-20, VIIRS NOAA-21 and MODIS NRT (Terra and The FIRMS proxy requests VIIRS NOAA-20, VIIRS NOAA-21 and MODIS NRT (Terra and
Aqua). It skips VIIRS Suomi NPP, which NASA stopped delivering on 2026-11-02. Aqua). It skips VIIRS Suomi NPP, which NASA stopped delivering on 2026-11-02.
`/api/firms` answers the trailing 24 hours to now, and the Active Fires row
reads `NASA FIRMS · LIVE · updated …`. A source may instead answer the 24 hours
to a set time, naming its end as `at` (epoch ms): the row then reads
`NASA FIRMS · 24 h to MM-DD HH:MM UTC`, `getStats()` reports `at`, and
`get_active_fires` says "in the 24 hours to" that time and reports it as
`window_end`. The dev proxy never sets `at`.
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
The `get_active_fires` tool reports acquisition times as UTC ISO timestamps, The `get_active_fires` tool reports acquisition times as UTC ISO timestamps,
including zero-padded hours and minutes; missing or invalid dates and times including zero-padded hours and minutes; missing or invalid dates and times
+5
View File
@@ -150,6 +150,11 @@ export async function fetchFirms(
* Keyless (no FIRMS_MAP_KEY): /api/firms → 503 {error:'no_key'}; status → * Keyless (no FIRMS_MAP_KEY): /api/firms → 503 {error:'no_key'}; status →
* {hasKey:false}. Upstream is never touched without a key. * {hasKey:false}. Upstream is never touched without a key.
* *
* /api/firms answers the trailing 24 hours to now. A source may instead
* answer the 24 hours to a set time, naming its end as `at` (epoch ms); the
* layer's row then shows that time rather than LIVE. This proxy never sets
* `at`.
*
* @returns {import('vite').Plugin} * @returns {import('vite').Plugin}
*/ */
export function firmsProxy() { export function firmsProxy() {
+1
View File
@@ -879,6 +879,7 @@
"KEY REQUIRED": "المفتاح مطلوب", "KEY REQUIRED": "المفتاح مطلوب",
"STALE · cached {age}": "متقادم · مخزّن {age}", "STALE · cached {age}": "متقادم · مخزّن {age}",
"LIVE · updated {age}": "مباشر · حُدّث {age}", "LIVE · updated {age}": "مباشر · حُدّث {age}",
"24 h to {day} {time} UTC": "24 ساعة حتى {day} {time} UTC",
"<1m ago": "قبل أقل من دقيقة", "<1m ago": "قبل أقل من دقيقة",
"FIRE · {frp} MW": "حريق · {frp} MW", "FIRE · {frp} MW": "حريق · {frp} MW",
"▲ {frp} MW": "▲ {frp} MW", "▲ {frp} MW": "▲ {frp} MW",
+1
View File
@@ -164,6 +164,7 @@
"loading...": "lädt...", "loading...": "lädt...",
"KEY REQUIRED": "SCHLÜSSEL ERFORDERLICH", "KEY REQUIRED": "SCHLÜSSEL ERFORDERLICH",
"LIVE · updated {age}": "LIVE · aktualisiert {age}", "LIVE · updated {age}": "LIVE · aktualisiert {age}",
"24 h to {day} {time} UTC": "24 Std. bis {day} {time} UTC",
"loading mapped installation context": "Kontext der kartierten Anlage wird geladen", "loading mapped installation context": "Kontext der kartierten Anlage wird geladen",
"MAPPED INSTALLATION": "KARTIERTE ANLAGE", "MAPPED INSTALLATION": "KARTIERTE ANLAGE",
"LAUNCH SITE · {site}": "STARTPLATZ · {site}", "LAUNCH SITE · {site}": "STARTPLATZ · {site}",
+1
View File
@@ -737,6 +737,7 @@
"KEY REQUIRED": "CLAVE REQUERIDA", "KEY REQUIRED": "CLAVE REQUERIDA",
"STALE · cached {age}": "DESACTUALIZADO · en caché hace {age}", "STALE · cached {age}": "DESACTUALIZADO · en caché hace {age}",
"LIVE · updated {age}": "EN VIVO · actualizado {age}", "LIVE · updated {age}": "EN VIVO · actualizado {age}",
"24 h to {day} {time} UTC": "24 h hasta {day} {time} UTC",
"<1m ago": "hace <1 min", "<1m ago": "hace <1 min",
"FIRE · {frp} MW": "INCENDIO · {frp} MW", "FIRE · {frp} MW": "INCENDIO · {frp} MW",
"▲ {frp} MW": "▲ {frp} MW", "▲ {frp} MW": "▲ {frp} MW",
+1
View File
@@ -734,6 +734,7 @@
"loading...": "chargement…", "loading...": "chargement…",
"KEY REQUIRED": "CLÉ REQUISE", "KEY REQUIRED": "CLÉ REQUISE",
"LIVE · updated {age}": "DIRECT · mis à jour {age}", "LIVE · updated {age}": "DIRECT · mis à jour {age}",
"24 h to {day} {time} UTC": "24 h jusqu'au {day} {time} UTC",
"Source snapshot time unavailable": "Heure de l'instantané source indisponible", "Source snapshot time unavailable": "Heure de l'instantané source indisponible",
"Live data unavailable": "Données en direct indisponibles", "Live data unavailable": "Données en direct indisponibles",
"Intel Picture": "Situation renseignement", "Intel Picture": "Situation renseignement",
+1
View File
@@ -750,6 +750,7 @@
"loading...": "טוען...", "loading...": "טוען...",
"KEY REQUIRED": "נדרש מפתח", "KEY REQUIRED": "נדרש מפתח",
"LIVE · updated {age}": "חי · עודכן {age}", "LIVE · updated {age}": "חי · עודכן {age}",
"24 h to {day} {time} UTC": "24 שעות עד {day} {time} UTC",
"FIRE": "שריפה", "FIRE": "שריפה",
"Source snapshot time unavailable": "זמן תמונת המצב של המקור אינו זמין", "Source snapshot time unavailable": "זמן תמונת המצב של המקור אינו זמין",
"Live data unavailable": "נתונים חיים אינם זמינים", "Live data unavailable": "נתונים חיים אינם זמינים",
+1
View File
@@ -734,6 +734,7 @@
"loading...": "caricamento...", "loading...": "caricamento...",
"KEY REQUIRED": "CHIAVE RICHIESTA", "KEY REQUIRED": "CHIAVE RICHIESTA",
"LIVE · updated {age}": "LIVE · aggiornato {age}", "LIVE · updated {age}": "LIVE · aggiornato {age}",
"24 h to {day} {time} UTC": "24 h fino al {day} {time} UTC",
"Source snapshot time unavailable": "Ora dello snapshot della fonte non disponibile", "Source snapshot time unavailable": "Ora dello snapshot della fonte non disponibile",
"Live data unavailable": "Dati live non disponibili", "Live data unavailable": "Dati live non disponibili",
"Intel Picture": "Quadro intelligence", "Intel Picture": "Quadro intelligence",
+1
View File
@@ -716,6 +716,7 @@
"loading...": "読込中…", "loading...": "読込中…",
"KEY REQUIRED": "キーが必要", "KEY REQUIRED": "キーが必要",
"LIVE · updated {age}": "ライブ · 更新 {age}", "LIVE · updated {age}": "ライブ · 更新 {age}",
"24 h to {day} {time} UTC": "{day} {time} UTC までの24時間",
"Source snapshot time unavailable": "ソースのスナップショット時刻を取得できません", "Source snapshot time unavailable": "ソースのスナップショット時刻を取得できません",
"Live data unavailable": "ライブデータを利用できません", "Live data unavailable": "ライブデータを利用できません",
"Intel Picture": "情勢図", "Intel Picture": "情勢図",
+1
View File
@@ -732,6 +732,7 @@
"loading...": "carregando...", "loading...": "carregando...",
"KEY REQUIRED": "CHAVE NECESSÁRIA", "KEY REQUIRED": "CHAVE NECESSÁRIA",
"LIVE · updated {age}": "AO VIVO · atualizado {age}", "LIVE · updated {age}": "AO VIVO · atualizado {age}",
"24 h to {day} {time} UTC": "24 h até {day} {time} UTC",
"Source snapshot time unavailable": "Horário do snapshot da fonte indisponível", "Source snapshot time unavailable": "Horário do snapshot da fonte indisponível",
"Live data unavailable": "Dados ao vivo indisponíveis", "Live data unavailable": "Dados ao vivo indisponíveis",
"Intel Picture": "Panorama de inteligência", "Intel Picture": "Panorama de inteligência",
+1
View File
@@ -147,6 +147,7 @@
"loading...": "загрузка...", "loading...": "загрузка...",
"KEY REQUIRED": "ТРЕБУЕТСЯ КЛЮЧ", "KEY REQUIRED": "ТРЕБУЕТСЯ КЛЮЧ",
"LIVE · updated {age}": "ОНЛАЙН · обновлено {age}", "LIVE · updated {age}": "ОНЛАЙН · обновлено {age}",
"24 h to {day} {time} UTC": "24 ч до {day} {time} UTC",
"Retrying mapped sites…": "Повторный запрос объектов…", "Retrying mapped sites…": "Повторный запрос объектов…",
"Fetching mapped sites…": "Загрузка объектов…", "Fetching mapped sites…": "Загрузка объектов…",
"Zoom in to search mapped installations": "Приблизьте, чтобы искать картографированные объекты", "Zoom in to search mapped installations": "Приблизьте, чтобы искать картографированные объекты",
+1
View File
@@ -742,6 +742,7 @@
"loading...": "завантаження...", "loading...": "завантаження...",
"KEY REQUIRED": "ПОТРІБЕН КЛЮЧ", "KEY REQUIRED": "ПОТРІБЕН КЛЮЧ",
"LIVE · updated {age}": "ОНЛАЙН · оновлено {age}", "LIVE · updated {age}": "ОНЛАЙН · оновлено {age}",
"24 h to {day} {time} UTC": "24 год до {day} {time} UTC",
"Source snapshot time unavailable": "Час знімка джерела невідомий", "Source snapshot time unavailable": "Час знімка джерела невідомий",
"Live data unavailable": "Дані в реальному часі недоступні", "Live data unavailable": "Дані в реальному часі недоступні",
"Intel Picture": "Розвідувальна обстановка", "Intel Picture": "Розвідувальна обстановка",
+1
View File
@@ -716,6 +716,7 @@
"loading...": "加载中…", "loading...": "加载中…",
"KEY REQUIRED": "需要密钥", "KEY REQUIRED": "需要密钥",
"LIVE · updated {age}": "实时 · 更新于 {age}", "LIVE · updated {age}": "实时 · 更新于 {age}",
"24 h to {day} {time} UTC": "截至 {day} {time} UTC 的 24 小时",
"Source snapshot time unavailable": "来源快照时间不可用", "Source snapshot time unavailable": "来源快照时间不可用",
"Live data unavailable": "实时数据不可用", "Live data unavailable": "实时数据不可用",
"Low-accuracy score < 2%": "低精度评分 < 2%", "Low-accuracy score < 2%": "低精度评分 < 2%",
+1
View File
@@ -711,6 +711,7 @@
"loading...": "加載中…", "loading...": "加載中…",
"KEY REQUIRED": "需要密鑰", "KEY REQUIRED": "需要密鑰",
"LIVE · updated {age}": "實時 · 更新於 {age}", "LIVE · updated {age}": "實時 · 更新於 {age}",
"24 h to {day} {time} UTC": "截至 {day} {time} UTC 的 24 小時",
"Source snapshot time unavailable": "來源快照時間不可用", "Source snapshot time unavailable": "來源快照時間不可用",
"Live data unavailable": "實時數據不可用", "Live data unavailable": "實時數據不可用",
"Low-accuracy score < 2%": "低精度評分 < 2%", "Low-accuracy score < 2%": "低精度評分 < 2%",
+8
View File
@@ -3,6 +3,10 @@ import { fireDetectionKey } from '../../data/firmsLabels.js';
import { filterFires } from '../../data/firmsIntensity.js'; import { filterFires } from '../../data/firmsIntensity.js';
import { msg } from '../../i18n/index.js'; import { msg } from '../../i18n/index.js';
/** A time a Date can print: epoch ms within JavaScript's date range. */
const isSnapshotEnd = (value) =>
Number.isFinite(value) && Math.abs(value) <= 8_640_000_000_000_000;
export function createIngestion({ export function createIngestion({
layerState, layerState,
services, services,
@@ -32,6 +36,7 @@ export function createIngestion({
return; return;
if (payload.keyRequired) { if (payload.keyRequired) {
layerState._keyRequired = true; layerState._keyRequired = true;
layerState._at = null;
layerState._error = null; layerState._error = null;
layerState._stale = false; layerState._stale = false;
return; return;
@@ -39,6 +44,9 @@ export function createIngestion({
layerState._keyRequired = false; layerState._keyRequired = false;
layerState._error = null; layerState._error = null;
layerState._stale = Boolean(payload?.stale); layerState._stale = Boolean(payload?.stale);
// A snapshot of the 24 hours to a set time names it; one of the
// trailing 24 hours to now does not.
layerState._at = isSnapshotEnd(payload?.at) ? payload.at : null;
const previousSelection = layerState._selectedFire; const previousSelection = layerState._selectedFire;
layerState._selectedFire = null; layerState._selectedFire = null;
layerState._allFires = adaptFirmsRecords(payload?.fires); layerState._allFires = adaptFirmsRecords(payload?.fires);
+1
View File
@@ -127,6 +127,7 @@ export function createLifecycle({
layerState._rowControlsListener = null; layerState._rowControlsListener = null;
layerState._cellCount = 0; layerState._cellCount = 0;
layerState._lastUpdate = null; layerState._lastUpdate = null;
layerState._at = null;
layerState._keyRequired = false; layerState._keyRequired = false;
layerState._stale = false; layerState._stale = false;
layerState._error = null; layerState._error = null;
+14 -3
View File
@@ -8,7 +8,7 @@ import {
rampTrackGradient, rampTrackGradient,
sliderPositionForFrp, sliderPositionForFrp,
} from '../../data/firmsIntensity.js'; } from '../../data/firmsIntensity.js';
import { formatNumber, t } from '../../i18n/index.js'; import { formatNumber, formatUtcDayMonth, t } from '../../i18n/index.js';
import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js'; import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js';
/** Slider resolution: whole-percent positions along the log axis. */ /** Slider resolution: whole-percent positions along the log axis. */
@@ -190,8 +190,9 @@ export function createQueries({
* Layer stats for the data panel. Degraded feed states surface through * Layer stats for the data panel. Degraded feed states surface through
* `error` (established qa-failstate pattern: a dead feed must never look * `error` (established qa-failstate pattern: a dead feed must never look
* like a healthy empty layer) with a matching human `loadingLabel`: * like a healthy empty layer) with a matching human `loadingLabel`:
* 'LIVE · updated Xm ago' fresh, 'STALE · cached Xh' when the proxy * 'LIVE · updated Xm ago' fresh, '24 h to MM-DD HH:MM UTC' for a
* served past-TTL cache, 'KEY REQUIRED' keyless. * snapshot that ends at a set time (`at`), 'STALE · cached Xh' when the
* proxy served past-TTL cache, 'KEY REQUIRED' keyless.
*/ */
getStats() { getStats() {
const now = Date.now(); const now = Date.now();
@@ -213,6 +214,11 @@ export function createQueries({
loadingLabel = staleText; loadingLabel = staleText;
} else if (layerState._error) { } else if (layerState._error) {
loadingLabel = layerState._error; loadingLabel = layerState._error;
} else if (Number.isFinite(layerState._at)) {
loadingLabel = t('24 h to {day} {time} UTC', {
day: formatUtcDayMonth(layerState._at),
time: new Date(layerState._at).toISOString().slice(11, 16),
});
} else if (layerState._lastUpdate) { } else if (layerState._lastUpdate) {
loadingLabel = t('LIVE · updated {age}', { loadingLabel = t('LIVE · updated {age}', {
age: components.model.formatAgoMinutes(now - layerState._lastUpdate), age: components.model.formatAgoMinutes(now - layerState._lastUpdate),
@@ -226,6 +232,11 @@ export function createQueries({
lastUpdate: layerState._lastUpdate, lastUpdate: layerState._lastUpdate,
loading: layerState._loading, loading: layerState._loading,
stale: layerState._stale, stale: layerState._stale,
// The end of the 24 hours shown when the snapshot ends at a set time;
// null for the trailing 24 hours to now. Such a snapshot is not live,
// so the row names the provider without the catalog's "LIVE".
at: Number.isFinite(layerState._at) ? layerState._at : null,
...(Number.isFinite(layerState._at) ? { source: 'NASA FIRMS' } : {}),
// The machine-readable half of the keyless state, ahead of the human // The machine-readable half of the keyless state, ahead of the human
// strings below: without it "no key configured" is indistinguishable // strings below: without it "no key configured" is indistinguishable
// from a broken feed, and the row reads as a fault instead of a step // from a broken feed, and the row reads as a fault instead of a step
+2
View File
@@ -77,6 +77,8 @@ export function createFirmsState({ services, config }) {
layerState._lastUpdate = null; layerState._lastUpdate = null;
layerState._at = null;
layerState._currentLodId = null; layerState._currentLodId = null;
layerState._currentLodIndex = -1; layerState._currentLodIndex = -1;
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getLocale, setLocale } from '../../i18n/index.js';
import { createModel } from './model.js';
import { createQueries } from './queries.js';
test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (context) => {
const now = 1800000000000;
context.mock.method(Date, 'now', () => now);
const previousLocale = getLocale();
const at = Date.UTC(2026, 9, 21, 15);
const layerState = {
_loading: false,
_stale: false,
_keyRequired: false,
_error: null,
_lastUpdate: now - 120000,
_at: at,
_count: 1,
_cellCount: 0,
_fires: [],
};
const model = createModel({ services: { anchors: {} } });
const { methods } = createQueries({
layerState,
components: { model },
config: {},
});
try {
setLocale('en');
assert.equal(methods.getStats().loadingLabel, '24 h to 10-21 15:00 UTC');
assert.equal(methods.getStats().at, at);
assert.equal(methods.getStats().source, 'NASA FIRMS', 'the row drops LIVE');
setLocale('de');
assert.equal(
methods.getStats().loadingLabel,
'24 Std. bis 21.10. 15:00 UTC',
);
layerState._at = null;
setLocale('en');
assert.equal(methods.getStats().loadingLabel, 'LIVE · updated 2m ago');
assert.equal(methods.getStats().at, null);
assert.equal(methods.getStats().source, undefined);
} finally {
setLocale(previousLocale);
}
});
+7 -2
View File
@@ -94,7 +94,8 @@ export const getActiveFires = defineTool({
name: 'get_active_fires', name: 'get_active_fires',
title: 'Active fires', title: 'Active fires',
description: description:
'Satellite fire detections from NASA FIRMS in the last 24 hours within an ' + 'Satellite fire detections from NASA FIRMS in the last 24 hours (or the 24 ' +
'hours to window_end, when the source names one) within an ' +
'area, most intense (fire radiative power) first. Optionally limited to a ' + 'area, most intense (fire radiative power) first. Optionally limited to a ' +
'minimum fire radiative power in megawatts and a confidence level — the ' + 'minimum fire radiative power in megawatts and a confidence level — the ' +
"same filter as the Active Fires layer's intensity controls.", "same filter as the Active Fires layer's intensity controls.",
@@ -171,7 +172,10 @@ export const getActiveFires = defineTool({
summary: summary:
`${countNoun(fires.length, 'fire detection')}` + `${countNoun(fires.length, 'fire detection')}` +
(filterNotes.length ? ` (${filterNotes.join(', ')})` : '') + (filterNotes.length ? ` (${filterNotes.join(', ')})` : '') +
` in ${area.label} in the last 24 hours` + ` in ${area.label} ` +
(Number.isFinite(snapshot.at)
? `in the 24 hours to ${isoTime(snapshot.at)}`
: 'in the last 24 hours') +
(notes.length ? ` (${notes.join('; ')}).` : '.'), (notes.length ? ` (${notes.join('; ')}).` : '.'),
data: { data: {
view: suggestView(services, { area, layers: ['local-firms'] }), view: suggestView(services, { area, layers: ['local-firms'] }),
@@ -182,6 +186,7 @@ export const getActiveFires = defineTool({
stale, stale,
missing_sources: missing, missing_sources: missing,
fetched_at: isoTime(snapshot.fetchedAt), fetched_at: isoTime(snapshot.fetchedAt),
window_end: Number.isFinite(snapshot.at) ? isoTime(snapshot.at) : null,
...result, ...result,
rows: result.rows.map((fire) => ({ rows: result.rows.map((fire) => ({
lat: fire.lat, lat: fire.lat,
+82 -11
View File
@@ -46,29 +46,80 @@ test('fire acquisition timestamps preserve valid leap days and UTC times', async
}); });
const WEEK = [ const WEEK = [
{ lat: 24.1, lon: 121.1, frp: 864, confidence: 'l', acqDate: '2026-10-01', acqTime: '0100' }, {
{ lat: 24.2, lon: 121.2, frp: 120, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' }, lat: 24.1,
{ lat: 24.3, lon: 121.3, frp: 20, confidence: 'n', acqDate: '2026-10-01', acqTime: '0100' }, lon: 121.1,
{ lat: 24.4, lon: 121.4, frp: 2, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' }, frp: 864,
{ lat: 30, lon: 100, frp: 500, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' }, confidence: 'l',
acqDate: '2026-10-01',
acqTime: '0100',
},
{
lat: 24.2,
lon: 121.2,
frp: 120,
confidence: 'h',
acqDate: '2026-10-01',
acqTime: '0100',
},
{
lat: 24.3,
lon: 121.3,
frp: 20,
confidence: 'n',
acqDate: '2026-10-01',
acqTime: '0100',
},
{
lat: 24.4,
lon: 121.4,
frp: 2,
confidence: 'h',
acqDate: '2026-10-01',
acqTime: '0100',
},
{
lat: 30,
lon: 100,
frp: 500,
confidence: 'h',
acqDate: '2026-10-01',
acqTime: '0100',
},
]; ];
const TAIWAN = { area: { bbox: [120, 23, 122, 25] } }; const TAIWAN = { area: { bbox: [120, 23, 122, 25] } };
test('get_active_fires shows every detection by default and reports the filter it used', async () => { test('get_active_fires shows every detection by default and reports the filter it used', async () => {
const result = await catalogWith(WEEK).call('get_active_fires', TAIWAN); const result = await catalogWith(WEEK).call('get_active_fires', TAIWAN);
assert.deepEqual(result.data.rows.map((row) => row.frp_mw), [864, 120, 20, 2]); assert.deepEqual(
result.data.rows.map((row) => row.frp_mw),
[864, 120, 20, 2],
);
assert.deepEqual(result.data.filter, { min_frp_mw: 0, confidence: 'all' }); assert.deepEqual(result.data.filter, { min_frp_mw: 0, confidence: 'all' });
assert.match(result.summary, /^4 fire detections in /); assert.match(result.summary, /^4 fire detections in /);
}); });
test('get_active_fires applies the same minimum-FRP and confidence filter as the layer', async () => { test('get_active_fires applies the same minimum-FRP and confidence filter as the layer', async () => {
const catalog = catalogWith(WEEK); const catalog = catalogWith(WEEK);
const strong = await catalog.call('get_active_fires', { ...TAIWAN, min_frp_mw: 20 }); const strong = await catalog.call('get_active_fires', {
assert.deepEqual(strong.data.rows.map((row) => row.frp_mw), [864, 120, 20]); ...TAIWAN,
min_frp_mw: 20,
});
assert.deepEqual(
strong.data.rows.map((row) => row.frp_mw),
[864, 120, 20],
);
assert.match(strong.summary, /^3 fire detections \(≥ 20 MW\) in /); assert.match(strong.summary, /^3 fire detections \(≥ 20 MW\) in /);
const nominal = await catalog.call('get_active_fires', { ...TAIWAN, confidence: 'nominal' }); const nominal = await catalog.call('get_active_fires', {
assert.deepEqual(nominal.data.rows.map((row) => row.frp_mw), [120, 20, 2], 'the 864 MW low-confidence fire drops'); ...TAIWAN,
confidence: 'nominal',
});
assert.deepEqual(
nominal.data.rows.map((row) => row.frp_mw),
[120, 20, 2],
'the 864 MW low-confidence fire drops',
);
assert.match(nominal.summary, /\(nominal or high confidence\)/); assert.match(nominal.summary, /\(nominal or high confidence\)/);
const both = await catalog.call('get_active_fires', { const both = await catalog.call('get_active_fires', {
@@ -76,7 +127,27 @@ test('get_active_fires applies the same minimum-FRP and confidence filter as the
min_frp_mw: 19.6, min_frp_mw: 19.6,
confidence: 'high', confidence: 'high',
}); });
assert.deepEqual(both.data.rows.map((row) => row.frp_mw), [120]); assert.deepEqual(
both.data.rows.map((row) => row.frp_mw),
[120],
);
assert.deepEqual(both.data.filter, { min_frp_mw: 20, confidence: 'high' }); assert.deepEqual(both.data.filter, { min_frp_mw: 20, confidence: 'high' });
assert.match(both.summary, /\(≥ 20 MW, high confidence\)/); assert.match(both.summary, /\(≥ 20 MW, high confidence\)/);
}); });
test('get_active_fires names the end of a snapshot that ends at a set time', async () => {
const at = Date.UTC(2026, 9, 1, 3);
const catalog = composeCatalog({
tools: [getActiveFires],
services: { fires: { getSnapshot: async () => ({ fires: WEEK, at }) } },
});
const result = await catalog.call('get_active_fires', TAIWAN);
assert.match(
result.summary,
/in the 24 hours to 2026-10-01T03:00:00\.000Z\.$/,
);
assert.equal(result.data.window_end, '2026-10-01T03:00:00.000Z');
const now = await catalogWith(WEEK).call('get_active_fires', TAIWAN);
assert.match(now.summary, /in the last 24 hours\.$/);
assert.equal(now.data.window_end, null);
});