diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d252b81..1f39f8d96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Cyclone advisories no longer go dark while NHC publishes an advisory ahead + of its nominal time: advisory and position times up to 90 minutes ahead of + the server clock are accepted (previously 5 minutes), so the whole feed is + not rejected for about half an hour each advisory cycle. + - Flights age out vanished aircraft again. OpenSky's routine position-less rows no longer mark every snapshot partial, which had kept vanished and landed aircraft at full opacity for up to five minutes; those rows retain only their diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e5b3d2ace..0af075a6d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -339,7 +339,12 @@ Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bound same-origin provider. The status endpoint lacks browser CORS; each installation fetches keyless public sources at runtime, with no centralized ingestion service. The five-minute snapshot covers Atlantic and eastern/central North Pacific only. -Current positions, advisory issue time and position time stay distinct. Tracks, +Current positions, advisory issue time and position time stay distinct. NHC may +release an advisory before its nominal time (routinely up to about 30 minutes, +rarely about an hour), so advisory issue and position times up to and including +90 minutes ahead of the server clock are accepted; 90 minutes is this app's +tolerance, not an NHC limit. Later, non-canonical, or more than 12-hour-old +times still reject the status snapshot. Tracks, forecast lead-hour points and cones render only when all source parts match the status advisory; a newer status shows its position with geometry pending rather than relabeling older geometry. The cone represents forecast center uncertainty, diff --git a/server/providers/cyclones.js b/server/providers/cyclones.js index fede1aade..d580357ce 100644 --- a/server/providers/cyclones.js +++ b/server/providers/cyclones.js @@ -41,6 +41,9 @@ function number(value, min, max) { const n = Number(value); return Number.isFinite(n) && n >= min && n <= max ? n : null; } +// NHC stamps an advisory with its nominal time (e.g. 03:00Z) and publishes it +// up to about half an hour before; allow that, but not a clock far ahead. +const EARLY_ADVISORY_MS = 90 * 60_000; function iso(value, now) { if ( typeof value !== 'string' || @@ -50,7 +53,7 @@ function iso(value, now) { const n = Date.parse(value); if ( !Number.isFinite(n) || - n > now + 5 * 60_000 || + n > now + EARLY_ADVISORY_MS || now - n > 12 * HOUR || new Date(n).toISOString().replace('.000Z', 'Z') !== value.replace('.000Z', 'Z') diff --git a/src/data/cyclonesProxy.test.mjs b/src/data/cyclonesProxy.test.mjs index 04729d33a..23e84826c 100644 --- a/src/data/cyclonesProxy.test.mjs +++ b/src/data/cyclonesProxy.test.mjs @@ -162,6 +162,44 @@ test('malformed, duplicate, oversized, future and expired status is rejected rat assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []); }); +test('advisories published before their nominal time are accepted, far-future ones are not', () => { + // NHC wrote the 03:00Z advisory at 02:34Z. + const early = Date.UTC(2026, 8, 16, 2, 34); + assert.equal(parseCycloneStatus(status(), early)[0].issuedAt, TIME); + assert.throws( + () => parseCycloneStatus(status(), Date.UTC(2026, 8, 16, 1, 25)), + /invalid_cyclone_data/, + ); +}); + +test('advisory and position times are bounded at 90 minutes ahead and 12 hours old', () => { + const MINUTE = 60_000; + const at = (offsetMs) => new Date(NOW + offsetMs).toISOString(); + const fields = { + issuedAt: (value) => + status({ + forecastAdvisory: { + advNum: '010', + issuance: value, + url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml', + }, + }), + positionAt: (value) => status({ lastUpdate: value }), + }; + for (const [field, payload] of Object.entries(fields)) { + for (const offset of [90 * MINUTE, -12 * 60 * MINUTE]) { + const [storm] = parseCycloneStatus(payload(at(offset)), NOW); + assert.equal(storm[field], at(offset), `${field} ${offset}`); + } + for (const offset of [90 * MINUTE + 1, -12 * 60 * MINUTE - 1]) + assert.throws( + () => parseCycloneStatus(payload(at(offset)), NOW), + /invalid_cyclone_data/, + `${field} ${offset}`, + ); + } +}); + test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => { const storms = parseCycloneStatus(status(), NOW); const parts = collections();