/** * A compact Earth-centred propagation engine for interactive scenario design. * * The state is inertial Cartesian SI units. It deliberately keeps its force * models explicit so an extension can replace any approximation (for example * the Sun/Moon circular ephemerides) without touching the integrator. */ export type Vector3 = readonly [number, number, number]; export type State = readonly [number, number, number, number, number, number]; export interface PropagationForces { gravity: boolean; j2: boolean; drag: boolean; moon: boolean; sun: boolean; solarRadiationPressure: boolean; } export interface PropagationRequest { altitudeKm: number; eccentricity: number; inclinationDeg: number; raanDeg: number; argumentOfPeriapsisDeg: number; trueAnomalyDeg: number; durationSeconds: number; sampleCount: number; massKg: number; areaM2: number; dragCoefficient: number; reflectivityCoefficient: number; forces: PropagationForces; } export interface PropagationSample { elapsedSeconds: number; state: State; } export const EARTH = Object.freeze({ mu: 3.986004418e14, radius: 6_378_136.3, j2: 1.08262668e-3, rotationRate: 7.2921159e-5, }); const SUN = Object.freeze({ mu: 1.32712440018e20, distance: 149_597_870_700, period: 365.256363004 * 86_400 }); const MOON = Object.freeze({ mu: 4.9048695e12, distance: 384_400_000, period: 27.321661 * 86_400, inclinationRad: 5.145 * Math.PI / 180 }); const SOLAR_PRESSURE_AT_1AU = 4.56e-6; const ATMOSPHERE_LAYERS: readonly (readonly [number, number, number])[] = [ [0, 1.225, 7_249], [25_000, 3.899e-2, 6_349], [50_000, 1.057e-3, 8_382], [80_000, 1.905e-5, 5_799], [100_000, 5.297e-7, 5_877], [120_000, 2.438e-8, 9_473], [150_000, 2.07e-9, 22_523], [200_000, 2.789e-10, 37_105], [300_000, 2.418e-11, 53_628], [400_000, 3.725e-12, 58_515], [500_000, 6.967e-13, 63_822], [700_000, 3.614e-14, 88_667], [1_000_000, 3.019e-15, 268_000], ]; const add = (a: Vector3, b: Vector3): Vector3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; const subtract = (a: Vector3, b: Vector3): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; const scale = (a: Vector3, factor: number): Vector3 => [a[0] * factor, a[1] * factor, a[2] * factor]; const cross = (a: Vector3, b: Vector3): Vector3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; const magnitude = (a: Vector3): number => Math.hypot(a[0], a[1], a[2]); const radians = (degrees: number): number => degrees * Math.PI / 180; function atmosphereDensity(altitudeM: number): number { const layer = [...ATMOSPHERE_LAYERS].reverse().find(([base]) => altitudeM >= base) ?? ATMOSPHERE_LAYERS[0]; if (!layer) return 0; return layer[1] * Math.exp(-(Math.max(0, altitudeM) - layer[0]) / layer[2]); } function circularEphemeris(distance: number, period: number, elapsedSeconds: number, inclinationRad: number = 0): Vector3 { const angle = 2 * Math.PI * elapsedSeconds / period; return [distance * Math.cos(angle), distance * Math.sin(angle) * Math.cos(inclinationRad), distance * Math.sin(angle) * Math.sin(inclinationRad)]; } function thirdBodyAcceleration(position: Vector3, bodyPosition: Vector3, mu: number): Vector3 { const delta = subtract(bodyPosition, position); const distance = magnitude(delta); const originDistance = magnitude(bodyPosition); return scale(subtract(scale(delta, 1 / distance ** 3), scale(bodyPosition, 1 / originDistance ** 3)), mu); } function accelerations(elapsedSeconds: number, state: State, request: PropagationRequest): Vector3 { const position: Vector3 = [state[0], state[1], state[2]]; const velocity: Vector3 = [state[3], state[4], state[5]]; const radius = magnitude(position); if (radius < EARTH.radius * 0.5) throw new RangeError("The trajectory reached an invalid Earth-centred radius."); let acceleration: Vector3 = [0, 0, 0]; if (request.forces.gravity) acceleration = add(acceleration, scale(position, -EARTH.mu / radius ** 3)); if (request.forces.j2) { const zSquared = position[2] ** 2; const radiusSquared = radius ** 2; const factor = 1.5 * EARTH.j2 * EARTH.mu * EARTH.radius ** 2 / radius ** 5; acceleration = add(acceleration, scale([ position[0] * (5 * zSquared / radiusSquared - 1), position[1] * (5 * zSquared / radiusSquared - 1), position[2] * (5 * zSquared / radiusSquared - 3), ], factor)); } if (request.forces.drag) { const atmosphereVelocity = cross([0, 0, EARTH.rotationRate], position); const relativeVelocity = subtract(velocity, atmosphereVelocity); const speed = magnitude(relativeVelocity); const factor = -0.5 * atmosphereDensity(radius - EARTH.radius) * request.dragCoefficient * request.areaM2 / request.massKg * speed; acceleration = add(acceleration, scale(relativeVelocity, factor)); } const sunPosition = circularEphemeris(SUN.distance, SUN.period, elapsedSeconds); if (request.forces.sun) acceleration = add(acceleration, thirdBodyAcceleration(position, sunPosition, SUN.mu)); if (request.forces.moon) acceleration = add(acceleration, thirdBodyAcceleration(position, circularEphemeris(MOON.distance, MOON.period, elapsedSeconds, MOON.inclinationRad), MOON.mu)); if (request.forces.solarRadiationPressure) { const awayFromSun = subtract(position, sunPosition); const distance = magnitude(awayFromSun); const pressure = SOLAR_PRESSURE_AT_1AU * (SUN.distance / distance) ** 2; acceleration = add(acceleration, scale(awayFromSun, pressure * request.reflectivityCoefficient * request.areaM2 / request.massKg / distance)); } return acceleration; } function derivative(elapsedSeconds: number, state: State, request: PropagationRequest): State { const acceleration = accelerations(elapsedSeconds, state, request); return [state[3], state[4], state[5], acceleration[0], acceleration[1], acceleration[2]]; } function addScaled(state: State, rate: State, factor: number): State { return [state[0] + rate[0] * factor, state[1] + rate[1] * factor, state[2] + rate[2] * factor, state[3] + rate[3] * factor, state[4] + rate[4] * factor, state[5] + rate[5] * factor]; } function rk4Step(elapsedSeconds: number, state: State, stepSeconds: number, request: PropagationRequest): State { const k1 = derivative(elapsedSeconds, state, request); const k2 = derivative(elapsedSeconds + stepSeconds / 2, addScaled(state, k1, stepSeconds / 2), request); const k3 = derivative(elapsedSeconds + stepSeconds / 2, addScaled(state, k2, stepSeconds / 2), request); const k4 = derivative(elapsedSeconds + stepSeconds, addScaled(state, k3, stepSeconds), request); const factor = stepSeconds / 6; return [ state[0] + factor * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]), state[1] + factor * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]), state[2] + factor * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2]), state[3] + factor * (k1[3] + 2 * k2[3] + 2 * k3[3] + k4[3]), state[4] + factor * (k1[4] + 2 * k2[4] + 2 * k3[4] + k4[4]), state[5] + factor * (k1[5] + 2 * k2[5] + 2 * k3[5] + k4[5]), ]; } export function initialState(request: PropagationRequest): State { if (request.eccentricity < 0 || request.eccentricity >= 1) throw new RangeError("Eccentricity must be in [0, 1)."); const perigeeRadius = EARTH.radius + request.altitudeKm * 1_000; const semimajorAxis = perigeeRadius / (1 - request.eccentricity); const inclination = radians(request.inclinationDeg); const raan = radians(request.raanDeg); const argument = radians(request.argumentOfPeriapsisDeg); const anomaly = radians(request.trueAnomalyDeg); const p = semimajorAxis * (1 - request.eccentricity ** 2); const radius = p / (1 + request.eccentricity * Math.cos(anomaly)); const positionPerifocal: Vector3 = [radius * Math.cos(anomaly), radius * Math.sin(anomaly), 0]; const speed = Math.sqrt(EARTH.mu / p); const velocityPerifocal: Vector3 = [-speed * Math.sin(anomaly), speed * (request.eccentricity + Math.cos(anomaly)), 0]; const rotate = ([x, y, z]: Vector3): Vector3 => [ (Math.cos(raan) * Math.cos(argument) - Math.sin(raan) * Math.sin(argument) * Math.cos(inclination)) * x + (-Math.cos(raan) * Math.sin(argument) - Math.sin(raan) * Math.cos(argument) * Math.cos(inclination)) * y, (Math.sin(raan) * Math.cos(argument) + Math.cos(raan) * Math.sin(argument) * Math.cos(inclination)) * x + (-Math.sin(raan) * Math.sin(argument) + Math.cos(raan) * Math.cos(argument) * Math.cos(inclination)) * y, Math.sin(argument) * Math.sin(inclination) * x + Math.cos(argument) * Math.sin(inclination) * y + z, ]; const position = rotate(positionPerifocal); const velocity = rotate(velocityPerifocal); return [position[0], position[1], position[2], velocity[0], velocity[1], velocity[2]]; } /** Fixed-step RK4 with small internal steps and explicit stored samples. */ export function propagate(request: PropagationRequest): PropagationSample[] { if (request.durationSeconds <= 0 || request.sampleCount < 2 || request.massKg <= 0 || request.areaM2 <= 0) throw new RangeError("Duration, samples, mass, and area must be positive."); const samples: PropagationSample[] = []; const stateAtStart = initialState(request); let state = stateAtStart; let elapsedSeconds = 0; const outputStep = request.durationSeconds / (request.sampleCount - 1); for (let index = 0; index < request.sampleCount; index += 1) { samples.push({ elapsedSeconds, state }); if (index === request.sampleCount - 1) break; const nextOutput = (index + 1) * outputStep; while (elapsedSeconds < nextOutput - 1e-8) { const step = Math.min(20, nextOutput - elapsedSeconds); state = rk4Step(elapsedSeconds, state, step, request); elapsedSeconds += step; } } return samples; }