Skip to main content

lox_orbits/propagators/
sgp4.rs

1// SPDX-FileCopyrightText: 2024 Helge Eichhorn <git@helgeeichhorn.de>
2//
3// SPDX-License-Identifier: MPL-2.0
4
5use glam::DVec3;
6use lox_core::coords::Cartesian;
7pub use sgp4::Elements;
8use sgp4::{Constants, ElementsError, MinutesSinceEpoch};
9use thiserror::Error;
10
11use lox_bodies::Earth;
12use lox_core::f64::consts::SECONDS_PER_MINUTE;
13use lox_frames::Teme;
14use lox_time::Time;
15use lox_time::deltas::TimeDelta;
16use lox_time::intervals::TimeInterval;
17use lox_time::time_scales::Tai;
18use lox_time::utc::UtcError;
19
20use crate::orbits::{CartesianOrbit, TrajectorError, Trajectory};
21use crate::propagators::Propagator;
22
23/// Errors that can occur during SGP4 propagation.
24#[derive(Debug, Error)]
25pub enum Sgp4Error {
26    /// Invalid TLE elements.
27    #[error(transparent)]
28    ElementsError(#[from] ElementsError),
29    /// Error constructing the output trajectory.
30    #[error(transparent)]
31    TrajectoryError(#[from] TrajectorError),
32    /// SGP4 prediction error.
33    #[error(transparent)]
34    Sgp4(#[from] sgp4::Error),
35    /// UTC conversion error.
36    #[error(transparent)]
37    Utc(#[from] UtcError),
38}
39
40/// SGP4/SDP4 orbit propagator for satellites described by Two-Line Element sets.
41#[derive(Debug, Clone)]
42pub struct Sgp4 {
43    constants: Constants,
44    time: Time<Tai>,
45    step: Option<TimeDelta>,
46}
47
48impl Sgp4 {
49    /// Create a new SGP4 propagator from TLE elements.
50    pub fn new(initial_state: Elements) -> Result<Self, Sgp4Error> {
51        let time: Time<Tai> = initial_state.datetime.and_utc().into();
52        // Use AFSPC compatibility mode because TLE data is fitted using
53        // AFSPC constants (WGS72). Using WGS84 with WGS72-fitted data
54        // introduces systematic errors.
55        let constants = Constants::from_elements_afspc_compatibility_mode(&initial_state)?;
56        Ok(Self {
57            constants,
58            time,
59            step: None,
60        })
61    }
62
63    /// Set the fixed time step used during propagation.
64    pub fn with_step(mut self, step: TimeDelta) -> Self {
65        self.step = Some(step);
66        self
67    }
68
69    /// Return the TLE epoch as a TAI time.
70    pub fn time(&self) -> Time<Tai> {
71        self.time
72    }
73
74    /// Propagate to a single time, returning a TEME state.
75    pub fn state_at(&self, time: Time<Tai>) -> Result<CartesianOrbit<Tai, Earth, Teme>, Sgp4Error> {
76        let dt = (time - self.time).to_seconds().to_f64() / SECONDS_PER_MINUTE;
77        let prediction = self.constants.propagate(MinutesSinceEpoch(dt))?;
78        // sgp4 crate returns km and km/s, convert to m and m/s
79        let position = DVec3::from_array(prediction.position) * 1e3;
80        let velocity = DVec3::from_array(prediction.velocity) * 1e3;
81        Ok(CartesianOrbit::new(
82            Cartesian::from_vecs(position, velocity),
83            time,
84            Earth,
85            Teme,
86        ))
87    }
88}
89
90impl Propagator<Tai, Earth> for Sgp4 {
91    type Frame = Teme;
92    type Error = Sgp4Error;
93
94    fn state_at(&self, time: Time<Tai>) -> Result<CartesianOrbit<Tai, Earth, Teme>, Sgp4Error> {
95        self.state_at(time)
96    }
97
98    fn propagate(
99        &self,
100        interval: TimeInterval<Tai>,
101    ) -> Result<Trajectory<Tai, Earth, Teme>, Sgp4Error> {
102        let step = self.step.unwrap_or(TimeDelta::from_seconds(60));
103        let states = interval
104            .step_by(step)
105            .map(|t| self.state_at(t))
106            .collect::<Result<Vec<_>, _>>()?;
107        Ok(Trajectory::try_new(states)?)
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use lox_frames::Icrf;
114    use lox_frames::providers::DefaultRotationProvider;
115    use lox_test_utils::assert_approx_eq;
116    use lox_time::deltas::TimeDelta;
117    use lox_time::intervals::Interval;
118
119    use super::*;
120
121    #[test]
122    fn test_sgp4_state_at() {
123        let tle = Elements::from_tle(
124            Some("ISS (ZARYA)".to_string()),
125            "1 25544U 98067A   24170.37528350  .00016566  00000+0  30244-3 0  9996".as_bytes(),
126            "2 25544  51.6410 309.3890 0010444 339.5369 107.8830 15.49495945458731".as_bytes(),
127        )
128        .unwrap();
129        let sgp4 = Sgp4::new(tle).unwrap();
130        let orbital_period = 92.821;
131        let t1 = sgp4.time() + TimeDelta::from_minutes_f64(orbital_period);
132        let s1 = sgp4.state_at(t1).unwrap();
133        let s1_icrf = s1.try_to_frame(Icrf, &DefaultRotationProvider).unwrap();
134        let k1 = s1_icrf.to_keplerian();
135        assert_approx_eq!(
136            k1.orbital_period().unwrap().to_seconds().to_f64() / SECONDS_PER_MINUTE,
137            orbital_period,
138            rtol <= 1e-4
139        );
140    }
141
142    #[test]
143    fn test_sgp4_propagate() {
144        let tle = Elements::from_tle(
145            Some("ISS (ZARYA)".to_string()),
146            "1 25544U 98067A   24170.37528350  .00016566  00000+0  30244-3 0  9996".as_bytes(),
147            "2 25544  51.6410 309.3890 0010444 339.5369 107.8830 15.49495945458731".as_bytes(),
148        )
149        .unwrap();
150        let sgp4 = Sgp4::new(tle).unwrap();
151        let t0 = sgp4.time();
152        let t1 = t0 + TimeDelta::from_minutes_f64(92.821);
153        let interval = Interval::new(t0, t1);
154        let traj = sgp4.propagate(interval).unwrap();
155        // With 60s default step over ~93 min, we should have ~94 points
156        assert!(traj.states().len() > 90);
157    }
158
159    #[test]
160    fn test_sgp4_propagate_in_frame() {
161        let tle = Elements::from_tle(
162            Some("ISS (ZARYA)".to_string()),
163            "1 25544U 98067A   24170.37528350  .00016566  00000+0  30244-3 0  9996".as_bytes(),
164            "2 25544  51.6410 309.3890 0010444 339.5369 107.8830 15.49495945458731".as_bytes(),
165        )
166        .unwrap();
167        let sgp4 = Sgp4::new(tle).unwrap();
168        let t0 = sgp4.time();
169        let t1 = t0 + TimeDelta::from_minutes(10);
170        let interval = Interval::new(t0, t1);
171        let traj = sgp4
172            .propagate(interval)
173            .unwrap()
174            .into_frame(Icrf, &DefaultRotationProvider)
175            .unwrap();
176        assert!(traj.states().len() > 5);
177        assert_eq!(traj.reference_frame(), Icrf);
178    }
179}