From 4c383214f7ffd678da0749084d0f32af4dc30a94 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 14:29:11 +0100 Subject: [PATCH] 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 --- changelog.d/1056-area-tool-breakdowns.md | 7 ++- src/data/vesselLabels.test.mjs | 2 + src/data/vesselTypes.js | 8 ++-- src/tools/queries/aviation.js | 9 ++-- src/tools/queries/aviation.test.mjs | 51 ++++++++++++++++++---- src/tools/queries/maritime.test.mjs | 54 ++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 19 deletions(-) diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md index 5425b3651..3ca857a45 100644 --- a/changelog.d/1056-area-tool-breakdowns.md +++ b/changelog.d/1056-area-tool-breakdowns.md @@ -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). diff --git a/src/data/vesselLabels.test.mjs b/src/data/vesselLabels.test.mjs index f69fffcc7..1573daa75 100644 --- a/src/data/vesselLabels.test.mjs +++ b/src/data/vesselLabels.test.mjs @@ -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), ''); }); diff --git a/src/data/vesselTypes.js b/src/data/vesselTypes.js index 12886795c..550f4596b 100644 --- a/src/data/vesselTypes.js +++ b/src/data/vesselTypes.js @@ -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'; } diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index fa2ff1172..a5f4babf0 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -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'), }, } : {}), diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 32c1e02cd..1af1352c0 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -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 () => { diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index dfa1fd761..d86bdf352 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -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,