1use crate::astro::constants::{J2_EARTH, MU_EARTH, RE_EARTH};
16use crate::astro::covariance::{Covariance6, Covariance6Error};
17use crate::astro::error::PropagationError;
18use crate::astro::forces::{CompositeForceModel, ForceModel, J2Gravity, TwoBodyGravity};
19use crate::astro::integrators::{Integrator, DP54, RK4};
20use crate::astro::propagator::api::{IntegratorOptions, PropagationContext};
21use crate::astro::propagator::dynamics::OrbitalDynamics;
22use crate::astro::propagator::result::PropagationResult;
23use crate::astro::state::CartesianState;
24
25pub type StateTransitionMatrix = [[f64; 6]; 6];
30
31const STM_RELATIVE_PERTURBATION: f64 = 1.0e-6;
32const STM_MIN_PERTURBATION: f64 = 1.0e-6;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum IntegratorKind {
37 Rk4,
40 Dp54,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq)]
52pub enum ForceModelKind {
53 TwoBody {
55 mu_km3_s2: f64,
57 },
58 TwoBodyJ2 {
60 mu_km3_s2: f64,
62 re_km: f64,
64 j2: f64,
66 },
67}
68
69impl ForceModelKind {
70 pub fn two_body() -> Self {
72 Self::TwoBody {
73 mu_km3_s2: MU_EARTH,
74 }
75 }
76
77 pub fn two_body_j2() -> Self {
80 Self::TwoBodyJ2 {
81 mu_km3_s2: MU_EARTH,
82 re_km: RE_EARTH,
83 j2: J2_EARTH,
84 }
85 }
86
87 fn build(self) -> Box<dyn ForceModel> {
91 match self {
92 ForceModelKind::TwoBody { mu_km3_s2 } => Box::new(TwoBodyGravity { mu: mu_km3_s2 }),
93 ForceModelKind::TwoBodyJ2 {
94 mu_km3_s2,
95 re_km,
96 j2,
97 } => {
98 let mut composite = CompositeForceModel::new();
99 composite.add(Box::new(TwoBodyGravity { mu: mu_km3_s2 }));
100 composite.add(Box::new(J2Gravity {
101 mu: mu_km3_s2,
102 re: re_km,
103 j2,
104 }));
105 Box::new(composite)
106 }
107 }
108 }
109}
110
111pub struct StatePropagator {
118 pub initial: CartesianState,
120 pub force_model: ForceModelKind,
122 pub integrator: IntegratorKind,
124 pub options: IntegratorOptions,
126}
127
128impl StatePropagator {
129 pub fn new(
133 epoch_tdb_seconds: f64,
134 position_km: [f64; 3],
135 velocity_km_s: [f64; 3],
136 force_model: ForceModelKind,
137 integrator: IntegratorKind,
138 ) -> Self {
139 Self {
140 initial: CartesianState::new(epoch_tdb_seconds, position_km, velocity_km_s),
141 force_model,
142 integrator,
143 options: IntegratorOptions::default(),
144 }
145 }
146
147 pub fn with_options(mut self, options: IntegratorOptions) -> Self {
149 self.options = options;
150 self
151 }
152
153 pub fn propagate_to(
158 &self,
159 t_end_tdb_seconds: f64,
160 ) -> Result<PropagationResult, PropagationError> {
161 let force = self.force_model.build();
162 let dynamics = OrbitalDynamics {
163 force_model: force.as_ref(),
164 };
165 let ctx = PropagationContext::default();
166 self.run(self.initial, t_end_tdb_seconds, &dynamics, &ctx)
167 }
168
169 pub fn propagate_state_with_covariance(
175 &self,
176 covariance0: Covariance6,
177 span_seconds: f64,
178 ) -> Result<(CartesianState, Covariance6), PropagationError> {
179 validate_initial_state(self.initial)?;
180 crate::validate::finite(span_seconds, "span_seconds").map_err(map_field_error)?;
181 let t_end_tdb_seconds = self.initial.epoch_tdb_seconds + span_seconds;
182 crate::validate::finite(t_end_tdb_seconds, "t_end_tdb_seconds").map_err(map_field_error)?;
183
184 if span_seconds == 0.0 {
185 return Ok((self.initial, covariance0));
186 }
187
188 let final_state = self.propagate_to(t_end_tdb_seconds)?.final_state;
189 let stm = self.state_transition_matrix_to(t_end_tdb_seconds)?;
190 let covariance = covariance0
191 .propagate_with_stm(&stm)
192 .map_err(map_covariance6_error)?;
193 Ok((final_state, covariance))
194 }
195
196 pub fn state_transition_matrix_for_span(
203 &self,
204 span_seconds: f64,
205 ) -> Result<StateTransitionMatrix, PropagationError> {
206 crate::validate::finite(span_seconds, "span_seconds").map_err(map_field_error)?;
207 self.state_transition_matrix_to(self.initial.epoch_tdb_seconds + span_seconds)
208 }
209
210 pub fn state_transition_matrix_to(
217 &self,
218 t_end_tdb_seconds: f64,
219 ) -> Result<StateTransitionMatrix, PropagationError> {
220 crate::validate::finite(t_end_tdb_seconds, "t_end_tdb_seconds").map_err(map_field_error)?;
221 if t_end_tdb_seconds == self.initial.epoch_tdb_seconds {
222 return Ok(identity_stm());
223 }
224
225 let force = self.force_model.build();
226 let dynamics = OrbitalDynamics {
227 force_model: force.as_ref(),
228 };
229 let ctx = PropagationContext::default();
230 let mut stm = [[0.0_f64; 6]; 6];
231 let initial_vector = state_vector(&self.initial);
232
233 for (column, &component) in initial_vector.iter().enumerate() {
234 let delta = finite_difference_step(component);
235 let plus = perturb_state(self.initial, column, delta);
236 let minus = perturb_state(self.initial, column, -delta);
237
238 let plus_final = self
239 .run(plus, t_end_tdb_seconds, &dynamics, &ctx)?
240 .final_state;
241 let minus_final = self
242 .run(minus, t_end_tdb_seconds, &dynamics, &ctx)?
243 .final_state;
244 let plus_vector = state_vector(&plus_final);
245 let minus_vector = state_vector(&minus_final);
246 let denom = 2.0 * delta;
247
248 for (row, stm_row) in stm.iter_mut().enumerate() {
249 stm_row[column] = (plus_vector[row] - minus_vector[row]) / denom;
250 }
251 }
252
253 validate_stm(&stm)?;
254 Ok(stm)
255 }
256
257 pub fn ephemeris(
266 &self,
267 epochs_tdb_seconds: &[f64],
268 ) -> Result<Vec<CartesianState>, PropagationError> {
269 validate_initial_state(self.initial)?;
270 validate_epoch_finite(self.initial.epoch_tdb_seconds, "initial.epoch_tdb_seconds")?;
271 validate_ephemeris_epochs(epochs_tdb_seconds)?;
272
273 let force = self.force_model.build();
274 let dynamics = OrbitalDynamics {
275 force_model: force.as_ref(),
276 };
277 let ctx = PropagationContext::default();
278
279 let mut states = Vec::with_capacity(epochs_tdb_seconds.len());
280 let mut current = self.initial;
281 for &t in epochs_tdb_seconds {
282 if t != current.epoch_tdb_seconds {
283 current = self.run(current, t, &dynamics, &ctx)?.final_state;
284 }
285 states.push(current);
286 }
287 Ok(states)
288 }
289
290 fn run(
293 &self,
294 initial: CartesianState,
295 t_end_tdb_seconds: f64,
296 dynamics: &OrbitalDynamics,
297 ctx: &PropagationContext,
298 ) -> Result<PropagationResult, PropagationError> {
299 validate_epoch_finite(initial.epoch_tdb_seconds, "initial.epoch_tdb_seconds")?;
300 validate_epoch_finite(t_end_tdb_seconds, "t_end_tdb_seconds")?;
301 validate_initial_state(initial)?;
302
303 match self.integrator {
304 IntegratorKind::Rk4 => {
305 RK4.propagate(initial, t_end_tdb_seconds, dynamics, ctx, &self.options)
306 }
307 IntegratorKind::Dp54 => {
308 DP54.propagate(initial, t_end_tdb_seconds, dynamics, ctx, &self.options)
309 }
310 }
311 }
312}
313
314fn map_field_error(error: crate::validate::FieldError) -> PropagationError {
315 PropagationError::InvalidInput(format!("{} {}", error.field(), error.reason()))
316}
317
318fn map_covariance6_error(error: Covariance6Error) -> PropagationError {
319 let reason = match error {
320 Covariance6Error::NonFinite => "not finite",
321 Covariance6Error::Asymmetric => "not symmetric",
322 Covariance6Error::NotPositiveSemidefinite => "not positive semidefinite",
323 };
324 PropagationError::InvalidInput(format!("covariance {reason}"))
325}
326
327fn identity_stm() -> StateTransitionMatrix {
328 let mut matrix = [[0.0_f64; 6]; 6];
329 for (idx, row) in matrix.iter_mut().enumerate() {
330 row[idx] = 1.0;
331 }
332 matrix
333}
334
335fn finite_difference_step(component: f64) -> f64 {
336 (component.abs().max(1.0) * STM_RELATIVE_PERTURBATION).max(STM_MIN_PERTURBATION)
337}
338
339fn perturb_state(state: CartesianState, component: usize, delta: f64) -> CartesianState {
340 let mut perturbed = state;
341 match component {
342 0 => perturbed.position_km.x += delta,
343 1 => perturbed.position_km.y += delta,
344 2 => perturbed.position_km.z += delta,
345 3 => perturbed.velocity_km_s.x += delta,
346 4 => perturbed.velocity_km_s.y += delta,
347 5 => perturbed.velocity_km_s.z += delta,
348 _ => unreachable!("state-transition matrix component index is in 0..6"),
349 }
350 perturbed
351}
352
353fn state_vector(state: &CartesianState) -> [f64; 6] {
354 [
355 state.position_km.x,
356 state.position_km.y,
357 state.position_km.z,
358 state.velocity_km_s.x,
359 state.velocity_km_s.y,
360 state.velocity_km_s.z,
361 ]
362}
363
364fn validate_ephemeris_epochs(epochs_tdb_seconds: &[f64]) -> Result<(), PropagationError> {
365 for &epoch_tdb_seconds in epochs_tdb_seconds {
366 validate_epoch_finite(epoch_tdb_seconds, "epochs_tdb_seconds")?;
367 }
368 Ok(())
369}
370
371fn validate_initial_state(initial: CartesianState) -> Result<(), PropagationError> {
372 validate_state_vector(initial.position_array(), "initial.position_km")?;
373 validate_state_vector(initial.velocity_array(), "initial.velocity_km_s")
374}
375
376fn validate_stm(stm: &StateTransitionMatrix) -> Result<(), PropagationError> {
377 for row in stm {
378 crate::validate::finite_slice(row, "state_transition_matrix").map_err(|error| {
379 PropagationError::NumericalFailure(format!("{} {}", error.field(), error.reason()))
380 })?;
381 }
382 Ok(())
383}
384
385fn validate_state_vector(values: [f64; 3], field: &'static str) -> Result<(), PropagationError> {
386 crate::validate::finite_slice(&values, field).map_err(|error| {
387 PropagationError::InvalidInput(format!("{} {}", error.field(), error.reason()))
388 })
389}
390
391fn validate_epoch_finite(value: f64, field: &'static str) -> Result<(), PropagationError> {
392 crate::validate::finite(value, field)
393 .map(|_| ())
394 .map_err(|error| {
395 PropagationError::InvalidInput(format!("{} {}", error.field(), error.reason()))
396 })
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use nalgebra::Vector3;
403 use std::sync::atomic::{AtomicUsize, Ordering};
404
405 struct CountingForce<'a> {
406 calls: &'a AtomicUsize,
407 }
408
409 impl ForceModel for CountingForce<'_> {
410 fn acceleration(
411 &self,
412 _state: &CartesianState,
413 _ctx: &PropagationContext,
414 ) -> Result<Vector3<f64>, PropagationError> {
415 self.calls.fetch_add(1, Ordering::SeqCst);
416 Ok(Vector3::zeros())
417 }
418 }
419
420 fn circular_state() -> ([f64; 3], [f64; 3], f64) {
421 let r: f64 = 7000.0;
422 let v = (MU_EARTH / r).sqrt();
423 ([r, 0.0, 0.0], [0.0, v, 0.0], r)
424 }
425
426 fn rk4_test_options() -> IntegratorOptions {
427 IntegratorOptions {
428 initial_step: 1.0,
429 ..IntegratorOptions::default()
430 }
431 }
432
433 fn rk4_two_body_propagator(initial: CartesianState) -> StatePropagator {
434 StatePropagator {
435 initial,
436 force_model: ForceModelKind::two_body(),
437 integrator: IntegratorKind::Rk4,
438 options: rk4_test_options(),
439 }
440 }
441
442 fn circular_rk4_two_body_propagator() -> StatePropagator {
443 let (pos, vel, _) = circular_state();
444 rk4_two_body_propagator(CartesianState::new(0.0, pos, vel))
445 }
446
447 #[test]
448 fn entry_point_matches_integrator_called_directly_bit_for_bit() {
449 let (pos, vel, _) = circular_state();
450 let opts = IntegratorOptions {
451 abs_tol: 1e-12,
452 rel_tol: 1e-12,
453 ..IntegratorOptions::default()
454 };
455
456 let propagator = StatePropagator::new(
458 0.0,
459 pos,
460 vel,
461 ForceModelKind::two_body(),
462 IntegratorKind::Dp54,
463 )
464 .with_options(IntegratorOptions {
465 abs_tol: 1e-12,
466 rel_tol: 1e-12,
467 ..IntegratorOptions::default()
468 });
469 let via_entry = propagator.propagate_to(3600.0).unwrap().final_state;
470
471 let force = TwoBodyGravity::default();
473 let dynamics = OrbitalDynamics {
474 force_model: &force,
475 };
476 let ctx = PropagationContext::default();
477 let via_direct = DP54
478 .propagate(
479 CartesianState::new(0.0, pos, vel),
480 3600.0,
481 &dynamics,
482 &ctx,
483 &opts,
484 )
485 .unwrap()
486 .final_state;
487
488 assert_eq!(
489 via_entry.position_km.x.to_bits(),
490 via_direct.position_km.x.to_bits()
491 );
492 assert_eq!(
493 via_entry.position_km.y.to_bits(),
494 via_direct.position_km.y.to_bits()
495 );
496 assert_eq!(
497 via_entry.position_km.z.to_bits(),
498 via_direct.position_km.z.to_bits()
499 );
500 assert_eq!(
501 via_entry.velocity_km_s.x.to_bits(),
502 via_direct.velocity_km_s.x.to_bits()
503 );
504 assert_eq!(
505 via_entry.velocity_km_s.y.to_bits(),
506 via_direct.velocity_km_s.y.to_bits()
507 );
508 assert_eq!(
509 via_entry.velocity_km_s.z.to_bits(),
510 via_direct.velocity_km_s.z.to_bits()
511 );
512 }
513
514 #[test]
515 fn ephemeris_last_sample_matches_single_shot_propagation() {
516 let (pos, vel, _) = circular_state();
521 let propagator = StatePropagator::new(
522 100.0,
523 pos,
524 vel,
525 ForceModelKind::two_body(),
526 IntegratorKind::Dp54,
527 );
528 let epochs = [100.0, 700.0, 1300.0];
529 let states = propagator.ephemeris(&epochs).unwrap();
530
531 assert_eq!(states.len(), 3);
532 assert_eq!(states[0].position_km.x.to_bits(), pos[0].to_bits());
534 assert_eq!(states[0].velocity_km_s.y.to_bits(), vel[1].to_bits());
535 for (state, &t) in states.iter().zip(epochs.iter()) {
536 assert_eq!(state.epoch_tdb_seconds, t);
537 }
538 }
539
540 #[test]
541 fn state_transition_matrix_zero_span_is_identity() {
542 let propagator = circular_rk4_two_body_propagator();
543 let stm = propagator.state_transition_matrix_for_span(0.0).unwrap();
544
545 for (i, row) in stm.iter().enumerate() {
546 for (j, &value) in row.iter().enumerate() {
547 let expected = if i == j { 1.0_f64 } else { 0.0_f64 };
548 assert_eq!(value.to_bits(), expected.to_bits());
549 }
550 }
551 }
552
553 #[test]
554 fn state_transition_matrix_has_short_span_two_body_structure() {
555 let propagator = circular_rk4_two_body_propagator();
556 let span = 10.0;
557 let stm = propagator.state_transition_matrix_for_span(span).unwrap();
558
559 for axis in 0..3 {
560 assert_close(stm[axis][axis], 1.0, 2.0e-4);
561 assert_close(stm[axis][axis + 3], span, 2.0e-3);
562 assert_close(stm[axis + 3][axis + 3], 1.0, 2.0e-4);
563 }
564 }
565
566 #[test]
567 fn state_transition_matrix_matches_independent_perturbation() {
568 let propagator = circular_rk4_two_body_propagator();
569 let span = 60.0;
570 let stm = propagator.state_transition_matrix_for_span(span).unwrap();
571 let base_final = propagator.propagate_to(span).unwrap().final_state;
572
573 let delta = [2.0e-4, -1.5e-4, 1.0e-4, 2.0e-7, -1.0e-7, 1.5e-7];
574 let mut perturbed_initial = propagator.initial;
575 for (component, &value) in delta.iter().enumerate() {
576 perturbed_initial = perturb_state(perturbed_initial, component, value);
577 }
578 let perturbed_final = rk4_two_body_propagator(perturbed_initial)
579 .propagate_to(span)
580 .unwrap()
581 .final_state;
582
583 let base_vector = state_vector(&base_final);
584 let perturbed_vector = state_vector(&perturbed_final);
585 let predicted = mat6_vec6(&stm, &delta);
586
587 for row in 0..6 {
588 let observed = perturbed_vector[row] - base_vector[row];
589 let tolerance = if row < 3 { 2.0e-8 } else { 2.0e-10 };
590 assert_close(predicted[row], observed, tolerance);
591 }
592 }
593
594 #[test]
595 fn state_transition_matrix_is_symplectic_for_short_two_body_span() {
596 let propagator = circular_rk4_two_body_propagator();
597 let stm = propagator.state_transition_matrix_for_span(30.0).unwrap();
598
599 assert!(max_symplectic_residual(&stm) < 1.0e-5);
600 }
601
602 #[test]
603 fn propagate_state_with_covariance_zero_span_returns_initial_inputs() {
604 let propagator = circular_rk4_two_body_propagator();
605 let covariance = test_covariance();
606
607 let (state, propagated_covariance) = propagator
608 .propagate_state_with_covariance(covariance, 0.0)
609 .unwrap();
610
611 assert_eq!(state, propagator.initial);
612 assert_eq!(propagated_covariance, covariance);
613 }
614
615 #[test]
616 fn propagate_state_with_covariance_keeps_covariance_psd_and_coupled() {
617 let propagator = circular_rk4_two_body_propagator();
618 let covariance0 = test_covariance();
619 let span = 120.0;
620
621 let (state, covariance_f) = propagator
622 .propagate_state_with_covariance(covariance0, span)
623 .unwrap();
624
625 assert_eq!(state.epoch_tdb_seconds, span);
626 assert!(covariance_f.is_symmetric());
627 assert!(covariance_f.is_positive_semidefinite());
628
629 let p0 = covariance0.as_matrix();
630 let pf = covariance_f.as_matrix();
631 let initial_position_trace = p0[0][0] + p0[1][1] + p0[2][2];
632 let final_position_trace = pf[0][0] + pf[1][1] + pf[2][2];
633 assert!(final_position_trace > initial_position_trace);
634
635 let max_position_velocity_coupling = (0..3)
636 .flat_map(|i| (3..6).map(move |j| pf[i][j].abs()))
637 .fold(0.0_f64, f64::max);
638 assert!(max_position_velocity_coupling > 1.0e-8);
639 }
640
641 #[test]
642 fn propagator_rejects_zero_initial_step() {
643 let (pos, vel, _) = circular_state();
644 let propagator = StatePropagator::new(
645 0.0,
646 pos,
647 vel,
648 ForceModelKind::two_body(),
649 IntegratorKind::Rk4,
650 )
651 .with_options(IntegratorOptions {
652 initial_step: 0.0,
653 ..IntegratorOptions::default()
654 });
655
656 assert_invalid_propagation_field(
657 propagator.propagate_to(60.0).unwrap_err(),
658 "initial_step",
659 );
660 }
661
662 #[test]
663 fn rejects_non_finite_epochs_before_running_integrator() {
664 let (pos, vel, _) = circular_state();
665 let calls = AtomicUsize::new(0);
666 let force = CountingForce { calls: &calls };
667 let dynamics = OrbitalDynamics {
668 force_model: &force,
669 };
670 let ctx = PropagationContext::default();
671 let propagator = StatePropagator::new(
672 0.0,
673 pos,
674 vel,
675 ForceModelKind::two_body(),
676 IntegratorKind::Dp54,
677 );
678
679 let cases = [
680 (
681 CartesianState::new(f64::NAN, pos, vel),
682 60.0,
683 "initial.epoch_tdb_seconds",
684 ),
685 (
686 CartesianState::new(0.0, pos, vel),
687 f64::INFINITY,
688 "t_end_tdb_seconds",
689 ),
690 ];
691
692 for (initial, t_end, field) in cases {
693 calls.store(0, Ordering::SeqCst);
694 let err = propagator
695 .run(initial, t_end, &dynamics, &ctx)
696 .expect_err("non-finite epoch should be rejected");
697
698 assert_non_finite_epoch_error(err, field);
699 assert_eq!(
700 calls.load(Ordering::SeqCst),
701 0,
702 "non-finite {field} must not enter the integrator"
703 );
704 }
705 }
706
707 #[test]
708 fn ephemeris_rejects_non_finite_query_epochs_before_first_segment() {
709 let (pos, vel, _) = circular_state();
710 let propagator = StatePropagator::new(
711 0.0,
712 pos,
713 vel,
714 ForceModelKind::two_body(),
715 IntegratorKind::Rk4,
716 )
717 .with_options(IntegratorOptions {
718 initial_step: 0.0,
719 ..IntegratorOptions::default()
720 });
721
722 let err = propagator
723 .ephemeris(&[60.0, f64::NAN])
724 .expect_err("non-finite query epoch should be rejected");
725
726 assert_non_finite_epoch_error(err, "epochs_tdb_seconds");
727 }
728
729 #[test]
730 fn rejects_non_finite_initial_state_vectors_before_running_integrator() {
731 let (pos, vel, _) = circular_state();
732 let calls = AtomicUsize::new(0);
733 let force = CountingForce { calls: &calls };
734 let dynamics = OrbitalDynamics {
735 force_model: &force,
736 };
737 let ctx = PropagationContext::default();
738 let propagator = StatePropagator::new(
739 0.0,
740 pos,
741 vel,
742 ForceModelKind::two_body(),
743 IntegratorKind::Rk4,
744 );
745
746 let cases = [
747 (
748 CartesianState::new(0.0, [f64::NAN, pos[1], pos[2]], vel),
749 "initial.position_km",
750 ),
751 (
752 CartesianState::new(0.0, [pos[0], f64::INFINITY, pos[2]], vel),
753 "initial.position_km",
754 ),
755 (
756 CartesianState::new(0.0, pos, [vel[0], f64::NAN, vel[2]]),
757 "initial.velocity_km_s",
758 ),
759 (
760 CartesianState::new(0.0, pos, [vel[0], vel[1], f64::NEG_INFINITY]),
761 "initial.velocity_km_s",
762 ),
763 ];
764
765 for (initial, field) in cases {
766 calls.store(0, Ordering::SeqCst);
767 let err = propagator
768 .run(initial, 60.0, &dynamics, &ctx)
769 .expect_err("non-finite state vector should be rejected");
770
771 assert_non_finite_state_error(err, field);
772 assert_eq!(
773 calls.load(Ordering::SeqCst),
774 0,
775 "non-finite {field} must not enter the integrator"
776 );
777 }
778 }
779
780 #[test]
781 fn propagate_to_rejects_non_finite_integrator_outputs() {
782 let (pos, vel, _) = circular_state();
783 let propagator = StatePropagator::new(
784 0.0,
785 pos,
786 vel,
787 ForceModelKind::TwoBody {
788 mu_km3_s2: f64::INFINITY,
789 },
790 IntegratorKind::Rk4,
791 )
792 .with_options(rk4_test_options());
793
794 let err = propagator
795 .propagate_to(1.0)
796 .expect_err("non-finite integration result should be rejected");
797
798 assert_output_non_finite_error(err, "final_state");
799 }
800
801 #[test]
802 fn state_transition_matrix_rejects_non_finite_propagation_legs() {
803 let (pos, vel, _) = circular_state();
804 let propagator = StatePropagator::new(
805 0.0,
806 pos,
807 vel,
808 ForceModelKind::TwoBody {
809 mu_km3_s2: f64::INFINITY,
810 },
811 IntegratorKind::Rk4,
812 )
813 .with_options(rk4_test_options());
814
815 let err = propagator
816 .state_transition_matrix_for_span(1.0)
817 .expect_err("non-finite STM propagation leg should be rejected");
818
819 assert_output_non_finite_error(err, "final_state");
820 }
821
822 fn assert_invalid_propagation_field(error: PropagationError, expected: &str) {
823 match error {
824 PropagationError::InvalidInput(message) => {
825 assert!(message.contains(expected), "{message}");
826 assert!(message.contains("not positive"), "{message}");
827 }
828 other => panic!("expected invalid propagation input for {expected}, got {other:?}"),
829 }
830 }
831
832 fn assert_close(actual: f64, expected: f64, tolerance: f64) {
833 assert!(
834 (actual - expected).abs() <= tolerance,
835 "{actual} differs from {expected} by more than {tolerance}"
836 );
837 }
838
839 fn test_covariance() -> Covariance6 {
840 Covariance6::from_diagonal([1.0e-6, 2.0e-6, 3.0e-6, 1.0e-8, 2.0e-8, 3.0e-8]).unwrap()
841 }
842
843 fn mat6_vec6(matrix: &StateTransitionMatrix, vector: &[f64; 6]) -> [f64; 6] {
844 let mut out = [0.0_f64; 6];
845 for (i, row) in matrix.iter().enumerate() {
846 for (j, &value) in row.iter().enumerate() {
847 out[i] += value * vector[j];
848 }
849 }
850 out
851 }
852
853 fn max_symplectic_residual(phi: &StateTransitionMatrix) -> f64 {
854 let mut max = 0.0_f64;
855 for i in 0..6 {
856 for j in 0..6 {
857 let mut value = 0.0_f64;
858 for k in 0..6 {
859 for l in 0..6 {
860 value += phi[k][i] * canonical_j(k, l) * phi[l][j];
861 }
862 }
863 let residual = (value - canonical_j(i, j)).abs();
864 max = max.max(residual);
865 }
866 }
867 max
868 }
869
870 fn canonical_j(row: usize, col: usize) -> f64 {
871 if row < 3 && col == row + 3 {
872 1.0
873 } else if row >= 3 && col + 3 == row {
874 -1.0
875 } else {
876 0.0
877 }
878 }
879
880 fn assert_non_finite_epoch_error(error: PropagationError, expected: &str) {
881 match error {
882 PropagationError::InvalidInput(message) => {
883 assert!(message.contains(expected), "{message}");
884 assert!(message.contains("not finite"), "{message}");
885 }
886 other => panic!("expected invalid epoch input for {expected}, got {other:?}"),
887 }
888 }
889
890 fn assert_non_finite_state_error(error: PropagationError, expected: &str) {
891 match error {
892 PropagationError::InvalidInput(message) => {
893 assert!(message.contains(expected), "{message}");
894 assert!(message.contains("not finite"), "{message}");
895 }
896 other => panic!("expected invalid state input for {expected}, got {other:?}"),
897 }
898 }
899
900 fn assert_output_non_finite_error(error: PropagationError, expected: &str) {
901 match error {
902 PropagationError::InvalidInput(message)
903 | PropagationError::NumericalFailure(message) => {
904 assert!(message.contains(expected), "{message}");
905 assert!(message.contains("not finite"), "{message}");
906 }
907 other => panic!("expected non-finite output for {expected}, got {other:?}"),
908 }
909 }
910}