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