1use 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
20const ZERO_POSITION_EPS: f64 = 1.0e-30;
23const PARALLEL_RV_EPS: f64 = 1.0e-30;
26const PSD_DIAGONAL_EPS: f64 = 1.0e-15;
29const PSD_MINOR_EPS: f64 = 1.0e-12;
32const SYMMETRY_EPS: f64 = 1.0e-12;
34const SYMMETRY_REL_EPS6: f64 = 1.0e-12;
36const PSD6_EIGEN_REL_EPS: f64 = 1.0e-10;
38const INTERPOLATION_EIGEN_REL_FLOOR: f64 = 1.0e-9;
40
41pub type Mat6 = [[f64; 6]; 6];
43
44#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct Covariance6 {
47 matrix: Mat6,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Covariance6Error {
53 NonFinite,
55 Asymmetric,
57 NotPositiveSemidefinite,
59 NotFactorizable,
61 InvalidInterpolationParameter,
63}
64
65impl Covariance6 {
66 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 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 pub const fn from_matrix_unchecked(matrix: Mat6) -> Self {
94 Self { matrix }
95 }
96
97 pub const fn as_matrix(&self) -> &Mat6 {
99 &self.matrix
100 }
101
102 pub const fn into_matrix(self) -> Mat6 {
104 self.matrix
105 }
106
107 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 pub fn is_symmetric(&self) -> bool {
118 symmetric6(&self.matrix)
119 }
120
121 pub fn is_positive_semidefinite(&self) -> bool {
123 positive_semidefinite6(&self.matrix)
124 }
125
126 #[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
157pub 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
171pub 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
183pub fn covariance6_km_to_m(covariance: &Covariance6) -> Result<Covariance6, Covariance6Error> {
188 scale_covariance6(covariance, 1.0e6)
189}
190
191pub fn covariance6_m_to_km(covariance: &Covariance6) -> Result<Covariance6, Covariance6Error> {
193 scale_covariance6(covariance, 1.0e-6)
194}
195
196#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum RtnFrameError {
253 InvalidInput {
255 field: &'static str,
256 reason: &'static str,
257 },
258 ZeroPosition,
260 ParallelPositionVelocity,
262}
263
264impl RtnFrameError {
265 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
311pub 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
337pub 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
351pub 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
358fn 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
367pub 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
429pub(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 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 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 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 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}