Skip to main content

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