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::portable;
15use crate::astro::math::vec3;
16use crate::astro::state::CartesianState;
17use crate::validate;
18use nalgebra::SMatrix;
19
20/// Position magnitudes below this are treated as a degenerate (zero) position
21/// vector, for which the RTN frame is undefined.
22const ZERO_POSITION_EPS: f64 = 1.0e-30;
23/// Orbit-normal magnitudes below this mean position and velocity are parallel,
24/// so the RTN frame normal (and thus the frame) is undefined.
25const PARALLEL_RV_EPS: f64 = 1.0e-30;
26/// Diagonal covariance entries are allowed to dip to this (negative) bound
27/// before the PSD check rejects them, absorbing float round-off.
28const PSD_DIAGONAL_EPS: f64 = 1.0e-15;
29/// Second- and third-order principal minors are allowed to dip to this
30/// (negative) bound before the PSD check rejects them.
31const PSD_MINOR_EPS: f64 = 1.0e-12;
32/// Off-diagonal pairs differing by more than this are treated as asymmetric.
33const SYMMETRY_EPS: f64 = 1.0e-12;
34/// Relative off-diagonal tolerance for 6x6 covariance symmetry checks.
35const SYMMETRY_REL_EPS6: f64 = 1.0e-12;
36/// Eigenvalues below this relative bound are treated as negative for 6x6 PSD.
37const PSD6_EIGEN_REL_EPS: f64 = 1.0e-10;
38/// Relative eigenvalue floor used only before interpolation Cholesky factoring.
39const INTERPOLATION_EIGEN_REL_FLOOR: f64 = 1.0e-9;
40
41/// Row-major 6x6 covariance for state vector `[r_x, r_y, r_z, v_x, v_y, v_z]`.
42pub type Mat6 = [[f64; 6]; 6];
43
44/// Typed 6x6 state covariance.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct Covariance6 {
47    matrix: Mat6,
48}
49
50/// Reason a 6x6 state covariance was rejected.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Covariance6Error {
53    /// At least one matrix entry was NaN or infinite.
54    NonFinite,
55    /// The matrix was not symmetric within the covariance tolerance.
56    Asymmetric,
57    /// The symmetric matrix was not positive semidefinite.
58    NotPositiveSemidefinite,
59    /// A PSD interpolation endpoint could not be Cholesky-factorized.
60    NotFactorizable,
61    /// The interpolation parameter was non-finite or outside `[0, 1]`.
62    InvalidInterpolationParameter,
63}
64
65impl Covariance6 {
66    /// Validate and wrap a row-major 6x6 state covariance.
67    pub fn try_from_matrix(matrix: Mat6) -> Result<Self, Covariance6Error> {
68        if !finite6(&matrix) {
69            return Err(Covariance6Error::NonFinite);
70        }
71        if !symmetric6(&matrix) {
72            return Err(Covariance6Error::Asymmetric);
73        }
74        if !positive_semidefinite6(&matrix) {
75            return Err(Covariance6Error::NotPositiveSemidefinite);
76        }
77        Ok(Self { matrix })
78    }
79
80    /// Build a diagonal state covariance from six variances.
81    pub fn from_diagonal(diagonal: [f64; 6]) -> Result<Self, Covariance6Error> {
82        let mut matrix = [[0.0_f64; 6]; 6];
83        for (idx, value) in diagonal.into_iter().enumerate() {
84            matrix[idx][idx] = value;
85        }
86        Self::try_from_matrix(matrix)
87    }
88
89    /// Wrap a matrix without validation.
90    ///
91    /// Intended for trusted fixtures; prefer [`Self::try_from_matrix`] for
92    /// caller data.
93    pub const fn from_matrix_unchecked(matrix: Mat6) -> Self {
94        Self { matrix }
95    }
96
97    /// Borrow the row-major 6x6 matrix.
98    pub const fn as_matrix(&self) -> &Mat6 {
99        &self.matrix
100    }
101
102    /// Consume this covariance and return its row-major 6x6 matrix.
103    pub const fn into_matrix(self) -> Mat6 {
104        self.matrix
105    }
106
107    /// Extract the 3x3 position covariance block.
108    pub fn position_covariance_km2(&self) -> Mat3 {
109        [
110            [self.matrix[0][0], self.matrix[0][1], self.matrix[0][2]],
111            [self.matrix[1][0], self.matrix[1][1], self.matrix[1][2]],
112            [self.matrix[2][0], self.matrix[2][1], self.matrix[2][2]],
113        ]
114    }
115
116    /// Whether this covariance is symmetric within the covariance tolerance.
117    pub fn is_symmetric(&self) -> bool {
118        symmetric6(&self.matrix)
119    }
120
121    /// Whether this covariance is positive semidefinite within tolerance.
122    pub fn is_positive_semidefinite(&self) -> bool {
123        positive_semidefinite6(&self.matrix)
124    }
125
126    /// Propagate this covariance through a state-transition matrix:
127    /// `P_f = Phi * P_0 * Phi^T`.
128    #[allow(clippy::needless_range_loop)]
129    pub fn propagate_with_stm(&self, stm: &Mat6) -> Result<Self, Covariance6Error> {
130        if !finite6(stm) {
131            return Err(Covariance6Error::NonFinite);
132        }
133
134        let mut temp = [[0.0_f64; 6]; 6];
135        for i in 0..6 {
136            for j in 0..6 {
137                for k in 0..6 {
138                    temp[i][j] += stm[i][k] * self.matrix[k][j];
139                }
140            }
141        }
142
143        let mut propagated = [[0.0_f64; 6]; 6];
144        for i in 0..6 {
145            for j in 0..6 {
146                for k in 0..6 {
147                    propagated[i][j] += temp[i][k] * stm[j][k];
148                }
149            }
150        }
151        symmetrize6(&mut propagated);
152
153        Self::try_from_matrix(propagated)
154    }
155}
156
157/// Transform a 6x6 inertial state covariance to RTN at `state`.
158///
159/// This uses the kinematic covariance convention: the same instantaneous RTN
160/// rotation is applied to position and velocity rows, without rotating-frame
161/// velocity terms.
162pub fn eci_to_rtn_covariance6(
163    covariance: &Covariance6,
164    state: &CartesianState,
165) -> Result<Covariance6, RtnFrameError> {
166    let rot = rtn_to_eci_rotation(state.position_array(), state.velocity_array())?;
167    let rot_t = mat3::inline_tr(&rot);
168    covariance_congruence6(covariance, &rot_t)
169}
170
171/// Transform a 6x6 RTN state covariance to inertial axes at `state`.
172///
173/// This is the inverse of [`eci_to_rtn_covariance6`] under the same kinematic
174/// covariance convention.
175pub fn rtn_to_eci_covariance6(
176    covariance: &Covariance6,
177    state: &CartesianState,
178) -> Result<Covariance6, RtnFrameError> {
179    let rot = rtn_to_eci_rotation(state.position_array(), state.velocity_array())?;
180    covariance_congruence6(covariance, &rot)
181}
182
183/// Convert a km-based 6x6 state covariance to m-based covariance units.
184///
185/// Every entry scales by 1e6 because position and velocity components both
186/// scale by 1e3 and covariance is quadratic in the state.
187pub fn covariance6_km_to_m(covariance: &Covariance6) -> Result<Covariance6, Covariance6Error> {
188    scale_covariance6(covariance, 1.0e6)
189}
190
191/// Convert an m-based 6x6 state covariance to km-based covariance units.
192pub fn covariance6_m_to_km(covariance: &Covariance6) -> Result<Covariance6, Covariance6Error> {
193    scale_covariance6(covariance, 1.0e-6)
194}
195
196/// PSD-safe interpolation between two same-frame 6x6 covariances.
197///
198/// The interpolation follows the Log-Cholesky geodesic: strictly lower
199/// Cholesky entries are linearly blended, diagonal entries are blended in log
200/// space, and the covariance is reconstructed as `L * L^T`. Endpoints are
201/// returned bit-for-bit. Singular but non-zero validated endpoints are nudged
202/// through `eigen_floor6` before factorization; an all-zero endpoint is
203/// rejected because the logarithmic diagonal is undefined.
204#[allow(clippy::needless_range_loop)]
205pub fn interpolate_covariance_psd(
206    a: &Covariance6,
207    b: &Covariance6,
208    u: f64,
209) -> Result<Covariance6, Covariance6Error> {
210    if !u.is_finite() || !(0.0..=1.0).contains(&u) {
211        return Err(Covariance6Error::InvalidInterpolationParameter);
212    }
213    if u == 0.0 {
214        return Ok(*a);
215    }
216    if u == 1.0 {
217        return Ok(*b);
218    }
219    if is_all_zero6(a.as_matrix()) || is_all_zero6(b.as_matrix()) {
220        return Err(Covariance6Error::NotFactorizable);
221    }
222
223    let la = cholesky_lower_with_floor(a.as_matrix())?;
224    let lb = cholesky_lower_with_floor(b.as_matrix())?;
225    let mut l = [[0.0_f64; 6]; 6];
226    for i in 0..6 {
227        for j in 0..=i {
228            l[i][j] = if i == j {
229                libm::exp(libm::log(la[i][j]) * (1.0 - u) + libm::log(lb[i][j]) * u)
230            } else {
231                la[i][j] * (1.0 - u) + lb[i][j] * u
232            };
233        }
234    }
235
236    let mut interpolated = [[0.0_f64; 6]; 6];
237    for i in 0..6 {
238        for j in 0..=i {
239            let mut value = 0.0_f64;
240            for k in 0..=j {
241                value += l[i][k] * l[j][k];
242            }
243            interpolated[i][j] = value;
244            interpolated[j][i] = value;
245        }
246    }
247    Covariance6::try_from_matrix(interpolated)
248}
249
250/// Reason an RTN->ECI transform could not be built from an orbit state.
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum RtnFrameError {
253    /// A numeric input was non-finite.
254    InvalidInput {
255        field: &'static str,
256        reason: &'static str,
257    },
258    /// The position vector is effectively zero.
259    ZeroPosition,
260    /// Position and velocity are parallel, leaving the orbit normal undefined.
261    ParallelPositionVelocity,
262}
263
264impl RtnFrameError {
265    /// Message string matching the historical sidereon error verbatim, so the
266    /// thin Elixir binding preserves its public `{:error, reason}` shapes.
267    pub fn message(self) -> &'static str {
268        match self {
269            RtnFrameError::InvalidInput { .. } => "invalid input",
270            RtnFrameError::ZeroPosition => "zero position vector",
271            RtnFrameError::ParallelPositionVelocity => "position and velocity are parallel",
272        }
273    }
274}
275
276fn invalid_input(field: &'static str, reason: &'static str) -> RtnFrameError {
277    RtnFrameError::InvalidInput { field, reason }
278}
279
280fn validate_vec3(field: &'static str, values: [f64; 3]) -> Result<(), RtnFrameError> {
281    if values.iter().all(|value| value.is_finite()) {
282        Ok(())
283    } else {
284        Err(invalid_input(field, "components must be finite"))
285    }
286}
287
288fn validate_covariance(field: &'static str, values: &Mat3) -> Result<(), RtnFrameError> {
289    validate::validate_covariance_psd(values, field).map_err(|error| match error {
290        validate::FieldError::NonFinite { field } => {
291            invalid_input(field, "components must be finite")
292        }
293        validate::FieldError::NotPositive { field } => invalid_input(field, "not positive"),
294        validate::FieldError::Negative { field } => invalid_input(field, "negative"),
295        validate::FieldError::OutOfRange { field, .. } => invalid_input(field, "out of range"),
296        validate::FieldError::Missing { field }
297        | validate::FieldError::FloatParse { field, .. }
298        | validate::FieldError::IntParse { field, .. }
299        | validate::FieldError::InvalidCivilDate { field, .. }
300        | validate::FieldError::InvalidCivilTime { field, .. } => invalid_input(field, "invalid"),
301    })
302}
303
304fn validate_mat3_finite(field: &'static str, values: &Mat3) -> Result<(), RtnFrameError> {
305    for row in values {
306        validate_vec3(field, *row)?;
307    }
308    Ok(())
309}
310
311/// Build the RTN->ECI rotation whose columns are the radial, transverse, and
312/// normal unit vectors of the orbit state `(r, v)`.
313///
314/// Operation order (magnitude before normalize, division not reciprocal
315/// multiply, cross-product component order) is fixed to reproduce the prior
316/// Elixir reference bit-for-bit.
317pub fn rtn_to_eci_rotation(r: [f64; 3], v: [f64; 3]) -> Result<Mat3, RtnFrameError> {
318    validate_vec3("position", r)?;
319    validate_vec3("velocity", v)?;
320    if vec3::norm3(r) < ZERO_POSITION_EPS {
321        return Err(RtnFrameError::ZeroPosition);
322    }
323    let r_hat = vec3::unit3_ref_unchecked(&r);
324    let h = vec3::cross3(r, v);
325    if vec3::norm3(h) < PARALLEL_RV_EPS {
326        return Err(RtnFrameError::ParallelPositionVelocity);
327    }
328    let n_hat = vec3::unit3_ref_unchecked(&h);
329    let t_hat = vec3::cross3(n_hat, r_hat);
330    Ok([
331        [r_hat[0], t_hat[0], n_hat[0]],
332        [r_hat[1], t_hat[1], n_hat[1]],
333        [r_hat[2], t_hat[2], n_hat[2]],
334    ])
335}
336
337/// Transform a 3x3 RTN position covariance to ECI: `C_eci = R * C_rtn * R^T`.
338///
339/// The triple product materialises the intermediate `R * C_rtn` and applies
340/// `R^T` in a second multiply (left-to-right `k` summation), matching the
341/// chained Elixir `mat_mul` reduction order rather than a fused Kahan product.
342pub fn rtn_to_eci(cov_rtn: &Mat3, r: [f64; 3], v: [f64; 3]) -> Result<Mat3, RtnFrameError> {
343    validate_covariance("cov_rtn", cov_rtn)?;
344    let rot = rtn_to_eci_rotation(r, v)?;
345    let rot_t = mat3::inline_tr(&rot);
346    let cov_eci = mat3::inline_rxr(&mat3::inline_rxr(&rot, cov_rtn), &rot_t);
347    validate_mat3_finite("cov_eci", &cov_eci)?;
348    Ok(cov_eci)
349}
350
351/// Whether a 3x3 matrix is symmetric within `SYMMETRY_EPS`.
352pub fn symmetric(m: &Mat3) -> bool {
353    (m[0][1] - m[1][0]).abs() < SYMMETRY_EPS
354        && (m[0][2] - m[2][0]).abs() < SYMMETRY_EPS
355        && (m[1][2] - m[2][1]).abs() < SYMMETRY_EPS
356}
357
358/// Determinant of a 3x3 matrix via cofactor expansion along the first row,
359/// matching the Elixir reference operation order.
360fn det3x3(m: &Mat3) -> f64 {
361    let (a, b, c) = (m[0][0], m[0][1], m[0][2]);
362    let (d, e, f) = (m[1][0], m[1][1], m[1][2]);
363    let (g, h, i) = (m[2][0], m[2][1], m[2][2]);
364    a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
365}
366
367/// Whether a symmetric 3x3 matrix is positive semidefinite by Sylvester's
368/// criterion: every leading-and-trailing principal minor is non-negative
369/// within tolerance. A non-symmetric matrix is rejected.
370pub fn positive_semidefinite(m: &Mat3) -> bool {
371    if !symmetric(m) {
372        return false;
373    }
374
375    let m11 = m[0][0];
376    let m22 = m[1][1];
377    let m33 = m[2][2];
378    let m12 = m[0][1];
379    let m13 = m[0][2];
380    let m23 = m[1][2];
381
382    let det12 = m11 * m22 - m12 * m12;
383    let det13 = m11 * m33 - m13 * m13;
384    let det23 = m22 * m33 - m23 * m23;
385    let det123 = det3x3(m);
386
387    m11 >= -PSD_DIAGONAL_EPS
388        && m22 >= -PSD_DIAGONAL_EPS
389        && m33 >= -PSD_DIAGONAL_EPS
390        && det12 >= -PSD_MINOR_EPS
391        && det13 >= -PSD_MINOR_EPS
392        && det23 >= -PSD_MINOR_EPS
393        && det123 >= -PSD_MINOR_EPS
394}
395
396pub(crate) fn finite6(m: &Mat6) -> bool {
397    m.iter().flatten().all(|value| value.is_finite())
398}
399
400fn covariance_scale6(m: &Mat6) -> f64 {
401    (0..6).fold(0.0_f64, |scale, idx| scale.max(m[idx][idx].abs()))
402}
403
404#[allow(clippy::needless_range_loop)]
405fn symmetric6(m: &Mat6) -> bool {
406    let tolerance = SYMMETRY_REL_EPS6 * covariance_scale6(m);
407    for i in 0..6 {
408        for j in (i + 1)..6 {
409            if (m[i][j] - m[j][i]).abs() > tolerance {
410                return false;
411            }
412        }
413    }
414    true
415}
416
417fn positive_semidefinite6(m: &Mat6) -> bool {
418    if !finite6(m) || !symmetric6(m) {
419        return false;
420    }
421
422    let matrix = SMatrix::<f64, 6, 6>::from_fn(|i, j| m[i][j]);
423    let (_, eigenvalues) = portable::symmetric_eigen6(&matrix);
424    let scale = covariance_scale6(m);
425    let floor = -PSD6_EIGEN_REL_EPS * scale;
426    eigenvalues.iter().all(|&lambda| lambda >= floor)
427}
428
429/// Clamp small eigenvalues of a symmetric 6x6 matrix to a relative floor.
430///
431/// This is used only to make marginal PSD interpolation endpoints strictly
432/// factorizable. It is not a propagation repair path.
433pub(crate) fn eigen_floor6(matrix: &Mat6, rel_floor: f64) -> Mat6 {
434    let m = SMatrix::<f64, 6, 6>::from_fn(|i, j| matrix[i][j]);
435    let (eigenvectors, eigenvalues) = portable::symmetric_eigen6(&m);
436    let scale = covariance_scale6(matrix);
437    let floor = rel_floor.max(0.0) * scale;
438    let mut diagonal = SMatrix::<f64, 6, 6>::zeros();
439    for i in 0..6 {
440        diagonal[(i, i)] = eigenvalues[i].max(floor);
441    }
442    let floored = portable::product_fixed(
443        &portable::product_fixed(&eigenvectors, &diagonal),
444        &eigenvectors.transpose(),
445    );
446    let mut out = mat6_from_smatrix(&floored);
447    symmetrize6(&mut out);
448    out
449}
450
451#[allow(clippy::needless_range_loop)]
452pub(crate) fn symmetrize6(m: &mut Mat6) {
453    for i in 0..6 {
454        for j in (i + 1)..6 {
455            let value = 0.5 * (m[i][j] + m[j][i]);
456            m[i][j] = value;
457            m[j][i] = value;
458        }
459    }
460}
461
462fn is_all_zero6(m: &Mat6) -> bool {
463    m.iter().flatten().all(|value| *value == 0.0)
464}
465
466fn mat6_from_smatrix(matrix: &SMatrix<f64, 6, 6>) -> Mat6 {
467    let mut out = [[0.0_f64; 6]; 6];
468    for i in 0..6 {
469        for j in 0..6 {
470            out[i][j] = matrix[(i, j)];
471        }
472    }
473    out
474}
475
476fn cholesky_lower(matrix: &Mat6) -> Option<Mat6> {
477    let m = SMatrix::<f64, 6, 6>::from_fn(|i, j| matrix[i][j]);
478    portable::cholesky_lower(&m).map(|lower| mat6_from_smatrix(&lower))
479}
480
481fn cholesky_lower_with_floor(matrix: &Mat6) -> Result<Mat6, Covariance6Error> {
482    if let Some(lower) = cholesky_lower(matrix) {
483        return Ok(lower);
484    }
485    let floored = eigen_floor6(matrix, INTERPOLATION_EIGEN_REL_FLOOR);
486    cholesky_lower(&floored).ok_or(Covariance6Error::NotFactorizable)
487}
488
489#[allow(clippy::needless_range_loop)]
490pub(crate) fn covariance_congruence6_checked(
491    covariance: &Covariance6,
492    rotation: &Mat3,
493) -> Result<Covariance6, Covariance6Error> {
494    let matrix = covariance.as_matrix();
495    let mut block_rotation = [[0.0_f64; 6]; 6];
496    for i in 0..3 {
497        for j in 0..3 {
498            block_rotation[i][j] = rotation[i][j];
499            block_rotation[i + 3][j + 3] = rotation[i][j];
500        }
501    }
502
503    let mut temp = [[0.0_f64; 6]; 6];
504    for i in 0..6 {
505        for j in 0..6 {
506            for k in 0..6 {
507                temp[i][j] += block_rotation[i][k] * matrix[k][j];
508            }
509        }
510    }
511
512    let mut transformed = [[0.0_f64; 6]; 6];
513    for i in 0..6 {
514        for j in 0..6 {
515            for k in 0..6 {
516                transformed[i][j] += temp[i][k] * block_rotation[j][k];
517            }
518        }
519    }
520    symmetrize6(&mut transformed);
521    Covariance6::try_from_matrix(transformed)
522}
523
524fn covariance_congruence6(
525    covariance: &Covariance6,
526    rotation: &Mat3,
527) -> Result<Covariance6, RtnFrameError> {
528    covariance_congruence6_checked(covariance, rotation).map_err(covariance_error_to_rtn_error)
529}
530
531fn covariance_error_to_rtn_error(error: Covariance6Error) -> RtnFrameError {
532    match error {
533        Covariance6Error::NonFinite => invalid_input("covariance", "components must be finite"),
534        Covariance6Error::Asymmetric => invalid_input("covariance", "not symmetric"),
535        Covariance6Error::NotPositiveSemidefinite
536        | Covariance6Error::NotFactorizable
537        | Covariance6Error::InvalidInterpolationParameter => {
538            invalid_input("covariance", "not positive")
539        }
540    }
541}
542
543fn scale_covariance6(
544    covariance: &Covariance6,
545    scale: f64,
546) -> Result<Covariance6, Covariance6Error> {
547    let mut scaled = *covariance.as_matrix();
548    for row in &mut scaled {
549        for value in row {
550            *value *= scale;
551        }
552    }
553    Covariance6::try_from_matrix(scaled)
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    /// Frozen ECI bits from the prior Elixir `Sidereon.Covariance.rtn_to_eci`
561    /// reference for `r = (7000.123, 1234.5, -250.7)`,
562    /// `v = (1.2, 7.4, 0.3)`, and the non-diagonal RTN covariance below.
563    /// Row-major; proves cross-language 0-ULP parity, including the last-ULP
564    /// off-diagonal asymmetry the chained multiply produces.
565    const RTN_TO_ECI_GOLDEN_BITS: [u64; 9] = [
566        0x4010077f74cce7ac,
567        0xbfd92b0043adb450,
568        0x3fe26dc422b0767a,
569        0xbfd92b0043adb44a,
570        0x402207fb1ad4c218,
571        0xbfb9ef5fd1874930,
572        0x3fe26dc422b0767a,
573        0xbfb9ef5fd1874930,
574        0x402ff4452ac4ca0f,
575    ];
576
577    #[test]
578    fn rtn_to_eci_matches_frozen_elixir_bits() {
579        let r = [7000.123, 1234.5, -250.7];
580        let v = [1.2, 7.4, 0.3];
581        let cov_rtn = [[4.0, 0.5, 0.1], [0.5, 9.0, 0.2], [0.1, 0.2, 16.0]];
582
583        let eci = rtn_to_eci(&cov_rtn, r, v).expect("non-degenerate state");
584
585        let mut flat = [0u64; 9];
586        for (idx, slot) in flat.iter_mut().enumerate() {
587            *slot = eci[idx / 3][idx % 3].to_bits();
588        }
589        assert_eq!(flat, RTN_TO_ECI_GOLDEN_BITS);
590    }
591
592    #[test]
593    fn rtn_to_eci_aligned_state_is_exactly_the_rtn_diagonal() {
594        // r along +X, v along +Y -> RTN axes coincide with ECI, so the
595        // transform is the identity and the diagonal is reproduced exactly.
596        let r = [7000.0, 0.0, 0.0];
597        let v = [0.0, 7.5, 0.0];
598        let cov_rtn = [[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 3.0]];
599
600        let eci = rtn_to_eci(&cov_rtn, r, v).expect("non-degenerate state");
601
602        assert_eq!(eci[0][0].to_bits(), 1.0_f64.to_bits());
603        assert_eq!(eci[1][1].to_bits(), 2.0_f64.to_bits());
604        assert_eq!(eci[2][2].to_bits(), 3.0_f64.to_bits());
605    }
606
607    #[test]
608    fn rtn_to_eci_rejects_zero_position() {
609        let err = rtn_to_eci(&identity(), [0.0, 0.0, 0.0], [0.0, 7.5, 0.0]).unwrap_err();
610        assert_eq!(err, RtnFrameError::ZeroPosition);
611        assert_eq!(err.message(), "zero position vector");
612    }
613
614    #[test]
615    fn rtn_to_eci_rejects_parallel_position_velocity() {
616        let err = rtn_to_eci(&identity(), [7000.0, 0.0, 0.0], [1.0, 0.0, 0.0]).unwrap_err();
617        assert_eq!(err, RtnFrameError::ParallelPositionVelocity);
618        assert_eq!(err.message(), "position and velocity are parallel");
619    }
620
621    #[test]
622    fn rtn_to_eci_rejects_nonfinite_geometry_and_covariance() {
623        let err = rtn_to_eci(&identity(), [7000.0, f64::NAN, 0.0], [0.0, 7.5, 0.0]).unwrap_err();
624        assert_eq!(
625            err,
626            RtnFrameError::InvalidInput {
627                field: "position",
628                reason: "components must be finite",
629            }
630        );
631
632        let err =
633            rtn_to_eci(&identity(), [7000.0, 0.0, 0.0], [0.0, f64::INFINITY, 0.0]).unwrap_err();
634        assert_eq!(
635            err,
636            RtnFrameError::InvalidInput {
637                field: "velocity",
638                reason: "components must be finite",
639            }
640        );
641
642        let mut cov = identity();
643        cov[2][1] = f64::NEG_INFINITY;
644        let err = rtn_to_eci(&cov, [7000.0, 0.0, 0.0], [0.0, 7.5, 0.0]).unwrap_err();
645        assert_eq!(
646            err,
647            RtnFrameError::InvalidInput {
648                field: "cov_rtn",
649                reason: "components must be finite",
650            }
651        );
652    }
653
654    #[test]
655    fn rtn_to_eci_rejects_invalid_covariance_geometry() {
656        let r = [7000.0, 0.0, 0.0];
657        let v = [0.0, 7.5, 0.0];
658
659        let mut negative_variance = identity();
660        negative_variance[0][0] = -1.0;
661        let err = rtn_to_eci(&negative_variance, r, v).unwrap_err();
662        assert_eq!(
663            err,
664            RtnFrameError::InvalidInput {
665                field: "cov_rtn",
666                reason: "not positive",
667            }
668        );
669
670        let asymmetric = [[1.0, 0.5, 0.0], [0.4, 1.0, 0.0], [0.0, 0.0, 1.0]];
671        let err = rtn_to_eci(&asymmetric, r, v).unwrap_err();
672        assert_eq!(
673            err,
674            RtnFrameError::InvalidInput {
675                field: "cov_rtn",
676                reason: "not positive",
677            }
678        );
679
680        let indefinite = [[1.0, 2.0, 0.0], [2.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
681        let err = rtn_to_eci(&indefinite, r, v).unwrap_err();
682        assert_eq!(
683            err,
684            RtnFrameError::InvalidInput {
685                field: "cov_rtn",
686                reason: "not positive",
687            }
688        );
689    }
690
691    #[test]
692    fn positive_semidefinite_accepts_identity_rejects_negative_and_asymmetric() {
693        assert!(positive_semidefinite(&identity()));
694
695        let negative_diag = [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
696        assert!(!positive_semidefinite(&negative_diag));
697
698        let asymmetric = [[1.0, 0.5, 0.0], [0.4, 1.0, 0.0], [0.0, 0.0, 1.0]];
699        assert!(!symmetric(&asymmetric));
700        assert!(!positive_semidefinite(&asymmetric));
701    }
702
703    #[test]
704    fn positive_semidefinite_rejects_symmetric_indefinite_matrix() {
705        // Symmetric but the 2x2 minor m11*m22 - m12^2 = 1 - 4 < 0.
706        let indefinite = [[1.0, 2.0, 0.0], [2.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
707        assert!(symmetric(&indefinite));
708        assert!(!positive_semidefinite(&indefinite));
709    }
710
711    #[test]
712    fn covariance6_accepts_diagonal_and_rejects_bad_matrices() {
713        let covariance =
714            Covariance6::from_diagonal([1.0, 2.0, 3.0, 1.0e-6, 2.0e-6, 3.0e-6]).unwrap();
715        assert!(covariance.is_symmetric());
716        assert!(covariance.is_positive_semidefinite());
717
718        let mut asymmetric = *covariance.as_matrix();
719        asymmetric[0][1] = 1.0e-3;
720        assert_eq!(
721            Covariance6::try_from_matrix(asymmetric),
722            Err(Covariance6Error::Asymmetric)
723        );
724
725        let mut indefinite = *covariance.as_matrix();
726        indefinite[5][5] = -1.0;
727        assert_eq!(
728            Covariance6::try_from_matrix(indefinite),
729            Err(Covariance6Error::NotPositiveSemidefinite)
730        );
731    }
732
733    #[test]
734    fn covariance6_scales_psd_tolerance_to_covariance_magnitude() {
735        let mut large = [[0.0_f64; 6]; 6];
736        for (idx, row) in large.iter_mut().enumerate() {
737            row[idx] = 1.0e18;
738        }
739        large[0][1] = 2.5e17;
740        large[1][0] = 2.5e17 + 1.0e3;
741
742        let covariance = Covariance6::try_from_matrix(large).expect("large PSD covariance");
743        assert!(covariance.is_symmetric());
744        assert!(covariance.is_positive_semidefinite());
745
746        let mut indefinite = large;
747        indefinite[2][2] = -1.0e9;
748        assert_eq!(
749            Covariance6::try_from_matrix(indefinite),
750            Err(Covariance6Error::NotPositiveSemidefinite)
751        );
752
753        let small =
754            Covariance6::from_diagonal([1.0e-18, 2.0e-18, 3.0e-18, 4.0e-18, 5.0e-18, 6.0e-18])
755                .expect("small PSD covariance");
756        assert!(small.is_symmetric());
757        assert!(small.is_positive_semidefinite());
758
759        let mut small_indefinite = *small.as_matrix();
760        small_indefinite[0][0] = -1.0e-20;
761        assert_eq!(
762            Covariance6::try_from_matrix(small_indefinite),
763            Err(Covariance6Error::NotPositiveSemidefinite)
764        );
765    }
766
767    #[test]
768    fn covariance6_rtn_round_trip_recovers_input() {
769        let state = CartesianState::new(100.0, [7000.0, 100.0, 20.0], [-0.1, 7.5, 0.3]);
770        let covariance = Covariance6::try_from_matrix([
771            [4.0, 0.2, 0.1, 1.0e-5, 2.0e-5, 3.0e-5],
772            [0.2, 9.0, 0.3, 4.0e-5, 5.0e-5, 6.0e-5],
773            [0.1, 0.3, 16.0, 7.0e-5, 8.0e-5, 9.0e-5],
774            [1.0e-5, 4.0e-5, 7.0e-5, 1.0e-4, 1.0e-5, 2.0e-5],
775            [2.0e-5, 5.0e-5, 8.0e-5, 1.0e-5, 2.0e-4, 3.0e-5],
776            [3.0e-5, 6.0e-5, 9.0e-5, 2.0e-5, 3.0e-5, 3.0e-4],
777        ])
778        .expect("SPD covariance");
779
780        let rtn = eci_to_rtn_covariance6(&covariance, &state).expect("ECI to RTN");
781        let eci = rtn_to_eci_covariance6(&rtn, &state).expect("RTN to ECI");
782
783        for i in 0..6 {
784            for j in 0..6 {
785                let expected = covariance.as_matrix()[i][j];
786                let actual = eci.as_matrix()[i][j];
787                let tolerance = 1.0e-12 * expected.abs().max(1.0);
788                assert!(
789                    (actual - expected).abs() <= tolerance,
790                    "entry [{i}][{j}] expected {expected}, got {actual}"
791                );
792            }
793        }
794    }
795
796    #[test]
797    fn covariance6_position_block_matches_existing_rtn_to_eci() {
798        let state = CartesianState::new(0.0, [7000.123, 1234.5, -250.7], [1.2, 7.4, 0.3]);
799        let cov_rtn = [[4.0, 0.5, 0.1], [0.5, 9.0, 0.2], [0.1, 0.2, 16.0]];
800        let full = Covariance6::from_diagonal([4.0, 9.0, 16.0, 1.0, 1.0, 1.0]).unwrap();
801        let mut matrix = *full.as_matrix();
802        for i in 0..3 {
803            for j in 0..3 {
804                matrix[i][j] = cov_rtn[i][j];
805            }
806        }
807        let full = Covariance6::try_from_matrix(matrix).unwrap();
808
809        let eci3 = rtn_to_eci(&cov_rtn, state.position_array(), state.velocity_array()).unwrap();
810        let eci6 = rtn_to_eci_covariance6(&full, &state).unwrap();
811
812        // The 6x6 path symmetrizes by spec after congruence, while the legacy
813        // 3x3 helper preserves its frozen multiply asymmetry for binding
814        // parity. Pin the deviation explicitly instead of widening silently.
815        for (i, row) in eci3.iter().enumerate() {
816            for (j, expected) in row.iter().enumerate() {
817                assert!((eci6.as_matrix()[i][j] - expected).abs() <= 1.0e-14);
818            }
819        }
820    }
821
822    #[test]
823    fn covariance6_unit_scaling_round_trips() {
824        let covariance =
825            Covariance6::from_diagonal([1.0, 2.0, 3.0, 1.0e-6, 2.0e-6, 3.0e-6]).unwrap();
826
827        let meters = covariance6_km_to_m(&covariance).expect("km to m");
828        assert_eq!(meters.as_matrix()[0][0].to_bits(), 1.0e6_f64.to_bits());
829        assert_eq!(meters.as_matrix()[3][3].to_bits(), 1.0_f64.to_bits());
830
831        let kilometers = covariance6_m_to_km(&meters).expect("m to km");
832        assert_eq!(kilometers, covariance);
833    }
834
835    #[test]
836    fn covariance6_interpolation_rejects_invalid_parameters_and_zero_endpoint() {
837        let a = Covariance6::from_diagonal([1.0, 2.0, 3.0, 1.0e-6, 2.0e-6, 3.0e-6]).unwrap();
838        let b = Covariance6::from_diagonal([4.0, 5.0, 6.0, 4.0e-6, 5.0e-6, 6.0e-6]).unwrap();
839
840        assert_eq!(interpolate_covariance_psd(&a, &b, 0.0).unwrap(), a);
841        assert_eq!(interpolate_covariance_psd(&a, &b, 1.0).unwrap(), b);
842        for u in [-0.1, 1.1, f64::NAN, f64::INFINITY] {
843            assert_eq!(
844                interpolate_covariance_psd(&a, &b, u),
845                Err(Covariance6Error::InvalidInterpolationParameter)
846            );
847        }
848
849        let zero = Covariance6::from_diagonal([0.0; 6]).unwrap();
850        assert_eq!(
851            interpolate_covariance_psd(&zero, &b, 0.5),
852            Err(Covariance6Error::NotFactorizable)
853        );
854    }
855
856    #[test]
857    fn covariance6_interpolation_floors_singular_endpoint() {
858        let singular = Covariance6::from_diagonal([1.0, 2.0, 3.0, 0.0, 5.0e-6, 6.0e-6]).unwrap();
859        let full_rank =
860            Covariance6::from_diagonal([1.5, 2.5, 3.5, 1.0e-6, 5.5e-6, 6.5e-6]).unwrap();
861
862        let interpolated = interpolate_covariance_psd(&singular, &full_rank, 0.5)
863            .expect("floored singular endpoint interpolates");
864
865        assert!(interpolated.is_symmetric());
866        assert!(interpolated.is_positive_semidefinite());
867    }
868
869    #[test]
870    #[allow(clippy::needless_range_loop)]
871    fn eigen_floor6_clamps_only_values_below_floor() {
872        let mut marginal = [[0.0_f64; 6]; 6];
873        for (idx, row) in marginal.iter_mut().enumerate() {
874            row[idx] = (idx + 1) as f64;
875        }
876        marginal[5][5] = -1.0e-15;
877
878        let floored = eigen_floor6(&marginal, 1.0e-9);
879        assert!(cholesky_lower(&floored).is_some());
880        assert!(Covariance6::try_from_matrix(floored).is_ok());
881
882        let healthy = Covariance6::from_diagonal([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
883        let healthy_floored = eigen_floor6(healthy.as_matrix(), 1.0e-12);
884        for i in 0..6 {
885            for j in 0..6 {
886                assert!((healthy_floored[i][j] - healthy.as_matrix()[i][j]).abs() <= 1.0e-12);
887            }
888        }
889    }
890
891    #[test]
892    #[allow(clippy::needless_range_loop)]
893    fn covariance6_cdm_lower_triangle_unit_bridge_is_pinned() {
894        let mut matrix = [[0.0_f64; 6]; 6];
895        let mut value = 1.0_f64;
896        for i in 0..6 {
897            for j in 0..=i {
898                matrix[i][j] = value;
899                matrix[j][i] = value;
900                value += 1.0;
901            }
902        }
903        for i in 0..6 {
904            matrix[i][i] += 30.0;
905        }
906        let covariance = Covariance6::try_from_matrix(matrix).unwrap();
907        let meters = covariance6_km_to_m(&covariance).unwrap();
908        let lower_triangle = [
909            meters.as_matrix()[0][0],
910            meters.as_matrix()[1][0],
911            meters.as_matrix()[1][1],
912            meters.as_matrix()[2][0],
913            meters.as_matrix()[2][1],
914            meters.as_matrix()[2][2],
915            meters.as_matrix()[3][0],
916            meters.as_matrix()[3][1],
917            meters.as_matrix()[3][2],
918            meters.as_matrix()[3][3],
919            meters.as_matrix()[4][0],
920            meters.as_matrix()[4][1],
921            meters.as_matrix()[4][2],
922            meters.as_matrix()[4][3],
923            meters.as_matrix()[4][4],
924            meters.as_matrix()[5][0],
925            meters.as_matrix()[5][1],
926            meters.as_matrix()[5][2],
927            meters.as_matrix()[5][3],
928            meters.as_matrix()[5][4],
929            meters.as_matrix()[5][5],
930        ];
931
932        assert_eq!(
933            lower_triangle,
934            [
935                31.0e6, 2.0e6, 33.0e6, 4.0e6, 5.0e6, 36.0e6, 7.0e6, 8.0e6, 9.0e6, 40.0e6, 11.0e6,
936                12.0e6, 13.0e6, 14.0e6, 45.0e6, 16.0e6, 17.0e6, 18.0e6, 19.0e6, 20.0e6, 51.0e6,
937            ]
938        );
939        assert_eq!(covariance6_m_to_km(&meters).unwrap(), covariance);
940    }
941
942    fn identity() -> Mat3 {
943        [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
944    }
945}