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