From 2110d06e36eb7b7ed0a6cf595257c414ecde2587 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:32:45 -0700 Subject: [PATCH 01/10] feat(tools): count and summarize every match in the area tools aircraft_in_area and vessels_in_area return at most 200 rows, so a client asked which operators dominate an area, or the average altitude there, could only estimate from a truncated sample. Both tools now take an optional group_by (aircraft: operator, origin_country, type_code; ships: type, destination) and stats (airborne altitude and speed for aircraft, speed for ships). Each is computed over every match before rows are capped. Breakdowns list the ten largest groups plus other and unknown counts, and the summary names the three largest. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ docs/CURRENT-STATE.md | 6 ++- docs/TOOLS.md | 4 +- scripts/format-scope.json | 1 + src/tools/queries/aviation.js | 37 +++++++++++++- src/tools/queries/aviation.test.mjs | 57 +++++++++++++++++++++ src/tools/queries/maritime.js | 22 +++++++- src/tools/queries/maritime.test.mjs | 25 +++++++++ src/tools/results.js | 69 +++++++++++++++++++++++++ src/tools/results.test.mjs | 78 +++++++++++++++++++++++++++++ 10 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 src/tools/results.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ab1c52d..0a5f41549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so a + client can ask which operators or ship types dominate an area, or the average + altitude or speed, over every match instead of estimating from the returned + rows. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 274294b5d..520bb9207 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -93,7 +93,11 @@ perimeters, terrain height, military installations and map features, plus a combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap -lists at 25 rows by default. Voice offers the same queries next to its app +lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also +count every match by a field (`group_by`: operator, country or type for +aircraft; type or destination for ships; the ten largest groups, then other and +unknown counts) and summarize airborne altitude and speed (`stats`: count, min, +median, mean, max). Both cover all matches, not just the rows returned. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 401058d91..f3b86f7a3 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -263,11 +263,11 @@ and the development server's internal routes. See SECURITY.md. | `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first | | `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first | | `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first | -| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed | +| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed; `group_by` and `stats` count and summarize every match | | `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration | | `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points | | `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb | -| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type | +| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match | | `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name | | `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points | | `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6b5c9b544..f8c056177 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -941,6 +941,7 @@ "scripts/qa-context-arrivals.mjs", "src/tools/catalog.test.mjs", "src/tools/area.test.mjs", + "src/tools/results.test.mjs", "src/tools/queries/queries.test.mjs", "src/tools/mcp/mcp.test.mjs", "src/tools/localServer.test.mjs", diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 0b8b54b79..e3aca82c4 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -11,9 +11,12 @@ import { } from '../area.js'; import { LIMIT_SCHEMA, + breakdown, + breakdownPhrase, capRows, countNoun, isoTime, + numericSummary, thinEvenly, } from '../results.js'; @@ -32,6 +35,11 @@ const MILITARY_SCHEMA = Object.freeze({ description: 'Use the military aircraft feed instead of the general feed.', }); const MAX_TRACK_POINTS = 200; +const GROUP_LABELS = Object.freeze({ + operator: 'operator', + origin_country: 'country', + type_code: 'type', +}); const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; @@ -82,7 +90,10 @@ export const aircraftInArea = defineTool({ title: 'Aircraft in an area', description: 'Aircraft currently reported inside an area, nearest the center first. ' + - 'Optionally military aircraft only, airborne only, or within an altitude band.', + 'Optionally military aircraft only, airborne only, or within an altitude band. ' + + 'group_by counts every matched aircraft by operator, country or type, and ' + + 'stats summarizes airborne altitude and speed; both cover all matches, not ' + + 'just the rows returned.', inputSchema: { type: 'object', properties: { @@ -91,6 +102,16 @@ export const aircraftInArea = defineTool({ airborne_only: { type: 'boolean' }, min_altitude_m: { type: 'number', minimum: -500, maximum: 30000 }, max_altitude_m: { type: 'number', minimum: -500, maximum: 30000 }, + group_by: { + type: 'string', + enum: Object.keys(GROUP_LABELS), + description: 'Count matched aircraft by this field, largest first.', + }, + stats: { + type: 'boolean', + description: + 'Summarize airborne altitude and speed over matched aircraft.', + }, limit: LIMIT_SCHEMA, }, required: ['area'], @@ -126,9 +147,11 @@ export const aircraftInArea = defineTool({ ...(regional ? [`regional feed: ${snapshot.coverage}`] : []), ...(snapshot.freshness === 'stale' ? ['data may be stale'] : []), ]; + const groups = args.group_by ? breakdown(rows, args.group_by) : null; return { summary: `${rows.length} ${kind} in ${area.label}` + + (groups ? breakdownPhrase(groups, GROUP_LABELS[args.group_by]) : '') + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { view: suggestView(services, { @@ -136,6 +159,18 @@ export const aircraftInArea = defineTool({ layers: [args.military ? 'military' : 'flights'], }), ...capRows(rows, args.limit), + ...(groups ? { breakdown: groups } : {}), + ...(args.stats + ? { + stats: { + altitude_m: numericSummary( + rows.filter((row) => !row.on_ground), + 'altitude_m', + ), + speed_mps: numericSummary(rows, 'speed_mps'), + }, + } + : {}), ...snapshotInfo(snapshot), }, }; diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 20ec833aa..618085b00 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -388,3 +388,60 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as assert.deepEqual(result.data.stale_feeds, ['civil']); assert.match(result.summary, /may be stale\.$/); }); + +test('aircraft in an area can be counted by operator over every match', async () => { + const fleet = [ + 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('a5', 51.5, -0.1, { operator: 'British Airways' }), + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(fleet) }, + }); + const result = await catalog.call('aircraft_in_area', { + area: bay, + group_by: 'operator', + stats: true, + limit: 1, + }); + assert.equal( + result.summary, + '4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1).', + ); + assert.equal(result.data.returned, 1); + assert.deepEqual(result.data.breakdown, { + field: 'operator', + groups: [ + { value: 'United', count: 2 }, + { value: 'Delta', count: 1 }, + ], + 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, + }); + assert.equal(result.data.stats.speed_mps.count, 4); +}); + +test('aircraft answers carry no breakdown or stats unless asked', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(civil) }, + }); + const result = await catalog.call('aircraft_in_area', { area: bay }); + assert.equal('breakdown' in result.data, false); + assert.equal('stats' in result.data, false); + await assert.rejects( + catalog.call('aircraft_in_area', { area: bay, group_by: 'callsign' }), + /must be one of/, + ); +}); diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index 5b64f2826..a918c1f52 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -12,9 +12,12 @@ import { } from '../area.js'; import { LIMIT_SCHEMA, + breakdown, + breakdownPhrase, capRows, countNoun, isoTime, + numericSummary, thinEvenly, } from '../results.js'; @@ -88,7 +91,9 @@ export const vesselsInArea = defineTool({ title: 'Vessels in an area', description: 'Ships currently reported by AIS inside an area, nearest the center ' + - 'first, optionally filtered by vessel type.', + 'first, optionally filtered by vessel type. group_by counts every matched ' + + 'ship by type or destination, and stats summarizes their speed; both ' + + 'cover all matches, not just the rows returned.', inputSchema: { type: 'object', properties: { @@ -99,6 +104,15 @@ export const vesselsInArea = defineTool({ maxLength: 40, description: 'Vessel type to match, such as "tanker" or "cargo".', }, + group_by: { + type: 'string', + enum: ['type', 'destination'], + description: 'Count matched vessels by this field, largest first.', + }, + stats: { + type: 'boolean', + description: 'Summarize speed over matched vessels.', + }, limit: LIMIT_SCHEMA, }, required: ['area'], @@ -119,13 +133,19 @@ export const vesselsInArea = defineTool({ .map((record) => vesselRow(record, center)) .sort((a, b) => a.distance_km - b.distance_km); const kind = args.type ? `${args.type} vessel` : 'vessel'; + const groups = args.group_by ? breakdown(rows, args.group_by) : null; return { summary: `${countNoun(rows.length, kind)} in ${area.label}` + + (groups ? breakdownPhrase(groups, args.group_by) : '') + (snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'), data: { view: suggestView(services, { area, layers: ['ais-live-vessels'] }), ...capRows(rows, args.limit), + ...(groups ? { breakdown: groups } : {}), + ...(args.stats + ? { stats: { speed_mps: numericSummary(rows, 'speed_mps') } } + : {}), ...snapshotInfo(snapshot), }, }; diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index 785e38223..d1c43e60a 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -224,3 +224,28 @@ test('an area search names its area to the server; a vessel lookup asks for ever assert.equal(lookup.has('lat'), false); assert.equal(lookup.has('radius_km'), false); }); + +test('vessels in an area can be counted by destination with a speed summary', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { vessels: vessels() }, + }); + const result = await catalog.call('vessels_in_area', { + area: bay, + group_by: 'destination', + stats: true, + }); + assert.equal( + result.summary, + '2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1).', + ); + assert.deepEqual(result.data.breakdown, { + field: 'destination', + groups: [{ value: 'OAKLAND', count: 1 }], + other_count: 0, + unknown_count: 1, + }); + assert.deepEqual(result.data.stats, { + speed_mps: { count: 2, min: 6.2, median: 6.2, mean: 6.2, max: 6.2 }, + }); +}); diff --git a/src/tools/results.js b/src/tools/results.js index 51ed82bbb..b4a24c72a 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -22,6 +22,75 @@ export function capRows(rows, limit = DEFAULT_LIMIT) { }; } +/** Most groups a breakdown lists before counting the rest as other. */ +export const MAX_GROUPS = 10; + +/** + * Count rows by one text field over every row, not just those returned. + * Values group case-insensitively and keep their first spelling; rows + * without a value are counted as unknown. + */ +export function breakdown(rows, field) { + const groups = new Map(); + let unknown = 0; + for (const row of rows) { + const value = typeof row[field] === 'string' ? row[field].trim() : ''; + if (!value) { + unknown += 1; + continue; + } + const key = value.toLowerCase(); + const group = groups.get(key); + if (group) group.count += 1; + else groups.set(key, { value, count: 1 }); + } + const sorted = [...groups.values()].sort( + (a, b) => b.count - a.count || a.value.localeCompare(b.value), + ); + const listed = sorted.slice(0, MAX_GROUPS); + return { + field, + groups: listed, + other_count: rows.length - unknown - sum(listed.map((g) => g.count)), + unknown_count: unknown, + }; +} + +/** Minimum, median, mean and maximum of one numeric field, or null. */ +export function numericSummary(rows, field) { + const values = rows + .map((row) => row[field]) + .filter(Number.isFinite) + .sort((a, b) => a - b); + if (!values.length) return null; + const middle = values.length >> 1; + const median = + values.length % 2 + ? values[middle] + : (values[middle - 1] + values[middle]) / 2; + const round1 = (value) => Number(value.toFixed(1)); + return { + count: values.length, + min: values[0], + median: round1(median), + mean: round1(sum(values) / values.length), + max: values.at(-1), + }; +} + +/** "; most by operator: Delta (12), United (9), American (7)" or "". */ +export function breakdownPhrase(result, label) { + if (!result.groups.length) return ''; + const top = result.groups + .slice(0, 3) + .map((group) => `${group.value} (${group.count})`); + return `; most by ${label}: ${top.join(', ')}`; +} + +function sum(values) { + return values.reduce((total, value) => total + value, 0); +} + /** Format an epoch millisecond time as ISO 8601, or null. */ export function isoTime(ms) { return Number.isFinite(ms) ? new Date(ms).toISOString() : null; diff --git a/src/tools/results.test.mjs b/src/tools/results.test.mjs new file mode 100644 index 000000000..ba67b9f64 --- /dev/null +++ b/src/tools/results.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + MAX_GROUPS, + breakdown, + breakdownPhrase, + numericSummary, +} from './results.js'; + +test('a breakdown counts every row, case-insensitively, largest first', () => { + const rows = [ + { operator: 'Delta' }, + { operator: 'United' }, + { operator: ' delta ' }, + { operator: null }, + { operator: '' }, + { operator: 'American' }, + { operator: 'United' }, + { operator: 'DELTA' }, + ]; + assert.deepEqual(breakdown(rows, 'operator'), { + field: 'operator', + groups: [ + { value: 'Delta', count: 3 }, + { value: 'United', count: 2 }, + { value: 'American', count: 1 }, + ], + other_count: 0, + unknown_count: 2, + }); +}); + +test('groups past the limit are counted as other', () => { + const rows = Array.from({ length: MAX_GROUPS + 3 }, (_, index) => ({ + type: `T${String(index).padStart(2, '0')}`, + })); + rows.push({ type: 'T00' }); + const result = breakdown(rows, 'type'); + assert.equal(result.groups.length, MAX_GROUPS); + assert.deepEqual(result.groups[0], { value: 'T00', count: 2 }); + assert.equal(result.other_count, 3); + assert.equal( + result.groups.reduce((total, group) => total + group.count, 0) + + result.other_count + + result.unknown_count, + rows.length, + ); +}); + +test('a numeric summary skips missing values', () => { + const rows = [{ v: 4 }, { v: null }, { v: 1 }, { v: 10 }, { v: 3 }, {}]; + assert.deepEqual(numericSummary(rows, 'v'), { + count: 4, + min: 1, + median: 3.5, + mean: 4.5, + max: 10, + }); + assert.equal(numericSummary([{ v: 2 }, { v: 7 }, { v: 5 }], 'v').median, 5); + assert.equal(numericSummary([{ v: null }], 'v'), null); + assert.equal(numericSummary([], 'v'), null); +}); + +test('the breakdown phrase names the three largest groups', () => { + const result = { + groups: [ + { value: 'A', count: 4 }, + { value: 'B', count: 3 }, + { value: 'C', count: 2 }, + { value: 'D', count: 1 }, + ], + }; + assert.equal( + breakdownPhrase(result, 'type'), + '; most by type: A (4), B (3), C (2)', + ); + assert.equal(breakdownPhrase({ groups: [] }, 'type'), ''); +}); From 603f428397f61ddd06893b3178081398f5f9c50c Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:35:24 -0700 Subject: [PATCH 02/10] fix(tools): say when a breakdown's field is missing from the feed OpenSky, the default civil feed, reports no operator or type code, so a breakdown by either put every aircraft under unknown while the summary said nothing. The summary now says "no operator reported" in that case, and otherwise appends the unknown count after the largest groups. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 5 +++-- src/tools/queries/aviation.js | 4 +++- src/tools/queries/aviation.test.mjs | 2 +- src/tools/queries/maritime.test.mjs | 2 +- src/tools/results.js | 13 ++++++++++--- src/tools/results.test.mjs | 12 +++++++++++- 6 files changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 520bb9207..f9e9c02a7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -96,8 +96,9 @@ shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also count every match by a field (`group_by`: operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and -unknown counts) and summarize airborne altitude and speed (`stats`: count, min, -median, mean, max). Both cover all matches, not just the rows returned. Voice offers the same queries next to its app +unknown counts; the summary says when a feed reports none of the field) and +summarize airborne altitude and speed (`stats`: count, min, median, mean, max). +Both cover all matches, not just the rows returned. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index e3aca82c4..23652f211 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -105,7 +105,9 @@ export const aircraftInArea = defineTool({ group_by: { type: 'string', enum: Object.keys(GROUP_LABELS), - description: 'Count matched aircraft by this field, largest first.', + description: + 'Count matched aircraft by this field, largest first. Feeds that do not ' + + 'report the field count those aircraft as unknown.', }, stats: { type: 'boolean', diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 618085b00..b6b6e3497 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -409,7 +409,7 @@ test('aircraft in an area can be counted by operator over every match', async () }); assert.equal( result.summary, - '4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1).', + '4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1); 1 unknown.', ); assert.equal(result.data.returned, 1); assert.deepEqual(result.data.breakdown, { diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index d1c43e60a..662e5bdf2 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -237,7 +237,7 @@ test('vessels in an area can be counted by destination with a speed summary', as }); assert.equal( result.summary, - '2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1).', + '2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1); 1 unknown.', ); assert.deepEqual(result.data.breakdown, { field: 'destination', diff --git a/src/tools/results.js b/src/tools/results.js index b4a24c72a..528f59ea5 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -78,13 +78,20 @@ export function numericSummary(rows, field) { }; } -/** "; most by operator: Delta (12), United (9), American (7)" or "". */ +/** + * "; most by operator: Delta (12), United (9), American (7); 4 unknown", or + * "; no operator reported" when no row carries the field. + */ export function breakdownPhrase(result, label) { - if (!result.groups.length) return ''; + if (!result.groups.length) + return result.unknown_count ? `; no ${label} reported` : ''; const top = result.groups .slice(0, 3) .map((group) => `${group.value} (${group.count})`); - return `; most by ${label}: ${top.join(', ')}`; + const unknown = result.unknown_count + ? `; ${result.unknown_count} unknown` + : ''; + return `; most by ${label}: ${top.join(', ')}${unknown}`; } function sum(values) { diff --git a/src/tools/results.test.mjs b/src/tools/results.test.mjs index ba67b9f64..2e480afcd 100644 --- a/src/tools/results.test.mjs +++ b/src/tools/results.test.mjs @@ -63,6 +63,7 @@ test('a numeric summary skips missing values', () => { test('the breakdown phrase names the three largest groups', () => { const result = { + unknown_count: 0, groups: [ { value: 'A', count: 4 }, { value: 'B', count: 3 }, @@ -74,5 +75,14 @@ test('the breakdown phrase names the three largest groups', () => { breakdownPhrase(result, 'type'), '; most by type: A (4), B (3), C (2)', ); - assert.equal(breakdownPhrase({ groups: [] }, 'type'), ''); + assert.equal( + breakdownPhrase({ ...result, unknown_count: 5 }, 'type'), + '; most by type: A (4), B (3), C (2); 5 unknown', + ); + // A feed that never reports the field says so instead of staying silent. + assert.equal( + breakdownPhrase({ groups: [], unknown_count: 236 }, 'operator'), + '; no operator reported', + ); + assert.equal(breakdownPhrase({ groups: [], unknown_count: 0 }, 'type'), ''); }); From 1371a867137aebcdcd488213a0b2ce7b3e42bfa9 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:11:47 -0700 Subject: [PATCH 03/10] docs: say the area breakdowns reach MCP clients only Voice leaves aircraft_in_area and vessels_in_area out, so the changelog no longer suggests spoken questions gain breakdowns. It also stops leading with operators, which the default civil feed never reports. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++---- docs/CURRENT-STATE.md | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a5f41549..b918e89cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ # Changelog -- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so a - client can ask which operators or ship types dominate an area, or the average - altitude or speed, over every match instead of estimating from the returned - rows. +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an + MCP client can ask which countries, aircraft types or ship types dominate an + area, or the average altitude or speed, over every match instead of + estimating from the returned rows. Operator and type are counted only where + the feed reports them. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f9e9c02a7..bdc9573b4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -98,7 +98,8 @@ count every match by a field (`group_by`: operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and unknown counts; the summary says when a feed reports none of the field) and summarize airborne altitude and speed (`stats`: count, min, median, mean, max). -Both cover all matches, not just the rows returned. Voice offers the same queries next to its app +Both cover all matches, not just the rows returned; voice leaves these two +tools out, so they reach MCP clients only. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). From 52863a9f422c4120b6533339b770b8ca7995121c Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:06:10 -0700 Subject: [PATCH 04/10] fix(tools): count AIS ship type 0 as unknown in vessel breakdowns AIS type 0 means "not available" (ITU-R M.1371), but the source keeps it as '0', so group_by: 'type' listed "0" as a type and understated unknown_count. vessels_in_area rows now report it as null, and a regression fixture mixes type 0 with Cargo and Tanker. Co-Authored-By: Claude Opus 5.5 --- src/tools/queries/maritime.js | 8 ++++++- src/tools/queries/maritime.test.mjs | 35 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index a918c1f52..b5bdc4afe 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -32,6 +32,12 @@ 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; +}; function vesselRow(record, from) { const point = { lat: record.latitude, lon: record.longitude }; @@ -39,7 +45,7 @@ function vesselRow(record, from) { mmsi: record.id, name: text(record.name), imo: text(record.imo), - type: text(record.type), + type: vesselType(record.type), destination: text(record.destination), lat: round(record.latitude, 5), lon: round(record.longitude, 5), diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index 662e5bdf2..c5a5ab21f 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -225,6 +225,41 @@ test('an area search names its area to the server; a vessel lookup asks for ever assert.equal(lookup.has('radius_km'), false); }); +test('AIS type 0 (not available) is counted as unknown, not as a type', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + vessels: vessels({ + records: [ + vessel('366000001', 37.78, -122.4, { type: '0' }), + vessel('366000002', 37.79, -122.41, { type: '0' }), + vessel('366000003', 37.77, -122.39, { type: 'Cargo' }), + vessel('366000004', 37.76, -122.38, { type: 'Tanker' }), + ], + }), + }, + }); + const result = await catalog.call('vessels_in_area', { + area: bay, + group_by: 'type', + }); + assert.deepEqual(result.data.breakdown, { + field: 'type', + groups: [ + { value: 'Cargo', count: 1 }, + { value: 'Tanker', count: 1 }, + ], + other_count: 0, + unknown_count: 2, + }); + assert.match(result.summary, /most by type: Cargo \(1\), Tanker \(1\); 2 unknown\./); + assert.doesNotMatch(result.summary, /0 \(/); + assert.equal( + result.data.rows.find((row) => row.mmsi === '366000001').type, + null, + ); +}); + test('vessels in an area can be counted by destination with a speed summary', async () => { const catalog = composeCatalog({ tools: coreTools, From 60444cca53af98c21713d2fe4825efc3d20b69c2 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:06:24 -0700 Subject: [PATCH 05/10] style: format the AIS type 0 regression test Co-Authored-By: Claude Opus 5.5 --- src/tools/queries/maritime.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index c5a5ab21f..f1d09bccb 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -252,7 +252,10 @@ test('AIS type 0 (not available) is counted as unknown, not as a type', async () other_count: 0, unknown_count: 2, }); - assert.match(result.summary, /most by type: Cargo \(1\), Tanker \(1\); 2 unknown\./); + assert.match( + result.summary, + /most by type: Cargo \(1\), Tanker \(1\); 2 unknown\./, + ); assert.doesNotMatch(result.summary, /0 \(/); assert.equal( result.data.rows.find((row) => row.mmsi === '366000001').type, From 8f6671b6c271abc9464ac990259a03db6943695c Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:22:39 -0700 Subject: [PATCH 06/10] Merge upstream main into feat/tools-area-breakdowns Move the changelog entry into changelog.d/1056-area-tool-breakdowns.md (upstream's one-file-per-PR rule, #787) and leave CHANGELOG.md as on main. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ----- changelog.d/1056-area-tool-breakdowns.md | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1056-area-tool-breakdowns.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 22dce6a93..8ee00d839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,5 @@ # Changelog -- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an - MCP client can ask which countries, aircraft types or ship types dominate an - area, or the average altitude or speed, over every match instead of - estimating from the returned rows. Operator and type are counted only where - the feed reports them. - Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both band edges, skips the starting station and rejects spectra from an earlier tune (Aditya, @Rohilalala, #1174). diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md new file mode 100644 index 000000000..75f713907 --- /dev/null +++ b/changelog.d/1056-area-tool-breakdowns.md @@ -0,0 +1,6 @@ +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an + MCP client can ask which countries, aircraft types or ship types dominate an + area, or the average altitude or speed, over every match instead of + estimating from the returned rows. Operator and type are counted only where + the feed reports them; AIS ship type 0 ("not available") counts as unknown + (quanticsoul4772, #1056). From aec4f67ca97ae6667a1055f7f5521fa15692590b Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:45:25 -0700 Subject: [PATCH 07/10] feat(tools): count aircraft in an area by airline OpenSky, the default civil feed, reports no operator, so group_by: operator said "no operator reported". aircraft_in_area now also takes group_by: 'airline', read from the ICAO designator in each callsign: three letters followed by a flight number that starts with a digit (SWA1234, UAL12A). Registrations used as callsigns (N123AB) count as unknown. Rows are unchanged; the designator is derived for the breakdown only. Live, Texas: "785 aircraft ...; most by airline: AAL (89), SWA (65), UAL (52); 283 unknown." Co-Authored-By: Claude Opus 5.5 --- changelog.d/1056-area-tool-breakdowns.md | 10 +++--- docs/CURRENT-STATE.md | 9 +++--- src/tools/queries/aviation.js | 25 +++++++++++++-- src/tools/queries/aviation.test.mjs | 41 ++++++++++++++++++++++++ src/tools/results.js | 8 +++-- 5 files changed, 80 insertions(+), 13 deletions(-) diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md index 75f713907..67ec8d766 100644 --- a/changelog.d/1056-area-tool-breakdowns.md +++ b/changelog.d/1056-area-tool-breakdowns.md @@ -1,6 +1,8 @@ - `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an - MCP client can ask which countries, aircraft types or ship types dominate an - area, or the average altitude or speed, over every match instead of - estimating from the returned rows. Operator and type are counted only where - the feed reports them; AIS ship type 0 ("not available") counts as unknown + MCP client can ask which airlines, countries, aircraft types or ship types + dominate an area, or the average altitude or speed, over every match instead + of estimating from the returned rows. Airlines come from the ICAO designator + 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; AIS ship type 0 ("not available") counts as unknown (quanticsoul4772, #1056). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9bc129b8f..9f86c1219 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -98,10 +98,11 @@ combined situation brief, military awareness around a point, the app's heads-up opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also -count every match by a field (`group_by`: operator, country or type for -aircraft; type or destination for ships; the ten largest groups, then other and -unknown counts; the summary says when a feed reports none of the field) and -summarize airborne altitude and speed (`stats`: count, min, median, mean, max). +count every match by a field (`group_by`: airline, read from the ICAO +designator in the callsign, or operator, country or type for aircraft; type or +destination for ships; the ten largest groups, then other and unknown counts; +the summary says when a feed reports none of the field) and summarize airborne +altitude and speed (`stats`: count, min, median, mean, max). Both cover all matches, not just the rows returned; voice leaves these two tools out, so they reach MCP clients only. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index d3d9bbeaa..6a4cbd54e 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -36,11 +36,29 @@ const MILITARY_SCHEMA = Object.freeze({ }); const MAX_TRACK_POINTS = 200; const GROUP_LABELS = Object.freeze({ + airline: 'airline', operator: 'operator', origin_country: 'country', type_code: 'type', }); +/** + * The ICAO airline designator a callsign flies under: three letters, then a + * flight number that starts with a digit (SWA1234, UAL12A, BAW1). Registrations + * used as callsigns (N123AB, GABCD) have no designator. + */ +export function airlineDesignator(callsign) { + const match = /^([A-Z]{3})\d[0-9A-Z]{0,4}$/.exec( + String(callsign ?? '') + .trim() + .toUpperCase(), + ); + return match ? match[1] : null; +} + +const groupValue = (field) => + field === 'airline' ? (row) => airlineDesignator(row.callsign) : undefined; + const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; @@ -91,7 +109,8 @@ export const aircraftInArea = defineTool({ description: 'Aircraft currently reported inside an area, nearest the center first. ' + 'Optionally military aircraft only, airborne only, or within an altitude band. ' + - 'group_by counts every matched aircraft by operator, country or type, and ' + + 'group_by counts every matched aircraft by airline (the ICAO designator ' + + 'in its callsign), operator, country or type, and ' + 'stats summarizes airborne altitude and speed; both cover all matches, not ' + 'just the rows returned.', inputSchema: { @@ -151,7 +170,9 @@ export const aircraftInArea = defineTool({ ...(regional ? [`regional feed: ${snapshot.coverage}`] : []), ...(snapshot.freshness === 'stale' ? ['data may be stale'] : []), ]; - const groups = args.group_by ? breakdown(rows, args.group_by) : null; + const groups = args.group_by + ? breakdown(rows, args.group_by, groupValue(args.group_by)) + : null; return { summary: `${rows.length} ${kind} in ${area.label}` + diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 14b2e2d38..721385ef7 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { composeCatalog, coreTools } from '../index.js'; +import { airlineDesignator } from './aviation.js'; import { LiveSourceError } from '../../sources/live/contract.js'; const record = (id, latitude, longitude, extra = {}) => ({ @@ -504,3 +505,43 @@ test('altitude bands require a reported altitude from normalized OpenSky records }); assert.equal(minimum.data.total, 3); }); + +test('an airline designator is read from an ICAO flight callsign only', () => { + for (const [callsign, designator] of [ + ['SWA1234', 'SWA'], + ['ual12a', 'UAL'], + [' BAW1 ', 'BAW'], + ['RCH123', 'RCH'], + ['N123AB', null], + ['GABCD', null], + ['SWA', null], + ['SWAX123', null], + ['', null], + [null, null], + ]) + assert.equal(airlineDesignator(callsign), designator, String(callsign)); +}); + +test('aircraft in an area can be counted by airline from their callsigns', async () => { + const fleet = [ + record('a1', 37.6, -122.3, { callsign: 'SWA100' }), + record('a2', 37.61, -122.3, { callsign: 'SWA2' }), + record('a3', 37.62, -122.3, { callsign: 'UAL9' }), + record('a4', 37.63, -122.3, { callsign: 'N123AB' }), + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(fleet) }, + }); + const result = await catalog.call('aircraft_in_area', { + area: bay, + group_by: 'airline', + }); + assert.equal( + result.summary, + '4 aircraft in 60 km around 37.600, -122.300; most by airline: SWA (2), UAL (1); 1 unknown.', + ); + assert.equal(result.data.breakdown.field, 'airline'); + // Rows are unchanged: the designator is derived, not added to each row. + assert.equal('airline' in result.data.rows[0], false); +}); diff --git a/src/tools/results.js b/src/tools/results.js index 952b2723e..247383757 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -28,13 +28,15 @@ export const MAX_GROUPS = 10; /** * Count rows by one text field over every row, not just those returned. * Values group case-insensitively and keep their first spelling; rows - * without a value are counted as unknown. + * without a value are counted as unknown. `valueOf` derives the value when + * it is not a row field as it stands. */ -export function breakdown(rows, field) { +export function breakdown(rows, field, valueOf = (row) => row[field]) { const groups = new Map(); let unknown = 0; for (const row of rows) { - const value = typeof row[field] === 'string' ? row[field].trim() : ''; + const raw = valueOf(row); + const value = typeof raw === 'string' ? raw.trim() : ''; if (!value) { unknown += 1; continue; From 7bf268ec4287af8e097fec00fc14cfb406ece5e1 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:37:07 -0700 Subject: [PATCH 08/10] fix(tools): read an airline only from a callsign of seven characters or fewer ICAO flight-plan Item 7 caps aircraft identification at seven characters, so the flight number after the designator is one to four characters, not five. Philippine RP-C1234 and Cuban CU-T1240 registrations read like a designator plus a flight number once the hyphen is dropped; they now count as unknown. Cubana's CUB123 still counts as CUB. Co-Authored-By: Claude Opus 5.5 --- src/tools/queries/aviation.js | 16 +++++++++------- src/tools/queries/aviation.test.mjs | 4 ++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 6a4cbd54e..fa2ff1172 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -44,15 +44,17 @@ const GROUP_LABELS = Object.freeze({ /** * The ICAO airline designator a callsign flies under: three letters, then a - * flight number that starts with a digit (SWA1234, UAL12A, BAW1). Registrations - * used as callsigns (N123AB, GABCD) have no designator. + * flight number that starts with a digit, seven characters at most (SWA1234, + * UAL12A, BAW1). Registrations used as callsigns (N123AB, GABCD) have no + * designator; Philippine RP-C1234 and Cuban CU-T1240, which read like one + * without their hyphen, are excluded too. */ export function airlineDesignator(callsign) { - const match = /^([A-Z]{3})\d[0-9A-Z]{0,4}$/.exec( - String(callsign ?? '') - .trim() - .toUpperCase(), - ); + const text = String(callsign ?? '') + .trim() + .toUpperCase(); + if (/^(?:RPC|CUT)\d{4}$/.test(text)) return null; + const match = /^([A-Z]{3})\d[0-9A-Z]{0,3}$/.exec(text); return match ? match[1] : null; } diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 721385ef7..32c1e02cd 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -516,6 +516,10 @@ test('an airline designator is read from an ICAO flight callsign only', () => { ['GABCD', null], ['SWA', null], ['SWAX123', null], + ['SWA12345', null], + ['RPC1234', null], + ['CUT1240', null], + ['CUB123', 'CUB'], ['', null], [null, null], ]) From d39256e70013d911b29b36f2904f53a1583d8755 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:33:40 -0700 Subject: [PATCH 09/10] 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 --- changelog.d/1056-area-tool-breakdowns.md | 3 +- scripts/package-boundaries.json | 4 ++ src/data/vesselLabels.js | 43 ++---------------- src/data/vesselTypes.js | 45 +++++++++++++++++++ src/tools/queries/maritime.js | 22 ++++++--- src/tools/queries/maritime.test.mjs | 57 ++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 src/data/vesselTypes.js diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md index 67ec8d766..5425b3651 100644 --- a/changelog.d/1056-area-tool-breakdowns.md +++ b/changelog.d/1056-area-tool-breakdowns.md @@ -4,5 +4,6 @@ of estimating from the returned rows. Airlines come from the ICAO designator 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; AIS ship type 0 ("not available") counts as unknown + 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). diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 43bc3d4de..07f2545a3 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1040,6 +1040,7 @@ "src/layers/vessels/tracking.js", "src/data/iconOrientation.js", "src/data/vesselLabels.js", + "src/data/vesselTypes.js", "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", "src/layers/vessels/snapshotRenderer.js", @@ -1841,6 +1842,7 @@ "src/data/trafficQueue.js", "src/data/trailRenderer.js", "src/data/vesselLabels.js", + "src/data/vesselTypes.js", "src/firstRunExperience.js", "src/hud.js", "src/hudLocality.js", @@ -2629,6 +2631,7 @@ "src/data/trafficQueue.js", "src/data/trailRenderer.js", "src/data/vesselLabels.js", + "src/data/vesselTypes.js", "src/keylessGeocoder.js", "src/layers/aircraft/classification.js", "src/layers/alpr/index.js", @@ -3782,6 +3785,7 @@ "src/tools/queries/maritime.js", "src/tools/queries/share.js", "src/data/idSwitches.js", + "src/data/vesselTypes.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/sources/gbfsStations.js", diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js index 311a238c6..fbf209040 100644 --- a/src/data/vesselLabels.js +++ b/src/data/vesselLabels.js @@ -5,6 +5,9 @@ */ import { t, msg } from '../i18n/index.js'; +import { normalizeVesselType } from './vesselTypes.js'; + +export { normalizeVesselType }; export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels'; /** Existing selector grid size; one ambient winner is retained per cell. */ @@ -43,31 +46,6 @@ const TYPE_STYLES = [ ]; const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' }; -const NUMERIC_TYPE_SPECIALS = { - 30: 'FISHING', - 31: 'TOWING', - 32: 'TOWING', - 33: 'DREDGER', - 34: 'DIVE OPS', - 35: 'MILITARY', - 36: 'SAILING', - 37: 'PLEASURE', - 50: 'PILOT', - 51: 'SAR', - 52: 'TUG', - 53: 'PORT TENDER', - 54: 'ANTI-POLLUTION', - 55: 'LAW ENFORCE', - 58: 'MEDICAL', -}; -const NUMERIC_TYPE_FAMILIES = { - 4: 'HIGH-SPEED', - 6: 'PASSENGER', - 7: 'CARGO', - 8: 'TANKER', - 9: 'OTHER', -}; - // Card text for the type tokens above. The tokens stay English: TYPE_STYLES // and tests match on them. const TYPE_DISPLAY_KEYS = Object.freeze({ @@ -104,21 +82,6 @@ export function displayVesselType(type) { return key ? t(key) : normalized; } -/** - * Resolve an AIS type to display text: bare numeric ship-type codes map to - * family names ("71" → "CARGO"); text types pass through unchanged. - * @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; - const code = Number(text); - if (code <= 0) return ''; - if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code]; - return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER'; -} - /** AIS ship type → CSS hex hue for the billboard chevron. */ export function vesselTypeCss(type) { return styleForType(type).css; diff --git a/src/data/vesselTypes.js b/src/data/vesselTypes.js new file mode 100644 index 000000000..12886795c --- /dev/null +++ b/src/data/vesselTypes.js @@ -0,0 +1,45 @@ +/** + * @module vesselTypes + * @description AIS ship-type codes (ITU-R M.1371) to family names, with no + * browser or i18n dependencies so the server-side query tools can use it. + */ + +const NUMERIC_TYPE_SPECIALS = { + 30: 'FISHING', + 31: 'TOWING', + 32: 'TOWING', + 33: 'DREDGER', + 34: 'DIVE OPS', + 35: 'MILITARY', + 36: 'SAILING', + 37: 'PLEASURE', + 50: 'PILOT', + 51: 'SAR', + 52: 'TUG', + 53: 'PORT TENDER', + 54: 'ANTI-POLLUTION', + 55: 'LAW ENFORCE', + 58: 'MEDICAL', +}; +const NUMERIC_TYPE_FAMILIES = { + 4: 'HIGH-SPEED', + 6: 'PASSENGER', + 7: 'CARGO', + 8: 'TANKER', + 9: 'OTHER', +}; + +/** + * Resolve an AIS type to display text: bare numeric ship-type codes map to + * family names ("71" → "CARGO"); text types pass through unchanged. + * @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; + const code = Number(text); + if (code <= 0) 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/maritime.js b/src/tools/queries/maritime.js index b5bdc4afe..5798a0936 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -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); diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index f1d09bccb..dfa1fd761 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -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, From 4c383214f7ffd678da0749084d0f32af4dc30a94 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 14:29:11 +0100 Subject: [PATCH 10/10] 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,