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 drag: None,
1294 };
1295 let (_, covariance_f) = propagator
1296 .propagate_state_with_covariance(covariance0, span_seconds)
1297 .map_err(|source| TcaError::CovariancePropagation {
1298 object,
1299 reason: source.to_string(),
1300 })?;
1301
1302 Ok(covariance_f.position_covariance_km2())
1303}
1304
1305fn satellite_epoch_state(
1306 satellite: &Satellite,
1307 object: TcaObject,
1308) -> Result<CartesianState, TcaError> {
1309 let prediction = satellite
1310 .propagate(MinutesSinceEpoch(0.0))
1311 .map_err(|source| TcaError::Propagate { object, source })?;
1312 Ok(CartesianState::new(
1313 0.0,
1314 prediction.position,
1315 prediction.velocity,
1316 ))
1317}
1318
1319fn teme_to_gcrs_rotation_at(time: JulianDate, _object: TcaObject) -> Result<Mat3, TcaError> {
1320 validate_julian_date_fields(time, "frame_time.whole", "frame_time.fraction")?;
1321 let time_scales = time_scales_from_sgp4_julian_date(time)?;
1322 Ok(teme_to_gcrs_matrix(&time_scales, false))
1323}
1324
1325fn teme_to_gcrs_state_transform_at(
1326 time: JulianDate,
1327 object: TcaObject,
1328) -> Result<TemeToGcrsStateTransform, TcaError> {
1329 let rotation = teme_to_gcrs_rotation_at(time, object)?;
1330 let before = teme_to_gcrs_rotation_at(
1331 add_seconds_to_julian_date(time, -FRAME_DERIVATIVE_STEP_SECONDS),
1332 object,
1333 )?;
1334 let after = teme_to_gcrs_rotation_at(
1335 add_seconds_to_julian_date(time, FRAME_DERIVATIVE_STEP_SECONDS),
1336 object,
1337 )?;
1338
1339 Ok(TemeToGcrsStateTransform {
1340 rotation,
1341 rotation_derivative: centered_rotation_derivative(
1342 &before,
1343 &after,
1344 FRAME_DERIVATIVE_STEP_SECONDS,
1345 ),
1346 })
1347}
1348
1349fn time_scales_from_sgp4_julian_date(time: JulianDate) -> Result<TimeScales, TcaError> {
1350 validate::finite(time.0, "julian_date").map_err(map_input)?;
1351 validate::finite(time.1, "julian_date").map_err(map_input)?;
1352
1353 let mut jd_whole = time.0.floor();
1354 let mut jd_fraction = (time.0 - jd_whole) + time.1;
1355 let fraction_day_offset = jd_fraction.floor();
1356 jd_whole += fraction_day_offset;
1357 jd_fraction -= fraction_day_offset;
1358
1359 let (jd_midnight, day_fraction) = if jd_fraction >= 0.5 {
1360 (jd_whole + 0.5, jd_fraction - 0.5)
1361 } else {
1362 (jd_whole - 0.5, jd_fraction + 0.5)
1363 };
1364
1365 julian_day_number_from_jd_midnight(jd_midnight)?;
1368 let (year, month, day, hour, minute, second) =
1369 civil_from_split_julian_date(jd_midnight, day_fraction);
1370
1371 TimeScales::from_utc(
1372 year as i32,
1373 month as i32,
1374 day as i32,
1375 hour as i32,
1376 minute as i32,
1377 second,
1378 )
1379 .map_err(|_| TcaError::InvalidInput {
1380 field: "julian_date",
1381 reason: "out of range",
1382 })
1383}
1384
1385fn julian_day_number_from_jd_midnight(jd_midnight: f64) -> Result<i64, TcaError> {
1386 let jdn = (jd_midnight + 0.5).round();
1387 let min_jdn = julian_day_number(0, 1, 1) as f64;
1388 let max_jdn = julian_day_number(9999, 12, 31) as f64;
1389 if jdn.is_finite() && (min_jdn..=max_jdn).contains(&jdn) {
1390 Ok(jdn as i64)
1391 } else {
1392 Err(TcaError::InvalidInput {
1393 field: "julian_date",
1394 reason: "out of range",
1395 })
1396 }
1397}
1398
1399fn centered_rotation_derivative(before: &Mat3, after: &Mat3, step_seconds: f64) -> Mat3 {
1400 let scale = 1.0 / (2.0 * step_seconds);
1401 let mut derivative = [[0.0_f64; 3]; 3];
1402 for i in 0..3 {
1403 for j in 0..3 {
1404 derivative[i][j] = (after[i][j] - before[i][j]) * scale;
1405 }
1406 }
1407 derivative
1408}
1409
1410#[derive(Clone, Copy)]
1411struct TemeToGcrsStateTransform {
1412 rotation: Mat3,
1413 rotation_derivative: Mat3,
1414}
1415
1416impl TemeToGcrsStateTransform {
1417 fn transform_state(self, state: CartesianState) -> CartesianState {
1418 let position_teme = state.position_array();
1419 let velocity_teme = state.velocity_array();
1420 let position_gcrs = mat3_vec3_mul_unchecked(&self.rotation, &position_teme);
1421 let velocity_rotated = mat3_vec3_mul_unchecked(&self.rotation, &velocity_teme);
1422 let velocity_coupled = mat3_vec3_mul_unchecked(&self.rotation_derivative, &position_teme);
1423 CartesianState::new(
1424 state.epoch_tdb_seconds,
1425 position_gcrs,
1426 vec3::add3(velocity_rotated, velocity_coupled),
1427 )
1428 }
1429
1430 fn transform_covariance(
1431 self,
1432 covariance: Covariance6,
1433 ) -> Result<Covariance6, Covariance6Error> {
1434 covariance.propagate_with_stm(&self.state_jacobian())
1435 }
1436
1437 fn state_jacobian(self) -> Mat6 {
1438 let mut jacobian = [[0.0_f64; 6]; 6];
1439 for row in 0..3 {
1440 for col in 0..3 {
1441 jacobian[row][col] = self.rotation[row][col];
1442 jacobian[row + 3][col] = self.rotation_derivative[row][col];
1443 jacobian[row + 3][col + 3] = self.rotation[row][col];
1444 }
1445 }
1446 jacobian
1447 }
1448}
1449
1450fn covariance_error_reason(error: Covariance6Error) -> &'static str {
1451 match error {
1452 Covariance6Error::NonFinite => "not finite",
1453 Covariance6Error::Asymmetric => "not symmetric",
1454 Covariance6Error::NotPositiveSemidefinite => "not positive semidefinite",
1455 }
1456}
1457
1458struct RelativeRange<'a> {
1459 primary: &'a Satellite,
1460 secondary: &'a Satellite,
1461 window_start: JulianDate,
1462 first_error: Mutex<Option<TcaError>>,
1463}
1464
1465impl<'a> RelativeRange<'a> {
1466 fn new(primary: &'a Satellite, secondary: &'a Satellite, window_start: JulianDate) -> Self {
1467 Self {
1468 primary,
1469 secondary,
1470 window_start,
1471 first_error: Mutex::new(None),
1472 }
1473 }
1474
1475 fn range_km_at(&self, time_seconds: f64) -> f64 {
1476 let time = add_seconds_to_julian_date(self.window_start, time_seconds);
1477 match relative_state_at(self.primary, self.secondary, time) {
1478 Ok(state) => vec3::norm3(state.relative_position_km),
1479 Err(error) => {
1480 self.record_error(error);
1481 f64::NAN
1482 }
1483 }
1484 }
1485
1486 fn record_error(&self, error: TcaError) {
1487 if let Ok(mut first_error) = self.first_error.lock() {
1488 if first_error.is_none() {
1489 *first_error = Some(error);
1490 }
1491 }
1492 }
1493
1494 fn take_error(&self) -> Option<TcaError> {
1495 self.first_error
1496 .lock()
1497 .ok()
1498 .and_then(|mut first_error| first_error.take())
1499 }
1500}
1501
1502impl ScalarEventPredicate for &RelativeRange<'_> {
1503 fn value_at(&self, time_seconds: f64) -> f64 {
1504 self.range_km_at(time_seconds)
1505 }
1506}
1507
1508fn validate_options(options: TcaFinderOptions) -> Result<TcaFinderOptions, TcaError> {
1509 validate::positive_step(options.coarse_step_seconds, "coarse_step_seconds")
1510 .map_err(map_input)?;
1511 validate::positive_step(options.time_tolerance_seconds, "time_tolerance_seconds")
1512 .map_err(map_input)?;
1513 Ok(options)
1514}
1515
1516fn validate_miss_distance_threshold(miss_distance_threshold_km: f64) -> Result<f64, TcaError> {
1517 validate::finite_nonneg(miss_distance_threshold_km, "miss_distance_threshold_km")
1518 .map_err(map_input)
1519}
1520
1521fn validate_tca_candidate_for_pc(candidate: TcaCandidate) -> Result<(), TcaError> {
1522 validate_julian_date_fields(
1523 candidate.tca_time,
1524 "candidate.tca_time.whole",
1525 "candidate.tca_time.fraction",
1526 )?;
1527 validate::finite(
1528 candidate.tca_seconds_since_window_start,
1529 "candidate.tca_seconds_since_window_start",
1530 )
1531 .map_err(map_input)?;
1532 validate::finite_nonneg(candidate.miss_distance_km, "candidate.miss_distance_km")
1533 .map_err(map_input)?;
1534 validate_bounded_vec3(
1535 candidate.relative_position_km,
1536 MAX_TCA_RELATIVE_POSITION_KM,
1537 "candidate.relative_position_km",
1538 )?;
1539 validate_bounded_vec3(
1540 candidate.relative_velocity_km_s,
1541 MAX_TCA_RELATIVE_VELOCITY_KM_S,
1542 "candidate.relative_velocity_km_s",
1543 )?;
1544 Ok(())
1545}
1546
1547fn validate_relative_state_for_pc(state: RelativeState) -> Result<(), TcaError> {
1548 validate_bounded_vec3(
1549 state.relative_position_km,
1550 MAX_TCA_RELATIVE_POSITION_KM,
1551 "relative_position_km",
1552 )?;
1553 validate_bounded_vec3(
1554 state.relative_velocity_km_s,
1555 MAX_TCA_RELATIVE_VELOCITY_KM_S,
1556 "relative_velocity_km_s",
1557 )?;
1558 Ok(())
1559}
1560
1561fn validate_bounded_vec3(
1562 value: [f64; 3],
1563 max_abs: f64,
1564 field: &'static str,
1565) -> Result<(), TcaError> {
1566 validate::finite_vec3(value, field).map_err(map_input)?;
1567 if value.iter().any(|component| component.abs() > max_abs) {
1568 return Err(TcaError::InvalidInput {
1569 field,
1570 reason: "out of range",
1571 });
1572 }
1573 Ok(())
1574}
1575
1576fn validate_window(window_start: JulianDate, window_end: JulianDate) -> Result<f64, TcaError> {
1577 validate_julian_date_fields(window_start, "window_start.whole", "window_start.fraction")?;
1578 validate_julian_date_fields(window_end, "window_end.whole", "window_end.fraction")?;
1579 let span_seconds = seconds_between_julian_dates(window_start, window_end);
1580 validate::finite_nonneg(span_seconds, "window_end").map_err(map_input)
1581}
1582
1583fn validate_julian_date_fields(
1584 time: JulianDate,
1585 whole_field: &'static str,
1586 fraction_field: &'static str,
1587) -> Result<(), TcaError> {
1588 validate::finite(time.0, whole_field).map_err(map_input)?;
1589 validate::finite(time.1, fraction_field).map_err(map_input)?;
1590 Ok(())
1591}
1592
1593fn seconds_between_julian_dates(start: JulianDate, end: JulianDate) -> f64 {
1594 (end.0 - start.0) * SECONDS_PER_DAY + (end.1 - start.1) * SECONDS_PER_DAY
1595}
1596
1597fn add_seconds_to_julian_date(start: JulianDate, seconds: f64) -> JulianDate {
1598 let (whole, fraction) = split_julian_date_add_seconds(start.0, start.1, seconds);
1599 JulianDate(whole, fraction)
1600}
1601
1602fn map_input(error: validate::FieldError) -> TcaError {
1603 TcaError::InvalidInput {
1604 field: error.field(),
1605 reason: error.reason(),
1606 }
1607}
1608
1609pub fn screen_catalog_pairs(
1619 positions_km: &[[f64; 3]],
1620 miss_threshold_km: f64,
1621) -> Vec<(usize, usize, f64)> {
1622 let mut pairs = Vec::new();
1623 for i in 0..positions_km.len() {
1624 for j in (i + 1)..positions_km.len() {
1625 let miss_km = vec3::norm3(vec3::sub3(positions_km[i], positions_km[j]));
1626 if miss_km <= miss_threshold_km {
1627 pairs.push((i, j, miss_km));
1628 }
1629 }
1630 }
1631 pairs
1632}
1633
1634pub fn screen_state_vector_catalog(
1640 objects: &[CatalogStateVector],
1641 options: CatalogScreeningOptions,
1642) -> Vec<CatalogScreeningResult> {
1643 let positions = objects
1644 .iter()
1645 .map(|object| object.position_km)
1646 .collect::<Vec<_>>();
1647 let mut results = screen_catalog_pairs(&positions, options.miss_threshold_km)
1648 .into_iter()
1649 .map(|(i, j, miss_km)| {
1650 let obj1 = &objects[i];
1651 let obj2 = &objects[j];
1652 let candidate = CatalogScreeningCandidate {
1653 i,
1654 j,
1655 id1: obj1.id.clone(),
1656 id2: obj2.id.clone(),
1657 miss_km,
1658 };
1659 let state1 = ConjunctionState {
1660 position_km: obj1.position_km,
1661 velocity_km_s: obj1.velocity_km_s,
1662 covariance_km2: obj1.covariance_km2,
1663 };
1664 let state2 = ConjunctionState {
1665 position_km: obj2.position_km,
1666 velocity_km_s: obj2.velocity_km_s,
1667 covariance_km2: obj2.covariance_km2,
1668 };
1669 match collision_probability(
1670 &state1,
1671 &state2,
1672 obj1.hard_body_radius_km + obj2.hard_body_radius_km,
1673 options.method,
1674 ) {
1675 Ok(probability) => CatalogScreeningResult {
1676 candidate,
1677 collision: Some(CatalogCollision {
1678 probability,
1679 method: options.method,
1680 }),
1681 error: None,
1682 },
1683 Err(error) => CatalogScreeningResult {
1684 candidate,
1685 collision: None,
1686 error: Some(error),
1687 },
1688 }
1689 })
1690 .filter(|result| {
1691 result
1692 .collision
1693 .as_ref()
1694 .is_none_or(|collision| collision.probability.pc >= options.pc_threshold)
1695 })
1696 .collect::<Vec<_>>();
1697
1698 results.sort_by(|a, b| catalog_result_pc(b).total_cmp(&catalog_result_pc(a)));
1699 results
1700}
1701
1702fn catalog_result_pc(result: &CatalogScreeningResult) -> f64 {
1703 result
1704 .collision
1705 .as_ref()
1706 .map_or(0.0, |collision| collision.probability.pc)
1707}
1708
1709#[cfg(test)]
1710mod tests {
1711 use super::*;
1712 use crate::astro::time::civil::civil_from_julian_day_number;
1713
1714 #[test]
1715 fn screen_catalog_pairs_thresholds_and_orders() {
1716 let positions = [[7000.0, 0.0, 0.0], [7000.1, 0.0, 0.0], [8000.0, 0.0, 0.0]];
1718
1719 let pairs = screen_catalog_pairs(&positions, 1.0);
1720 assert_eq!(pairs.len(), 1);
1721 let (i, j, miss) = pairs[0];
1722 assert_eq!((i, j), (0, 1));
1723 assert!((miss - 0.1).abs() < 1.0e-9);
1724
1725 let all = screen_catalog_pairs(&positions, 1.0e9);
1727 assert_eq!(
1728 all.iter().map(|(i, j, _)| (*i, *j)).collect::<Vec<_>>(),
1729 vec![(0, 1), (0, 2), (1, 2)]
1730 );
1731
1732 assert!(screen_catalog_pairs(&[], 1.0).is_empty());
1734 assert!(screen_catalog_pairs(&[[0.0, 0.0, 0.0]], 1.0).is_empty());
1735 }
1736
1737 #[test]
1738 fn state_vector_catalog_driver_matches_manual_prefilter_and_pc() {
1739 let cov = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
1740 let objects = vec![
1741 CatalogStateVector {
1742 id: Some("A".to_string()),
1743 position_km: [7000.0, 0.0, 0.0],
1744 velocity_km_s: [0.0, 0.0, 0.0],
1745 covariance_km2: cov,
1746 hard_body_radius_km: 0.01,
1747 },
1748 CatalogStateVector {
1749 id: Some("B".to_string()),
1750 position_km: [7000.02, 0.0, 0.0],
1751 velocity_km_s: [0.0, 7.5, 0.0],
1752 covariance_km2: cov,
1753 hard_body_radius_km: 0.01,
1754 },
1755 CatalogStateVector {
1756 id: Some("C".to_string()),
1757 position_km: [7000.04, 0.0, 0.0],
1758 velocity_km_s: [0.0, 0.0, 0.0],
1759 covariance_km2: cov,
1760 hard_body_radius_km: 0.02,
1761 },
1762 ];
1763 let options = CatalogScreeningOptions {
1764 miss_threshold_km: 0.05,
1765 pc_threshold: 0.0,
1766 method: PcMethod::FosterEqualArea,
1767 };
1768
1769 let driver = screen_state_vector_catalog(&objects, options);
1770 let positions = objects
1771 .iter()
1772 .map(|object| object.position_km)
1773 .collect::<Vec<_>>();
1774 let mut manual = screen_catalog_pairs(&positions, options.miss_threshold_km)
1775 .into_iter()
1776 .map(|(i, j, miss_km)| {
1777 let obj1 = &objects[i];
1778 let obj2 = &objects[j];
1779 let candidate = CatalogScreeningCandidate {
1780 i,
1781 j,
1782 id1: obj1.id.clone(),
1783 id2: obj2.id.clone(),
1784 miss_km,
1785 };
1786 let state1 = ConjunctionState {
1787 position_km: obj1.position_km,
1788 velocity_km_s: obj1.velocity_km_s,
1789 covariance_km2: obj1.covariance_km2,
1790 };
1791 let state2 = ConjunctionState {
1792 position_km: obj2.position_km,
1793 velocity_km_s: obj2.velocity_km_s,
1794 covariance_km2: obj2.covariance_km2,
1795 };
1796 match collision_probability(
1797 &state1,
1798 &state2,
1799 obj1.hard_body_radius_km + obj2.hard_body_radius_km,
1800 options.method,
1801 ) {
1802 Ok(probability) => CatalogScreeningResult {
1803 candidate,
1804 collision: Some(CatalogCollision {
1805 probability,
1806 method: options.method,
1807 }),
1808 error: None,
1809 },
1810 Err(error) => CatalogScreeningResult {
1811 candidate,
1812 collision: None,
1813 error: Some(error),
1814 },
1815 }
1816 })
1817 .collect::<Vec<_>>();
1818 manual.sort_by(|a, b| catalog_result_pc(b).total_cmp(&catalog_result_pc(a)));
1819
1820 assert_eq!(driver, manual);
1821 assert!(driver
1822 .iter()
1823 .any(|result| result.error == Some(ConjunctionError::UndefinedFrame)));
1824 }
1825
1826 const ISS_L1: &str = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
1827 const ISS_L2: &str = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
1828 const ISS_FAST_L1: &str =
1829 "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
1830 const ISS_FAST_L2: &str =
1831 "2 25544 51.6414 295.8524 0003435 262.6267 202.7868 15.64005638121106";
1832 const ISS_OPPOSITE_L2: &str =
1833 "2 25544 51.6414 295.8524 0003435 262.6267 24.2868 15.54005638121106";
1834
1835 #[test]
1836 fn constructed_tles_find_expected_close_approach() {
1837 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
1838 let start = primary.epoch_jd();
1839 let end = add_seconds_to_julian_date(start, 12_000.0);
1840 let candidates = find_tca_candidates_from_tles(
1841 ISS_L1,
1842 ISS_L2,
1843 ISS_FAST_L1,
1844 ISS_FAST_L2,
1845 start,
1846 end,
1847 TcaFinderOptions {
1848 coarse_step_seconds: 30.0,
1849 time_tolerance_seconds: 1.0e-4,
1850 },
1851 )
1852 .expect("TCA search succeeds");
1853
1854 assert_eq!(candidates.len(), 1);
1855 let best = candidates[0];
1856
1857 assert_close(
1858 best.tca_seconds_since_window_start,
1859 3_599.834_762_793_019_3,
1860 2.0e-4,
1861 );
1862 assert_close(best.miss_distance_km, 28.942_032_135_766_88, 1.0e-9);
1863 assert_close(
1864 vec3::norm3(best.relative_position_km),
1865 best.miss_distance_km,
1866 1.0e-12,
1867 );
1868 assert!(vec3::norm3(best.relative_velocity_km_s) > 0.0);
1869 }
1870
1871 #[test]
1872 fn tca_candidates_include_window_boundary_minima() {
1873 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
1874 let secondary =
1875 Satellite::from_tle(ISS_FAST_L1, ISS_FAST_L2).expect("secondary TLE parses");
1876 let options = TcaFinderOptions {
1877 coarse_step_seconds: 30.0,
1878 time_tolerance_seconds: 1.0e-4,
1879 };
1880 let full_start = primary.epoch_jd();
1881 let full_end = add_seconds_to_julian_date(full_start, 12_000.0);
1882 let interior = find_tca_candidates(&primary, &secondary, full_start, full_end, options)
1883 .expect("interior TCA search succeeds");
1884 assert_eq!(interior.len(), 1);
1885 let tca = interior[0];
1886
1887 let start_boundary_end = add_seconds_to_julian_date(tca.tca_time, 600.0);
1888 let start_boundary = find_tca_candidates(
1889 &primary,
1890 &secondary,
1891 tca.tca_time,
1892 start_boundary_end,
1893 options,
1894 )
1895 .expect("start-boundary TCA search succeeds");
1896
1897 assert_eq!(start_boundary.len(), 1);
1898 assert_close(start_boundary[0].tca_seconds_since_window_start, 0.0, 0.0);
1899 assert_close(
1900 start_boundary[0].miss_distance_km,
1901 tca.miss_distance_km,
1902 1.0e-9,
1903 );
1904
1905 let end_boundary_start = add_seconds_to_julian_date(tca.tca_time, -600.0);
1906 let end_boundary = find_tca_candidates(
1907 &primary,
1908 &secondary,
1909 end_boundary_start,
1910 tca.tca_time,
1911 options,
1912 )
1913 .expect("end-boundary TCA search succeeds");
1914
1915 assert_eq!(end_boundary.len(), 1);
1916 assert_close(
1917 end_boundary[0].tca_seconds_since_window_start,
1918 600.0,
1919 1.0e-8,
1920 );
1921 assert_close(
1922 end_boundary[0].miss_distance_km,
1923 tca.miss_distance_km,
1924 1.0e-9,
1925 );
1926 }
1927
1928 #[test]
1929 fn tca_candidates_suppress_constant_range_boundary_minima() {
1930 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
1931 let secondary = Satellite::from_tle(ISS_L1, ISS_L2).expect("secondary TLE parses");
1932 let start = primary.epoch_jd();
1933 let end = add_seconds_to_julian_date(start, 900.0);
1934 let options = TcaFinderOptions {
1935 coarse_step_seconds: 30.0,
1936 time_tolerance_seconds: 1.0e-4,
1937 };
1938
1939 let candidates = find_tca_candidates(&primary, &secondary, start, end, options)
1940 .expect("constant-range TCA search succeeds");
1941
1942 assert!(
1943 candidates.is_empty(),
1944 "constant relative range should not emit boundary TCAs: {candidates:?}"
1945 );
1946 }
1947
1948 #[test]
1949 fn tca_boundary_minimum_uses_last_partial_coarse_sample() {
1950 let minima = minimum_extrema_including_boundaries_from_values(
1951 Vec::new(),
1952 95.0,
1953 30.0,
1954 |time_seconds| {
1955 Ok(sampled_range_value(
1956 time_seconds,
1957 &[
1958 (0.0, 10.0),
1959 (30.0, 9.0),
1960 (65.0, 9.0),
1961 (90.0, 11.0),
1962 (95.0, 10.0),
1963 ],
1964 ))
1965 },
1966 )
1967 .expect("boundary classification succeeds");
1968
1969 assert_eq!(minima.len(), 1);
1970 assert_close(minima[0].time_seconds, 95.0, 0.0);
1971 assert_close(minima[0].value, 10.0, 0.0);
1972 assert_eq!(minima[0].kind, ExtremumKind::Minimum);
1973 }
1974
1975 #[test]
1976 fn tca_boundary_minimum_uses_repeated_addition_penultimate_sample() {
1977 let minima = minimum_extrema_including_boundaries_from_values(
1978 Vec::new(),
1979 1.05,
1980 0.1,
1981 |time_seconds| {
1982 Ok(sampled_range_value(
1983 time_seconds,
1984 &[
1985 (0.0, 10.0),
1986 (0.1, 9.0),
1987 (0.999_999_999_999_999_9, 11.0),
1988 (1.0, 9.0),
1989 (1.05, 10.0),
1990 ],
1991 ))
1992 },
1993 )
1994 .expect("boundary classification succeeds");
1995
1996 assert_eq!(minima.len(), 1);
1997 assert_close(minima[0].time_seconds, 1.05, 0.0);
1998 assert_close(minima[0].value, 10.0, 0.0);
1999 assert_eq!(minima[0].kind, ExtremumKind::Minimum);
2000 }
2001
2002 #[test]
2003 fn tca_boundary_minimum_uses_inserted_midpoint_sample() {
2004 let midpoint_minimum = ExtremumEvent {
2005 time_seconds: 5.0,
2006 value: 8.0,
2007 kind: ExtremumKind::Minimum,
2008 };
2009 let minima = minimum_extrema_including_boundaries_from_values(
2010 vec![midpoint_minimum],
2011 10.0,
2012 30.0,
2013 |time_seconds| {
2014 Ok(sampled_range_value(
2015 time_seconds,
2016 &[(0.0, 9.0), (5.0, 8.0), (10.0, 10.0)],
2017 ))
2018 },
2019 )
2020 .expect("boundary classification succeeds");
2021
2022 assert_eq!(minima, vec![midpoint_minimum]);
2023 }
2024
2025 #[test]
2026 fn tca_boundary_neighbor_times_keep_exact_multiple_window() {
2027 assert_eq!(extrema_boundary_neighbor_times(120.0, 30.0), (30.0, 90.0));
2028 }
2029
2030 #[test]
2031 fn tca_candidate_pipeline_deduplicates_flat_bottom_close_approach() {
2032 let start = JulianDate(2_458_303.0, 0.0);
2033 let extrema = EventFinder::new(0.0, 3.0, 1.0, 1.0e-12)
2034 .expect("valid finder")
2035 .find_extrema(flat_bottom_range_km)
2036 .expect("finite flat-bottom range");
2037 let candidates: Vec<_> = extrema
2038 .into_iter()
2039 .filter(|event| event.kind == ExtremumKind::Minimum)
2040 .map(|event| TcaCandidate {
2041 tca_time: add_seconds_to_julian_date(start, event.time_seconds),
2042 tca_seconds_since_window_start: event.time_seconds,
2043 miss_distance_km: event.value,
2044 relative_position_km: [event.value, 0.0, 0.0],
2045 relative_velocity_km_s: [0.0; 3],
2046 })
2047 .collect();
2048
2049 assert_eq!(candidates.len(), 1);
2050 assert!((1.0..=2.0).contains(&candidates[0].tca_seconds_since_window_start));
2051 assert_close(candidates[0].miss_distance_km, 1.0, 1.0e-12);
2052 }
2053
2054 #[test]
2055 fn teme_to_gcrs_covariance_transport_uses_full_state_jacobian() {
2056 let transform = teme_to_gcrs_state_transform_at(
2057 JulianDate(2_458_303.0, 0.809_691_02),
2058 TcaObject::Primary,
2059 )
2060 .expect("TEME->GCRS state transform is valid");
2061 let covariance = Covariance6::from_diagonal([100.0, 144.0, 196.0, 1.0e-4, 1.5e-4, 2.0e-4])
2062 .expect("test covariance is valid");
2063
2064 let actual = transform
2065 .transform_covariance(covariance)
2066 .expect("full-jacobian transform preserves covariance validity");
2067 let expected =
2068 manual_full_jacobian_covariance(covariance.as_matrix(), &transform.state_jacobian())
2069 .expect("manual full-jacobian covariance is valid");
2070 assert_covariance_close(actual.as_matrix(), expected.as_matrix(), 1.0e-10);
2071
2072 let position_only = covariance
2073 .propagate_with_stm(&position_only_state_jacobian(&transform.rotation))
2074 .expect("position-only transform preserves covariance validity");
2075 assert_covariance_diff_exceeds(actual.as_matrix(), position_only.as_matrix(), 1.0e-12);
2076 assert!(actual.is_symmetric());
2077 assert!(actual.is_positive_semidefinite());
2078 }
2079
2080 #[test]
2081 fn split_jd_preserves_civil_day_just_before_midnight() {
2082 let epsilon_days = 2.0e-10;
2083 let tca_time = JulianDate(2_458_303.0, 0.5 - epsilon_days);
2084 assert_eq!(tca_time.0 + tca_time.1, 2_458_303.5);
2085
2086 let day_fraction = tca_time.1 + 0.5;
2087 let seconds_of_day = day_fraction * SECONDS_PER_DAY;
2088 let expected_second = seconds_of_day
2089 - 23.0 * crate::constants::SECONDS_PER_HOUR
2090 - 59.0 * crate::constants::SECONDS_PER_MINUTE;
2091 let expected =
2092 TimeScales::from_utc(2018, 7, 3, 23, 59, expected_second).expect("valid UTC instant");
2093 let actual =
2094 time_scales_from_sgp4_julian_date(tca_time).expect("split JD converts to time scales");
2095 assert_eq!(actual, expected);
2096
2097 let one_day_late =
2098 TimeScales::from_utc(2018, 7, 4, 23, 59, expected_second).expect("valid UTC instant");
2099 assert_ne!(actual, one_day_late);
2100
2101 let actual_rotation =
2102 teme_to_gcrs_rotation_at(tca_time, TcaObject::Primary).expect("split rotation");
2103 let expected_rotation = teme_to_gcrs_matrix(&expected, false);
2104 assert_eq!(actual_rotation, expected_rotation);
2105 let one_day_late_rotation = teme_to_gcrs_matrix(&one_day_late, false);
2106 assert_matrix_diff_exceeds(&actual_rotation, &one_day_late_rotation, 1.0e-8);
2107
2108 let candidate = TcaCandidate {
2109 tca_time,
2110 tca_seconds_since_window_start: 0.0,
2111 miss_distance_km: vec3::norm3([0.012, -0.017, 0.006]),
2112 relative_position_km: [0.012, -0.017, 0.006],
2113 relative_velocity_km_s: [0.09, 7.4, -1.2],
2114 };
2115 let pc_options = TcaPcOptions::with_covariances(
2116 0.010,
2117 PcMethod::Alfano2005,
2118 [
2119 [4.0e-4, 1.0e-5, -2.0e-5],
2120 [1.0e-5, 2.5e-5, 3.0e-6],
2121 [-2.0e-5, 3.0e-6, 9.0e-5],
2122 ],
2123 [[1.0e-5, 0.0, 0.0], [0.0, 6.4e-5, 0.0], [0.0, 0.0, 2.5e-5]],
2124 );
2125 let actual_pc = tca_collision_probability(candidate, pc_options)
2126 .expect("split-JD Pc")
2127 .collision_probability;
2128 let one_day_late_pc = tca_collision_probability(
2129 TcaCandidate {
2130 tca_time: JulianDate(tca_time.0 + 1.0, tca_time.1),
2131 ..candidate
2132 },
2133 pc_options,
2134 )
2135 .expect("one-day-late Pc")
2136 .collision_probability;
2137 assert_probability_diff_exceeds(actual_pc.pc, one_day_late_pc.pc, 1.0e-10);
2138 }
2139
2140 #[test]
2141 fn split_jd_matches_summed_path_away_from_midnight() {
2142 let midday = JulianDate(2_458_303.0, 0.0);
2143
2144 let actual =
2145 time_scales_from_sgp4_julian_date(midday).expect("midday converts to time scales");
2146 let summed = summed_time_scales_from_sgp4_julian_date(midday);
2147 assert_eq!(actual, summed);
2148
2149 let actual_rotation =
2150 teme_to_gcrs_rotation_at(midday, TcaObject::Primary).expect("midday rotation");
2151 let summed_rotation = summed_teme_to_gcrs_rotation_at(midday);
2152 assert_eq!(actual_rotation, summed_rotation);
2153 }
2154
2155 #[test]
2156 fn tca_pc_rejects_unconvertible_julian_dates_as_invalid_input() {
2157 let options = TcaPcOptions::with_default_covariance(0.010, PcMethod::Alfano2005);
2158
2159 assert_eq!(
2160 tca_collision_probability(tca_pc_test_candidate(JulianDate(1.0e20, 0.0)), options),
2161 Err(TcaError::InvalidInput {
2162 field: "julian_date",
2163 reason: "out of range",
2164 })
2165 );
2166 assert!(matches!(
2167 tca_collision_probability(
2168 tca_pc_test_candidate(JulianDate(f64::INFINITY, 0.0)),
2169 options
2170 ),
2171 Err(TcaError::InvalidInput {
2172 reason: "not finite",
2173 ..
2174 })
2175 ));
2176 }
2177
2178 #[test]
2179 fn tca_pc_rejects_invalid_candidate_relative_state_before_transform() {
2180 let options = TcaPcOptions::with_default_covariance(0.010, PcMethod::Alfano2005);
2181 let mut candidate = tca_pc_test_candidate(JulianDate(2_458_303.0, 0.0));
2182 candidate.relative_position_km[0] = f64::NAN;
2183
2184 assert_eq!(
2185 tca_collision_probability(candidate, options),
2186 Err(TcaError::InvalidInput {
2187 field: "candidate.relative_position_km",
2188 reason: "not finite",
2189 })
2190 );
2191
2192 candidate.relative_position_km = [MAX_TCA_RELATIVE_POSITION_KM * 2.0, 0.0, 0.0];
2193 assert_eq!(
2194 tca_collision_probability(candidate, options),
2195 Err(TcaError::InvalidInput {
2196 field: "candidate.relative_position_km",
2197 reason: "out of range",
2198 })
2199 );
2200 }
2201
2202 #[test]
2203 fn tca_pc_in_range_julian_date_matches_summed_reference_path() {
2204 let candidate = tca_pc_test_candidate(JulianDate(2_458_303.0, 0.0));
2205 let options = TcaPcOptions::with_default_covariance(0.010, PcMethod::Alfano2005);
2206
2207 let actual_state =
2208 tca_candidate_relative_state_for_pc(candidate).expect("in-range frame transform");
2209 let expected_transform = summed_teme_to_gcrs_state_transform_at(candidate.tca_time);
2210 let expected_state = expected_transform.transform_state(CartesianState::new(
2211 0.0,
2212 candidate.relative_position_km,
2213 candidate.relative_velocity_km_s,
2214 ));
2215 assert_eq!(
2216 actual_state.relative_position_km,
2217 expected_state.position_array()
2218 );
2219 assert_eq!(
2220 actual_state.relative_velocity_km_s,
2221 expected_state.velocity_array()
2222 );
2223
2224 let actual = tca_collision_probability(candidate, options).expect("in-range TCA Pc");
2225 let expected_primary = ConjunctionState {
2226 position_km: expected_state.position_array(),
2227 velocity_km_s: expected_state.velocity_array(),
2228 covariance_km2: options.covariances.primary_covariance_km2,
2229 };
2230 let expected_secondary = ConjunctionState {
2231 position_km: [0.0; 3],
2232 velocity_km_s: [0.0; 3],
2233 covariance_km2: options.covariances.secondary_covariance_km2,
2234 };
2235 let expected = collision_probability(
2236 &expected_primary,
2237 &expected_secondary,
2238 options.hard_body_radius_km,
2239 options.method,
2240 )
2241 .expect("summed-reference TCA Pc");
2242 assert_eq!(actual.candidate, candidate);
2243 assert_eq!(actual.collision_probability, expected);
2244 }
2245
2246 #[test]
2247 fn invalid_window_is_rejected() {
2248 let start = JulianDate(2_458_303.0, 0.5);
2249 let end = JulianDate(2_458_303.0, 0.4);
2250 assert_eq!(
2251 find_tca_candidates_from_tles(
2252 ISS_L1,
2253 ISS_L2,
2254 ISS_FAST_L1,
2255 ISS_FAST_L2,
2256 start,
2257 end,
2258 TcaFinderOptions::default(),
2259 ),
2260 Err(TcaError::InvalidInput {
2261 field: "window_end",
2262 reason: "negative",
2263 })
2264 );
2265 }
2266
2267 #[test]
2268 fn screening_returns_only_threshold_breaches_and_parallel_matches_serial() {
2269 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
2270 let far = Satellite::from_tle(ISS_FAST_L1, ISS_OPPOSITE_L2).expect("far TLE parses");
2271 let close = Satellite::from_tle(ISS_FAST_L1, ISS_FAST_L2).expect("close TLE parses");
2272 let secondaries = [far, close];
2273 let start = primary.epoch_jd();
2274 let end = add_seconds_to_julian_date(start, 12_000.0);
2275 let options = TcaFinderOptions {
2276 coarse_step_seconds: 30.0,
2277 time_tolerance_seconds: 1.0e-4,
2278 };
2279
2280 let serial =
2281 screen_tca_candidates_serial(&primary, &secondaries, start, end, 30.0, options)
2282 .expect("serial screening succeeds");
2283 let parallel =
2284 screen_tca_candidates_parallel(&primary, &secondaries, start, end, 30.0, options)
2285 .expect("parallel screening succeeds");
2286
2287 assert_eq!(serial, parallel);
2288 assert_eq!(serial.len(), 1);
2289
2290 let hit = serial[0];
2291 assert_eq!(hit.secondary_index, 1);
2292 assert_close(
2293 hit.candidate.tca_seconds_since_window_start,
2294 3_599.834_762_793_019_3,
2295 2.0e-4,
2296 );
2297 assert_close(
2298 hit.candidate.miss_distance_km,
2299 28.942_032_135_766_88,
2300 1.0e-9,
2301 );
2302 assert!(hit.candidate.miss_distance_km <= 30.0);
2303 }
2304
2305 #[test]
2306 fn tca_pc_matches_direct_conjunction_call_with_supplied_covariance() {
2307 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
2308 let secondary =
2309 Satellite::from_tle(ISS_FAST_L1, ISS_FAST_L2).expect("secondary TLE parses");
2310 let start = primary.epoch_jd();
2311 let end = add_seconds_to_julian_date(start, 12_000.0);
2312 let tca_options = TcaFinderOptions {
2313 coarse_step_seconds: 30.0,
2314 time_tolerance_seconds: 1.0e-4,
2315 };
2316 let candidates = find_tca_candidates(&primary, &secondary, start, end, tca_options)
2317 .expect("TCA search succeeds");
2318 assert_eq!(candidates.len(), 1);
2319
2320 let candidate = candidates[0];
2321 let primary_covariance_km2 = [[0.04, 0.001, 0.0], [0.001, 0.09, 0.002], [0.0, 0.002, 0.16]];
2322 let secondary_covariance_km2 = [
2323 [0.01, 0.0, 0.0],
2324 [0.0, 0.0225, 0.0005],
2325 [0.0, 0.0005, 0.0625],
2326 ];
2327 let pc_options = TcaPcOptions::with_covariances(
2328 0.020,
2329 PcMethod::Alfano2005,
2330 primary_covariance_km2,
2331 secondary_covariance_km2,
2332 );
2333
2334 let conjunction =
2335 tca_collision_probability(candidate, pc_options).expect("Pc is defined at TCA");
2336 let pc_state =
2337 tca_candidate_relative_state_for_pc(candidate).expect("candidate converts to GCRS");
2338 let direct_primary = ConjunctionState {
2339 position_km: pc_state.relative_position_km,
2340 velocity_km_s: pc_state.relative_velocity_km_s,
2341 covariance_km2: primary_covariance_km2,
2342 };
2343 let direct_secondary = ConjunctionState {
2344 position_km: [0.0; 3],
2345 velocity_km_s: [0.0; 3],
2346 covariance_km2: secondary_covariance_km2,
2347 };
2348 let direct = collision_probability(
2349 &direct_primary,
2350 &direct_secondary,
2351 pc_options.hard_body_radius_km,
2352 pc_options.method,
2353 )
2354 .expect("direct Pc is defined");
2355
2356 assert_eq!(conjunction.candidate, candidate);
2357 assert_eq!(conjunction.collision_probability, direct);
2358
2359 let from_finder =
2360 find_tca_conjunctions(&primary, &secondary, start, end, tca_options, pc_options)
2361 .expect("TCA Pc search succeeds");
2362 assert_eq!(from_finder, vec![conjunction]);
2363 }
2364
2365 #[test]
2366 fn tca_pc_with_initial_covariances_matches_direct_pc_after_manual_propagation() {
2367 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
2368 let secondary =
2369 Satellite::from_tle(ISS_FAST_L1, ISS_FAST_L2).expect("secondary TLE parses");
2370 let start = primary.epoch_jd();
2371 let end = add_seconds_to_julian_date(start, 12_000.0);
2372 let tca_options = TcaFinderOptions {
2373 coarse_step_seconds: 30.0,
2374 time_tolerance_seconds: 1.0e-4,
2375 };
2376 let candidates = find_tca_candidates(&primary, &secondary, start, end, tca_options)
2377 .expect("TCA search succeeds");
2378 assert_eq!(candidates.len(), 1);
2379
2380 let candidate = candidates[0];
2381 let primary_covariance0 =
2382 Covariance6::from_diagonal([100.0, 144.0, 196.0, 1.0e-4, 1.5e-4, 2.0e-4]).unwrap();
2383 let secondary_covariance0 =
2384 Covariance6::from_diagonal([64.0, 81.0, 121.0, 8.0e-5, 9.0e-5, 1.0e-4]).unwrap();
2385 let pc_options = TcaPropagatedCovariancePcOptions::new(
2386 0.020,
2387 PcMethod::Alfano2005,
2388 primary_covariance0,
2389 secondary_covariance0,
2390 )
2391 .with_covariance_propagator(
2392 ForceModelKind::two_body_j2(),
2393 IntegratorKind::Rk4,
2394 IntegratorOptions {
2395 initial_step: 30.0,
2396 ..IntegratorOptions::default()
2397 },
2398 );
2399
2400 let conjunction = tca_collision_probability_with_propagated_covariance(
2401 &primary, &secondary, candidate, pc_options,
2402 )
2403 .expect("propagated covariance Pc is defined");
2404
2405 let primary_covariance_km2 =
2406 manual_position_covariance_at_tca(&primary, primary_covariance0, candidate, pc_options);
2407 let secondary_covariance_km2 = manual_position_covariance_at_tca(
2408 &secondary,
2409 secondary_covariance0,
2410 candidate,
2411 pc_options,
2412 );
2413 assert_ne!(
2414 primary_covariance_km2,
2415 primary_covariance0.position_covariance_km2()
2416 );
2417 assert_ne!(
2418 secondary_covariance_km2,
2419 secondary_covariance0.position_covariance_km2()
2420 );
2421 assert_matrix_diff_exceeds(
2422 &primary_covariance_km2,
2423 &pre_fix_position_covariance_at_tca(
2424 &primary,
2425 primary_covariance0,
2426 candidate,
2427 pc_options,
2428 ),
2429 1.0e-6,
2430 );
2431 assert_matrix_diff_exceeds(
2432 &secondary_covariance_km2,
2433 &pre_fix_position_covariance_at_tca(
2434 &secondary,
2435 secondary_covariance0,
2436 candidate,
2437 pc_options,
2438 ),
2439 1.0e-6,
2440 );
2441
2442 let pc_state =
2443 tca_candidate_relative_state_for_pc(candidate).expect("candidate converts to GCRS");
2444 let direct_primary = ConjunctionState {
2445 position_km: pc_state.relative_position_km,
2446 velocity_km_s: pc_state.relative_velocity_km_s,
2447 covariance_km2: primary_covariance_km2,
2448 };
2449 let direct_secondary = ConjunctionState {
2450 position_km: [0.0; 3],
2451 velocity_km_s: [0.0; 3],
2452 covariance_km2: secondary_covariance_km2,
2453 };
2454 let direct = collision_probability(
2455 &direct_primary,
2456 &direct_secondary,
2457 pc_options.hard_body_radius_km,
2458 pc_options.method,
2459 )
2460 .expect("direct propagated-covariance Pc is defined");
2461
2462 assert_eq!(conjunction.candidate, candidate);
2463 assert_eq!(conjunction.collision_probability, direct);
2464
2465 let unconverted_primary = ConjunctionState {
2466 position_km: candidate.relative_position_km,
2467 velocity_km_s: candidate.relative_velocity_km_s,
2468 covariance_km2: primary_covariance_km2,
2469 };
2470 let unconverted = collision_probability(
2471 &unconverted_primary,
2472 &direct_secondary,
2473 pc_options.hard_body_radius_km,
2474 pc_options.method,
2475 )
2476 .expect("unconverted TEME candidate still produces a Pc");
2477 assert_probability_diff_exceeds(direct.pc, unconverted.pc, 1.0e-20);
2478
2479 let from_finder = find_tca_conjunctions_with_propagated_covariance(
2480 &primary,
2481 &secondary,
2482 start,
2483 end,
2484 tca_options,
2485 pc_options,
2486 )
2487 .expect("propagated covariance TCA Pc search succeeds");
2488 assert_eq!(from_finder, vec![conjunction]);
2489 }
2490
2491 #[test]
2492 fn tle_catalog_screening_propagates_initial_covariances_to_pc() {
2493 let primary_satellite = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
2494 let start = primary_satellite.epoch_jd();
2495 let window = TcaWindow::from_start_and_duration_seconds(start, 12_000.0)
2496 .expect("window duration is valid");
2497 let tca_options = TcaFinderOptions {
2498 coarse_step_seconds: 30.0,
2499 time_tolerance_seconds: 1.0e-4,
2500 };
2501 let primary_covariance0 =
2502 Covariance6::from_diagonal([100.0, 144.0, 196.0, 1.0e-4, 1.5e-4, 2.0e-4]).unwrap();
2503 let secondary_covariance0 =
2504 Covariance6::from_diagonal([64.0, 81.0, 121.0, 8.0e-5, 9.0e-5, 1.0e-4]).unwrap();
2505 let primary = TcaTleWithCovariance::new(ISS_L1, ISS_L2, primary_covariance0);
2506 let secondary = TcaTleWithCovariance::new(ISS_FAST_L1, ISS_FAST_L2, secondary_covariance0);
2507 let pc_options = TcaPropagatedCovarianceOptions::new(0.020, PcMethod::Alfano2005)
2508 .with_covariance_propagator(
2509 ForceModelKind::two_body_j2(),
2510 IntegratorKind::Rk4,
2511 IntegratorOptions {
2512 initial_step: 30.0,
2513 ..IntegratorOptions::default()
2514 },
2515 );
2516
2517 let secondaries = [secondary];
2518 let serial = screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog_serial(
2519 primary,
2520 &secondaries,
2521 window,
2522 30.0,
2523 tca_options,
2524 pc_options,
2525 )
2526 .expect("serial propagated-covariance screening succeeds");
2527 let parallel =
2528 screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog_parallel(
2529 primary,
2530 &secondaries,
2531 window,
2532 30.0,
2533 tca_options,
2534 pc_options,
2535 )
2536 .expect("parallel propagated-covariance screening succeeds");
2537
2538 assert_eq!(serial, parallel);
2539 assert_eq!(serial.len(), 1);
2540 assert_eq!(serial[0].secondary_index, 0);
2541 assert!(serial[0].conjunction.candidate.miss_distance_km <= 30.0);
2542 assert!(serial[0].conjunction.collision_probability.pc.is_finite());
2543 assert!((0.0..=1.0).contains(&serial[0].conjunction.collision_probability.pc));
2544
2545 let pairwise = find_tca_conjunctions_with_propagated_covariance_between_tles(
2546 primary.tle,
2547 secondary.tle,
2548 window,
2549 tca_options,
2550 pc_options.for_initial_covariances(primary_covariance0, secondary_covariance0),
2551 )
2552 .expect("pairwise propagated-covariance TCA Pc search succeeds");
2553 assert_eq!(serial[0].conjunction, pairwise[0]);
2554 }
2555
2556 fn flat_bottom_range_km(time_seconds: f64) -> f64 {
2557 if time_seconds < 1.0 {
2558 2.0 - time_seconds
2559 } else if time_seconds <= 2.0 {
2560 1.0
2561 } else {
2562 time_seconds - 1.0
2563 }
2564 }
2565
2566 fn sampled_range_value(time_seconds: f64, samples: &[(f64, f64)]) -> f64 {
2567 samples
2568 .iter()
2569 .find_map(|(sample_time, value)| {
2570 (time_seconds.to_bits() == sample_time.to_bits()).then_some(*value)
2571 })
2572 .unwrap_or_else(|| panic!("unexpected boundary sample time {time_seconds}"))
2573 }
2574
2575 fn assert_close(actual: f64, expected: f64, tolerance: f64) {
2576 assert!(
2577 (actual - expected).abs() <= tolerance,
2578 "{actual} differs from {expected} by more than {tolerance}"
2579 );
2580 }
2581
2582 fn assert_matrix_diff_exceeds(actual: &Mat3, expected: &Mat3, threshold: f64) {
2583 let mut max_diff = 0.0_f64;
2584 for i in 0..3 {
2585 for j in 0..3 {
2586 max_diff = max_diff.max((actual[i][j] - expected[i][j]).abs());
2587 }
2588 }
2589 assert!(
2590 max_diff > threshold,
2591 "matrix diff {max_diff} did not exceed {threshold}"
2592 );
2593 }
2594
2595 fn assert_probability_diff_exceeds(actual: f64, expected: f64, threshold: f64) {
2596 let diff = (actual - expected).abs();
2597 assert!(
2598 diff > threshold,
2599 "probability diff {diff} did not exceed {threshold}: {actual} vs {expected}"
2600 );
2601 }
2602
2603 fn assert_covariance_close(actual: &Mat6, expected: &Mat6, tolerance: f64) {
2604 for i in 0..6 {
2605 for j in 0..6 {
2606 assert!(
2607 (actual[i][j] - expected[i][j]).abs() <= tolerance,
2608 "covariance[{i}][{j}] = {} differs from {} by more than {tolerance}",
2609 actual[i][j],
2610 expected[i][j]
2611 );
2612 }
2613 }
2614 }
2615
2616 fn assert_covariance_diff_exceeds(actual: &Mat6, expected: &Mat6, threshold: f64) {
2617 let mut max_diff = 0.0_f64;
2618 for i in 0..6 {
2619 for j in 0..6 {
2620 max_diff = max_diff.max((actual[i][j] - expected[i][j]).abs());
2621 }
2622 }
2623 assert!(
2624 max_diff > threshold,
2625 "covariance diff {max_diff} did not exceed {threshold}"
2626 );
2627 }
2628
2629 fn summed_time_scales_from_sgp4_julian_date(time: JulianDate) -> TimeScales {
2630 let jd_total = time.0 + time.1;
2631 let mut jd_midnight = (jd_total - 0.5).floor() + 0.5;
2632 let mut day_fraction = (time.0 - jd_midnight) + time.1;
2633 if !(0.0..1.0).contains(&day_fraction) {
2634 let day_offset = day_fraction.floor();
2635 jd_midnight += day_offset;
2636 day_fraction -= day_offset;
2637 }
2638
2639 let jdn = (jd_midnight + 0.5).round() as i64;
2640 let (year, month, day) = civil_from_julian_day_number(jdn);
2641 let seconds_of_day = day_fraction * SECONDS_PER_DAY;
2642 let hour = (seconds_of_day / crate::constants::SECONDS_PER_HOUR).floor() as i32;
2643 let minute = ((seconds_of_day - f64::from(hour) * crate::constants::SECONDS_PER_HOUR)
2644 / crate::constants::SECONDS_PER_MINUTE)
2645 .floor() as i32;
2646 let second = seconds_of_day
2647 - f64::from(hour) * crate::constants::SECONDS_PER_HOUR
2648 - f64::from(minute) * crate::constants::SECONDS_PER_MINUTE;
2649
2650 TimeScales::from_utc(year as i32, month as i32, day as i32, hour, minute, second)
2651 .expect("summed JD test instant is valid")
2652 }
2653
2654 fn summed_teme_to_gcrs_rotation_at(time: JulianDate) -> Mat3 {
2655 let time_scales = summed_time_scales_from_sgp4_julian_date(time);
2656 teme_to_gcrs_matrix(&time_scales, false)
2657 }
2658
2659 fn summed_teme_to_gcrs_state_transform_at(time: JulianDate) -> TemeToGcrsStateTransform {
2660 let rotation = summed_teme_to_gcrs_rotation_at(time);
2661 let before = summed_teme_to_gcrs_rotation_at(add_seconds_to_julian_date(
2662 time,
2663 -FRAME_DERIVATIVE_STEP_SECONDS,
2664 ));
2665 let after = summed_teme_to_gcrs_rotation_at(add_seconds_to_julian_date(
2666 time,
2667 FRAME_DERIVATIVE_STEP_SECONDS,
2668 ));
2669
2670 TemeToGcrsStateTransform {
2671 rotation,
2672 rotation_derivative: centered_rotation_derivative(
2673 &before,
2674 &after,
2675 FRAME_DERIVATIVE_STEP_SECONDS,
2676 ),
2677 }
2678 }
2679
2680 fn tca_pc_test_candidate(tca_time: JulianDate) -> TcaCandidate {
2681 TcaCandidate {
2682 tca_time,
2683 tca_seconds_since_window_start: 0.0,
2684 miss_distance_km: vec3::norm3([0.012, -0.017, 0.006]),
2685 relative_position_km: [0.012, -0.017, 0.006],
2686 relative_velocity_km_s: [0.09, 7.4, -1.2],
2687 }
2688 }
2689
2690 #[allow(clippy::needless_range_loop)]
2691 fn manual_full_jacobian_covariance(
2692 covariance: &Mat6,
2693 jacobian: &Mat6,
2694 ) -> Result<Covariance6, Covariance6Error> {
2695 let mut transformed = [[0.0_f64; 6]; 6];
2696 for i in 0..6 {
2697 for j in 0..6 {
2698 for k in 0..6 {
2699 for l in 0..6 {
2700 transformed[i][j] += jacobian[i][k] * covariance[k][l] * jacobian[j][l];
2701 }
2702 }
2703 }
2704 }
2705 symmetrize6_for_test(&mut transformed);
2706 Covariance6::try_from_matrix(transformed)
2707 }
2708
2709 fn position_only_state_jacobian(rotation: &Mat3) -> Mat6 {
2710 let mut jacobian = [[0.0_f64; 6]; 6];
2711 for row in 0..3 {
2712 for col in 0..3 {
2713 jacobian[row][col] = rotation[row][col];
2714 jacobian[row + 3][col + 3] = rotation[row][col];
2715 }
2716 }
2717 jacobian
2718 }
2719
2720 #[allow(clippy::needless_range_loop)]
2721 fn symmetrize6_for_test(matrix: &mut Mat6) {
2722 for i in 0..6 {
2723 for j in (i + 1)..6 {
2724 let avg = 0.5 * (matrix[i][j] + matrix[j][i]);
2725 matrix[i][j] = avg;
2726 matrix[j][i] = avg;
2727 }
2728 }
2729 }
2730
2731 fn manual_position_covariance_at_tca(
2732 satellite: &Satellite,
2733 covariance0: Covariance6,
2734 candidate: TcaCandidate,
2735 options: TcaPropagatedCovariancePcOptions,
2736 ) -> [[f64; 3]; 3] {
2737 let epoch_state = satellite
2738 .propagate(MinutesSinceEpoch(0.0))
2739 .expect("satellite propagates at its epoch");
2740 let epoch = satellite.epoch_jd();
2741 let span_seconds = seconds_between_julian_dates(epoch, candidate.tca_time);
2742 let epoch_teme_to_gcrs = teme_to_gcrs_state_transform_at(epoch, TcaObject::Primary)
2743 .expect("epoch frame transform");
2744 let initial = epoch_teme_to_gcrs.transform_state(CartesianState::new(
2745 0.0,
2746 epoch_state.position,
2747 epoch_state.velocity,
2748 ));
2749 let covariance0 = epoch_teme_to_gcrs
2750 .transform_covariance(covariance0)
2751 .expect("initial covariance rotates into GCRS");
2752 let propagator = StatePropagator {
2753 initial,
2754 force_model: options.force_model,
2755 integrator: options.integrator,
2756 options: options.integrator_options,
2757 drag: None,
2758 };
2759 let (_, covariance_f) = propagator
2760 .propagate_state_with_covariance(covariance0, span_seconds)
2761 .expect("manual covariance propagation succeeds");
2762 covariance_f.position_covariance_km2()
2763 }
2764
2765 fn pre_fix_position_covariance_at_tca(
2766 satellite: &Satellite,
2767 covariance0: Covariance6,
2768 candidate: TcaCandidate,
2769 options: TcaPropagatedCovariancePcOptions,
2770 ) -> [[f64; 3]; 3] {
2771 let epoch_state = satellite
2772 .propagate(MinutesSinceEpoch(0.0))
2773 .expect("satellite propagates at its epoch");
2774 let epoch = satellite.epoch_jd();
2775 let span_seconds = seconds_between_julian_dates(epoch, candidate.tca_time);
2776 let propagator = StatePropagator {
2777 initial: CartesianState::new(0.0, epoch_state.position, epoch_state.velocity),
2778 force_model: options.force_model,
2779 integrator: options.integrator,
2780 options: options.integrator_options,
2781 drag: None,
2782 };
2783 let (_, covariance_f) = propagator
2784 .propagate_state_with_covariance(covariance0, span_seconds)
2785 .expect("pre-fix covariance propagation succeeds");
2786 covariance_f.position_covariance_km2()
2787 }
2788}