orbit-studio / src /modules /Orbit.test.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
8.51 kB
import dayjs from "dayjs";
import { describe, expect, test } from "vitest";
import Orbit from "./Orbit";
import { parseGpPayload, type GpRecord } from "./util/gp";
const TLE = "ISS (ZARYA)\n1 25544U 98067A 18342.69352573 .00002284 00000-0 41838-4 0 9992\n2 25544 51.6407 229.0798 0005166 124.8351 329.3296 15.54069892145658";
// Physical invariants — orbital radius, altitude band, a non-empty pass list —
// rather than exact decimals: the values SGP4 produces for a given element set
// shift between satellite.js releases, so pinned decimals fail on upgrade
// without anything being wrong.
describe("Orbit (TLE record)", () => {
const orbit = new Orbit("ISS", parseGpPayload(TLE)[0] as GpRecord);
test("calculates a plausible satellite position", () => {
const time = dayjs("2018-12-01").toDate();
const positionECI = orbit.positionECI(time);
expect(positionECI).not.toBeNull();
// ISS orbital radius is ~6780 km (LEO).
const radius = Math.sqrt(positionECI!.x ** 2 + positionECI!.y ** 2 + positionECI!.z ** 2);
expect(radius).toBeGreaterThan(6600);
expect(radius).toBeLessThan(6900);
const positionGeodetic = orbit.positionGeodetic(time);
expect(positionGeodetic).not.toBeNull();
// ISS altitude is ~400 km (± band).
expect(positionGeodetic!.height / 1000).toBeGreaterThan(380);
expect(positionGeodetic!.height / 1000).toBeLessThan(430);
});
test("calculates passes", () => {
const gs = { latitude: 48.177, longitude: 11.7476, height: 0 };
const start = dayjs("2018-12-08");
const end = dayjs("2018-12-22");
const passes = orbit.computePassesElevation(gs, start.toDate(), end.toDate(), 1, 500);
// Roughly one visible pass per ~1.5 orbits over Munich across two weeks.
expect(passes.length).toBeGreaterThan(50);
});
test("exposes tle lines and satnum", () => {
expect(orbit.tle).toHaveLength(3);
expect(orbit.satnum).toBe("25544");
});
});
// Swath containment is tested against ground stations placed at a known offset
// from the ground track, so the assertions are about geometry rather than about
// whichever passes SGP4 happens to produce over a real city.
describe("Orbit swath containment", () => {
const orbit = new Orbit("ISS", parseGpPayload(TLE)[0] as GpRecord);
const EARTH_RADIUS_KM = 6371;
const deg2rad = Math.PI / 180;
const rad2deg = 180 / Math.PI;
const AT = dayjs("2018-12-08T12:00:00Z").toDate();
/** Destination point `distanceKm` from (lat, lon) along `bearingRad`, in degrees. */
function destination(latDeg: number, lonDeg: number, bearingRad: number, distanceKm: number) {
const lat = latDeg * deg2rad;
const lon = lonDeg * deg2rad;
const delta = distanceKm / EARTH_RADIUS_KM;
const lat2 = Math.asin(Math.sin(lat) * Math.cos(delta) + Math.cos(lat) * Math.sin(delta) * Math.cos(bearingRad));
const lon2 = lon + Math.atan2(Math.sin(bearingRad) * Math.sin(delta) * Math.cos(lat), Math.cos(delta) - Math.sin(lat) * Math.sin(lat2));
return { latitude: lat2 * rad2deg, longitude: lon2 * rad2deg, height: 0 };
}
/** The satellite's flight bearing (radians) at AT, from two subpoints. */
function flightBearing(): number {
const here = orbit.positionGeodetic(AT)!;
const ahead = orbit.positionGeodetic(new Date(AT.getTime() + 10_000))!;
const lat1 = here.latitude * deg2rad;
const lon1 = here.longitude * deg2rad;
const lat2 = ahead.latitude * deg2rad;
const lon2 = ahead.longitude * deg2rad;
const deltaLon = lon2 - lon1;
return Math.atan2(Math.sin(deltaLon) * Math.cos(lat2), Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(deltaLon));
}
/** Ground stations 400 km to starboard and to port of the track at AT. */
function flankingStations(offsetKm = 400) {
const here = orbit.positionGeodetic(AT)!;
const bearing = flightBearing();
return {
starboard: destination(here.latitude, here.longitude, bearing + Math.PI / 2, offsetKm),
port: destination(here.latitude, here.longitude, bearing - Math.PI / 2, offsetKm),
};
}
test("names the side a station lies on", () => {
const { starboard, port } = flankingStations();
expect(orbit.trackOffsets(starboard, AT)!.side).toBe("starboard");
expect(orbit.trackOffsets(port, AT)!.side).toBe("port");
});
test("reports the great-circle distance to the subpoint", () => {
const { starboard, port } = flankingStations();
expect(orbit.trackOffsets(starboard, AT)!.distanceKm).toBeCloseTo(400, 0);
expect(orbit.trackOffsets(port, AT)!.distanceKm).toBeCloseTo(400, 0);
});
test("bounds a station straight ahead by distance, so it is not served", () => {
// A per-side test keyed on cross-track offset alone would place this station at
// zero offset and serve it for the whole orbit; distance bounds it.
const here = orbit.positionGeodetic(AT)!;
const ahead = destination(here.latitude, here.longitude, flightBearing(), 1200);
expect(orbit.trackOffsets(ahead, AT)!.distanceKm).toBeCloseTo(1200, -1);
expect(orbit.computePassesSwath(ahead, { starboardKm: 600, portKm: 600 }, AT, new Date(AT.getTime() + 60_000))).toHaveLength(0);
});
test("an asymmetric swath serves the wide side and not the narrow one", () => {
const { starboard, port } = flankingStations();
// 400 km off-track: inside a 600 km starboard extent, outside a 200 km port one.
const swath = { starboardKm: 600, portKm: 200 };
const start = AT;
const end = new Date(AT.getTime() + 30 * 60_000);
expect(orbit.computePassesSwath(starboard, swath, start, end).length).toBeGreaterThan(0);
expect(orbit.computePassesSwath(port, swath, start, end)).toHaveLength(0);
// Mirroring the extents flips which station is served — the sides are not
// interchangeable, which a single total width could never express.
const mirrored = { starboardKm: 200, portKm: 600 };
expect(orbit.computePassesSwath(starboard, mirrored, start, end)).toHaveLength(0);
expect(orbit.computePassesSwath(port, mirrored, start, end).length).toBeGreaterThan(0);
});
test("a symmetric swath is the plain distance test the old single-width model used", () => {
// Pins the ADR-0002 claim that symmetric satellites keep their windows exactly:
// containment must be `distance <= total / 2`, side-independent.
const { starboard, port } = flankingStations(400);
const start = AT;
const end = new Date(AT.getTime() + 30 * 60_000);
for (const station of [starboard, port]) {
// 400 km out: served by a 401 km side extent, not by a 399 km one.
expect(orbit.computePassesSwath(station, { starboardKm: 401, portKm: 401 }, start, end).length).toBeGreaterThan(0);
expect(orbit.computePassesSwath(station, { starboardKm: 399, portKm: 399 }, start, end)).toHaveLength(0);
}
});
test("a symmetric swath serves both sides alike", () => {
const { starboard, port } = flankingStations();
const swath = { starboardKm: 600, portKm: 600 };
const start = AT;
const end = new Date(AT.getTime() + 30 * 60_000);
expect(orbit.computePassesSwath(starboard, swath, start, end).length).toBeGreaterThan(0);
expect(orbit.computePassesSwath(port, swath, start, end).length).toBeGreaterThan(0);
});
test("reports the total width on the pass, for display", () => {
const { starboard } = flankingStations();
const passes = orbit.computePassesSwath(starboard, { starboardKm: 600, portKm: 200 }, AT, new Date(AT.getTime() + 30 * 60_000));
expect(passes[0]!.swathWidth).toBe(800);
});
});
describe("Orbit (GpRecord path)", () => {
test("OMM record builds an orbit without tle lines", () => {
const omm = JSON.stringify([
{
OBJECT_NAME: "ISS (ZARYA)",
OBJECT_ID: "1998-067A",
EPOCH: "2026-07-04T02:07:57.020160",
MEAN_MOTION: 15.48879284,
ECCENTRICITY: 0.00067632,
INCLINATION: 51.6303,
RA_OF_ASC_NODE: 216.4301,
ARG_OF_PERICENTER: 253.0749,
MEAN_ANOMALY: 106.9498,
NORAD_CAT_ID: 25544,
ELEMENT_SET_NO: 999,
BSTAR: 0.00014587488,
MEAN_MOTION_DOT: 7.564e-5,
MEAN_MOTION_DDOT: 0,
},
]);
const record = parseGpPayload(omm)[0] as GpRecord;
const orbit = new Orbit("ISS", record);
expect(orbit.satnum).toBe("25544");
expect(orbit.tle).toBeUndefined();
expect(orbit.record.kind).toBe("omm");
expect(orbit.error).toBe(0);
});
});