From 8b68ff8412747986895811912ea4d5e26c4c0a9c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:49:44 -0400 Subject: [PATCH 1/4] fix(firms): reject missing and out-of-range detection coordinates --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 ++++ src/data/firms-coordinate-evidence.test.mjs | 31 +++++++++++++++++++++ src/data/firmsAdapt.js | 13 ++++++++- src/data/firmsCsv.js | 10 ++++++- 5 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 src/data/firms-coordinate-evidence.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..446192c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - 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. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..967fbda64 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(firms): reject missing and out-of-range detection coordinates + +Require explicit numeric coordinate evidence and WGS84 ranges in both ingestion paths, preserving genuine zero coordinates and sequential admitted detection indices. diff --git a/src/data/firms-coordinate-evidence.test.mjs b/src/data/firms-coordinate-evidence.test.mjs new file mode 100644 index 000000000..494b38c18 --- /dev/null +++ b/src/data/firms-coordinate-evidence.test.mjs @@ -0,0 +1,31 @@ +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], + ], + ); +}); diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index 2fc909bce..b8f07ec2d 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -21,7 +21,18 @@ 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 ( + ![record?.lat, record?.lon].every( + (value) => + typeof value === 'number' || + (typeof value === 'string' && value.trim() !== ''), + ) || + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + continue; fires.push({ index: fires.length, lat, diff --git a/src/data/firmsCsv.js b/src/data/firmsCsv.js index 7f9019e18..c89d10a0a 100644 --- a/src/data/firmsCsv.js +++ b/src/data/firmsCsv.js @@ -105,7 +105,15 @@ 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; + 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, From 5a14a06a52ca70517b1a6199177d1b4ea2a47f0c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:28 -0400 Subject: [PATCH 2/4] docs: address maintainer review for PR #1044 --- docs/CURRENT-STATE.md | 7 ++----- src/data/firmsAdapt.js | 14 +++++++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 967fbda64..4c33b1744 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1429,6 +1429,8 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## 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 throws, that source reports failure without a contradictory success entry. The existing per-record append continues to support large feeds; sequential @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(firms): reject missing and out-of-range detection coordinates - -Require explicit numeric coordinate evidence and WGS84 ranges in both ingestion paths, preserving genuine zero coordinates and sequential admitted detection indices. diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index b8f07ec2d..3e3f27631 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -22,11 +22,8 @@ export function adaptFirmsRecords(records) { const lat = Number(record?.lat); const lon = Number(record?.lon); if ( - ![record?.lat, record?.lon].every( - (value) => - typeof value === 'number' || - (typeof value === 'string' && value.trim() !== ''), - ) || + !hasCoordinateValue(record?.lat) || + !hasCoordinateValue(record?.lon) || !Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || @@ -121,3 +118,10 @@ function finiteNumber(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; } + +function hasCoordinateValue(value) { + return ( + typeof value === 'number' || + (typeof value === 'string' && value.trim() !== '') + ); +} From e26a715b04968cac14646735232ce75b32716793 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:12:33 -0400 Subject: [PATCH 3/4] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 446192c88..077d7c5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - 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. - Treat explicitly current, complete empty vessel coverage as a successful update, From 6a7b89e436f282356375f6dff667ec8ffb0c1a30 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 19:21:57 -0700 Subject: [PATCH 4/4] test(firms): cover both coordinate bounds and blank values on each axis Documents the adapter's skip rule and cross-references the two checks, which sit in separate packages (provider parser and fire layer). --- src/data/firms-coordinate-evidence.test.mjs | 38 +++++++++++++++++++++ src/data/firmsAdapt.js | 5 +-- src/data/firmsCsv.js | 2 ++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/data/firms-coordinate-evidence.test.mjs b/src/data/firms-coordinate-evidence.test.mjs index 494b38c18..d60a7ee37 100644 --- a/src/data/firms-coordinate-evidence.test.mjs +++ b/src/data/firms-coordinate-evidence.test.mjs @@ -29,3 +29,41 @@ test('missing positions do not become fire detections at zero coordinates', () = ], ); }); + +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], + ], + ); +}); diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index 3e3f27631..312838e80 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -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} records - /api/firms `fires` array. @@ -119,6 +119,7 @@ function finiteNumber(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' || diff --git a/src/data/firmsCsv.js b/src/data/firmsCsv.js index c89d10a0a..aafeac50b 100644 --- a/src/data/firmsCsv.js +++ b/src/data/firmsCsv.js @@ -105,6 +105,8 @@ export function parseFirmsCsv(text) { if (parts.length < header.length) continue; // malformed row — skip const lat = Number(parts[iLat]); const lon = Number(parts[iLon]); + // 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() ||