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::integrity::{self, IntegrityError};
62use crate::validate;
63
64pub use crate::integrity::ErrorEllipse2;
65
66const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
67const LOS_UNIT_TOLERANCE: f64 = 1.0e-3;
68const GEOCENTRIC_MIN_RADIUS_M: f64 = 1.0;
72
73#[derive(Debug, Clone, Copy, PartialEq)]
80pub struct LineOfSight {
81 pub e_x: f64,
83 pub e_y: f64,
85 pub e_z: f64,
87}
88
89impl LineOfSight {
90 pub const fn new(e_x: f64, e_y: f64, e_z: f64) -> Self {
92 Self { e_x, e_y, e_z }
93 }
94
95 fn design_row(self) -> [f64; 4] {
97 [-self.e_x, -self.e_y, -self.e_z, 1.0]
98 }
99}
100
101pub fn line_of_sight_from_az_el_deg(
108 azimuth_deg: f64,
109 elevation_deg: f64,
110 receiver: Wgs84Geodetic,
111) -> Result<LineOfSight, DopError> {
112 validate_az_el_receiver(azimuth_deg, elevation_deg, receiver)?;
113 let az = azimuth_deg * DEG_TO_RAD;
114 let el = elevation_deg * DEG_TO_RAD;
115 let cos_el = el.cos();
116 let east = cos_el * az.sin();
117 let north = cos_el * az.cos();
118 let up = el.sin();
119
120 let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
121 let e_x = r[0][0] * east + r[1][0] * north + r[2][0] * up;
122 let e_y = r[0][1] * east + r[1][1] * north + r[2][1] * up;
123 let e_z = r[0][2] * east + r[1][2] * north + r[2][2] * up;
124 let los = LineOfSight::new(e_x, e_y, e_z);
125 validate_los(core::slice::from_ref(&los))?;
126 Ok(los)
127}
128
129#[derive(Debug, Clone, PartialEq)]
143pub struct Dop {
144 pub gdop: f64,
148 pub pdop: f64,
150 pub hdop: f64,
152 pub vdop: f64,
154 pub tdop: f64,
158 pub system_tdops: Vec<(GnssSystem, f64)>,
171}
172
173#[derive(Debug, Clone, PartialEq)]
180pub struct GeometryCofactor {
181 pub state: [[f64; 4]; 4],
183 pub position_ecef: [[f64; 3]; 3],
185 pub position_enu: [[f64; 3]; 3],
187}
188
189#[derive(Debug, Clone, PartialEq)]
195pub struct DesignGeometryCofactor {
196 pub state: Vec<Vec<f64>>,
198 pub position: [[f64; 3]; 3],
200 pub position_rotated: [[f64; 3]; 3],
202}
203
204#[derive(Debug, Clone, Copy, PartialEq)]
209pub struct PositionCovariance {
210 pub ecef_m2: [[f64; 3]; 3],
212 pub enu_m2: [[f64; 3]; 3],
214}
215
216#[derive(Debug, Clone, Copy, PartialEq)]
222pub struct HorizontalErrorEllipse {
223 pub confidence: f64,
225 pub chi_square_scale: f64,
227 pub semi_major_m: f64,
229 pub semi_minor_m: f64,
231 pub azimuth_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
330pub fn ecef_to_enu_rotation(lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
335 let sphi = lat_rad.sin();
336 let cphi = lat_rad.cos();
337 let slam = lon_rad.sin();
338 let clam = lon_rad.cos();
339 [
340 [-slam, clam, 0.0],
341 [-sphi * clam, -sphi * slam, cphi],
342 [cphi * clam, cphi * slam, sphi],
343 ]
344}
345
346fn rotate_pos_block(q: &[[f64; 4]; 4], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
350 let qpos = [
351 [q[0][0], q[0][1], q[0][2]],
352 [q[1][0], q[1][1], q[1][2]],
353 [q[2][0], q[2][1], q[2][2]],
354 ];
355 rotate3(&qpos, r)
356}
357
358pub fn dop(los: &[LineOfSight], weights: &[f64], receiver: Wgs84Geodetic) -> Result<Dop, DopError> {
367 dop_with_convention(los, weights, receiver, EnuConvention::GeodeticNormal)
368}
369
370pub fn dop_with_convention(
378 los: &[LineOfSight],
379 weights: &[f64],
380 receiver: Wgs84Geodetic,
381 convention: EnuConvention,
382) -> Result<Dop, DopError> {
383 validate_dop_inputs(los, weights, receiver)?;
384 if los.len() < 4 {
385 return Err(DopError::TooFewSatellites);
386 }
387
388 let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
389 let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
390 let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
391
392 let r = enu_rotation(receiver, convention)?;
393 let enu = rotate_pos_block(&q, &r);
394
395 let qe = enu[0][0];
396 let qn = enu[1][1];
397 let qu = enu[2][2];
398 let qt = q[3][3];
399
400 let gdop_arg = q[0][0] + q[1][1] + q[2][2] + q[3][3];
408 let pdop_arg = qe + qn + qu;
409 let hdop_arg = qe + qn;
410 let vdop_arg = qu;
411 let tdop_arg = qt;
412 for arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg] {
413 #[allow(clippy::neg_cmp_op_on_partial_ord)]
415 let nonpositive_or_nan = !(arg >= 0.0);
416 if nonpositive_or_nan || !arg.is_finite() {
417 return Err(DopError::Singular);
418 }
419 }
420
421 Ok(Dop {
422 gdop: gdop_arg.sqrt(),
423 pdop: pdop_arg.sqrt(),
424 hdop: hdop_arg.sqrt(),
425 vdop: vdop_arg.sqrt(),
426 tdop: tdop_arg.sqrt(),
427 system_tdops: Vec::new(),
432 })
433}
434
435pub fn geometry_cofactor(
441 los: &[LineOfSight],
442 weights: &[f64],
443 receiver: Wgs84Geodetic,
444) -> Result<GeometryCofactor, DopError> {
445 geometry_cofactor_with_convention(los, weights, receiver, EnuConvention::GeodeticNormal)
446}
447
448pub fn geometry_cofactor_with_convention(
455 los: &[LineOfSight],
456 weights: &[f64],
457 receiver: Wgs84Geodetic,
458 convention: EnuConvention,
459) -> Result<GeometryCofactor, DopError> {
460 validate_dop_inputs(los, weights, receiver)?;
461 if los.len() < 4 {
462 return Err(DopError::TooFewSatellites);
463 }
464
465 let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
466 let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
467 let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
468 validate_cofactor_variances(&q)?;
469
470 let r = enu_rotation(receiver, convention)?;
471 let enu = rotate_pos_block(&q, &r);
472 validate_matrix3(&enu, "position_enu")?;
473 Ok(GeometryCofactor {
474 state: q,
475 position_ecef: position_block(&q),
476 position_enu: enu,
477 })
478}
479
480pub fn dop_from_design_rows(
489 rows: &[Vec<f64>],
490 weights: &[f64],
491 position_dimension: usize,
492 position_rotation: [[f64; 3]; 3],
493) -> Result<Dop, DopError> {
494 let cofactor =
495 geometry_cofactor_from_design_rows(rows, weights, position_dimension, position_rotation)?;
496 let time_col = position_dimension;
497 let q = &cofactor.state;
498 let rotated = cofactor.position_rotated;
499 let qe = rotated[0][0];
500 let qn = rotated[1][1];
501 let qu = rotated[2][2];
502 let qt = q[time_col][time_col];
503 let trace: f64 = (0..q.len()).map(|i| q[i][i]).sum();
504
505 let gdop_arg = trace;
506 let pdop_arg = qe + qn + qu;
507 let hdop_arg = qe + qn;
508 let vdop_arg = qu;
509 let tdop_arg = qt;
510 for arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg] {
511 #[allow(clippy::neg_cmp_op_on_partial_ord)]
512 let negative_or_nan = !(arg >= 0.0);
513 if negative_or_nan || !arg.is_finite() {
514 return Err(DopError::Singular);
515 }
516 }
517
518 Ok(Dop {
519 gdop: gdop_arg.sqrt(),
520 pdop: pdop_arg.sqrt(),
521 hdop: hdop_arg.sqrt(),
522 vdop: vdop_arg.sqrt(),
523 tdop: tdop_arg.sqrt(),
524 system_tdops: Vec::new(),
525 })
526}
527
528pub fn geometry_cofactor_from_design_rows(
535 rows: &[Vec<f64>],
536 weights: &[f64],
537 position_dimension: usize,
538 position_rotation: [[f64; 3]; 3],
539) -> Result<DesignGeometryCofactor, DopError> {
540 validate_design_rows(rows, weights, position_dimension, &position_rotation)?;
541 let p = position_dimension + 1;
542 if rows.len() < p {
543 return Err(DopError::TooFewSatellites);
544 }
545
546 let q = if p == 4 {
547 let fixed_rows: Vec<[f64; 4]> = rows
548 .iter()
549 .map(|row| [row[0], row[1], row[2], row[3]])
550 .collect();
551 let normal = normal_matrix_4_weighted_column_outer(&fixed_rows, weights)
552 .map_err(map_linear_error)?;
553 let fixed = invert_4x4_cofactor(&normal).ok_or(DopError::Singular)?;
554 fixed.iter().map(|row| row.to_vec()).collect()
555 } else {
556 let mut normal = vec![vec![0.0_f64; p]; p];
557 for (row, &weight) in rows.iter().zip(weights) {
558 for i in 0..p {
559 for j in 0..p {
560 normal[i][j] += row[i] * weight * row[j];
561 }
562 }
563 }
564 invert_symmetric_pd(&normal).ok_or(DopError::Singular)?
565 };
566 validate_general_cofactor_variances(&q)?;
567
568 let mut position = [[0.0_f64; 3]; 3];
569 for i in 0..position_dimension {
570 for j in 0..position_dimension {
571 position[i][j] = q[i][j];
572 }
573 }
574 let position_rotated = rotate3(&position, &position_rotation);
575 validate_matrix3(&position_rotated, "position_rotated")?;
576
577 Ok(DesignGeometryCofactor {
578 state: q,
579 position,
580 position_rotated,
581 })
582}
583
584pub fn position_covariance_from_geometry_m2(
590 los: &[LineOfSight],
591 weights: &[f64],
592 receiver: Wgs84Geodetic,
593 range_variance_scale_m2: f64,
594) -> Result<PositionCovariance, DopError> {
595 validate_variance_scale(range_variance_scale_m2)?;
596 let cofactor = geometry_cofactor(los, weights, receiver)?;
597 Ok(PositionCovariance {
598 ecef_m2: scale_matrix3(cofactor.position_ecef, range_variance_scale_m2),
599 enu_m2: scale_matrix3(cofactor.position_enu, range_variance_scale_m2),
600 })
601}
602
603pub fn rotate_covariance_ecef_to_enu_m2(
608 covariance_ecef_m2: [[f64; 3]; 3],
609 receiver: Wgs84Geodetic,
610) -> Result<[[f64; 3]; 3], DopError> {
611 validate_matrix3(&covariance_ecef_m2, "covariance_ecef_m2")?;
612 validate_receiver(receiver)?;
613 let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
614 Ok(rotate3(&covariance_ecef_m2, &r))
615}
616
617pub fn horizontal_error_ellipse(
623 covariance_enu_m2: [[f64; 3]; 3],
624 confidence: f64,
625) -> Result<HorizontalErrorEllipse, DopError> {
626 validate_matrix3(&covariance_enu_m2, "covariance_enu_m2")?;
627 let en_block = [
628 [covariance_enu_m2[0][0], covariance_enu_m2[0][1]],
629 [covariance_enu_m2[1][0], covariance_enu_m2[1][1]],
630 ];
631 let ellipse = error_ellipse_2x2(en_block, confidence).map_err(|err| match err {
632 DopError::InvalidInput {
635 field: "covariance",
636 reason,
637 } => invalid_input("covariance_enu_m2", reason),
638 other => other,
639 })?;
640 Ok(HorizontalErrorEllipse {
641 confidence: ellipse.confidence,
642 chi_square_scale: ellipse.chi_square_scale,
643 semi_major_m: ellipse.semi_major,
644 semi_minor_m: ellipse.semi_minor,
645 azimuth_rad: ellipse.orientation_rad,
646 })
647}
648
649pub fn error_ellipse_2x2(
658 covariance: [[f64; 2]; 2],
659 confidence: f64,
660) -> Result<ErrorEllipse2, DopError> {
661 integrity::error_ellipse_2x2(covariance, confidence).map_err(map_integrity_error)
662}
663
664pub fn error_ellipse_2x2_unit(covariance: [[f64; 2]; 2]) -> Result<ErrorEllipse2, DopError> {
669 integrity::error_ellipse_2x2_unit(covariance).map_err(map_integrity_error)
670}
671
672pub fn error_ellipse_from_geometry(
674 los: &[LineOfSight],
675 weights: &[f64],
676 receiver: Wgs84Geodetic,
677 range_variance_scale_m2: f64,
678 confidence: f64,
679) -> Result<HorizontalErrorEllipse, DopError> {
680 let covariance =
681 position_covariance_from_geometry_m2(los, weights, receiver, range_variance_scale_m2)?;
682 horizontal_error_ellipse(covariance.enu_m2, confidence)
683}
684
685fn validate_dop_inputs(
686 los: &[LineOfSight],
687 weights: &[f64],
688 receiver: Wgs84Geodetic,
689) -> Result<(), DopError> {
690 if los.len() != weights.len() {
691 return Err(invalid_input("weights", "length must match los"));
692 }
693 validate_los(los)?;
694 validate_weights(weights)?;
695 validate_receiver(receiver)
696}
697
698fn validate_los(los: &[LineOfSight]) -> Result<(), DopError> {
699 for line in los {
700 if !(line.e_x.is_finite() && line.e_y.is_finite() && line.e_z.is_finite()) {
701 return Err(invalid_input("los", "not finite"));
702 }
703 let norm = (line.e_x * line.e_x + line.e_y * line.e_y + line.e_z * line.e_z).sqrt();
704 if !norm.is_finite() {
705 return Err(invalid_input("los", "not finite"));
706 }
707 if (norm - 1.0).abs() > LOS_UNIT_TOLERANCE {
708 return Err(invalid_input("los", "not unit length"));
709 }
710 }
711 Ok(())
712}
713
714fn validate_cofactor_variances(q: &[[f64; 4]; 4]) -> Result<(), DopError> {
715 for row in q {
716 validate::finite_slice(row, "cofactor").map_err(map_validation_error)?;
717 }
718 for (idx, row) in q.iter().enumerate() {
719 let variance = row[idx];
720 #[allow(clippy::neg_cmp_op_on_partial_ord)]
721 let negative_or_nan = !(variance >= 0.0);
722 if negative_or_nan || !variance.is_finite() {
723 return Err(DopError::Singular);
724 }
725 }
726 Ok(())
727}
728
729fn validate_variance_scale(value: f64) -> Result<(), DopError> {
730 if !value.is_finite() {
731 return Err(invalid_input("range_variance_scale_m2", "not finite"));
732 }
733 if value < 0.0 {
734 return Err(invalid_input("range_variance_scale_m2", "negative"));
735 }
736 Ok(())
737}
738
739fn validate_matrix3(matrix: &[[f64; 3]; 3], field: &'static str) -> Result<(), DopError> {
740 for row in matrix {
741 validate::finite_slice(row, field).map_err(map_validation_error)?;
742 }
743 Ok(())
744}
745
746fn validate_design_rows(
747 rows: &[Vec<f64>],
748 weights: &[f64],
749 position_dimension: usize,
750 position_rotation: &[[f64; 3]; 3],
751) -> Result<(), DopError> {
752 if !(2..=3).contains(&position_dimension) {
753 return Err(invalid_input("position_dimension", "must be 2 or 3"));
754 }
755 if rows.len() != weights.len() {
756 return Err(invalid_input("weights", "length must match rows"));
757 }
758 let p = position_dimension + 1;
759 for row in rows {
760 if row.len() != p {
761 return Err(invalid_input("rows", "length must match state dimension"));
762 }
763 validate::finite_slice(row, "rows").map_err(map_validation_error)?;
764 }
765 validate_weights(weights)?;
766 validate_matrix3(position_rotation, "position_rotation")
767}
768
769fn validate_general_cofactor_variances(q: &[Vec<f64>]) -> Result<(), DopError> {
770 for row in q {
771 validate::finite_slice(row, "cofactor").map_err(map_validation_error)?;
772 }
773 for (idx, row) in q.iter().enumerate() {
774 let variance = row[idx];
775 #[allow(clippy::neg_cmp_op_on_partial_ord)]
776 let negative_or_nan = !(variance >= 0.0);
777 if negative_or_nan || !variance.is_finite() {
778 return Err(DopError::Singular);
779 }
780 }
781 Ok(())
782}
783
784fn position_block(q: &[[f64; 4]; 4]) -> [[f64; 3]; 3] {
785 [
786 [q[0][0], q[0][1], q[0][2]],
787 [q[1][0], q[1][1], q[1][2]],
788 [q[2][0], q[2][1], q[2][2]],
789 ]
790}
791
792fn scale_matrix3(matrix: [[f64; 3]; 3], scale: f64) -> [[f64; 3]; 3] {
793 [
794 [
795 matrix[0][0] * scale,
796 matrix[0][1] * scale,
797 matrix[0][2] * scale,
798 ],
799 [
800 matrix[1][0] * scale,
801 matrix[1][1] * scale,
802 matrix[1][2] * scale,
803 ],
804 [
805 matrix[2][0] * scale,
806 matrix[2][1] * scale,
807 matrix[2][2] * scale,
808 ],
809 ]
810}
811
812fn validate_weights(weights: &[f64]) -> Result<(), DopError> {
813 if weights.iter().any(|weight| !weight.is_finite()) {
814 return Err(invalid_input("weights", "not finite"));
815 }
816 if weights.iter().any(|&weight| weight < 0.0) {
817 return Err(invalid_input("weights", "negative"));
818 }
819 Ok(())
820}
821
822fn validate_receiver(receiver: Wgs84Geodetic) -> Result<(), DopError> {
823 if !(receiver.lat_rad.is_finite()
824 && receiver.lon_rad.is_finite()
825 && receiver.height_m.is_finite())
826 {
827 return Err(invalid_input("receiver", "not finite"));
828 }
829 if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
830 return Err(invalid_input("receiver.lat_rad", "out of range"));
831 }
832 if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
833 return Err(invalid_input("receiver.lon_rad", "out of range"));
834 }
835 Ok(())
836}
837
838fn validate_az_el_receiver(
839 azimuth_deg: f64,
840 elevation_deg: f64,
841 receiver: Wgs84Geodetic,
842) -> Result<(), DopError> {
843 if !azimuth_deg.is_finite() {
844 return Err(invalid_input("azimuth_deg", "not finite"));
845 }
846 if !elevation_deg.is_finite() {
847 return Err(invalid_input("elevation_deg", "not finite"));
848 }
849 if !(-90.0..=90.0).contains(&elevation_deg) {
850 return Err(invalid_input("elevation_deg", "out of range"));
851 }
852 validate_receiver(receiver)
853}
854
855fn invalid_input(field: &'static str, reason: &'static str) -> DopError {
856 DopError::InvalidInput { field, reason }
857}
858
859fn map_linear_error(error: LinearError) -> DopError {
860 match error {
861 LinearError::InvalidInput { field, reason } => invalid_input(field, reason),
862 }
863}
864
865fn map_validation_error(error: validate::FieldError) -> DopError {
866 invalid_input(error.field(), error.reason())
867}
868
869fn map_integrity_error(error: IntegrityError) -> DopError {
870 match error {
871 IntegrityError::NonFinite => invalid_input("covariance", "not finite"),
872 IntegrityError::NotPositiveSemidefinite => {
873 invalid_input("covariance", "not positive semidefinite")
874 }
875 IntegrityError::InvalidProbability { reason } => invalid_input("confidence", reason),
876 IntegrityError::InvalidInput { field, reason } => invalid_input(field, reason),
877 IntegrityError::Singular => DopError::Singular,
878 }
879}
880
881fn rotate3(q: &[[f64; 3]; 3], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
887 inline_rxr(&inline_rxr(r, q), &inline_tr(r))
888}
889
890pub(crate) fn dop_multi(
915 los: &[LineOfSight],
916 clock_index: &[usize],
917 systems: &[GnssSystem],
918 n_clocks: usize,
919 weights: &[f64],
920 receiver: Wgs84Geodetic,
921) -> Result<Dop, DopError> {
922 validate_dop_multi_inputs(los, clock_index, systems, n_clocks, weights, receiver)?;
923 let p = 3 + n_clocks;
924 if los.len() < p {
925 return Err(DopError::TooFewSatellites);
926 }
927
928 let mut a = vec![vec![0.0_f64; p]; p];
929 for k in 0..los.len() {
930 let mut row = vec![0.0_f64; p];
931 row[0] = -los[k].e_x;
932 row[1] = -los[k].e_y;
933 row[2] = -los[k].e_z;
934 row[3 + clock_index[k]] = 1.0;
935 let w = weights[k];
936 #[allow(clippy::needless_range_loop)]
937 for i in 0..p {
938 for j in 0..p {
939 a[i][j] += row[i] * w * row[j];
940 }
941 }
942 }
943 let q = invert_symmetric_pd(&a).ok_or(DopError::Singular)?;
944
945 let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
946 let qpos = [
947 [q[0][0], q[0][1], q[0][2]],
948 [q[1][0], q[1][1], q[1][2]],
949 [q[2][0], q[2][1], q[2][2]],
950 ];
951 let enu = rotate3(&qpos, &r);
952
953 let qe = enu[0][0];
954 let qn = enu[1][1];
955 let qu = enu[2][2];
956 let qt = q[3][3];
957 let trace: f64 = (0..p).map(|i| q[i][i]).sum();
958 let system_tdop_args: Vec<f64> = (0..n_clocks).map(|i| q[3 + i][3 + i]).collect();
961
962 let gdop_arg = trace;
963 let pdop_arg = qe + qn + qu;
964 let hdop_arg = qe + qn;
965 let vdop_arg = qu;
966 let tdop_arg = qt;
967 for &arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg]
972 .iter()
973 .chain(system_tdop_args.iter())
974 {
975 #[allow(clippy::neg_cmp_op_on_partial_ord)]
976 let nonpositive_or_nan = !(arg >= 0.0);
977 if nonpositive_or_nan || !arg.is_finite() {
978 return Err(DopError::Singular);
979 }
980 }
981
982 Ok(Dop {
983 gdop: gdop_arg.sqrt(),
984 pdop: pdop_arg.sqrt(),
985 hdop: hdop_arg.sqrt(),
986 vdop: vdop_arg.sqrt(),
987 tdop: tdop_arg.sqrt(),
988 system_tdops: system_tdop_args
989 .iter()
990 .enumerate()
991 .map(|(i, &v)| (systems[i], v.sqrt()))
992 .collect(),
993 })
994}
995
996fn validate_dop_multi_inputs(
997 los: &[LineOfSight],
998 clock_index: &[usize],
999 systems: &[GnssSystem],
1000 n_clocks: usize,
1001 weights: &[f64],
1002 receiver: Wgs84Geodetic,
1003) -> Result<(), DopError> {
1004 if los.len() != weights.len() {
1005 return Err(invalid_input("weights", "length must match los"));
1006 }
1007 if los.len() != clock_index.len() {
1008 return Err(invalid_input("clock_index", "length must match los"));
1009 }
1010 if n_clocks == 0 {
1011 return Err(invalid_input("n_clocks", "not positive"));
1012 }
1013 if systems.len() != n_clocks {
1014 return Err(invalid_input("systems", "length must match n_clocks"));
1015 }
1016 if clock_index.iter().any(|&idx| idx >= n_clocks) {
1017 return Err(invalid_input("clock_index", "out of range"));
1018 }
1019 validate_los(los)?;
1020 validate_weights(weights)?;
1021 validate_receiver(receiver)
1022}
1023
1024#[cfg(all(test, sidereon_repo_tests))]
1025pub(crate) mod test_support {
1026 use super::*;
1030
1031 pub(crate) fn normal_matrix_for(los: &[LineOfSight], weights: &[f64]) -> [[f64; 4]; 4] {
1032 let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
1033 normal_matrix_4_weighted_column_outer(&rows, weights).expect("valid DOP test inputs")
1034 }
1035
1036 pub(crate) fn det4_for(a: &[[f64; 4]; 4]) -> f64 {
1037 crate::astro::math::linear::det4_cofactor(a)
1038 }
1039
1040 pub(crate) fn inv4_for(a: &[[f64; 4]; 4]) -> Option<[[f64; 4]; 4]> {
1041 invert_4x4_cofactor(a)
1042 }
1043
1044 pub(crate) fn enu_block_for(q: &[[f64; 4]; 4], lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
1045 let r = ecef_to_enu_rotation(lat_rad, lon_rad);
1046 rotate_pos_block(q, &r)
1047 }
1048}
1049
1050#[cfg(test)]
1051mod public_api_tests {
1052 use super::*;
1053
1054 fn receiver() -> Wgs84Geodetic {
1055 Wgs84Geodetic::new(45.0_f64.to_radians(), -75.0_f64.to_radians(), 100.0)
1056 .expect("valid receiver")
1057 }
1058
1059 fn sample_geometry() -> (Vec<LineOfSight>, Vec<f64>, Wgs84Geodetic) {
1060 let rx = receiver();
1061 let az_el = [
1062 (5.0, 25.0),
1063 (80.0, 35.0),
1064 (155.0, 55.0),
1065 (235.0, 40.0),
1066 (310.0, 65.0),
1067 ];
1068 let los = az_el
1069 .into_iter()
1070 .map(|(az, el)| line_of_sight_from_az_el_deg(az, el, rx).expect("valid LOS"))
1071 .collect::<Vec<_>>();
1072 let weights = vec![1.0, 0.8, 1.4, 0.9, 1.1];
1073 (los, weights, rx)
1074 }
1075
1076 #[test]
1077 fn geometry_cofactor_exposes_the_dop_position_block() {
1078 let (los, weights, rx) = sample_geometry();
1079 let d = dop(&los, &weights, rx).expect("DOP");
1080 let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
1081
1082 let qe = q.position_enu[0][0];
1083 let qn = q.position_enu[1][1];
1084 let qu = q.position_enu[2][2];
1085 assert_eq!(d.pdop.to_bits(), (qe + qn + qu).sqrt().to_bits());
1086 assert_eq!(d.hdop.to_bits(), (qe + qn).sqrt().to_bits());
1087 assert_eq!(d.vdop.to_bits(), qu.sqrt().to_bits());
1088 assert_eq!(d.tdop.to_bits(), q.state[3][3].sqrt().to_bits());
1089 }
1090
1091 #[test]
1092 fn position_covariance_scales_the_raw_cofactor() {
1093 let (los, weights, rx) = sample_geometry();
1094 let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
1095 let cov =
1096 position_covariance_from_geometry_m2(&los, &weights, rx, 4.0).expect("covariance");
1097 for i in 0..3 {
1098 for j in 0..3 {
1099 assert_eq!(
1100 cov.ecef_m2[i][j].to_bits(),
1101 (q.position_ecef[i][j] * 4.0).to_bits()
1102 );
1103 assert_eq!(
1104 cov.enu_m2[i][j].to_bits(),
1105 (q.position_enu[i][j] * 4.0).to_bits()
1106 );
1107 }
1108 }
1109 }
1110
1111 #[test]
1112 fn horizontal_error_ellipse_uses_chi_square_two_dof_scale() {
1113 let covariance = [[9.0, 2.0, 0.0], [2.0, 4.0, 0.0], [0.0, 0.0, 16.0]];
1114 let ellipse = horizontal_error_ellipse(covariance, 0.95).expect("ellipse");
1115 let expected_scale = -2.0 * (1.0_f64 - 0.95).ln();
1116 assert_eq!(ellipse.chi_square_scale.to_bits(), expected_scale.to_bits());
1117 assert!(ellipse.semi_major_m >= ellipse.semi_minor_m);
1118 assert!(ellipse.semi_minor_m > 0.0);
1119 assert!(ellipse.azimuth_rad.is_finite());
1120 }
1121
1122 #[test]
1123 fn error_ellipse_2x2_matches_numpy_eigh() {
1124 let ellipse = error_ellipse_2x2([[9.0, 2.0], [2.0, 4.0]], 0.95).expect("ellipse");
1126 assert!((ellipse.chi_square_scale - 5.99146454710798).abs() < 1e-12);
1127 assert!((ellipse.semi_major - 7.6240780089041085).abs() < 1e-12);
1128 assert!((ellipse.semi_minor - 4.445500379771495).abs() < 1e-12);
1129 assert!((ellipse.orientation_rad - 0.3373704711117763).abs() < 1e-12);
1130 }
1131
1132 #[test]
1133 fn horizontal_error_ellipse_delegates_to_2x2_primitive() {
1134 let cov3 = [[9.0, 2.0, 1.0], [2.0, 4.0, -3.0], [1.0, -3.0, 16.0]];
1137 let wrapper = horizontal_error_ellipse(cov3, 0.95).expect("wrapper");
1138 let primitive =
1139 error_ellipse_2x2([[cov3[0][0], cov3[0][1]], [cov3[1][0], cov3[1][1]]], 0.95)
1140 .expect("primitive");
1141 assert_eq!(
1142 wrapper.semi_major_m.to_bits(),
1143 primitive.semi_major.to_bits()
1144 );
1145 assert_eq!(
1146 wrapper.semi_minor_m.to_bits(),
1147 primitive.semi_minor.to_bits()
1148 );
1149 assert_eq!(
1150 wrapper.azimuth_rad.to_bits(),
1151 primitive.orientation_rad.to_bits()
1152 );
1153 }
1154
1155 #[test]
1156 fn geocentric_convention_changes_only_horizontal_vertical_split() {
1157 let (los, weights, rx) = sample_geometry();
1158 let geodetic = dop(&los, &weights, rx).expect("geodetic DOP");
1159 let geocentric = dop_with_convention(&los, &weights, rx, EnuConvention::GeocentricRadial)
1160 .expect("geocentric DOP");
1161
1162 assert_eq!(geodetic.gdop.to_bits(), geocentric.gdop.to_bits());
1165 assert_eq!(geodetic.tdop.to_bits(), geocentric.tdop.to_bits());
1166
1167 assert!((geodetic.pdop - geocentric.pdop).abs() < 1e-9 * geodetic.pdop);
1169
1170 let hdop_rel = (geodetic.hdop - geocentric.hdop).abs() / geodetic.hdop;
1173 assert!(hdop_rel > 0.0, "convention must change HDOP");
1174 assert!(
1175 hdop_rel < 1e-2,
1176 "HDOP shift {hdop_rel} larger than expected"
1177 );
1178 assert_ne!(geodetic.vdop.to_bits(), geocentric.vdop.to_bits());
1179 }
1180
1181 #[test]
1182 fn geocentric_convention_rejects_zero_radius_receiver() {
1183 let geocenter = Wgs84Geodetic::new(0.0, 0.0, -crate::astro::constants::earth::WGS84_A_M)
1188 .expect("valid geodetic receiver");
1189 let (los, weights, _) = sample_geometry();
1190 let err = dop_with_convention(&los, &weights, geocenter, EnuConvention::GeocentricRadial)
1191 .expect_err("zero-radius geocentric up must be rejected");
1192 assert!(matches!(
1193 err,
1194 DopError::InvalidInput {
1195 field: "receiver",
1196 ..
1197 }
1198 ));
1199
1200 assert!(
1203 dop_with_convention(&los, &weights, geocenter, EnuConvention::GeodeticNormal).is_ok()
1204 );
1205 }
1206
1207 #[test]
1208 fn default_dop_equals_explicit_geodetic_convention_bit_for_bit() {
1209 let (los, weights, rx) = sample_geometry();
1210 let default = dop(&los, &weights, rx).expect("default");
1211 let explicit =
1212 dop_with_convention(&los, &weights, rx, EnuConvention::GeodeticNormal).expect("geo");
1213 assert_eq!(default.hdop.to_bits(), explicit.hdop.to_bits());
1214 assert_eq!(default.vdop.to_bits(), explicit.vdop.to_bits());
1215 assert_eq!(default.pdop.to_bits(), explicit.pdop.to_bits());
1216 }
1217}
1218
1219#[cfg(all(test, sidereon_repo_tests))]
1220mod tests;