Merge pull request #1056 from quanticsoul4772/feat/tools-area-breakdowns

feat(tools): count and summarize every match in the area tools
This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-19 14:53:34 +01:00
commit beca41c676
14 files changed
+652 -48

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an
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. Ship types count by family, so AIS codes 70 to 79 are all
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).
+8 -1
View File
@@ -112,7 +112,14 @@ 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`: 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
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
Aircraft altitude-band queries require a reported barometric or geometric altitude
+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
@@ -1028,6 +1028,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",
+4
View File
@@ -1044,6 +1044,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",
@@ -1845,6 +1846,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",
@@ -2633,6 +2635,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",
@@ -3786,6 +3789,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",
+3 -40
View File
@@ -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;
+2
View File
@@ -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), '');
});
+47
View File
@@ -0,0 +1,47 @@
/**
* @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. 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+$/.test(text)) return text;
const code = Number(text);
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';
}
+60 -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,31 @@ 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({
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, 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 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;
}
const groupValue = (field) =>
field === 'airline' ? (row) => airlineDesignator(row.callsign) : undefined;
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
@@ -82,7 +110,11 @@ 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 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: {
type: 'object',
properties: {
@@ -91,6 +123,18 @@ 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. Feeds that do not ' +
'report the field count those aircraft as unknown.',
},
stats: {
type: 'boolean',
description:
'Summarize airborne altitude and speed over matched aircraft.',
},
limit: LIMIT_SCHEMA,
},
required: ['area'],
@@ -128,9 +172,14 @@ 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, groupValue(args.group_by))
: null;
const airborne = rows.filter((row) => !row.on_ground);
return {
summary:
`${rows.length} ${kind} in ${area.label}` +
(groups ? breakdownPhrase(groups, GROUP_LABELS[args.group_by]) : '') +
(notes.length ? ` (${notes.join('; ')}).` : '.'),
data: {
view: suggestView(services, {
@@ -138,6 +187,16 @@ export const aircraftInArea = defineTool({
layers: [args.military ? 'military' : 'flights'],
}),
...capRows(rows, args.limit),
...(groups ? { breakdown: groups } : {}),
...(args.stats
? {
// Parked aircraft would pull both figures toward zero.
stats: {
altitude_m: numericSummary(airborne, 'altitude_m'),
speed_mps: numericSummary(airborne, 'speed_mps'),
},
}
: {}),
...snapshotInfo(snapshot),
},
};
+135
View File
@@ -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 = {}) => ({
@@ -389,6 +390,96 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as
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,
speedMps: 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); 1 unknown.',
);
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,
});
// 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 },
});
});
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 () => {
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/,
);
});
test('altitude bands require a reported altitude from normalized OpenSky records', async () => {
const { normalizeOpenSkyAircraft } =
await import('../../sources/live/aircraft.js');
@@ -447,3 +538,47 @@ 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],
['SWA12345', null],
['RPC1234', null],
['CUT1240', null],
['CUB123', 'CUB'],
['', 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);
});
+38 -4
View File
@@ -12,11 +12,15 @@ import {
} from '../area.js';
import {
LIMIT_SCHEMA,
breakdown,
breakdownPhrase,
capRows,
countNoun,
isoTime,
numericSummary,
thinEvenly,
} from '../results.js';
import { normalizeVesselType } from '../../data/vesselTypes.js';
const MMSI_SCHEMA = Object.freeze({
type: 'string',
@@ -29,6 +33,18 @@ 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 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) {
const point = { lat: record.latitude, lon: record.longitude };
@@ -36,8 +52,8 @@ function vesselRow(record, from) {
mmsi: record.id,
name: text(record.name),
imo: text(record.imo),
type: text(record.type),
destination: text(record.destination),
type: vesselType(record.type),
destination: vesselDestination(record.destination),
lat: round(record.latitude, 5),
lon: round(record.longitude, 5),
speed_mps: round(record.speedMps, 1),
@@ -88,7 +104,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 +117,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'],
@@ -114,18 +141,25 @@ 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);
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),
},
};
+174
View File
@@ -224,3 +224,177 @@ 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('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('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('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,
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,
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); 1 unknown.',
);
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 },
});
});
+78
View File
@@ -22,6 +22,84 @@ 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. `valueOf` derives the value when
* it is not a row field as it stands.
*/
export function breakdown(rows, field, valueOf = (row) => row[field]) {
const groups = new Map();
let unknown = 0;
for (const row of rows) {
const raw = valueOf(row);
const value = typeof raw === 'string' ? raw.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); 4 unknown", or
* "; no operator reported" when no row carries the field.
*/
export function breakdownPhrase(result, label) {
if (!result.groups.length)
return result.unknown_count ? `; no ${label} reported` : '';
const top = result.groups
.slice(0, 3)
.map((group) => `${group.value} (${group.count})`);
const unknown = result.unknown_count
? `; ${result.unknown_count} unknown`
: '';
return `; most by ${label}: ${top.join(', ')}${unknown}`;
}
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) {
if (!Number.isFinite(ms)) return null;
+88
View File
@@ -0,0 +1,88 @@
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 = {
unknown_count: 0,
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({ ...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'), '');
});