diff --git a/changelog.d/1650-firms-window-label.md b/changelog.d/1650-firms-window-label.md new file mode 100644 index 000000000..47b1f8bdf --- /dev/null +++ b/changelog.d/1650-firms-window-label.md @@ -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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7e2470098..9c274434a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/server/providers/firms.js b/server/providers/firms.js index 0fe7f1cb5..2b0e00944 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -145,15 +145,16 @@ export async function fetchFirms( * * Routes: * GET /api/firms → {fetchedAt, stale, ttlMs, sources, count, fires} - * - * 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`. * GET /api/firms/status → {hasKey, lastFetch, count, stale, ttlMs, transactions} * * 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() { diff --git a/src/i18n/firms-live-zh.test.mjs b/src/i18n/firms-live-zh.test.mjs index 04d22ec0d..03b3af6c8 100644 --- a/src/i18n/firms-live-zh.test.mjs +++ b/src/i18n/firms-live-zh.test.mjs @@ -43,43 +43,3 @@ for (const [age, zh, en, hant] of cases) { } }); } - -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); - 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); - } finally { - setLocale(previousLocale); - } -}); diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 452d8bf04..b3857c8a6 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -734,7 +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", + "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", diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js index dc3f64d9a..d70d52d46 100644 --- a/src/layers/firms/ingestion.js +++ b/src/layers/firms/ingestion.js @@ -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; @@ -41,7 +46,7 @@ export function createIngestion({ 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 = Number.isFinite(payload?.at) ? payload.at : null; + layerState._at = isSnapshotEnd(payload?.at) ? payload.at : null; const previousSelection = layerState._selectedFire; layerState._selectedFire = null; layerState._allFires = adaptFirmsRecords(payload?.fires); diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index f2e5674e5..c6cf70d4e 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -233,8 +233,10 @@ export function createQueries({ 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. + // 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 diff --git a/src/layers/firms/windowLabel.test.mjs b/src/layers/firms/windowLabel.test.mjs new file mode 100644 index 000000000..8113db14d --- /dev/null +++ b/src/layers/firms/windowLabel.test.mjs @@ -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); + } +}); diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index e671db426..817b01ff5 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -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, diff --git a/src/tools/queries/hazards.test.mjs b/src/tools/queries/hazards.test.mjs index c1d3eb43a..1c1529bc3 100644 --- a/src/tools/queries/hazards.test.mjs +++ b/src/tools/queries/hazards.test.mjs @@ -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); +});