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