mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
591f299d11
commit
2110d06e36
10 files changed
+299
-5
No files matched your search
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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'), '');
|
||||
});
|
||||
Reference in new issue
Block a user