1use crate::astro::math::linear::{
55 invert_4x4_cofactor, invert_symmetric_pd, normal_matrix_4_weighted_column_outer, LinearError,
56};
57use crate::astro::math::mat3::{inline_rxr, inline_tr};
58
59use crate::frame::Wgs84Geodetic;
60use crate::id::GnssSystem;
61use crate::validate;
62
63const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
64const LOS_UNIT_TOLERANCE: f64 = 1.0e-3;
65const GEOCENTRIC_MIN_RADIUS_M: f64 = 1.0;
69
70#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct LineOfSight {
78 pub e_x: f64,
80 pub e_y: f64,
82 pub e_z: f64,
84}
85
86impl LineOfSight {
87 pub const fn new(e_x: f64, e_y: f64, e_z: f64) -> Self {
89 Self { e_x, e_y, e_z }
90 }
91
92 fn design_row(self) -> [f64; 4] {
94 [-self.e_x, -self.e_y, -self.e_z, 1.0]
95 }
96}
97
98pub fn line_of_sight_from_az_el_deg(
105 azimuth_deg: f64,
106 elevation_deg: f64,
107 receiver: Wgs84Geodetic,
108) -> Result<LineOfSight, DopError> {
109 validate_az_el_receiver(azimuth_deg, elevation_deg, receiver)?;
110 let az = azimuth_deg * DEG_TO_RAD;
111 let el = elevation_deg * DEG_TO_RAD;
112 let cos_el = el.cos();
113 let east = cos_el * az.sin();
114 let north = cos_el * az.cos();
115 let up = el.sin();
116
117 let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
118 let e_x = r[0][0] * east + r[1][0] * north + r[2][0] * up;
119 let e_y = r[0][1] * east + r[1][1] * north + r[2][1] * up;
120 let e_z = r[0][2] * east + r[1][2] * north + r[2][2] * up;
121 let los = LineOfSight::new(e_x, e_y, e_z);
122 validate_los(core::slice::from_ref(&los))?;
123 Ok(los)
124}
125
126#[derive(Debug, Clone, PartialEq)]
136pub struct Dop {
137 pub gdop: f64,
141 pub pdop: f64,
143 pub hdop: f64,
145 pub vdop: f64,
147 pub tdop: f64,
151 pub system_tdops: Vec<(GnssSystem, f64)>,
164}
165
166#[derive(Debug, Clone, PartialEq)]
173pub struct GeometryCofactor {
174 pub state: [[f64; 4]; 4],
176 pub position_ecef: [[f64; 3]; 3],
178 pub position_enu: [[f64; 3]; 3],
180}
181
182#[derive(Debug, Clone, Copy, PartialEq)]
187pub struct PositionCovariance {
188 pub ecef_m2: [[f64; 3]; 3],
190 pub enu_m2: [[f64; 3]; 3],
192}
193
194#[derive(Debug, Clone, Copy, PartialEq)]
200pub struct HorizontalErrorEllipse {
201 pub confidence: f64,
203 pub chi_square_scale: f64,
205 pub semi_major_m: f64,
207 pub semi_minor_m: f64,
209 pub azimuth_rad: f64,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq)]
220pub struct ErrorEllipse2 {
221 pub confidence: f64,
223 pub chi_square_scale: f64,
226 pub semi_major: f64,
228 pub semi_minor: f64,
230 pub orientation_rad: f64,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum DopError {
238 InvalidInput {
240 field: &'static str,
242 reason: &'static str,
244 },
245 TooFewSatellites,
249 Singular,
252}
253
254impl core::fmt::Display for DopError {
255 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
256 match self {
257 DopError::InvalidInput { field, reason } => {
258 write!(f, "invalid DOP input {field}: {reason}")
259 }
260 DopError::TooFewSatellites => {
261 write!(
262 f,
263 "fewer satellites than parameters: geometry is rank-deficient"
264 )
265 }
266 DopError::Singular => {
267 write!(f, "singular or ill-conditioned geometry: no finite DOP")
268 }
269 }
270 }
271}
272
273impl std::error::Error for DopError {}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
284pub enum EnuConvention {
285 #[default]
288 GeodeticNormal,
289 GeocentricRadial,
292}
293
294fn enu_rotation(
300 receiver: Wgs84Geodetic,
301 convention: EnuConvention,
302) -> Result<[[f64; 3]; 3], DopError> {
303 match convention {
304 EnuConvention::GeodeticNormal => {
305 Ok(ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad))
306 }
307 EnuConvention::GeocentricRadial => {
308 let ecef = crate::frame::geodetic_to_itrf(receiver)
309 .map_err(|_| invalid_input("receiver", "geocentric basis unavailable"))?;
310 let p = ecef.as_array();
311 let radius = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
318 if radius <= GEOCENTRIC_MIN_RADIUS_M {
319 return Err(invalid_input(
320 "receiver",
321 "geocentric up undefined at zero radius",
322 ));
323 }
324 let (north, east, up) = crate::frame::geocentric_neu_basis(p);
325 Ok([east, north, up])
326 }
327 }
328}
329
330fn ecef_to_enu_rotation(lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
332 let sphi = lat_rad.sin();
333 let cphi = lat_rad.cos();
334 let slam = lon_rad.sin();
335 let clam = lon_rad.cos();
336 [
337 [-slam, clam, 0.0],
338 [-sphi * clam, -sphi * slam, cphi],
339 [cphi * clam, cphi * slam, sphi],
340 ]
341}
342
343fn rotate_pos_block(q: &[[f64; 4]; 4], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
347 let qpos = [
348 [q[0][0], q[0][1], q[0][2]],
349 [q[1][0], q[1][1], q[1][2]],
350 [q[2][0], q[2][1], q[2][2]],
351 ];
352 rotate3(&qpos, r)
353}
354
355pub fn dop(los: &[LineOfSight], weights: &[f64], receiver: Wgs84Geodetic) -> Result<Dop, DopError> {
364 dop_with_convention(los, weights, receiver, EnuConvention::GeodeticNormal)
365}
366
367pub fn dop_with_convention(
375 los: &[LineOfSight],
376 weights: &[f64],
377 receiver: Wgs84Geodetic,
378 convention: EnuConvention,
379) -> Result<Dop, DopError> {
380 validate_dop_inputs(los, weights, receiver)?;
381 if los.len() < 4 {
382 return Err(DopError::TooFewSatellites);
383 }
384
385 let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
386 let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
387 let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
388
389 let r = enu_rotation(receiver, convention)?;
390 let enu = rotate_pos_block(&q, &r);
391
392 let qe = enu[0][0];
393 let qn = enu[1][1];
394 let qu = enu[2][2];
395 let qt = q[3][3];
396
397 let gdop_arg = q[0][0] + q[1][1] + q[2][2] + q[3][3];
405 let pdop_arg = qe + qn + qu;
406 let hdop_arg = qe + qn;
407 let vdop_arg = qu;
408 let tdop_arg = qt;
409 for arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg] {
410 #[allow(clippy::neg_cmp_op_on_partial_ord)]
412 let nonpositive_or_nan = !(arg >= 0.0);
413 if nonpositive_or_nan || !arg.is_finite() {
414 return Err(DopError::Singular);
415 }
416 }
417
418 Ok(Dop {
419 gdop: gdop_arg.sqrt(),
420 pdop: pdop_arg.sqrt(),
421 hdop: hdop_arg.sqrt(),
422 vdop: vdop_arg.sqrt(),
423 tdop: tdop_arg.sqrt(),
424 system_tdops: Vec::new(),
429 })
430}
431
432pub fn geometry_cofactor(
438 los: &[LineOfSight],
439 weights: &[f64],
440 receiver: Wgs84Geodetic,
441) -> Result<GeometryCofactor, DopError> {
442 geometry_cofactor_with_convention(los, weights, receiver, EnuConvention::GeodeticNormal)
443}
444
445pub fn geometry_cofactor_with_convention(
452 los: &[LineOfSight],
453 weights: &[f64],
454 receiver: Wgs84Geodetic,
455 convention: EnuConvention,
456) -> Result<GeometryCofactor, DopError> {
457 validate_dop_inputs(los, weights, receiver)?;
458 if los.len() < 4 {
459 return Err(DopError::TooFewSatellites);
460 }
461
462 let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
463 let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
464 let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
465 validate_cofactor_variances(&q)?;
466
467 let r = enu_rotation(receiver, convention)?;
468 let enu = rotate_pos_block(&q, &r);
469 validate_matrix3(&enu, "position_enu")?;
470 Ok(GeometryCofactor {
471 state: q,
472 position_ecef: position_block(&q),
473 position_enu: enu,
474 })
475}
476
477pub fn position_covariance_from_geometry_m2(
483 los: &[LineOfSight],
484 weights: &[f64],
485 receiver: Wgs84Geodetic,
486 range_variance_scale_m2: f64,
487) -> Result<PositionCovariance, DopError> {
488 validate_variance_scale(range_variance_scale_m2)?;
489 let cofactor = geometry_cofactor(los, weights, receiver)?;
490 Ok(PositionCovariance {
491 ecef_m2: scale_matrix3(cofactor.position_ecef, range_variance_scale_m2),
492 enu_m2: scale_matrix3(cofactor.position_enu, range_variance_scale_m2),
493 })
494}
495
496pub fn horizontal_error_ellipse(
502 covariance_enu_m2: [[f64; 3]; 3],
503 confidence: f64,
504) -> Result<HorizontalErrorEllipse, DopError> {
505 validate_matrix3(&covariance_enu_m2, "covariance_enu_m2")?;
506 let en_block = [
507 [covariance_enu_m2[0][0], covariance_enu_m2[0][1]],
508 [covariance_enu_m2[1][0], covariance_enu_m2[1][1]],
509 ];
510 let ellipse = error_ellipse_2x2(en_block, confidence).map_err(|err| match err {
511 DopError::InvalidInput {
514 field: "covariance",
515 reason,
516 } => invalid_input("covariance_enu_m2", reason),
517 other => other,
518 })?;
519 Ok(HorizontalErrorEllipse {
520 confidence: ellipse.confidence,
521 chi_square_scale: ellipse.chi_square_scale,
522 semi_major_m: ellipse.semi_major,
523 semi_minor_m: ellipse.semi_minor,
524 azimuth_rad: ellipse.orientation_rad,
525 })
526}
527
528pub fn error_ellipse_2x2(
537 covariance: [[f64; 2]; 2],
538 confidence: f64,
539) -> Result<ErrorEllipse2, DopError> {
540 for row in &covariance {
541 validate::finite_slice(row, "covariance").map_err(map_validation_error)?;
542 }
543 validate_confidence(confidence)?;
544
545 let a = covariance[0][0];
546 let b = 0.5 * (covariance[0][1] + covariance[1][0]);
547 let c = covariance[1][1];
548 let half_delta = 0.5 * (a - c);
549 let center = 0.5 * (a + c);
550 let root = (half_delta * half_delta + b * b).sqrt();
551 let lambda_major = center + root;
552 let lambda_minor = center - root;
553 if !lambda_major.is_finite() || !lambda_minor.is_finite() || lambda_minor < -1.0e-12 {
554 return Err(invalid_input("covariance", "not positive semidefinite"));
555 }
556
557 let chi_square_scale = -2.0 * (1.0 - confidence).ln();
558 let semi_major = (lambda_major.max(0.0) * chi_square_scale).sqrt();
559 let semi_minor = (lambda_minor.max(0.0) * chi_square_scale).sqrt();
560 let orientation_rad = if root == 0.0 {
561 0.0
562 } else {
563 0.5 * (2.0 * b).atan2(a - c)
564 };
565 Ok(ErrorEllipse2 {
566 confidence,
567 chi_square_scale,
568 semi_major,
569 semi_minor,
570 orientation_rad,
571 })
572}
573
574pub fn error_ellipse_from_geometry(
576 los: &[LineOfSight],
577 weights: &[f64],
578 receiver: Wgs84Geodetic,
579 range_variance_scale_m2: f64,
580 confidence: f64,
581) -> Result<HorizontalErrorEllipse, DopError> {
582 let covariance =
583 position_covariance_from_geometry_m2(los, weights, receiver, range_variance_scale_m2)?;
584 horizontal_error_ellipse(covariance.enu_m2, confidence)
585}
586
587fn validate_dop_inputs(
588 los: &[LineOfSight],
589 weights: &[f64],
590 receiver: Wgs84Geodetic,
591) -> Result<(), DopError> {
592 if los.len() != weights.len() {
593 return Err(invalid_input("weights", "length must match los"));
594 }
595 validate_los(los)?;
596 validate_weights(weights)?;
597 validate_receiver(receiver)
598}
599
600fn validate_los(los: &[LineOfSight]) -> Result<(), DopError> {
601 for line in los {
602 if !(line.e_x.is_finite() && line.e_y.is_finite() && line.e_z.is_finite()) {
603 return Err(invalid_input("los", "not finite"));
604 }
605 let norm = (line.e_x * line.e_x + line.e_y * line.e_y + line.e_z * line.e_z).sqrt();
606 if !norm.is_finite() {
607 return Err(invalid_input("los", "not finite"));
608 }
609 if (norm - 1.0).abs() > LOS_UNIT_TOLERANCE {
610 return Err(invalid_input("los", "not unit length"));
611 }
612 }
613 Ok(())
614}
615
616fn validate_cofactor_variances(q: &[[f64; 4]; 4]) -> Result<(), DopError> {
617 for row in q {
618 validate::finite_slice(row, "cofactor").map_err(map_validation_error)?;
619 }
620 for (idx, row) in q.iter().enumerate() {
621 let variance = row[idx];
622 #[allow(clippy::neg_cmp_op_on_partial_ord)]
623 let negative_or_nan = !(variance >= 0.0);
624 if negative_or_nan || !variance.is_finite() {
625 return Err(DopError::Singular);
626 }
627 }
628 Ok(())
629}
630
631fn validate_variance_scale(value: f64) -> Result<(), DopError> {
632 if !value.is_finite() {
633 return Err(invalid_input("range_variance_scale_m2", "not finite"));
634 }
635 if value < 0.0 {
636 return Err(invalid_input("range_variance_scale_m2", "negative"));
637 }
638 Ok(())
639}
640
641fn validate_confidence(value: f64) -> Result<(), DopError> {
642 if !value.is_finite() {
643 return Err(invalid_input("confidence", "not finite"));
644 }
645 if !(0.0..1.0).contains(&value) {
646 return Err(invalid_input("confidence", "out of range"));
647 }
648 Ok(())
649}
650
651fn validate_matrix3(matrix: &[[f64; 3]; 3], field: &'static str) -> Result<(), DopError> {
652 for row in matrix {
653 validate::finite_slice(row, field).map_err(map_validation_error)?;
654 }
655 Ok(())
656}
657
658fn position_block(q: &[[f64; 4]; 4]) -> [[f64; 3]; 3] {
659 [
660 [q[0][0], q[0][1], q[0][2]],
661 [q[1][0], q[1][1], q[1][2]],
662 [q[2][0], q[2][1], q[2][2]],
663 ]
664}
665
666fn scale_matrix3(matrix: [[f64; 3]; 3], scale: f64) -> [[f64; 3]; 3] {
667 [
668 [
669 matrix[0][0] * scale,
670 matrix[0][1] * scale,
671 matrix[0][2] * scale,
672 ],
673 [
674 matrix[1][0] * scale,
675 matrix[1][1] * scale,
676 matrix[1][2] * scale,
677 ],
678 [
679 matrix[2][0] * scale,
680 matrix[2][1] * scale,
681 matrix[2][2] * scale,
682 ],
683 ]
684}
685
686fn validate_weights(weights: &[f64]) -> Result<(), DopError> {
687 if weights.iter().any(|weight| !weight.is_finite()) {
688 return Err(invalid_input("weights", "not finite"));
689 }
690 if weights.iter().any(|&weight| weight < 0.0) {
691 return Err(invalid_input("weights", "negative"));
692 }
693 Ok(())
694}
695
696fn validate_receiver(receiver: Wgs84Geodetic) -> Result<(), DopError> {
697 if !(receiver.lat_rad.is_finite()
698 && receiver.lon_rad.is_finite()
699 && receiver.height_m.is_finite())
700 {
701 return Err(invalid_input("receiver", "not finite"));
702 }
703 if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
704 return Err(invalid_input("receiver.lat_rad", "out of range"));
705 }
706 if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
707 return Err(invalid_input("receiver.lon_rad", "out of range"));
708 }
709 Ok(())
710}
711
712fn validate_az_el_receiver(
713 azimuth_deg: f64,
714 elevation_deg: f64,
715 receiver: Wgs84Geodetic,
716) -> Result<(), DopError> {
717 if !azimuth_deg.is_finite() {
718 return Err(invalid_input("azimuth_deg", "not finite"));
719 }
720 if !elevation_deg.is_finite() {
721 return Err(invalid_input("elevation_deg", "not finite"));
722 }
723 if !(-90.0..=90.0).contains(&elevation_deg) {
724 return Err(invalid_input("elevation_deg", "out of range"));
725 }
726 validate_receiver(receiver)
727}
728
729fn invalid_input(field: &'static str, reason: &'static str) -> DopError {
730 DopError::InvalidInput { field, reason }
731}
732
733fn map_linear_error(error: LinearError) -> DopError {
734 match error {
735 LinearError::InvalidInput { field, reason } => invalid_input(field, reason),
736 }
737}
738
739fn map_validation_error(error: validate::FieldError) -> DopError {
740 invalid_input(error.field(), error.reason())
741}
742
743fn rotate3(q: &[[f64; 3]; 3], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
749 inline_rxr(&inline_rxr(r, q), &inline_tr(r))
750}
751
752pub(crate) fn dop_multi(
777 los: &[LineOfSight],
778 clock_index: &[usize],
779 systems: &[GnssSystem],
780 n_clocks: usize,
781 weights: &[f64],
782 receiver: Wgs84Geodetic,
783) -> Result<Dop, DopError> {
784 validate_dop_multi_inputs(los, clock_index, systems, n_clocks, weights, receiver)?;
785 let p = 3 + n_clocks;
786 if los.len() < p {
787 return Err(DopError::TooFewSatellites);
788 }
789
790 let mut a = vec![vec![0.0_f64; p]; p];
791 for k in 0..los.len() {
792 let mut row = vec![0.0_f64; p];
793 row[0] = -los[k].e_x;
794 row[1] = -los[k].e_y;
795 row[2] = -los[k].e_z;
796 row[3 + clock_index[k]] = 1.0;
797 let w = weights[k];
798 #[allow(clippy::needless_range_loop)]
799 for i in 0..p {
800 for j in 0..p {
801 a[i][j] += row[i] * w * row[j];
802 }
803 }
804 }
805 let q = invert_symmetric_pd(&a).ok_or(DopError::Singular)?;
806
807 let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
808 let qpos = [
809 [q[0][0], q[0][1], q[0][2]],
810 [q[1][0], q[1][1], q[1][2]],
811 [q[2][0], q[2][1], q[2][2]],
812 ];
813 let enu = rotate3(&qpos, &r);
814
815 let qe = enu[0][0];
816 let qn = enu[1][1];
817 let qu = enu[2][2];
818 let qt = q[3][3];
819 let trace: f64 = (0..p).map(|i| q[i][i]).sum();
820 let system_tdop_args: Vec<f64> = (0..n_clocks).map(|i| q[3 + i][3 + i]).collect();
823
824 let gdop_arg = trace;
825 let pdop_arg = qe + qn + qu;
826 let hdop_arg = qe + qn;
827 let vdop_arg = qu;
828 let tdop_arg = qt;
829 for &arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg]
834 .iter()
835 .chain(system_tdop_args.iter())
836 {
837 #[allow(clippy::neg_cmp_op_on_partial_ord)]
838 let nonpositive_or_nan = !(arg >= 0.0);
839 if nonpositive_or_nan || !arg.is_finite() {
840 return Err(DopError::Singular);
841 }
842 }
843
844 Ok(Dop {
845 gdop: gdop_arg.sqrt(),
846 pdop: pdop_arg.sqrt(),
847 hdop: hdop_arg.sqrt(),
848 vdop: vdop_arg.sqrt(),
849 tdop: tdop_arg.sqrt(),
850 system_tdops: system_tdop_args
851 .iter()
852 .enumerate()
853 .map(|(i, &v)| (systems[i], v.sqrt()))
854 .collect(),
855 })
856}
857
858fn validate_dop_multi_inputs(
859 los: &[LineOfSight],
860 clock_index: &[usize],
861 systems: &[GnssSystem],
862 n_clocks: usize,
863 weights: &[f64],
864 receiver: Wgs84Geodetic,
865) -> Result<(), DopError> {
866 if los.len() != weights.len() {
867 return Err(invalid_input("weights", "length must match los"));
868 }
869 if los.len() != clock_index.len() {
870 return Err(invalid_input("clock_index", "length must match los"));
871 }
872 if n_clocks == 0 {
873 return Err(invalid_input("n_clocks", "not positive"));
874 }
875 if systems.len() != n_clocks {
876 return Err(invalid_input("systems", "length must match n_clocks"));
877 }
878 if clock_index.iter().any(|&idx| idx >= n_clocks) {
879 return Err(invalid_input("clock_index", "out of range"));
880 }
881 validate_los(los)?;
882 validate_weights(weights)?;
883 validate_receiver(receiver)
884}
885
886#[cfg(all(test, sidereon_repo_tests))]
887pub(crate) mod test_support {
888 use super::*;
892
893 pub(crate) fn normal_matrix_for(los: &[LineOfSight], weights: &[f64]) -> [[f64; 4]; 4] {
894 let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
895 normal_matrix_4_weighted_column_outer(&rows, weights).expect("valid DOP test inputs")
896 }
897
898 pub(crate) fn det4_for(a: &[[f64; 4]; 4]) -> f64 {
899 crate::astro::math::linear::det4_cofactor(a)
900 }
901
902 pub(crate) fn inv4_for(a: &[[f64; 4]; 4]) -> Option<[[f64; 4]; 4]> {
903 invert_4x4_cofactor(a)
904 }
905
906 pub(crate) fn enu_block_for(q: &[[f64; 4]; 4], lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
907 let r = ecef_to_enu_rotation(lat_rad, lon_rad);
908 rotate_pos_block(q, &r)
909 }
910}
911
912#[cfg(test)]
913mod public_api_tests {
914 use super::*;
915
916 fn receiver() -> Wgs84Geodetic {
917 Wgs84Geodetic::new(45.0_f64.to_radians(), -75.0_f64.to_radians(), 100.0)
918 .expect("valid receiver")
919 }
920
921 fn sample_geometry() -> (Vec<LineOfSight>, Vec<f64>, Wgs84Geodetic) {
922 let rx = receiver();
923 let az_el = [
924 (5.0, 25.0),
925 (80.0, 35.0),
926 (155.0, 55.0),
927 (235.0, 40.0),
928 (310.0, 65.0),
929 ];
930 let los = az_el
931 .into_iter()
932 .map(|(az, el)| line_of_sight_from_az_el_deg(az, el, rx).expect("valid LOS"))
933 .collect::<Vec<_>>();
934 let weights = vec![1.0, 0.8, 1.4, 0.9, 1.1];
935 (los, weights, rx)
936 }
937
938 #[test]
939 fn geometry_cofactor_exposes_the_dop_position_block() {
940 let (los, weights, rx) = sample_geometry();
941 let d = dop(&los, &weights, rx).expect("DOP");
942 let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
943
944 let qe = q.position_enu[0][0];
945 let qn = q.position_enu[1][1];
946 let qu = q.position_enu[2][2];
947 assert_eq!(d.pdop.to_bits(), (qe + qn + qu).sqrt().to_bits());
948 assert_eq!(d.hdop.to_bits(), (qe + qn).sqrt().to_bits());
949 assert_eq!(d.vdop.to_bits(), qu.sqrt().to_bits());
950 assert_eq!(d.tdop.to_bits(), q.state[3][3].sqrt().to_bits());
951 }
952
953 #[test]
954 fn position_covariance_scales_the_raw_cofactor() {
955 let (los, weights, rx) = sample_geometry();
956 let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
957 let cov =
958 position_covariance_from_geometry_m2(&los, &weights, rx, 4.0).expect("covariance");
959 for i in 0..3 {
960 for j in 0..3 {
961 assert_eq!(
962 cov.ecef_m2[i][j].to_bits(),
963 (q.position_ecef[i][j] * 4.0).to_bits()
964 );
965 assert_eq!(
966 cov.enu_m2[i][j].to_bits(),
967 (q.position_enu[i][j] * 4.0).to_bits()
968 );
969 }
970 }
971 }
972
973 #[test]
974 fn horizontal_error_ellipse_uses_chi_square_two_dof_scale() {
975 let covariance = [[9.0, 2.0, 0.0], [2.0, 4.0, 0.0], [0.0, 0.0, 16.0]];
976 let ellipse = horizontal_error_ellipse(covariance, 0.95).expect("ellipse");
977 let expected_scale = -2.0 * (1.0_f64 - 0.95).ln();
978 assert_eq!(ellipse.chi_square_scale.to_bits(), expected_scale.to_bits());
979 assert!(ellipse.semi_major_m >= ellipse.semi_minor_m);
980 assert!(ellipse.semi_minor_m > 0.0);
981 assert!(ellipse.azimuth_rad.is_finite());
982 }
983
984 #[test]
985 fn error_ellipse_2x2_matches_numpy_eigh() {
986 let ellipse = error_ellipse_2x2([[9.0, 2.0], [2.0, 4.0]], 0.95).expect("ellipse");
988 assert!((ellipse.chi_square_scale - 5.99146454710798).abs() < 1e-12);
989 assert!((ellipse.semi_major - 7.6240780089041085).abs() < 1e-12);
990 assert!((ellipse.semi_minor - 4.445500379771495).abs() < 1e-12);
991 assert!((ellipse.orientation_rad - 0.3373704711117763).abs() < 1e-12);
992 }
993
994 #[test]
995 fn horizontal_error_ellipse_delegates_to_2x2_primitive() {
996 let cov3 = [[9.0, 2.0, 1.0], [2.0, 4.0, -3.0], [1.0, -3.0, 16.0]];
999 let wrapper = horizontal_error_ellipse(cov3, 0.95).expect("wrapper");
1000 let primitive =
1001 error_ellipse_2x2([[cov3[0][0], cov3[0][1]], [cov3[1][0], cov3[1][1]]], 0.95)
1002 .expect("primitive");
1003 assert_eq!(
1004 wrapper.semi_major_m.to_bits(),
1005 primitive.semi_major.to_bits()
1006 );
1007 assert_eq!(
1008 wrapper.semi_minor_m.to_bits(),
1009 primitive.semi_minor.to_bits()
1010 );
1011 assert_eq!(
1012 wrapper.azimuth_rad.to_bits(),
1013 primitive.orientation_rad.to_bits()
1014 );
1015 }
1016
1017 #[test]
1018 fn geocentric_convention_changes_only_horizontal_vertical_split() {
1019 let (los, weights, rx) = sample_geometry();
1020 let geodetic = dop(&los, &weights, rx).expect("geodetic DOP");
1021 let geocentric = dop_with_convention(&los, &weights, rx, EnuConvention::GeocentricRadial)
1022 .expect("geocentric DOP");
1023
1024 assert_eq!(geodetic.gdop.to_bits(), geocentric.gdop.to_bits());
1027 assert_eq!(geodetic.tdop.to_bits(), geocentric.tdop.to_bits());
1028
1029 assert!((geodetic.pdop - geocentric.pdop).abs() < 1e-9 * geodetic.pdop);
1031
1032 let hdop_rel = (geodetic.hdop - geocentric.hdop).abs() / geodetic.hdop;
1035 assert!(hdop_rel > 0.0, "convention must change HDOP");
1036 assert!(
1037 hdop_rel < 1e-2,
1038 "HDOP shift {hdop_rel} larger than expected"
1039 );
1040 assert_ne!(geodetic.vdop.to_bits(), geocentric.vdop.to_bits());
1041 }
1042
1043 #[test]
1044 fn geocentric_convention_rejects_zero_radius_receiver() {
1045 let geocenter = Wgs84Geodetic::new(0.0, 0.0, -crate::astro::constants::earth::WGS84_A_M)
1050 .expect("valid geodetic receiver");
1051 let (los, weights, _) = sample_geometry();
1052 let err = dop_with_convention(&los, &weights, geocenter, EnuConvention::GeocentricRadial)
1053 .expect_err("zero-radius geocentric up must be rejected");
1054 assert!(matches!(
1055 err,
1056 DopError::InvalidInput {
1057 field: "receiver",
1058 ..
1059 }
1060 ));
1061
1062 assert!(
1065 dop_with_convention(&los, &weights, geocenter, EnuConvention::GeodeticNormal).is_ok()
1066 );
1067 }
1068
1069 #[test]
1070 fn default_dop_equals_explicit_geodetic_convention_bit_for_bit() {
1071 let (los, weights, rx) = sample_geometry();
1072 let default = dop(&los, &weights, rx).expect("default");
1073 let explicit =
1074 dop_with_convention(&los, &weights, rx, EnuConvention::GeodeticNormal).expect("geo");
1075 assert_eq!(default.hdop.to_bits(), explicit.hdop.to_bits());
1076 assert_eq!(default.vdop.to_bits(), explicit.vdop.to_bits());
1077 assert_eq!(default.pdop.to_bits(), explicit.pdop.to_bits());
1078 }
1079}
1080
1081#[cfg(all(test, sidereon_repo_tests))]
1082mod tests;