use crate::kinematics::closest_approach;
use crate::types::StateVector;
use geo3d::Vec3;
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Covariance3 {
pub m: [[f64; 3]; 3],
}
impl Covariance3 {
#[inline]
pub const fn from_matrix(m: [[f64; 3]; 3]) -> Self {
Covariance3 { m }
}
#[inline]
pub fn isotropic(sigma_m: f64) -> Self {
let v = sigma_m * sigma_m;
Covariance3::from_matrix([[v, 0.0, 0.0], [0.0, v, 0.0], [0.0, 0.0, v]])
}
#[inline]
pub fn diagonal(sigma_x_m: f64, sigma_y_m: f64, sigma_z_m: f64) -> Self {
Covariance3::from_matrix([
[sigma_x_m * sigma_x_m, 0.0, 0.0],
[0.0, sigma_y_m * sigma_y_m, 0.0],
[0.0, 0.0, sigma_z_m * sigma_z_m],
])
}
fn quad(&self, x: Vec3, y: Vec3) -> f64 {
let cy = Vec3::new(
self.m[0][0] * y.x + self.m[0][1] * y.y + self.m[0][2] * y.z,
self.m[1][0] * y.x + self.m[1][1] * y.y + self.m[1][2] * y.z,
self.m[2][0] * y.x + self.m[2][1] * y.y + self.m[2][2] * y.z,
);
x.dot(cy)
}
}
impl core::ops::Add for Covariance3 {
type Output = Covariance3;
fn add(self, o: Covariance3) -> Covariance3 {
let mut m = self.m;
for (row, orow) in m.iter_mut().zip(o.m.iter()) {
for (a, b) in row.iter_mut().zip(orow.iter()) {
*a += *b;
}
}
Covariance3 { m }
}
}
pub fn collision_probability(
a: StateVector,
cov_a: Covariance3,
b: StateVector,
cov_b: Covariance3,
hard_body_radius_m: f64,
) -> f64 {
let tca = closest_approach(a, b).tca;
let miss = a.at(tca).position.vec() - b.at(tca).position.vec();
let v_rel = a.velocity - b.velocity;
let zhat = match v_rel.normalized() {
Some(z) => z,
None => return if miss.norm() < hard_body_radius_m { 1.0 } else { 0.0 },
};
let xhat = (miss - zhat * miss.dot(zhat))
.normalized()
.unwrap_or_else(|| any_perpendicular(zhat));
let yhat = zhat.cross(xhat);
let cov = cov_a + cov_b;
let (a2, b2, d2) = (cov.quad(xhat, xhat), cov.quad(xhat, yhat), cov.quad(yhat, yhat));
let (mx, my) = (miss.dot(xhat), miss.dot(yhat));
let mean = (a2 + d2) / 2.0;
let radius = (((a2 - d2) / 2.0).powi(2) + b2 * b2).sqrt();
let (lam1, lam2) = (mean + radius, mean - radius);
if lam1 <= 0.0 || lam2 <= 0.0 {
return if miss.norm() < hard_body_radius_m { 1.0 } else { 0.0 };
}
let (s, c) = (0.5 * (2.0 * b2).atan2(a2 - d2)).sin_cos();
let (xm, ym) = (mx * c + my * s, -mx * s + my * c);
let u = hard_body_radius_m * hard_body_radius_m / (lam1 * lam2).sqrt();
let v = xm * xm / lam1 + ym * ym / lam2;
chan_series(u, v)
}
fn chan_series(u: f64, v: f64) -> f64 {
let eu = (-u / 2.0).exp();
let mut outer = 1.0; let mut inner = 1.0; let mut inner_term = 1.0; let mut sum = 0.0;
for m in 0..1000usize {
let contrib = outer * (1.0 - eu * inner);
sum += contrib;
if m > 2 && contrib < 1e-15 * sum.max(1e-300) {
break;
}
let mp1 = 2.0 * (m as f64 + 1.0);
outer *= v / mp1;
inner_term *= u / mp1;
inner += inner_term;
}
((-v / 2.0).exp() * sum).clamp(0.0, 1.0)
}
fn any_perpendicular(z: Vec3) -> Vec3 {
let axis = if z.x.abs() <= z.y.abs() && z.x.abs() <= z.z.abs() {
Vec3::new(1.0, 0.0, 0.0)
} else if z.y.abs() <= z.z.abs() {
Vec3::new(0.0, 1.0, 0.0)
} else {
Vec3::new(0.0, 0.0, 1.0)
};
z.cross(axis).normalized().unwrap_or(Vec3::new(1.0, 0.0, 0.0))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Ecef, Epoch};
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
#[test]
fn zero_miss_matches_the_rayleigh_closed_form() {
let t = Epoch::from_unix_seconds(1_700_000_000.0);
let a = StateVector::new(t, Ecef::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0));
let b = StateVector::new(t, Ecef::new(100.0, 0.0, 0.0), Vec3::new(-1.0, 0.0, 0.0));
for (sig, r) in [(10.0_f64, 5.0_f64), (25.0, 10.0), (50.0, 20.0)] {
let comb = 2.0 * sig * sig; let expected = 1.0 - (-(r * r) / (2.0 * comb)).exp();
let pc = collision_probability(a, Covariance3::isotropic(sig), b, Covariance3::isotropic(sig), r);
assert!(close(pc, expected, 1e-6), "σ={sig} R={r}: {pc} vs {expected}");
}
}
#[test]
fn probability_falls_with_miss_distance_and_stays_bounded() {
let t = Epoch::from_unix_seconds(1_700_000_000.0);
let a = StateVector::new(t, Ecef::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0));
let mut last = 1.0;
for miss in [0.0, 5.0, 10.0, 30.0, 100.0] {
let b = StateVector::new(t, Ecef::new(100.0, miss, 0.0), Vec3::new(-1.0, 0.0, 0.0));
let pc = collision_probability(a, Covariance3::isotropic(10.0), b, Covariance3::isotropic(10.0), 5.0);
assert!((0.0..=1.0).contains(&pc));
assert!(pc <= last + 1e-12, "Pc should not increase with miss: {pc} > {last}");
last = pc;
}
assert!(last < 1e-3); }
#[test]
fn zero_relative_velocity_is_a_deterministic_hit_test() {
let t = Epoch::from_unix_seconds(1_700_000_000.0);
let a = StateVector::new(t, Ecef::new(0.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 0.0));
let touching = StateVector::new(t, Ecef::new(3.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 0.0));
assert_eq!(
collision_probability(
a,
Covariance3::isotropic(1.0),
touching,
Covariance3::isotropic(1.0),
5.0
),
1.0
);
let apart = StateVector::new(t, Ecef::new(30.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 0.0));
assert_eq!(
collision_probability(a, Covariance3::isotropic(1.0), apart, Covariance3::isotropic(1.0), 5.0),
0.0
);
}
}