1use crate::astro::math::mat3::{self, Mat3};
14use crate::astro::math::vec3;
15use crate::astro::state::CartesianState;
16use crate::validate;
17use nalgebra::SMatrix;
18
19const ZERO_POSITION_EPS: f64 = 1.0e-30;
22const PARALLEL_RV_EPS: f64 = 1.0e-30;
25const PSD_DIAGONAL_EPS: f64 = 1.0e-15;
28const PSD_MINOR_EPS: f64 = 1.0e-12;
31const SYMMETRY_EPS: f64 = 1.0e-12;
33const SYMMETRY_REL_EPS6: f64 = 1.0e-12;
35const PSD6_EIGEN_REL_EPS: f64 = 1.0e-10;
37const INTERPOLATION_EIGEN_REL_FLOOR: f64 = 1.0e-9;
39
40pub type Mat6 = [[f64; 6]; 6];
42
43#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct Covariance6 {
46 matrix: Mat6,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Covariance6Error {
52 NonFinite,
54 Asymmetric,
56 NotPositiveSemidefinite,
58 NotFactorizable,
60 InvalidInterpolationParameter,
62}
63
64impl Covariance6 {
65 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 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 pub const fn from_matrix_unchecked(matrix: Mat6) -> Self {
93 Self { matrix }
94 }
95
96 pub const fn as_matrix(&self) -> &Mat6 {
98 &self.matrix
99 }
100
101 pub const fn into_matrix(self) -> Mat6 {
103 self.matrix
104 }
105
106 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 pub fn is_symmetric(&self) -> bool {
117 symmetric6(&self.matrix)
118 }
119
120 pub fn is_positive_semidefinite(&self) -> bool {
122 positive_semidefinite6(&self.matrix)
123 }
124
125 #[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
156pub 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
170pub 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
182pub fn covariance6_km_to_m(covariance: &Covariance6) -> Result<Covariance6, Covariance6Error> {
187 scale_covariance6(covariance, 1.0e6)
188}
189
190pub fn covariance6_m_to_km(covariance: &Covariance6) -> Result<Covariance6, Covariance6Error> {
192 scale_covariance6(covariance, 1.0e-6)
193}
194
195#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum RtnFrameError {
252 InvalidInput {
254 field: &'static str,
255 reason: &'static str,
256 },
257 ZeroPosition,
259 ParallelPositionVelocity,
261}
262
263impl RtnFrameError {
264 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
310pub 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
336pub 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
350pub 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
357fn 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
366pub 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
428pub(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 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 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 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 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}