fix(tools): ship types read as their family; letterless destinations are unknown

AIS sends ship types as ITU-R M.1371 codes, so vessels_in_area grouped
"70", "74" and "79" as three types, and type: "tanker" matched nothing on
feeds that send codes. Codes now read as the family the vessel cards already
show (CARGO, TANKER, TUG), for rows, group_by and the type filter. The code
table moves to src/data/vesselTypes.js, which has no i18n import, so the
server-side tools can use it; vesselLabels re-exports it.

A destination with no letters ("0", "---") now counts as unknown.

Live, Singapore: "most by type: CARGO (3), TANKER (3), PASSENGER (1)"; "how
many tankers near Singapore" found 3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
quanticsoul4772andClaude Opus 5.5 committed 2026-10-17 15:49:02 -07:00
1 parent d06e485c12
commit d39256e700
6 files changed
+126 -48

No files matched your search

+15 -7
View File
@@ -20,6 +20,7 @@ import {
numericSummary,
thinEvenly,
} from '../results.js';
import { normalizeVesselType } from '../../data/vesselTypes.js';
const MMSI_SCHEMA = Object.freeze({
type: 'string',
@@ -32,11 +33,17 @@ const ALL_VESSELS = 1_000_000;
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
const text = (value) => (typeof value === 'string' && value ? value : null);
// AIS ship type 0 is "not available" (ITU-R M.1371); the source keeps it as
// '0', so it is reported as unknown rather than counted as a type.
const vesselType = (value) => {
const type = text(value);
return type === '0' ? null : type;
// AIS sends ship types as ITU-R M.1371 codes ("70" to "79" are all cargo), so
// a code reads as its family ("CARGO"), which is what group_by counts and the
// type filter matches; text types pass through. Type 0 is "not available" and
// reads as unknown.
const vesselType = (value) => normalizeVesselType(text(value) ?? '') || null;
// Crews type the destination by hand; one with no letters ("0", "---") names
// no port and reads as unknown.
const vesselDestination = (value) => {
const destination = text(value);
return destination && /\p{L}/u.test(destination) ? destination : null;
};
function vesselRow(record, from) {
@@ -46,7 +53,7 @@ function vesselRow(record, from) {
name: text(record.name),
imo: text(record.imo),
type: vesselType(record.type),
destination: text(record.destination),
destination: vesselDestination(record.destination),
lat: round(record.latitude, 5),
lon: round(record.longitude, 5),
speed_mps: round(record.speedMps, 1),
@@ -134,7 +141,8 @@ export const vesselsInArea = defineTool({
.filter(
(record) =>
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
(!wanted || String(record.type).toLowerCase().includes(wanted)),
(!wanted ||
(vesselType(record.type) ?? '').toLowerCase().includes(wanted)),
)
.map((record) => vesselRow(record, center))
.sort((a, b) => a.distance_km - b.distance_km);
+57
View File
@@ -263,6 +263,63 @@ test('AIS type 0 (not available) is counted as unknown, not as a type', async ()
);
});
test('numeric AIS ship-type codes group and filter by their family', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: vessels({
records: [
vessel('366000001', 37.78, -122.4, { type: '70' }),
vessel('366000002', 37.79, -122.41, { type: '74' }),
vessel('366000003', 37.77, -122.39, { type: '79' }),
vessel('366000004', 37.76, -122.38, { type: '80' }),
vessel('366000005', 37.75, -122.37, { type: '52' }),
],
}),
},
});
const grouped = await catalog.call('vessels_in_area', {
area: bay,
group_by: 'type',
});
assert.deepEqual(grouped.data.breakdown.groups, [
{ value: 'CARGO', count: 3 },
{ value: 'TANKER', count: 1 },
{ value: 'TUG', count: 1 },
]);
const tankers = await catalog.call('vessels_in_area', {
area: bay,
type: 'tanker',
});
assert.deepEqual(
tankers.data.rows.map((row) => [row.mmsi, row.type]),
[['366000004', 'TANKER']],
);
});
test('a destination with no letters counts as unknown', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: vessels({
records: [
vessel('366000001', 37.78, -122.4, { destination: '0' }),
vessel('366000002', 37.79, -122.41, { destination: '---' }),
vessel('366000003', 37.77, -122.39, { destination: 'NLRTM' }),
],
}),
},
});
const result = await catalog.call('vessels_in_area', {
area: bay,
group_by: 'destination',
});
assert.deepEqual(result.data.breakdown.groups, [
{ value: 'NLRTM', count: 1 },
]);
assert.equal(result.data.breakdown.unknown_count, 2);
});
test('vessels in an area can be counted by destination with a speed summary', async () => {
const catalog = composeCatalog({
tools: coreTools,