mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #1351 from rudycelekli/fix/ais-static-keeps-motion
fix(ais): keep a tracked vessel's motion when static data arrives
This commit is contained in:
4 files changed
+264
-10
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
- Keep a tracked vessel's speed, course, heading and fix time when AISStream
|
||||||
|
static data arrives; the static message only merges name, type, destination
|
||||||
|
and IMO (rudycelekli, #1351).
|
||||||
|
- Apply changed static metadata immediately, retaining known fields when a
|
||||||
|
later static report omits them, without refreshing the vessel's position
|
||||||
|
(samehkhamis, #1351).
|
||||||
@@ -455,6 +455,14 @@ 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
|
Genuine readings are unchanged, including a stopped vessel's zero and the
|
||||||
highest speed and course the fields can encode.
|
highest speed and course the fields can encode.
|
||||||
|
|
||||||
|
For an already tracked vessel, AISStream static messages update name, type,
|
||||||
|
destination and IMO without changing its position, motion, fix timestamps,
|
||||||
|
position freshness or trail. Non-empty static values replace earlier values;
|
||||||
|
omitted, empty or unavailable fields retain known metadata across Class B's
|
||||||
|
separate reports and in the cache used by the next position update. Position
|
||||||
|
reports still prefer their own metadata name. Invalid Class B parts are ignored;
|
||||||
|
absent fields prefer the latest live value over the static cache.
|
||||||
|
|
||||||
Native `<select>` controls declare a dark colour scheme and explicit option
|
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.
|
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
|
The scheme is declared on the controls rather than on the document root, so
|
||||||
|
|||||||
@@ -43,14 +43,55 @@ export function ingestAisStreamEnvelope(envelope) {
|
|||||||
if (!mmsi) return false;
|
if (!mmsi) return false;
|
||||||
|
|
||||||
if (messageType === 'ShipStaticData' || messageType === 'StaticDataReport') {
|
if (messageType === 'ShipStaticData' || messageType === 'StaticDataReport') {
|
||||||
|
const previous = _aisStreamStatic.get(mmsi) || {};
|
||||||
|
const live = _aisStreamVessels.get(mmsi);
|
||||||
|
const liveName = live?.name === `MMSI ${mmsi}` ? '' : live?.name;
|
||||||
|
const fields =
|
||||||
|
messageType === 'StaticDataReport'
|
||||||
|
? {
|
||||||
|
Name:
|
||||||
|
message.ReportA?.Valid === false
|
||||||
|
? undefined
|
||||||
|
: message.ReportA?.Name,
|
||||||
|
Type:
|
||||||
|
message.ReportB?.Valid === false
|
||||||
|
? undefined
|
||||||
|
: message.ReportB?.ShipType,
|
||||||
|
}
|
||||||
|
: message;
|
||||||
|
// Static reports may be partial (including Class B's separate A/B parts).
|
||||||
|
// Keep known values in the cache as well as in the current live row.
|
||||||
const staticData = {
|
const staticData = {
|
||||||
name: vesselNameFromAis(metadata, message, _aisStreamStatic.get(mmsi)),
|
// The envelope repeats a cached name even on Class B's type-only part.
|
||||||
type: vesselTypeFromAis(message, _aisStreamStatic.get(mmsi)),
|
// Only an actual static name report replaces a known live name.
|
||||||
destination: stringValue(message.Destination),
|
name:
|
||||||
imo: stringValue(message.ImoNumber ?? message.IMO),
|
stringValue(fields.Name ?? fields.ShipName) ||
|
||||||
|
liveName ||
|
||||||
|
previous.name ||
|
||||||
|
stringValue(metadata.ShipName),
|
||||||
|
type:
|
||||||
|
staticCode(fields.Type ?? fields.ShipType) ||
|
||||||
|
staticCode(live?.type) ||
|
||||||
|
staticCode(previous.type) ||
|
||||||
|
'',
|
||||||
|
destination:
|
||||||
|
stringValue(fields.Destination) ||
|
||||||
|
live?.destination ||
|
||||||
|
previous.destination ||
|
||||||
|
'',
|
||||||
|
imo:
|
||||||
|
staticCode(fields.ImoNumber ?? fields.IMO) ||
|
||||||
|
staticCode(live?.imo) ||
|
||||||
|
staticCode(previous.imo) ||
|
||||||
|
'',
|
||||||
};
|
};
|
||||||
_aisStreamStatic.set(mmsi, staticData);
|
_aisStreamStatic.set(mmsi, staticData);
|
||||||
mergeAisStaticIntoLiveVessel(mmsi, staticData);
|
mergeAisStaticIntoLiveVessel(mmsi, staticData);
|
||||||
|
// AISStream repeats the last known position in a static message's
|
||||||
|
// MetaData, but the message has no speed, course or heading and is not a
|
||||||
|
// new fix. Rebuilding a tracked vessel from it would blank its motion and
|
||||||
|
// restamp its position time, so only merge the static fields.
|
||||||
|
if (_aisStreamVessels.has(mmsi)) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lat = numberValue(
|
const lat = numberValue(
|
||||||
@@ -191,12 +232,10 @@ function approxMetersBetween(lat1, lon1, lat2, lon2) {
|
|||||||
function mergeAisStaticIntoLiveVessel(mmsi, staticData) {
|
function mergeAisStaticIntoLiveVessel(mmsi, staticData) {
|
||||||
const existing = _aisStreamVessels.get(mmsi);
|
const existing = _aisStreamVessels.get(mmsi);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
if (staticData.name && (!existing.name || existing.name === `MMSI ${mmsi}`))
|
if (staticData.name) existing.name = staticData.name;
|
||||||
existing.name = staticData.name;
|
if (staticData.type) existing.type = staticData.type;
|
||||||
if (staticData.type && !existing.type) existing.type = staticData.type;
|
if (staticData.destination) existing.destination = staticData.destination;
|
||||||
if (staticData.destination && !existing.destination)
|
if (staticData.imo) existing.imo = staticData.imo;
|
||||||
existing.destination = staticData.destination;
|
|
||||||
if (staticData.imo && !existing.imo) existing.imo = staticData.imo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function vesselNameFromAis(metadata, message, staticData = {}) {
|
function vesselNameFromAis(metadata, message, staticData = {}) {
|
||||||
@@ -266,6 +305,12 @@ function stringValue(value) {
|
|||||||
return String(value).trim();
|
return String(value).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AIS uses zero for unavailable ship type and IMO, not as a replacement value.
|
||||||
|
function staticCode(value) {
|
||||||
|
const number = numberValue(value);
|
||||||
|
return Number.isInteger(number) && number > 0 ? String(number) : '';
|
||||||
|
}
|
||||||
|
|
||||||
function numberValue(value) {
|
function numberValue(value) {
|
||||||
if (value === null || value === undefined || value === '') return null;
|
if (value === null || value === undefined || value === '') return null;
|
||||||
const number = Number(value);
|
const number = Number(value);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
AISSTREAM_STALE_MS,
|
||||||
aisStreamRows,
|
aisStreamRows,
|
||||||
ingestAisStreamEnvelope,
|
ingestAisStreamEnvelope,
|
||||||
|
readAisTrack,
|
||||||
} from '../../server/providers/vessels/ais-store.js';
|
} from '../../server/providers/vessels/ais-store.js';
|
||||||
|
|
||||||
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
|
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
|
||||||
@@ -63,3 +65,196 @@ test('the fastest encodable reading below the unavailable code is kept', () => {
|
|||||||
assert.equal(row.speed, 102.2);
|
assert.equal(row.speed, 102.2);
|
||||||
assert.equal(row.course, 359.9);
|
assert.equal(row.course, 359.9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a static data report keeps the motion and fix time of a tracked vessel', () => {
|
||||||
|
ingestAisStreamEnvelope(
|
||||||
|
positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }),
|
||||||
|
);
|
||||||
|
// AISStream's MetaData carries the last known position on every message,
|
||||||
|
// static data included.
|
||||||
|
assert.equal(
|
||||||
|
ingestAisStreamEnvelope({
|
||||||
|
MessageType: 'ShipStaticData',
|
||||||
|
Message: {
|
||||||
|
ShipStaticData: {
|
||||||
|
UserID: 244690000,
|
||||||
|
Name: 'EXAMPLE VESSEL',
|
||||||
|
Type: 70,
|
||||||
|
Destination: 'ROTTERDAM',
|
||||||
|
ImoNumber: 9876543,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MetaData: {
|
||||||
|
MMSI: '244690000',
|
||||||
|
latitude: 53.40879,
|
||||||
|
longitude: 6.19741,
|
||||||
|
time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const row = rowFor('244690000');
|
||||||
|
assert.equal(row.speed, 12.4);
|
||||||
|
assert.equal(row.course, 86.7);
|
||||||
|
assert.equal(row.heading, 87);
|
||||||
|
assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z');
|
||||||
|
assert.equal(row.destination, 'ROTTERDAM');
|
||||||
|
assert.equal(row.imo, '9876543');
|
||||||
|
});
|
||||||
|
|
||||||
|
// AISStream carries both Class B parts; the unused part has Valid: false.
|
||||||
|
const classBStatic = (part, value) => ({
|
||||||
|
MessageID: 24,
|
||||||
|
RepeatIndicator: 0,
|
||||||
|
Valid: true,
|
||||||
|
Reserved: 0,
|
||||||
|
PartNumber: part === 'B',
|
||||||
|
ReportA: { Valid: part === 'A', Name: part === 'A' ? value : '' },
|
||||||
|
ReportB: {
|
||||||
|
Valid: part === 'B',
|
||||||
|
ShipType: part === 'B' ? value : 0,
|
||||||
|
VendorIDName: '',
|
||||||
|
VenderIDModel: 0,
|
||||||
|
VenderIDSerial: 0,
|
||||||
|
CallSign: '',
|
||||||
|
Dimension: { A: 0, B: 0, C: 0, D: 0 },
|
||||||
|
FixType: 0,
|
||||||
|
Spare: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [index, scenario] of [
|
||||||
|
{
|
||||||
|
type: 'ShipStaticData',
|
||||||
|
initial: [
|
||||||
|
{
|
||||||
|
Name: 'OLD VESSEL',
|
||||||
|
Type: 70,
|
||||||
|
Destination: 'ROTTERDAM',
|
||||||
|
ImoNumber: 9876543,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
Name: 'NEW VESSEL',
|
||||||
|
Type: 80,
|
||||||
|
Destination: 'HAMBURG',
|
||||||
|
ImoNumber: 9876544,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
empty: { Name: ' ', Type: 0, Destination: '', ImoNumber: 0 },
|
||||||
|
expected: {
|
||||||
|
name: 'NEW VESSEL',
|
||||||
|
type: '80',
|
||||||
|
destination: 'HAMBURG',
|
||||||
|
imo: '9876544',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'StaticDataReport',
|
||||||
|
initial: [classBStatic('A', 'OLD VESSEL'), classBStatic('B', 36)],
|
||||||
|
updates: [classBStatic('A', 'NEW VESSEL'), classBStatic('B', 37)],
|
||||||
|
empty: classBStatic('A', ''),
|
||||||
|
expected: { name: 'NEW VESSEL', type: '37', destination: '', imo: '' },
|
||||||
|
},
|
||||||
|
].entries()) {
|
||||||
|
test(`${scenario.type} replaces static fields without inventing a position fix`, (t) => {
|
||||||
|
const mmsi = String(244690100 + index);
|
||||||
|
const start = Date.parse('2026-09-17T20:00:00Z');
|
||||||
|
let now = start;
|
||||||
|
t.mock.method(Date, 'now', () => now);
|
||||||
|
const position = (
|
||||||
|
lat,
|
||||||
|
body = { Sog: 12.4, Cog: 86.7, TrueHeading: 87 },
|
||||||
|
) => {
|
||||||
|
const envelope = positionReport(mmsi, body);
|
||||||
|
Object.assign(envelope.MetaData, {
|
||||||
|
latitude: lat,
|
||||||
|
time_utc: new Date(now).toISOString(),
|
||||||
|
});
|
||||||
|
return ingestAisStreamEnvelope(envelope);
|
||||||
|
};
|
||||||
|
const sendStatic = (body) =>
|
||||||
|
ingestAisStreamEnvelope({
|
||||||
|
MessageType: scenario.type,
|
||||||
|
Message: { [scenario.type]: { UserID: Number(mmsi), ...body } },
|
||||||
|
MetaData: {
|
||||||
|
MMSI: mmsi,
|
||||||
|
ShipName: 'STALE HEADER',
|
||||||
|
latitude: 54,
|
||||||
|
longitude: 7,
|
||||||
|
time_utc: new Date(now).toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
position(53.40879);
|
||||||
|
for (const body of scenario.initial) assert.equal(sendStatic(body), true);
|
||||||
|
now += 60_000;
|
||||||
|
position(53.41879);
|
||||||
|
const before = rowFor(mmsi);
|
||||||
|
const track = readAisTrack(mmsi);
|
||||||
|
assert.equal(track.length, 2);
|
||||||
|
|
||||||
|
now += 5 * 60_000;
|
||||||
|
for (const body of scenario.updates) assert.equal(sendStatic(body), true);
|
||||||
|
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||||
|
assert.deepEqual(readAisTrack(mmsi), track);
|
||||||
|
|
||||||
|
// Empty or omitted static fields must not erase known metadata, including
|
||||||
|
// the cached values used when the next actual position arrives.
|
||||||
|
assert.equal(sendStatic(scenario.empty), true);
|
||||||
|
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||||
|
assert.deepEqual(readAisTrack(mmsi), track);
|
||||||
|
if (scenario.type === 'StaticDataReport') {
|
||||||
|
sendStatic({
|
||||||
|
...classBStatic('A', ''),
|
||||||
|
ReportA: { Valid: false, Name: 'INVALID NAME' },
|
||||||
|
ReportB: { ...classBStatic('B', 99).ReportB, Valid: false },
|
||||||
|
});
|
||||||
|
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||||
|
}
|
||||||
|
now = start + 60_000 + AISSTREAM_STALE_MS + 1;
|
||||||
|
assert.equal(
|
||||||
|
rowFor(mmsi),
|
||||||
|
undefined,
|
||||||
|
'static traffic must not renew position freshness',
|
||||||
|
);
|
||||||
|
position(53.42879, { Sog: 0, Cog: 0, TrueHeading: 0 });
|
||||||
|
const next = rowFor(mmsi);
|
||||||
|
for (const [key, value] of Object.entries(scenario.expected))
|
||||||
|
assert.equal(next[key], value, key);
|
||||||
|
assert.equal(next.speed, 0);
|
||||||
|
assert.equal(next.course, 0);
|
||||||
|
assert.equal(next.heading, 0);
|
||||||
|
assert.equal(next.last_position_epoch, Math.floor(now / 1000));
|
||||||
|
assert.equal(readAisTrack(mmsi).length, 3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('partial static data keeps newer live metadata ahead of the old static cache', () => {
|
||||||
|
const mmsi = '244690200';
|
||||||
|
const staticReport = (body) => ({
|
||||||
|
MessageType: 'ShipStaticData',
|
||||||
|
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
|
||||||
|
MetaData: { MMSI: mmsi },
|
||||||
|
});
|
||||||
|
ingestAisStreamEnvelope(staticReport({ Name: 'OLD VESSEL', Type: 70 }));
|
||||||
|
const extended = positionReport(mmsi, {});
|
||||||
|
extended.MessageType = 'ExtendedClassBPositionReport';
|
||||||
|
extended.Message = {
|
||||||
|
ExtendedClassBPositionReport: {
|
||||||
|
UserID: Number(mmsi),
|
||||||
|
Type: 80,
|
||||||
|
Sog: 12,
|
||||||
|
Cog: 86,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
extended.MetaData.ShipName = 'NEW VESSEL';
|
||||||
|
ingestAisStreamEnvelope(extended);
|
||||||
|
const before = rowFor(mmsi);
|
||||||
|
ingestAisStreamEnvelope(staticReport({ Destination: 'HAMBURG' }));
|
||||||
|
assert.deepEqual(rowFor(mmsi), { ...before, destination: 'HAMBURG' });
|
||||||
|
ingestAisStreamEnvelope(positionReport(mmsi, { Sog: 12, Cog: 86 }));
|
||||||
|
assert.equal(rowFor(mmsi).name, 'NEW VESSEL');
|
||||||
|
assert.equal(rowFor(mmsi).type, '80');
|
||||||
|
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user