mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +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
@@ -104,6 +104,20 @@ export function distanceKm(a, b) {
|
||||
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. */
|
||||
export function areaContains(area, point) {
|
||||
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.
|
||||
*
|
||||
@@ -114,7 +138,12 @@ export function composeCatalog({
|
||||
const problems = validateValue(tool.inputSchema, args);
|
||||
if (problems.length)
|
||||
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)
|
||||
throw new TypeError(`${tool.name} returned no summary or data`);
|
||||
return result;
|
||||
|
||||
@@ -31,10 +31,10 @@ test('definitions are validated, frozen and read-only by default', () => {
|
||||
echo('x', {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { a: { type: 'string', pattern: '.' } },
|
||||
properties: { a: { type: 'string', format: 'email' } },
|
||||
},
|
||||
}),
|
||||
/unsupported keyword pattern/,
|
||||
/unsupported keyword format/,
|
||||
);
|
||||
assert.throws(
|
||||
() => 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', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
|
||||
+24
-2
@@ -3,8 +3,18 @@
|
||||
* 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 { getRecentLaunches } from './queries/space.js';
|
||||
import {
|
||||
getRecentLaunches,
|
||||
nextSatellitePass,
|
||||
satellitesOverhead,
|
||||
} from './queries/space.js';
|
||||
|
||||
export {
|
||||
defineTool,
|
||||
@@ -12,7 +22,13 @@ export {
|
||||
ToolError,
|
||||
TOOL_ERROR_CODES,
|
||||
} 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 { createGeocodePlaceService, placeFromGeocodeResult } from './places.js';
|
||||
|
||||
@@ -21,4 +37,10 @@ export const coreTools = Object.freeze([
|
||||
getEarthquakes,
|
||||
getActiveFires,
|
||||
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.deepEqual(
|
||||
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(
|
||||
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 { 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);
|
||||
|
||||
@@ -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:
|
||||
* object, array, string, number, integer and boolean types; required,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,7 @@ const KEYWORDS = new Set([
|
||||
'maximum',
|
||||
'minLength',
|
||||
'maxLength',
|
||||
'pattern',
|
||||
'minItems',
|
||||
'maxItems',
|
||||
'default',
|
||||
@@ -42,6 +43,7 @@ export function assertSupportedSchema(schema, path = 'schema') {
|
||||
}
|
||||
if (!TYPES.has(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 || {}))
|
||||
assertSupportedSchema(child, `${path}.${name}`);
|
||||
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`);
|
||||
if (schema.maxLength != null && value.length > schema.maxLength)
|
||||
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;
|
||||
case 'number':
|
||||
case 'integer':
|
||||
|
||||
Reference in new issue
Block a user