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