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