1use crate::astro::math::linear::{
39 invert_4x4_cofactor, invert_symmetric_pd, normal_matrix_4_weighted_column_outer, LinearError,
40};
41use crate::astro::math::mat3::{inline_rxr, inline_tr};
42
43use crate::frame::Wgs84Geodetic;
44use crate::id::GnssSystem;
45use crate::validate;
46
47const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
48const LOS_UNIT_TOLERANCE: f64 = 1.0e-3;
49
50#[derive(Debug, Clone, Copy, PartialEq)]
57pub struct LineOfSight {
58 pub e_x: f64,
60 pub e_y: f64,
62 pub e_z: f64,
64}
65
66impl LineOfSight {
67 pub const fn new(e_x: f64, e_y: f64, e_z: f64) -> Self {
69 Self { e_x, e_y, e_z }
70 }
71
72 fn design_row(self) -> [f64; 4] {
74 [-self.e_x, -self.e_y, -self.e_z, 1.0]
75 }
76}
77
78pub fn line_of_sight_from_az_el_deg(
85 azimuth_deg: f64,
86 elevation_deg: f64,
87 receiver: Wgs84Geodetic,
88) -> Result<LineOfSight, DopError> {
89 validate_az_el_receiver(azimuth_deg, elevation_deg, receiver)?;
90 let az = azimuth_deg * DEG_TO_RAD;
91 let el = elevation_deg * DEG_TO_RAD;
92 let cos_el = el.cos();
93 let east = cos_el * az.sin();
94 let north = cos_el * az.cos();
95 let up = el.sin();
96
97 let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
98 let e_x = r[0][0] * east + r[1][0] * north + r[2][0] * up;
99 let e_y = r[0][1] * east + r[1][1] * north + r[2][1] * up;
100 let e_z = r[0][2] * east + r[1][2] * north + r[2][2] * up;
101 let los = LineOfSight::new(e_x, e_y, e_z);
102 validate_los(core::slice::from_ref(&los))?;
103 Ok(los)
104}
105
106#[derive(Debug, Clone, PartialEq)]
116pub struct Dop {
117 pub gdop: f64,
121 pub pdop: f64,
123 pub hdop: f64,
125 pub vdop: f64,
127 pub tdop: f64,
131 pub system_tdops: Vec<(GnssSystem, f64)>,
144}
145
146#[derive(Debug, Clone, PartialEq)]
153pub struct GeometryCofactor {
154 pub state: [[f64; 4]; 4],
156 pub position_ecef: [[f64; 3]; 3],
158 pub position_enu: [[f64; 3]; 3],
160}
161
162#[derive(Debug, Clone, Copy, PartialEq)]
167pub struct PositionCovariance {
168 pub ecef_m2: [[f64; 3]; 3],
170 pub enu_m2: [[f64; 3]; 3],
172}
173
174#[derive(Debug, Clone, Copy, PartialEq)]
180pub struct HorizontalErrorEllipse {
181 pub confidence: f64,
183 pub chi_square_scale: f64,
185 pub semi_major_m: f64,
187 pub semi_minor_m: f64,
189 pub azimuth_rad: f64,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum DopError {
196 InvalidInput {
198 field: &'static str,
200 reason: &'static str,
202 },
203 TooFewSatellites,
207 Singular,
210}
211
212impl core::fmt::Display for DopError {
213 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
214 match self {
215 DopError::InvalidInput { field, reason } => {
216 write!(f, "invalid DOP input {field}: {reason}")
217 }
218 DopError::TooFewSatellites => {
219 write!(
220 f,
221 "fewer satellites than parameters: geometry is rank-deficient"
222 )
223 }
224 DopError::Singular => {
225 write!(f, "singular or ill-conditioned geometry: no finite DOP")
226 }
227 }
228 }
229}
230
231impl std::error::Error for DopError {}
232
233fn ecef_to_enu_rotation(lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
235 let sphi = lat_rad.sin();
236 let cphi = lat_rad.cos();
237 let slam = lon_rad.sin();
238 let clam = lon_rad.cos();
239 [
240 [-slam, clam, 0.0],
241 [-sphi * clam, -sphi * slam, cphi],
242 [cphi * clam, cphi * slam, sphi],
243 ]
244}
245
246fn rotate_pos_block(q: &[[f64; 4]; 4], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
250 let qpos = [
251 [q[0][0], q[0][1], q[0][2]],
252 [q[1][0], q[1][1], q[1][2]],
253 [q[2][0], q[2][1], q[2][2]],
254 ];
255 rotate3(&qpos, r)
256}
257
258pub fn dop(los: &[LineOfSight], weights: &[f64], receiver: Wgs84Geodetic) -> Result<Dop, DopError> {
267 validate_dop_inputs(los, weights, receiver)?;
268 if los.len() < 4 {
269 return Err(DopError::TooFewSatellites);
270 }
271
272 let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
273 let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
274 let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
275
276 let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
277 let enu = rotate_pos_block(&q, &r);
278
279 let qe = enu[0][0];
280 let qn = enu[1][1];
281 let qu = enu[2][2];
282 let qt = q[3][3];
283
284 let gdop_arg = q[0][0] + q[1][1] + q[2][2] + q[3][3];
292 let pdop_arg = qe + qn + qu;
293 let hdop_arg = qe + qn;
294 let vdop_arg = qu;
295 let tdop_arg = qt;
296 for arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg] {
297 #[allow(clippy::neg_cmp_op_on_partial_ord)]
299 let nonpositive_or_nan = !(arg >= 0.0);
300 if nonpositive_or_nan || !arg.is_finite() {
301 return Err(DopError::Singular);
302 }
303 }
304
305 Ok(Dop {
306 gdop: gdop_arg.sqrt(),
307 pdop: pdop_arg.sqrt(),
308 hdop: hdop_arg.sqrt(),
309 vdop: vdop_arg.sqrt(),
310 tdop: tdop_arg.sqrt(),
311 system_tdops: Vec::new(),
316 })
317}
318
319pub fn geometry_cofactor(
325 los: &[LineOfSight],
326 weights: &[f64],
327 receiver: Wgs84Geodetic,
328) -> Result<GeometryCofactor, DopError> {
329 validate_dop_inputs(los, weights, receiver)?;
330 if los.len() < 4 {
331 return Err(DopError::TooFewSatellites);
332 }
333
334 let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
335 let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
336 let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
337 validate_cofactor_variances(&q)?;
338
339 let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
340 let enu = rotate_pos_block(&q, &r);
341 validate_matrix3(&enu, "position_enu")?;
342 Ok(GeometryCofactor {
343 state: q,
344 position_ecef: position_block(&q),
345 position_enu: enu,
346 })
347}
348
349pub fn position_covariance_from_geometry_m2(
355 los: &[LineOfSight],
356 weights: &[f64],
357 receiver: Wgs84Geodetic,
358 range_variance_scale_m2: f64,
359) -> Result<PositionCovariance, DopError> {
360 validate_variance_scale(range_variance_scale_m2)?;
361 let cofactor = geometry_cofactor(los, weights, receiver)?;
362 Ok(PositionCovariance {
363 ecef_m2: scale_matrix3(cofactor.position_ecef, range_variance_scale_m2),
364 enu_m2: scale_matrix3(cofactor.position_enu, range_variance_scale_m2),
365 })
366}
367
368pub fn horizontal_error_ellipse(
374 covariance_enu_m2: [[f64; 3]; 3],
375 confidence: f64,
376) -> Result<HorizontalErrorEllipse, DopError> {
377 validate_matrix3(&covariance_enu_m2, "covariance_enu_m2")?;
378 validate_confidence(confidence)?;
379
380 let a = covariance_enu_m2[0][0];
381 let b = 0.5 * (covariance_enu_m2[0][1] + covariance_enu_m2[1][0]);
382 let c = covariance_enu_m2[1][1];
383 let half_delta = 0.5 * (a - c);
384 let center = 0.5 * (a + c);
385 let root = (half_delta * half_delta + b * b).sqrt();
386 let lambda_major = center + root;
387 let lambda_minor = center - root;
388 if !lambda_major.is_finite() || !lambda_minor.is_finite() || lambda_minor < -1.0e-12 {
389 return Err(invalid_input(
390 "covariance_enu_m2",
391 "not positive semidefinite",
392 ));
393 }
394
395 let chi_square_scale = -2.0 * (1.0 - confidence).ln();
396 let semi_major_m = (lambda_major.max(0.0) * chi_square_scale).sqrt();
397 let semi_minor_m = (lambda_minor.max(0.0) * chi_square_scale).sqrt();
398 let azimuth_rad = if root == 0.0 {
399 0.0
400 } else {
401 0.5 * (2.0 * b).atan2(a - c)
402 };
403 Ok(HorizontalErrorEllipse {
404 confidence,
405 chi_square_scale,
406 semi_major_m,
407 semi_minor_m,
408 azimuth_rad,
409 })
410}
411
412pub fn error_ellipse_from_geometry(
414 los: &[LineOfSight],
415 weights: &[f64],
416 receiver: Wgs84Geodetic,
417 range_variance_scale_m2: f64,
418 confidence: f64,
419) -> Result<HorizontalErrorEllipse, DopError> {
420 let covariance =
421 position_covariance_from_geometry_m2(los, weights, receiver, range_variance_scale_m2)?;
422 horizontal_error_ellipse(covariance.enu_m2, confidence)
423}
424
425fn validate_dop_inputs(
426 los: &[LineOfSight],
427 weights: &[f64],
428 receiver: Wgs84Geodetic,
429) -> Result<(), DopError> {
430 if los.len() != weights.len() {
431 return Err(invalid_input("weights", "length must match los"));
432 }
433 validate_los(los)?;
434 validate_weights(weights)?;
435 validate_receiver(receiver)
436}
437
438fn validate_los(los: &[LineOfSight]) -> Result<(), DopError> {
439 for line in los {
440 if !(line.e_x.is_finite() && line.e_y.is_finite() && line.e_z.is_finite()) {
441 return Err(invalid_input("los", "not finite"));
442 }
443 let norm = (line.e_x * line.e_x + line.e_y * line.e_y + line.e_z * line.e_z).sqrt();
444 if !norm.is_finite() {
445 return Err(invalid_input("los", "not finite"));
446 }
447 if (norm - 1.0).abs() > LOS_UNIT_TOLERANCE {
448 return Err(invalid_input("los", "not unit length"));
449 }
450 }
451 Ok(())
452}
453
454fn validate_cofactor_variances(q: &[[f64; 4]; 4]) -> Result<(), DopError> {
455 for row in q {
456 validate::finite_slice(row, "cofactor").map_err(map_validation_error)?;
457 }
458 for (idx, row) in q.iter().enumerate() {
459 let variance = row[idx];
460 #[allow(clippy::neg_cmp_op_on_partial_ord)]
461 let negative_or_nan = !(variance >= 0.0);
462 if negative_or_nan || !variance.is_finite() {
463 return Err(DopError::Singular);
464 }
465 }
466 Ok(())
467}
468
469fn validate_variance_scale(value: f64) -> Result<(), DopError> {
470 if !value.is_finite() {
471 return Err(invalid_input("range_variance_scale_m2", "not finite"));
472 }
473 if value < 0.0 {
474 return Err(invalid_input("range_variance_scale_m2", "negative"));
475 }
476 Ok(())
477}
478
479fn validate_confidence(value: f64) -> Result<(), DopError> {
480 if !value.is_finite() {
481 return Err(invalid_input("confidence", "not finite"));
482 }
483 if !(0.0..1.0).contains(&value) {
484 return Err(invalid_input("confidence", "out of range"));
485 }
486 Ok(())
487}
488
489fn validate_matrix3(matrix: &[[f64; 3]; 3], field: &'static str) -> Result<(), DopError> {
490 for row in matrix {
491 validate::finite_slice(row, field).map_err(map_validation_error)?;
492 }
493 Ok(())
494}
495
496fn position_block(q: &[[f64; 4]; 4]) -> [[f64; 3]; 3] {
497 [
498 [q[0][0], q[0][1], q[0][2]],
499 [q[1][0], q[1][1], q[1][2]],
500 [q[2][0], q[2][1], q[2][2]],
501 ]
502}
503
504fn scale_matrix3(matrix: [[f64; 3]; 3], scale: f64) -> [[f64; 3]; 3] {
505 [
506 [
507 matrix[0][0] * scale,
508 matrix[0][1] * scale,
509 matrix[0][2] * scale,
510 ],
511 [
512 matrix[1][0] * scale,
513 matrix[1][1] * scale,
514 matrix[1][2] * scale,
515 ],
516 [
517 matrix[2][0] * scale,
518 matrix[2][1] * scale,
519 matrix[2][2] * scale,
520 ],
521 ]
522}
523
524fn validate_weights(weights: &[f64]) -> Result<(), DopError> {
525 if weights.iter().any(|weight| !weight.is_finite()) {
526 return Err(invalid_input("weights", "not finite"));
527 }
528 if weights.iter().any(|&weight| weight < 0.0) {
529 return Err(invalid_input("weights", "negative"));
530 }
531 Ok(())
532}
533
534fn validate_receiver(receiver: Wgs84Geodetic) -> Result<(), DopError> {
535 if !(receiver.lat_rad.is_finite()
536 && receiver.lon_rad.is_finite()
537 && receiver.height_m.is_finite())
538 {
539 return Err(invalid_input("receiver", "not finite"));
540 }
541 if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
542 return Err(invalid_input("receiver.lat_rad", "out of range"));
543 }
544 if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
545 return Err(invalid_input("receiver.lon_rad", "out of range"));
546 }
547 Ok(())
548}
549
550fn validate_az_el_receiver(
551 azimuth_deg: f64,
552 elevation_deg: f64,
553 receiver: Wgs84Geodetic,
554) -> Result<(), DopError> {
555 if !azimuth_deg.is_finite() {
556 return Err(invalid_input("azimuth_deg", "not finite"));
557 }
558 if !elevation_deg.is_finite() {
559 return Err(invalid_input("elevation_deg", "not finite"));
560 }
561 if !(-90.0..=90.0).contains(&elevation_deg) {
562 return Err(invalid_input("elevation_deg", "out of range"));
563 }
564 validate_receiver(receiver)
565}
566
567fn invalid_input(field: &'static str, reason: &'static str) -> DopError {
568 DopError::InvalidInput { field, reason }
569}
570
571fn map_linear_error(error: LinearError) -> DopError {
572 match error {
573 LinearError::InvalidInput { field, reason } => invalid_input(field, reason),
574 }
575}
576
577fn map_validation_error(error: validate::FieldError) -> DopError {
578 invalid_input(error.field(), error.reason())
579}
580
581fn rotate3(q: &[[f64; 3]; 3], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
587 inline_rxr(&inline_rxr(r, q), &inline_tr(r))
588}
589
590pub(crate) fn dop_multi(
615 los: &[LineOfSight],
616 clock_index: &[usize],
617 systems: &[GnssSystem],
618 n_clocks: usize,
619 weights: &[f64],
620 receiver: Wgs84Geodetic,
621) -> Result<Dop, DopError> {
622 validate_dop_multi_inputs(los, clock_index, systems, n_clocks, weights, receiver)?;
623 let p = 3 + n_clocks;
624 if los.len() < p {
625 return Err(DopError::TooFewSatellites);
626 }
627
628 let mut a = vec![vec![0.0_f64; p]; p];
629 for k in 0..los.len() {
630 let mut row = vec![0.0_f64; p];
631 row[0] = -los[k].e_x;
632 row[1] = -los[k].e_y;
633 row[2] = -los[k].e_z;
634 row[3 + clock_index[k]] = 1.0;
635 let w = weights[k];
636 #[allow(clippy::needless_range_loop)]
637 for i in 0..p {
638 for j in 0..p {
639 a[i][j] += row[i] * w * row[j];
640 }
641 }
642 }
643 let q = invert_symmetric_pd(&a).ok_or(DopError::Singular)?;
644
645 let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
646 let qpos = [
647 [q[0][0], q[0][1], q[0][2]],
648 [q[1][0], q[1][1], q[1][2]],
649 [q[2][0], q[2][1], q[2][2]],
650 ];
651 let enu = rotate3(&qpos, &r);
652
653 let qe = enu[0][0];
654 let qn = enu[1][1];
655 let qu = enu[2][2];
656 let qt = q[3][3];
657 let trace: f64 = (0..p).map(|i| q[i][i]).sum();
658 let system_tdop_args: Vec<f64> = (0..n_clocks).map(|i| q[3 + i][3 + i]).collect();
661
662 let gdop_arg = trace;
663 let pdop_arg = qe + qn + qu;
664 let hdop_arg = qe + qn;
665 let vdop_arg = qu;
666 let tdop_arg = qt;
667 for &arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg]
672 .iter()
673 .chain(system_tdop_args.iter())
674 {
675 #[allow(clippy::neg_cmp_op_on_partial_ord)]
676 let nonpositive_or_nan = !(arg >= 0.0);
677 if nonpositive_or_nan || !arg.is_finite() {
678 return Err(DopError::Singular);
679 }
680 }
681
682 Ok(Dop {
683 gdop: gdop_arg.sqrt(),
684 pdop: pdop_arg.sqrt(),
685 hdop: hdop_arg.sqrt(),
686 vdop: vdop_arg.sqrt(),
687 tdop: tdop_arg.sqrt(),
688 system_tdops: system_tdop_args
689 .iter()
690 .enumerate()
691 .map(|(i, &v)| (systems[i], v.sqrt()))
692 .collect(),
693 })
694}
695
696fn validate_dop_multi_inputs(
697 los: &[LineOfSight],
698 clock_index: &[usize],
699 systems: &[GnssSystem],
700 n_clocks: usize,
701 weights: &[f64],
702 receiver: Wgs84Geodetic,
703) -> Result<(), DopError> {
704 if los.len() != weights.len() {
705 return Err(invalid_input("weights", "length must match los"));
706 }
707 if los.len() != clock_index.len() {
708 return Err(invalid_input("clock_index", "length must match los"));
709 }
710 if n_clocks == 0 {
711 return Err(invalid_input("n_clocks", "not positive"));
712 }
713 if systems.len() != n_clocks {
714 return Err(invalid_input("systems", "length must match n_clocks"));
715 }
716 if clock_index.iter().any(|&idx| idx >= n_clocks) {
717 return Err(invalid_input("clock_index", "out of range"));
718 }
719 validate_los(los)?;
720 validate_weights(weights)?;
721 validate_receiver(receiver)
722}
723
724#[cfg(all(test, sidereon_repo_tests))]
725pub(crate) mod test_support {
726 use super::*;
730
731 pub(crate) fn normal_matrix_for(los: &[LineOfSight], weights: &[f64]) -> [[f64; 4]; 4] {
732 let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
733 normal_matrix_4_weighted_column_outer(&rows, weights).expect("valid DOP test inputs")
734 }
735
736 pub(crate) fn det4_for(a: &[[f64; 4]; 4]) -> f64 {
737 crate::astro::math::linear::det4_cofactor(a)
738 }
739
740 pub(crate) fn inv4_for(a: &[[f64; 4]; 4]) -> Option<[[f64; 4]; 4]> {
741 invert_4x4_cofactor(a)
742 }
743
744 pub(crate) fn enu_block_for(q: &[[f64; 4]; 4], lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
745 let r = ecef_to_enu_rotation(lat_rad, lon_rad);
746 rotate_pos_block(q, &r)
747 }
748}
749
750#[cfg(test)]
751mod public_api_tests {
752 use super::*;
753
754 fn receiver() -> Wgs84Geodetic {
755 Wgs84Geodetic::new(45.0_f64.to_radians(), -75.0_f64.to_radians(), 100.0)
756 .expect("valid receiver")
757 }
758
759 fn sample_geometry() -> (Vec<LineOfSight>, Vec<f64>, Wgs84Geodetic) {
760 let rx = receiver();
761 let az_el = [
762 (5.0, 25.0),
763 (80.0, 35.0),
764 (155.0, 55.0),
765 (235.0, 40.0),
766 (310.0, 65.0),
767 ];
768 let los = az_el
769 .into_iter()
770 .map(|(az, el)| line_of_sight_from_az_el_deg(az, el, rx).expect("valid LOS"))
771 .collect::<Vec<_>>();
772 let weights = vec![1.0, 0.8, 1.4, 0.9, 1.1];
773 (los, weights, rx)
774 }
775
776 #[test]
777 fn geometry_cofactor_exposes_the_dop_position_block() {
778 let (los, weights, rx) = sample_geometry();
779 let d = dop(&los, &weights, rx).expect("DOP");
780 let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
781
782 let qe = q.position_enu[0][0];
783 let qn = q.position_enu[1][1];
784 let qu = q.position_enu[2][2];
785 assert_eq!(d.pdop.to_bits(), (qe + qn + qu).sqrt().to_bits());
786 assert_eq!(d.hdop.to_bits(), (qe + qn).sqrt().to_bits());
787 assert_eq!(d.vdop.to_bits(), qu.sqrt().to_bits());
788 assert_eq!(d.tdop.to_bits(), q.state[3][3].sqrt().to_bits());
789 }
790
791 #[test]
792 fn position_covariance_scales_the_raw_cofactor() {
793 let (los, weights, rx) = sample_geometry();
794 let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
795 let cov =
796 position_covariance_from_geometry_m2(&los, &weights, rx, 4.0).expect("covariance");
797 for i in 0..3 {
798 for j in 0..3 {
799 assert_eq!(
800 cov.ecef_m2[i][j].to_bits(),
801 (q.position_ecef[i][j] * 4.0).to_bits()
802 );
803 assert_eq!(
804 cov.enu_m2[i][j].to_bits(),
805 (q.position_enu[i][j] * 4.0).to_bits()
806 );
807 }
808 }
809 }
810
811 #[test]
812 fn horizontal_error_ellipse_uses_chi_square_two_dof_scale() {
813 let covariance = [[9.0, 2.0, 0.0], [2.0, 4.0, 0.0], [0.0, 0.0, 16.0]];
814 let ellipse = horizontal_error_ellipse(covariance, 0.95).expect("ellipse");
815 let expected_scale = -2.0 * (1.0_f64 - 0.95).ln();
816 assert_eq!(ellipse.chi_square_scale.to_bits(), expected_scale.to_bits());
817 assert!(ellipse.semi_major_m >= ellipse.semi_minor_m);
818 assert!(ellipse.semi_minor_m > 0.0);
819 assert!(ellipse.azimuth_rad.is_finite());
820 }
821}
822
823#[cfg(all(test, sidereon_repo_tests))]
824mod tests;