Skip to main content

sidereon_core/astro/
elements.rs

1//! State-vector to classical orbital element conversions.
2//!
3//! Authoritative implementations of the standard two-body conversions between an
4//! inertial Cartesian state (ECI position and velocity) and the classical
5//! Keplerian elements; language bindings are thin marshaling layers over these
6//! functions.
7//!
8//! - [`rv2coe`] - position/velocity to classical elements (Algorithm 9,
9//!   Vallado 2022, pp. 113-116).
10//! - [`coe2rv`] - classical elements to position/velocity (Algorithm 10,
11//!   Vallado 2022, pp. 118-120).
12//!
13//! ## Angle conventions
14//!
15//! All angles in [`ClassicalElements`] are radians, normalized to `[0, 2*pi)`
16//! except inclination which lies in `[0, pi]`. The right ascension of the
17//! ascending node, argument of perigee, and true anomaly follow the Vallado
18//! reference conventions, including the canonical auxiliary outputs that resolve
19//! the degenerate elements of circular and equatorial orbits:
20//!
21//! - argument of latitude (`u = argp + nu`) for circular inclined orbits, where
22//!   the argument of perigee is undefined;
23//! - longitude of perigee (`lonper`) for elliptical equatorial orbits, where the
24//!   node is undefined;
25//! - true longitude (`truelon`) for circular equatorial orbits, where both the
26//!   node and the argument of perigee are undefined.
27//!
28//! The auxiliary outputs that do not apply to a given orbit are returned as
29//! `f64::NAN`. [`coe2rv`] reads back whichever auxiliary angle the orbit type
30//! requires, so a [`ClassicalElements`] produced by [`rv2coe`] round-trips
31//! through [`coe2rv`] regardless of orbit type.
32
33use crate::astro::math::vec3;
34
35/// Vallado degeneracy threshold (`small`) for treating eccentricity or
36/// inclination as zero when classifying the orbit type (Algorithm 9).
37const SMALL: f64 = 1.0e-8;
38
39const TWO_PI: f64 = 2.0 * std::f64::consts::PI;
40const PI: f64 = std::f64::consts::PI;
41const HALF_PI: f64 = std::f64::consts::FRAC_PI_2;
42
43/// Geometric classification of a two-body orbit, which determines which of the
44/// classical elements are defined and which auxiliary angle resolves the
45/// degenerate ones.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum OrbitType {
48    /// Eccentric and inclined: all six classical elements are defined.
49    EllipticalInclined,
50    /// Eccentric but equatorial: the ascending node is undefined; the longitude
51    /// of perigee ([`ClassicalElements::lonper`]) replaces it.
52    EllipticalEquatorial,
53    /// Circular but inclined: the argument of perigee is undefined; the argument
54    /// of latitude ([`ClassicalElements::arglat`]) replaces it.
55    CircularInclined,
56    /// Circular and equatorial: both the node and the argument of perigee are
57    /// undefined; the true longitude ([`ClassicalElements::truelon`]) replaces
58    /// them.
59    CircularEquatorial,
60}
61
62/// Classical (Keplerian) orbital elements in the Vallado convention.
63///
64/// Angles are radians. [`semi_latus_rectum`](Self::p) is the primary size
65/// element used by [`coe2rv`] so the representation stays well defined for
66/// parabolic orbits, where the semi-major axis is infinite.
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct ClassicalElements {
69    /// Semi-latus rectum `p = h^2 / mu` (km).
70    pub p: f64,
71    /// Semi-major axis `a` (km). `f64::INFINITY` for a parabolic orbit
72    /// (`ecc == 1`).
73    pub a: f64,
74    /// Eccentricity (dimensionless).
75    pub ecc: f64,
76    /// Inclination in `[0, pi]` (rad).
77    pub incl: f64,
78    /// Right ascension of the ascending node in `[0, 2*pi)` (rad). Undefined
79    /// (`f64::NAN`) for equatorial orbits.
80    pub raan: f64,
81    /// Argument of perigee in `[0, 2*pi)` (rad). Undefined (`f64::NAN`) for
82    /// circular orbits.
83    pub argp: f64,
84    /// True anomaly in `[0, 2*pi)` (rad). Undefined (`f64::NAN`) for circular
85    /// orbits.
86    pub nu: f64,
87    /// Argument of latitude `u = argp + nu` in `[0, 2*pi)` (rad). Defined for
88    /// circular inclined orbits, `f64::NAN` otherwise.
89    pub arglat: f64,
90    /// True longitude in `[0, 2*pi)` (rad). Defined for circular equatorial
91    /// orbits, `f64::NAN` otherwise.
92    pub truelon: f64,
93    /// Longitude of perigee in `[0, 2*pi)` (rad). Defined for elliptical
94    /// equatorial orbits, `f64::NAN` otherwise.
95    pub lonper: f64,
96    /// Geometric classification of the orbit.
97    pub orbit_type: OrbitType,
98}
99
100impl ClassicalElements {
101    /// Build a non-degenerate (elliptical inclined) element set from the six
102    /// primary elements, leaving the auxiliary special-case angles undefined.
103    ///
104    /// This is the convenience constructor for ordinary orbits fed to
105    /// [`coe2rv`]. For circular or equatorial orbits, populate the relevant
106    /// auxiliary angle and [`orbit_type`](Self::orbit_type) directly, or obtain
107    /// the element set from [`rv2coe`].
108    ///
109    /// `p` is the semi-latus rectum (km); the semi-major axis is derived as
110    /// `p / (1 - ecc^2)`.
111    pub fn new(p: f64, ecc: f64, incl: f64, raan: f64, argp: f64, nu: f64) -> Self {
112        let a = if (ecc - 1.0).abs() < SMALL {
113            f64::INFINITY
114        } else {
115            p / (1.0 - ecc * ecc)
116        };
117        Self {
118            p,
119            a,
120            ecc,
121            incl,
122            raan,
123            argp,
124            nu,
125            arglat: f64::NAN,
126            truelon: f64::NAN,
127            lonper: f64::NAN,
128            orbit_type: OrbitType::EllipticalInclined,
129        }
130    }
131}
132
133/// Error returned by the classical-element conversions.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
135pub enum ElementsError {
136    /// An input position, velocity, element, or `mu` was not finite.
137    #[error("non-finite input {field}")]
138    NonFinite {
139        /// The offending input.
140        field: &'static str,
141    },
142    /// The gravitational parameter was not strictly positive.
143    #[error("mu must be positive")]
144    NonPositiveMu,
145    /// The position vector has (near) zero magnitude, so it cannot be
146    /// normalized.
147    #[error("position vector has near-zero magnitude")]
148    ZeroPosition,
149    /// The specific angular momentum is (near) zero (rectilinear trajectory), so
150    /// no orbit plane is defined.
151    #[error("angular momentum is near zero (degenerate orbit)")]
152    DegenerateOrbit,
153    /// The semi-latus rectum is non-positive, so the orbit is not defined.
154    #[error("semi-latus rectum must be positive")]
155    NonPositiveSemiLatus,
156}
157
158/// Convert an inertial Cartesian state to classical orbital elements.
159///
160/// `r` is the ECI position (km), `v` the ECI velocity (km/s), and `mu` the
161/// gravitational parameter (km^3/s^2). Implements Vallado Algorithm 9 (RV2COE)
162/// including the canonical special-case handling for circular and equatorial
163/// orbits.
164pub fn rv2coe(r: [f64; 3], v: [f64; 3], mu: f64) -> Result<ClassicalElements, ElementsError> {
165    validate_finite(&r, "r")?;
166    validate_finite(&v, "v")?;
167    if !mu.is_finite() {
168        return Err(ElementsError::NonFinite { field: "mu" });
169    }
170    if mu <= 0.0 {
171        return Err(ElementsError::NonPositiveMu);
172    }
173
174    let magr = vec3::norm3(r);
175    let magv = vec3::norm3(v);
176    if magr < SMALL {
177        return Err(ElementsError::ZeroPosition);
178    }
179
180    // Angular momentum h = r x v.
181    let hbar = vec3::cross3(r, v);
182    let magh = vec3::norm3(hbar);
183    if magh < SMALL {
184        return Err(ElementsError::DegenerateOrbit);
185    }
186
187    // Node vector n = k x h, pointing toward the ascending node.
188    let nbar = [-hbar[1], hbar[0], 0.0];
189    let magn = vec3::norm3(nbar);
190
191    // Eccentricity vector e = ((v^2 - mu/r) r - (r.v) v) / mu.
192    let rdotv = vec3::dot3(r, v);
193    let c1 = magv * magv - mu / magr;
194    let ebar = [
195        (c1 * r[0] - rdotv * v[0]) / mu,
196        (c1 * r[1] - rdotv * v[1]) / mu,
197        (c1 * r[2] - rdotv * v[2]) / mu,
198    ];
199    let ecc = vec3::norm3(ebar);
200
201    // Specific mechanical energy and the size elements.
202    let sme = magv * magv * 0.5 - mu / magr;
203    let a = if sme.abs() > SMALL {
204        -mu / (2.0 * sme)
205    } else {
206        f64::INFINITY
207    };
208    let p = magh * magh / mu;
209
210    let incl = clamp_acos(hbar[2] / magh);
211
212    let orbit_type = classify(ecc, incl);
213
214    // Right ascension of the ascending node.
215    let raan = if magn > SMALL {
216        let mut omega = clamp_acos(nbar[0] / magn);
217        if nbar[1] < 0.0 {
218            omega = TWO_PI - omega;
219        }
220        omega
221    } else {
222        f64::NAN
223    };
224
225    // Argument of perigee (elliptical inclined only).
226    let argp = if orbit_type == OrbitType::EllipticalInclined {
227        let mut ap = angle_between(nbar, ebar);
228        if ebar[2] < 0.0 {
229            ap = TWO_PI - ap;
230        }
231        ap
232    } else {
233        f64::NAN
234    };
235
236    // True anomaly (any eccentric orbit).
237    let nu = if ecc > SMALL {
238        let mut ta = angle_between(ebar, r);
239        if rdotv < 0.0 {
240            ta = TWO_PI - ta;
241        }
242        ta
243    } else {
244        f64::NAN
245    };
246
247    // Argument of latitude (circular inclined).
248    let arglat = if orbit_type == OrbitType::CircularInclined {
249        let mut u = angle_between(nbar, r);
250        if r[2] < 0.0 {
251            u = TWO_PI - u;
252        }
253        u
254    } else {
255        f64::NAN
256    };
257
258    // Longitude of perigee (elliptical equatorial).
259    let lonper = if orbit_type == OrbitType::EllipticalEquatorial {
260        let mut lp = clamp_acos(ebar[0] / ecc);
261        if ebar[1] < 0.0 {
262            lp = TWO_PI - lp;
263        }
264        if incl > HALF_PI {
265            lp = TWO_PI - lp;
266        }
267        normalize_angle(lp)
268    } else {
269        f64::NAN
270    };
271
272    // True longitude (circular equatorial).
273    let truelon = if orbit_type == OrbitType::CircularEquatorial {
274        let mut tl = clamp_acos(r[0] / magr);
275        if r[1] < 0.0 {
276            tl = TWO_PI - tl;
277        }
278        if incl > HALF_PI {
279            tl = TWO_PI - tl;
280        }
281        normalize_angle(tl)
282    } else {
283        f64::NAN
284    };
285
286    // Canonicalize the shape and orientation elements that the classification
287    // collapsed, so coe2rv reconstructs a self-consistent state that round-trips.
288    // A circular orbit carries exactly zero eccentricity (its true anomaly folds
289    // into the auxiliary angle); an equatorial orbit carries exactly zero or pi
290    // inclination (its node folds into the auxiliary angle). Keeping the tiny
291    // residual ecc/incl while zeroing the dropped angles would not round-trip.
292    let circular = matches!(
293        orbit_type,
294        OrbitType::CircularInclined | OrbitType::CircularEquatorial
295    );
296    let equatorial = matches!(
297        orbit_type,
298        OrbitType::EllipticalEquatorial | OrbitType::CircularEquatorial
299    );
300    let ecc = if circular { 0.0 } else { ecc };
301    let incl = if equatorial {
302        if incl > HALF_PI {
303            PI
304        } else {
305            0.0
306        }
307    } else {
308        incl
309    };
310
311    Ok(ClassicalElements {
312        p,
313        a,
314        ecc,
315        incl,
316        raan,
317        argp,
318        nu,
319        arglat,
320        truelon,
321        lonper,
322        orbit_type,
323    })
324}
325
326/// Convert classical orbital elements to an inertial Cartesian state.
327///
328/// Returns the ECI position (km) and velocity (km/s) for `mu` (km^3/s^2).
329/// Implements Vallado Algorithm 10 (COE2RV), reading whichever auxiliary angle
330/// ([`ClassicalElements::arglat`], [`truelon`](ClassicalElements::truelon), or
331/// [`lonper`](ClassicalElements::lonper)) the orbit type requires.
332pub fn coe2rv(coe: &ClassicalElements, mu: f64) -> Result<([f64; 3], [f64; 3]), ElementsError> {
333    if !mu.is_finite() {
334        return Err(ElementsError::NonFinite { field: "mu" });
335    }
336    if mu <= 0.0 {
337        return Err(ElementsError::NonPositiveMu);
338    }
339    if !coe.p.is_finite() {
340        return Err(ElementsError::NonFinite { field: "p" });
341    }
342    if coe.p <= 0.0 {
343        return Err(ElementsError::NonPositiveSemiLatus);
344    }
345    if !coe.ecc.is_finite() {
346        return Err(ElementsError::NonFinite { field: "ecc" });
347    }
348    if !coe.incl.is_finite() {
349        return Err(ElementsError::NonFinite { field: "incl" });
350    }
351
352    // Resolve the in-plane and orientation angles, substituting the auxiliary
353    // special-case angle for any element that is undefined for this orbit type.
354    let (raan, argp, nu) = match coe.orbit_type {
355        OrbitType::EllipticalInclined => {
356            check_angle(coe.raan, "raan")?;
357            check_angle(coe.argp, "argp")?;
358            check_angle(coe.nu, "nu")?;
359            (coe.raan, coe.argp, coe.nu)
360        }
361        OrbitType::CircularInclined => {
362            check_angle(coe.raan, "raan")?;
363            check_angle(coe.arglat, "arglat")?;
364            (coe.raan, 0.0, coe.arglat)
365        }
366        OrbitType::EllipticalEquatorial => {
367            check_angle(coe.lonper, "lonper")?;
368            check_angle(coe.nu, "nu")?;
369            (0.0, coe.lonper, coe.nu)
370        }
371        OrbitType::CircularEquatorial => {
372            check_angle(coe.truelon, "truelon")?;
373            (0.0, 0.0, coe.truelon)
374        }
375    };
376
377    let ecc = coe.ecc;
378    let p = coe.p;
379    let incl = coe.incl;
380
381    let (sin_nu, cos_nu) = nu.sin_cos();
382
383    // Perifocal (PQW) position and velocity.
384    let temp = p / (1.0 + ecc * cos_nu);
385    let r_pqw = [temp * cos_nu, temp * sin_nu, 0.0];
386    let sqrt_mu_p = (mu / p).sqrt();
387    let v_pqw = [-sin_nu * sqrt_mu_p, (ecc + cos_nu) * sqrt_mu_p, 0.0];
388
389    // Perifocal-to-ECI rotation R = ROT3(-raan) ROT1(-incl) ROT3(-argp).
390    let (sin_raan, cos_raan) = raan.sin_cos();
391    let (sin_argp, cos_argp) = argp.sin_cos();
392    let (sin_incl, cos_incl) = incl.sin_cos();
393
394    let m11 = cos_raan * cos_argp - sin_raan * sin_argp * cos_incl;
395    let m12 = -cos_raan * sin_argp - sin_raan * cos_argp * cos_incl;
396    let m21 = sin_raan * cos_argp + cos_raan * sin_argp * cos_incl;
397    let m22 = -sin_raan * sin_argp + cos_raan * cos_argp * cos_incl;
398    let m31 = sin_argp * sin_incl;
399    let m32 = cos_argp * sin_incl;
400
401    let r = [
402        m11 * r_pqw[0] + m12 * r_pqw[1],
403        m21 * r_pqw[0] + m22 * r_pqw[1],
404        m31 * r_pqw[0] + m32 * r_pqw[1],
405    ];
406    let v = [
407        m11 * v_pqw[0] + m12 * v_pqw[1],
408        m21 * v_pqw[0] + m22 * v_pqw[1],
409        m31 * v_pqw[0] + m32 * v_pqw[1],
410    ];
411
412    Ok((r, v))
413}
414
415/// Classify an orbit from its eccentricity and inclination per the Vallado
416/// degeneracy thresholds.
417fn classify(ecc: f64, incl: f64) -> OrbitType {
418    let equatorial = incl < SMALL || (incl - PI).abs() < SMALL;
419    let circular = ecc < SMALL;
420    match (circular, equatorial) {
421        (true, true) => OrbitType::CircularEquatorial,
422        (true, false) => OrbitType::CircularInclined,
423        (false, true) => OrbitType::EllipticalEquatorial,
424        (false, false) => OrbitType::EllipticalInclined,
425    }
426}
427
428/// Normalize an angle to the documented `[0, 2*pi)` range. Applied after the
429/// retrograde and quadrant corrections so a value driven to exactly `2*pi` at the
430/// boundary wraps back to `0` rather than escaping the half-open range.
431#[inline]
432fn normalize_angle(x: f64) -> f64 {
433    x.rem_euclid(TWO_PI)
434}
435
436/// `acos` with the argument clamped to `[-1, 1]` to absorb round-off that would
437/// otherwise produce a NaN at the poles of the conversion.
438#[inline]
439fn clamp_acos(x: f64) -> f64 {
440    x.clamp(-1.0, 1.0).acos()
441}
442
443/// Unsigned angle between two vectors in `[0, pi]`, clamped against round-off.
444#[inline]
445fn angle_between(a: [f64; 3], b: [f64; 3]) -> f64 {
446    let denom = vec3::norm3(a) * vec3::norm3(b);
447    clamp_acos(vec3::dot3(a, b) / denom)
448}
449
450#[inline]
451fn validate_finite(v: &[f64; 3], field: &'static str) -> Result<(), ElementsError> {
452    if v.iter().all(|x| x.is_finite()) {
453        Ok(())
454    } else {
455        Err(ElementsError::NonFinite { field })
456    }
457}
458
459#[inline]
460fn check_angle(x: f64, field: &'static str) -> Result<(), ElementsError> {
461    if x.is_finite() {
462        Ok(())
463    } else {
464        Err(ElementsError::NonFinite { field })
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    /// Vallado reference suite gravitational parameter (km^3/s^2).
473    const MU: f64 = 398600.4418;
474    const DEG: f64 = std::f64::consts::PI / 180.0;
475
476    fn assert_close(got: f64, want: f64, tol: f64, what: &str) {
477        assert!(
478            (got - want).abs() < tol,
479            "{what}: got {got}, want {want}, diff {}",
480            (got - want).abs()
481        );
482    }
483
484    fn assert_vec_close(got: [f64; 3], want: [f64; 3], tol: f64, what: &str) {
485        for i in 0..3 {
486            assert!(
487                (got[i] - want[i]).abs() < tol,
488                "{what}[{i}]: got {}, want {}, diff {}",
489                got[i],
490                want[i],
491                (got[i] - want[i]).abs()
492            );
493        }
494    }
495
496    /// Vallado 2022 Example 2-5 (RV2COE): the canonical worked example.
497    #[test]
498    fn rv2coe_vallado_example_2_5() {
499        let r = [6524.834, 6862.875, 6448.296];
500        let v = [4.901327, 5.533756, -1.976341];
501
502        let coe = rv2coe(r, v, MU).unwrap();
503
504        // Published results (Vallado 2022, Example 2-5).
505        assert_close(coe.p, 11067.790, 1.0e-2, "p");
506        assert_close(coe.a, 36127.343, 1.0e-2, "a");
507        assert_close(coe.ecc, 0.832853, 1.0e-5, "ecc");
508        assert_close(coe.incl, 87.870 * DEG, 1.0e-4, "incl");
509        assert_close(coe.raan, 227.898 * DEG, 1.0e-4, "raan");
510        assert_close(coe.argp, 53.38 * DEG, 1.0e-3, "argp");
511        assert_close(coe.nu, 92.335 * DEG, 1.0e-4, "nu");
512        assert_eq!(coe.orbit_type, OrbitType::EllipticalInclined);
513    }
514
515    /// Vallado Example 2-6 (COE2RV) is the inverse of 2-5: the published
516    /// elements must reproduce the state vector.
517    #[test]
518    fn coe2rv_vallado_example_2_6() {
519        // Vallado 2022 Example 2-6 (COE2RV) uses the rounded elements published
520        // for that worked example, p = 11067.790 km, e = 0.83285, i = 87.87 deg,
521        // raan = 227.89 deg, argp = 53.38 deg, nu = 92.335 deg. These are not bit
522        // identical to the higher-precision Example 2-5 outputs, so they must be
523        // fed in exactly as published to reproduce the published state.
524        let coe = ClassicalElements::new(
525            11067.790,
526            0.83285,
527            87.87 * DEG,
528            227.89 * DEG,
529            53.38 * DEG,
530            92.335 * DEG,
531        );
532
533        let (r, v) = coe2rv(&coe, MU).unwrap();
534
535        // The published state is r = [6525.344, 6861.535, 6449.125] km,
536        // v = [4.902276, 5.533124, -1.975709] km/s. Tolerance reflects the
537        // rounding of the published elements fed back in.
538        assert_vec_close(r, [6525.344, 6861.535, 6449.125], 5.0e-2, "r");
539        assert_vec_close(v, [4.902276, 5.533124, -1.975709], 1.0e-3, "v");
540    }
541
542    #[test]
543    fn round_trip_elliptical_inclined() {
544        let r = [6524.834, 6862.875, 6448.296];
545        let v = [4.901327, 5.533756, -1.976341];
546
547        let coe = rv2coe(r, v, MU).unwrap();
548        let (r2, v2) = coe2rv(&coe, MU).unwrap();
549
550        assert_vec_close(r2, r, 1.0e-7, "r");
551        assert_vec_close(v2, v, 1.0e-10, "v");
552    }
553
554    #[test]
555    fn round_trip_circular_inclined() {
556        // Circular inclined orbit: argument of perigee is undefined, so the
557        // round trip must travel through the argument of latitude. Build the
558        // state from elements so it is exactly circular.
559        let raan0 = 80.0 * DEG;
560        let incl0 = 51.6 * DEG;
561        let arglat0 = 135.0 * DEG;
562
563        let mut coe = ClassicalElements::new(7000.0, 0.0, incl0, raan0, 0.0, 0.0);
564        coe.orbit_type = OrbitType::CircularInclined;
565        coe.argp = f64::NAN;
566        coe.nu = f64::NAN;
567        coe.arglat = arglat0;
568
569        let (r, v) = coe2rv(&coe, MU).unwrap();
570        let back = rv2coe(r, v, MU).unwrap();
571
572        assert_eq!(back.orbit_type, OrbitType::CircularInclined);
573        assert!(back.argp.is_nan());
574        assert!(back.arglat.is_finite());
575        assert_close(back.incl, incl0, 1.0e-10, "incl");
576        assert_close(back.raan, raan0, 1.0e-10, "raan");
577        assert_close(back.arglat, arglat0, 1.0e-10, "arglat");
578
579        let (r2, v2) = coe2rv(&back, MU).unwrap();
580        assert_vec_close(r2, r, 1.0e-8, "r");
581        assert_vec_close(v2, v, 1.0e-11, "v");
582    }
583
584    #[test]
585    fn round_trip_circular_equatorial() {
586        // Construct a circular equatorial state directly, then round-trip it.
587        let radius = 7000.0_f64;
588        let speed = (MU / radius).sqrt();
589        let truelon0 = 35.0 * DEG;
590        let r = [radius * truelon0.cos(), radius * truelon0.sin(), 0.0];
591        let v = [-speed * truelon0.sin(), speed * truelon0.cos(), 0.0];
592
593        let coe = rv2coe(r, v, MU).unwrap();
594        assert_eq!(coe.orbit_type, OrbitType::CircularEquatorial);
595        assert!(coe.raan.is_nan());
596        assert!(coe.argp.is_nan());
597        assert!(coe.nu.is_nan());
598        assert_close(coe.truelon, truelon0, 1.0e-9, "truelon");
599
600        let (r2, v2) = coe2rv(&coe, MU).unwrap();
601        assert_vec_close(r2, r, 1.0e-8, "r");
602        assert_vec_close(v2, v, 1.0e-11, "v");
603    }
604
605    #[test]
606    fn round_trip_elliptical_equatorial() {
607        // Eccentric orbit in the equatorial plane: the node is undefined, so the
608        // round trip must travel through the longitude of perigee.
609        let p = 11067.790_f64;
610        let ecc = 0.4_f64;
611        let lonper0 = 110.0 * DEG;
612        let nu0 = 40.0 * DEG;
613
614        let mut coe = ClassicalElements::new(p, ecc, 0.0, 0.0, 0.0, nu0);
615        coe.orbit_type = OrbitType::EllipticalEquatorial;
616        coe.raan = f64::NAN;
617        coe.argp = f64::NAN;
618        coe.lonper = lonper0;
619
620        let (r, v) = coe2rv(&coe, MU).unwrap();
621        let back = rv2coe(r, v, MU).unwrap();
622
623        assert_eq!(back.orbit_type, OrbitType::EllipticalEquatorial);
624        assert_close(back.ecc, ecc, 1.0e-12, "ecc");
625        assert_close(back.incl, 0.0, 1.0e-12, "incl");
626        assert_close(back.lonper, lonper0, 1.0e-10, "lonper");
627        assert_close(back.nu, nu0, 1.0e-10, "nu");
628
629        let (r2, v2) = coe2rv(&back, MU).unwrap();
630        assert_vec_close(r2, r, 1.0e-8, "r");
631        assert_vec_close(v2, v, 1.0e-11, "v");
632    }
633
634    #[test]
635    fn rejects_bad_inputs() {
636        let r = [7000.0, 0.0, 0.0];
637        let v = [0.0, 7.5, 0.0];
638
639        assert_eq!(rv2coe(r, v, -1.0), Err(ElementsError::NonPositiveMu));
640        assert_eq!(
641            rv2coe([0.0, 0.0, 0.0], v, MU),
642            Err(ElementsError::ZeroPosition)
643        );
644        // Rectilinear radial motion has zero angular momentum.
645        assert_eq!(
646            rv2coe(r, [7.5, 0.0, 0.0], MU),
647            Err(ElementsError::DegenerateOrbit)
648        );
649        assert!(matches!(
650            rv2coe([f64::NAN, 0.0, 0.0], v, MU),
651            Err(ElementsError::NonFinite { field: "r" })
652        ));
653
654        let coe = ClassicalElements::new(11067.79, 0.1, 0.5, 0.1, 0.2, 0.3);
655        assert_eq!(coe2rv(&coe, 0.0), Err(ElementsError::NonPositiveMu));
656        let mut bad = coe;
657        bad.p = -1.0;
658        assert_eq!(coe2rv(&bad, MU), Err(ElementsError::NonPositiveSemiLatus));
659    }
660}