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, and additive perturbation sets). This is a thin
7//! orchestration layer over
8//! [`crate::astro::integrators`] and [`crate::astro::forces`]: it constructs the
9//! force model, wraps it in [`OrbitalDynamics`], and drives the chosen
10//! integrator. It adds no integration math of its own, so a single-shot
11//! [`StatePropagator::propagate_to`] is bit-for-bit identical to assembling the
12//! force model, dynamics, integrator, and options by hand. State-transition
13//! matrices are formed by finite-differencing this same entry point, so they
14//! reflect the selected force model and integrator without duplicating dynamics.
15
16use crate::astro::constants::{J2_EARTH, MU_EARTH, RE_EARTH};
17use crate::astro::covariance::{Covariance6, Covariance6Error};
18use crate::astro::error::PropagationError;
19use crate::astro::forces::{
20    CompositeForceModel, DragParameters, EarthRadiationPressure, ForceModel, J2Gravity,
21    SchwarzschildRelativity, SolarRadiationPressure, SourcedDragForce, SpaceWeatherSource,
22    SphericalHarmonicGravityConfig, ThirdBodyGravity, TwoBodyGravity, ZonalGravity,
23};
24use crate::astro::integrators::{Integrator, DP54, RK4};
25use crate::astro::propagator::api::{IntegratorOptions, PropagationContext};
26use crate::astro::propagator::covariance::{
27    CovarianceFrame, CovariancePropagationOptions, LabeledCovariance6,
28};
29use crate::astro::propagator::dynamics::OrbitalDynamics;
30use crate::astro::propagator::result::PropagationResult;
31use crate::astro::state::CartesianState;
32
33/// Row-major 6x6 state-transition matrix for `[r_x, r_y, r_z, v_x, v_y, v_z]`.
34///
35/// Entry `[i][j]` is the finite-difference derivative of final-state component
36/// `i` with respect to initial-state component `j`.
37pub type StateTransitionMatrix = [[f64; 6]; 6];
38
39const STM_RELATIVE_PERTURBATION: f64 = 1.0e-6;
40const STM_MIN_PERTURBATION: f64 = 1.0e-6;
41
42/// Which numerical integrator drives the propagation.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum IntegratorKind {
45    /// Fixed-step classical Runge-Kutta 4 (step taken from
46    /// [`IntegratorOptions::initial_step`]; tolerances are ignored).
47    Rk4,
48    /// Adaptive Dormand-Prince 5(4) with PI step control (honors the absolute
49    /// and relative tolerances).
50    Dp54,
51}
52
53/// Which force model supplies the acceleration during propagation.
54///
55/// The legacy variants carry their own physical parameters so a caller can
56/// propagate a non-Earth central body by supplying a different gravitational
57/// parameter. The [`Self::two_body`] / [`Self::two_body_j2`] constructors fill
58/// in the canonical Earth values from [`crate::astro::constants`]. The
59/// [`Self::Composite`] variant adds optional force components without changing
60/// those legacy branches.
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub enum ForceModelKind {
63    /// Pure two-body (Keplerian) gravity.
64    TwoBody {
65        /// Gravitational parameter, km^3/s^2.
66        mu_km3_s2: f64,
67    },
68    /// Two-body gravity plus the J2 oblateness perturbation.
69    TwoBodyJ2 {
70        /// Gravitational parameter, km^3/s^2.
71        mu_km3_s2: f64,
72        /// Reference equatorial radius, km.
73        re_km: f64,
74        /// J2 zonal harmonic coefficient (dimensionless).
75        j2: f64,
76    },
77    /// Additive composition of central gravity and optional perturbations.
78    Composite {
79        /// Force components to add.
80        components: ForceModelComponents,
81    },
82}
83
84/// Additive force components for [`ForceModelKind::Composite`].
85#[derive(Debug, Clone, Copy, PartialEq)]
86pub struct ForceModelComponents {
87    /// Optional central two-body gravitational parameter, km^3/s^2.
88    pub two_body_mu_km3_s2: Option<f64>,
89    /// Optional zonal gravity perturbation.
90    pub zonal: Option<ZonalGravity>,
91    /// Optional embedded EGM96 spherical-harmonic perturbation.
92    pub spherical_harmonic: Option<SphericalHarmonicGravityConfig>,
93    /// Optional Sun and Moon third-body perturbation.
94    pub third_body: Option<ThirdBodyGravity>,
95    /// Optional cannonball solar radiation pressure perturbation.
96    pub solar_radiation_pressure: Option<SolarRadiationPressure>,
97    /// Optional cannonball Earth albedo and infrared radiation pressure perturbation.
98    pub earth_radiation_pressure: Option<EarthRadiationPressure>,
99    /// Optional geocentric Schwarzschild relativistic correction.
100    pub relativity: Option<SchwarzschildRelativity>,
101}
102
103impl Default for ForceModelComponents {
104    fn default() -> Self {
105        Self::EMPTY
106    }
107}
108
109impl ForceModelComponents {
110    /// No force components.
111    pub const EMPTY: Self = Self {
112        two_body_mu_km3_s2: None,
113        zonal: None,
114        spherical_harmonic: None,
115        third_body: None,
116        solar_radiation_pressure: None,
117        earth_radiation_pressure: None,
118        relativity: None,
119    };
120
121    /// Canonical Earth two-body central gravity.
122    pub fn earth_two_body() -> Self {
123        Self {
124            two_body_mu_km3_s2: Some(MU_EARTH),
125            ..Self::EMPTY
126        }
127    }
128
129    /// Canonical Earth Phase A force set, with optional spacecraft SRP parameters.
130    pub fn earth_phase_a(solar_radiation_pressure: Option<SolarRadiationPressure>) -> Self {
131        Self {
132            two_body_mu_km3_s2: Some(MU_EARTH),
133            zonal: Some(ZonalGravity::earth_j2_through_j6()),
134            spherical_harmonic: None,
135            third_body: Some(ThirdBodyGravity::default()),
136            solar_radiation_pressure,
137            earth_radiation_pressure: None,
138            relativity: Some(SchwarzschildRelativity::default()),
139        }
140    }
141
142    /// Canonical Earth Phase B force set with embedded EGM96 harmonics.
143    pub fn earth_phase_b(
144        max_degree: u16,
145        max_order: u16,
146        solar_radiation_pressure: Option<SolarRadiationPressure>,
147    ) -> Result<Self, PropagationError> {
148        Ok(Self {
149            two_body_mu_km3_s2: Some(MU_EARTH),
150            zonal: None,
151            spherical_harmonic: Some(SphericalHarmonicGravityConfig::earth(
152                max_degree, max_order,
153            )?),
154            third_body: Some(ThirdBodyGravity::default()),
155            solar_radiation_pressure,
156            earth_radiation_pressure: None,
157            relativity: Some(SchwarzschildRelativity::default()),
158        })
159    }
160
161    /// Set or replace central two-body gravity.
162    pub fn with_two_body_mu(mut self, mu_km3_s2: f64) -> Self {
163        self.two_body_mu_km3_s2 = Some(mu_km3_s2);
164        self
165    }
166
167    /// Set or replace zonal gravity.
168    pub fn with_zonal(mut self, zonal: ZonalGravity) -> Self {
169        self.zonal = Some(zonal);
170        self
171    }
172
173    /// Set or replace embedded EGM96 spherical-harmonic gravity.
174    pub fn with_spherical_harmonic(mut self, gravity: SphericalHarmonicGravityConfig) -> Self {
175        self.spherical_harmonic = Some(gravity);
176        self
177    }
178
179    /// Set or replace third-body gravity.
180    pub fn with_third_body(mut self, third_body: ThirdBodyGravity) -> Self {
181        self.third_body = Some(third_body);
182        self
183    }
184
185    /// Set or replace solar radiation pressure.
186    pub fn with_solar_radiation_pressure(mut self, srp: SolarRadiationPressure) -> Self {
187        self.solar_radiation_pressure = Some(srp);
188        self
189    }
190
191    /// Set or replace Earth albedo and infrared radiation pressure.
192    pub fn with_earth_radiation_pressure(mut self, pressure: EarthRadiationPressure) -> Self {
193        self.earth_radiation_pressure = Some(pressure);
194        self
195    }
196
197    /// Set or replace relativity.
198    pub fn with_relativity(mut self, relativity: SchwarzschildRelativity) -> Self {
199        self.relativity = Some(relativity);
200        self
201    }
202}
203
204impl ForceModelKind {
205    /// Earth two-body gravity using the canonical [`MU_EARTH`].
206    pub fn two_body() -> Self {
207        Self::TwoBody {
208            mu_km3_s2: MU_EARTH,
209        }
210    }
211
212    /// Earth two-body + J2 using the canonical [`MU_EARTH`], [`RE_EARTH`], and
213    /// [`J2_EARTH`].
214    pub fn two_body_j2() -> Self {
215        Self::TwoBodyJ2 {
216            mu_km3_s2: MU_EARTH,
217            re_km: RE_EARTH,
218            j2: J2_EARTH,
219        }
220    }
221
222    /// Build an additive force model from components.
223    pub fn composite(components: ForceModelComponents) -> Self {
224        Self::Composite { components }
225    }
226
227    /// Earth two-body plus Phase A perturbations.
228    pub fn earth_phase_a(solar_radiation_pressure: Option<SolarRadiationPressure>) -> Self {
229        Self::Composite {
230            components: ForceModelComponents::earth_phase_a(solar_radiation_pressure),
231        }
232    }
233
234    /// Earth two-body plus Phase B perturbations.
235    pub fn earth_phase_b(
236        max_degree: u16,
237        max_order: u16,
238        solar_radiation_pressure: Option<SolarRadiationPressure>,
239    ) -> Result<Self, PropagationError> {
240        Ok(Self::Composite {
241            components: ForceModelComponents::earth_phase_b(
242                max_degree,
243                max_order,
244                solar_radiation_pressure,
245            )?,
246        })
247    }
248
249    /// Build the boxed [`ForceModel`] this variant describes. Reuses the force
250    /// model implementations; no acceleration math is duplicated here.
251    fn build(self) -> Result<Box<dyn ForceModel>, PropagationError> {
252        match self {
253            ForceModelKind::TwoBody { mu_km3_s2 } => Ok(Box::new(TwoBodyGravity { mu: mu_km3_s2 })),
254            ForceModelKind::TwoBodyJ2 {
255                mu_km3_s2,
256                re_km,
257                j2,
258            } => {
259                let mut composite = CompositeForceModel::new();
260                composite.add(Box::new(TwoBodyGravity { mu: mu_km3_s2 }));
261                composite.add(Box::new(J2Gravity {
262                    mu: mu_km3_s2,
263                    re: re_km,
264                    j2,
265                }));
266                Ok(Box::new(composite))
267            }
268            ForceModelKind::Composite { components } => {
269                if components.zonal.is_some() && components.spherical_harmonic.is_some() {
270                    return Err(PropagationError::InvalidInput(
271                        "zonal and spherical harmonic gravity cannot both be selected".to_string(),
272                    ));
273                }
274                let mut composite = CompositeForceModel::new();
275                if let Some(mu_km3_s2) = components.two_body_mu_km3_s2 {
276                    composite.add(Box::new(TwoBodyGravity { mu: mu_km3_s2 }));
277                }
278                if let Some(zonal) = components.zonal {
279                    composite.add(Box::new(zonal));
280                }
281                if let Some(spherical_harmonic) = components.spherical_harmonic {
282                    composite.add(Box::new(spherical_harmonic.build()?));
283                }
284                if let Some(third_body) = components.third_body {
285                    composite.add(Box::new(third_body));
286                }
287                if let Some(srp) = components.solar_radiation_pressure {
288                    composite.add(Box::new(srp));
289                }
290                if let Some(pressure) = components.earth_radiation_pressure {
291                    composite.add(Box::new(pressure));
292                }
293                if let Some(relativity) = components.relativity {
294                    composite.add(Box::new(relativity));
295                }
296                Ok(Box::new(composite))
297            }
298        }
299    }
300}
301
302/// A propagatable object built from a raw initial state.
303///
304/// Construct it with [`StatePropagator::new`] (or by filling the public fields),
305/// then call [`StatePropagator::propagate_to`] for a single end epoch or
306/// [`StatePropagator::ephemeris`] to sample the trajectory at a sequence of
307/// epochs.
308pub struct StatePropagator {
309    /// Initial ECI Cartesian state (its `epoch_tdb_seconds` is the start epoch).
310    pub initial: CartesianState,
311    /// Force model used to compute the acceleration.
312    pub force_model: ForceModelKind,
313    /// Integrator that advances the state.
314    pub integrator: IntegratorKind,
315    /// Step-size / tolerance controls passed to the integrator.
316    pub options: IntegratorOptions,
317    /// Optional atmospheric drag perturbation layered on the gravity model.
318    pub drag: Option<DragParameters>,
319    /// Optional per-epoch space-weather source for atmospheric drag.
320    pub space_weather: Option<SpaceWeatherSource>,
321}
322
323impl StatePropagator {
324    /// Build a propagator from a raw epoch (TDB seconds), ECI position (km), and
325    /// ECI velocity (km/s), with the given force model and integrator and the
326    /// default [`IntegratorOptions`].
327    pub fn new(
328        epoch_tdb_seconds: f64,
329        position_km: [f64; 3],
330        velocity_km_s: [f64; 3],
331        force_model: ForceModelKind,
332        integrator: IntegratorKind,
333    ) -> Self {
334        Self {
335            initial: CartesianState::new(epoch_tdb_seconds, position_km, velocity_km_s),
336            force_model,
337            integrator,
338            options: IntegratorOptions::default(),
339            drag: None,
340            space_weather: None,
341        }
342    }
343
344    /// Replace the integrator options (builder-style).
345    pub fn with_options(mut self, options: IntegratorOptions) -> Self {
346        self.options = options;
347        self
348    }
349
350    /// Enable atmospheric drag (builder-style).
351    pub fn with_drag(mut self, drag: DragParameters) -> Self {
352        self.drag = Some(drag);
353        self
354    }
355
356    /// Use a per-epoch space-weather source for the drag perturbation.
357    pub fn with_space_weather(mut self, source: SpaceWeatherSource) -> Self {
358        self.space_weather = Some(source);
359        self
360    }
361
362    /// Propagate from the initial epoch to `t_end_tdb_seconds` (an absolute TDB
363    /// epoch), returning the underlying integrator's full
364    /// [`PropagationResult`]. Bit-for-bit identical to building the force model,
365    /// [`OrbitalDynamics`], integrator, and options by hand.
366    pub fn propagate_to(
367        &self,
368        t_end_tdb_seconds: f64,
369    ) -> Result<PropagationResult, PropagationError> {
370        let ctx = PropagationContext::default();
371        self.propagate_to_with_context(t_end_tdb_seconds, &ctx)
372    }
373
374    /// Propagate to `t_end_tdb_seconds` with an explicit propagation context.
375    ///
376    /// This is the opt-in path for force models that need shared per-evaluation
377    /// services such as a body-fixed frame provider. Passing
378    /// [`PropagationContext::default`] is equivalent to [`Self::propagate_to`].
379    pub fn propagate_to_with_context(
380        &self,
381        t_end_tdb_seconds: f64,
382        ctx: &PropagationContext,
383    ) -> Result<PropagationResult, PropagationError> {
384        let force = self.build_force()?;
385        let dynamics = OrbitalDynamics {
386            force_model: force.as_ref(),
387        };
388        self.run(self.initial, t_end_tdb_seconds, &dynamics, ctx)
389    }
390
391    /// Propagate the initial state and a 6x6 state covariance over a relative
392    /// span in seconds.
393    ///
394    /// This is the single-segment, no-process-noise convenience wrapper over
395    /// [`Self::propagate_covariance`].
396    pub fn propagate_state_with_covariance(
397        &self,
398        covariance0: Covariance6,
399        span_seconds: f64,
400    ) -> Result<(CartesianState, Covariance6), PropagationError> {
401        validate_initial_state(self.initial)?;
402        crate::validate::finite(span_seconds, "span_seconds").map_err(map_field_error)?;
403        let t_end_tdb_seconds = self.initial.epoch_tdb_seconds + span_seconds;
404        crate::validate::finite(t_end_tdb_seconds, "t_end_tdb_seconds").map_err(map_field_error)?;
405
406        if span_seconds == 0.0 {
407            return Ok((self.initial, covariance0));
408        }
409
410        let ephemeris = self.propagate_covariance(
411            LabeledCovariance6 {
412                covariance: covariance0,
413                frame: CovarianceFrame::Inertial,
414            },
415            &[t_end_tdb_seconds],
416            &CovariancePropagationOptions::default(),
417        )?;
418        let node = ephemeris.nodes()[0];
419        Ok((node.state, node.covariance))
420    }
421
422    /// Build the finite-difference state-transition matrix over a relative
423    /// propagation span in seconds.
424    ///
425    /// Columns perturb the initial state in `[r_x, r_y, r_z, v_x, v_y, v_z]`
426    /// order. Each plus/minus leg is propagated through [`Self::propagate_to`]'s
427    /// same force-model and integrator assembly path.
428    pub fn state_transition_matrix_for_span(
429        &self,
430        span_seconds: f64,
431    ) -> Result<StateTransitionMatrix, PropagationError> {
432        crate::validate::finite(span_seconds, "span_seconds").map_err(map_field_error)?;
433        self.state_transition_matrix_to(self.initial.epoch_tdb_seconds + span_seconds)
434    }
435
436    /// Build the finite-difference state-transition matrix to an absolute TDB
437    /// epoch in seconds.
438    ///
439    /// The zero-span STM is exactly identity and does not call the propagator.
440    /// Non-zero spans propagate twelve perturbed initial states with central
441    /// differences and the existing numerical propagator.
442    pub fn state_transition_matrix_to(
443        &self,
444        t_end_tdb_seconds: f64,
445    ) -> Result<StateTransitionMatrix, PropagationError> {
446        let ctx = PropagationContext::default();
447        self.state_transition_matrix_to_with_context(t_end_tdb_seconds, &ctx)
448    }
449
450    /// Build the finite-difference state-transition matrix with an explicit
451    /// propagation context.
452    ///
453    /// Passing [`PropagationContext::default`] is equivalent to
454    /// [`Self::state_transition_matrix_to`].
455    pub fn state_transition_matrix_to_with_context(
456        &self,
457        t_end_tdb_seconds: f64,
458        ctx: &PropagationContext,
459    ) -> Result<StateTransitionMatrix, PropagationError> {
460        crate::validate::finite(t_end_tdb_seconds, "t_end_tdb_seconds").map_err(map_field_error)?;
461        let force = self.build_force()?;
462        let dynamics = OrbitalDynamics {
463            force_model: force.as_ref(),
464        };
465        self.state_transition_matrix_between(self.initial, t_end_tdb_seconds, &dynamics, ctx)
466    }
467
468    pub(super) fn state_transition_matrix_between(
469        &self,
470        initial: CartesianState,
471        t_end_tdb_seconds: f64,
472        dynamics: &OrbitalDynamics,
473        ctx: &PropagationContext,
474    ) -> Result<StateTransitionMatrix, PropagationError> {
475        crate::validate::finite(t_end_tdb_seconds, "t_end_tdb_seconds").map_err(map_field_error)?;
476        if t_end_tdb_seconds == initial.epoch_tdb_seconds {
477            return Ok(identity_stm());
478        }
479
480        let mut stm = [[0.0_f64; 6]; 6];
481        let initial_vector = state_vector(&initial);
482
483        for (column, &component) in initial_vector.iter().enumerate() {
484            let delta = finite_difference_step(component);
485            let plus = perturb_state(initial, column, delta);
486            let minus = perturb_state(initial, column, -delta);
487
488            let plus_final = self
489                .run(plus, t_end_tdb_seconds, dynamics, ctx)?
490                .final_state;
491            let minus_final = self
492                .run(minus, t_end_tdb_seconds, dynamics, ctx)?
493                .final_state;
494            let plus_vector = state_vector(&plus_final);
495            let minus_vector = state_vector(&minus_final);
496            let denom = 2.0 * delta;
497
498            for (row, stm_row) in stm.iter_mut().enumerate() {
499                stm_row[column] = (plus_vector[row] - minus_vector[row]) / denom;
500            }
501        }
502
503        validate_stm(&stm)?;
504        Ok(stm)
505    }
506
507    /// Sample the trajectory at a sequence of absolute TDB epochs (seconds),
508    /// returning the Cartesian state at each. The epochs must be monotonic in
509    /// the propagation direction; the satellite is stepped from one requested
510    /// epoch to the next (sequential segments), so the cost is linear in the
511    /// number of epochs. An epoch equal to the current epoch returns the current
512    /// state without re-integrating.
513    ///
514    /// The force model is built once and reused across every segment.
515    pub fn ephemeris(
516        &self,
517        epochs_tdb_seconds: &[f64],
518    ) -> Result<Vec<CartesianState>, PropagationError> {
519        let ctx = PropagationContext::default();
520        self.ephemeris_with_context(epochs_tdb_seconds, &ctx)
521    }
522
523    /// Sample the trajectory with an explicit propagation context.
524    ///
525    /// Passing [`PropagationContext::default`] is equivalent to
526    /// [`Self::ephemeris`].
527    pub fn ephemeris_with_context(
528        &self,
529        epochs_tdb_seconds: &[f64],
530        ctx: &PropagationContext,
531    ) -> Result<Vec<CartesianState>, PropagationError> {
532        validate_initial_state(self.initial)?;
533        validate_epoch_finite(self.initial.epoch_tdb_seconds, "initial.epoch_tdb_seconds")?;
534        validate_ephemeris_epochs(epochs_tdb_seconds)?;
535
536        let force = self.build_force()?;
537        let dynamics = OrbitalDynamics {
538            force_model: force.as_ref(),
539        };
540
541        let mut states = Vec::with_capacity(epochs_tdb_seconds.len());
542        let mut current = self.initial;
543        for &t in epochs_tdb_seconds {
544            if t != current.epoch_tdb_seconds {
545                current = self.run(current, t, &dynamics, ctx)?.final_state;
546            }
547            states.push(current);
548        }
549        Ok(states)
550    }
551
552    /// Dispatch to the selected integrator. Kept private so the public surface
553    /// stays `propagate_to` / `ephemeris`.
554    pub(super) fn run(
555        &self,
556        initial: CartesianState,
557        t_end_tdb_seconds: f64,
558        dynamics: &OrbitalDynamics,
559        ctx: &PropagationContext,
560    ) -> Result<PropagationResult, PropagationError> {
561        validate_epoch_finite(initial.epoch_tdb_seconds, "initial.epoch_tdb_seconds")?;
562        validate_epoch_finite(t_end_tdb_seconds, "t_end_tdb_seconds")?;
563        validate_initial_state(initial)?;
564
565        match self.integrator {
566            IntegratorKind::Rk4 => {
567                RK4.propagate(initial, t_end_tdb_seconds, dynamics, ctx, &self.options)
568            }
569            IntegratorKind::Dp54 => {
570                DP54.propagate(initial, t_end_tdb_seconds, dynamics, ctx, &self.options)
571            }
572        }
573    }
574
575    pub(super) fn build_force(&self) -> Result<Box<dyn ForceModel>, PropagationError> {
576        let gravity = self.force_model.build()?;
577        match (self.drag, self.space_weather.clone()) {
578            (Some(drag), Some(source)) => {
579                let mut composite = CompositeForceModel::new();
580                composite.add(gravity);
581                composite.add(Box::new(SourcedDragForce::new(drag, source)));
582                Ok(Box::new(composite))
583            }
584            (Some(drag), None) => {
585                let mut composite = CompositeForceModel::new();
586                composite.add(gravity);
587                composite.add(Box::new(drag.to_force()));
588                Ok(Box::new(composite))
589            }
590            (None, Some(_)) => Err(PropagationError::InvalidInput(
591                "space weather source without drag".to_string(),
592            )),
593            (None, None) => Ok(gravity),
594        }
595    }
596}
597
598fn map_field_error(error: crate::validate::FieldError) -> PropagationError {
599    PropagationError::InvalidInput(format!("{} {}", error.field(), error.reason()))
600}
601
602pub(super) fn map_covariance6_error(error: Covariance6Error) -> PropagationError {
603    let reason = match error {
604        Covariance6Error::NonFinite => "not finite",
605        Covariance6Error::Asymmetric => "not symmetric",
606        Covariance6Error::NotPositiveSemidefinite => "not positive semidefinite",
607        Covariance6Error::NotFactorizable => "not factorizable",
608        Covariance6Error::InvalidInterpolationParameter => "invalid interpolation parameter",
609    };
610    PropagationError::NumericalFailure(format!("covariance {reason}"))
611}
612
613fn identity_stm() -> StateTransitionMatrix {
614    let mut matrix = [[0.0_f64; 6]; 6];
615    for (idx, row) in matrix.iter_mut().enumerate() {
616        row[idx] = 1.0;
617    }
618    matrix
619}
620
621fn finite_difference_step(component: f64) -> f64 {
622    (component.abs().max(1.0) * STM_RELATIVE_PERTURBATION).max(STM_MIN_PERTURBATION)
623}
624
625fn perturb_state(state: CartesianState, component: usize, delta: f64) -> CartesianState {
626    let mut perturbed = state;
627    match component {
628        0 => perturbed.position_km.x += delta,
629        1 => perturbed.position_km.y += delta,
630        2 => perturbed.position_km.z += delta,
631        3 => perturbed.velocity_km_s.x += delta,
632        4 => perturbed.velocity_km_s.y += delta,
633        5 => perturbed.velocity_km_s.z += delta,
634        _ => unreachable!("state-transition matrix component index is in 0..6"),
635    }
636    perturbed
637}
638
639fn state_vector(state: &CartesianState) -> [f64; 6] {
640    [
641        state.position_km.x,
642        state.position_km.y,
643        state.position_km.z,
644        state.velocity_km_s.x,
645        state.velocity_km_s.y,
646        state.velocity_km_s.z,
647    ]
648}
649
650fn validate_ephemeris_epochs(epochs_tdb_seconds: &[f64]) -> Result<(), PropagationError> {
651    for &epoch_tdb_seconds in epochs_tdb_seconds {
652        validate_epoch_finite(epoch_tdb_seconds, "epochs_tdb_seconds")?;
653    }
654    Ok(())
655}
656
657fn validate_initial_state(initial: CartesianState) -> Result<(), PropagationError> {
658    validate_state_vector(initial.position_array(), "initial.position_km")?;
659    validate_state_vector(initial.velocity_array(), "initial.velocity_km_s")
660}
661
662fn validate_stm(stm: &StateTransitionMatrix) -> Result<(), PropagationError> {
663    for row in stm {
664        crate::validate::finite_slice(row, "state_transition_matrix").map_err(|error| {
665            PropagationError::NumericalFailure(format!("{} {}", error.field(), error.reason()))
666        })?;
667    }
668    Ok(())
669}
670
671fn validate_state_vector(values: [f64; 3], field: &'static str) -> Result<(), PropagationError> {
672    crate::validate::finite_slice(&values, field).map_err(|error| {
673        PropagationError::InvalidInput(format!("{} {}", error.field(), error.reason()))
674    })
675}
676
677fn validate_epoch_finite(value: f64, field: &'static str) -> Result<(), PropagationError> {
678    crate::validate::finite(value, field)
679        .map(|_| ())
680        .map_err(|error| {
681            PropagationError::InvalidInput(format!("{} {}", error.field(), error.reason()))
682        })
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688    use crate::astro::forces::{DragParameters, SpaceWeather, SpaceWeatherSource};
689    use crate::astro::integrators::Integrator;
690    use nalgebra::Vector3;
691    use std::sync::atomic::{AtomicUsize, Ordering};
692    use std::sync::Mutex;
693
694    struct CountingForce<'a> {
695        calls: &'a AtomicUsize,
696    }
697
698    impl ForceModel for CountingForce<'_> {
699        fn acceleration(
700            &self,
701            _state: &CartesianState,
702            _ctx: &PropagationContext,
703        ) -> Result<Vector3<f64>, PropagationError> {
704            self.calls.fetch_add(1, Ordering::SeqCst);
705            Ok(Vector3::zeros())
706        }
707    }
708
709    #[derive(Default)]
710    struct EpochRecordingForce {
711        epochs: Mutex<Vec<f64>>,
712    }
713
714    impl ForceModel for EpochRecordingForce {
715        fn acceleration(
716            &self,
717            state: &CartesianState,
718            _ctx: &PropagationContext,
719        ) -> Result<Vector3<f64>, PropagationError> {
720            self.epochs
721                .lock()
722                .expect("epoch recorder mutex")
723                .push(state.epoch_tdb_seconds);
724            Ok(Vector3::zeros())
725        }
726    }
727
728    fn circular_state() -> ([f64; 3], [f64; 3], f64) {
729        let r: f64 = 7000.0;
730        let v = (MU_EARTH / r).sqrt();
731        ([r, 0.0, 0.0], [0.0, v, 0.0], r)
732    }
733
734    fn leo_state(altitude_km: f64) -> CartesianState {
735        let r = RE_EARTH + altitude_km;
736        let v = (MU_EARTH / r).sqrt();
737        CartesianState::new(0.0, [r, 0.0, 0.0], [0.0, v, 0.0])
738    }
739
740    fn test_drag_parameters(bc_factor_m2_kg: f64) -> DragParameters {
741        DragParameters::from_bc_factor_m2_kg(
742            bc_factor_m2_kg,
743            SpaceWeather::default(),
744            crate::astro::forces::DragForce::DEFAULT_REENTRY_ALTITUDE_KM,
745        )
746        .expect("valid drag")
747    }
748
749    fn assert_states_bit_for_bit(left: &[CartesianState], right: &[CartesianState]) {
750        assert_eq!(left.len(), right.len());
751        for (left, right) in left.iter().zip(right.iter()) {
752            assert_eq!(
753                left.epoch_tdb_seconds.to_bits(),
754                right.epoch_tdb_seconds.to_bits()
755            );
756            for idx in 0..3 {
757                assert_eq!(
758                    left.position_array()[idx].to_bits(),
759                    right.position_array()[idx].to_bits()
760                );
761                assert_eq!(
762                    left.velocity_array()[idx].to_bits(),
763                    right.velocity_array()[idx].to_bits()
764                );
765            }
766        }
767    }
768
769    fn rk4_test_options() -> IntegratorOptions {
770        IntegratorOptions {
771            initial_step: 1.0,
772            ..IntegratorOptions::default()
773        }
774    }
775
776    fn dp54_drag_options() -> IntegratorOptions {
777        IntegratorOptions {
778            abs_tol: 1.0e-9,
779            rel_tol: 1.0e-11,
780            initial_step: 30.0,
781            min_step: 1.0e-6,
782            max_step: 120.0,
783            max_steps: 200_000,
784            dense_output: false,
785        }
786    }
787
788    fn rk4_two_body_propagator(initial: CartesianState) -> StatePropagator {
789        StatePropagator {
790            initial,
791            force_model: ForceModelKind::two_body(),
792            integrator: IntegratorKind::Rk4,
793            options: rk4_test_options(),
794            drag: None,
795            space_weather: None,
796        }
797    }
798
799    fn circular_rk4_two_body_propagator() -> StatePropagator {
800        let (pos, vel, _) = circular_state();
801        rk4_two_body_propagator(CartesianState::new(0.0, pos, vel))
802    }
803
804    #[test]
805    fn two_body_and_j2_bits_unchanged_when_drag_none() {
806        let state = CartesianState::new(0.0, [7000.0, -1210.0, 1300.0], [1.0, 7.2, 0.5]);
807        let options = IntegratorOptions {
808            initial_step: 10.0,
809            ..IntegratorOptions::default()
810        };
811        let two_body = StatePropagator {
812            initial: state,
813            force_model: ForceModelKind::two_body(),
814            integrator: IntegratorKind::Rk4,
815            options,
816            drag: None,
817            space_weather: None,
818        }
819        .propagate_to(120.0)
820        .expect("two-body propagation")
821        .final_state;
822        let j2 = StatePropagator {
823            initial: state,
824            force_model: ForceModelKind::two_body_j2(),
825            integrator: IntegratorKind::Rk4,
826            options,
827            drag: None,
828            space_weather: None,
829        }
830        .propagate_to(120.0)
831        .expect("J2 propagation")
832        .final_state;
833
834        assert_eq!(
835            [
836                two_body.position_km.x.to_bits(),
837                two_body.position_km.y.to_bits(),
838                two_body.position_km.z.to_bits(),
839                two_body.velocity_km_s.x.to_bits(),
840                two_body.velocity_km_s.y.to_bits(),
841                two_body.velocity_km_s.z.to_bits(),
842            ],
843            [
844                4_664_491_478_405_259_647,
845                13_868_042_866_614_843_437,
846                4_653_651_863_611_153_978,
847                4_592_023_743_103_375_898,
848                4_619_903_732_185_607_459,
849                4_599_631_655_498_578_350,
850            ]
851        );
852        assert_eq!(
853            [
854                j2.position_km.x.to_bits(),
855                j2.position_km.y.to_bits(),
856                j2.position_km.z.to_bits(),
857                j2.velocity_km_s.x.to_bits(),
858                j2.velocity_km_s.y.to_bits(),
859                j2.velocity_km_s.z.to_bits(),
860            ],
861            [
862                4_664_491_415_796_994_328,
863                13_868_042_735_578_520_184,
864                4_653_651_704_383_841_626,
865                4_591_955_084_532_107_724,
866                4_619_903_849_988_711_109,
867                4_599_620_700_962_266_984,
868            ]
869        );
870    }
871
872    #[test]
873    fn composite_with_phase_a_terms_off_matches_two_body_bit_for_bit() {
874        let state = CartesianState::new(0.0, [7000.0, -1210.0, 1300.0], [1.0, 7.2, 0.5]);
875        let options = IntegratorOptions {
876            initial_step: 10.0,
877            ..IntegratorOptions::default()
878        };
879        let legacy = StatePropagator {
880            initial: state,
881            force_model: ForceModelKind::two_body(),
882            integrator: IntegratorKind::Rk4,
883            options,
884            drag: None,
885            space_weather: None,
886        }
887        .ephemeris(&[0.0, 60.0, 120.0])
888        .expect("legacy two-body ephemeris");
889        let composite = StatePropagator {
890            initial: state,
891            force_model: ForceModelKind::composite(ForceModelComponents::earth_two_body()),
892            integrator: IntegratorKind::Rk4,
893            options,
894            drag: None,
895            space_weather: None,
896        }
897        .ephemeris(&[0.0, 60.0, 120.0])
898        .expect("composite two-body ephemeris");
899
900        assert_states_bit_for_bit(&legacy, &composite);
901    }
902
903    #[test]
904    fn earth_radiation_pressure_component_is_opt_in() {
905        let components = ForceModelComponents::earth_phase_a(None);
906        assert_eq!(components.earth_radiation_pressure, None);
907
908        let pressure = EarthRadiationPressure::new(1.2, 0.011).expect("valid model");
909        let with_pressure = components.with_earth_radiation_pressure(pressure);
910        assert_eq!(components.earth_radiation_pressure, None);
911        assert_eq!(with_pressure.earth_radiation_pressure, Some(pressure));
912    }
913
914    #[test]
915    fn phase_a_all_forces_leo_smoke_stays_bounded() {
916        let initial = leo_state(500.0);
917        let srp = SolarRadiationPressure::new(1.3, 0.02).expect("valid SRP");
918        let propagator = StatePropagator::new(
919            initial.epoch_tdb_seconds,
920            initial.position_array(),
921            initial.velocity_array(),
922            ForceModelKind::earth_phase_a(Some(srp)),
923            IntegratorKind::Dp54,
924        )
925        .with_options(IntegratorOptions {
926            abs_tol: 1.0e-10,
927            rel_tol: 1.0e-12,
928            initial_step: 30.0,
929            max_step: 120.0,
930            ..IntegratorOptions::default()
931        })
932        .with_drag(test_drag_parameters(0.02));
933
934        let result = propagator
935            .propagate_to(initial.epoch_tdb_seconds + 1800.0)
936            .expect("Phase A propagation");
937        let final_state = result.final_state;
938
939        assert!(final_state
940            .position_km
941            .iter()
942            .all(|value| value.is_finite()));
943        assert!(final_state
944            .velocity_km_s
945            .iter()
946            .all(|value| value.is_finite()));
947        assert!((6500.0..8000.0).contains(&final_state.position_km.norm()));
948        assert!((6.0..9.0).contains(&final_state.velocity_km_s.norm()));
949    }
950
951    #[test]
952    fn fixed_space_weather_source_matches_fixed_drag_ephemeris_bit_for_bit() {
953        let initial = leo_state(250.0);
954        let drag = test_drag_parameters(0.15);
955        let fixed = StatePropagator::new(
956            initial.epoch_tdb_seconds,
957            initial.position_array(),
958            initial.velocity_array(),
959            ForceModelKind::two_body(),
960            IntegratorKind::Dp54,
961        )
962        .with_options(dp54_drag_options())
963        .with_drag(drag);
964        let sourced = StatePropagator::new(
965            initial.epoch_tdb_seconds,
966            initial.position_array(),
967            initial.velocity_array(),
968            ForceModelKind::two_body(),
969            IntegratorKind::Dp54,
970        )
971        .with_options(dp54_drag_options())
972        .with_drag(drag)
973        .with_space_weather(SpaceWeatherSource::Fixed(drag.space_weather()));
974        let epochs: Vec<f64> = (0..=6)
975            .map(|i| initial.epoch_tdb_seconds + i as f64 * 300.0)
976            .collect();
977
978        let fixed_states = fixed.ephemeris(&epochs).expect("fixed ephemeris");
979        let sourced_states = sourced.ephemeris(&epochs).expect("sourced ephemeris");
980        assert_states_bit_for_bit(&fixed_states, &sourced_states);
981    }
982
983    #[test]
984    fn space_weather_source_without_drag_is_invalid_input() {
985        let initial = leo_state(250.0);
986        let err = StatePropagator::new(
987            initial.epoch_tdb_seconds,
988            initial.position_array(),
989            initial.velocity_array(),
990            ForceModelKind::two_body(),
991            IntegratorKind::Rk4,
992        )
993        .with_space_weather(SpaceWeatherSource::Fixed(SpaceWeather::default()))
994        .propagate_to(initial.epoch_tdb_seconds + 60.0)
995        .expect_err("source without drag fails");
996        match err {
997            PropagationError::InvalidInput(message) => {
998                assert!(message.contains("space weather source without drag"));
999            }
1000            other => panic!("expected invalid input, got {other:?}"),
1001        }
1002    }
1003
1004    #[test]
1005    fn entry_point_matches_integrator_called_directly_bit_for_bit() {
1006        let (pos, vel, _) = circular_state();
1007        let opts = IntegratorOptions {
1008            abs_tol: 1e-12,
1009            rel_tol: 1e-12,
1010            ..IntegratorOptions::default()
1011        };
1012
1013        // Entry point.
1014        let propagator = StatePropagator::new(
1015            0.0,
1016            pos,
1017            vel,
1018            ForceModelKind::two_body(),
1019            IntegratorKind::Dp54,
1020        )
1021        .with_options(IntegratorOptions {
1022            abs_tol: 1e-12,
1023            rel_tol: 1e-12,
1024            ..IntegratorOptions::default()
1025        });
1026        let via_entry = propagator
1027            .propagate_to(crate::constants::SECONDS_PER_HOUR)
1028            .unwrap()
1029            .final_state;
1030
1031        // Integrator called directly, the way the entry point assembles it.
1032        let force = TwoBodyGravity::default();
1033        let dynamics = OrbitalDynamics {
1034            force_model: &force,
1035        };
1036        let ctx = PropagationContext::default();
1037        let via_direct = DP54
1038            .propagate(
1039                CartesianState::new(0.0, pos, vel),
1040                crate::constants::SECONDS_PER_HOUR,
1041                &dynamics,
1042                &ctx,
1043                &opts,
1044            )
1045            .unwrap()
1046            .final_state;
1047
1048        assert_eq!(
1049            via_entry.position_km.x.to_bits(),
1050            via_direct.position_km.x.to_bits()
1051        );
1052        assert_eq!(
1053            via_entry.position_km.y.to_bits(),
1054            via_direct.position_km.y.to_bits()
1055        );
1056        assert_eq!(
1057            via_entry.position_km.z.to_bits(),
1058            via_direct.position_km.z.to_bits()
1059        );
1060        assert_eq!(
1061            via_entry.velocity_km_s.x.to_bits(),
1062            via_direct.velocity_km_s.x.to_bits()
1063        );
1064        assert_eq!(
1065            via_entry.velocity_km_s.y.to_bits(),
1066            via_direct.velocity_km_s.y.to_bits()
1067        );
1068        assert_eq!(
1069            via_entry.velocity_km_s.z.to_bits(),
1070            via_direct.velocity_km_s.z.to_bits()
1071        );
1072    }
1073
1074    #[test]
1075    fn ephemeris_last_sample_matches_single_shot_propagation() {
1076        // ephemeris() segments from epoch->t1->t2; the final sample must equal a
1077        // single propagate_to() over the same span only when the intermediate
1078        // node lands on the same point. We assert the cheaper invariant: the
1079        // first sample is the initial state, and the sample count matches.
1080        let (pos, vel, _) = circular_state();
1081        let propagator = StatePropagator::new(
1082            100.0,
1083            pos,
1084            vel,
1085            ForceModelKind::two_body(),
1086            IntegratorKind::Dp54,
1087        );
1088        let epochs = [100.0, 700.0, 1300.0];
1089        let states = propagator.ephemeris(&epochs).unwrap();
1090
1091        assert_eq!(states.len(), 3);
1092        // First sample is the initial state, untouched.
1093        assert_eq!(states[0].position_km.x.to_bits(), pos[0].to_bits());
1094        assert_eq!(states[0].velocity_km_s.y.to_bits(), vel[1].to_bits());
1095        for (state, &t) in states.iter().zip(epochs.iter()) {
1096            assert_eq!(state.epoch_tdb_seconds, t);
1097        }
1098    }
1099
1100    #[test]
1101    fn state_transition_matrix_zero_span_is_identity() {
1102        let propagator = circular_rk4_two_body_propagator();
1103        let stm = propagator.state_transition_matrix_for_span(0.0).unwrap();
1104
1105        for (i, row) in stm.iter().enumerate() {
1106            for (j, &value) in row.iter().enumerate() {
1107                let expected = if i == j { 1.0_f64 } else { 0.0_f64 };
1108                assert_eq!(value.to_bits(), expected.to_bits());
1109            }
1110        }
1111    }
1112
1113    #[test]
1114    fn state_transition_matrix_has_short_span_two_body_structure() {
1115        let propagator = circular_rk4_two_body_propagator();
1116        let span = 10.0;
1117        let stm = propagator.state_transition_matrix_for_span(span).unwrap();
1118
1119        for axis in 0..3 {
1120            assert_close(stm[axis][axis], 1.0, 2.0e-4);
1121            assert_close(stm[axis][axis + 3], span, 2.0e-3);
1122            assert_close(stm[axis + 3][axis + 3], 1.0, 2.0e-4);
1123        }
1124    }
1125
1126    #[test]
1127    fn state_transition_matrix_matches_independent_perturbation() {
1128        let propagator = circular_rk4_two_body_propagator();
1129        let span = 60.0;
1130        let stm = propagator.state_transition_matrix_for_span(span).unwrap();
1131        let base_final = propagator.propagate_to(span).unwrap().final_state;
1132
1133        let delta = [2.0e-4, -1.5e-4, 1.0e-4, 2.0e-7, -1.0e-7, 1.5e-7];
1134        let mut perturbed_initial = propagator.initial;
1135        for (component, &value) in delta.iter().enumerate() {
1136            perturbed_initial = perturb_state(perturbed_initial, component, value);
1137        }
1138        let perturbed_final = rk4_two_body_propagator(perturbed_initial)
1139            .propagate_to(span)
1140            .unwrap()
1141            .final_state;
1142
1143        let base_vector = state_vector(&base_final);
1144        let perturbed_vector = state_vector(&perturbed_final);
1145        let predicted = mat6_vec6(&stm, &delta);
1146
1147        for row in 0..6 {
1148            let observed = perturbed_vector[row] - base_vector[row];
1149            let tolerance = if row < 3 { 2.0e-8 } else { 2.0e-10 };
1150            assert_close(predicted[row], observed, tolerance);
1151        }
1152    }
1153
1154    #[test]
1155    fn state_transition_matrix_is_symplectic_for_short_two_body_span() {
1156        let propagator = circular_rk4_two_body_propagator();
1157        let stm = propagator.state_transition_matrix_for_span(30.0).unwrap();
1158
1159        assert!(max_symplectic_residual(&stm) < 1.0e-5);
1160    }
1161
1162    #[test]
1163    fn stm_with_drag_test() {
1164        let initial = leo_state(300.0);
1165        let propagator = StatePropagator::new(
1166            initial.epoch_tdb_seconds,
1167            initial.position_array(),
1168            initial.velocity_array(),
1169            ForceModelKind::two_body(),
1170            IntegratorKind::Rk4,
1171        )
1172        .with_options(IntegratorOptions {
1173            initial_step: 2.0,
1174            ..IntegratorOptions::default()
1175        })
1176        .with_drag(test_drag_parameters(0.2));
1177        let stm = propagator
1178            .state_transition_matrix_for_span(8.0)
1179            .expect("drag STM");
1180
1181        for row in stm {
1182            for value in row {
1183                assert!(value.is_finite());
1184            }
1185        }
1186        assert!(stm[0][3] > 0.0);
1187        assert!(stm[1][4] > 0.0);
1188        assert!(stm[2][5] > 0.0);
1189    }
1190
1191    #[test]
1192    fn integrator_presents_advancing_substep_epoch() {
1193        let initial = CartesianState::new(10.0, [7000.0, 0.0, 0.0], [0.0, 7.5, 0.0]);
1194        let options = IntegratorOptions {
1195            initial_step: 10.0,
1196            max_step: 10.0,
1197            ..IntegratorOptions::default()
1198        };
1199
1200        for integrator in [IntegratorKind::Rk4, IntegratorKind::Dp54] {
1201            let force = EpochRecordingForce::default();
1202            let dynamics = OrbitalDynamics {
1203                force_model: &force,
1204            };
1205            let ctx = PropagationContext::default();
1206            match integrator {
1207                IntegratorKind::Rk4 => {
1208                    RK4.propagate(initial, 20.0, &dynamics, &ctx, &options)
1209                        .expect("RK4 propagation");
1210                }
1211                IntegratorKind::Dp54 => {
1212                    DP54.propagate(initial, 20.0, &dynamics, &ctx, &options)
1213                        .expect("DP54 propagation");
1214                }
1215            }
1216            let epochs = force.epochs.lock().expect("epoch recorder mutex");
1217            assert!(epochs
1218                .iter()
1219                .any(|&epoch| epoch > initial.epoch_tdb_seconds));
1220            assert!(epochs.contains(&20.0));
1221        }
1222    }
1223
1224    #[test]
1225    fn stm_with_drag_differs_from_no_drag() {
1226        let initial = leo_state(300.0);
1227        let options = IntegratorOptions {
1228            initial_step: 2.0,
1229            ..IntegratorOptions::default()
1230        };
1231        let no_drag = StatePropagator::new(
1232            initial.epoch_tdb_seconds,
1233            initial.position_array(),
1234            initial.velocity_array(),
1235            ForceModelKind::two_body(),
1236            IntegratorKind::Rk4,
1237        )
1238        .with_options(options)
1239        .state_transition_matrix_for_span(20.0)
1240        .expect("no-drag STM");
1241        let with_drag = StatePropagator::new(
1242            initial.epoch_tdb_seconds,
1243            initial.position_array(),
1244            initial.velocity_array(),
1245            ForceModelKind::two_body(),
1246            IntegratorKind::Rk4,
1247        )
1248        .with_options(options)
1249        .with_drag(test_drag_parameters(0.4))
1250        .state_transition_matrix_for_span(20.0)
1251        .expect("drag STM");
1252
1253        let mut max_diff = 0.0_f64;
1254        for row in 0..6 {
1255            for col in 0..6 {
1256                max_diff = max_diff.max((with_drag[row][col] - no_drag[row][col]).abs());
1257            }
1258        }
1259        assert!(max_diff > 1.0e-10, "STM diff {max_diff}");
1260    }
1261
1262    #[test]
1263    fn propagate_state_with_covariance_zero_span_returns_initial_inputs() {
1264        let propagator = circular_rk4_two_body_propagator();
1265        let covariance = test_covariance();
1266
1267        let (state, propagated_covariance) = propagator
1268            .propagate_state_with_covariance(covariance, 0.0)
1269            .unwrap();
1270
1271        assert_eq!(state, propagator.initial);
1272        assert_eq!(propagated_covariance, covariance);
1273    }
1274
1275    #[test]
1276    fn propagate_state_with_covariance_keeps_covariance_psd_and_coupled() {
1277        let propagator = circular_rk4_two_body_propagator();
1278        let covariance0 = test_covariance();
1279        let span = 120.0;
1280
1281        let (state, covariance_f) = propagator
1282            .propagate_state_with_covariance(covariance0, span)
1283            .unwrap();
1284
1285        assert_eq!(state.epoch_tdb_seconds, span);
1286        assert!(covariance_f.is_symmetric());
1287        assert!(covariance_f.is_positive_semidefinite());
1288
1289        let p0 = covariance0.as_matrix();
1290        let pf = covariance_f.as_matrix();
1291        let initial_position_trace = p0[0][0] + p0[1][1] + p0[2][2];
1292        let final_position_trace = pf[0][0] + pf[1][1] + pf[2][2];
1293        assert!(final_position_trace > initial_position_trace);
1294
1295        let max_position_velocity_coupling = (0..3)
1296            .flat_map(|i| (3..6).map(move |j| pf[i][j].abs()))
1297            .fold(0.0_f64, f64::max);
1298        assert!(max_position_velocity_coupling > 1.0e-8);
1299    }
1300
1301    #[test]
1302    fn propagator_rejects_zero_initial_step() {
1303        let (pos, vel, _) = circular_state();
1304        let propagator = StatePropagator::new(
1305            0.0,
1306            pos,
1307            vel,
1308            ForceModelKind::two_body(),
1309            IntegratorKind::Rk4,
1310        )
1311        .with_options(IntegratorOptions {
1312            initial_step: 0.0,
1313            ..IntegratorOptions::default()
1314        });
1315
1316        assert_invalid_propagation_field(
1317            propagator.propagate_to(60.0).unwrap_err(),
1318            "initial_step",
1319        );
1320    }
1321
1322    #[test]
1323    fn rejects_non_finite_epochs_before_running_integrator() {
1324        let (pos, vel, _) = circular_state();
1325        let calls = AtomicUsize::new(0);
1326        let force = CountingForce { calls: &calls };
1327        let dynamics = OrbitalDynamics {
1328            force_model: &force,
1329        };
1330        let ctx = PropagationContext::default();
1331        let propagator = StatePropagator::new(
1332            0.0,
1333            pos,
1334            vel,
1335            ForceModelKind::two_body(),
1336            IntegratorKind::Dp54,
1337        );
1338
1339        let cases = [
1340            (
1341                CartesianState::new(f64::NAN, pos, vel),
1342                60.0,
1343                "initial.epoch_tdb_seconds",
1344            ),
1345            (
1346                CartesianState::new(0.0, pos, vel),
1347                f64::INFINITY,
1348                "t_end_tdb_seconds",
1349            ),
1350        ];
1351
1352        for (initial, t_end, field) in cases {
1353            calls.store(0, Ordering::SeqCst);
1354            let err = propagator
1355                .run(initial, t_end, &dynamics, &ctx)
1356                .expect_err("non-finite epoch should be rejected");
1357
1358            assert_non_finite_epoch_error(err, field);
1359            assert_eq!(
1360                calls.load(Ordering::SeqCst),
1361                0,
1362                "non-finite {field} must not enter the integrator"
1363            );
1364        }
1365    }
1366
1367    #[test]
1368    fn ephemeris_rejects_non_finite_query_epochs_before_first_segment() {
1369        let (pos, vel, _) = circular_state();
1370        let propagator = StatePropagator::new(
1371            0.0,
1372            pos,
1373            vel,
1374            ForceModelKind::two_body(),
1375            IntegratorKind::Rk4,
1376        )
1377        .with_options(IntegratorOptions {
1378            initial_step: 0.0,
1379            ..IntegratorOptions::default()
1380        });
1381
1382        let err = propagator
1383            .ephemeris(&[60.0, f64::NAN])
1384            .expect_err("non-finite query epoch should be rejected");
1385
1386        assert_non_finite_epoch_error(err, "epochs_tdb_seconds");
1387    }
1388
1389    #[test]
1390    fn rejects_non_finite_initial_state_vectors_before_running_integrator() {
1391        let (pos, vel, _) = circular_state();
1392        let calls = AtomicUsize::new(0);
1393        let force = CountingForce { calls: &calls };
1394        let dynamics = OrbitalDynamics {
1395            force_model: &force,
1396        };
1397        let ctx = PropagationContext::default();
1398        let propagator = StatePropagator::new(
1399            0.0,
1400            pos,
1401            vel,
1402            ForceModelKind::two_body(),
1403            IntegratorKind::Rk4,
1404        );
1405
1406        let cases = [
1407            (
1408                CartesianState::new(0.0, [f64::NAN, pos[1], pos[2]], vel),
1409                "initial.position_km",
1410            ),
1411            (
1412                CartesianState::new(0.0, [pos[0], f64::INFINITY, pos[2]], vel),
1413                "initial.position_km",
1414            ),
1415            (
1416                CartesianState::new(0.0, pos, [vel[0], f64::NAN, vel[2]]),
1417                "initial.velocity_km_s",
1418            ),
1419            (
1420                CartesianState::new(0.0, pos, [vel[0], vel[1], f64::NEG_INFINITY]),
1421                "initial.velocity_km_s",
1422            ),
1423        ];
1424
1425        for (initial, field) in cases {
1426            calls.store(0, Ordering::SeqCst);
1427            let err = propagator
1428                .run(initial, 60.0, &dynamics, &ctx)
1429                .expect_err("non-finite state vector should be rejected");
1430
1431            assert_non_finite_state_error(err, field);
1432            assert_eq!(
1433                calls.load(Ordering::SeqCst),
1434                0,
1435                "non-finite {field} must not enter the integrator"
1436            );
1437        }
1438    }
1439
1440    #[test]
1441    fn propagate_to_rejects_non_finite_integrator_outputs() {
1442        let (pos, vel, _) = circular_state();
1443        let propagator = StatePropagator::new(
1444            0.0,
1445            pos,
1446            vel,
1447            ForceModelKind::TwoBody {
1448                mu_km3_s2: f64::INFINITY,
1449            },
1450            IntegratorKind::Rk4,
1451        )
1452        .with_options(rk4_test_options());
1453
1454        let err = propagator
1455            .propagate_to(1.0)
1456            .expect_err("non-finite integration result should be rejected");
1457
1458        assert_output_non_finite_error(err, "final_state");
1459    }
1460
1461    #[test]
1462    fn state_transition_matrix_rejects_non_finite_propagation_legs() {
1463        let (pos, vel, _) = circular_state();
1464        let propagator = StatePropagator::new(
1465            0.0,
1466            pos,
1467            vel,
1468            ForceModelKind::TwoBody {
1469                mu_km3_s2: f64::INFINITY,
1470            },
1471            IntegratorKind::Rk4,
1472        )
1473        .with_options(rk4_test_options());
1474
1475        let err = propagator
1476            .state_transition_matrix_for_span(1.0)
1477            .expect_err("non-finite STM propagation leg should be rejected");
1478
1479        assert_output_non_finite_error(err, "final_state");
1480    }
1481
1482    fn assert_invalid_propagation_field(error: PropagationError, expected: &str) {
1483        match error {
1484            PropagationError::InvalidInput(message) => {
1485                assert!(message.contains(expected), "{message}");
1486                assert!(message.contains("not positive"), "{message}");
1487            }
1488            other => panic!("expected invalid propagation input for {expected}, got {other:?}"),
1489        }
1490    }
1491
1492    fn assert_close(actual: f64, expected: f64, tolerance: f64) {
1493        assert!(
1494            (actual - expected).abs() <= tolerance,
1495            "{actual} differs from {expected} by more than {tolerance}"
1496        );
1497    }
1498
1499    fn test_covariance() -> Covariance6 {
1500        Covariance6::from_diagonal([1.0e-6, 2.0e-6, 3.0e-6, 1.0e-8, 2.0e-8, 3.0e-8]).unwrap()
1501    }
1502
1503    fn mat6_vec6(matrix: &StateTransitionMatrix, vector: &[f64; 6]) -> [f64; 6] {
1504        let mut out = [0.0_f64; 6];
1505        for (i, row) in matrix.iter().enumerate() {
1506            for (j, &value) in row.iter().enumerate() {
1507                out[i] += value * vector[j];
1508            }
1509        }
1510        out
1511    }
1512
1513    fn max_symplectic_residual(phi: &StateTransitionMatrix) -> f64 {
1514        let mut max = 0.0_f64;
1515        for i in 0..6 {
1516            for j in 0..6 {
1517                let mut value = 0.0_f64;
1518                for k in 0..6 {
1519                    for l in 0..6 {
1520                        value += phi[k][i] * canonical_j(k, l) * phi[l][j];
1521                    }
1522                }
1523                let residual = (value - canonical_j(i, j)).abs();
1524                max = max.max(residual);
1525            }
1526        }
1527        max
1528    }
1529
1530    fn canonical_j(row: usize, col: usize) -> f64 {
1531        if row < 3 && col == row + 3 {
1532            1.0
1533        } else if row >= 3 && col + 3 == row {
1534            -1.0
1535        } else {
1536            0.0
1537        }
1538    }
1539
1540    fn assert_non_finite_epoch_error(error: PropagationError, expected: &str) {
1541        match error {
1542            PropagationError::InvalidInput(message) => {
1543                assert!(message.contains(expected), "{message}");
1544                assert!(message.contains("not finite"), "{message}");
1545            }
1546            other => panic!("expected invalid epoch input for {expected}, got {other:?}"),
1547        }
1548    }
1549
1550    fn assert_non_finite_state_error(error: PropagationError, expected: &str) {
1551        match error {
1552            PropagationError::InvalidInput(message) => {
1553                assert!(message.contains(expected), "{message}");
1554                assert!(message.contains("not finite"), "{message}");
1555            }
1556            other => panic!("expected invalid state input for {expected}, got {other:?}"),
1557        }
1558    }
1559
1560    fn assert_output_non_finite_error(error: PropagationError, expected: &str) {
1561        match error {
1562            PropagationError::InvalidInput(message)
1563            | PropagationError::NumericalFailure(message) => {
1564                assert!(message.contains(expected), "{message}");
1565                assert!(message.contains("not finite"), "{message}");
1566            }
1567            other => panic!("expected non-finite output for {expected}, got {other:?}"),
1568        }
1569    }
1570}