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