Skip to main content

lox_core/
elements.rs

1// SPDX-FileCopyrightText: 2025 Helge Eichhorn <git@helgeeichhorn.de>
2//
3// SPDX-License-Identifier: MPL-2.0
4
5//! Data types for representing orbital elements.
6
7use std::f64::consts::PI;
8use std::f64::consts::TAU;
9use std::fmt::Display;
10
11use glam::DVec3;
12use lox_test_utils::ApproxEq;
13use lox_test_utils::approx_eq;
14use thiserror::Error;
15
16use crate::anomalies::AnomalyError;
17use crate::anomalies::MeanAnomaly;
18use crate::anomalies::{EccentricAnomaly, TrueAnomaly};
19use crate::time::deltas::TimeDelta;
20use crate::utils::Linspace;
21use crate::{
22    coords::Cartesian,
23    glam::Azimuth,
24    units::{Angle, AngleUnits, Distance, DistanceUnits},
25};
26
27/// The standard gravitational parameter of a celestial body µ = GM.
28#[derive(Debug, Default, Clone, Copy, PartialEq, PartialOrd, ApproxEq)]
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30#[repr(transparent)]
31pub struct GravitationalParameter(f64);
32
33impl GravitationalParameter {
34    /// Creates a new gravitational parameter from an `f64` value in m³/s².
35    pub const fn m3_per_s2(mu: f64) -> Self {
36        Self(mu)
37    }
38
39    /// Creates a new gravitational parameter from an `f64` value in km³/s².
40    pub const fn km3_per_s2(mu: f64) -> Self {
41        Self(1e9 * mu)
42    }
43
44    /// Returns the value of the gravitational parameters as an `f64`.
45    pub const fn as_f64(&self) -> f64 {
46        self.0
47    }
48}
49
50impl Display for GravitationalParameter {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        (self.0 * 1e-9).fmt(f)?;
53        write!(f, " km³/s²")
54    }
55}
56
57/// Type alias for the semi-major axis of an orbit, stored as a [`Distance`].
58pub type SemiMajorAxis = Distance;
59
60/// The Keplerian orbit types or conic sections.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63pub enum OrbitType {
64    /// Circular orbit (e ≈ 0).
65    Circular,
66    /// Elliptic orbit (0 < e < 1).
67    Elliptic,
68    /// Parabolic orbit (e ≈ 1).
69    Parabolic,
70    /// Hyperbolic orbit (e > 1).
71    Hyperbolic,
72}
73
74impl Display for OrbitType {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            OrbitType::Circular => "circular".fmt(f),
78            OrbitType::Elliptic => "elliptic".fmt(f),
79            OrbitType::Parabolic => "parabolic".fmt(f),
80            OrbitType::Hyperbolic => "hyperbolic".fmt(f),
81        }
82    }
83}
84
85/// Error returned when attempting to create an [`Eccentricity`] with a negative value.
86#[derive(Debug, Clone, Error)]
87#[error("eccentricity cannot be negative but was {0}")]
88pub struct NegativeEccentricityError(f64);
89
90/// Orbital eccentricity.
91#[derive(Debug, Default, Clone, Copy, PartialEq, PartialOrd, ApproxEq)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93#[repr(transparent)]
94pub struct Eccentricity(f64);
95
96impl Eccentricity {
97    /// Tries to create a new [`Eccentricity`] instance from an `f64` value.
98    ///
99    /// # Errors
100    ///
101    /// Returns a [`NegativeEccentricityError`] if the value is smaller than zero.
102    pub const fn try_new(ecc: f64) -> Result<Eccentricity, NegativeEccentricityError> {
103        if ecc < 0.0 {
104            return Err(NegativeEccentricityError(ecc));
105        }
106        Ok(Eccentricity(ecc))
107    }
108
109    /// Returns the value of the eccentricity as an `f64`.
110    pub const fn as_f64(&self) -> f64 {
111        self.0
112    }
113
114    /// Returns the [`OrbitType`] based on the eccentricity.
115    pub fn orbit_type(&self) -> OrbitType {
116        match self.0 {
117            ecc if approx_eq!(ecc, 0.0, atol <= 1e-8) => OrbitType::Circular,
118            ecc if approx_eq!(ecc, 1.0, rtol <= 1e-8) => OrbitType::Parabolic,
119            ecc if ecc > 0.0 && ecc < 1.0 => OrbitType::Elliptic,
120            _ => OrbitType::Hyperbolic,
121        }
122    }
123
124    /// Checks if the orbit is circular.
125    pub fn is_circular(&self) -> bool {
126        matches!(self.orbit_type(), OrbitType::Circular)
127    }
128
129    /// Checks if the orbit is elliptic.
130    pub fn is_elliptic(&self) -> bool {
131        matches!(self.orbit_type(), OrbitType::Elliptic)
132    }
133
134    /// Checks if the orbit is parabolic.
135    pub fn is_parabolic(&self) -> bool {
136        matches!(self.orbit_type(), OrbitType::Parabolic)
137    }
138
139    /// Checks if the orbit is hyperbolic.
140    pub fn is_hyperbolic(&self) -> bool {
141        matches!(self.orbit_type(), OrbitType::Hyperbolic)
142    }
143
144    /// Checks if the orbit is circular or elliptic.
145    pub fn is_circular_or_elliptic(&self) -> bool {
146        matches!(self.orbit_type(), OrbitType::Circular | OrbitType::Elliptic)
147    }
148}
149
150impl Display for Eccentricity {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        self.0.fmt(f)
153    }
154}
155
156/// Error returned when an [`Inclination`] is outside the valid range [0°, 180°].
157#[derive(Debug, Clone, Error)]
158#[error("inclination must be between 0 and 180 deg but was {0}")]
159pub struct InclinationError(Angle);
160
161/// Orbital inclination.
162#[derive(Debug, Default, Clone, Copy, PartialEq, PartialOrd, ApproxEq)]
163#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
164#[repr(transparent)]
165pub struct Inclination(Angle);
166
167impl Inclination {
168    /// Tries to create a new [`Inclination`] from an angle. Must be between 0 and π.
169    pub const fn try_new(inclination: Angle) -> Result<Inclination, InclinationError> {
170        let inc = inclination.as_f64();
171        if inc < 0.0 || inc > PI {
172            return Err(InclinationError(inclination));
173        }
174        Ok(Inclination(inclination))
175    }
176
177    /// Returns the inclination in radians as an `f64`.
178    pub const fn as_f64(&self) -> f64 {
179        self.0.as_f64()
180    }
181}
182
183impl Display for Inclination {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        self.0.fmt(f)
186    }
187}
188
189/// Error returned when a [`LongitudeOfAscendingNode`] is outside the valid range [0°, 360°].
190#[derive(Debug, Clone, Error)]
191#[error("longitude of ascending node must be between 0 and 360 deg but was {0}")]
192pub struct LongitudeOfAscendingNodeError(Angle);
193
194/// Longitude of ascending node.
195#[derive(Debug, Default, Clone, Copy, PartialEq, PartialOrd, ApproxEq)]
196#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
197#[repr(transparent)]
198pub struct LongitudeOfAscendingNode(Angle);
199
200impl LongitudeOfAscendingNode {
201    /// Tries to create a new [`LongitudeOfAscendingNode`]. Must be between 0 and 2π.
202    pub const fn try_new(
203        longitude_of_ascending_node: Angle,
204    ) -> Result<LongitudeOfAscendingNode, LongitudeOfAscendingNodeError> {
205        let node = longitude_of_ascending_node.as_f64();
206        if node < 0.0 || node > TAU {
207            return Err(LongitudeOfAscendingNodeError(longitude_of_ascending_node));
208        }
209        Ok(LongitudeOfAscendingNode(longitude_of_ascending_node))
210    }
211
212    /// Returns the longitude of ascending node in radians as an `f64`.
213    pub const fn as_f64(&self) -> f64 {
214        self.0.as_f64()
215    }
216}
217
218impl Display for LongitudeOfAscendingNode {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        self.0.fmt(f)
221    }
222}
223
224/// Error returned when an [`ArgumentOfPeriapsis`] is outside the valid range [0°, 360°].
225#[derive(Debug, Clone, Error)]
226#[error("argument of periapsis must be between 0 and 360 deg but was {0}")]
227pub struct ArgumentOfPeriapsisError(Angle);
228
229/// Argument of periapsis.
230#[derive(Debug, Default, Clone, Copy, PartialEq, PartialOrd, ApproxEq)]
231#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
232#[repr(transparent)]
233pub struct ArgumentOfPeriapsis(Angle);
234
235impl ArgumentOfPeriapsis {
236    /// Tries to create a new [`ArgumentOfPeriapsis`]. Must be between 0 and 2π.
237    pub const fn try_new(
238        argument_of_periapsis: Angle,
239    ) -> Result<ArgumentOfPeriapsis, ArgumentOfPeriapsisError> {
240        let arg = argument_of_periapsis.as_f64();
241        if arg < 0.0 || arg > TAU {
242            return Err(ArgumentOfPeriapsisError(argument_of_periapsis));
243        }
244        Ok(ArgumentOfPeriapsis(argument_of_periapsis))
245    }
246
247    /// Returns the argument of periapsis in radians as an `f64`.
248    pub const fn as_f64(&self) -> f64 {
249        self.0.as_f64()
250    }
251}
252
253impl Display for ArgumentOfPeriapsis {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        self.0.fmt(f)
256    }
257}
258
259/// A set of Keplerian orbital elements.
260#[derive(Debug, Clone, Copy, PartialEq, ApproxEq)]
261#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
262pub struct Keplerian {
263    semi_major_axis: SemiMajorAxis,
264    eccentricity: Eccentricity,
265    inclination: Inclination,
266    longitude_of_ascending_node: LongitudeOfAscendingNode,
267    argument_of_periapsis: ArgumentOfPeriapsis,
268    true_anomaly: TrueAnomaly,
269}
270
271impl Keplerian {
272    /// Creates a new set of Keplerian elements from pre-validated components.
273    pub fn new(
274        semi_major_axis: SemiMajorAxis,
275        eccentricity: Eccentricity,
276        inclination: Inclination,
277        longitude_of_ascending_node: LongitudeOfAscendingNode,
278        argument_of_periapsis: ArgumentOfPeriapsis,
279        true_anomaly: TrueAnomaly,
280    ) -> Self {
281        Self {
282            semi_major_axis,
283            eccentricity,
284            inclination,
285            longitude_of_ascending_node,
286            argument_of_periapsis,
287            true_anomaly,
288        }
289    }
290
291    /// Returns a new [`KeplerianBuilder`].
292    pub fn builder() -> KeplerianBuilder {
293        KeplerianBuilder::default()
294    }
295
296    /// Returns the semi-major axis.
297    pub fn semi_major_axis(&self) -> SemiMajorAxis {
298        self.semi_major_axis
299    }
300
301    /// Returns the eccentricity.
302    pub fn eccentricity(&self) -> Eccentricity {
303        self.eccentricity
304    }
305
306    /// Returns the inclination.
307    pub fn inclination(&self) -> Inclination {
308        self.inclination
309    }
310
311    /// Returns the longitude of ascending node.
312    pub fn longitude_of_ascending_node(&self) -> LongitudeOfAscendingNode {
313        self.longitude_of_ascending_node
314    }
315
316    /// Returns the argument of periapsis.
317    pub fn argument_of_periapsis(&self) -> ArgumentOfPeriapsis {
318        self.argument_of_periapsis
319    }
320
321    /// Returns the true anomaly.
322    pub fn true_anomaly(&self) -> TrueAnomaly {
323        self.true_anomaly
324    }
325
326    /// Returns the semi-parameter (semi-latus rectum) of the orbit.
327    pub fn semi_parameter(&self) -> Distance {
328        if self.eccentricity.is_circular() {
329            self.semi_major_axis
330        } else {
331            Distance::new(self.semi_major_axis.as_f64() * (1.0 - self.eccentricity.0.powi(2)))
332        }
333    }
334
335    /// Converts the orbital elements to position and velocity in the perifocal frame.
336    pub fn to_perifocal(&self, grav_param: GravitationalParameter) -> (DVec3, DVec3) {
337        let ecc = self.eccentricity.as_f64();
338        let mu = grav_param.as_f64();
339        let semiparameter = self.semi_parameter().as_f64();
340        let (sin_nu, cos_nu) = self.true_anomaly.as_angle().sin_cos();
341        let sqrt_mu_p = (mu / semiparameter).sqrt();
342
343        let pos = DVec3::new(cos_nu, sin_nu, 0.0) * (semiparameter / (1.0 + ecc * cos_nu));
344        let vel = DVec3::new(-sin_nu, ecc + cos_nu, 0.0) * sqrt_mu_p;
345
346        (pos, vel)
347    }
348
349    /// Converts the orbital elements to a Cartesian state vector.
350    pub fn to_cartesian(&self, grav_param: GravitationalParameter) -> Cartesian {
351        let (pos, vel) = self.to_perifocal(grav_param);
352        let rot = self.longitude_of_ascending_node.0.rotation_z().transpose()
353            * self.inclination.0.rotation_x().transpose()
354            * self.argument_of_periapsis.0.rotation_z().transpose();
355        Cartesian::from_vecs(rot * pos, rot * vel)
356    }
357
358    /// Returns the orbital period, or `None` for non-elliptic orbits.
359    pub fn orbital_period(&self, grav_param: GravitationalParameter) -> Option<TimeDelta> {
360        if !self.eccentricity().is_circular_or_elliptic() {
361            return None;
362        }
363        let mu = grav_param.as_f64();
364        let a = self.semi_major_axis.as_f64();
365        Some(TimeDelta::from_seconds_f64(TAU * (a.powf(3.0) / mu).sqrt()))
366    }
367
368    /// Returns an iterator over `n` Cartesian positions equally spaced around the orbit.
369    pub fn iter_trace(
370        &self,
371        grav_param: GravitationalParameter,
372        n: usize,
373    ) -> impl Iterator<Item = Cartesian> {
374        assert!(self.eccentricity().is_circular_or_elliptic());
375        Linspace::new(-PI, PI, n).map(move |ecc| {
376            let true_anomaly = EccentricAnomaly::new(ecc.rad()).to_true(self.eccentricity);
377            Keplerian {
378                true_anomaly,
379                ..*self
380            }
381            .to_cartesian(grav_param)
382        })
383    }
384
385    /// Returns `n` Cartesian positions equally spaced around the orbit, or `None` for non-elliptic orbits.
386    pub fn trace(&self, grav_param: GravitationalParameter, n: usize) -> Option<Vec<Cartesian>> {
387        if !self.eccentricity().is_circular_or_elliptic() {
388            return None;
389        }
390        Some(self.iter_trace(grav_param, n).collect())
391    }
392}
393
394impl Cartesian {
395    /// Computes the eccentricity vector from the Cartesian state.
396    pub fn eccentricity_vector(&self, grav_param: GravitationalParameter) -> DVec3 {
397        let mu = grav_param.as_f64();
398        let r = self.position();
399        let v = self.velocity();
400
401        let rm = r.length();
402        let v2 = v.dot(v);
403        let rv = r.dot(v);
404
405        ((v2 - mu / rm) * r - rv * v) / mu
406    }
407
408    /// Converts the Cartesian state to Keplerian orbital elements.
409    pub fn to_keplerian(&self, grav_param: GravitationalParameter) -> Keplerian {
410        let r = self.position();
411        let v = self.velocity();
412        let mu = grav_param.as_f64();
413
414        let rm = r.length();
415        let vm = v.length();
416        let h = r.cross(v);
417        let hm = h.length();
418        let node = DVec3::Z.cross(h);
419        let e = self.eccentricity_vector(grav_param);
420        let eccentricity = Eccentricity(e.length());
421        let inclination = h.angle_between(DVec3::Z).rad();
422
423        let equatorial = approx_eq!(inclination, Angle::ZERO, atol <= 1e-8);
424        let circular = eccentricity.is_circular();
425
426        let semi_major_axis = if circular {
427            hm.powi(2) / mu
428        } else {
429            -mu / (2.0 * (vm.powi(2) / 2.0 - mu / rm))
430        };
431
432        let (longitude_of_ascending_node, argument_of_periapsis, true_anomaly) =
433            if equatorial && !circular {
434                (
435                    Angle::ZERO,
436                    e.azimuth(),
437                    TrueAnomaly::new(Angle::from_atan2(h.dot(e.cross(r)) / hm, r.dot(e))),
438                )
439            } else if !equatorial && circular {
440                (
441                    node.azimuth(),
442                    Angle::ZERO,
443                    TrueAnomaly::new(Angle::from_atan2(r.dot(h.cross(node)) / hm, r.dot(node))),
444                )
445            } else if equatorial && circular {
446                (Angle::ZERO, Angle::ZERO, TrueAnomaly::new(r.azimuth()))
447            } else {
448                let true_anomaly = if semi_major_axis > 0.0 {
449                    let e_se = r.dot(v) / (mu * semi_major_axis).sqrt();
450                    let e_ce = (rm * vm.powi(2)) / mu - 1.0;
451                    EccentricAnomaly::new(Angle::from_atan2(e_se, e_ce)).to_true(eccentricity)
452                } else {
453                    let e_sh = r.dot(v) / (-mu * semi_major_axis).sqrt();
454                    let e_ch = (rm * vm.powi(2)) / mu - 1.0;
455                    EccentricAnomaly::new((((e_ch + e_sh) / (e_ch - e_sh)).ln() / 2.0).rad())
456                        .to_true(eccentricity)
457                };
458                let px = r.dot(node);
459                let py = r.dot(h.cross(node)) / hm;
460                (
461                    node.azimuth(),
462                    Angle::from_atan2(py, px) - true_anomaly.as_angle(),
463                    true_anomaly,
464                )
465            };
466
467        Keplerian {
468            semi_major_axis: semi_major_axis.m(),
469            eccentricity,
470            inclination: Inclination(inclination),
471            longitude_of_ascending_node: LongitudeOfAscendingNode(
472                longitude_of_ascending_node.mod_two_pi(),
473            ),
474            argument_of_periapsis: ArgumentOfPeriapsis(argument_of_periapsis.mod_two_pi()),
475            true_anomaly,
476        }
477    }
478}
479
480/// Error returned when constructing a [`Keplerian`] via the builder.
481#[derive(Debug, Clone, Error)]
482pub enum KeplerianError {
483    /// The eccentricity is negative.
484    #[error(transparent)]
485    NegativeEccentricity(#[from] NegativeEccentricityError),
486    /// The semi-major axis sign is inconsistent with the eccentricity.
487    #[error(
488        "{} semi-major axis ({semi_major_axis}) for {} eccentricity ({eccentricity})",
489        if .semi_major_axis.as_f64().signum() == -1.0 {"negative"} else {"positive"},
490        .eccentricity.orbit_type()
491    )]
492    InvalidShape {
493        /// The invalid semi-major axis.
494        semi_major_axis: SemiMajorAxis,
495        /// The eccentricity that conflicts with the semi-major axis sign.
496        eccentricity: Eccentricity,
497    },
498    /// No shape parameters were provided.
499    #[error(
500        "no orbital shape parameters (semi-major axis and eccentricity, radii, or altitudes) were provided"
501    )]
502    MissingShape,
503    /// The inclination is invalid.
504    #[error(transparent)]
505    InvalidInclination(#[from] InclinationError),
506    /// The longitude of ascending node is invalid.
507    #[error(transparent)]
508    InvalidLongitudeOfAscendingNode(#[from] LongitudeOfAscendingNodeError),
509    /// The argument of periapsis is invalid.
510    #[error(transparent)]
511    InvalidArgumentOfPeriapsis(#[from] ArgumentOfPeriapsisError),
512    /// The anomaly conversion failed.
513    #[error(transparent)]
514    Anomaly(#[from] AnomalyError),
515}
516
517/// Builder for constructing validated [`Keplerian`] elements.
518#[derive(Debug, Default, Clone)]
519pub struct KeplerianBuilder {
520    shape: Option<(
521        SemiMajorAxis,
522        Result<Eccentricity, NegativeEccentricityError>,
523    )>,
524    inclination: Angle,
525    longitude_of_ascending_node: Angle,
526    argument_of_periapsis: Angle,
527    true_anomaly: Option<Angle>,
528    mean_anomaly: Option<Angle>,
529}
530
531impl KeplerianBuilder {
532    /// Creates a new builder with default values.
533    pub fn new() -> Self {
534        Self::default()
535    }
536
537    /// Sets the semi-major axis and eccentricity.
538    pub fn with_semi_major_axis(
539        mut self,
540        semi_major_axis: SemiMajorAxis,
541        eccentricity: f64,
542    ) -> Self {
543        self.shape = Some((semi_major_axis, Eccentricity::try_new(eccentricity)));
544        self
545    }
546
547    /// Sets the orbit shape from periapsis and apoapsis radii.
548    pub fn with_radii(mut self, periapsis_radius: Distance, apoapsis_radius: Distance) -> Self {
549        let rp = periapsis_radius.as_f64();
550        let ra = apoapsis_radius.as_f64();
551        let semi_major_axis = SemiMajorAxis::new((rp + ra) / 2.0);
552
553        let eccentricity = Eccentricity::try_new((ra - rp) / (ra + rp));
554
555        self.shape = Some((semi_major_axis, eccentricity));
556
557        self
558    }
559
560    /// Sets the orbit shape from periapsis and apoapsis altitudes above a mean radius.
561    pub fn with_altitudes(
562        self,
563        periapsis_altitude: Distance,
564        apoapsis_altitude: Distance,
565        mean_radius: Distance,
566    ) -> Self {
567        let rp = periapsis_altitude + mean_radius;
568        let ra = apoapsis_altitude + mean_radius;
569        self.with_radii(rp, ra)
570    }
571
572    /// Sets the inclination.
573    pub fn with_inclination(mut self, inclination: Angle) -> Self {
574        self.inclination = inclination;
575        self
576    }
577
578    /// Sets the longitude of ascending node.
579    pub fn with_longitude_of_ascending_node(mut self, longitude_of_ascending_node: Angle) -> Self {
580        self.longitude_of_ascending_node = longitude_of_ascending_node;
581        self
582    }
583
584    /// Sets the argument of periapsis.
585    pub fn with_argument_of_periapsis(mut self, argument_of_periapsis: Angle) -> Self {
586        self.argument_of_periapsis = argument_of_periapsis;
587        self
588    }
589
590    /// Sets the true anomaly.
591    pub fn with_true_anomaly(mut self, true_anomaly: Angle) -> Self {
592        self.true_anomaly = Some(true_anomaly);
593        self
594    }
595
596    /// Sets the mean anomaly (converted to true anomaly during build).
597    pub fn with_mean_anomaly(mut self, mean_anomaly: Angle) -> Self {
598        self.mean_anomaly = Some(mean_anomaly);
599        self
600    }
601
602    /// Validates all parameters and builds the [`Keplerian`] elements.
603    pub fn build(self) -> Result<Keplerian, KeplerianError> {
604        let (semi_major_axis, eccentricity) = self.shape.ok_or(KeplerianError::MissingShape)?;
605
606        let eccentricity = eccentricity?;
607
608        Self::check_shape(semi_major_axis, eccentricity)?;
609
610        let inclination = Inclination::try_new(self.inclination)?;
611        let longitude_of_ascending_node =
612            LongitudeOfAscendingNode::try_new(self.longitude_of_ascending_node)?;
613        let argument_of_periapsis = ArgumentOfPeriapsis::try_new(self.argument_of_periapsis)?;
614
615        let true_anomaly = match self.true_anomaly {
616            Some(true_anomaly) => TrueAnomaly::new(true_anomaly),
617            None => match self.mean_anomaly {
618                Some(mean_anomaly) => MeanAnomaly::new(mean_anomaly).to_true(eccentricity)?,
619                None => TrueAnomaly::new(Angle::ZERO),
620            },
621        };
622
623        Ok(Keplerian {
624            semi_major_axis,
625            eccentricity,
626            inclination,
627            longitude_of_ascending_node,
628            argument_of_periapsis,
629            true_anomaly,
630        })
631    }
632
633    fn check_shape(
634        semi_major_axis: SemiMajorAxis,
635        eccentricity: Eccentricity,
636    ) -> Result<(), KeplerianError> {
637        let ecc = eccentricity.as_f64();
638        let sma = semi_major_axis.as_f64();
639        if (ecc > 1.0 && sma > 0.0) || (ecc < 1.0 && sma < 0.0) {
640            return Err(KeplerianError::InvalidShape {
641                semi_major_axis,
642                eccentricity,
643            });
644        }
645        Ok(())
646    }
647}
648
649#[cfg(test)]
650mod tests {
651    use lox_test_utils::assert_approx_eq;
652
653    use crate::units::VelocityUnits;
654
655    use super::*;
656
657    #[test]
658    fn test_cartesian_to_keplerian_roundtrip() {
659        let mu = GravitationalParameter::km3_per_s2(398600.43550702266f64);
660
661        let cartesian = Cartesian::builder()
662            .position(
663                -0.107622532467967e7.m(),
664                -0.676589636432773e7.m(),
665                -0.332308783350379e6.m(),
666            )
667            .velocity(
668                0.935685775154103e4.mps(),
669                -0.331234775037644e4.mps(),
670                -0.118801577532701e4.mps(),
671            )
672            .build();
673
674        let cartesian1 = cartesian.to_keplerian(mu).to_cartesian(mu);
675
676        assert_approx_eq!(cartesian.position(), cartesian1.position(), rtol <= 1e-8);
677        assert_approx_eq!(cartesian.velocity(), cartesian1.velocity(), rtol <= 1e-6);
678    }
679
680    #[test]
681    fn test_keplerian_builder() {
682        let mu = GravitationalParameter::km3_per_s2(398600.43550702266f64);
683
684        let semi_major_axis = 24464.560.km();
685        let eccentricity = 0.7311;
686        let inclination = 0.122138.rad();
687        let ascending_node = 1.00681.rad();
688        let argument_of_periapsis = 3.10686.rad();
689        let true_anomaly = 0.44369564302687126.rad();
690
691        let k = Keplerian::builder()
692            .with_semi_major_axis(semi_major_axis, eccentricity)
693            .with_inclination(inclination)
694            .with_longitude_of_ascending_node(ascending_node)
695            .with_argument_of_periapsis(argument_of_periapsis)
696            .with_true_anomaly(true_anomaly)
697            .build()
698            .unwrap();
699        let k1 = k.to_cartesian(mu).to_keplerian(mu);
700        assert_approx_eq!(k, k1);
701    }
702}