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