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