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