orbit-studio / src /modules /Orbit.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
12.9 kB
import dayjs from "dayjs";
import * as satellitejs from "satellite.js";
import type { SwathExtents } from "../config/satelliteMetadata";
import { createSatrec, recordTleLines, type GpRecord } from "./util/gp";
const deg2rad = Math.PI / 180;
const rad2deg = 180 / Math.PI;
export interface GeodeticPosition {
longitude: number; // degrees
latitude: number; // degrees
height: number; // meters
velocity?: number; // km/s, present when calculateVelocity = true
}
export interface GroundStationPosition {
longitude: number; // degrees
latitude: number; // degrees
height: number; // meters (converted to km internally before passing to satellite.js)
}
export interface ElevationPass {
name: string;
start: number;
end: number;
duration: number;
azimuthStart: number;
azimuthApex: number;
azimuthEnd: number;
maxElevation: number;
apex?: number;
}
export interface SwathPass {
name: string;
start: number;
end: number;
duration: number;
minDistance: number;
minDistanceTime: number;
swathWidth: number;
}
/** Which side of the ground track something lies on, relative to flight direction. */
export type SwathSide = "starboard" | "port";
/** A ground station's position relative to the ground track (see Orbit.trackOffsets). */
export interface TrackOffsets {
side: SwathSide;
distanceKm: number;
}
const EARTH_RADIUS_KM = 6371;
// Lookahead used to derive the ground-track bearing from two subpoints. Short
// enough that the track is locally straight, long enough that the two subpoints
// are ~75 km apart in LEO and the bearing is not dominated by rounding.
const BEARING_SAMPLE_MS = 10_000;
/** Initial bearing from one geodetic point to another, all in radians. */
function bearingRad(fromLat: number, fromLon: number, toLat: number, toLon: number): number {
const deltaLon = toLon - fromLon;
const y = Math.sin(deltaLon) * Math.cos(toLat);
const x = Math.cos(fromLat) * Math.sin(toLat) - Math.sin(fromLat) * Math.cos(toLat) * Math.cos(deltaLon);
return Math.atan2(y, x);
}
/** Great-circle distance (km) between two geodetic points, all in radians. */
function greatCircleKm(fromLat: number, fromLon: number, toLat: number, toLon: number): number {
const deltaLat = toLat - fromLat;
const deltaLon = toLon - fromLon;
const a = Math.sin(deltaLat / 2) ** 2 + Math.cos(fromLat) * Math.cos(toLat) * Math.sin(deltaLon / 2) ** 2;
return EARTH_RADIUS_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export default class Orbit {
name: string;
// The element set this orbit was built from; always present.
record: GpRecord;
// The three TLE lines, present only for kind:"tle" records so the entity info
// panel can render them. Undefined for OMM-sourced orbits.
tle?: string[];
satrec: satellitejs.SatRec;
constructor(name: string, record: GpRecord) {
this.name = name;
this.record = record;
this.tle = recordTleLines(record);
this.satrec = createSatrec(record);
}
get satnum(): string {
return this.satrec.satnum;
}
get error(): number {
return this.satrec.error;
}
get julianDate(): number {
return this.satrec.jdsatepoch;
}
get orbitalPeriod(): number {
const meanMotionRad = this.satrec.no;
const period = (2 * Math.PI) / meanMotionRad;
return period;
}
positionECI(time: Date): satellitejs.EciVec3<number> | null {
const result = satellitejs.propagate(this.satrec, time);
return result && typeof result.position !== "boolean" ? result.position : null;
}
positionECF(time: Date): satellitejs.EcfVec3<number> | null {
const positionEci = this.positionECI(time);
if (!positionEci) return null;
const gmst = satellitejs.gstime(time);
const positionEcf = satellitejs.eciToEcf(positionEci, gmst);
return positionEcf;
}
positionGeodetic(timestamp: Date, calculateVelocity = false): GeodeticPosition | null {
const result = satellitejs.propagate(this.satrec, timestamp);
if (!result || typeof result.position === "boolean" || typeof result.velocity === "boolean") return null;
const { position: positionEci, velocity: velocityVector } = result;
const gmst = satellitejs.gstime(timestamp);
const positionGd = satellitejs.eciToGeodetic(positionEci, gmst);
return {
longitude: positionGd.longitude * rad2deg,
latitude: positionGd.latitude * rad2deg,
height: positionGd.height * 1000,
...(calculateVelocity && {
velocity: Math.sqrt(velocityVector.x * velocityVector.x + velocityVector.y * velocityVector.y + velocityVector.z * velocityVector.z),
}),
};
}
computePassesElevation(
groundStationPosition: GroundStationPosition,
startDate: Date = dayjs().toDate(),
endDate: Date = dayjs(startDate).add(7, "day").toDate(),
minElevation = 5,
maxPasses = 50,
): ElevationPass[] {
const groundStation = { ...groundStationPosition };
groundStation.latitude *= deg2rad;
groundStation.longitude *= deg2rad;
groundStation.height /= 1000;
const date = new Date(startDate);
const passes: ElevationPass[] = [];
let pass: Partial<ElevationPass> | null = null;
let ongoingPass = false;
let lastElevation = 0;
// eslint-disable-next-line no-unmodified-loop-condition -- date is mutated via setMinutes/setSeconds
while (date < endDate) {
const positionEcf = this.positionECF(date);
if (!positionEcf) {
date.setMinutes(date.getMinutes() + 1);
continue;
}
const lookAngles = satellitejs.ecfToLookAngles(groundStation, positionEcf);
const elevation = lookAngles.elevation / deg2rad;
if (elevation > minElevation) {
if (!ongoingPass) {
pass = {
name: this.name,
start: date.getTime(),
azimuthStart: lookAngles.azimuth,
maxElevation: elevation,
azimuthApex: lookAngles.azimuth,
};
ongoingPass = true;
} else if (pass && elevation > (pass.maxElevation ?? -Infinity)) {
pass.maxElevation = elevation;
pass.apex = date.getTime();
pass.azimuthApex = lookAngles.azimuth;
}
date.setSeconds(date.getSeconds() + 5);
} else if (ongoingPass && pass) {
pass.end = date.getTime();
pass.duration = (pass.end as number) - (pass.start as number);
pass.azimuthEnd = lookAngles.azimuth;
pass.azimuthStart = (pass.azimuthStart as number) / deg2rad;
pass.azimuthApex = (pass.azimuthApex as number) / deg2rad;
pass.azimuthEnd = (pass.azimuthEnd as number) / deg2rad;
passes.push(pass as ElevationPass);
if (passes.length >= maxPasses) {
break;
}
ongoingPass = false;
lastElevation = -180;
date.setMinutes(date.getMinutes() + this.orbitalPeriod * 0.5);
} else {
const deltaElevation = elevation - lastElevation;
lastElevation = elevation;
if (deltaElevation < 0) {
date.setMinutes(date.getMinutes() + this.orbitalPeriod * 0.5);
lastElevation = -180;
} else if (elevation < -20) {
date.setMinutes(date.getMinutes() + 5);
} else if (elevation < -5) {
date.setMinutes(date.getMinutes() + 1);
} else if (elevation < -1) {
date.setSeconds(date.getSeconds() + 5);
} else {
date.setSeconds(date.getSeconds() + 2);
}
}
}
return passes;
}
/**
* Where a ground station sits relative to the ground track at `date`:
*
* - `distanceKm` — great-circle distance to the subpoint. This is the magnitude
* containment compares against, and the pass's closest approach.
* - `side` — which side of the track the station is on, from the sign of its
* cross-track offset. Starboard is the velocity bearing + 90°. This is the
* only thing the cross-track decomposition is needed for: it selects WHICH
* extent applies, while `distanceKm` decides whether the station is within it.
*
* `undefined` when the position cannot be propagated.
*
* The flight bearing comes from two subpoints BEARING_SAMPLE_MS apart rather
* than the velocity vector: positionGeodetic returns only the speed magnitude,
* and rotating the ECI velocity into ECF without the ω × r term would skew the
* bearing by a few degrees.
*/
trackOffsets(groundStation: GroundStationPosition, date: Date): TrackOffsets | undefined {
const here = this.positionGeodetic(date);
const ahead = this.positionGeodetic(new Date(date.getTime() + BEARING_SAMPLE_MS));
if (!here || !ahead) {
return undefined;
}
const satLat = here.latitude * deg2rad;
const satLon = here.longitude * deg2rad;
const stationLat = groundStation.latitude * deg2rad;
const stationLon = groundStation.longitude * deg2rad;
const flightBearing = bearingRad(satLat, satLon, ahead.latitude * deg2rad, ahead.longitude * deg2rad);
const stationBearing = bearingRad(satLat, satLon, stationLat, stationLon);
const distanceKm = greatCircleKm(satLat, satLon, stationLat, stationLon);
// sin() of the bearing difference carries the side: positive means the station
// lies clockwise of the flight direction, i.e. to starboard. Only the sign is
// used, so the cross-track magnitude is never computed.
const side: SwathSide = Math.sin(stationBearing - flightBearing) >= 0 ? "starboard" : "port";
return { side, distanceKm };
}
computePassesSwath(
groundStationPosition: GroundStationPosition,
swath: SwathExtents,
startDate: Date = dayjs().toDate(),
endDate: Date = dayjs(startDate).add(7, "day").toDate(),
maxPasses = 50,
): SwathPass[] {
const swathWidth = swath.starboardKm + swath.portKm;
// The widest side bounds how far a station can be and still be served, so it
// drives the coarse time-stepping below (which runs before the side is known).
const maxExtent = Math.max(swath.starboardKm, swath.portKm);
const date = new Date(startDate);
const passes: SwathPass[] = [];
let pass: Partial<SwathPass> | null = null;
let ongoingPass = false;
let lastDistance = Number.MAX_VALUE;
// eslint-disable-next-line no-unmodified-loop-condition -- date is mutated via setMinutes/setSeconds
while (date < endDate) {
const offsets = this.trackOffsets(groundStationPosition, date);
if (offsets === undefined) {
date.setMinutes(date.getMinutes() + 1);
continue;
}
const { side, distanceKm } = offsets;
// The footprint is a half-disc per side: the station is served when its
// distance to the subpoint is within the extent of the side it lies on.
//
// For a symmetric swath both sides share one radius and this is byte-for-byte
// the test used before (`distanceKm <= swathKm / 2`), so the 37 symmetric
// satellites keep their pass windows exactly. An asymmetric sensor narrows
// only the side that is actually narrower.
const extentKm = side === "starboard" ? swath.starboardKm : swath.portKm;
if (distanceKm <= extentKm) {
if (!ongoingPass) {
pass = {
name: this.name,
start: date.getTime(),
minDistance: distanceKm,
minDistanceTime: date.getTime(),
swathWidth,
};
ongoingPass = true;
} else if (pass && distanceKm < (pass.minDistance ?? Infinity)) {
pass.minDistance = distanceKm;
pass.minDistanceTime = date.getTime();
}
date.setSeconds(date.getSeconds() + 30); // 30 second steps during pass
} else if (ongoingPass && pass) {
pass.end = date.getTime();
pass.duration = (pass.end as number) - (pass.start as number);
passes.push(pass as SwathPass);
if (passes.length >= maxPasses) {
break;
}
ongoingPass = false;
lastDistance = Number.MAX_VALUE;
// Skip ahead to avoid immediate re-entry
date.setMinutes(date.getMinutes() + Math.max(5, this.orbitalPeriod * 0.1));
} else {
// A ladder rather than one step size: coarse while the station is far
// and receding, fine as it closes. A step small enough never to miss a
// pass would otherwise walk the whole window at that resolution.
const deltaDistance = distanceKm - lastDistance;
lastDistance = distanceKm;
if (deltaDistance > 0 && distanceKm > maxExtent * 3) {
date.setMinutes(date.getMinutes() + Math.max(10, this.orbitalPeriod * 0.2));
} else if (distanceKm > maxExtent * 2) {
date.setMinutes(date.getMinutes() + 5);
} else {
date.setMinutes(date.getMinutes() + 1);
}
}
}
return passes;
}
}