Skip to main content

lox_core/elements/
modified_equinoctial.rs

1// SPDX-FileCopyrightText: 2026 Helge Eichhorn <git@helgeeichhorn.de>
2// SPDX-FileCopyrightText: 2026 Marijan Smetko <msmetko@msmetko.xyz>
3//
4// SPDX-License-Identifier: MPL-2.0
5
6//! Modified Equinoctial Elements (MEE), non-singular for circular,
7//! equatorial, and parabolic orbits.
8//!
9//! Based on:
10//! Walker, M. J. H., Ireland, B., & Owens, J. (1985). *A set of modified equinoctial orbit elements*.
11//! Celestial Mechanics, 36, 409-419. <https://doi.org/10.1007/BF01227493>
12//!
13//! Erratum: <https://doi.org/10.1007/BF01238929>
14
15use crate::anomalies::AnomalyError;
16use crate::coords::Cartesian;
17use crate::elements::keplerian::{GravitationalParameter, Keplerian, KeplerianError};
18use crate::math::float::{atan, atan2, cos, sin, sqrt, tan};
19use crate::units::{Angle, AngleUnits, Distance};
20use glam::DVec3;
21use thiserror::Error;
22
23/// Modified Equinoctial Elements (MEE).
24///
25/// Non-singular for circular (e = 0) and equatorial (i = 0) orbits.
26/// Also fully supports parabolic (e = 1) orbits, unlike Type I equinoctial elements
27/// which use the semi-major axis. Singular at i = π (retrograde) where tan(i/2) diverges.
28/// Formulas in parenthesis are from Keplerian notation
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub struct ModifiedEquinoctial {
31    /// Semi-latus rectum (semi-parameter) `p` \[m\].
32    p: Distance,
33    /// Eccentricity vector, component 1 (`f` = e · cos(ω + Ω)).
34    f: f64,
35    /// Eccentricity vector, component 2 (`g` = e · sin(ω + Ω)).
36    g: f64,
37    /// Node vector, component 1 (`h` = tan(i/2) · cos(Ω)).
38    h: f64,
39    /// Node vector, component 2 (`k` = tan(i/2) · sin(Ω)).
40    k: f64,
41    /// True longitude `l` = Ω + ω + ν.
42    l: Angle,
43}
44
45/// Error returned when constructing or converting modified equinoctial elements.
46#[derive(Debug, Clone, Error)]
47pub enum ModifiedEquinoctialError {
48    /// The anomaly conversion failed (e.g. attempting to convert or evaluate
49    /// anomalies beyond physically hyperbolic bounds limits).
50    #[error(transparent)]
51    Anomaly(#[from] AnomalyError),
52    /// The Keplerian conversion failed (e.g. during conversion back to Keplerian,
53    /// encountering inconsistencies in reconstructing the orbital shape mapping).
54    #[error(transparent)]
55    Keplerian(#[from] KeplerianError),
56}
57
58impl ModifiedEquinoctial {
59    /// Creates modified equinoctial elements from raw component values.
60    ///
61    /// `p` is the semi-latus rectum in meters, `f` = e·cos(ω+Ω), `g` = e·sin(ω+Ω),
62    /// `h` = tan(i/2)·cos(Ω), `k` = tan(i/2)·sin(Ω), `l` = Ω + ω + ν.
63    pub fn new(p: Distance, f: f64, g: f64, h: f64, k: f64, l: Angle) -> Self {
64        Self { p, f, g, h, k, l }
65    }
66
67    /// Converts modified equinoctial elements to a Cartesian state.
68    pub fn to_cartesian(
69        &self,
70        mu: GravitationalParameter,
71    ) -> Result<Cartesian, ModifiedEquinoctialError> {
72        let p = self.p.as_f64();
73        let mu_f64 = mu.as_f64();
74
75        let s2 = 1.0 + self.h * self.h + self.k * self.k;
76        let f_hat = DVec3::new(
77            1.0 + self.h * self.h - self.k * self.k,
78            2.0 * self.h * self.k,
79            -2.0 * self.k,
80        ) / s2;
81        let g_hat = DVec3::new(
82            2.0 * self.h * self.k,
83            1.0 - self.h * self.h + self.k * self.k,
84            2.0 * self.h,
85        ) / s2;
86
87        let (sin_l, cos_l) = self.l.sin_cos();
88        let w = 1.0 + self.f * cos_l + self.g * sin_l;
89        let r_mag = p / w;
90
91        let r_orb = DVec3::new(r_mag * cos_l, r_mag * sin_l, 0.0);
92        let v_orb = DVec3::new(
93            -sqrt(mu_f64 / p) * (sin_l + self.g),
94            sqrt(mu_f64 / p) * (cos_l + self.f),
95            0.0,
96        );
97
98        let r_vec = r_orb.x * f_hat + r_orb.y * g_hat;
99        let v_vec = v_orb.x * f_hat + v_orb.y * g_hat;
100
101        Ok(Cartesian::from_vecs(r_vec, v_vec))
102    }
103
104    /// Converts modified equinoctial elements back to Keplerian.
105    pub fn to_keplerian(&self) -> Result<Keplerian, ModifiedEquinoctialError> {
106        let e = self.eccentricity();
107        let pomega = atan2(self.g, self.f); // ω + Ω
108        let half_i_tan = sqrt(self.h * self.h + self.k * self.k);
109        let i = 2.0 * atan(half_i_tan);
110        let big_omega = atan2(self.k, self.h);
111
112        let omega = pomega - big_omega;
113        let nu = self.l.as_f64() - pomega;
114        let semi_major_axis = Distance::new(self.p.as_f64() / (1.0 - e * e));
115
116        let kep = Keplerian::builder()
117            .with_semi_major_axis(semi_major_axis, e)
118            .with_inclination(i.rad())
119            .with_longitude_of_ascending_node(big_omega.rad().mod_two_pi())
120            .with_argument_of_periapsis(omega.rad().mod_two_pi())
121            .with_true_anomaly(nu.rad().mod_two_pi())
122            .build()?;
123
124        Ok(kep)
125    }
126
127    /// Returns the semi-latus rectum (semi-parameter).
128    pub fn p(&self) -> Distance {
129        self.p
130    }
131
132    /// Returns f = e·cos(ω + Ω).
133    pub fn f(&self) -> f64 {
134        self.f
135    }
136
137    /// Returns g = e·sin(ω + Ω).
138    pub fn g(&self) -> f64 {
139        self.g
140    }
141
142    /// Returns h = tan(i/2)·cos(Ω).
143    pub fn h(&self) -> f64 {
144        self.h
145    }
146
147    /// Returns k = tan(i/2)·sin(Ω).
148    pub fn k(&self) -> f64 {
149        self.k
150    }
151
152    /// Returns the true longitude `L` = Ω + ω + ν.
153    pub fn l(&self) -> Angle {
154        self.l
155    }
156
157    /// Returns the eccentricity e = √(f² + g²).
158    pub fn eccentricity(&self) -> f64 {
159        sqrt(self.f * self.f + self.g * self.g)
160    }
161
162    /// Returns the inclination i = 2·atan(√(h² + k²)) in radians.
163    pub fn inclination(&self) -> f64 {
164        2.0 * atan(sqrt(self.h * self.h + self.k * self.k))
165    }
166}
167
168impl Cartesian {
169    /// Converts the Cartesian state to modified equinoctial elements.
170    pub fn to_modified_equinoctial(
171        &self,
172        mu: GravitationalParameter,
173    ) -> Result<ModifiedEquinoctial, ModifiedEquinoctialError> {
174        let r_vec = self.position();
175        let v_vec = self.velocity();
176        let r_mag = r_vec.length();
177        let mu_f64 = mu.as_f64();
178
179        // Angular momentum
180        let h_vec = r_vec.cross(v_vec);
181        let h_mag = h_vec.length();
182
183        // Semi-parameter p
184        let p = Distance::new((h_mag * h_mag) / mu_f64);
185
186        // Eccentricity vector
187        let e_vec = v_vec.cross(h_vec) / mu_f64 - r_vec / r_mag;
188
189        // Equinoctial parameters h, k
190        let h = -h_vec.y / (h_mag + h_vec.z);
191        let k = h_vec.x / (h_mag + h_vec.z);
192
193        // Equinoctial frame unit vectors
194        let s2 = 1.0 + h * h + k * k;
195        let f_hat = DVec3::new(1.0 + h * h - k * k, 2.0 * h * k, -2.0 * k) / s2;
196        let g_hat = DVec3::new(2.0 * h * k, 1.0 - h * h + k * k, 2.0 * h) / s2;
197
198        let f = e_vec.dot(f_hat);
199        let g = e_vec.dot(g_hat);
200
201        let x = r_vec.dot(f_hat);
202        let y = r_vec.dot(g_hat);
203        let l = Angle::from_atan2(y, x);
204
205        Ok(ModifiedEquinoctial { p, f, g, h, k, l })
206    }
207}
208
209impl Keplerian {
210    /// Converts Keplerian elements to modified equinoctial.
211    pub fn to_modified_equinoctial(&self) -> ModifiedEquinoctial {
212        let e = self.eccentricity().as_f64();
213        let i = self.inclination().as_f64();
214        let omega = self.argument_of_periapsis().as_f64();
215        let big_omega = self.longitude_of_ascending_node().as_f64();
216        let nu = self.true_anomaly().as_f64();
217
218        let p_val = self.semi_parameter();
219        let pomega = omega + big_omega; // longitude of periapsis
220
221        let f = e * cos(pomega);
222        let g = e * sin(pomega);
223        let half_i_tan = tan(i / 2.0);
224        let h = half_i_tan * cos(big_omega);
225        let k = half_i_tan * sin(big_omega);
226        let l = Angle::new(pomega + nu);
227
228        ModifiedEquinoctial {
229            p: p_val,
230            f,
231            g,
232            h,
233            k,
234            l,
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use alloc::string::ToString;
242    use lox_approx::assert_approx_eq;
243
244    use crate::elements::keplerian::GravitationalParameter;
245    use crate::units::{AngleUnits, DistanceUnits, VelocityUnits};
246
247    use super::*;
248
249    fn general_orbit_keplerian() -> Keplerian {
250        Keplerian::builder()
251            .with_semi_major_axis(24464.560.km(), 0.7311)
252            .with_inclination(0.122138.rad())
253            .with_longitude_of_ascending_node(1.00681.rad())
254            .with_argument_of_periapsis(3.10686.rad())
255            .with_true_anomaly(0.44369564302687126.rad())
256            .build()
257            .unwrap()
258    }
259
260    fn general_orbit_mu() -> GravitationalParameter {
261        GravitationalParameter::km3_per_s2(398600.43550702266)
262    }
263
264    #[test]
265    fn test_keplerian_roundtrip() {
266        let kep = general_orbit_keplerian();
267        let eq = kep.to_modified_equinoctial();
268        let kep2 = eq.to_keplerian().unwrap();
269
270        assert_approx_eq!(
271            kep.semi_major_axis().as_f64(),
272            kep2.semi_major_axis().as_f64(),
273            rtol <= 1e-12
274        );
275        assert_approx_eq!(
276            kep.eccentricity().as_f64(),
277            kep2.eccentricity().as_f64(),
278            atol <= 1e-12
279        );
280        assert_approx_eq!(
281            kep.inclination().as_f64(),
282            kep2.inclination().as_f64(),
283            atol <= 1e-12
284        );
285
286        let mu = general_orbit_mu();
287        let c1 = kep.to_cartesian(mu);
288        let c2 = kep2.to_cartesian(mu);
289        assert_approx_eq!(c1.position(), c2.position(), rtol <= 1e-10);
290        assert_approx_eq!(c1.velocity(), c2.velocity(), rtol <= 1e-10);
291    }
292
293    #[test]
294    fn test_cartesian_roundtrip() {
295        let mu = general_orbit_mu();
296        let cart = Cartesian::builder()
297            .position(
298                -0.107622532467967e7.m(),
299                -0.676589636432773e7.m(),
300                -0.332308783350379e6.m(),
301            )
302            .velocity(
303                0.935685775154103e4.mps(),
304                -0.331234775037644e4.mps(),
305                -0.118801577532701e4.mps(),
306            )
307            .build();
308
309        let eq = cart.to_modified_equinoctial(mu).unwrap();
310        let cart2 = eq.to_cartesian(mu).unwrap();
311
312        assert_approx_eq!(cart.position(), cart2.position(), rtol <= 1e-10);
313        assert_approx_eq!(cart.velocity(), cart2.velocity(), rtol <= 1e-10);
314    }
315
316    #[test]
317    fn test_circular_orbit() {
318        // e = 0: f and g should be zero, l should be well-defined
319        let kep = Keplerian::builder()
320            .with_semi_major_axis(6878.137.km(), 0.0)
321            .with_inclination(97.42_f64.to_radians().rad())
322            .with_longitude_of_ascending_node(69.3_f64.to_radians().rad())
323            .with_argument_of_periapsis(0.0.rad())
324            .with_true_anomaly(45.0_f64.to_radians().rad())
325            .build()
326            .unwrap();
327
328        let eq = kep.to_modified_equinoctial();
329
330        assert_approx_eq!(eq.f(), 0.0, atol <= 1e-15);
331        assert_approx_eq!(eq.g(), 0.0, atol <= 1e-15);
332        assert_approx_eq!(eq.eccentricity(), 0.0, atol <= 1e-15);
333        assert_approx_eq!(eq.inclination(), 97.42_f64.to_radians(), atol <= 1e-12);
334        assert!(eq.h().abs() > 0.1);
335        assert!(eq.k().abs() > 0.1);
336
337        let mu = GravitationalParameter::km3_per_s2(398600.4418);
338
339        let c1 = kep.to_cartesian(mu);
340
341        // Also test mapping directly from cartesian respects e=0
342        let eq_from_c = c1.to_modified_equinoctial(mu).unwrap();
343        assert_approx_eq!(eq_from_c.f(), 0.0, atol <= 1e-12);
344        assert_approx_eq!(eq_from_c.g(), 0.0, atol <= 1e-12);
345
346        let c2 = eq.to_cartesian(mu).unwrap();
347        assert_approx_eq!(c1.position(), c2.position(), rtol <= 1e-10);
348        assert_approx_eq!(c1.velocity(), c2.velocity(), rtol <= 1e-10);
349    }
350
351    #[test]
352    fn test_equatorial_orbit() {
353        // i = 0: h and k should be zero
354        let kep = Keplerian::builder()
355            .with_semi_major_axis(42164.0.km(), 0.001)
356            .with_inclination(0.0.rad())
357            .with_longitude_of_ascending_node(0.0.rad())
358            .with_argument_of_periapsis(45.0_f64.to_radians().rad())
359            .with_true_anomaly(30.0_f64.to_radians().rad())
360            .build()
361            .unwrap();
362
363        let eq = kep.to_modified_equinoctial();
364
365        assert_approx_eq!(eq.h(), 0.0, atol <= 1e-15);
366        assert_approx_eq!(eq.k(), 0.0, atol <= 1e-15);
367        assert_approx_eq!(eq.inclination(), 0.0, atol <= 1e-15);
368        assert!(eq.eccentricity() > 0.0);
369
370        let mu = GravitationalParameter::km3_per_s2(398600.4418);
371        let c1 = kep.to_cartesian(mu);
372
373        let eq_from_c = c1.to_modified_equinoctial(mu).unwrap();
374        assert_approx_eq!(eq_from_c.h(), 0.0, atol <= 1e-15);
375        assert_approx_eq!(eq_from_c.k(), 0.0, atol <= 1e-15);
376
377        let c2 = eq.to_cartesian(mu).unwrap();
378        assert_approx_eq!(c1.position(), c2.position(), rtol <= 1e-10);
379        assert_approx_eq!(c1.velocity(), c2.velocity(), rtol <= 1e-10);
380    }
381
382    #[test]
383    fn test_circular_equatorial_orbit() {
384        // Both e = 0 and i = 0: f, g, h, k all zero
385        let kep = Keplerian::builder()
386            .with_semi_major_axis(42164.0.km(), 0.0)
387            .with_inclination(0.0.rad())
388            .with_longitude_of_ascending_node(0.0.rad())
389            .with_argument_of_periapsis(0.0.rad())
390            .with_true_anomaly(90.0_f64.to_radians().rad())
391            .build()
392            .unwrap();
393
394        let eq = kep.to_modified_equinoctial();
395
396        assert_approx_eq!(eq.f(), 0.0, atol <= 1e-15);
397        assert_approx_eq!(eq.g(), 0.0, atol <= 1e-15);
398        assert_approx_eq!(eq.h(), 0.0, atol <= 1e-15);
399        assert_approx_eq!(eq.k(), 0.0, atol <= 1e-15);
400        assert_approx_eq!(eq.eccentricity(), 0.0, atol <= 1e-15);
401        assert_approx_eq!(eq.inclination(), 0.0, atol <= 1e-15);
402
403        // lambda should equal the true anomaly
404        assert_approx_eq!(eq.l().as_f64(), 90.0_f64.to_radians(), atol <= 1e-12);
405
406        let mu = GravitationalParameter::km3_per_s2(398600.4418);
407        let c1 = kep.to_cartesian(mu);
408        let c2 = eq.to_cartesian(mu).unwrap();
409        assert_approx_eq!(c1.position(), c2.position(), rtol <= 1e-10);
410        assert_approx_eq!(c1.velocity(), c2.velocity(), rtol <= 1e-10);
411    }
412
413    #[test]
414    fn test_parabolic_orbit_directly() {
415        let mu = general_orbit_mu();
416        let mu_f64 = mu.as_f64();
417        // Construct a parabolic state (e = 1.0)
418        // Set an arbitrary p
419        let p_vec = 10000.0 * 1000.0; // 10,000 km
420
421        // Let's create it on the x axis so r = p / 2, v = sqrt(2 mu / r)
422        let r_mag = p_vec / 2.0;
423        let v_mag = (2.0 * mu_f64 / r_mag).sqrt();
424
425        let cart = Cartesian::builder()
426            .position(r_mag.m(), 0.0.m(), 0.0.m())
427            .velocity(0.0.mps(), v_mag.mps(), 0.0.mps())
428            .build();
429
430        let eq = cart.to_modified_equinoctial(mu).unwrap();
431
432        // For this state, e must be exactly 1.0!
433        assert_approx_eq!(eq.eccentricity(), 1.0, atol <= 1e-12);
434
435        // Reconstruct and compare
436        let cart2 = eq.to_cartesian(mu).unwrap();
437        assert_approx_eq!(cart.position(), cart2.position(), rtol <= 1e-10);
438        assert_approx_eq!(cart.velocity(), cart2.velocity(), rtol <= 1e-10);
439    }
440
441    #[test]
442    fn test_error_and_new_constructor() {
443        let mee = ModifiedEquinoctial::new(100.0.m(), 0.1, 0.2, 0.3, 0.4, 0.5.rad());
444        assert_eq!(mee.p(), 100.0.m());
445        assert_eq!(mee.f(), 0.1);
446        assert_eq!(mee.g(), 0.2);
447        assert_eq!(mee.h(), 0.3);
448        assert_eq!(mee.k(), 0.4);
449        assert_eq!(mee.l(), 0.5.rad());
450
451        let err1: ModifiedEquinoctialError =
452            crate::elements::keplerian::KeplerianError::MissingShape.into();
453        assert_eq!(
454            err1.to_string(),
455            "no orbital shape parameters (semi-major axis and eccentricity, radii, or altitudes) were provided"
456        );
457
458        let err2: ModifiedEquinoctialError = crate::anomalies::AnomalyError::InvalidTrueAnomaly {
459            nu: 1.0.rad(),
460            max_nu: 0.5.rad(),
461        }
462        .into();
463        assert!(err2.to_string().contains("outside valid range"));
464    }
465}