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.
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
`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
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 `get_active_fires` tool reports acquisition times as UTC ISO timestamps,
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 →
* {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}
*/
export function firmsProxy() {
+1
View File
@@ -879,6 +879,7 @@
"KEY REQUIRED": "المفتاح مطلوب",
"STALE · cached {age}": "متقادم · مخزّن {age}",
"LIVE · updated {age}": "مباشر · حُدّث {age}",
"24 h to {day} {time} UTC": "24 ساعة حتى {day} {time} UTC",
"<1m ago": "قبل أقل من دقيقة",
"FIRE · {frp} MW": "حريق · {frp} MW",
"▲ {frp} MW": "▲ {frp} MW",
+1
View File
@@ -164,6 +164,7 @@
"loading...": "lädt...",
"KEY REQUIRED": "SCHLÜSSEL ERFORDERLICH",
"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",
"MAPPED INSTALLATION": "KARTIERTE ANLAGE",
"LAUNCH SITE · {site}": "STARTPLATZ · {site}",
+1
View File
@@ -737,6 +737,7 @@
"KEY REQUIRED": "CLAVE REQUERIDA",
"STALE · cached {age}": "DESACTUALIZADO · en caché hace {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",
"FIRE · {frp} MW": "INCENDIO · {frp} MW",
"▲ {frp} MW": "▲ {frp} MW",
+1
View File
@@ -734,6 +734,7 @@
"loading...": "chargement…",
"KEY REQUIRED": "CLÉ REQUISE",
"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",
"Live data unavailable": "Données en direct indisponibles",
"Intel Picture": "Situation renseignement",
+1
View File
@@ -750,6 +750,7 @@
"loading...": "טוען...",
"KEY REQUIRED": "נדרש מפתח",
"LIVE · updated {age}": "חי · עודכן {age}",
"24 h to {day} {time} UTC": "24 שעות עד {day} {time} UTC",
"FIRE": "שריפה",
"Source snapshot time unavailable": "זמן תמונת המצב של המקור אינו זמין",
"Live data unavailable": "נתונים חיים אינם זמינים",
+1
View File
@@ -734,6 +734,7 @@
"loading...": "caricamento...",
"KEY REQUIRED": "CHIAVE RICHIESTA",
"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",
"Live data unavailable": "Dati live non disponibili",
"Intel Picture": "Quadro intelligence",
+1
View File
@@ -716,6 +716,7 @@
"loading...": "読込中…",
"KEY REQUIRED": "キーが必要",
"LIVE · updated {age}": "ライブ · 更新 {age}",
"24 h to {day} {time} UTC": "{day} {time} UTC までの24時間",
"Source snapshot time unavailable": "ソースのスナップショット時刻を取得できません",
"Live data unavailable": "ライブデータを利用できません",
"Intel Picture": "情勢図",
+1
View File
@@ -732,6 +732,7 @@
"loading...": "carregando...",
"KEY REQUIRED": "CHAVE NECESSÁRIA",
"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",
"Live data unavailable": "Dados ao vivo indisponíveis",
"Intel Picture": "Panorama de inteligência",
+1
View File
@@ -147,6 +147,7 @@
"loading...": "загрузка...",
"KEY REQUIRED": "ТРЕБУЕТСЯ КЛЮЧ",
"LIVE · updated {age}": "ОНЛАЙН · обновлено {age}",
"24 h to {day} {time} UTC": "24 ч до {day} {time} UTC",
"Retrying mapped sites…": "Повторный запрос объектов…",
"Fetching mapped sites…": "Загрузка объектов…",
"Zoom in to search mapped installations": "Приблизьте, чтобы искать картографированные объекты",
+1
View File
@@ -742,6 +742,7 @@
"loading...": "завантаження...",
"KEY REQUIRED": "ПОТРІБЕН КЛЮЧ",
"LIVE · updated {age}": "ОНЛАЙН · оновлено {age}",
"24 h to {day} {time} UTC": "24 год до {day} {time} UTC",
"Source snapshot time unavailable": "Час знімка джерела невідомий",
"Live data unavailable": "Дані в реальному часі недоступні",
"Intel Picture": "Розвідувальна обстановка",
+1
View File
@@ -716,6 +716,7 @@
"loading...": "加载中…",
"KEY REQUIRED": "需要密钥",
"LIVE · updated {age}": "实时 · 更新于 {age}",
"24 h to {day} {time} UTC": "截至 {day} {time} UTC 的 24 小时",
"Source snapshot time unavailable": "来源快照时间不可用",
"Live data unavailable": "实时数据不可用",
"Low-accuracy score < 2%": "低精度评分 < 2%",
+1
View File
@@ -711,6 +711,7 @@
"loading...": "加載中…",
"KEY REQUIRED": "需要密鑰",
"LIVE · updated {age}": "實時 · 更新於 {age}",
"24 h to {day} {time} UTC": "截至 {day} {time} UTC 的 24 小時",
"Source snapshot time unavailable": "來源快照時間不可用",
"Live data unavailable": "實時數據不可用",
"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 { 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({
layerState,
services,
@@ -32,6 +36,7 @@ export function createIngestion({
return;
if (payload.keyRequired) {
layerState._keyRequired = true;
layerState._at = null;
layerState._error = null;
layerState._stale = false;
return;
@@ -39,6 +44,9 @@ export function createIngestion({
layerState._keyRequired = false;
layerState._error = null;
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;
layerState._selectedFire = null;
layerState._allFires = adaptFirmsRecords(payload?.fires);
+1
View File
@@ -127,6 +127,7 @@ export function createLifecycle({
layerState._rowControlsListener = null;
layerState._cellCount = 0;
layerState._lastUpdate = null;
layerState._at = null;
layerState._keyRequired = false;
layerState._stale = false;
layerState._error = null;
+14 -3
View File
@@ -8,7 +8,7 @@ import {
rampTrackGradient,
sliderPositionForFrp,
} 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';
/** 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
* `error` (established qa-failstate pattern: a dead feed must never look
* like a healthy empty layer) with a matching human `loadingLabel`:
* 'LIVE · updated Xm ago' fresh, 'STALE · cached Xh' when the proxy
* served past-TTL cache, 'KEY REQUIRED' keyless.
* 'LIVE · updated Xm ago' fresh, '24 h to MM-DD HH:MM UTC' for a
* snapshot that ends at a set time (`at`), 'STALE · cached Xh' when the
* proxy served past-TTL cache, 'KEY REQUIRED' keyless.
*/
getStats() {
const now = Date.now();
@@ -213,6 +214,11 @@ export function createQueries({
loadingLabel = staleText;
} else if (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) {
loadingLabel = t('LIVE · updated {age}', {
age: components.model.formatAgoMinutes(now - layerState._lastUpdate),
@@ -226,6 +232,11 @@ export function createQueries({
lastUpdate: layerState._lastUpdate,
loading: layerState._loading,
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
// strings below: without it "no key configured" is indistinguishable
// 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._at = null;
layerState._currentLodId = null;
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',
title: 'Active fires',
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 ' +
'minimum fire radiative power in megawatts and a confidence level — the ' +
"same filter as the Active Fires layer's intensity controls.",
@@ -171,7 +172,10 @@ export const getActiveFires = defineTool({
summary:
`${countNoun(fires.length, 'fire detection')}` +
(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('; ')}).` : '.'),
data: {
view: suggestView(services, { area, layers: ['local-firms'] }),
@@ -182,6 +186,7 @@ export const getActiveFires = defineTool({
stale,
missing_sources: missing,
fetched_at: isoTime(snapshot.fetchedAt),
window_end: Number.isFinite(snapshot.at) ? isoTime(snapshot.at) : null,
...result,
rows: result.rows.map((fire) => ({
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 = [
{ 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.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' },
{
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.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] } };
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);
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.match(result.summary, /^4 fire detections in /);
});
test('get_active_fires applies the same minimum-FRP and confidence filter as the layer', async () => {
const catalog = catalogWith(WEEK);
const strong = await catalog.call('get_active_fires', { ...TAIWAN, min_frp_mw: 20 });
assert.deepEqual(strong.data.rows.map((row) => row.frp_mw), [864, 120, 20]);
const strong = await catalog.call('get_active_fires', {
...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 /);
const nominal = await catalog.call('get_active_fires', { ...TAIWAN, confidence: 'nominal' });
assert.deepEqual(nominal.data.rows.map((row) => row.frp_mw), [120, 20, 2], 'the 864 MW low-confidence fire drops');
const nominal = await catalog.call('get_active_fires', {
...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\)/);
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,
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.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);
});