satkit/orbitprop/
propagator.rs

1use super::drag::{drag_and_partials, drag_force};
2use super::point_gravity::{point_gravity, point_gravity_and_partials};
3use super::settings::PropSettings;
4
5use crate::earthgravity;
6use crate::lpephem;
7use crate::ode;
8use crate::ode::ODEError;
9use crate::ode::ODEResult;
10use crate::ode::RKAdaptive;
11use crate::orbitprop::Precomputed;
12use crate::{Duration, Instant};
13use lpephem::sun::shadowfunc;
14
15use anyhow::{Context, Result};
16
17use crate::types::*;
18
19use crate::consts;
20use crate::orbitprop::SatProperties;
21use num_traits::identities::Zero;
22
23use thiserror::Error;
24
25use nalgebra as na;
26
27use serde::{Deserialize, Serialize};
28
29#[derive(Debug, Serialize, Deserialize, Clone)]
30pub struct PropagationResult<const T: usize> {
31    pub time_start: Instant,
32    pub state_start: Matrix<6, T>,
33    pub time_end: Instant,
34    pub state_end: Matrix<6, T>,
35    pub accepted_steps: u32,
36    pub rejected_steps: u32,
37    pub num_eval: u32,
38    pub odesol: Option<ode::ODESolution<Matrix<6, T>>>,
39}
40
41impl<const T: usize> PropagationResult<T> {
42    pub fn interp(&self, time: &Instant) -> Result<Matrix<6, T>> {
43        interp_propresult(self, time)
44    }
45}
46
47pub type StateType<const C: usize> = na::SMatrix<f64, 6, C>;
48
49// Simple state with position & velocity
50pub type SimpleState = StateType<1>;
51
52// Covariance State in includes
53pub type CovState = StateType<7>;
54
55#[derive(Debug, Error)]
56pub enum PropagationError {
57    #[error("Invalid number of columns: {c}")]
58    InvalidStateColumns { c: usize },
59    #[error("No Dense Output in Solution")]
60    NoDenseOutputInSolution,
61    #[error("ODE Error: {0}")]
62    ODEError(ode::ODEError),
63}
64
65//
66// This actually implements the force model that is used to
67// integrate the ODE to get position and velocity
68//
69// State is position and velocity
70// Force is computed and integrated to get velocity
71// Velocity is integrated to get position
72//
73// If C=7, a 6x6 state transition matrix is appended as additional
74// colums, making the integrated "state" a 6x7 matrix
75// The state transition matrix can be used to propagate covariances
76//
77// See Montenbruk & Gill for details (Chapter 7)
78//
79
80///
81/// High-precision Propagation a satellite state from a given start time
82/// to a given stop time, with input settings and
83/// satellite properties
84///
85/// Uses Runga-kutta methods for integrating the force equations
86///
87/// The default propagator uses a Runga-Kutta 9(8) integrator
88/// with coefficients computed by Verner:
89/// <https://www.sfu.a/~jverner//>
90///
91/// This works much better than lower-order Runga-Kutta solvers such as
92/// Dorumund-Prince, and I don't know why it isn't more popular in
93/// numerical packages
94///
95/// # Forces included in the propagator:
96///
97/// * Earth gravity with higher-order zonal terms
98/// * Gravitational pull of sun, moon
99/// * Solar radiation pressure
100/// * Atmospheric drag: NRL-MSISE 2000 model, with option to include space weather
101///   (effects can be large)
102///
103/// # Arguments:
104///
105/// * `state` - The satellite state, represented as:
106///    * `SimpleState` - a 6x1 matrix where the 1st three elements represent GCRF position in meters,
107///      and the 2nd three elements represent GCRF velocity in meters / second
108///    * `CovState` - a 6x7 matrix where the first column is the same as SimpleState above, and columns
109///      2-7 represent the 6x6 state transition matrix, dS/dS0
110///      The state transition matrix should be initialized to identity when running
111///      The output of the state transition matrix can be used to compute the evolution of the
112///      state covariance  (see Montenbruck and Gill for details)
113///  * `start` - The time at the initial state
114///  * `stop` - The time at which to propagate for computing new states
115///  * `step_seconds` - An optional value representing intervals between `start` and `stop` at which
116///    the new state will be computed
117///  * `settings` - Settings for the Runga-Kutta propagator
118///  * `satprops` - Properties of the satellite, such as ballistic coefficient & susceptibility to
119///    radiation pressure
120///
121/// # Returns
122/// * `PropagationResult` object with details of the propagation compute, the final state, and intermediate states if step size
123///   is set
124///
125/// # Example:
126///
127/// ```
128/// // Setup a simple Geosynchronous orbit with initial position along the x axis
129/// // and initial velocity along the y axis
130/// let mut state = satkit::orbitprop::SimpleState::zeros();
131/// state[0] = satkit::consts::GEO_R;
132/// state[4] = (satkit::consts::MU_EARTH / satkit::consts::GEO_R).sqrt();
133///
134/// // Setup the details of the propagation
135/// let mut settings = satkit::orbitprop::PropSettings::default();
136/// settings.abs_error = 1.0e-9;
137/// settings.rel_error = 1.0e-14;
138/// settings.gravity_order = 4;
139///
140/// // Pick an arbitrary start time
141/// let starttime = satkit::Instant::from_datetime(2015, 3, 20, 0, 0, 0.0).unwrap();
142/// // Propagate to 1/2 day ahead
143/// let stoptime = starttime + satkit::Duration::from_days(0.5);
144///
145/// // Look at the results
146/// let res = satkit::orbitprop::propagate(&state, &starttime, &stoptime, &settings, None).unwrap();
147///
148/// println!("results = {:?}", res);
149/// // Expect:
150/// // res = PropagationResult { time: [Instant { mjd_tai: 57101.50040509259 }],
151/// //                           state: [[[-42153870.84175911, -379423.6616440884, -26.239180898423687,
152/// //                                     27.66411233952899, -3075.146656613106, 0.0020580348953689828]]],
153/// //                           accepted_steps: 45, rejected_steps: 0, num_eval: 722
154/// //                          }
155/// ```
156///
157/// # Example 2:
158///
159/// ```
160/// // Now, propagate the state transition matrix
161///
162/// // Setup a simple Geosynchronous orbit with initial position along the x axis
163/// // and initial velocity along the y axis
164/// use nalgebra as na;
165/// let mut state = satkit::orbitprop::CovState::zeros();
166/// state.fixed_view_mut::<3, 1>(0, 0).copy_from(&na::vector![satkit::consts::GEO_R, 0.0, 0.0]);
167/// state.fixed_view_mut::<3, 1>(3, 0).copy_from(&na::vector![0.0, (satkit::consts::MU_EARTH/satkit::consts::GEO_R).sqrt(), 0.0]);
168/// // initialize state transition matrix to zero
169/// state.fixed_view_mut::<6, 6>(0, 1).copy_from(&na::Matrix6::<f64>::identity());
170///
171///
172/// // Setup the details of the propagation
173/// let mut settings = satkit::orbitprop::PropSettings::default();
174/// settings.abs_error = 1.0e-9;
175/// settings.rel_error = 1.0e-14;
176/// settings.gravity_order = 4;
177///
178/// // Pick an arbitrary start time
179/// let starttime = satkit::Instant::from_datetime(2015, 3, 20, 0, 0, 0.0).unwrap();
180/// // Propagate to 1/2 day ahead
181/// let stoptime = starttime + satkit::Duration::from_days(0.5);
182///
183/// // Look at the results
184/// let res = satkit::orbitprop::propagate(&state, &starttime, &stoptime, &settings, None).unwrap();
185///
186/// println!("results = {:?}", res);
187/// ```
188///
189///
190pub fn propagate<const C: usize>(
191    state: &StateType<C>,
192    start: &Instant,
193    stop: &Instant,
194    settings: &PropSettings,
195    satprops: Option<&dyn SatProperties>,
196) -> Result<PropagationResult<C>> {
197    // Duration to end of integration, in seconds
198    let x_end: f64 = (*stop - *start).as_seconds();
199
200    let odesettings = crate::ode::RKAdaptiveSettings {
201        abserror: settings.abs_error,
202        relerror: settings.rel_error,
203        dense_output: settings.enable_interp,
204        ..Default::default()
205    };
206
207    // Get or create data for interpolation
208    let interp: &Precomputed = {
209        if let Some(sinterp) = &settings.precomputed {
210            if stop > start {
211                if (*start >= sinterp.start) && (*stop <= sinterp.stop) {
212                    sinterp
213                } else {
214                    &Precomputed::new(start, stop)
215                        .context("Cannot compute precomputed interpolation data for propagation")?
216                }
217            } else if (*stop >= sinterp.start) && (*start <= sinterp.stop) {
218                sinterp
219            } else {
220                &Precomputed::new(start, stop)
221                    .context("Cannot compute precomputed interpolation data for propagation")?
222            }
223        } else {
224            &Precomputed::new(start, stop)
225                .context("Cannot compute precomputed interpolation dat for propagation")?
226        }
227    };
228
229    let ydot = |x: f64, y: &Matrix<6, C>| -> ODEResult<Matrix<6, C>> {
230        // The time variable in the ODE is in seconds
231        let time: Instant = *start + Duration::from_seconds(x);
232
233        // get GCRS position & velocity;
234        let pos_gcrf: na::Vector3<f64> = y.fixed_view::<3, 1>(0, 0).into();
235        let vel_gcrf: na::Vector3<f64> = y.fixed_view::<3, 1>(3, 0).into();
236
237        // Get interpolated values
238        let (qgcrf2itrf, sun_gcrf, moon_gcrf) = match interp.interp(&time) {
239            Ok(v) => v,
240            Err(e) => return Err(ODEError::YDotError(e.to_string())),
241        };
242        let qitrf2gcrf = qgcrf2itrf.conjugate();
243
244        // Position in ITRF coordinates
245        let pos_itrf = qgcrf2itrf * pos_gcrf;
246
247        const fn is_one<const C2: usize>() -> bool {
248            C2 == 1
249        }
250        const fn is_seven<const C2: usize>() -> bool {
251            C2 == 7
252        }
253
254        // Propagating a "simple" 6-dof (position, velocity) state
255        if is_one::<C>() {
256            let mut accel = Vector3::zeros();
257
258            // Gravity in the ITRF frame
259            let gravity_itrf =
260                earthgravity::jgm3().accel(&pos_itrf, settings.gravity_order as usize);
261
262            // Gravity in the GCRS frame
263            accel += qitrf2gcrf * gravity_itrf;
264
265            // Acceleration due to moon
266            accel += point_gravity(&pos_gcrf, &moon_gcrf, crate::consts::MU_MOON);
267
268            // Acceleration due to sun
269            accel += point_gravity(&pos_gcrf, &sun_gcrf, crate::consts::MU_SUN);
270
271            // Add solar pressure & drag if that is defined in satellite properties
272            if let Some(props) = satprops {
273                let ss = y.fixed_view::<6, 1>(0, 0);
274
275                // Compute solar pressure
276                let solarpressure = -shadowfunc(&sun_gcrf, &pos_gcrf)
277                    * props.cr_a_over_m(&time, &ss.into())
278                    * 4.56e-6
279                    * sun_gcrf
280                    / sun_gcrf.norm();
281                accel += solarpressure;
282
283                // Compute drag
284                if pos_gcrf.norm() < 700.0e3 + crate::consts::EARTH_RADIUS {
285                    let cd_a_over_m = props.cd_a_over_m(&time, &ss.into());
286
287                    if cd_a_over_m > 1e-6 {
288                        accel += drag_force(
289                            &pos_gcrf,
290                            &pos_itrf,
291                            &vel_gcrf,
292                            &time,
293                            cd_a_over_m,
294                            settings.use_spaceweather,
295                        );
296                    }
297                }
298            } // end of handling drag & solarpressure
299
300            let mut dy = Matrix::<6, C>::zeros();
301            // change in position is velocity
302            dy.fixed_view_mut::<3, 1>(0, 0).copy_from(&vel_gcrf);
303
304            // Change in velocity is acceleration
305            dy.fixed_view_mut::<3, 1>(3, 0).copy_from(&accel);
306
307            Ok(dy)
308        }
309        // If C==7, we are also integrating the state transition matrix
310        else if is_seven::<C>() {
311            // For state transition matrix, we need to compute force partials with respect to position
312            // (for all forces but drag, partial with respect to velocity are zero)
313            let (gravity_accel, gravity_partials) =
314                earthgravity::jgm3().accel_and_partials(&pos_itrf, settings.gravity_order as usize);
315            let (sun_accel, sun_partials) =
316                point_gravity_and_partials(&pos_gcrf, &sun_gcrf, consts::MU_SUN);
317            let (moon_accel, moon_partials) =
318                point_gravity_and_partials(&pos_gcrf, &moon_gcrf, consts::MU_MOON);
319
320            let mut accel = qitrf2gcrf * gravity_accel + sun_accel + moon_accel;
321
322            // Equation 7.42 in Montenbruck & Gill
323            let mut dfdy: StateType<6> = StateType::<6>::zeros();
324            dfdy.fixed_view_mut::<3, 3>(0, 3)
325                .copy_from(&na::Matrix3::<f64>::identity());
326
327            let ritrf2gcrf = qitrf2gcrf.to_rotation_matrix();
328            // Sum partials with respect to position for gravity, sun, and moon
329            // Note: gravity partials need to be rotated into the gcrf frame from itrf
330            let mut dadr = ritrf2gcrf * gravity_partials * ritrf2gcrf.transpose()
331                + sun_partials
332                + moon_partials;
333
334            // Handle satellite properties for drag and radiation pressure
335            if let Some(props) = satprops {
336                // Satellite state as 6-element position, velcoity matrix
337                // used to query cd_a_over_m
338                let ss = y.fixed_view::<6, 1>(0, 0);
339
340                // Compute solar pressure
341                // Partials for this are very small since the sun is very very far away, changes in
342                // satellite position don't change radiaion pressure much, so we will ignore...
343                let solarpressure = -shadowfunc(&sun_gcrf, &pos_gcrf)
344                    * props.cr_a_over_m(&time, &ss.into())
345                    * 4.56e-6
346                    * sun_gcrf
347                    / sun_gcrf.norm();
348                accel += solarpressure;
349
350                // We know drag is negligible above 700 km, so ignore if this is the case
351                if pos_gcrf.norm() < 700.0e3 + crate::consts::EARTH_RADIUS {
352                    let cd_a_over_m = props.cd_a_over_m(&time, &ss.into());
353                    if cd_a_over_m > 1e-6 {
354                        let (drag_accel, ddragaccel_dr, ddragaccel_dv) = drag_and_partials(
355                            &pos_gcrf,
356                            &qgcrf2itrf,
357                            &vel_gcrf,
358                            &time,
359                            cd_a_over_m,
360                            settings.use_spaceweather,
361                        );
362
363                        // Add acceleration from drag to accel vector
364                        accel += drag_accel;
365
366                        // Add drag partials with respect to position to
367                        // daccel dr
368                        dadr += ddragaccel_dr;
369
370                        // Drag is the only force term that produces a finite partial with respect
371                        // to velocity, so copy it directly into dfdy here.
372                        dfdy.fixed_view_mut::<3, 3>(3, 3).copy_from(&ddragaccel_dv);
373                    }
374                }
375            }
376            dfdy.fixed_view_mut::<3, 3>(3, 0).copy_from(&dadr);
377
378            // Derivative of state transition matrix is dfdy * state transition matrix
379            let dphi: na::Matrix<f64, na::Const<6>, na::Const<6>, na::ArrayStorage<f64, 6, 6>> =
380                dfdy * y.fixed_view::<6, 6>(0, 1);
381
382            let mut dy = Matrix::<6, C>::zero();
383            dy.fixed_view_mut::<3, 1>(0, 0).copy_from(&vel_gcrf);
384            dy.fixed_view_mut::<3, 1>(3, 0).copy_from(&accel);
385            dy.fixed_view_mut::<6, 6>(0, 1).copy_from(&dphi);
386            Ok(dy)
387        } else {
388            ODEError::YDotError(PropagationError::InvalidStateColumns { c: C }.to_string()).into()
389        }
390    };
391
392    match settings.enable_interp {
393        false => {
394            let res = match crate::ode::solvers::RKV98NoInterp::integrate(
395                0.0,
396                x_end,
397                state,
398                ydot,
399                &odesettings,
400            ) {
401                Ok(res) => res,
402                Err(e) => return Err(PropagationError::ODEError(e).into()),
403            };
404
405            Ok(PropagationResult {
406                time_start: *start,
407                state_start: *state,
408                time_end: *stop,
409                state_end: res.y,
410                accepted_steps: res.naccept as u32,
411                rejected_steps: res.nreject as u32,
412                num_eval: res.nevals as u32,
413                odesol: Some(res),
414            })
415        }
416        true => {
417            let res = crate::ode::solvers::RKV98::integrate(0.0, x_end, state, ydot, &odesettings)?;
418            Ok(PropagationResult {
419                time_start: *start,
420                state_start: *state,
421                time_end: *stop,
422                state_end: res.y,
423                accepted_steps: res.naccept as u32,
424                rejected_steps: res.nreject as u32,
425                num_eval: res.nevals as u32,
426                odesol: Some(res),
427            })
428        }
429    }
430}
431
432pub fn interp_propresult<const C: usize>(
433    res: &PropagationResult<C>,
434    time: &Instant,
435) -> Result<StateType<C>> {
436    if let Some(sol) = &res.odesol {
437        if sol.dense.is_some() {
438            let x = (time - res.time_start).as_seconds();
439            let y = crate::ode::solvers::RKV98::interpolate(x, sol)?;
440            Ok(y)
441        } else {
442            Err(PropagationError::NoDenseOutputInSolution.into())
443        }
444    } else {
445        Err(PropagationError::NoDenseOutputInSolution.into())
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::{consts, orbitprop::SatPropertiesStatic};
453    use std::f64::consts::PI;
454
455    use std::fs::File;
456
457    use crate::Duration;
458    use std::io::{self, BufRead};
459
460    #[test]
461    fn test_short_propagate() -> Result<()> {
462        let starttime = Instant::from_datetime(2015, 3, 20, 0, 0, 0.0)?;
463        let stoptime = starttime + Duration::from_seconds(0.1);
464
465        let mut state: SimpleState = SimpleState::zeros();
466
467        state[0] = consts::GEO_R;
468        state[4] = (consts::MU_EARTH / consts::GEO_R).sqrt();
469
470        let settings = PropSettings {
471            abs_error: 1.0e-9,
472            rel_error: 1.0e-14,
473            gravity_order: 4,
474            ..Default::default()
475        };
476
477        let _res1 = propagate(&state, &starttime, &stoptime, &settings, None)?;
478
479        Ok(())
480    }
481
482    #[test]
483    fn test_propagate() -> Result<()> {
484        let starttime = Instant::from_datetime(2015, 3, 20, 0, 0, 0.0)?;
485        let stoptime = starttime + Duration::from_days(0.25);
486
487        let mut state: SimpleState = SimpleState::zeros();
488
489        state[0] = consts::GEO_R;
490        state[4] = (consts::MU_EARTH / consts::GEO_R).sqrt();
491
492        let mut settings = PropSettings {
493            abs_error: 1.0e-9,
494            rel_error: 1.0e-14,
495            gravity_order: 4,
496            ..Default::default()
497        };
498        settings.precompute_terms(&starttime, &stoptime)?;
499
500        let res1 = propagate(&state, &starttime, &stoptime, &settings, None)?;
501        // Try to propagate back to original time
502        let res2 = propagate(&res1.state_end, &stoptime, &starttime, &settings, None)?;
503        // See if propagating back to original time matches
504        for ix in 0..6_usize {
505            assert!((res2.state_end[ix] - state[ix]).abs() < 1.0)
506        }
507
508        Ok(())
509    }
510
511    #[test]
512    fn test_interp() -> Result<()> {
513        let starttime = Instant::from_datetime(2015, 3, 20, 0, 0, 0.0)?;
514        let stoptime = starttime + Duration::from_days(1.0);
515
516        let mut state: SimpleState = SimpleState::zeros();
517
518        state[0] = consts::GEO_R;
519        state[4] = (consts::MU_EARTH / consts::GEO_R).sqrt();
520
521        let settings = PropSettings {
522            abs_error: 1.0e-9,
523            rel_error: 1.0e-14,
524            gravity_order: 4,
525            ..Default::default()
526        };
527
528        // Propagate forward
529        let res = propagate(&state, &starttime, &stoptime, &settings, None)?;
530        // propagate backward to original time
531        let res2 = propagate(&res.state_end, &stoptime, &starttime, &settings, None)?;
532        // Check that we recover the original state
533        for ix in 0..6_usize {
534            assert!((state[ix] - res2.state_end[ix]).abs() < 1.0e-3);
535        }
536
537        // Check interpolation forward and backward return the same result
538        let newtime = starttime + Duration::from_days(0.45);
539        let interp = res.interp(&newtime)?;
540        let interp2 = res2.interp(&newtime)?;
541        for ix in 0..6_usize {
542            assert!((interp[ix] - interp2[ix]).abs() < 1e-3);
543        }
544        Ok(())
545    }
546
547    #[test]
548    fn test_state_transition() -> Result<()> {
549        // Check the state transition matrix:
550        // Explicitly propagate two slightly different states,
551        // separated by "dstate",
552        // and compare with difference in final states as predicted
553        // by state transition matrix
554        // Note also: drag partials are very small relative to other terms,
555        // making it difficult to confirm that calculations are correct.
556
557        let starttime = Instant::from_datetime(2015, 3, 20, 0, 0, 0.0)?;
558        let stoptime = starttime + Duration::from_days(0.5);
559
560        let mut state: CovState = CovState::zeros();
561
562        let theta = PI / 6.0;
563        state[0] = consts::GEO_R * theta.cos();
564        state[2] = consts::GEO_R * theta.sin();
565        state[4] = (consts::MU_EARTH / consts::GEO_R).sqrt() * theta.cos();
566        state[5] = (consts::MU_EARTH / consts::GEO_R).sqrt() * theta.sin();
567        state
568            .fixed_view_mut::<6, 6>(0, 1)
569            .copy_from(&na::Matrix6::<f64>::identity());
570
571        let settings = PropSettings {
572            abs_error: 1.0e-9,
573            rel_error: 1.0e-14,
574            gravity_order: 4,
575            ..Default::default()
576        };
577
578        // Made-up small variations in the state
579        let dstate = na::vector![6.0, -10.0, 120.5, 0.1, 0.2, -0.3];
580
581        // Propagate state (and state-transition matrix)
582        let res = propagate(&state, &starttime, &stoptime, &settings, None)?;
583
584        // Explicitly propagate state + dstate
585        let res2 = propagate(
586            &(state.fixed_view::<6, 1>(0, 0) + dstate),
587            &starttime,
588            &stoptime,
589            &settings,
590            None,
591        )?;
592
593        // Difference in states from explicitly propagating with
594        // "dstate" change in initial conditions
595        let dstate_prop = res2.state_end - res.state_end.fixed_view::<6, 1>(0, 0);
596
597        // Difference in states estimated from state transition matrix
598        let dstate_phi = res.state_end.fixed_view::<6, 6>(0, 1) * dstate;
599        for ix in 0..6_usize {
600            assert!((dstate_prop[ix] - dstate_phi[ix]).abs() / dstate_prop[ix] < 1e-3);
601        }
602
603        Ok(())
604    }
605
606    #[test]
607    fn test_state_transition_drag() -> Result<()> {
608        // Check the state transition matrix:
609        // Explicitly propagate two slightly different states,
610        // separated by "dstate",
611        // and compare with difference in final states as predicted
612        // by state transition matrix
613        // This version has a low-altitude satellite and we will
614        // set a fininte cdaoverm value so that there is drag
615        // and we can check drag partials
616
617        let starttime = Instant::from_datetime(2015, 3, 20, 0, 0, 0.0)?;
618        let stoptime = starttime + crate::Duration::from_days(0.2);
619
620        let mut state: CovState = CovState::zeros();
621
622        let pgcrf = na::vector![3059573.85713792, 5855177.98848048, -7191.45042671];
623        let vgcrf = na::vector![916.08123489, -468.22498656, 7700.48460839];
624
625        // 30-deg inclination
626        state.fixed_view_mut::<3, 1>(0, 0).copy_from(&pgcrf);
627        state.fixed_view_mut::<3, 1>(3, 0).copy_from(&vgcrf);
628        state
629            .fixed_view_mut::<6, 6>(0, 1)
630            .copy_from(&na::Matrix6::<f64>::identity());
631
632        let settings = PropSettings {
633            abs_error: 1.0e-9,
634            rel_error: 1.0e-14,
635            gravity_order: 4,
636            ..Default::default()
637        };
638
639        let satprops: SatPropertiesStatic = SatPropertiesStatic::new(2.0 * 0.3 * 0.1 / 5.0, 0.0);
640
641        // Made-up small variations in the state
642        let dstate = na::vector![2.0, -4.0, 20.5, 0.05, 0.02, -0.01];
643
644        // Propagate state (and state-transition matrix)
645
646        let res = propagate(&state, &starttime, &stoptime, &settings, Some(&satprops))?;
647
648        // Explicitly propagate state + dstate
649        let res2 = propagate(
650            &(state.fixed_view::<6, 1>(0, 0) + dstate),
651            &starttime,
652            &stoptime,
653            &settings,
654            Some(&satprops),
655        )?;
656
657        // Difference in states from explicitly propagating with
658        // "dstate" change in initial conditions
659        let dstate_prop = res2.state_end - res.state_end.fixed_view::<6, 1>(0, 0);
660
661        let dstate_phi = res.state_end.fixed_view::<6, 6>(0, 1) * dstate;
662
663        // Are differences within 1%?
664        for ix in 0..6_usize {
665            assert!((dstate_prop[ix] - dstate_phi[ix]).abs() / dstate_prop[ix] < 0.1);
666        }
667
668        Ok(())
669    }
670
671    #[test]
672    fn test_gps() -> Result<()> {
673        let testvecfile = crate::utils::test::get_testvec_dir()
674            .unwrap()
675            .join("orbitprop")
676            .join("ESA0OPSFIN_20233640000_01D_05M_ORB.SP3");
677
678        if !testvecfile.is_file() {
679            anyhow::bail!(
680                "Required GPS SP3 File: \"{}\" does not exist.
681                Clone test vectors from:
682                <https://storage.googleapis.com/satkit-testvecs/>
683                or using python script in satkit repo: `python/test/download_testvecs.py`
684                or set \"SATKIT_TESTVEC_ROOT\" to point to directory",
685                testvecfile.to_string_lossy()
686            );
687        }
688        let file: File = File::open(testvecfile.clone())?;
689
690        let times: Vec<crate::Instant> = io::BufReader::new(file)
691            .lines()
692            .filter(|x: &Result<String, io::Error>| x.as_ref().unwrap().starts_with('*'))
693            .map(|rline| -> Result<crate::Instant> {
694                let line = rline.unwrap();
695                let lvals: Vec<&str> = line.split_whitespace().collect();
696                let year: i32 = lvals[1].parse()?;
697                let mon: i32 = lvals[2].parse()?;
698                let day: i32 = lvals[3].parse()?;
699                let hour: i32 = lvals[4].parse()?;
700                let min: i32 = lvals[5].parse()?;
701                let sec: f64 = lvals[6].parse()?;
702                Instant::from_datetime(year, mon, day, hour, min, sec)
703            })
704            .collect::<Result<Vec<crate::Instant>, _>>()?;
705
706        let file: File = File::open(testvecfile)?;
707
708        let satnum: usize = 20;
709        let satstr = format!("PG{}", satnum);
710        let pitrf: Vec<na::Vector3<f64>> = io::BufReader::new(file)
711            .lines()
712            .filter(|x| {
713                let rline = &x.as_ref().unwrap()[0..4];
714                rline == satstr
715            })
716            .map(|rline| -> Result<na::Vector3<f64>> {
717                let line = rline.unwrap();
718                let lvals: Vec<&str> = line.split_whitespace().collect();
719                let px: f64 = lvals[1].parse()?;
720                let py: f64 = lvals[2].parse()?;
721                let pz: f64 = lvals[3].parse()?;
722                Ok(na::vector![px, py, pz] * 1.0e3)
723            })
724            .collect::<Result<Vec<na::Vector3<f64>>>>()?;
725
726        assert!(times.len() == pitrf.len());
727        let pgcrf: Vec<na::Vector3<f64>> = pitrf
728            .iter()
729            .enumerate()
730            .map(|(idx, p)| {
731                let q = crate::frametransform::qitrf2gcrf(&times[idx]);
732                q * p
733            })
734            .collect();
735
736        let v0 = na::vector![
737            2.47130562e+03,
738            2.94682753e+03,
739            -5.34172176e+02,
740            2.32565692e-02
741        ];
742
743        let state0 = na::vector![pgcrf[0][0], pgcrf[0][1], pgcrf[0][2], v0[0], v0[1], v0[2]];
744        let satprops: SatPropertiesStatic = SatPropertiesStatic::new(0.0, v0[3]);
745
746        let settings = PropSettings {
747            enable_interp: true,
748            ..Default::default()
749        };
750
751        let res = propagate(
752            &state0,
753            &times[0],
754            &times[times.len() - 1],
755            &settings,
756            Some(&satprops),
757        )?;
758
759        // We've propagated over a day; assert that the difference in position on all three coordinate axes
760        // is less than 10 meters for all 5-minute intervals
761        for iv in 0..(pgcrf.len()) {
762            let interp_state = res.interp(&times[iv])?;
763            for ix in 0..3 {
764                assert!((pgcrf[iv][ix] - interp_state[ix]).abs() < 8.0);
765            }
766        }
767
768        Ok(())
769    }
770}