Skip to main content

sidereon_core/astro/
conjunction.rs

1//! Conjunction geometry and collision-probability (`Pc`) computation.
2//!
3//! Builds the orthonormal encounter frame from two objects' relative state,
4//! projects the combined position covariance into the 2D encounter plane, and
5//! integrates the collision probability over the hard-body disk with several
6//! independent methods (Foster 2D equal-area, Foster 2D numerical, and Alfano
7//! 2005). This is the authoritative implementation; the Elixir binding is a
8//! thin marshaling and input-validation layer over it.
9//!
10//! All positions are in km, velocities in km/s, and covariances in km^2.
11
12use crate::astro::covariance::{positive_semidefinite, symmetric};
13use crate::astro::math::special::erf;
14use crate::astro::math::vec3;
15use crate::validate;
16use std::f64::consts::PI;
17
18/// Relative velocities below this (km/s) leave the encounter frame undefined.
19const ZERO_REL_SPEED_EPS_KM_S: f64 = 1.0e-12;
20/// Orthogonal miss distances below this (km) are treated as a collision course,
21/// where the cross-track axis is degenerate and chosen arbitrarily.
22const COLLISION_COURSE_EPS_KM: f64 = 1.0e-12;
23/// On a collision course, pick the world axis least aligned with `y_hat` to
24/// seed an orthogonal vector; switch axes once a component dominates.
25const COLLISION_TRIAL_AXIS_THRESHOLD: f64 = 0.9;
26/// Standard deviations at or below this (km) collapse the Gaussian to a point,
27/// for which the disk-integral collision probability is zero.
28const SIGMA_FLOOR_KM: f64 = 1.0e-12;
29/// Magnitudes below this are treated as zero when choosing/normalising the
30/// encounter-plane eigenvector.
31const EIGENVECTOR_EPS: f64 = 1.0e-30;
32
33/// Number of Simpson intervals for the Alfano (2005) 1D quadrature. 200 gives
34/// sub-nanometer precision on the validated CARA test cases.
35const ALFANO_SIMPSON_INTERVALS: usize = 200;
36/// Radial samples for the Foster 2D polar-grid numerical integration.
37const FOSTER_NUMERICAL_RADIAL_STEPS: usize = 20;
38/// Angular samples for the Foster 2D polar-grid numerical integration.
39const FOSTER_NUMERICAL_ANGULAR_STEPS: usize = 40;
40
41/// Orthonormal encounter frame and the relative state it was built from.
42///
43/// Axes: `x_hat` is the in-plane cross-track axis, `y_hat` is along the
44/// relative velocity, and `z_hat` is the encounter-plane normal.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct EncounterFrame {
47    pub x_hat: [f64; 3],
48    pub y_hat: [f64; 3],
49    pub z_hat: [f64; 3],
50    pub relative_position_km: [f64; 3],
51    pub relative_velocity_km_s: [f64; 3],
52    pub miss_km: f64,
53    pub relative_speed_km_s: f64,
54}
55
56/// Collision-probability integration method.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum PcMethod {
59    /// Foster 2D with the equal-area square approximation.
60    FosterEqualArea,
61    /// Foster 2D with polar-grid numerical integration.
62    FosterNumerical,
63    /// Alfano (2005) 1D Simpson's rule with analytical cross-axis integration.
64    Alfano2005,
65}
66
67/// One object's state for a conjunction: ECI position (km), velocity (km/s),
68/// and 3x3 position covariance (km^2).
69#[derive(Debug, Clone, Copy, PartialEq)]
70pub struct ConjunctionState {
71    pub position_km: [f64; 3],
72    pub velocity_km_s: [f64; 3],
73    pub covariance_km2: [[f64; 3]; 3],
74}
75
76/// Collision-probability result and the encounter-plane summary it came from.
77#[derive(Debug, Clone, Copy, PartialEq)]
78pub struct CollisionPc {
79    pub pc: f64,
80    pub miss_km: f64,
81    pub relative_speed_km_s: f64,
82    pub sigma_x_km: f64,
83    pub sigma_z_km: f64,
84}
85
86/// Why a conjunction geometry or collision-probability solve could not run.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum ConjunctionError {
89    /// A numeric input or derived probability was non-finite.
90    NonFinite { field: &'static str },
91    /// A strictly positive input was zero or negative.
92    NotPositive { field: &'static str },
93    /// The encounter frame is undefined because relative velocity is zero.
94    UndefinedFrame,
95}
96
97impl core::fmt::Display for ConjunctionError {
98    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
99        match self {
100            Self::NonFinite { field } => write!(f, "{field} must be finite"),
101            Self::NotPositive { field } => write!(f, "{field} must be positive"),
102            Self::UndefinedFrame => write!(f, "encounter frame is undefined"),
103        }
104    }
105}
106
107impl std::error::Error for ConjunctionError {}
108
109/// Build the orthonormal encounter frame from two objects' states.
110///
111/// Returns an error when any state component is non-finite or the relative
112/// velocity is below
113/// [`ZERO_REL_SPEED_EPS_KM_S`], which leaves the frame undefined.
114pub fn encounter_frame(
115    r1: [f64; 3],
116    v1: [f64; 3],
117    r2: [f64; 3],
118    v2: [f64; 3],
119) -> Result<EncounterFrame, ConjunctionError> {
120    validate::finite_vec3(r1, "object1.position_km").map_err(map_conjunction_field_error)?;
121    validate::finite_vec3(v1, "object1.velocity_km_s").map_err(map_conjunction_field_error)?;
122    validate::finite_vec3(r2, "object2.position_km").map_err(map_conjunction_field_error)?;
123    validate::finite_vec3(v2, "object2.velocity_km_s").map_err(map_conjunction_field_error)?;
124    let frame =
125        encounter_frame_unchecked(r1, v1, r2, v2).ok_or(ConjunctionError::UndefinedFrame)?;
126    validate_encounter_frame(&frame)?;
127    Ok(frame)
128}
129
130fn encounter_frame_unchecked(
131    r1: [f64; 3],
132    v1: [f64; 3],
133    r2: [f64; 3],
134    v2: [f64; 3],
135) -> Option<EncounterFrame> {
136    let dr = vec3::sub3(r2, r1);
137    let dv = vec3::sub3(v2, v1);
138
139    let rel_speed = vec3::norm3(dv);
140    if rel_speed < ZERO_REL_SPEED_EPS_KM_S {
141        return None;
142    }
143
144    // Unit vector along the relative velocity. The division form (not the
145    // reciprocal-multiply of `vec3::unit3`) is required to stay bit-exact with
146    // the reference. `rel_speed` is exactly `norm3(dv)`.
147    let y_hat = [dv[0] / rel_speed, dv[1] / rel_speed, dv[2] / rel_speed];
148
149    // Component of the relative position orthogonal to the relative velocity.
150    let dr_dot_y = vec3::dot3(dr, y_hat);
151    let dr_ortho = vec3::sub3(dr, vec3::scale3(y_hat, dr_dot_y));
152    let miss_ortho = vec3::norm3(dr_ortho);
153
154    let x_hat = if miss_ortho < COLLISION_COURSE_EPS_KM {
155        // Collision course: the cross-track direction is degenerate, so build
156        // any unit vector orthogonal to `y_hat`.
157        let trial = if y_hat[0].abs() < COLLISION_TRIAL_AXIS_THRESHOLD {
158            [1.0, 0.0, 0.0]
159        } else {
160            [0.0, 1.0, 0.0]
161        };
162        let cr = vec3::cross3(y_hat, trial);
163        let n = vec3::norm3(cr);
164        [cr[0] / n, cr[1] / n, cr[2] / n]
165    } else {
166        // `miss_ortho` is exactly `norm3(dr_ortho)`.
167        [
168            dr_ortho[0] / miss_ortho,
169            dr_ortho[1] / miss_ortho,
170            dr_ortho[2] / miss_ortho,
171        ]
172    };
173
174    let z_hat = vec3::cross3(y_hat, x_hat);
175
176    Some(EncounterFrame {
177        x_hat,
178        y_hat,
179        z_hat,
180        relative_position_km: dr,
181        relative_velocity_km_s: dv,
182        miss_km: miss_ortho,
183        relative_speed_km_s: rel_speed,
184    })
185}
186
187/// Project a 3x3 ECI position covariance into the 2D encounter plane `(x, z)`.
188///
189/// Computes `R * C * R^T` where the rows of `R` are `x_hat` and `z_hat`. The
190/// accumulation order matches the reference so the result is bit-exact.
191pub fn encounter_plane_covariance(
192    frame: &EncounterFrame,
193    cov: &[[f64; 3]; 3],
194) -> Result<[[f64; 2]; 2], ConjunctionError> {
195    validate_encounter_frame(frame)?;
196    validate_covariance(cov, "covariance_km2")?;
197    let projected = encounter_plane_covariance_unchecked(frame, cov);
198    validate_plane_covariance(&projected, "encounter_plane_covariance")?;
199    Ok(projected)
200}
201
202fn encounter_plane_covariance_unchecked(
203    frame: &EncounterFrame,
204    cov: &[[f64; 3]; 3],
205) -> [[f64; 2]; 2] {
206    let r = [frame.x_hat, frame.z_hat];
207
208    // M = R * C  (2x3).
209    let mut m = [[0.0_f64; 3]; 2];
210    for (i, m_row) in m.iter_mut().enumerate() {
211        for (j, m_ij) in m_row.iter_mut().enumerate() {
212            let mut s = 0.0_f64;
213            s += r[i][0] * cov[0][j];
214            s += r[i][1] * cov[1][j];
215            s += r[i][2] * cov[2][j];
216            *m_ij = s;
217        }
218    }
219
220    // C_enc = M * R^T  (2x2); column `jj` of R^T is row `jj` of R.
221    let mut out = [[0.0_f64; 2]; 2];
222    for (i, out_row) in out.iter_mut().enumerate() {
223        for (jj, out_ij) in out_row.iter_mut().enumerate() {
224            let mut s = 0.0_f64;
225            s += m[i][0] * r[jj][0];
226            s += m[i][1] * r[jj][1];
227            s += m[i][2] * r[jj][2];
228            *out_ij = s;
229        }
230    }
231
232    out
233}
234
235/// Compute the collision probability for two objects' states and covariances.
236///
237/// Returns `None` when [`encounter_frame`] is undefined (zero relative
238/// velocity). The covariances are combined by element-wise addition.
239pub fn collision_probability(
240    object1: &ConjunctionState,
241    object2: &ConjunctionState,
242    hard_body_radius_km: f64,
243    method: PcMethod,
244) -> Result<CollisionPc, ConjunctionError> {
245    validate_conjunction_state(
246        object1,
247        "object1.position_km",
248        "object1.velocity_km_s",
249        "object1.covariance_km2",
250    )?;
251    validate_conjunction_state(
252        object2,
253        "object2.position_km",
254        "object2.velocity_km_s",
255        "object2.covariance_km2",
256    )?;
257    validate::finite_positive(hard_body_radius_km, "hard_body_radius_km")
258        .map_err(map_conjunction_field_error)?;
259
260    let frame = encounter_frame_unchecked(
261        object1.position_km,
262        object1.velocity_km_s,
263        object2.position_km,
264        object2.velocity_km_s,
265    )
266    .ok_or(ConjunctionError::UndefinedFrame)?;
267    validate_encounter_frame(&frame)?;
268    let combined = add_cov(&object1.covariance_km2, &object2.covariance_km2);
269    validate_covariance(&combined, "combined_covariance_km2")?;
270    let c_enc = encounter_plane_covariance_unchecked(&frame, &combined);
271    validate_plane_covariance(&c_enc, "encounter_plane_covariance")?;
272    let (sigma_x, sigma_z, xm, zm) = principal_components(&frame, &c_enc);
273    validate_principal_components(sigma_x, sigma_z, xm, zm)?;
274
275    let pc = match method {
276        PcMethod::FosterEqualArea => {
277            foster_equal_area(sigma_x, sigma_z, xm, zm, hard_body_radius_km)
278        }
279        PcMethod::FosterNumerical => {
280            foster_numerical(sigma_x, sigma_z, xm, zm, hard_body_radius_km)
281        }
282        PcMethod::Alfano2005 => alfano_2005(sigma_x, sigma_z, xm, zm, hard_body_radius_km),
283    };
284    let pc = validate::finite(pc, "collision probability").map_err(map_conjunction_field_error)?;
285
286    Ok(CollisionPc {
287        pc: if pc < 0.0 { 0.0 } else { pc },
288        miss_km: frame.miss_km,
289        relative_speed_km_s: frame.relative_speed_km_s,
290        sigma_x_km: sigma_x,
291        sigma_z_km: sigma_z,
292    })
293}
294
295fn validate_conjunction_state(
296    state: &ConjunctionState,
297    position_field: &'static str,
298    velocity_field: &'static str,
299    covariance_field: &'static str,
300) -> Result<(), ConjunctionError> {
301    validate::finite_vec3(state.position_km, position_field)
302        .map_err(map_conjunction_field_error)?;
303    validate::finite_vec3(state.velocity_km_s, velocity_field)
304        .map_err(map_conjunction_field_error)?;
305    validate_covariance(&state.covariance_km2, covariance_field)?;
306    Ok(())
307}
308
309fn validate_encounter_frame(frame: &EncounterFrame) -> Result<(), ConjunctionError> {
310    validate::finite_vec3(frame.x_hat, "frame.x_hat").map_err(map_conjunction_field_error)?;
311    validate::finite_vec3(frame.y_hat, "frame.y_hat").map_err(map_conjunction_field_error)?;
312    validate::finite_vec3(frame.z_hat, "frame.z_hat").map_err(map_conjunction_field_error)?;
313    validate::finite_vec3(frame.relative_position_km, "frame.relative_position_km")
314        .map_err(map_conjunction_field_error)?;
315    validate::finite_vec3(frame.relative_velocity_km_s, "frame.relative_velocity_km_s")
316        .map_err(map_conjunction_field_error)?;
317    validate::finite(frame.miss_km, "frame.miss_km").map_err(map_conjunction_field_error)?;
318    validate::finite(frame.relative_speed_km_s, "frame.relative_speed_km_s")
319        .map_err(map_conjunction_field_error)?;
320    Ok(())
321}
322
323fn validate_plane_covariance(
324    cov: &[[f64; 2]; 2],
325    field: &'static str,
326) -> Result<(), ConjunctionError> {
327    for row in cov {
328        validate::finite_slice(row, field).map_err(map_conjunction_field_error)?;
329    }
330    Ok(())
331}
332
333fn validate_principal_components(
334    sigma_x: f64,
335    sigma_z: f64,
336    xm: f64,
337    zm: f64,
338) -> Result<(), ConjunctionError> {
339    validate::finite(sigma_x, "sigma_x_km").map_err(map_conjunction_field_error)?;
340    validate::finite(sigma_z, "sigma_z_km").map_err(map_conjunction_field_error)?;
341    validate::finite(xm, "miss_x_km").map_err(map_conjunction_field_error)?;
342    validate::finite(zm, "miss_z_km").map_err(map_conjunction_field_error)?;
343    Ok(())
344}
345
346fn validate_covariance(cov: &[[f64; 3]; 3], field: &'static str) -> Result<(), ConjunctionError> {
347    for row in cov {
348        validate::finite_slice(row, field).map_err(map_conjunction_field_error)?;
349    }
350    if !symmetric(cov) || !positive_semidefinite(cov) {
351        return Err(ConjunctionError::NotPositive { field });
352    }
353    Ok(())
354}
355
356fn map_conjunction_field_error(error: validate::FieldError) -> ConjunctionError {
357    match error {
358        validate::FieldError::NonFinite { field } => ConjunctionError::NonFinite { field },
359        validate::FieldError::NotPositive { field }
360        | validate::FieldError::Negative { field }
361        | validate::FieldError::OutOfRange { field, .. } => ConjunctionError::NotPositive { field },
362        validate::FieldError::Missing { field }
363        | validate::FieldError::FloatParse { field, .. }
364        | validate::FieldError::IntParse { field, .. }
365        | validate::FieldError::InvalidCivilDate { field, .. }
366        | validate::FieldError::InvalidCivilTime { field, .. } => {
367            ConjunctionError::NonFinite { field }
368        }
369    }
370}
371
372fn add_cov(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
373    let mut out = [[0.0_f64; 3]; 3];
374    for i in 0..3 {
375        for j in 0..3 {
376            out[i][j] = a[i][j] + b[i][j];
377        }
378    }
379    out
380}
381
382/// Standard deviations, transformed miss components, of the principal-axes
383/// encounter-plane Gaussian. Returns `(sigma_x, sigma_z, xm, zm)`.
384fn principal_components(frame: &EncounterFrame, c_enc: &[[f64; 2]; 2]) -> (f64, f64, f64, f64) {
385    let a = c_enc[0][0];
386    let b = c_enc[0][1];
387    let c = c_enc[1][0];
388    let d = c_enc[1][1];
389
390    let trace = a + d;
391    let det = a * d - b * c;
392    let disc = (trace * trace / 4.0 - det).max(0.0).sqrt();
393
394    let l1 = trace / 2.0 + disc;
395    let l2 = trace / 2.0 - disc;
396
397    let sigma_x = l1.max(0.0).sqrt();
398    let sigma_z = l2.max(0.0).sqrt();
399
400    let (vx, vy) = if b.abs() > EIGENVECTOR_EPS {
401        normalize_2d(l1 - d, c)
402    } else if c.abs() > EIGENVECTOR_EPS {
403        normalize_2d(c, l1 - a)
404    } else {
405        (1.0, 0.0)
406    };
407
408    let theta = vy.atan2(vx);
409    let xm = frame.miss_km * theta.cos();
410    let zm = -frame.miss_km * theta.sin();
411
412    (sigma_x, sigma_z, xm, zm)
413}
414
415fn normalize_2d(x: f64, y: f64) -> (f64, f64) {
416    let m = (x * x + y * y).sqrt();
417    if m > EIGENVECTOR_EPS {
418        (x / m, y / m)
419    } else {
420        (1.0, 0.0)
421    }
422}
423
424/// Foster 2D collision probability via the equal-area square approximation.
425fn foster_equal_area(sigma_x: f64, sigma_z: f64, xm: f64, zm: f64, hbr: f64) -> f64 {
426    if sigma_x > SIGMA_FLOOR_KM && sigma_z > SIGMA_FLOOR_KM {
427        let hsq = (PI / 4.0).sqrt() * hbr;
428        let sqrt2 = 2.0_f64.sqrt();
429        0.25 * (erf((xm + hsq) / (sqrt2 * sigma_x)) - erf((xm - hsq) / (sqrt2 * sigma_x)))
430            * (erf((zm + hsq) / (sqrt2 * sigma_z)) - erf((zm - hsq) / (sqrt2 * sigma_z)))
431    } else {
432        0.0
433    }
434}
435
436/// Alfano (2005) collision probability: 1D Simpson's composite rule along the
437/// wide axis with the cross-axis integrated analytically as a difference of
438/// error functions.
439fn alfano_2005(sigma_x: f64, sigma_z: f64, xm: f64, zm: f64, hbr: f64) -> f64 {
440    if sigma_x > SIGMA_FLOOR_KM && sigma_z > SIGMA_FLOOR_KM {
441        let n = ALFANO_SIMPSON_INTERVALS;
442        let h = 2.0 * hbr / n as f64;
443        let sqrt2 = 2.0_f64.sqrt();
444        let sqrt_2pi = (2.0 * PI).sqrt();
445
446        let integrand = |i: usize| -> f64 {
447            let x = -hbr + i as f64 * h;
448            let y_top_sq = hbr * hbr - x * x;
449            if y_top_sq <= 0.0 {
450                0.0
451            } else {
452                let y_top = y_top_sq.sqrt();
453                let arg = (x - xm) * (x - xm) / (2.0 * sigma_x * sigma_x);
454                let exp_term = (-arg).exp();
455                let erf_top = erf((y_top - zm) / (sigma_z * sqrt2));
456                let erf_bot = erf((-y_top - zm) / (sigma_z * sqrt2));
457                exp_term * (erf_top - erf_bot)
458            }
459        };
460
461        let mut simpson_sum = 0.0_f64;
462        for i in 0..=n {
463            let weight = if i == 0 || i == n {
464                1.0
465            } else if i % 2 == 1 {
466                4.0
467            } else {
468                2.0
469            };
470            simpson_sum += weight * integrand(i);
471        }
472
473        let prefactor = 1.0 / (2.0 * sigma_x * sqrt_2pi);
474        prefactor * (h / 3.0) * simpson_sum
475    } else {
476        0.0
477    }
478}
479
480/// Foster 2D collision probability via polar-grid numerical integration.
481fn foster_numerical(sigma_x: f64, sigma_z: f64, xm: f64, zm: f64, hbr: f64) -> f64 {
482    if sigma_x > SIGMA_FLOOR_KM && sigma_z > SIGMA_FLOOR_KM {
483        let steps_r = FOSTER_NUMERICAL_RADIAL_STEPS;
484        let steps_theta = FOSTER_NUMERICAL_ANGULAR_STEPS;
485        let dr = hbr / steps_r as f64;
486        let dtheta = 2.0 * PI / steps_theta as f64;
487
488        let mut acc = 0.0_f64;
489        for i in 0..steps_r {
490            for j in 0..steps_theta {
491                let r = (i as f64 + 0.5) * dr;
492                let theta = (j as f64 + 0.5) * dtheta;
493                let x = r * theta.cos();
494                let z = r * theta.sin();
495
496                // `powf(2.0)` (not `x * x`) matches the reference `:math.pow(_, 2)`
497                // for bit-exact parity.
498                let arg = (x - xm).powf(2.0) / (2.0 * sigma_x * sigma_x)
499                    + (z - zm).powf(2.0) / (2.0 * sigma_z * sigma_z);
500                let f = (-arg).exp() / (2.0 * PI * sigma_x * sigma_z);
501                acc += f * r * dr * dtheta;
502            }
503        }
504        acc
505    } else {
506        0.0
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    // NASA CARA Omitron test case: states in ECI km / km/s, covariances in km^2.
515    const OMITRON_OBJ1: ConjunctionState = ConjunctionState {
516        position_km: [378.39559, 4305.721887, 5752.767554],
517        velocity_km_s: [2.360800244, 5.580331936, -4.322349039],
518        covariance_km2: [
519            [44.5757544811362, 81.6751751052616, -67.8687662707124],
520            [81.6751751052616, 158.453402956163, -128.616921644857],
521            [-67.8687662707124, -128.616921644858, 105.490542562701],
522        ],
523    };
524    const OMITRON_OBJ2: ConjunctionState = ConjunctionState {
525        position_km: [374.5180598, 4307.560983, 5751.130418],
526        velocity_km_s: [-5.388125081, -3.946827739, 3.322820358],
527        covariance_km2: [
528            [2.31067077720423, 1.69905293875632, -1.4170164577661],
529            [1.69905293875632, 1.24957388457206, -1.04174164279599],
530            [-1.4170164577661, -1.04174164279599, 0.869260558223714],
531        ],
532    };
533    const OMITRON_HBR_KM: f64 = 0.020;
534
535    fn omitron_pc(method: PcMethod, hbr: f64) -> CollisionPc {
536        collision_probability(&OMITRON_OBJ1, &OMITRON_OBJ2, hbr, method)
537            .expect("omitron frame is well defined")
538    }
539
540    #[test]
541    fn collision_probability_matches_cara_published_value() {
542        // External-product published value (NASA CARA Analysis Tools, Omitron
543        // case). A tolerance is legitimate here: this is a computed quantity
544        // compared against an external product's published number, not an
545        // implementation-vs-implementation comparison.
546        let result = omitron_pc(PcMethod::FosterEqualArea, OMITRON_HBR_KM);
547        assert!((result.pc - 2.706_015_734_901_11e-5).abs() < 1.0e-9);
548    }
549
550    #[test]
551    fn collision_probability_has_frozen_bits() {
552        // Frozen-bits regression golden of the deterministic implementation's
553        // own output. The Pc values come from the pure-Rust `libm::erf`, which
554        // is identical on every platform; pinning them here guards against
555        // accidental op-order or implementation drift. The geometry fields
556        // (miss, relative speed, sigmas) do not depend on erf.
557        let ea = omitron_pc(PcMethod::FosterEqualArea, OMITRON_HBR_KM);
558        assert_eq!(ea.pc.to_bits(), 0x3efc_5fe7_e374_e761);
559        assert_eq!(ea.miss_km.to_bits(), 0x4012_5f76_bb63_5a77);
560        assert_eq!(ea.relative_speed_km_s.to_bits(), 0x402c_ee85_c45b_2fca);
561        assert_eq!(ea.sigma_x_km.to_bits(), 0x400b_2f8b_338c_261f);
562        assert_eq!(ea.sigma_z_km.to_bits(), 0x3fe9_dc26_3a96_e94c);
563
564        let num = omitron_pc(PcMethod::FosterNumerical, OMITRON_HBR_KM);
565        assert_eq!(num.pc.to_bits(), 0x3efc_5fed_5966_2e48);
566
567        let alf = omitron_pc(PcMethod::Alfano2005, OMITRON_HBR_KM);
568        assert_eq!(alf.pc.to_bits(), 0x3efc_5edd_3b73_ec59);
569
570        assert_eq!(
571            omitron_pc(PcMethod::Alfano2005, 0.010).pc.to_bits(),
572            0x3edc_5f31_c28e_87bb
573        );
574        assert_eq!(
575            omitron_pc(PcMethod::Alfano2005, 0.040).pc.to_bits(),
576            0x3f1c_5d8b_338e_291a
577        );
578    }
579
580    #[test]
581    fn methods_agree_for_well_conditioned_geometry() {
582        let ea = omitron_pc(PcMethod::FosterEqualArea, OMITRON_HBR_KM).pc;
583        let num = omitron_pc(PcMethod::FosterNumerical, OMITRON_HBR_KM).pc;
584        let alf = omitron_pc(PcMethod::Alfano2005, OMITRON_HBR_KM).pc;
585
586        assert!((num - ea).abs() < ea * 0.01);
587        assert!((alf - ea).abs() < ea * 0.01);
588        assert!((alf - num).abs() < num * 0.01);
589        assert!(alf > 0.0);
590    }
591
592    #[test]
593    fn larger_hard_body_radius_increases_pc() {
594        for method in [
595            PcMethod::FosterEqualArea,
596            PcMethod::FosterNumerical,
597            PcMethod::Alfano2005,
598        ] {
599            let small = omitron_pc(method, 0.010).pc;
600            let large = omitron_pc(method, 0.040).pc;
601            assert!(large > small);
602        }
603    }
604
605    #[test]
606    fn swapping_objects_preserves_pc() {
607        let forward = omitron_pc(PcMethod::FosterEqualArea, OMITRON_HBR_KM).pc;
608        let swapped = collision_probability(
609            &OMITRON_OBJ2,
610            &OMITRON_OBJ1,
611            OMITRON_HBR_KM,
612            PcMethod::FosterEqualArea,
613        )
614        .unwrap()
615        .pc;
616        assert!((forward - swapped).abs() < 1.0e-15);
617    }
618
619    #[test]
620    fn zero_relative_velocity_has_no_frame() {
621        let cov = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
622        assert!(encounter_frame(
623            [7000.0, 0.0, 0.0],
624            [0.0, 7.5, 0.0],
625            [7000.01, 0.0, 0.0],
626            [0.0, 7.5, 0.0],
627        )
628        .is_err_and(|err| err == ConjunctionError::UndefinedFrame));
629        let obj1 = ConjunctionState {
630            position_km: [7000.0, 0.0, 0.0],
631            velocity_km_s: [0.0, 7.5, 0.0],
632            covariance_km2: cov,
633        };
634        let obj2 = ConjunctionState {
635            position_km: [7000.01, 0.0, 0.0],
636            velocity_km_s: [0.0, 7.5, 0.0],
637            covariance_km2: cov,
638        };
639        assert_eq!(
640            collision_probability(&obj1, &obj2, 0.015, PcMethod::FosterEqualArea),
641            Err(ConjunctionError::UndefinedFrame)
642        );
643    }
644
645    #[test]
646    fn non_finite_state_inputs_are_rejected() {
647        let mut object = OMITRON_OBJ1;
648        object.position_km[0] = f64::NAN;
649        assert_eq!(
650            collision_probability(
651                &object,
652                &OMITRON_OBJ2,
653                OMITRON_HBR_KM,
654                PcMethod::FosterEqualArea,
655            ),
656            Err(ConjunctionError::NonFinite {
657                field: "object1.position_km"
658            })
659        );
660
661        assert_eq!(
662            encounter_frame(
663                [f64::INFINITY, 0.0, 0.0],
664                [0.0, 7.5, 0.0],
665                [7000.01, 0.0, 0.0],
666                [0.0, -7.5, 0.0],
667            ),
668            Err(ConjunctionError::NonFinite {
669                field: "object1.position_km"
670            })
671        );
672    }
673
674    #[test]
675    fn non_finite_encounter_frame_outputs_are_rejected() {
676        assert!(matches!(
677            encounter_frame(
678                [0.0, 0.0, 0.0],
679                [0.0, 0.0, 0.0],
680                [f64::MAX, 0.0, 0.0],
681                [0.0, f64::MAX, 0.0],
682            ),
683            Err(ConjunctionError::NonFinite {
684                field: "frame.x_hat"
685                    | "frame.y_hat"
686                    | "frame.z_hat"
687                    | "frame.relative_position_km"
688                    | "frame.relative_velocity_km_s"
689                    | "frame.miss_km"
690                    | "frame.relative_speed_km_s"
691            })
692        ));
693    }
694
695    #[test]
696    fn invalid_covariance_shape_is_rejected_before_pc() {
697        for covariance_km2 in [
698            [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
699            [[1.0, 2.0, 0.0], [2.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
700            [[1.0, 0.25, 0.0], [0.20, 1.0, 0.0], [0.0, 0.0, 1.0]],
701        ] {
702            let object = ConjunctionState {
703                covariance_km2,
704                ..OMITRON_OBJ1
705            };
706
707            assert_eq!(
708                collision_probability(
709                    &object,
710                    &OMITRON_OBJ2,
711                    OMITRON_HBR_KM,
712                    PcMethod::FosterEqualArea,
713                ),
714                Err(ConjunctionError::NotPositive {
715                    field: "object1.covariance_km2"
716                })
717            );
718        }
719    }
720
721    #[test]
722    fn covariance_and_hbr_inputs_are_rejected() {
723        let mut object = OMITRON_OBJ2;
724        object.covariance_km2[1][2] = f64::INFINITY;
725        assert_eq!(
726            collision_probability(
727                &OMITRON_OBJ1,
728                &object,
729                OMITRON_HBR_KM,
730                PcMethod::FosterEqualArea,
731            ),
732            Err(ConjunctionError::NonFinite {
733                field: "object2.covariance_km2"
734            })
735        );
736
737        assert_eq!(
738            collision_probability(&OMITRON_OBJ1, &OMITRON_OBJ2, 0.0, PcMethod::FosterEqualArea),
739            Err(ConjunctionError::NotPositive {
740                field: "hard_body_radius_km"
741            })
742        );
743
744        let frame = encounter_frame(
745            [7000.0, 0.0, 0.0],
746            [0.0, 7.5, 0.0],
747            [7000.1, 0.0, 0.0],
748            [0.0, -7.5, 0.0],
749        )
750        .unwrap();
751        let cov = [[1.0, 0.0, 0.0], [0.0, f64::NAN, 0.0], [0.0, 0.0, 3.0]];
752        assert_eq!(
753            encounter_plane_covariance(&frame, &cov),
754            Err(ConjunctionError::NonFinite {
755                field: "covariance_km2"
756            })
757        );
758    }
759
760    #[test]
761    fn head_on_frame_matches_reference_bits() {
762        let frame = encounter_frame(
763            [7000.0, 0.0, 0.0],
764            [0.0, 7.5, 0.0],
765            [7000.1, 0.0, 0.0],
766            [0.0, -7.5, 0.0],
767        )
768        .unwrap();
769
770        assert_eq!(frame.x_hat[0].to_bits(), 1.0_f64.to_bits());
771        assert_eq!(frame.x_hat[1].to_bits(), 0.0_f64.to_bits());
772        assert_eq!(frame.x_hat[2].to_bits(), 0.0_f64.to_bits());
773        assert_eq!(frame.y_hat[0].to_bits(), 0.0_f64.to_bits());
774        assert_eq!(frame.y_hat[1].to_bits(), (-1.0_f64).to_bits());
775        assert_eq!(frame.y_hat[2].to_bits(), 0.0_f64.to_bits());
776        assert_eq!(frame.z_hat[0].to_bits(), (-0.0_f64).to_bits());
777        assert_eq!(frame.z_hat[1].to_bits(), 0.0_f64.to_bits());
778        assert_eq!(frame.z_hat[2].to_bits(), 1.0_f64.to_bits());
779        assert_eq!(frame.miss_km.to_bits(), 0x3fb9_9999_999a_0000);
780        assert_eq!(frame.relative_speed_km_s.to_bits(), 0x402e_0000_0000_0000);
781    }
782
783    #[test]
784    fn encounter_plane_covariance_projects_onto_xz() {
785        let frame = encounter_frame(
786            [7000.0, 0.0, 0.0],
787            [0.0, 7.5, 0.0],
788            [7000.1, 0.0, 0.0],
789            [0.0, -7.5, 0.0],
790        )
791        .unwrap();
792        let cov = [[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 3.0]];
793        let c_enc = encounter_plane_covariance(&frame, &cov).unwrap();
794        assert_eq!(c_enc, [[1.0, 0.0], [0.0, 3.0]]);
795    }
796}