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