use crate::error::GeomError;
use crate::math::Vec3;
#[must_use]
pub fn stumpff_c(z: f64) -> f64 {
if z.abs() < 0.1 {
let mut term = 0.5;
let mut total = term;
for k in 1..8 {
term *= -z / ((2 * k + 1) as f64 * (2 * k + 2) as f64);
total += term;
}
return total;
}
if z > 0.0 {
let root = z.sqrt();
2.0 * (0.5 * root).sin().powi(2) / z
} else {
let root = (-z).sqrt();
(root.cosh() - 1.0) / (-z)
}
}
#[must_use]
pub fn stumpff_s(z: f64) -> f64 {
if z.abs() < 0.1 {
let mut term = 1.0 / 6.0;
let mut total = term;
for k in 1..8 {
term *= -z / ((2 * k + 2) as f64 * (2 * k + 3) as f64);
total += term;
}
return total;
}
if z > 0.0 {
let root = z.sqrt();
(root - root.sin()) / (z * root)
} else {
let root = (-z).sqrt();
(root.sinh() - root) / (root * root * root)
}
}
pub fn lambert_universal(
r1: Vec3,
r2: Vec3,
tof: f64,
mu: f64,
prograde: bool,
) -> Result<(Vec3, Vec3), GeomError> {
if !(mu > 0.0) || !(tof > 0.0) || !mu.is_finite() || !tof.is_finite() {
return Err(GeomError::InvalidArgument("lambert_universal: bad time or parameter"));
}
let (m1, m2) = (r1.magnitude(), r2.magnitude());
if !(m1 > 0.0) || !(m2 > 0.0) || !m1.is_finite() || !m2.is_finite() {
return Err(GeomError::InvalidArgument("lambert_universal: a position is degenerate"));
}
let cos_theta = (r1.dot(&r2) / (m1 * m2)).clamp(-1.0, 1.0);
let cross = r1.cross(&r2);
let mut theta = cos_theta.acos();
let direct = cross.z >= 0.0;
if prograde != direct {
theta = std::f64::consts::TAU - theta;
}
let sin_theta = theta.sin();
if sin_theta.abs() < 1e-12 {
return Err(GeomError::Degenerate(
"the transfer angle is zero or pi: the plane is undefined and the solution is not unique",
));
}
let a_coefficient = sin_theta * (m1 * m2 / (1.0 - cos_theta)).sqrt();
if !a_coefficient.is_finite() || a_coefficient.abs() < 1e-12 {
return Err(GeomError::Degenerate("the transfer geometry is degenerate"));
}
let y_of = |z: f64| -> f64 {
let c = stumpff_c(z);
m1 + m2 + a_coefficient * (z * stumpff_s(z) - 1.0) / c.sqrt()
};
let time_of = |z: f64| -> Option<f64> {
let y = y_of(z);
if y < 0.0 {
return None;
}
let c = stumpff_c(z);
let x = (y / c).sqrt();
let t = (x * x * x * stumpff_s(z) + a_coefficient * y.sqrt()) / mu.sqrt();
t.is_finite().then_some(t)
};
let ceiling = 4.0 * std::f64::consts::PI * std::f64::consts::PI;
let mut high = ceiling - 1e-8;
let mut low = -1.0;
let mut bracketed = false;
for _ in 0..200 {
match time_of(low) {
Some(t) if t <= tof => {
bracketed = true;
break;
}
None => {
bracketed = true;
break;
}
Some(_) => low *= 2.0,
}
if low < -1e14 {
break;
}
}
if !bracketed {
return Err(GeomError::Degenerate("no transfer is fast enough for that flight time"));
}
let Some(long) = time_of(high) else {
return Err(GeomError::Degenerate("the slow end of the bracket has no transfer"));
};
if long < tof {
return Err(GeomError::Degenerate(
"that flight time exceeds what a single-revolution transfer can take",
));
}
let mut z = 0.5 * (low + high);
for _ in 0..300 {
match time_of(z) {
Some(t) if t < tof => low = z,
Some(_) => high = z,
None => low = z,
}
z = 0.5 * (low + high);
if high - low < 1e-15 * (1.0 + z.abs()) {
break;
}
}
let y = y_of(z);
if !(y > 0.0) {
return Err(GeomError::Degenerate("the converged transfer has no positive chord"));
}
let f = 1.0 - y / m1;
let g = a_coefficient * (y / mu).sqrt();
let g_dot = 1.0 - y / m2;
if !(g.abs() > 0.0) || !g.is_finite() {
return Err(GeomError::Degenerate("the transfer's g coefficient vanished"));
}
let v1 = Vec3::new((r2.x - f * r1.x) / g, (r2.y - f * r1.y) / g, (r2.z - f * r1.z) / g);
let v2 = Vec3::new(
(g_dot * r2.x - r1.x) / g,
(g_dot * r2.y - r1.y) / g,
(g_dot * r2.z - r1.z) / g,
);
if !v1.magnitude().is_finite() || !v2.magnitude().is_finite() {
return Err(GeomError::Degenerate("the transfer velocities are not finite"));
}
Ok((v1, v2))
}
pub type Ephemeris = (f64, Vec3, Vec3);
pub fn porkchop_data(
departures: &[Ephemeris],
arrivals: &[Ephemeris],
mu: f64,
prograde: bool,
) -> Result<Vec<Vec<Option<f64>>>, GeomError> {
if departures.is_empty() || arrivals.is_empty() || !(mu > 0.0) || !mu.is_finite() {
return Err(GeomError::InvalidArgument("porkchop_data: bad grid or parameter"));
}
if departures.len().saturating_mul(arrivals.len()) > 1_000_000 {
return Err(GeomError::InvalidArgument("porkchop_data: that grid is too large"));
}
Ok(departures
.iter()
.map(|(t0, r0, v0)| {
arrivals
.iter()
.map(|(t1, r1, _)| {
let tof = t1 - t0;
if !(tof > 0.0) {
return None;
}
let (depart, _) = lambert_universal(*r0, *r1, tof, mu, prograde).ok()?;
let excess = Vec3::new(depart.x - v0.x, depart.y - v0.y, depart.z - v0.z);
Some(excess.magnitude_squared())
})
.collect()
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::astrophysics::kepler::{orbit_period, propagate_kepler, state_from_elements};
use crate::astrophysics::orbital_elements::OrbitalElements;
use crate::monte_carlo::Rng;
const MU: f64 = 398_600.441_8;
const TAU: f64 = std::f64::consts::TAU;
const PI: f64 = std::f64::consts::PI;
fn distance(a: Vec3, b: Vec3) -> f64 {
((a.x - b.x).powi(2) + (a.y - b.y).powi(2) + (a.z - b.z).powi(2)).sqrt()
}
#[test]
fn the_stumpff_functions_are_continuous_where_the_series_takes_over() {
for boundary in [0.1f64, -0.1] {
let inside = stumpff_c(boundary - boundary.signum() * 1e-12);
let outside = stumpff_c(boundary + boundary.signum() * 1e-12);
assert!(
(inside - outside).abs() < 1e-12,
"C jumped at {boundary}: {inside} against {outside}"
);
let inside = stumpff_s(boundary - boundary.signum() * 1e-12);
let outside = stumpff_s(boundary + boundary.signum() * 1e-12);
assert!(
(inside - outside).abs() < 1e-12,
"S jumped at {boundary}: {inside} against {outside}"
);
}
assert!((stumpff_c(0.0) - 0.5).abs() < 1e-17);
assert!((stumpff_s(0.0) - 1.0 / 6.0).abs() < 1e-17);
for z in [-40.0f64, -5.0, -0.5, 0.5, 5.0, 39.0] {
let series = |first: f64, step: fn(usize) -> f64| {
let mut term = first;
let mut total = term;
for k in 1..40 {
term *= -z / step(k);
total += term;
}
total
};
let c = series(0.5, |k| (2 * k + 1) as f64 * (2 * k + 2) as f64);
assert!(
(stumpff_c(z) - c).abs() < 1e-10 * stumpff_c(z).abs().max(1.0),
"C({z}) was {} against the series' {c}",
stumpff_c(z)
);
let sn = series(1.0 / 6.0, |k| (2 * k + 2) as f64 * (2 * k + 3) as f64);
assert!(
(stumpff_s(z) - sn).abs() < 1e-10 * stumpff_s(z).abs().max(1.0),
"S({z}) was {} against the series' {sn}",
stumpff_s(z)
);
}
}
#[test]
fn the_positive_branch_survives_the_single_revolution_boundary() {
let ceiling = 4.0 * PI * PI;
for offset in [1e-4f64, 1e-6, 1e-8, 1e-10] {
let c = stumpff_c(ceiling - offset);
assert!(c > 0.0 && c.is_finite(), "C was {c} at {offset} below the boundary");
let expected = offset * offset / (128.0 * PI.powi(4));
assert!(
(c / expected - 1.0).abs() < 1e-3,
"C was {c} against the expected {expected}"
);
}
assert!(stumpff_s(ceiling - 1e-8) > 0.0);
}
#[test]
fn lambert_reproduces_the_textbook_transfer() {
let r1 = Vec3::new(15945.34, 0.0, 0.0);
let r2 = Vec3::new(12_214.838_99, 10_249.467_31, 0.0);
let (v1, v2) = lambert_universal(r1, r2, 76.0 * 60.0, MU, true).unwrap();
assert!((v1.x - 2.058_913).abs() < 1e-5, "v1.x was {}", v1.x);
assert!((v1.y - 2.915_965).abs() < 1e-5, "v1.y was {}", v1.y);
assert!(v1.z.abs() < 1e-12, "the transfer left the plane");
assert!((v2.x - -3.451_569).abs() < 1e-4, "v2.x was {}", v2.x);
assert!((v2.y - 0.910_301).abs() < 1e-4, "v2.y was {}", v2.y);
assert!(v2.z.abs() < 1e-12);
}
#[test]
fn a_lambert_solution_propagates_to_the_target_it_was_solved_for() {
let mut rng = Rng::new(0x0A57_3001);
for _ in 0..300 {
let elements = OrbitalElements {
semi_major_axis: 8000.0 + 30000.0 * rng.next_f64(),
eccentricity: 0.7 * rng.next_f64(),
inclination: 0.1 + 2.9 * rng.next_f64(),
longitude_ascending_node: TAU * rng.next_f64(),
argument_periapsis: TAU * rng.next_f64(),
true_anomaly: TAU * rng.next_f64(),
};
let (r_a, v_a) = state_from_elements(&elements, MU).unwrap();
let period = orbit_period(elements.semi_major_axis, MU).unwrap();
let tof = period * (0.05 + 0.5 * rng.next_f64());
let (r_b, v_b) = propagate_kepler(r_a, v_a, tof, MU).unwrap();
let prograde = r_a.cross(&v_a).z >= 0.0;
let (s1, s2) = lambert_universal(r_a, r_b, tof, MU, prograde).unwrap();
assert!(
distance(s1, v_a) < 1e-9 * v_a.magnitude(),
"the departure velocity was off by {}",
distance(s1, v_a)
);
assert!(distance(s2, v_b) < 1e-9 * v_b.magnitude());
let (landed, _) = propagate_kepler(r_a, s1, tof, MU).unwrap();
assert!(distance(landed, r_b) < 1e-7 * r_b.magnitude());
}
}
#[test]
fn there_is_a_cheapest_flight_time_and_it_is_not_at_either_end() {
let r_a = Vec3::new(10000.0, 0.0, 0.0);
let r_b = Vec3::new(0.0, 12000.0, 0.0);
let times: Vec<f64> =
(0..60).map(|k| 300.0 * (1.0 + 0.12f64).powi(k)).take_while(|t| *t < 3e5).collect();
let speeds: Vec<f64> = times
.iter()
.map(|t| lambert_universal(r_a, r_b, *t, MU, true).unwrap().0.magnitude())
.collect();
let cheapest = speeds
.iter()
.enumerate()
.min_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.map(|(index, _)| index)
.unwrap();
assert!(cheapest > 0 && cheapest < speeds.len() - 1, "the minimum was at an end");
for pair in speeds[..=cheapest].windows(2) {
assert!(pair[1] <= pair[0], "the cost rose before the minimum");
}
for pair in speeds[cheapest..].windows(2) {
assert!(pair[1] >= pair[0], "the cost fell after the minimum");
}
let (fast, _) = lambert_universal(r_a, r_b, 200.0, MU, true).unwrap();
assert!(0.5 * fast.magnitude_squared() - MU / r_a.magnitude() > 0.0, "not hyperbolic");
let (slow, _) = lambert_universal(r_a, r_b, 20000.0, MU, true).unwrap();
assert!(0.5 * slow.magnitude_squared() - MU / r_a.magnitude() < 0.0, "not elliptic");
}
#[test]
fn going_the_long_way_round_is_a_different_orbit() {
let r_a = Vec3::new(10000.0, 0.0, 0.0);
let r_b = Vec3::new(0.0, 15000.0, 0.0);
let (short, _) = lambert_universal(r_a, r_b, 5000.0, MU, true).unwrap();
let (long, _) = lambert_universal(r_a, r_b, 5000.0, MU, false).unwrap();
assert!(
distance(short, long) > 0.01,
"the two directions gave the same velocity: {short:?} and {long:?}"
);
assert!(r_a.cross(&short).z > 0.0, "the prograde transfer went the wrong way");
assert!(r_a.cross(&long).z < 0.0, "the retrograde transfer went the wrong way");
}
#[test]
fn a_degenerate_geometry_is_refused_rather_than_guessed_at() {
let r = Vec3::new(10000.0, 0.0, 0.0);
assert!(lambert_universal(r, r, 1000.0, MU, true).is_err());
let opposite = Vec3::new(-12000.0, 0.0, 0.0);
assert!(lambert_universal(r, opposite, 5000.0, MU, true).is_err());
let nearly = Vec3::new(-12000.0, 1.0, 0.0);
assert!(lambert_universal(r, nearly, 5000.0, MU, true).is_ok());
assert!(lambert_universal(r, Vec3::new(0.0, 12000.0, 0.0), 0.0, MU, true).is_err());
assert!(lambert_universal(r, Vec3::new(0.0, 12000.0, 0.0), -100.0, MU, true).is_err());
assert!(lambert_universal(r, Vec3::new(0.0, 12000.0, 0.0), 1000.0, 0.0, true).is_err());
let origin = Vec3::new(0.0, 0.0, 0.0);
assert!(lambert_universal(origin, r, 1000.0, MU, true).is_err());
}
#[test]
fn a_porkchop_grid_is_cheapest_near_the_transfer_it_wants() {
let departures: Vec<Ephemeris> = (0..5)
.map(|i| (i as f64 * 600.0, Vec3::new(10000.0, 0.0, 0.0), Vec3::new(0.0, 6.3, 0.0)))
.collect();
let arrivals: Vec<Ephemeris> = (0..5)
.map(|j| {
(3000.0 + j as f64 * 900.0, Vec3::new(0.0, 15000.0, 0.0), Vec3::new(-5.1, 0.0, 0.0))
})
.collect();
let grid = porkchop_data(&departures, &arrivals, MU, true).unwrap();
assert_eq!(grid.len(), 5);
assert!(grid.iter().all(|row| row.len() == 5));
let mut best = f64::INFINITY;
for row in &grid {
for c3 in row.iter().flatten() {
assert!(*c3 >= 0.0 && c3.is_finite(), "a characteristic energy was {c3}");
best = best.min(*c3);
}
}
assert!(best.is_finite() && best < 5.0, "the best departure cost {best}");
let backwards: Vec<Ephemeris> =
vec![(0.0, Vec3::new(0.0, 15000.0, 0.0), Vec3::new(-5.1, 0.0, 0.0))];
let empty = porkchop_data(&departures, &backwards, MU, true).unwrap();
assert!(empty.iter().all(|row| row.iter().all(Option::is_none)));
assert!(porkchop_data(&[], &arrivals, MU, true).is_err());
assert!(porkchop_data(&departures, &[], MU, true).is_err());
assert!(porkchop_data(&departures, &arrivals, 0.0, true).is_err());
}
}