use serde::Serialize;
use std::f64::consts::PI;
type Vec3 = [f64; 3];
pub const G0: f64 = 9.806_65;
pub const MU_SUN: f64 = 1.327_124_400_18e20;
pub const AU_M: f64 = 1.495_978_707e11;
fn add(a: Vec3, b: Vec3) -> Vec3 {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn sub(a: Vec3, b: Vec3) -> Vec3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn scale(a: Vec3, s: f64) -> Vec3 {
[a[0] * s, a[1] * s, a[2] * s]
}
fn dot(a: Vec3, b: Vec3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: Vec3, b: Vec3) -> Vec3 {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn norm(a: Vec3) -> f64 {
dot(a, a).sqrt()
}
fn unit(a: Vec3) -> Vec3 {
let n = norm(a);
[a[0] / n, a[1] / n, a[2] / n]
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ManeuverFrame {
Eci,
Lvlh,
}
#[derive(Clone, Copy, Debug)]
pub struct ImpulsiveManeuver {
pub dv: Vec3,
pub frame: ManeuverFrame,
pub exec_cov: [[f64; 3]; 3],
}
pub fn lvlh_to_eci(r: Vec3, v: Vec3) -> [[f64; 3]; 3] {
let ur = unit(r);
let uh = unit(cross(r, v));
let ut = cross(uh, ur);
[ur, ut, uh]
}
fn rotate(m: &[[f64; 3]; 3], x: Vec3) -> Vec3 {
[
m[0][0] * x[0] + m[1][0] * x[1] + m[2][0] * x[2],
m[0][1] * x[0] + m[1][1] * x[1] + m[2][1] * x[2],
m[0][2] * x[0] + m[1][2] * x[1] + m[2][2] * x[2],
]
}
pub fn apply_impulse(
state: [f64; 6],
cov: [[f64; 6]; 6],
man: &ImpulsiveManeuver,
) -> ([f64; 6], [[f64; 6]; 6]) {
let r = [state[0], state[1], state[2]];
let v = [state[3], state[4], state[5]];
let (dv_eci, q_eci) = match man.frame {
ManeuverFrame::Eci => (man.dv, man.exec_cov),
ManeuverFrame::Lvlh => {
let m = lvlh_to_eci(r, v);
let mut q = [[0.0f64; 3]; 3];
for (i, qi) in q.iter_mut().enumerate() {
for (j, qij) in qi.iter_mut().enumerate() {
let mut s = 0.0;
for (a, ma) in man.exec_cov.iter().enumerate() {
for (b, &mab) in ma.iter().enumerate() {
s += m[a][i] * mab * m[b][j];
}
}
*qij = s;
}
}
(rotate(&m, man.dv), q)
}
};
let mut out = state;
out[3] += dv_eci[0];
out[4] += dv_eci[1];
out[5] += dv_eci[2];
let mut cov_after = cov;
for i in 0..3 {
for j in 0..3 {
cov_after[3 + i][3 + j] += q_eci[i][j];
}
}
(out, cov_after)
}
#[derive(Clone, Copy, Debug)]
pub struct FiniteBurn {
pub thrust_n: f64,
pub isp_s: f64,
pub m0_kg: f64,
pub burn_s: f64,
pub dir: Vec3,
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct FiniteBurnResult {
pub dv_ms: f64,
pub mf_kg: f64,
pub tsiolkovsky_ms: f64,
pub rel_err: f64,
}
pub fn tsiolkovsky(isp_s: f64, m0_kg: f64, mf_kg: f64) -> f64 {
isp_s * G0 * (m0_kg / mf_kg).ln()
}
pub fn integrate_finite_burn(burn: &FiniteBurn, steps: usize) -> Result<FiniteBurnResult, String> {
let mdot = burn.thrust_n / (burn.isp_s * G0);
let mf = burn.m0_kg - mdot * burn.burn_s;
if mf <= 0.0 {
return Err(format!(
"burn exhausts mass: m0={} kg, ṁ={} kg/s, burn={} s ⇒ mf={} kg",
burn.m0_kg, mdot, burn.burn_s, mf
));
}
let d = unit(burn.dir);
let h = burn.burn_s / steps as f64;
let deriv = |_t: f64, y: &[f64]| -> Vec<f64> {
let m = y[3];
let a = burn.thrust_n / m;
vec![a * d[0], a * d[1], a * d[2], -mdot]
};
let mut y = vec![0.0, 0.0, 0.0, burn.m0_kg];
let mut t = 0.0;
for _ in 0..steps {
y = crate::integrator::rk4_step(&deriv, t, &y, h);
t += h;
}
let dv_ms = norm([y[0], y[1], y[2]]);
let tsiol = tsiolkovsky(burn.isp_s, burn.m0_kg, mf);
let rel_err = (dv_ms - tsiol).abs() / tsiol;
Ok(FiniteBurnResult {
dv_ms,
mf_kg: mf,
tsiolkovsky_ms: tsiol,
rel_err,
})
}
fn stumpff(psi: f64) -> (f64, f64) {
if psi > 1e-6 {
let s = psi.sqrt();
((1.0 - s.cos()) / psi, (s - s.sin()) / (psi * s))
} else if psi < -1e-6 {
let s = (-psi).sqrt();
((s.cosh() - 1.0) / (-psi), (s.sinh() - s) / ((-psi) * s))
} else {
(
0.5 - psi / 24.0 + psi * psi / 720.0,
1.0 / 6.0 - psi / 120.0 + psi * psi / 5040.0,
)
}
}
pub fn kepler_universal(r0: Vec3, v0: Vec3, dt: f64, mu: f64) -> (Vec3, Vec3) {
let sqrt_mu = mu.sqrt();
let r0n = norm(r0);
let v0n = norm(v0);
let rv0 = dot(r0, v0); let alpha = 2.0 / r0n - v0n * v0n / mu; let mut chi = if alpha > 1e-9 {
sqrt_mu * dt * alpha
} else if alpha < -1e-9 {
let a = 1.0 / alpha;
dt.signum()
* (-a).sqrt()
* ((-2.0 * mu * alpha * dt)
/ (dot(r0, v0) + dt.signum() * (-mu * a).sqrt() * (1.0 - r0n * alpha)))
.ln()
} else {
sqrt_mu * dt / r0n
};
for _ in 0..100 {
let psi = chi * chi * alpha;
let (c2, c3) = stumpff(psi);
let r = chi * chi * c2 + (rv0 / sqrt_mu) * chi * (1.0 - psi * c3) + r0n * (1.0 - psi * c2);
let f = sqrt_mu * dt
- chi * chi * chi * c3
- (rv0 / sqrt_mu) * chi * chi * c2
- r0n * chi * (1.0 - psi * c3);
let dchi = f / r;
chi += dchi;
if dchi.abs() < 1e-10 {
break;
}
}
let psi = chi * chi * alpha;
let (c2, c3) = stumpff(psi);
let f = 1.0 - chi * chi / r0n * c2;
let g = dt - chi * chi * chi / sqrt_mu * c3;
let r_vec = add(scale(r0, f), scale(v0, g));
let rn = norm(r_vec);
let fdot = sqrt_mu / (rn * r0n) * chi * (psi * c3 - 1.0);
let gdot = 1.0 - chi * chi / rn * c2;
let v_vec = add(scale(r0, fdot), scale(v0, gdot));
(r_vec, v_vec)
}
fn hyp2f1b(x: f64) -> f64 {
if x >= 1.0 {
return f64::INFINITY;
}
let mut res = 1.0_f64;
let mut term = 1.0_f64;
let mut i = 0.0_f64;
loop {
term *= (3.0 + i) * (1.0 + i) / (2.5 + i) * x / (i + 1.0);
let newres = res + term;
if newres == res {
return newres;
}
res = newres;
i += 1.0;
}
}
fn compute_y(x: f64, ll: f64) -> f64 {
(1.0 - ll * ll * (1.0 - x * x)).sqrt()
}
fn compute_psi(x: f64, y: f64, ll: f64) -> f64 {
if (-1.0..1.0).contains(&x) {
(x * y + ll * (1.0 - x * x)).acos()
} else if x > 1.0 {
((y - x * ll) * (x * x - 1.0).sqrt()).asinh()
} else {
0.0
}
}
fn tof_equation_y(x: f64, y: f64, t0: f64, ll: f64) -> f64 {
let tof = if (0.6_f64).sqrt() < x && x < (1.4_f64).sqrt() {
let eta = y - ll * x;
let s1 = 0.5 * (1.0 - ll - x * eta);
let q = 4.0 / 3.0 * hyp2f1b(s1);
0.5 * (eta * eta * eta * q + 4.0 * ll * eta)
} else {
let psi = compute_psi(x, y, ll);
(psi / (1.0 - x * x).abs().sqrt() - x + ll * y) / (1.0 - x * x)
};
tof - t0
}
fn tof_eq_p(x: f64, y: f64, tof: f64, ll: f64) -> f64 {
(3.0 * tof * x - 2.0 + 2.0 * ll * ll * ll * x / y) / (1.0 - x * x)
}
fn tof_eq_p2(x: f64, y: f64, tof: f64, dt: f64, ll: f64) -> f64 {
(3.0 * tof + 5.0 * x * dt + 2.0 * (1.0 - ll * ll) * ll * ll * ll / (y * y * y)) / (1.0 - x * x)
}
fn tof_eq_p3(x: f64, y: f64, _tof: f64, dt: f64, ddt: f64, ll: f64) -> f64 {
(7.0 * x * ddt + 8.0 * dt - 6.0 * (1.0 - ll * ll) * ll.powi(5) * x / y.powi(5)) / (1.0 - x * x)
}
fn householder(x0: f64, big_t: f64, ll: f64) -> f64 {
let mut x = x0;
for _ in 0..35 {
let y = compute_y(x, ll);
let fval = tof_equation_y(x, y, big_t, ll);
let tof = fval + big_t;
let fp = tof_eq_p(x, y, tof, ll);
let fpp = tof_eq_p2(x, y, tof, fp, ll);
let fppp = tof_eq_p3(x, y, tof, fp, fpp, ll);
let denom = fp * (fp * fp - fval * fpp) + fppp * fval * fval / 6.0;
let dx = fval * (fp * fp - fval * fpp / 2.0) / denom;
x -= dx;
if dx.abs() < 1e-12 {
break;
}
}
x
}
pub fn lambert(
r1: Vec3,
r2: Vec3,
tof: f64,
mu: f64,
prograde: bool,
) -> Result<(Vec3, Vec3), String> {
if tof <= 0.0 {
return Err("time of flight must be positive".into());
}
let r1n = norm(r1);
let r2n = norm(r2);
let c_vec = sub(r2, r1);
let c = norm(c_vec);
let s = 0.5 * (r1n + r2n + c);
let ir1 = unit(r1);
let ir2 = unit(r2);
let cross12 = cross(ir1, ir2);
let cross_n = norm(cross12);
if cross_n < 1e-12 {
return Err("collinear endpoints: transfer plane is undefined (≈0°/180°)".into());
}
let ih = scale(cross12, 1.0 / cross_n);
let mut ll = (1.0 - (c / s).min(1.0)).sqrt();
let (mut it1, mut it2);
if ih[2] < 0.0 {
ll = -ll;
it1 = cross(ir1, ih);
it2 = cross(ir2, ih);
} else {
it1 = cross(ih, ir1);
it2 = cross(ih, ir2);
}
if !prograde {
ll = -ll;
it1 = scale(it1, -1.0);
it2 = scale(it2, -1.0);
}
it1 = unit(it1);
it2 = unit(it2);
let big_t = (2.0 * mu / (s * s * s)).sqrt() * tof;
let t0 = ll.acos() + ll * (1.0 - ll * ll).sqrt(); let t1 = 2.0 / 3.0 * (1.0 - ll * ll * ll); let x0 = if big_t >= t0 {
(t0 / big_t).powf(2.0 / 3.0) - 1.0
} else if big_t < t1 {
2.5 * t1 / big_t * (t1 - big_t) / (1.0 - ll.powi(5)) + 1.0
} else {
(t0 / big_t).powf((t1 / t0).log2()) - 1.0
};
let x = householder(x0, big_t, ll);
let y = compute_y(x, ll);
let gamma = (mu * s / 2.0).sqrt();
let rho = (r1n - r2n) / c;
let sigma = (1.0 - rho * rho).sqrt();
let vr1 = gamma * ((ll * y - x) - rho * (ll * y + x)) / r1n;
let vr2 = -gamma * ((ll * y - x) + rho * (ll * y + x)) / r2n;
let vt1 = gamma * sigma * (y + ll * x) / r1n;
let vt2 = gamma * sigma * (y + ll * x) / r2n;
let v1 = add(scale(ir1, vr1), scale(it1, vt1));
let v2 = add(scale(ir2, vr2), scale(it2, vt2));
Ok((v1, v2))
}
#[derive(Clone, Copy, Debug)]
pub struct CircularCoplanarBody {
pub radius_m: f64,
pub phase0_rad: f64,
pub mu_central: f64,
}
impl CircularCoplanarBody {
pub fn angular_rate(&self) -> f64 {
(self.mu_central / self.radius_m.powi(3)).sqrt()
}
pub fn position(&self, t: f64) -> Vec3 {
let th = self.phase0_rad + self.angular_rate() * t;
[self.radius_m * th.cos(), self.radius_m * th.sin(), 0.0]
}
pub fn velocity(&self, t: f64) -> Vec3 {
let n = self.angular_rate();
let th = self.phase0_rad + n * t;
let speed = self.radius_m * n;
[-speed * th.sin(), speed * th.cos(), 0.0]
}
}
#[derive(Clone, Debug, Serialize)]
pub struct PorkchopGrid {
pub dep_epochs_s: Vec<f64>,
pub arr_epochs_s: Vec<f64>,
pub c3_km2s2: Vec<Vec<f64>>,
pub vinf_arr_kms: Vec<Vec<f64>>,
}
impl PorkchopGrid {
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self)
.expect("PorkchopGrid (only Vec<f64>/Vec<Vec<f64>> fields) always serialises")
}
pub fn min_c3(&self) -> Option<(f64, usize, usize)> {
let mut best: Option<(f64, usize, usize)> = None;
for (i, row) in self.c3_km2s2.iter().enumerate() {
for (j, &c3) in row.iter().enumerate() {
let better = match best {
None => true,
Some((b, _, _)) => c3 < b,
};
if c3.is_finite() && better {
best = Some((c3, i, j));
}
}
}
best
}
}
pub fn porkchop(
dep: &CircularCoplanarBody,
arr: &CircularCoplanarBody,
dep_epochs_s: &[f64],
arr_epochs_s: &[f64],
mu_helio: f64,
) -> PorkchopGrid {
let collinear_guard = (0.5_f64).to_radians();
let mut c3 = vec![vec![f64::NAN; arr_epochs_s.len()]; dep_epochs_s.len()];
let mut vinf = vec![vec![f64::NAN; arr_epochs_s.len()]; dep_epochs_s.len()];
for (i, &td) in dep_epochs_s.iter().enumerate() {
for (j, &ta) in arr_epochs_s.iter().enumerate() {
let tof = ta - td;
if tof <= 0.0 {
continue;
}
let r1 = dep.position(td);
let r2 = arr.position(ta);
let cosang = (dot(r1, r2) / (norm(r1) * norm(r2))).clamp(-1.0, 1.0);
let ang = cosang.acos();
if ang < collinear_guard || (PI - ang) < collinear_guard {
continue;
}
if let Ok((v1, v2)) = lambert(r1, r2, tof, mu_helio, true) {
let vinf_dep = sub(v1, dep.velocity(td));
let vinf_arr = sub(v2, arr.velocity(ta));
c3[i][j] = dot(vinf_dep, vinf_dep) / 1.0e6; vinf[i][j] = norm(vinf_arr) / 1000.0; }
}
}
PorkchopGrid {
dep_epochs_s: dep_epochs_s.to_vec(),
arr_epochs_s: arr_epochs_s.to_vec(),
c3_km2s2: c3,
vinf_arr_kms: vinf,
}
}
pub fn hohmann_departure_c3(r1: f64, r2: f64, mu: f64) -> f64 {
let a_t = 0.5 * (r1 + r2);
let v_circ1 = (mu / r1).sqrt();
let v_peri = (mu * (2.0 / r1 - 1.0 / a_t)).sqrt();
let vinf = v_peri - v_circ1;
vinf * vinf
}
pub fn hohmann_tof(r1: f64, r2: f64, mu: f64) -> f64 {
let a_t = 0.5 * (r1 + r2);
PI * (a_t * a_t * a_t / mu).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
fn approx(a: f64, b: f64, tol: f64) {
assert!((a - b).abs() <= tol, "expected {a} ≈ {b} (tol {tol})");
}
#[test]
fn impulsive_eci_dv_jumps_velocity_and_adds_only_velocity_covariance() {
let state = [7.0e6, 0.0, 0.0, 0.0, 7.5e3, 0.0];
let mut cov = [[0.0f64; 6]; 6];
for (i, row) in cov.iter_mut().enumerate() {
row[i] = (i as f64 + 1.0) * 10.0; }
let man = ImpulsiveManeuver {
dv: [10.0, -5.0, 2.0],
frame: ManeuverFrame::Eci,
exec_cov: [[0.04, 0.0, 0.0], [0.0, 0.09, 0.0], [0.0, 0.0, 0.01]],
};
let (out, p) = apply_impulse(state, cov, &man);
approx(out[0], 7.0e6, 0.0);
approx(out[3], 10.0, 1e-12);
approx(out[4], 7.5e3 - 5.0, 1e-9);
approx(out[5], 2.0, 1e-12);
for i in 0..3 {
for j in 0..3 {
approx(p[i][j], cov[i][j], 1e-12);
}
}
approx(p[3][3], cov[3][3] + 0.04, 1e-12);
approx(p[4][4], cov[4][4] + 0.09, 1e-12);
approx(p[5][5], cov[5][5] + 0.01, 1e-12);
}
#[test]
fn lvlh_execution_covariance_is_rotated_trace_preserving_and_symmetric() {
let state = [7.0e6, 1.0e6, 5.0e5, 1.0e3, 7.2e3, 1.5e3];
let cov = [[0.0f64; 6]; 6];
let (a, b, c) = (0.05, 0.20, 0.02);
let man = ImpulsiveManeuver {
dv: [3.0, 0.0, 0.0],
frame: ManeuverFrame::Lvlh,
exec_cov: [[a, 0.0, 0.0], [0.0, b, 0.0], [0.0, 0.0, c]],
};
let (_out, p) = apply_impulse(state, cov, &man);
let tr = p[3][3] + p[4][4] + p[5][5];
approx(tr, a + b + c, 1e-12);
approx(p[3][4], p[4][3], 1e-15);
approx(p[3][5], p[5][3], 1e-15);
approx(p[4][5], p[5][4], 1e-15);
let r = [state[0], state[1], state[2]];
let v = [state[3], state[4], state[5]];
let m = lvlh_to_eci(r, v);
for col in &m {
approx(norm(*col), 1.0, 1e-12);
}
approx(dot(m[0], m[1]), 0.0, 1e-12);
approx(dot(m[0], m[2]), 0.0, 1e-12);
approx(dot(m[1], m[2]), 0.0, 1e-12);
}
#[test]
fn finite_burn_matches_tsiolkovsky_to_better_than_a_hundredth_percent() {
let burn = FiniteBurn {
thrust_n: 500.0,
isp_s: 300.0,
m0_kg: 1000.0,
burn_s: 100.0,
dir: [0.0, 1.0, 0.0],
};
let res = integrate_finite_burn(&burn, 2000).unwrap();
let mdot = 500.0 / (300.0 * G0);
approx(res.mf_kg, 1000.0 - mdot * 100.0, 1e-9);
approx(
res.tsiolkovsky_ms,
tsiolkovsky(300.0, 1000.0, res.mf_kg),
0.0,
);
approx(res.tsiolkovsky_ms, 50.43, 0.05);
assert!(
res.rel_err < 1e-4,
"finite-burn ΔV rel err {} ≥ 1e-4",
res.rel_err
);
}
#[test]
fn finite_burn_errors_when_mass_is_exhausted() {
let burn = FiniteBurn {
thrust_n: 5000.0,
isp_s: 200.0,
m0_kg: 100.0,
burn_s: 100.0,
dir: [1.0, 0.0, 0.0],
};
assert!(integrate_finite_burn(&burn, 100).is_err());
}
#[test]
fn kepler_universal_round_trips_forward_then_back() {
let r0 = [7.0e6, 0.0, 0.0];
let v0 = [0.0, 8.0e3, 1.0e3];
let mu = crate::orbit::MU_EARTH;
let (r1, v1) = kepler_universal(r0, v0, 2000.0, mu);
let (r2, v2) = kepler_universal(r1, v1, -2000.0, mu);
approx(norm(sub(r2, r0)), 0.0, 1e-3);
approx(norm(sub(v2, v0)), 0.0, 1e-6);
}
#[test]
fn lambert_recovers_the_velocities_of_a_known_two_body_arc() {
let mu = crate::orbit::MU_EARTH;
let r1 = [7.0e6, 0.0, 0.0];
let v1_true = [0.0, 8.0e3, 1.0e3]; let tof = 2000.0;
let (r2, v2_true) = kepler_universal(r1, v1_true, tof, mu);
let (v1, v2) = lambert(r1, r2, tof, mu, true).unwrap();
approx(norm(sub(v1, v1_true)), 0.0, 1e-3);
approx(norm(sub(v2, v2_true)), 0.0, 1e-3);
}
#[test]
fn lambert_output_propagates_back_onto_r2() {
let mu = MU_SUN;
let r1 = [1.0 * AU_M, 0.2 * AU_M, 0.0];
let r2 = [-0.3 * AU_M, 1.3 * AU_M, 0.05 * AU_M];
let tof = 200.0 * 86400.0;
let (v1, v2) = lambert(r1, r2, tof, mu, true).unwrap();
let (r_end, v_end) = kepler_universal(r1, v1, tof, mu);
approx(norm(sub(r_end, r2)), 0.0, 1.0e3); approx(norm(sub(v_end, v2)), 0.0, 1e-3);
}
#[test]
fn lambert_rejects_collinear_endpoints() {
let mu = crate::orbit::MU_EARTH;
let r1 = [7.0e6, 0.0, 0.0];
let r2 = [-9.0e6, 0.0, 0.0]; assert!(lambert(r1, r2, 3000.0, mu, true).is_err());
}
#[test]
fn porkchop_cells_are_round_trip_consistent_and_min_is_near_the_hohmann_floor() {
let r1 = AU_M;
let r2 = 1.524 * AU_M;
let tof_h = hohmann_tof(r1, r2, MU_SUN);
let dep = CircularCoplanarBody {
radius_m: r1,
phase0_rad: 0.0,
mu_central: MU_SUN,
};
let n2 = (MU_SUN / r2.powi(3)).sqrt();
let arr = CircularCoplanarBody {
radius_m: r2,
phase0_rad: PI - n2 * tof_h,
mu_central: MU_SUN,
};
let day = 86400.0;
let dep_epochs: Vec<f64> = (-20..=20).map(|k| k as f64 * 5.0 * day).collect();
let arr_epochs: Vec<f64> = (0..=40)
.map(|k| tof_h - 100.0 * day + k as f64 * 5.0 * day)
.collect();
let grid = porkchop(&dep, &arr, &dep_epochs, &arr_epochs, MU_SUN);
let mut finite_cells = 0;
for (i, &td) in dep_epochs.iter().enumerate() {
for (j, &ta) in arr_epochs.iter().enumerate() {
if grid.c3_km2s2[i][j].is_finite() {
finite_cells += 1;
let tof = ta - td;
let rr1 = dep.position(td);
let rr2 = arr.position(ta);
let (v1, _) = lambert(rr1, rr2, tof, MU_SUN, true).unwrap();
let (r_end, _) = kepler_universal(rr1, v1, tof, MU_SUN);
assert!(
norm(sub(r_end, rr2)) < 5.0e3,
"cell ({i},{j}) round-trip miss {} m",
norm(sub(r_end, rr2))
);
}
}
}
assert!(finite_cells > 100, "too few finite cells: {finite_cells}");
let c3_floor = hohmann_departure_c3(r1, r2, MU_SUN) / 1.0e6; let (min_c3, mi, mj) = grid.min_c3().unwrap();
assert!(
min_c3 >= c3_floor - 1e-6,
"grid min C3 {min_c3} below Hohmann floor {c3_floor}"
);
assert!(
min_c3 < c3_floor * 1.05,
"grid min C3 {min_c3} not within 5% of Hohmann floor {c3_floor}"
);
let tof_min = arr_epochs[mj] - dep_epochs[mi];
approx(tof_min, tof_h, 60.0 * day);
}
#[test]
fn porkchop_json_round_trips_with_matching_dimensions() {
let dep = CircularCoplanarBody {
radius_m: AU_M,
phase0_rad: 0.0,
mu_central: MU_SUN,
};
let arr = CircularCoplanarBody {
radius_m: 1.524 * AU_M,
phase0_rad: 1.0,
mu_central: MU_SUN,
};
let day = 86400.0;
let dep_epochs: Vec<f64> = (0..5).map(|k| k as f64 * 10.0 * day).collect();
let arr_epochs: Vec<f64> = (0..6).map(|k| (200 + k * 10) as f64 * day).collect();
let grid = porkchop(&dep, &arr, &dep_epochs, &arr_epochs, MU_SUN);
let json = grid.to_json();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["dep_epochs_s"].as_array().unwrap().len(), 5);
assert_eq!(parsed["arr_epochs_s"].as_array().unwrap().len(), 6);
assert_eq!(parsed["c3_km2s2"].as_array().unwrap().len(), 5);
assert_eq!(parsed["c3_km2s2"][0].as_array().unwrap().len(), 6);
assert_eq!(parsed["vinf_arr_kms"].as_array().unwrap().len(), 5);
}
}