Merge pull request #649 from theerayutbo/fix/ais-unavailable-sog-cog

fix: treat AIS "not available" speed and course as unknown
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-20 16:38:40 -07:00
commit ebca84e87a
5 files changed
+103 -3

No files matched your search

+4
View File
@@ -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.
- Render native `<select>` option lists in the dark UI palette. The closed
controls were already skinned, but the browser-painted popups fell back to the
platform light palette, leaving near-white option text on a white surface in
+8
View File
@@ -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.
Native `<select>` controls declare a dark colour scheme and explicit option
colours, so the browser-painted option list matches the panel it drops out of.
The scheme is declared on the controls rather than on the document root, so
+2 -1
View File
@@ -744,5 +744,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"
]
+24 -2
View File
@@ -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();
+65
View File
@@ -0,0 +1,65 @@
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);
});