Spaces:
Configuration error
Configuration error
| 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); | |
| }); | |
| }); | |