mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat: add aviation and satellite tools
Add aircraft_in_area, find_aircraft, get_aircraft_track and get_aircraft_info over the live aircraft sources, with an optional military feed, and next_satellite_pass and satellites_overhead over the satellite catalog and the existing pass predictor. Source failures from the live contract now become tool errors with the matching code, so a rate limit reaches clients as retry_later with a wait time. Tool schemas may use pattern.
This commit is contained in:
1 parent
9895942633
commit
22c3fe40b0
16 files changed
+1111
-16
No files matched your search
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
- Add tools for language-model clients and a local MCP server. `npm run mcp`
|
- Add tools for language-model clients and a local MCP server. `npm run mcp`
|
||||||
serves earthquake, active-fire and recent-launch queries over stdio to
|
serves earthquake, active-fire, launch, aircraft and satellite queries over
|
||||||
clients such as Claude Code, reading from a running app. Tools are defined
|
stdio to clients such as Claude Code, reading from a running app. Tools are defined
|
||||||
once in `gods-eye-view/tools`, reuse the layers' source factories, and are
|
once in `gods-eye-view/tools`, reuse the layers' source factories, and are
|
||||||
exposed through the protocol adapter in `gods-eye-view/tools/mcp`.
|
exposed through the protocol adapter in `gods-eye-view/tools/mcp`.
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
|
and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
|
||||||
Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client,
|
Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client,
|
||||||
reading from a running app's `/api` routes (default `http://localhost:4173`).
|
reading from a running app's `/api` routes (default `http://localhost:4173`).
|
||||||
The first queries are `get_earthquakes`, `get_active_fires` and
|
Queries cover earthquakes, active fires, recent launches, aircraft (in an
|
||||||
`get_recent_launches`. Tools reuse the layers' portable source factories, take a
|
area, by identifier, tracks, and type and route lookups) and satellites (next
|
||||||
|
pass over a point, and those overhead now). Tools reuse the layers' portable source factories, take a
|
||||||
shared `area` argument (place name, bounding box, or point and radius) and cap
|
shared `area` argument (place name, bounding box, or point and radius) and cap
|
||||||
lists at 25 rows by default. The app does not import the tools, so its runtime
|
lists at 25 rows by default. The app does not import the tools, so its runtime
|
||||||
behavior is unchanged. See [tools and the MCP server](TOOLS.md).
|
behavior is unchanged. See [tools and the MCP server](TOOLS.md).
|
||||||
|
|||||||
@@ -87,3 +87,9 @@ public feeds the sources already use.
|
|||||||
| `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first |
|
| `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_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 |
|
| `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 |
|
||||||
|
| `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 |
|
||||||
|
| `next_satellite_pass` | `satellites` | Next pass over a point (default the ISS), with naked-eye visibility |
|
||||||
|
| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a point now, highest first |
|
||||||
@@ -869,5 +869,7 @@
|
|||||||
"src/tools/area.test.mjs",
|
"src/tools/area.test.mjs",
|
||||||
"src/tools/queries/queries.test.mjs",
|
"src/tools/queries/queries.test.mjs",
|
||||||
"src/tools/mcp/mcp.test.mjs",
|
"src/tools/mcp/mcp.test.mjs",
|
||||||
"src/tools/localServer.test.mjs"
|
"src/tools/localServer.test.mjs",
|
||||||
|
"src/tools/queries/aviation.test.mjs",
|
||||||
|
"src/tools/queries/space.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -3086,9 +3086,12 @@
|
|||||||
"src/tools/results.js",
|
"src/tools/results.js",
|
||||||
"src/tools/places.js",
|
"src/tools/places.js",
|
||||||
"src/tools/queries/hazards.js",
|
"src/tools/queries/hazards.js",
|
||||||
"src/tools/queries/space.js"
|
"src/tools/queries/space.js",
|
||||||
|
"src/tools/queries/aviation.js",
|
||||||
|
"src/sources/tle.js",
|
||||||
|
"src/data/satellitePass.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": ["satellite.js"]
|
||||||
},
|
},
|
||||||
"tools-mcp": {
|
"tools-mcp": {
|
||||||
"exports": ["./tools/mcp"],
|
"exports": ["./tools/mcp"],
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js';
|
import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js';
|
||||||
import { createFirmsSource } from '../../src/layers/firms/source.js';
|
import { createFirmsSource } from '../../src/layers/firms/source.js';
|
||||||
import { createLaunchSource } from '../../src/layers/launches/source.js';
|
import { createLaunchSource } from '../../src/layers/launches/source.js';
|
||||||
|
import { createSatelliteSource } from '../../src/layers/satellites/source.js';
|
||||||
|
import {
|
||||||
|
createAdsbLolSource,
|
||||||
|
createOpenSkySource,
|
||||||
|
} from '../../src/sources/live/standalone.js';
|
||||||
import { createGeocodePlaceService } from '../../src/tools/places.js';
|
import { createGeocodePlaceService } from '../../src/tools/places.js';
|
||||||
|
|
||||||
export const DEFAULT_API_BASE = 'http://localhost:4173';
|
export const DEFAULT_API_BASE = 'http://localhost:4173';
|
||||||
@@ -34,6 +39,9 @@ export function createLocalToolServices(options = {}) {
|
|||||||
earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
|
earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
|
||||||
fires: createFirmsSource({ fetchImpl }),
|
fires: createFirmsSource({ fetchImpl }),
|
||||||
launches: createLaunchSource({ fetchImpl }),
|
launches: createLaunchSource({ fetchImpl }),
|
||||||
|
aircraft: createOpenSkySource({ fetchImpl }),
|
||||||
|
military: createAdsbLolSource({ fetchImpl }),
|
||||||
|
satellites: createSatelliteSource({ fetchImpl }),
|
||||||
places: createGeocodePlaceService({ fetchImpl }),
|
places: createGeocodePlaceService({ fetchImpl }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -104,6 +104,20 @@ export function distanceKm(a, b) {
|
|||||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The center of a resolved area, handling boxes across the antimeridian. */
|
||||||
|
export function areaCenter(area) {
|
||||||
|
if (area.center) return { lat: area.center.lat, lon: area.center.lon };
|
||||||
|
const width =
|
||||||
|
area.west <= area.east
|
||||||
|
? area.east - area.west
|
||||||
|
: area.east + 360 - area.west;
|
||||||
|
const lon = area.west + width / 2;
|
||||||
|
return {
|
||||||
|
lat: (area.south + area.north) / 2,
|
||||||
|
lon: lon > 180 ? lon - 360 : lon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a point lies inside a resolved area, including radius areas. */
|
/** Whether a point lies inside a resolved area, including radius areas. */
|
||||||
export function areaContains(area, point) {
|
export function areaContains(area, point) {
|
||||||
if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon))
|
if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon))
|
||||||
|
|||||||
+30
-1
@@ -33,6 +33,30 @@ export class ToolError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live sources report failures with these codes (src/sources/live/contract.js).
|
||||||
|
const SOURCE_ERROR_CODES = {
|
||||||
|
limited: 'retry_later',
|
||||||
|
denied: 'unavailable',
|
||||||
|
unavailable: 'unavailable',
|
||||||
|
malformed: 'malformed',
|
||||||
|
unsupported: 'unsupported',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Translate a source failure into a tool error; other errors pass through. */
|
||||||
|
export function fromSourceError(error) {
|
||||||
|
const code =
|
||||||
|
error?.name === 'LiveSourceError' &&
|
||||||
|
Object.hasOwn(SOURCE_ERROR_CODES, error.code)
|
||||||
|
? SOURCE_ERROR_CODES[error.code]
|
||||||
|
: null;
|
||||||
|
if (!code) return error;
|
||||||
|
const retryAfterSeconds =
|
||||||
|
code === 'retry_later' && Number.isFinite(error.retryAfterMs)
|
||||||
|
? Math.ceil(error.retryAfterMs / 1000)
|
||||||
|
: null;
|
||||||
|
return new ToolError(code, error.message, { retryAfterSeconds });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and freeze a tool definition.
|
* Validate and freeze a tool definition.
|
||||||
*
|
*
|
||||||
@@ -114,7 +138,12 @@ export function composeCatalog({
|
|||||||
const problems = validateValue(tool.inputSchema, args);
|
const problems = validateValue(tool.inputSchema, args);
|
||||||
if (problems.length)
|
if (problems.length)
|
||||||
throw new ToolError('invalid_arguments', problems.join('; '));
|
throw new ToolError('invalid_arguments', problems.join('; '));
|
||||||
const result = await tool.run(args, { services, signal });
|
let result;
|
||||||
|
try {
|
||||||
|
result = await tool.run(args, { services, signal });
|
||||||
|
} catch (error) {
|
||||||
|
throw fromSourceError(error);
|
||||||
|
}
|
||||||
if (!result || typeof result.summary !== 'string' || !result.data)
|
if (!result || typeof result.summary !== 'string' || !result.data)
|
||||||
throw new TypeError(`${tool.name} returned no summary or data`);
|
throw new TypeError(`${tool.name} returned no summary or data`);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ test('definitions are validated, frozen and read-only by default', () => {
|
|||||||
echo('x', {
|
echo('x', {
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { a: { type: 'string', pattern: '.' } },
|
properties: { a: { type: 'string', format: 'email' } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
/unsupported keyword pattern/,
|
/unsupported keyword format/,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => echo('x', { inputSchema: { type: 'string' } }),
|
() => echo('x', { inputSchema: { type: 'string' } }),
|
||||||
@@ -129,6 +129,45 @@ test('tool errors only use known codes', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('only live source errors are translated', async () => {
|
||||||
|
const { fromSourceError } = await import('./catalog.js');
|
||||||
|
const plain = new Error('x');
|
||||||
|
assert.equal(fromSourceError(plain), plain);
|
||||||
|
const denied = Object.assign(new Error('refused'), {
|
||||||
|
name: 'LiveSourceError',
|
||||||
|
code: 'denied',
|
||||||
|
});
|
||||||
|
const translated = fromSourceError(denied);
|
||||||
|
assert.equal(translated.code, 'unavailable');
|
||||||
|
assert.equal(translated.retryAfterSeconds, null);
|
||||||
|
const odd = Object.assign(new Error('?'), {
|
||||||
|
name: 'LiveSourceError',
|
||||||
|
code: 'toString',
|
||||||
|
});
|
||||||
|
assert.equal(fromSourceError(odd), odd);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('patterns are validated and must compile', () => {
|
||||||
|
const schema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: { id: { type: 'string', pattern: '^[a-f]{2}$' } },
|
||||||
|
};
|
||||||
|
assert.deepEqual(validateValue(schema, { id: 'ab' }), []);
|
||||||
|
assert.deepEqual(validateValue(schema, { id: 'xy' }), [
|
||||||
|
'arguments.id must match ^[a-f]{2}$',
|
||||||
|
]);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
echo('x', {
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { id: { type: 'string', pattern: '(' } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
SyntaxError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('the validator checks every supported keyword', () => {
|
test('the validator checks every supported keyword', () => {
|
||||||
const schema = {
|
const schema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
|||||||
+24
-2
@@ -3,8 +3,18 @@
|
|||||||
* surface that exposes them. See docs/TOOLS.md.
|
* surface that exposes them. See docs/TOOLS.md.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
aircraftInArea,
|
||||||
|
findAircraft,
|
||||||
|
getAircraftInfo,
|
||||||
|
getAircraftTrack,
|
||||||
|
} from './queries/aviation.js';
|
||||||
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
|
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
|
||||||
import { getRecentLaunches } from './queries/space.js';
|
import {
|
||||||
|
getRecentLaunches,
|
||||||
|
nextSatellitePass,
|
||||||
|
satellitesOverhead,
|
||||||
|
} from './queries/space.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
defineTool,
|
defineTool,
|
||||||
@@ -12,7 +22,13 @@ export {
|
|||||||
ToolError,
|
ToolError,
|
||||||
TOOL_ERROR_CODES,
|
TOOL_ERROR_CODES,
|
||||||
} from './catalog.js';
|
} from './catalog.js';
|
||||||
export { AREA_SCHEMA, resolveArea, areaContains, distanceKm } from './area.js';
|
export {
|
||||||
|
AREA_SCHEMA,
|
||||||
|
resolveArea,
|
||||||
|
areaCenter,
|
||||||
|
areaContains,
|
||||||
|
distanceKm,
|
||||||
|
} from './area.js';
|
||||||
export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js';
|
export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js';
|
||||||
export { createGeocodePlaceService, placeFromGeocodeResult } from './places.js';
|
export { createGeocodePlaceService, placeFromGeocodeResult } from './places.js';
|
||||||
|
|
||||||
@@ -21,4 +37,10 @@ export const coreTools = Object.freeze([
|
|||||||
getEarthquakes,
|
getEarthquakes,
|
||||||
getActiveFires,
|
getActiveFires,
|
||||||
getRecentLaunches,
|
getRecentLaunches,
|
||||||
|
aircraftInArea,
|
||||||
|
findAircraft,
|
||||||
|
getAircraftTrack,
|
||||||
|
getAircraftInfo,
|
||||||
|
nextSatellitePass,
|
||||||
|
satellitesOverhead,
|
||||||
]);
|
]);
|
||||||
@@ -68,7 +68,17 @@ test('the stdio server answers newline-delimited requests using only its data so
|
|||||||
assert.equal(byId.get(null).error.code, -32700);
|
assert.equal(byId.get(null).error.code, -32700);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
byId.get(2).result.tools.map((tool) => tool.name),
|
byId.get(2).result.tools.map((tool) => tool.name),
|
||||||
['get_earthquakes', 'get_active_fires', 'get_recent_launches'],
|
[
|
||||||
|
'get_earthquakes',
|
||||||
|
'get_active_fires',
|
||||||
|
'get_recent_launches',
|
||||||
|
'aircraft_in_area',
|
||||||
|
'find_aircraft',
|
||||||
|
'get_aircraft_track',
|
||||||
|
'get_aircraft_info',
|
||||||
|
'next_satellite_pass',
|
||||||
|
'satellites_overhead',
|
||||||
|
],
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
byId.get(3).result.content[0].text,
|
byId.get(3).result.content[0].text,
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
/** Aviation queries over the live aircraft sources (src/sources/live). */
|
||||||
|
|
||||||
|
import { defineTool, ToolError } from '../catalog.js';
|
||||||
|
import {
|
||||||
|
AREA_SCHEMA,
|
||||||
|
areaCenter,
|
||||||
|
areaContains,
|
||||||
|
distanceKm,
|
||||||
|
resolveArea,
|
||||||
|
} from '../area.js';
|
||||||
|
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||||
|
|
||||||
|
const ICAO24_SCHEMA = Object.freeze({
|
||||||
|
type: 'string',
|
||||||
|
pattern: '^[0-9a-fA-F]{6}$',
|
||||||
|
description: 'ICAO 24-bit address as six hex characters, such as "a1b2c3".',
|
||||||
|
});
|
||||||
|
const CALLSIGN_SCHEMA = Object.freeze({
|
||||||
|
type: 'string',
|
||||||
|
pattern: '^[A-Za-z0-9]{2,8}$',
|
||||||
|
description: 'Flight callsign, such as "UAL123".',
|
||||||
|
});
|
||||||
|
const MILITARY_SCHEMA = Object.freeze({
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Use the military aircraft feed instead of the general feed.',
|
||||||
|
});
|
||||||
|
const MAX_TRACK_POINTS = 200;
|
||||||
|
|
||||||
|
const round = (value, digits) =>
|
||||||
|
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||||
|
|
||||||
|
/** The common row for an aircraft record from any live aircraft source. */
|
||||||
|
function aircraftRow(record, from) {
|
||||||
|
const point = { lat: record.latitude, lon: record.longitude };
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
callsign: record.callsign || null,
|
||||||
|
lat: round(record.latitude, 5),
|
||||||
|
lon: round(record.longitude, 5),
|
||||||
|
altitude_m: round(record.baroAltitudeM ?? record.ellipsoidAltitudeM, 0),
|
||||||
|
on_ground: record.onGround === true,
|
||||||
|
speed_mps: round(record.speedMps, 1),
|
||||||
|
course_deg: round(record.courseDeg, 0),
|
||||||
|
vertical_rate_mps: round(record.verticalRateMps, 1),
|
||||||
|
type_code: record.typeCode || null,
|
||||||
|
registration: record.registration || null,
|
||||||
|
operator: record.operator || null,
|
||||||
|
origin_country: record.originCountry || null,
|
||||||
|
last_contact: isoTime(record.contactTimeMs ?? record.positionTimeMs),
|
||||||
|
...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotInfo(snapshot) {
|
||||||
|
return {
|
||||||
|
source: snapshot.source ?? null,
|
||||||
|
coverage: snapshot.coverage ?? null,
|
||||||
|
observed_at: isoTime(snapshot.observedAtMs),
|
||||||
|
freshness: snapshot.freshness ?? 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedFor(services, military) {
|
||||||
|
if (!military) return services.aircraft;
|
||||||
|
if (!services.military)
|
||||||
|
throw new ToolError(
|
||||||
|
'unsupported',
|
||||||
|
'The military aircraft feed is not available here',
|
||||||
|
);
|
||||||
|
return services.military;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aircraftInArea = defineTool({
|
||||||
|
name: 'aircraft_in_area',
|
||||||
|
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.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
area: AREA_SCHEMA,
|
||||||
|
military: MILITARY_SCHEMA,
|
||||||
|
airborne_only: { type: 'boolean' },
|
||||||
|
min_altitude_m: { type: 'number', minimum: -500, maximum: 30000 },
|
||||||
|
max_altitude_m: { type: 'number', minimum: -500, maximum: 30000 },
|
||||||
|
limit: LIMIT_SCHEMA,
|
||||||
|
},
|
||||||
|
required: ['area'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['aircraft'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const area = await resolveArea(args.area, { services, signal });
|
||||||
|
const center = areaCenter(area);
|
||||||
|
const snapshot = await feedFor(services, args.military).getSnapshot(
|
||||||
|
{ latitude: center.lat, longitude: center.lon },
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
const altitude = (record) =>
|
||||||
|
record.baroAltitudeM ?? record.ellipsoidAltitudeM;
|
||||||
|
const rows = snapshot.records
|
||||||
|
.filter(
|
||||||
|
(record) =>
|
||||||
|
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
||||||
|
!(args.airborne_only && record.onGround) &&
|
||||||
|
(args.min_altitude_m == null ||
|
||||||
|
altitude(record) >= args.min_altitude_m) &&
|
||||||
|
(args.max_altitude_m == null ||
|
||||||
|
altitude(record) <= args.max_altitude_m),
|
||||||
|
)
|
||||||
|
.map((record) => aircraftRow(record, center))
|
||||||
|
.sort((a, b) => a.distance_km - b.distance_km);
|
||||||
|
const kind = args.military ? 'military aircraft' : 'aircraft';
|
||||||
|
return {
|
||||||
|
summary:
|
||||||
|
`${rows.length} ${kind} in ${area.label}` +
|
||||||
|
(snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'),
|
||||||
|
data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const findAircraft = defineTool({
|
||||||
|
name: 'find_aircraft',
|
||||||
|
title: 'Find an aircraft',
|
||||||
|
description:
|
||||||
|
'Find aircraft currently reported anywhere by callsign, ICAO 24-bit ' +
|
||||||
|
'address or registration. Registrations are known only for aircraft in ' +
|
||||||
|
'the military feed.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
callsign: CALLSIGN_SCHEMA,
|
||||||
|
icao24: ICAO24_SCHEMA,
|
||||||
|
registration: { type: 'string', minLength: 2, maxLength: 12 },
|
||||||
|
limit: LIMIT_SCHEMA,
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['aircraft'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const given = ['callsign', 'icao24', 'registration'].filter(
|
||||||
|
(key) => args[key] != null,
|
||||||
|
);
|
||||||
|
if (given.length !== 1)
|
||||||
|
throw new ToolError(
|
||||||
|
'invalid_arguments',
|
||||||
|
'Give exactly one of callsign, icao24 or registration',
|
||||||
|
);
|
||||||
|
const [key] = given;
|
||||||
|
const wanted = args[key].trim().toUpperCase();
|
||||||
|
const field = {
|
||||||
|
callsign: 'callsign',
|
||||||
|
icao24: 'id',
|
||||||
|
registration: 'registration',
|
||||||
|
}[key];
|
||||||
|
const feeds = [services.aircraft, services.military].filter(Boolean);
|
||||||
|
const snapshots = await Promise.all(
|
||||||
|
feeds.map((feed) => feed.getSnapshot({}, { signal })),
|
||||||
|
);
|
||||||
|
const seen = new Set();
|
||||||
|
const rows = snapshots
|
||||||
|
.flatMap((snapshot) => snapshot.records)
|
||||||
|
.filter(
|
||||||
|
(record) =>
|
||||||
|
String(record[field] || '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase() === wanted,
|
||||||
|
)
|
||||||
|
.filter((record) => !seen.has(record.id) && seen.add(record.id))
|
||||||
|
.map((record) => aircraftRow(record));
|
||||||
|
return {
|
||||||
|
summary: rows.length
|
||||||
|
? `Found ${countNoun(rows.length, 'aircraft', 'aircraft')} with ${key} ${wanted}.`
|
||||||
|
: `No aircraft with ${key} ${wanted} is currently reported.`,
|
||||||
|
data: capRows(rows, args.limit),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getAircraftTrack = defineTool({
|
||||||
|
name: 'get_aircraft_track',
|
||||||
|
title: 'Aircraft track',
|
||||||
|
description:
|
||||||
|
'Recent positions of one aircraft by ICAO 24-bit address, oldest first. ' +
|
||||||
|
`Long tracks are evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { icao24: ICAO24_SCHEMA, military: MILITARY_SCHEMA },
|
||||||
|
required: ['icao24'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['aircraft'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const icao24 = args.icao24.toLowerCase();
|
||||||
|
const track = await feedFor(services, args.military).getTrack(icao24, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const points = [...track.records].sort(
|
||||||
|
(a, b) => a.observedAtMs - b.observedAtMs,
|
||||||
|
);
|
||||||
|
const kept = thin(points, MAX_TRACK_POINTS);
|
||||||
|
const first = points[0];
|
||||||
|
const last = points.at(-1);
|
||||||
|
return {
|
||||||
|
summary: points.length
|
||||||
|
? `${countNoun(points.length, 'position')} for ${icao24} from ${isoTime(first.observedAtMs)} to ${isoTime(last.observedAtMs)}.`
|
||||||
|
: `No recent track is available for ${icao24}.`,
|
||||||
|
data: {
|
||||||
|
icao24,
|
||||||
|
total: points.length,
|
||||||
|
returned: kept.length,
|
||||||
|
thinned: kept.length < points.length,
|
||||||
|
points: kept.map((point) => ({
|
||||||
|
time: isoTime(point.observedAtMs),
|
||||||
|
lat: round(point.latitude, 5),
|
||||||
|
lon: round(point.longitude, 5),
|
||||||
|
altitude_m: round(point.baroAltitudeM, 0),
|
||||||
|
on_ground: point.onGround === true,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Keep the first and last items and evenly spaced items between them. */
|
||||||
|
function thin(items, max) {
|
||||||
|
if (items.length <= max) return items;
|
||||||
|
const step = (items.length - 1) / (max - 1);
|
||||||
|
return Array.from(
|
||||||
|
{ length: max },
|
||||||
|
(_, index) => items[Math.round(index * step)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAircraftInfo = defineTool({
|
||||||
|
name: 'get_aircraft_info',
|
||||||
|
title: 'Aircraft type and route',
|
||||||
|
description:
|
||||||
|
'Look up an aircraft type and registration by ICAO 24-bit address, and ' +
|
||||||
|
'a flight route by callsign, from adsbdb. Give either or both.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { icao24: ICAO24_SCHEMA, callsign: CALLSIGN_SCHEMA },
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['aircraft'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
if (args.icao24 == null && args.callsign == null)
|
||||||
|
throw new ToolError('invalid_arguments', 'Give icao24, callsign or both');
|
||||||
|
const lookup = (kind, id) =>
|
||||||
|
id == null
|
||||||
|
? null
|
||||||
|
: services.aircraft.getEnrichment({ kind, id }, { signal });
|
||||||
|
const icao24 = args.icao24?.toLowerCase();
|
||||||
|
const callsign = args.callsign?.toUpperCase();
|
||||||
|
const [type, route] = await Promise.all([
|
||||||
|
lookup('type', icao24),
|
||||||
|
lookup('route', callsign),
|
||||||
|
]);
|
||||||
|
const aircraft = type?.found
|
||||||
|
? {
|
||||||
|
icao24,
|
||||||
|
type_code: type.typeCode ?? null,
|
||||||
|
type_name: type.typeName ?? null,
|
||||||
|
registration: type.registration ?? null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const airport = (value) =>
|
||||||
|
value
|
||||||
|
? {
|
||||||
|
code: value.code || null,
|
||||||
|
name: value.name || null,
|
||||||
|
lat: value.lat ?? null,
|
||||||
|
lon: value.lon ?? null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const flight = route?.found
|
||||||
|
? {
|
||||||
|
callsign,
|
||||||
|
airline: route.airline ?? null,
|
||||||
|
origin: airport(route.origin),
|
||||||
|
destination: airport(route.destination),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const parts = [];
|
||||||
|
if (icao24)
|
||||||
|
parts.push(
|
||||||
|
aircraft
|
||||||
|
? `${icao24} is ${aircraft.type_name || aircraft.type_code || 'an aircraft of unknown type'}${aircraft.registration ? ` (${aircraft.registration})` : ''}`
|
||||||
|
: `no type is known for ${icao24}`,
|
||||||
|
);
|
||||||
|
if (callsign)
|
||||||
|
parts.push(
|
||||||
|
flight
|
||||||
|
? `${callsign} flies ${flight.origin?.code || flight.origin?.name} to ${flight.destination?.code || flight.destination?.name}${flight.airline ? ` (${flight.airline})` : ''}`
|
||||||
|
: `no route is known for ${callsign}`,
|
||||||
|
);
|
||||||
|
const summary = parts.join('; ');
|
||||||
|
return {
|
||||||
|
summary: `${summary.charAt(0).toUpperCase()}${summary.slice(1)}.`,
|
||||||
|
data: { aircraft, route: flight },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { composeCatalog, coreTools } from '../index.js';
|
||||||
|
import { LiveSourceError } from '../../sources/live/contract.js';
|
||||||
|
|
||||||
|
const record = (id, latitude, longitude, extra = {}) => ({
|
||||||
|
id,
|
||||||
|
reference: id,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
callsign: null,
|
||||||
|
originCountry: null,
|
||||||
|
positionTimeMs: Date.UTC(2026, 0, 1),
|
||||||
|
contactTimeMs: Date.UTC(2026, 0, 1, 0, 0, 5),
|
||||||
|
baroAltitudeM: 10000,
|
||||||
|
ellipsoidAltitudeM: null,
|
||||||
|
onGround: false,
|
||||||
|
speedMps: 230.04,
|
||||||
|
courseDeg: 90.4,
|
||||||
|
verticalRateMps: 0,
|
||||||
|
category: null,
|
||||||
|
typeCode: null,
|
||||||
|
registration: null,
|
||||||
|
operator: null,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const snapshot = (records, extra = {}) => ({
|
||||||
|
records,
|
||||||
|
complete: true,
|
||||||
|
source: 'Test feed',
|
||||||
|
coverage: 'test coverage',
|
||||||
|
observedAtMs: Date.UTC(2026, 0, 1),
|
||||||
|
freshness: 'current',
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const feed = (records, { track = [], enrichment = {}, ...extra } = {}) => {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
async getSnapshot(query) {
|
||||||
|
calls.push(query);
|
||||||
|
return snapshot(records, extra);
|
||||||
|
},
|
||||||
|
async getTrack(reference) {
|
||||||
|
calls.push(reference);
|
||||||
|
return { records: track, complete: false };
|
||||||
|
},
|
||||||
|
async getEnrichment({ kind, id }) {
|
||||||
|
calls.push(`${kind}:${id}`);
|
||||||
|
return enrichment[`${kind}:${id}`] ?? { found: false };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const civil = [
|
||||||
|
record('aaa111', 37.7, -122.4, {
|
||||||
|
callsign: 'UAL123',
|
||||||
|
originCountry: 'United States',
|
||||||
|
}),
|
||||||
|
record('bbb222', 37.58, -122.28, { onGround: true, baroAltitudeM: 0 }),
|
||||||
|
record('ccc333', 51.5, -0.1, { callsign: 'BAW1' }),
|
||||||
|
];
|
||||||
|
const military = [
|
||||||
|
record('ae0001', 37.6, -122.3, {
|
||||||
|
callsign: 'RCH123',
|
||||||
|
registration: '07-7175',
|
||||||
|
typeCode: 'C17',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const bay = { lat: 37.6, lon: -122.3, radius_km: 60 };
|
||||||
|
|
||||||
|
test('aircraft in an area are filtered, nearest first, with source details', async () => {
|
||||||
|
const aircraft = feed(civil);
|
||||||
|
const catalog = composeCatalog({ tools: coreTools, services: { aircraft } });
|
||||||
|
const result = await catalog.call('aircraft_in_area', { area: bay });
|
||||||
|
assert.equal(result.summary, '2 aircraft in 60 km around 37.600, -122.300.');
|
||||||
|
assert.deepEqual(
|
||||||
|
result.data.rows.map((row) => row.id),
|
||||||
|
['bbb222', 'aaa111'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(aircraft.calls[0], { latitude: 37.6, longitude: -122.3 });
|
||||||
|
assert.deepEqual(result.data.rows[1], {
|
||||||
|
id: 'aaa111',
|
||||||
|
callsign: 'UAL123',
|
||||||
|
lat: 37.7,
|
||||||
|
lon: -122.4,
|
||||||
|
altitude_m: 10000,
|
||||||
|
on_ground: false,
|
||||||
|
speed_mps: 230,
|
||||||
|
course_deg: 90,
|
||||||
|
vertical_rate_mps: 0,
|
||||||
|
type_code: null,
|
||||||
|
registration: null,
|
||||||
|
operator: null,
|
||||||
|
origin_country: 'United States',
|
||||||
|
last_contact: '2026-01-01T00:00:05.000Z',
|
||||||
|
distance_km: 14.2,
|
||||||
|
});
|
||||||
|
assert.equal(result.data.source, 'Test feed');
|
||||||
|
assert.equal(result.data.observed_at, '2026-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
const airborne = await catalog.call('aircraft_in_area', {
|
||||||
|
area: bay,
|
||||||
|
airborne_only: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
airborne.data.rows.map((row) => row.id),
|
||||||
|
['aaa111'],
|
||||||
|
);
|
||||||
|
const low = await catalog.call('aircraft_in_area', {
|
||||||
|
area: bay,
|
||||||
|
max_altitude_m: 100,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
low.data.rows.map((row) => row.id),
|
||||||
|
['bbb222'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the military feed is used on request and reported when missing', async () => {
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: {
|
||||||
|
aircraft: feed(civil),
|
||||||
|
military: feed(military, { freshness: 'stale' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await catalog.call('aircraft_in_area', {
|
||||||
|
area: bay,
|
||||||
|
military: true,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
result.summary,
|
||||||
|
'1 military aircraft in 60 km around 37.600, -122.300 (data may be stale).',
|
||||||
|
);
|
||||||
|
assert.equal(result.data.rows[0].registration, '07-7175');
|
||||||
|
const civilOnly = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { aircraft: feed(civil) },
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
civilOnly.call('aircraft_in_area', { area: bay, military: true }),
|
||||||
|
(error) => error.code === 'unsupported',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aircraft are found by exactly one identifier across feeds', async () => {
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { aircraft: feed(civil), military: feed(military) },
|
||||||
|
});
|
||||||
|
const byCallsign = await catalog.call('find_aircraft', {
|
||||||
|
callsign: 'ual123',
|
||||||
|
});
|
||||||
|
assert.equal(byCallsign.summary, 'Found 1 aircraft with callsign UAL123.');
|
||||||
|
assert.deepEqual(
|
||||||
|
byCallsign.data.rows.map((row) => row.id),
|
||||||
|
['aaa111'],
|
||||||
|
);
|
||||||
|
assert.equal(byCallsign.data.rows[0].distance_km, undefined);
|
||||||
|
const byRegistration = await catalog.call('find_aircraft', {
|
||||||
|
registration: '07-7175',
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
byRegistration.data.rows.map((row) => row.id),
|
||||||
|
['ae0001'],
|
||||||
|
);
|
||||||
|
const none = await catalog.call('find_aircraft', { icao24: 'ABCDEF' });
|
||||||
|
assert.equal(
|
||||||
|
none.summary,
|
||||||
|
'No aircraft with icao24 ABCDEF is currently reported.',
|
||||||
|
);
|
||||||
|
await assert.rejects(catalog.call('find_aircraft', {}), /exactly one/);
|
||||||
|
await assert.rejects(
|
||||||
|
catalog.call('find_aircraft', { callsign: 'UAL123', icao24: 'aaa111' }),
|
||||||
|
/exactly one/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
catalog.call('find_aircraft', { icao24: 'xyz' }),
|
||||||
|
/must match/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracks are ordered and thinned to 200 points', async () => {
|
||||||
|
const start = Date.UTC(2026, 0, 1);
|
||||||
|
const track = Array.from({ length: 450 }, (_, index) => ({
|
||||||
|
latitude: 37 + index / 1000,
|
||||||
|
longitude: -122,
|
||||||
|
observedAtMs: start + (449 - index) * 1000,
|
||||||
|
baroAltitudeM: 1000,
|
||||||
|
onGround: false,
|
||||||
|
}));
|
||||||
|
const aircraft = feed(civil, { track });
|
||||||
|
const catalog = composeCatalog({ tools: coreTools, services: { aircraft } });
|
||||||
|
const result = await catalog.call('get_aircraft_track', { icao24: 'AAA111' });
|
||||||
|
assert.equal(aircraft.calls[0], 'aaa111');
|
||||||
|
assert.equal(
|
||||||
|
result.summary,
|
||||||
|
'450 positions for aaa111 from 2026-01-01T00:00:00.000Z to 2026-01-01T00:07:29.000Z.',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
total: result.data.total,
|
||||||
|
returned: result.data.returned,
|
||||||
|
thinned: result.data.thinned,
|
||||||
|
},
|
||||||
|
{ total: 450, returned: 200, thinned: true },
|
||||||
|
);
|
||||||
|
assert.equal(result.data.points[0].time, '2026-01-01T00:00:00.000Z');
|
||||||
|
assert.equal(result.data.points.at(-1).time, '2026-01-01T00:07:29.000Z');
|
||||||
|
const empty = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { aircraft: feed(civil) },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(await empty.call('get_aircraft_track', { icao24: 'aaa111' })).summary,
|
||||||
|
'No recent track is available for aaa111.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aircraft info combines type and route lookups', async () => {
|
||||||
|
const aircraft = feed(civil, {
|
||||||
|
enrichment: {
|
||||||
|
'type:aaa111': {
|
||||||
|
found: true,
|
||||||
|
typeCode: 'B738',
|
||||||
|
typeName: 'Boeing 737-800',
|
||||||
|
registration: 'N12345',
|
||||||
|
},
|
||||||
|
'route:UAL123': {
|
||||||
|
found: true,
|
||||||
|
airline: 'United Airlines',
|
||||||
|
origin: { code: 'SFO', name: 'San Francisco', lat: 37.6, lon: -122.4 },
|
||||||
|
destination: { code: 'EWR', name: 'Newark', lat: 40.7, lon: -74.2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const catalog = composeCatalog({ tools: coreTools, services: { aircraft } });
|
||||||
|
const both = await catalog.call('get_aircraft_info', {
|
||||||
|
icao24: 'AAA111',
|
||||||
|
callsign: 'ual123',
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
both.summary,
|
||||||
|
'Aaa111 is Boeing 737-800 (N12345); UAL123 flies SFO to EWR (United Airlines).',
|
||||||
|
);
|
||||||
|
assert.equal(both.data.route.destination.name, 'Newark');
|
||||||
|
const unknown = await catalog.call('get_aircraft_info', { callsign: 'XYZ9' });
|
||||||
|
assert.equal(unknown.summary, 'No route is known for XYZ9.');
|
||||||
|
assert.deepEqual(unknown.data, { aircraft: null, route: null });
|
||||||
|
await assert.rejects(
|
||||||
|
catalog.call('get_aircraft_info', {}),
|
||||||
|
/icao24, callsign or both/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live source failures become tool errors with retry guidance', async () => {
|
||||||
|
const limited = {
|
||||||
|
getSnapshot: async () => {
|
||||||
|
throw new LiveSourceError('limited', 'OpenSky rate limited', {
|
||||||
|
retryAfterMs: 45000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { aircraft: limited },
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
catalog.call('aircraft_in_area', { area: bay }),
|
||||||
|
(error) =>
|
||||||
|
error.name === 'ToolError' &&
|
||||||
|
error.code === 'retry_later' &&
|
||||||
|
error.retryAfterSeconds === 45 &&
|
||||||
|
error.message === 'OpenSky rate limited',
|
||||||
|
);
|
||||||
|
});
|
||||||
+220
-2
@@ -1,7 +1,13 @@
|
|||||||
/** Space queries: launches. */
|
/** Space queries: launches, satellite passes and satellites overhead. */
|
||||||
|
|
||||||
|
import { twoline2satrec } from 'satellite.js';
|
||||||
|
import {
|
||||||
|
findNextSatellitePass,
|
||||||
|
lookAnglesAt,
|
||||||
|
} from '../../data/satellitePass.js';
|
||||||
|
import { parseTleText, tleCatalogNumber } from '../../sources/tle.js';
|
||||||
import { defineTool, ToolError } from '../catalog.js';
|
import { defineTool, ToolError } from '../catalog.js';
|
||||||
import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js';
|
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||||
|
|
||||||
const text = (value) => (typeof value === 'string' && value ? value : null);
|
const text = (value) => (typeof value === 'string' && value ? value : null);
|
||||||
|
|
||||||
@@ -47,3 +53,215 @@ export const getRecentLaunches = defineTool({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Groups served by the satellite catalog route; `stations` includes the ISS.
|
||||||
|
const GROUPS = [
|
||||||
|
'stations',
|
||||||
|
'visual',
|
||||||
|
'gps-ops',
|
||||||
|
'glo-ops',
|
||||||
|
'galileo',
|
||||||
|
'geo',
|
||||||
|
'starlink',
|
||||||
|
];
|
||||||
|
const ISS_NORAD = 25544;
|
||||||
|
const COMPASS = [
|
||||||
|
'N',
|
||||||
|
'NNE',
|
||||||
|
'NE',
|
||||||
|
'ENE',
|
||||||
|
'E',
|
||||||
|
'ESE',
|
||||||
|
'SE',
|
||||||
|
'SSE',
|
||||||
|
'S',
|
||||||
|
'SSW',
|
||||||
|
'SW',
|
||||||
|
'WSW',
|
||||||
|
'W',
|
||||||
|
'WNW',
|
||||||
|
'NW',
|
||||||
|
'NNW',
|
||||||
|
];
|
||||||
|
const OBSERVER = {
|
||||||
|
lat: {
|
||||||
|
type: 'number',
|
||||||
|
minimum: -90,
|
||||||
|
maximum: 90,
|
||||||
|
description: 'Observer latitude.',
|
||||||
|
},
|
||||||
|
lon: {
|
||||||
|
type: 'number',
|
||||||
|
minimum: -180,
|
||||||
|
maximum: 180,
|
||||||
|
description: 'Observer longitude.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const GROUP_SCHEMA = {
|
||||||
|
type: 'string',
|
||||||
|
enum: GROUPS,
|
||||||
|
description: 'CelesTrak group to search (default "stations").',
|
||||||
|
};
|
||||||
|
|
||||||
|
const compass = (degrees) =>
|
||||||
|
COMPASS[Math.round((((degrees % 360) + 360) % 360) / 22.5) % 16];
|
||||||
|
const round = (value, digits) =>
|
||||||
|
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||||
|
const nowMs = (services) => services.clock?.now() ?? Date.now();
|
||||||
|
|
||||||
|
async function readCatalog(services, group, signal) {
|
||||||
|
const result = await services.satellites.readGroup(group, { signal });
|
||||||
|
if (!result.ok)
|
||||||
|
throw new ToolError(
|
||||||
|
'unavailable',
|
||||||
|
`The ${group} satellite catalog is unavailable (HTTP ${result.status})`,
|
||||||
|
);
|
||||||
|
return parseTleText(result.text).flatMap((entry) => {
|
||||||
|
try {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...entry,
|
||||||
|
norad: tleCatalogNumber(entry.line1),
|
||||||
|
satrec: twoline2satrec(entry.line1, entry.line2),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchSatellite(entries, wanted) {
|
||||||
|
if (wanted == null)
|
||||||
|
return entries.find((entry) => entry.norad === ISS_NORAD) ?? null;
|
||||||
|
const text = String(wanted).trim().toUpperCase();
|
||||||
|
const number = /^\d+$/.test(text) ? Number(text) : null;
|
||||||
|
return (
|
||||||
|
entries.find((entry) => number != null && entry.norad === number) ??
|
||||||
|
entries.find((entry) => entry.name.toUpperCase() === text) ??
|
||||||
|
entries.find((entry) => entry.name.toUpperCase().includes(text)) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nextSatellitePass = defineTool({
|
||||||
|
name: 'next_satellite_pass',
|
||||||
|
title: 'Next satellite pass',
|
||||||
|
description:
|
||||||
|
'When a satellite next rises over an observer, with rise, peak and set ' +
|
||||||
|
'times, peak elevation, rise direction and naked-eye visibility. Defaults ' +
|
||||||
|
'to the International Space Station.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
...OBSERVER,
|
||||||
|
satellite: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 80,
|
||||||
|
description:
|
||||||
|
'Satellite name or NORAD catalog number (default the ISS).',
|
||||||
|
},
|
||||||
|
group: GROUP_SCHEMA,
|
||||||
|
min_elevation_deg: { type: 'number', minimum: 0, maximum: 80 },
|
||||||
|
visible_only: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Only passes visible to the naked eye.',
|
||||||
|
},
|
||||||
|
hours: { type: 'number', minimum: 1, maximum: 72 },
|
||||||
|
},
|
||||||
|
required: ['lat', 'lon'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['satellites'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const group = args.group ?? 'stations';
|
||||||
|
const entries = await readCatalog(services, group, signal);
|
||||||
|
const satellite = matchSatellite(entries, args.satellite);
|
||||||
|
if (!satellite)
|
||||||
|
throw new ToolError(
|
||||||
|
'invalid_arguments',
|
||||||
|
`No satellite matching "${args.satellite ?? 'ISS'}" is in the ${group} group`,
|
||||||
|
);
|
||||||
|
const hours = args.hours ?? 24;
|
||||||
|
const pass = findNextSatellitePass({
|
||||||
|
satrec: satellite.satrec,
|
||||||
|
latDeg: args.lat,
|
||||||
|
lonDeg: args.lon,
|
||||||
|
fromMs: nowMs(services),
|
||||||
|
minElevDeg: args.min_elevation_deg ?? 10,
|
||||||
|
horizonHours: hours,
|
||||||
|
requireVisible: args.visible_only === true,
|
||||||
|
});
|
||||||
|
const name = satellite.name;
|
||||||
|
if (!pass)
|
||||||
|
return {
|
||||||
|
summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over this location in the next ${hours} hours.`,
|
||||||
|
data: { satellite: name, norad: satellite.norad, pass: null },
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
summary:
|
||||||
|
`${name} next rises ${isoTime(pass.riseMs)} in the ${compass(pass.riseAzDeg)}, ` +
|
||||||
|
`peaking at ${Math.round(pass.maxElevDeg)}°` +
|
||||||
|
`${pass.visible ? '; visible to the naked eye' : ''}.`,
|
||||||
|
data: {
|
||||||
|
satellite: name,
|
||||||
|
norad: satellite.norad,
|
||||||
|
pass: {
|
||||||
|
rise: isoTime(pass.riseMs),
|
||||||
|
peak: isoTime(pass.maxElevMs),
|
||||||
|
set: isoTime(pass.setMs),
|
||||||
|
max_elevation_deg: round(pass.maxElevDeg, 1),
|
||||||
|
rise_azimuth_deg: round(pass.riseAzDeg, 0),
|
||||||
|
rise_direction: compass(pass.riseAzDeg),
|
||||||
|
visible: pass.visible,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const satellitesOverhead = defineTool({
|
||||||
|
name: 'satellites_overhead',
|
||||||
|
title: 'Satellites overhead',
|
||||||
|
description:
|
||||||
|
'Satellites in a group that are above an observer right now, highest first, ' +
|
||||||
|
'with elevation and direction.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
...OBSERVER,
|
||||||
|
group: GROUP_SCHEMA,
|
||||||
|
min_elevation_deg: { type: 'number', minimum: 0, maximum: 90 },
|
||||||
|
limit: LIMIT_SCHEMA,
|
||||||
|
},
|
||||||
|
required: ['lat', 'lon'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['satellites'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const group = args.group ?? 'stations';
|
||||||
|
const minimum = args.min_elevation_deg ?? 10;
|
||||||
|
const at = nowMs(services);
|
||||||
|
const rows = (await readCatalog(services, group, signal))
|
||||||
|
.flatMap((entry) => {
|
||||||
|
const look = lookAnglesAt(entry.satrec, at, args.lat, args.lon);
|
||||||
|
return look && look.elevDeg >= minimum
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: entry.name,
|
||||||
|
norad: entry.norad,
|
||||||
|
elevation_deg: round(look.elevDeg, 1),
|
||||||
|
azimuth_deg: round(look.azDeg, 0),
|
||||||
|
direction: compass(look.azDeg),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.elevation_deg - a.elevation_deg);
|
||||||
|
return {
|
||||||
|
summary: `${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above this location at ${isoTime(at)}.`,
|
||||||
|
data: { ...capRows(rows, args.limit), group, at: isoTime(at) },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { composeCatalog, coreTools } from '../index.js';
|
||||||
|
import { parseTleText, tleCatalogNumber } from '../../sources/tle.js';
|
||||||
|
|
||||||
|
// Element sets published for 1 January 2024.
|
||||||
|
const TLE = `ISS (ZARYA)
|
||||||
|
1 25544U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
|
||||||
|
2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432414
|
||||||
|
CSS (TIANHE)
|
||||||
|
1 48274U 21035A 24001.50000000 .00020137 00000-0 24481-3 0 9993
|
||||||
|
2 48274 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
|
||||||
|
`;
|
||||||
|
const CLOCK = { now: () => Date.UTC(2024, 0, 1, 12) };
|
||||||
|
const satellites = (text = TLE, ok = true) => ({
|
||||||
|
calls: [],
|
||||||
|
async readGroup(group) {
|
||||||
|
this.calls.push(group);
|
||||||
|
return { ok, status: ok ? 200 : 503, text };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TLE text parses into named entries with catalog numbers', () => {
|
||||||
|
const entries = parseTleText(`${TLE}\nBROKEN\nnot a line\nnor this\n`);
|
||||||
|
assert.deepEqual(
|
||||||
|
entries.map((entry) => entry.name),
|
||||||
|
['ISS (ZARYA)', 'CSS (TIANHE)'],
|
||||||
|
);
|
||||||
|
assert.equal(tleCatalogNumber(entries[0].line1), 25544);
|
||||||
|
assert.equal(tleCatalogNumber('1 xx'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { satellites: satellites(), clock: CLOCK },
|
||||||
|
});
|
||||||
|
const result = await catalog.call('next_satellite_pass', {
|
||||||
|
lat: 40.7,
|
||||||
|
lon: -74,
|
||||||
|
});
|
||||||
|
const { pass } = result.data;
|
||||||
|
assert.equal(result.data.satellite, 'ISS (ZARYA)');
|
||||||
|
assert.equal(result.data.norad, 25544);
|
||||||
|
assert.ok(Date.parse(pass.rise) >= CLOCK.now());
|
||||||
|
assert.ok(Date.parse(pass.rise) < Date.parse(pass.peak));
|
||||||
|
assert.ok(Date.parse(pass.peak) < Date.parse(pass.set));
|
||||||
|
assert.ok(pass.max_elevation_deg >= 10 && pass.max_elevation_deg <= 90);
|
||||||
|
assert.match(pass.rise_direction, /^[NESW]{1,3}$/);
|
||||||
|
assert.match(
|
||||||
|
result.summary,
|
||||||
|
new RegExp(
|
||||||
|
`^ISS \\(ZARYA\\) next rises ${pass.rise} in the ${pass.rise_direction}, peaking at \\d+°`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('satellites are matched by name or catalog number within the group', async () => {
|
||||||
|
const source = satellites();
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { satellites: source, clock: CLOCK },
|
||||||
|
});
|
||||||
|
const byName = await catalog.call('next_satellite_pass', {
|
||||||
|
lat: 0,
|
||||||
|
lon: 0,
|
||||||
|
satellite: 'tianhe',
|
||||||
|
});
|
||||||
|
assert.equal(byName.data.norad, 48274);
|
||||||
|
const byNumber = await catalog.call('next_satellite_pass', {
|
||||||
|
lat: 0,
|
||||||
|
lon: 0,
|
||||||
|
satellite: '48274',
|
||||||
|
group: 'visual',
|
||||||
|
});
|
||||||
|
assert.equal(byNumber.data.satellite, 'CSS (TIANHE)');
|
||||||
|
assert.deepEqual(source.calls, ['stations', 'visual']);
|
||||||
|
await assert.rejects(
|
||||||
|
catalog.call('next_satellite_pass', {
|
||||||
|
lat: 0,
|
||||||
|
lon: 0,
|
||||||
|
satellite: 'Hubble',
|
||||||
|
}),
|
||||||
|
/No satellite matching "Hubble" is in the stations group/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
catalog.call('next_satellite_pass', { lat: 0, lon: 0, group: 'weather' }),
|
||||||
|
/must be one of/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pass that never comes is a normal answer, and catalog failures are unavailable', async () => {
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { satellites: satellites(), clock: CLOCK },
|
||||||
|
});
|
||||||
|
// The ISS orbit is inclined 51.6°, so it never rises 10° above the pole.
|
||||||
|
const none = await catalog.call('next_satellite_pass', {
|
||||||
|
lat: 89.9,
|
||||||
|
lon: 0,
|
||||||
|
hours: 6,
|
||||||
|
});
|
||||||
|
assert.equal(none.data.pass, null);
|
||||||
|
assert.equal(
|
||||||
|
none.summary,
|
||||||
|
'ISS (ZARYA) has no pass over this location in the next 6 hours.',
|
||||||
|
);
|
||||||
|
const down = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { satellites: satellites('', false), clock: CLOCK },
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
down.call('next_satellite_pass', { lat: 0, lon: 0 }),
|
||||||
|
(error) => error.code === 'unavailable' && /HTTP 503/.test(error.message),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('satellites overhead are those above the elevation, highest first', async () => {
|
||||||
|
const source = satellites();
|
||||||
|
const services = { satellites: source, clock: CLOCK };
|
||||||
|
const catalog = composeCatalog({ tools: coreTools, services });
|
||||||
|
const pass = (
|
||||||
|
await catalog.call('next_satellite_pass', { lat: 40.7, lon: -74 })
|
||||||
|
).data.pass;
|
||||||
|
const atPeak = {
|
||||||
|
satellites: source,
|
||||||
|
clock: { now: () => Date.parse(pass.peak) },
|
||||||
|
};
|
||||||
|
const overhead = await composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: atPeak,
|
||||||
|
}).call('satellites_overhead', {
|
||||||
|
lat: 40.7,
|
||||||
|
lon: -74,
|
||||||
|
});
|
||||||
|
assert.equal(overhead.data.rows[0].name, 'ISS (ZARYA)');
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(overhead.data.rows[0].elevation_deg - pass.max_elevation_deg) < 1,
|
||||||
|
);
|
||||||
|
assert.equal(overhead.data.at, pass.peak);
|
||||||
|
const strict = await composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: atPeak,
|
||||||
|
}).call('satellites_overhead', {
|
||||||
|
lat: 40.7,
|
||||||
|
lon: -74,
|
||||||
|
min_elevation_deg: 90,
|
||||||
|
});
|
||||||
|
assert.equal(strict.data.total, 0);
|
||||||
|
assert.match(
|
||||||
|
strict.summary,
|
||||||
|
/^0 satellites from the stations group are at least 90° above/,
|
||||||
|
);
|
||||||
|
});
|
||||||
+8
-1
@@ -2,7 +2,7 @@
|
|||||||
* Validates tool arguments against the JSON Schema subset tool definitions use:
|
* Validates tool arguments against the JSON Schema subset tool definitions use:
|
||||||
* object, array, string, number, integer and boolean types; required,
|
* object, array, string, number, integer and boolean types; required,
|
||||||
* properties, additionalProperties, items, enum, minimum, maximum, minLength,
|
* properties, additionalProperties, items, enum, minimum, maximum, minLength,
|
||||||
* maxLength, minItems and maxItems. Unsupported keywords are rejected when a
|
* maxLength, pattern, minItems and maxItems. Unsupported keywords are rejected when a
|
||||||
* tool is defined, so a schema can never promise checks that are not made.
|
* tool is defined, so a schema can never promise checks that are not made.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ const KEYWORDS = new Set([
|
|||||||
'maximum',
|
'maximum',
|
||||||
'minLength',
|
'minLength',
|
||||||
'maxLength',
|
'maxLength',
|
||||||
|
'pattern',
|
||||||
'minItems',
|
'minItems',
|
||||||
'maxItems',
|
'maxItems',
|
||||||
'default',
|
'default',
|
||||||
@@ -42,6 +43,7 @@ export function assertSupportedSchema(schema, path = 'schema') {
|
|||||||
}
|
}
|
||||||
if (!TYPES.has(schema.type))
|
if (!TYPES.has(schema.type))
|
||||||
throw new TypeError(`${path} has unsupported type ${schema.type}`);
|
throw new TypeError(`${path} has unsupported type ${schema.type}`);
|
||||||
|
if (schema.pattern !== undefined) new RegExp(schema.pattern, 'u');
|
||||||
for (const [name, child] of Object.entries(schema.properties || {}))
|
for (const [name, child] of Object.entries(schema.properties || {}))
|
||||||
assertSupportedSchema(child, `${path}.${name}`);
|
assertSupportedSchema(child, `${path}.${name}`);
|
||||||
if (schema.items) assertSupportedSchema(schema.items, `${path}[]`);
|
if (schema.items) assertSupportedSchema(schema.items, `${path}[]`);
|
||||||
@@ -99,6 +101,11 @@ export function validateValue(schema, value, path = 'arguments') {
|
|||||||
fail(`must be at least ${schema.minLength} characters`);
|
fail(`must be at least ${schema.minLength} characters`);
|
||||||
if (schema.maxLength != null && value.length > schema.maxLength)
|
if (schema.maxLength != null && value.length > schema.maxLength)
|
||||||
fail(`must be at most ${schema.maxLength} characters`);
|
fail(`must be at most ${schema.maxLength} characters`);
|
||||||
|
if (
|
||||||
|
schema.pattern !== undefined &&
|
||||||
|
!new RegExp(schema.pattern, 'u').test(value)
|
||||||
|
)
|
||||||
|
fail(`must match ${schema.pattern}`);
|
||||||
break;
|
break;
|
||||||
case 'number':
|
case 'number':
|
||||||
case 'integer':
|
case 'integer':
|
||||||
|
|||||||
Reference in new issue
Block a user