1use crate::astro::frames::transforms::itrs_to_geodetic_compute;
148use crate::astro::math::linear::{invert_matrix_last_tie, invert_symmetric_pd};
149use crate::constants::F_L1_HZ;
150use crate::estimation::substrate::parameters::undifferenced_design_row;
151use crate::geometry::{dop, DopError, LineOfSight, Wgs84Geodetic};
152use crate::observables::{predict, ObservableEphemerisSource, PredictOptions};
153use crate::quality::{chi2_inv, DEFAULT_P_FA};
154use crate::validate;
155
156use super::{
157 no_ephemeris, solve_float_epoch, state_from_solution, ztd_unknown_count, FloatEpoch,
158 FloatResidual, FloatSolution, FloatSolveConfig, FloatSolveError, FloatState,
159 TroposphereOptions,
160};
161
162const DEFAULT_MISSED_DETECTION_PROBABILITY: f64 = 1.0e-3;
163const DEFAULT_MEASUREMENT_SIGMA_M: f64 = 1.0;
164const RESIDUAL_COMPONENTS_PER_ROW: usize = 2;
165const SNAPSHOT_BASE_STATES: usize = 4;
166const LEVERAGE_TOLERANCE: f64 = 1.0e-12;
167const HIGH_LEVERAGE_RESIDUAL_ZERO_TOLERANCE: f64 = 1.0e-8;
168const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
169
170#[derive(Debug, Clone, Copy, PartialEq)]
172pub struct RaimConfig {
173 pub false_alarm_probability: f64,
175 pub missed_detection_probability: f64,
177 pub measurement_sigma_m: f64,
179 pub chi_square_threshold: Option<f64>,
182}
183
184impl Default for RaimConfig {
185 fn default() -> Self {
186 Self {
187 false_alarm_probability: DEFAULT_P_FA,
188 missed_detection_probability: DEFAULT_MISSED_DETECTION_PROBABILITY,
189 measurement_sigma_m: DEFAULT_MEASUREMENT_SIGMA_M,
190 chi_square_threshold: None,
191 }
192 }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum RaimStatus {
198 Passed,
200 FaultDetected,
202 NotEnoughRedundancy,
204}
205
206#[derive(Debug, Clone, PartialEq)]
208pub struct RaimResult {
209 pub status: RaimStatus,
211 pub detected: bool,
213 pub test_statistic: f64,
215 pub threshold: Option<f64>,
217 pub redundancy: isize,
219 pub satellite_statistics: Vec<SatelliteTestStatistic>,
221 pub most_likely_fault: Option<String>,
223 pub hpl_m: Option<f64>,
225 pub vpl_m: Option<f64>,
227}
228
229#[derive(Debug, Clone, PartialEq)]
231pub struct RaimGeometryRow {
232 pub satellite_id: String,
234 pub line_of_sight: LineOfSight,
236}
237
238#[derive(Debug, Clone, PartialEq)]
240pub struct SatelliteTestStatistic {
241 pub satellite_id: String,
243 pub code: f64,
245 pub phase: f64,
247 pub statistic: f64,
249}
250
251#[derive(Debug, Clone, PartialEq)]
253pub struct RaimIdentification {
254 pub statistics: Vec<SatelliteTestStatistic>,
256 pub most_likely_fault: Option<String>,
258}
259
260#[derive(Debug, Clone, Copy, PartialEq)]
262pub struct ProtectionLevels {
263 pub hpl_m: f64,
265 pub vpl_m: f64,
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum RaimFdeStatus {
272 Clean,
274 Restored,
276 CannotExclude,
278 IntegrityNotRestored,
280}
281
282#[derive(Debug, Clone, PartialEq)]
284pub struct RaimFdeResult {
285 pub solution: FloatSolution,
287 pub raim: RaimResult,
289 pub excluded_sats: Vec<String>,
291 pub status: RaimFdeStatus,
293}
294
295#[derive(Debug, Clone, PartialEq)]
297pub enum RaimFdeError {
298 Solve(FloatSolveError),
300 Raim(RaimError),
302}
303
304impl core::fmt::Display for RaimFdeError {
305 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
306 match self {
307 Self::Solve(error) => write!(f, "PPP FDE solve failed: {error}"),
308 Self::Raim(error) => write!(f, "PPP FDE RAIM check failed: {error}"),
309 }
310 }
311}
312
313impl std::error::Error for RaimFdeError {}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub enum RaimError {
318 InvalidConfig {
320 field: &'static str,
322 reason: &'static str,
324 },
325 InvalidResidual {
327 field: &'static str,
329 },
330 InvalidGeometry {
332 field: &'static str,
334 reason: &'static str,
336 },
337 Dop(DopError),
339 SingularGeometry,
341}
342
343impl core::fmt::Display for RaimError {
344 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
345 match self {
346 Self::InvalidConfig { field, reason } => {
347 write!(f, "invalid RAIM configuration {field}: {reason}")
348 }
349 Self::InvalidResidual { field } => write!(f, "invalid RAIM residual {field}"),
350 Self::InvalidGeometry { field, reason } => {
351 write!(f, "invalid RAIM geometry {field}: {reason}")
352 }
353 Self::Dop(error) => write!(f, "RAIM DOP failed: {error}"),
354 Self::SingularGeometry => write!(f, "singular RAIM geometry"),
355 }
356 }
357}
358
359impl std::error::Error for RaimError {}
360
361pub fn global_test(
368 residuals: &[FloatResidual],
369 n_states: usize,
370 config: RaimConfig,
371) -> Result<RaimResult, RaimError> {
372 validate_config(config)?;
373 let test_statistic = weighted_sse(residuals, config.measurement_sigma_m)?;
374 let n_obs = residuals
375 .len()
376 .checked_mul(RESIDUAL_COMPONENTS_PER_ROW)
377 .ok_or(RaimError::InvalidConfig {
378 field: "residuals",
379 reason: "too many residual rows",
380 })?;
381 let redundancy = n_obs as isize - n_states as isize;
382
383 if redundancy <= 0 {
384 return Ok(RaimResult {
385 status: RaimStatus::NotEnoughRedundancy,
386 detected: false,
387 test_statistic,
388 threshold: None,
389 redundancy,
390 satellite_statistics: Vec::new(),
391 most_likely_fault: None,
392 hpl_m: None,
393 vpl_m: None,
394 });
395 }
396
397 let threshold =
398 match config.chi_square_threshold {
399 Some(threshold) => threshold,
400 None => chi2_inv(1.0 - config.false_alarm_probability, redundancy as usize).map_err(
401 |_| RaimError::InvalidConfig {
402 field: "false_alarm_probability",
403 reason: "cannot derive chi-square threshold",
404 },
405 )?,
406 };
407 validate::finite_positive(threshold, "chi_square_threshold").map_err(|_| {
408 RaimError::InvalidConfig {
409 field: "chi_square_threshold",
410 reason: "must be positive and finite",
411 }
412 })?;
413 let detected = test_statistic > threshold;
414 Ok(RaimResult {
415 status: if detected {
416 RaimStatus::FaultDetected
417 } else {
418 RaimStatus::Passed
419 },
420 detected,
421 test_statistic,
422 threshold: Some(threshold),
423 redundancy,
424 satellite_statistics: Vec::new(),
425 most_likely_fault: None,
426 hpl_m: None,
427 vpl_m: None,
428 })
429}
430
431pub fn global_test_with_geometry(
437 residuals: &[FloatResidual],
438 geometry: &[RaimGeometryRow],
439 config: RaimConfig,
440) -> Result<RaimResult, RaimError> {
441 let n_states = snapshot_state_count_for_geometry(geometry)?;
442 let mut result = global_test(residuals, n_states, config)?;
443 if result.status == RaimStatus::NotEnoughRedundancy {
444 return Ok(result);
445 }
446 let identification = per_satellite_statistics(residuals, geometry, config)?;
447 result.satellite_statistics = identification.statistics;
448 result.most_likely_fault = identification.most_likely_fault;
449 Ok(result)
450}
451
452pub fn per_satellite_statistics(
459 residuals: &[FloatResidual],
460 geometry: &[RaimGeometryRow],
461 config: RaimConfig,
462) -> Result<RaimIdentification, RaimError> {
463 per_satellite_statistics_with_ztd(residuals, geometry, None, config)
464}
465
466fn per_satellite_statistics_with_ztd(
467 residuals: &[FloatResidual],
468 geometry: &[RaimGeometryRow],
469 ztd_mappings: Option<&[f64]>,
470 config: RaimConfig,
471) -> Result<RaimIdentification, RaimError> {
472 validate_config(config)?;
473 validate_geometry(residuals, geometry)?;
474 if let Some(mappings) = ztd_mappings {
475 validate_ztd_mappings(residuals.len(), mappings)?;
476 }
477 let rows = snapshot_design_rows(
478 residuals,
479 geometry,
480 ztd_mappings,
481 config.measurement_sigma_m,
482 )?;
483 let normal = normal_matrix(&rows)?;
484 let q = invert_matrix_last_tie(&normal).ok_or(RaimError::SingularGeometry)?;
485
486 let mut statistics = Vec::with_capacity(residuals.len());
487 let mut most_likely_fault = None;
488 let mut worst = f64::NEG_INFINITY;
489 for (idx, residual) in residuals.iter().enumerate() {
490 let code = standardized_abs(&rows[2 * idx], &q)?;
491 let phase = standardized_abs(&rows[2 * idx + 1], &q)?;
492 let statistic = code.max(phase);
493 if !code.is_finite() || !phase.is_finite() || !statistic.is_finite() {
494 return Err(RaimError::SingularGeometry);
495 }
496 if statistic > worst {
497 worst = statistic;
498 most_likely_fault = Some(residual.satellite_id.clone());
499 }
500 statistics.push(SatelliteTestStatistic {
501 satellite_id: residual.satellite_id.clone(),
502 code,
503 phase,
504 statistic,
505 });
506 }
507
508 Ok(RaimIdentification {
509 statistics,
510 most_likely_fault,
511 })
512}
513
514pub fn protection_levels(
520 geometry: &[RaimGeometryRow],
521 receiver: Wgs84Geodetic,
522 config: RaimConfig,
523) -> Result<ProtectionLevels, RaimError> {
524 validate_config(config)?;
525 validate_geometry_only(geometry)?;
526 let los = geometry
527 .iter()
528 .map(|row| row.line_of_sight)
529 .collect::<Vec<_>>();
530 let weights = vec![1.0; los.len()];
531 let d = dop(&los, &weights, receiver).map_err(RaimError::Dop)?;
532 let k_md = missed_detection_multiplier(config)?;
533 let hpl_m = k_md * config.measurement_sigma_m * d.hdop;
534 let vpl_m = k_md * config.measurement_sigma_m * d.vdop;
535 if hpl_m.is_finite() && hpl_m > 0.0 && vpl_m.is_finite() && vpl_m > 0.0 {
536 Ok(ProtectionLevels { hpl_m, vpl_m })
537 } else {
538 Err(RaimError::SingularGeometry)
539 }
540}
541
542fn protection_levels_with_ztd(
543 geometry: &[RaimGeometryRow],
544 ztd_mappings: Option<&[f64]>,
545 receiver: Wgs84Geodetic,
546 config: RaimConfig,
547) -> Result<ProtectionLevels, RaimError> {
548 let Some(ztd_mappings) = ztd_mappings else {
549 return protection_levels(geometry, receiver, config);
550 };
551
552 validate_config(config)?;
553 validate_geometry_only(geometry)?;
554 validate_ztd_mappings(geometry.len(), ztd_mappings)?;
555
556 let q = protection_position_covariance_with_ztd(geometry, ztd_mappings)?;
557 let enu = rotate_position_covariance_to_enu(&q, receiver);
558 scaled_protection_levels(enu[0][0] + enu[1][1], enu[2][2], config)
559}
560
561pub fn fde_float_epoch(
567 source: &dyn ObservableEphemerisSource,
568 epoch: FloatEpoch,
569 initial_state: FloatState,
570 solve_config: FloatSolveConfig,
571 raim_config: RaimConfig,
572) -> Result<RaimFdeResult, RaimFdeError> {
573 let mut current_epoch = epoch;
574 let mut seed_state = initial_state;
575 let mut excluded_sats = Vec::new();
576
577 loop {
578 let solution = solve_float_epoch(
579 source,
580 current_epoch.clone(),
581 seed_state.clone(),
582 solve_config.clone(),
583 )
584 .map_err(RaimFdeError::Solve)?;
585 let raim = raim_for_solution(
586 source,
587 ¤t_epoch,
588 &solution,
589 solve_config.tropo,
590 raim_config,
591 )
592 .map_err(RaimFdeError::Raim)?;
593
594 if raim.status == RaimStatus::NotEnoughRedundancy {
595 return Ok(RaimFdeResult {
596 solution,
597 raim,
598 excluded_sats,
599 status: RaimFdeStatus::CannotExclude,
600 });
601 }
602
603 if !raim.detected {
604 return Ok(RaimFdeResult {
605 solution,
606 raim,
607 status: if excluded_sats.is_empty() {
608 RaimFdeStatus::Clean
609 } else {
610 RaimFdeStatus::Restored
611 },
612 excluded_sats,
613 });
614 }
615
616 let Some(candidate) = raim.most_likely_fault.clone() else {
617 return Ok(RaimFdeResult {
618 solution,
619 raim,
620 excluded_sats,
621 status: RaimFdeStatus::IntegrityNotRestored,
622 });
623 };
624 let Some(next_epoch) = exclude_satellite(¤t_epoch, &candidate) else {
625 return Ok(RaimFdeResult {
626 solution,
627 raim,
628 excluded_sats,
629 status: RaimFdeStatus::IntegrityNotRestored,
630 });
631 };
632 if !has_positive_redundancy_after_exclusion(
633 next_epoch.observations.len(),
634 solve_config.tropo,
635 )
636 .map_err(RaimFdeError::Raim)?
637 {
638 return Ok(RaimFdeResult {
639 solution,
640 raim,
641 excluded_sats,
642 status: RaimFdeStatus::CannotExclude,
643 });
644 }
645
646 excluded_sats.push(candidate);
647 seed_state = state_from_solution(&solution, &seed_state);
648 current_epoch = next_epoch;
649 }
650}
651
652pub fn solve_float_epoch_with_raim(
658 source: &dyn ObservableEphemerisSource,
659 epoch: FloatEpoch,
660 initial_state: FloatState,
661 solve_config: FloatSolveConfig,
662 raim_config: RaimConfig,
663) -> Result<RaimFdeResult, RaimFdeError> {
664 fde_float_epoch(source, epoch, initial_state, solve_config, raim_config)
665}
666
667fn validate_config(config: RaimConfig) -> Result<(), RaimError> {
668 validate_probability(config.false_alarm_probability, "false_alarm_probability")?;
669 validate_probability(
670 config.missed_detection_probability,
671 "missed_detection_probability",
672 )?;
673 validate::finite_positive(config.measurement_sigma_m, "measurement_sigma_m")
674 .map(|_| ())
675 .map_err(|_| RaimError::InvalidConfig {
676 field: "measurement_sigma_m",
677 reason: "must be positive and finite",
678 })?;
679 if let Some(threshold) = config.chi_square_threshold {
680 validate::finite_positive(threshold, "chi_square_threshold")
681 .map(|_| ())
682 .map_err(|_| RaimError::InvalidConfig {
683 field: "chi_square_threshold",
684 reason: "must be positive and finite",
685 })?;
686 }
687 Ok(())
688}
689
690fn validate_probability(value: f64, field: &'static str) -> Result<(), RaimError> {
691 let value = validate::finite(value, field).map_err(|_| RaimError::InvalidConfig {
692 field,
693 reason: "must be finite",
694 })?;
695 if value > 0.0 && value < 1.0 {
696 Ok(())
697 } else {
698 Err(RaimError::InvalidConfig {
699 field,
700 reason: "must be inside (0, 1)",
701 })
702 }
703}
704
705fn weighted_sse(residuals: &[FloatResidual], measurement_sigma_m: f64) -> Result<f64, RaimError> {
706 let mut sse = 0.0;
707 for row in residuals {
708 let code_m = validate_residual(row.code_m, "code_m")?;
709 let phase_m = validate_residual(row.phase_m, "phase_m")?;
710 let code_weight = validate_weight(row.code_weight, "code_weight")?;
711 let phase_weight = validate_weight(row.phase_weight, "phase_weight")?;
712 let code = code_m * code_weight / measurement_sigma_m;
713 let phase = phase_m * phase_weight / measurement_sigma_m;
714 if !code.is_finite() || !phase.is_finite() {
715 return Err(RaimError::InvalidResidual {
716 field: "weighted_residual",
717 });
718 }
719 sse += code * code + phase * phase;
720 if !sse.is_finite() {
721 return Err(RaimError::InvalidResidual {
722 field: "weighted_sse",
723 });
724 }
725 }
726 Ok(sse)
727}
728
729fn validate_residual(value: f64, field: &'static str) -> Result<f64, RaimError> {
730 validate::finite(value, field).map_err(|_| RaimError::InvalidResidual { field })
731}
732
733fn validate_weight(value: f64, field: &'static str) -> Result<f64, RaimError> {
734 validate::finite_positive(value, field).map_err(|_| RaimError::InvalidResidual { field })
735}
736
737fn missed_detection_multiplier(config: RaimConfig) -> Result<f64, RaimError> {
738 chi2_inv(1.0 - config.missed_detection_probability, 1)
739 .map(f64::sqrt)
740 .map_err(|_| RaimError::InvalidConfig {
741 field: "missed_detection_probability",
742 reason: "cannot derive missed-detection multiplier",
743 })
744}
745
746fn raim_for_solution(
747 source: &dyn ObservableEphemerisSource,
748 epoch: &FloatEpoch,
749 solution: &FloatSolution,
750 tropo: TroposphereOptions,
751 config: RaimConfig,
752) -> Result<RaimResult, RaimError> {
753 let geometry = geometry_for_solution(source, epoch, solution, tropo).map_err(|_| {
754 RaimError::InvalidGeometry {
755 field: "solution",
756 reason: "could not build line-of-sight geometry",
757 }
758 })?;
759 validate_geometry(&solution.residuals_m, &geometry.rows)?;
760 let n_states = snapshot_state_count_for_solution(solution)?;
761 let mut result = global_test(&solution.residuals_m, n_states, config)?;
762 if result.status != RaimStatus::NotEnoughRedundancy {
763 let identification = per_satellite_statistics_with_ztd(
764 &solution.residuals_m,
765 &geometry.rows,
766 geometry.ztd_mappings.as_deref(),
767 config,
768 )?;
769 result.satellite_statistics = identification.statistics;
770 result.most_likely_fault = identification.most_likely_fault;
771 }
772 if result.status != RaimStatus::NotEnoughRedundancy {
773 let receiver = receiver_geodetic(solution.position_m);
774 let levels = protection_levels_with_ztd(
775 &geometry.rows,
776 geometry.ztd_mappings.as_deref(),
777 receiver,
778 config,
779 )?;
780 result.hpl_m = Some(levels.hpl_m);
781 result.vpl_m = Some(levels.vpl_m);
782 }
783 Ok(result)
784}
785
786#[derive(Debug, Clone)]
787struct RaimSolutionGeometry {
788 rows: Vec<RaimGeometryRow>,
789 ztd_mappings: Option<Vec<f64>>,
790}
791
792fn geometry_for_solution(
793 source: &dyn ObservableEphemerisSource,
794 epoch: &FloatEpoch,
795 solution: &FloatSolution,
796 tropo: TroposphereOptions,
797) -> Result<RaimSolutionGeometry, FloatSolveError> {
798 let mut rows = Vec::with_capacity(solution.residuals_m.len());
799 let mut ztd_mappings = solution
800 .ztd_residual_m
801 .is_some()
802 .then(|| Vec::with_capacity(solution.residuals_m.len()));
803 for residual in &solution.residuals_m {
804 let obs = epoch
805 .observations
806 .iter()
807 .find(|obs| obs.satellite_id == residual.satellite_id)
808 .ok_or(FloatSolveError::InvalidInput {
809 field: "raim geometry satellite_id",
810 reason: "residual satellite missing from epoch",
811 })?;
812 let pred = predict(
813 source,
814 obs.sat,
815 solution.position_m,
816 epoch.t_rx_j2000_s,
817 PredictOptions {
818 carrier_hz: F_L1_HZ,
819 light_time: true,
820 sagnac: true,
821 },
822 )
823 .map_err(|error| no_ephemeris(obs, error))?;
824 validate::finite_vec3(pred.los_unit, "raim geometry los_unit").map_err(|error| {
825 FloatSolveError::InvalidInput {
826 field: error.field(),
827 reason: error.reason(),
828 }
829 })?;
830 if let Some(mappings) = &mut ztd_mappings {
831 let tropo_model =
832 super::model::model_troposphere(&pred, solution.position_m, epoch, tropo)?;
833 validate::finite(tropo_model.ztd_mapping, "raim geometry ztd_mapping").map_err(
834 |error| FloatSolveError::InvalidInput {
835 field: error.field(),
836 reason: error.reason(),
837 },
838 )?;
839 mappings.push(tropo_model.ztd_mapping);
840 }
841 rows.push(RaimGeometryRow {
842 satellite_id: residual.satellite_id.clone(),
843 line_of_sight: LineOfSight::new(pred.los_unit[0], pred.los_unit[1], pred.los_unit[2]),
844 });
845 }
846 Ok(RaimSolutionGeometry { rows, ztd_mappings })
847}
848
849fn exclude_satellite(epoch: &FloatEpoch, satellite_id: &str) -> Option<FloatEpoch> {
850 let mut next = epoch.clone();
851 let before = next.observations.len();
852 next.observations
853 .retain(|obs| obs.satellite_id != satellite_id);
854 (next.observations.len() < before).then_some(next)
855}
856
857fn has_positive_redundancy_after_exclusion(
858 n_sats_after: usize,
859 tropo: super::TroposphereOptions,
860) -> Result<bool, RaimError> {
861 let n_obs = n_sats_after * RESIDUAL_COMPONENTS_PER_ROW;
862 let n_states = snapshot_state_count_from_parts(1, ztd_unknown_count(tropo), n_sats_after)?;
863 Ok(n_obs > n_states)
864}
865
866fn receiver_geodetic(position_m: [f64; 3]) -> Wgs84Geodetic {
867 let (lat_deg, lon_deg, height_km) = itrs_to_geodetic_compute(
868 position_m[0] / 1000.0,
869 position_m[1] / 1000.0,
870 position_m[2] / 1000.0,
871 )
872 .expect("valid receiver ITRS coordinates");
873 Wgs84Geodetic::new(
874 lat_deg * DEG_TO_RAD,
875 lon_deg * DEG_TO_RAD,
876 height_km * 1000.0,
877 )
878 .expect("valid receiver geodetic coordinates")
879}
880
881fn snapshot_state_count_for_geometry(geometry: &[RaimGeometryRow]) -> Result<usize, RaimError> {
882 snapshot_state_count_from_parts(1, 0, geometry.len())
883}
884
885fn snapshot_state_count_for_solution(solution: &FloatSolution) -> Result<usize, RaimError> {
886 snapshot_state_count_from_parts(
887 solution.epoch_clocks_m.len(),
888 usize::from(solution.ztd_residual_m.is_some()),
889 solution.ambiguities_m.len(),
890 )
891}
892
893fn snapshot_state_count_from_parts(
894 n_clocks: usize,
895 n_ztd: usize,
896 n_ambiguities: usize,
897) -> Result<usize, RaimError> {
898 SNAPSHOT_BASE_STATES
899 .checked_add(n_clocks.saturating_sub(1))
900 .and_then(|count| count.checked_add(n_ztd))
901 .and_then(|count| count.checked_add(n_ambiguities))
902 .ok_or(RaimError::InvalidGeometry {
903 field: "geometry",
904 reason: "too many rows",
905 })
906}
907
908fn validate_geometry(
909 residuals: &[FloatResidual],
910 geometry: &[RaimGeometryRow],
911) -> Result<(), RaimError> {
912 if residuals.len() != geometry.len() {
913 return Err(RaimError::InvalidGeometry {
914 field: "geometry",
915 reason: "length must match residuals",
916 });
917 }
918 for (residual, row) in residuals.iter().zip(geometry) {
919 if residual.satellite_id != row.satellite_id {
920 return Err(RaimError::InvalidGeometry {
921 field: "satellite_id",
922 reason: "order must match residuals",
923 });
924 }
925 validate_geometry_row(row)?;
926 }
927 Ok(())
928}
929
930fn validate_geometry_only(geometry: &[RaimGeometryRow]) -> Result<(), RaimError> {
931 for row in geometry {
932 validate_geometry_row(row)?;
933 }
934 Ok(())
935}
936
937fn validate_geometry_row(row: &RaimGeometryRow) -> Result<(), RaimError> {
938 validate::finite(row.line_of_sight.e_x, "line_of_sight.e_x").map_err(|_| {
939 RaimError::InvalidGeometry {
940 field: "line_of_sight.e_x",
941 reason: "must be finite",
942 }
943 })?;
944 validate::finite(row.line_of_sight.e_y, "line_of_sight.e_y").map_err(|_| {
945 RaimError::InvalidGeometry {
946 field: "line_of_sight.e_y",
947 reason: "must be finite",
948 }
949 })?;
950 validate::finite(row.line_of_sight.e_z, "line_of_sight.e_z").map_err(|_| {
951 RaimError::InvalidGeometry {
952 field: "line_of_sight.e_z",
953 reason: "must be finite",
954 }
955 })?;
956 Ok(())
957}
958
959fn validate_ztd_mappings(expected_len: usize, mappings: &[f64]) -> Result<(), RaimError> {
960 if mappings.len() != expected_len {
961 return Err(RaimError::InvalidGeometry {
962 field: "ztd_mapping",
963 reason: "length must match geometry",
964 });
965 }
966 for &mapping in mappings {
967 validate::finite(mapping, "ztd_mapping").map_err(|_| RaimError::InvalidGeometry {
968 field: "ztd_mapping",
969 reason: "must be finite",
970 })?;
971 }
972 Ok(())
973}
974
975fn protection_position_covariance_with_ztd(
976 geometry: &[RaimGeometryRow],
977 ztd_mappings: &[f64],
978) -> Result<[[f64; 3]; 3], RaimError> {
979 const PROTECTION_STATES_WITH_ZTD: usize = SNAPSHOT_BASE_STATES + 1;
980 let mut normal = vec![vec![0.0_f64; PROTECTION_STATES_WITH_ZTD]; PROTECTION_STATES_WITH_ZTD];
981
982 for (row, &ztd_mapping) in geometry.iter().zip(ztd_mappings) {
983 let h = [
984 -row.line_of_sight.e_x,
985 -row.line_of_sight.e_y,
986 -row.line_of_sight.e_z,
987 1.0,
988 ztd_mapping,
989 ];
990 for (i, normal_row) in normal.iter_mut().enumerate() {
991 let h_i = h[i];
992 for (j, normal_ij) in normal_row.iter_mut().enumerate() {
993 *normal_ij += h_i * h[j];
994 }
995 }
996 }
997
998 let q = invert_symmetric_pd(&normal).ok_or(RaimError::SingularGeometry)?;
999 Ok([
1000 [q[0][0], q[0][1], q[0][2]],
1001 [q[1][0], q[1][1], q[1][2]],
1002 [q[2][0], q[2][1], q[2][2]],
1003 ])
1004}
1005
1006#[allow(clippy::needless_range_loop)]
1007fn rotate_position_covariance_to_enu(q: &[[f64; 3]; 3], receiver: Wgs84Geodetic) -> [[f64; 3]; 3] {
1008 let sphi = receiver.lat_rad.sin();
1009 let cphi = receiver.lat_rad.cos();
1010 let slam = receiver.lon_rad.sin();
1011 let clam = receiver.lon_rad.cos();
1012 let r = [
1013 [-slam, clam, 0.0],
1014 [-sphi * clam, -sphi * slam, cphi],
1015 [cphi * clam, cphi * slam, sphi],
1016 ];
1017
1018 let mut rq = [[0.0_f64; 3]; 3];
1019 for i in 0..3 {
1020 for j in 0..3 {
1021 let mut s = 0.0_f64;
1022 for k in 0..3 {
1023 s += r[i][k] * q[k][j];
1024 }
1025 rq[i][j] = s;
1026 }
1027 }
1028 let mut enu = [[0.0_f64; 3]; 3];
1029 for i in 0..3 {
1030 for j in 0..3 {
1031 let mut s = 0.0_f64;
1032 for k in 0..3 {
1033 s += rq[i][k] * r[j][k];
1034 }
1035 enu[i][j] = s;
1036 }
1037 }
1038 enu
1039}
1040
1041fn scaled_protection_levels(
1042 hdop_arg: f64,
1043 vdop_arg: f64,
1044 config: RaimConfig,
1045) -> Result<ProtectionLevels, RaimError> {
1046 for arg in [hdop_arg, vdop_arg] {
1047 #[allow(clippy::neg_cmp_op_on_partial_ord)]
1048 let negative_or_nan = !(arg >= 0.0);
1049 if negative_or_nan || !arg.is_finite() {
1050 return Err(RaimError::SingularGeometry);
1051 }
1052 }
1053
1054 let k_md = missed_detection_multiplier(config)?;
1055 let hpl_m = k_md * config.measurement_sigma_m * hdop_arg.sqrt();
1056 let vpl_m = k_md * config.measurement_sigma_m * vdop_arg.sqrt();
1057 if hpl_m.is_finite() && hpl_m > 0.0 && vpl_m.is_finite() && vpl_m > 0.0 {
1058 Ok(ProtectionLevels { hpl_m, vpl_m })
1059 } else {
1060 Err(RaimError::SingularGeometry)
1061 }
1062}
1063
1064#[derive(Debug, Clone)]
1065struct StandardizationRow {
1066 h: Vec<f64>,
1067 residual: f64,
1068}
1069
1070fn snapshot_design_rows(
1071 residuals: &[FloatResidual],
1072 geometry: &[RaimGeometryRow],
1073 ztd_mappings: Option<&[f64]>,
1074 measurement_sigma_m: f64,
1075) -> Result<Vec<StandardizationRow>, RaimError> {
1076 let mut rows = Vec::with_capacity(residuals.len() * RESIDUAL_COMPONENTS_PER_ROW);
1077 let n_ambiguities = residuals.len();
1078 for (ambiguity_idx, (residual, geometry)) in residuals.iter().zip(geometry).enumerate() {
1079 let code_residual = validate_residual(residual.code_m, "code_m")?;
1080 let phase_residual = validate_residual(residual.phase_m, "phase_m")?;
1081 let code_weight =
1082 validate_weight(residual.code_weight, "code_weight")? / measurement_sigma_m;
1083 let phase_weight =
1084 validate_weight(residual.phase_weight, "phase_weight")? / measurement_sigma_m;
1085 if !code_weight.is_finite() || !phase_weight.is_finite() {
1086 return Err(RaimError::InvalidResidual {
1087 field: "weighted_residual",
1088 });
1089 }
1090 let ztd_mapping = ztd_mappings.map(|mappings| mappings[ambiguity_idx]);
1091 let code = code_residual * code_weight;
1092 let phase = phase_residual * phase_weight;
1093 if !code.is_finite() || !phase.is_finite() {
1094 return Err(RaimError::InvalidResidual {
1095 field: "weighted_residual",
1096 });
1097 }
1098 rows.push(StandardizationRow {
1099 h: weighted_snapshot_row(
1100 geometry.line_of_sight,
1101 code_weight,
1102 ztd_mapping,
1103 n_ambiguities,
1104 None,
1105 ),
1106 residual: code,
1107 });
1108 rows.push(StandardizationRow {
1109 h: weighted_snapshot_row(
1110 geometry.line_of_sight,
1111 phase_weight,
1112 ztd_mapping,
1113 n_ambiguities,
1114 Some(ambiguity_idx),
1115 ),
1116 residual: phase,
1117 });
1118 }
1119 Ok(rows)
1120}
1121
1122fn weighted_snapshot_row(
1123 line_of_sight: LineOfSight,
1124 weight: f64,
1125 ztd_mapping: Option<f64>,
1126 n_ambiguities: usize,
1127 active_ambiguity: Option<usize>,
1128) -> Vec<f64> {
1129 let mut row = undifferenced_design_row(
1130 [-line_of_sight.e_x, -line_of_sight.e_y, -line_of_sight.e_z],
1131 0,
1132 1,
1133 ztd_mapping,
1134 n_ambiguities,
1135 active_ambiguity,
1136 );
1137 for value in &mut row {
1138 *value *= weight;
1139 }
1140 row
1141}
1142
1143fn normal_matrix(rows: &[StandardizationRow]) -> Result<Vec<Vec<f64>>, RaimError> {
1144 let n = rows.first().map(|row| row.h.len()).unwrap_or(0);
1145 if n == 0 {
1146 return Err(RaimError::SingularGeometry);
1147 }
1148 let mut normal = vec![vec![0.0; n]; n];
1149 for row in rows {
1150 for (i, normal_row) in normal.iter_mut().enumerate() {
1151 let h_i = row.h[i];
1152 for (j, normal_ij) in normal_row.iter_mut().enumerate() {
1153 *normal_ij += h_i * row.h[j];
1154 }
1155 }
1156 }
1157 Ok(normal)
1158}
1159
1160fn standardized_abs(row: &StandardizationRow, q: &[Vec<f64>]) -> Result<f64, RaimError> {
1161 let leverage = row_leverage(&row.h, q)?;
1162 let variance_factor = 1.0 - leverage;
1163 if !variance_factor.is_finite() {
1164 return Err(RaimError::SingularGeometry);
1165 }
1166 if variance_factor.abs() <= LEVERAGE_TOLERANCE {
1167 return if row.residual.abs() <= HIGH_LEVERAGE_RESIDUAL_ZERO_TOLERANCE {
1168 Ok(0.0)
1169 } else {
1170 Err(RaimError::SingularGeometry)
1171 };
1172 }
1173 if variance_factor < 0.0 {
1174 return Err(RaimError::SingularGeometry);
1175 }
1176 let standardized = row.residual.abs() / variance_factor.sqrt();
1177 if standardized.is_finite() {
1178 Ok(standardized)
1179 } else {
1180 Err(RaimError::SingularGeometry)
1181 }
1182}
1183
1184fn row_leverage(row: &[f64], q: &[Vec<f64>]) -> Result<f64, RaimError> {
1185 if q.len() != row.len() || q.iter().any(|q_row| q_row.len() != row.len()) {
1186 return Err(RaimError::SingularGeometry);
1187 }
1188 let mut value = 0.0;
1189 for i in 0..row.len() {
1190 for j in 0..row.len() {
1191 value += row[i] * q[i][j] * row[j];
1192 }
1193 }
1194 if value.is_finite() {
1195 Ok(value)
1196 } else {
1197 Err(RaimError::SingularGeometry)
1198 }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204 use std::collections::BTreeMap;
1205
1206 use crate::geometry::line_of_sight_from_az_el_deg;
1207 use crate::observables::{ObservableState, ObservablesError};
1208 use crate::ppp_corrections::CivilDateTime;
1209 use crate::{GnssSatelliteId, GnssSystem};
1210
1211 fn residual(satellite_id: &str, code_m: f64, phase_m: f64) -> FloatResidual {
1212 FloatResidual {
1213 epoch_index: 0,
1214 satellite_id: satellite_id.to_string(),
1215 code_m,
1216 phase_m,
1217 code_weight: 1.0,
1218 phase_weight: 1.0,
1219 }
1220 }
1221
1222 #[derive(Debug, Clone)]
1223 struct TestSource {
1224 states: BTreeMap<GnssSatelliteId, [f64; 3]>,
1225 }
1226
1227 impl ObservableEphemerisSource for TestSource {
1228 fn observable_state_at_j2000_s(
1229 &self,
1230 sat: GnssSatelliteId,
1231 _t_j2000_s: f64,
1232 ) -> Result<ObservableState, ObservablesError> {
1233 Ok(ObservableState {
1234 position_ecef_m: *self.states.get(&sat).ok_or(ObservablesError::NoEphemeris)?,
1235 clock_s: Some(0.0),
1236 })
1237 }
1238 }
1239
1240 fn geometry(satellite_id: &str, line_of_sight: LineOfSight) -> RaimGeometryRow {
1241 RaimGeometryRow {
1242 satellite_id: satellite_id.to_string(),
1243 line_of_sight,
1244 }
1245 }
1246
1247 fn clean_residuals() -> Vec<FloatResidual> {
1248 vec![
1249 residual("G01", 0.1, -0.1),
1250 residual("G02", -0.1, 0.1),
1251 residual("G03", 0.05, -0.05),
1252 residual("G04", -0.05, 0.05),
1253 ]
1254 }
1255
1256 fn test_geometry() -> Vec<RaimGeometryRow> {
1257 vec![
1258 geometry("G01", LineOfSight::new(1.0, 0.0, 0.0)),
1259 geometry("G02", LineOfSight::new(-1.0, 0.0, 0.0)),
1260 geometry("G03", LineOfSight::new(0.0, 1.0, 0.0)),
1261 geometry("G04", LineOfSight::new(0.0, 0.0, 1.0)),
1262 geometry(
1263 "G05",
1264 LineOfSight::new(
1265 1.0 / 3.0_f64.sqrt(),
1266 1.0 / 3.0_f64.sqrt(),
1267 1.0 / 3.0_f64.sqrt(),
1268 ),
1269 ),
1270 ]
1271 }
1272
1273 fn protection_receiver() -> Wgs84Geodetic {
1274 Wgs84Geodetic::new(0.7, -1.2, 0.0).expect("valid geodetic receiver")
1275 }
1276
1277 fn protection_geometry(points: &[(f64, f64)]) -> Vec<RaimGeometryRow> {
1278 points
1279 .iter()
1280 .enumerate()
1281 .map(|(idx, &(azimuth_deg, elevation_deg))| {
1282 geometry(
1283 &format!("G{:02}", idx + 1),
1284 line_of_sight_from_az_el_deg(azimuth_deg, elevation_deg, protection_receiver())
1285 .expect("valid protection geometry"),
1286 )
1287 })
1288 .collect()
1289 }
1290
1291 fn fde_config() -> FloatSolveConfig {
1292 FloatSolveConfig {
1293 weights: super::super::MeasurementWeights {
1294 code: 1.0,
1295 phase: 1.0,
1296 elevation_weighting: false,
1297 },
1298 tropo: super::super::TroposphereOptions::disabled(),
1299 corrections: super::super::RangeCorrections::disabled(),
1300 opts: super::super::FloatSolveOptions {
1301 max_iterations: 50,
1302 position_tolerance_m: 1.0e-7,
1303 clock_tolerance_m: 1.0e-7,
1304 ambiguity_tolerance_m: 1.0e-7,
1305 ztd_tolerance_m: 1.0e-7,
1306 },
1307 residual_screen: false,
1308 }
1309 }
1310
1311 fn fde_raim_config() -> RaimConfig {
1312 RaimConfig {
1313 chi_square_threshold: Some(10.0),
1314 ..RaimConfig::default()
1315 }
1316 }
1317
1318 fn synthetic_case(
1319 n_sats: usize,
1320 biased_satellite: Option<&str>,
1321 ) -> (TestSource, FloatEpoch, FloatState) {
1322 let sat_positions = [
1323 (1u8, [20_200_000.0, 13_000_000.0, 21_500_000.0]),
1324 (2, [-21_300_000.0, 14_500_000.0, 20_700_000.0]),
1325 (3, [15_200_000.0, -22_000_000.0, 19_500_000.0]),
1326 (4, [-18_200_000.0, -16_000_000.0, 21_000_000.0]),
1327 (5, [22_000_000.0, -12_000_000.0, 20_200_000.0]),
1328 (6, [-12_000_000.0, 23_000_000.0, 18_000_000.0]),
1329 ];
1330 let ids = sat_positions
1331 .iter()
1332 .take(n_sats)
1333 .map(|(prn, _)| {
1334 GnssSatelliteId::new(GnssSystem::Gps, *prn).expect("valid satellite id")
1335 })
1336 .collect::<Vec<_>>();
1337 let source = TestSource {
1338 states: ids
1339 .iter()
1340 .zip(sat_positions.iter())
1341 .map(|(id, (_, position))| (*id, *position))
1342 .collect(),
1343 };
1344 let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
1345 let clock_m = 12.5;
1346 let ambiguities_m = ids
1347 .iter()
1348 .enumerate()
1349 .map(|(idx, id)| (id.to_string(), 0.25 + idx as f64 * 0.1))
1350 .collect::<BTreeMap<_, _>>();
1351 let observations = ids
1352 .iter()
1353 .map(|id| {
1354 let satellite_id = id.to_string();
1355 let prediction = predict(
1356 &source,
1357 *id,
1358 truth,
1359 0.0,
1360 PredictOptions {
1361 carrier_hz: F_L1_HZ,
1362 light_time: true,
1363 sagnac: true,
1364 },
1365 )
1366 .expect("synthetic prediction");
1367 let bias = if Some(satellite_id.as_str()) == biased_satellite {
1368 50.0
1369 } else {
1370 0.0
1371 };
1372 let code_m = prediction.geometric_range_m + clock_m + bias;
1373 let ambiguity_m = ambiguities_m.get(&satellite_id).copied().unwrap();
1374 super::super::FloatObservation {
1375 sat: *id,
1376 satellite_id: satellite_id.clone(),
1377 ambiguity_id: satellite_id,
1378 code_m,
1379 phase_m: prediction.geometric_range_m + clock_m + ambiguity_m,
1380 freq1_hz: 0.0,
1381 freq2_hz: 0.0,
1382 }
1383 })
1384 .collect::<Vec<_>>();
1385 let initial_ambiguities = observations
1386 .iter()
1387 .map(|obs| (obs.ambiguity_id.clone(), obs.phase_m - obs.code_m))
1388 .collect();
1389 let epoch = FloatEpoch {
1390 epoch: CivilDateTime {
1391 year: 2020,
1392 month: 6,
1393 day: 24,
1394 hour: 12,
1395 minute: 0,
1396 second: 0.0,
1397 },
1398 jd_whole: 2_459_024.5,
1399 jd_fraction: 0.5,
1400 t_rx_j2000_s: 0.0,
1401 observations,
1402 };
1403 let state = FloatState {
1404 position_m: [truth[0] + 80.0, truth[1] - 60.0, truth[2] + 40.0],
1405 clocks_m: vec![0.0],
1406 ambiguities_m: initial_ambiguities,
1407 ztd_m: 0.0,
1408 };
1409 (source, epoch, state)
1410 }
1411
1412 fn assert_position_close(actual: [f64; 3], expected: [f64; 3], tolerance_m: f64) {
1413 for idx in 0..3 {
1414 assert!(
1415 (actual[idx] - expected[idx]).abs() <= tolerance_m,
1416 "axis {idx}: got {}, expected {}",
1417 actual[idx],
1418 expected[idx]
1419 );
1420 }
1421 }
1422
1423 #[test]
1424 fn clean_residuals_pass_global_test() {
1425 let result = global_test(&clean_residuals(), 7, RaimConfig::default()).unwrap();
1426 assert_eq!(result.status, RaimStatus::Passed);
1427 assert!(!result.detected);
1428 assert_eq!(result.redundancy, 1);
1429 assert!(result.threshold.expect("threshold") > result.test_statistic);
1430 }
1431
1432 #[test]
1433 fn injected_bias_trips_global_test() {
1434 let mut residuals = clean_residuals();
1435 residuals[2].code_m = 5.0;
1436
1437 let result = global_test(&residuals, 7, RaimConfig::default()).unwrap();
1438 assert_eq!(result.status, RaimStatus::FaultDetected);
1439 assert!(result.detected);
1440 assert_eq!(result.redundancy, 1);
1441 assert!(result.test_statistic > result.threshold.expect("threshold"));
1442 }
1443
1444 #[test]
1445 fn global_test_rejects_overflowed_weighted_sse() {
1446 let mut residuals = clean_residuals();
1447 residuals[0].code_m = f64::MAX;
1448
1449 assert_eq!(
1450 global_test(&residuals, 7, RaimConfig::default()),
1451 Err(RaimError::InvalidResidual {
1452 field: "weighted_sse",
1453 })
1454 );
1455 }
1456
1457 #[test]
1458 fn raim_redundancy_counts_estimated_ztd_state() {
1459 let (source, epoch, state) = synthetic_case(6, None);
1460 let mut solve_config = fde_config();
1461 let mut tropo = super::super::TroposphereOptions::disabled();
1462 tropo.enabled = true;
1463 tropo.estimate_ztd = true;
1464 solve_config.tropo = tropo;
1465 let solve_tropo = solve_config.tropo;
1466 let solution = solve_float_epoch(&source, epoch.clone(), state, solve_config)
1467 .expect("ZTD-estimated solve");
1468
1469 assert!(solution.ztd_residual_m.is_some());
1470 let raim = raim_for_solution(
1471 &source,
1472 &epoch,
1473 &solution,
1474 solve_tropo,
1475 RaimConfig::default(),
1476 )
1477 .expect("RAIM for ZTD-estimated solution");
1478 let expected_states = 3 + solution.epoch_clocks_m.len() + 1 + solution.ambiguities_m.len();
1479 let expected_redundancy = (solution.residuals_m.len() * RESIDUAL_COMPONENTS_PER_ROW)
1480 as isize
1481 - expected_states as isize;
1482 let expected = global_test(
1483 &solution.residuals_m,
1484 expected_states,
1485 RaimConfig::default(),
1486 )
1487 .expect("expected-dof global test");
1488 let without_ztd = global_test(
1489 &solution.residuals_m,
1490 expected_states - 1,
1491 RaimConfig::default(),
1492 )
1493 .expect("old-dof global test");
1494
1495 assert_eq!(expected_states, 11);
1496 assert_eq!(expected_redundancy, 1);
1497 assert_eq!(raim.redundancy, expected_redundancy);
1498 assert_eq!(
1499 raim.threshold.map(f64::to_bits),
1500 expected.threshold.map(f64::to_bits)
1501 );
1502 assert_ne!(
1503 raim.threshold.map(f64::to_bits),
1504 without_ztd.threshold.map(f64::to_bits)
1505 );
1506 }
1507
1508 #[test]
1509 fn ztd_identification_uses_estimated_state_projection() {
1510 let (source, epoch, state) = synthetic_case(6, Some("G02"));
1511 let mut solve_config = fde_config();
1512 let mut tropo = super::super::TroposphereOptions::disabled();
1513 tropo.enabled = true;
1514 tropo.estimate_ztd = true;
1515 solve_config.tropo = tropo;
1516 let solve_tropo = solve_config.tropo;
1517 let mut solution =
1518 solve_float_epoch(&source, epoch.clone(), state, solve_config).expect("solve");
1519 assert!(solution.ztd_residual_m.is_some());
1520
1521 for residual in &mut solution.residuals_m {
1522 residual.code_m = if residual.satellite_id == "G02" {
1523 1.0
1524 } else if residual.satellite_id == "G05" {
1525 2.0
1526 } else {
1527 0.0
1528 };
1529 residual.phase_m = 0.0;
1530 }
1531
1532 let geometry =
1533 geometry_for_solution(&source, &epoch, &solution, solve_tropo).expect("geometry");
1534 let without_ztd =
1535 per_satellite_statistics(&solution.residuals_m, &geometry.rows, RaimConfig::default())
1536 .expect("without ztd");
1537 assert_eq!(without_ztd.most_likely_fault.as_deref(), Some("G05"));
1538
1539 let raim = raim_for_solution(
1540 &source,
1541 &epoch,
1542 &solution,
1543 solve_tropo,
1544 RaimConfig::default(),
1545 )
1546 .expect("RAIM with ZTD projection");
1547 assert_eq!(raim.most_likely_fault.as_deref(), Some("G02"));
1548 let g02 = raim
1549 .satellite_statistics
1550 .iter()
1551 .find(|stat| stat.satellite_id == "G02")
1552 .expect("G02 statistic");
1553 let g05 = raim
1554 .satellite_statistics
1555 .iter()
1556 .find(|stat| stat.satellite_id == "G05")
1557 .expect("G05 statistic");
1558 assert!(g02.statistic > 10.0 * g05.statistic);
1559 }
1560
1561 #[test]
1562 fn nonpositive_redundancy_returns_status() {
1563 let residuals = vec![residual("G01", 100.0, 0.0), residual("G02", 0.0, 0.0)];
1564
1565 let zero = global_test(&residuals, 4, RaimConfig::default()).unwrap();
1566 assert_eq!(zero.status, RaimStatus::NotEnoughRedundancy);
1567 assert!(!zero.detected);
1568 assert_eq!(zero.threshold, None);
1569 assert_eq!(zero.redundancy, 0);
1570
1571 let negative = global_test(&residuals, 5, RaimConfig::default()).unwrap();
1572 assert_eq!(negative.status, RaimStatus::NotEnoughRedundancy);
1573 assert!(!negative.detected);
1574 assert_eq!(negative.threshold, None);
1575 assert_eq!(negative.redundancy, -1);
1576 }
1577
1578 #[test]
1579 fn injected_outlier_has_largest_normalized_residual() {
1580 let mut residuals = clean_residuals();
1581 residuals.push(residual("G05", 0.0, 0.0));
1582 for residual in &mut residuals {
1583 residual.phase_m = 0.0;
1584 }
1585 residuals[3].code_m = 5.0;
1586
1587 let identification =
1588 per_satellite_statistics(&residuals, &test_geometry(), RaimConfig::default()).unwrap();
1589
1590 assert_eq!(identification.most_likely_fault.as_deref(), Some("G04"));
1591 let g04 = identification
1592 .statistics
1593 .iter()
1594 .find(|stat| stat.satellite_id == "G04")
1595 .expect("G04 statistic");
1596 assert_eq!(g04.statistic, g04.code);
1597 for stat in &identification.statistics {
1598 if stat.satellite_id != "G04" {
1599 assert!(g04.statistic > stat.statistic);
1600 }
1601 }
1602 }
1603
1604 #[test]
1605 fn phase_outlier_with_ambiguity_columns_is_unobservable() {
1606 let mut residuals = clean_residuals();
1607 residuals.push(residual("G05", 0.0, 0.0));
1608 for residual in &mut residuals {
1609 residual.code_m = 0.0;
1610 residual.phase_m = 0.0;
1611 }
1612 residuals[2].phase_m = 5.0;
1613
1614 assert_eq!(
1615 per_satellite_statistics(&residuals, &test_geometry(), RaimConfig::default()),
1616 Err(RaimError::SingularGeometry)
1617 );
1618 }
1619
1620 #[test]
1621 fn protection_levels_are_finite_and_positive() {
1622 let geometry = protection_geometry(&[
1623 (0.0, 70.0),
1624 (72.0, 55.0),
1625 (144.0, 50.0),
1626 (216.0, 45.0),
1627 (288.0, 40.0),
1628 (45.0, 65.0),
1629 ]);
1630
1631 let levels =
1632 protection_levels(&geometry, protection_receiver(), RaimConfig::default()).unwrap();
1633
1634 assert!(levels.hpl_m.is_finite() && levels.hpl_m > 0.0);
1635 assert!(levels.vpl_m.is_finite() && levels.vpl_m > 0.0);
1636 }
1637
1638 #[test]
1639 fn protection_levels_without_ztd_match_public_dop_path() {
1640 let geometry = protection_geometry(&[
1641 (0.0, 70.0),
1642 (72.0, 55.0),
1643 (144.0, 50.0),
1644 (216.0, 45.0),
1645 (288.0, 40.0),
1646 (45.0, 65.0),
1647 ]);
1648
1649 let public =
1650 protection_levels(&geometry, protection_receiver(), RaimConfig::default()).unwrap();
1651 let internal = protection_levels_with_ztd(
1652 &geometry,
1653 None,
1654 protection_receiver(),
1655 RaimConfig::default(),
1656 )
1657 .unwrap();
1658
1659 assert_eq!(internal.hpl_m.to_bits(), public.hpl_m.to_bits());
1660 assert_eq!(internal.vpl_m.to_bits(), public.vpl_m.to_bits());
1661 }
1662
1663 #[test]
1664 fn ztd_protection_levels_use_estimated_state_projection() {
1665 let (source, epoch, state) = synthetic_case(6, None);
1666 let mut solve_config = fde_config();
1667 let mut tropo = super::super::TroposphereOptions::disabled();
1668 tropo.enabled = true;
1669 tropo.estimate_ztd = true;
1670 solve_config.tropo = tropo;
1671 let solve_tropo = solve_config.tropo;
1672 let solution = solve_float_epoch(&source, epoch.clone(), state, solve_config)
1673 .expect("ZTD-estimated solve");
1674 let geometry =
1675 geometry_for_solution(&source, &epoch, &solution, solve_tropo).expect("geometry");
1676 let receiver = receiver_geodetic(solution.position_m);
1677
1678 let without_ztd =
1679 protection_levels(&geometry.rows, receiver, RaimConfig::default()).unwrap();
1680 let with_ztd = protection_levels_with_ztd(
1681 &geometry.rows,
1682 geometry.ztd_mappings.as_deref(),
1683 receiver,
1684 RaimConfig::default(),
1685 )
1686 .unwrap();
1687 let raim = raim_for_solution(
1688 &source,
1689 &epoch,
1690 &solution,
1691 solve_tropo,
1692 RaimConfig::default(),
1693 )
1694 .expect("RAIM with ZTD protection levels");
1695
1696 assert!(with_ztd.hpl_m.is_finite() && with_ztd.hpl_m > 0.0);
1697 assert!(with_ztd.vpl_m.is_finite() && with_ztd.vpl_m > 0.0);
1698 assert_ne!(with_ztd.hpl_m.to_bits(), without_ztd.hpl_m.to_bits());
1699 assert_ne!(with_ztd.vpl_m.to_bits(), without_ztd.vpl_m.to_bits());
1700 assert_eq!(raim.hpl_m.expect("HPL").to_bits(), with_ztd.hpl_m.to_bits());
1701 assert_eq!(raim.vpl_m.expect("VPL").to_bits(), with_ztd.vpl_m.to_bits());
1702 }
1703
1704 #[test]
1705 fn protection_levels_grow_when_geometry_degrades() {
1706 let normal = protection_geometry(&[
1707 (0.0, 70.0),
1708 (72.0, 55.0),
1709 (144.0, 50.0),
1710 (216.0, 45.0),
1711 (288.0, 40.0),
1712 (45.0, 65.0),
1713 ]);
1714 let degraded = protection_geometry(&[
1715 (0.0, 25.0),
1716 (8.0, 28.0),
1717 (16.0, 30.0),
1718 (24.0, 32.0),
1719 (32.0, 35.0),
1720 (40.0, 38.0),
1721 ]);
1722
1723 let normal =
1724 protection_levels(&normal, protection_receiver(), RaimConfig::default()).unwrap();
1725 let degraded =
1726 protection_levels(°raded, protection_receiver(), RaimConfig::default()).unwrap();
1727
1728 assert!(degraded.hpl_m > normal.hpl_m);
1729 assert!(degraded.vpl_m > normal.vpl_m);
1730 }
1731
1732 #[test]
1733 fn ztd_protection_levels_grow_when_geometry_degrades() {
1734 let normal = protection_geometry(&[
1735 (0.0, 70.0),
1736 (72.0, 55.0),
1737 (144.0, 50.0),
1738 (216.0, 45.0),
1739 (288.0, 40.0),
1740 (45.0, 65.0),
1741 ]);
1742 let normal_mappings = [1.064, 1.221, 1.305, 1.414, 1.556, 1.103];
1743 let degraded = protection_geometry(&[
1744 (0.0, 25.0),
1745 (8.0, 28.0),
1746 (16.0, 30.0),
1747 (24.0, 32.0),
1748 (32.0, 35.0),
1749 (40.0, 38.0),
1750 ]);
1751 let degraded_mappings = [2.366, 2.134, 2.0, 1.887, 1.743, 1.624];
1752
1753 let normal = protection_levels_with_ztd(
1754 &normal,
1755 Some(&normal_mappings),
1756 protection_receiver(),
1757 RaimConfig::default(),
1758 )
1759 .unwrap();
1760 let degraded = protection_levels_with_ztd(
1761 °raded,
1762 Some(°raded_mappings),
1763 protection_receiver(),
1764 RaimConfig::default(),
1765 )
1766 .unwrap();
1767
1768 assert!(normal.hpl_m.is_finite() && normal.hpl_m > 0.0);
1769 assert!(normal.vpl_m.is_finite() && normal.vpl_m > 0.0);
1770 assert!(degraded.hpl_m.is_finite() && degraded.hpl_m > 0.0);
1771 assert!(degraded.vpl_m.is_finite() && degraded.vpl_m > 0.0);
1772 assert!(degraded.hpl_m > normal.hpl_m);
1773 assert!(degraded.vpl_m > normal.vpl_m);
1774 }
1775
1776 #[test]
1777 fn fde_excludes_faulted_satellite_and_restores_solution() {
1778 let (clean_source, clean_epoch, clean_state) = synthetic_case(6, None);
1779 let clean = solve_float_epoch(&clean_source, clean_epoch, clean_state, fde_config())
1780 .expect("clean solve");
1781 let (biased_source, biased_epoch, biased_state) = synthetic_case(6, Some("G05"));
1782
1783 let fde = fde_float_epoch(
1784 &biased_source,
1785 biased_epoch,
1786 biased_state,
1787 fde_config(),
1788 fde_raim_config(),
1789 )
1790 .expect("FDE");
1791
1792 assert_eq!(fde.status, RaimFdeStatus::Restored);
1793 assert_eq!(fde.excluded_sats, vec!["G05".to_string()]);
1794 assert!(fde.raim.hpl_m.expect("HPL") > 0.0);
1795 assert!(fde.raim.vpl_m.expect("VPL") > 0.0);
1796 assert_position_close(fde.solution.position_m, clean.position_m, 1.0e-3);
1797 }
1798
1799 #[test]
1800 fn fde_refuses_exclusion_when_redundancy_would_be_exhausted() {
1801 let (source, epoch, state) = synthetic_case(5, Some("G05"));
1802
1803 let fde =
1804 fde_float_epoch(&source, epoch, state, fde_config(), fde_raim_config()).expect("FDE");
1805
1806 assert_eq!(fde.status, RaimFdeStatus::CannotExclude);
1807 assert!(fde.excluded_sats.is_empty());
1808 assert!(fde.raim.detected);
1809 }
1810}