Skip to main content

sidereon_core/astro/
anomaly.rs

1//! Anomaly conversions and analytic two-body propagation for classical elements.
2//!
3//! The scalar conversion functions use the same names across all conic regimes.
4//! The middle anomaly is eccentric anomaly `E` for elliptic orbits, hyperbolic
5//! anomaly `H` for hyperbolic orbits, and Barker's parabolic anomaly `D` for the
6//! parabolic boundary selected by eccentricity.
7
8use crate::astro::elements::{ClassicalElements, OrbitType};
9use crate::astro::math::{normalize_angle, wrap_to_pi, SMALL};
10
11const PI: f64 = std::f64::consts::PI;
12const TOL_ABS: f64 = 1.0e-12;
13const TOL_REL: f64 = 1.0e-14;
14const MAX_ITER: usize = 50;
15
16#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
17pub enum AnomalyError {
18    #[error("non-finite input {field}")]
19    NonFinite { field: &'static str },
20    #[error("eccentricity must be non-negative")]
21    NegativeEccentricity,
22    #[error("mu must be positive")]
23    NonPositiveMu,
24    #[error("semi-latus rectum must be positive")]
25    NonPositiveSemiLatus,
26    #[error("true anomaly {nu} is beyond the open-orbit asymptote {limit}")]
27    BeyondAsymptote { nu: f64, limit: f64 },
28    #[error("inconsistent element {field}")]
29    InconsistentElements { field: &'static str },
30    #[error("Kepler solve did not converge in {iterations} iters (residual {residual})")]
31    NonConvergent { iterations: usize, residual: f64 },
32}
33
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct KeplerSolution {
36    pub anomaly: f64,
37    pub iterations: usize,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41enum Regime {
42    Elliptic,
43    Parabolic,
44    Hyperbolic,
45}
46
47pub fn solve_kepler(mean_anom: f64, ecc: f64) -> Result<KeplerSolution, AnomalyError> {
48    check_finite(mean_anom, "mean_anom")?;
49    validate_ecc(ecc)?;
50
51    match regime(ecc) {
52        Regime::Elliptic => solve_iterative(
53            normalize_angle(mean_anom),
54            ecc,
55            Regime::Elliptic,
56            elliptic_seed(mean_anom, ecc),
57        ),
58        Regime::Parabolic => Ok(KeplerSolution {
59            anomaly: barker_d_from_mean(mean_anom),
60            iterations: 0,
61        }),
62        Regime::Hyperbolic => solve_iterative(
63            mean_anom,
64            ecc,
65            Regime::Hyperbolic,
66            libm::asinh(mean_anom / ecc),
67        ),
68    }
69}
70
71pub fn mean_to_eccentric(mean_anom: f64, ecc: f64) -> Result<f64, AnomalyError> {
72    Ok(solve_kepler(mean_anom, ecc)?.anomaly)
73}
74
75pub fn eccentric_to_mean(ecc_anom: f64, ecc: f64) -> Result<f64, AnomalyError> {
76    check_finite(ecc_anom, "ecc_anom")?;
77    validate_ecc(ecc)?;
78
79    match regime(ecc) {
80        Regime::Elliptic => {
81            let anomaly = normalize_angle(ecc_anom);
82            Ok(normalize_angle(anomaly - ecc * libm::sin(anomaly)))
83        }
84        Regime::Parabolic => Ok(ecc_anom + ecc_anom.powi(3) / 3.0),
85        Regime::Hyperbolic => Ok(ecc * libm::sinh(ecc_anom) - ecc_anom),
86    }
87}
88
89pub fn eccentric_to_true(ecc_anom: f64, ecc: f64) -> Result<f64, AnomalyError> {
90    check_finite(ecc_anom, "ecc_anom")?;
91    validate_ecc(ecc)?;
92
93    match regime(ecc) {
94        Regime::Elliptic => {
95            let anomaly = normalize_angle(ecc_anom);
96            let sin_nu = ((1.0 - ecc) * (1.0 + ecc)).sqrt() * libm::sin(anomaly);
97            let cos_nu = libm::cos(anomaly) - ecc;
98            Ok(normalize_angle(libm::atan2(sin_nu, cos_nu)))
99        }
100        Regime::Parabolic => Ok(normalize_angle(2.0 * libm::atan(ecc_anom))),
101        Regime::Hyperbolic => {
102            let sin_nu = ((ecc - 1.0) * (ecc + 1.0)).sqrt() * libm::sinh(ecc_anom);
103            let cos_nu = ecc - libm::cosh(ecc_anom);
104            Ok(normalize_angle(libm::atan2(sin_nu, cos_nu)))
105        }
106    }
107}
108
109pub fn true_to_eccentric(true_anom: f64, ecc: f64) -> Result<f64, AnomalyError> {
110    check_finite(true_anom, "true_anom")?;
111    validate_ecc(ecc)?;
112
113    match regime(ecc) {
114        Regime::Elliptic => {
115            let nu = normalize_angle(true_anom);
116            let half_nu = 0.5 * nu;
117            let s = (1.0 - ecc).sqrt() * libm::sin(half_nu);
118            let c = (1.0 + ecc).sqrt() * libm::cos(half_nu);
119            let sin_e = 2.0 * s * c;
120            let cos_e = c * c - s * s;
121            Ok(normalize_angle(libm::atan2(sin_e, cos_e)))
122        }
123        Regime::Parabolic => {
124            check_open_true_anomaly(true_anom, PI)?;
125            Ok(libm::tan(0.5 * wrap_to_pi(true_anom)))
126        }
127        Regime::Hyperbolic => {
128            let limit = libm::acos(-1.0 / ecc);
129            check_open_true_anomaly(true_anom, limit)?;
130            let nu = wrap_to_pi(true_anom);
131            Ok(libm::asinh(
132                libm::sin(nu) * ((ecc - 1.0) * (ecc + 1.0)).sqrt() / (1.0 + ecc * libm::cos(nu)),
133            ))
134        }
135    }
136}
137
138pub fn mean_to_true(mean_anom: f64, ecc: f64) -> Result<f64, AnomalyError> {
139    let ecc_anom = mean_to_eccentric(mean_anom, ecc)?;
140    eccentric_to_true(ecc_anom, ecc)
141}
142
143pub fn true_to_mean(true_anom: f64, ecc: f64) -> Result<f64, AnomalyError> {
144    let ecc_anom = true_to_eccentric(true_anom, ecc)?;
145    eccentric_to_mean(ecc_anom, ecc)
146}
147
148pub fn propagate_kepler(
149    elements: &ClassicalElements,
150    mu: f64,
151    dt: f64,
152) -> Result<ClassicalElements, AnomalyError> {
153    validate_propagation_inputs(elements, mu, dt)?;
154
155    let mut out = *elements;
156    let conic = regime(elements.ecc);
157
158    match elements.orbit_type {
159        OrbitType::CircularInclined => {
160            let mean_motion = (mu / elements.a.powi(3)).sqrt();
161            out.arglat = normalize_angle(elements.arglat + mean_motion * dt);
162        }
163        OrbitType::CircularEquatorial => {
164            let mean_motion = (mu / elements.a.powi(3)).sqrt();
165            out.truelon = normalize_angle(elements.truelon + mean_motion * dt);
166        }
167        OrbitType::EllipticalInclined | OrbitType::EllipticalEquatorial => {
168            let mean0 = true_to_mean(elements.nu, elements.ecc)?;
169            let mean1 = match conic {
170                Regime::Elliptic => {
171                    let mean_motion = (mu / elements.a.powi(3)).sqrt();
172                    normalize_angle(mean0 + mean_motion * dt)
173                }
174                Regime::Parabolic => {
175                    let mean_motion = (mu / elements.p.powi(3)).sqrt();
176                    mean0 + mean_motion * dt
177                }
178                Regime::Hyperbolic => {
179                    let mean_motion = (mu / (-elements.a).powi(3)).sqrt();
180                    mean0 + mean_motion * dt
181                }
182            };
183            out.nu = mean_to_true(mean1, elements.ecc)?;
184        }
185    }
186
187    Ok(out)
188}
189
190fn elliptic_seed(mean_anom: f64, ecc: f64) -> f64 {
191    let mean = normalize_angle(mean_anom);
192    if ecc < 0.8 {
193        mean + ecc * libm::sin(mean)
194    } else {
195        PI
196    }
197}
198
199fn solve_iterative(
200    mean_anom: f64,
201    ecc: f64,
202    conic: Regime,
203    seed: f64,
204) -> Result<KeplerSolution, AnomalyError> {
205    let mut anomaly = seed;
206    let tolerance = TOL_ABS + TOL_REL * mean_anom.abs();
207    let mut residual = f64::NAN;
208
209    for iterations in 1..=MAX_ITER {
210        let (f, fp, fpp) = residuals(anomaly, mean_anom, ecc, conic);
211        residual = f;
212        if f.abs() <= tolerance {
213            return Ok(KeplerSolution {
214                anomaly: if conic == Regime::Elliptic {
215                    normalize_angle(anomaly)
216                } else {
217                    anomaly
218                },
219                iterations,
220            });
221        }
222
223        let denom = 2.0 * fp * fp - f * fpp;
224        let step = if denom.is_finite() && denom.abs() > f64::EPSILON {
225            2.0 * f * fp / denom
226        } else {
227            f / fp
228        };
229        anomaly -= step;
230    }
231
232    Err(AnomalyError::NonConvergent {
233        iterations: MAX_ITER,
234        residual,
235    })
236}
237
238fn residuals(anomaly: f64, mean_anom: f64, ecc: f64, conic: Regime) -> (f64, f64, f64) {
239    match conic {
240        Regime::Elliptic => (
241            anomaly - ecc * libm::sin(anomaly) - mean_anom,
242            1.0 - ecc * libm::cos(anomaly),
243            ecc * libm::sin(anomaly),
244        ),
245        Regime::Hyperbolic => (
246            ecc * libm::sinh(anomaly) - anomaly - mean_anom,
247            ecc * libm::cosh(anomaly) - 1.0,
248            ecc * libm::sinh(anomaly),
249        ),
250        Regime::Parabolic => unreachable!(),
251    }
252}
253
254fn barker_d_from_mean(mean_anom: f64) -> f64 {
255    2.0 * libm::sinh(libm::asinh(1.5 * mean_anom) / 3.0)
256}
257
258fn validate_propagation_inputs(
259    elements: &ClassicalElements,
260    mu: f64,
261    dt: f64,
262) -> Result<(), AnomalyError> {
263    check_finite(mu, "mu")?;
264    if mu <= 0.0 {
265        return Err(AnomalyError::NonPositiveMu);
266    }
267    check_finite(dt, "dt")?;
268    validate_ecc(elements.ecc)?;
269    check_finite(elements.p, "p")?;
270    if elements.p <= 0.0 {
271        return Err(AnomalyError::NonPositiveSemiLatus);
272    }
273    check_finite(elements.incl, "incl")?;
274
275    match regime(elements.ecc) {
276        Regime::Elliptic => {
277            if !elements.a.is_finite() || elements.a <= 0.0 {
278                return Err(AnomalyError::InconsistentElements { field: "a" });
279            }
280        }
281        Regime::Parabolic => {}
282        Regime::Hyperbolic => {
283            if !elements.a.is_finite() || elements.a >= 0.0 {
284                return Err(AnomalyError::InconsistentElements { field: "a" });
285            }
286        }
287    }
288
289    validate_orbit_type_fields(elements)
290}
291
292fn validate_orbit_type_fields(elements: &ClassicalElements) -> Result<(), AnomalyError> {
293    let equatorial = is_equatorial(elements.incl);
294
295    match elements.orbit_type {
296        OrbitType::EllipticalInclined => {
297            check_finite(elements.raan, "raan")?;
298            check_finite(elements.argp, "argp")?;
299            check_finite(elements.nu, "nu")?;
300            require_eccentric(elements.ecc)?;
301            require_inclined(equatorial)?;
302        }
303        OrbitType::EllipticalEquatorial => {
304            check_finite(elements.lonper, "lonper")?;
305            check_finite(elements.nu, "nu")?;
306            require_eccentric(elements.ecc)?;
307            require_equatorial(equatorial)?;
308        }
309        OrbitType::CircularInclined => {
310            check_finite(elements.raan, "raan")?;
311            check_finite(elements.arglat, "arglat")?;
312            require_circular(elements.ecc)?;
313            require_inclined(equatorial)?;
314        }
315        OrbitType::CircularEquatorial => {
316            check_finite(elements.truelon, "truelon")?;
317            require_circular(elements.ecc)?;
318            require_equatorial(equatorial)?;
319        }
320    }
321
322    Ok(())
323}
324
325fn require_eccentric(ecc: f64) -> Result<(), AnomalyError> {
326    if ecc >= SMALL {
327        Ok(())
328    } else {
329        Err(AnomalyError::InconsistentElements { field: "ecc" })
330    }
331}
332
333fn require_circular(ecc: f64) -> Result<(), AnomalyError> {
334    if ecc < SMALL {
335        Ok(())
336    } else {
337        Err(AnomalyError::InconsistentElements { field: "ecc" })
338    }
339}
340
341fn require_equatorial(equatorial: bool) -> Result<(), AnomalyError> {
342    if equatorial {
343        Ok(())
344    } else {
345        Err(AnomalyError::InconsistentElements { field: "incl" })
346    }
347}
348
349fn require_inclined(equatorial: bool) -> Result<(), AnomalyError> {
350    if !equatorial {
351        Ok(())
352    } else {
353        Err(AnomalyError::InconsistentElements { field: "incl" })
354    }
355}
356
357fn is_equatorial(incl: f64) -> bool {
358    incl < SMALL || (incl - PI).abs() < SMALL
359}
360
361fn validate_ecc(ecc: f64) -> Result<(), AnomalyError> {
362    check_finite(ecc, "ecc")?;
363    if ecc < 0.0 {
364        Err(AnomalyError::NegativeEccentricity)
365    } else {
366        Ok(())
367    }
368}
369
370fn check_open_true_anomaly(true_anom: f64, limit: f64) -> Result<(), AnomalyError> {
371    if wrap_to_pi(true_anom).abs() >= limit {
372        Err(AnomalyError::BeyondAsymptote {
373            nu: true_anom,
374            limit,
375        })
376    } else {
377        Ok(())
378    }
379}
380
381fn check_finite(value: f64, field: &'static str) -> Result<(), AnomalyError> {
382    if value.is_finite() {
383        Ok(())
384    } else {
385        Err(AnomalyError::NonFinite { field })
386    }
387}
388
389fn regime(ecc: f64) -> Regime {
390    if ecc <= 1.0 - SMALL {
391        Regime::Elliptic
392    } else if ecc < 1.0 + SMALL {
393        Regime::Parabolic
394    } else {
395        Regime::Hyperbolic
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    const DEG: f64 = std::f64::consts::PI / 180.0;
404
405    fn assert_close(got: f64, want: f64, tol: f64, label: &str) {
406        assert!(
407            (got - want).abs() <= tol,
408            "{label}: got {got}, want {want}, diff {}",
409            (got - want).abs()
410        );
411    }
412
413    fn assert_angle_close(got: f64, want: f64, tol: f64, label: &str) {
414        let diff = wrap_to_pi(got - want).abs();
415        assert!(diff <= tol, "{label}: got {got}, want {want}, diff {diff}");
416    }
417
418    #[test]
419    fn vallado_example_2_1_elliptic_kepler() {
420        // Vallado 2022 Example 2-1, KepEqtnE: M = 235.4 deg, e = 0.4,
421        // E = 220.512074 deg.
422        let eccentric = mean_to_eccentric(235.4 * DEG, 0.4).unwrap();
423        assert_close(eccentric, 220.512074 * DEG, 1.0e-6, "E");
424    }
425
426    #[test]
427    fn elliptic_round_trips_across_grid() {
428        let eccs = [
429            0.0,
430            1.0e-9,
431            0.01,
432            0.1,
433            0.3,
434            0.5,
435            0.7,
436            0.9,
437            0.99,
438            1.0 - 1.0e-6,
439        ];
440
441        for ecc in eccs {
442            for step in 0..24 {
443                let mean = step as f64 * crate::astro::math::TWO_PI / 24.0;
444                let eccentric = mean_to_eccentric(mean, ecc).unwrap();
445                let true_anom = eccentric_to_true(eccentric, ecc).unwrap();
446
447                assert_angle_close(
448                    eccentric_to_mean(eccentric, ecc).unwrap(),
449                    mean,
450                    1.0e-11,
451                    "M",
452                );
453                assert_angle_close(
454                    true_to_eccentric(true_anom, ecc).unwrap(),
455                    eccentric,
456                    1.0e-11,
457                    "E",
458                );
459                assert_angle_close(
460                    true_to_mean(true_anom, ecc).unwrap(),
461                    mean,
462                    1.0e-11,
463                    "M true",
464                );
465
466                let solution = solve_kepler(mean, ecc).unwrap();
467                let residual = wrap_to_pi(eccentric_to_mean(solution.anomaly, ecc).unwrap() - mean);
468                assert!(solution.iterations <= MAX_ITER);
469                assert!(residual.abs() <= TOL_ABS + TOL_REL * mean.abs());
470            }
471        }
472    }
473
474    #[test]
475    fn hyperbolic_round_trips_across_grid() {
476        let eccs = [1.0 + 1.0e-4, 1.1, 1.5, 2.4, 5.0];
477        let means = [-10.0, -3.0, -1.0, -0.1, 0.0, 0.1, 1.0, 3.0, 10.0];
478
479        for ecc in eccs {
480            for mean in means {
481                let hyper = mean_to_eccentric(mean, ecc).unwrap();
482                let true_anom = eccentric_to_true(hyper, ecc).unwrap();
483                let mean_back = eccentric_to_mean(hyper, ecc).unwrap();
484                let hyper_back = true_to_eccentric(true_anom, ecc).unwrap();
485                let mean_from_true = true_to_mean(true_anom, ecc).unwrap();
486
487                let scale = 1.0_f64.max(mean.abs());
488                assert!((mean_back - mean).abs() <= 1.0e-9 * scale);
489                assert!((hyper_back - hyper).abs() <= 1.0e-9 * 1.0_f64.max(hyper.abs()));
490                assert!((mean_from_true - mean).abs() <= 1.0e-9 * scale);
491
492                let solution = solve_kepler(mean, ecc).unwrap();
493                let residual = ecc * libm::sinh(solution.anomaly) - solution.anomaly - mean;
494                assert!(solution.iterations <= MAX_ITER);
495                assert!(residual.abs() <= TOL_ABS + TOL_REL * mean.abs());
496            }
497        }
498    }
499
500    #[test]
501    fn parabolic_barker_round_trips_wide_signed_range() {
502        for step in 0..24 {
503            if step == 12 {
504                continue;
505            }
506            let true_anom = step as f64 * crate::astro::math::TWO_PI / 24.0;
507            let d = true_to_eccentric(true_anom, 1.0).unwrap();
508            let mean = eccentric_to_mean(d, 1.0).unwrap();
509            let d_back = mean_to_eccentric(mean, 1.0).unwrap();
510            let true_back = mean_to_true(mean, 1.0).unwrap();
511
512            assert!((d_back - d).abs() <= 1.0e-11 * 1.0_f64.max(d.abs()));
513            assert_angle_close(true_back, true_anom, 1.0e-11, "parabolic true");
514        }
515
516        let means = [-1.0e6, -10.0, -0.1, 0.0, 0.1, 10.0, 1.0e6];
517
518        for mean in means {
519            let d = mean_to_eccentric(mean, 1.0).unwrap();
520            let true_anom = eccentric_to_true(d, 1.0).unwrap();
521            let d_back = true_to_eccentric(true_anom, 1.0).unwrap();
522            let mean_back = true_to_mean(true_anom, 1.0).unwrap();
523
524            assert!((d_back - d).abs() <= 1.0e-11 * 1.0_f64.max(d.abs()));
525            assert!((mean_back - mean).abs() <= 1.0e-9 * 1.0_f64.max(mean.abs()));
526            assert_eq!(solve_kepler(mean, 1.0).unwrap().iterations, 0);
527        }
528    }
529
530    #[test]
531    fn large_elliptic_mean_anomaly_is_reduced() {
532        let baseline = mean_to_eccentric(1.3, 0.7).unwrap();
533        let many_revs = mean_to_eccentric(100.0 * crate::astro::math::TWO_PI + 1.3, 0.7).unwrap();
534        assert_angle_close(many_revs, baseline, 1.0e-12, "large M");
535    }
536
537    #[test]
538    fn degenerate_circular_anomalies_match() {
539        let mean = 5.8;
540        assert_angle_close(mean_to_eccentric(mean, 0.0).unwrap(), mean, 1.0e-14, "E");
541        assert_angle_close(mean_to_true(mean, 0.0).unwrap(), mean, 1.0e-14, "nu");
542        assert_angle_close(true_to_mean(mean, 0.0).unwrap(), mean, 1.0e-14, "M");
543    }
544
545    #[test]
546    fn true_anomaly_at_or_beyond_open_asymptote_is_rejected() {
547        assert!(matches!(
548            true_to_eccentric(PI, 1.0),
549            Err(AnomalyError::BeyondAsymptote { .. })
550        ));
551
552        let ecc = 1.5;
553        let limit = libm::acos(-1.0_f64 / ecc);
554        assert!(matches!(
555            true_to_eccentric(limit, ecc),
556            Err(AnomalyError::BeyondAsymptote { .. })
557        ));
558        assert!(matches!(
559            true_to_mean(limit + 1.0e-3, ecc),
560            Err(AnomalyError::BeyondAsymptote { .. })
561        ));
562    }
563
564    #[test]
565    fn scalar_error_paths_are_distinct() {
566        assert_eq!(
567            mean_to_eccentric(f64::NAN, 0.1),
568            Err(AnomalyError::NonFinite { field: "mean_anom" })
569        );
570        assert_eq!(
571            mean_to_eccentric(0.0, f64::INFINITY),
572            Err(AnomalyError::NonFinite { field: "ecc" })
573        );
574        assert_eq!(
575            mean_to_eccentric(0.0, -0.1),
576            Err(AnomalyError::NegativeEccentricity)
577        );
578    }
579}