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