Merge current main into GNSS integration candidate

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-15 13:46:45 +02:00
commit 65239ed110
70 files changed
+1433 -168

No files matched your search

@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseFirmsCsv } from './firmsCsv.js';
import { adaptFirmsRecords } from './firmsAdapt.js';
test('missing positions do not become fire detections at zero coordinates', () => {
const csv =
'latitude,longitude,acq_date,acq_time,confidence,frp\n,1,2026-07-16,1200,n,1\n1, ,2026-07-16,1200,n,1\n95,1,2026-07-16,1200,n,1\n1,181,2026-07-16,1200,n,1\n0,0,2026-07-16,1200,n,1\n-90,-180,2026-07-16,1200,n,1';
assert.deepEqual(
parseFirmsCsv(csv).map((r) => [r.lat, r.lon]),
[
[0, 0],
[-90, -180],
],
);
const records = [
{ lat: null, lon: 1 },
{ lat: '', lon: 1 },
{ lat: false, lon: 1 },
{ lat: 95, lon: 1 },
{ lat: 1, lon: 181 },
{ lat: '0', lon: 0 },
{ lat: -90, lon: -180 },
];
assert.deepEqual(
adaptFirmsRecords(records).map((r) => [r.index, r.lat, r.lon]),
[
[0, 0, 0],
[1, -90, -180],
],
);
});
test('both axes keep their exact bounds and drop values just past them', () => {
const row = (lat, lon) => `${lat},${lon},2026-07-16,1200,n,1`;
const csv = [
'latitude,longitude,acq_date,acq_time,confidence,frp',
row(90, 180),
row(-90, -180),
row(90.000001, 0),
row(-90.000001, 0),
row(0, 180.000001),
row(0, -180.000001),
row(' ', 5),
row(5, '\t'),
].join('\n');
assert.deepEqual(
parseFirmsCsv(csv).map((r) => [r.lat, r.lon]),
[
[90, 180],
[-90, -180],
],
);
const kept = adaptFirmsRecords([
{ lat: 90, lon: 180 },
{ lat: '-90', lon: '-180' },
{ lat: -90.000001, lon: 0 },
{ lat: 0, lon: -180.000001 },
{ lat: 5, lon: ' ' },
{ lat: 5, lon: undefined },
{ lat: [5], lon: 5 },
]);
assert.deepEqual(
kept.map((r) => [r.lat, r.lon]),
[
[90, 180],
[-90, -180],
],
);
});
+19 -3
View File
@@ -7,8 +7,8 @@
*/
/**
* Map proxy fire records into internal fire records. Records with
* non-finite coordinates are skipped; `index` is the post-skip position
* Map proxy fire records into internal fire records. Records with missing,
* non-numeric or out-of-range (WGS84) coordinates are skipped; `index` is the post-skip position
* (it keys pick ids and context-store ids, so it must stay sequential).
* `contextEntity`/`position` start null and are lazily filled by the layer.
* @param {?Array<Object>} records - /api/firms `fires` array.
@@ -21,7 +21,15 @@ export function adaptFirmsRecords(records) {
for (const record of records) {
const lat = Number(record?.lat);
const lon = Number(record?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (
!hasCoordinateValue(record?.lat) ||
!hasCoordinateValue(record?.lon) ||
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
)
continue;
fires.push({
index: fires.length,
lat,
@@ -110,3 +118,11 @@ function finiteNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
// Same rule as parseFirmsCsv: a blank or non-numeric value is not a zero.
function hasCoordinateValue(value) {
return (
typeof value === 'number' ||
(typeof value === 'string' && value.trim() !== '')
);
}
+11 -1
View File
@@ -105,7 +105,17 @@ export function parseFirmsCsv(text) {
if (parts.length < header.length) continue; // malformed row — skip
const lat = Number(parts[iLat]);
const lon = Number(parts[iLon]);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
// Blank or out-of-range positions are dropped, never read as 0 (see
// hasCoordinateValue in firmsAdapt.js for the proxy-record side).
if (
!parts[iLat].trim() ||
!parts[iLon].trim() ||
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
)
continue;
records.push({
lat,
+10 -4
View File
@@ -2398,10 +2398,13 @@ test('a newer absolute intent aborts a hung guard-compensation instead of starvi
removeGuard();
// The newest intent must be able to abort the hung compensation and run.
const pendingFinalOn = mgr.setEnabled('flights', true, { origin: 'user' });
let giveUpTimer;
const finalOn = await Promise.race([
pendingFinalOn,
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
]);
new Promise((resolve) => {
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
}),
]).finally(() => clearTimeout(giveUpTimer));
assert.notEqual(finalOn, 'starved', 'newest intent must not starve behind the compensation');
assert.equal(compensationAborted, true, 'the hung compensation was aborted');
assert.equal(await pendingBlockedOn, false, 'the guard-blocked request stays unfulfilled');
@@ -2451,10 +2454,13 @@ test('re-entrant setEnabled from a blocked-adoption listener supersedes the comp
releaseFirstDisable();
const blockedOn = await pendingBlockedOn;
let giveUpTimer;
const reentrant = await Promise.race([
(async () => reentrantResult === null ? 'never-fired' : await reentrantResult)(),
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
]);
new Promise((resolve) => {
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
}),
]).finally(() => clearTimeout(giveUpTimer));
await pendingOff;
await mgr.waitForLayerSettled('flights');
unsubscribe();
+15 -7
View File
@@ -87,14 +87,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
return articles;
}
/** A reported number, or null when the observation is missing or not numeric. */
function observedNumber(value) {
if (
value == null ||
typeof value === 'boolean' ||
(typeof value === 'string' && !value.trim())
)
return null;
return Number.isFinite(Number(value)) ? Number(value) : null;
}
/** Normalize Open-Meteo current conditions into a small source-stamped record. */
export function normalizeRegionalWeather(payload) {
const current = payload?.current;
if (!current || !Number.isFinite(Number(current.temperature_2m))) return null;
const numberOrNull = (value) => {
if (value === null || value === undefined || value === '') return null;
return Number.isFinite(Number(value)) ? Number(value) : null;
};
if (!current || observedNumber(current.temperature_2m) == null) return null;
const numberOrNull = observedNumber;
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
const observedRaw =
@@ -119,8 +127,8 @@ export function normalizeRegionalWeather(payload) {
/** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */
export function weatherCodeLabel(code) {
const value = Number(code);
if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN';
const value = observedNumber(code);
if (value == null) return 'CONDITIONS UNKNOWN';
if (value === 0) return 'CLEAR';
if ([1, 2].includes(value)) return 'PARTLY CLOUDY';
if (value === 3) return 'OVERCAST';
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeOsrmSteps, ROUTE_STEPS_MAX } from './routeSteps.js';
const step = (type) => ({
maneuver: { type, location: [10, 20] },
distance: 10,
duration: 1,
});
test('exact-cap routes remain complete and boundary exits retain their distance', () => {
const steps = Array.from({ length: ROUTE_STEPS_MAX }, () => step('turn'));
assert.equal(normalizeOsrmSteps({ legs: [{ steps }] }).truncated, false);
assert.equal(
normalizeOsrmSteps({ legs: [{ steps: [...steps, step('turn')] }] })
.truncated,
true,
);
steps[steps.length - 1] = step('roundabout');
const result = normalizeOsrmSteps({
legs: [{ steps: [...steps, step('exit roundabout')] }],
});
assert.equal(result.truncated, false);
assert.equal(result.steps.length, ROUTE_STEPS_MAX);
assert.equal(result.steps.at(-1).distanceM, 20);
});
+1 -1
View File
@@ -185,9 +185,9 @@ export function normalizeOsrmSteps(route) {
}
continue;
}
if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true };
step.instruction = instructionFor(step);
out.push(step);
if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true };
}
}
return { steps: out, truncated: false };
+2 -2
View File
@@ -206,9 +206,9 @@ test('the step list is capped, and the cap is reported rather than hidden', () =
// would present a route that simply stops in the middle of a road.
assert.equal(cut.truncated, true);
const exact = normalizeOsrmSteps({
legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX - 1) }],
legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX) }],
});
assert.equal(exact.steps.length, ROUTE_STEPS_MAX - 1);
assert.equal(exact.steps.length, ROUTE_STEPS_MAX);
assert.equal(exact.truncated, false, 'a route that fits is not cut');
});
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeRegionalWeather, weatherCodeLabel } from './regionalModel.js';
test('missing weather observations never assert clear conditions', () => {
for (const value of [null, undefined, '', ' ', false, true]) {
assert.equal(weatherCodeLabel(value), 'CONDITIONS UNKNOWN');
assert.equal(
normalizeRegionalWeather({ current: { temperature_2m: value } }),
null,
);
}
assert.equal(weatherCodeLabel(0), 'CLEAR');
assert.equal(weatherCodeLabel('0'), 'CLEAR');
const record = normalizeRegionalWeather({
current: { temperature_2m: 0, weather_code: null, wind_speed_10m: false },
});
assert.equal(record.temperatureC, 0);
assert.equal(record.weatherCode, null);
assert.equal(record.windKph, null);
});