From aec4f67ca97ae6667a1055f7f5521fa15692590b Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:45:25 -0700 Subject: [PATCH] feat(tools): count aircraft in an area by airline OpenSky, the default civil feed, reports no operator, so group_by: operator said "no operator reported". aircraft_in_area now also takes group_by: 'airline', read from the ICAO designator in each callsign: three letters followed by a flight number that starts with a digit (SWA1234, UAL12A). Registrations used as callsigns (N123AB) count as unknown. Rows are unchanged; the designator is derived for the breakdown only. Live, Texas: "785 aircraft ...; most by airline: AAL (89), SWA (65), UAL (52); 283 unknown." Co-Authored-By: Claude Opus 5.5 --- changelog.d/1056-area-tool-breakdowns.md | 10 +++--- docs/CURRENT-STATE.md | 9 +++--- src/tools/queries/aviation.js | 25 +++++++++++++-- src/tools/queries/aviation.test.mjs | 41 ++++++++++++++++++++++++ src/tools/results.js | 8 +++-- 5 files changed, 80 insertions(+), 13 deletions(-) diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md index 75f713907..67ec8d766 100644 --- a/changelog.d/1056-area-tool-breakdowns.md +++ b/changelog.d/1056-area-tool-breakdowns.md @@ -1,6 +1,8 @@ - `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an - MCP client can ask which countries, aircraft types or ship types dominate an - area, or the average altitude or speed, over every match instead of - estimating from the returned rows. Operator and type are counted only where - the feed reports them; AIS ship type 0 ("not available") counts as unknown + MCP client can ask which airlines, countries, aircraft types or ship types + dominate an area, or the average altitude or speed, over every match instead + of estimating from the returned rows. Airlines come from the ICAO designator + in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds + that report no operator; operator and type are counted only where the feed + reports them; AIS ship type 0 ("not available") counts as unknown (quanticsoul4772, #1056). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9bc129b8f..9f86c1219 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -98,10 +98,11 @@ combined situation brief, military awareness around a point, the app's heads-up opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also -count every match by a field (`group_by`: operator, country or type for -aircraft; type or destination for ships; the ten largest groups, then other and -unknown counts; the summary says when a feed reports none of the field) and -summarize airborne altitude and speed (`stats`: count, min, median, mean, max). +count every match by a field (`group_by`: airline, read from the ICAO +designator in the callsign, or operator, country or type for aircraft; type or +destination for ships; the ten largest groups, then other and unknown counts; +the summary says when a feed reports none of the field) and summarize airborne +altitude and speed (`stats`: count, min, median, mean, max). Both cover all matches, not just the rows returned; voice leaves these two tools out, so they reach MCP clients only. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index d3d9bbeaa..6a4cbd54e 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -36,11 +36,29 @@ const MILITARY_SCHEMA = Object.freeze({ }); const MAX_TRACK_POINTS = 200; const GROUP_LABELS = Object.freeze({ + airline: 'airline', operator: 'operator', origin_country: 'country', type_code: 'type', }); +/** + * The ICAO airline designator a callsign flies under: three letters, then a + * flight number that starts with a digit (SWA1234, UAL12A, BAW1). Registrations + * used as callsigns (N123AB, GABCD) have no designator. + */ +export function airlineDesignator(callsign) { + const match = /^([A-Z]{3})\d[0-9A-Z]{0,4}$/.exec( + String(callsign ?? '') + .trim() + .toUpperCase(), + ); + return match ? match[1] : null; +} + +const groupValue = (field) => + field === 'airline' ? (row) => airlineDesignator(row.callsign) : undefined; + const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; @@ -91,7 +109,8 @@ export const aircraftInArea = defineTool({ description: 'Aircraft currently reported inside an area, nearest the center first. ' + 'Optionally military aircraft only, airborne only, or within an altitude band. ' + - 'group_by counts every matched aircraft by operator, country or type, and ' + + 'group_by counts every matched aircraft by airline (the ICAO designator ' + + 'in its callsign), operator, country or type, and ' + 'stats summarizes airborne altitude and speed; both cover all matches, not ' + 'just the rows returned.', inputSchema: { @@ -151,7 +170,9 @@ export const aircraftInArea = defineTool({ ...(regional ? [`regional feed: ${snapshot.coverage}`] : []), ...(snapshot.freshness === 'stale' ? ['data may be stale'] : []), ]; - const groups = args.group_by ? breakdown(rows, args.group_by) : null; + const groups = args.group_by + ? breakdown(rows, args.group_by, groupValue(args.group_by)) + : null; return { summary: `${rows.length} ${kind} in ${area.label}` + diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 14b2e2d38..721385ef7 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { composeCatalog, coreTools } from '../index.js'; +import { airlineDesignator } from './aviation.js'; import { LiveSourceError } from '../../sources/live/contract.js'; const record = (id, latitude, longitude, extra = {}) => ({ @@ -504,3 +505,43 @@ test('altitude bands require a reported altitude from normalized OpenSky records }); assert.equal(minimum.data.total, 3); }); + +test('an airline designator is read from an ICAO flight callsign only', () => { + for (const [callsign, designator] of [ + ['SWA1234', 'SWA'], + ['ual12a', 'UAL'], + [' BAW1 ', 'BAW'], + ['RCH123', 'RCH'], + ['N123AB', null], + ['GABCD', null], + ['SWA', null], + ['SWAX123', null], + ['', null], + [null, null], + ]) + assert.equal(airlineDesignator(callsign), designator, String(callsign)); +}); + +test('aircraft in an area can be counted by airline from their callsigns', async () => { + const fleet = [ + record('a1', 37.6, -122.3, { callsign: 'SWA100' }), + record('a2', 37.61, -122.3, { callsign: 'SWA2' }), + record('a3', 37.62, -122.3, { callsign: 'UAL9' }), + record('a4', 37.63, -122.3, { callsign: 'N123AB' }), + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(fleet) }, + }); + const result = await catalog.call('aircraft_in_area', { + area: bay, + group_by: 'airline', + }); + assert.equal( + result.summary, + '4 aircraft in 60 km around 37.600, -122.300; most by airline: SWA (2), UAL (1); 1 unknown.', + ); + assert.equal(result.data.breakdown.field, 'airline'); + // Rows are unchanged: the designator is derived, not added to each row. + assert.equal('airline' in result.data.rows[0], false); +}); diff --git a/src/tools/results.js b/src/tools/results.js index 952b2723e..247383757 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -28,13 +28,15 @@ export const MAX_GROUPS = 10; /** * Count rows by one text field over every row, not just those returned. * Values group case-insensitively and keep their first spelling; rows - * without a value are counted as unknown. + * without a value are counted as unknown. `valueOf` derives the value when + * it is not a row field as it stands. */ -export function breakdown(rows, field) { +export function breakdown(rows, field, valueOf = (row) => row[field]) { const groups = new Map(); let unknown = 0; for (const row of rows) { - const value = typeof row[field] === 'string' ? row[field].trim() : ''; + const raw = valueOf(row); + const value = typeof raw === 'string' ? raw.trim() : ''; if (!value) { unknown += 1; continue;