mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
14 files changed
+652
-48
No files matched your search
@@ -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).
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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), '');
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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'), '');
|
||||
});
|
||||
Reference in new issue
Block a user