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