use tracing::instrument;
use crate::error::{FalakError, Result};
pub type MassRatio = f64;
pub type SynodicState = [f64; 6];
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct LagrangePoints {
pub l1: [f64; 3],
pub l2: [f64; 3],
pub l3: [f64; 3],
pub l4: [f64; 3],
pub l5: [f64; 3],
}
#[must_use = "returns the five Lagrange points"]
#[instrument(level = "trace")]
pub fn lagrange_points(mu: MassRatio) -> Result<LagrangePoints> {
if mu <= 0.0 || mu > 0.5 {
return Err(FalakError::InvalidParameter(
format!("mass ratio must be in (0, 0.5], got {mu}").into(),
));
}
let l1_x = find_collinear_l1(mu);
let l2_x = find_collinear_l2(mu);
let l3_x = find_collinear_l3(mu);
Ok(LagrangePoints {
l1: [l1_x, 0.0, 0.0],
l2: [l2_x, 0.0, 0.0],
l3: [l3_x, 0.0, 0.0],
l4: [0.5 - mu, 3.0_f64.sqrt() / 2.0, 0.0],
l5: [0.5 - mu, -3.0_f64.sqrt() / 2.0, 0.0],
})
}
#[must_use]
#[inline]
pub fn jacobi_constant(state: &SynodicState, mu: MassRatio) -> f64 {
let [x, y, z, vx, vy, vz] = *state;
let omega = pseudo_potential(x, y, z, mu);
2.0 * omega - (vx * vx + vy * vy + vz * vz)
}
#[must_use]
#[inline]
pub fn pseudo_potential(x: f64, y: f64, z: f64, mu: MassRatio) -> f64 {
let r1 = distance_to_m1(x, y, z, mu);
let r2 = distance_to_m2(x, y, z, mu);
0.5 * (x * x + y * y) + (1.0 - mu) / r1 + mu / r2
}
#[must_use]
#[inline]
pub fn zero_velocity_value(x: f64, y: f64, z: f64, mu: MassRatio) -> f64 {
2.0 * pseudo_potential(x, y, z, mu)
}
#[must_use]
pub fn equations_of_motion(state: &SynodicState, mu: MassRatio) -> SynodicState {
let [x, y, z, vx, vy, _vz] = *state;
let r1 = distance_to_m1(x, y, z, mu);
let r2 = distance_to_m2(x, y, z, mu);
let r1_3 = r1 * r1 * r1;
let r2_3 = r2 * r2 * r2;
let ax = 2.0 * vy + x - (1.0 - mu) * (x + mu) / r1_3 - mu * (x - 1.0 + mu) / r2_3;
let ay = -2.0 * vx + y - (1.0 - mu) * y / r1_3 - mu * y / r2_3;
let az = -(1.0 - mu) * z / r1_3 - mu * z / r2_3;
[vx, vy, _vz, ax, ay, az]
}
#[must_use]
#[inline]
fn distance_to_m1(x: f64, y: f64, z: f64, mu: MassRatio) -> f64 {
((x + mu) * (x + mu) + y * y + z * z).sqrt()
}
#[must_use]
#[inline]
fn distance_to_m2(x: f64, y: f64, z: f64, mu: MassRatio) -> f64 {
((x - 1.0 + mu) * (x - 1.0 + mu) + y * y + z * z).sqrt()
}
fn find_collinear_l1(mu: MassRatio) -> f64 {
let mut gamma = (mu / 3.0).cbrt();
for _ in 0..50 {
let x = 1.0 - mu - gamma;
let f = collinear_force(x, mu);
let df = collinear_force_derivative(x, mu);
if df.abs() < 1e-30 {
break;
}
let dx = f / df;
gamma += dx;
if dx.abs() < 1e-14 {
break;
}
}
1.0 - mu - gamma
}
fn find_collinear_l2(mu: MassRatio) -> f64 {
let mut gamma = (mu / 3.0).cbrt();
for _ in 0..50 {
let x = 1.0 - mu + gamma;
let f = collinear_force(x, mu);
let df = collinear_force_derivative(x, mu);
if df.abs() < 1e-30 {
break;
}
let dx = f / df;
gamma -= dx;
if dx.abs() < 1e-14 {
break;
}
}
1.0 - mu + gamma
}
fn find_collinear_l3(mu: MassRatio) -> f64 {
let mut x = -1.0 + 5.0 * mu / 12.0;
for _ in 0..50 {
let f = collinear_force(x, mu);
let df = collinear_force_derivative(x, mu);
if df.abs() < 1e-30 {
break;
}
let dx = f / df;
x -= dx;
if dx.abs() < 1e-14 {
break;
}
}
x
}
#[inline]
fn collinear_force(x: f64, mu: MassRatio) -> f64 {
let d1 = x + mu;
let d2 = x - 1.0 + mu;
let r1 = d1.abs();
let r2 = d2.abs();
if r1 < 1e-15 || r2 < 1e-15 {
return 0.0;
}
x - (1.0 - mu) * d1 / (r1 * r1 * r1) - mu * d2 / (r2 * r2 * r2)
}
#[inline]
fn collinear_force_derivative(x: f64, mu: MassRatio) -> f64 {
let d1 = x + mu;
let d2 = x - 1.0 + mu;
let r1 = d1.abs();
let r2 = d2.abs();
if r1 < 1e-15 || r2 < 1e-15 {
return 1.0;
}
let r1_3 = r1 * r1 * r1;
let r2_3 = r2 * r2 * r2;
1.0 - (1.0 - mu) * (1.0 / r1_3 - 3.0 * d1 * d1 / (r1_3 * r1 * r1))
- mu * (1.0 / r2_3 - 3.0 * d2 * d2 / (r2_3 * r2 * r2))
}
#[cfg(test)]
mod tests {
use super::*;
const MU_EM: f64 = 0.012_150_585;
const MU_SE: f64 = 3.003_467e-6;
#[test]
fn lagrange_points_earth_moon() {
let lp = lagrange_points(MU_EM).unwrap();
assert!(
lp.l1[0] > 0.8 && lp.l1[0] < 0.9,
"L1 x={} should be ~0.837",
lp.l1[0]
);
assert!(lp.l1[1].abs() < 1e-10, "L1 should be on x-axis");
assert!(
lp.l2[0] > 1.1 && lp.l2[0] < 1.2,
"L2 x={} should be ~1.156",
lp.l2[0]
);
assert!(
lp.l3[0] > -1.01 && lp.l3[0] < -0.99,
"L3 x={} should be ~-1.005",
lp.l3[0]
);
let expected_x = 0.5 - MU_EM;
let expected_y = 3.0_f64.sqrt() / 2.0;
assert!((lp.l4[0] - expected_x).abs() < 1e-10, "L4 x");
assert!((lp.l4[1] - expected_y).abs() < 1e-10, "L4 y");
assert!((lp.l5[0] - expected_x).abs() < 1e-10, "L5 x");
assert!((lp.l5[1] + expected_y).abs() < 1e-10, "L5 y");
}
#[test]
fn lagrange_points_sun_earth() {
let lp = lagrange_points(MU_SE).unwrap();
assert!(
lp.l1[0] > 0.98 && lp.l1[0] < 1.0,
"SE L1 x={} should be ~0.990",
lp.l1[0]
);
assert!(
lp.l2[0] > 1.0 && lp.l2[0] < 1.02,
"SE L2 x={} should be ~1.010",
lp.l2[0]
);
}
#[test]
fn lagrange_points_are_equilibria() {
let lp = lagrange_points(MU_EM).unwrap();
for (name, point) in [
("L1", lp.l1),
("L2", lp.l2),
("L3", lp.l3),
("L4", lp.l4),
("L5", lp.l5),
] {
let state = [point[0], point[1], point[2], 0.0, 0.0, 0.0];
let deriv = equations_of_motion(&state, MU_EM);
let accel_mag =
(deriv[3] * deriv[3] + deriv[4] * deriv[4] + deriv[5] * deriv[5]).sqrt();
assert!(
accel_mag < 1e-10,
"{name}: acceleration magnitude {accel_mag:.2e} should be ~0"
);
}
}
#[test]
fn jacobi_constant_conserved() {
let lp = lagrange_points(MU_EM).unwrap();
let state_l1 = [lp.l1[0], lp.l1[1], lp.l1[2], 0.0, 0.0, 0.0];
let cj = jacobi_constant(&state_l1, MU_EM);
let zv = zero_velocity_value(lp.l1[0], lp.l1[1], lp.l1[2], MU_EM);
assert!(
(cj - zv).abs() < 1e-10,
"At L1 with zero velocity, C_J={cj} should equal 2Ω={zv}"
);
}
#[test]
fn jacobi_ordering() {
let lp = lagrange_points(MU_EM).unwrap();
let cj = |p: [f64; 3]| jacobi_constant(&[p[0], p[1], p[2], 0.0, 0.0, 0.0], MU_EM);
let cj1 = cj(lp.l1);
let cj2 = cj(lp.l2);
let cj3 = cj(lp.l3);
let cj4 = cj(lp.l4);
let cj5 = cj(lp.l5);
assert!(cj1 > cj2, "C_J(L1)={cj1} should > C_J(L2)={cj2}");
assert!(cj2 > cj3, "C_J(L2)={cj2} should > C_J(L3)={cj3}");
assert!(cj3 > cj4, "C_J(L3)={cj3} should > C_J(L4)={cj4}");
assert!(
(cj4 - cj5).abs() < 1e-10,
"C_J(L4)={cj4} should ≈ C_J(L5)={cj5}"
);
}
#[test]
fn equations_of_motion_symmetry() {
let state = [0.5, 0.0, 0.0, 0.0, 0.3, 0.0];
let d = equations_of_motion(&state, MU_EM);
assert!(
d[4].abs() < 1e-12,
"ay on x-axis should be zero (centrifugal + gravity cancel): {}",
d[4]
);
let s1 = [0.5, 0.3, 0.0, 0.0, 0.0, 0.0];
let s2 = [0.5, -0.3, 0.0, 0.0, 0.0, 0.0];
let d1 = equations_of_motion(&s1, MU_EM);
let d2 = equations_of_motion(&s2, MU_EM);
assert!(
(d1[3] - d2[3]).abs() < 1e-12,
"ax should be same: {} vs {}",
d1[3],
d2[3]
);
assert!(
(d1[4] + d2[4]).abs() < 1e-12,
"ay should be negated: {} vs {}",
d1[4],
d2[4]
);
}
#[test]
fn pseudo_potential_at_primaries() {
let val = pseudo_potential(1.0 - MU_EM + 0.001, 0.0, 0.0, MU_EM);
assert!(val > 10.0, "potential near m₂ should be large: {val}");
let val_closer = pseudo_potential(1.0 - MU_EM + 0.0001, 0.0, 0.0, MU_EM);
assert!(
val_closer > val,
"closer to m₂ should increase potential: {val_closer} vs {val}"
);
}
#[test]
fn lagrange_invalid_mu() {
assert!(lagrange_points(0.0).is_err());
assert!(lagrange_points(-0.1).is_err());
assert!(lagrange_points(0.6).is_err());
}
}