Skip to main content

lox_orbits/propagators/
semi_analytical.rs

1// SPDX-FileCopyrightText: 2024 Helge Eichhorn <git@helgeeichhorn.de>
2//
3// SPDX-License-Identifier: MPL-2.0
4
5use lox_core::coords::Cartesian;
6use lox_time::Time;
7use lox_time::deltas::TimeDelta;
8use lox_time::intervals::TimeInterval;
9use lox_time::time_scales::{DynTimeScale, TimeScale};
10use thiserror::Error;
11
12use lox_bodies::{DynOrigin, Origin, PointMass, TryPointMass, UndefinedOriginPropertyError};
13
14use crate::orbits::{CartesianOrbit, TrajectorError, Trajectory};
15use crate::propagators::{Propagator, stumpff};
16use lox_frames::{
17    DynFrame, NonQuasiInertialFrameError, QuasiInertial, ReferenceFrame, TryQuasiInertial,
18};
19
20/// Errors that can occur during Vallado universal-variable propagation.
21#[derive(Debug, Error)]
22pub enum ValladoError {
23    /// The iterative solver did not converge within the maximum number of iterations.
24    #[error("did not converge")]
25    NotConverged,
26    /// Error constructing the output trajectory.
27    #[error(transparent)]
28    TrajectoryError(#[from] TrajectorError),
29    /// The origin body lacks a required physical property.
30    #[error(transparent)]
31    UndefinedOriginProperty(#[from] UndefinedOriginPropertyError),
32    /// The reference frame is not quasi-inertial.
33    #[error(transparent)]
34    NonQuasiInertialFrame(#[from] NonQuasiInertialFrameError),
35}
36
37/// Keplerian orbit propagator using Vallado's universal-variable formulation.
38#[derive(Debug, Copy, Clone, PartialEq)]
39pub struct Vallado<T: TimeScale, O: Origin, R: ReferenceFrame> {
40    initial_state: CartesianOrbit<T, O, R>,
41    max_iter: i32,
42    step: Option<TimeDelta>,
43}
44
45/// Type alias for a [`Vallado`] propagator using dynamic time scale, origin, and frame.
46pub type DynVallado = Vallado<DynTimeScale, DynOrigin, DynFrame>;
47
48// Infallible — static bounds guarantee inertial frame and point mass.
49impl<T, O, R> Vallado<T, O, R>
50where
51    T: TimeScale,
52    O: PointMass + Copy,
53    R: QuasiInertial,
54{
55    /// Create a new Vallado propagator from the given initial state.
56    pub fn new(initial_state: CartesianOrbit<T, O, R>) -> Self {
57        Self {
58            initial_state,
59            max_iter: 300,
60            step: None,
61        }
62    }
63}
64
65// Fallible — Try* bounds (covers DynOrigin and DynFrame).
66impl<T, O, R> Vallado<T, O, R>
67where
68    T: TimeScale,
69    O: TryPointMass + Copy,
70    R: TryQuasiInertial + Copy,
71{
72    /// Try to create a new Vallado propagator, returning an error if the origin or frame is invalid.
73    pub fn try_new(initial_state: CartesianOrbit<T, O, R>) -> Result<Self, ValladoError> {
74        initial_state.origin().try_gravitational_parameter()?;
75        initial_state.reference_frame().try_quasi_inertial()?;
76        Ok(Self {
77            initial_state,
78            max_iter: 300,
79            step: None,
80        })
81    }
82}
83
84impl<T, O, R> Vallado<T, O, R>
85where
86    T: TimeScale,
87    O: TryPointMass + Copy,
88    R: ReferenceFrame,
89{
90    fn gravitational_parameter(&self) -> f64 {
91        self.initial_state
92            .origin()
93            .try_gravitational_parameter()
94            .expect("gravitational parameter should be available")
95            .as_f64()
96    }
97
98    /// Set the maximum number of iterations for the universal-variable solver.
99    pub fn with_max_iter(mut self, max_iter: i32) -> Self {
100        self.max_iter = max_iter;
101        self
102    }
103
104    /// Return the maximum number of solver iterations.
105    pub fn max_iter(&self) -> i32 {
106        self.max_iter
107    }
108
109    /// Set the fixed time step used during propagation.
110    pub fn with_step(mut self, step: TimeDelta) -> Self {
111        self.step = Some(step);
112        self
113    }
114
115    /// Return a reference to the initial orbital state.
116    pub fn initial_state(&self) -> &CartesianOrbit<T, O, R> {
117        &self.initial_state
118    }
119
120    /// Return the central body origin.
121    pub fn origin(&self) -> O {
122        self.initial_state.origin()
123    }
124
125    /// Return the reference frame.
126    pub fn reference_frame(&self) -> R
127    where
128        R: Copy,
129    {
130        self.initial_state.reference_frame()
131    }
132
133    /// Propagate to a single time.
134    pub fn state_at(&self, time: Time<T>) -> Result<CartesianOrbit<T, O, R>, ValladoError>
135    where
136        T: Copy,
137        R: Copy,
138    {
139        let frame = self.reference_frame();
140        let origin = self.origin();
141        let mu = self.gravitational_parameter();
142        let t0 = self.initial_state.time();
143        let dt = (time - t0).to_seconds().to_f64();
144        let sqrt_mu = mu.sqrt();
145        let p0 = self.initial_state.position();
146        let v0 = self.initial_state.velocity();
147        let dot_p0v0 = p0.dot(v0);
148        let norm_p0 = p0.length();
149        let alpha = -v0.dot(v0) / mu + 2.0 / norm_p0;
150
151        let mut xi_new = if alpha > 0.0 {
152            sqrt_mu * dt * alpha
153        } else if alpha < 0.0 {
154            dt.signum()
155                * (-1.0 / alpha).powf(0.5)
156                * (-2.0 * mu * alpha * dt
157                    / (dot_p0v0 + dt.signum() * (-mu / alpha).sqrt() * (1.0 - norm_p0 * alpha)))
158                    .ln()
159        } else {
160            sqrt_mu * dt / norm_p0
161        };
162
163        let mut count = 0;
164        while count < self.max_iter {
165            let xi = xi_new;
166            let psi = xi * xi * alpha;
167            let c2_psi = stumpff::c2(psi);
168            let c3_psi = stumpff::c3(psi);
169            let norm_r = xi.powi(2) * c2_psi
170                + dot_p0v0 / sqrt_mu * xi * (1.0 - psi * c3_psi)
171                + norm_p0 * (1.0 - psi * c2_psi);
172            let delta_xi = (sqrt_mu * dt
173                - xi.powi(3) * c3_psi
174                - dot_p0v0 / sqrt_mu * xi.powi(2) * c2_psi
175                - norm_p0 * xi * (1.0 - psi * c3_psi))
176                / norm_r;
177            xi_new = xi + delta_xi;
178            if (xi_new - xi).abs() < 1e-7 {
179                let f = 1.0 - xi.powi(2) / norm_p0 * c2_psi;
180                let g = dt - xi.powi(3) / sqrt_mu * c3_psi;
181
182                let gdot = 1.0 - xi.powi(2) / norm_r * c2_psi;
183                let fdot = sqrt_mu / (norm_r * norm_p0) * xi * (psi * c3_psi - 1.0);
184
185                debug_assert!((f * gdot - fdot * g - 1.0).abs() < 1e-5);
186
187                let p = f * p0 + g * v0;
188                let v = fdot * p0 + gdot * v0;
189
190                return Ok(CartesianOrbit::new(
191                    Cartesian::from_vecs(p, v),
192                    time,
193                    origin,
194                    frame,
195                ));
196            } else {
197                count += 1
198            }
199        }
200        Err(ValladoError::NotConverged)
201    }
202}
203
204// Single impl covers both typed and DynVallado
205impl<T, O, R> Propagator<T, O> for Vallado<T, O, R>
206where
207    T: TimeScale + Copy + PartialOrd,
208    O: TryPointMass + Copy,
209    R: ReferenceFrame + Copy,
210{
211    type Frame = R;
212    type Error = ValladoError;
213
214    fn state_at(&self, time: Time<T>) -> Result<CartesianOrbit<T, O, R>, ValladoError> {
215        self.state_at(time)
216    }
217
218    fn propagate(&self, interval: TimeInterval<T>) -> Result<Trajectory<T, O, R>, ValladoError> {
219        let step = self.step.unwrap_or(TimeDelta::from_seconds(1));
220        let states = interval
221            .step_by(step)
222            .map(|t| self.state_at(t))
223            .collect::<Result<Vec<_>, _>>()?;
224        Ok(Trajectory::try_new(states)?)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use lox_bodies::Earth;
231    use lox_core::elements::Keplerian as CoreKeplerian;
232    use lox_core::units::{AngleUnits, DistanceUnits};
233    use lox_frames::Icrf;
234    use lox_test_utils::assert_approx_eq;
235    use lox_time::intervals::Interval;
236    use lox_time::time_scales::Tdb;
237    use lox_time::utc;
238    use lox_time::utc::Utc;
239
240    use super::*;
241
242    #[test]
243    fn test_vallado_state_at() {
244        let utc = utc!(2023, 3, 25, 21, 8, 0.0).unwrap();
245        let time = utc.to_time().to_scale(Tdb);
246        let semi_major = 24464.560;
247        let eccentricity = 0.7311;
248        let inclination = 0.122138;
249        let ascending_node = 1.00681;
250        let periapsis_arg = 3.10686;
251        let true_anomaly = 0.44369564302687126;
252
253        let k0 = CoreKeplerian::builder()
254            .with_semi_major_axis(semi_major.km(), eccentricity)
255            .with_inclination(inclination.rad())
256            .with_longitude_of_ascending_node(ascending_node.rad())
257            .with_argument_of_periapsis(periapsis_arg.rad())
258            .with_true_anomaly(true_anomaly.rad())
259            .build()
260            .unwrap();
261
262        let mu = Earth.gravitational_parameter();
263        let s0 = CartesianOrbit::new(k0.to_cartesian(mu), time, Earth, Icrf);
264        let period = k0.orbital_period(mu).unwrap();
265        let t1 = time + period;
266
267        let propagator = Vallado::new(s0);
268        let s1 = propagator.state_at(t1).expect("propagator should converge");
269
270        let k1 = s1.to_keplerian();
271        assert_approx_eq!(
272            k1.semi_major_axis().as_f64(),
273            semi_major.km().as_f64(),
274            rtol <= 1e-8
275        );
276        assert_approx_eq!(k1.eccentricity().as_f64(), eccentricity, rtol <= 1e-8);
277        assert_approx_eq!(k1.inclination().as_f64(), inclination, rtol <= 1e-8);
278        assert_approx_eq!(
279            k1.longitude_of_ascending_node().as_f64(),
280            ascending_node,
281            rtol <= 1e-8
282        );
283        assert_approx_eq!(
284            k1.argument_of_periapsis().as_f64(),
285            periapsis_arg,
286            rtol <= 1e-8
287        );
288        assert_approx_eq!(k1.true_anomaly().as_f64(), true_anomaly, rtol <= 1e-8);
289        assert_approx_eq!(k1.time(), t1);
290    }
291
292    #[test]
293    fn test_vallado_propagate() {
294        let utc = utc!(2023, 3, 25, 21, 8, 0.0).unwrap();
295        let time = utc.to_time().to_scale(Tdb);
296        let semi_major = 24464.560;
297        let eccentricity = 0.7311;
298        let inclination = 0.122138;
299        let ascending_node = 1.00681;
300        let periapsis_arg = 3.10686;
301        let true_anomaly = 0.44369564302687126;
302
303        let k0 = CoreKeplerian::builder()
304            .with_semi_major_axis(semi_major.km(), eccentricity)
305            .with_inclination(inclination.rad())
306            .with_longitude_of_ascending_node(ascending_node.rad())
307            .with_argument_of_periapsis(periapsis_arg.rad())
308            .with_true_anomaly(true_anomaly.rad())
309            .build()
310            .unwrap();
311
312        let mu = Earth.gravitational_parameter();
313        let s0 = CartesianOrbit::new(k0.to_cartesian(mu), time, Earth, Icrf);
314        let period = k0.orbital_period(mu).unwrap();
315        let t_end = time + period;
316        let interval = Interval::new(time, t_end);
317        let trajectory = Vallado::new(s0).propagate(interval).unwrap();
318        let s1 = trajectory.interpolate(period);
319        let k1 = s1.to_keplerian();
320
321        assert_approx_eq!(
322            k1.semi_major_axis().as_f64(),
323            semi_major.km().as_f64(),
324            rtol <= 1e-8
325        );
326        assert_approx_eq!(k1.eccentricity().as_f64(), eccentricity, rtol <= 1e-8);
327        assert_approx_eq!(k1.inclination().as_f64(), inclination, rtol <= 1e-8);
328        assert_approx_eq!(
329            k1.longitude_of_ascending_node().as_f64(),
330            ascending_node,
331            rtol <= 1e-8
332        );
333        assert_approx_eq!(
334            k1.argument_of_periapsis().as_f64(),
335            periapsis_arg,
336            rtol <= 1e-8
337        );
338        assert_approx_eq!(k1.true_anomaly().as_f64(), true_anomaly, rtol <= 1e-8);
339    }
340
341    #[test]
342    fn test_try_new_with_static_types() {
343        let utc = utc!(2023, 3, 25, 21, 8, 0.0).unwrap();
344        let time = utc.to_time().to_scale(Tdb);
345        let pos = glam::DVec3::new(-1076225.32, -6765896.36, -332308.78);
346        let vel = glam::DVec3::new(9356.86, -3312.35, -1188.02);
347        let s0 = CartesianOrbit::new(
348            lox_core::coords::Cartesian::from_vecs(pos, vel),
349            time,
350            Earth,
351            Icrf,
352        );
353        // try_new should accept static types that implement TryPointMass
354        let vallado = Vallado::try_new(s0);
355        assert!(vallado.is_ok());
356    }
357
358    #[test]
359    fn test_try_new_rejects_non_point_mass() {
360        use lox_bodies::DynOrigin;
361
362        let utc = utc!(2023, 3, 25, 21, 8, 0.0).unwrap();
363        let time = utc.to_dyn_time();
364        let pos = glam::DVec3::new(-1076225.32, -6765896.36, -332308.78);
365        let vel = glam::DVec3::new(9356.86, -3312.35, -1188.02);
366        let s0 = CartesianOrbit::new(
367            lox_core::coords::Cartesian::from_vecs(pos, vel),
368            time,
369            DynOrigin::Callirrhoe,
370            DynFrame::Icrf,
371        );
372        let result = Vallado::try_new(s0);
373        assert!(result.is_err());
374    }
375
376    #[test]
377    fn test_try_new_rejects_non_inertial_frame() {
378        use lox_bodies::DynOrigin;
379
380        let utc = utc!(2023, 3, 25, 21, 8, 0.0).unwrap();
381        let time = utc.to_dyn_time();
382        let pos = glam::DVec3::new(-1076225.32, -6765896.36, -332308.78);
383        let vel = glam::DVec3::new(9356.86, -3312.35, -1188.02);
384        let s0 = CartesianOrbit::new(
385            lox_core::coords::Cartesian::from_vecs(pos, vel),
386            time,
387            DynOrigin::Earth,
388            DynFrame::Iau(DynOrigin::Earth),
389        );
390        let result = Vallado::try_new(s0);
391        assert!(result.is_err());
392    }
393}