Skip to main content

sidereon_core/astro/
covariance.rs

1//! Position-covariance modeling for conjunction and orbit analysis.
2//!
3//! Owns the authoritative RTN->ECI frame transform of a 3x3 position
4//! covariance, typed 6x6 state covariance propagation, and the symmetric
5//! positive-semidefinite (PSD) validation used to reject ill-formed
6//! covariances. The sidereon Elixir binding is a thin marshaling and
7//! structural-validation layer over this module; no frame or PSD formula lives
8//! there.
9//!
10//! The covariance is transformed but never rescaled here, so it carries the
11//! squared units of whatever position vectors it was formed from.
12
13use crate::astro::math::mat3::{self, Mat3};
14use crate::astro::math::vec3;
15use crate::validate;
16use nalgebra::SMatrix;
17
18/// Position magnitudes below this are treated as a degenerate (zero) position
19/// vector, for which the RTN frame is undefined.
20const ZERO_POSITION_EPS: f64 = 1.0e-30;
21/// Orbit-normal magnitudes below this mean position and velocity are parallel,
22/// so the RTN frame normal (and thus the frame) is undefined.
23const PARALLEL_RV_EPS: f64 = 1.0e-30;
24/// Diagonal covariance entries are allowed to dip to this (negative) bound
25/// before the PSD check rejects them, absorbing float round-off.
26const PSD_DIAGONAL_EPS: f64 = 1.0e-15;
27/// Second- and third-order principal minors are allowed to dip to this
28/// (negative) bound before the PSD check rejects them.
29const PSD_MINOR_EPS: f64 = 1.0e-12;
30/// Off-diagonal pairs differing by more than this are treated as asymmetric.
31const SYMMETRY_EPS: f64 = 1.0e-12;
32/// Relative off-diagonal tolerance for 6x6 covariance symmetry checks.
33const SYMMETRY_REL_EPS6: f64 = 1.0e-12;
34/// Eigenvalues below this relative bound are treated as negative for 6x6 PSD.
35const PSD6_EIGEN_REL_EPS: f64 = 1.0e-10;
36
37/// Row-major 6x6 covariance for state vector `[r_x, r_y, r_z, v_x, v_y, v_z]`.
38pub type Mat6 = [[f64; 6]; 6];
39
40/// Typed 6x6 state covariance.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct Covariance6 {
43    matrix: Mat6,
44}
45
46/// Reason a 6x6 state covariance was rejected.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Covariance6Error {
49    /// At least one matrix entry was NaN or infinite.
50    NonFinite,
51    /// The matrix was not symmetric within the covariance tolerance.
52    Asymmetric,
53    /// The symmetric matrix was not positive semidefinite.
54    NotPositiveSemidefinite,
55}
56
57impl Covariance6 {
58    /// Validate and wrap a row-major 6x6 state covariance.
59    pub fn try_from_matrix(matrix: Mat6) -> Result<Self, Covariance6Error> {
60        if !finite6(&matrix) {
61            return Err(Covariance6Error::NonFinite);
62        }
63        if !symmetric6(&matrix) {
64            return Err(Covariance6Error::Asymmetric);
65        }
66        if !positive_semidefinite6(&matrix) {
67            return Err(Covariance6Error::NotPositiveSemidefinite);
68        }
69        Ok(Self { matrix })
70    }
71
72    /// Build a diagonal state covariance from six variances.
73    pub fn from_diagonal(diagonal: [f64; 6]) -> Result<Self, Covariance6Error> {
74        let mut matrix = [[0.0_f64; 6]; 6];
75        for (idx, value) in diagonal.into_iter().enumerate() {
76            matrix[idx][idx] = value;
77        }
78        Self::try_from_matrix(matrix)
79    }
80
81    /// Wrap a matrix without validation.
82    ///
83    /// Intended for trusted fixtures; prefer [`Self::try_from_matrix`] for
84    /// caller data.
85    pub const fn from_matrix_unchecked(matrix: Mat6) -> Self {
86        Self { matrix }
87    }
88
89    /// Borrow the row-major 6x6 matrix.
90    pub const fn as_matrix(&self) -> &Mat6 {
91        &self.matrix
92    }
93
94    /// Consume this covariance and return its row-major 6x6 matrix.
95    pub const fn into_matrix(self) -> Mat6 {
96        self.matrix
97    }
98
99    /// Extract the 3x3 position covariance block.
100    pub fn position_covariance_km2(&self) -> Mat3 {
101        [
102            [self.matrix[0][0], self.matrix[0][1], self.matrix[0][2]],
103            [self.matrix[1][0], self.matrix[1][1], self.matrix[1][2]],
104            [self.matrix[2][0], self.matrix[2][1], self.matrix[2][2]],
105        ]
106    }
107
108    /// Whether this covariance is symmetric within the covariance tolerance.
109    pub fn is_symmetric(&self) -> bool {
110        symmetric6(&self.matrix)
111    }
112
113    /// Whether this covariance is positive semidefinite within tolerance.
114    pub fn is_positive_semidefinite(&self) -> bool {
115        positive_semidefinite6(&self.matrix)
116    }
117
118    /// Propagate this covariance through a state-transition matrix:
119    /// `P_f = Phi * P_0 * Phi^T`.
120    #[allow(clippy::needless_range_loop)]
121    pub fn propagate_with_stm(&self, stm: &Mat6) -> Result<Self, Covariance6Error> {
122        if !finite6(stm) {
123            return Err(Covariance6Error::NonFinite);
124        }
125
126        let mut temp = [[0.0_f64; 6]; 6];
127        for i in 0..6 {
128            for j in 0..6 {
129                for k in 0..6 {
130                    temp[i][j] += stm[i][k] * self.matrix[k][j];
131                }
132            }
133        }
134
135        let mut propagated = [[0.0_f64; 6]; 6];
136        for i in 0..6 {
137            for j in 0..6 {
138                for k in 0..6 {
139                    propagated[i][j] += temp[i][k] * stm[j][k];
140                }
141            }
142        }
143        symmetrize6(&mut propagated);
144
145        Self::try_from_matrix(propagated)
146    }
147}
148
149/// Reason an RTN->ECI transform could not be built from an orbit state.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum RtnFrameError {
152    /// A numeric input was non-finite.
153    InvalidInput {
154        field: &'static str,
155        reason: &'static str,
156    },
157    /// The position vector is effectively zero.
158    ZeroPosition,
159    /// Position and velocity are parallel, leaving the orbit normal undefined.
160    ParallelPositionVelocity,
161}
162
163impl RtnFrameError {
164    /// Message string matching the historical sidereon error verbatim, so the
165    /// thin Elixir binding preserves its public `{:error, reason}` shapes.
166    pub fn message(self) -> &'static str {
167        match self {
168            RtnFrameError::InvalidInput { .. } => "invalid input",
169            RtnFrameError::ZeroPosition => "zero position vector",
170            RtnFrameError::ParallelPositionVelocity => "position and velocity are parallel",
171        }
172    }
173}
174
175fn invalid_input(field: &'static str, reason: &'static str) -> RtnFrameError {
176    RtnFrameError::InvalidInput { field, reason }
177}
178
179fn validate_vec3(field: &'static str, values: [f64; 3]) -> Result<(), RtnFrameError> {
180    if values.iter().all(|value| value.is_finite()) {
181        Ok(())
182    } else {
183        Err(invalid_input(field, "components must be finite"))
184    }
185}
186
187fn validate_covariance(field: &'static str, values: &Mat3) -> Result<(), RtnFrameError> {
188    validate::validate_covariance_psd(values, field).map_err(|error| match error {
189        validate::FieldError::NonFinite { field } => {
190            invalid_input(field, "components must be finite")
191        }
192        validate::FieldError::NotPositive { field } => invalid_input(field, "not positive"),
193        validate::FieldError::Negative { field } => invalid_input(field, "negative"),
194        validate::FieldError::OutOfRange { field, .. } => invalid_input(field, "out of range"),
195        validate::FieldError::Missing { field }
196        | validate::FieldError::FloatParse { field, .. }
197        | validate::FieldError::IntParse { field, .. }
198        | validate::FieldError::InvalidCivilDate { field, .. }
199        | validate::FieldError::InvalidCivilTime { field, .. } => invalid_input(field, "invalid"),
200    })
201}
202
203fn validate_mat3_finite(field: &'static str, values: &Mat3) -> Result<(), RtnFrameError> {
204    for row in values {
205        validate_vec3(field, *row)?;
206    }
207    Ok(())
208}
209
210/// Build the RTN->ECI rotation whose columns are the radial, transverse, and
211/// normal unit vectors of the orbit state `(r, v)`.
212///
213/// Operation order (magnitude before normalize, division not reciprocal
214/// multiply, cross-product component order) is fixed to reproduce the prior
215/// Elixir reference bit-for-bit.
216pub fn rtn_to_eci_rotation(r: [f64; 3], v: [f64; 3]) -> Result<Mat3, RtnFrameError> {
217    validate_vec3("position", r)?;
218    validate_vec3("velocity", v)?;
219    if vec3::norm3(r) < ZERO_POSITION_EPS {
220        return Err(RtnFrameError::ZeroPosition);
221    }
222    let r_hat = vec3::unit3_ref_unchecked(&r);
223    let h = vec3::cross3(r, v);
224    if vec3::norm3(h) < PARALLEL_RV_EPS {
225        return Err(RtnFrameError::ParallelPositionVelocity);
226    }
227    let n_hat = vec3::unit3_ref_unchecked(&h);
228    let t_hat = vec3::cross3(n_hat, r_hat);
229    Ok([
230        [r_hat[0], t_hat[0], n_hat[0]],
231        [r_hat[1], t_hat[1], n_hat[1]],
232        [r_hat[2], t_hat[2], n_hat[2]],
233    ])
234}
235
236/// Transform a 3x3 RTN position covariance to ECI: `C_eci = R * C_rtn * R^T`.
237///
238/// The triple product materialises the intermediate `R * C_rtn` and applies
239/// `R^T` in a second multiply (left-to-right `k` summation), matching the
240/// chained Elixir `mat_mul` reduction order rather than a fused Kahan product.
241pub fn rtn_to_eci(cov_rtn: &Mat3, r: [f64; 3], v: [f64; 3]) -> Result<Mat3, RtnFrameError> {
242    validate_covariance("cov_rtn", cov_rtn)?;
243    let rot = rtn_to_eci_rotation(r, v)?;
244    let rot_t = mat3::inline_tr(&rot);
245    let cov_eci = mat3::inline_rxr(&mat3::inline_rxr(&rot, cov_rtn), &rot_t);
246    validate_mat3_finite("cov_eci", &cov_eci)?;
247    Ok(cov_eci)
248}
249
250/// Whether a 3x3 matrix is symmetric within [`SYMMETRY_EPS`].
251pub fn symmetric(m: &Mat3) -> bool {
252    (m[0][1] - m[1][0]).abs() < SYMMETRY_EPS
253        && (m[0][2] - m[2][0]).abs() < SYMMETRY_EPS
254        && (m[1][2] - m[2][1]).abs() < SYMMETRY_EPS
255}
256
257/// Determinant of a 3x3 matrix via cofactor expansion along the first row,
258/// matching the Elixir reference operation order.
259fn det3x3(m: &Mat3) -> f64 {
260    let (a, b, c) = (m[0][0], m[0][1], m[0][2]);
261    let (d, e, f) = (m[1][0], m[1][1], m[1][2]);
262    let (g, h, i) = (m[2][0], m[2][1], m[2][2]);
263    a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
264}
265
266/// Whether a symmetric 3x3 matrix is positive semidefinite by Sylvester's
267/// criterion: every leading-and-trailing principal minor is non-negative
268/// within tolerance. A non-symmetric matrix is rejected.
269pub fn positive_semidefinite(m: &Mat3) -> bool {
270    if !symmetric(m) {
271        return false;
272    }
273
274    let m11 = m[0][0];
275    let m22 = m[1][1];
276    let m33 = m[2][2];
277    let m12 = m[0][1];
278    let m13 = m[0][2];
279    let m23 = m[1][2];
280
281    let det12 = m11 * m22 - m12 * m12;
282    let det13 = m11 * m33 - m13 * m13;
283    let det23 = m22 * m33 - m23 * m23;
284    let det123 = det3x3(m);
285
286    m11 >= -PSD_DIAGONAL_EPS
287        && m22 >= -PSD_DIAGONAL_EPS
288        && m33 >= -PSD_DIAGONAL_EPS
289        && det12 >= -PSD_MINOR_EPS
290        && det13 >= -PSD_MINOR_EPS
291        && det23 >= -PSD_MINOR_EPS
292        && det123 >= -PSD_MINOR_EPS
293}
294
295fn finite6(m: &Mat6) -> bool {
296    m.iter().flatten().all(|value| value.is_finite())
297}
298
299fn covariance_scale6(m: &Mat6) -> f64 {
300    (0..6).fold(0.0_f64, |scale, idx| scale.max(m[idx][idx].abs()))
301}
302
303#[allow(clippy::needless_range_loop)]
304fn symmetric6(m: &Mat6) -> bool {
305    let tolerance = SYMMETRY_REL_EPS6 * covariance_scale6(m);
306    for i in 0..6 {
307        for j in (i + 1)..6 {
308            if (m[i][j] - m[j][i]).abs() > tolerance {
309                return false;
310            }
311        }
312    }
313    true
314}
315
316fn positive_semidefinite6(m: &Mat6) -> bool {
317    if !finite6(m) || !symmetric6(m) {
318        return false;
319    }
320
321    let matrix = SMatrix::<f64, 6, 6>::from_fn(|i, j| m[i][j]);
322    let eigenvalues = matrix.symmetric_eigen().eigenvalues;
323    let scale = covariance_scale6(m);
324    let floor = -PSD6_EIGEN_REL_EPS * scale;
325    eigenvalues.iter().all(|&lambda| lambda >= floor)
326}
327
328#[allow(clippy::needless_range_loop)]
329fn symmetrize6(m: &mut Mat6) {
330    for i in 0..6 {
331        for j in (i + 1)..6 {
332            let value = 0.5 * (m[i][j] + m[j][i]);
333            m[i][j] = value;
334            m[j][i] = value;
335        }
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    /// Frozen ECI bits from the prior Elixir `Sidereon.Covariance.rtn_to_eci`
344    /// reference for `r = (7000.123, 1234.5, -250.7)`,
345    /// `v = (1.2, 7.4, 0.3)`, and the non-diagonal RTN covariance below.
346    /// Row-major; proves cross-language 0-ULP parity, including the last-ULP
347    /// off-diagonal asymmetry the chained multiply produces.
348    const RTN_TO_ECI_GOLDEN_BITS: [u64; 9] = [
349        0x4010077f74cce7ac,
350        0xbfd92b0043adb450,
351        0x3fe26dc422b0767a,
352        0xbfd92b0043adb44a,
353        0x402207fb1ad4c218,
354        0xbfb9ef5fd1874930,
355        0x3fe26dc422b0767a,
356        0xbfb9ef5fd1874930,
357        0x402ff4452ac4ca0f,
358    ];
359
360    #[test]
361    fn rtn_to_eci_matches_frozen_elixir_bits() {
362        let r = [7000.123, 1234.5, -250.7];
363        let v = [1.2, 7.4, 0.3];
364        let cov_rtn = [[4.0, 0.5, 0.1], [0.5, 9.0, 0.2], [0.1, 0.2, 16.0]];
365
366        let eci = rtn_to_eci(&cov_rtn, r, v).expect("non-degenerate state");
367
368        let mut flat = [0u64; 9];
369        for (idx, slot) in flat.iter_mut().enumerate() {
370            *slot = eci[idx / 3][idx % 3].to_bits();
371        }
372        assert_eq!(flat, RTN_TO_ECI_GOLDEN_BITS);
373    }
374
375    #[test]
376    fn rtn_to_eci_aligned_state_is_exactly_the_rtn_diagonal() {
377        // r along +X, v along +Y -> RTN axes coincide with ECI, so the
378        // transform is the identity and the diagonal is reproduced exactly.
379        let r = [7000.0, 0.0, 0.0];
380        let v = [0.0, 7.5, 0.0];
381        let cov_rtn = [[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 3.0]];
382
383        let eci = rtn_to_eci(&cov_rtn, r, v).expect("non-degenerate state");
384
385        assert_eq!(eci[0][0].to_bits(), 1.0_f64.to_bits());
386        assert_eq!(eci[1][1].to_bits(), 2.0_f64.to_bits());
387        assert_eq!(eci[2][2].to_bits(), 3.0_f64.to_bits());
388    }
389
390    #[test]
391    fn rtn_to_eci_rejects_zero_position() {
392        let err = rtn_to_eci(&identity(), [0.0, 0.0, 0.0], [0.0, 7.5, 0.0]).unwrap_err();
393        assert_eq!(err, RtnFrameError::ZeroPosition);
394        assert_eq!(err.message(), "zero position vector");
395    }
396
397    #[test]
398    fn rtn_to_eci_rejects_parallel_position_velocity() {
399        let err = rtn_to_eci(&identity(), [7000.0, 0.0, 0.0], [1.0, 0.0, 0.0]).unwrap_err();
400        assert_eq!(err, RtnFrameError::ParallelPositionVelocity);
401        assert_eq!(err.message(), "position and velocity are parallel");
402    }
403
404    #[test]
405    fn rtn_to_eci_rejects_nonfinite_geometry_and_covariance() {
406        let err = rtn_to_eci(&identity(), [7000.0, f64::NAN, 0.0], [0.0, 7.5, 0.0]).unwrap_err();
407        assert_eq!(
408            err,
409            RtnFrameError::InvalidInput {
410                field: "position",
411                reason: "components must be finite",
412            }
413        );
414
415        let err =
416            rtn_to_eci(&identity(), [7000.0, 0.0, 0.0], [0.0, f64::INFINITY, 0.0]).unwrap_err();
417        assert_eq!(
418            err,
419            RtnFrameError::InvalidInput {
420                field: "velocity",
421                reason: "components must be finite",
422            }
423        );
424
425        let mut cov = identity();
426        cov[2][1] = f64::NEG_INFINITY;
427        let err = rtn_to_eci(&cov, [7000.0, 0.0, 0.0], [0.0, 7.5, 0.0]).unwrap_err();
428        assert_eq!(
429            err,
430            RtnFrameError::InvalidInput {
431                field: "cov_rtn",
432                reason: "components must be finite",
433            }
434        );
435    }
436
437    #[test]
438    fn rtn_to_eci_rejects_invalid_covariance_geometry() {
439        let r = [7000.0, 0.0, 0.0];
440        let v = [0.0, 7.5, 0.0];
441
442        let mut negative_variance = identity();
443        negative_variance[0][0] = -1.0;
444        let err = rtn_to_eci(&negative_variance, r, v).unwrap_err();
445        assert_eq!(
446            err,
447            RtnFrameError::InvalidInput {
448                field: "cov_rtn",
449                reason: "not positive",
450            }
451        );
452
453        let asymmetric = [[1.0, 0.5, 0.0], [0.4, 1.0, 0.0], [0.0, 0.0, 1.0]];
454        let err = rtn_to_eci(&asymmetric, r, v).unwrap_err();
455        assert_eq!(
456            err,
457            RtnFrameError::InvalidInput {
458                field: "cov_rtn",
459                reason: "not positive",
460            }
461        );
462
463        let indefinite = [[1.0, 2.0, 0.0], [2.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
464        let err = rtn_to_eci(&indefinite, r, v).unwrap_err();
465        assert_eq!(
466            err,
467            RtnFrameError::InvalidInput {
468                field: "cov_rtn",
469                reason: "not positive",
470            }
471        );
472    }
473
474    #[test]
475    fn positive_semidefinite_accepts_identity_rejects_negative_and_asymmetric() {
476        assert!(positive_semidefinite(&identity()));
477
478        let negative_diag = [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
479        assert!(!positive_semidefinite(&negative_diag));
480
481        let asymmetric = [[1.0, 0.5, 0.0], [0.4, 1.0, 0.0], [0.0, 0.0, 1.0]];
482        assert!(!symmetric(&asymmetric));
483        assert!(!positive_semidefinite(&asymmetric));
484    }
485
486    #[test]
487    fn positive_semidefinite_rejects_symmetric_indefinite_matrix() {
488        // Symmetric but the 2x2 minor m11*m22 - m12^2 = 1 - 4 < 0.
489        let indefinite = [[1.0, 2.0, 0.0], [2.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
490        assert!(symmetric(&indefinite));
491        assert!(!positive_semidefinite(&indefinite));
492    }
493
494    #[test]
495    fn covariance6_accepts_diagonal_and_rejects_bad_matrices() {
496        let covariance =
497            Covariance6::from_diagonal([1.0, 2.0, 3.0, 1.0e-6, 2.0e-6, 3.0e-6]).unwrap();
498        assert!(covariance.is_symmetric());
499        assert!(covariance.is_positive_semidefinite());
500
501        let mut asymmetric = *covariance.as_matrix();
502        asymmetric[0][1] = 1.0e-3;
503        assert_eq!(
504            Covariance6::try_from_matrix(asymmetric),
505            Err(Covariance6Error::Asymmetric)
506        );
507
508        let mut indefinite = *covariance.as_matrix();
509        indefinite[5][5] = -1.0;
510        assert_eq!(
511            Covariance6::try_from_matrix(indefinite),
512            Err(Covariance6Error::NotPositiveSemidefinite)
513        );
514    }
515
516    #[test]
517    fn covariance6_scales_psd_tolerance_to_covariance_magnitude() {
518        let mut large = [[0.0_f64; 6]; 6];
519        for (idx, row) in large.iter_mut().enumerate() {
520            row[idx] = 1.0e18;
521        }
522        large[0][1] = 2.5e17;
523        large[1][0] = 2.5e17 + 1.0e3;
524
525        let covariance = Covariance6::try_from_matrix(large).expect("large PSD covariance");
526        assert!(covariance.is_symmetric());
527        assert!(covariance.is_positive_semidefinite());
528
529        let mut indefinite = large;
530        indefinite[2][2] = -1.0e9;
531        assert_eq!(
532            Covariance6::try_from_matrix(indefinite),
533            Err(Covariance6Error::NotPositiveSemidefinite)
534        );
535
536        let small =
537            Covariance6::from_diagonal([1.0e-18, 2.0e-18, 3.0e-18, 4.0e-18, 5.0e-18, 6.0e-18])
538                .expect("small PSD covariance");
539        assert!(small.is_symmetric());
540        assert!(small.is_positive_semidefinite());
541
542        let mut small_indefinite = *small.as_matrix();
543        small_indefinite[0][0] = -1.0e-20;
544        assert_eq!(
545            Covariance6::try_from_matrix(small_indefinite),
546            Err(Covariance6Error::NotPositiveSemidefinite)
547        );
548    }
549
550    fn identity() -> Mat3 {
551        [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
552    }
553}