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)]
140pub struct Dop {
141 pub gdop: f64,
145 pub pdop: f64,
147 pub hdop: f64,
149 pub vdop: f64,
151 pub tdop: f64,
155 pub system_tdops: Vec<(GnssSystem, f64)>,
168}
169
170#[derive(Debug, Clone, PartialEq)]
177pub struct GeometryCofactor {
178 pub state: [[f64; 4]; 4],
180 pub position_ecef: [[f64; 3]; 3],
182 pub position_enu: [[f64; 3]; 3],
184}
185
186#[derive(Debug, Clone, PartialEq)]
192pub struct DesignGeometryCofactor {
193 pub state: Vec<Vec<f64>>,
195 pub position: [[f64; 3]; 3],
197 pub position_rotated: [[f64; 3]; 3],
199}
200
201#[derive(Debug, Clone, Copy, PartialEq)]
206pub struct PositionCovariance {
207 pub ecef_m2: [[f64; 3]; 3],
209 pub enu_m2: [[f64; 3]; 3],
211}
212
213#[derive(Debug, Clone, Copy, PartialEq)]
219pub struct HorizontalErrorEllipse {
220 pub confidence: f64,
222 pub chi_square_scale: f64,
224 pub semi_major_m: f64,
226 pub semi_minor_m: f64,
228 pub azimuth_rad: f64,
230}
231
232#[derive(Debug, Clone, Copy, PartialEq)]
239pub struct ErrorEllipse2 {
240 pub confidence: f64,
242 pub chi_square_scale: f64,
245 pub semi_major: f64,
247 pub semi_minor: f64,
249 pub orientation_rad: f64,
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum DopError {
257 InvalidInput {
259 field: &'static str,
261 reason: &'static str,
263 },
264 TooFewSatellites,
268 Singular,
271}
272
273impl core::fmt::Display for DopError {
274 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
275 match self {
276 DopError::InvalidInput { field, reason } => {
277 write!(f, "invalid DOP input {field}: {reason}")
278 }
279 DopError::TooFewSatellites => {
280 write!(
281 f,
282 "fewer satellites than parameters: geometry is rank-deficient"
283 )
284 }
285 DopError::Singular => {
286 write!(f, "singular or ill-conditioned geometry: no finite DOP")
287 }
288 }
289 }
290}
291
292impl std::error::Error for DopError {}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
303pub enum EnuConvention {
304 #[default]
307 GeodeticNormal,
308 GeocentricRadial,
311}
312
313fn enu_rotation(
319 receiver: Wgs84Geodetic,
320 convention: EnuConvention,
321) -> Result<[[f64; 3]; 3], DopError> {
322 match convention {
323 EnuConvention::GeodeticNormal => {
324 Ok(ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad))
325 }
326 EnuConvention::GeocentricRadial => {
327 let ecef = crate::frame::geodetic_to_itrf(receiver)
328 .map_err(|_| invalid_input("receiver", "geocentric basis unavailable"))?;
329 let p = ecef.as_array();
330 let radius = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
337 if radius <= GEOCENTRIC_MIN_RADIUS_M {
338 return Err(invalid_input(
339 "receiver",
340 "geocentric up undefined at zero radius",
341 ));
342 }
343 let (north, east, up) = crate::frame::geocentric_neu_basis(p);
344 Ok([east, north, up])
345 }
346 }
347}
348
349pub(crate) fn ecef_to_enu_rotation(lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
351 let sphi = lat_rad.sin();
352 let cphi = lat_rad.cos();
353 let slam = lon_rad.sin();
354 let clam = lon_rad.cos();
355 [
356 [-slam, clam, 0.0],
357 [-sphi * clam, -sphi * slam, cphi],
358 [cphi * clam, cphi * slam, sphi],
359 ]
360}
361
362fn rotate_pos_block(q: &[[f64; 4]; 4], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
366 let qpos = [
367 [q[0][0], q[0][1], q[0][2]],
368 [q[1][0], q[1][1], q[1][2]],
369 [q[2][0], q[2][1], q[2][2]],
370 ];
371 rotate3(&qpos, r)
372}
373
374pub fn dop(los: &[LineOfSight], weights: &[f64], receiver: Wgs84Geodetic) -> Result<Dop, DopError> {
383 dop_with_convention(los, weights, receiver, EnuConvention::GeodeticNormal)
384}
385
386pub fn dop_with_convention(
394 los: &[LineOfSight],
395 weights: &[f64],
396 receiver: Wgs84Geodetic,
397 convention: EnuConvention,
398) -> Result<Dop, DopError> {
399 validate_dop_inputs(los, weights, receiver)?;
400 if los.len() < 4 {
401 return Err(DopError::TooFewSatellites);
402 }
403
404 let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
405 let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
406 let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
407
408 let r = enu_rotation(receiver, convention)?;
409 let enu = rotate_pos_block(&q, &r);
410
411 let qe = enu[0][0];
412 let qn = enu[1][1];
413 let qu = enu[2][2];
414 let qt = q[3][3];
415
416 let gdop_arg = q[0][0] + q[1][1] + q[2][2] + q[3][3];
424 let pdop_arg = qe + qn + qu;
425 let hdop_arg = qe + qn;
426 let vdop_arg = qu;
427 let tdop_arg = qt;
428 for arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg] {
429 #[allow(clippy::neg_cmp_op_on_partial_ord)]
431 let nonpositive_or_nan = !(arg >= 0.0);
432 if nonpositive_or_nan || !arg.is_finite() {
433 return Err(DopError::Singular);
434 }
435 }
436
437 Ok(Dop {
438 gdop: gdop_arg.sqrt(),
439 pdop: pdop_arg.sqrt(),
440 hdop: hdop_arg.sqrt(),
441 vdop: vdop_arg.sqrt(),
442 tdop: tdop_arg.sqrt(),
443 system_tdops: Vec::new(),
448 })
449}
450
451pub fn geometry_cofactor(
457 los: &[LineOfSight],
458 weights: &[f64],
459 receiver: Wgs84Geodetic,
460) -> Result<GeometryCofactor, DopError> {
461 geometry_cofactor_with_convention(los, weights, receiver, EnuConvention::GeodeticNormal)
462}
463
464pub fn geometry_cofactor_with_convention(
471 los: &[LineOfSight],
472 weights: &[f64],
473 receiver: Wgs84Geodetic,
474 convention: EnuConvention,
475) -> Result<GeometryCofactor, DopError> {
476 validate_dop_inputs(los, weights, receiver)?;
477 if los.len() < 4 {
478 return Err(DopError::TooFewSatellites);
479 }
480
481 let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
482 let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
483 let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
484 validate_cofactor_variances(&q)?;
485
486 let r = enu_rotation(receiver, convention)?;
487 let enu = rotate_pos_block(&q, &r);
488 validate_matrix3(&enu, "position_enu")?;
489 Ok(GeometryCofactor {
490 state: q,
491 position_ecef: position_block(&q),
492 position_enu: enu,
493 })
494}
495
496pub fn dop_from_design_rows(
505 rows: &[Vec<f64>],
506 weights: &[f64],
507 position_dimension: usize,
508 position_rotation: [[f64; 3]; 3],
509) -> Result<Dop, DopError> {
510 let cofactor =
511 geometry_cofactor_from_design_rows(rows, weights, position_dimension, position_rotation)?;
512 let time_col = position_dimension;
513 let q = &cofactor.state;
514 let rotated = cofactor.position_rotated;
515 let qe = rotated[0][0];
516 let qn = rotated[1][1];
517 let qu = rotated[2][2];
518 let qt = q[time_col][time_col];
519 let trace: f64 = (0..q.len()).map(|i| q[i][i]).sum();
520
521 let gdop_arg = trace;
522 let pdop_arg = qe + qn + qu;
523 let hdop_arg = qe + qn;
524 let vdop_arg = qu;
525 let tdop_arg = qt;
526 for arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg] {
527 #[allow(clippy::neg_cmp_op_on_partial_ord)]
528 let negative_or_nan = !(arg >= 0.0);
529 if negative_or_nan || !arg.is_finite() {
530 return Err(DopError::Singular);
531 }
532 }
533
534 Ok(Dop {
535 gdop: gdop_arg.sqrt(),
536 pdop: pdop_arg.sqrt(),
537 hdop: hdop_arg.sqrt(),
538 vdop: vdop_arg.sqrt(),
539 tdop: tdop_arg.sqrt(),
540 system_tdops: Vec::new(),
541 })
542}
543
544pub fn geometry_cofactor_from_design_rows(
551 rows: &[Vec<f64>],
552 weights: &[f64],
553 position_dimension: usize,
554 position_rotation: [[f64; 3]; 3],
555) -> Result<DesignGeometryCofactor, DopError> {
556 validate_design_rows(rows, weights, position_dimension, &position_rotation)?;
557 let p = position_dimension + 1;
558 if rows.len() < p {
559 return Err(DopError::TooFewSatellites);
560 }
561
562 let q = if p == 4 {
563 let fixed_rows: Vec<[f64; 4]> = rows
564 .iter()
565 .map(|row| [row[0], row[1], row[2], row[3]])
566 .collect();
567 let normal = normal_matrix_4_weighted_column_outer(&fixed_rows, weights)
568 .map_err(map_linear_error)?;
569 let fixed = invert_4x4_cofactor(&normal).ok_or(DopError::Singular)?;
570 fixed.iter().map(|row| row.to_vec()).collect()
571 } else {
572 let mut normal = vec![vec![0.0_f64; p]; p];
573 for (row, &weight) in rows.iter().zip(weights) {
574 for i in 0..p {
575 for j in 0..p {
576 normal[i][j] += row[i] * weight * row[j];
577 }
578 }
579 }
580 invert_symmetric_pd(&normal).ok_or(DopError::Singular)?
581 };
582 validate_general_cofactor_variances(&q)?;
583
584 let mut position = [[0.0_f64; 3]; 3];
585 for i in 0..position_dimension {
586 for j in 0..position_dimension {
587 position[i][j] = q[i][j];
588 }
589 }
590 let position_rotated = rotate3(&position, &position_rotation);
591 validate_matrix3(&position_rotated, "position_rotated")?;
592
593 Ok(DesignGeometryCofactor {
594 state: q,
595 position,
596 position_rotated,
597 })
598}
599
600pub fn position_covariance_from_geometry_m2(
606 los: &[LineOfSight],
607 weights: &[f64],
608 receiver: Wgs84Geodetic,
609 range_variance_scale_m2: f64,
610) -> Result<PositionCovariance, DopError> {
611 validate_variance_scale(range_variance_scale_m2)?;
612 let cofactor = geometry_cofactor(los, weights, receiver)?;
613 Ok(PositionCovariance {
614 ecef_m2: scale_matrix3(cofactor.position_ecef, range_variance_scale_m2),
615 enu_m2: scale_matrix3(cofactor.position_enu, range_variance_scale_m2),
616 })
617}
618
619pub fn horizontal_error_ellipse(
625 covariance_enu_m2: [[f64; 3]; 3],
626 confidence: f64,
627) -> Result<HorizontalErrorEllipse, DopError> {
628 validate_matrix3(&covariance_enu_m2, "covariance_enu_m2")?;
629 let en_block = [
630 [covariance_enu_m2[0][0], covariance_enu_m2[0][1]],
631 [covariance_enu_m2[1][0], covariance_enu_m2[1][1]],
632 ];
633 let ellipse = error_ellipse_2x2(en_block, confidence).map_err(|err| match err {
634 DopError::InvalidInput {
637 field: "covariance",
638 reason,
639 } => invalid_input("covariance_enu_m2", reason),
640 other => other,
641 })?;
642 Ok(HorizontalErrorEllipse {
643 confidence: ellipse.confidence,
644 chi_square_scale: ellipse.chi_square_scale,
645 semi_major_m: ellipse.semi_major,
646 semi_minor_m: ellipse.semi_minor,
647 azimuth_rad: ellipse.orientation_rad,
648 })
649}
650
651pub fn error_ellipse_2x2(
660 covariance: [[f64; 2]; 2],
661 confidence: f64,
662) -> Result<ErrorEllipse2, DopError> {
663 for row in &covariance {
664 validate::finite_slice(row, "covariance").map_err(map_validation_error)?;
665 }
666 validate_confidence(confidence)?;
667
668 let a = covariance[0][0];
669 let b = 0.5 * (covariance[0][1] + covariance[1][0]);
670 let c = covariance[1][1];
671 let half_delta = 0.5 * (a - c);
672 let center = 0.5 * (a + c);
673 let root = (half_delta * half_delta + b * b).sqrt();
674 let lambda_major = center + root;
675 let lambda_minor = center - root;
676 if !lambda_major.is_finite() || !lambda_minor.is_finite() || lambda_minor < -1.0e-12 {
677 return Err(invalid_input("covariance", "not positive semidefinite"));
678 }
679
680 let chi_square_scale = -2.0 * (1.0 - confidence).ln();
681 let semi_major = (lambda_major.max(0.0) * chi_square_scale).sqrt();
682 let semi_minor = (lambda_minor.max(0.0) * chi_square_scale).sqrt();
683 let orientation_rad = if root == 0.0 {
684 0.0
685 } else {
686 0.5 * (2.0 * b).atan2(a - c)
687 };
688 Ok(ErrorEllipse2 {
689 confidence,
690 chi_square_scale,
691 semi_major,
692 semi_minor,
693 orientation_rad,
694 })
695}
696
697pub fn error_ellipse_from_geometry(
699 los: &[LineOfSight],
700 weights: &[f64],
701 receiver: Wgs84Geodetic,
702 range_variance_scale_m2: f64,
703 confidence: f64,
704) -> Result<HorizontalErrorEllipse, DopError> {
705 let covariance =
706 position_covariance_from_geometry_m2(los, weights, receiver, range_variance_scale_m2)?;
707 horizontal_error_ellipse(covariance.enu_m2, confidence)
708}
709
710fn validate_dop_inputs(
711 los: &[LineOfSight],
712 weights: &[f64],
713 receiver: Wgs84Geodetic,
714) -> Result<(), DopError> {
715 if los.len() != weights.len() {
716 return Err(invalid_input("weights", "length must match los"));
717 }
718 validate_los(los)?;
719 validate_weights(weights)?;
720 validate_receiver(receiver)
721}
722
723fn validate_los(los: &[LineOfSight]) -> Result<(), DopError> {
724 for line in los {
725 if !(line.e_x.is_finite() && line.e_y.is_finite() && line.e_z.is_finite()) {
726 return Err(invalid_input("los", "not finite"));
727 }
728 let norm = (line.e_x * line.e_x + line.e_y * line.e_y + line.e_z * line.e_z).sqrt();
729 if !norm.is_finite() {
730 return Err(invalid_input("los", "not finite"));
731 }
732 if (norm - 1.0).abs() > LOS_UNIT_TOLERANCE {
733 return Err(invalid_input("los", "not unit length"));
734 }
735 }
736 Ok(())
737}
738
739fn validate_cofactor_variances(q: &[[f64; 4]; 4]) -> Result<(), DopError> {
740 for row in q {
741 validate::finite_slice(row, "cofactor").map_err(map_validation_error)?;
742 }
743 for (idx, row) in q.iter().enumerate() {
744 let variance = row[idx];
745 #[allow(clippy::neg_cmp_op_on_partial_ord)]
746 let negative_or_nan = !(variance >= 0.0);
747 if negative_or_nan || !variance.is_finite() {
748 return Err(DopError::Singular);
749 }
750 }
751 Ok(())
752}
753
754fn validate_variance_scale(value: f64) -> Result<(), DopError> {
755 if !value.is_finite() {
756 return Err(invalid_input("range_variance_scale_m2", "not finite"));
757 }
758 if value < 0.0 {
759 return Err(invalid_input("range_variance_scale_m2", "negative"));
760 }
761 Ok(())
762}
763
764fn validate_confidence(value: f64) -> Result<(), DopError> {
765 if !value.is_finite() {
766 return Err(invalid_input("confidence", "not finite"));
767 }
768 if !(0.0..1.0).contains(&value) {
769 return Err(invalid_input("confidence", "out of range"));
770 }
771 Ok(())
772}
773
774fn validate_matrix3(matrix: &[[f64; 3]; 3], field: &'static str) -> Result<(), DopError> {
775 for row in matrix {
776 validate::finite_slice(row, field).map_err(map_validation_error)?;
777 }
778 Ok(())
779}
780
781fn validate_design_rows(
782 rows: &[Vec<f64>],
783 weights: &[f64],
784 position_dimension: usize,
785 position_rotation: &[[f64; 3]; 3],
786) -> Result<(), DopError> {
787 if !(2..=3).contains(&position_dimension) {
788 return Err(invalid_input("position_dimension", "must be 2 or 3"));
789 }
790 if rows.len() != weights.len() {
791 return Err(invalid_input("weights", "length must match rows"));
792 }
793 let p = position_dimension + 1;
794 for row in rows {
795 if row.len() != p {
796 return Err(invalid_input("rows", "length must match state dimension"));
797 }
798 validate::finite_slice(row, "rows").map_err(map_validation_error)?;
799 }
800 validate_weights(weights)?;
801 validate_matrix3(position_rotation, "position_rotation")
802}
803
804fn validate_general_cofactor_variances(q: &[Vec<f64>]) -> Result<(), DopError> {
805 for row in q {
806 validate::finite_slice(row, "cofactor").map_err(map_validation_error)?;
807 }
808 for (idx, row) in q.iter().enumerate() {
809 let variance = row[idx];
810 #[allow(clippy::neg_cmp_op_on_partial_ord)]
811 let negative_or_nan = !(variance >= 0.0);
812 if negative_or_nan || !variance.is_finite() {
813 return Err(DopError::Singular);
814 }
815 }
816 Ok(())
817}
818
819fn position_block(q: &[[f64; 4]; 4]) -> [[f64; 3]; 3] {
820 [
821 [q[0][0], q[0][1], q[0][2]],
822 [q[1][0], q[1][1], q[1][2]],
823 [q[2][0], q[2][1], q[2][2]],
824 ]
825}
826
827fn scale_matrix3(matrix: [[f64; 3]; 3], scale: f64) -> [[f64; 3]; 3] {
828 [
829 [
830 matrix[0][0] * scale,
831 matrix[0][1] * scale,
832 matrix[0][2] * scale,
833 ],
834 [
835 matrix[1][0] * scale,
836 matrix[1][1] * scale,
837 matrix[1][2] * scale,
838 ],
839 [
840 matrix[2][0] * scale,
841 matrix[2][1] * scale,
842 matrix[2][2] * scale,
843 ],
844 ]
845}
846
847fn validate_weights(weights: &[f64]) -> Result<(), DopError> {
848 if weights.iter().any(|weight| !weight.is_finite()) {
849 return Err(invalid_input("weights", "not finite"));
850 }
851 if weights.iter().any(|&weight| weight < 0.0) {
852 return Err(invalid_input("weights", "negative"));
853 }
854 Ok(())
855}
856
857fn validate_receiver(receiver: Wgs84Geodetic) -> Result<(), DopError> {
858 if !(receiver.lat_rad.is_finite()
859 && receiver.lon_rad.is_finite()
860 && receiver.height_m.is_finite())
861 {
862 return Err(invalid_input("receiver", "not finite"));
863 }
864 if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
865 return Err(invalid_input("receiver.lat_rad", "out of range"));
866 }
867 if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
868 return Err(invalid_input("receiver.lon_rad", "out of range"));
869 }
870 Ok(())
871}
872
873fn validate_az_el_receiver(
874 azimuth_deg: f64,
875 elevation_deg: f64,
876 receiver: Wgs84Geodetic,
877) -> Result<(), DopError> {
878 if !azimuth_deg.is_finite() {
879 return Err(invalid_input("azimuth_deg", "not finite"));
880 }
881 if !elevation_deg.is_finite() {
882 return Err(invalid_input("elevation_deg", "not finite"));
883 }
884 if !(-90.0..=90.0).contains(&elevation_deg) {
885 return Err(invalid_input("elevation_deg", "out of range"));
886 }
887 validate_receiver(receiver)
888}
889
890fn invalid_input(field: &'static str, reason: &'static str) -> DopError {
891 DopError::InvalidInput { field, reason }
892}
893
894fn map_linear_error(error: LinearError) -> DopError {
895 match error {
896 LinearError::InvalidInput { field, reason } => invalid_input(field, reason),
897 }
898}
899
900fn map_validation_error(error: validate::FieldError) -> DopError {
901 invalid_input(error.field(), error.reason())
902}
903
904fn rotate3(q: &[[f64; 3]; 3], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
910 inline_rxr(&inline_rxr(r, q), &inline_tr(r))
911}
912
913pub(crate) fn dop_multi(
938 los: &[LineOfSight],
939 clock_index: &[usize],
940 systems: &[GnssSystem],
941 n_clocks: usize,
942 weights: &[f64],
943 receiver: Wgs84Geodetic,
944) -> Result<Dop, DopError> {
945 validate_dop_multi_inputs(los, clock_index, systems, n_clocks, weights, receiver)?;
946 let p = 3 + n_clocks;
947 if los.len() < p {
948 return Err(DopError::TooFewSatellites);
949 }
950
951 let mut a = vec![vec![0.0_f64; p]; p];
952 for k in 0..los.len() {
953 let mut row = vec![0.0_f64; p];
954 row[0] = -los[k].e_x;
955 row[1] = -los[k].e_y;
956 row[2] = -los[k].e_z;
957 row[3 + clock_index[k]] = 1.0;
958 let w = weights[k];
959 #[allow(clippy::needless_range_loop)]
960 for i in 0..p {
961 for j in 0..p {
962 a[i][j] += row[i] * w * row[j];
963 }
964 }
965 }
966 let q = invert_symmetric_pd(&a).ok_or(DopError::Singular)?;
967
968 let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
969 let qpos = [
970 [q[0][0], q[0][1], q[0][2]],
971 [q[1][0], q[1][1], q[1][2]],
972 [q[2][0], q[2][1], q[2][2]],
973 ];
974 let enu = rotate3(&qpos, &r);
975
976 let qe = enu[0][0];
977 let qn = enu[1][1];
978 let qu = enu[2][2];
979 let qt = q[3][3];
980 let trace: f64 = (0..p).map(|i| q[i][i]).sum();
981 let system_tdop_args: Vec<f64> = (0..n_clocks).map(|i| q[3 + i][3 + i]).collect();
984
985 let gdop_arg = trace;
986 let pdop_arg = qe + qn + qu;
987 let hdop_arg = qe + qn;
988 let vdop_arg = qu;
989 let tdop_arg = qt;
990 for &arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg]
995 .iter()
996 .chain(system_tdop_args.iter())
997 {
998 #[allow(clippy::neg_cmp_op_on_partial_ord)]
999 let nonpositive_or_nan = !(arg >= 0.0);
1000 if nonpositive_or_nan || !arg.is_finite() {
1001 return Err(DopError::Singular);
1002 }
1003 }
1004
1005 Ok(Dop {
1006 gdop: gdop_arg.sqrt(),
1007 pdop: pdop_arg.sqrt(),
1008 hdop: hdop_arg.sqrt(),
1009 vdop: vdop_arg.sqrt(),
1010 tdop: tdop_arg.sqrt(),
1011 system_tdops: system_tdop_args
1012 .iter()
1013 .enumerate()
1014 .map(|(i, &v)| (systems[i], v.sqrt()))
1015 .collect(),
1016 })
1017}
1018
1019fn validate_dop_multi_inputs(
1020 los: &[LineOfSight],
1021 clock_index: &[usize],
1022 systems: &[GnssSystem],
1023 n_clocks: usize,
1024 weights: &[f64],
1025 receiver: Wgs84Geodetic,
1026) -> Result<(), DopError> {
1027 if los.len() != weights.len() {
1028 return Err(invalid_input("weights", "length must match los"));
1029 }
1030 if los.len() != clock_index.len() {
1031 return Err(invalid_input("clock_index", "length must match los"));
1032 }
1033 if n_clocks == 0 {
1034 return Err(invalid_input("n_clocks", "not positive"));
1035 }
1036 if systems.len() != n_clocks {
1037 return Err(invalid_input("systems", "length must match n_clocks"));
1038 }
1039 if clock_index.iter().any(|&idx| idx >= n_clocks) {
1040 return Err(invalid_input("clock_index", "out of range"));
1041 }
1042 validate_los(los)?;
1043 validate_weights(weights)?;
1044 validate_receiver(receiver)
1045}
1046
1047#[cfg(all(test, sidereon_repo_tests))]
1048pub(crate) mod test_support {
1049 use super::*;
1053
1054 pub(crate) fn normal_matrix_for(los: &[LineOfSight], weights: &[f64]) -> [[f64; 4]; 4] {
1055 let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
1056 normal_matrix_4_weighted_column_outer(&rows, weights).expect("valid DOP test inputs")
1057 }
1058
1059 pub(crate) fn det4_for(a: &[[f64; 4]; 4]) -> f64 {
1060 crate::astro::math::linear::det4_cofactor(a)
1061 }
1062
1063 pub(crate) fn inv4_for(a: &[[f64; 4]; 4]) -> Option<[[f64; 4]; 4]> {
1064 invert_4x4_cofactor(a)
1065 }
1066
1067 pub(crate) fn enu_block_for(q: &[[f64; 4]; 4], lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
1068 let r = ecef_to_enu_rotation(lat_rad, lon_rad);
1069 rotate_pos_block(q, &r)
1070 }
1071}
1072
1073#[cfg(test)]
1074mod public_api_tests {
1075 use super::*;
1076
1077 fn receiver() -> Wgs84Geodetic {
1078 Wgs84Geodetic::new(45.0_f64.to_radians(), -75.0_f64.to_radians(), 100.0)
1079 .expect("valid receiver")
1080 }
1081
1082 fn sample_geometry() -> (Vec<LineOfSight>, Vec<f64>, Wgs84Geodetic) {
1083 let rx = receiver();
1084 let az_el = [
1085 (5.0, 25.0),
1086 (80.0, 35.0),
1087 (155.0, 55.0),
1088 (235.0, 40.0),
1089 (310.0, 65.0),
1090 ];
1091 let los = az_el
1092 .into_iter()
1093 .map(|(az, el)| line_of_sight_from_az_el_deg(az, el, rx).expect("valid LOS"))
1094 .collect::<Vec<_>>();
1095 let weights = vec![1.0, 0.8, 1.4, 0.9, 1.1];
1096 (los, weights, rx)
1097 }
1098
1099 #[test]
1100 fn geometry_cofactor_exposes_the_dop_position_block() {
1101 let (los, weights, rx) = sample_geometry();
1102 let d = dop(&los, &weights, rx).expect("DOP");
1103 let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
1104
1105 let qe = q.position_enu[0][0];
1106 let qn = q.position_enu[1][1];
1107 let qu = q.position_enu[2][2];
1108 assert_eq!(d.pdop.to_bits(), (qe + qn + qu).sqrt().to_bits());
1109 assert_eq!(d.hdop.to_bits(), (qe + qn).sqrt().to_bits());
1110 assert_eq!(d.vdop.to_bits(), qu.sqrt().to_bits());
1111 assert_eq!(d.tdop.to_bits(), q.state[3][3].sqrt().to_bits());
1112 }
1113
1114 #[test]
1115 fn position_covariance_scales_the_raw_cofactor() {
1116 let (los, weights, rx) = sample_geometry();
1117 let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
1118 let cov =
1119 position_covariance_from_geometry_m2(&los, &weights, rx, 4.0).expect("covariance");
1120 for i in 0..3 {
1121 for j in 0..3 {
1122 assert_eq!(
1123 cov.ecef_m2[i][j].to_bits(),
1124 (q.position_ecef[i][j] * 4.0).to_bits()
1125 );
1126 assert_eq!(
1127 cov.enu_m2[i][j].to_bits(),
1128 (q.position_enu[i][j] * 4.0).to_bits()
1129 );
1130 }
1131 }
1132 }
1133
1134 #[test]
1135 fn horizontal_error_ellipse_uses_chi_square_two_dof_scale() {
1136 let covariance = [[9.0, 2.0, 0.0], [2.0, 4.0, 0.0], [0.0, 0.0, 16.0]];
1137 let ellipse = horizontal_error_ellipse(covariance, 0.95).expect("ellipse");
1138 let expected_scale = -2.0 * (1.0_f64 - 0.95).ln();
1139 assert_eq!(ellipse.chi_square_scale.to_bits(), expected_scale.to_bits());
1140 assert!(ellipse.semi_major_m >= ellipse.semi_minor_m);
1141 assert!(ellipse.semi_minor_m > 0.0);
1142 assert!(ellipse.azimuth_rad.is_finite());
1143 }
1144
1145 #[test]
1146 fn error_ellipse_2x2_matches_numpy_eigh() {
1147 let ellipse = error_ellipse_2x2([[9.0, 2.0], [2.0, 4.0]], 0.95).expect("ellipse");
1149 assert!((ellipse.chi_square_scale - 5.99146454710798).abs() < 1e-12);
1150 assert!((ellipse.semi_major - 7.6240780089041085).abs() < 1e-12);
1151 assert!((ellipse.semi_minor - 4.445500379771495).abs() < 1e-12);
1152 assert!((ellipse.orientation_rad - 0.3373704711117763).abs() < 1e-12);
1153 }
1154
1155 #[test]
1156 fn horizontal_error_ellipse_delegates_to_2x2_primitive() {
1157 let cov3 = [[9.0, 2.0, 1.0], [2.0, 4.0, -3.0], [1.0, -3.0, 16.0]];
1160 let wrapper = horizontal_error_ellipse(cov3, 0.95).expect("wrapper");
1161 let primitive =
1162 error_ellipse_2x2([[cov3[0][0], cov3[0][1]], [cov3[1][0], cov3[1][1]]], 0.95)
1163 .expect("primitive");
1164 assert_eq!(
1165 wrapper.semi_major_m.to_bits(),
1166 primitive.semi_major.to_bits()
1167 );
1168 assert_eq!(
1169 wrapper.semi_minor_m.to_bits(),
1170 primitive.semi_minor.to_bits()
1171 );
1172 assert_eq!(
1173 wrapper.azimuth_rad.to_bits(),
1174 primitive.orientation_rad.to_bits()
1175 );
1176 }
1177
1178 #[test]
1179 fn geocentric_convention_changes_only_horizontal_vertical_split() {
1180 let (los, weights, rx) = sample_geometry();
1181 let geodetic = dop(&los, &weights, rx).expect("geodetic DOP");
1182 let geocentric = dop_with_convention(&los, &weights, rx, EnuConvention::GeocentricRadial)
1183 .expect("geocentric DOP");
1184
1185 assert_eq!(geodetic.gdop.to_bits(), geocentric.gdop.to_bits());
1188 assert_eq!(geodetic.tdop.to_bits(), geocentric.tdop.to_bits());
1189
1190 assert!((geodetic.pdop - geocentric.pdop).abs() < 1e-9 * geodetic.pdop);
1192
1193 let hdop_rel = (geodetic.hdop - geocentric.hdop).abs() / geodetic.hdop;
1196 assert!(hdop_rel > 0.0, "convention must change HDOP");
1197 assert!(
1198 hdop_rel < 1e-2,
1199 "HDOP shift {hdop_rel} larger than expected"
1200 );
1201 assert_ne!(geodetic.vdop.to_bits(), geocentric.vdop.to_bits());
1202 }
1203
1204 #[test]
1205 fn geocentric_convention_rejects_zero_radius_receiver() {
1206 let geocenter = Wgs84Geodetic::new(0.0, 0.0, -crate::astro::constants::earth::WGS84_A_M)
1211 .expect("valid geodetic receiver");
1212 let (los, weights, _) = sample_geometry();
1213 let err = dop_with_convention(&los, &weights, geocenter, EnuConvention::GeocentricRadial)
1214 .expect_err("zero-radius geocentric up must be rejected");
1215 assert!(matches!(
1216 err,
1217 DopError::InvalidInput {
1218 field: "receiver",
1219 ..
1220 }
1221 ));
1222
1223 assert!(
1226 dop_with_convention(&los, &weights, geocenter, EnuConvention::GeodeticNormal).is_ok()
1227 );
1228 }
1229
1230 #[test]
1231 fn default_dop_equals_explicit_geodetic_convention_bit_for_bit() {
1232 let (los, weights, rx) = sample_geometry();
1233 let default = dop(&los, &weights, rx).expect("default");
1234 let explicit =
1235 dop_with_convention(&los, &weights, rx, EnuConvention::GeodeticNormal).expect("geo");
1236 assert_eq!(default.hdop.to_bits(), explicit.hdop.to_bits());
1237 assert_eq!(default.vdop.to_bits(), explicit.vdop.to_bits());
1238 assert_eq!(default.pdop.to_bits(), explicit.pdop.to_bits());
1239 }
1240}
1241
1242#[cfg(all(test, sidereon_repo_tests))]
1243mod tests;