Merge pull request #741 from bilawalsidhu/fix/cyclone-advisory-time

fix(cyclones): accept advisories NHC publishes before their nominal time
This commit is contained in:
Mazeyar Moeini Feizabadi authored and GitHub committed 2026-10-16 02:16:22 +02:00
commit a0400b52fb
4 files changed
+53 -2

No files matched your search

+5
View File
@@ -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
+6 -1
View File
@@ -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,
+4 -1
View File
@@ -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')
+38
View File
@@ -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();