Merge main into PR #1390

This commit is contained in:
Sameh Khamis committed 2026-10-25 16:15:38 -07:00
commit f4b3517e9b
8 files changed
+100 -4

No files matched your search

@@ -0,0 +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`. Missing or invalid dates and times remain
unknown rather than rolling into another calendar day
(Rudy Celekli, @rudycelekli, #1395).
@@ -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).
+5
View File
@@ -1984,6 +1984,9 @@ accessible name remains "Don't show this again". Control behavior is unchanged.
## FIRMS source status ## 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 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 The FIRMS proxy records source success after appending its rows. If aggregation
throws, that source reports failure without a contradictory success entry. throws, that source reports failure without a contradictory success entry.
@@ -5995,6 +5998,8 @@ callsign, class and category, adsbdb operator/type and a plausible route when
known, altitude, ground speed, track, vertical rate, position age, message known, altitude, ground speed, track, vertical rate, position age, message
count, "Heard by your receiver"); markers are not camera-followed. Positioned count, "Heard by your receiver"); markers are not camera-followed. Positioned
aircraft join detection boxes, labelled only with a decoded callsign. 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` Receivers publish one record shape from `src/sources/adsbRecords.js`
(`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`, (`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`,
+1 -1
View File
@@ -152,7 +152,7 @@ export function localAdsbCardModel(
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', { t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
altitude: grouped(altitude), altitude: grouped(altitude),
speed: speed === null ? DASH : speed, speed: speed === null ? DASH : speed,
track: track === null ? DASH : `${track}°`, track: track === null ? DASH : `${track % 360}°`,
}), }),
vertical === null vertical === null
? t('V/S {rate}', { rate: DASH }) ? t('V/S {rate}', { rate: DASH })
+19
View File
@@ -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°');
});
+19 -2
View File
@@ -5,6 +5,24 @@ import { defineTool, ToolError } from '../catalog.js';
import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.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;
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({ export const getEarthquakes = defineTool({
name: 'get_earthquakes', name: 'get_earthquakes',
title: 'Recent earthquakes', title: 'Recent earthquakes',
@@ -114,8 +132,7 @@ export const getActiveFires = defineTool({
lon: fire.lon, lon: fire.lon,
frp_mw: fire.frp ?? null, frp_mw: fire.frp ?? null,
confidence: fire.confidence ?? null, confidence: fire.confidence ?? null,
acquired: acquired: isoTime(acquiredMsUtc(fire.acqDate, fire.acqTime)),
[fire.acqDate, fire.acqTime].filter(Boolean).join(' ') || null,
satellite: fire.satellite ?? null, satellite: fire.satellite ?? null,
day_night: fire.daynight ?? null, day_night: fire.daynight ?? null,
})), })),
+46
View File
@@ -0,0 +1,46 @@
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');
});
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);
}
});
+1 -1
View File
@@ -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), result.data.rows.map((row) => row.frp_mw),
[40, 12], [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({ const keyless = composeCatalog({
tools: coreTools, tools: coreTools,