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::scales::{julian_day_number, TimeScales};
22use crate::validate;
23use std::sync::Mutex;
24
25const SECONDS_PER_DAY: f64 = 86_400.0;
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, Copy, PartialEq, Eq)]
341pub enum TcaObject {
342 Primary,
344 Secondary,
346}
347
348#[derive(Debug, Clone, PartialEq, thiserror::Error)]
350pub enum TcaError {
351 #[error("invalid TCA input {field}: {reason}")]
353 InvalidInput {
354 field: &'static str,
356 reason: &'static str,
358 },
359 #[error("{object:?} satellite initialization failed: {source}")]
361 Init {
362 object: TcaObject,
364 source: Sgp4Error,
366 },
367 #[error("{object:?} satellite propagation failed: {source}")]
369 Propagate {
370 object: TcaObject,
372 source: Sgp4Error,
374 },
375 #[error("{object:?} covariance propagation failed: {reason}")]
377 CovariancePropagation {
378 object: TcaObject,
380 reason: String,
382 },
383 #[error(transparent)]
385 EventFinder(#[from] EventFinderError),
386 #[error(transparent)]
388 Conjunction(#[from] ConjunctionError),
389}
390
391pub fn find_tca_candidates_from_tles(
393 primary_line1: &str,
394 primary_line2: &str,
395 secondary_line1: &str,
396 secondary_line2: &str,
397 window_start: JulianDate,
398 window_end: JulianDate,
399 options: TcaFinderOptions,
400) -> Result<Vec<TcaCandidate>, TcaError> {
401 let primary =
402 Satellite::from_tle(primary_line1, primary_line2).map_err(|source| TcaError::Init {
403 object: TcaObject::Primary,
404 source,
405 })?;
406 let secondary =
407 Satellite::from_tle(secondary_line1, secondary_line2).map_err(|source| TcaError::Init {
408 object: TcaObject::Secondary,
409 source,
410 })?;
411
412 find_tca_candidates(&primary, &secondary, window_start, window_end, options)
413}
414
415pub fn find_tca_candidates_between_tles(
417 primary_tle: TcaTle<'_>,
418 secondary_tle: TcaTle<'_>,
419 window: TcaWindow,
420 options: TcaFinderOptions,
421) -> Result<Vec<TcaCandidate>, TcaError> {
422 find_tca_candidates_from_tles(
423 primary_tle.line1,
424 primary_tle.line2,
425 secondary_tle.line1,
426 secondary_tle.line2,
427 window.start,
428 window.end,
429 options,
430 )
431}
432
433pub fn find_tca_candidates(
435 primary: &Satellite,
436 secondary: &Satellite,
437 window_start: JulianDate,
438 window_end: JulianDate,
439 options: TcaFinderOptions,
440) -> Result<Vec<TcaCandidate>, TcaError> {
441 let span_seconds = validate_window(window_start, window_end)?;
442 let options = validate_options(options)?;
443 if span_seconds <= 0.0 {
444 return Ok(Vec::new());
445 }
446
447 let finder = EventFinder::new(
448 0.0,
449 span_seconds,
450 options.coarse_step_seconds,
451 options.time_tolerance_seconds,
452 )
453 .map_err(TcaError::EventFinder)?;
454 let predicate = RelativeRange::new(primary, secondary, window_start);
455 let extrema = finder.find_extrema(&predicate).map_err(|error| {
456 predicate
457 .take_error()
458 .unwrap_or(TcaError::EventFinder(error))
459 })?;
460 let minima = minimum_extrema_including_boundaries(
461 &predicate,
462 extrema,
463 span_seconds,
464 options.coarse_step_seconds,
465 )?;
466
467 minima
468 .into_iter()
469 .map(|event| tca_candidate_from_extremum(primary, secondary, window_start, event))
470 .collect()
471}
472
473pub fn find_tca_conjunctions_from_tles(
475 primary_tle: TcaTle<'_>,
476 secondary_tle: TcaTle<'_>,
477 window_start: JulianDate,
478 window_end: JulianDate,
479 tca_options: TcaFinderOptions,
480 pc_options: TcaPcOptions,
481) -> Result<Vec<TcaConjunction>, TcaError> {
482 find_tca_candidates_from_tles(
483 primary_tle.line1,
484 primary_tle.line2,
485 secondary_tle.line1,
486 secondary_tle.line2,
487 window_start,
488 window_end,
489 tca_options,
490 )?
491 .into_iter()
492 .map(|candidate| tca_collision_probability(candidate, pc_options))
493 .collect()
494}
495
496pub fn find_tca_conjunctions_between_tles(
498 primary_tle: TcaTle<'_>,
499 secondary_tle: TcaTle<'_>,
500 window: TcaWindow,
501 tca_options: TcaFinderOptions,
502 pc_options: TcaPcOptions,
503) -> Result<Vec<TcaConjunction>, TcaError> {
504 find_tca_conjunctions_from_tles(
505 primary_tle,
506 secondary_tle,
507 window.start,
508 window.end,
509 tca_options,
510 pc_options,
511 )
512}
513
514pub fn find_tca_conjunctions(
516 primary: &Satellite,
517 secondary: &Satellite,
518 window_start: JulianDate,
519 window_end: JulianDate,
520 tca_options: TcaFinderOptions,
521 pc_options: TcaPcOptions,
522) -> Result<Vec<TcaConjunction>, TcaError> {
523 find_tca_candidates(primary, secondary, window_start, window_end, tca_options)?
524 .into_iter()
525 .map(|candidate| tca_collision_probability(candidate, pc_options))
526 .collect()
527}
528
529pub fn find_tca_conjunctions_with_propagated_covariance_from_tles(
532 primary_tle: TcaTle<'_>,
533 secondary_tle: TcaTle<'_>,
534 window_start: JulianDate,
535 window_end: JulianDate,
536 tca_options: TcaFinderOptions,
537 pc_options: TcaPropagatedCovariancePcOptions,
538) -> Result<Vec<TcaConjunction>, TcaError> {
539 let primary = satellite_from_tle(primary_tle, TcaObject::Primary)?;
540 let secondary = satellite_from_tle(secondary_tle, TcaObject::Secondary)?;
541 find_tca_conjunctions_with_propagated_covariance(
542 &primary,
543 &secondary,
544 window_start,
545 window_end,
546 tca_options,
547 pc_options,
548 )
549}
550
551pub fn find_tca_conjunctions_with_propagated_covariance_between_tles(
554 primary_tle: TcaTle<'_>,
555 secondary_tle: TcaTle<'_>,
556 window: TcaWindow,
557 tca_options: TcaFinderOptions,
558 pc_options: TcaPropagatedCovariancePcOptions,
559) -> Result<Vec<TcaConjunction>, TcaError> {
560 find_tca_conjunctions_with_propagated_covariance_from_tles(
561 primary_tle,
562 secondary_tle,
563 window.start,
564 window.end,
565 tca_options,
566 pc_options,
567 )
568}
569
570pub fn find_tca_conjunctions_with_propagated_covariance(
573 primary: &Satellite,
574 secondary: &Satellite,
575 window_start: JulianDate,
576 window_end: JulianDate,
577 tca_options: TcaFinderOptions,
578 pc_options: TcaPropagatedCovariancePcOptions,
579) -> Result<Vec<TcaConjunction>, TcaError> {
580 find_tca_candidates(primary, secondary, window_start, window_end, tca_options)?
581 .into_iter()
582 .map(|candidate| {
583 tca_collision_probability_with_propagated_covariance(
584 primary, secondary, candidate, pc_options,
585 )
586 })
587 .collect()
588}
589
590pub fn screen_tca_candidates_serial(
595 primary: &Satellite,
596 secondaries: &[Satellite],
597 window_start: JulianDate,
598 window_end: JulianDate,
599 miss_distance_threshold_km: f64,
600 options: TcaFinderOptions,
601) -> Result<Vec<TcaScreeningHit>, TcaError> {
602 screen_tca_candidates_batched(
603 primary,
604 secondaries,
605 window_start,
606 window_end,
607 miss_distance_threshold_km,
608 options,
609 BatchMode::Serial,
610 )
611}
612
613pub fn screen_tca_candidates_parallel(
619 primary: &Satellite,
620 secondaries: &[Satellite],
621 window_start: JulianDate,
622 window_end: JulianDate,
623 miss_distance_threshold_km: f64,
624 options: TcaFinderOptions,
625) -> Result<Vec<TcaScreeningHit>, TcaError> {
626 screen_tca_candidates_batched(
627 primary,
628 secondaries,
629 window_start,
630 window_end,
631 miss_distance_threshold_km,
632 options,
633 BatchMode::Parallel,
634 )
635}
636
637pub fn screen_tca_candidates_from_tle_catalog_serial(
639 primary_tle: TcaTle<'_>,
640 secondary_tles: &[TcaTle<'_>],
641 window: TcaWindow,
642 miss_distance_threshold_km: f64,
643 options: TcaFinderOptions,
644) -> Result<Vec<TcaScreeningHit>, TcaError> {
645 let primary = satellite_from_tle(primary_tle, TcaObject::Primary)?;
646 let secondaries = satellites_from_tles(secondary_tles)?;
647 screen_tca_candidates_serial(
648 &primary,
649 &secondaries,
650 window.start,
651 window.end,
652 miss_distance_threshold_km,
653 options,
654 )
655}
656
657pub fn screen_tca_candidates_from_tle_catalog_parallel(
659 primary_tle: TcaTle<'_>,
660 secondary_tles: &[TcaTle<'_>],
661 window: TcaWindow,
662 miss_distance_threshold_km: f64,
663 options: TcaFinderOptions,
664) -> Result<Vec<TcaScreeningHit>, TcaError> {
665 let primary = satellite_from_tle(primary_tle, TcaObject::Primary)?;
666 let secondaries = satellites_from_tles(secondary_tles)?;
667 screen_tca_candidates_parallel(
668 &primary,
669 &secondaries,
670 window.start,
671 window.end,
672 miss_distance_threshold_km,
673 options,
674 )
675}
676
677pub fn screen_tca_conjunctions_serial(
679 primary: &Satellite,
680 secondaries: &[Satellite],
681 window_start: JulianDate,
682 window_end: JulianDate,
683 miss_distance_threshold_km: f64,
684 tca_options: TcaFinderOptions,
685 pc_options: TcaPcOptions,
686) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
687 let hits = screen_tca_candidates_serial(
688 primary,
689 secondaries,
690 window_start,
691 window_end,
692 miss_distance_threshold_km,
693 tca_options,
694 )?;
695 screening_hits_to_conjunctions(hits, pc_options)
696}
697
698pub fn screen_tca_conjunctions_from_tle_catalog_serial(
700 primary_tle: TcaTle<'_>,
701 secondary_tles: &[TcaTle<'_>],
702 window: TcaWindow,
703 miss_distance_threshold_km: f64,
704 tca_options: TcaFinderOptions,
705 pc_options: TcaPcOptions,
706) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
707 let hits = screen_tca_candidates_from_tle_catalog_serial(
708 primary_tle,
709 secondary_tles,
710 window,
711 miss_distance_threshold_km,
712 tca_options,
713 )?;
714 screening_hits_to_conjunctions(hits, pc_options)
715}
716
717pub fn screen_tca_conjunctions_parallel(
719 primary: &Satellite,
720 secondaries: &[Satellite],
721 window_start: JulianDate,
722 window_end: JulianDate,
723 miss_distance_threshold_km: f64,
724 tca_options: TcaFinderOptions,
725 pc_options: TcaPcOptions,
726) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
727 let hits = screen_tca_candidates_parallel(
728 primary,
729 secondaries,
730 window_start,
731 window_end,
732 miss_distance_threshold_km,
733 tca_options,
734 )?;
735 screening_hits_to_conjunctions(hits, pc_options)
736}
737
738pub fn screen_tca_conjunctions_from_tle_catalog_parallel(
740 primary_tle: TcaTle<'_>,
741 secondary_tles: &[TcaTle<'_>],
742 window: TcaWindow,
743 miss_distance_threshold_km: f64,
744 tca_options: TcaFinderOptions,
745 pc_options: TcaPcOptions,
746) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
747 let hits = screen_tca_candidates_from_tle_catalog_parallel(
748 primary_tle,
749 secondary_tles,
750 window,
751 miss_distance_threshold_km,
752 tca_options,
753 )?;
754 screening_hits_to_conjunctions(hits, pc_options)
755}
756
757pub fn screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog_serial(
760 primary: TcaTleWithCovariance<'_>,
761 secondaries: &[TcaTleWithCovariance<'_>],
762 window: TcaWindow,
763 miss_distance_threshold_km: f64,
764 tca_options: TcaFinderOptions,
765 pc_options: TcaPropagatedCovarianceOptions,
766) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
767 screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog(
768 primary,
769 secondaries,
770 window,
771 miss_distance_threshold_km,
772 tca_options,
773 pc_options,
774 BatchMode::Serial,
775 )
776}
777
778pub fn screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog_parallel(
781 primary: TcaTleWithCovariance<'_>,
782 secondaries: &[TcaTleWithCovariance<'_>],
783 window: TcaWindow,
784 miss_distance_threshold_km: f64,
785 tca_options: TcaFinderOptions,
786 pc_options: TcaPropagatedCovarianceOptions,
787) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
788 screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog(
789 primary,
790 secondaries,
791 window,
792 miss_distance_threshold_km,
793 tca_options,
794 pc_options,
795 BatchMode::Parallel,
796 )
797}
798
799fn screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog(
800 primary: TcaTleWithCovariance<'_>,
801 secondaries: &[TcaTleWithCovariance<'_>],
802 window: TcaWindow,
803 miss_distance_threshold_km: f64,
804 tca_options: TcaFinderOptions,
805 pc_options: TcaPropagatedCovarianceOptions,
806 mode: BatchMode,
807) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
808 let primary_satellite = satellite_from_tle(primary.tle, TcaObject::Primary)?;
809 let secondary_satellites = satellites_from_tles_with_covariance(secondaries)?;
810 let hits = screen_tca_candidates_batched(
811 &primary_satellite,
812 &secondary_satellites,
813 window.start,
814 window.end,
815 miss_distance_threshold_km,
816 tca_options,
817 mode,
818 )?;
819 screening_hits_to_propagated_covariance_conjunctions(
820 hits,
821 &primary_satellite,
822 primary.covariance0,
823 &secondary_satellites,
824 secondaries,
825 pc_options,
826 )
827}
828
829pub fn tca_collision_probability(
835 candidate: TcaCandidate,
836 options: TcaPcOptions,
837) -> Result<TcaConjunction, TcaError> {
838 validate_tca_candidate_for_pc(candidate)?;
839 let pc_state = tca_candidate_relative_state_for_pc(candidate)?;
840 let primary_state = ConjunctionState {
841 position_km: pc_state.relative_position_km,
842 velocity_km_s: pc_state.relative_velocity_km_s,
843 covariance_km2: options.covariances.primary_covariance_km2,
844 };
845 let secondary_state = ConjunctionState {
846 position_km: [0.0; 3],
847 velocity_km_s: [0.0; 3],
848 covariance_km2: options.covariances.secondary_covariance_km2,
849 };
850 let collision_probability = collision_probability(
851 &primary_state,
852 &secondary_state,
853 options.hard_body_radius_km,
854 options.method,
855 )?;
856
857 Ok(TcaConjunction {
858 candidate,
859 collision_probability,
860 })
861}
862
863pub fn tca_collision_probability_with_propagated_covariance(
866 primary: &Satellite,
867 secondary: &Satellite,
868 candidate: TcaCandidate,
869 options: TcaPropagatedCovariancePcOptions,
870) -> Result<TcaConjunction, TcaError> {
871 validate_tca_candidate_for_pc(candidate)?;
872 let primary_covariance_km2 = propagate_position_covariance_to_tca(
873 primary,
874 TcaObject::Primary,
875 options.primary_covariance0,
876 candidate.tca_time,
877 options,
878 )?;
879 let secondary_covariance_km2 = propagate_position_covariance_to_tca(
880 secondary,
881 TcaObject::Secondary,
882 options.secondary_covariance0,
883 candidate.tca_time,
884 options,
885 )?;
886 let at_tca_options = TcaPcOptions::with_covariances(
887 options.hard_body_radius_km,
888 options.method,
889 primary_covariance_km2,
890 secondary_covariance_km2,
891 );
892 tca_collision_probability(candidate, at_tca_options)
893}
894
895#[derive(Debug, Clone, Copy)]
896enum BatchMode {
897 Serial,
898 Parallel,
899}
900
901fn screen_tca_candidates_batched(
902 primary: &Satellite,
903 secondaries: &[Satellite],
904 window_start: JulianDate,
905 window_end: JulianDate,
906 miss_distance_threshold_km: f64,
907 options: TcaFinderOptions,
908 mode: BatchMode,
909) -> Result<Vec<TcaScreeningHit>, TcaError> {
910 let span_seconds = validate_window(window_start, window_end)?;
911 let options = validate_options(options)?;
912 let miss_distance_threshold_km = validate_miss_distance_threshold(miss_distance_threshold_km)?;
913 if span_seconds <= 0.0 || secondaries.is_empty() {
914 return Ok(Vec::new());
915 }
916
917 let finder = EventFinder::new(
918 0.0,
919 span_seconds,
920 options.coarse_step_seconds,
921 options.time_tolerance_seconds,
922 )
923 .map_err(TcaError::EventFinder)?;
924 let ranges = secondaries
925 .iter()
926 .map(|secondary| RelativeRange::new(primary, secondary, window_start))
927 .collect::<Vec<_>>();
928 let predicates = ranges.iter().collect::<Vec<_>>();
929 let extrema_by_secondary = match mode {
930 BatchMode::Serial => finder.find_extrema_batch_serial(&predicates),
931 BatchMode::Parallel => finder.find_extrema_batch_parallel(&predicates),
932 };
933
934 let mut hits = Vec::new();
935 for ((secondary_index, secondary), (range, extrema_result)) in secondaries
936 .iter()
937 .enumerate()
938 .zip(ranges.iter().zip(extrema_by_secondary))
939 {
940 let extrema = extrema_result
941 .map_err(|error| range.take_error().unwrap_or(TcaError::EventFinder(error)))?;
942 let minima = minimum_extrema_including_boundaries(
943 range,
944 extrema,
945 span_seconds,
946 options.coarse_step_seconds,
947 )?;
948 for event in minima {
949 let candidate = tca_candidate_from_extremum(primary, secondary, window_start, event)?;
950 if candidate.miss_distance_km <= miss_distance_threshold_km {
951 hits.push(TcaScreeningHit {
952 secondary_index,
953 candidate,
954 });
955 }
956 }
957 }
958
959 Ok(hits)
960}
961
962fn minimum_extrema_including_boundaries(
963 range: &RelativeRange<'_>,
964 extrema: Vec<ExtremumEvent>,
965 span_seconds: f64,
966 coarse_step_seconds: f64,
967) -> Result<Vec<ExtremumEvent>, TcaError> {
968 minimum_extrema_including_boundaries_from_values(
969 extrema,
970 span_seconds,
971 coarse_step_seconds,
972 |time_seconds| finite_range_km_at(range, time_seconds),
973 )
974}
975
976fn minimum_extrema_including_boundaries_from_values(
977 extrema: Vec<ExtremumEvent>,
978 span_seconds: f64,
979 coarse_step_seconds: f64,
980 mut value_at: impl FnMut(f64) -> Result<f64, TcaError>,
981) -> Result<Vec<ExtremumEvent>, TcaError> {
982 let (start_neighbor_time, end_neighbor_time) =
983 extrema_boundary_neighbor_times(span_seconds, coarse_step_seconds);
984 let start_value = value_at(0.0)?;
985 let start_neighbor_value = value_at(start_neighbor_time)?;
986 let end_neighbor_value = value_at(end_neighbor_time)?;
987 let end_value = value_at(span_seconds)?;
988
989 let mut minima = Vec::with_capacity(extrema.len() + 2);
990 if is_strict_boundary_minimum(start_value, start_neighbor_value) {
991 minima.push(ExtremumEvent {
992 time_seconds: 0.0,
993 value: start_value,
994 kind: ExtremumKind::Minimum,
995 });
996 }
997 minima.extend(
998 extrema
999 .into_iter()
1000 .filter(|event| event.kind == ExtremumKind::Minimum),
1001 );
1002 if is_strict_boundary_minimum(end_value, end_neighbor_value) {
1003 minima.push(ExtremumEvent {
1004 time_seconds: span_seconds,
1005 value: end_value,
1006 kind: ExtremumKind::Minimum,
1007 });
1008 }
1009 Ok(minima)
1010}
1011
1012fn extrema_boundary_neighbor_times(span_seconds: f64, coarse_step_seconds: f64) -> (f64, f64) {
1013 debug_assert!(span_seconds > 0.0);
1014 debug_assert!(coarse_step_seconds > 0.0);
1015
1016 let sample_iterations =
1017 ((span_seconds / coarse_step_seconds).ceil() as usize).saturating_add(1);
1018 let mut offset_seconds = 0.0;
1019 let mut sample_count = 0_usize;
1020 let mut start_neighbor_time = None;
1021 let mut end_neighbor_time = 0.0;
1022
1023 for _ in 0..sample_iterations {
1024 if offset_seconds >= span_seconds {
1025 break;
1026 }
1027 let time_seconds = offset_seconds;
1028 if time_seconds >= span_seconds {
1029 break;
1030 }
1031
1032 sample_count += 1;
1033 if sample_count == 2 {
1034 start_neighbor_time = Some(time_seconds);
1035 }
1036 end_neighbor_time = time_seconds;
1037
1038 let next_offset_seconds = offset_seconds + coarse_step_seconds;
1039 debug_assert!(next_offset_seconds > offset_seconds);
1040 offset_seconds = next_offset_seconds;
1041 }
1042
1043 if sample_count == 1 {
1044 let midpoint = span_seconds * 0.5;
1045 return (midpoint, midpoint);
1046 }
1047
1048 (
1049 start_neighbor_time.expect("multi-sample boundary has a start neighbor"),
1050 end_neighbor_time,
1051 )
1052}
1053
1054fn is_strict_boundary_minimum(boundary_value: f64, neighbor_value: f64) -> bool {
1055 neighbor_value - boundary_value > boundary_range_tolerance(boundary_value, neighbor_value)
1056}
1057
1058fn boundary_range_tolerance(a: f64, b: f64) -> f64 {
1059 BOUNDARY_RANGE_ABS_TOL_KM.max(BOUNDARY_RANGE_REL_TOL * a.abs().max(b.abs()).max(1.0))
1060}
1061
1062fn finite_range_km_at(range: &RelativeRange<'_>, time_seconds: f64) -> Result<f64, TcaError> {
1063 let value = range.range_km_at(time_seconds);
1064 if value.is_finite() {
1065 Ok(value)
1066 } else if let Some(error) = range.take_error() {
1067 Err(error)
1068 } else {
1069 Err(TcaError::EventFinder(EventFinderError::InvalidInput {
1070 field: "predicate",
1071 reason: "not finite",
1072 }))
1073 }
1074}
1075
1076fn satellites_from_tles(tles: &[TcaTle<'_>]) -> Result<Vec<Satellite>, TcaError> {
1077 tles.iter()
1078 .map(|tle| satellite_from_tle(*tle, TcaObject::Secondary))
1079 .collect()
1080}
1081
1082fn satellites_from_tles_with_covariance(
1083 tles: &[TcaTleWithCovariance<'_>],
1084) -> Result<Vec<Satellite>, TcaError> {
1085 tles.iter()
1086 .map(|tle| satellite_from_tle(tle.tle, TcaObject::Secondary))
1087 .collect()
1088}
1089
1090fn satellite_from_tle(tle: TcaTle<'_>, object: TcaObject) -> Result<Satellite, TcaError> {
1091 Satellite::from_tle(tle.line1, tle.line2).map_err(|source| TcaError::Init { object, source })
1092}
1093
1094fn screening_hits_to_conjunctions(
1095 hits: Vec<TcaScreeningHit>,
1096 pc_options: TcaPcOptions,
1097) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
1098 hits.into_iter()
1099 .map(|hit| {
1100 Ok(TcaScreeningConjunctionHit {
1101 secondary_index: hit.secondary_index,
1102 conjunction: tca_collision_probability(hit.candidate, pc_options)?,
1103 })
1104 })
1105 .collect()
1106}
1107
1108fn screening_hits_to_propagated_covariance_conjunctions(
1109 hits: Vec<TcaScreeningHit>,
1110 primary: &Satellite,
1111 primary_covariance0: Covariance6,
1112 secondaries: &[Satellite],
1113 secondary_objects: &[TcaTleWithCovariance<'_>],
1114 pc_options: TcaPropagatedCovarianceOptions,
1115) -> Result<Vec<TcaScreeningConjunctionHit>, TcaError> {
1116 hits.into_iter()
1117 .map(|hit| {
1118 let secondary = &secondaries[hit.secondary_index];
1119 let secondary_covariance0 = secondary_objects[hit.secondary_index].covariance0;
1120 let conjunction = tca_collision_probability_with_propagated_covariance(
1121 primary,
1122 secondary,
1123 hit.candidate,
1124 pc_options.for_initial_covariances(primary_covariance0, secondary_covariance0),
1125 )?;
1126 Ok(TcaScreeningConjunctionHit {
1127 secondary_index: hit.secondary_index,
1128 conjunction,
1129 })
1130 })
1131 .collect()
1132}
1133
1134fn tca_candidate_from_extremum(
1135 primary: &Satellite,
1136 secondary: &Satellite,
1137 window_start: JulianDate,
1138 event: ExtremumEvent,
1139) -> Result<TcaCandidate, TcaError> {
1140 let tca_time = add_seconds_to_julian_date(window_start, event.time_seconds);
1141 let state = relative_state_at(primary, secondary, tca_time)?;
1142 Ok(TcaCandidate {
1143 tca_time,
1144 tca_seconds_since_window_start: event.time_seconds,
1145 miss_distance_km: vec3::norm3(state.relative_position_km),
1146 relative_position_km: state.relative_position_km,
1147 relative_velocity_km_s: state.relative_velocity_km_s,
1148 })
1149}
1150
1151#[derive(Debug, Clone, Copy)]
1152struct RelativeState {
1153 relative_position_km: [f64; 3],
1154 relative_velocity_km_s: [f64; 3],
1155}
1156
1157fn relative_state_at(
1158 primary: &Satellite,
1159 secondary: &Satellite,
1160 time: JulianDate,
1161) -> Result<RelativeState, TcaError> {
1162 let primary_prediction = primary
1163 .propagate_jd(time)
1164 .map_err(|source| TcaError::Propagate {
1165 object: TcaObject::Primary,
1166 source,
1167 })?;
1168 let secondary_prediction =
1169 secondary
1170 .propagate_jd(time)
1171 .map_err(|source| TcaError::Propagate {
1172 object: TcaObject::Secondary,
1173 source,
1174 })?;
1175
1176 Ok(RelativeState {
1177 relative_position_km: vec3::sub3(
1178 primary_prediction.position,
1179 secondary_prediction.position,
1180 ),
1181 relative_velocity_km_s: vec3::sub3(
1182 primary_prediction.velocity,
1183 secondary_prediction.velocity,
1184 ),
1185 })
1186}
1187
1188fn tca_candidate_relative_state_for_pc(candidate: TcaCandidate) -> Result<RelativeState, TcaError> {
1189 let transform = teme_to_gcrs_state_transform_at(candidate.tca_time, TcaObject::Primary)?;
1190 let state = transform.transform_state(CartesianState::new(
1191 0.0,
1192 candidate.relative_position_km,
1193 candidate.relative_velocity_km_s,
1194 ));
1195 let state = RelativeState {
1196 relative_position_km: state.position_array(),
1197 relative_velocity_km_s: state.velocity_array(),
1198 };
1199 validate_relative_state_for_pc(state)?;
1200 Ok(state)
1201}
1202
1203fn propagate_position_covariance_to_tca(
1204 satellite: &Satellite,
1205 object: TcaObject,
1206 covariance0: Covariance6,
1207 tca_time: JulianDate,
1208 options: TcaPropagatedCovariancePcOptions,
1209) -> Result<[[f64; 3]; 3], TcaError> {
1210 validate_julian_date_fields(tca_time, "tca_time.whole", "tca_time.fraction")?;
1211 let epoch_time = satellite.epoch_jd();
1212 let span_seconds = seconds_between_julian_dates(epoch_time, tca_time);
1213 validate::finite(span_seconds, "tca_time").map_err(map_input)?;
1214
1215 let epoch_teme_to_gcrs = teme_to_gcrs_state_transform_at(epoch_time, object)?;
1216 let initial_state =
1217 epoch_teme_to_gcrs.transform_state(satellite_epoch_state(satellite, object)?);
1218 let covariance0 = epoch_teme_to_gcrs
1219 .transform_covariance(covariance0)
1220 .map_err(|source| TcaError::CovariancePropagation {
1221 object,
1222 reason: format!(
1223 "initial TEME->GCRS covariance transform failed: {}",
1224 covariance_error_reason(source)
1225 ),
1226 })?;
1227 let propagator = StatePropagator {
1228 initial: initial_state,
1229 force_model: options.force_model,
1230 integrator: options.integrator,
1231 options: options.integrator_options,
1232 };
1233 let (_, covariance_f) = propagator
1234 .propagate_state_with_covariance(covariance0, span_seconds)
1235 .map_err(|source| TcaError::CovariancePropagation {
1236 object,
1237 reason: source.to_string(),
1238 })?;
1239
1240 Ok(covariance_f.position_covariance_km2())
1241}
1242
1243fn satellite_epoch_state(
1244 satellite: &Satellite,
1245 object: TcaObject,
1246) -> Result<CartesianState, TcaError> {
1247 let prediction = satellite
1248 .propagate(MinutesSinceEpoch(0.0))
1249 .map_err(|source| TcaError::Propagate { object, source })?;
1250 Ok(CartesianState::new(
1251 0.0,
1252 prediction.position,
1253 prediction.velocity,
1254 ))
1255}
1256
1257fn teme_to_gcrs_rotation_at(time: JulianDate, _object: TcaObject) -> Result<Mat3, TcaError> {
1258 validate_julian_date_fields(time, "frame_time.whole", "frame_time.fraction")?;
1259 let time_scales = time_scales_from_sgp4_julian_date(time)?;
1260 Ok(teme_to_gcrs_matrix(&time_scales, false))
1261}
1262
1263fn teme_to_gcrs_state_transform_at(
1264 time: JulianDate,
1265 object: TcaObject,
1266) -> Result<TemeToGcrsStateTransform, TcaError> {
1267 let rotation = teme_to_gcrs_rotation_at(time, object)?;
1268 let before = teme_to_gcrs_rotation_at(
1269 add_seconds_to_julian_date(time, -FRAME_DERIVATIVE_STEP_SECONDS),
1270 object,
1271 )?;
1272 let after = teme_to_gcrs_rotation_at(
1273 add_seconds_to_julian_date(time, FRAME_DERIVATIVE_STEP_SECONDS),
1274 object,
1275 )?;
1276
1277 Ok(TemeToGcrsStateTransform {
1278 rotation,
1279 rotation_derivative: centered_rotation_derivative(
1280 &before,
1281 &after,
1282 FRAME_DERIVATIVE_STEP_SECONDS,
1283 ),
1284 })
1285}
1286
1287fn time_scales_from_sgp4_julian_date(time: JulianDate) -> Result<TimeScales, TcaError> {
1288 validate::finite(time.0, "julian_date").map_err(map_input)?;
1289 validate::finite(time.1, "julian_date").map_err(map_input)?;
1290
1291 let mut jd_whole = time.0.floor();
1292 let mut jd_fraction = (time.0 - jd_whole) + time.1;
1293 let fraction_day_offset = jd_fraction.floor();
1294 jd_whole += fraction_day_offset;
1295 jd_fraction -= fraction_day_offset;
1296
1297 let (jd_midnight, day_fraction) = if jd_fraction >= 0.5 {
1298 (jd_whole + 0.5, jd_fraction - 0.5)
1299 } else {
1300 (jd_whole - 0.5, jd_fraction + 0.5)
1301 };
1302
1303 let jdn = julian_day_number_from_jd_midnight(jd_midnight)?;
1304 let (year, month, day) = civil_from_jdn(jdn);
1305 let seconds_of_day = day_fraction * SECONDS_PER_DAY;
1306 let hour = (seconds_of_day / 3600.0).floor() as i32;
1307 let minute = ((seconds_of_day - f64::from(hour) * 3600.0) / 60.0).floor() as i32;
1308 let second = seconds_of_day - f64::from(hour) * 3600.0 - f64::from(minute) * 60.0;
1309
1310 TimeScales::from_utc(year as i32, month as i32, day as i32, hour, minute, second).map_err(
1311 |_| TcaError::InvalidInput {
1312 field: "julian_date",
1313 reason: "out of range",
1314 },
1315 )
1316}
1317
1318fn julian_day_number_from_jd_midnight(jd_midnight: f64) -> Result<i64, TcaError> {
1319 let jdn = (jd_midnight + 0.5).round();
1320 let min_jdn = julian_day_number(0, 1, 1) as f64;
1321 let max_jdn = julian_day_number(9999, 12, 31) as f64;
1322 if jdn.is_finite() && (min_jdn..=max_jdn).contains(&jdn) {
1323 Ok(jdn as i64)
1324 } else {
1325 Err(TcaError::InvalidInput {
1326 field: "julian_date",
1327 reason: "out of range",
1328 })
1329 }
1330}
1331
1332fn civil_from_jdn(jdn: i64) -> (i64, i64, i64) {
1333 let l = jdn + 68_569;
1334 let n = 4 * l / 146_097;
1335 let l = l - (146_097 * n + 3) / 4;
1336 let i = 4_000 * (l + 1) / 1_461_001;
1337 let l = l - 1_461 * i / 4 + 31;
1338 let j = 80 * l / 2_447;
1339 let day = l - 2_447 * j / 80;
1340 let l = j / 11;
1341 let month = j + 2 - 12 * l;
1342 let year = 100 * (n - 49) + i + l;
1343 (year, month, day)
1344}
1345
1346fn centered_rotation_derivative(before: &Mat3, after: &Mat3, step_seconds: f64) -> Mat3 {
1347 let scale = 1.0 / (2.0 * step_seconds);
1348 let mut derivative = [[0.0_f64; 3]; 3];
1349 for i in 0..3 {
1350 for j in 0..3 {
1351 derivative[i][j] = (after[i][j] - before[i][j]) * scale;
1352 }
1353 }
1354 derivative
1355}
1356
1357#[derive(Clone, Copy)]
1358struct TemeToGcrsStateTransform {
1359 rotation: Mat3,
1360 rotation_derivative: Mat3,
1361}
1362
1363impl TemeToGcrsStateTransform {
1364 fn transform_state(self, state: CartesianState) -> CartesianState {
1365 let position_teme = state.position_array();
1366 let velocity_teme = state.velocity_array();
1367 let position_gcrs = mat3_vec3_mul_unchecked(&self.rotation, &position_teme);
1368 let velocity_rotated = mat3_vec3_mul_unchecked(&self.rotation, &velocity_teme);
1369 let velocity_coupled = mat3_vec3_mul_unchecked(&self.rotation_derivative, &position_teme);
1370 CartesianState::new(
1371 state.epoch_tdb_seconds,
1372 position_gcrs,
1373 vec3::add3(velocity_rotated, velocity_coupled),
1374 )
1375 }
1376
1377 fn transform_covariance(
1378 self,
1379 covariance: Covariance6,
1380 ) -> Result<Covariance6, Covariance6Error> {
1381 covariance.propagate_with_stm(&self.state_jacobian())
1382 }
1383
1384 fn state_jacobian(self) -> Mat6 {
1385 let mut jacobian = [[0.0_f64; 6]; 6];
1386 for row in 0..3 {
1387 for col in 0..3 {
1388 jacobian[row][col] = self.rotation[row][col];
1389 jacobian[row + 3][col] = self.rotation_derivative[row][col];
1390 jacobian[row + 3][col + 3] = self.rotation[row][col];
1391 }
1392 }
1393 jacobian
1394 }
1395}
1396
1397fn covariance_error_reason(error: Covariance6Error) -> &'static str {
1398 match error {
1399 Covariance6Error::NonFinite => "not finite",
1400 Covariance6Error::Asymmetric => "not symmetric",
1401 Covariance6Error::NotPositiveSemidefinite => "not positive semidefinite",
1402 }
1403}
1404
1405struct RelativeRange<'a> {
1406 primary: &'a Satellite,
1407 secondary: &'a Satellite,
1408 window_start: JulianDate,
1409 first_error: Mutex<Option<TcaError>>,
1410}
1411
1412impl<'a> RelativeRange<'a> {
1413 fn new(primary: &'a Satellite, secondary: &'a Satellite, window_start: JulianDate) -> Self {
1414 Self {
1415 primary,
1416 secondary,
1417 window_start,
1418 first_error: Mutex::new(None),
1419 }
1420 }
1421
1422 fn range_km_at(&self, time_seconds: f64) -> f64 {
1423 let time = add_seconds_to_julian_date(self.window_start, time_seconds);
1424 match relative_state_at(self.primary, self.secondary, time) {
1425 Ok(state) => vec3::norm3(state.relative_position_km),
1426 Err(error) => {
1427 self.record_error(error);
1428 f64::NAN
1429 }
1430 }
1431 }
1432
1433 fn record_error(&self, error: TcaError) {
1434 if let Ok(mut first_error) = self.first_error.lock() {
1435 if first_error.is_none() {
1436 *first_error = Some(error);
1437 }
1438 }
1439 }
1440
1441 fn take_error(&self) -> Option<TcaError> {
1442 self.first_error
1443 .lock()
1444 .ok()
1445 .and_then(|mut first_error| first_error.take())
1446 }
1447}
1448
1449impl ScalarEventPredicate for &RelativeRange<'_> {
1450 fn value_at(&self, time_seconds: f64) -> f64 {
1451 self.range_km_at(time_seconds)
1452 }
1453}
1454
1455fn validate_options(options: TcaFinderOptions) -> Result<TcaFinderOptions, TcaError> {
1456 validate::positive_step(options.coarse_step_seconds, "coarse_step_seconds")
1457 .map_err(map_input)?;
1458 validate::positive_step(options.time_tolerance_seconds, "time_tolerance_seconds")
1459 .map_err(map_input)?;
1460 Ok(options)
1461}
1462
1463fn validate_miss_distance_threshold(miss_distance_threshold_km: f64) -> Result<f64, TcaError> {
1464 validate::finite_nonneg(miss_distance_threshold_km, "miss_distance_threshold_km")
1465 .map_err(map_input)
1466}
1467
1468fn validate_tca_candidate_for_pc(candidate: TcaCandidate) -> Result<(), TcaError> {
1469 validate_julian_date_fields(
1470 candidate.tca_time,
1471 "candidate.tca_time.whole",
1472 "candidate.tca_time.fraction",
1473 )?;
1474 validate::finite(
1475 candidate.tca_seconds_since_window_start,
1476 "candidate.tca_seconds_since_window_start",
1477 )
1478 .map_err(map_input)?;
1479 validate::finite_nonneg(candidate.miss_distance_km, "candidate.miss_distance_km")
1480 .map_err(map_input)?;
1481 validate_bounded_vec3(
1482 candidate.relative_position_km,
1483 MAX_TCA_RELATIVE_POSITION_KM,
1484 "candidate.relative_position_km",
1485 )?;
1486 validate_bounded_vec3(
1487 candidate.relative_velocity_km_s,
1488 MAX_TCA_RELATIVE_VELOCITY_KM_S,
1489 "candidate.relative_velocity_km_s",
1490 )?;
1491 Ok(())
1492}
1493
1494fn validate_relative_state_for_pc(state: RelativeState) -> Result<(), TcaError> {
1495 validate_bounded_vec3(
1496 state.relative_position_km,
1497 MAX_TCA_RELATIVE_POSITION_KM,
1498 "relative_position_km",
1499 )?;
1500 validate_bounded_vec3(
1501 state.relative_velocity_km_s,
1502 MAX_TCA_RELATIVE_VELOCITY_KM_S,
1503 "relative_velocity_km_s",
1504 )?;
1505 Ok(())
1506}
1507
1508fn validate_bounded_vec3(
1509 value: [f64; 3],
1510 max_abs: f64,
1511 field: &'static str,
1512) -> Result<(), TcaError> {
1513 validate::finite_vec3(value, field).map_err(map_input)?;
1514 if value.iter().any(|component| component.abs() > max_abs) {
1515 return Err(TcaError::InvalidInput {
1516 field,
1517 reason: "out of range",
1518 });
1519 }
1520 Ok(())
1521}
1522
1523fn validate_window(window_start: JulianDate, window_end: JulianDate) -> Result<f64, TcaError> {
1524 validate_julian_date_fields(window_start, "window_start.whole", "window_start.fraction")?;
1525 validate_julian_date_fields(window_end, "window_end.whole", "window_end.fraction")?;
1526 let span_seconds = seconds_between_julian_dates(window_start, window_end);
1527 validate::finite_nonneg(span_seconds, "window_end").map_err(map_input)
1528}
1529
1530fn validate_julian_date_fields(
1531 time: JulianDate,
1532 whole_field: &'static str,
1533 fraction_field: &'static str,
1534) -> Result<(), TcaError> {
1535 validate::finite(time.0, whole_field).map_err(map_input)?;
1536 validate::finite(time.1, fraction_field).map_err(map_input)?;
1537 Ok(())
1538}
1539
1540fn seconds_between_julian_dates(start: JulianDate, end: JulianDate) -> f64 {
1541 (end.0 - start.0) * SECONDS_PER_DAY + (end.1 - start.1) * SECONDS_PER_DAY
1542}
1543
1544fn add_seconds_to_julian_date(start: JulianDate, seconds: f64) -> JulianDate {
1545 let mut whole = start.0;
1546 let mut fraction = start.1 + seconds / SECONDS_PER_DAY;
1547 let carry = fraction.floor();
1548 whole += carry;
1549 fraction -= carry;
1550 JulianDate(whole, fraction)
1551}
1552
1553fn map_input(error: validate::FieldError) -> TcaError {
1554 TcaError::InvalidInput {
1555 field: error.field(),
1556 reason: error.reason(),
1557 }
1558}
1559
1560#[cfg(test)]
1561mod tests {
1562 use super::*;
1563
1564 const ISS_L1: &str = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
1565 const ISS_L2: &str = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
1566 const ISS_FAST_L1: &str =
1567 "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
1568 const ISS_FAST_L2: &str =
1569 "2 25544 51.6414 295.8524 0003435 262.6267 202.7868 15.64005638121106";
1570 const ISS_OPPOSITE_L2: &str =
1571 "2 25544 51.6414 295.8524 0003435 262.6267 24.2868 15.54005638121106";
1572
1573 #[test]
1574 fn constructed_tles_find_expected_close_approach() {
1575 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
1576 let start = primary.epoch_jd();
1577 let end = add_seconds_to_julian_date(start, 12_000.0);
1578 let candidates = find_tca_candidates_from_tles(
1579 ISS_L1,
1580 ISS_L2,
1581 ISS_FAST_L1,
1582 ISS_FAST_L2,
1583 start,
1584 end,
1585 TcaFinderOptions {
1586 coarse_step_seconds: 30.0,
1587 time_tolerance_seconds: 1.0e-4,
1588 },
1589 )
1590 .expect("TCA search succeeds");
1591
1592 assert_eq!(candidates.len(), 1);
1593 let best = candidates[0];
1594
1595 assert_close(
1596 best.tca_seconds_since_window_start,
1597 3_599.834_762_793_019_3,
1598 2.0e-4,
1599 );
1600 assert_close(best.miss_distance_km, 28.942_032_135_766_88, 1.0e-9);
1601 assert_close(
1602 vec3::norm3(best.relative_position_km),
1603 best.miss_distance_km,
1604 1.0e-12,
1605 );
1606 assert!(vec3::norm3(best.relative_velocity_km_s) > 0.0);
1607 }
1608
1609 #[test]
1610 fn tca_candidates_include_window_boundary_minima() {
1611 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
1612 let secondary =
1613 Satellite::from_tle(ISS_FAST_L1, ISS_FAST_L2).expect("secondary TLE parses");
1614 let options = TcaFinderOptions {
1615 coarse_step_seconds: 30.0,
1616 time_tolerance_seconds: 1.0e-4,
1617 };
1618 let full_start = primary.epoch_jd();
1619 let full_end = add_seconds_to_julian_date(full_start, 12_000.0);
1620 let interior = find_tca_candidates(&primary, &secondary, full_start, full_end, options)
1621 .expect("interior TCA search succeeds");
1622 assert_eq!(interior.len(), 1);
1623 let tca = interior[0];
1624
1625 let start_boundary_end = add_seconds_to_julian_date(tca.tca_time, 600.0);
1626 let start_boundary = find_tca_candidates(
1627 &primary,
1628 &secondary,
1629 tca.tca_time,
1630 start_boundary_end,
1631 options,
1632 )
1633 .expect("start-boundary TCA search succeeds");
1634
1635 assert_eq!(start_boundary.len(), 1);
1636 assert_close(start_boundary[0].tca_seconds_since_window_start, 0.0, 0.0);
1637 assert_close(
1638 start_boundary[0].miss_distance_km,
1639 tca.miss_distance_km,
1640 1.0e-9,
1641 );
1642
1643 let end_boundary_start = add_seconds_to_julian_date(tca.tca_time, -600.0);
1644 let end_boundary = find_tca_candidates(
1645 &primary,
1646 &secondary,
1647 end_boundary_start,
1648 tca.tca_time,
1649 options,
1650 )
1651 .expect("end-boundary TCA search succeeds");
1652
1653 assert_eq!(end_boundary.len(), 1);
1654 assert_close(
1655 end_boundary[0].tca_seconds_since_window_start,
1656 600.0,
1657 1.0e-8,
1658 );
1659 assert_close(
1660 end_boundary[0].miss_distance_km,
1661 tca.miss_distance_km,
1662 1.0e-9,
1663 );
1664 }
1665
1666 #[test]
1667 fn tca_candidates_suppress_constant_range_boundary_minima() {
1668 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
1669 let secondary = Satellite::from_tle(ISS_L1, ISS_L2).expect("secondary TLE parses");
1670 let start = primary.epoch_jd();
1671 let end = add_seconds_to_julian_date(start, 900.0);
1672 let options = TcaFinderOptions {
1673 coarse_step_seconds: 30.0,
1674 time_tolerance_seconds: 1.0e-4,
1675 };
1676
1677 let candidates = find_tca_candidates(&primary, &secondary, start, end, options)
1678 .expect("constant-range TCA search succeeds");
1679
1680 assert!(
1681 candidates.is_empty(),
1682 "constant relative range should not emit boundary TCAs: {candidates:?}"
1683 );
1684 }
1685
1686 #[test]
1687 fn tca_boundary_minimum_uses_last_partial_coarse_sample() {
1688 let minima = minimum_extrema_including_boundaries_from_values(
1689 Vec::new(),
1690 95.0,
1691 30.0,
1692 |time_seconds| {
1693 Ok(sampled_range_value(
1694 time_seconds,
1695 &[
1696 (0.0, 10.0),
1697 (30.0, 9.0),
1698 (65.0, 9.0),
1699 (90.0, 11.0),
1700 (95.0, 10.0),
1701 ],
1702 ))
1703 },
1704 )
1705 .expect("boundary classification succeeds");
1706
1707 assert_eq!(minima.len(), 1);
1708 assert_close(minima[0].time_seconds, 95.0, 0.0);
1709 assert_close(minima[0].value, 10.0, 0.0);
1710 assert_eq!(minima[0].kind, ExtremumKind::Minimum);
1711 }
1712
1713 #[test]
1714 fn tca_boundary_minimum_uses_repeated_addition_penultimate_sample() {
1715 let minima = minimum_extrema_including_boundaries_from_values(
1716 Vec::new(),
1717 1.05,
1718 0.1,
1719 |time_seconds| {
1720 Ok(sampled_range_value(
1721 time_seconds,
1722 &[
1723 (0.0, 10.0),
1724 (0.1, 9.0),
1725 (0.999_999_999_999_999_9, 11.0),
1726 (1.0, 9.0),
1727 (1.05, 10.0),
1728 ],
1729 ))
1730 },
1731 )
1732 .expect("boundary classification succeeds");
1733
1734 assert_eq!(minima.len(), 1);
1735 assert_close(minima[0].time_seconds, 1.05, 0.0);
1736 assert_close(minima[0].value, 10.0, 0.0);
1737 assert_eq!(minima[0].kind, ExtremumKind::Minimum);
1738 }
1739
1740 #[test]
1741 fn tca_boundary_minimum_uses_inserted_midpoint_sample() {
1742 let midpoint_minimum = ExtremumEvent {
1743 time_seconds: 5.0,
1744 value: 8.0,
1745 kind: ExtremumKind::Minimum,
1746 };
1747 let minima = minimum_extrema_including_boundaries_from_values(
1748 vec![midpoint_minimum],
1749 10.0,
1750 30.0,
1751 |time_seconds| {
1752 Ok(sampled_range_value(
1753 time_seconds,
1754 &[(0.0, 9.0), (5.0, 8.0), (10.0, 10.0)],
1755 ))
1756 },
1757 )
1758 .expect("boundary classification succeeds");
1759
1760 assert_eq!(minima, vec![midpoint_minimum]);
1761 }
1762
1763 #[test]
1764 fn tca_boundary_neighbor_times_keep_exact_multiple_window() {
1765 assert_eq!(extrema_boundary_neighbor_times(120.0, 30.0), (30.0, 90.0));
1766 }
1767
1768 #[test]
1769 fn tca_candidate_pipeline_deduplicates_flat_bottom_close_approach() {
1770 let start = JulianDate(2_458_303.0, 0.0);
1771 let extrema = EventFinder::new(0.0, 3.0, 1.0, 1.0e-12)
1772 .expect("valid finder")
1773 .find_extrema(flat_bottom_range_km)
1774 .expect("finite flat-bottom range");
1775 let candidates: Vec<_> = extrema
1776 .into_iter()
1777 .filter(|event| event.kind == ExtremumKind::Minimum)
1778 .map(|event| TcaCandidate {
1779 tca_time: add_seconds_to_julian_date(start, event.time_seconds),
1780 tca_seconds_since_window_start: event.time_seconds,
1781 miss_distance_km: event.value,
1782 relative_position_km: [event.value, 0.0, 0.0],
1783 relative_velocity_km_s: [0.0; 3],
1784 })
1785 .collect();
1786
1787 assert_eq!(candidates.len(), 1);
1788 assert!((1.0..=2.0).contains(&candidates[0].tca_seconds_since_window_start));
1789 assert_close(candidates[0].miss_distance_km, 1.0, 1.0e-12);
1790 }
1791
1792 #[test]
1793 fn teme_to_gcrs_covariance_transport_uses_full_state_jacobian() {
1794 let transform = teme_to_gcrs_state_transform_at(
1795 JulianDate(2_458_303.0, 0.809_691_02),
1796 TcaObject::Primary,
1797 )
1798 .expect("TEME->GCRS state transform is valid");
1799 let covariance = Covariance6::from_diagonal([100.0, 144.0, 196.0, 1.0e-4, 1.5e-4, 2.0e-4])
1800 .expect("test covariance is valid");
1801
1802 let actual = transform
1803 .transform_covariance(covariance)
1804 .expect("full-jacobian transform preserves covariance validity");
1805 let expected =
1806 manual_full_jacobian_covariance(covariance.as_matrix(), &transform.state_jacobian())
1807 .expect("manual full-jacobian covariance is valid");
1808 assert_covariance_close(actual.as_matrix(), expected.as_matrix(), 1.0e-10);
1809
1810 let position_only = covariance
1811 .propagate_with_stm(&position_only_state_jacobian(&transform.rotation))
1812 .expect("position-only transform preserves covariance validity");
1813 assert_covariance_diff_exceeds(actual.as_matrix(), position_only.as_matrix(), 1.0e-12);
1814 assert!(actual.is_symmetric());
1815 assert!(actual.is_positive_semidefinite());
1816 }
1817
1818 #[test]
1819 fn split_jd_preserves_civil_day_just_before_midnight() {
1820 let epsilon_days = 2.0e-10;
1821 let tca_time = JulianDate(2_458_303.0, 0.5 - epsilon_days);
1822 assert_eq!(tca_time.0 + tca_time.1, 2_458_303.5);
1823
1824 let day_fraction = tca_time.1 + 0.5;
1825 let seconds_of_day = day_fraction * SECONDS_PER_DAY;
1826 let expected_second = seconds_of_day - 23.0 * 3600.0 - 59.0 * 60.0;
1827 let expected =
1828 TimeScales::from_utc(2018, 7, 3, 23, 59, expected_second).expect("valid UTC instant");
1829 let actual =
1830 time_scales_from_sgp4_julian_date(tca_time).expect("split JD converts to time scales");
1831 assert_eq!(actual, expected);
1832
1833 let one_day_late =
1834 TimeScales::from_utc(2018, 7, 4, 23, 59, expected_second).expect("valid UTC instant");
1835 assert_ne!(actual, one_day_late);
1836
1837 let actual_rotation =
1838 teme_to_gcrs_rotation_at(tca_time, TcaObject::Primary).expect("split rotation");
1839 let expected_rotation = teme_to_gcrs_matrix(&expected, false);
1840 assert_eq!(actual_rotation, expected_rotation);
1841 let one_day_late_rotation = teme_to_gcrs_matrix(&one_day_late, false);
1842 assert_matrix_diff_exceeds(&actual_rotation, &one_day_late_rotation, 1.0e-8);
1843
1844 let candidate = TcaCandidate {
1845 tca_time,
1846 tca_seconds_since_window_start: 0.0,
1847 miss_distance_km: vec3::norm3([0.012, -0.017, 0.006]),
1848 relative_position_km: [0.012, -0.017, 0.006],
1849 relative_velocity_km_s: [0.09, 7.4, -1.2],
1850 };
1851 let pc_options = TcaPcOptions::with_covariances(
1852 0.010,
1853 PcMethod::Alfano2005,
1854 [
1855 [4.0e-4, 1.0e-5, -2.0e-5],
1856 [1.0e-5, 2.5e-5, 3.0e-6],
1857 [-2.0e-5, 3.0e-6, 9.0e-5],
1858 ],
1859 [[1.0e-5, 0.0, 0.0], [0.0, 6.4e-5, 0.0], [0.0, 0.0, 2.5e-5]],
1860 );
1861 let actual_pc = tca_collision_probability(candidate, pc_options)
1862 .expect("split-JD Pc")
1863 .collision_probability;
1864 let one_day_late_pc = tca_collision_probability(
1865 TcaCandidate {
1866 tca_time: JulianDate(tca_time.0 + 1.0, tca_time.1),
1867 ..candidate
1868 },
1869 pc_options,
1870 )
1871 .expect("one-day-late Pc")
1872 .collision_probability;
1873 assert_probability_diff_exceeds(actual_pc.pc, one_day_late_pc.pc, 1.0e-10);
1874 }
1875
1876 #[test]
1877 fn split_jd_matches_summed_path_away_from_midnight() {
1878 let midday = JulianDate(2_458_303.0, 0.0);
1879
1880 let actual =
1881 time_scales_from_sgp4_julian_date(midday).expect("midday converts to time scales");
1882 let summed = summed_time_scales_from_sgp4_julian_date(midday);
1883 assert_eq!(actual, summed);
1884
1885 let actual_rotation =
1886 teme_to_gcrs_rotation_at(midday, TcaObject::Primary).expect("midday rotation");
1887 let summed_rotation = summed_teme_to_gcrs_rotation_at(midday);
1888 assert_eq!(actual_rotation, summed_rotation);
1889 }
1890
1891 #[test]
1892 fn tca_pc_rejects_unconvertible_julian_dates_as_invalid_input() {
1893 let options = TcaPcOptions::with_default_covariance(0.010, PcMethod::Alfano2005);
1894
1895 assert_eq!(
1896 tca_collision_probability(tca_pc_test_candidate(JulianDate(1.0e20, 0.0)), options),
1897 Err(TcaError::InvalidInput {
1898 field: "julian_date",
1899 reason: "out of range",
1900 })
1901 );
1902 assert!(matches!(
1903 tca_collision_probability(
1904 tca_pc_test_candidate(JulianDate(f64::INFINITY, 0.0)),
1905 options
1906 ),
1907 Err(TcaError::InvalidInput {
1908 reason: "not finite",
1909 ..
1910 })
1911 ));
1912 }
1913
1914 #[test]
1915 fn tca_pc_rejects_invalid_candidate_relative_state_before_transform() {
1916 let options = TcaPcOptions::with_default_covariance(0.010, PcMethod::Alfano2005);
1917 let mut candidate = tca_pc_test_candidate(JulianDate(2_458_303.0, 0.0));
1918 candidate.relative_position_km[0] = f64::NAN;
1919
1920 assert_eq!(
1921 tca_collision_probability(candidate, options),
1922 Err(TcaError::InvalidInput {
1923 field: "candidate.relative_position_km",
1924 reason: "not finite",
1925 })
1926 );
1927
1928 candidate.relative_position_km = [MAX_TCA_RELATIVE_POSITION_KM * 2.0, 0.0, 0.0];
1929 assert_eq!(
1930 tca_collision_probability(candidate, options),
1931 Err(TcaError::InvalidInput {
1932 field: "candidate.relative_position_km",
1933 reason: "out of range",
1934 })
1935 );
1936 }
1937
1938 #[test]
1939 fn tca_pc_in_range_julian_date_matches_summed_reference_path() {
1940 let candidate = tca_pc_test_candidate(JulianDate(2_458_303.0, 0.0));
1941 let options = TcaPcOptions::with_default_covariance(0.010, PcMethod::Alfano2005);
1942
1943 let actual_state =
1944 tca_candidate_relative_state_for_pc(candidate).expect("in-range frame transform");
1945 let expected_transform = summed_teme_to_gcrs_state_transform_at(candidate.tca_time);
1946 let expected_state = expected_transform.transform_state(CartesianState::new(
1947 0.0,
1948 candidate.relative_position_km,
1949 candidate.relative_velocity_km_s,
1950 ));
1951 assert_eq!(
1952 actual_state.relative_position_km,
1953 expected_state.position_array()
1954 );
1955 assert_eq!(
1956 actual_state.relative_velocity_km_s,
1957 expected_state.velocity_array()
1958 );
1959
1960 let actual = tca_collision_probability(candidate, options).expect("in-range TCA Pc");
1961 let expected_primary = ConjunctionState {
1962 position_km: expected_state.position_array(),
1963 velocity_km_s: expected_state.velocity_array(),
1964 covariance_km2: options.covariances.primary_covariance_km2,
1965 };
1966 let expected_secondary = ConjunctionState {
1967 position_km: [0.0; 3],
1968 velocity_km_s: [0.0; 3],
1969 covariance_km2: options.covariances.secondary_covariance_km2,
1970 };
1971 let expected = collision_probability(
1972 &expected_primary,
1973 &expected_secondary,
1974 options.hard_body_radius_km,
1975 options.method,
1976 )
1977 .expect("summed-reference TCA Pc");
1978 assert_eq!(actual.candidate, candidate);
1979 assert_eq!(actual.collision_probability, expected);
1980 }
1981
1982 #[test]
1983 fn invalid_window_is_rejected() {
1984 let start = JulianDate(2_458_303.0, 0.5);
1985 let end = JulianDate(2_458_303.0, 0.4);
1986 assert_eq!(
1987 find_tca_candidates_from_tles(
1988 ISS_L1,
1989 ISS_L2,
1990 ISS_FAST_L1,
1991 ISS_FAST_L2,
1992 start,
1993 end,
1994 TcaFinderOptions::default(),
1995 ),
1996 Err(TcaError::InvalidInput {
1997 field: "window_end",
1998 reason: "negative",
1999 })
2000 );
2001 }
2002
2003 #[test]
2004 fn screening_returns_only_threshold_breaches_and_parallel_matches_serial() {
2005 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
2006 let far = Satellite::from_tle(ISS_FAST_L1, ISS_OPPOSITE_L2).expect("far TLE parses");
2007 let close = Satellite::from_tle(ISS_FAST_L1, ISS_FAST_L2).expect("close TLE parses");
2008 let secondaries = [far, close];
2009 let start = primary.epoch_jd();
2010 let end = add_seconds_to_julian_date(start, 12_000.0);
2011 let options = TcaFinderOptions {
2012 coarse_step_seconds: 30.0,
2013 time_tolerance_seconds: 1.0e-4,
2014 };
2015
2016 let serial =
2017 screen_tca_candidates_serial(&primary, &secondaries, start, end, 30.0, options)
2018 .expect("serial screening succeeds");
2019 let parallel =
2020 screen_tca_candidates_parallel(&primary, &secondaries, start, end, 30.0, options)
2021 .expect("parallel screening succeeds");
2022
2023 assert_eq!(serial, parallel);
2024 assert_eq!(serial.len(), 1);
2025
2026 let hit = serial[0];
2027 assert_eq!(hit.secondary_index, 1);
2028 assert_close(
2029 hit.candidate.tca_seconds_since_window_start,
2030 3_599.834_762_793_019_3,
2031 2.0e-4,
2032 );
2033 assert_close(
2034 hit.candidate.miss_distance_km,
2035 28.942_032_135_766_88,
2036 1.0e-9,
2037 );
2038 assert!(hit.candidate.miss_distance_km <= 30.0);
2039 }
2040
2041 #[test]
2042 fn tca_pc_matches_direct_conjunction_call_with_supplied_covariance() {
2043 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
2044 let secondary =
2045 Satellite::from_tle(ISS_FAST_L1, ISS_FAST_L2).expect("secondary TLE parses");
2046 let start = primary.epoch_jd();
2047 let end = add_seconds_to_julian_date(start, 12_000.0);
2048 let tca_options = TcaFinderOptions {
2049 coarse_step_seconds: 30.0,
2050 time_tolerance_seconds: 1.0e-4,
2051 };
2052 let candidates = find_tca_candidates(&primary, &secondary, start, end, tca_options)
2053 .expect("TCA search succeeds");
2054 assert_eq!(candidates.len(), 1);
2055
2056 let candidate = candidates[0];
2057 let primary_covariance_km2 = [[0.04, 0.001, 0.0], [0.001, 0.09, 0.002], [0.0, 0.002, 0.16]];
2058 let secondary_covariance_km2 = [
2059 [0.01, 0.0, 0.0],
2060 [0.0, 0.0225, 0.0005],
2061 [0.0, 0.0005, 0.0625],
2062 ];
2063 let pc_options = TcaPcOptions::with_covariances(
2064 0.020,
2065 PcMethod::Alfano2005,
2066 primary_covariance_km2,
2067 secondary_covariance_km2,
2068 );
2069
2070 let conjunction =
2071 tca_collision_probability(candidate, pc_options).expect("Pc is defined at TCA");
2072 let pc_state =
2073 tca_candidate_relative_state_for_pc(candidate).expect("candidate converts to GCRS");
2074 let direct_primary = ConjunctionState {
2075 position_km: pc_state.relative_position_km,
2076 velocity_km_s: pc_state.relative_velocity_km_s,
2077 covariance_km2: primary_covariance_km2,
2078 };
2079 let direct_secondary = ConjunctionState {
2080 position_km: [0.0; 3],
2081 velocity_km_s: [0.0; 3],
2082 covariance_km2: secondary_covariance_km2,
2083 };
2084 let direct = collision_probability(
2085 &direct_primary,
2086 &direct_secondary,
2087 pc_options.hard_body_radius_km,
2088 pc_options.method,
2089 )
2090 .expect("direct Pc is defined");
2091
2092 assert_eq!(conjunction.candidate, candidate);
2093 assert_eq!(conjunction.collision_probability, direct);
2094
2095 let from_finder =
2096 find_tca_conjunctions(&primary, &secondary, start, end, tca_options, pc_options)
2097 .expect("TCA Pc search succeeds");
2098 assert_eq!(from_finder, vec![conjunction]);
2099 }
2100
2101 #[test]
2102 fn tca_pc_with_initial_covariances_matches_direct_pc_after_manual_propagation() {
2103 let primary = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
2104 let secondary =
2105 Satellite::from_tle(ISS_FAST_L1, ISS_FAST_L2).expect("secondary TLE parses");
2106 let start = primary.epoch_jd();
2107 let end = add_seconds_to_julian_date(start, 12_000.0);
2108 let tca_options = TcaFinderOptions {
2109 coarse_step_seconds: 30.0,
2110 time_tolerance_seconds: 1.0e-4,
2111 };
2112 let candidates = find_tca_candidates(&primary, &secondary, start, end, tca_options)
2113 .expect("TCA search succeeds");
2114 assert_eq!(candidates.len(), 1);
2115
2116 let candidate = candidates[0];
2117 let primary_covariance0 =
2118 Covariance6::from_diagonal([100.0, 144.0, 196.0, 1.0e-4, 1.5e-4, 2.0e-4]).unwrap();
2119 let secondary_covariance0 =
2120 Covariance6::from_diagonal([64.0, 81.0, 121.0, 8.0e-5, 9.0e-5, 1.0e-4]).unwrap();
2121 let pc_options = TcaPropagatedCovariancePcOptions::new(
2122 0.020,
2123 PcMethod::Alfano2005,
2124 primary_covariance0,
2125 secondary_covariance0,
2126 )
2127 .with_covariance_propagator(
2128 ForceModelKind::two_body_j2(),
2129 IntegratorKind::Rk4,
2130 IntegratorOptions {
2131 initial_step: 30.0,
2132 ..IntegratorOptions::default()
2133 },
2134 );
2135
2136 let conjunction = tca_collision_probability_with_propagated_covariance(
2137 &primary, &secondary, candidate, pc_options,
2138 )
2139 .expect("propagated covariance Pc is defined");
2140
2141 let primary_covariance_km2 =
2142 manual_position_covariance_at_tca(&primary, primary_covariance0, candidate, pc_options);
2143 let secondary_covariance_km2 = manual_position_covariance_at_tca(
2144 &secondary,
2145 secondary_covariance0,
2146 candidate,
2147 pc_options,
2148 );
2149 assert_ne!(
2150 primary_covariance_km2,
2151 primary_covariance0.position_covariance_km2()
2152 );
2153 assert_ne!(
2154 secondary_covariance_km2,
2155 secondary_covariance0.position_covariance_km2()
2156 );
2157 assert_matrix_diff_exceeds(
2158 &primary_covariance_km2,
2159 &pre_fix_position_covariance_at_tca(
2160 &primary,
2161 primary_covariance0,
2162 candidate,
2163 pc_options,
2164 ),
2165 1.0e-6,
2166 );
2167 assert_matrix_diff_exceeds(
2168 &secondary_covariance_km2,
2169 &pre_fix_position_covariance_at_tca(
2170 &secondary,
2171 secondary_covariance0,
2172 candidate,
2173 pc_options,
2174 ),
2175 1.0e-6,
2176 );
2177
2178 let pc_state =
2179 tca_candidate_relative_state_for_pc(candidate).expect("candidate converts to GCRS");
2180 let direct_primary = ConjunctionState {
2181 position_km: pc_state.relative_position_km,
2182 velocity_km_s: pc_state.relative_velocity_km_s,
2183 covariance_km2: primary_covariance_km2,
2184 };
2185 let direct_secondary = ConjunctionState {
2186 position_km: [0.0; 3],
2187 velocity_km_s: [0.0; 3],
2188 covariance_km2: secondary_covariance_km2,
2189 };
2190 let direct = collision_probability(
2191 &direct_primary,
2192 &direct_secondary,
2193 pc_options.hard_body_radius_km,
2194 pc_options.method,
2195 )
2196 .expect("direct propagated-covariance Pc is defined");
2197
2198 assert_eq!(conjunction.candidate, candidate);
2199 assert_eq!(conjunction.collision_probability, direct);
2200
2201 let unconverted_primary = ConjunctionState {
2202 position_km: candidate.relative_position_km,
2203 velocity_km_s: candidate.relative_velocity_km_s,
2204 covariance_km2: primary_covariance_km2,
2205 };
2206 let unconverted = collision_probability(
2207 &unconverted_primary,
2208 &direct_secondary,
2209 pc_options.hard_body_radius_km,
2210 pc_options.method,
2211 )
2212 .expect("unconverted TEME candidate still produces a Pc");
2213 assert_probability_diff_exceeds(direct.pc, unconverted.pc, 1.0e-20);
2214
2215 let from_finder = find_tca_conjunctions_with_propagated_covariance(
2216 &primary,
2217 &secondary,
2218 start,
2219 end,
2220 tca_options,
2221 pc_options,
2222 )
2223 .expect("propagated covariance TCA Pc search succeeds");
2224 assert_eq!(from_finder, vec![conjunction]);
2225 }
2226
2227 #[test]
2228 fn tle_catalog_screening_propagates_initial_covariances_to_pc() {
2229 let primary_satellite = Satellite::from_tle(ISS_L1, ISS_L2).expect("primary TLE parses");
2230 let start = primary_satellite.epoch_jd();
2231 let window = TcaWindow::from_start_and_duration_seconds(start, 12_000.0)
2232 .expect("window duration is valid");
2233 let tca_options = TcaFinderOptions {
2234 coarse_step_seconds: 30.0,
2235 time_tolerance_seconds: 1.0e-4,
2236 };
2237 let primary_covariance0 =
2238 Covariance6::from_diagonal([100.0, 144.0, 196.0, 1.0e-4, 1.5e-4, 2.0e-4]).unwrap();
2239 let secondary_covariance0 =
2240 Covariance6::from_diagonal([64.0, 81.0, 121.0, 8.0e-5, 9.0e-5, 1.0e-4]).unwrap();
2241 let primary = TcaTleWithCovariance::new(ISS_L1, ISS_L2, primary_covariance0);
2242 let secondary = TcaTleWithCovariance::new(ISS_FAST_L1, ISS_FAST_L2, secondary_covariance0);
2243 let pc_options = TcaPropagatedCovarianceOptions::new(0.020, PcMethod::Alfano2005)
2244 .with_covariance_propagator(
2245 ForceModelKind::two_body_j2(),
2246 IntegratorKind::Rk4,
2247 IntegratorOptions {
2248 initial_step: 30.0,
2249 ..IntegratorOptions::default()
2250 },
2251 );
2252
2253 let secondaries = [secondary];
2254 let serial = screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog_serial(
2255 primary,
2256 &secondaries,
2257 window,
2258 30.0,
2259 tca_options,
2260 pc_options,
2261 )
2262 .expect("serial propagated-covariance screening succeeds");
2263 let parallel =
2264 screen_tca_conjunctions_with_propagated_covariance_from_tle_catalog_parallel(
2265 primary,
2266 &secondaries,
2267 window,
2268 30.0,
2269 tca_options,
2270 pc_options,
2271 )
2272 .expect("parallel propagated-covariance screening succeeds");
2273
2274 assert_eq!(serial, parallel);
2275 assert_eq!(serial.len(), 1);
2276 assert_eq!(serial[0].secondary_index, 0);
2277 assert!(serial[0].conjunction.candidate.miss_distance_km <= 30.0);
2278 assert!(serial[0].conjunction.collision_probability.pc.is_finite());
2279 assert!((0.0..=1.0).contains(&serial[0].conjunction.collision_probability.pc));
2280
2281 let pairwise = find_tca_conjunctions_with_propagated_covariance_between_tles(
2282 primary.tle,
2283 secondary.tle,
2284 window,
2285 tca_options,
2286 pc_options.for_initial_covariances(primary_covariance0, secondary_covariance0),
2287 )
2288 .expect("pairwise propagated-covariance TCA Pc search succeeds");
2289 assert_eq!(serial[0].conjunction, pairwise[0]);
2290 }
2291
2292 fn flat_bottom_range_km(time_seconds: f64) -> f64 {
2293 if time_seconds < 1.0 {
2294 2.0 - time_seconds
2295 } else if time_seconds <= 2.0 {
2296 1.0
2297 } else {
2298 time_seconds - 1.0
2299 }
2300 }
2301
2302 fn sampled_range_value(time_seconds: f64, samples: &[(f64, f64)]) -> f64 {
2303 samples
2304 .iter()
2305 .find_map(|(sample_time, value)| {
2306 (time_seconds.to_bits() == sample_time.to_bits()).then_some(*value)
2307 })
2308 .unwrap_or_else(|| panic!("unexpected boundary sample time {time_seconds}"))
2309 }
2310
2311 fn assert_close(actual: f64, expected: f64, tolerance: f64) {
2312 assert!(
2313 (actual - expected).abs() <= tolerance,
2314 "{actual} differs from {expected} by more than {tolerance}"
2315 );
2316 }
2317
2318 fn assert_matrix_diff_exceeds(actual: &Mat3, expected: &Mat3, threshold: f64) {
2319 let mut max_diff = 0.0_f64;
2320 for i in 0..3 {
2321 for j in 0..3 {
2322 max_diff = max_diff.max((actual[i][j] - expected[i][j]).abs());
2323 }
2324 }
2325 assert!(
2326 max_diff > threshold,
2327 "matrix diff {max_diff} did not exceed {threshold}"
2328 );
2329 }
2330
2331 fn assert_probability_diff_exceeds(actual: f64, expected: f64, threshold: f64) {
2332 let diff = (actual - expected).abs();
2333 assert!(
2334 diff > threshold,
2335 "probability diff {diff} did not exceed {threshold}: {actual} vs {expected}"
2336 );
2337 }
2338
2339 fn assert_covariance_close(actual: &Mat6, expected: &Mat6, tolerance: f64) {
2340 for i in 0..6 {
2341 for j in 0..6 {
2342 assert!(
2343 (actual[i][j] - expected[i][j]).abs() <= tolerance,
2344 "covariance[{i}][{j}] = {} differs from {} by more than {tolerance}",
2345 actual[i][j],
2346 expected[i][j]
2347 );
2348 }
2349 }
2350 }
2351
2352 fn assert_covariance_diff_exceeds(actual: &Mat6, expected: &Mat6, threshold: f64) {
2353 let mut max_diff = 0.0_f64;
2354 for i in 0..6 {
2355 for j in 0..6 {
2356 max_diff = max_diff.max((actual[i][j] - expected[i][j]).abs());
2357 }
2358 }
2359 assert!(
2360 max_diff > threshold,
2361 "covariance diff {max_diff} did not exceed {threshold}"
2362 );
2363 }
2364
2365 fn summed_time_scales_from_sgp4_julian_date(time: JulianDate) -> TimeScales {
2366 let jd_total = time.0 + time.1;
2367 let mut jd_midnight = (jd_total - 0.5).floor() + 0.5;
2368 let mut day_fraction = (time.0 - jd_midnight) + time.1;
2369 if !(0.0..1.0).contains(&day_fraction) {
2370 let day_offset = day_fraction.floor();
2371 jd_midnight += day_offset;
2372 day_fraction -= day_offset;
2373 }
2374
2375 let jdn = (jd_midnight + 0.5).round() as i64;
2376 let (year, month, day) = civil_from_jdn(jdn);
2377 let seconds_of_day = day_fraction * SECONDS_PER_DAY;
2378 let hour = (seconds_of_day / 3600.0).floor() as i32;
2379 let minute = ((seconds_of_day - f64::from(hour) * 3600.0) / 60.0).floor() as i32;
2380 let second = seconds_of_day - f64::from(hour) * 3600.0 - f64::from(minute) * 60.0;
2381
2382 TimeScales::from_utc(year as i32, month as i32, day as i32, hour, minute, second)
2383 .expect("summed JD test instant is valid")
2384 }
2385
2386 fn summed_teme_to_gcrs_rotation_at(time: JulianDate) -> Mat3 {
2387 let time_scales = summed_time_scales_from_sgp4_julian_date(time);
2388 teme_to_gcrs_matrix(&time_scales, false)
2389 }
2390
2391 fn summed_teme_to_gcrs_state_transform_at(time: JulianDate) -> TemeToGcrsStateTransform {
2392 let rotation = summed_teme_to_gcrs_rotation_at(time);
2393 let before = summed_teme_to_gcrs_rotation_at(add_seconds_to_julian_date(
2394 time,
2395 -FRAME_DERIVATIVE_STEP_SECONDS,
2396 ));
2397 let after = summed_teme_to_gcrs_rotation_at(add_seconds_to_julian_date(
2398 time,
2399 FRAME_DERIVATIVE_STEP_SECONDS,
2400 ));
2401
2402 TemeToGcrsStateTransform {
2403 rotation,
2404 rotation_derivative: centered_rotation_derivative(
2405 &before,
2406 &after,
2407 FRAME_DERIVATIVE_STEP_SECONDS,
2408 ),
2409 }
2410 }
2411
2412 fn tca_pc_test_candidate(tca_time: JulianDate) -> TcaCandidate {
2413 TcaCandidate {
2414 tca_time,
2415 tca_seconds_since_window_start: 0.0,
2416 miss_distance_km: vec3::norm3([0.012, -0.017, 0.006]),
2417 relative_position_km: [0.012, -0.017, 0.006],
2418 relative_velocity_km_s: [0.09, 7.4, -1.2],
2419 }
2420 }
2421
2422 #[allow(clippy::needless_range_loop)]
2423 fn manual_full_jacobian_covariance(
2424 covariance: &Mat6,
2425 jacobian: &Mat6,
2426 ) -> Result<Covariance6, Covariance6Error> {
2427 let mut transformed = [[0.0_f64; 6]; 6];
2428 for i in 0..6 {
2429 for j in 0..6 {
2430 for k in 0..6 {
2431 for l in 0..6 {
2432 transformed[i][j] += jacobian[i][k] * covariance[k][l] * jacobian[j][l];
2433 }
2434 }
2435 }
2436 }
2437 symmetrize6_for_test(&mut transformed);
2438 Covariance6::try_from_matrix(transformed)
2439 }
2440
2441 fn position_only_state_jacobian(rotation: &Mat3) -> Mat6 {
2442 let mut jacobian = [[0.0_f64; 6]; 6];
2443 for row in 0..3 {
2444 for col in 0..3 {
2445 jacobian[row][col] = rotation[row][col];
2446 jacobian[row + 3][col + 3] = rotation[row][col];
2447 }
2448 }
2449 jacobian
2450 }
2451
2452 #[allow(clippy::needless_range_loop)]
2453 fn symmetrize6_for_test(matrix: &mut Mat6) {
2454 for i in 0..6 {
2455 for j in (i + 1)..6 {
2456 let avg = 0.5 * (matrix[i][j] + matrix[j][i]);
2457 matrix[i][j] = avg;
2458 matrix[j][i] = avg;
2459 }
2460 }
2461 }
2462
2463 fn manual_position_covariance_at_tca(
2464 satellite: &Satellite,
2465 covariance0: Covariance6,
2466 candidate: TcaCandidate,
2467 options: TcaPropagatedCovariancePcOptions,
2468 ) -> [[f64; 3]; 3] {
2469 let epoch_state = satellite
2470 .propagate(MinutesSinceEpoch(0.0))
2471 .expect("satellite propagates at its epoch");
2472 let epoch = satellite.epoch_jd();
2473 let span_seconds = seconds_between_julian_dates(epoch, candidate.tca_time);
2474 let epoch_teme_to_gcrs = teme_to_gcrs_state_transform_at(epoch, TcaObject::Primary)
2475 .expect("epoch frame transform");
2476 let initial = epoch_teme_to_gcrs.transform_state(CartesianState::new(
2477 0.0,
2478 epoch_state.position,
2479 epoch_state.velocity,
2480 ));
2481 let covariance0 = epoch_teme_to_gcrs
2482 .transform_covariance(covariance0)
2483 .expect("initial covariance rotates into GCRS");
2484 let propagator = StatePropagator {
2485 initial,
2486 force_model: options.force_model,
2487 integrator: options.integrator,
2488 options: options.integrator_options,
2489 };
2490 let (_, covariance_f) = propagator
2491 .propagate_state_with_covariance(covariance0, span_seconds)
2492 .expect("manual covariance propagation succeeds");
2493 covariance_f.position_covariance_km2()
2494 }
2495
2496 fn pre_fix_position_covariance_at_tca(
2497 satellite: &Satellite,
2498 covariance0: Covariance6,
2499 candidate: TcaCandidate,
2500 options: TcaPropagatedCovariancePcOptions,
2501 ) -> [[f64; 3]; 3] {
2502 let epoch_state = satellite
2503 .propagate(MinutesSinceEpoch(0.0))
2504 .expect("satellite propagates at its epoch");
2505 let epoch = satellite.epoch_jd();
2506 let span_seconds = seconds_between_julian_dates(epoch, candidate.tca_time);
2507 let propagator = StatePropagator {
2508 initial: CartesianState::new(0.0, epoch_state.position, epoch_state.velocity),
2509 force_model: options.force_model,
2510 integrator: options.integrator,
2511 options: options.integrator_options,
2512 };
2513 let (_, covariance_f) = propagator
2514 .propagate_state_with_covariance(covariance0, span_seconds)
2515 .expect("pre-fix covariance propagation succeeds");
2516 covariance_f.position_covariance_km2()
2517 }
2518}