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