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,