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 = libm::sin(elevation_deg * DEG_TO_RAD);
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 * libm::pow(10.0_f64, -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 * libm::exp(-x + a * libm::log(x) - gln)
1264 } else {
1265 let gln = log_gamma(a);
1266 let q = gamma_continued_fraction(a, x) * libm::exp(-x + a * libm::log(x) - gln);
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 libm::log(std::f64::consts::PI)
1335 - libm::log(libm::sin(std::f64::consts::PI * z))
1336 - log_gamma(1.0 - z)
1337 } else {
1338 let z = z - 1.0;
1339 let mut x = LANCZOS[0];
1340 for (i, coef) in LANCZOS.iter().enumerate().skip(1) {
1341 x += coef / (z + i as f64);
1342 }
1343 let t = z + 7.5;
1344 libm::log(SQRT_2PI) + (z + 0.5) * libm::log(t) - t + libm::log(x)
1345 }
1346}
1347
1348#[cfg(test)]
1349mod tests {
1350 use super::*;
1351 use crate::{GnssSatelliteId, GnssSystem};
1352
1353 use std::path::PathBuf;
1354
1355 use crate::rinex_nav::BroadcastStore;
1356 use crate::rinex_obs::{pseudoranges, RinexObs, SignalPolicy};
1357 use crate::spp::{Corrections, KlobucharCoeffs, RobustConfig, SurfaceMet};
1358
1359 fn fixture_path(name: &str) -> PathBuf {
1360 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1361 .join("tests/fixtures")
1362 .join(name)
1363 }
1364
1365 fn esbc_broadcast_store() -> BroadcastStore {
1368 let nav = std::fs::read_to_string(fixture_path("nav/ESBC00DNK_R_20201770000_01D_MN.rnx"))
1369 .expect("read ESBC broadcast NAV fixture");
1370 BroadcastStore::from_nav(&nav).expect("parse ESBC broadcast NAV")
1371 }
1372
1373 fn esbc_first_epoch_inputs() -> SolveInputs {
1375 let obs_text = std::fs::read_to_string(fixture_path(
1376 "obs/ESBC00DNK_R_20201770000_01D_30S_MO_trim.rnx",
1377 ))
1378 .expect("read ESBC OBS fixture");
1379 let obs = RinexObs::parse(&obs_text).expect("parse ESBC OBS fixture");
1380 let policy = SignalPolicy {
1381 codes: [(GnssSystem::Gps, vec!["C1C".to_string()])]
1382 .into_iter()
1383 .collect(),
1384 };
1385 let observations = pseudoranges(&obs, &obs.epochs()[0], &policy)
1386 .expect("valid pseudoranges")
1387 .into_iter()
1388 .map(|(satellite_id, pseudorange_m)| Observation {
1389 satellite_id,
1390 pseudorange_m,
1391 })
1392 .collect();
1393
1394 SolveInputs {
1395 observations,
1396 t_rx_j2000_s: 646_315_200.0,
1397 t_rx_second_of_day_s: 0.0,
1398 day_of_year: 177.0,
1399 initial_guess: [3_582_135.0, 532_569.0, 5_232_779.0, 0.0],
1400 corrections: Corrections {
1401 ionosphere: false,
1402 troposphere: true,
1403 },
1404 klobuchar: KlobucharCoeffs {
1405 alpha: [0.0; 4],
1406 beta: [0.0; 4],
1407 },
1408 beidou_klobuchar: None,
1409 galileo_nequick: None,
1410 sbas_iono: None,
1411 glonass_channels: std::collections::BTreeMap::new(),
1412 met: SurfaceMet {
1413 pressure_hpa: 1013.25,
1414 temperature_k: 288.15,
1415 relative_humidity: 0.5,
1416 },
1417 robust: None,
1418 }
1419 }
1420
1421 fn assert_receiver_solution_bits_eq(left: &ReceiverSolution, right: &ReceiverSolution) {
1422 assert_eq!(left.position.x_m.to_bits(), right.position.x_m.to_bits());
1423 assert_eq!(left.position.y_m.to_bits(), right.position.y_m.to_bits());
1424 assert_eq!(left.position.z_m.to_bits(), right.position.z_m.to_bits());
1425 assert_eq!(left.geodetic, right.geodetic);
1426 assert_eq!(left.rx_clock_s.to_bits(), right.rx_clock_s.to_bits());
1427 assert_eq!(left.rx_clock_drift_s_s, right.rx_clock_drift_s_s);
1428 assert_eq!(left.dop, right.dop);
1429 assert_eq!(
1430 left.position_covariance
1431 .ecef_m2
1432 .iter()
1433 .flatten()
1434 .map(|v| v.to_bits())
1435 .collect::<Vec<_>>(),
1436 right
1437 .position_covariance
1438 .ecef_m2
1439 .iter()
1440 .flatten()
1441 .map(|v| v.to_bits())
1442 .collect::<Vec<_>>()
1443 );
1444 assert_eq!(
1445 left.position_covariance
1446 .enu_m2
1447 .iter()
1448 .flatten()
1449 .map(|v| v.to_bits())
1450 .collect::<Vec<_>>(),
1451 right
1452 .position_covariance
1453 .enu_m2
1454 .iter()
1455 .flatten()
1456 .map(|v| v.to_bits())
1457 .collect::<Vec<_>>()
1458 );
1459 assert_eq!(
1460 left.residuals_m
1461 .iter()
1462 .map(|v| v.to_bits())
1463 .collect::<Vec<_>>(),
1464 right
1465 .residuals_m
1466 .iter()
1467 .map(|v| v.to_bits())
1468 .collect::<Vec<_>>()
1469 );
1470 assert_eq!(left.used_sats, right.used_sats);
1471 assert_eq!(left.rejected_sats, right.rejected_sats);
1472 assert_eq!(left.metadata, right.metadata);
1473 }
1474
1475 fn fde_spp_options(inputs: &SolveInputs) -> FdeSppOptions {
1476 FdeSppOptions {
1477 fde: FdeOptions {
1478 raim: RaimOptions::default(),
1479 max_iterations: inputs.observations.len().saturating_sub(4),
1480 },
1481 validation: SolutionValidationOptions::default(),
1482 }
1483 }
1484
1485 fn position_delta_m(left: &ReceiverSolution, right: &ReceiverSolution) -> f64 {
1486 ((left.position.x_m - right.position.x_m).powi(2)
1487 + (left.position.y_m - right.position.y_m).powi(2)
1488 + (left.position.z_m - right.position.z_m).powi(2))
1489 .sqrt()
1490 }
1491
1492 #[test]
1496 fn fde_spp_matches_manual_composition_bit_for_bit() {
1497 let store = esbc_broadcast_store();
1498 let with_geodetic = true;
1499
1500 let clean_inputs = esbc_first_epoch_inputs();
1504 let clean = solve(&store, &clean_inputs, with_geodetic).expect("clean solve converges");
1505 assert!(
1506 clean.used_sats.len() >= 6,
1507 "scenario needs redundancy for a testable RAIM exclusion"
1508 );
1509 let outlier_sat = *clean.used_sats.last().expect("a used satellite");
1510
1511 let mut inputs = clean_inputs;
1514 let outlier_obs = inputs
1515 .observations
1516 .iter_mut()
1517 .find(|obs| obs.satellite_id == outlier_sat)
1518 .expect("outlier satellite is present in the observation set");
1519 outlier_obs.pseudorange_m += 1000.0;
1520
1521 let options = fde_spp_options(&inputs);
1522
1523 let driver = fde_spp(&store, &inputs, with_geodetic, &options)
1525 .expect("driver FDE resolves the fault");
1526
1527 let observations = inputs.observations.clone();
1529 let reference = fde(&observations, &options.fde, |remaining| {
1530 let mut next = inputs.clone();
1531 next.observations = remaining.to_vec();
1532 let solution = solve(&store, &next, with_geodetic).map_err(FdeSppError::Spp)?;
1533 validate_receiver_solution(&solution, options.validation)
1534 .map_err(FdeSppError::Validation)?;
1535 Ok::<_, FdeSppError>(solution)
1536 })
1537 .expect("reference FDE resolves the fault");
1538
1539 assert_eq!(driver.excluded, reference.excluded);
1541 assert_eq!(driver.iterations, reference.iterations);
1542 assert_receiver_solution_bits_eq(&driver.solution, &reference.solution);
1543
1544 assert!(driver.iterations >= 1, "the fault must drive an exclusion");
1551 assert!(!driver.excluded.is_empty());
1552 assert_eq!(driver.excluded.len(), driver.iterations);
1553 let surviving = raim_for_solution(&driver.solution, &options.fde.raim).expect("raim");
1554 assert!(
1555 !surviving.fault_detected,
1556 "the protected set must pass RAIM (or be untestable)"
1557 );
1558 }
1559
1560 #[test]
1563 fn fde_spp_clean_set_takes_no_exclusion_and_matches_manual() {
1564 let store = esbc_broadcast_store();
1565 let inputs = esbc_first_epoch_inputs();
1566 let options = fde_spp_options(&inputs);
1567
1568 let driver = fde_spp(&store, &inputs, false, &options).expect("driver solves clean set");
1569
1570 let observations = inputs.observations.clone();
1571 let reference = fde(&observations, &options.fde, |remaining| {
1572 let mut next = inputs.clone();
1573 next.observations = remaining.to_vec();
1574 let solution = solve(&store, &next, false).map_err(FdeSppError::Spp)?;
1575 validate_receiver_solution(&solution, options.validation)
1576 .map_err(FdeSppError::Validation)?;
1577 Ok::<_, FdeSppError>(solution)
1578 })
1579 .expect("reference solves clean set");
1580
1581 assert_eq!(driver.iterations, 0);
1582 assert!(driver.excluded.is_empty());
1583 assert_eq!(driver.iterations, reference.iterations);
1584 assert_eq!(driver.excluded, reference.excluded);
1585 assert_receiver_solution_bits_eq(&driver.solution, &reference.solution);
1586 }
1587
1588 #[test]
1589 fn spp_robust_fde_driver_clean_set_uses_robust_solve_without_exclusion() {
1590 let store = esbc_broadcast_store();
1591 let inputs = esbc_first_epoch_inputs();
1592 let options = fde_spp_options(&inputs);
1593
1594 let driver =
1595 spp_robust_fde_driver(&store, &inputs, false, RobustConfig::default(), &options)
1596 .expect("robust FDE solves clean set");
1597
1598 assert_eq!(driver.iterations, 0);
1599 assert!(driver.excluded.is_empty());
1600 assert!(driver.solution.metadata.outer_iterations > 0);
1601 assert!(driver.solution.metadata.final_robust_scale_m.is_some());
1602 let surviving = raim_for_solution(&driver.solution, &options.fde.raim).expect("raim");
1603 assert!(!surviving.fault_detected);
1604 }
1605
1606 #[test]
1607 fn spp_robust_fde_driver_excludes_fault_and_recovers_solution() {
1608 let store = esbc_broadcast_store();
1609 let clean_inputs = esbc_first_epoch_inputs();
1610 let clean_options = fde_spp_options(&clean_inputs);
1611 let robust = RobustConfig::default();
1612 let clean = spp_robust_fde_driver(&store, &clean_inputs, false, robust, &clean_options)
1613 .expect("clean robust FDE solve");
1614 let outlier_sat = gps(15);
1615 assert!(clean.solution.used_sats.contains(&outlier_sat));
1616
1617 let mut faulty_inputs = clean_inputs.clone();
1618 let outlier_obs = faulty_inputs
1619 .observations
1620 .iter_mut()
1621 .find(|obs| obs.satellite_id == outlier_sat)
1622 .expect("outlier satellite is observed");
1623 outlier_obs.pseudorange_m += 1000.0;
1624 let faulty_options = fde_spp_options(&faulty_inputs);
1625
1626 let driver = spp_robust_fde_driver(&store, &faulty_inputs, false, robust, &faulty_options)
1627 .expect("robust FDE resolves fault");
1628
1629 assert_eq!(driver.iterations, 1);
1630 assert_eq!(driver.iterations, driver.excluded.len());
1631 assert_eq!(driver.excluded, vec![outlier_sat.to_string()]);
1632 assert!(driver.solution.metadata.outer_iterations > 0);
1633 assert!(driver.solution.metadata.final_robust_scale_m.is_some());
1634 let surviving = raim_for_solution(&driver.solution, &faulty_options.fde.raim)
1635 .expect("surviving set RAIM");
1636 assert!(!surviving.fault_detected);
1637 let recovered_delta_m = position_delta_m(&driver.solution, &clean.solution);
1638 assert!(
1639 recovered_delta_m < 1.0,
1640 "protected solution should stay close to the clean robust solution, got {recovered_delta_m} m with exclusions {:?}",
1641 driver.excluded
1642 );
1643 }
1644
1645 #[derive(Debug, Clone)]
1646 struct TestSolution {
1647 used_sats: Vec<String>,
1648 residuals_m: Vec<f64>,
1649 }
1650
1651 impl RaimSolution for TestSolution {
1652 fn raim_used_sats(&self) -> Vec<String> {
1653 self.used_sats.clone()
1654 }
1655
1656 fn raim_residuals_m(&self) -> &[f64] {
1657 &self.residuals_m
1658 }
1659 }
1660
1661 fn gps(prn: u8) -> GnssSatelliteId {
1662 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
1663 }
1664
1665 fn valid_receiver_solution() -> ReceiverSolution {
1666 ReceiverSolution {
1667 position: crate::frame::ItrfPositionM::new(6_378_137.0, 0.0, 0.0).unwrap(),
1668 geodetic: None,
1669 rx_clock_s: 0.0,
1670 rx_clock_drift_s_s: None,
1671 system_clocks_s: vec![(GnssSystem::Gps, 0.0)],
1672 dop: Some(crate::dop::Dop {
1673 gdop: 2.5,
1674 pdop: 2.0,
1675 hdop: 1.5,
1676 vdop: 1.0,
1677 tdop: 0.5,
1678 system_tdops: vec![(GnssSystem::Gps, 0.5)],
1679 }),
1680 system_tdops: vec![(GnssSystem::Gps, 0.5)],
1681 position_covariance: crate::dop::PositionCovariance {
1682 ecef_m2: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1683 enu_m2: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1684 },
1685 residuals_m: vec![0.1, -0.1, 0.0, 0.05, -0.05],
1686 used_sats: (1..=5).map(gps).collect(),
1687 rejected_sats: Vec::new(),
1688 geometry_quality: crate::geometry_quality::GeometryQuality {
1689 tier: crate::geometry_quality::ObservabilityTier::Nominal,
1690 redundancy: 1,
1691 rank: 4,
1692 condition_number: 1.0,
1693 gdop: 2.5,
1694 raim_checkable: true,
1695 covariance_validated: true,
1696 },
1697 metadata: crate::spp::SolutionMetadata {
1698 iterations: 3,
1699 converged: true,
1700 status: crate::astro::math::least_squares::Status::StepTolerance,
1701 ionosphere_applied: false,
1702 troposphere_applied: false,
1703 outer_iterations: 0,
1704 final_robust_scale_m: None,
1705 used_count: 5,
1706 systems: vec![GnssSystem::Gps],
1707 redundancy: 1,
1708 raim_checkable: true,
1709 },
1710 }
1711 }
1712
1713 #[test]
1714 fn pseudorange_variance_matches_elevation_model() {
1715 let opts = PseudorangeVarianceOptions::default();
1716 let variance = pseudorange_variance(30.0, opts).unwrap();
1717 assert!((variance - 0.45).abs() < 1.0e-15);
1718 assert_eq!(
1719 pseudorange_variance(0.0, opts),
1720 Err(QualityError::InvalidElevation)
1721 );
1722 let horizon_opts = PseudorangeVarianceOptions { b_m: 0.0, ..opts };
1723 assert_eq!(
1724 pseudorange_variance(0.0, horizon_opts),
1725 Ok(horizon_opts.a_m * horizon_opts.a_m)
1726 );
1727 assert_eq!(
1728 pseudorange_variance(-90.0, horizon_opts),
1729 Ok(horizon_opts.a_m * horizon_opts.a_m)
1730 );
1731 assert_eq!(
1732 pseudorange_variance(90.1, horizon_opts),
1733 Err(QualityError::InvalidElevation)
1734 );
1735 assert_eq!(
1736 pseudorange_variance(f64::NAN, opts),
1737 Err(QualityError::InvalidElevation)
1738 );
1739 }
1740
1741 #[test]
1742 fn cn0_model_requires_cn0_and_adds_noise_term() {
1743 let opts = PseudorangeVarianceOptions {
1744 model: PseudorangeVarianceModel::ElevationCn0,
1745 cn0_dbhz: None,
1746 ..Default::default()
1747 };
1748 assert_eq!(
1749 pseudorange_variance(30.0, opts),
1750 Err(QualityError::MissingCn0)
1751 );
1752
1753 let weak = pseudorange_variance(
1754 30.0,
1755 PseudorangeVarianceOptions {
1756 cn0_dbhz: Some(30.0),
1757 ..opts
1758 },
1759 )
1760 .unwrap();
1761 let strong = pseudorange_variance(
1762 30.0,
1763 PseudorangeVarianceOptions {
1764 cn0_dbhz: Some(50.0),
1765 ..opts
1766 },
1767 )
1768 .unwrap();
1769 assert!(strong < weak);
1770 }
1771
1772 #[test]
1773 fn pseudorange_variance_rejects_nonfinite_and_negative_parameters() {
1774 let invalid_a = PseudorangeVarianceOptions {
1775 a_m: f64::NAN,
1776 ..Default::default()
1777 };
1778 assert_eq!(
1779 pseudorange_variance(30.0, invalid_a),
1780 Err(QualityError::InvalidParameter)
1781 );
1782
1783 let invalid_b = PseudorangeVarianceOptions {
1784 b_m: -1.0,
1785 ..Default::default()
1786 };
1787 assert_eq!(
1788 pseudorange_variance(30.0, invalid_b),
1789 Err(QualityError::InvalidParameter)
1790 );
1791
1792 let invalid_cn0_scale = PseudorangeVarianceOptions {
1793 cn0_scale_m2: f64::INFINITY,
1794 ..Default::default()
1795 };
1796 assert_eq!(
1797 pseudorange_variance(30.0, invalid_cn0_scale),
1798 Err(QualityError::InvalidParameter)
1799 );
1800
1801 let invalid_cn0 = PseudorangeVarianceOptions {
1802 model: PseudorangeVarianceModel::ElevationCn0,
1803 cn0_dbhz: Some(f64::NAN),
1804 ..Default::default()
1805 };
1806 assert_eq!(
1807 pseudorange_variance(30.0, invalid_cn0),
1808 Err(QualityError::InvalidParameter)
1809 );
1810 }
1811
1812 #[test]
1813 fn pseudorange_variance_rejects_zero_total_variance() {
1814 let zero_variance = PseudorangeVarianceOptions {
1815 a_m: 0.0,
1816 b_m: 0.0,
1817 ..Default::default()
1818 };
1819 assert_eq!(
1820 pseudorange_variance(30.0, zero_variance),
1821 Err(QualityError::InvalidParameter)
1822 );
1823
1824 let entries = vec![WeightEntry {
1825 satellite_id: "G01".to_string(),
1826 elevation_deg: 30.0,
1827 cn0_dbhz: None,
1828 }];
1829 let weights = weight_vector(&entries, zero_variance);
1830 assert!(
1831 !weights.contains_key("G01"),
1832 "zero variance must not produce an infinite inverse-variance weight"
1833 );
1834 }
1835
1836 #[test]
1837 fn sigma_and_weight_maps_drop_invalid_entries() {
1838 let entries = vec![
1839 WeightEntry {
1840 satellite_id: "G01".to_string(),
1841 elevation_deg: 90.0,
1842 cn0_dbhz: None,
1843 },
1844 WeightEntry {
1845 satellite_id: "G02".to_string(),
1846 elevation_deg: -91.0,
1847 cn0_dbhz: None,
1848 },
1849 ];
1850 let sigmas = sigmas(&entries, Default::default());
1851 let weights = weight_vector(&entries, Default::default());
1852 assert!(sigmas.contains_key("G01"));
1853 assert!(!sigmas.contains_key("G02"));
1854 assert_eq!(weights["G01"], 1.0 / (sigmas["G01"] * sigmas["G01"]));
1855 }
1856
1857 #[test]
1858 fn sigma_and_weight_maps_retain_horizon_entries_without_elevation_term() {
1859 let entries = vec![
1860 WeightEntry {
1861 satellite_id: "G01".to_string(),
1862 elevation_deg: 0.0,
1863 cn0_dbhz: None,
1864 },
1865 WeightEntry {
1866 satellite_id: "G02".to_string(),
1867 elevation_deg: f64::NAN,
1868 cn0_dbhz: None,
1869 },
1870 ];
1871 let options = PseudorangeVarianceOptions {
1872 b_m: 0.0,
1873 ..Default::default()
1874 };
1875 let sigmas = sigmas(&entries, options);
1876 let weights = weight_vector(&entries, options);
1877 assert_eq!(sigmas["G01"], options.a_m);
1878 assert_eq!(weights["G01"], 1.0 / (options.a_m * options.a_m));
1879 assert!(!sigmas.contains_key("G02"));
1880 assert!(!weights.contains_key("G02"));
1881 }
1882
1883 #[test]
1884 fn chi_square_inverse_matches_reference_values() {
1885 let refs = [
1886 (1, 10.828),
1887 (2, 13.816),
1888 (3, 16.266),
1889 (4, 18.467),
1890 (5, 20.515),
1891 ];
1892 for (dof, expected) in refs {
1893 let got = chi2_inv(0.999, dof).unwrap();
1894 assert!((got - expected).abs() < 1.0e-3);
1895 }
1896 assert_eq!(chi2_inv(1.0, 1), Err(QualityError::InvalidProbability));
1897 assert_eq!(chi2_inv(0.95, 0), Err(QualityError::InvalidDof));
1898 }
1899
1900 #[test]
1901 fn residual_diagnostics_reports_weighted_redundancy_and_reduced_chi_square() {
1902 let residuals = [1.0, -2.0, 0.5, 3.0, -1.5];
1903 let weights = [1.0, 0.25, 4.0, 1.0, 0.5];
1904 let diagnostics =
1905 residual_diagnostics(&residuals, Some(&weights), 3, Some(1.0e-3)).expect("diagnostics");
1906
1907 let wss = residuals
1908 .iter()
1909 .zip(weights)
1910 .map(|(r, w)| r * r * w)
1911 .sum::<f64>();
1912 assert_eq!(diagnostics.n_residuals, 5);
1913 assert_eq!(diagnostics.n_parameters, 3);
1914 assert_eq!(diagnostics.degrees_of_freedom, 2);
1915 assert_eq!(diagnostics.weighted_sum_squares.to_bits(), wss.to_bits());
1916 assert_eq!(
1917 diagnostics.reduced_chi_square.unwrap().to_bits(),
1918 (wss / 2.0).to_bits()
1919 );
1920 assert_eq!(
1921 diagnostics.normalized_residuals[1].to_bits(),
1922 (-1.0f64).to_bits()
1923 );
1924 assert_eq!(diagnostics.worst_index, Some(3));
1925 assert!(diagnostics.chi_square_threshold.unwrap().is_finite());
1926 assert_eq!(diagnostics.chi_square_consistent, Some(true));
1927 }
1928
1929 #[test]
1930 fn residual_diagnostics_handles_no_redundancy_and_rejects_bad_inputs() {
1931 let residuals = [1.0, -1.0];
1932 let diagnostics =
1933 residual_diagnostics(&residuals, None, 2, Some(1.0e-3)).expect("diagnostics");
1934 assert_eq!(diagnostics.degrees_of_freedom, 0);
1935 assert_eq!(diagnostics.reduced_chi_square, None);
1936 assert_eq!(diagnostics.chi_square_threshold, None);
1937 assert_eq!(diagnostics.chi_square_consistent, None);
1938
1939 assert_eq!(
1940 residual_diagnostics(&[1.0, f64::NAN], None, 1, None),
1941 Err(QualityError::InvalidResiduals)
1942 );
1943 assert_eq!(
1944 residual_diagnostics(&[1.0], Some(&[0.0]), 0, None),
1945 Err(QualityError::InvalidWeight)
1946 );
1947 assert_eq!(
1948 residual_diagnostics(&[1.0], None, 0, Some(1.0)),
1949 Err(QualityError::InvalidProbability)
1950 );
1951 }
1952
1953 #[test]
1954 fn raim_reports_fault_and_worst_satellite() {
1955 let input = RaimInput {
1956 used_sats: ["G01", "G02", "G03", "G04", "G05"]
1957 .into_iter()
1958 .map(str::to_string)
1959 .collect(),
1960 residuals_m: vec![0.0, 0.0, 0.0, 0.0, 5.0],
1961 };
1962 let result = raim(&input, &RaimOptions::default()).unwrap();
1963 assert!(result.fault_detected);
1964 assert!(result.testable);
1965 assert_eq!(result.dof, 1);
1966 assert_eq!(result.test_statistic, 25.0);
1967 assert_eq!(result.worst_sat.as_deref(), Some("G05"));
1968 }
1969
1970 #[test]
1971 fn raim_dof_zero_is_not_testable() {
1972 let input = RaimInput {
1973 used_sats: ["G01", "G02", "G03", "G04"]
1974 .into_iter()
1975 .map(str::to_string)
1976 .collect(),
1977 residuals_m: vec![0.0, 0.0, 0.0, 0.0],
1978 };
1979 let result = raim(&input, &RaimOptions::default()).unwrap();
1980 assert!(!result.fault_detected);
1981 assert!(!result.testable);
1982 assert_eq!(result.threshold, None);
1983 assert_eq!(result.dof, 0);
1984 }
1985
1986 #[test]
1987 fn raim_rejects_nonpositive_system_overrides() {
1988 let input = RaimInput {
1989 used_sats: ["G01", "G02", "G03", "G04", "G05"]
1990 .into_iter()
1991 .map(str::to_string)
1992 .collect(),
1993 residuals_m: vec![0.0; 5],
1994 };
1995
1996 for n_systems in [0, -1] {
1997 let options = RaimOptions {
1998 n_systems: Some(n_systems),
1999 ..Default::default()
2000 };
2001 assert_eq!(
2002 raim(&input, &options),
2003 Err(QualityError::InvalidSystemCount)
2004 );
2005 }
2006 }
2007
2008 #[test]
2009 fn raim_positive_system_override_controls_dof() {
2010 let input = RaimInput {
2011 used_sats: ["G01", "G02", "G03", "G04", "G05", "G06"]
2012 .into_iter()
2013 .map(str::to_string)
2014 .collect(),
2015 residuals_m: vec![0.0; 6],
2016 };
2017 let options = RaimOptions {
2018 n_systems: Some(2),
2019 ..Default::default()
2020 };
2021
2022 let result = raim(&input, &options).unwrap();
2023 assert!(result.testable);
2024 assert_eq!(result.dof, 1);
2025 }
2026
2027 #[test]
2028 fn raim_rejects_misaligned_or_nonfinite_residuals() {
2029 let input = RaimInput {
2030 used_sats: ["G01", "G02"].into_iter().map(str::to_string).collect(),
2031 residuals_m: vec![1.0],
2032 };
2033 assert_eq!(
2034 raim(&input, &RaimOptions::default()),
2035 Err(QualityError::InvalidResiduals)
2036 );
2037
2038 let input = RaimInput {
2039 used_sats: ["G01", "G02"].into_iter().map(str::to_string).collect(),
2040 residuals_m: vec![1.0, f64::NAN],
2041 };
2042 assert_eq!(
2043 raim(&input, &RaimOptions::default()),
2044 Err(QualityError::InvalidResiduals)
2045 );
2046 }
2047
2048 #[test]
2049 fn raim_rejects_nonfinite_weights_and_probability() {
2050 let input = RaimInput {
2051 used_sats: ["G01", "G02", "G03", "G04", "G05"]
2052 .into_iter()
2053 .map(str::to_string)
2054 .collect(),
2055 residuals_m: vec![0.0; 5],
2056 };
2057 let mut weights = BTreeMap::new();
2058 weights.insert("G01".to_string(), f64::NAN);
2059 let options = RaimOptions {
2060 weights: RaimWeights::BySatellite(weights),
2061 ..Default::default()
2062 };
2063 assert_eq!(raim(&input, &options), Err(QualityError::InvalidWeight));
2064
2065 let options = RaimOptions {
2066 p_fa: f64::NAN,
2067 ..Default::default()
2068 };
2069 assert_eq!(
2070 raim(&input, &options),
2071 Err(QualityError::InvalidProbability)
2072 );
2073 }
2074
2075 #[test]
2076 fn fde_excludes_largest_normalized_residual() {
2077 let observations: Vec<Observation> = (1..=5)
2078 .map(|prn| Observation {
2079 satellite_id: gps(prn),
2080 pseudorange_m: prn as f64,
2081 })
2082 .collect();
2083
2084 let options = FdeOptions {
2085 raim: RaimOptions::default(),
2086 max_iterations: 1,
2087 };
2088 let result = fde(&observations, &options, |remaining| {
2089 let used_sats = remaining
2090 .iter()
2091 .map(|ob| ob.satellite_id.to_string())
2092 .collect::<Vec<_>>();
2093 let residuals_m = remaining
2094 .iter()
2095 .map(|ob| if ob.satellite_id == gps(5) { 5.0 } else { 0.0 })
2096 .collect::<Vec<_>>();
2097 Ok::<_, ()>(TestSolution {
2098 used_sats,
2099 residuals_m,
2100 })
2101 })
2102 .unwrap();
2103
2104 assert_eq!(result.excluded, vec!["G05".to_string()]);
2105 assert_eq!(result.iterations, 1);
2106 assert_eq!(result.solution.used_sats.len(), 4);
2107 }
2108
2109 #[test]
2110 fn fde_refuses_fault_when_budget_is_exhausted() {
2111 let observations: Vec<Observation> = (1..=5)
2112 .map(|prn| Observation {
2113 satellite_id: gps(prn),
2114 pseudorange_m: prn as f64,
2115 })
2116 .collect();
2117 let options = FdeOptions {
2118 raim: RaimOptions::default(),
2119 max_iterations: 0,
2120 };
2121 let err = fde(&observations, &options, |remaining| {
2122 Ok::<_, ()>(TestSolution {
2123 used_sats: remaining
2124 .iter()
2125 .map(|ob| ob.satellite_id.to_string())
2126 .collect(),
2127 residuals_m: vec![0.0, 0.0, 0.0, 0.0, 5.0],
2128 })
2129 })
2130 .unwrap_err();
2131
2132 assert_eq!(err, FdeError::FaultUnresolved(25.0));
2133 }
2134
2135 #[test]
2136 fn receiver_solution_validation_rejects_invalid_gate_options() {
2137 let solution = valid_receiver_solution();
2138 for (options, field, reason) in [
2139 (
2140 SolutionValidationOptions {
2141 max_pdop: Some(f64::NAN),
2142 ..Default::default()
2143 },
2144 "max_pdop",
2145 "not finite",
2146 ),
2147 (
2148 SolutionValidationOptions {
2149 max_pdop: Some(0.0),
2150 ..Default::default()
2151 },
2152 "max_pdop",
2153 "not positive",
2154 ),
2155 (
2156 SolutionValidationOptions {
2157 min_plausible_radius_m: 0.0,
2158 ..Default::default()
2159 },
2160 "min_plausible_radius_m",
2161 "not positive",
2162 ),
2163 (
2164 SolutionValidationOptions {
2165 max_plausible_radius_m: f64::INFINITY,
2166 ..Default::default()
2167 },
2168 "max_plausible_radius_m",
2169 "not finite",
2170 ),
2171 (
2172 SolutionValidationOptions {
2173 max_converged_residual_rms_m: f64::NAN,
2174 ..Default::default()
2175 },
2176 "max_converged_residual_rms_m",
2177 "not finite",
2178 ),
2179 ] {
2180 assert_eq!(
2181 validate_receiver_solution(&solution, options),
2182 Err(SolutionValidationError::InvalidOptions { field, reason })
2183 );
2184 }
2185
2186 let inverted_radius = SolutionValidationOptions {
2187 min_plausible_radius_m: 8_000_000.0,
2188 max_plausible_radius_m: 7_000_000.0,
2189 ..Default::default()
2190 };
2191 assert_eq!(
2192 validate_receiver_solution(&solution, inverted_radius),
2193 Err(SolutionValidationError::InvalidOptions {
2194 field: "plausible_radius_m",
2195 reason: "must be increasing",
2196 })
2197 );
2198 }
2199
2200 #[test]
2201 fn receiver_solution_validation_rejects_nonfinite_residuals() {
2202 let mut solution = valid_receiver_solution();
2203 solution.residuals_m[1] = f64::NAN;
2204 assert_eq!(
2205 validate_receiver_solution(&solution, SolutionValidationOptions::default()),
2206 Err(SolutionValidationError::InvalidResiduals)
2207 );
2208 }
2209
2210 fn range_design_rows() -> Vec<[f64; 4]> {
2213 vec![
2214 [-0.10, -0.20, -0.97, 1.0],
2215 [0.50, -0.30, -0.81, 1.0],
2216 [-0.60, 0.40, -0.69, 1.0],
2217 [0.20, 0.80, -0.56, 1.0],
2218 [0.70, 0.50, -0.51, 1.0],
2219 [-0.50, -0.70, -0.51, 1.0],
2220 [0.30, -0.60, -0.74, 1.0],
2221 [-0.80, 0.10, -0.59, 1.0],
2222 ]
2223 }
2224
2225 fn range_rows(dx_true: [f64; 4]) -> Vec<RangeFdeRow> {
2226 range_design_rows()
2227 .iter()
2228 .enumerate()
2229 .map(|(i, h)| RangeFdeRow {
2230 id: format!("S{:02}", i + 1),
2231 residual_m: h.iter().zip(dx_true).map(|(a, b)| a * b).sum(),
2232 design_row: h.to_vec(),
2233 weight: 1.0,
2234 })
2235 .collect()
2236 }
2237
2238 fn assert_close(got: &[f64], want: &[f64], tol: f64) {
2239 assert_eq!(got.len(), want.len());
2240 for (g, w) in got.iter().zip(want) {
2241 assert!((g - w).abs() < tol, "got {g}, want {w}");
2242 }
2243 }
2244
2245 #[test]
2246 fn range_fde_clean_set_recovers_state_without_exclusions() {
2247 let dx_true = [1.0, -2.0, 0.5, 3.0];
2248 let rows = range_rows(dx_true);
2249 let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2250
2251 assert!(!result.global_test.fault_detected);
2252 assert!(result.global_test.testable);
2253 assert_eq!(result.global_test.dof, 4);
2254 assert!(result.excluded.is_empty());
2255 assert_eq!(result.iterations, 0);
2256 assert!(result.global_test.weighted_sum_squares < 1.0e-12);
2257 assert_close(&result.state_correction, &dx_true, 1.0e-9);
2258 assert_eq!(result.state_covariance.len(), 4);
2259 }
2260
2261 #[test]
2262 fn range_fde_detects_and_excludes_a_single_outlier() {
2263 let dx_true = [1.0, -2.0, 0.5, 3.0];
2264 let mut rows = range_rows(dx_true);
2265 rows[2].residual_m += 50.0; let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2268
2269 assert_eq!(result.excluded, vec!["S03".to_string()]);
2270 assert_eq!(result.iterations, 1);
2271 assert!(!result.global_test.fault_detected);
2272 assert_close(&result.state_correction, &dx_true, 1.0e-9);
2273
2274 let s03 = result
2275 .diagnostics
2276 .iter()
2277 .find(|d| d.id == "S03")
2278 .expect("S03 diagnostic");
2279 assert!(s03.excluded);
2280 assert!(s03.post_fit_residual_m.abs() > 40.0);
2282 for d in result.diagnostics.iter().filter(|d| !d.excluded) {
2284 assert!(d.normalized_residual.abs() < 1.0e-6);
2285 }
2286 }
2287
2288 #[test]
2289 fn range_fde_excludes_multiple_outliers() {
2290 let dx_true = [0.5, 1.5, -1.0, 2.0];
2291 let mut rows = range_rows(dx_true);
2292 rows[2].residual_m += 50.0; rows[5].residual_m -= 40.0; let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2296
2297 assert_eq!(result.iterations, 2);
2298 let mut excluded = result.excluded.clone();
2299 excluded.sort();
2300 assert_eq!(excluded, vec!["S03".to_string(), "S06".to_string()]);
2301 assert!(!result.global_test.fault_detected);
2302 assert_close(&result.state_correction, &dx_true, 1.0e-9);
2303 }
2304
2305 #[test]
2306 fn range_fde_respects_the_exclusion_budget() {
2307 let dx_true = [0.5, 1.5, -1.0, 2.0];
2308 let mut rows = range_rows(dx_true);
2309 rows[2].residual_m += 50.0;
2310 rows[5].residual_m -= 40.0;
2311
2312 let options = RangeFdeOptions {
2313 max_exclusions: 1,
2314 ..Default::default()
2315 };
2316 let result = raim_fde_design(&rows, &options).expect("fde");
2317
2318 assert_eq!(result.iterations, 1);
2320 assert_eq!(result.excluded.len(), 1);
2321 assert!(result.global_test.fault_detected);
2322 }
2323
2324 #[test]
2325 fn range_fde_rejects_rank_deficient_geometry() {
2326 let rows: Vec<RangeFdeRow> = (0..5)
2327 .map(|i| RangeFdeRow {
2328 id: format!("S{:02}", i + 1),
2329 residual_m: 1.0,
2330 design_row: vec![1.0, 0.0, 0.0, 1.0], weight: 1.0,
2332 })
2333 .collect();
2334 assert_eq!(
2335 raim_fde_design(&rows, &RangeFdeOptions::default()),
2336 Err(QualityError::SingularGeometry)
2337 );
2338 }
2339
2340 #[test]
2341 fn range_fde_rejects_malformed_inputs() {
2342 assert_eq!(
2343 raim_fde_design(&[], &RangeFdeOptions::default()),
2344 Err(QualityError::InvalidDesign)
2345 );
2346
2347 let too_few = vec![RangeFdeRow {
2349 id: "S01".to_string(),
2350 residual_m: 0.0,
2351 design_row: vec![1.0, 0.0, 0.0, 1.0],
2352 weight: 1.0,
2353 }];
2354 assert_eq!(
2355 raim_fde_design(&too_few, &RangeFdeOptions::default()),
2356 Err(QualityError::InvalidDesign)
2357 );
2358
2359 let mut ragged = range_rows([1.0, 0.0, 0.0, 0.0]);
2361 ragged[1].design_row.pop();
2362 assert_eq!(
2363 raim_fde_design(&ragged, &RangeFdeOptions::default()),
2364 Err(QualityError::InvalidDesign)
2365 );
2366
2367 let mut bad_weight = range_rows([1.0, 0.0, 0.0, 0.0]);
2369 bad_weight[0].weight = 0.0;
2370 assert_eq!(
2371 raim_fde_design(&bad_weight, &RangeFdeOptions::default()),
2372 Err(QualityError::InvalidWeight)
2373 );
2374
2375 let mut bad_residual = range_rows([1.0, 0.0, 0.0, 0.0]);
2376 bad_residual[0].residual_m = f64::NAN;
2377 assert_eq!(
2378 raim_fde_design(&bad_residual, &RangeFdeOptions::default()),
2379 Err(QualityError::InvalidResiduals)
2380 );
2381
2382 let rows = range_rows([1.0, 0.0, 0.0, 0.0]);
2383 let bad_p = RangeFdeOptions {
2384 p_fa: 1.0,
2385 ..Default::default()
2386 };
2387 assert_eq!(
2388 raim_fde_design(&rows, &bad_p),
2389 Err(QualityError::InvalidProbability)
2390 );
2391 }
2392
2393 #[test]
2394 fn chi_square_threshold_matches_rtklib_demo5_chisqr_table() {
2395 let table: [f64; 20] = [
2401 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,
2402 37.7, 39.3, 40.8, 42.3, 43.8, 45.3,
2403 ];
2404 for (i, &expected) in table.iter().enumerate() {
2405 let dof = i + 1;
2406 let got = chi2_inv(0.999, dof).expect("chi2 quantile");
2407 let tol = (0.01 * expected).max(0.05);
2408 assert!(
2409 (got - expected).abs() < tol,
2410 "dof {dof}: got {got}, demo5 chisqr {expected}"
2411 );
2412 }
2413 }
2414}