From 88f6e8bcf815b03f9a7bcfab20eed8763247e15d Mon Sep 17 00:00:00 2001 From: Theerayut Boonkird Date: Fri, 18 Sep 2026 00:41:06 +0100 Subject: [PATCH 1/3] 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); +}); From b0f5072347ff90937fce8c709d107528dadfc5e3 Mon Sep 17 00:00:00 2001 From: Theerayut Boonkird Date: Fri, 18 Sep 2026 00:41:06 +0100 Subject: [PATCH 2/3] docs: record the AIS unavailable-code handling CONTRIBUTING asks for docs/CURRENT-STATE.md and CHANGELOG.md in the same PR as a runtime behaviour change. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 ++++ docs/CURRENT-STATE.md | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f3fd7822..2b5622678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Report AIS speed and course that carry the standard "not available" code as + unknown instead of 102.3 knots and 360 degrees. Genuine readings, including a + stopped vessel's zero and the highest encodable values, are unchanged. + - Distinguish PARTIAL vessel snapshots from STALE data in the layer panel, with accepted-record counts and unchanged retention, freshness and outage safeguards. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b5727f353..a8a313819 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,13 @@ # God's Eye View Current State +AIS 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", so +those reports arrive as 102.3 knots and 360 degrees. Both are stored as unknown, +and vessel cards, the HUD and analyst queries show a missing measurement rather +than a reading. Heading keeps its existing separate check for its own sentinel. +Genuine readings are unchanged, including a stopped vessel's zero and the +highest speed and course the fields can encode. + Vessel snapshot completeness is separate from freshness. A current snapshot with rejected or duplicate records shows PARTIAL with accepted/received counts; stale or unknown freshness and transport failures retain their warnings. Partial From 75869d0c103794e4c60f879b6a9f8ca531634694 Mon Sep 17 00:00:00 2001 From: Theerayut Boonkird Date: Fri, 18 Sep 2026 00:43:02 +0100 Subject: [PATCH 3/3] chore: adopt the new AIS test into the explicit formatting scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/format.mjs skips *.test.mjs during runtime discovery, so a test added under a discovered root is never seen by format:check until it is listed in scripts/format-scope.json — which is what CODE-BOUNDARIES.md means by adding tests to the explicit list as they are adopted. The file went in unformatted and the check stayed green, hiding it. Listed and formatted; format:check now covers 917 files instead of 916. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/format-scope.json | 3 ++- src/data/aisStreamSentinels.test.mjs | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index caffd41d2..dc623c0c0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -743,5 +743,6 @@ "src/director/interactions/interactions.test.mjs", "scripts/qa-director-interactions.mjs", "src/director/sharing/sharing.test.mjs", - "scripts/qa-director-sharing.mjs" + "scripts/qa-director-sharing.mjs", + "src/data/aisStreamSentinels.test.mjs" ] diff --git a/src/data/aisStreamSentinels.test.mjs b/src/data/aisStreamSentinels.test.mjs index 4377c37c2..6595b795e 100644 --- a/src/data/aisStreamSentinels.test.mjs +++ b/src/data/aisStreamSentinels.test.mjs @@ -1,6 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { aisStreamRows, ingestAisStreamEnvelope } from '../../server/providers/vessels/ais-store.js'; +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