Merge pull request #1044 from rudycelekli/contrib/gods-023-firms-coordinate-evidence-20261011-reviewed

fix(firms): reject missing and out-of-range detection coordinates
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-14 19:43:39 -07:00
commit 84bfba8d39
5 files changed
+103 -4

No files matched your search

+2
View File
@@ -36,6 +36,8 @@
- Director timeline seeks cross the dateline along the short longitude arc. - Director timeline seeks cross the dateline along the short longitude arc.
- Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid.
- Frequently reused terrain batches retain their cached heights ahead of unused cells. - Frequently reused terrain batches retain their cached heights ahead of unused cells.
- Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or - Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or
+2
View File
@@ -1429,6 +1429,8 @@ accessible name remains "Don't show this again". Control behavior is unchanged.
## FIRMS source status ## FIRMS source status
FIRMS ingestion and adaptation require explicit finite coordinates within WGS84 ranges; genuine zero coordinates remain valid and admitted detection indices stay sequential.
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.
The existing per-record append continues to support large feeds; sequential The existing per-record append continues to support large feeds; sequential
@@ -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 * Map proxy fire records into internal fire records. Records with missing,
* non-finite coordinates are skipped; `index` is the post-skip position * 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). * (it keys pick ids and context-store ids, so it must stay sequential).
* `contextEntity`/`position` start null and are lazily filled by the layer. * `contextEntity`/`position` start null and are lazily filled by the layer.
* @param {?Array<Object>} records - /api/firms `fires` array. * @param {?Array<Object>} records - /api/firms `fires` array.
@@ -21,7 +21,15 @@ export function adaptFirmsRecords(records) {
for (const record of records) { for (const record of records) {
const lat = Number(record?.lat); const lat = Number(record?.lat);
const lon = Number(record?.lon); 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({ fires.push({
index: fires.length, index: fires.length,
lat, lat,
@@ -110,3 +118,11 @@ function finiteNumber(value) {
const number = Number(value); const number = Number(value);
return Number.isFinite(number) ? number : 0; 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 if (parts.length < header.length) continue; // malformed row — skip
const lat = Number(parts[iLat]); const lat = Number(parts[iLat]);
const lon = Number(parts[iLon]); 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({ records.push({
lat, lat,