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