fix(tools): average aircraft speed over airborne aircraft only

aircraft_in_area stats summarized altitude over airborne aircraft but
speed over every match, so parked aircraft pulled the mean speed toward
zero. Both figures now use the same airborne subset, and the on-ground
fixture is stationary so the test can tell the difference.

Reserved AIS ship-type codes 100 to 255 now read as unknown, as type 0
does, instead of showing up in the breakdown as their own type.

Adds tests for an area where every aircraft is on the ground and for
find_vessel type and destination normalization, and notes in the
changelog fragment that every vessel answer is now normalized.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-19 14:29:11 +01:00
1 parent d39256e700
commit 4c383214f7
6 files changed
+112 -19

No files matched your search

+5 -2
View File
@@ -5,5 +5,8 @@
in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds
that report no operator; operator and type are counted only where the feed
reports them. Ship types count by family, so AIS codes 70 to 79 are all
cargo, and type 0 ("not available") counts as unknown
(quanticsoul4772, #1056).
cargo; type 0 ("not available") and the reserved codes 100 to 255 count as
unknown. Altitude and speed stats cover airborne aircraft only, so parked
aircraft do not pull the means down. Every vessel answer, `find_vessel`
included, now reads AIS ship-type codes as their family and a destination
with no letters as unknown (quanticsoul4772, #1056).
+2
View File
@@ -29,6 +29,8 @@ test('normalizeVesselType preserves text and degrades unknown codes', () => {
assert.equal(normalizeVesselType('Crude Oil Tanker'), 'Crude Oil Tanker');
assert.equal(normalizeVesselType('0'), '');
assert.equal(normalizeVesselType('25'), 'OTHER');
assert.equal(normalizeVesselType('100'), '');
assert.equal(normalizeVesselType('255'), '');
assert.equal(normalizeVesselType(undefined), '');
});
+5 -3
View File
@@ -31,15 +31,17 @@ const NUMERIC_TYPE_FAMILIES = {
/**
* Resolve an AIS type to display text: bare numeric ship-type codes map to
* family names ("71" → "CARGO"); text types pass through unchanged.
* family names ("71" → "CARGO"); text types pass through unchanged. Code 0
* ("not available") and the reserved codes 100 to 255 name no type and
* resolve to "".
* @param {string} type Raw AIS type.
* @returns {string}
*/
export function normalizeVesselType(type) {
const text = String(type || '').trim();
if (!text || !/^\d{1,2}$/.test(text)) return text;
if (!text || !/^\d+$/.test(text)) return text;
const code = Number(text);
if (code <= 0) return '';
if (code <= 0 || code >= 100) return '';
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
}
+4 -5
View File
@@ -175,6 +175,7 @@ export const aircraftInArea = defineTool({
const groups = args.group_by
? breakdown(rows, args.group_by, groupValue(args.group_by))
: null;
const airborne = rows.filter((row) => !row.on_ground);
return {
summary:
`${rows.length} ${kind} in ${area.label}` +
@@ -189,12 +190,10 @@ export const aircraftInArea = defineTool({
...(groups ? { breakdown: groups } : {}),
...(args.stats
? {
// Parked aircraft would pull both figures toward zero.
stats: {
altitude_m: numericSummary(
rows.filter((row) => !row.on_ground),
'altitude_m',
),
speed_mps: numericSummary(rows, 'speed_mps'),
altitude_m: numericSummary(airborne, 'altitude_m'),
speed_mps: numericSummary(airborne, 'speed_mps'),
},
}
: {}),
+42 -9
View File
@@ -395,7 +395,11 @@ test('aircraft in an area can be counted by operator over every match', async ()
record('a1', 37.6, -122.3, { operator: 'United' }),
record('a2', 37.61, -122.3, { operator: 'Delta' }),
record('a3', 37.62, -122.3, { operator: 'United', baroAltitudeM: 3000 }),
record('a4', 37.63, -122.3, { onGround: true, baroAltitudeM: 0 }),
record('a4', 37.63, -122.3, {
onGround: true,
baroAltitudeM: 0,
speedMps: 0,
}),
record('a5', 51.5, -0.1, { operator: 'British Airways' }),
];
const catalog = composeCatalog({
@@ -422,15 +426,44 @@ test('aircraft in an area can be counted by operator over every match', async ()
other_count: 0,
unknown_count: 1,
});
// Altitude covers airborne aircraft only; speed covers every match.
assert.deepEqual(result.data.stats.altitude_m, {
count: 3,
min: 3000,
median: 10000,
mean: 7666.7,
max: 10000,
// Both figures cover airborne aircraft only: the parked a4 stands at 0 m
// and 0 m/s and would pull each mean down.
assert.deepEqual(result.data.stats, {
altitude_m: {
count: 3,
min: 3000,
median: 10000,
mean: 7666.7,
max: 10000,
},
speed_mps: { count: 3, min: 230, median: 230, mean: 230, max: 230 },
});
assert.equal(result.data.stats.speed_mps.count, 4);
});
test('aircraft stats are empty when every match is on the ground', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
aircraft: feed([
record('g1', 37.6, -122.3, {
onGround: true,
baroAltitudeM: 0,
speedMps: 0,
}),
record('g2', 37.61, -122.3, {
onGround: true,
baroAltitudeM: 0,
speedMps: 4,
}),
]),
},
});
const result = await catalog.call('aircraft_in_area', {
area: bay,
stats: true,
});
assert.match(result.summary, /^2 aircraft in /);
assert.deepEqual(result.data.stats, { altitude_m: null, speed_mps: null });
});
test('aircraft answers carry no breakdown or stats unless asked', async () => {
+54
View File
@@ -297,6 +297,60 @@ test('numeric AIS ship-type codes group and filter by their family', async () =>
);
});
test('reserved AIS ship-type codes 100 to 255 count as unknown', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: vessels({
records: [
vessel('366000001', 37.78, -122.4, { type: '100' }),
vessel('366000002', 37.79, -122.41, { type: '255' }),
vessel('366000003', 37.77, -122.39, { type: '70' }),
],
}),
},
});
const result = await catalog.call('vessels_in_area', {
area: bay,
group_by: 'type',
});
assert.deepEqual(result.data.breakdown.groups, [
{ value: 'CARGO', count: 1 },
]);
assert.equal(result.data.breakdown.unknown_count, 2);
assert.doesNotMatch(result.summary, /\b(100|255) \(/);
});
test('find_vessel answers carry normalized types and destinations', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: vessels({
records: [
vessel('366000001', 37.78, -122.4, {
type: '71',
destination: '---',
}),
vessel('366000002', 37.79, -122.41, {
type: '0',
destination: 'NLRTM',
}),
],
}),
},
});
const cargo = await catalog.call('find_vessel', { mmsi: '366000001' });
assert.deepEqual(
cargo.data.rows.map((row) => [row.type, row.destination]),
[['CARGO', null]],
);
const unknown = await catalog.call('find_vessel', { mmsi: '366000002' });
assert.deepEqual(
unknown.data.rows.map((row) => [row.type, row.destination]),
[[null, 'NLRTM']],
);
});
test('a destination with no letters counts as unknown', async () => {
const catalog = composeCatalog({
tools: coreTools,