Skip to main content

sidereon_core/astro/
equinoctial.rs

1//! Equinoctial and modified equinoctial orbital elements.
2//!
3//! This module is pure element algebra layered on the existing classical
4//! conversion kernels. Cartesian conversions compose through [`rv2coe`] and
5//! [`coe2rv`], and the mean-longitude equinoctial path uses the shared anomaly
6//! conversions.
7
8use crate::astro::anomaly::{mean_to_true, true_to_mean, AnomalyError};
9use crate::astro::elements::{
10    clamp_acos, classify, coe2rv, rv2coe, ClassicalElements, ElementsError, OrbitType,
11};
12use crate::astro::math::{normalize_angle, wrap_to_pi, SMALL, TWO_PI};
13
14const PI: f64 = std::f64::consts::PI;
15const HALF_PI: f64 = std::f64::consts::FRAC_PI_2;
16const STABLE_HALF_ANGLE_MIN: f64 = 1.0e-4;
17const STABLE_HALF_ANGLE_MAX: f64 = 1.0e4;
18
19/// Retrograde-factor selector for equinoctial-family element sets.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum RetrogradeFactor {
22    /// I = +1. Default. Singular only at i = pi.
23    Prograde,
24    /// I = -1. Singular only at i = 0. Use for orbits at or near i = pi.
25    Retrograde,
26}
27
28impl RetrogradeFactor {
29    #[inline]
30    fn sign(self) -> f64 {
31        match self {
32            Self::Prograde => 1.0,
33            Self::Retrograde => -1.0,
34        }
35    }
36}
37
38/// Equinoctial elements `(a, h, k, p, q, lambda)`, mean-longitude form.
39///
40/// The `p` and `q` fields are inclination components, not the semi-latus
41/// rectum.
42///
43/// ```
44/// use sidereon_core::astro::equinoctial::{EquinoctialElements, RetrogradeFactor};
45///
46/// let eq = EquinoctialElements {
47///     a: 7000.0,
48///     h: 0.0,
49///     k: 0.001,
50///     p: 0.0,
51///     q: 0.0,
52///     lambda: 0.2,
53///     retrograde: RetrogradeFactor::Prograde,
54/// };
55/// assert_eq!(eq.p, 0.0);
56/// ```
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub struct EquinoctialElements {
59    /// Semi-major axis (km), finite for all supported equinoctial conics.
60    pub a: f64,
61    /// Eccentricity component `e sin(varpi)`.
62    pub h: f64,
63    /// Eccentricity component `e cos(varpi)`.
64    pub k: f64,
65    /// Inclination component `tan(i/2)^I sin(raan)`.
66    pub p: f64,
67    /// Inclination component `tan(i/2)^I cos(raan)`.
68    pub q: f64,
69    /// Mean longitude (rad).
70    pub lambda: f64,
71    /// Retrograde factor used to build the components.
72    pub retrograde: RetrogradeFactor,
73}
74
75/// Modified equinoctial elements `(p, f, g, h, k, L)`, true-longitude form.
76///
77/// The `p` field is the semi-latus rectum. The `h` and `k` fields are
78/// inclination components.
79///
80/// ```
81/// use sidereon_core::astro::equinoctial::{
82///     ModifiedEquinoctialElements, RetrogradeFactor,
83/// };
84///
85/// let mee = ModifiedEquinoctialElements {
86///     p: 6999.0,
87///     f: 0.001,
88///     g: 0.0,
89///     h: 0.0,
90///     k: 0.0,
91///     l: 0.2,
92///     retrograde: RetrogradeFactor::Prograde,
93/// };
94/// assert!(mee.p > 0.0);
95/// ```
96#[derive(Debug, Clone, Copy, PartialEq)]
97pub struct ModifiedEquinoctialElements {
98    /// Semi-latus rectum (km), the same size element carried by
99    /// [`ClassicalElements::p`].
100    pub p: f64,
101    /// Eccentricity component `e cos(varpi)`.
102    pub f: f64,
103    /// Eccentricity component `e sin(varpi)`.
104    pub g: f64,
105    /// Inclination component `tan(i/2)^I cos(raan)`.
106    pub h: f64,
107    /// Inclination component `tan(i/2)^I sin(raan)`.
108    pub k: f64,
109    /// True longitude `L` (rad).
110    pub l: f64,
111    /// Retrograde factor used to build the components.
112    pub retrograde: RetrogradeFactor,
113}
114
115/// Error returned by equinoctial-family conversions.
116#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
117pub enum EquinoctialError {
118    /// A classical-element or state conversion failed.
119    #[error(transparent)]
120    Elements(#[from] ElementsError),
121    /// The mean-longitude anomaly step failed.
122    #[error(transparent)]
123    Anomaly(#[from] AnomalyError),
124    /// The selected retrograde factor is singular at this inclination.
125    #[error("inclination at the singular pole for the selected retrograde factor")]
126    RetrogradePole,
127    /// The mean-longitude equinoctial set cannot represent a parabolic orbit.
128    #[error("parabolic orbit is not representable in the equinoctial set; use MEE")]
129    ParabolicEquinoctial,
130}
131
132/// Convert classical elements to mean-longitude equinoctial elements.
133pub fn coe2eq(
134    coe: &ClassicalElements,
135    factor: RetrogradeFactor,
136) -> Result<EquinoctialElements, EquinoctialError> {
137    validate_classical_common(coe)?;
138    if is_parabolic(coe.ecc) {
139        return Err(EquinoctialError::ParabolicEquinoctial);
140    }
141    check_finite(coe.a, "a")?;
142    check_retrograde_pole(coe.incl, factor)?;
143
144    let resolved = resolve_classical_angles(coe)?;
145    let varpi = wrap_to_pi(resolved.argp + factor.sign() * resolved.raan);
146    let mean = true_to_mean(resolved.nu, coe.ecc)?;
147    let lambda = mean_longitude(mean, varpi, coe.ecc);
148    let scale = inclination_scale(coe.incl, factor)?;
149
150    Ok(EquinoctialElements {
151        a: coe.a,
152        h: coe.ecc * varpi.sin(),
153        k: coe.ecc * varpi.cos(),
154        p: scale * resolved.raan.sin(),
155        q: scale * resolved.raan.cos(),
156        lambda,
157        retrograde: factor,
158    })
159}
160
161/// Convert mean-longitude equinoctial elements to classical elements.
162pub fn eq2coe(eq: &EquinoctialElements) -> Result<ClassicalElements, EquinoctialError> {
163    let recovered = recover_equinoctial(eq)?;
164    Ok(recovered_to_classical(&recovered))
165}
166
167/// Convert classical elements to modified equinoctial elements.
168pub fn coe2mee(
169    coe: &ClassicalElements,
170    factor: RetrogradeFactor,
171) -> Result<ModifiedEquinoctialElements, EquinoctialError> {
172    validate_classical_common(coe)?;
173    validate_classical_a_for_mee(coe)?;
174    check_retrograde_pole(coe.incl, factor)?;
175
176    let resolved = resolve_classical_angles(coe)?;
177    let varpi = wrap_to_pi(resolved.argp + factor.sign() * resolved.raan);
178    let l = normalize_angle(varpi + resolved.nu);
179    let scale = inclination_scale(coe.incl, factor)?;
180
181    Ok(ModifiedEquinoctialElements {
182        p: coe.p,
183        f: coe.ecc * varpi.cos(),
184        g: coe.ecc * varpi.sin(),
185        h: scale * resolved.raan.cos(),
186        k: scale * resolved.raan.sin(),
187        l,
188        retrograde: factor,
189    })
190}
191
192/// Convert modified equinoctial elements to classical elements.
193pub fn mee2coe(mee: &ModifiedEquinoctialElements) -> Result<ClassicalElements, EquinoctialError> {
194    let recovered = recover_modified_equinoctial(mee)?;
195    Ok(recovered_to_classical(&recovered))
196}
197
198/// Convert an inertial Cartesian state to mean-longitude equinoctial elements.
199pub fn rv2eq(
200    r: [f64; 3],
201    v: [f64; 3],
202    mu: f64,
203    factor: RetrogradeFactor,
204) -> Result<EquinoctialElements, EquinoctialError> {
205    let coe = rv2coe(r, v, mu)?;
206    coe2eq(&coe, factor)
207}
208
209/// Convert mean-longitude equinoctial elements to an inertial Cartesian state.
210pub fn eq2rv(eq: &EquinoctialElements, mu: f64) -> Result<([f64; 3], [f64; 3]), EquinoctialError> {
211    let coe = eq2coe(eq)?;
212    Ok(coe2rv(&coe, mu)?)
213}
214
215/// Convert an inertial Cartesian state to modified equinoctial elements.
216pub fn rv2mee(
217    r: [f64; 3],
218    v: [f64; 3],
219    mu: f64,
220    factor: RetrogradeFactor,
221) -> Result<ModifiedEquinoctialElements, EquinoctialError> {
222    let coe = rv2coe(r, v, mu)?;
223    coe2mee(&coe, factor)
224}
225
226/// Convert modified equinoctial elements to an inertial Cartesian state.
227pub fn mee2rv(
228    mee: &ModifiedEquinoctialElements,
229    mu: f64,
230) -> Result<([f64; 3], [f64; 3]), EquinoctialError> {
231    let coe = mee2coe(mee)?;
232    Ok(coe2rv(&coe, mu)?)
233}
234
235/// Convert mean-longitude equinoctial elements to modified equinoctial elements.
236pub fn eq2mee(eq: &EquinoctialElements) -> Result<ModifiedEquinoctialElements, EquinoctialError> {
237    let recovered = recover_equinoctial(eq)?;
238    let scale = inclination_scale(recovered.incl, recovered.retrograde)?;
239
240    Ok(ModifiedEquinoctialElements {
241        p: recovered.p,
242        f: recovered.ecc * recovered.varpi.cos(),
243        g: recovered.ecc * recovered.varpi.sin(),
244        h: scale * recovered.raan.cos(),
245        k: scale * recovered.raan.sin(),
246        l: recovered.l,
247        retrograde: recovered.retrograde,
248    })
249}
250
251/// Convert modified equinoctial elements to mean-longitude equinoctial elements.
252pub fn mee2eq(mee: &ModifiedEquinoctialElements) -> Result<EquinoctialElements, EquinoctialError> {
253    let recovered = recover_modified_equinoctial(mee)?;
254    if is_parabolic(recovered.ecc) {
255        return Err(EquinoctialError::ParabolicEquinoctial);
256    }
257    let mean = true_to_mean(recovered.nu, recovered.ecc)?;
258    let lambda = mean_longitude(mean, recovered.varpi, recovered.ecc);
259    let scale = inclination_scale(recovered.incl, recovered.retrograde)?;
260
261    Ok(EquinoctialElements {
262        a: recovered.a,
263        h: recovered.ecc * recovered.varpi.sin(),
264        k: recovered.ecc * recovered.varpi.cos(),
265        p: scale * recovered.raan.sin(),
266        q: scale * recovered.raan.cos(),
267        lambda,
268        retrograde: recovered.retrograde,
269    })
270}
271
272#[derive(Debug, Clone, Copy)]
273struct ResolvedClassicalAngles {
274    raan: f64,
275    argp: f64,
276    nu: f64,
277}
278
279#[derive(Debug, Clone, Copy)]
280struct RecoveredConic {
281    p: f64,
282    a: f64,
283    ecc: f64,
284    incl: f64,
285    raan: f64,
286    varpi: f64,
287    nu: f64,
288    l: f64,
289    retrograde: RetrogradeFactor,
290}
291
292fn recover_equinoctial(eq: &EquinoctialElements) -> Result<RecoveredConic, EquinoctialError> {
293    validate_equinoctial(eq)?;
294
295    let ecc = eq.h.hypot(eq.k);
296    if is_parabolic(ecc) {
297        return Err(EquinoctialError::ParabolicEquinoctial);
298    }
299    let varpi = eq.h.atan2(eq.k);
300    let incl = recover_inclination(eq.p, eq.q, eq.retrograde)?;
301    let raan = eq.p.atan2(eq.q);
302    let mean = eq.lambda - varpi;
303    let nu = mean_to_true(mean, ecc)?;
304    let l = normalize_angle(varpi + nu);
305    let p = eq.a * (1.0 - ecc * ecc);
306    if !p.is_finite() {
307        return Err(ElementsError::NonFinite { field: "p" }.into());
308    }
309    if p <= 0.0 {
310        return Err(ElementsError::NonPositiveSemiLatus.into());
311    }
312
313    Ok(RecoveredConic {
314        p,
315        a: eq.a,
316        ecc,
317        incl,
318        raan,
319        varpi,
320        nu,
321        l,
322        retrograde: eq.retrograde,
323    })
324}
325
326fn recover_modified_equinoctial(
327    mee: &ModifiedEquinoctialElements,
328) -> Result<RecoveredConic, EquinoctialError> {
329    validate_modified_equinoctial(mee)?;
330
331    let ecc = mee.f.hypot(mee.g);
332    let varpi = mee.g.atan2(mee.f);
333    let incl = recover_inclination(mee.h, mee.k, mee.retrograde)?;
334    let raan = mee.k.atan2(mee.h);
335    let nu = mee.l - varpi;
336    let a = if is_parabolic(ecc) {
337        f64::INFINITY
338    } else {
339        mee.p / (1.0 - ecc * ecc)
340    };
341    if !is_parabolic(ecc) && !a.is_finite() {
342        return Err(ElementsError::NonFinite { field: "a" }.into());
343    }
344
345    Ok(RecoveredConic {
346        p: mee.p,
347        a,
348        ecc,
349        incl,
350        raan,
351        varpi,
352        nu,
353        l: normalize_angle(mee.l),
354        retrograde: mee.retrograde,
355    })
356}
357
358fn recovered_to_classical(recovered: &RecoveredConic) -> ClassicalElements {
359    let orbit_type = classify(recovered.ecc, recovered.incl);
360    let circular = matches!(
361        orbit_type,
362        OrbitType::CircularInclined | OrbitType::CircularEquatorial
363    );
364    let equatorial = matches!(
365        orbit_type,
366        OrbitType::EllipticalEquatorial | OrbitType::CircularEquatorial
367    );
368    let ecc = if circular { 0.0 } else { recovered.ecc };
369    let incl = if equatorial {
370        if recovered.incl > HALF_PI {
371            PI
372        } else {
373            0.0
374        }
375    } else {
376        recovered.incl
377    };
378    let (p, a) = if circular && recovered.a.is_finite() {
379        (recovered.a, recovered.a)
380    } else {
381        (recovered.p, recovered.a)
382    };
383
384    let raan = normalize_angle(recovered.raan);
385    let argp = normalize_angle(recovered.varpi - recovered.retrograde.sign() * raan);
386    let nu = normalize_angle(recovered.nu);
387    let l = normalize_angle(recovered.l);
388
389    match orbit_type {
390        OrbitType::EllipticalInclined => ClassicalElements {
391            p,
392            a,
393            ecc,
394            incl,
395            raan,
396            argp,
397            nu,
398            arglat: f64::NAN,
399            truelon: f64::NAN,
400            lonper: f64::NAN,
401            orbit_type,
402        },
403        OrbitType::CircularInclined => ClassicalElements {
404            p,
405            a,
406            ecc,
407            incl,
408            raan,
409            argp: f64::NAN,
410            nu: f64::NAN,
411            arglat: normalize_angle(l - recovered.retrograde.sign() * raan),
412            truelon: f64::NAN,
413            lonper: f64::NAN,
414            orbit_type,
415        },
416        OrbitType::EllipticalEquatorial => ClassicalElements {
417            p,
418            a,
419            ecc,
420            incl,
421            raan: f64::NAN,
422            argp: f64::NAN,
423            nu,
424            arglat: f64::NAN,
425            truelon: f64::NAN,
426            lonper: folded_equatorial_angle(recovered.varpi, incl),
427            orbit_type,
428        },
429        OrbitType::CircularEquatorial => ClassicalElements {
430            p,
431            a,
432            ecc,
433            incl,
434            raan: f64::NAN,
435            argp: f64::NAN,
436            nu: f64::NAN,
437            arglat: f64::NAN,
438            truelon: folded_equatorial_angle(l, incl),
439            lonper: f64::NAN,
440            orbit_type,
441        },
442    }
443}
444
445fn resolve_classical_angles(
446    coe: &ClassicalElements,
447) -> Result<ResolvedClassicalAngles, EquinoctialError> {
448    match coe.orbit_type {
449        OrbitType::EllipticalInclined => {
450            check_finite(coe.raan, "raan")?;
451            check_finite(coe.argp, "argp")?;
452            check_finite(coe.nu, "nu")?;
453            Ok(ResolvedClassicalAngles {
454                raan: coe.raan,
455                argp: coe.argp,
456                nu: coe.nu,
457            })
458        }
459        OrbitType::CircularInclined => {
460            check_finite(coe.raan, "raan")?;
461            check_finite(coe.arglat, "arglat")?;
462            Ok(ResolvedClassicalAngles {
463                raan: coe.raan,
464                argp: 0.0,
465                nu: coe.arglat,
466            })
467        }
468        OrbitType::EllipticalEquatorial => {
469            check_finite(coe.lonper, "lonper")?;
470            check_finite(coe.nu, "nu")?;
471            Ok(ResolvedClassicalAngles {
472                raan: 0.0,
473                argp: unfolded_equatorial_angle(coe.lonper, coe.incl),
474                nu: coe.nu,
475            })
476        }
477        OrbitType::CircularEquatorial => {
478            check_finite(coe.truelon, "truelon")?;
479            Ok(ResolvedClassicalAngles {
480                raan: 0.0,
481                argp: 0.0,
482                nu: unfolded_equatorial_angle(coe.truelon, coe.incl),
483            })
484        }
485    }
486}
487
488fn validate_classical_common(coe: &ClassicalElements) -> Result<(), EquinoctialError> {
489    check_finite(coe.p, "p")?;
490    if coe.p <= 0.0 {
491        return Err(ElementsError::NonPositiveSemiLatus.into());
492    }
493    check_finite(coe.ecc, "ecc")?;
494    check_finite(coe.incl, "incl")?;
495    Ok(())
496}
497
498fn validate_classical_a_for_mee(coe: &ClassicalElements) -> Result<(), EquinoctialError> {
499    if is_parabolic(coe.ecc) {
500        Ok(())
501    } else {
502        check_finite(coe.a, "a")
503    }
504}
505
506fn validate_equinoctial(eq: &EquinoctialElements) -> Result<(), EquinoctialError> {
507    check_finite(eq.a, "a")?;
508    check_finite(eq.h, "h")?;
509    check_finite(eq.k, "k")?;
510    check_finite(eq.p, "p")?;
511    check_finite(eq.q, "q")?;
512    check_finite(eq.lambda, "lambda")
513}
514
515fn validate_modified_equinoctial(
516    mee: &ModifiedEquinoctialElements,
517) -> Result<(), EquinoctialError> {
518    check_finite(mee.p, "p")?;
519    if mee.p <= 0.0 {
520        return Err(ElementsError::NonPositiveSemiLatus.into());
521    }
522    check_finite(mee.f, "f")?;
523    check_finite(mee.g, "g")?;
524    check_finite(mee.h, "h")?;
525    check_finite(mee.k, "k")?;
526    check_finite(mee.l, "l")
527}
528
529fn check_finite(value: f64, field: &'static str) -> Result<(), EquinoctialError> {
530    if value.is_finite() {
531        Ok(())
532    } else {
533        Err(ElementsError::NonFinite { field }.into())
534    }
535}
536
537fn inclination_scale(incl: f64, factor: RetrogradeFactor) -> Result<f64, EquinoctialError> {
538    check_retrograde_pole(incl, factor)?;
539    let tan_half = (0.5 * incl).tan();
540    let scale = match factor {
541        RetrogradeFactor::Prograde => tan_half,
542        RetrogradeFactor::Retrograde => 1.0 / tan_half,
543    };
544    if scale.is_finite() {
545        Ok(scale)
546    } else {
547        Err(EquinoctialError::RetrogradePole)
548    }
549}
550
551fn recover_inclination(x: f64, y: f64, factor: RetrogradeFactor) -> Result<f64, EquinoctialError> {
552    let scale = x.hypot(y);
553    if !scale.is_finite() {
554        return Err(EquinoctialError::RetrogradePole);
555    }
556
557    let atan_incl = match factor {
558        RetrogradeFactor::Prograde => 2.0 * scale.atan(),
559        RetrogradeFactor::Retrograde => PI - 2.0 * scale.atan(),
560    };
561    let incl = if (STABLE_HALF_ANGLE_MIN..=STABLE_HALF_ANGLE_MAX).contains(&scale) {
562        let scale_sq = scale * scale;
563        let cos_incl = match factor {
564            RetrogradeFactor::Prograde => (1.0 - scale_sq) / (1.0 + scale_sq),
565            RetrogradeFactor::Retrograde => (scale_sq - 1.0) / (1.0 + scale_sq),
566        };
567        clamp_acos(cos_incl)
568    } else {
569        atan_incl
570    };
571    check_retrograde_pole(incl, factor)?;
572    Ok(incl)
573}
574
575fn check_retrograde_pole(incl: f64, factor: RetrogradeFactor) -> Result<(), EquinoctialError> {
576    match factor {
577        RetrogradeFactor::Prograde if (incl - PI).abs() < SMALL => {
578            Err(EquinoctialError::RetrogradePole)
579        }
580        RetrogradeFactor::Retrograde if incl.abs() < SMALL => Err(EquinoctialError::RetrogradePole),
581        _ => Ok(()),
582    }
583}
584
585fn folded_equatorial_angle(angle: f64, incl: f64) -> f64 {
586    let angle = normalize_angle(angle);
587    if incl > HALF_PI {
588        normalize_angle(TWO_PI - angle)
589    } else {
590        angle
591    }
592}
593
594fn unfolded_equatorial_angle(angle: f64, incl: f64) -> f64 {
595    folded_equatorial_angle(angle, incl)
596}
597
598fn mean_longitude(mean: f64, varpi: f64, ecc: f64) -> f64 {
599    if ecc < 1.0 {
600        normalize_angle(mean + varpi)
601    } else {
602        mean + varpi
603    }
604}
605
606fn is_parabolic(ecc: f64) -> bool {
607    (ecc - 1.0).abs() < SMALL
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    const MU: f64 = 398600.4418;
615    const DEG: f64 = std::f64::consts::PI / 180.0;
616
617    #[derive(Debug, Clone, Copy)]
618    struct Case {
619        name: &'static str,
620        coe: ClassicalElements,
621        factor: RetrogradeFactor,
622    }
623
624    fn assert_close(got: f64, want: f64, tol: f64, label: &str) {
625        assert!(
626            (got - want).abs() <= tol,
627            "{label}: got {got}, want {want}, diff {}",
628            (got - want).abs()
629        );
630    }
631
632    fn assert_scaled_close(got: f64, want: f64, rel: f64, label: &str) {
633        let scale = 1.0_f64.max(want.abs());
634        assert!(
635            (got - want).abs() <= rel * scale,
636            "{label}: got {got}, want {want}, diff {}",
637            (got - want).abs()
638        );
639    }
640
641    fn angle_diff(a: f64, b: f64) -> f64 {
642        let diff = normalize_angle(a - b);
643        diff.min(TWO_PI - diff)
644    }
645
646    fn assert_angle_close(got: f64, want: f64, tol: f64, label: &str) {
647        let diff = angle_diff(got, want);
648        assert!(diff <= tol, "{label}: got {got}, want {want}, diff {diff}");
649    }
650
651    fn assert_vec_close(got: [f64; 3], want: [f64; 3], tol: f64, label: &str) {
652        for i in 0..3 {
653            assert!(
654                (got[i] - want[i]).abs() <= tol,
655                "{label}[{i}]: got {}, want {}, diff {}",
656                got[i],
657                want[i],
658                (got[i] - want[i]).abs()
659            );
660        }
661    }
662
663    fn assert_state_close(got: ([f64; 3], [f64; 3]), want: ([f64; 3], [f64; 3]), label: &str) {
664        assert_vec_close(got.0, want.0, 1.0e-7, &format!("{label} r"));
665        assert_vec_close(got.1, want.1, 1.0e-10, &format!("{label} v"));
666    }
667
668    fn classical(p: f64, ecc: f64, incl: f64, raan: f64, argp: f64, nu: f64) -> ClassicalElements {
669        ClassicalElements::new(p, ecc, incl, raan, argp, nu)
670    }
671
672    fn circular_inclined(p: f64, incl: f64, raan: f64, arglat: f64) -> ClassicalElements {
673        let mut coe = ClassicalElements::new(p, 0.0, incl, raan, 0.0, 0.0);
674        coe.orbit_type = OrbitType::CircularInclined;
675        coe.argp = f64::NAN;
676        coe.nu = f64::NAN;
677        coe.arglat = arglat;
678        coe
679    }
680
681    fn elliptical_equatorial(
682        p: f64,
683        ecc: f64,
684        incl: f64,
685        lonper: f64,
686        nu: f64,
687    ) -> ClassicalElements {
688        let mut coe = ClassicalElements::new(p, ecc, incl, 0.0, 0.0, nu);
689        coe.orbit_type = OrbitType::EllipticalEquatorial;
690        coe.raan = f64::NAN;
691        coe.argp = f64::NAN;
692        coe.lonper = lonper;
693        coe
694    }
695
696    fn regimes() -> Vec<Case> {
697        vec![
698            Case {
699                name: "leo circular inclined",
700                coe: circular_inclined(7000.0, 51.6 * DEG, 80.0 * DEG, 135.0 * DEG),
701                factor: RetrogradeFactor::Prograde,
702            },
703            Case {
704                name: "geo near equatorial",
705                coe: classical(
706                    42164.0 * (1.0 - 0.001_f64.powi(2)),
707                    0.001,
708                    0.01 * DEG,
709                    40.0 * DEG,
710                    20.0 * DEG,
711                    10.0 * DEG,
712                ),
713                factor: RetrogradeFactor::Prograde,
714            },
715            Case {
716                name: "molniya",
717                coe: classical(
718                    26600.0 * (1.0 - 0.74_f64.powi(2)),
719                    0.74,
720                    63.4 * DEG,
721                    30.0 * DEG,
722                    270.0 * DEG,
723                    15.0 * DEG,
724                ),
725                factor: RetrogradeFactor::Prograde,
726            },
727            Case {
728                name: "near circular",
729                coe: classical(
730                    7200.0 * (1.0 - 1.0e-6_f64.powi(2)),
731                    1.0e-6,
732                    40.0 * DEG,
733                    12.0 * DEG,
734                    25.0 * DEG,
735                    80.0 * DEG,
736                ),
737                factor: RetrogradeFactor::Prograde,
738            },
739            Case {
740                name: "near equatorial",
741                coe: classical(
742                    9000.0 * (1.0 - 0.2_f64.powi(2)),
743                    0.2,
744                    1.0e-4 * DEG,
745                    90.0 * DEG,
746                    80.0 * DEG,
747                    70.0 * DEG,
748                ),
749                factor: RetrogradeFactor::Prograde,
750            },
751            Case {
752                name: "combined near circular near equatorial",
753                coe: classical(
754                    7100.0 * (1.0 - 1.0e-6_f64.powi(2)),
755                    1.0e-6,
756                    1.0e-4 * DEG,
757                    5.0 * DEG,
758                    5.0 * DEG,
759                    45.0 * DEG,
760                ),
761                factor: RetrogradeFactor::Prograde,
762            },
763            Case {
764                name: "retrograde prograde factor",
765                coe: classical(
766                    12000.0 * (1.0 - 0.1_f64.powi(2)),
767                    0.1,
768                    175.0 * DEG,
769                    45.0 * DEG,
770                    10.0 * DEG,
771                    250.0 * DEG,
772                ),
773                factor: RetrogradeFactor::Prograde,
774            },
775            Case {
776                name: "true retrograde",
777                coe: elliptical_equatorial(
778                    11000.0 * (1.0 - 0.2_f64.powi(2)),
779                    0.2,
780                    PI,
781                    40.0 * DEG,
782                    80.0 * DEG,
783                ),
784                factor: RetrogradeFactor::Retrograde,
785            },
786        ]
787    }
788
789    fn assert_classical_recombined_close(
790        got: &ClassicalElements,
791        want: &ClassicalElements,
792        factor: RetrogradeFactor,
793        label: &str,
794    ) {
795        assert_eq!(got.orbit_type, want.orbit_type, "{label} orbit_type");
796        assert_scaled_close(got.p, want.p, 1.0e-10, &format!("{label} p"));
797        if got.a.is_finite() && want.a.is_finite() {
798            assert_scaled_close(got.a, want.a, 1.0e-10, &format!("{label} a"));
799        } else {
800            assert_eq!(got.a.is_infinite(), want.a.is_infinite(), "{label} a inf");
801        }
802        assert_close(got.ecc, want.ecc, 1.0e-10, &format!("{label} ecc"));
803        assert_angle_close(got.incl, want.incl, 1.0e-10, &format!("{label} incl"));
804
805        let got_resolved = resolve_classical_angles(got).unwrap();
806        let want_resolved = resolve_classical_angles(want).unwrap();
807        let got_varpi = wrap_to_pi(got_resolved.argp + factor.sign() * got_resolved.raan);
808        let want_varpi = wrap_to_pi(want_resolved.argp + factor.sign() * want_resolved.raan);
809        assert_angle_close(got_varpi, want_varpi, 1.0e-10, &format!("{label} varpi"));
810        assert_angle_close(
811            normalize_angle(got_varpi + got_resolved.nu),
812            normalize_angle(want_varpi + want_resolved.nu),
813            1.0e-10,
814            &format!("{label} L"),
815        );
816    }
817
818    fn assert_eq_close(
819        got: &EquinoctialElements,
820        want: &EquinoctialElements,
821        ecc: f64,
822        label: &str,
823    ) {
824        assert_scaled_close(got.a, want.a, 1.0e-10, &format!("{label} a"));
825        assert_close(got.h, want.h, 1.0e-10, &format!("{label} h"));
826        assert_close(got.k, want.k, 1.0e-10, &format!("{label} k"));
827        assert_close(got.p, want.p, 1.0e-10, &format!("{label} p"));
828        assert_close(got.q, want.q, 1.0e-10, &format!("{label} q"));
829        if ecc > 1.0 {
830            assert_scaled_close(got.lambda, want.lambda, 1.0e-9, &format!("{label} lambda"));
831        } else {
832            assert_angle_close(got.lambda, want.lambda, 1.0e-10, &format!("{label} lambda"));
833        }
834        assert_eq!(got.retrograde, want.retrograde, "{label} factor");
835    }
836
837    fn assert_mee_close(
838        got: &ModifiedEquinoctialElements,
839        want: &ModifiedEquinoctialElements,
840        label: &str,
841    ) {
842        assert_scaled_close(got.p, want.p, 1.0e-10, &format!("{label} p"));
843        assert_close(got.f, want.f, 1.0e-10, &format!("{label} f"));
844        assert_close(got.g, want.g, 1.0e-10, &format!("{label} g"));
845        assert_close(got.h, want.h, 1.0e-10, &format!("{label} h"));
846        assert_close(got.k, want.k, 1.0e-10, &format!("{label} k"));
847        assert_angle_close(got.l, want.l, 1.0e-10, &format!("{label} l"));
848        assert_eq!(got.retrograde, want.retrograde, "{label} factor");
849    }
850
851    #[test]
852    fn round_trip_state_across_regimes() {
853        for case in regimes() {
854            let state = coe2rv(&case.coe, MU).unwrap();
855
856            let eq = rv2eq(state.0, state.1, MU, case.factor).unwrap();
857            let eq_state = eq2rv(&eq, MU).unwrap();
858            assert_state_close(eq_state, state, case.name);
859
860            let mee = rv2mee(state.0, state.1, MU, case.factor).unwrap();
861            let mee_state = mee2rv(&mee, MU).unwrap();
862            assert_state_close(mee_state, state, case.name);
863        }
864    }
865
866    #[test]
867    fn round_trip_element_algebra_across_regimes() {
868        for case in regimes() {
869            let eq = coe2eq(&case.coe, case.factor).unwrap();
870            let eq_back = eq2coe(&eq).unwrap();
871            assert_classical_recombined_close(&eq_back, &case.coe, case.factor, case.name);
872
873            let mee = coe2mee(&case.coe, case.factor).unwrap();
874            let mee_back = mee2coe(&mee).unwrap();
875            assert_classical_recombined_close(&mee_back, &case.coe, case.factor, case.name);
876
877            let converted_mee = eq2mee(&eq).unwrap();
878            assert_mee_close(&converted_mee, &mee, case.name);
879
880            let converted_eq = mee2eq(&mee).unwrap();
881            assert_eq_close(&converted_eq, &eq, case.coe.ecc, case.name);
882        }
883    }
884
885    #[test]
886    fn vallado_reference_components_match_full_precision_and_rounded_values() {
887        let r = [6524.834, 6862.875, 6448.296];
888        let v = [4.901327, 5.533756, -1.976341];
889        let coe = rv2coe(r, v, MU).unwrap();
890
891        let eq = coe2eq(&coe, RetrogradeFactor::Prograde).unwrap();
892        let mee = coe2mee(&coe, RetrogradeFactor::Prograde).unwrap();
893
894        // Derivation: rv2coe gives the full-precision classical oracle for this
895        // state. The expected components below are recomputed from those values
896        // with the definitions in this module. The rounded check uses Vallado's
897        // printed Example 2-5 elements only as a loose external anchor.
898        let varpi = wrap_to_pi(coe.argp + coe.raan);
899        let mean = true_to_mean(coe.nu, coe.ecc).unwrap();
900        let scale = (0.5 * coe.incl).tan();
901        assert_close(eq.a, coe.a, 1.0e-12, "eq a full");
902        assert_close(eq.h, coe.ecc * varpi.sin(), 1.0e-12, "eq h full");
903        assert_close(eq.k, coe.ecc * varpi.cos(), 1.0e-12, "eq k full");
904        assert_close(eq.p, scale * coe.raan.sin(), 1.0e-12, "eq p full");
905        assert_close(eq.q, scale * coe.raan.cos(), 1.0e-12, "eq q full");
906        assert_angle_close(eq.lambda, mean + varpi, 1.0e-12, "eq lambda full");
907
908        assert_close(mee.p, coe.p, 1.0e-12, "mee p full");
909        assert_close(mee.f, coe.ecc * varpi.cos(), 1.0e-12, "mee f full");
910        assert_close(mee.g, coe.ecc * varpi.sin(), 1.0e-12, "mee g full");
911        assert_close(mee.h, scale * coe.raan.cos(), 1.0e-12, "mee h full");
912        assert_close(mee.k, scale * coe.raan.sin(), 1.0e-12, "mee k full");
913        assert_angle_close(mee.l, varpi + coe.nu, 1.0e-12, "mee l full");
914
915        let e = 0.832853_f64;
916        let incl = 87.870 * DEG;
917        let raan = 227.898 * DEG;
918        let argp = 53.38 * DEG;
919        let nu = 92.335 * DEG;
920        let varpi = wrap_to_pi(argp + raan);
921        let mean = true_to_mean(nu, e).unwrap();
922        let scale = (0.5 * incl).tan();
923
924        assert_close(eq.a, 36127.343, 1.0e-2, "eq a rounded");
925        assert_close(eq.h, e * varpi.sin(), 1.0e-4, "eq h rounded");
926        assert_close(eq.k, e * varpi.cos(), 1.0e-4, "eq k rounded");
927        assert_close(eq.p, scale * raan.sin(), 1.0e-4, "eq p rounded");
928        assert_close(eq.q, scale * raan.cos(), 1.0e-4, "eq q rounded");
929        assert_angle_close(eq.lambda, mean + varpi, 1.0e-4, "eq lambda rounded");
930
931        assert_close(mee.p, 11067.790, 1.0e-2, "mee p rounded");
932        assert_close(mee.f, e * varpi.cos(), 1.0e-4, "mee f rounded");
933        assert_close(mee.g, e * varpi.sin(), 1.0e-4, "mee g rounded");
934        assert_close(mee.h, scale * raan.cos(), 1.0e-4, "mee h rounded");
935        assert_close(mee.k, scale * raan.sin(), 1.0e-4, "mee k rounded");
936        assert_angle_close(mee.l, varpi + nu, 1.0e-4, "mee l rounded");
937    }
938
939    #[test]
940    fn mee2rv_near_equatorial_low_eccentricity_regression() {
941        let coe = classical(
942            7000.0 * (1.0 - 1.0e-4_f64.powi(2)),
943            1.0e-4,
944            1.0e-3 * DEG,
945            15.0 * DEG,
946            25.0 * DEG,
947            35.0 * DEG,
948        );
949        let state = coe2rv(&coe, MU).unwrap();
950        let mee = coe2mee(&coe, RetrogradeFactor::Prograde).unwrap();
951        assert_state_close(mee2rv(&mee, MU).unwrap(), state, "near equatorial mee2rv");
952    }
953
954    #[test]
955    fn retrograde_pole_errors_and_opposite_factor_round_trips() {
956        let coe = elliptical_equatorial(
957            11000.0 * (1.0 - 0.2_f64.powi(2)),
958            0.2,
959            PI,
960            40.0 * DEG,
961            80.0 * DEG,
962        );
963        assert_eq!(
964            coe2eq(&coe, RetrogradeFactor::Prograde),
965            Err(EquinoctialError::RetrogradePole)
966        );
967        assert_eq!(
968            coe2mee(&coe, RetrogradeFactor::Prograde),
969            Err(EquinoctialError::RetrogradePole)
970        );
971
972        let state = coe2rv(&coe, MU).unwrap();
973        let eq = coe2eq(&coe, RetrogradeFactor::Retrograde).unwrap();
974        assert_state_close(eq2rv(&eq, MU).unwrap(), state, "retrograde eq");
975        let mee = coe2mee(&coe, RetrogradeFactor::Retrograde).unwrap();
976        assert_state_close(mee2rv(&mee, MU).unwrap(), state, "retrograde mee");
977    }
978
979    #[test]
980    fn parabolic_equinoctial_rejected_and_mee_round_trips() {
981        let coe = classical(12000.0, 1.0, 30.0 * DEG, 40.0 * DEG, 50.0 * DEG, 60.0 * DEG);
982        assert_eq!(
983            coe2eq(&coe, RetrogradeFactor::Prograde),
984            Err(EquinoctialError::ParabolicEquinoctial)
985        );
986
987        let state = coe2rv(&coe, MU).unwrap();
988        assert_eq!(
989            rv2eq(state.0, state.1, MU, RetrogradeFactor::Prograde),
990            Err(EquinoctialError::ParabolicEquinoctial)
991        );
992
993        let mee = coe2mee(&coe, RetrogradeFactor::Prograde).unwrap();
994        assert_close(mee.p, coe.p, 1.0e-12, "mee parabolic p");
995        let back = mee2coe(&mee).unwrap();
996        assert!(back.a.is_infinite());
997        assert_close(back.p, coe.p, 1.0e-12, "coe parabolic p");
998        assert_state_close(mee2rv(&mee, MU).unwrap(), state, "parabolic mee");
999    }
1000
1001    #[test]
1002    fn input_rejections_are_typed() {
1003        let coe = classical(
1004            7200.0 * (1.0 - 0.01_f64.powi(2)),
1005            0.01,
1006            40.0 * DEG,
1007            12.0 * DEG,
1008            25.0 * DEG,
1009            80.0 * DEG,
1010        );
1011        let state = coe2rv(&coe, MU).unwrap();
1012        let mut eq = coe2eq(&coe, RetrogradeFactor::Prograde).unwrap();
1013        eq.h = f64::NAN;
1014        assert!(matches!(
1015            eq2coe(&eq),
1016            Err(EquinoctialError::Elements(ElementsError::NonFinite {
1017                field: "h"
1018            }))
1019        ));
1020
1021        let mut mee = coe2mee(&coe, RetrogradeFactor::Prograde).unwrap();
1022        mee.l = f64::INFINITY;
1023        assert!(matches!(
1024            mee2coe(&mee),
1025            Err(EquinoctialError::Elements(ElementsError::NonFinite {
1026                field: "l"
1027            }))
1028        ));
1029
1030        let eq = coe2eq(&coe, RetrogradeFactor::Prograde).unwrap();
1031        assert_eq!(
1032            eq2rv(&eq, 0.0),
1033            Err(EquinoctialError::Elements(ElementsError::NonPositiveMu))
1034        );
1035        assert_eq!(
1036            rv2eq(state.0, state.1, 0.0, RetrogradeFactor::Prograde),
1037            Err(EquinoctialError::Elements(ElementsError::NonPositiveMu))
1038        );
1039        assert_eq!(
1040            rv2eq([0.0, 0.0, 0.0], state.1, MU, RetrogradeFactor::Prograde),
1041            Err(EquinoctialError::Elements(ElementsError::ZeroPosition))
1042        );
1043        assert_eq!(
1044            rv2mee(
1045                [7000.0, 0.0, 0.0],
1046                [7.5, 0.0, 0.0],
1047                MU,
1048                RetrogradeFactor::Prograde
1049            ),
1050            Err(EquinoctialError::Elements(ElementsError::DegenerateOrbit))
1051        );
1052
1053        let hyperbolic = classical(10000.0, 1.5, 40.0 * DEG, 20.0 * DEG, 30.0 * DEG, PI);
1054        assert!(matches!(
1055            coe2eq(&hyperbolic, RetrogradeFactor::Prograde),
1056            Err(EquinoctialError::Anomaly(
1057                AnomalyError::BeyondAsymptote { .. }
1058            ))
1059        ));
1060    }
1061}