From 88f6e8bcf815b03f9a7bcfab20eed8763247e15d Mon Sep 17 00:00:00 2001 From: Theerayut Boonkird Date: Fri, 18 Sep 2026 00:41:06 +0100 Subject: [PATCH] fix: treat AIS "not available" speed and course as unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over ground in 0.1-degree units, reserving the top code of each field for "not available": SOG 1023 reaches us as 102.3 knots and COG 3600 as 360 degrees. Both are finite, so numberValue passed them through as ordinary readings and every consumer — vessel cards, the HUD, voice analyst queries — treated a missing measurement as a real one. Observed on a live AISStream feed of 3,062 vessels: exactly 3 records at 102.3 knots with no reading anywhere between 40 and 102.3, and 404 records at exactly 360 degrees. One Dutch rescue boat moored at its station carried both sentinels at once, so an analyst summary reported a maximum speed of 102.3 knots — faster than any vessel afloat. Filter both at the store, where AISStream messages are decoded, so the sentinels never enter the cache. Heading already had this treatment through normalizedHeading with its own separate sentinel of 511. Co-Authored-By: Claude Opus 5 (1M context) --- server/providers/vessels/ais-store.js | 26 ++++++++++- src/data/aisStreamSentinels.test.mjs | 62 +++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 src/data/aisStreamSentinels.test.mjs diff --git a/server/providers/vessels/ais-store.js b/server/providers/vessels/ais-store.js index 4a53464f6..297081b38 100644 --- a/server/providers/vessels/ais-store.js +++ b/server/providers/vessels/ais-store.js @@ -72,8 +72,8 @@ export function ingestAisStreamEnvelope(envelope) { imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo), type: vesselTypeFromAis(message, staticData), destination: stringValue(message.Destination ?? staticData.destination), - speed: numberValue(message.Sog ?? message.SOG), - course: numberValue(message.Cog ?? message.COG), + speed: normalizedSpeedOverGround(message.Sog ?? message.SOG), + course: normalizedCourseOverGround(message.Cog ?? message.COG), heading: normalizedHeading(message.TrueHeading ?? message.Heading), last_position_UTC: normalizeAisTimestamp( metadata.time_utc ?? metadata.TimeUtc, @@ -277,6 +277,28 @@ function normalizedHeading(value) { return heading !== null && heading >= 0 && heading <= 360 ? heading : null; } +/** + * Speed over ground in knots, or null when the report carries AIS's "not + * available" code. ITU-R M.1371 encodes SOG in 0.1-knot units where 1022 means + * "102.2 knots or higher" and 1023 means unavailable, so 102.3 is a sentinel + * and not a reading. It is finite, so every consumer would otherwise treat it + * as a genuine speed. + */ +function normalizedSpeedOverGround(value) { + const speed = numberValue(value); + return speed !== null && speed >= 0 && speed < 102.3 ? speed : null; +} + +/** + * Course over ground in degrees, or null when the report carries AIS's "not + * available" code (3600 in 0.1-degree units, i.e. 360). Valid courses stop at + * 359.9; heading keeps its own separate sentinel of 511. + */ +function normalizedCourseOverGround(value) { + const course = numberValue(value); + return course !== null && course >= 0 && course < 360 ? course : null; +} + function normalizeAisTimestamp(value) { const text = stringValue(value); if (!text) return new Date().toISOString(); diff --git a/src/data/aisStreamSentinels.test.mjs b/src/data/aisStreamSentinels.test.mjs new file mode 100644 index 000000000..4377c37c2 --- /dev/null +++ b/src/data/aisStreamSentinels.test.mjs @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { aisStreamRows, ingestAisStreamEnvelope } from '../../server/providers/vessels/ais-store.js'; + +// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over +// ground in 0.1-degree units, reserving the top code of each field for "not +// available": SOG 1023 arrives as 102.3 knots and COG 3600 as 360 degrees. +// Both are finite numbers, so they reach consumers as ordinary readings unless +// the store rejects them here. +const positionReport = (mmsi, body) => ({ + MessageType: 'PositionReport', + Message: { PositionReport: { UserID: Number(mmsi), ...body } }, + MetaData: { + MMSI: mmsi, + latitude: 53.40879, + longitude: 6.19741, + time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC', + }, +}); + +const rowFor = (mmsi) => aisStreamRows(50000).find((row) => row.mmsi === mmsi); + +test('speed over ground reported as unavailable is unknown, not 102.3 knots', () => { + assert.equal( + ingestAisStreamEnvelope(positionReport('244060365', { Sog: 102.3 })), + true, + ); + assert.equal(rowFor('244060365').speed, null); +}); + +test('course over ground reported as unavailable is unknown, not 360 degrees', () => { + assert.equal( + ingestAisStreamEnvelope(positionReport('245382000', { Cog: 360 })), + true, + ); + assert.equal(rowFor('245382000').course, null); +}); + +test('genuine speed and course readings survive the unavailable check', () => { + ingestAisStreamEnvelope( + positionReport('341260000', { Sog: 12.9, Cog: 74.6 }), + ); + const row = rowFor('341260000'); + assert.equal(row.speed, 12.9); + assert.equal(row.course, 74.6); +}); + +test('a stopped vessel keeps its zero speed and zero course', () => { + ingestAisStreamEnvelope(positionReport('244374235', { Sog: 0, Cog: 0 })); + const row = rowFor('244374235'); + assert.equal(row.speed, 0); + assert.equal(row.course, 0); +}); + +test('the fastest encodable reading below the unavailable code is kept', () => { + ingestAisStreamEnvelope( + positionReport('244060366', { Sog: 102.2, Cog: 359.9 }), + ); + const row = rowFor('244060366'); + assert.equal(row.speed, 102.2); + assert.equal(row.course, 359.9); +});