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