1use 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
34pub type StateTransitionMatrix = [[f64; 6]; 6];
39
40const STM_RELATIVE_PERTURBATION: f64 = 1.0e-6;
41const STM_MIN_PERTURBATION: f64 = 1.0e-6;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum IntegratorKind {
46 Rk4,
49 Dp54,
52}
53
54#[allow(clippy::large_enum_variant)]
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub enum ForceModelKind {
65 TwoBody {
67 mu_km3_s2: f64,
69 },
70 TwoBodyJ2 {
72 mu_km3_s2: f64,
74 re_km: f64,
76 j2: f64,
78 },
79 Composite {
81 components: ForceModelComponents,
83 },
84}
85
86#[derive(Debug, Clone, Copy, PartialEq)]
88pub struct ForceModelComponents {
89 pub two_body_mu_km3_s2: Option<f64>,
91 pub zonal: Option<ZonalGravity>,
93 pub spherical_harmonic: Option<SphericalHarmonicGravityConfig>,
95 pub third_body: Option<ThirdBodyGravity>,
97 pub solid_earth_tide: Option<SolidEarthTideGravity>,
99 pub solid_earth_pole_tide: Option<SolidEarthPoleTideGravity>,
101 pub solar_radiation_pressure: Option<SolarRadiationPressure>,
103 pub earth_radiation_pressure: Option<EarthRadiationPressure>,
105 pub relativity: Option<SchwarzschildRelativity>,
107}
108
109impl Default for ForceModelComponents {
110 fn default() -> Self {
111 Self::EMPTY
112 }
113}
114
115impl ForceModelComponents {
116 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 pub fn earth_two_body() -> Self {
131 Self {
132 two_body_mu_km3_s2: Some(MU_EARTH),
133 ..Self::EMPTY
134 }
135 }
136
137 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 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 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 pub fn with_zonal(mut self, zonal: ZonalGravity) -> Self {
181 self.zonal = Some(zonal);
182 self
183 }
184
185 pub fn with_spherical_harmonic(mut self, gravity: SphericalHarmonicGravityConfig) -> Self {
187 self.spherical_harmonic = Some(gravity);
188 self
189 }
190
191 pub fn with_third_body(mut self, third_body: ThirdBodyGravity) -> Self {
193 self.third_body = Some(third_body);
194 self
195 }
196
197 pub fn with_solid_earth_tide(mut self, tide: SolidEarthTideGravity) -> Self {
199 self.solid_earth_tide = Some(tide);
200 self
201 }
202
203 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 pub fn with_solar_radiation_pressure(mut self, srp: SolarRadiationPressure) -> Self {
211 self.solar_radiation_pressure = Some(srp);
212 self
213 }
214
215 pub fn with_earth_radiation_pressure(mut self, pressure: EarthRadiationPressure) -> Self {
217 self.earth_radiation_pressure = Some(pressure);
218 self
219 }
220
221 pub fn with_relativity(mut self, relativity: SchwarzschildRelativity) -> Self {
223 self.relativity = Some(relativity);
224 self
225 }
226}
227
228impl ForceModelKind {
229 pub fn two_body() -> Self {
231 Self::TwoBody {
232 mu_km3_s2: MU_EARTH,
233 }
234 }
235
236 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 pub fn composite(components: ForceModelComponents) -> Self {
248 Self::Composite { components }
249 }
250
251 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 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 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
332pub struct StatePropagator {
339 pub initial: CartesianState,
341 pub force_model: ForceModelKind,
343 pub integrator: IntegratorKind,
345 pub options: IntegratorOptions,
347 pub drag: Option<DragParameters>,
349 pub space_weather: Option<SpaceWeatherSource>,
351}
352
353impl StatePropagator {
354 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 pub fn with_options(mut self, options: IntegratorOptions) -> Self {
376 self.options = options;
377 self
378 }
379
380 pub fn with_drag(mut self, drag: DragParameters) -> Self {
382 self.drag = Some(drag);
383 self
384 }
385
386 pub fn with_space_weather(mut self, source: SpaceWeatherSource) -> Self {
388 self.space_weather = Some(source);
389 self
390 }
391
392 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 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 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 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 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 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 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 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 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 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 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 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 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}