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:
Sameh Khamis committed 2026-10-01 12:05:50 -07:00
1 parent 9895942633
commit 22c3fe40b0
16 files changed
+1111 -16

No files matched your search

+306
View File
@@ -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 },
};
},
});
+276
View File
@@ -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
View File
@@ -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) },
};
},
});
+154
View File
@@ -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/,
);
});