1use crate::astro::conjunction::{
8 collision_probability, CollisionPc, ConjunctionError, ConjunctionState, PcMethod,
9};
10use crate::astro::covariance::{Covariance6, Covariance6Error, Mat6};
11use crate::astro::events::{
12 EventFinder, EventFinderError, ExtremumEvent, ExtremumKind, ScalarEventPredicate,
13};
14use crate::astro::frames::transforms::{mat3_vec3_mul_unchecked, teme_to_gcrs_matrix};
15use crate::astro::math::mat3::Mat3;
16use crate::astro::math::vec3;
17use crate::astro::propagator::api::IntegratorOptions;
18use crate::astro::propagator::{ForceModelKind, IntegratorKind, StatePropagator};
19use crate::astro::sgp4::{Error as Sgp4Error, JulianDate, MinutesSinceEpoch, Satellite};
20use crate::astro::state::CartesianState;
21use crate::astro::time::civil::{civil_from_split_julian_date, split_julian_date_add_seconds};
22use crate::astro::time::scales::{julian_day_number, TimeScales};
23use crate::constants::SECONDS_PER_DAY;
24use crate::validate;
25use std::sync::Mutex;
26const FRAME_DERIVATIVE_STEP_SECONDS: f64 = 0.5;
27const BOUNDARY_RANGE_ABS_TOL_KM: f64 = 1.0e-9;
28const BOUNDARY_RANGE_REL_TOL: f64 = 1.0e-12;
29const MAX_TCA_RELATIVE_POSITION_KM: f64 = 1.0e12;
30const MAX_TCA_RELATIVE_VELOCITY_KM_S: f64 = 1.0e9;
31pub const DEFAULT_TCA_POSITION_COVARIANCE_KM2: [[f64; 3]; 3] =
37 [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
38
39#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct TcaFinderOptions {
42 pub coarse_step_seconds: f64,
44 pub time_tolerance_seconds: f64,
46}
47
48impl Default for TcaFinderOptions {
49 fn default() -> Self {
50 Self {
51 coarse_step_seconds: 60.0,
52 time_tolerance_seconds: 1.0e-3,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct TcaCandidate {
63 pub tca_time: JulianDate,
65 pub tca_seconds_since_window_start: f64,
67 pub miss_distance_km: f64,
69 pub relative_position_km: [f64; 3],
71 pub relative_velocity_km_s: [f64; 3],
73}
74
75#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct TcaScreeningHit {
78 pub secondary_index: usize,
80 pub candidate: TcaCandidate,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct TcaTle<'a> {
87 pub line1: &'a str,
89 pub line2: &'a str,
91}
92
93impl<'a> TcaTle<'a> {
94 pub const fn new(line1: &'a str, line2: &'a str) -> Self {
96 Self { line1, line2 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct TcaTleWithCovariance<'a> {
103 pub tle: TcaTle<'a>,
105 pub covariance0: Covariance6,
107}
108
109impl<'a> TcaTleWithCovariance<'a> {
110 pub const fn new(line1: &'a str, line2: &'a str, covariance0: Covariance6) -> Self {
112 Self {
113 tle: TcaTle::new(line1, line2),
114 covariance0,
115 }
116 }
117
118 pub const fn from_tle(tle: TcaTle<'a>, covariance0: Covariance6) -> Self {
120 Self { tle, covariance0 }
121 }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq)]
126pub struct TcaWindow {
127 pub start: JulianDate,
129 pub end: JulianDate,
131}
132
133impl TcaWindow {
134 pub const fn new(start: JulianDate, end: JulianDate) -> Self {
136 Self { start, end }
137 }
138
139 pub fn from_start_and_duration_seconds(
141 start: JulianDate,
142 duration_seconds: f64,
143 ) -> Result<Self, TcaError> {
144 validate::finite_nonneg(duration_seconds, "duration_seconds").map_err(map_input)?;
145 Ok(Self {
146 start,
147 end: add_seconds_to_julian_date(start, duration_seconds),
148 })
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq)]
154pub struct TcaPcCovariances {
155 pub primary_covariance_km2: [[f64; 3]; 3],
157 pub secondary_covariance_km2: [[f64; 3]; 3],
159}
160
161impl Default for TcaPcCovariances {
162 fn default() -> Self {
163 Self {
164 primary_covariance_km2: DEFAULT_TCA_POSITION_COVARIANCE_KM2,
165 secondary_covariance_km2: DEFAULT_TCA_POSITION_COVARIANCE_KM2,
166 }
167 }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq)]
172pub struct TcaPcOptions {
173 pub hard_body_radius_km: f64,
175 pub method: PcMethod,
177 pub covariances: TcaPcCovariances,
179}
180
181impl TcaPcOptions {
182 pub fn with_default_covariance(hard_body_radius_km: f64, method: PcMethod) -> Self {
184 Self {
185 hard_body_radius_km,
186 method,
187 covariances: TcaPcCovariances::default(),
188 }
189 }
190
191 pub fn with_covariances(
193 hard_body_radius_km: f64,
194 method: PcMethod,
195 primary_covariance_km2: [[f64; 3]; 3],
196 secondary_covariance_km2: [[f64; 3]; 3],
197 ) -> Self {
198 Self {
199 hard_body_radius_km,
200 method,
201 covariances: TcaPcCovariances {
202 primary_covariance_km2,
203 secondary_covariance_km2,
204 },
205 }
206 }
207}
208
209#[derive(Debug, Clone, Copy, PartialEq)]
212pub struct TcaPropagatedCovarianceOptions {
213 pub hard_body_radius_km: f64,
215 pub method: PcMethod,
217 pub force_model: ForceModelKind,
219 pub integrator: IntegratorKind,
221 pub integrator_options: IntegratorOptions,
223}
224
225impl TcaPropagatedCovarianceOptions {
226 pub fn new(hard_body_radius_km: f64, method: PcMethod) -> Self {
229 Self {
230 hard_body_radius_km,
231 method,
232 force_model: ForceModelKind::two_body_j2(),
233 integrator: IntegratorKind::Dp54,
234 integrator_options: IntegratorOptions::default(),
235 }
236 }
237
238 pub fn with_covariance_propagator(
240 mut self,
241 force_model: ForceModelKind,
242 integrator: IntegratorKind,
243 integrator_options: IntegratorOptions,
244 ) -> Self {
245 self.force_model = force_model;
246 self.integrator = integrator;
247 self.integrator_options = integrator_options;
248 self
249 }
250
251 fn for_initial_covariances(
252 self,
253 primary_covariance0: Covariance6,
254 secondary_covariance0: Covariance6,
255 ) -> TcaPropagatedCovariancePcOptions {
256 TcaPropagatedCovariancePcOptions {
257 hard_body_radius_km: self.hard_body_radius_km,
258 method: self.method,
259 primary_covariance0,
260 secondary_covariance0,
261 force_model: self.force_model,
262 integrator: self.integrator,
263 integrator_options: self.integrator_options,
264 }
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq)]
270pub struct TcaPropagatedCovariancePcOptions {
271 pub hard_body_radius_km: f64,
273 pub method: PcMethod,
275 pub primary_covariance0: Covariance6,
277 pub secondary_covariance0: Covariance6,
279 pub force_model: ForceModelKind,
281 pub integrator: IntegratorKind,
283 pub integrator_options: IntegratorOptions,
285}
286
287impl TcaPropagatedCovariancePcOptions {
288 pub fn new(
291 hard_body_radius_km: f64,
292 method: PcMethod,
293 primary_covariance0: Covariance6,
294 secondary_covariance0: Covariance6,
295 ) -> Self {
296 Self {
297 hard_body_radius_km,
298 method,
299 primary_covariance0,
300 secondary_covariance0,
301 force_model: ForceModelKind::two_body_j2(),
302 integrator: IntegratorKind::Dp54,
303 integrator_options: IntegratorOptions::default(),
304 }
305 }
306
307 pub fn with_covariance_propagator(
309 mut self,
310 force_model: ForceModelKind,
311 integrator: IntegratorKind,
312 integrator_options: IntegratorOptions,
313 ) -> Self {
314 self.force_model = force_model;
315 self.integrator = integrator;
316 self.integrator_options = integrator_options;
317 self
318 }
319}
320
321#[derive(Debug, Clone, Copy, PartialEq)]
323pub struct TcaConjunction {
324 pub candidate: TcaCandidate,
326 pub collision_probability: CollisionPc,
328}
329
330#[derive(Debug, Clone, Copy, PartialEq)]
332pub struct TcaScreeningConjunctionHit {
333 pub secondary_index: usize,
335 pub conjunction: TcaConjunction,
337}
338
339#[derive(Debug, Clone, PartialEq)]
341pub struct CatalogStateVector {
342 pub id: Option<String>,
344 pub position_km: [f64; 3],
346 pub velocity_km_s: [f64; 3],
348 pub covariance_km2: [[f64; 3]; 3],
350 pub hard_body_radius_km: f64,
352}
353
354#[derive(Debug, Clone, Copy, PartialEq)]
356pub struct CatalogScreeningOptions {
357 pub miss_threshold_km: f64,
359 pub pc_threshold: f64,
361 pub method: PcMethod,
363}
364
365impl Default for CatalogScreeningOptions {
366 fn default() -> Self {
367 Self {
368 miss_threshold_km: 50.0,
369 pc_threshold: 0.0,
370 method: PcMethod::FosterEqualArea,
371 }
372 }
373}
374
375#[derive(Debug, Clone, PartialEq)]
377pub struct CatalogScreeningCandidate {
378 pub i: usize,
379 pub j: usize,
380 pub id1: Option<String>,
381 pub id2: Option<String>,
382 pub miss_km: f64,
383}
384
385#[derive(Debug, Clone, Copy, PartialEq)]
387pub struct CatalogCollision {
388 pub probability: CollisionPc,
389 pub method: PcMethod,
390}
391
392#[derive(Debug, Clone, PartialEq)]
394pub struct CatalogScreeningResult {
395 pub candidate: CatalogScreeningCandidate,
396 pub collision: Option<CatalogCollision>,
397 pub error: Option<ConjunctionError>,
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub enum TcaObject {
403 Primary,
405 Secondary,
407}
408
409#[derive(Debug, Clone, PartialEq, thiserror::Error)]
411pub enum TcaError {
412 #[error("invalid TCA input {field}: {reason}")]
414 InvalidInput {
415 field: &'static str,
417 reason: &'static str,
419 },
420 #[error("{object:?} satellite initialization failed: {source}")]
422 Init {
423 object: TcaObject,
425 source: Sgp4Error,
427 },
428 #[error("{object:?} satellite propagation failed: {source}")]
430 Propagate {
431 object: TcaObject,
433 source: Sgp4Error,
435 },
436 #[error("{object:?} covariance propagation failed: {reason}")]
438 CovariancePropagation {
439 object: TcaObject,
441 reason: String,
443 },
444 #[error(transparent)]
446 EventFinder(#[from] EventFinderError),
447 #[error(transparent)]
449 Conjunction(#[from] ConjunctionError),
450}
451
452pub fn find_tca_candidates_from_tles(
454 primary_line1: &str,
455 primary_line2: &str,
456 secondary_line1: &str,
457 secondary_line2: &str,
458 window_start: JulianDate,
459 window_end: JulianDate,
460 options: TcaFinderOptions,
461) -> Result<Vec<TcaCandidate>, TcaError> {
462 let primary =
463 Satellite::from_tle(primary_line1, primary_line2).map_err(|source| TcaError::Init {
464 object: TcaObject::Primary,
465 source,
466 })?;
467 let secondary =
468 Satellite::from_tle(secondary_line1, secondary_line2).map_err(|source| TcaError::Init {
469 object: TcaObject::Secondary,
470 source,
471 })?;
472
473 find_tca_candidates(&primary, &secondary, window_start, window_end, options)
474}
475
476pub fn find_tca_candidates_between_tles(
478 primary_tle: TcaTle<'_>,
479 secondary_tle: TcaTle<'_>,
480 window: TcaWindow,
481 options: TcaFinderOptions,
482) -> Result<Vec<TcaCandidate>, TcaError> {
483 find_tca_candidates_from_tles(
484 primary_tle.line1,
485 primary_tle.line2,
486 secondary_tle.line1,
487 secondary_tle.line2,
488 window.start,
489 window.end,
490 options,
491 )
492}
493
494pub fn find_tca_candidates(
496 primary: &Satellite,
497 secondary: &Satellite,
498 window_start: JulianDate,
499 window_end: JulianDate,
500 options: TcaFinderOptions,
501) -> Result<Vec<TcaCandidate>, TcaError> {
502 let span_seconds = validate_window(window_start, window_end)?;
503 let options = validate_options(options)?;
504 if span_seconds <= 0.0 {
505 return Ok(Vec::new());
506 }
507
508 let finder = EventFinder::new(
509 0.0,
510 span_seconds,
511 options.coarse_step_seconds,
512 options.time_tolerance_seconds,
513 )
514 .map_err(TcaError::EventFinder)?;
515 let predicate = RelativeRange::new(primary, secondary, window_start);
516 let extrema = finder.find_extrema(&predicate).map_err(|error| {
517 predicate
518 .take_error()
519 .unwrap_or(TcaError::EventFinder(error))
520 })?;
521 let minima = minimum_extrema_including_boundaries(
522 &predicate,
523 extrema,
524 span_seconds,
525 options.coarse_step_seconds,
526 )?;
527
528 minima
529 .into_iter()
530 .map(|event| tca_candidate_from_extremum(primary, secondary, window_start, event))
531 .collect()
532}
533
534pub fn find_tca_conjunctions_from_tles(
536 primary_tle: TcaTle<'_>,
537 secondary_tle: TcaTle<'_>,
538 window_start: JulianDate,
539 window_end: JulianDate,
540 tca_options: TcaFinderOptions,
541 pc_options: TcaPcOptions,
542) -> Result<Vec<TcaConjunction>, TcaError> {
543 find_tca_candidates_from_tles(
544 primary_tle.line1,
545 primary_tle.line2,
546 secondary_tle.line1,
547 secondary_tle.line2,
548 window_start,
549 window_end,
550 tca_options,
551 )?
552 .into_iter()
553 .map(|candidate| tca_collision_probability(candidate, pc_options))
554 .collect()
555}
556
557pub fn find_tca_conjunctions_between_tles(
559 primary_tle: TcaTle<'_>,
560 secondary_tle: TcaTle<'_>,
561 window: TcaWindow,
562 tca_options: TcaFinderOptions,
563 pc_options: TcaPcOptions,
564) -> Result<Vec<TcaConjunction>, TcaError> {
565 find_tca_conjunctions_from_tles(
566 primary_tle,
567 secondary_tle,
568 window.start,
569 window.end,
570 tca_options,
571 pc_options,
572 )
573}
574
575pub fn find_tca_conjunctions(
577 primary: &Satellite,
578 secondary: &Satellite,
579 window_start: JulianDate,
580 window_end: JulianDate,
581 tca_options: TcaFinderOptions,
582 pc_options: TcaPcOptions,
583) -> Result<Vec<TcaConjunction>, TcaError> {
584 find_tca_candidates(primary, secondary, window_start, window_end, tca_options)?
585 .into_iter()
586 .map(|candidate| tca_collision_probability(candidate, pc_options))
587 .collect()
588}
589
590pub fn find_tca_conjunctions_with_propagated_covariance_from_tles(
593 primary_tle: TcaTle<'_>,
594 secondary_tle: TcaTle<'_>,
595 window_start: JulianDate,
596 window_end: JulianDate,
597 tca_options: TcaFinderOptions,
598 pc_options: TcaPropagatedCovariancePcOptions,
599) -> Result<Vec<TcaConjunction>, TcaError> {
600 let primary = satellite_from_tle(primary_tle, TcaObject::Primary)?;
601 let secondary = satellite_from_tle(secondary_tle, TcaObject::Secondary)?;
602 find_tca_conjunctions_with_propagated_covariance(
603 &primary,
604 &secondary,
605 window_start,
606 window_end,
607 tca_options,
608 pc_options,
609 )
610}
611
612pub fn find_tca_conjunctions_with_propagated_covariance_between_tles(
615 primary_tle: TcaTle<'_>,
616 secondary_tle: TcaTle<'_>,
617 window: TcaWindow,
618 tca_options: TcaFinderOptions,
619 pc_options: TcaPropagatedCovariancePcOptions,
620) -> Result<Vec<TcaConjunction>, TcaError> {
621 find_tca_conjunctions_with_propagated_covariance_from_tles(
622 primary_tle,
623 secondary_tle,
624 window.start,
625 window.end,
626 tca_options,
627 pc_options,
628 )
629}
630
631pub fn find_tca_conjunctions_with_propagated_covariance(
634 primary: &Satellite,
635 secondary: &Satellite,
636 window_start: JulianDate,
637 window_end: JulianDate,
638 tca_options: TcaFinderOptions,
639 pc_options: TcaPropagatedCovariancePcOptions,
640) -> Result<Vec<TcaConjunction>, TcaError> {
641 find_tca_candidates(primary, secondary, window_start, window_end, tca_options)?
642 .into_iter()
643 .map(|candidate| {
644 tca_collision_probability_with_propagated_covariance(
645 primary, secondary, candidate, pc_options,
646 )
647 })
648 .collect()
649}
650
651pub fn screen_tca_candidates_serial(
656 primary: &Satellite,
657 secondaries: &[Satellite],
658 window_start: JulianDate,
659 window_end: JulianDate,
660 miss_distance_threshold_km: f64,
661 options: TcaFinderOptions,
662) -> Result<Vec<TcaScreeningHit>, TcaError> {
663 screen_tca_candidates_batched(
664 primary,
665 secondaries,
666 window_start,
667 window_end,
668 miss_distance_threshold_km,
669 options,
670 BatchMode::Serial,
671 )
672}
673
674pub fn screen_tca_candidates_parallel(
680 primary: &Satellite,
681 secondaries: &[Satellite],
682 window_start: JulianDate,
683 window_end: JulianDate,
684 miss_distance_threshold_km: f64,
685 options: TcaFinderOptions,
686) -> Result<Vec<TcaScreeningHit>, TcaError> {
687 screen_tca_candidates_batched(
688 primary,
689 secondaries,
690 window_start,
691 window_end,
692 miss_distance_threshold_km,
693 options,
694 BatchMode::Parallel,
695 )
696}
697
698pub fn screen_tca_candidates_from_tle_catalog_serial(
700 primary_tle: TcaTle<'_>,
701 secondary_tles: &[TcaTle<'_>],
702 window: TcaWindow,
703 miss_distance_threshold_km: f64,
704 options: TcaFinderOptions,
705) -> Result<Vec<TcaScreeningHit>, TcaError> {
706 let primary = satellite_from_tle(primary_tle, TcaObject::Primary)?;
707 let secondaries = satellites_from_tles(secondary_tles)?;
708 screen_tca_candidates_serial(
709 &primary,
710 &secondaries,
711 window.start,
712 window.end,
713 miss_distance_threshold_km,
714 options,
715 )
716}
717
718pub fn screen_tca_candidates_from_tle_catalog_parallel(
720 primary_tle: TcaTle<'_>,
721 secondary_tles: &[TcaTle<'_>],
722 window: TcaWindow,
723 miss_distance_threshold_km: f64,
724 options: TcaFinderOptions,
725) -> Result<Vec<TcaScreeningHit>, TcaError> {
726 let primary = satellite_from_tle(primary_tle, TcaObject::Primary)?;
727 let secondaries = satellites_from_tles(secondary_tles)?;
728 screen_tca_candidates_parallel(
729 &primary,
730 &secondaries,
731 window.start,
732 window.end,
733 miss_distance_threshold_km,
734 options,
735 )
736}
737
738pub fn screen_tca_conjunctions_serial(
740 primary: &Satellite,
741 secondaries: &[Satellite],
742 window_start: JulianDate,
743 window_end: JulianDate,
744 miss_distance_threshold_km: f64,
745 tca_options: TcaFinderOptions,
746 pc_options: TcaPcOptions,
747) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
748 let hits = screen_tca_candidates_serial(
749 primary,
750 secondaries,
751 window_start,
752 window_end,
753 miss_distance_threshold_km,
754 tca_options,
755 )?;
756 screening_hits_to_conjunctions(hits, pc_options)
757}
758
759pub fn screen_tca_conjunctions_from_tle_catalog_serial(
761 primary_tle: TcaTle<'_>,
762 secondary_tles: &[TcaTle<'_>],
763 window: TcaWindow,
764 miss_distance_threshold_km: f64,
765 tca_options: TcaFinderOptions,
766 pc_options: TcaPcOptions,
767) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
768 let hits = screen_tca_candidates_from_tle_catalog_serial(
769 primary_tle,
770 secondary_tles,
771 window,
772 miss_distance_threshold_km,
773 tca_options,
774 )?;
775 screening_hits_to_conjunctions(hits, pc_options)
776}
777
778pub fn screen_tca_conjunctions_parallel(
780 primary: &Satellite,
781 secondaries: &[Satellite],
782 window_start: JulianDate,
783 window_end: JulianDate,
784 miss_distance_threshold_km: f64,
785 tca_options: TcaFinderOptions,
786 pc_options: TcaPcOptions,
787) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
788 let hits = screen_tca_candidates_parallel(
789 primary,
790 secondaries,
791 window_start,
792 window_end,
793 miss_distance_threshold_km,
794 tca_options,
795 )?;
796 screening_hits_to_conjunctions(hits, pc_options)
797}
798
799pub fn screen_tca_conjunctions_from_tle_catalog_parallel(
801 primary_tle: TcaTle<'_>,
802 secondary_tles: &[TcaTle<'_>],
803 window: TcaWindow,
804 miss_distance_threshold_km: f64,
805 tca_options: TcaFinderOptions,
806 pc_options: TcaPcOptions,
807) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
808 let hits = screen_tca_candidates_from_tle_catalog_parallel(
809 primary_tle,
810 secondary_tles,
811 window,
812 miss_distance_threshold_km,
813 tca_options,
814 )?;
815 screening_hits_to_conjunctions(hits, pc_options)
816}
817
818pub fn screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog_serial(
821 primary: TcaTleWithCovariance<'_>,
822 secondaries: &[TcaTleWithCovariance<'_>],
823 window: TcaWindow,
824 miss_distance_threshold_km: f64,
825 tca_options: TcaFinderOptions,
826 pc_options: TcaPropagatedCovarianceOptions,
827) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
828 screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog(
829 primary,
830 secondaries,
831 window,
832 miss_distance_threshold_km,
833 tca_options,
834 pc_options,
835 BatchMode::Serial,
836 )
837}
838
839pub fn screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog_parallel(
842 primary: TcaTleWithCovariance<'_>,
843 secondaries: &[TcaTleWithCovariance<'_>],
844 window: TcaWindow,
845 miss_distance_threshold_km: f64,
846 tca_options: TcaFinderOptions,
847 pc_options: TcaPropagatedCovarianceOptions,
848) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
849 screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog(
850 primary,
851 secondaries,
852 window,
853 miss_distance_threshold_km,
854 tca_options,
855 pc_options,
856 BatchMode::Parallel,
857 )
858}
859
860fn screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog(
861 primary: TcaTleWithCovariance<'_>,
862 secondaries: &[TcaTleWithCovariance<'_>],
863 window: TcaWindow,
864 miss_distance_threshold_km: f64,
865 tca_options: TcaFinderOptions,
866 pc_options: TcaPropagatedCovarianceOptions,
867 mode: BatchMode,
868) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
869 let primary_satellite = satellite_from_tle(primary.tle, TcaObject::Primary)?;
870 let secondary_satellites = satellites_from_tles_with_covariance(secondaries)?;
871 let hits = screen_tca_candidates_batched(
872 &primary_satellite,
873 &secondary_satellites,
874 window.start,
875 window.end,
876 miss_distance_threshold_km,
877 tca_options,
878 mode,
879 )?;
880 screening_hits_to_propagated_covariance_conjunctions(
881 hits,
882 &primary_satellite,
883 primary.covariance0,
884 &secondary_satellites,
885 secondaries,
886 pc_options,
887 )
888}
889
890pub fn tca_collision_probability(
896 candidate: TcaCandidate,
897 options: TcaPcOptions,
898) -> Result<TcaConjunction, TcaError> {
899 validate_tca_candidate_for_pc(candidate)?;
900 let pc_state = tca_candidate_relative_state_for_pc(candidate)?;
901 let primary_state = ConjunctionState {
902 position_km: pc_state.relative_position_km,
903 velocity_km_s: pc_state.relative_velocity_km_s,
904 covariance_km2: options.covariances.primary_covariance_km2,
905 };
906 let secondary_state = ConjunctionState {
907 position_km: [0.0; 3],
908 velocity_km_s: [0.0; 3],
909 covariance_km2: options.covariances.secondary_covariance_km2,
910 };
911 let collision_probability = collision_probability(
912 &primary_state,
913 &secondary_state,
914 options.hard_body_radius_km,
915 options.method,
916 )?;
917
918 Ok(TcaConjunction {
919 candidate,
920 collision_probability,
921 })
922}
923
924pub fn tca_collision_probability_with_propagated_covariance(
927 primary: &Satellite,
928 secondary: &Satellite,
929 candidate: TcaCandidate,
930 options: TcaPropagatedCovariancePcOptions,
931) -> Result<TcaConjunction, TcaError> {
932 validate_tca_candidate_for_pc(candidate)?;
933 let primary_covariance_km2 = propagate_position_covariance_to_tca(
934 primary,
935 TcaObject::Primary,
936 options.primary_covariance0,
937 candidate.tca_time,
938 options,
939 )?;
940 let secondary_covariance_km2 = propagate_position_covariance_to_tca(
941 secondary,
942 TcaObject::Secondary,
943 options.secondary_covariance0,
944 candidate.tca_time,
945 options,
946 )?;
947 let at_tca_options = TcaPcOptions::with_covariances(
948 options.hard_body_radius_km,
949 options.method,
950 primary_covariance_km2,
951 secondary_covariance_km2,
952 );
953 tca_collision_probability(candidate, at_tca_options)
954}
955
956#[derive(Debug, Clone, Copy)]
957enum BatchMode {
958 Serial,
959 Parallel,
960}
961
962fn screen_tca_candidates_batched(
963 primary: &Satellite,
964 secondaries: &[Satellite],
965 window_start: JulianDate,
966 window_end: JulianDate,
967 miss_distance_threshold_km: f64,
968 options: TcaFinderOptions,
969 mode: BatchMode,
970) -> Result<Vec<TcaScreeningHit>, TcaError> {
971 let span_seconds = validate_window(window_start, window_end)?;
972 let options = validate_options(options)?;
973 let miss_distance_threshold_km = validate_miss_distance_threshold(miss_distance_threshold_km)?;
974 if span_seconds <= 0.0 || secondaries.is_empty() {
975 return Ok(Vec::new());
976 }
977
978 let finder = EventFinder::new(
979 0.0,
980 span_seconds,
981 options.coarse_step_seconds,
982 options.time_tolerance_seconds,
983 )
984 .map_err(TcaError::EventFinder)?;
985 let ranges = secondaries
986 .iter()
987 .map(|secondary| RelativeRange::new(primary, secondary, window_start))
988 .collect::<Vec<_>>();
989 let predicates = ranges.iter().collect::<Vec<_>>();
990 let extrema_by_secondary = match mode {
991 BatchMode::Serial => finder.find_extrema_batch_serial(&predicates),
992 BatchMode::Parallel => finder.find_extrema_batch_parallel(&predicates),
993 };
994
995 let mut hits = Vec::new();
996 for ((secondary_index, secondary), (range, extrema_result)) in secondaries
997 .iter()
998 .enumerate()
999 .zip(ranges.iter().zip(extrema_by_secondary))
1000 {
1001 let extrema = extrema_result
1002 .map_err(|error| range.take_error().unwrap_or(TcaError::EventFinder(error)))?;
1003 let minima = minimum_extrema_including_boundaries(
1004 range,
1005 extrema,
1006 span_seconds,
1007 options.coarse_step_seconds,
1008 )?;
1009 for event in minima {
1010 let candidate = tca_candidate_from_extremum(primary, secondary, window_start, event)?;
1011 if candidate.miss_distance_km <= miss_distance_threshold_km {
1012 hits.push(TcaScreeningHit {
1013 secondary_index,
1014 candidate,
1015 });
1016 }
1017 }
1018 }
1019
1020 Ok(hits)
1021}
1022
1023fn minimum_extrema_including_boundaries(
1024 range: &RelativeRange<'_>,
1025 extrema: Vec<ExtremumEvent>,
1026 span_seconds: f64,
1027 coarse_step_seconds: f64,
1028) -> Result<Vec<ExtremumEvent>, TcaError> {
1029 minimum_extrema_including_boundaries_from_values(
1030 extrema,
1031 span_seconds,
1032 coarse_step_seconds,
1033 |time_seconds| finite_range_km_at(range, time_seconds),
1034 )
1035}
1036
1037fn minimum_extrema_including_boundaries_from_values(
1038 extrema: Vec<ExtremumEvent>,
1039 span_seconds: f64,
1040 coarse_step_seconds: f64,
1041 mut value_at: impl FnMut(f64) -> Result<f64, TcaError>,
1042) -> Result<Vec<ExtremumEvent>, TcaError> {
1043 let (start_neighbor_time, end_neighbor_time) =
1044 extrema_boundary_neighbor_times(span_seconds, coarse_step_seconds);
1045 let start_value = value_at(0.0)?;
1046 let start_neighbor_value = value_at(start_neighbor_time)?;
1047 let end_neighbor_value = value_at(end_neighbor_time)?;
1048 let end_value = value_at(span_seconds)?;
1049
1050 let mut minima = Vec::with_capacity(extrema.len() + 2);
1051 if is_strict_boundary_minimum(start_value, start_neighbor_value) {
1052 minima.push(ExtremumEvent {
1053 time_seconds: 0.0,
1054 value: start_value,
1055 kind: ExtremumKind::Minimum,
1056 });
1057 }
1058 minima.extend(
1059 extrema
1060 .into_iter()
1061 .filter(|event| event.kind == ExtremumKind::Minimum),
1062 );
1063 if is_strict_boundary_minimum(end_value, end_neighbor_value) {
1064 minima.push(ExtremumEvent {
1065 time_seconds: span_seconds,
1066 value: end_value,
1067 kind: ExtremumKind::Minimum,
1068 });
1069 }
1070 Ok(minima)
1071}
1072
1073fn extrema_boundary_neighbor_times(span_seconds: f64, coarse_step_seconds: f64) -> (f64, f64) {
1074 debug_assert!(span_seconds > 0.0);
1075 debug_assert!(coarse_step_seconds > 0.0);
1076
1077 let sample_iterations =
1078 ((span_seconds / coarse_step_seconds).ceil() as usize).saturating_add(1);
1079 let mut offset_seconds = 0.0;
1080 let mut sample_count = 0_usize;
1081 let mut start_neighbor_time = None;
1082 let mut end_neighbor_time = 0.0;
1083
1084 for _ in 0..sample_iterations {
1085 if offset_seconds >= span_seconds {
1086 break;
1087 }
1088 let time_seconds = offset_seconds;
1089 if time_seconds >= span_seconds {
1090 break;
1091 }
1092
1093 sample_count += 1;
1094 if sample_count == 2 {
1095 start_neighbor_time = Some(time_seconds);
1096 }
1097 end_neighbor_time = time_seconds;
1098
1099 let next_offset_seconds = offset_seconds + coarse_step_seconds;
1100 debug_assert!(next_offset_seconds > offset_seconds);
1101 offset_seconds = next_offset_seconds;
1102 }
1103
1104 if sample_count == 1 {
1105 let midpoint = span_seconds * 0.5;
1106 return (midpoint, midpoint);
1107 }
1108
1109 (
1110 start_neighbor_time.expect("multi-sample boundary has a start neighbor"),
1111 end_neighbor_time,
1112 )
1113}
1114
1115fn is_strict_boundary_minimum(boundary_value: f64, neighbor_value: f64) -> bool {
1116 neighbor_value - boundary_value > boundary_range_tolerance(boundary_value, neighbor_value)
1117}
1118
1119fn boundary_range_tolerance(a: f64, b: f64) -> f64 {
1120 BOUNDARY_RANGE_ABS_TOL_KM.max(BOUNDARY_RANGE_REL_TOL * a.abs().max(b.abs()).max(1.0))
1121}
1122
1123fn finite_range_km_at(range: &RelativeRange<'_>, time_seconds: f64) -> Result<f64, TcaError> {
1124 let value = range.range_km_at(time_seconds);
1125 if value.is_finite() {
1126 Ok(value)
1127 } else if let Some(error) = range.take_error() {
1128 Err(error)
1129 } else {
1130 Err(TcaError::EventFinder(EventFinderError::InvalidInput {
1131 field: "predicate",
1132 reason: "not finite",
1133 }))
1134 }
1135}
1136
1137fn satellites_from_tles(tles: &[TcaTle<'_>]) -> Result<Vec<Satellite>, TcaError> {
1138 tles.iter()
1139 .map(|tle| satellite_from_tle(*tle, TcaObject::Secondary))
1140 .collect()
1141}
1142
1143fn satellites_from_tles_with_covariance(
1144 tles: &[TcaTleWithCovariance<'_>],
1145) -> Result<Vec<Satellite>, TcaError> {
1146 tles.iter()
1147 .map(|tle| satellite_from_tle(tle.tle, TcaObject::Secondary))
1148 .collect()
1149}
1150
1151fn satellite_from_tle(tle: TcaTle<'_>, object: TcaObject) -> Result<Satellite, TcaError> {
1152 Satellite::from_tle(tle.line1, tle.line2).map_err(|source| TcaError::Init { object, source })
1153}
1154
1155fn screening_hits_to_conjunctions(
1156 hits: Vec<TcaScreeningHit>,
1157 pc_options: TcaPcOptions,
1158) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
1159 hits.into_iter()
1160 .map(|hit| {
1161 Ok(TcaScreeningConjunctionHit {
1162 secondary_index: hit.secondary_index,
1163 conjunction: tca_collision_probability(hit.candidate, pc_options)?,
1164 })
1165 })
1166 .collect()
1167}
1168
1169fn screening_hits_to_propagated_covariance_conjunctions(
1170 hits: Vec<TcaScreeningHit>,
1171 primary: &Satellite,
1172 primary_covariance0: Covariance6,
1173 secondaries: &[Satellite],
1174 secondary_objects: &[TcaTleWithCovariance<'_>],
1175 pc_options: TcaPropagatedCovarianceOptions,
1176) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
1177 hits.into_iter()
1178 .map(|hit| {
1179 let secondary = &secondaries[hit.secondary_index];
1180 let secondary_covariance0 = secondary_objects[hit.secondary_index].covariance0;
1181 let conjunction = tca_collision_probability_with_propagated_covariance(
1182 primary,
1183 secondary,
1184 hit.candidate,
1185 pc_options.for_initial_covariances(primary_covariance0, secondary_covariance0),
1186 )?;
1187 Ok(TcaScreeningConjunctionHit {
1188 secondary_index: hit.secondary_index,
1189 conjunction,
1190 })
1191 })
1192 .collect()
1193}
1194
1195fn tca_candidate_from_extremum(
1196 primary: &Satellite,
1197 secondary: &Satellite,
1198 window_start: JulianDate,
1199 event: ExtremumEvent,
1200) -> Result<TcaCandidate, TcaError> {
1201 let tca_time = add_seconds_to_julian_date(window_start, event.time_seconds);
1202 let state = relative_state_at(primary, secondary, tca_time)?;
1203 Ok(TcaCandidate {
1204 tca_time,
1205 tca_seconds_since_window_start: event.time_seconds,
1206 miss_distance_km: vec3::norm3(state.relative_position_km),
1207 relative_position_km: state.relative_position_km,
1208 relative_velocity_km_s: state.relative_velocity_km_s,
1209 })
1210}
1211
1212#[derive(Debug, Clone, Copy)]
1213struct RelativeState {
1214 relative_position_km: [f64; 3],
1215 relative_velocity_km_s: [f64; 3],
1216}
1217
1218fn relative_state_at(
1219 primary: &Satellite,
1220 secondary: &Satellite,
1221 time: JulianDate,
1222) -> Result<RelativeState, TcaError> {
1223 let primary_prediction = primary
1224 .propagate_jd(time)
1225 .map_err(|source| TcaError::Propagate {
1226 object: TcaObject::Primary,
1227 source,
1228 })?;
1229 let secondary_prediction =
1230 secondary
1231 .propagate_jd(time)
1232 .map_err(|source| TcaError::Propagate {
1233 object: TcaObject::Secondary,
1234 source,
1235 })?;
1236
1237 Ok(RelativeState {
1238 relative_position_km: vec3::sub3(
1239 primary_prediction.position,
1240 secondary_prediction.position,
1241 ),
1242 relative_velocity_km_s: vec3::sub3(
1243 primary_prediction.velocity,
1244 secondary_prediction.velocity,
1245 ),
1246 })
1247}
1248
1249fn tca_candidate_relative_state_for_pc(candidate: TcaCandidate) -> Result<RelativeState, TcaError> {
1250 let transform = teme_to_gcrs_state_transform_at(candidate.tca_time, TcaObject::Primary)?;
1251 let state = transform.transform_state(CartesianState::new(
1252 0.0,
1253 candidate.relative_position_km,
1254 candidate.relative_velocity_km_s,
1255 ));
1256 let state = RelativeState {
1257 relative_position_km: state.position_array(),
1258 relative_velocity_km_s: state.velocity_array(),
1259 };
1260 validate_relative_state_for_pc(state)?;
1261 Ok(state)
1262}
1263
1264fn propagate_position_covariance_to_tca(
1265 satellite: &Satellite,
1266 object: TcaObject,
1267 covariance0: Covariance6,
1268 tca_time: JulianDate,
1269 options: TcaPropagatedCovariancePcOptions,
1270) -> Result<[[f64; 3]; 3], TcaError> {
1271 validate_julian_date_fields(tca_time, "tca_time.whole", "tca_time.fraction")?;
1272 let epoch_time = satellite.epoch_jd();
1273 let span_seconds = seconds_between_julian_dates(epoch_time, tca_time);
1274 validate::finite(span_seconds, "tca_time").map_err(map_input)?;
1275
1276 let epoch_teme_to_gcrs = teme_to_gcrs_state_transform_at(epoch_time, object)?;
1277 let initial_state =
1278 epoch_teme_to_gcrs.transform_state(satellite_epoch_state(satellite, object)?);
1279 let covariance0 = epoch_teme_to_gcrs
1280 .transform_covariance(covariance0)
1281 .map_err(|source| TcaError::CovariancePropagation {
1282 object,
1283 reason: format!(
1284 "initial TEME->GCRS covariance transform failed: {}",
1285 covariance_error_reason(source)
1286 ),
1287 })?;
1288 let propagator = StatePropagator {
1289 initial: initial_state,
1290 force_model: options.force_model,
1291 integrator: options.integrator,
1292 options: options.integrator_options,
1293 };
1294 let (_, covariance_f) = propagator
1295 .propagate_state_with_covariance(covariance0, span_seconds)
1296 .map_err(|source| TcaError::CovariancePropagation {
1297 object,
1298 reason: source.to_string(),
1299 })?;
1300
1301 Ok(covariance_f.position_covariance_km2())
1302}
1303
1304fn satellite_epoch_state(
1305 satellite: &Satellite,
1306 object: TcaObject,
1307) -> Result<CartesianState, TcaError> {
1308 let prediction = satellite
1309 .propagate(MinutesSinceEpoch(0.0))
1310 .map_err(|source| TcaError::Propagate { object, source })?;
1311 Ok(CartesianState::new(
1312 0.0,
1313 prediction.position,
1314 prediction.velocity,
1315 ))
1316}
1317
1318fn teme_to_gcrs_rotation_at(time: JulianDate, _object: TcaObject) -> Result<Mat3, TcaError> {
1319 validate_julian_date_fields(time, "frame_time.whole", "frame_time.fraction")?;
1320 let time_scales = time_scales_from_sgp4_julian_date(time)?;
1321 Ok(teme_to_gcrs_matrix(&time_scales, false))
1322}
1323
1324fn teme_to_gcrs_state_transform_at(
1325 time: JulianDate,
1326 object: TcaObject,
1327) -> Result<TemeToGcrsStateTransform, TcaError> {
1328 let rotation = teme_to_gcrs_rotation_at(time, object)?;
1329 let before = teme_to_gcrs_rotation_at(
1330 add_seconds_to_julian_date(time, -FRAME_DERIVATIVE_STEP_SECONDS),
1331 object,
1332 )?;
1333 let after = teme_to_gcrs_rotation_at(
1334 add_seconds_to_julian_date(time, FRAME_DERIVATIVE_STEP_SECONDS),
1335 object,
1336 )?;
1337
1338 Ok(TemeToGcrsStateTransform {
1339 rotation,
1340 rotation_derivative: centered_rotation_derivative(
1341 &before,
1342 &after,
1343 FRAME_DERIVATIVE_STEP_SECONDS,
1344 ),
1345 })
1346}
1347
1348fn time_scales_from_sgp4_julian_date(time: JulianDate) -> Result<TimeScales, TcaError> {
1349 validate::finite(time.0, "julian_date").map_err(map_input)?;
1350 validate::finite(time.1, "julian_date").map_err(map_input)?;
1351
1352 let mut jd_whole = time.0.floor();
1353 let mut jd_fraction = (time.0 - jd_whole) + time.1;
1354 let fraction_day_offset = jd_fraction.floor();
1355 jd_whole += fraction_day_offset;
1356 jd_fraction -= fraction_day_offset;
1357
1358 let (jd_midnight, day_fraction) = if jd_fraction >= 0.5 {
1359 (jd_whole + 0.5, jd_fraction - 0.5)
1360 } else {
1361 (jd_whole - 0.5, jd_fraction + 0.5)
1362 };
1363
1364 julian_day_number_from_jd_midnight(jd_midnight)?;
1367 let (year, month, day, hour, minute, second) =
1368 civil_from_split_julian_date(jd_midnight, day_fraction);
1369
1370 TimeScales::from_utc(
1371 year as i32,
1372 month as i32,
1373 day as i32,
1374 hour as i32,
1375 minute as i32,
1376 second,
1377 )
1378 .map_err(|_| TcaError::InvalidInput {
1379 field: "julian_date",
1380 reason: "out of range",
1381 })
1382}
1383
1384fn julian_day_number_from_jd_midnight(jd_midnight: f64) -> Result<i64, TcaError> {
1385 let jdn = (jd_midnight + 0.5).round();
1386 let min_jdn = julian_day_number(0, 1, 1) as f64;
1387 let max_jdn = julian_day_number(9999, 12, 31) as f64;
1388 if jdn.is_finite() && (min_jdn..=max_jdn).contains(&jdn) {
1389 Ok(jdn as i64)
1390 } else {
1391 Err(TcaError::InvalidInput {
1392 field: "julian_date",
1393 reason: "out of range",
1394 })
1395 }
1396}
1397
1398fn centered_rotation_derivative(before: &Mat3, after: &Mat3, step_seconds: f64) -> Mat3 {
1399 let scale = 1.0 / (2.0 * step_seconds);
1400 let mut derivative = [[0.0_f64; 3]; 3];
1401 for i in 0..3 {
1402 for j in 0..3 {
1403 derivative[i][j] = (after[i][j] - before[i][j]) * scale;
1404 }
1405 }
1406 derivative
1407}
1408
1409#[derive(Clone, Copy)]
1410struct TemeToGcrsStateTransform {
1411 rotation: Mat3,
1412 rotation_derivative: Mat3,
1413}
1414
1415impl TemeToGcrsStateTransform {
1416 fn transform_state(self, state: CartesianState) -> CartesianState {
1417 let position_teme = state.position_array();
1418 let velocity_teme = state.velocity_array();
1419 let position_gcrs = mat3_vec3_mul_unchecked(&self.rotation, &position_teme);
1420 let velocity_rotated = mat3_vec3_mul_unchecked(&self.rotation, &velocity_teme);
1421 let velocity_coupled = mat3_vec3_mul_unchecked(&self.rotation_derivative, &position_teme);
1422 CartesianState::new(
1423 state.epoch_tdb_seconds,
1424 position_gcrs,
1425 vec3::add3(velocity_rotated, velocity_coupled),
1426 )
1427 }
1428
1429 fn transform_covariance(
1430 self,
1431 covariance: Covariance6,
1432 ) -> Result<Covariance6, Covariance6Error> {
1433 covariance.propagate_with_stm(&self.state_jacobian())
1434 }
1435
1436 fn state_jacobian(self) -> Mat6 {
1437 let mut jacobian = [[0.0_f64; 6]; 6];
1438 for row in 0..3 {
1439 for col in 0..3 {
1440 jacobian[row][col] = self.rotation[row][col];
1441 jacobian[row + 3][col] = self.rotation_derivative[row][col];
1442 jacobian[row + 3][col + 3] = self.rotation[row][col];
1443 }
1444 }
1445 jacobian
1446 }
1447}
1448
1449fn covariance_error_reason(error: Covariance6Error) -> &'static str {
1450 match error {
1451 Covariance6Error::NonFinite => "not finite",
1452 Covariance6Error::Asymmetric => "not symmetric",
1453 Covariance6Error::NotPositiveSemidefinite => "not positive semidefinite",
1454 }
1455}
1456
1457struct RelativeRange<'a> {
1458 primary: &'a Satellite,
1459 secondary: &'a Satellite,
1460 window_start: JulianDate,
1461 first_error: Mutex<Option<TcaError>>,
1462}
1463
1464impl<'a> RelativeRange<'a> {
1465 fn new(primary: &'a Satellite, secondary: &'a Satellite, window_start: JulianDate) -> Self {
1466 Self {
1467 primary,
1468 secondary,
1469 window_start,
1470 first_error: Mutex::new(None),
1471 }
1472 }
1473
1474 fn range_km_at(&self, time_seconds: f64) -> f64 {
1475 let time = add_seconds_to_julian_date(self.window_start, time_seconds);
1476 match relative_state_at(self.primary, self.secondary, time) {
1477 Ok(state) => vec3::norm3(state.relative_position_km),
1478 Err(error) => {
1479 self.record_error(error);
1480 f64::NAN
1481 }
1482 }
1483 }
1484
1485 fn record_error(&self, error: TcaError) {
1486 if let Ok(mut first_error) = self.first_error.lock() {
1487 if first_error.is_none() {
1488 *first_error = Some(error);
1489 }
1490 }
1491 }
1492
1493 fn take_error(&self) -> Option<TcaError> {
1494 self.first_error
1495 .lock()
1496 .ok()
1497 .and_then(|mut first_error| first_error.take())
1498 }
1499}
1500
1501impl ScalarEventPredicate for &RelativeRange<'_> {
1502 fn value_at(&self, time_seconds: f64) -> f64 {
1503 self.range_km_at(time_seconds)
1504 }
1505}
1506
1507fn validate_options(options: TcaFinderOptions) -> Result<TcaFinderOptions, TcaError> {
1508 validate::positive_step(options.coarse_step_seconds, "coarse_step_seconds")
1509 .map_err(map_input)?;
1510 validate::positive_step(options.time_tolerance_seconds, "time_tolerance_seconds")
1511 .map_err(map_input)?;
1512 Ok(options)
1513}
1514
1515fn validate_miss_distance_threshold(miss_distance_threshold_km: f64) -> Result<f64, TcaError> {
1516 validate::finite_nonneg(miss_distance_threshold_km, "miss_distance_threshold_km")
1517 .map_err(map_input)
1518}
1519
1520fn validate_tca_candidate_for_pc(candidate: TcaCandidate) -> Result<(), TcaError> {
1521 validate_julian_date_fields(
1522 candidate.tca_time,
1523 "candidate.tca_time.whole",
1524 "candidate.tca_time.fraction",
1525 )?;
1526 validate::finite(
1527 candidate.tca_seconds_since_window_start,
1528 "candidate.tca_seconds_since_window_start",
1529 )
1530 .map_err(map_input)?;
1531 validate::finite_nonneg(candidate.miss_distance_km, "candidate.miss_distance_km")
1532 .map_err(map_input)?;
1533 validate_bounded_vec3(
1534 candidate.relative_position_km,
1535 MAX_TCA_RELATIVE_POSITION_KM,
1536 "candidate.relative_position_km",
1537 )?;
1538 validate_bounded_vec3(
1539 candidate.relative_velocity_km_s,
1540 MAX_TCA_RELATIVE_VELOCITY_KM_S,
1541 "candidate.relative_velocity_km_s",
1542 )?;
1543 Ok(())
1544}
1545
1546fn validate_relative_state_for_pc(state: RelativeState) -> Result<(), TcaError> {
1547 validate_bounded_vec3(
1548 state.relative_position_km,
1549 MAX_TCA_RELATIVE_POSITION_KM,
1550 "relative_position_km",
1551 )?;
1552 validate_bounded_vec3(
1553 state.relative_velocity_km_s,
1554 MAX_TCA_RELATIVE_VELOCITY_KM_S,
1555 "relative_velocity_km_s",
1556 )?;
1557 Ok(())
1558}
1559
1560fn validate_bounded_vec3(
1561 value: [f64; 3],
1562 max_abs: f64,
1563 field: &'static str,
1564) -> Result<(), TcaError> {
1565 validate::finite_vec3(value, field).map_err(map_input)?;
1566 if value.iter().any(|component| component.abs() > max_abs) {
1567 return Err(TcaError::InvalidInput {
1568 field,
1569 reason: "out of range",
1570 });
1571 }
1572 Ok(())
1573}
1574
1575fn validate_window(window_start: JulianDate, window_end: JulianDate) -> Result<f64, TcaError> {
1576 validate_julian_date_fields(window_start, "window_start.whole", "window_start.fraction")?;
1577 validate_julian_date_fields(window_end, "window_end.whole", "window_end.fraction")?;
1578 let span_seconds = seconds_between_julian_dates(window_start, window_end);
1579 validate::finite_nonneg(span_seconds, "window_end").map_err(map_input)
1580}
1581
1582fn validate_julian_date_fields(
1583 time: JulianDate,
1584 whole_field: &'static str,
1585 fraction_field: &'static str,
1586) -> Result<(), TcaError> {
1587 validate::finite(time.0, whole_field).map_err(map_input)?;
1588 validate::finite(time.1, fraction_field).map_err(map_input)?;
1589 Ok(())
1590}
1591
1592fn seconds_between_julian_dates(start: JulianDate, end: JulianDate) -> f64 {
1593 (end.0 - start.0) * SECONDS_PER_DAY + (end.1 - start.1) * SECONDS_PER_DAY
1594}
1595
1596fn add_seconds_to_julian_date(start: JulianDate, seconds: f64) -> JulianDate {
1597 let (whole, fraction) = split_julian_date_add_seconds(start.0, start.1, seconds);
1598 JulianDate(whole, fraction)
1599}
1600
1601fn map_input(error: validate::FieldError) -> TcaError {
1602 TcaError::InvalidInput {
1603 field: error.field(),
1604 reason: error.reason(),
1605 }
1606}
1607
1608pub fn screen_catalog_pairs(
1618 positions_km: &[[f64; 3]],
1619 miss_threshold_km: f64,
1620) -> Vec<(usize, usize, f64)> {
1621 let mut pairs = Vec::new();
1622 for i in 0..positions_km.len() {
1623 for j in (i + 1)..positions_km.len() {
1624 let miss_km = vec3::norm3(vec3::sub3(positions_km[i], positions_km[j]));
1625 if miss_km <= miss_threshold_km {
1626 pairs.push((i, j, miss_km));
1627 }
1628 }
1629 }
1630 pairs
1631}
1632
1633pub fn screen_state_vector_catalog(
1639 objects: &[CatalogStateVector],
1640 options: CatalogScreeningOptions,
1641) -> Vec<CatalogScreeningResult> {
1642 let positions = objects
1643 .iter()
1644 .map(|object| object.position_km)
1645 .collect::<Vec<_>>();
1646 let mut results = screen_catalog_pairs(&positions, options.miss_threshold_km)
1647 .into_iter()
1648 .map(|(i, j, miss_km)| {
1649 let obj1 = &objects[i];
1650 let obj2 = &objects[j];
1651 let candidate = CatalogScreeningCandidate {
1652 i,
1653 j,
1654 id1: obj1.id.clone(),
1655 id2: obj2.id.clone(),
1656 miss_km,
1657 };
1658 let state1 = ConjunctionState {
1659 position_km: obj1.position_km,
1660 velocity_km_s: obj1.velocity_km_s,
1661 covariance_km2: obj1.covariance_km2,
1662 };
1663 let state2 = ConjunctionState {
1664 position_km: obj2.position_km,
1665 velocity_km_s: obj2.velocity_km_s,
1666 covariance_km2: obj2.covariance_km2,
1667 };
1668 match collision_probability(
1669 &state1,
1670 &state2,
1671 obj1.hard_body_radius_km + obj2.hard_body_radius_km,
1672 options.method,
1673 ) {
1674 Ok(probability) => CatalogScreeningResult {
1675 candidate,
1676 collision: Some(CatalogCollision {
1677 probability,
1678 method: options.method,
1679 }),
1680 error: None,
1681 },
1682 Err(error) => CatalogScreeningResult {
1683 candidate,
1684 collision: None,
1685 error: Some(error),
1686 },
1687 }
1688 })
1689 .filter(|result| {
1690 result
1691 .collision
1692 .as_ref()
1693 .is_none_or(|collision| collision.probability.pc >= options.pc_threshold)
1694 })
1695 .collect::<Vec<_>>();
1696
1697 results.sort_by(|a, b| catalog_result_pc(b).total_cmp(&catalog_result_pc(a)));
1698 results
1699}
1700
1701fn catalog_result_pc(result: &CatalogScreeningResult) -> f64 {
1702 result
1703 .collision
1704 .as_ref()
1705 .map_or(0.0, |collision| collision.probability.pc)
1706}
1707
1708#[cfg(test)]
1709mod tests {
1710 use super::*;
1711 use crate::astro::time::civil::civil_from_julian_day_number;
1712
1713 #[test]
1714 fn screen_catalog_pairs_thresholds_and_orders() {
1715 let positions = [[7000.0, 0.0, 0.0], [7000.1, 0.0, 0.0], [8000.0, 0.0, 0.0]];
1717
1718 let pairs = screen_catalog_pairs(&positions, 1.0);
1719 assert_eq!(pairs.len(), 1);
1720 let (i, j, miss) = pairs[0];
1721 assert_eq!((i, j), (0, 1));
1722 assert!((miss - 0.1).abs() < 1.0e-9);
1723
1724 let all = screen_catalog_pairs(&positions, 1.0e9);
1726 assert_eq!(
1727 all.iter().map(|(i, j, _)| (*i, *j)).collect::<Vec<_>>(),
1728 vec![(0, 1), (0, 2), (1, 2)]
1729 );
1730
1731 assert!(screen_catalog_pairs(&[], 1.0).is_empty());
1733 assert!(screen_catalog_pairs(&[[0.0, 0.0, 0.0]], 1.0).is_empty());
1734 }
1735
1736 #[test]
1737 fn state_vector_catalog_driver_matches_manual_prefilter_and_pc() {
1738 let cov = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
1739 let objects = vec![
1740 CatalogStateVector {
1741 id: Some("A".to_string()),
1742 position_km: [7000.0, 0.0, 0.0],
1743 velocity_km_s: [0.0, 0.0, 0.0],
1744 covariance_km2: cov,
1745 hard_body_radius_km: 0.01,
1746 },
1747 CatalogStateVector {
1748 id: Some("B".to_string()),
1749 position_km: [7000.02, 0.0, 0.0],
1750 velocity_km_s: [0.0, 7.5, 0.0],
1751 covariance_km2: cov,
1752 hard_body_radius_km: 0.01,
1753 },
1754 CatalogStateVector {
1755 id: Some("C".to_string()),
1756 position_km: [7000.04, 0.0, 0.0],
1757 velocity_km_s: [0.0, 0.0, 0.0],
1758 covariance_km2: cov,
1759 hard_body_radius_km: 0.02,
1760 },
1761 ];
1762 let options = CatalogScreeningOptions {
1763 miss_threshold_km: 0.05,
1764 pc_threshold: 0.0,
1765 method: PcMethod::FosterEqualArea,
1766 };
1767
1768 let driver = screen_state_vector_catalog(&objects, options);
1769 let positions = objects
1770 .iter()
1771 .map(|object| object.position_km)
1772 .collect::<Vec<_>>();
1773 let mut manual = screen_catalog_pairs(&positions, options.miss_threshold_km)
1774 .into_iter()
1775 .map(|(i, j, miss_km)| {
1776 let obj1 = &objects[i];
1777 let obj2 = &objects[j];
1778 let candidate = CatalogScreeningCandidate {
1779 i,
1780 j,
1781 id1: obj1.id.clone(),
1782 id2: obj2.id.clone(),
1783 miss_km,
1784 };
1785 let state1 = ConjunctionState {
1786 position_km: obj1.position_km,
1787 velocity_km_s: obj1.velocity_km_s,
1788 covariance_km2: obj1.covariance_km2,
1789 };
1790 let state2 = ConjunctionState {
1791 position_km: obj2.position_km,
1792 velocity_km_s: obj2.velocity_km_s,
1793 covariance_km2: obj2.covariance_km2,
1794 };
1795 match collision_probability(
1796 &state1,
1797 &state2,
1798 obj1.hard_body_radius_km + obj2.hard_body_radius_km,
1799 options.method,
1800 ) {
1801 Ok(probability) => CatalogScreeningResult {
1802 candidate,
1803 collision: Some(CatalogCollision {
1804 probability,
1805 method: options.method,
1806 }),
1807 error: None,
1808 },
1809 Err(error) => CatalogScreeningResult {
1810 candidate,
1811 collision: None,
1812 error: Some(error),
1813 },
1814 }
1815 })
1816 .collect::<Vec<_>>();
1817 manual.sort_by(|a, b| catalog_result_pc(b).total_cmp(&catalog_result_pc(a)));
1818
1819 assert_eq!(driver, manual);
1820 assert!(driver
1821 .iter()
1822 .any(|result| result.error == Some(ConjunctionError::UndefinedFrame)));
1823 }
1824
1825 const ISS_L1: &str = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
1826 const ISS_L2: &str = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
1827 const ISS_FAST_L1: &str =
1828 "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
1829 const ISS_FAST_L2: &str =
1830 "2 25544 51.6414 295.8524 0003435 262.6267 202.7868 15.64005638121106";
1831 const ISS_OPPOSITE_L2: &str =
1832 "2 25544 51.6414 295.8524 0003435 262.6267 24.2868 15.54005638121106";
1833
1834 #[test]
1835 fn constructed_tles_find_expected_close_approach() {
1836 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
1837 let start = primary.epoch_jd();
1838 let end = add_seconds_to_julian_date(start, 12_000.0);
1839 let candidates = find_tca_candidates_from_tles(
1840 ISS_L1,
1841 ISS_L2,
1842 ISS_FAST_L1,
1843 ISS_FAST_L2,
1844 start,
1845 end,
1846 TcaFinderOptions {
1847 coarse_step_seconds: 30.0,
1848 time_tolerance_seconds: 1.0e-4,
1849 },
1850 )
1851 .expect("TCA search succeeds");
1852
1853 assert_eq!(candidates.len(), 1);
1854 let best = candidates[0];
1855
1856 assert_close(
1857 best.tca_seconds_since_window_start,
1858 3_599.834_762_793_019_3,
1859 2.0e-4,
1860 );
1861 assert_close(best.miss_distance_km, 28.942_032_135_766_88, 1.0e-9);
1862 assert_close(
1863 vec3::norm3(best.relative_position_km),
1864 best.miss_distance_km,
1865 1.0e-12,
1866 );
1867 assert!(vec3::norm3(best.relative_velocity_km_s) > 0.0);
1868 }
1869
1870 #[test]
1871 fn tca_candidates_include_window_boundary_minima() {
1872 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
1873 let secondary =
1874 Satellite::from_tle(ISS_FAST_L1, ISS_FAST_L2).expect("secondary TLE parses");
1875 let options = TcaFinderOptions {
1876 coarse_step_seconds: 30.0,
1877 time_tolerance_seconds: 1.0e-4,
1878 };
1879 let full_start = primary.epoch_jd();
1880 let full_end = add_seconds_to_julian_date(full_start, 12_000.0);
1881 let interior = find_tca_candidates(&primary, &secondary, full_start, full_end, options)
1882 .expect("interior TCA search succeeds");
1883 assert_eq!(interior.len(), 1);
1884 let tca = interior[0];
1885
1886 let start_boundary_end = add_seconds_to_julian_date(tca.tca_time, 600.0);
1887 let start_boundary = find_tca_candidates(
1888 &primary,
1889 &secondary,
1890 tca.tca_time,
1891 start_boundary_end,
1892 options,
1893 )
1894 .expect("start-boundary TCA search succeeds");
1895
1896 assert_eq!(start_boundary.len(), 1);
1897 assert_close(start_boundary[0].tca_seconds_since_window_start, 0.0, 0.0);
1898 assert_close(
1899 start_boundary[0].miss_distance_km,
1900 tca.miss_distance_km,
1901 1.0e-9,
1902 );
1903
1904 let end_boundary_start = add_seconds_to_julian_date(tca.tca_time, -600.0);
1905 let end_boundary = find_tca_candidates(
1906 &primary,
1907 &secondary,
1908 end_boundary_start,
1909 tca.tca_time,
1910 options,
1911 )
1912 .expect("end-boundary TCA search succeeds");
1913
1914 assert_eq!(end_boundary.len(), 1);
1915 assert_close(
1916 end_boundary[0].tca_seconds_since_window_start,
1917 600.0,
1918 1.0e-8,
1919 );
1920 assert_close(
1921 end_boundary[0].miss_distance_km,
1922 tca.miss_distance_km,
1923 1.0e-9,
1924 );
1925 }
1926
1927 #[test]
1928 fn tca_candidates_suppress_constant_range_boundary_minima() {
1929 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
1930 let secondary = Satellite::from_tle(ISS_L1, ISS_L2).expect("secondary TLE parses");
1931 let start = primary.epoch_jd();
1932 let end = add_seconds_to_julian_date(start, 900.0);
1933 let options = TcaFinderOptions {
1934 coarse_step_seconds: 30.0,
1935 time_tolerance_seconds: 1.0e-4,
1936 };
1937
1938 let candidates = find_tca_candidates(&primary, &secondary, start, end, options)
1939 .expect("constant-range TCA search succeeds");
1940
1941 assert!(
1942 candidates.is_empty(),
1943 "constant relative range should not emit boundary TCAs: {candidates:?}"
1944 );
1945 }
1946
1947 #[test]
1948 fn tca_boundary_minimum_uses_last_partial_coarse_sample() {
1949 let minima = minimum_extrema_including_boundaries_from_values(
1950 Vec::new(),
1951 95.0,
1952 30.0,
1953 |time_seconds| {
1954 Ok(sampled_range_value(
1955 time_seconds,
1956 &[
1957 (0.0, 10.0),
1958 (30.0, 9.0),
1959 (65.0, 9.0),
1960 (90.0, 11.0),
1961 (95.0, 10.0),
1962 ],
1963 ))
1964 },
1965 )
1966 .expect("boundary classification succeeds");
1967
1968 assert_eq!(minima.len(), 1);
1969 assert_close(minima[0].time_seconds, 95.0, 0.0);
1970 assert_close(minima[0].value, 10.0, 0.0);
1971 assert_eq!(minima[0].kind, ExtremumKind::Minimum);
1972 }
1973
1974 #[test]
1975 fn tca_boundary_minimum_uses_repeated_addition_penultimate_sample() {
1976 let minima = minimum_extrema_including_boundaries_from_values(
1977 Vec::new(),
1978 1.05,
1979 0.1,
1980 |time_seconds| {
1981 Ok(sampled_range_value(
1982 time_seconds,
1983 &[
1984 (0.0, 10.0),
1985 (0.1, 9.0),
1986 (0.999_999_999_999_999_9, 11.0),
1987 (1.0, 9.0),
1988 (1.05, 10.0),
1989 ],
1990 ))
1991 },
1992 )
1993 .expect("boundary classification succeeds");
1994
1995 assert_eq!(minima.len(), 1);
1996 assert_close(minima[0].time_seconds, 1.05, 0.0);
1997 assert_close(minima[0].value, 10.0, 0.0);
1998 assert_eq!(minima[0].kind, ExtremumKind::Minimum);
1999 }
2000
2001 #[test]
2002 fn tca_boundary_minimum_uses_inserted_midpoint_sample() {
2003 let midpoint_minimum = ExtremumEvent {
2004 time_seconds: 5.0,
2005 value: 8.0,
2006 kind: ExtremumKind::Minimum,
2007 };
2008 let minima = minimum_extrema_including_boundaries_from_values(
2009 vec![midpoint_minimum],
2010 10.0,
2011 30.0,
2012 |time_seconds| {
2013 Ok(sampled_range_value(
2014 time_seconds,
2015 &[(0.0, 9.0), (5.0, 8.0), (10.0, 10.0)],
2016 ))
2017 },
2018 )
2019 .expect("boundary classification succeeds");
2020
2021 assert_eq!(minima, vec![midpoint_minimum]);
2022 }
2023
2024 #[test]
2025 fn tca_boundary_neighbor_times_keep_exact_multiple_window() {
2026 assert_eq!(extrema_boundary_neighbor_times(120.0, 30.0), (30.0, 90.0));
2027 }
2028
2029 #[test]
2030 fn tca_candidate_pipeline_deduplicates_flat_bottom_close_approach() {
2031 let start = JulianDate(2_458_303.0, 0.0);
2032 let extrema = EventFinder::new(0.0, 3.0, 1.0, 1.0e-12)
2033 .expect("valid finder")
2034 .find_extrema(flat_bottom_range_km)
2035 .expect("finite flat-bottom range");
2036 let candidates: Vec<_> = extrema
2037 .into_iter()
2038 .filter(|event| event.kind == ExtremumKind::Minimum)
2039 .map(|event| TcaCandidate {
2040 tca_time: add_seconds_to_julian_date(start, event.time_seconds),
2041 tca_seconds_since_window_start: event.time_seconds,
2042 miss_distance_km: event.value,
2043 relative_position_km: [event.value, 0.0, 0.0],
2044 relative_velocity_km_s: [0.0; 3],
2045 })
2046 .collect();
2047
2048 assert_eq!(candidates.len(), 1);
2049 assert!((1.0..=2.0).contains(&candidates[0].tca_seconds_since_window_start));
2050 assert_close(candidates[0].miss_distance_km, 1.0, 1.0e-12);
2051 }
2052
2053 #[test]
2054 fn teme_to_gcrs_covariance_transport_uses_full_state_jacobian() {
2055 let transform = teme_to_gcrs_state_transform_at(
2056 JulianDate(2_458_303.0, 0.809_691_02),
2057 TcaObject::Primary,
2058 )
2059 .expect("TEME->GCRS state transform is valid");
2060 let covariance = Covariance6::from_diagonal([100.0, 144.0, 196.0, 1.0e-4, 1.5e-4, 2.0e-4])
2061 .expect("test covariance is valid");
2062
2063 let actual = transform
2064 .transform_covariance(covariance)
2065 .expect("full-jacobian transform preserves covariance validity");
2066 let expected =
2067 manual_full_jacobian_covariance(covariance.as_matrix(), &transform.state_jacobian())
2068 .expect("manual full-jacobian covariance is valid");
2069 assert_covariance_close(actual.as_matrix(), expected.as_matrix(), 1.0e-10);
2070
2071 let position_only = covariance
2072 .propagate_with_stm(&position_only_state_jacobian(&transform.rotation))
2073 .expect("position-only transform preserves covariance validity");
2074 assert_covariance_diff_exceeds(actual.as_matrix(), position_only.as_matrix(), 1.0e-12);
2075 assert!(actual.is_symmetric());
2076 assert!(actual.is_positive_semidefinite());
2077 }
2078
2079 #[test]
2080 fn split_jd_preserves_civil_day_just_before_midnight() {
2081 let epsilon_days = 2.0e-10;
2082 let tca_time = JulianDate(2_458_303.0, 0.5 - epsilon_days);
2083 assert_eq!(tca_time.0 + tca_time.1, 2_458_303.5);
2084
2085 let day_fraction = tca_time.1 + 0.5;
2086 let seconds_of_day = day_fraction * SECONDS_PER_DAY;
2087 let expected_second = seconds_of_day - 23.0 * 3600.0 - 59.0 * 60.0;
2088 let expected =
2089 TimeScales::from_utc(2018, 7, 3, 23, 59, expected_second).expect("valid UTC instant");
2090 let actual =
2091 time_scales_from_sgp4_julian_date(tca_time).expect("split JD converts to time scales");
2092 assert_eq!(actual, expected);
2093
2094 let one_day_late =
2095 TimeScales::from_utc(2018, 7, 4, 23, 59, expected_second).expect("valid UTC instant");
2096 assert_ne!(actual, one_day_late);
2097
2098 let actual_rotation =
2099 teme_to_gcrs_rotation_at(tca_time, TcaObject::Primary).expect("split rotation");
2100 let expected_rotation = teme_to_gcrs_matrix(&expected, false);
2101 assert_eq!(actual_rotation, expected_rotation);
2102 let one_day_late_rotation = teme_to_gcrs_matrix(&one_day_late, false);
2103 assert_matrix_diff_exceeds(&actual_rotation, &one_day_late_rotation, 1.0e-8);
2104
2105 let candidate = TcaCandidate {
2106 tca_time,
2107 tca_seconds_since_window_start: 0.0,
2108 miss_distance_km: vec3::norm3([0.012, -0.017, 0.006]),
2109 relative_position_km: [0.012, -0.017, 0.006],
2110 relative_velocity_km_s: [0.09, 7.4, -1.2],
2111 };
2112 let pc_options = TcaPcOptions::with_covariances(
2113 0.010,
2114 PcMethod::Alfano2005,
2115 [
2116 [4.0e-4, 1.0e-5, -2.0e-5],
2117 [1.0e-5, 2.5e-5, 3.0e-6],
2118 [-2.0e-5, 3.0e-6, 9.0e-5],
2119 ],
2120 [[1.0e-5, 0.0, 0.0], [0.0, 6.4e-5, 0.0], [0.0, 0.0, 2.5e-5]],
2121 );
2122 let actual_pc = tca_collision_probability(candidate, pc_options)
2123 .expect("split-JD Pc")
2124 .collision_probability;
2125 let one_day_late_pc = tca_collision_probability(
2126 TcaCandidate {
2127 tca_time: JulianDate(tca_time.0 + 1.0, tca_time.1),
2128 ..candidate
2129 },
2130 pc_options,
2131 )
2132 .expect("one-day-late Pc")
2133 .collision_probability;
2134 assert_probability_diff_exceeds(actual_pc.pc, one_day_late_pc.pc, 1.0e-10);
2135 }
2136
2137 #[test]
2138 fn split_jd_matches_summed_path_away_from_midnight() {
2139 let midday = JulianDate(2_458_303.0, 0.0);
2140
2141 let actual =
2142 time_scales_from_sgp4_julian_date(midday).expect("midday converts to time scales");
2143 let summed = summed_time_scales_from_sgp4_julian_date(midday);
2144 assert_eq!(actual, summed);
2145
2146 let actual_rotation =
2147 teme_to_gcrs_rotation_at(midday, TcaObject::Primary).expect("midday rotation");
2148 let summed_rotation = summed_teme_to_gcrs_rotation_at(midday);
2149 assert_eq!(actual_rotation, summed_rotation);
2150 }
2151
2152 #[test]
2153 fn tca_pc_rejects_unconvertible_julian_dates_as_invalid_input() {
2154 let options = TcaPcOptions::with_default_covariance(0.010, PcMethod::Alfano2005);
2155
2156 assert_eq!(
2157 tca_collision_probability(tca_pc_test_candidate(JulianDate(1.0e20, 0.0)), options),
2158 Err(TcaError::InvalidInput {
2159 field: "julian_date",
2160 reason: "out of range",
2161 })
2162 );
2163 assert!(matches!(
2164 tca_collision_probability(
2165 tca_pc_test_candidate(JulianDate(f64::INFINITY, 0.0)),
2166 options
2167 ),
2168 Err(TcaError::InvalidInput {
2169 reason: "not finite",
2170 ..
2171 })
2172 ));
2173 }
2174
2175 #[test]
2176 fn tca_pc_rejects_invalid_candidate_relative_state_before_transform() {
2177 let options = TcaPcOptions::with_default_covariance(0.010, PcMethod::Alfano2005);
2178 let mut candidate = tca_pc_test_candidate(JulianDate(2_458_303.0, 0.0));
2179 candidate.relative_position_km[0] = f64::NAN;
2180
2181 assert_eq!(
2182 tca_collision_probability(candidate, options),
2183 Err(TcaError::InvalidInput {
2184 field: "candidate.relative_position_km",
2185 reason: "not finite",
2186 })
2187 );
2188
2189 candidate.relative_position_km = [MAX_TCA_RELATIVE_POSITION_KM * 2.0, 0.0, 0.0];
2190 assert_eq!(
2191 tca_collision_probability(candidate, options),
2192 Err(TcaError::InvalidInput {
2193 field: "candidate.relative_position_km",
2194 reason: "out of range",
2195 })
2196 );
2197 }
2198
2199 #[test]
2200 fn tca_pc_in_range_julian_date_matches_summed_reference_path() {
2201 let candidate = tca_pc_test_candidate(JulianDate(2_458_303.0, 0.0));
2202 let options = TcaPcOptions::with_default_covariance(0.010, PcMethod::Alfano2005);
2203
2204 let actual_state =
2205 tca_candidate_relative_state_for_pc(candidate).expect("in-range frame transform");
2206 let expected_transform = summed_teme_to_gcrs_state_transform_at(candidate.tca_time);
2207 let expected_state = expected_transform.transform_state(CartesianState::new(
2208 0.0,
2209 candidate.relative_position_km,
2210 candidate.relative_velocity_km_s,
2211 ));
2212 assert_eq!(
2213 actual_state.relative_position_km,
2214 expected_state.position_array()
2215 );
2216 assert_eq!(
2217 actual_state.relative_velocity_km_s,
2218 expected_state.velocity_array()
2219 );
2220
2221 let actual = tca_collision_probability(candidate, options).expect("in-range TCA Pc");
2222 let expected_primary = ConjunctionState {
2223 position_km: expected_state.position_array(),
2224 velocity_km_s: expected_state.velocity_array(),
2225 covariance_km2: options.covariances.primary_covariance_km2,
2226 };
2227 let expected_secondary = ConjunctionState {
2228 position_km: [0.0; 3],
2229 velocity_km_s: [0.0; 3],
2230 covariance_km2: options.covariances.secondary_covariance_km2,
2231 };
2232 let expected = collision_probability(
2233 &expected_primary,
2234 &expected_secondary,
2235 options.hard_body_radius_km,
2236 options.method,
2237 )
2238 .expect("summed-reference TCA Pc");
2239 assert_eq!(actual.candidate, candidate);
2240 assert_eq!(actual.collision_probability, expected);
2241 }
2242
2243 #[test]
2244 fn invalid_window_is_rejected() {
2245 let start = JulianDate(2_458_303.0, 0.5);
2246 let end = JulianDate(2_458_303.0, 0.4);
2247 assert_eq!(
2248 find_tca_candidates_from_tles(
2249 ISS_L1,
2250 ISS_L2,
2251 ISS_FAST_L1,
2252 ISS_FAST_L2,
2253 start,
2254 end,
2255 TcaFinderOptions::default(),
2256 ),
2257 Err(TcaError::InvalidInput {
2258 field: "window_end",
2259 reason: "negative",
2260 })
2261 );
2262 }
2263
2264 #[test]
2265 fn screening_returns_only_threshold_breaches_and_parallel_matches_serial() {
2266 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
2267 let far = Satellite::from_tle(ISS_FAST_L1, ISS_OPPOSITE_L2).expect("far TLE parses");
2268 let close = Satellite::from_tle(ISS_FAST_L1, ISS_FAST_L2).expect("close TLE parses");
2269 let secondaries = [far, close];
2270 let start = primary.epoch_jd();
2271 let end = add_seconds_to_julian_date(start, 12_000.0);
2272 let options = TcaFinderOptions {
2273 coarse_step_seconds: 30.0,
2274 time_tolerance_seconds: 1.0e-4,
2275 };
2276
2277 let serial =
2278 screen_tca_candidates_serial(&primary, &secondaries, start, end, 30.0, options)
2279 .expect("serial screening succeeds");
2280 let parallel =
2281 screen_tca_candidates_parallel(&primary, &secondaries, start, end, 30.0, options)
2282 .expect("parallel screening succeeds");
2283
2284 assert_eq!(serial, parallel);
2285 assert_eq!(serial.len(), 1);
2286
2287 let hit = serial[0];
2288 assert_eq!(hit.secondary_index, 1);
2289 assert_close(
2290 hit.candidate.tca_seconds_since_window_start,
2291 3_599.834_762_793_019_3,
2292 2.0e-4,
2293 );
2294 assert_close(
2295 hit.candidate.miss_distance_km,
2296 28.942_032_135_766_88,
2297 1.0e-9,
2298 );
2299 assert!(hit.candidate.miss_distance_km <= 30.0);
2300 }
2301
2302 #[test]
2303 fn tca_pc_matches_direct_conjunction_call_with_supplied_covariance() {
2304 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
2305 let secondary =
2306 Satellite::from_tle(ISS_FAST_L1, ISS_FAST_L2).expect("secondary TLE parses");
2307 let start = primary.epoch_jd();
2308 let end = add_seconds_to_julian_date(start, 12_000.0);
2309 let tca_options = TcaFinderOptions {
2310 coarse_step_seconds: 30.0,
2311 time_tolerance_seconds: 1.0e-4,
2312 };
2313 let candidates = find_tca_candidates(&primary, &secondary, start, end, tca_options)
2314 .expect("TCA search succeeds");
2315 assert_eq!(candidates.len(), 1);
2316
2317 let candidate = candidates[0];
2318 let primary_covariance_km2 = [[0.04, 0.001, 0.0], [0.001, 0.09, 0.002], [0.0, 0.002, 0.16]];
2319 let secondary_covariance_km2 = [
2320 [0.01, 0.0, 0.0],
2321 [0.0, 0.0225, 0.0005],
2322 [0.0, 0.0005, 0.0625],
2323 ];
2324 let pc_options = TcaPcOptions::with_covariances(
2325 0.020,
2326 PcMethod::Alfano2005,
2327 primary_covariance_km2,
2328 secondary_covariance_km2,
2329 );
2330
2331 let conjunction =
2332 tca_collision_probability(candidate, pc_options).expect("Pc is defined at TCA");
2333 let pc_state =
2334 tca_candidate_relative_state_for_pc(candidate).expect("candidate converts to GCRS");
2335 let direct_primary = ConjunctionState {
2336 position_km: pc_state.relative_position_km,
2337 velocity_km_s: pc_state.relative_velocity_km_s,
2338 covariance_km2: primary_covariance_km2,
2339 };
2340 let direct_secondary = ConjunctionState {
2341 position_km: [0.0; 3],
2342 velocity_km_s: [0.0; 3],
2343 covariance_km2: secondary_covariance_km2,
2344 };
2345 let direct = collision_probability(
2346 &direct_primary,
2347 &direct_secondary,
2348 pc_options.hard_body_radius_km,
2349 pc_options.method,
2350 )
2351 .expect("direct Pc is defined");
2352
2353 assert_eq!(conjunction.candidate, candidate);
2354 assert_eq!(conjunction.collision_probability, direct);
2355
2356 let from_finder =
2357 find_tca_conjunctions(&primary, &secondary, start, end, tca_options, pc_options)
2358 .expect("TCA Pc search succeeds");
2359 assert_eq!(from_finder, vec![conjunction]);
2360 }
2361
2362 #[test]
2363 fn tca_pc_with_initial_covariances_matches_direct_pc_after_manual_propagation() {
2364 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
2365 let secondary =
2366 Satellite::from_tle(ISS_FAST_L1, ISS_FAST_L2).expect("secondary TLE parses");
2367 let start = primary.epoch_jd();
2368 let end = add_seconds_to_julian_date(start, 12_000.0);
2369 let tca_options = TcaFinderOptions {
2370 coarse_step_seconds: 30.0,
2371 time_tolerance_seconds: 1.0e-4,
2372 };
2373 let candidates = find_tca_candidates(&primary, &secondary, start, end, tca_options)
2374 .expect("TCA search succeeds");
2375 assert_eq!(candidates.len(), 1);
2376
2377 let candidate = candidates[0];
2378 let primary_covariance0 =
2379 Covariance6::from_diagonal([100.0, 144.0, 196.0, 1.0e-4, 1.5e-4, 2.0e-4]).unwrap();
2380 let secondary_covariance0 =
2381 Covariance6::from_diagonal([64.0, 81.0, 121.0, 8.0e-5, 9.0e-5, 1.0e-4]).unwrap();
2382 let pc_options = TcaPropagatedCovariancePcOptions::new(
2383 0.020,
2384 PcMethod::Alfano2005,
2385 primary_covariance0,
2386 secondary_covariance0,
2387 )
2388 .with_covariance_propagator(
2389 ForceModelKind::two_body_j2(),
2390 IntegratorKind::Rk4,
2391 IntegratorOptions {
2392 initial_step: 30.0,
2393 ..IntegratorOptions::default()
2394 },
2395 );
2396
2397 let conjunction = tca_collision_probability_with_propagated_covariance(
2398 &primary, &secondary, candidate, pc_options,
2399 )
2400 .expect("propagated covariance Pc is defined");
2401
2402 let primary_covariance_km2 =
2403 manual_position_covariance_at_tca(&primary, primary_covariance0, candidate, pc_options);
2404 let secondary_covariance_km2 = manual_position_covariance_at_tca(
2405 &secondary,
2406 secondary_covariance0,
2407 candidate,
2408 pc_options,
2409 );
2410 assert_ne!(
2411 primary_covariance_km2,
2412 primary_covariance0.position_covariance_km2()
2413 );
2414 assert_ne!(
2415 secondary_covariance_km2,
2416 secondary_covariance0.position_covariance_km2()
2417 );
2418 assert_matrix_diff_exceeds(
2419 &primary_covariance_km2,
2420 &pre_fix_position_covariance_at_tca(
2421 &primary,
2422 primary_covariance0,
2423 candidate,
2424 pc_options,
2425 ),
2426 1.0e-6,
2427 );
2428 assert_matrix_diff_exceeds(
2429 &secondary_covariance_km2,
2430 &pre_fix_position_covariance_at_tca(
2431 &secondary,
2432 secondary_covariance0,
2433 candidate,
2434 pc_options,
2435 ),
2436 1.0e-6,
2437 );
2438
2439 let pc_state =
2440 tca_candidate_relative_state_for_pc(candidate).expect("candidate converts to GCRS");
2441 let direct_primary = ConjunctionState {
2442 position_km: pc_state.relative_position_km,
2443 velocity_km_s: pc_state.relative_velocity_km_s,
2444 covariance_km2: primary_covariance_km2,
2445 };
2446 let direct_secondary = ConjunctionState {
2447 position_km: [0.0; 3],
2448 velocity_km_s: [0.0; 3],
2449 covariance_km2: secondary_covariance_km2,
2450 };
2451 let direct = collision_probability(
2452 &direct_primary,
2453 &direct_secondary,
2454 pc_options.hard_body_radius_km,
2455 pc_options.method,
2456 )
2457 .expect("direct propagated-covariance Pc is defined");
2458
2459 assert_eq!(conjunction.candidate, candidate);
2460 assert_eq!(conjunction.collision_probability, direct);
2461
2462 let unconverted_primary = ConjunctionState {
2463 position_km: candidate.relative_position_km,
2464 velocity_km_s: candidate.relative_velocity_km_s,
2465 covariance_km2: primary_covariance_km2,
2466 };
2467 let unconverted = collision_probability(
2468 &unconverted_primary,
2469 &direct_secondary,
2470 pc_options.hard_body_radius_km,
2471 pc_options.method,
2472 )
2473 .expect("unconverted TEME candidate still produces a Pc");
2474 assert_probability_diff_exceeds(direct.pc, unconverted.pc, 1.0e-20);
2475
2476 let from_finder = find_tca_conjunctions_with_propagated_covariance(
2477 &primary,
2478 &secondary,
2479 start,
2480 end,
2481 tca_options,
2482 pc_options,
2483 )
2484 .expect("propagated covariance TCA Pc search succeeds");
2485 assert_eq!(from_finder, vec![conjunction]);
2486 }
2487
2488 #[test]
2489 fn tle_catalog_screening_propagates_initial_covariances_to_pc() {
2490 let primary_satellite = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
2491 let start = primary_satellite.epoch_jd();
2492 let window = TcaWindow::from_start_and_duration_seconds(start, 12_000.0)
2493 .expect("window duration is valid");
2494 let tca_options = TcaFinderOptions {
2495 coarse_step_seconds: 30.0,
2496 time_tolerance_seconds: 1.0e-4,
2497 };
2498 let primary_covariance0 =
2499 Covariance6::from_diagonal([100.0, 144.0, 196.0, 1.0e-4, 1.5e-4, 2.0e-4]).unwrap();
2500 let secondary_covariance0 =
2501 Covariance6::from_diagonal([64.0, 81.0, 121.0, 8.0e-5, 9.0e-5, 1.0e-4]).unwrap();
2502 let primary = TcaTleWithCovariance::new(ISS_L1, ISS_L2, primary_covariance0);
2503 let secondary = TcaTleWithCovariance::new(ISS_FAST_L1, ISS_FAST_L2, secondary_covariance0);
2504 let pc_options = TcaPropagatedCovarianceOptions::new(0.020, PcMethod::Alfano2005)
2505 .with_covariance_propagator(
2506 ForceModelKind::two_body_j2(),
2507 IntegratorKind::Rk4,
2508 IntegratorOptions {
2509 initial_step: 30.0,
2510 ..IntegratorOptions::default()
2511 },
2512 );
2513
2514 let secondaries = [secondary];
2515 let serial = screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog_serial(
2516 primary,
2517 &secondaries,
2518 window,
2519 30.0,
2520 tca_options,
2521 pc_options,
2522 )
2523 .expect("serial propagated-covariance screening succeeds");
2524 let parallel =
2525 screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog_parallel(
2526 primary,
2527 &secondaries,
2528 window,
2529 30.0,
2530 tca_options,
2531 pc_options,
2532 )
2533 .expect("parallel propagated-covariance screening succeeds");
2534
2535 assert_eq!(serial, parallel);
2536 assert_eq!(serial.len(), 1);
2537 assert_eq!(serial[0].secondary_index, 0);
2538 assert!(serial[0].conjunction.candidate.miss_distance_km <= 30.0);
2539 assert!(serial[0].conjunction.collision_probability.pc.is_finite());
2540 assert!((0.0..=1.0).contains(&serial[0].conjunction.collision_probability.pc));
2541
2542 let pairwise = find_tca_conjunctions_with_propagated_covariance_between_tles(
2543 primary.tle,
2544 secondary.tle,
2545 window,
2546 tca_options,
2547 pc_options.for_initial_covariances(primary_covariance0, secondary_covariance0),
2548 )
2549 .expect("pairwise propagated-covariance TCA Pc search succeeds");
2550 assert_eq!(serial[0].conjunction, pairwise[0]);
2551 }
2552
2553 fn flat_bottom_range_km(time_seconds: f64) -> f64 {
2554 if time_seconds < 1.0 {
2555 2.0 - time_seconds
2556 } else if time_seconds <= 2.0 {
2557 1.0
2558 } else {
2559 time_seconds - 1.0
2560 }
2561 }
2562
2563 fn sampled_range_value(time_seconds: f64, samples: &[(f64, f64)]) -> f64 {
2564 samples
2565 .iter()
2566 .find_map(|(sample_time, value)| {
2567 (time_seconds.to_bits() == sample_time.to_bits()).then_some(*value)
2568 })
2569 .unwrap_or_else(|| panic!("unexpected boundary sample time {time_seconds}"))
2570 }
2571
2572 fn assert_close(actual: f64, expected: f64, tolerance: f64) {
2573 assert!(
2574 (actual - expected).abs() <= tolerance,
2575 "{actual} differs from {expected} by more than {tolerance}"
2576 );
2577 }
2578
2579 fn assert_matrix_diff_exceeds(actual: &Mat3, expected: &Mat3, threshold: f64) {
2580 let mut max_diff = 0.0_f64;
2581 for i in 0..3 {
2582 for j in 0..3 {
2583 max_diff = max_diff.max((actual[i][j] - expected[i][j]).abs());
2584 }
2585 }
2586 assert!(
2587 max_diff > threshold,
2588 "matrix diff {max_diff} did not exceed {threshold}"
2589 );
2590 }
2591
2592 fn assert_probability_diff_exceeds(actual: f64, expected: f64, threshold: f64) {
2593 let diff = (actual - expected).abs();
2594 assert!(
2595 diff > threshold,
2596 "probability diff {diff} did not exceed {threshold}: {actual} vs {expected}"
2597 );
2598 }
2599
2600 fn assert_covariance_close(actual: &Mat6, expected: &Mat6, tolerance: f64) {
2601 for i in 0..6 {
2602 for j in 0..6 {
2603 assert!(
2604 (actual[i][j] - expected[i][j]).abs() <= tolerance,
2605 "covariance[{i}][{j}] = {} differs from {} by more than {tolerance}",
2606 actual[i][j],
2607 expected[i][j]
2608 );
2609 }
2610 }
2611 }
2612
2613 fn assert_covariance_diff_exceeds(actual: &Mat6, expected: &Mat6, threshold: f64) {
2614 let mut max_diff = 0.0_f64;
2615 for i in 0..6 {
2616 for j in 0..6 {
2617 max_diff = max_diff.max((actual[i][j] - expected[i][j]).abs());
2618 }
2619 }
2620 assert!(
2621 max_diff > threshold,
2622 "covariance diff {max_diff} did not exceed {threshold}"
2623 );
2624 }
2625
2626 fn summed_time_scales_from_sgp4_julian_date(time: JulianDate) -> TimeScales {
2627 let jd_total = time.0 + time.1;
2628 let mut jd_midnight = (jd_total - 0.5).floor() + 0.5;
2629 let mut day_fraction = (time.0 - jd_midnight) + time.1;
2630 if !(0.0..1.0).contains(&day_fraction) {
2631 let day_offset = day_fraction.floor();
2632 jd_midnight += day_offset;
2633 day_fraction -= day_offset;
2634 }
2635
2636 let jdn = (jd_midnight + 0.5).round() as i64;
2637 let (year, month, day) = civil_from_julian_day_number(jdn);
2638 let seconds_of_day = day_fraction * SECONDS_PER_DAY;
2639 let hour = (seconds_of_day / 3600.0).floor() as i32;
2640 let minute = ((seconds_of_day - f64::from(hour) * 3600.0) / 60.0).floor() as i32;
2641 let second = seconds_of_day - f64::from(hour) * 3600.0 - f64::from(minute) * 60.0;
2642
2643 TimeScales::from_utc(year as i32, month as i32, day as i32, hour, minute, second)
2644 .expect("summed JD test instant is valid")
2645 }
2646
2647 fn summed_teme_to_gcrs_rotation_at(time: JulianDate) -> Mat3 {
2648 let time_scales = summed_time_scales_from_sgp4_julian_date(time);
2649 teme_to_gcrs_matrix(&time_scales, false)
2650 }
2651
2652 fn summed_teme_to_gcrs_state_transform_at(time: JulianDate) -> TemeToGcrsStateTransform {
2653 let rotation = summed_teme_to_gcrs_rotation_at(time);
2654 let before = summed_teme_to_gcrs_rotation_at(add_seconds_to_julian_date(
2655 time,
2656 -FRAME_DERIVATIVE_STEP_SECONDS,
2657 ));
2658 let after = summed_teme_to_gcrs_rotation_at(add_seconds_to_julian_date(
2659 time,
2660 FRAME_DERIVATIVE_STEP_SECONDS,
2661 ));
2662
2663 TemeToGcrsStateTransform {
2664 rotation,
2665 rotation_derivative: centered_rotation_derivative(
2666 &before,
2667 &after,
2668 FRAME_DERIVATIVE_STEP_SECONDS,
2669 ),
2670 }
2671 }
2672
2673 fn tca_pc_test_candidate(tca_time: JulianDate) -> TcaCandidate {
2674 TcaCandidate {
2675 tca_time,
2676 tca_seconds_since_window_start: 0.0,
2677 miss_distance_km: vec3::norm3([0.012, -0.017, 0.006]),
2678 relative_position_km: [0.012, -0.017, 0.006],
2679 relative_velocity_km_s: [0.09, 7.4, -1.2],
2680 }
2681 }
2682
2683 #[allow(clippy::needless_range_loop)]
2684 fn manual_full_jacobian_covariance(
2685 covariance: &Mat6,
2686 jacobian: &Mat6,
2687 ) -> Result<Covariance6, Covariance6Error> {
2688 let mut transformed = [[0.0_f64; 6]; 6];
2689 for i in 0..6 {
2690 for j in 0..6 {
2691 for k in 0..6 {
2692 for l in 0..6 {
2693 transformed[i][j] += jacobian[i][k] * covariance[k][l] * jacobian[j][l];
2694 }
2695 }
2696 }
2697 }
2698 symmetrize6_for_test(&mut transformed);
2699 Covariance6::try_from_matrix(transformed)
2700 }
2701
2702 fn position_only_state_jacobian(rotation: &Mat3) -> Mat6 {
2703 let mut jacobian = [[0.0_f64; 6]; 6];
2704 for row in 0..3 {
2705 for col in 0..3 {
2706 jacobian[row][col] = rotation[row][col];
2707 jacobian[row + 3][col + 3] = rotation[row][col];
2708 }
2709 }
2710 jacobian
2711 }
2712
2713 #[allow(clippy::needless_range_loop)]
2714 fn symmetrize6_for_test(matrix: &mut Mat6) {
2715 for i in 0..6 {
2716 for j in (i + 1)..6 {
2717 let avg = 0.5 * (matrix[i][j] + matrix[j][i]);
2718 matrix[i][j] = avg;
2719 matrix[j][i] = avg;
2720 }
2721 }
2722 }
2723
2724 fn manual_position_covariance_at_tca(
2725 satellite: &Satellite,
2726 covariance0: Covariance6,
2727 candidate: TcaCandidate,
2728 options: TcaPropagatedCovariancePcOptions,
2729 ) -> [[f64; 3]; 3] {
2730 let epoch_state = satellite
2731 .propagate(MinutesSinceEpoch(0.0))
2732 .expect("satellite propagates at its epoch");
2733 let epoch = satellite.epoch_jd();
2734 let span_seconds = seconds_between_julian_dates(epoch, candidate.tca_time);
2735 let epoch_teme_to_gcrs = teme_to_gcrs_state_transform_at(epoch, TcaObject::Primary)
2736 .expect("epoch frame transform");
2737 let initial = epoch_teme_to_gcrs.transform_state(CartesianState::new(
2738 0.0,
2739 epoch_state.position,
2740 epoch_state.velocity,
2741 ));
2742 let covariance0 = epoch_teme_to_gcrs
2743 .transform_covariance(covariance0)
2744 .expect("initial covariance rotates into GCRS");
2745 let propagator = StatePropagator {
2746 initial,
2747 force_model: options.force_model,
2748 integrator: options.integrator,
2749 options: options.integrator_options,
2750 };
2751 let (_, covariance_f) = propagator
2752 .propagate_state_with_covariance(covariance0, span_seconds)
2753 .expect("manual covariance propagation succeeds");
2754 covariance_f.position_covariance_km2()
2755 }
2756
2757 fn pre_fix_position_covariance_at_tca(
2758 satellite: &Satellite,
2759 covariance0: Covariance6,
2760 candidate: TcaCandidate,
2761 options: TcaPropagatedCovariancePcOptions,
2762 ) -> [[f64; 3]; 3] {
2763 let epoch_state = satellite
2764 .propagate(MinutesSinceEpoch(0.0))
2765 .expect("satellite propagates at its epoch");
2766 let epoch = satellite.epoch_jd();
2767 let span_seconds = seconds_between_julian_dates(epoch, candidate.tca_time);
2768 let propagator = StatePropagator {
2769 initial: CartesianState::new(0.0, epoch_state.position, epoch_state.velocity),
2770 force_model: options.force_model,
2771 integrator: options.integrator,
2772 options: options.integrator_options,
2773 };
2774 let (_, covariance_f) = propagator
2775 .propagate_state_with_covariance(covariance0, span_seconds)
2776 .expect("pre-fix covariance propagation succeeds");
2777 covariance_f.position_covariance_km2()
2778 }
2779}