Skip to main content

sidereon_core/astro/propagator/
numerical.rs

1//! High-level numerical state-vector propagation entry point.
2//!
3//! Builds a propagatable object from a raw epoch plus an ECI Cartesian state
4//! (position + velocity) and propagates it forward (or backward) with the
5//! existing integrators ([`RK4`], [`DP54`]) and the existing force models
6//! (two-body, two-body + J2). This is a thin orchestration layer over
7//! [`crate::astro::integrators`] and [`crate::astro::forces`]: it constructs the
8//! force model, wraps it in [`OrbitalDynamics`], and drives the chosen
9//! integrator. It adds no integration math of its own, so a single-shot
10//! [`StatePropagator::propagate_to`] is bit-for-bit identical to assembling the
11//! force model, dynamics, integrator, and options by hand. State-transition
12//! matrices are formed by finite-differencing this same entry point, so they
13//! reflect the selected force model and integrator without duplicating dynamics.
14
15use crate::astro::constants::{J2_EARTH, MU_EARTH, RE_EARTH};
16use crate::astro::covariance::{Covariance6, Covariance6Error};
17use crate::astro::error::PropagationError;
18use crate::astro::forces::{CompositeForceModel, ForceModel, J2Gravity, TwoBodyGravity};
19use crate::astro::integrators::{Integrator, DP54, RK4};
20use crate::astro::propagator::api::{IntegratorOptions, PropagationContext};
21use crate::astro::propagator::dynamics::OrbitalDynamics;
22use crate::astro::propagator::result::PropagationResult;
23use crate::astro::state::CartesianState;
24
25/// Row-major 6x6 state-transition matrix for `[r_x, r_y, r_z, v_x, v_y, v_z]`.
26///
27/// Entry `[i][j]` is the finite-difference derivative of final-state component
28/// `i` with respect to initial-state component `j`.
29pub type StateTransitionMatrix = [[f64; 6]; 6];
30
31const STM_RELATIVE_PERTURBATION: f64 = 1.0e-6;
32const STM_MIN_PERTURBATION: f64 = 1.0e-6;
33
34/// Which numerical integrator drives the propagation.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum IntegratorKind {
37    /// Fixed-step classical Runge-Kutta 4 (step taken from
38    /// [`IntegratorOptions::initial_step`]; tolerances are ignored).
39    Rk4,
40    /// Adaptive Dormand-Prince 5(4) with PI step control (honors the absolute
41    /// and relative tolerances).
42    Dp54,
43}
44
45/// Which force model supplies the acceleration during propagation.
46///
47/// Each variant carries its own physical parameters so a caller can propagate a
48/// non-Earth central body by supplying a different gravitational parameter. The
49/// [`Self::two_body`] / [`Self::two_body_j2`] constructors fill in the canonical
50/// Earth values from [`crate::astro::constants`].
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub enum ForceModelKind {
53    /// Pure two-body (Keplerian) gravity.
54    TwoBody {
55        /// Gravitational parameter, km^3/s^2.
56        mu_km3_s2: f64,
57    },
58    /// Two-body gravity plus the J2 oblateness perturbation.
59    TwoBodyJ2 {
60        /// Gravitational parameter, km^3/s^2.
61        mu_km3_s2: f64,
62        /// Reference equatorial radius, km.
63        re_km: f64,
64        /// J2 zonal harmonic coefficient (dimensionless).
65        j2: f64,
66    },
67}
68
69impl ForceModelKind {
70    /// Earth two-body gravity using the canonical [`MU_EARTH`].
71    pub fn two_body() -> Self {
72        Self::TwoBody {
73            mu_km3_s2: MU_EARTH,
74        }
75    }
76
77    /// Earth two-body + J2 using the canonical [`MU_EARTH`], [`RE_EARTH`], and
78    /// [`J2_EARTH`].
79    pub fn two_body_j2() -> Self {
80        Self::TwoBodyJ2 {
81            mu_km3_s2: MU_EARTH,
82            re_km: RE_EARTH,
83            j2: J2_EARTH,
84        }
85    }
86
87    /// Build the boxed [`ForceModel`] this variant describes. Reuses the
88    /// existing [`TwoBodyGravity`] / [`J2Gravity`] / [`CompositeForceModel`]
89    /// implementations; no acceleration math is duplicated here.
90    fn build(self) -> Box<dyn ForceModel> {
91        match self {
92            ForceModelKind::TwoBody { mu_km3_s2 } => Box::new(TwoBodyGravity { mu: mu_km3_s2 }),
93            ForceModelKind::TwoBodyJ2 {
94                mu_km3_s2,
95                re_km,
96                j2,
97            } => {
98                let mut composite = CompositeForceModel::new();
99                composite.add(Box::new(TwoBodyGravity { mu: mu_km3_s2 }));
100                composite.add(Box::new(J2Gravity {
101                    mu: mu_km3_s2,
102                    re: re_km,
103                    j2,
104                }));
105                Box::new(composite)
106            }
107        }
108    }
109}
110
111/// A propagatable object built from a raw initial state.
112///
113/// Construct it with [`StatePropagator::new`] (or by filling the public fields),
114/// then call [`StatePropagator::propagate_to`] for a single end epoch or
115/// [`StatePropagator::ephemeris`] to sample the trajectory at a sequence of
116/// epochs.
117pub struct StatePropagator {
118    /// Initial ECI Cartesian state (its `epoch_tdb_seconds` is the start epoch).
119    pub initial: CartesianState,
120    /// Force model used to compute the acceleration.
121    pub force_model: ForceModelKind,
122    /// Integrator that advances the state.
123    pub integrator: IntegratorKind,
124    /// Step-size / tolerance controls passed to the integrator.
125    pub options: IntegratorOptions,
126}
127
128impl StatePropagator {
129    /// Build a propagator from a raw epoch (TDB seconds), ECI position (km), and
130    /// ECI velocity (km/s), with the given force model and integrator and the
131    /// default [`IntegratorOptions`].
132    pub fn new(
133        epoch_tdb_seconds: f64,
134        position_km: [f64; 3],
135        velocity_km_s: [f64; 3],
136        force_model: ForceModelKind,
137        integrator: IntegratorKind,
138    ) -> Self {
139        Self {
140            initial: CartesianState::new(epoch_tdb_seconds, position_km, velocity_km_s),
141            force_model,
142            integrator,
143            options: IntegratorOptions::default(),
144        }
145    }
146
147    /// Replace the integrator options (builder-style).
148    pub fn with_options(mut self, options: IntegratorOptions) -> Self {
149        self.options = options;
150        self
151    }
152
153    /// Propagate from the initial epoch to `t_end_tdb_seconds` (an absolute TDB
154    /// epoch), returning the underlying integrator's full
155    /// [`PropagationResult`]. Bit-for-bit identical to building the force model,
156    /// [`OrbitalDynamics`], integrator, and options by hand.
157    pub fn propagate_to(
158        &self,
159        t_end_tdb_seconds: f64,
160    ) -> Result<PropagationResult, PropagationError> {
161        let force = self.force_model.build();
162        let dynamics = OrbitalDynamics {
163            force_model: force.as_ref(),
164        };
165        let ctx = PropagationContext::default();
166        self.run(self.initial, t_end_tdb_seconds, &dynamics, &ctx)
167    }
168
169    /// Propagate the initial state and a 6x6 state covariance over a relative
170    /// span in seconds.
171    ///
172    /// The returned covariance is `P_f = Phi * P_0 * Phi^T`, where `Phi` is the
173    /// finite-difference STM produced by [`Self::state_transition_matrix_for_span`].
174    pub fn propagate_state_with_covariance(
175        &self,
176        covariance0: Covariance6,
177        span_seconds: f64,
178    ) -> Result<(CartesianState, Covariance6), PropagationError> {
179        validate_initial_state(self.initial)?;
180        crate::validate::finite(span_seconds, "span_seconds").map_err(map_field_error)?;
181        let t_end_tdb_seconds = self.initial.epoch_tdb_seconds + span_seconds;
182        crate::validate::finite(t_end_tdb_seconds, "t_end_tdb_seconds").map_err(map_field_error)?;
183
184        if span_seconds == 0.0 {
185            return Ok((self.initial, covariance0));
186        }
187
188        let final_state = self.propagate_to(t_end_tdb_seconds)?.final_state;
189        let stm = self.state_transition_matrix_to(t_end_tdb_seconds)?;
190        let covariance = covariance0
191            .propagate_with_stm(&stm)
192            .map_err(map_covariance6_error)?;
193        Ok((final_state, covariance))
194    }
195
196    /// Build the finite-difference state-transition matrix over a relative
197    /// propagation span in seconds.
198    ///
199    /// Columns perturb the initial state in `[r_x, r_y, r_z, v_x, v_y, v_z]`
200    /// order. Each plus/minus leg is propagated through [`Self::propagate_to`]'s
201    /// same force-model and integrator assembly path.
202    pub fn state_transition_matrix_for_span(
203        &self,
204        span_seconds: f64,
205    ) -> Result<StateTransitionMatrix, PropagationError> {
206        crate::validate::finite(span_seconds, "span_seconds").map_err(map_field_error)?;
207        self.state_transition_matrix_to(self.initial.epoch_tdb_seconds + span_seconds)
208    }
209
210    /// Build the finite-difference state-transition matrix to an absolute TDB
211    /// epoch in seconds.
212    ///
213    /// The zero-span STM is exactly identity and does not call the propagator.
214    /// Non-zero spans propagate twelve perturbed initial states with central
215    /// differences and the existing numerical propagator.
216    pub fn state_transition_matrix_to(
217        &self,
218        t_end_tdb_seconds: f64,
219    ) -> Result<StateTransitionMatrix, PropagationError> {
220        crate::validate::finite(t_end_tdb_seconds, "t_end_tdb_seconds").map_err(map_field_error)?;
221        if t_end_tdb_seconds == self.initial.epoch_tdb_seconds {
222            return Ok(identity_stm());
223        }
224
225        let force = self.force_model.build();
226        let dynamics = OrbitalDynamics {
227            force_model: force.as_ref(),
228        };
229        let ctx = PropagationContext::default();
230        let mut stm = [[0.0_f64; 6]; 6];
231        let initial_vector = state_vector(&self.initial);
232
233        for (column, &component) in initial_vector.iter().enumerate() {
234            let delta = finite_difference_step(component);
235            let plus = perturb_state(self.initial, column, delta);
236            let minus = perturb_state(self.initial, column, -delta);
237
238            let plus_final = self
239                .run(plus, t_end_tdb_seconds, &dynamics, &ctx)?
240                .final_state;
241            let minus_final = self
242                .run(minus, t_end_tdb_seconds, &dynamics, &ctx)?
243                .final_state;
244            let plus_vector = state_vector(&plus_final);
245            let minus_vector = state_vector(&minus_final);
246            let denom = 2.0 * delta;
247
248            for (row, stm_row) in stm.iter_mut().enumerate() {
249                stm_row[column] = (plus_vector[row] - minus_vector[row]) / denom;
250            }
251        }
252
253        validate_stm(&stm)?;
254        Ok(stm)
255    }
256
257    /// Sample the trajectory at a sequence of absolute TDB epochs (seconds),
258    /// returning the Cartesian state at each. The epochs must be monotonic in
259    /// the propagation direction; the satellite is stepped from one requested
260    /// epoch to the next (sequential segments), so the cost is linear in the
261    /// number of epochs. An epoch equal to the current epoch returns the current
262    /// state without re-integrating.
263    ///
264    /// The force model is built once and reused across every segment.
265    pub fn ephemeris(
266        &self,
267        epochs_tdb_seconds: &[f64],
268    ) -> Result<Vec<CartesianState>, PropagationError> {
269        validate_initial_state(self.initial)?;
270        validate_epoch_finite(self.initial.epoch_tdb_seconds, "initial.epoch_tdb_seconds")?;
271        validate_ephemeris_epochs(epochs_tdb_seconds)?;
272
273        let force = self.force_model.build();
274        let dynamics = OrbitalDynamics {
275            force_model: force.as_ref(),
276        };
277        let ctx = PropagationContext::default();
278
279        let mut states = Vec::with_capacity(epochs_tdb_seconds.len());
280        let mut current = self.initial;
281        for &t in epochs_tdb_seconds {
282            if t != current.epoch_tdb_seconds {
283                current = self.run(current, t, &dynamics, &ctx)?.final_state;
284            }
285            states.push(current);
286        }
287        Ok(states)
288    }
289
290    /// Dispatch to the selected integrator. Kept private so the public surface
291    /// stays `propagate_to` / `ephemeris`.
292    fn run(
293        &self,
294        initial: CartesianState,
295        t_end_tdb_seconds: f64,
296        dynamics: &OrbitalDynamics,
297        ctx: &PropagationContext,
298    ) -> Result<PropagationResult, PropagationError> {
299        validate_epoch_finite(initial.epoch_tdb_seconds, "initial.epoch_tdb_seconds")?;
300        validate_epoch_finite(t_end_tdb_seconds, "t_end_tdb_seconds")?;
301        validate_initial_state(initial)?;
302
303        match self.integrator {
304            IntegratorKind::Rk4 => {
305                RK4.propagate(initial, t_end_tdb_seconds, dynamics, ctx, &self.options)
306            }
307            IntegratorKind::Dp54 => {
308                DP54.propagate(initial, t_end_tdb_seconds, dynamics, ctx, &self.options)
309            }
310        }
311    }
312}
313
314fn map_field_error(error: crate::validate::FieldError) -> PropagationError {
315    PropagationError::InvalidInput(format!("{} {}", error.field(), error.reason()))
316}
317
318fn map_covariance6_error(error: Covariance6Error) -> PropagationError {
319    let reason = match error {
320        Covariance6Error::NonFinite => "not finite",
321        Covariance6Error::Asymmetric => "not symmetric",
322        Covariance6Error::NotPositiveSemidefinite => "not positive semidefinite",
323    };
324    PropagationError::InvalidInput(format!("covariance {reason}"))
325}
326
327fn identity_stm() -> StateTransitionMatrix {
328    let mut matrix = [[0.0_f64; 6]; 6];
329    for (idx, row) in matrix.iter_mut().enumerate() {
330        row[idx] = 1.0;
331    }
332    matrix
333}
334
335fn finite_difference_step(component: f64) -> f64 {
336    (component.abs().max(1.0) * STM_RELATIVE_PERTURBATION).max(STM_MIN_PERTURBATION)
337}
338
339fn perturb_state(state: CartesianState, component: usize, delta: f64) -> CartesianState {
340    let mut perturbed = state;
341    match component {
342        0 => perturbed.position_km.x += delta,
343        1 => perturbed.position_km.y += delta,
344        2 => perturbed.position_km.z += delta,
345        3 => perturbed.velocity_km_s.x += delta,
346        4 => perturbed.velocity_km_s.y += delta,
347        5 => perturbed.velocity_km_s.z += delta,
348        _ => unreachable!("state-transition matrix component index is in 0..6"),
349    }
350    perturbed
351}
352
353fn state_vector(state: &CartesianState) -> [f64; 6] {
354    [
355        state.position_km.x,
356        state.position_km.y,
357        state.position_km.z,
358        state.velocity_km_s.x,
359        state.velocity_km_s.y,
360        state.velocity_km_s.z,
361    ]
362}
363
364fn validate_ephemeris_epochs(epochs_tdb_seconds: &[f64]) -> Result<(), PropagationError> {
365    for &epoch_tdb_seconds in epochs_tdb_seconds {
366        validate_epoch_finite(epoch_tdb_seconds, "epochs_tdb_seconds")?;
367    }
368    Ok(())
369}
370
371fn validate_initial_state(initial: CartesianState) -> Result<(), PropagationError> {
372    validate_state_vector(initial.position_array(), "initial.position_km")?;
373    validate_state_vector(initial.velocity_array(), "initial.velocity_km_s")
374}
375
376fn validate_stm(stm: &StateTransitionMatrix) -> Result<(), PropagationError> {
377    for row in stm {
378        crate::validate::finite_slice(row, "state_transition_matrix").map_err(|error| {
379            PropagationError::NumericalFailure(format!("{} {}", error.field(), error.reason()))
380        })?;
381    }
382    Ok(())
383}
384
385fn validate_state_vector(values: [f64; 3], field: &'static str) -> Result<(), PropagationError> {
386    crate::validate::finite_slice(&values, field).map_err(|error| {
387        PropagationError::InvalidInput(format!("{} {}", error.field(), error.reason()))
388    })
389}
390
391fn validate_epoch_finite(value: f64, field: &'static str) -> Result<(), PropagationError> {
392    crate::validate::finite(value, field)
393        .map(|_| ())
394        .map_err(|error| {
395            PropagationError::InvalidInput(format!("{} {}", error.field(), error.reason()))
396        })
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use nalgebra::Vector3;
403    use std::sync::atomic::{AtomicUsize, Ordering};
404
405    struct CountingForce<'a> {
406        calls: &'a AtomicUsize,
407    }
408
409    impl ForceModel for CountingForce<'_> {
410        fn acceleration(
411            &self,
412            _state: &CartesianState,
413            _ctx: &PropagationContext,
414        ) -> Result<Vector3<f64>, PropagationError> {
415            self.calls.fetch_add(1, Ordering::SeqCst);
416            Ok(Vector3::zeros())
417        }
418    }
419
420    fn circular_state() -> ([f64; 3], [f64; 3], f64) {
421        let r: f64 = 7000.0;
422        let v = (MU_EARTH / r).sqrt();
423        ([r, 0.0, 0.0], [0.0, v, 0.0], r)
424    }
425
426    fn rk4_test_options() -> IntegratorOptions {
427        IntegratorOptions {
428            initial_step: 1.0,
429            ..IntegratorOptions::default()
430        }
431    }
432
433    fn rk4_two_body_propagator(initial: CartesianState) -> StatePropagator {
434        StatePropagator {
435            initial,
436            force_model: ForceModelKind::two_body(),
437            integrator: IntegratorKind::Rk4,
438            options: rk4_test_options(),
439        }
440    }
441
442    fn circular_rk4_two_body_propagator() -> StatePropagator {
443        let (pos, vel, _) = circular_state();
444        rk4_two_body_propagator(CartesianState::new(0.0, pos, vel))
445    }
446
447    #[test]
448    fn entry_point_matches_integrator_called_directly_bit_for_bit() {
449        let (pos, vel, _) = circular_state();
450        let opts = IntegratorOptions {
451            abs_tol: 1e-12,
452            rel_tol: 1e-12,
453            ..IntegratorOptions::default()
454        };
455
456        // Entry point.
457        let propagator = StatePropagator::new(
458            0.0,
459            pos,
460            vel,
461            ForceModelKind::two_body(),
462            IntegratorKind::Dp54,
463        )
464        .with_options(IntegratorOptions {
465            abs_tol: 1e-12,
466            rel_tol: 1e-12,
467            ..IntegratorOptions::default()
468        });
469        let via_entry = propagator.propagate_to(3600.0).unwrap().final_state;
470
471        // Integrator called directly, the way the entry point assembles it.
472        let force = TwoBodyGravity::default();
473        let dynamics = OrbitalDynamics {
474            force_model: &force,
475        };
476        let ctx = PropagationContext::default();
477        let via_direct = DP54
478            .propagate(
479                CartesianState::new(0.0, pos, vel),
480                3600.0,
481                &dynamics,
482                &ctx,
483                &opts,
484            )
485            .unwrap()
486            .final_state;
487
488        assert_eq!(
489            via_entry.position_km.x.to_bits(),
490            via_direct.position_km.x.to_bits()
491        );
492        assert_eq!(
493            via_entry.position_km.y.to_bits(),
494            via_direct.position_km.y.to_bits()
495        );
496        assert_eq!(
497            via_entry.position_km.z.to_bits(),
498            via_direct.position_km.z.to_bits()
499        );
500        assert_eq!(
501            via_entry.velocity_km_s.x.to_bits(),
502            via_direct.velocity_km_s.x.to_bits()
503        );
504        assert_eq!(
505            via_entry.velocity_km_s.y.to_bits(),
506            via_direct.velocity_km_s.y.to_bits()
507        );
508        assert_eq!(
509            via_entry.velocity_km_s.z.to_bits(),
510            via_direct.velocity_km_s.z.to_bits()
511        );
512    }
513
514    #[test]
515    fn ephemeris_last_sample_matches_single_shot_propagation() {
516        // ephemeris() segments from epoch->t1->t2; the final sample must equal a
517        // single propagate_to() over the same span only when the intermediate
518        // node lands on the same point. We assert the cheaper invariant: the
519        // first sample is the initial state, and the sample count matches.
520        let (pos, vel, _) = circular_state();
521        let propagator = StatePropagator::new(
522            100.0,
523            pos,
524            vel,
525            ForceModelKind::two_body(),
526            IntegratorKind::Dp54,
527        );
528        let epochs = [100.0, 700.0, 1300.0];
529        let states = propagator.ephemeris(&epochs).unwrap();
530
531        assert_eq!(states.len(), 3);
532        // First sample is the initial state, untouched.
533        assert_eq!(states[0].position_km.x.to_bits(), pos[0].to_bits());
534        assert_eq!(states[0].velocity_km_s.y.to_bits(), vel[1].to_bits());
535        for (state, &t) in states.iter().zip(epochs.iter()) {
536            assert_eq!(state.epoch_tdb_seconds, t);
537        }
538    }
539
540    #[test]
541    fn state_transition_matrix_zero_span_is_identity() {
542        let propagator = circular_rk4_two_body_propagator();
543        let stm = propagator.state_transition_matrix_for_span(0.0).unwrap();
544
545        for (i, row) in stm.iter().enumerate() {
546            for (j, &value) in row.iter().enumerate() {
547                let expected = if i == j { 1.0_f64 } else { 0.0_f64 };
548                assert_eq!(value.to_bits(), expected.to_bits());
549            }
550        }
551    }
552
553    #[test]
554    fn state_transition_matrix_has_short_span_two_body_structure() {
555        let propagator = circular_rk4_two_body_propagator();
556        let span = 10.0;
557        let stm = propagator.state_transition_matrix_for_span(span).unwrap();
558
559        for axis in 0..3 {
560            assert_close(stm[axis][axis], 1.0, 2.0e-4);
561            assert_close(stm[axis][axis + 3], span, 2.0e-3);
562            assert_close(stm[axis + 3][axis + 3], 1.0, 2.0e-4);
563        }
564    }
565
566    #[test]
567    fn state_transition_matrix_matches_independent_perturbation() {
568        let propagator = circular_rk4_two_body_propagator();
569        let span = 60.0;
570        let stm = propagator.state_transition_matrix_for_span(span).unwrap();
571        let base_final = propagator.propagate_to(span).unwrap().final_state;
572
573        let delta = [2.0e-4, -1.5e-4, 1.0e-4, 2.0e-7, -1.0e-7, 1.5e-7];
574        let mut perturbed_initial = propagator.initial;
575        for (component, &value) in delta.iter().enumerate() {
576            perturbed_initial = perturb_state(perturbed_initial, component, value);
577        }
578        let perturbed_final = rk4_two_body_propagator(perturbed_initial)
579            .propagate_to(span)
580            .unwrap()
581            .final_state;
582
583        let base_vector = state_vector(&base_final);
584        let perturbed_vector = state_vector(&perturbed_final);
585        let predicted = mat6_vec6(&stm, &delta);
586
587        for row in 0..6 {
588            let observed = perturbed_vector[row] - base_vector[row];
589            let tolerance = if row < 3 { 2.0e-8 } else { 2.0e-10 };
590            assert_close(predicted[row], observed, tolerance);
591        }
592    }
593
594    #[test]
595    fn state_transition_matrix_is_symplectic_for_short_two_body_span() {
596        let propagator = circular_rk4_two_body_propagator();
597        let stm = propagator.state_transition_matrix_for_span(30.0).unwrap();
598
599        assert!(max_symplectic_residual(&stm) < 1.0e-5);
600    }
601
602    #[test]
603    fn propagate_state_with_covariance_zero_span_returns_initial_inputs() {
604        let propagator = circular_rk4_two_body_propagator();
605        let covariance = test_covariance();
606
607        let (state, propagated_covariance) = propagator
608            .propagate_state_with_covariance(covariance, 0.0)
609            .unwrap();
610
611        assert_eq!(state, propagator.initial);
612        assert_eq!(propagated_covariance, covariance);
613    }
614
615    #[test]
616    fn propagate_state_with_covariance_keeps_covariance_psd_and_coupled() {
617        let propagator = circular_rk4_two_body_propagator();
618        let covariance0 = test_covariance();
619        let span = 120.0;
620
621        let (state, covariance_f) = propagator
622            .propagate_state_with_covariance(covariance0, span)
623            .unwrap();
624
625        assert_eq!(state.epoch_tdb_seconds, span);
626        assert!(covariance_f.is_symmetric());
627        assert!(covariance_f.is_positive_semidefinite());
628
629        let p0 = covariance0.as_matrix();
630        let pf = covariance_f.as_matrix();
631        let initial_position_trace = p0[0][0] + p0[1][1] + p0[2][2];
632        let final_position_trace = pf[0][0] + pf[1][1] + pf[2][2];
633        assert!(final_position_trace > initial_position_trace);
634
635        let max_position_velocity_coupling = (0..3)
636            .flat_map(|i| (3..6).map(move |j| pf[i][j].abs()))
637            .fold(0.0_f64, f64::max);
638        assert!(max_position_velocity_coupling > 1.0e-8);
639    }
640
641    #[test]
642    fn propagator_rejects_zero_initial_step() {
643        let (pos, vel, _) = circular_state();
644        let propagator = StatePropagator::new(
645            0.0,
646            pos,
647            vel,
648            ForceModelKind::two_body(),
649            IntegratorKind::Rk4,
650        )
651        .with_options(IntegratorOptions {
652            initial_step: 0.0,
653            ..IntegratorOptions::default()
654        });
655
656        assert_invalid_propagation_field(
657            propagator.propagate_to(60.0).unwrap_err(),
658            "initial_step",
659        );
660    }
661
662    #[test]
663    fn rejects_non_finite_epochs_before_running_integrator() {
664        let (pos, vel, _) = circular_state();
665        let calls = AtomicUsize::new(0);
666        let force = CountingForce { calls: &calls };
667        let dynamics = OrbitalDynamics {
668            force_model: &force,
669        };
670        let ctx = PropagationContext::default();
671        let propagator = StatePropagator::new(
672            0.0,
673            pos,
674            vel,
675            ForceModelKind::two_body(),
676            IntegratorKind::Dp54,
677        );
678
679        let cases = [
680            (
681                CartesianState::new(f64::NAN, pos, vel),
682                60.0,
683                "initial.epoch_tdb_seconds",
684            ),
685            (
686                CartesianState::new(0.0, pos, vel),
687                f64::INFINITY,
688                "t_end_tdb_seconds",
689            ),
690        ];
691
692        for (initial, t_end, field) in cases {
693            calls.store(0, Ordering::SeqCst);
694            let err = propagator
695                .run(initial, t_end, &dynamics, &ctx)
696                .expect_err("non-finite epoch should be rejected");
697
698            assert_non_finite_epoch_error(err, field);
699            assert_eq!(
700                calls.load(Ordering::SeqCst),
701                0,
702                "non-finite {field} must not enter the integrator"
703            );
704        }
705    }
706
707    #[test]
708    fn ephemeris_rejects_non_finite_query_epochs_before_first_segment() {
709        let (pos, vel, _) = circular_state();
710        let propagator = StatePropagator::new(
711            0.0,
712            pos,
713            vel,
714            ForceModelKind::two_body(),
715            IntegratorKind::Rk4,
716        )
717        .with_options(IntegratorOptions {
718            initial_step: 0.0,
719            ..IntegratorOptions::default()
720        });
721
722        let err = propagator
723            .ephemeris(&[60.0, f64::NAN])
724            .expect_err("non-finite query epoch should be rejected");
725
726        assert_non_finite_epoch_error(err, "epochs_tdb_seconds");
727    }
728
729    #[test]
730    fn rejects_non_finite_initial_state_vectors_before_running_integrator() {
731        let (pos, vel, _) = circular_state();
732        let calls = AtomicUsize::new(0);
733        let force = CountingForce { calls: &calls };
734        let dynamics = OrbitalDynamics {
735            force_model: &force,
736        };
737        let ctx = PropagationContext::default();
738        let propagator = StatePropagator::new(
739            0.0,
740            pos,
741            vel,
742            ForceModelKind::two_body(),
743            IntegratorKind::Rk4,
744        );
745
746        let cases = [
747            (
748                CartesianState::new(0.0, [f64::NAN, pos[1], pos[2]], vel),
749                "initial.position_km",
750            ),
751            (
752                CartesianState::new(0.0, [pos[0], f64::INFINITY, pos[2]], vel),
753                "initial.position_km",
754            ),
755            (
756                CartesianState::new(0.0, pos, [vel[0], f64::NAN, vel[2]]),
757                "initial.velocity_km_s",
758            ),
759            (
760                CartesianState::new(0.0, pos, [vel[0], vel[1], f64::NEG_INFINITY]),
761                "initial.velocity_km_s",
762            ),
763        ];
764
765        for (initial, field) in cases {
766            calls.store(0, Ordering::SeqCst);
767            let err = propagator
768                .run(initial, 60.0, &dynamics, &ctx)
769                .expect_err("non-finite state vector should be rejected");
770
771            assert_non_finite_state_error(err, field);
772            assert_eq!(
773                calls.load(Ordering::SeqCst),
774                0,
775                "non-finite {field} must not enter the integrator"
776            );
777        }
778    }
779
780    #[test]
781    fn propagate_to_rejects_non_finite_integrator_outputs() {
782        let (pos, vel, _) = circular_state();
783        let propagator = StatePropagator::new(
784            0.0,
785            pos,
786            vel,
787            ForceModelKind::TwoBody {
788                mu_km3_s2: f64::INFINITY,
789            },
790            IntegratorKind::Rk4,
791        )
792        .with_options(rk4_test_options());
793
794        let err = propagator
795            .propagate_to(1.0)
796            .expect_err("non-finite integration result should be rejected");
797
798        assert_output_non_finite_error(err, "final_state");
799    }
800
801    #[test]
802    fn state_transition_matrix_rejects_non_finite_propagation_legs() {
803        let (pos, vel, _) = circular_state();
804        let propagator = StatePropagator::new(
805            0.0,
806            pos,
807            vel,
808            ForceModelKind::TwoBody {
809                mu_km3_s2: f64::INFINITY,
810            },
811            IntegratorKind::Rk4,
812        )
813        .with_options(rk4_test_options());
814
815        let err = propagator
816            .state_transition_matrix_for_span(1.0)
817            .expect_err("non-finite STM propagation leg should be rejected");
818
819        assert_output_non_finite_error(err, "final_state");
820    }
821
822    fn assert_invalid_propagation_field(error: PropagationError, expected: &str) {
823        match error {
824            PropagationError::InvalidInput(message) => {
825                assert!(message.contains(expected), "{message}");
826                assert!(message.contains("not positive"), "{message}");
827            }
828            other => panic!("expected invalid propagation input for {expected}, got {other:?}"),
829        }
830    }
831
832    fn assert_close(actual: f64, expected: f64, tolerance: f64) {
833        assert!(
834            (actual - expected).abs() <= tolerance,
835            "{actual} differs from {expected} by more than {tolerance}"
836        );
837    }
838
839    fn test_covariance() -> Covariance6 {
840        Covariance6::from_diagonal([1.0e-6, 2.0e-6, 3.0e-6, 1.0e-8, 2.0e-8, 3.0e-8]).unwrap()
841    }
842
843    fn mat6_vec6(matrix: &StateTransitionMatrix, vector: &[f64; 6]) -> [f64; 6] {
844        let mut out = [0.0_f64; 6];
845        for (i, row) in matrix.iter().enumerate() {
846            for (j, &value) in row.iter().enumerate() {
847                out[i] += value * vector[j];
848            }
849        }
850        out
851    }
852
853    fn max_symplectic_residual(phi: &StateTransitionMatrix) -> f64 {
854        let mut max = 0.0_f64;
855        for i in 0..6 {
856            for j in 0..6 {
857                let mut value = 0.0_f64;
858                for k in 0..6 {
859                    for l in 0..6 {
860                        value += phi[k][i] * canonical_j(k, l) * phi[l][j];
861                    }
862                }
863                let residual = (value - canonical_j(i, j)).abs();
864                max = max.max(residual);
865            }
866        }
867        max
868    }
869
870    fn canonical_j(row: usize, col: usize) -> f64 {
871        if row < 3 && col == row + 3 {
872            1.0
873        } else if row >= 3 && col + 3 == row {
874            -1.0
875        } else {
876            0.0
877        }
878    }
879
880    fn assert_non_finite_epoch_error(error: PropagationError, expected: &str) {
881        match error {
882            PropagationError::InvalidInput(message) => {
883                assert!(message.contains(expected), "{message}");
884                assert!(message.contains("not finite"), "{message}");
885            }
886            other => panic!("expected invalid epoch input for {expected}, got {other:?}"),
887        }
888    }
889
890    fn assert_non_finite_state_error(error: PropagationError, expected: &str) {
891        match error {
892            PropagationError::InvalidInput(message) => {
893                assert!(message.contains(expected), "{message}");
894                assert!(message.contains("not finite"), "{message}");
895            }
896            other => panic!("expected invalid state input for {expected}, got {other:?}"),
897        }
898    }
899
900    fn assert_output_non_finite_error(error: PropagationError, expected: &str) {
901        match error {
902            PropagationError::InvalidInput(message)
903            | PropagationError::NumericalFailure(message) => {
904                assert!(message.contains(expected), "{message}");
905                assert!(message.contains("not finite"), "{message}");
906            }
907            other => panic!("expected non-finite output for {expected}, got {other:?}"),
908        }
909    }
910}