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 geometry_quality: crate::geometry_quality::GeometryQuality {
1642 tier: crate::geometry_quality::ObservabilityTier::Nominal,
1643 redundancy: 1,
1644 rank: 4,
1645 condition_number: 1.0,
1646 gdop: 2.5,
1647 raim_checkable: true,
1648 covariance_validated: true,
1649 },
1650 metadata: crate::spp::SolutionMetadata {
1651 iterations: 3,
1652 converged: true,
1653 status: crate::astro::math::least_squares::Status::StepTolerance,
1654 ionosphere_applied: false,
1655 troposphere_applied: false,
1656 outer_iterations: 0,
1657 final_robust_scale_m: None,
1658 used_count: 5,
1659 systems: vec![GnssSystem::Gps],
1660 redundancy: 1,
1661 raim_checkable: true,
1662 },
1663 }
1664 }
1665
1666 #[test]
1667 fn pseudorange_variance_matches_elevation_model() {
1668 let opts = PseudorangeVarianceOptions::default();
1669 let variance = pseudorange_variance(30.0, opts).unwrap();
1670 assert!((variance - 0.45).abs() < 1.0e-15);
1671 assert_eq!(
1672 pseudorange_variance(0.0, opts),
1673 Err(QualityError::InvalidElevation)
1674 );
1675 let horizon_opts = PseudorangeVarianceOptions { b_m: 0.0, ..opts };
1676 assert_eq!(
1677 pseudorange_variance(0.0, horizon_opts),
1678 Ok(horizon_opts.a_m * horizon_opts.a_m)
1679 );
1680 assert_eq!(
1681 pseudorange_variance(-90.0, horizon_opts),
1682 Ok(horizon_opts.a_m * horizon_opts.a_m)
1683 );
1684 assert_eq!(
1685 pseudorange_variance(90.1, horizon_opts),
1686 Err(QualityError::InvalidElevation)
1687 );
1688 assert_eq!(
1689 pseudorange_variance(f64::NAN, opts),
1690 Err(QualityError::InvalidElevation)
1691 );
1692 }
1693
1694 #[test]
1695 fn cn0_model_requires_cn0_and_adds_noise_term() {
1696 let opts = PseudorangeVarianceOptions {
1697 model: PseudorangeVarianceModel::ElevationCn0,
1698 cn0_dbhz: None,
1699 ..Default::default()
1700 };
1701 assert_eq!(
1702 pseudorange_variance(30.0, opts),
1703 Err(QualityError::MissingCn0)
1704 );
1705
1706 let weak = pseudorange_variance(
1707 30.0,
1708 PseudorangeVarianceOptions {
1709 cn0_dbhz: Some(30.0),
1710 ..opts
1711 },
1712 )
1713 .unwrap();
1714 let strong = pseudorange_variance(
1715 30.0,
1716 PseudorangeVarianceOptions {
1717 cn0_dbhz: Some(50.0),
1718 ..opts
1719 },
1720 )
1721 .unwrap();
1722 assert!(strong < weak);
1723 }
1724
1725 #[test]
1726 fn pseudorange_variance_rejects_nonfinite_and_negative_parameters() {
1727 let invalid_a = PseudorangeVarianceOptions {
1728 a_m: f64::NAN,
1729 ..Default::default()
1730 };
1731 assert_eq!(
1732 pseudorange_variance(30.0, invalid_a),
1733 Err(QualityError::InvalidParameter)
1734 );
1735
1736 let invalid_b = PseudorangeVarianceOptions {
1737 b_m: -1.0,
1738 ..Default::default()
1739 };
1740 assert_eq!(
1741 pseudorange_variance(30.0, invalid_b),
1742 Err(QualityError::InvalidParameter)
1743 );
1744
1745 let invalid_cn0_scale = PseudorangeVarianceOptions {
1746 cn0_scale_m2: f64::INFINITY,
1747 ..Default::default()
1748 };
1749 assert_eq!(
1750 pseudorange_variance(30.0, invalid_cn0_scale),
1751 Err(QualityError::InvalidParameter)
1752 );
1753
1754 let invalid_cn0 = PseudorangeVarianceOptions {
1755 model: PseudorangeVarianceModel::ElevationCn0,
1756 cn0_dbhz: Some(f64::NAN),
1757 ..Default::default()
1758 };
1759 assert_eq!(
1760 pseudorange_variance(30.0, invalid_cn0),
1761 Err(QualityError::InvalidParameter)
1762 );
1763 }
1764
1765 #[test]
1766 fn pseudorange_variance_rejects_zero_total_variance() {
1767 let zero_variance = PseudorangeVarianceOptions {
1768 a_m: 0.0,
1769 b_m: 0.0,
1770 ..Default::default()
1771 };
1772 assert_eq!(
1773 pseudorange_variance(30.0, zero_variance),
1774 Err(QualityError::InvalidParameter)
1775 );
1776
1777 let entries = vec![WeightEntry {
1778 satellite_id: "G01".to_string(),
1779 elevation_deg: 30.0,
1780 cn0_dbhz: None,
1781 }];
1782 let weights = weight_vector(&entries, zero_variance);
1783 assert!(
1784 !weights.contains_key("G01"),
1785 "zero variance must not produce an infinite inverse-variance weight"
1786 );
1787 }
1788
1789 #[test]
1790 fn sigma_and_weight_maps_drop_invalid_entries() {
1791 let entries = vec![
1792 WeightEntry {
1793 satellite_id: "G01".to_string(),
1794 elevation_deg: 90.0,
1795 cn0_dbhz: None,
1796 },
1797 WeightEntry {
1798 satellite_id: "G02".to_string(),
1799 elevation_deg: -91.0,
1800 cn0_dbhz: None,
1801 },
1802 ];
1803 let sigmas = sigmas(&entries, Default::default());
1804 let weights = weight_vector(&entries, Default::default());
1805 assert!(sigmas.contains_key("G01"));
1806 assert!(!sigmas.contains_key("G02"));
1807 assert_eq!(weights["G01"], 1.0 / (sigmas["G01"] * sigmas["G01"]));
1808 }
1809
1810 #[test]
1811 fn sigma_and_weight_maps_retain_horizon_entries_without_elevation_term() {
1812 let entries = vec![
1813 WeightEntry {
1814 satellite_id: "G01".to_string(),
1815 elevation_deg: 0.0,
1816 cn0_dbhz: None,
1817 },
1818 WeightEntry {
1819 satellite_id: "G02".to_string(),
1820 elevation_deg: f64::NAN,
1821 cn0_dbhz: None,
1822 },
1823 ];
1824 let options = PseudorangeVarianceOptions {
1825 b_m: 0.0,
1826 ..Default::default()
1827 };
1828 let sigmas = sigmas(&entries, options);
1829 let weights = weight_vector(&entries, options);
1830 assert_eq!(sigmas["G01"], options.a_m);
1831 assert_eq!(weights["G01"], 1.0 / (options.a_m * options.a_m));
1832 assert!(!sigmas.contains_key("G02"));
1833 assert!(!weights.contains_key("G02"));
1834 }
1835
1836 #[test]
1837 fn chi_square_inverse_matches_reference_values() {
1838 let refs = [
1839 (1, 10.828),
1840 (2, 13.816),
1841 (3, 16.266),
1842 (4, 18.467),
1843 (5, 20.515),
1844 ];
1845 for (dof, expected) in refs {
1846 let got = chi2_inv(0.999, dof).unwrap();
1847 assert!((got - expected).abs() < 1.0e-3);
1848 }
1849 assert_eq!(chi2_inv(1.0, 1), Err(QualityError::InvalidProbability));
1850 assert_eq!(chi2_inv(0.95, 0), Err(QualityError::InvalidDof));
1851 }
1852
1853 #[test]
1854 fn residual_diagnostics_reports_weighted_redundancy_and_reduced_chi_square() {
1855 let residuals = [1.0, -2.0, 0.5, 3.0, -1.5];
1856 let weights = [1.0, 0.25, 4.0, 1.0, 0.5];
1857 let diagnostics =
1858 residual_diagnostics(&residuals, Some(&weights), 3, Some(1.0e-3)).expect("diagnostics");
1859
1860 let wss = residuals
1861 .iter()
1862 .zip(weights)
1863 .map(|(r, w)| r * r * w)
1864 .sum::<f64>();
1865 assert_eq!(diagnostics.n_residuals, 5);
1866 assert_eq!(diagnostics.n_parameters, 3);
1867 assert_eq!(diagnostics.degrees_of_freedom, 2);
1868 assert_eq!(diagnostics.weighted_sum_squares.to_bits(), wss.to_bits());
1869 assert_eq!(
1870 diagnostics.reduced_chi_square.unwrap().to_bits(),
1871 (wss / 2.0).to_bits()
1872 );
1873 assert_eq!(
1874 diagnostics.normalized_residuals[1].to_bits(),
1875 (-1.0f64).to_bits()
1876 );
1877 assert_eq!(diagnostics.worst_index, Some(3));
1878 assert!(diagnostics.chi_square_threshold.unwrap().is_finite());
1879 assert_eq!(diagnostics.chi_square_consistent, Some(true));
1880 }
1881
1882 #[test]
1883 fn residual_diagnostics_handles_no_redundancy_and_rejects_bad_inputs() {
1884 let residuals = [1.0, -1.0];
1885 let diagnostics =
1886 residual_diagnostics(&residuals, None, 2, Some(1.0e-3)).expect("diagnostics");
1887 assert_eq!(diagnostics.degrees_of_freedom, 0);
1888 assert_eq!(diagnostics.reduced_chi_square, None);
1889 assert_eq!(diagnostics.chi_square_threshold, None);
1890 assert_eq!(diagnostics.chi_square_consistent, None);
1891
1892 assert_eq!(
1893 residual_diagnostics(&[1.0, f64::NAN], None, 1, None),
1894 Err(QualityError::InvalidResiduals)
1895 );
1896 assert_eq!(
1897 residual_diagnostics(&[1.0], Some(&[0.0]), 0, None),
1898 Err(QualityError::InvalidWeight)
1899 );
1900 assert_eq!(
1901 residual_diagnostics(&[1.0], None, 0, Some(1.0)),
1902 Err(QualityError::InvalidProbability)
1903 );
1904 }
1905
1906 #[test]
1907 fn raim_reports_fault_and_worst_satellite() {
1908 let input = RaimInput {
1909 used_sats: ["G01", "G02", "G03", "G04", "G05"]
1910 .into_iter()
1911 .map(str::to_string)
1912 .collect(),
1913 residuals_m: vec![0.0, 0.0, 0.0, 0.0, 5.0],
1914 };
1915 let result = raim(&input, &RaimOptions::default()).unwrap();
1916 assert!(result.fault_detected);
1917 assert!(result.testable);
1918 assert_eq!(result.dof, 1);
1919 assert_eq!(result.test_statistic, 25.0);
1920 assert_eq!(result.worst_sat.as_deref(), Some("G05"));
1921 }
1922
1923 #[test]
1924 fn raim_dof_zero_is_not_testable() {
1925 let input = RaimInput {
1926 used_sats: ["G01", "G02", "G03", "G04"]
1927 .into_iter()
1928 .map(str::to_string)
1929 .collect(),
1930 residuals_m: vec![0.0, 0.0, 0.0, 0.0],
1931 };
1932 let result = raim(&input, &RaimOptions::default()).unwrap();
1933 assert!(!result.fault_detected);
1934 assert!(!result.testable);
1935 assert_eq!(result.threshold, None);
1936 assert_eq!(result.dof, 0);
1937 }
1938
1939 #[test]
1940 fn raim_rejects_nonpositive_system_overrides() {
1941 let input = RaimInput {
1942 used_sats: ["G01", "G02", "G03", "G04", "G05"]
1943 .into_iter()
1944 .map(str::to_string)
1945 .collect(),
1946 residuals_m: vec![0.0; 5],
1947 };
1948
1949 for n_systems in [0, -1] {
1950 let options = RaimOptions {
1951 n_systems: Some(n_systems),
1952 ..Default::default()
1953 };
1954 assert_eq!(
1955 raim(&input, &options),
1956 Err(QualityError::InvalidSystemCount)
1957 );
1958 }
1959 }
1960
1961 #[test]
1962 fn raim_positive_system_override_controls_dof() {
1963 let input = RaimInput {
1964 used_sats: ["G01", "G02", "G03", "G04", "G05", "G06"]
1965 .into_iter()
1966 .map(str::to_string)
1967 .collect(),
1968 residuals_m: vec![0.0; 6],
1969 };
1970 let options = RaimOptions {
1971 n_systems: Some(2),
1972 ..Default::default()
1973 };
1974
1975 let result = raim(&input, &options).unwrap();
1976 assert!(result.testable);
1977 assert_eq!(result.dof, 1);
1978 }
1979
1980 #[test]
1981 fn raim_rejects_misaligned_or_nonfinite_residuals() {
1982 let input = RaimInput {
1983 used_sats: ["G01", "G02"].into_iter().map(str::to_string).collect(),
1984 residuals_m: vec![1.0],
1985 };
1986 assert_eq!(
1987 raim(&input, &RaimOptions::default()),
1988 Err(QualityError::InvalidResiduals)
1989 );
1990
1991 let input = RaimInput {
1992 used_sats: ["G01", "G02"].into_iter().map(str::to_string).collect(),
1993 residuals_m: vec![1.0, f64::NAN],
1994 };
1995 assert_eq!(
1996 raim(&input, &RaimOptions::default()),
1997 Err(QualityError::InvalidResiduals)
1998 );
1999 }
2000
2001 #[test]
2002 fn raim_rejects_nonfinite_weights_and_probability() {
2003 let input = RaimInput {
2004 used_sats: ["G01", "G02", "G03", "G04", "G05"]
2005 .into_iter()
2006 .map(str::to_string)
2007 .collect(),
2008 residuals_m: vec![0.0; 5],
2009 };
2010 let mut weights = BTreeMap::new();
2011 weights.insert("G01".to_string(), f64::NAN);
2012 let options = RaimOptions {
2013 weights: RaimWeights::BySatellite(weights),
2014 ..Default::default()
2015 };
2016 assert_eq!(raim(&input, &options), Err(QualityError::InvalidWeight));
2017
2018 let options = RaimOptions {
2019 p_fa: f64::NAN,
2020 ..Default::default()
2021 };
2022 assert_eq!(
2023 raim(&input, &options),
2024 Err(QualityError::InvalidProbability)
2025 );
2026 }
2027
2028 #[test]
2029 fn fde_excludes_largest_normalized_residual() {
2030 let observations: Vec<Observation> = (1..=5)
2031 .map(|prn| Observation {
2032 satellite_id: gps(prn),
2033 pseudorange_m: prn as f64,
2034 })
2035 .collect();
2036
2037 let options = FdeOptions {
2038 raim: RaimOptions::default(),
2039 max_iterations: 1,
2040 };
2041 let result = fde(&observations, &options, |remaining| {
2042 let used_sats = remaining
2043 .iter()
2044 .map(|ob| ob.satellite_id.to_string())
2045 .collect::<Vec<_>>();
2046 let residuals_m = remaining
2047 .iter()
2048 .map(|ob| if ob.satellite_id == gps(5) { 5.0 } else { 0.0 })
2049 .collect::<Vec<_>>();
2050 Ok::<_, ()>(TestSolution {
2051 used_sats,
2052 residuals_m,
2053 })
2054 })
2055 .unwrap();
2056
2057 assert_eq!(result.excluded, vec!["G05".to_string()]);
2058 assert_eq!(result.iterations, 1);
2059 assert_eq!(result.solution.used_sats.len(), 4);
2060 }
2061
2062 #[test]
2063 fn fde_refuses_fault_when_budget_is_exhausted() {
2064 let observations: Vec<Observation> = (1..=5)
2065 .map(|prn| Observation {
2066 satellite_id: gps(prn),
2067 pseudorange_m: prn as f64,
2068 })
2069 .collect();
2070 let options = FdeOptions {
2071 raim: RaimOptions::default(),
2072 max_iterations: 0,
2073 };
2074 let err = fde(&observations, &options, |remaining| {
2075 Ok::<_, ()>(TestSolution {
2076 used_sats: remaining
2077 .iter()
2078 .map(|ob| ob.satellite_id.to_string())
2079 .collect(),
2080 residuals_m: vec![0.0, 0.0, 0.0, 0.0, 5.0],
2081 })
2082 })
2083 .unwrap_err();
2084
2085 assert_eq!(err, FdeError::FaultUnresolved(25.0));
2086 }
2087
2088 #[test]
2089 fn receiver_solution_validation_rejects_invalid_gate_options() {
2090 let solution = valid_receiver_solution();
2091 for (options, field, reason) in [
2092 (
2093 SolutionValidationOptions {
2094 max_pdop: Some(f64::NAN),
2095 ..Default::default()
2096 },
2097 "max_pdop",
2098 "not finite",
2099 ),
2100 (
2101 SolutionValidationOptions {
2102 max_pdop: Some(0.0),
2103 ..Default::default()
2104 },
2105 "max_pdop",
2106 "not positive",
2107 ),
2108 (
2109 SolutionValidationOptions {
2110 min_plausible_radius_m: 0.0,
2111 ..Default::default()
2112 },
2113 "min_plausible_radius_m",
2114 "not positive",
2115 ),
2116 (
2117 SolutionValidationOptions {
2118 max_plausible_radius_m: f64::INFINITY,
2119 ..Default::default()
2120 },
2121 "max_plausible_radius_m",
2122 "not finite",
2123 ),
2124 (
2125 SolutionValidationOptions {
2126 max_converged_residual_rms_m: f64::NAN,
2127 ..Default::default()
2128 },
2129 "max_converged_residual_rms_m",
2130 "not finite",
2131 ),
2132 ] {
2133 assert_eq!(
2134 validate_receiver_solution(&solution, options),
2135 Err(SolutionValidationError::InvalidOptions { field, reason })
2136 );
2137 }
2138
2139 let inverted_radius = SolutionValidationOptions {
2140 min_plausible_radius_m: 8_000_000.0,
2141 max_plausible_radius_m: 7_000_000.0,
2142 ..Default::default()
2143 };
2144 assert_eq!(
2145 validate_receiver_solution(&solution, inverted_radius),
2146 Err(SolutionValidationError::InvalidOptions {
2147 field: "plausible_radius_m",
2148 reason: "must be increasing",
2149 })
2150 );
2151 }
2152
2153 #[test]
2154 fn receiver_solution_validation_rejects_nonfinite_residuals() {
2155 let mut solution = valid_receiver_solution();
2156 solution.residuals_m[1] = f64::NAN;
2157 assert_eq!(
2158 validate_receiver_solution(&solution, SolutionValidationOptions::default()),
2159 Err(SolutionValidationError::InvalidResiduals)
2160 );
2161 }
2162
2163 fn range_design_rows() -> Vec<[f64; 4]> {
2166 vec![
2167 [-0.10, -0.20, -0.97, 1.0],
2168 [0.50, -0.30, -0.81, 1.0],
2169 [-0.60, 0.40, -0.69, 1.0],
2170 [0.20, 0.80, -0.56, 1.0],
2171 [0.70, 0.50, -0.51, 1.0],
2172 [-0.50, -0.70, -0.51, 1.0],
2173 [0.30, -0.60, -0.74, 1.0],
2174 [-0.80, 0.10, -0.59, 1.0],
2175 ]
2176 }
2177
2178 fn range_rows(dx_true: [f64; 4]) -> Vec<RangeFdeRow> {
2179 range_design_rows()
2180 .iter()
2181 .enumerate()
2182 .map(|(i, h)| RangeFdeRow {
2183 id: format!("S{:02}", i + 1),
2184 residual_m: h.iter().zip(dx_true).map(|(a, b)| a * b).sum(),
2185 design_row: h.to_vec(),
2186 weight: 1.0,
2187 })
2188 .collect()
2189 }
2190
2191 fn assert_close(got: &[f64], want: &[f64], tol: f64) {
2192 assert_eq!(got.len(), want.len());
2193 for (g, w) in got.iter().zip(want) {
2194 assert!((g - w).abs() < tol, "got {g}, want {w}");
2195 }
2196 }
2197
2198 #[test]
2199 fn range_fde_clean_set_recovers_state_without_exclusions() {
2200 let dx_true = [1.0, -2.0, 0.5, 3.0];
2201 let rows = range_rows(dx_true);
2202 let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2203
2204 assert!(!result.global_test.fault_detected);
2205 assert!(result.global_test.testable);
2206 assert_eq!(result.global_test.dof, 4);
2207 assert!(result.excluded.is_empty());
2208 assert_eq!(result.iterations, 0);
2209 assert!(result.global_test.weighted_sum_squares < 1.0e-12);
2210 assert_close(&result.state_correction, &dx_true, 1.0e-9);
2211 assert_eq!(result.state_covariance.len(), 4);
2212 }
2213
2214 #[test]
2215 fn range_fde_detects_and_excludes_a_single_outlier() {
2216 let dx_true = [1.0, -2.0, 0.5, 3.0];
2217 let mut rows = range_rows(dx_true);
2218 rows[2].residual_m += 50.0; let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2221
2222 assert_eq!(result.excluded, vec!["S03".to_string()]);
2223 assert_eq!(result.iterations, 1);
2224 assert!(!result.global_test.fault_detected);
2225 assert_close(&result.state_correction, &dx_true, 1.0e-9);
2226
2227 let s03 = result
2228 .diagnostics
2229 .iter()
2230 .find(|d| d.id == "S03")
2231 .expect("S03 diagnostic");
2232 assert!(s03.excluded);
2233 assert!(s03.post_fit_residual_m.abs() > 40.0);
2235 for d in result.diagnostics.iter().filter(|d| !d.excluded) {
2237 assert!(d.normalized_residual.abs() < 1.0e-6);
2238 }
2239 }
2240
2241 #[test]
2242 fn range_fde_excludes_multiple_outliers() {
2243 let dx_true = [0.5, 1.5, -1.0, 2.0];
2244 let mut rows = range_rows(dx_true);
2245 rows[2].residual_m += 50.0; rows[5].residual_m -= 40.0; let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2249
2250 assert_eq!(result.iterations, 2);
2251 let mut excluded = result.excluded.clone();
2252 excluded.sort();
2253 assert_eq!(excluded, vec!["S03".to_string(), "S06".to_string()]);
2254 assert!(!result.global_test.fault_detected);
2255 assert_close(&result.state_correction, &dx_true, 1.0e-9);
2256 }
2257
2258 #[test]
2259 fn range_fde_respects_the_exclusion_budget() {
2260 let dx_true = [0.5, 1.5, -1.0, 2.0];
2261 let mut rows = range_rows(dx_true);
2262 rows[2].residual_m += 50.0;
2263 rows[5].residual_m -= 40.0;
2264
2265 let options = RangeFdeOptions {
2266 max_exclusions: 1,
2267 ..Default::default()
2268 };
2269 let result = raim_fde_design(&rows, &options).expect("fde");
2270
2271 assert_eq!(result.iterations, 1);
2273 assert_eq!(result.excluded.len(), 1);
2274 assert!(result.global_test.fault_detected);
2275 }
2276
2277 #[test]
2278 fn range_fde_rejects_rank_deficient_geometry() {
2279 let rows: Vec<RangeFdeRow> = (0..5)
2280 .map(|i| RangeFdeRow {
2281 id: format!("S{:02}", i + 1),
2282 residual_m: 1.0,
2283 design_row: vec![1.0, 0.0, 0.0, 1.0], weight: 1.0,
2285 })
2286 .collect();
2287 assert_eq!(
2288 raim_fde_design(&rows, &RangeFdeOptions::default()),
2289 Err(QualityError::SingularGeometry)
2290 );
2291 }
2292
2293 #[test]
2294 fn range_fde_rejects_malformed_inputs() {
2295 assert_eq!(
2296 raim_fde_design(&[], &RangeFdeOptions::default()),
2297 Err(QualityError::InvalidDesign)
2298 );
2299
2300 let too_few = vec![RangeFdeRow {
2302 id: "S01".to_string(),
2303 residual_m: 0.0,
2304 design_row: vec![1.0, 0.0, 0.0, 1.0],
2305 weight: 1.0,
2306 }];
2307 assert_eq!(
2308 raim_fde_design(&too_few, &RangeFdeOptions::default()),
2309 Err(QualityError::InvalidDesign)
2310 );
2311
2312 let mut ragged = range_rows([1.0, 0.0, 0.0, 0.0]);
2314 ragged[1].design_row.pop();
2315 assert_eq!(
2316 raim_fde_design(&ragged, &RangeFdeOptions::default()),
2317 Err(QualityError::InvalidDesign)
2318 );
2319
2320 let mut bad_weight = range_rows([1.0, 0.0, 0.0, 0.0]);
2322 bad_weight[0].weight = 0.0;
2323 assert_eq!(
2324 raim_fde_design(&bad_weight, &RangeFdeOptions::default()),
2325 Err(QualityError::InvalidWeight)
2326 );
2327
2328 let mut bad_residual = range_rows([1.0, 0.0, 0.0, 0.0]);
2329 bad_residual[0].residual_m = f64::NAN;
2330 assert_eq!(
2331 raim_fde_design(&bad_residual, &RangeFdeOptions::default()),
2332 Err(QualityError::InvalidResiduals)
2333 );
2334
2335 let rows = range_rows([1.0, 0.0, 0.0, 0.0]);
2336 let bad_p = RangeFdeOptions {
2337 p_fa: 1.0,
2338 ..Default::default()
2339 };
2340 assert_eq!(
2341 raim_fde_design(&rows, &bad_p),
2342 Err(QualityError::InvalidProbability)
2343 );
2344 }
2345
2346 #[test]
2347 fn chi_square_threshold_matches_rtklib_demo5_chisqr_table() {
2348 let table: [f64; 20] = [
2354 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,
2355 37.7, 39.3, 40.8, 42.3, 43.8, 45.3,
2356 ];
2357 for (i, &expected) in table.iter().enumerate() {
2358 let dof = i + 1;
2359 let got = chi2_inv(0.999, dof).expect("chi2 quantile");
2360 let tol = (0.01 * expected).max(0.05);
2361 assert!(
2362 (got - expected).abs() < tol,
2363 "dof {dof}: got {got}, demo5 chisqr {expected}"
2364 );
2365 }
2366 }
2367}