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