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 <[email protected]>
This commit is contained in:
quanticsoul4772andClaude Opus 5.5 committed 2026-10-10 20:32:45 -07:00
1 parent 591f299d11
commit 2110d06e36
10 files changed
+299 -5

No files matched your search

+5
View File
@@ -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.
+5 -1
View File
@@ -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).
+2 -2
View File
@@ -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 |
+1
View File
@@ -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",
+36 -1
View File
@@ -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),
},
};
+57
View File
@@ -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/,
);
});
+21 -1
View File
@@ -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),
},
};
+25
View File
@@ -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 },
});
});
+69
View File
@@ -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;
+78
View File
@@ -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'), '');
});