From 57df5f61ba84ed23628180388241a1765a523a9b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 15:58:45 -0400 Subject: [PATCH 1/5] fix(fires): report a 00:45 UTC detection as 00:45, not "45" get_active_fires joined FIRMS acq_date and the unpadded acq_time, so a detection at 00:45 UTC came back as "2026-01-01 45". The row now uses acquisitionMsUtc, which pads the time, and reports an ISO UTC timestamp like the other tool times. --- changelog.d/1395-fires-tool-acquired-time.md | 4 ++++ src/tools/queries/hazards.js | 4 ++-- src/tools/queries/hazards.test.mjs | 17 +++++++++++++++++ src/tools/queries/queries.test.mjs | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1395-fires-tool-acquired-time.md create mode 100644 src/tools/queries/hazards.test.mjs diff --git a/changelog.d/1395-fires-tool-acquired-time.md b/changelog.d/1395-fires-tool-acquired-time.md new file mode 100644 index 000000000..ea3bddd1d --- /dev/null +++ b/changelog.d/1395-fires-tool-acquired-time.md @@ -0,0 +1,4 @@ +- Report a fire detection's acquisition time as a UTC timestamp in + `get_active_fires`. The tool joined FIRMS's unpadded date and time, so a + 00:45 UTC detection read "2026-01-01 45"; it now reads + `2026-01-01T00:45:00.000Z` (Rudy Celekli, @rudycelekli, #1395). diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index 475df97ba..f644be562 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -1,5 +1,6 @@ /** Natural hazard queries: earthquakes and active fires. */ +import { acquisitionMsUtc } from '../../data/firmsCsv.js'; import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; @@ -114,8 +115,7 @@ export const getActiveFires = defineTool({ lon: fire.lon, frp_mw: fire.frp ?? null, confidence: fire.confidence ?? null, - acquired: - [fire.acqDate, fire.acqTime].filter(Boolean).join(' ') || null, + acquired: isoTime(acquisitionMsUtc(fire.acqDate, fire.acqTime)), satellite: fire.satellite ?? null, day_night: fire.daynight ?? null, })), diff --git a/src/tools/queries/hazards.test.mjs b/src/tools/queries/hazards.test.mjs new file mode 100644 index 000000000..fe2e6ac1f --- /dev/null +++ b/src/tools/queries/hazards.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog } from '../catalog.js'; +import { getActiveFires } from './hazards.js'; + +const catalogWith = (fires) => + composeCatalog({ + tools: [getActiveFires], + services: { fires: { getSnapshot: async () => ({ fires }) } }, + }); + +test('a fire acquired at 00:45 UTC reads as 00:45, not "45"', async () => { + const result = await catalogWith([ + { lat: 24, lon: 121, frp: 5, acqDate: '2026-01-01', acqTime: '45' }, + ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); + assert.equal(result.data.rows[0].acquired, '2026-01-01T00:45:00.000Z'); +}); diff --git a/src/tools/queries/queries.test.mjs b/src/tools/queries/queries.test.mjs index b64f5e009..5f53cf9e8 100644 --- a/src/tools/queries/queries.test.mjs +++ b/src/tools/queries/queries.test.mjs @@ -125,7 +125,7 @@ test('fires need an area and report a missing key as unavailable', async () => { result.data.rows.map((row) => row.frp_mw), [40, 12], ); - assert.equal(result.data.rows[1].acquired, '2026-01-01 0130'); + assert.equal(result.data.rows[1].acquired, '2026-01-01T01:30:00.000Z'); const keyless = composeCatalog({ tools: coreTools, From 315c0f5dc61304cdf0adbd71e5efb1633e47e474 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 15:58:46 -0400 Subject: [PATCH 2/5] =?UTF-8?q?fix(local-adsb):=20show=20a=20359.7=C2=B0?= =?UTF-8?q?=20track=20as=20TRK=200=C2=B0,=20not=20TRK=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Local ADS-B card rounded the track without wrapping, so an aircraft tracking 359.5° or more read "TRK 360°". The rounded track now wraps to 0°. --- changelog.d/1396-local-adsb-track-north.md | 3 +++ src/layers/localAdsb/card.js | 2 +- src/layers/localAdsb/card.test.mjs | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1396-local-adsb-track-north.md create mode 100644 src/layers/localAdsb/card.test.mjs diff --git a/changelog.d/1396-local-adsb-track-north.md b/changelog.d/1396-local-adsb-track-north.md new file mode 100644 index 000000000..9235a19a3 --- /dev/null +++ b/changelog.d/1396-local-adsb-track-north.md @@ -0,0 +1,3 @@ +- Show a locally heard aircraft tracking 359.5° or more as TRK 0°. The Local + ADS-B card rounded the track without wrapping, so 359.7° read "TRK 360°" + (Rudy Celekli, @rudycelekli, #1396). diff --git a/src/layers/localAdsb/card.js b/src/layers/localAdsb/card.js index 1b0122925..c1c923a8c 100644 --- a/src/layers/localAdsb/card.js +++ b/src/layers/localAdsb/card.js @@ -152,7 +152,7 @@ export function localAdsbCardModel( t('ALT {altitude} FT · GS {speed} KT · TRK {track}', { altitude: grouped(altitude), speed: speed === null ? DASH : speed, - track: track === null ? DASH : `${track}°`, + track: track === null ? DASH : `${track % 360}°`, }), vertical === null ? t('V/S {rate}', { rate: DASH }) diff --git a/src/layers/localAdsb/card.test.mjs b/src/layers/localAdsb/card.test.mjs new file mode 100644 index 000000000..ec53024da --- /dev/null +++ b/src/layers/localAdsb/card.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../../i18n/index.js'; +import { localAdsbCardModel } from './card.js'; + +test('a track of 359.7° reads TRK 0°, not TRK 360°', () => { + setLocale('en'); + const { details } = localAdsbCardModel( + { + icao: 'abc123', + altitudeFt: 12500, + groundSpeedKt: 240, + trackDeg: 359.7, + lastPositionAt: 1000, + }, + 5000, + ); + assert.equal(details[1], 'ALT 12,500 FT · GS 240 KT · TRK 0°'); +}); From 224d71753b02eb2fde881e283e1053e9ca2b639b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 16:01:26 -0400 Subject: [PATCH 3/5] fix(fires): parse the detection time inside the tools package The tools package cannot import firmsCsv. Pad the FIRMS clock here so 00:45 UTC still reads as 00:45. --- src/tools/queries/hazards.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index f644be562..194f9b8c1 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -1,11 +1,24 @@ /** Natural hazard queries: earthquakes and active fires. */ -import { acquisitionMsUtc } from '../../data/firmsCsv.js'; import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +function acquiredMsUtc(acqDate, acqTime) { + if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate)) + return NaN; + const timeText = String(acqTime ?? '').trim(); + if (!/^\d{1,4}$/.test(timeText)) return NaN; + const hhmm = timeText.padStart(4, '0'); + const hours = Number(hhmm.slice(0, 2)); + const minutes = Number(hhmm.slice(2, 4)); + if (hours > 23 || minutes > 59) return NaN; + return Date.parse( + `${acqDate}T${hhmm.slice(0, 2)}:${hhmm.slice(2, 4)}:00.000Z`, + ); +} + export const getEarthquakes = defineTool({ name: 'get_earthquakes', title: 'Recent earthquakes', @@ -115,7 +128,7 @@ export const getActiveFires = defineTool({ lon: fire.lon, frp_mw: fire.frp ?? null, confidence: fire.confidence ?? null, - acquired: isoTime(acquisitionMsUtc(fire.acqDate, fire.acqTime)), + acquired: isoTime(acquiredMsUtc(fire.acqDate, fire.acqTime)), satellite: fire.satellite ?? null, day_night: fire.daynight ?? null, })), From 3de745bd33123b9203e2b160d8bbbb6772d399a1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 14:08:25 -0700 Subject: [PATCH 4/5] fix(fires): reject rolled-over acquisition dates and document UTC times --- changelog.d/1395-fires-tool-acquired-time.md | 4 ++- docs/CURRENT-STATE.md | 3 ++ src/tools/queries/hazards.js | 6 +++- src/tools/queries/hazards.test.mjs | 29 ++++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/changelog.d/1395-fires-tool-acquired-time.md b/changelog.d/1395-fires-tool-acquired-time.md index ea3bddd1d..17d74e481 100644 --- a/changelog.d/1395-fires-tool-acquired-time.md +++ b/changelog.d/1395-fires-tool-acquired-time.md @@ -1,4 +1,6 @@ - Report a fire detection's acquisition time as a UTC timestamp in `get_active_fires`. The tool joined FIRMS's unpadded date and time, so a 00:45 UTC detection read "2026-01-01 45"; it now reads - `2026-01-01T00:45:00.000Z` (Rudy Celekli, @rudycelekli, #1395). + `2026-01-01T00:45:00.000Z`. Missing or invalid dates and times remain + unknown rather than rolling into another calendar day + (Rudy Celekli, @rudycelekli, #1395). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b15babbb2..5332216e5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1984,6 +1984,9 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status 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 +remain `null`, including impossible calendar dates from a custom source. The FIRMS proxy records source success after appending its rows. If aggregation throws, that source reports failure without a contradictory success entry. diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index 194f9b8c1..2b11c639c 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -14,9 +14,13 @@ function acquiredMsUtc(acqDate, acqTime) { const hours = Number(hhmm.slice(0, 2)); const minutes = Number(hhmm.slice(2, 4)); if (hours > 23 || minutes > 59) return NaN; - return Date.parse( + const timestamp = Date.parse( `${acqDate}T${hhmm.slice(0, 2)}:${hhmm.slice(2, 4)}:00.000Z`, ); + return Number.isFinite(timestamp) && + new Date(timestamp).toISOString().slice(0, 10) === acqDate + ? timestamp + : NaN; } export const getEarthquakes = defineTool({ diff --git a/src/tools/queries/hazards.test.mjs b/src/tools/queries/hazards.test.mjs index fe2e6ac1f..fa17158dd 100644 --- a/src/tools/queries/hazards.test.mjs +++ b/src/tools/queries/hazards.test.mjs @@ -15,3 +15,32 @@ test('a fire acquired at 00:45 UTC reads as 00:45, not "45"', async () => { ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); assert.equal(result.data.rows[0].acquired, '2026-01-01T00:45:00.000Z'); }); + +test('fire acquisition timestamps reject impossible calendar dates', async () => { + for (const acqDate of [ + '2026-02-29', + '2026-02-30', + '2026-04-31', + '1900-02-29', + '2026-13-01', + '2026-01-00', + ]) { + const result = await catalogWith([ + { lat: 24, lon: 121, acqDate, acqTime: '45' }, + ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); + assert.equal(result.data.rows[0].acquired, null, acqDate); + } +}); + +test('fire acquisition timestamps preserve valid leap days and UTC times', async () => { + for (const [acqDate, acqTime, expected] of [ + ['2024-02-29', '45', '2024-02-29T00:45:00.000Z'], + ['2000-02-29', 0, '2000-02-29T00:00:00.000Z'], + ['2026-04-30', '2359', '2026-04-30T23:59:00.000Z'], + ]) { + const result = await catalogWith([ + { lat: 24, lon: 121, acqDate, acqTime }, + ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); + assert.equal(result.data.rows[0].acquired, expected); + } +}); From 6642176de540776a04a7b3d9ed6413c190504530 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 14:08:32 -0700 Subject: [PATCH 5/5] docs: document Local ADS-B track rounding --- docs/CURRENT-STATE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b15babbb2..921604789 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5994,6 +5994,8 @@ callsign, class and category, adsbdb operator/type and a plausible route when known, altitude, ground speed, track, vertical rate, position age, message count, "Heard by your receiver"); markers are not camera-followed. Positioned aircraft join detection boxes, labelled only with a decoded callsign. +The card's `TRK` label wraps after rounding to whole degrees, so a track of +359.5° displays as `TRK 0°` rather than `TRK 360°`. Receivers publish one record shape from `src/sources/adsbRecords.js` (`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`,