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(crate) fn 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 dop_from_design_rows(
486 rows: &[Vec<f64>],
487 weights: &[f64],
488 position_dimension: usize,
489 position_rotation: [[f64; 3]; 3],
490) -> Result<Dop, DopError> {
491 let cofactor =
492 geometry_cofactor_from_design_rows(rows, weights, position_dimension, position_rotation)?;
493 let time_col = position_dimension;
494 let q = &cofactor.state;
495 let rotated = cofactor.position_rotated;
496 let qe = rotated[0][0];
497 let qn = rotated[1][1];
498 let qu = rotated[2][2];
499 let qt = q[time_col][time_col];
500 let trace: f64 = (0..q.len()).map(|i| q[i][i]).sum();
501
502 let gdop_arg = trace;
503 let pdop_arg = qe + qn + qu;
504 let hdop_arg = qe + qn;
505 let vdop_arg = qu;
506 let tdop_arg = qt;
507 for arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg] {
508 #[allow(clippy::neg_cmp_op_on_partial_ord)]
509 let negative_or_nan = !(arg >= 0.0);
510 if negative_or_nan || !arg.is_finite() {
511 return Err(DopError::Singular);
512 }
513 }
514
515 Ok(Dop {
516 gdop: gdop_arg.sqrt(),
517 pdop: pdop_arg.sqrt(),
518 hdop: hdop_arg.sqrt(),
519 vdop: vdop_arg.sqrt(),
520 tdop: tdop_arg.sqrt(),
521 system_tdops: Vec::new(),
522 })
523}
524
525pub fn geometry_cofactor_from_design_rows(
532 rows: &[Vec<f64>],
533 weights: &[f64],
534 position_dimension: usize,
535 position_rotation: [[f64; 3]; 3],
536) -> Result<DesignGeometryCofactor, DopError> {
537 validate_design_rows(rows, weights, position_dimension, &position_rotation)?;
538 let p = position_dimension + 1;
539 if rows.len() < p {
540 return Err(DopError::TooFewSatellites);
541 }
542
543 let q = if p == 4 {
544 let fixed_rows: Vec<[f64; 4]> = rows
545 .iter()
546 .map(|row| [row[0], row[1], row[2], row[3]])
547 .collect();
548 let normal = normal_matrix_4_weighted_column_outer(&fixed_rows, weights)
549 .map_err(map_linear_error)?;
550 let fixed = invert_4x4_cofactor(&normal).ok_or(DopError::Singular)?;
551 fixed.iter().map(|row| row.to_vec()).collect()
552 } else {
553 let mut normal = vec![vec![0.0_f64; p]; p];
554 for (row, &weight) in rows.iter().zip(weights) {
555 for i in 0..p {
556 for j in 0..p {
557 normal[i][j] += row[i] * weight * row[j];
558 }
559 }
560 }
561 invert_symmetric_pd(&normal).ok_or(DopError::Singular)?
562 };
563 validate_general_cofactor_variances(&q)?;
564
565 let mut position = [[0.0_f64; 3]; 3];
566 for i in 0..position_dimension {
567 for j in 0..position_dimension {
568 position[i][j] = q[i][j];
569 }
570 }
571 let position_rotated = rotate3(&position, &position_rotation);
572 validate_matrix3(&position_rotated, "position_rotated")?;
573
574 Ok(DesignGeometryCofactor {
575 state: q,
576 position,
577 position_rotated,
578 })
579}
580
581pub fn position_covariance_from_geometry_m2(
587 los: &[LineOfSight],
588 weights: &[f64],
589 receiver: Wgs84Geodetic,
590 range_variance_scale_m2: f64,
591) -> Result<PositionCovariance, DopError> {
592 validate_variance_scale(range_variance_scale_m2)?;
593 let cofactor = geometry_cofactor(los, weights, receiver)?;
594 Ok(PositionCovariance {
595 ecef_m2: scale_matrix3(cofactor.position_ecef, range_variance_scale_m2),
596 enu_m2: scale_matrix3(cofactor.position_enu, range_variance_scale_m2),
597 })
598}
599
600pub fn rotate_covariance_ecef_to_enu_m2(
605 covariance_ecef_m2: [[f64; 3]; 3],
606 receiver: Wgs84Geodetic,
607) -> Result<[[f64; 3]; 3], DopError> {
608 validate_matrix3(&covariance_ecef_m2, "covariance_ecef_m2")?;
609 validate_receiver(receiver)?;
610 let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
611 Ok(rotate3(&covariance_ecef_m2, &r))
612}
613
614pub fn horizontal_error_ellipse(
620 covariance_enu_m2: [[f64; 3]; 3],
621 confidence: f64,
622) -> Result<HorizontalErrorEllipse, DopError> {
623 validate_matrix3(&covariance_enu_m2, "covariance_enu_m2")?;
624 let en_block = [
625 [covariance_enu_m2[0][0], covariance_enu_m2[0][1]],
626 [covariance_enu_m2[1][0], covariance_enu_m2[1][1]],
627 ];
628 let ellipse = error_ellipse_2x2(en_block, confidence).map_err(|err| match err {
629 DopError::InvalidInput {
632 field: "covariance",
633 reason,
634 } => invalid_input("covariance_enu_m2", reason),
635 other => other,
636 })?;
637 Ok(HorizontalErrorEllipse {
638 confidence: ellipse.confidence,
639 chi_square_scale: ellipse.chi_square_scale,
640 semi_major_m: ellipse.semi_major,
641 semi_minor_m: ellipse.semi_minor,
642 azimuth_rad: ellipse.orientation_rad,
643 })
644}
645
646pub fn error_ellipse_2x2(
655 covariance: [[f64; 2]; 2],
656 confidence: f64,
657) -> Result<ErrorEllipse2, DopError> {
658 integrity::error_ellipse_2x2(covariance, confidence).map_err(map_integrity_error)
659}
660
661pub fn error_ellipse_2x2_unit(covariance: [[f64; 2]; 2]) -> Result<ErrorEllipse2, DopError> {
666 integrity::error_ellipse_2x2_unit(covariance).map_err(map_integrity_error)
667}
668
669pub fn error_ellipse_from_geometry(
671 los: &[LineOfSight],
672 weights: &[f64],
673 receiver: Wgs84Geodetic,
674 range_variance_scale_m2: f64,
675 confidence: f64,
676) -> Result<HorizontalErrorEllipse, DopError> {
677 let covariance =
678 position_covariance_from_geometry_m2(los, weights, receiver, range_variance_scale_m2)?;
679 horizontal_error_ellipse(covariance.enu_m2, confidence)
680}
681
682fn validate_dop_inputs(
683 los: &[LineOfSight],
684 weights: &[f64],
685 receiver: Wgs84Geodetic,
686) -> Result<(), DopError> {
687 if los.len() != weights.len() {
688 return Err(invalid_input("weights", "length must match los"));
689 }
690 validate_los(los)?;
691 validate_weights(weights)?;
692 validate_receiver(receiver)
693}
694
695fn validate_los(los: &[LineOfSight]) -> Result<(), DopError> {
696 for line in los {
697 if !(line.e_x.is_finite() && line.e_y.is_finite() && line.e_z.is_finite()) {
698 return Err(invalid_input("los", "not finite"));
699 }
700 let norm = (line.e_x * line.e_x + line.e_y * line.e_y + line.e_z * line.e_z).sqrt();
701 if !norm.is_finite() {
702 return Err(invalid_input("los", "not finite"));
703 }
704 if (norm - 1.0).abs() > LOS_UNIT_TOLERANCE {
705 return Err(invalid_input("los", "not unit length"));
706 }
707 }
708 Ok(())
709}
710
711fn validate_cofactor_variances(q: &[[f64; 4]; 4]) -> Result<(), DopError> {
712 for row in q {
713 validate::finite_slice(row, "cofactor").map_err(map_validation_error)?;
714 }
715 for (idx, row) in q.iter().enumerate() {
716 let variance = row[idx];
717 #[allow(clippy::neg_cmp_op_on_partial_ord)]
718 let negative_or_nan = !(variance >= 0.0);
719 if negative_or_nan || !variance.is_finite() {
720 return Err(DopError::Singular);
721 }
722 }
723 Ok(())
724}
725
726fn validate_variance_scale(value: f64) -> Result<(), DopError> {
727 if !value.is_finite() {
728 return Err(invalid_input("range_variance_scale_m2", "not finite"));
729 }
730 if value < 0.0 {
731 return Err(invalid_input("range_variance_scale_m2", "negative"));
732 }
733 Ok(())
734}
735
736fn validate_matrix3(matrix: &[[f64; 3]; 3], field: &'static str) -> Result<(), DopError> {
737 for row in matrix {
738 validate::finite_slice(row, field).map_err(map_validation_error)?;
739 }
740 Ok(())
741}
742
743fn validate_design_rows(
744 rows: &[Vec<f64>],
745 weights: &[f64],
746 position_dimension: usize,
747 position_rotation: &[[f64; 3]; 3],
748) -> Result<(), DopError> {
749 if !(2..=3).contains(&position_dimension) {
750 return Err(invalid_input("position_dimension", "must be 2 or 3"));
751 }
752 if rows.len() != weights.len() {
753 return Err(invalid_input("weights", "length must match rows"));
754 }
755 let p = position_dimension + 1;
756 for row in rows {
757 if row.len() != p {
758 return Err(invalid_input("rows", "length must match state dimension"));
759 }
760 validate::finite_slice(row, "rows").map_err(map_validation_error)?;
761 }
762 validate_weights(weights)?;
763 validate_matrix3(position_rotation, "position_rotation")
764}
765
766fn validate_general_cofactor_variances(q: &[Vec<f64>]) -> Result<(), DopError> {
767 for row in q {
768 validate::finite_slice(row, "cofactor").map_err(map_validation_error)?;
769 }
770 for (idx, row) in q.iter().enumerate() {
771 let variance = row[idx];
772 #[allow(clippy::neg_cmp_op_on_partial_ord)]
773 let negative_or_nan = !(variance >= 0.0);
774 if negative_or_nan || !variance.is_finite() {
775 return Err(DopError::Singular);
776 }
777 }
778 Ok(())
779}
780
781fn position_block(q: &[[f64; 4]; 4]) -> [[f64; 3]; 3] {
782 [
783 [q[0][0], q[0][1], q[0][2]],
784 [q[1][0], q[1][1], q[1][2]],
785 [q[2][0], q[2][1], q[2][2]],
786 ]
787}
788
789fn scale_matrix3(matrix: [[f64; 3]; 3], scale: f64) -> [[f64; 3]; 3] {
790 [
791 [
792 matrix[0][0] * scale,
793 matrix[0][1] * scale,
794 matrix[0][2] * scale,
795 ],
796 [
797 matrix[1][0] * scale,
798 matrix[1][1] * scale,
799 matrix[1][2] * scale,
800 ],
801 [
802 matrix[2][0] * scale,
803 matrix[2][1] * scale,
804 matrix[2][2] * scale,
805 ],
806 ]
807}
808
809fn validate_weights(weights: &[f64]) -> Result<(), DopError> {
810 if weights.iter().any(|weight| !weight.is_finite()) {
811 return Err(invalid_input("weights", "not finite"));
812 }
813 if weights.iter().any(|&weight| weight < 0.0) {
814 return Err(invalid_input("weights", "negative"));
815 }
816 Ok(())
817}
818
819fn validate_receiver(receiver: Wgs84Geodetic) -> Result<(), DopError> {
820 if !(receiver.lat_rad.is_finite()
821 && receiver.lon_rad.is_finite()
822 && receiver.height_m.is_finite())
823 {
824 return Err(invalid_input("receiver", "not finite"));
825 }
826 if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
827 return Err(invalid_input("receiver.lat_rad", "out of range"));
828 }
829 if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
830 return Err(invalid_input("receiver.lon_rad", "out of range"));
831 }
832 Ok(())
833}
834
835fn validate_az_el_receiver(
836 azimuth_deg: f64,
837 elevation_deg: f64,
838 receiver: Wgs84Geodetic,
839) -> Result<(), DopError> {
840 if !azimuth_deg.is_finite() {
841 return Err(invalid_input("azimuth_deg", "not finite"));
842 }
843 if !elevation_deg.is_finite() {
844 return Err(invalid_input("elevation_deg", "not finite"));
845 }
846 if !(-90.0..=90.0).contains(&elevation_deg) {
847 return Err(invalid_input("elevation_deg", "out of range"));
848 }
849 validate_receiver(receiver)
850}
851
852fn invalid_input(field: &'static str, reason: &'static str) -> DopError {
853 DopError::InvalidInput { field, reason }
854}
855
856fn map_linear_error(error: LinearError) -> DopError {
857 match error {
858 LinearError::InvalidInput { field, reason } => invalid_input(field, reason),
859 }
860}
861
862fn map_validation_error(error: validate::FieldError) -> DopError {
863 invalid_input(error.field(), error.reason())
864}
865
866fn map_integrity_error(error: IntegrityError) -> DopError {
867 match error {
868 IntegrityError::NonFinite => invalid_input("covariance", "not finite"),
869 IntegrityError::NotPositiveSemidefinite => {
870 invalid_input("covariance", "not positive semidefinite")
871 }
872 IntegrityError::InvalidProbability { reason } => invalid_input("confidence", reason),
873 IntegrityError::InvalidInput { field, reason } => invalid_input(field, reason),
874 IntegrityError::Singular => DopError::Singular,
875 }
876}
877
878fn rotate3(q: &[[f64; 3]; 3], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
884 inline_rxr(&inline_rxr(r, q), &inline_tr(r))
885}
886
887pub(crate) fn dop_multi(
912 los: &[LineOfSight],
913 clock_index: &[usize],
914 systems: &[GnssSystem],
915 n_clocks: usize,
916 weights: &[f64],
917 receiver: Wgs84Geodetic,
918) -> Result<Dop, DopError> {
919 validate_dop_multi_inputs(los, clock_index, systems, n_clocks, weights, receiver)?;
920 let p = 3 + n_clocks;
921 if los.len() < p {
922 return Err(DopError::TooFewSatellites);
923 }
924
925 let mut a = vec![vec![0.0_f64; p]; p];
926 for k in 0..los.len() {
927 let mut row = vec![0.0_f64; p];
928 row[0] = -los[k].e_x;
929 row[1] = -los[k].e_y;
930 row[2] = -los[k].e_z;
931 row[3 + clock_index[k]] = 1.0;
932 let w = weights[k];
933 #[allow(clippy::needless_range_loop)]
934 for i in 0..p {
935 for j in 0..p {
936 a[i][j] += row[i] * w * row[j];
937 }
938 }
939 }
940 let q = invert_symmetric_pd(&a).ok_or(DopError::Singular)?;
941
942 let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
943 let qpos = [
944 [q[0][0], q[0][1], q[0][2]],
945 [q[1][0], q[1][1], q[1][2]],
946 [q[2][0], q[2][1], q[2][2]],
947 ];
948 let enu = rotate3(&qpos, &r);
949
950 let qe = enu[0][0];
951 let qn = enu[1][1];
952 let qu = enu[2][2];
953 let qt = q[3][3];
954 let trace: f64 = (0..p).map(|i| q[i][i]).sum();
955 let system_tdop_args: Vec<f64> = (0..n_clocks).map(|i| q[3 + i][3 + i]).collect();
958
959 let gdop_arg = trace;
960 let pdop_arg = qe + qn + qu;
961 let hdop_arg = qe + qn;
962 let vdop_arg = qu;
963 let tdop_arg = qt;
964 for &arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg]
969 .iter()
970 .chain(system_tdop_args.iter())
971 {
972 #[allow(clippy::neg_cmp_op_on_partial_ord)]
973 let nonpositive_or_nan = !(arg >= 0.0);
974 if nonpositive_or_nan || !arg.is_finite() {
975 return Err(DopError::Singular);
976 }
977 }
978
979 Ok(Dop {
980 gdop: gdop_arg.sqrt(),
981 pdop: pdop_arg.sqrt(),
982 hdop: hdop_arg.sqrt(),
983 vdop: vdop_arg.sqrt(),
984 tdop: tdop_arg.sqrt(),
985 system_tdops: system_tdop_args
986 .iter()
987 .enumerate()
988 .map(|(i, &v)| (systems[i], v.sqrt()))
989 .collect(),
990 })
991}
992
993fn validate_dop_multi_inputs(
994 los: &[LineOfSight],
995 clock_index: &[usize],
996 systems: &[GnssSystem],
997 n_clocks: usize,
998 weights: &[f64],
999 receiver: Wgs84Geodetic,
1000) -> Result<(), DopError> {
1001 if los.len() != weights.len() {
1002 return Err(invalid_input("weights", "length must match los"));
1003 }
1004 if los.len() != clock_index.len() {
1005 return Err(invalid_input("clock_index", "length must match los"));
1006 }
1007 if n_clocks == 0 {
1008 return Err(invalid_input("n_clocks", "not positive"));
1009 }
1010 if systems.len() != n_clocks {
1011 return Err(invalid_input("systems", "length must match n_clocks"));
1012 }
1013 if clock_index.iter().any(|&idx| idx >= n_clocks) {
1014 return Err(invalid_input("clock_index", "out of range"));
1015 }
1016 validate_los(los)?;
1017 validate_weights(weights)?;
1018 validate_receiver(receiver)
1019}
1020
1021#[cfg(all(test, sidereon_repo_tests))]
1022pub(crate) mod test_support {
1023 use super::*;
1027
1028 pub(crate) fn normal_matrix_for(los: &[LineOfSight], weights: &[f64]) -> [[f64; 4]; 4] {
1029 let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
1030 normal_matrix_4_weighted_column_outer(&rows, weights).expect("valid DOP test inputs")
1031 }
1032
1033 pub(crate) fn det4_for(a: &[[f64; 4]; 4]) -> f64 {
1034 crate::astro::math::linear::det4_cofactor(a)
1035 }
1036
1037 pub(crate) fn inv4_for(a: &[[f64; 4]; 4]) -> Option<[[f64; 4]; 4]> {
1038 invert_4x4_cofactor(a)
1039 }
1040
1041 pub(crate) fn enu_block_for(q: &[[f64; 4]; 4], lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
1042 let r = ecef_to_enu_rotation(lat_rad, lon_rad);
1043 rotate_pos_block(q, &r)
1044 }
1045}
1046
1047#[cfg(test)]
1048mod public_api_tests {
1049 use super::*;
1050
1051 fn receiver() -> Wgs84Geodetic {
1052 Wgs84Geodetic::new(45.0_f64.to_radians(), -75.0_f64.to_radians(), 100.0)
1053 .expect("valid receiver")
1054 }
1055
1056 fn sample_geometry() -> (Vec<LineOfSight>, Vec<f64>, Wgs84Geodetic) {
1057 let rx = receiver();
1058 let az_el = [
1059 (5.0, 25.0),
1060 (80.0, 35.0),
1061 (155.0, 55.0),
1062 (235.0, 40.0),
1063 (310.0, 65.0),
1064 ];
1065 let los = az_el
1066 .into_iter()
1067 .map(|(az, el)| line_of_sight_from_az_el_deg(az, el, rx).expect("valid LOS"))
1068 .collect::<Vec<_>>();
1069 let weights = vec![1.0, 0.8, 1.4, 0.9, 1.1];
1070 (los, weights, rx)
1071 }
1072
1073 #[test]
1074 fn geometry_cofactor_exposes_the_dop_position_block() {
1075 let (los, weights, rx) = sample_geometry();
1076 let d = dop(&los, &weights, rx).expect("DOP");
1077 let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
1078
1079 let qe = q.position_enu[0][0];
1080 let qn = q.position_enu[1][1];
1081 let qu = q.position_enu[2][2];
1082 assert_eq!(d.pdop.to_bits(), (qe + qn + qu).sqrt().to_bits());
1083 assert_eq!(d.hdop.to_bits(), (qe + qn).sqrt().to_bits());
1084 assert_eq!(d.vdop.to_bits(), qu.sqrt().to_bits());
1085 assert_eq!(d.tdop.to_bits(), q.state[3][3].sqrt().to_bits());
1086 }
1087
1088 #[test]
1089 fn position_covariance_scales_the_raw_cofactor() {
1090 let (los, weights, rx) = sample_geometry();
1091 let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
1092 let cov =
1093 position_covariance_from_geometry_m2(&los, &weights, rx, 4.0).expect("covariance");
1094 for i in 0..3 {
1095 for j in 0..3 {
1096 assert_eq!(
1097 cov.ecef_m2[i][j].to_bits(),
1098 (q.position_ecef[i][j] * 4.0).to_bits()
1099 );
1100 assert_eq!(
1101 cov.enu_m2[i][j].to_bits(),
1102 (q.position_enu[i][j] * 4.0).to_bits()
1103 );
1104 }
1105 }
1106 }
1107
1108 #[test]
1109 fn horizontal_error_ellipse_uses_chi_square_two_dof_scale() {
1110 let covariance = [[9.0, 2.0, 0.0], [2.0, 4.0, 0.0], [0.0, 0.0, 16.0]];
1111 let ellipse = horizontal_error_ellipse(covariance, 0.95).expect("ellipse");
1112 let expected_scale = -2.0 * (1.0_f64 - 0.95).ln();
1113 assert_eq!(ellipse.chi_square_scale.to_bits(), expected_scale.to_bits());
1114 assert!(ellipse.semi_major_m >= ellipse.semi_minor_m);
1115 assert!(ellipse.semi_minor_m > 0.0);
1116 assert!(ellipse.azimuth_rad.is_finite());
1117 }
1118
1119 #[test]
1120 fn error_ellipse_2x2_matches_numpy_eigh() {
1121 let ellipse = error_ellipse_2x2([[9.0, 2.0], [2.0, 4.0]], 0.95).expect("ellipse");
1123 assert!((ellipse.chi_square_scale - 5.99146454710798).abs() < 1e-12);
1124 assert!((ellipse.semi_major - 7.6240780089041085).abs() < 1e-12);
1125 assert!((ellipse.semi_minor - 4.445500379771495).abs() < 1e-12);
1126 assert!((ellipse.orientation_rad - 0.3373704711117763).abs() < 1e-12);
1127 }
1128
1129 #[test]
1130 fn horizontal_error_ellipse_delegates_to_2x2_primitive() {
1131 let cov3 = [[9.0, 2.0, 1.0], [2.0, 4.0, -3.0], [1.0, -3.0, 16.0]];
1134 let wrapper = horizontal_error_ellipse(cov3, 0.95).expect("wrapper");
1135 let primitive =
1136 error_ellipse_2x2([[cov3[0][0], cov3[0][1]], [cov3[1][0], cov3[1][1]]], 0.95)
1137 .expect("primitive");
1138 assert_eq!(
1139 wrapper.semi_major_m.to_bits(),
1140 primitive.semi_major.to_bits()
1141 );
1142 assert_eq!(
1143 wrapper.semi_minor_m.to_bits(),
1144 primitive.semi_minor.to_bits()
1145 );
1146 assert_eq!(
1147 wrapper.azimuth_rad.to_bits(),
1148 primitive.orientation_rad.to_bits()
1149 );
1150 }
1151
1152 #[test]
1153 fn geocentric_convention_changes_only_horizontal_vertical_split() {
1154 let (los, weights, rx) = sample_geometry();
1155 let geodetic = dop(&los, &weights, rx).expect("geodetic DOP");
1156 let geocentric = dop_with_convention(&los, &weights, rx, EnuConvention::GeocentricRadial)
1157 .expect("geocentric DOP");
1158
1159 assert_eq!(geodetic.gdop.to_bits(), geocentric.gdop.to_bits());
1162 assert_eq!(geodetic.tdop.to_bits(), geocentric.tdop.to_bits());
1163
1164 assert!((geodetic.pdop - geocentric.pdop).abs() < 1e-9 * geodetic.pdop);
1166
1167 let hdop_rel = (geodetic.hdop - geocentric.hdop).abs() / geodetic.hdop;
1170 assert!(hdop_rel > 0.0, "convention must change HDOP");
1171 assert!(
1172 hdop_rel < 1e-2,
1173 "HDOP shift {hdop_rel} larger than expected"
1174 );
1175 assert_ne!(geodetic.vdop.to_bits(), geocentric.vdop.to_bits());
1176 }
1177
1178 #[test]
1179 fn geocentric_convention_rejects_zero_radius_receiver() {
1180 let geocenter = Wgs84Geodetic::new(0.0, 0.0, -crate::astro::constants::earth::WGS84_A_M)
1185 .expect("valid geodetic receiver");
1186 let (los, weights, _) = sample_geometry();
1187 let err = dop_with_convention(&los, &weights, geocenter, EnuConvention::GeocentricRadial)
1188 .expect_err("zero-radius geocentric up must be rejected");
1189 assert!(matches!(
1190 err,
1191 DopError::InvalidInput {
1192 field: "receiver",
1193 ..
1194 }
1195 ));
1196
1197 assert!(
1200 dop_with_convention(&los, &weights, geocenter, EnuConvention::GeodeticNormal).is_ok()
1201 );
1202 }
1203
1204 #[test]
1205 fn default_dop_equals_explicit_geodetic_convention_bit_for_bit() {
1206 let (los, weights, rx) = sample_geometry();
1207 let default = dop(&los, &weights, rx).expect("default");
1208 let explicit =
1209 dop_with_convention(&los, &weights, rx, EnuConvention::GeodeticNormal).expect("geo");
1210 assert_eq!(default.hdop.to_bits(), explicit.hdop.to_bits());
1211 assert_eq!(default.vdop.to_bits(), explicit.vdop.to_bits());
1212 assert_eq!(default.pdop.to_bits(), explicit.pdop.to_bits());
1213 }
1214}
1215
1216#[cfg(all(test, sidereon_repo_tests))]
1217mod tests;