orbit-studio / src /modules /simulation /propagator.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
9.76 kB
/**
* 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;
}