Skip to main content

sidereon_core/astro/
tca.rs

1//! Time of closest approach search between two TLE-backed satellites.
2//!
3//! The search predicate is relative range in the TEME frame. Sampling and
4//! refinement are delegated to the shared event finder; this module only owns
5//! the SGP4 propagation glue and the relative-state result packaging.
6
7use 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;
34/// Fallback per-object position covariance for Pc screening, km^2.
35///
36/// This is a convenience covariance for catalog screening when no object
37/// covariance is available. Callers with conjunction data should supply their
38/// own object covariances through [`TcaPcOptions::with_covariances`].
39pub 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/// Options for [`find_tca_candidates`] and [`find_tca_candidates_from_tles`].
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct TcaFinderOptions {
45    /// Coarse sampling step used to bracket local range minima.
46    pub coarse_step_seconds: f64,
47    /// Time tolerance to which each local range minimum is refined.
48    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/// One local time of closest approach candidate.
61///
62/// The relative state is `primary - secondary`, in the same TEME frame that
63/// SGP4 returns.
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct TcaCandidate {
66    /// Refined absolute TCA as a split Julian date.
67    pub tca_time: JulianDate,
68    /// Refined seconds since the search window start.
69    pub tca_seconds_since_window_start: f64,
70    /// Norm of [`TcaCandidate::relative_position_km`].
71    pub miss_distance_km: f64,
72    /// Primary minus secondary TEME position, km.
73    pub relative_position_km: [f64; 3],
74    /// Primary minus secondary TEME velocity, km/s.
75    pub relative_velocity_km_s: [f64; 3],
76}
77
78/// One threshold-screening result for a primary against a secondary catalog.
79#[derive(Debug, Clone, Copy, PartialEq)]
80pub struct TcaScreeningHit {
81    /// Index of the secondary satellite in the caller-supplied catalog slice.
82    pub secondary_index: usize,
83    /// Refined TCA candidate whose miss distance is at or below the threshold.
84    pub candidate: TcaCandidate,
85}
86
87/// Borrowed two-line element set.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub struct TcaTle<'a> {
90    /// TLE line 1.
91    pub line1: &'a str,
92    /// TLE line 2.
93    pub line2: &'a str,
94}
95
96impl<'a> TcaTle<'a> {
97    /// Build a borrowed TLE pair.
98    pub const fn new(line1: &'a str, line2: &'a str) -> Self {
99        Self { line1, line2 }
100    }
101}
102
103/// Borrowed TLE plus its 6x6 state covariance at the TLE epoch.
104#[derive(Debug, Clone, Copy, PartialEq)]
105pub struct TcaTleWithCovariance<'a> {
106    /// Borrowed two-line element set.
107    pub tle: TcaTle<'a>,
108    /// Cartesian TEME state covariance at the satellite's TLE epoch.
109    pub covariance0: Covariance6,
110}
111
112impl<'a> TcaTleWithCovariance<'a> {
113    /// Build a borrowed TLE pair with its initial state covariance.
114    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    /// Attach an initial state covariance to an existing borrowed TLE.
122    pub const fn from_tle(tle: TcaTle<'a>, covariance0: Covariance6) -> Self {
123        Self { tle, covariance0 }
124    }
125}
126
127/// Absolute time window searched for TCA events.
128#[derive(Debug, Clone, Copy, PartialEq)]
129pub struct TcaWindow {
130    /// Start of the search window.
131    pub start: JulianDate,
132    /// End of the search window.
133    pub end: JulianDate,
134}
135
136impl TcaWindow {
137    /// Build a TCA search window from absolute start and end Julian dates.
138    pub const fn new(start: JulianDate, end: JulianDate) -> Self {
139        Self { start, end }
140    }
141
142    /// Build a TCA search window from a start Julian date and duration.
143    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/// Per-object position covariances used when computing Pc at a TCA.
156#[derive(Debug, Clone, Copy, PartialEq)]
157pub struct TcaPcCovariances {
158    /// Primary-object GCRS position covariance, km^2.
159    pub primary_covariance_km2: [[f64; 3]; 3],
160    /// Secondary-object GCRS position covariance, km^2.
161    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/// Collision-probability options for evaluating a TCA candidate.
174#[derive(Debug, Clone, Copy, PartialEq)]
175pub struct TcaPcOptions {
176    /// Hard-body radius passed to the conjunction Pc module, km.
177    pub hard_body_radius_km: f64,
178    /// Collision-probability method from the conjunction Pc module.
179    pub method: PcMethod,
180    /// Per-object position covariances, km^2.
181    pub covariances: TcaPcCovariances,
182}
183
184impl TcaPcOptions {
185    /// Build Pc options using the fallback TCA position covariance.
186    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    /// Build Pc options with caller-supplied object position covariances.
195    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/// Collision-probability and covariance-transport settings for screening
213/// helpers whose primary/secondary covariances vary by catalog object.
214#[derive(Debug, Clone, Copy, PartialEq)]
215pub struct TcaPropagatedCovarianceOptions {
216    /// Hard-body radius passed to the conjunction Pc module, km.
217    pub hard_body_radius_km: f64,
218    /// Collision-probability method from the conjunction Pc module.
219    pub method: PcMethod,
220    /// Numerical force model used only for covariance transport.
221    pub force_model: ForceModelKind,
222    /// Numerical integrator used only for covariance transport.
223    pub integrator: IntegratorKind,
224    /// Step-size / tolerance controls for covariance transport.
225    pub integrator_options: IntegratorOptions,
226    /// Additive RTN acceleration process noise used during covariance transport.
227    pub process_noise: ProcessNoise,
228}
229
230impl TcaPropagatedCovarianceOptions {
231    /// Build propagated-covariance screening settings using Earth two-body + J2
232    /// and adaptive Dormand-Prince propagation for covariance transport.
233    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    /// Replace the numerical covariance-transport settings.
245    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    /// Replace the additive covariance process-noise model.
258    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/// Collision-probability options for propagating state covariances to a TCA.
282#[derive(Debug, Clone, Copy, PartialEq)]
283pub struct TcaPropagatedCovariancePcOptions {
284    /// Hard-body radius passed to the conjunction Pc module, km.
285    pub hard_body_radius_km: f64,
286    /// Collision-probability method from the conjunction Pc module.
287    pub method: PcMethod,
288    /// Primary-object initial 6x6 Cartesian TEME state covariance at the primary TLE epoch.
289    ///
290    /// To use a CDM RTN covariance, convert from m-based CDM units with
291    /// [`crate::astro::covariance::covariance6_m_to_km`], then rotate with
292    /// [`crate::astro::covariance::rtn_to_eci_covariance6`] at the object's
293    /// TEME epoch state before passing it here.
294    pub primary_covariance0: Covariance6,
295    /// Secondary-object initial 6x6 Cartesian TEME state covariance at the secondary TLE epoch.
296    ///
297    /// CDM RTN covariances follow the same conversion recipe as
298    /// [`Self::primary_covariance0`].
299    pub secondary_covariance0: Covariance6,
300    /// Numerical force model used only for covariance transport.
301    pub force_model: ForceModelKind,
302    /// Numerical integrator used only for covariance transport.
303    pub integrator: IntegratorKind,
304    /// Step-size / tolerance controls for covariance transport.
305    pub integrator_options: IntegratorOptions,
306    /// Additive RTN acceleration process noise used during covariance transport.
307    pub process_noise: ProcessNoise,
308}
309
310impl TcaPropagatedCovariancePcOptions {
311    /// Build propagated-covariance Pc options using Earth two-body + J2 and
312    /// adaptive Dormand-Prince propagation for the covariance transport.
313    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    /// Replace the numerical covariance-transport settings.
332    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    /// Replace the additive covariance process-noise model.
345    pub fn with_process_noise(mut self, process_noise: ProcessNoise) -> Self {
346        self.process_noise = process_noise;
347        self
348    }
349}
350
351/// A TCA candidate with the existing conjunction-module Pc result at that TCA.
352#[derive(Debug, Clone, Copy, PartialEq)]
353pub struct TcaConjunction {
354    /// Refined TCA candidate and miss-distance summary.
355    pub candidate: TcaCandidate,
356    /// Collision probability computed by [`crate::astro::conjunction`].
357    pub collision_probability: CollisionPc,
358}
359
360/// A threshold-screening hit with Pc evaluated at the returned TCA.
361#[derive(Debug, Clone, Copy, PartialEq)]
362pub struct TcaScreeningConjunctionHit {
363    /// Index of the secondary satellite in the caller-supplied catalog slice.
364    pub secondary_index: usize,
365    /// TCA and Pc result for this threshold breach.
366    pub conjunction: TcaConjunction,
367}
368
369/// One object in a materialized state-vector screening catalog.
370#[derive(Debug, Clone, PartialEq)]
371pub struct CatalogStateVector {
372    /// Optional caller-supplied object identifier.
373    pub id: Option<String>,
374    /// ECI position, km.
375    pub position_km: [f64; 3],
376    /// ECI velocity, km/s.
377    pub velocity_km_s: [f64; 3],
378    /// Position covariance, km^2.
379    pub covariance_km2: [[f64; 3]; 3],
380    /// Object hard-body radius, km.
381    pub hard_body_radius_km: f64,
382}
383
384/// Options for [`screen_state_vector_catalog`].
385#[derive(Debug, Clone, Copy, PartialEq)]
386pub struct CatalogScreeningOptions {
387    /// Coarse position-pair filter threshold, km.
388    pub miss_threshold_km: f64,
389    /// Minimum successful collision probability retained in the result set.
390    pub pc_threshold: f64,
391    /// Collision-probability method.
392    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/// A prefiltered catalog pair.
406#[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/// Successful collision-probability evaluation for a catalog candidate.
416#[derive(Debug, Clone, Copy, PartialEq)]
417pub struct CatalogCollision {
418    pub probability: CollisionPc,
419    pub method: PcMethod,
420}
421
422/// One catalog screening result row.
423#[derive(Debug, Clone, PartialEq)]
424pub struct CatalogScreeningResult {
425    pub candidate: CatalogScreeningCandidate,
426    pub collision: Option<CatalogCollision>,
427    pub error: Option<ConjunctionError>,
428}
429
430/// Which object failed during TCA setup or propagation.
431#[derive(Debug, Clone, Copy, PartialEq, Eq)]
432pub enum TcaObject {
433    /// The first satellite supplied by the caller.
434    Primary,
435    /// The second satellite supplied by the caller.
436    Secondary,
437}
438
439/// Error while finding TCA candidates.
440#[derive(Debug, Clone, PartialEq, thiserror::Error)]
441pub enum TcaError {
442    /// Invalid TCA input.
443    #[error("invalid TCA input {field}: {reason}")]
444    InvalidInput {
445        /// Field or input source that failed validation.
446        field: &'static str,
447        /// Stable reason string.
448        reason: &'static str,
449    },
450    /// TLE parsing or SGP4 initialization failed.
451    #[error("{object:?} satellite initialization failed: {source}")]
452    Init {
453        /// Object that failed initialization.
454        object: TcaObject,
455        /// SGP4 initialization error.
456        source: Sgp4Error,
457    },
458    /// SGP4 propagation failed during the search.
459    #[error("{object:?} satellite propagation failed: {source}")]
460    Propagate {
461        /// Object that failed propagation.
462        object: TcaObject,
463        /// SGP4 propagation error.
464        source: Sgp4Error,
465    },
466    /// Numerical state-covariance propagation failed while transporting Pc inputs.
467    #[error("{object:?} covariance propagation failed: {reason}")]
468    CovariancePropagation {
469        /// Object whose covariance propagation failed.
470        object: TcaObject,
471        /// Stable diagnostic from the numerical propagator.
472        reason: String,
473    },
474    /// The shared event finder rejected configuration or predicate values.
475    #[error(transparent)]
476    EventFinder(#[from] EventFinderError),
477    /// The existing conjunction Pc module rejected the TCA relative state.
478    #[error(transparent)]
479    Conjunction(#[from] ConjunctionError),
480}
481
482/// Find local TCA candidates between two TLE strings over an absolute JD window.
483pub 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
506/// Find local TCA candidates between two borrowed TLEs.
507pub 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
524/// Find local TCA candidates between two initialized SGP4 satellites.
525pub 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
564/// Find TCA candidates between two TLE strings and compute Pc at each TCA.
565pub 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
587/// Find TCA candidates between two borrowed TLEs and compute Pc at each TCA.
588pub 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
605/// Find TCA candidates between two initialized SGP4 satellites and compute Pc.
606pub 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
620/// Find TCA candidates between two borrowed TLEs and compute Pc after propagating
621/// each object's initial state covariance to the candidate TCA.
622pub 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
642/// Find TCA candidates between two borrowed TLEs and compute Pc after
643/// propagating each object's initial state covariance to TCA.
644pub 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
661/// Find TCA candidates between two initialized SGP4 satellites and compute Pc
662/// after propagating each object's initial state covariance to each TCA.
663pub 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
681/// Serially screen a primary against a secondary catalog for threshold TCAs.
682///
683/// Returns one hit per local TCA whose miss distance is at or below
684/// `miss_distance_threshold_km`, preserving the caller's secondary indices.
685pub 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
704/// Parallel-screen a primary against a secondary catalog for threshold TCAs.
705///
706/// Results are in the same deterministic order as
707/// [`screen_tca_candidates_serial`]: secondary catalog order, then TCA time
708/// order within each secondary.
709pub 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
728/// Serially screen a primary TLE against a borrowed secondary TLE catalog.
729pub 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
748/// Parallel-screen a primary TLE against a borrowed secondary TLE catalog.
749pub 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
768/// Serially screen a catalog and compute Pc for each threshold TCA.
769pub 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
789/// Serially screen a borrowed TLE catalog and compute Pc for each threshold TCA.
790pub 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
808/// Parallel-screen a catalog and compute Pc for each threshold TCA.
809pub 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
829/// Parallel-screen a borrowed TLE catalog and compute Pc for each threshold TCA.
830pub 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
848/// Serially screen a borrowed TLE catalog and compute Pc for each threshold TCA
849/// after propagating each object's initial covariance to the TCA.
850pub 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
869/// Parallel-screen a borrowed TLE catalog and compute Pc for each threshold TCA
870/// after propagating each object's initial covariance to the TCA.
871pub 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
920/// Compute Pc for an already-refined TEME TCA candidate.
921///
922/// The candidate relative state is converted to GCRS before invoking the
923/// conjunction Pc solver. Supplied position covariances must be in that same
924/// GCRS frame.
925pub 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
954/// Compute Pc for an already-refined TCA candidate after propagating each
955/// object's initial state covariance to the TCA epoch.
956pub 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    // Range-gate the calendar day (the canonical split-to-civil helper assumes a
1408    // valid domain); the discarded JDN equals the one the helper recomputes.
1409    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
1653/// Single-epoch catalog coarse screen.
1654///
1655/// Given object positions at one common epoch, return every unordered pair
1656/// `(i, j)` with `i < j` whose Euclidean separation is at or below
1657/// `miss_threshold_km`, paired with that separation `miss_km`. Results are
1658/// ordered by `i` ascending, then `j` ascending. This is the cheap geometric
1659/// pre-filter a catalog screener runs before evaluating collision probability
1660/// on the survivors; the distance uses [`vec3`] so callers (e.g. language
1661/// bindings) never reimplement it.
1662pub 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
1678/// Screen a materialized state-vector catalog and evaluate Pc on survivors.
1679///
1680/// Results with a successful Pc below [`CatalogScreeningOptions::pc_threshold`]
1681/// are dropped. Candidate rows whose Pc evaluation fails are retained with the
1682/// error populated and sort as zero probability.
1683pub 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        // A-B are 0.1 km apart; C is ~1000 km away. Only A-B survives at 1 km.
1761        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        // With a huge threshold every i<j pair is returned, i asc then j asc.
1770        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        // Degenerate inputs.
1777        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}