use core::f64::consts::PI;
use qtty::angular::Radians;
use crate::eccentricity::Eccentricity;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AnomalyOptions {
pub max_iter: u32,
pub tol: f64,
}
impl Default for AnomalyOptions {
fn default() -> Self {
Self {
max_iter: 64,
tol: 1.0e-12,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
pub enum AnomalyError {
#[error(
"Kepler solver did not converge after {iterations} iterations (residual {residual:e})"
)]
NotConverged {
iterations: u32,
residual: f64,
},
#[error("invalid eccentricity {0}")]
InvalidEccentricity(f64),
#[error("invalid mean anomaly {0}")]
InvalidMeanAnomaly(f64),
}
pub fn kepler_elliptic(
mean_anomaly: Radians,
ecc: Eccentricity,
opts: AnomalyOptions,
) -> Result<Radians, AnomalyError> {
let ecc_value = ecc.value();
let mean_value = mean_anomaly.value();
if !(0.0..1.0).contains(&ecc_value) || !ecc_value.is_finite() {
return Err(AnomalyError::InvalidEccentricity(ecc_value));
}
if !mean_value.is_finite() {
return Err(AnomalyError::InvalidMeanAnomaly(mean_value));
}
if ecc_value == 0.0 {
return Ok(mean_anomaly);
}
let mut e_anom = mean_value + ecc_value * mean_value.sin();
for i in 0..opts.max_iter {
let mut residual = elliptic_residual(e_anom, ecc_value, mean_value);
if residual.abs() <= opts.tol {
return Ok(Radians::new(e_anom));
}
let fp = 1.0 - ecc_value * e_anom.cos();
e_anom -= residual / fp;
if !e_anom.is_finite() {
break;
}
if i + 1 == opts.max_iter {
residual = elliptic_residual(e_anom, ecc_value, mean_value);
if residual.abs() <= opts.tol {
return Ok(Radians::new(e_anom));
}
}
}
elliptic_bisection(mean_value, ecc_value, opts).map(Radians::new)
}
pub fn kepler_hyperbolic(
mean_anomaly: Radians,
ecc: Eccentricity,
opts: AnomalyOptions,
) -> Result<Radians, AnomalyError> {
let ecc_value = ecc.value();
let mean_value = mean_anomaly.value();
if ecc_value <= 1.0 || !ecc_value.is_finite() {
return Err(AnomalyError::InvalidEccentricity(ecc_value));
}
if !mean_value.is_finite() {
return Err(AnomalyError::InvalidMeanAnomaly(mean_value));
}
if mean_value == 0.0 {
return Ok(Radians::new(0.0));
}
let abs_mean = mean_value.abs();
let mut f_anom = if abs_mean > 50.0 * ecc_value {
mean_value.signum() * (2.0 * abs_mean / ecc_value).ln()
} else {
(mean_value / ecc_value).asinh()
};
for _ in 0..opts.max_iter {
let residual = hyperbolic_residual(f_anom, ecc_value, mean_value);
if residual.abs() <= opts.tol {
return Ok(Radians::new(f_anom));
}
let fp = ecc_value * f_anom.cosh() - 1.0;
f_anom -= residual / fp;
if !f_anom.is_finite() {
break;
}
}
hyperbolic_bisection(mean_value, ecc_value, opts).map(Radians::new)
}
#[must_use]
pub fn kepler_parabolic(mean_anomaly_d: f64) -> f64 {
let a = 1.5 * mean_anomaly_d;
(a + (a * a + 1.0).sqrt()).cbrt() - ((a * a + 1.0).sqrt() - a).cbrt()
}
#[must_use]
pub fn true_from_eccentric(ea: Radians, ecc: Eccentricity) -> Radians {
let ea_value = ea.value();
let ecc_value = ecc.value();
let s = ((1.0 + ecc_value).sqrt() * (0.5 * ea_value).sin())
.atan2((1.0 - ecc_value).sqrt() * (0.5 * ea_value).cos());
Radians::new(wrap_two_pi_raw(2.0 * s))
}
#[must_use]
pub fn eccentric_from_true(nu: Radians, ecc: Eccentricity) -> Radians {
let nu_value = nu.value();
let ecc_value = ecc.value();
let e = 2.0
* (((1.0 - ecc_value).sqrt() * (0.5 * nu_value).sin())
.atan2((1.0 + ecc_value).sqrt() * (0.5 * nu_value).cos()));
Radians::new(wrap_two_pi_raw(e))
}
#[must_use]
pub fn mean_from_eccentric(ea: Radians, ecc: Eccentricity) -> Radians {
let ea_value = ea.value();
Radians::new(ea_value - ecc.value() * ea_value.sin())
}
pub fn eccentric_from_mean(
m: Radians,
ecc: Eccentricity,
opts: AnomalyOptions,
) -> Result<Radians, AnomalyError> {
kepler_elliptic(m, ecc, opts)
}
pub fn true_from_mean(
m: Radians,
ecc: Eccentricity,
opts: AnomalyOptions,
) -> Result<Radians, AnomalyError> {
Ok(true_from_eccentric(eccentric_from_mean(m, ecc, opts)?, ecc))
}
#[must_use]
pub fn mean_from_true(nu: Radians, ecc: Eccentricity) -> Radians {
mean_from_eccentric(eccentric_from_true(nu, ecc), ecc)
}
#[must_use]
pub fn true_from_hyperbolic(fa: f64, ecc: Eccentricity) -> Radians {
let ecc_value = ecc.value();
Radians::new(
2.0 * (((ecc_value + 1.0).sqrt() * (0.5 * fa).sinh())
.atan2((ecc_value - 1.0).sqrt() * (0.5 * fa).cosh())),
)
}
#[must_use]
pub fn hyperbolic_from_true(nu: Radians, ecc: Eccentricity) -> f64 {
let ecc_value = ecc.value();
let t = (0.5 * nu.value()).tan() * ((ecc_value - 1.0) / (ecc_value + 1.0)).sqrt();
2.0 * t.atanh()
}
#[must_use]
pub fn mean_from_hyperbolic(fa: f64, ecc: Eccentricity) -> Radians {
Radians::new(ecc.value() * fa.sinh() - fa)
}
pub fn hyperbolic_from_mean(
m: Radians,
ecc: Eccentricity,
opts: AnomalyOptions,
) -> Result<f64, AnomalyError> {
kepler_hyperbolic(m, ecc, opts).map(|value| value.value())
}
#[must_use]
pub(crate) fn wrap_two_pi_raw(x: f64) -> f64 {
x.rem_euclid(2.0 * PI)
}
#[inline]
fn elliptic_residual(ea: f64, ecc: f64, mean_anomaly: f64) -> f64 {
ea - ecc * ea.sin() - mean_anomaly
}
fn elliptic_bisection(
mean_anomaly: f64,
ecc: f64,
opts: AnomalyOptions,
) -> Result<f64, AnomalyError> {
let mut lower = mean_anomaly - PI;
let mut upper = mean_anomaly + PI;
let mut residual = elliptic_residual(0.5 * (lower + upper), ecc, mean_anomaly);
for _ in 0..opts.max_iter {
let mid = 0.5 * (lower + upper);
residual = elliptic_residual(mid, ecc, mean_anomaly);
if residual.abs() <= opts.tol || (upper - lower).abs() <= opts.tol {
return Ok(mid);
}
if residual.is_sign_positive() {
upper = mid;
} else {
lower = mid;
}
}
Err(AnomalyError::NotConverged {
iterations: opts.max_iter,
residual: residual.abs(),
})
}
#[inline]
fn hyperbolic_residual(fa: f64, ecc: f64, mean_anomaly: f64) -> f64 {
ecc * fa.sinh() - fa - mean_anomaly
}
fn hyperbolic_bisection(
mean_anomaly: f64,
ecc: f64,
opts: AnomalyOptions,
) -> Result<f64, AnomalyError> {
let sign = mean_anomaly.signum();
let positive_mean_anomaly = mean_anomaly.abs();
let mut lower = 0.0;
let mut upper = (positive_mean_anomaly / ecc).asinh().max(1.0);
let mut upper_residual = hyperbolic_residual(upper, ecc, positive_mean_anomaly);
for _ in 0..opts.max_iter {
if upper_residual.is_sign_positive() || upper_residual == 0.0 {
break;
}
upper *= 2.0;
upper_residual = hyperbolic_residual(upper, ecc, positive_mean_anomaly);
}
if upper_residual.is_sign_negative() {
return Err(AnomalyError::NotConverged {
iterations: opts.max_iter,
residual: upper_residual.abs(),
});
}
let mut residual = upper_residual;
for _ in 0..opts.max_iter {
let mid = 0.5 * (lower + upper);
residual = hyperbolic_residual(mid, ecc, positive_mean_anomaly);
if residual.abs() <= opts.tol || (upper - lower).abs() <= opts.tol {
return Ok(sign * mid);
}
if residual.is_sign_positive() {
upper = mid;
} else {
lower = mid;
}
}
Err(AnomalyError::NotConverged {
iterations: opts.max_iter,
residual: residual.abs(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use core::f64::consts::TAU;
#[test]
fn solvers_converge() {
let zero = Eccentricity::new(0.0).unwrap();
assert_eq!(
kepler_elliptic(Radians::new(0.4), zero, AnomalyOptions::default()).unwrap(),
Radians::new(0.4)
);
let elliptic_ecc = Eccentricity::new(0.4).unwrap();
let e =
kepler_elliptic(Radians::new(1.0), elliptic_ecc, AnomalyOptions::default()).unwrap();
assert!((mean_from_eccentric(e, elliptic_ecc).value() - 1.0).abs() < 1e-12);
let hyperbolic_ecc = Eccentricity::new(1.4).unwrap();
let f = kepler_hyperbolic(Radians::new(1.0), hyperbolic_ecc, AnomalyOptions::default())
.unwrap();
assert!((mean_from_hyperbolic(f.value(), hyperbolic_ecc).value() - 1.0).abs() < 1e-12);
}
#[test]
fn round_trip_elliptic_anomalies() {
let nu = Radians::new(1.2);
let ecc = Eccentricity::new(0.3).unwrap();
let ea = eccentric_from_true(nu, ecc);
let m = mean_from_eccentric(ea, ecc);
let nu2 = true_from_mean(m, ecc, AnomalyOptions::default()).unwrap();
let diff = (nu2.value() - nu.value() + core::f64::consts::PI).rem_euclid(TAU)
- core::f64::consts::PI;
assert!(diff.abs() < 1e-12);
}
#[test]
fn round_trip_hyperbolic_anomalies() {
let nu = Radians::new(0.8);
let ecc = Eccentricity::new(1.7).unwrap();
let f = hyperbolic_from_true(nu, ecc);
let m = mean_from_hyperbolic(f, ecc);
let f2 = hyperbolic_from_mean(m, ecc, AnomalyOptions::default()).unwrap();
assert!((f2 - f).abs() < 1e-12);
}
#[test]
fn invalid_eccentricity() {
assert!(matches!(
kepler_elliptic(
Radians::new(0.0),
Eccentricity::new_unchecked(1.0),
AnomalyOptions::default()
),
Err(AnomalyError::InvalidEccentricity(_))
));
assert!(matches!(
kepler_hyperbolic(
Radians::new(0.0),
Eccentricity::new_unchecked(0.9),
AnomalyOptions::default()
),
Err(AnomalyError::InvalidEccentricity(_))
));
}
#[test]
fn detects_non_convergence() {
let err = kepler_elliptic(
Radians::new(1.0),
Eccentricity::new(0.9).unwrap(),
AnomalyOptions {
max_iter: 0,
tol: 1e-16,
},
)
.unwrap_err();
assert!(matches!(err, AnomalyError::NotConverged { .. }));
}
#[test]
fn near_parabolic_hyperbolic_solver_converges() {
let ecc = Eccentricity::new(1.0000001).unwrap();
let mean = Radians::new(0.001_f64.to_radians() + 0.01720209895 * 0.5);
let f = kepler_hyperbolic(
mean,
ecc,
AnomalyOptions {
max_iter: 100,
tol: 1e-14,
},
)
.unwrap();
assert!(f.value().is_finite());
assert!(hyperbolic_residual(f.value(), ecc.value(), mean.value()).abs() < 1e-13);
}
}