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