1use std::collections::{BTreeMap, BTreeSet};
7
8pub mod normality;
9
10pub use crate::araim::reliability::{
11 reliability_araim, reliability_design, wtest_noncentrality, wtest_noncentrality_components,
12 ObservationReliability, RangeReliabilityRow, ReliabilityOptions, ReliabilityReport,
13 ReliabilitySummary, WtestNoncentralityComponents,
14};
15
16use crate::astro::math::linear::{invert_symmetric_pd, normal_equations_weighted};
17use crate::constants::DEG_TO_RAD;
18use crate::spp::{
19 solve, EphemerisSource, Observation, ReceiverSolution, RobustConfig, SolveInputs, SppError,
20};
21use crate::validate;
22
23pub const DEFAULT_VARIANCE_A_M: f64 = 0.3;
25pub const DEFAULT_VARIANCE_B_M: f64 = 0.3;
27pub const DEFAULT_P_FA: f64 = 1.0e-3;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum PseudorangeVarianceModel {
33 Elevation,
35 ElevationCn0,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct PseudorangeVarianceOptions {
42 pub a_m: f64,
44 pub b_m: f64,
46 pub model: PseudorangeVarianceModel,
48 pub cn0_dbhz: Option<f64>,
51 pub cn0_scale_m2: f64,
53}
54
55impl Default for PseudorangeVarianceOptions {
56 fn default() -> Self {
57 Self {
58 a_m: DEFAULT_VARIANCE_A_M,
59 b_m: DEFAULT_VARIANCE_B_M,
60 model: PseudorangeVarianceModel::Elevation,
61 cn0_dbhz: None,
62 cn0_scale_m2: 1.0,
63 }
64 }
65}
66
67impl PseudorangeVarianceOptions {
68 fn with_entry_cn0(self, cn0_dbhz: f64) -> Self {
69 Self {
70 model: PseudorangeVarianceModel::ElevationCn0,
71 cn0_dbhz: Some(cn0_dbhz),
72 ..self
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq)]
79pub struct WeightEntry {
80 pub satellite_id: String,
82 pub elevation_deg: f64,
84 pub cn0_dbhz: Option<f64>,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum QualityError {
92 InvalidElevation,
94 MissingCn0,
96 InvalidParameter,
98 InvalidProbability,
100 InvalidSystemCount,
102 InvalidDof,
104 InvalidWeight,
106 InvalidReliabilityParameter,
108 InvalidResiduals,
110 InvalidDesign,
113 SingularGeometry,
116}
117
118impl core::fmt::Display for QualityError {
119 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
120 match self {
121 Self::InvalidElevation => write!(f, "invalid elevation"),
122 Self::MissingCn0 => write!(f, "missing C/N0"),
123 Self::InvalidParameter => write!(f, "invalid quality parameter"),
124 Self::InvalidProbability => write!(f, "invalid probability"),
125 Self::InvalidSystemCount => write!(f, "invalid RAIM system count"),
126 Self::InvalidDof => write!(f, "invalid degrees of freedom"),
127 Self::InvalidWeight => write!(f, "invalid RAIM weight"),
128 Self::InvalidReliabilityParameter => write!(f, "invalid reliability parameter"),
129 Self::InvalidResiduals => write!(f, "invalid RAIM residuals"),
130 Self::InvalidDesign => write!(f, "invalid linearized measurement design"),
131 Self::SingularGeometry => write!(f, "singular or rank-deficient geometry"),
132 }
133 }
134}
135
136impl std::error::Error for QualityError {}
137
138pub fn pseudorange_variance(
140 elevation_deg: f64,
141 options: PseudorangeVarianceOptions,
142) -> Result<f64, QualityError> {
143 validate_elevation_deg(elevation_deg)?;
144 validate_variance_options(options)?;
145
146 let mut elevation_var = options.a_m * options.a_m;
147 if options.b_m != 0.0 {
148 let sin_el = (elevation_deg * DEG_TO_RAD).sin();
149 let scaled = options.b_m * options.b_m / (sin_el * sin_el);
150 if !scaled.is_finite() {
151 return Err(QualityError::InvalidElevation);
152 }
153 elevation_var += scaled;
154 }
155
156 let variance = match options.model {
157 PseudorangeVarianceModel::Elevation => elevation_var,
158 PseudorangeVarianceModel::ElevationCn0 => {
159 let Some(cn0) = options.cn0_dbhz else {
160 return Err(QualityError::MissingCn0);
161 };
162 validate_nonneg_parameter(cn0, "cn0_dbhz")?;
163 elevation_var + options.cn0_scale_m2 * 10.0_f64.powf(-cn0 / 10.0)
164 }
165 };
166
167 validate_positive_variance(variance)?;
168 Ok(variance)
169}
170
171fn validate_elevation_deg(elevation_deg: f64) -> Result<(), QualityError> {
172 validate::finite(elevation_deg, "elevation_deg").map_err(|_| QualityError::InvalidElevation)?;
173 if (-90.0..=90.0).contains(&elevation_deg) {
174 Ok(())
175 } else {
176 Err(QualityError::InvalidElevation)
177 }
178}
179
180fn validate_variance_options(options: PseudorangeVarianceOptions) -> Result<(), QualityError> {
181 validate_nonneg_parameter(options.a_m, "variance a_m")?;
182 validate_nonneg_parameter(options.b_m, "variance b_m")?;
183 validate_nonneg_parameter(options.cn0_scale_m2, "variance cn0_scale_m2")
184}
185
186fn validate_nonneg_parameter(value: f64, field: &'static str) -> Result<(), QualityError> {
187 validate::finite_nonneg(value, field)
188 .map(|_| ())
189 .map_err(map_parameter_error)
190}
191
192fn validate_positive_variance(value: f64) -> Result<(), QualityError> {
193 validate::finite_positive(value, "pseudorange variance")
194 .map(|_| ())
195 .map_err(map_parameter_error)
196}
197
198fn map_parameter_error(_error: validate::FieldError) -> QualityError {
199 QualityError::InvalidParameter
200}
201
202pub fn sigmas(
205 entries: &[WeightEntry],
206 options: PseudorangeVarianceOptions,
207) -> BTreeMap<String, f64> {
208 entries
209 .iter()
210 .filter_map(|entry| {
211 let opts = match entry.cn0_dbhz {
212 Some(cn0) => options.with_entry_cn0(cn0),
213 None => options,
214 };
215 pseudorange_variance(entry.elevation_deg, opts)
216 .ok()
217 .map(|var| (entry.satellite_id.clone(), var.sqrt()))
218 })
219 .collect()
220}
221
222pub fn weight_vector(
225 entries: &[WeightEntry],
226 options: PseudorangeVarianceOptions,
227) -> BTreeMap<String, f64> {
228 entries
229 .iter()
230 .filter_map(|entry| {
231 let opts = match entry.cn0_dbhz {
232 Some(cn0) => options.with_entry_cn0(cn0),
233 None => options,
234 };
235 pseudorange_variance(entry.elevation_deg, opts)
236 .ok()
237 .map(|var| (entry.satellite_id.clone(), 1.0 / var))
238 })
239 .collect()
240}
241
242#[derive(Debug, Clone, PartialEq)]
244pub enum RaimWeights {
245 Unit,
247 BySatellite(BTreeMap<String, f64>),
250}
251
252impl RaimWeights {
253 fn validate(&self) -> Result<(), QualityError> {
254 match self {
255 Self::Unit => Ok(()),
256 Self::BySatellite(weights) => weights
257 .values()
258 .try_for_each(|w| validate::finite_positive(*w, "raim weight").map(|_| ()))
259 .map_err(|_| QualityError::InvalidWeight),
260 }
261 }
262
263 fn weight_for(&self, satellite_id: &str) -> f64 {
264 match self {
265 Self::Unit => 1.0,
266 Self::BySatellite(weights) => weights.get(satellite_id).copied().unwrap_or(1.0),
267 }
268 }
269}
270
271#[derive(Debug, Clone, PartialEq)]
273pub struct RaimOptions {
274 pub p_fa: f64,
276 pub weights: RaimWeights,
278 pub n_systems: Option<isize>,
280}
281
282impl Default for RaimOptions {
283 fn default() -> Self {
284 Self {
285 p_fa: DEFAULT_P_FA,
286 weights: RaimWeights::Unit,
287 n_systems: None,
288 }
289 }
290}
291
292#[derive(Debug, Clone, PartialEq)]
294pub struct RaimInput {
295 pub used_sats: Vec<String>,
297 pub residuals_m: Vec<f64>,
299}
300
301pub trait RaimSolution {
303 fn raim_used_sats(&self) -> Vec<String>;
305 fn raim_residuals_m(&self) -> &[f64];
307}
308
309impl RaimSolution for ReceiverSolution {
310 fn raim_used_sats(&self) -> Vec<String> {
311 self.used_sats.iter().map(ToString::to_string).collect()
312 }
313
314 fn raim_residuals_m(&self) -> &[f64] {
315 &self.residuals_m
316 }
317}
318
319#[derive(Debug, Clone, PartialEq)]
321pub struct RaimResult {
322 pub fault_detected: bool,
324 pub test_statistic: f64,
326 pub threshold: Option<f64>,
328 pub dof: isize,
330 pub testable: bool,
332 pub normalized_residuals: BTreeMap<String, f64>,
334 pub worst_sat: Option<String>,
336}
337
338#[derive(Debug, Clone, PartialEq)]
340pub struct ResidualDiagnostics {
341 pub n_residuals: usize,
343 pub n_parameters: usize,
345 pub degrees_of_freedom: isize,
347 pub weighted_sum_squares: f64,
349 pub rms_m: f64,
351 pub normalized_residuals: Vec<f64>,
353 pub worst_index: Option<usize>,
355 pub reduced_chi_square: Option<f64>,
358 pub chi_square_threshold: Option<f64>,
361 pub chi_square_consistent: Option<bool>,
364}
365
366pub fn residual_diagnostics(
373 residuals_m: &[f64],
374 weights: Option<&[f64]>,
375 n_parameters: usize,
376 p_fa: Option<f64>,
377) -> Result<ResidualDiagnostics, QualityError> {
378 validate::finite_slice(residuals_m, "diagnostic residuals")
379 .map_err(|_| QualityError::InvalidResiduals)?;
380 let weights = match weights {
381 Some(weights) => {
382 if weights.len() != residuals_m.len() {
383 return Err(QualityError::InvalidWeight);
384 }
385 validate_weights_slice(weights)?;
386 Some(weights)
387 }
388 None => None,
389 };
390 if let Some(p_fa) = p_fa {
391 validate_probability(p_fa)?;
392 }
393
394 let degrees_of_freedom = residuals_m.len() as isize - n_parameters as isize;
395 let mut weighted_sum_squares = 0.0;
396 let mut normalized_residuals = Vec::with_capacity(residuals_m.len());
397 let mut worst_index = None;
398 let mut worst_abs = f64::NEG_INFINITY;
399 for (idx, residual_m) in residuals_m.iter().enumerate() {
400 let weight = weights.map(|w| w[idx]).unwrap_or(1.0);
401 let normalized = residual_m * weight.sqrt();
402 weighted_sum_squares += residual_m * residual_m * weight;
403 normalized_residuals.push(normalized);
404 let abs_normalized = normalized.abs();
405 if abs_normalized > worst_abs {
406 worst_abs = abs_normalized;
407 worst_index = Some(idx);
408 }
409 }
410
411 let rms_m = residual_rms(residuals_m);
412 let reduced_chi_square = if degrees_of_freedom > 0 {
413 Some(weighted_sum_squares / degrees_of_freedom as f64)
414 } else {
415 None
416 };
417 let chi_square_threshold = match (p_fa, degrees_of_freedom > 0) {
418 (Some(p_fa), true) => Some(chi2_inv(1.0 - p_fa, degrees_of_freedom as usize)?),
419 _ => None,
420 };
421 let chi_square_consistent =
422 chi_square_threshold.map(|threshold| weighted_sum_squares <= threshold);
423
424 Ok(ResidualDiagnostics {
425 n_residuals: residuals_m.len(),
426 n_parameters,
427 degrees_of_freedom,
428 weighted_sum_squares,
429 rms_m,
430 normalized_residuals,
431 worst_index,
432 reduced_chi_square,
433 chi_square_threshold,
434 chi_square_consistent,
435 })
436}
437
438pub fn raim_for_solution<S: RaimSolution>(
440 solution: &S,
441 options: &RaimOptions,
442) -> Result<RaimResult, QualityError> {
443 raim(
444 &RaimInput {
445 used_sats: solution.raim_used_sats(),
446 residuals_m: solution.raim_residuals_m().to_vec(),
447 },
448 options,
449 )
450}
451
452pub fn raim(input: &RaimInput, options: &RaimOptions) -> Result<RaimResult, QualityError> {
454 validate_probability(options.p_fa)?;
455 options.weights.validate()?;
456 validate_raim_input(input)?;
457
458 let n_used = input.used_sats.len() as isize;
459 let n_systems = raim_system_count(input, options)?;
460 let dof = n_used - (3 + n_systems);
461
462 let mut test_statistic = 0.0;
463 let mut normalized_residuals = BTreeMap::new();
464 let mut worst_sat = None::<String>;
465 let mut worst_abs = f64::NEG_INFINITY;
466
467 for (satellite_id, residual_m) in input.used_sats.iter().zip(input.residuals_m.iter()) {
468 let weight = options.weights.weight_for(satellite_id);
469 let normalized = residual_m * weight.sqrt();
470 test_statistic += residual_m * residual_m * weight;
471 normalized_residuals.insert(satellite_id.clone(), normalized);
472 let abs_normalized = normalized.abs();
473 if abs_normalized > worst_abs {
474 worst_abs = abs_normalized;
475 worst_sat = Some(satellite_id.clone());
476 }
477 }
478
479 if dof <= 0 {
480 return Ok(RaimResult {
481 fault_detected: false,
482 test_statistic,
483 threshold: None,
484 dof,
485 testable: false,
486 normalized_residuals,
487 worst_sat,
488 });
489 }
490
491 let threshold = chi2_inv(1.0 - options.p_fa, dof as usize)?;
492 Ok(RaimResult {
493 fault_detected: test_statistic > threshold,
494 test_statistic,
495 threshold: Some(threshold),
496 dof,
497 testable: true,
498 normalized_residuals,
499 worst_sat,
500 })
501}
502
503fn validate_probability(p: f64) -> Result<(), QualityError> {
504 let p = validate::finite(p, "probability").map_err(|_| QualityError::InvalidProbability)?;
505 if p > 0.0 && p < 1.0 {
506 Ok(())
507 } else {
508 Err(QualityError::InvalidProbability)
509 }
510}
511
512fn validate_raim_input(input: &RaimInput) -> Result<(), QualityError> {
513 if input.used_sats.len() != input.residuals_m.len() {
514 return Err(QualityError::InvalidResiduals);
515 }
516 validate::finite_slice(&input.residuals_m, "raim residuals")
517 .map_err(|_| QualityError::InvalidResiduals)
518}
519
520fn validate_weights_slice(weights: &[f64]) -> Result<(), QualityError> {
521 weights
522 .iter()
523 .try_for_each(|w| validate::finite_positive(*w, "diagnostic weight").map(|_| ()))
524 .map_err(|_| QualityError::InvalidWeight)
525}
526
527fn raim_system_count(input: &RaimInput, options: &RaimOptions) -> Result<isize, QualityError> {
528 match options.n_systems {
529 Some(n_systems) if n_systems >= 1 => Ok(n_systems),
530 Some(_) => Err(QualityError::InvalidSystemCount),
531 None => Ok(distinct_systems(&input.used_sats)),
532 }
533}
534
535fn distinct_systems(used_sats: &[String]) -> isize {
536 used_sats
537 .iter()
538 .filter_map(|sat| sat.chars().next())
539 .collect::<BTreeSet<_>>()
540 .len() as isize
541}
542
543#[derive(Debug, Clone, PartialEq)]
545pub struct FdeResult<S> {
546 pub solution: S,
548 pub excluded: Vec<String>,
550 pub iterations: usize,
552}
553
554#[derive(Debug, Clone, PartialEq)]
556pub enum FdeError<E> {
557 FaultUnresolved(f64),
559 Solve(E),
561 Raim(QualityError),
563}
564
565#[derive(Debug, Clone, PartialEq)]
567pub struct FdeOptions {
568 pub raim: RaimOptions,
570 pub max_iterations: usize,
572}
573
574pub fn fde<S, E, F>(
576 observations: &[Observation],
577 options: &FdeOptions,
578 mut solve: F,
579) -> Result<FdeResult<S>, FdeError<E>>
580where
581 S: RaimSolution,
582 F: FnMut(&[Observation]) -> Result<S, E>,
583{
584 let mut remaining = observations.to_vec();
585 let mut excluded = Vec::new();
586 let mut iter = 0usize;
587
588 loop {
589 let solution = solve(&remaining).map_err(FdeError::Solve)?;
590 let result = raim_for_solution(&solution, &options.raim).map_err(FdeError::Raim)?;
591
592 if !result.fault_detected {
593 return Ok(FdeResult {
594 solution,
595 excluded,
596 iterations: iter,
597 });
598 }
599
600 let Some(worst) = result.worst_sat else {
601 return Err(FdeError::FaultUnresolved(result.test_statistic));
602 };
603
604 if iter >= options.max_iterations {
605 return Err(FdeError::FaultUnresolved(result.test_statistic));
606 }
607
608 remaining.retain(|ob| ob.satellite_id.to_string() != worst);
609 excluded.push(worst);
610 iter += 1;
611 }
612}
613
614#[derive(Debug, Clone)]
620pub enum FdeSppError {
621 Spp(SppError),
623 Validation(SolutionValidationError),
625}
626
627impl core::fmt::Display for FdeSppError {
628 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
629 match self {
630 Self::Spp(err) => write!(f, "SPP solve failed: {err}"),
631 Self::Validation(err) => write!(f, "solution validation failed: {err}"),
632 }
633 }
634}
635
636impl std::error::Error for FdeSppError {}
637
638#[derive(Debug, Clone, PartialEq)]
641pub struct FdeSppOptions {
642 pub fde: FdeOptions,
644 pub validation: SolutionValidationOptions,
647}
648
649pub fn fde_spp(
665 eph: &dyn EphemerisSource,
666 inputs: &SolveInputs,
667 with_geodetic: bool,
668 options: &FdeSppOptions,
669) -> Result<FdeResult<ReceiverSolution>, FdeError<FdeSppError>> {
670 let observations = inputs.observations.clone();
671 fde(&observations, &options.fde, |remaining| {
672 let mut next = inputs.clone();
673 next.observations = remaining.to_vec();
674 let solution = solve(eph, &next, with_geodetic).map_err(FdeSppError::Spp)?;
675 validate_receiver_solution(&solution, options.validation)
676 .map_err(FdeSppError::Validation)?;
677 Ok(solution)
678 })
679}
680
681pub fn spp_robust_fde_driver(
688 eph: &dyn EphemerisSource,
689 inputs: &SolveInputs,
690 with_geodetic: bool,
691 robust: RobustConfig,
692 options: &FdeSppOptions,
693) -> Result<FdeResult<ReceiverSolution>, FdeError<FdeSppError>> {
694 let mut robust_inputs = inputs.clone();
695 robust_inputs.robust = Some(robust);
696 fde_spp(eph, &robust_inputs, with_geodetic, options)
697}
698
699#[derive(Debug, Clone, PartialEq)]
711pub struct RangeFdeRow {
712 pub id: String,
714 pub residual_m: f64,
716 pub design_row: Vec<f64>,
719 pub weight: f64,
722}
723
724#[derive(Debug, Clone, Copy, PartialEq)]
726pub struct RangeFdeOptions {
727 pub p_fa: f64,
731 pub max_exclusions: usize,
733 pub min_redundancy: usize,
739}
740
741impl Default for RangeFdeOptions {
742 fn default() -> Self {
743 Self {
744 p_fa: DEFAULT_P_FA,
745 max_exclusions: usize::MAX,
746 min_redundancy: 1,
747 }
748 }
749}
750
751#[derive(Debug, Clone, Copy, PartialEq)]
753pub struct RangeChiSquareTest {
754 pub weighted_sum_squares: f64,
756 pub dof: isize,
758 pub threshold: Option<f64>,
760 pub testable: bool,
762 pub fault_detected: bool,
764}
765
766#[derive(Debug, Clone, PartialEq)]
768pub struct RangeMeasurementDiagnostic {
769 pub id: String,
771 pub excluded: bool,
773 pub post_fit_residual_m: f64,
777 pub normalized_residual: f64,
779}
780
781#[derive(Debug, Clone, PartialEq)]
783pub struct RangeFdeResult {
784 pub state_correction: Vec<f64>,
786 pub state_covariance: Vec<Vec<f64>>,
788 pub global_test: RangeChiSquareTest,
790 pub excluded: Vec<String>,
792 pub diagnostics: Vec<RangeMeasurementDiagnostic>,
794 pub iterations: usize,
796}
797
798struct WlsFit {
800 dx: Vec<f64>,
801 covariance: Vec<Vec<f64>>,
802}
803
804pub fn raim_fde_design(
844 rows: &[RangeFdeRow],
845 options: &RangeFdeOptions,
846) -> Result<RangeFdeResult, QualityError> {
847 validate_probability(options.p_fa)?;
848 let n_state = validate_range_rows(rows)?;
849
850 let mut active: Vec<usize> = (0..rows.len()).collect();
851 let mut excluded: Vec<String> = Vec::new();
852 let mut iterations = 0usize;
853
854 let mut fit = solve_range_wls(rows, &active, n_state)?;
855 loop {
856 let test = range_chi_square_test(rows, &active, &fit, n_state, options.p_fa)?;
857
858 if !test.fault_detected || excluded.len() >= options.max_exclusions {
859 return Ok(finish_range_fde(
860 rows, &active, &excluded, fit, test, iterations,
861 ));
862 }
863
864 let Some((slot, candidate_fit)) =
867 best_range_exclusion(rows, &active, n_state, options.min_redundancy)
868 else {
869 return Ok(finish_range_fde(
870 rows, &active, &excluded, fit, test, iterations,
871 ));
872 };
873
874 excluded.push(rows[active[slot]].id.clone());
875 active.remove(slot);
876 fit = candidate_fit;
877 iterations += 1;
878 }
879}
880
881fn finish_range_fde(
882 rows: &[RangeFdeRow],
883 active: &[usize],
884 excluded: &[String],
885 fit: WlsFit,
886 test: RangeChiSquareTest,
887 iterations: usize,
888) -> RangeFdeResult {
889 let active_set: BTreeSet<usize> = active.iter().copied().collect();
890 let diagnostics = rows
891 .iter()
892 .enumerate()
893 .map(|(idx, row)| {
894 let post_fit = row.residual_m - dot(&row.design_row, &fit.dx);
895 RangeMeasurementDiagnostic {
896 id: row.id.clone(),
897 excluded: !active_set.contains(&idx),
898 post_fit_residual_m: post_fit,
899 normalized_residual: post_fit * row.weight.sqrt(),
900 }
901 })
902 .collect();
903
904 RangeFdeResult {
905 state_correction: fit.dx,
906 state_covariance: fit.covariance,
907 global_test: test,
908 excluded: excluded.to_vec(),
909 diagnostics,
910 iterations,
911 }
912}
913
914fn best_range_exclusion(
918 rows: &[RangeFdeRow],
919 active: &[usize],
920 n_state: usize,
921 min_redundancy: usize,
922) -> Option<(usize, WlsFit)> {
923 if active.len() < n_state + min_redundancy + 1 {
925 return None;
926 }
927
928 let mut best: Option<(usize, WlsFit, f64)> = None;
929 let mut remaining: Vec<usize> = Vec::with_capacity(active.len() - 1);
930 for slot in 0..active.len() {
931 remaining.clear();
932 remaining.extend(active.iter().enumerate().filter_map(|(s, &idx)| {
933 if s == slot {
934 None
935 } else {
936 Some(idx)
937 }
938 }));
939
940 let Ok(candidate) = solve_range_wls(rows, &remaining, n_state) else {
941 continue;
942 };
943 let rms = reduced_weighted_rms(rows, &remaining, &candidate);
944
945 let better = match &best {
946 Some((_, _, best_rms)) => rms < *best_rms,
947 None => true,
948 };
949 if better {
950 best = Some((slot, candidate, rms));
951 }
952 }
953
954 best.map(|(slot, fit, _)| (slot, fit))
955}
956
957fn reduced_weighted_rms(rows: &[RangeFdeRow], active: &[usize], fit: &WlsFit) -> f64 {
960 if active.is_empty() {
961 return 0.0;
962 }
963 let mut wss = 0.0;
964 for &idx in active {
965 let row = &rows[idx];
966 let v = row.residual_m - dot(&row.design_row, &fit.dx);
967 wss += row.weight * v * v;
968 }
969 (wss / active.len() as f64).sqrt()
970}
971
972fn range_chi_square_test(
973 rows: &[RangeFdeRow],
974 active: &[usize],
975 fit: &WlsFit,
976 n_state: usize,
977 p_fa: f64,
978) -> Result<RangeChiSquareTest, QualityError> {
979 let mut weighted_sum_squares = 0.0;
980 for &idx in active {
981 let row = &rows[idx];
982 let v = row.residual_m - dot(&row.design_row, &fit.dx);
983 weighted_sum_squares += row.weight * v * v;
984 }
985
986 let dof = active.len() as isize - n_state as isize;
987 if dof <= 0 {
988 return Ok(RangeChiSquareTest {
989 weighted_sum_squares,
990 dof,
991 threshold: None,
992 testable: false,
993 fault_detected: false,
994 });
995 }
996
997 let threshold = chi2_inv(1.0 - p_fa, dof as usize)?;
998 Ok(RangeChiSquareTest {
999 weighted_sum_squares,
1000 dof,
1001 threshold: Some(threshold),
1002 testable: true,
1003 fault_detected: weighted_sum_squares > threshold,
1004 })
1005}
1006
1007fn solve_range_wls(
1014 rows: &[RangeFdeRow],
1015 active: &[usize],
1016 n_state: usize,
1017) -> Result<WlsFit, QualityError> {
1018 let (ata, aty) = normal_equations_weighted(
1019 active.iter().map(|&idx| {
1020 let row = &rows[idx];
1021 (row.design_row.as_slice(), row.residual_m, row.weight.sqrt())
1022 }),
1023 n_state,
1024 )
1025 .ok_or(QualityError::InvalidDesign)?;
1026
1027 let covariance = invert_symmetric_pd(&ata).ok_or(QualityError::SingularGeometry)?;
1028 let dx = (0..n_state)
1029 .map(|i| (0..n_state).map(|j| covariance[i][j] * aty[j]).sum())
1030 .collect();
1031 Ok(WlsFit { dx, covariance })
1032}
1033
1034fn dot(a: &[f64], b: &[f64]) -> f64 {
1035 a.iter().zip(b).map(|(x, y)| x * y).sum()
1036}
1037
1038fn validate_range_rows(rows: &[RangeFdeRow]) -> Result<usize, QualityError> {
1039 let first = rows.first().ok_or(QualityError::InvalidDesign)?;
1040 let n_state = first.design_row.len();
1041 if n_state == 0 || rows.len() < n_state {
1042 return Err(QualityError::InvalidDesign);
1043 }
1044 for row in rows {
1045 if row.design_row.len() != n_state {
1046 return Err(QualityError::InvalidDesign);
1047 }
1048 validate::finite_slice(&row.design_row, "design row")
1049 .map_err(|_| QualityError::InvalidDesign)?;
1050 validate::finite(row.residual_m, "design residual")
1051 .map_err(|_| QualityError::InvalidResiduals)?;
1052 validate::finite_positive(row.weight, "design weight")
1053 .map_err(|_| QualityError::InvalidWeight)?;
1054 }
1055 Ok(n_state)
1056}
1057
1058#[derive(Debug, Clone, Copy, PartialEq)]
1060pub struct SolutionValidationOptions {
1061 pub max_pdop: Option<f64>,
1063 pub min_plausible_radius_m: f64,
1065 pub max_plausible_radius_m: f64,
1067 pub max_converged_residual_rms_m: f64,
1069}
1070
1071impl Default for SolutionValidationOptions {
1072 fn default() -> Self {
1073 Self {
1074 max_pdop: None,
1075 min_plausible_radius_m: 6_344_752.0,
1076 max_plausible_radius_m: 8_378_137.0,
1077 max_converged_residual_rms_m: 1.0e4,
1078 }
1079 }
1080}
1081
1082#[derive(Debug, Clone, Copy, PartialEq)]
1084pub enum SolutionValidationError {
1085 InvalidOptions {
1087 field: &'static str,
1089 reason: &'static str,
1091 },
1092 DegenerateGeometryRankDeficient,
1094 DegenerateGeometryPdop(f64),
1096 ImplausiblePosition(f64),
1098 InvalidResiduals,
1100 NoConvergence(f64),
1102}
1103
1104impl core::fmt::Display for SolutionValidationError {
1105 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1106 match self {
1107 Self::InvalidOptions { field, reason } => {
1108 write!(f, "invalid receiver validation option {field}: {reason}")
1109 }
1110 Self::DegenerateGeometryRankDeficient => {
1111 write!(f, "receiver geometry is rank deficient")
1112 }
1113 Self::DegenerateGeometryPdop(pdop) => {
1114 write!(
1115 f,
1116 "receiver geometry PDOP {pdop} exceeds the configured limit"
1117 )
1118 }
1119 Self::ImplausiblePosition(radius_m) => write!(
1120 f,
1121 "receiver geocentric radius {radius_m} m is outside the plausible range"
1122 ),
1123 Self::InvalidResiduals => {
1124 write!(f, "converged solution residuals must be finite")
1125 }
1126 Self::NoConvergence(rms_m) => write!(
1127 f,
1128 "converged solution residual RMS {rms_m} m is implausibly large"
1129 ),
1130 }
1131 }
1132}
1133
1134impl std::error::Error for SolutionValidationError {}
1135
1136pub fn validate_receiver_solution(
1138 solution: &ReceiverSolution,
1139 options: SolutionValidationOptions,
1140) -> Result<(), SolutionValidationError> {
1141 validate_solution_validation_options(options)?;
1142
1143 let Some(dop) = solution.dop.as_ref() else {
1144 return Err(SolutionValidationError::DegenerateGeometryRankDeficient);
1145 };
1146
1147 if let Some(max_pdop) = options.max_pdop {
1148 if dop.pdop > max_pdop {
1149 return Err(SolutionValidationError::DegenerateGeometryPdop(dop.pdop));
1150 }
1151 }
1152
1153 let p = solution.position.as_array();
1154 let radius_m = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
1155 if radius_m < options.min_plausible_radius_m || radius_m > options.max_plausible_radius_m {
1156 return Err(SolutionValidationError::ImplausiblePosition(radius_m));
1157 }
1158
1159 if solution.metadata.converged {
1160 if validate::finite_slice(&solution.residuals_m, "solution residuals").is_err() {
1161 return Err(SolutionValidationError::InvalidResiduals);
1162 }
1163 let rms = residual_rms(&solution.residuals_m);
1164 if !rms.is_finite() {
1165 return Err(SolutionValidationError::InvalidResiduals);
1166 }
1167 if rms > options.max_converged_residual_rms_m {
1168 return Err(SolutionValidationError::NoConvergence(rms));
1169 }
1170 }
1171
1172 Ok(())
1173}
1174
1175fn validate_solution_validation_options(
1176 options: SolutionValidationOptions,
1177) -> Result<(), SolutionValidationError> {
1178 if let Some(max_pdop) = options.max_pdop {
1179 validate::finite_positive(max_pdop, "max_pdop").map_err(validation_option_error)?;
1180 }
1181 validate::finite_positive(options.min_plausible_radius_m, "min_plausible_radius_m")
1182 .map_err(validation_option_error)?;
1183 validate::finite_positive(options.max_plausible_radius_m, "max_plausible_radius_m")
1184 .map_err(validation_option_error)?;
1185 if options.min_plausible_radius_m >= options.max_plausible_radius_m {
1186 return Err(invalid_validation_option(
1187 "plausible_radius_m",
1188 "must be increasing",
1189 ));
1190 }
1191 validate::finite_positive(
1192 options.max_converged_residual_rms_m,
1193 "max_converged_residual_rms_m",
1194 )
1195 .map_err(validation_option_error)?;
1196 Ok(())
1197}
1198
1199fn validation_option_error(error: validate::FieldError) -> SolutionValidationError {
1200 invalid_validation_option(error.field(), error.reason())
1201}
1202
1203fn invalid_validation_option(field: &'static str, reason: &'static str) -> SolutionValidationError {
1204 SolutionValidationError::InvalidOptions { field, reason }
1205}
1206
1207fn residual_rms(residuals: &[f64]) -> f64 {
1208 if residuals.is_empty() {
1209 return 0.0;
1210 }
1211 let sum_sq = residuals.iter().map(|r| r * r).sum::<f64>();
1212 (sum_sq / residuals.len() as f64).sqrt()
1213}
1214
1215pub fn chi2_inv(p: f64, k: usize) -> Result<f64, QualityError> {
1217 validate_probability(p)?;
1218 if k == 0 {
1219 return Err(QualityError::InvalidDof);
1220 }
1221 let a = 0.5 * k as f64;
1222 let hi0 = (k as f64 + 10.0 * (2.0 * k as f64).sqrt()).max(1.0);
1223 let hi = chi2_bracket_hi(p, a, hi0);
1224 Ok(chi2_bisect(p, a, 0.0, hi, 0))
1225}
1226
1227fn chi2_bracket_hi(p: f64, a: f64, hi: f64) -> f64 {
1228 if chi2_cdf(hi, a) >= p {
1229 hi
1230 } else {
1231 chi2_bracket_hi(p, a, hi * 2.0)
1232 }
1233}
1234
1235fn chi2_bisect(p: f64, a: f64, lo: f64, hi: f64, iter: usize) -> f64 {
1236 if iter >= 120 {
1237 return 0.5 * (lo + hi);
1238 }
1239 let mid = 0.5 * (lo + hi);
1240 if chi2_cdf(mid, a) < p {
1241 chi2_bisect(p, a, mid, hi, iter + 1)
1242 } else {
1243 chi2_bisect(p, a, lo, mid, iter + 1)
1244 }
1245}
1246
1247fn chi2_cdf(x: f64, a: f64) -> f64 {
1248 regularized_gamma_p(a, 0.5 * x)
1249}
1250
1251const GAMMA_EPS: f64 = 1.0e-15;
1252const GAMMA_FPMIN: f64 = 1.0e-300;
1253const GAMMA_ITMAX: usize = 1_000;
1254
1255fn regularized_gamma_p(a: f64, x: f64) -> f64 {
1256 if x <= 0.0 {
1257 return 0.0;
1258 }
1259
1260 if x < a + 1.0 {
1261 let gln = log_gamma(a);
1262 let sum = gamma_series(x, 1.0 / a, 1.0 / a, a, 1);
1263 sum * (-x + a * x.ln() - gln).exp()
1264 } else {
1265 let gln = log_gamma(a);
1266 let q = gamma_continued_fraction(a, x) * (-x + a * x.ln() - gln).exp();
1267 1.0 - q
1268 }
1269}
1270
1271fn gamma_series(x: f64, sum: f64, del: f64, ap: f64, n: usize) -> f64 {
1272 if n > GAMMA_ITMAX {
1273 return sum;
1274 }
1275 let ap = ap + 1.0;
1276 let del = del * x / ap;
1277 let sum = sum + del;
1278 if del.abs() < sum.abs() * GAMMA_EPS {
1279 sum
1280 } else {
1281 gamma_series(x, sum, del, ap, n + 1)
1282 }
1283}
1284
1285fn gamma_continued_fraction(a: f64, x: f64) -> f64 {
1286 let b = x + 1.0 - a;
1287 let c = 1.0 / GAMMA_FPMIN;
1288 let d = 1.0 / safe_denominator(b);
1289 gamma_cf_iter(a, b, c, d, d, 1)
1290}
1291
1292fn gamma_cf_iter(a: f64, b: f64, c: f64, d: f64, h: f64, n: usize) -> f64 {
1293 if n > GAMMA_ITMAX {
1294 return h;
1295 }
1296
1297 let an = -(n as f64) * (n as f64 - a);
1298 let b = b + 2.0;
1299 let d = 1.0 / safe_denominator(an * d + b);
1300 let c = safe_denominator(b + an / c);
1301 let delta = d * c;
1302 let h = h * delta;
1303
1304 if (delta - 1.0).abs() < GAMMA_EPS {
1305 h
1306 } else {
1307 gamma_cf_iter(a, b, c, d, h, n + 1)
1308 }
1309}
1310
1311fn safe_denominator(x: f64) -> f64 {
1312 if x.abs() < GAMMA_FPMIN {
1313 GAMMA_FPMIN
1314 } else {
1315 x
1316 }
1317}
1318
1319const LANCZOS: [f64; 9] = [
1320 0.9999999999998099,
1321 676.5203681218851,
1322 -1259.1392167224028,
1323 771.3234287776531,
1324 -176.6150291621406,
1325 12.507343278686905,
1326 -0.13857109526572012,
1327 9.984369578019572e-6,
1328 1.5056327351493116e-7,
1329];
1330const SQRT_2PI: f64 = 2.5066282746310002;
1331
1332fn log_gamma(z: f64) -> f64 {
1333 if z < 0.5 {
1334 std::f64::consts::PI.ln() - (std::f64::consts::PI * z).sin().ln() - log_gamma(1.0 - z)
1335 } else {
1336 let z = z - 1.0;
1337 let mut x = LANCZOS[0];
1338 for (i, coef) in LANCZOS.iter().enumerate().skip(1) {
1339 x += coef / (z + i as f64);
1340 }
1341 let t = z + 7.5;
1342 SQRT_2PI.ln() + (z + 0.5) * t.ln() - t + x.ln()
1343 }
1344}
1345
1346#[cfg(test)]
1347mod tests {
1348 use super::*;
1349 use crate::{GnssSatelliteId, GnssSystem};
1350
1351 use std::path::PathBuf;
1352
1353 use crate::rinex_nav::BroadcastStore;
1354 use crate::rinex_obs::{pseudoranges, RinexObs, SignalPolicy};
1355 use crate::spp::{Corrections, KlobucharCoeffs, RobustConfig, SurfaceMet};
1356
1357 fn fixture_path(name: &str) -> PathBuf {
1358 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1359 .join("tests/fixtures")
1360 .join(name)
1361 }
1362
1363 fn esbc_broadcast_store() -> BroadcastStore {
1366 let nav = std::fs::read_to_string(fixture_path("nav/ESBC00DNK_R_20201770000_01D_MN.rnx"))
1367 .expect("read ESBC broadcast NAV fixture");
1368 BroadcastStore::from_nav(&nav).expect("parse ESBC broadcast NAV")
1369 }
1370
1371 fn esbc_first_epoch_inputs() -> SolveInputs {
1373 let obs_text = std::fs::read_to_string(fixture_path(
1374 "obs/ESBC00DNK_R_20201770000_01D_30S_MO_trim.rnx",
1375 ))
1376 .expect("read ESBC OBS fixture");
1377 let obs = RinexObs::parse(&obs_text).expect("parse ESBC OBS fixture");
1378 let policy = SignalPolicy {
1379 codes: [(GnssSystem::Gps, vec!["C1C".to_string()])]
1380 .into_iter()
1381 .collect(),
1382 };
1383 let observations = pseudoranges(&obs, &obs.epochs()[0], &policy)
1384 .expect("valid pseudoranges")
1385 .into_iter()
1386 .map(|(satellite_id, pseudorange_m)| Observation {
1387 satellite_id,
1388 pseudorange_m,
1389 })
1390 .collect();
1391
1392 SolveInputs {
1393 observations,
1394 t_rx_j2000_s: 646_315_200.0,
1395 t_rx_second_of_day_s: 0.0,
1396 day_of_year: 177.0,
1397 initial_guess: [3_582_135.0, 532_569.0, 5_232_779.0, 0.0],
1398 corrections: Corrections {
1399 ionosphere: false,
1400 troposphere: true,
1401 },
1402 klobuchar: KlobucharCoeffs {
1403 alpha: [0.0; 4],
1404 beta: [0.0; 4],
1405 },
1406 beidou_klobuchar: None,
1407 galileo_nequick: None,
1408 sbas_iono: None,
1409 glonass_channels: std::collections::BTreeMap::new(),
1410 met: SurfaceMet {
1411 pressure_hpa: 1013.25,
1412 temperature_k: 288.15,
1413 relative_humidity: 0.5,
1414 },
1415 robust: None,
1416 }
1417 }
1418
1419 fn assert_receiver_solution_bits_eq(left: &ReceiverSolution, right: &ReceiverSolution) {
1420 assert_eq!(left.position.x_m.to_bits(), right.position.x_m.to_bits());
1421 assert_eq!(left.position.y_m.to_bits(), right.position.y_m.to_bits());
1422 assert_eq!(left.position.z_m.to_bits(), right.position.z_m.to_bits());
1423 assert_eq!(left.geodetic, right.geodetic);
1424 assert_eq!(left.rx_clock_s.to_bits(), right.rx_clock_s.to_bits());
1425 assert_eq!(left.rx_clock_drift_s_s, right.rx_clock_drift_s_s);
1426 assert_eq!(left.dop, right.dop);
1427 assert_eq!(
1428 left.position_covariance
1429 .ecef_m2
1430 .iter()
1431 .flatten()
1432 .map(|v| v.to_bits())
1433 .collect::<Vec<_>>(),
1434 right
1435 .position_covariance
1436 .ecef_m2
1437 .iter()
1438 .flatten()
1439 .map(|v| v.to_bits())
1440 .collect::<Vec<_>>()
1441 );
1442 assert_eq!(
1443 left.position_covariance
1444 .enu_m2
1445 .iter()
1446 .flatten()
1447 .map(|v| v.to_bits())
1448 .collect::<Vec<_>>(),
1449 right
1450 .position_covariance
1451 .enu_m2
1452 .iter()
1453 .flatten()
1454 .map(|v| v.to_bits())
1455 .collect::<Vec<_>>()
1456 );
1457 assert_eq!(
1458 left.residuals_m
1459 .iter()
1460 .map(|v| v.to_bits())
1461 .collect::<Vec<_>>(),
1462 right
1463 .residuals_m
1464 .iter()
1465 .map(|v| v.to_bits())
1466 .collect::<Vec<_>>()
1467 );
1468 assert_eq!(left.used_sats, right.used_sats);
1469 assert_eq!(left.rejected_sats, right.rejected_sats);
1470 assert_eq!(left.metadata, right.metadata);
1471 }
1472
1473 fn fde_spp_options(inputs: &SolveInputs) -> FdeSppOptions {
1474 FdeSppOptions {
1475 fde: FdeOptions {
1476 raim: RaimOptions::default(),
1477 max_iterations: inputs.observations.len().saturating_sub(4),
1478 },
1479 validation: SolutionValidationOptions::default(),
1480 }
1481 }
1482
1483 fn position_delta_m(left: &ReceiverSolution, right: &ReceiverSolution) -> f64 {
1484 ((left.position.x_m - right.position.x_m).powi(2)
1485 + (left.position.y_m - right.position.y_m).powi(2)
1486 + (left.position.z_m - right.position.z_m).powi(2))
1487 .sqrt()
1488 }
1489
1490 #[test]
1494 fn fde_spp_matches_manual_composition_bit_for_bit() {
1495 let store = esbc_broadcast_store();
1496 let with_geodetic = true;
1497
1498 let clean_inputs = esbc_first_epoch_inputs();
1502 let clean = solve(&store, &clean_inputs, with_geodetic).expect("clean solve converges");
1503 assert!(
1504 clean.used_sats.len() >= 6,
1505 "scenario needs redundancy for a testable RAIM exclusion"
1506 );
1507 let outlier_sat = *clean.used_sats.last().expect("a used satellite");
1508
1509 let mut inputs = clean_inputs;
1512 let outlier_obs = inputs
1513 .observations
1514 .iter_mut()
1515 .find(|obs| obs.satellite_id == outlier_sat)
1516 .expect("outlier satellite is present in the observation set");
1517 outlier_obs.pseudorange_m += 1000.0;
1518
1519 let options = fde_spp_options(&inputs);
1520
1521 let driver = fde_spp(&store, &inputs, with_geodetic, &options)
1523 .expect("driver FDE resolves the fault");
1524
1525 let observations = inputs.observations.clone();
1527 let reference = fde(&observations, &options.fde, |remaining| {
1528 let mut next = inputs.clone();
1529 next.observations = remaining.to_vec();
1530 let solution = solve(&store, &next, with_geodetic).map_err(FdeSppError::Spp)?;
1531 validate_receiver_solution(&solution, options.validation)
1532 .map_err(FdeSppError::Validation)?;
1533 Ok::<_, FdeSppError>(solution)
1534 })
1535 .expect("reference FDE resolves the fault");
1536
1537 assert_eq!(driver.excluded, reference.excluded);
1539 assert_eq!(driver.iterations, reference.iterations);
1540 assert_receiver_solution_bits_eq(&driver.solution, &reference.solution);
1541
1542 assert!(driver.iterations >= 1, "the fault must drive an exclusion");
1549 assert!(!driver.excluded.is_empty());
1550 assert_eq!(driver.excluded.len(), driver.iterations);
1551 let surviving = raim_for_solution(&driver.solution, &options.fde.raim).expect("raim");
1552 assert!(
1553 !surviving.fault_detected,
1554 "the protected set must pass RAIM (or be untestable)"
1555 );
1556 }
1557
1558 #[test]
1561 fn fde_spp_clean_set_takes_no_exclusion_and_matches_manual() {
1562 let store = esbc_broadcast_store();
1563 let inputs = esbc_first_epoch_inputs();
1564 let options = fde_spp_options(&inputs);
1565
1566 let driver = fde_spp(&store, &inputs, false, &options).expect("driver solves clean set");
1567
1568 let observations = inputs.observations.clone();
1569 let reference = fde(&observations, &options.fde, |remaining| {
1570 let mut next = inputs.clone();
1571 next.observations = remaining.to_vec();
1572 let solution = solve(&store, &next, false).map_err(FdeSppError::Spp)?;
1573 validate_receiver_solution(&solution, options.validation)
1574 .map_err(FdeSppError::Validation)?;
1575 Ok::<_, FdeSppError>(solution)
1576 })
1577 .expect("reference solves clean set");
1578
1579 assert_eq!(driver.iterations, 0);
1580 assert!(driver.excluded.is_empty());
1581 assert_eq!(driver.iterations, reference.iterations);
1582 assert_eq!(driver.excluded, reference.excluded);
1583 assert_receiver_solution_bits_eq(&driver.solution, &reference.solution);
1584 }
1585
1586 #[test]
1587 fn spp_robust_fde_driver_clean_set_uses_robust_solve_without_exclusion() {
1588 let store = esbc_broadcast_store();
1589 let inputs = esbc_first_epoch_inputs();
1590 let options = fde_spp_options(&inputs);
1591
1592 let driver =
1593 spp_robust_fde_driver(&store, &inputs, false, RobustConfig::default(), &options)
1594 .expect("robust FDE solves clean set");
1595
1596 assert_eq!(driver.iterations, 0);
1597 assert!(driver.excluded.is_empty());
1598 assert!(driver.solution.metadata.outer_iterations > 0);
1599 assert!(driver.solution.metadata.final_robust_scale_m.is_some());
1600 let surviving = raim_for_solution(&driver.solution, &options.fde.raim).expect("raim");
1601 assert!(!surviving.fault_detected);
1602 }
1603
1604 #[test]
1605 fn spp_robust_fde_driver_excludes_fault_and_recovers_solution() {
1606 let store = esbc_broadcast_store();
1607 let clean_inputs = esbc_first_epoch_inputs();
1608 let clean_options = fde_spp_options(&clean_inputs);
1609 let robust = RobustConfig::default();
1610 let clean = spp_robust_fde_driver(&store, &clean_inputs, false, robust, &clean_options)
1611 .expect("clean robust FDE solve");
1612 let outlier_sat = gps(15);
1613 assert!(clean.solution.used_sats.contains(&outlier_sat));
1614
1615 let mut faulty_inputs = clean_inputs.clone();
1616 let outlier_obs = faulty_inputs
1617 .observations
1618 .iter_mut()
1619 .find(|obs| obs.satellite_id == outlier_sat)
1620 .expect("outlier satellite is observed");
1621 outlier_obs.pseudorange_m += 1000.0;
1622 let faulty_options = fde_spp_options(&faulty_inputs);
1623
1624 let driver = spp_robust_fde_driver(&store, &faulty_inputs, false, robust, &faulty_options)
1625 .expect("robust FDE resolves fault");
1626
1627 assert_eq!(driver.iterations, 1);
1628 assert_eq!(driver.iterations, driver.excluded.len());
1629 assert_eq!(driver.excluded, vec![outlier_sat.to_string()]);
1630 assert!(driver.solution.metadata.outer_iterations > 0);
1631 assert!(driver.solution.metadata.final_robust_scale_m.is_some());
1632 let surviving = raim_for_solution(&driver.solution, &faulty_options.fde.raim)
1633 .expect("surviving set RAIM");
1634 assert!(!surviving.fault_detected);
1635 let recovered_delta_m = position_delta_m(&driver.solution, &clean.solution);
1636 assert!(
1637 recovered_delta_m < 1.0,
1638 "protected solution should stay close to the clean robust solution, got {recovered_delta_m} m with exclusions {:?}",
1639 driver.excluded
1640 );
1641 }
1642
1643 #[derive(Debug, Clone)]
1644 struct TestSolution {
1645 used_sats: Vec<String>,
1646 residuals_m: Vec<f64>,
1647 }
1648
1649 impl RaimSolution for TestSolution {
1650 fn raim_used_sats(&self) -> Vec<String> {
1651 self.used_sats.clone()
1652 }
1653
1654 fn raim_residuals_m(&self) -> &[f64] {
1655 &self.residuals_m
1656 }
1657 }
1658
1659 fn gps(prn: u8) -> GnssSatelliteId {
1660 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
1661 }
1662
1663 fn valid_receiver_solution() -> ReceiverSolution {
1664 ReceiverSolution {
1665 position: crate::frame::ItrfPositionM::new(6_378_137.0, 0.0, 0.0).unwrap(),
1666 geodetic: None,
1667 rx_clock_s: 0.0,
1668 rx_clock_drift_s_s: None,
1669 system_clocks_s: vec![(GnssSystem::Gps, 0.0)],
1670 dop: Some(crate::dop::Dop {
1671 gdop: 2.5,
1672 pdop: 2.0,
1673 hdop: 1.5,
1674 vdop: 1.0,
1675 tdop: 0.5,
1676 system_tdops: vec![(GnssSystem::Gps, 0.5)],
1677 }),
1678 system_tdops: vec![(GnssSystem::Gps, 0.5)],
1679 position_covariance: crate::dop::PositionCovariance {
1680 ecef_m2: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1681 enu_m2: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1682 },
1683 residuals_m: vec![0.1, -0.1, 0.0, 0.05, -0.05],
1684 used_sats: (1..=5).map(gps).collect(),
1685 rejected_sats: Vec::new(),
1686 geometry_quality: crate::geometry_quality::GeometryQuality {
1687 tier: crate::geometry_quality::ObservabilityTier::Nominal,
1688 redundancy: 1,
1689 rank: 4,
1690 condition_number: 1.0,
1691 gdop: 2.5,
1692 raim_checkable: true,
1693 covariance_validated: true,
1694 },
1695 metadata: crate::spp::SolutionMetadata {
1696 iterations: 3,
1697 converged: true,
1698 status: crate::astro::math::least_squares::Status::StepTolerance,
1699 ionosphere_applied: false,
1700 troposphere_applied: false,
1701 outer_iterations: 0,
1702 final_robust_scale_m: None,
1703 used_count: 5,
1704 systems: vec![GnssSystem::Gps],
1705 redundancy: 1,
1706 raim_checkable: true,
1707 },
1708 }
1709 }
1710
1711 #[test]
1712 fn pseudorange_variance_matches_elevation_model() {
1713 let opts = PseudorangeVarianceOptions::default();
1714 let variance = pseudorange_variance(30.0, opts).unwrap();
1715 assert!((variance - 0.45).abs() < 1.0e-15);
1716 assert_eq!(
1717 pseudorange_variance(0.0, opts),
1718 Err(QualityError::InvalidElevation)
1719 );
1720 let horizon_opts = PseudorangeVarianceOptions { b_m: 0.0, ..opts };
1721 assert_eq!(
1722 pseudorange_variance(0.0, horizon_opts),
1723 Ok(horizon_opts.a_m * horizon_opts.a_m)
1724 );
1725 assert_eq!(
1726 pseudorange_variance(-90.0, horizon_opts),
1727 Ok(horizon_opts.a_m * horizon_opts.a_m)
1728 );
1729 assert_eq!(
1730 pseudorange_variance(90.1, horizon_opts),
1731 Err(QualityError::InvalidElevation)
1732 );
1733 assert_eq!(
1734 pseudorange_variance(f64::NAN, opts),
1735 Err(QualityError::InvalidElevation)
1736 );
1737 }
1738
1739 #[test]
1740 fn cn0_model_requires_cn0_and_adds_noise_term() {
1741 let opts = PseudorangeVarianceOptions {
1742 model: PseudorangeVarianceModel::ElevationCn0,
1743 cn0_dbhz: None,
1744 ..Default::default()
1745 };
1746 assert_eq!(
1747 pseudorange_variance(30.0, opts),
1748 Err(QualityError::MissingCn0)
1749 );
1750
1751 let weak = pseudorange_variance(
1752 30.0,
1753 PseudorangeVarianceOptions {
1754 cn0_dbhz: Some(30.0),
1755 ..opts
1756 },
1757 )
1758 .unwrap();
1759 let strong = pseudorange_variance(
1760 30.0,
1761 PseudorangeVarianceOptions {
1762 cn0_dbhz: Some(50.0),
1763 ..opts
1764 },
1765 )
1766 .unwrap();
1767 assert!(strong < weak);
1768 }
1769
1770 #[test]
1771 fn pseudorange_variance_rejects_nonfinite_and_negative_parameters() {
1772 let invalid_a = PseudorangeVarianceOptions {
1773 a_m: f64::NAN,
1774 ..Default::default()
1775 };
1776 assert_eq!(
1777 pseudorange_variance(30.0, invalid_a),
1778 Err(QualityError::InvalidParameter)
1779 );
1780
1781 let invalid_b = PseudorangeVarianceOptions {
1782 b_m: -1.0,
1783 ..Default::default()
1784 };
1785 assert_eq!(
1786 pseudorange_variance(30.0, invalid_b),
1787 Err(QualityError::InvalidParameter)
1788 );
1789
1790 let invalid_cn0_scale = PseudorangeVarianceOptions {
1791 cn0_scale_m2: f64::INFINITY,
1792 ..Default::default()
1793 };
1794 assert_eq!(
1795 pseudorange_variance(30.0, invalid_cn0_scale),
1796 Err(QualityError::InvalidParameter)
1797 );
1798
1799 let invalid_cn0 = PseudorangeVarianceOptions {
1800 model: PseudorangeVarianceModel::ElevationCn0,
1801 cn0_dbhz: Some(f64::NAN),
1802 ..Default::default()
1803 };
1804 assert_eq!(
1805 pseudorange_variance(30.0, invalid_cn0),
1806 Err(QualityError::InvalidParameter)
1807 );
1808 }
1809
1810 #[test]
1811 fn pseudorange_variance_rejects_zero_total_variance() {
1812 let zero_variance = PseudorangeVarianceOptions {
1813 a_m: 0.0,
1814 b_m: 0.0,
1815 ..Default::default()
1816 };
1817 assert_eq!(
1818 pseudorange_variance(30.0, zero_variance),
1819 Err(QualityError::InvalidParameter)
1820 );
1821
1822 let entries = vec![WeightEntry {
1823 satellite_id: "G01".to_string(),
1824 elevation_deg: 30.0,
1825 cn0_dbhz: None,
1826 }];
1827 let weights = weight_vector(&entries, zero_variance);
1828 assert!(
1829 !weights.contains_key("G01"),
1830 "zero variance must not produce an infinite inverse-variance weight"
1831 );
1832 }
1833
1834 #[test]
1835 fn sigma_and_weight_maps_drop_invalid_entries() {
1836 let entries = vec![
1837 WeightEntry {
1838 satellite_id: "G01".to_string(),
1839 elevation_deg: 90.0,
1840 cn0_dbhz: None,
1841 },
1842 WeightEntry {
1843 satellite_id: "G02".to_string(),
1844 elevation_deg: -91.0,
1845 cn0_dbhz: None,
1846 },
1847 ];
1848 let sigmas = sigmas(&entries, Default::default());
1849 let weights = weight_vector(&entries, Default::default());
1850 assert!(sigmas.contains_key("G01"));
1851 assert!(!sigmas.contains_key("G02"));
1852 assert_eq!(weights["G01"], 1.0 / (sigmas["G01"] * sigmas["G01"]));
1853 }
1854
1855 #[test]
1856 fn sigma_and_weight_maps_retain_horizon_entries_without_elevation_term() {
1857 let entries = vec![
1858 WeightEntry {
1859 satellite_id: "G01".to_string(),
1860 elevation_deg: 0.0,
1861 cn0_dbhz: None,
1862 },
1863 WeightEntry {
1864 satellite_id: "G02".to_string(),
1865 elevation_deg: f64::NAN,
1866 cn0_dbhz: None,
1867 },
1868 ];
1869 let options = PseudorangeVarianceOptions {
1870 b_m: 0.0,
1871 ..Default::default()
1872 };
1873 let sigmas = sigmas(&entries, options);
1874 let weights = weight_vector(&entries, options);
1875 assert_eq!(sigmas["G01"], options.a_m);
1876 assert_eq!(weights["G01"], 1.0 / (options.a_m * options.a_m));
1877 assert!(!sigmas.contains_key("G02"));
1878 assert!(!weights.contains_key("G02"));
1879 }
1880
1881 #[test]
1882 fn chi_square_inverse_matches_reference_values() {
1883 let refs = [
1884 (1, 10.828),
1885 (2, 13.816),
1886 (3, 16.266),
1887 (4, 18.467),
1888 (5, 20.515),
1889 ];
1890 for (dof, expected) in refs {
1891 let got = chi2_inv(0.999, dof).unwrap();
1892 assert!((got - expected).abs() < 1.0e-3);
1893 }
1894 assert_eq!(chi2_inv(1.0, 1), Err(QualityError::InvalidProbability));
1895 assert_eq!(chi2_inv(0.95, 0), Err(QualityError::InvalidDof));
1896 }
1897
1898 #[test]
1899 fn residual_diagnostics_reports_weighted_redundancy_and_reduced_chi_square() {
1900 let residuals = [1.0, -2.0, 0.5, 3.0, -1.5];
1901 let weights = [1.0, 0.25, 4.0, 1.0, 0.5];
1902 let diagnostics =
1903 residual_diagnostics(&residuals, Some(&weights), 3, Some(1.0e-3)).expect("diagnostics");
1904
1905 let wss = residuals
1906 .iter()
1907 .zip(weights)
1908 .map(|(r, w)| r * r * w)
1909 .sum::<f64>();
1910 assert_eq!(diagnostics.n_residuals, 5);
1911 assert_eq!(diagnostics.n_parameters, 3);
1912 assert_eq!(diagnostics.degrees_of_freedom, 2);
1913 assert_eq!(diagnostics.weighted_sum_squares.to_bits(), wss.to_bits());
1914 assert_eq!(
1915 diagnostics.reduced_chi_square.unwrap().to_bits(),
1916 (wss / 2.0).to_bits()
1917 );
1918 assert_eq!(
1919 diagnostics.normalized_residuals[1].to_bits(),
1920 (-1.0f64).to_bits()
1921 );
1922 assert_eq!(diagnostics.worst_index, Some(3));
1923 assert!(diagnostics.chi_square_threshold.unwrap().is_finite());
1924 assert_eq!(diagnostics.chi_square_consistent, Some(true));
1925 }
1926
1927 #[test]
1928 fn residual_diagnostics_handles_no_redundancy_and_rejects_bad_inputs() {
1929 let residuals = [1.0, -1.0];
1930 let diagnostics =
1931 residual_diagnostics(&residuals, None, 2, Some(1.0e-3)).expect("diagnostics");
1932 assert_eq!(diagnostics.degrees_of_freedom, 0);
1933 assert_eq!(diagnostics.reduced_chi_square, None);
1934 assert_eq!(diagnostics.chi_square_threshold, None);
1935 assert_eq!(diagnostics.chi_square_consistent, None);
1936
1937 assert_eq!(
1938 residual_diagnostics(&[1.0, f64::NAN], None, 1, None),
1939 Err(QualityError::InvalidResiduals)
1940 );
1941 assert_eq!(
1942 residual_diagnostics(&[1.0], Some(&[0.0]), 0, None),
1943 Err(QualityError::InvalidWeight)
1944 );
1945 assert_eq!(
1946 residual_diagnostics(&[1.0], None, 0, Some(1.0)),
1947 Err(QualityError::InvalidProbability)
1948 );
1949 }
1950
1951 #[test]
1952 fn raim_reports_fault_and_worst_satellite() {
1953 let input = RaimInput {
1954 used_sats: ["G01", "G02", "G03", "G04", "G05"]
1955 .into_iter()
1956 .map(str::to_string)
1957 .collect(),
1958 residuals_m: vec![0.0, 0.0, 0.0, 0.0, 5.0],
1959 };
1960 let result = raim(&input, &RaimOptions::default()).unwrap();
1961 assert!(result.fault_detected);
1962 assert!(result.testable);
1963 assert_eq!(result.dof, 1);
1964 assert_eq!(result.test_statistic, 25.0);
1965 assert_eq!(result.worst_sat.as_deref(), Some("G05"));
1966 }
1967
1968 #[test]
1969 fn raim_dof_zero_is_not_testable() {
1970 let input = RaimInput {
1971 used_sats: ["G01", "G02", "G03", "G04"]
1972 .into_iter()
1973 .map(str::to_string)
1974 .collect(),
1975 residuals_m: vec![0.0, 0.0, 0.0, 0.0],
1976 };
1977 let result = raim(&input, &RaimOptions::default()).unwrap();
1978 assert!(!result.fault_detected);
1979 assert!(!result.testable);
1980 assert_eq!(result.threshold, None);
1981 assert_eq!(result.dof, 0);
1982 }
1983
1984 #[test]
1985 fn raim_rejects_nonpositive_system_overrides() {
1986 let input = RaimInput {
1987 used_sats: ["G01", "G02", "G03", "G04", "G05"]
1988 .into_iter()
1989 .map(str::to_string)
1990 .collect(),
1991 residuals_m: vec![0.0; 5],
1992 };
1993
1994 for n_systems in [0, -1] {
1995 let options = RaimOptions {
1996 n_systems: Some(n_systems),
1997 ..Default::default()
1998 };
1999 assert_eq!(
2000 raim(&input, &options),
2001 Err(QualityError::InvalidSystemCount)
2002 );
2003 }
2004 }
2005
2006 #[test]
2007 fn raim_positive_system_override_controls_dof() {
2008 let input = RaimInput {
2009 used_sats: ["G01", "G02", "G03", "G04", "G05", "G06"]
2010 .into_iter()
2011 .map(str::to_string)
2012 .collect(),
2013 residuals_m: vec![0.0; 6],
2014 };
2015 let options = RaimOptions {
2016 n_systems: Some(2),
2017 ..Default::default()
2018 };
2019
2020 let result = raim(&input, &options).unwrap();
2021 assert!(result.testable);
2022 assert_eq!(result.dof, 1);
2023 }
2024
2025 #[test]
2026 fn raim_rejects_misaligned_or_nonfinite_residuals() {
2027 let input = RaimInput {
2028 used_sats: ["G01", "G02"].into_iter().map(str::to_string).collect(),
2029 residuals_m: vec![1.0],
2030 };
2031 assert_eq!(
2032 raim(&input, &RaimOptions::default()),
2033 Err(QualityError::InvalidResiduals)
2034 );
2035
2036 let input = RaimInput {
2037 used_sats: ["G01", "G02"].into_iter().map(str::to_string).collect(),
2038 residuals_m: vec![1.0, f64::NAN],
2039 };
2040 assert_eq!(
2041 raim(&input, &RaimOptions::default()),
2042 Err(QualityError::InvalidResiduals)
2043 );
2044 }
2045
2046 #[test]
2047 fn raim_rejects_nonfinite_weights_and_probability() {
2048 let input = RaimInput {
2049 used_sats: ["G01", "G02", "G03", "G04", "G05"]
2050 .into_iter()
2051 .map(str::to_string)
2052 .collect(),
2053 residuals_m: vec![0.0; 5],
2054 };
2055 let mut weights = BTreeMap::new();
2056 weights.insert("G01".to_string(), f64::NAN);
2057 let options = RaimOptions {
2058 weights: RaimWeights::BySatellite(weights),
2059 ..Default::default()
2060 };
2061 assert_eq!(raim(&input, &options), Err(QualityError::InvalidWeight));
2062
2063 let options = RaimOptions {
2064 p_fa: f64::NAN,
2065 ..Default::default()
2066 };
2067 assert_eq!(
2068 raim(&input, &options),
2069 Err(QualityError::InvalidProbability)
2070 );
2071 }
2072
2073 #[test]
2074 fn fde_excludes_largest_normalized_residual() {
2075 let observations: Vec<Observation> = (1..=5)
2076 .map(|prn| Observation {
2077 satellite_id: gps(prn),
2078 pseudorange_m: prn as f64,
2079 })
2080 .collect();
2081
2082 let options = FdeOptions {
2083 raim: RaimOptions::default(),
2084 max_iterations: 1,
2085 };
2086 let result = fde(&observations, &options, |remaining| {
2087 let used_sats = remaining
2088 .iter()
2089 .map(|ob| ob.satellite_id.to_string())
2090 .collect::<Vec<_>>();
2091 let residuals_m = remaining
2092 .iter()
2093 .map(|ob| if ob.satellite_id == gps(5) { 5.0 } else { 0.0 })
2094 .collect::<Vec<_>>();
2095 Ok::<_, ()>(TestSolution {
2096 used_sats,
2097 residuals_m,
2098 })
2099 })
2100 .unwrap();
2101
2102 assert_eq!(result.excluded, vec!["G05".to_string()]);
2103 assert_eq!(result.iterations, 1);
2104 assert_eq!(result.solution.used_sats.len(), 4);
2105 }
2106
2107 #[test]
2108 fn fde_refuses_fault_when_budget_is_exhausted() {
2109 let observations: Vec<Observation> = (1..=5)
2110 .map(|prn| Observation {
2111 satellite_id: gps(prn),
2112 pseudorange_m: prn as f64,
2113 })
2114 .collect();
2115 let options = FdeOptions {
2116 raim: RaimOptions::default(),
2117 max_iterations: 0,
2118 };
2119 let err = fde(&observations, &options, |remaining| {
2120 Ok::<_, ()>(TestSolution {
2121 used_sats: remaining
2122 .iter()
2123 .map(|ob| ob.satellite_id.to_string())
2124 .collect(),
2125 residuals_m: vec![0.0, 0.0, 0.0, 0.0, 5.0],
2126 })
2127 })
2128 .unwrap_err();
2129
2130 assert_eq!(err, FdeError::FaultUnresolved(25.0));
2131 }
2132
2133 #[test]
2134 fn receiver_solution_validation_rejects_invalid_gate_options() {
2135 let solution = valid_receiver_solution();
2136 for (options, field, reason) in [
2137 (
2138 SolutionValidationOptions {
2139 max_pdop: Some(f64::NAN),
2140 ..Default::default()
2141 },
2142 "max_pdop",
2143 "not finite",
2144 ),
2145 (
2146 SolutionValidationOptions {
2147 max_pdop: Some(0.0),
2148 ..Default::default()
2149 },
2150 "max_pdop",
2151 "not positive",
2152 ),
2153 (
2154 SolutionValidationOptions {
2155 min_plausible_radius_m: 0.0,
2156 ..Default::default()
2157 },
2158 "min_plausible_radius_m",
2159 "not positive",
2160 ),
2161 (
2162 SolutionValidationOptions {
2163 max_plausible_radius_m: f64::INFINITY,
2164 ..Default::default()
2165 },
2166 "max_plausible_radius_m",
2167 "not finite",
2168 ),
2169 (
2170 SolutionValidationOptions {
2171 max_converged_residual_rms_m: f64::NAN,
2172 ..Default::default()
2173 },
2174 "max_converged_residual_rms_m",
2175 "not finite",
2176 ),
2177 ] {
2178 assert_eq!(
2179 validate_receiver_solution(&solution, options),
2180 Err(SolutionValidationError::InvalidOptions { field, reason })
2181 );
2182 }
2183
2184 let inverted_radius = SolutionValidationOptions {
2185 min_plausible_radius_m: 8_000_000.0,
2186 max_plausible_radius_m: 7_000_000.0,
2187 ..Default::default()
2188 };
2189 assert_eq!(
2190 validate_receiver_solution(&solution, inverted_radius),
2191 Err(SolutionValidationError::InvalidOptions {
2192 field: "plausible_radius_m",
2193 reason: "must be increasing",
2194 })
2195 );
2196 }
2197
2198 #[test]
2199 fn receiver_solution_validation_rejects_nonfinite_residuals() {
2200 let mut solution = valid_receiver_solution();
2201 solution.residuals_m[1] = f64::NAN;
2202 assert_eq!(
2203 validate_receiver_solution(&solution, SolutionValidationOptions::default()),
2204 Err(SolutionValidationError::InvalidResiduals)
2205 );
2206 }
2207
2208 fn range_design_rows() -> Vec<[f64; 4]> {
2211 vec![
2212 [-0.10, -0.20, -0.97, 1.0],
2213 [0.50, -0.30, -0.81, 1.0],
2214 [-0.60, 0.40, -0.69, 1.0],
2215 [0.20, 0.80, -0.56, 1.0],
2216 [0.70, 0.50, -0.51, 1.0],
2217 [-0.50, -0.70, -0.51, 1.0],
2218 [0.30, -0.60, -0.74, 1.0],
2219 [-0.80, 0.10, -0.59, 1.0],
2220 ]
2221 }
2222
2223 fn range_rows(dx_true: [f64; 4]) -> Vec<RangeFdeRow> {
2224 range_design_rows()
2225 .iter()
2226 .enumerate()
2227 .map(|(i, h)| RangeFdeRow {
2228 id: format!("S{:02}", i + 1),
2229 residual_m: h.iter().zip(dx_true).map(|(a, b)| a * b).sum(),
2230 design_row: h.to_vec(),
2231 weight: 1.0,
2232 })
2233 .collect()
2234 }
2235
2236 fn assert_close(got: &[f64], want: &[f64], tol: f64) {
2237 assert_eq!(got.len(), want.len());
2238 for (g, w) in got.iter().zip(want) {
2239 assert!((g - w).abs() < tol, "got {g}, want {w}");
2240 }
2241 }
2242
2243 #[test]
2244 fn range_fde_clean_set_recovers_state_without_exclusions() {
2245 let dx_true = [1.0, -2.0, 0.5, 3.0];
2246 let rows = range_rows(dx_true);
2247 let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2248
2249 assert!(!result.global_test.fault_detected);
2250 assert!(result.global_test.testable);
2251 assert_eq!(result.global_test.dof, 4);
2252 assert!(result.excluded.is_empty());
2253 assert_eq!(result.iterations, 0);
2254 assert!(result.global_test.weighted_sum_squares < 1.0e-12);
2255 assert_close(&result.state_correction, &dx_true, 1.0e-9);
2256 assert_eq!(result.state_covariance.len(), 4);
2257 }
2258
2259 #[test]
2260 fn range_fde_detects_and_excludes_a_single_outlier() {
2261 let dx_true = [1.0, -2.0, 0.5, 3.0];
2262 let mut rows = range_rows(dx_true);
2263 rows[2].residual_m += 50.0; let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2266
2267 assert_eq!(result.excluded, vec!["S03".to_string()]);
2268 assert_eq!(result.iterations, 1);
2269 assert!(!result.global_test.fault_detected);
2270 assert_close(&result.state_correction, &dx_true, 1.0e-9);
2271
2272 let s03 = result
2273 .diagnostics
2274 .iter()
2275 .find(|d| d.id == "S03")
2276 .expect("S03 diagnostic");
2277 assert!(s03.excluded);
2278 assert!(s03.post_fit_residual_m.abs() > 40.0);
2280 for d in result.diagnostics.iter().filter(|d| !d.excluded) {
2282 assert!(d.normalized_residual.abs() < 1.0e-6);
2283 }
2284 }
2285
2286 #[test]
2287 fn range_fde_excludes_multiple_outliers() {
2288 let dx_true = [0.5, 1.5, -1.0, 2.0];
2289 let mut rows = range_rows(dx_true);
2290 rows[2].residual_m += 50.0; rows[5].residual_m -= 40.0; let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2294
2295 assert_eq!(result.iterations, 2);
2296 let mut excluded = result.excluded.clone();
2297 excluded.sort();
2298 assert_eq!(excluded, vec!["S03".to_string(), "S06".to_string()]);
2299 assert!(!result.global_test.fault_detected);
2300 assert_close(&result.state_correction, &dx_true, 1.0e-9);
2301 }
2302
2303 #[test]
2304 fn range_fde_respects_the_exclusion_budget() {
2305 let dx_true = [0.5, 1.5, -1.0, 2.0];
2306 let mut rows = range_rows(dx_true);
2307 rows[2].residual_m += 50.0;
2308 rows[5].residual_m -= 40.0;
2309
2310 let options = RangeFdeOptions {
2311 max_exclusions: 1,
2312 ..Default::default()
2313 };
2314 let result = raim_fde_design(&rows, &options).expect("fde");
2315
2316 assert_eq!(result.iterations, 1);
2318 assert_eq!(result.excluded.len(), 1);
2319 assert!(result.global_test.fault_detected);
2320 }
2321
2322 #[test]
2323 fn range_fde_rejects_rank_deficient_geometry() {
2324 let rows: Vec<RangeFdeRow> = (0..5)
2325 .map(|i| RangeFdeRow {
2326 id: format!("S{:02}", i + 1),
2327 residual_m: 1.0,
2328 design_row: vec![1.0, 0.0, 0.0, 1.0], weight: 1.0,
2330 })
2331 .collect();
2332 assert_eq!(
2333 raim_fde_design(&rows, &RangeFdeOptions::default()),
2334 Err(QualityError::SingularGeometry)
2335 );
2336 }
2337
2338 #[test]
2339 fn range_fde_rejects_malformed_inputs() {
2340 assert_eq!(
2341 raim_fde_design(&[], &RangeFdeOptions::default()),
2342 Err(QualityError::InvalidDesign)
2343 );
2344
2345 let too_few = vec![RangeFdeRow {
2347 id: "S01".to_string(),
2348 residual_m: 0.0,
2349 design_row: vec![1.0, 0.0, 0.0, 1.0],
2350 weight: 1.0,
2351 }];
2352 assert_eq!(
2353 raim_fde_design(&too_few, &RangeFdeOptions::default()),
2354 Err(QualityError::InvalidDesign)
2355 );
2356
2357 let mut ragged = range_rows([1.0, 0.0, 0.0, 0.0]);
2359 ragged[1].design_row.pop();
2360 assert_eq!(
2361 raim_fde_design(&ragged, &RangeFdeOptions::default()),
2362 Err(QualityError::InvalidDesign)
2363 );
2364
2365 let mut bad_weight = range_rows([1.0, 0.0, 0.0, 0.0]);
2367 bad_weight[0].weight = 0.0;
2368 assert_eq!(
2369 raim_fde_design(&bad_weight, &RangeFdeOptions::default()),
2370 Err(QualityError::InvalidWeight)
2371 );
2372
2373 let mut bad_residual = range_rows([1.0, 0.0, 0.0, 0.0]);
2374 bad_residual[0].residual_m = f64::NAN;
2375 assert_eq!(
2376 raim_fde_design(&bad_residual, &RangeFdeOptions::default()),
2377 Err(QualityError::InvalidResiduals)
2378 );
2379
2380 let rows = range_rows([1.0, 0.0, 0.0, 0.0]);
2381 let bad_p = RangeFdeOptions {
2382 p_fa: 1.0,
2383 ..Default::default()
2384 };
2385 assert_eq!(
2386 raim_fde_design(&rows, &bad_p),
2387 Err(QualityError::InvalidProbability)
2388 );
2389 }
2390
2391 #[test]
2392 fn chi_square_threshold_matches_rtklib_demo5_chisqr_table() {
2393 let table: [f64; 20] = [
2399 10.8, 13.8, 16.3, 18.5, 20.5, 22.5, 24.3, 26.1, 27.9, 29.6, 31.3, 32.9, 34.5, 36.1,
2400 37.7, 39.3, 40.8, 42.3, 43.8, 45.3,
2401 ];
2402 for (i, &expected) in table.iter().enumerate() {
2403 let dof = i + 1;
2404 let got = chi2_inv(0.999, dof).expect("chi2 quantile");
2405 let tol = (0.01 * expected).max(0.05);
2406 assert!(
2407 (got - expected).abs() < tol,
2408 "dof {dof}: got {got}, demo5 chisqr {expected}"
2409 );
2410 }
2411 }
2412}