use crate::error::GeomError;
use crate::math::Vec3;
pub fn combined_maneuver(v1: f64, v2: f64, plane_change: f64) -> Result<f64, GeomError> {
if v1 < 0.0 || v2 < 0.0 || ![v1, v2, plane_change].iter().all(|x| x.is_finite()) {
return Err(GeomError::InvalidArgument("combined_maneuver: bad speeds or angle"));
}
Ok((v1 * v1 + v2 * v2 - 2.0 * v1 * v2 * plane_change.cos()).max(0.0).sqrt())
}
pub fn sphere_of_influence(
distance: f64,
body_mass: f64,
primary_mass: f64,
) -> Result<f64, GeomError> {
if !(distance > 0.0) || !(body_mass > 0.0) || !(primary_mass > 0.0) {
return Err(GeomError::InvalidArgument("sphere_of_influence: bad distance or mass"));
}
if !distance.is_finite() || !body_mass.is_finite() || !primary_mass.is_finite() {
return Err(GeomError::InvalidArgument("sphere_of_influence: an input is not finite"));
}
if body_mass >= primary_mass {
return Err(GeomError::InvalidArgument(
"the body is not lighter than its primary, so it has no sphere of influence within it",
));
}
Ok(distance * (body_mass / primary_mass).powf(0.4))
}
pub fn patched_conic_escape(
parking_radius: f64,
mu: f64,
v_infinity: f64,
) -> Result<f64, GeomError> {
if !(parking_radius > 0.0) || !(mu > 0.0) || v_infinity < 0.0 {
return Err(GeomError::InvalidArgument("patched_conic_escape: bad parameters"));
}
if ![parking_radius, mu, v_infinity].iter().all(|x| x.is_finite()) {
return Err(GeomError::InvalidArgument("patched_conic_escape: an input is not finite"));
}
let circular = (mu / parking_radius).sqrt();
Ok((v_infinity * v_infinity + 2.0 * mu / parking_radius).sqrt() - circular)
}
pub fn gravity_assist_deflection(
v_infinity: f64,
periapsis: f64,
mu: f64,
) -> Result<f64, GeomError> {
if !(v_infinity > 0.0) || !(periapsis > 0.0) || !(mu > 0.0) {
return Err(GeomError::InvalidArgument("gravity_assist_deflection: bad parameters"));
}
if ![v_infinity, periapsis, mu].iter().all(|x| x.is_finite()) {
return Err(GeomError::InvalidArgument("gravity_assist_deflection: an input is not finite"));
}
let e = 1.0 + periapsis * v_infinity * v_infinity / mu;
Ok(2.0 * (1.0 / e).asin())
}
pub fn oberth_effect_dv(
speed: f64,
delta_v: f64,
radius: f64,
mu: f64,
) -> Result<f64, GeomError> {
if speed < 0.0 || !(radius > 0.0) || !(mu > 0.0) {
return Err(GeomError::InvalidArgument("oberth_effect_dv: bad parameters"));
}
if ![speed, delta_v, radius, mu].iter().all(|x| x.is_finite()) {
return Err(GeomError::InvalidArgument("oberth_effect_dv: an input is not finite"));
}
let after = speed + delta_v;
if after < 0.0 {
return Err(GeomError::Degenerate("the burn reverses the motion past a standstill"));
}
Ok(0.5 * after * after - mu / radius)
}
pub fn j2_raan_drift(
a: f64,
e: f64,
inclination: f64,
j2: f64,
body_radius: f64,
mu: f64,
) -> Result<f64, GeomError> {
if !(a > 0.0) || !(body_radius > 0.0) || !(mu > 0.0) || !(0.0..1.0).contains(&e) {
return Err(GeomError::InvalidArgument("j2_raan_drift: bad orbit or body"));
}
if ![a, e, inclination, j2, body_radius, mu].iter().all(|x| x.is_finite()) {
return Err(GeomError::InvalidArgument("j2_raan_drift: an input is not finite"));
}
let p = a * (1.0 - e * e);
let n = (mu / (a * a * a)).sqrt();
Ok(-1.5 * n * j2 * (body_radius / p).powi(2) * inclination.cos())
}
pub fn sun_synchronous_inclination(
a: f64,
e: f64,
j2: f64,
body_radius: f64,
mu: f64,
drift_per_second: f64,
) -> Result<f64, GeomError> {
if !(a > 0.0) || !(body_radius > 0.0) || !(mu > 0.0) || !(0.0..1.0).contains(&e) {
return Err(GeomError::InvalidArgument("sun_synchronous_inclination: bad orbit or body"));
}
if ![a, e, j2, body_radius, mu, drift_per_second].iter().all(|x| x.is_finite()) {
return Err(GeomError::InvalidArgument("sun_synchronous_inclination: bad input"));
}
let p = a * (1.0 - e * e);
let n = (mu / (a * a * a)).sqrt();
let coefficient = -1.5 * n * j2 * (body_radius / p).powi(2);
if coefficient == 0.0 {
return Err(GeomError::Degenerate("without oblateness there is no nodal drift to use"));
}
let cosine = drift_per_second / coefficient;
if !(-1.0..=1.0).contains(&cosine) {
return Err(GeomError::Degenerate(
"no inclination gives that drift: the orbit is too high for J2 to turn it",
));
}
Ok(cosine.acos())
}
pub fn ground_track(
r0: Vec3,
v0: Vec3,
mu: f64,
rotation_rate: f64,
duration: f64,
samples: usize,
) -> Result<Vec<(f64, f64)>, GeomError> {
if !(mu > 0.0) || !mu.is_finite() || !rotation_rate.is_finite() || !(duration > 0.0) {
return Err(GeomError::InvalidArgument("ground_track: bad parameters"));
}
if samples == 0 || samples > 1_000_000 {
return Err(GeomError::InvalidArgument("ground_track: bad sample count"));
}
let mut out = Vec::with_capacity(samples);
for step in 0..samples {
let t = duration * step as f64 / (samples - 1).max(1) as f64;
let (r, _) = crate::astrophysics::kepler::propagate_kepler(r0, v0, t, mu)?;
let magnitude = r.magnitude();
if !(magnitude > 0.0) {
return Err(GeomError::Degenerate("the track passed through the centre"));
}
let latitude = (r.z / magnitude).clamp(-1.0, 1.0).asin();
let inertial = r.y.atan2(r.x);
let longitude = wrap_pi(inertial - rotation_rate * t);
out.push((longitude, latitude));
}
Ok(out)
}
fn wrap_pi(angle: f64) -> f64 {
let tau = std::f64::consts::TAU;
let wrapped = (angle + std::f64::consts::PI).rem_euclid(tau) - std::f64::consts::PI;
if wrapped <= -std::f64::consts::PI {
wrapped + tau
} else {
wrapped
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::astrophysics::kepler::{orbit_period, state_from_elements, vis_viva};
use crate::astrophysics::orbital_elements::OrbitalElements;
const MU: f64 = 398_600.441_8;
const RE: f64 = 6378.137;
const J2: f64 = 1.082_626_68e-3;
const PI: f64 = std::f64::consts::PI;
const TAU: f64 = std::f64::consts::TAU;
#[test]
fn doing_both_at_once_beats_doing_them_one_after_the_other() {
for (v1, v2) in [(7.7f64, 7.7f64), (3.07, 1.6), (10.0, 4.0)] {
for degrees in [1.0f64, 10.0, 30.0, 60.0, 90.0, 150.0] {
let angle = degrees.to_radians();
let together = combined_maneuver(v1, v2, angle).unwrap();
let separate = (v2 - v1).abs() + 2.0 * v2 * (0.5 * angle).sin();
assert!(
together <= separate + 1e-12,
"at {degrees} degrees the combined burn cost {together} against {separate}"
);
}
if (v1 - v2).abs() < 1e-12 {
let angle = 0.7;
assert!(
(combined_maneuver(v1, v2, angle).unwrap() - 2.0 * v2 * (0.5 * angle).sin())
.abs()
< 1e-12
);
}
assert!((combined_maneuver(v1, v2, 0.0).unwrap() - (v2 - v1).abs()).abs() < 1e-12);
assert!((combined_maneuver(v1, v2, PI).unwrap() - (v1 + v2)).abs() < 1e-12);
}
assert!(combined_maneuver(-1.0, 5.0, 0.1).is_err());
assert!(combined_maneuver(1.0, f64::NAN, 0.1).is_err());
}
#[test]
fn a_sphere_of_influence_scales_as_the_two_fifths_power_of_the_mass_ratio() {
let earth_mass = 5.972e24;
let sun_mass = 1.989e30;
let au = 1.495_978_707e8;
let soi = sphere_of_influence(au, earth_mass, sun_mass).unwrap();
assert!((soi - 924_000.0).abs() < 15_000.0, "it came out at {soi} km");
let balance = au * (earth_mass / sun_mass).sqrt();
assert!((balance - 259_000.0).abs() < 5_000.0, "the balance radius was {balance}");
assert!(soi > 3.0 * balance, "the sphere of influence was only {soi}");
let base = sphere_of_influence(1.0, 1.0, 1000.0).unwrap();
for factor in [4.0f64, 32.0, 100.0] {
let scaled = sphere_of_influence(1.0, factor, 1000.0).unwrap();
assert!(
(scaled / base - factor.powf(0.4)).abs() < 1e-12,
"scaling the mass by {factor} scaled the radius by {}",
scaled / base
);
}
assert!(
(sphere_of_influence(7.0, 1.0, 1000.0).unwrap() / base - 7.0).abs() < 1e-12
);
assert!(sphere_of_influence(au, sun_mass, earth_mass).is_err());
assert!(sphere_of_influence(0.0, 1.0, 2.0).is_err());
}
#[test]
fn escaping_from_low_orbit_costs_a_fraction_of_the_speed_already_there() {
let radius = 6678.0;
let circular = (MU / radius).sqrt();
let bare = patched_conic_escape(radius, MU, 0.0).unwrap();
assert!(
(bare / circular - (2.0f64.sqrt() - 1.0)).abs() < 1e-12,
"the escape burn was {} of circular speed",
bare / circular
);
let with_excess = patched_conic_escape(radius, MU, 3.0).unwrap();
assert!(with_excess > bare);
assert!(
with_excess - bare < 1.0,
"three km/s of excess cost {} extra",
with_excess - bare
);
let high = patched_conic_escape(42_164.0, MU, 3.0).unwrap();
let low = patched_conic_escape(6678.0, MU, 3.0).unwrap();
let high_share = (high - patched_conic_escape(42_164.0, MU, 0.0).unwrap()) / 3.0;
let low_share = (low - bare) / 3.0;
assert!(low_share < high_share, "the low orbit was not the better place to burn");
assert!(patched_conic_escape(0.0, MU, 1.0).is_err());
assert!(patched_conic_escape(7000.0, MU, -1.0).is_err());
}
#[test]
fn a_flyby_turns_more_the_slower_and_closer_it_is() {
let mu_jupiter = 1.266_865_34e8;
let radius = 71_492.0;
let mut previous = PI;
for excess in [1.0f64, 3.0, 6.0, 12.0, 30.0] {
let turn = gravity_assist_deflection(excess, 2.0 * radius, mu_jupiter).unwrap();
assert!(turn > 0.0 && turn < PI, "the turn was {turn}");
assert!(turn < previous, "a faster pass turned further at {excess} km/s");
previous = turn;
}
let mut previous = 0.0;
for altitude in [20.0f64, 5.0, 2.0, 1.1] {
let turn = gravity_assist_deflection(6.0, altitude * radius, mu_jupiter).unwrap();
assert!(turn > previous, "a closer pass turned less at {altitude} radii");
previous = turn;
}
let extreme = gravity_assist_deflection(0.5, 1.01 * radius, mu_jupiter).unwrap();
assert!(extreme > 2.5, "the extreme flyby only turned {extreme} rad");
assert!(gravity_assist_deflection(0.0, radius, mu_jupiter).is_err());
assert!(gravity_assist_deflection(6.0, 0.0, mu_jupiter).is_err());
}
#[test]
fn the_same_burn_buys_more_energy_where_the_craft_is_already_fast() {
let (a, e) = (24_000.0f64, 0.7f64);
let periapsis = a * (1.0 - e);
let apoapsis = a * (1.0 + e);
let fast = vis_viva(periapsis, a, MU).unwrap();
let slow = vis_viva(apoapsis, a, MU).unwrap();
let before = -MU / (2.0 * a);
let burn = 0.5;
let at_periapsis = oberth_effect_dv(fast, burn, periapsis, MU).unwrap() - before;
let at_apoapsis = oberth_effect_dv(slow, burn, apoapsis, MU).unwrap() - before;
assert!(at_periapsis > 0.0 && at_apoapsis > 0.0);
assert!(
at_periapsis > 4.0 * at_apoapsis,
"periapsis bought {at_periapsis} against apoapsis' {at_apoapsis}"
);
assert!((at_periapsis - fast * burn - 0.5 * burn * burn).abs() < 1e-9);
assert!((oberth_effect_dv(fast, 0.0, periapsis, MU).unwrap() - before).abs() < 1e-9);
assert!(oberth_effect_dv(1.0, -2.0, 7000.0, MU).is_err());
assert!(oberth_effect_dv(1.0, 1.0, 0.0, MU).is_err());
}
#[test]
fn the_nodal_drift_is_westward_prograde_and_vanishes_at_the_pole() {
let a = RE + 500.0;
let prograde = j2_raan_drift(a, 0.0, 45f64.to_radians(), J2, RE, MU).unwrap();
assert!(prograde < 0.0, "a prograde orbit drifted eastward: {prograde}");
let polar = j2_raan_drift(a, 0.0, PI / 2.0, J2, RE, MU).unwrap();
assert!(polar.abs() < 1e-18, "a polar orbit drifted by {polar}");
let retrograde = j2_raan_drift(a, 0.0, 120f64.to_radians(), J2, RE, MU).unwrap();
assert!(retrograde > 0.0, "a retrograde orbit drifted westward: {retrograde}");
let equatorial = j2_raan_drift(a, 0.0, 0.0, J2, RE, MU).unwrap();
assert!(equatorial < prograde, "the equatorial drift was not the largest");
let per_day = prograde * 86_400.0;
assert!(
(per_day.to_degrees() + 5.4).abs() < 0.2,
"it drifted {} degrees a day",
per_day.to_degrees()
);
let high = j2_raan_drift(RE + 20_000.0, 0.0, 45f64.to_radians(), J2, RE, MU).unwrap();
assert!(high.abs() < 0.1 * prograde.abs());
assert!(j2_raan_drift(a, 1.0, 0.5, J2, RE, MU).is_err());
assert!(j2_raan_drift(0.0, 0.0, 0.5, J2, RE, MU).is_err());
}
#[test]
fn a_sun_synchronous_orbit_is_retrograde_and_near_ninety_eight_degrees() {
let yearly = TAU / 365.242_19 / 86_400.0;
for altitude in [400.0f64, 600.0, 800.0] {
let a = RE + altitude;
let inclination =
sun_synchronous_inclination(a, 0.0, J2, RE, MU, yearly).unwrap();
let degrees = inclination.to_degrees();
assert!(
(97.0..100.5).contains(°rees),
"at {altitude} km it came out at {degrees} degrees"
);
let drift = j2_raan_drift(a, 0.0, inclination, J2, RE, MU).unwrap();
assert!(
(drift - yearly).abs() < 1e-15,
"the drift was {drift} against the required {yearly}"
);
}
let low = sun_synchronous_inclination(RE + 300.0, 0.0, J2, RE, MU, yearly).unwrap();
let high = sun_synchronous_inclination(RE + 1200.0, 0.0, J2, RE, MU, yearly).unwrap();
assert!(high > low, "the higher orbit needed less inclination");
assert!(sun_synchronous_inclination(RE + 40_000.0, 0.0, J2, RE, MU, yearly).is_err());
assert!(sun_synchronous_inclination(RE + 500.0, 0.0, 0.0, RE, MU, yearly).is_err());
}
#[test]
fn a_ground_track_stays_within_its_inclination_and_walks_west() {
let inclination = 51.6f64.to_radians();
let elements = OrbitalElements {
semi_major_axis: RE + 400.0,
eccentricity: 0.0,
inclination,
longitude_ascending_node: 0.0,
argument_periapsis: 0.0,
true_anomaly: 0.0,
};
let (r0, v0) = state_from_elements(&elements, MU).unwrap();
let period = orbit_period(elements.semi_major_axis, MU).unwrap();
let rotation = TAU / 86_164.0;
let track = ground_track(r0, v0, MU, rotation, 3.0 * period, 3000).unwrap();
assert_eq!(track.len(), 3000);
let highest = track.iter().map(|(_, lat)| lat.abs()).fold(0.0f64, f64::max);
assert!(
highest <= inclination + 1e-9,
"the track reached {} degrees on a {} degree orbit",
highest.to_degrees(),
inclination.to_degrees()
);
assert!(
(highest - inclination).abs() < 1e-3,
"the track only reached {} degrees",
highest.to_degrees()
);
for (longitude, latitude) in &track {
assert!((-PI..=PI).contains(longitude));
assert!(latitude.abs() <= PI / 2.0);
}
let expected_walk = rotation * period;
let nodes: Vec<f64> = track
.windows(2)
.filter(|pair| pair[0].1 < 0.0 && pair[1].1 >= 0.0)
.map(|pair| pair[1].0)
.collect();
assert!(nodes.len() >= 2, "the track did not cross the equator twice");
let walked = wrap_pi(nodes[0] - nodes[1]);
assert!(
(walked - expected_walk).abs() < 0.02,
"it walked {walked} rad against the expected {expected_walk}"
);
assert!(walked > 0.0, "the track did not move westward");
assert!(ground_track(r0, v0, MU, rotation, 0.0, 100).is_err());
assert!(ground_track(r0, v0, MU, rotation, 1000.0, 0).is_err());
assert!(ground_track(r0, v0, 0.0, rotation, 1000.0, 100).is_err());
}
}