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