1use std::collections::{BTreeMap, BTreeSet};
7
8use crate::constants::DEG_TO_RAD;
9use crate::spp::{Observation, ReceiverSolution};
10use crate::validate;
11
12pub const DEFAULT_VARIANCE_A_M: f64 = 0.3;
14pub const DEFAULT_VARIANCE_B_M: f64 = 0.3;
16pub const DEFAULT_P_FA: f64 = 1.0e-3;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum PseudorangeVarianceModel {
22 Elevation,
24 ElevationCn0,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq)]
30pub struct PseudorangeVarianceOptions {
31 pub a_m: f64,
33 pub b_m: f64,
35 pub model: PseudorangeVarianceModel,
37 pub cn0_dbhz: Option<f64>,
40 pub cn0_scale_m2: f64,
42}
43
44impl Default for PseudorangeVarianceOptions {
45 fn default() -> Self {
46 Self {
47 a_m: DEFAULT_VARIANCE_A_M,
48 b_m: DEFAULT_VARIANCE_B_M,
49 model: PseudorangeVarianceModel::Elevation,
50 cn0_dbhz: None,
51 cn0_scale_m2: 1.0,
52 }
53 }
54}
55
56impl PseudorangeVarianceOptions {
57 fn with_entry_cn0(self, cn0_dbhz: f64) -> Self {
58 Self {
59 model: PseudorangeVarianceModel::ElevationCn0,
60 cn0_dbhz: Some(cn0_dbhz),
61 ..self
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq)]
68pub struct WeightEntry {
69 pub satellite_id: String,
71 pub elevation_deg: f64,
73 pub cn0_dbhz: Option<f64>,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum QualityError {
81 InvalidElevation,
83 MissingCn0,
85 InvalidParameter,
87 InvalidProbability,
89 InvalidSystemCount,
91 InvalidDof,
93 InvalidWeight,
95 InvalidResiduals,
97}
98
99impl core::fmt::Display for QualityError {
100 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
101 match self {
102 Self::InvalidElevation => write!(f, "invalid elevation"),
103 Self::MissingCn0 => write!(f, "missing C/N0"),
104 Self::InvalidParameter => write!(f, "invalid quality parameter"),
105 Self::InvalidProbability => write!(f, "invalid probability"),
106 Self::InvalidSystemCount => write!(f, "invalid RAIM system count"),
107 Self::InvalidDof => write!(f, "invalid degrees of freedom"),
108 Self::InvalidWeight => write!(f, "invalid RAIM weight"),
109 Self::InvalidResiduals => write!(f, "invalid RAIM residuals"),
110 }
111 }
112}
113
114impl std::error::Error for QualityError {}
115
116pub fn pseudorange_variance(
118 elevation_deg: f64,
119 options: PseudorangeVarianceOptions,
120) -> Result<f64, QualityError> {
121 validate_elevation_deg(elevation_deg)?;
122 validate_variance_options(options)?;
123
124 let mut elevation_var = options.a_m * options.a_m;
125 if options.b_m != 0.0 {
126 let sin_el = (elevation_deg * DEG_TO_RAD).sin();
127 let scaled = options.b_m * options.b_m / (sin_el * sin_el);
128 if !scaled.is_finite() {
129 return Err(QualityError::InvalidElevation);
130 }
131 elevation_var += scaled;
132 }
133
134 let variance = match options.model {
135 PseudorangeVarianceModel::Elevation => elevation_var,
136 PseudorangeVarianceModel::ElevationCn0 => {
137 let Some(cn0) = options.cn0_dbhz else {
138 return Err(QualityError::MissingCn0);
139 };
140 validate_nonneg_parameter(cn0, "cn0_dbhz")?;
141 elevation_var + options.cn0_scale_m2 * 10.0_f64.powf(-cn0 / 10.0)
142 }
143 };
144
145 validate_positive_variance(variance)?;
146 Ok(variance)
147}
148
149fn validate_elevation_deg(elevation_deg: f64) -> Result<(), QualityError> {
150 validate::finite(elevation_deg, "elevation_deg").map_err(|_| QualityError::InvalidElevation)?;
151 if (-90.0..=90.0).contains(&elevation_deg) {
152 Ok(())
153 } else {
154 Err(QualityError::InvalidElevation)
155 }
156}
157
158fn validate_variance_options(options: PseudorangeVarianceOptions) -> Result<(), QualityError> {
159 validate_nonneg_parameter(options.a_m, "variance a_m")?;
160 validate_nonneg_parameter(options.b_m, "variance b_m")?;
161 validate_nonneg_parameter(options.cn0_scale_m2, "variance cn0_scale_m2")
162}
163
164fn validate_nonneg_parameter(value: f64, field: &'static str) -> Result<(), QualityError> {
165 validate::finite_nonneg(value, field)
166 .map(|_| ())
167 .map_err(map_parameter_error)
168}
169
170fn validate_positive_variance(value: f64) -> Result<(), QualityError> {
171 validate::finite_positive(value, "pseudorange variance")
172 .map(|_| ())
173 .map_err(map_parameter_error)
174}
175
176fn map_parameter_error(_error: validate::FieldError) -> QualityError {
177 QualityError::InvalidParameter
178}
179
180pub fn sigmas(
183 entries: &[WeightEntry],
184 options: PseudorangeVarianceOptions,
185) -> BTreeMap<String, f64> {
186 entries
187 .iter()
188 .filter_map(|entry| {
189 let opts = match entry.cn0_dbhz {
190 Some(cn0) => options.with_entry_cn0(cn0),
191 None => options,
192 };
193 pseudorange_variance(entry.elevation_deg, opts)
194 .ok()
195 .map(|var| (entry.satellite_id.clone(), var.sqrt()))
196 })
197 .collect()
198}
199
200pub fn weight_vector(
203 entries: &[WeightEntry],
204 options: PseudorangeVarianceOptions,
205) -> BTreeMap<String, f64> {
206 entries
207 .iter()
208 .filter_map(|entry| {
209 let opts = match entry.cn0_dbhz {
210 Some(cn0) => options.with_entry_cn0(cn0),
211 None => options,
212 };
213 pseudorange_variance(entry.elevation_deg, opts)
214 .ok()
215 .map(|var| (entry.satellite_id.clone(), 1.0 / var))
216 })
217 .collect()
218}
219
220#[derive(Debug, Clone, PartialEq)]
222pub enum RaimWeights {
223 Unit,
225 BySatellite(BTreeMap<String, f64>),
228}
229
230impl RaimWeights {
231 fn validate(&self) -> Result<(), QualityError> {
232 match self {
233 Self::Unit => Ok(()),
234 Self::BySatellite(weights) => weights
235 .values()
236 .try_for_each(|w| validate::finite_positive(*w, "raim weight").map(|_| ()))
237 .map_err(|_| QualityError::InvalidWeight),
238 }
239 }
240
241 fn weight_for(&self, satellite_id: &str) -> f64 {
242 match self {
243 Self::Unit => 1.0,
244 Self::BySatellite(weights) => weights.get(satellite_id).copied().unwrap_or(1.0),
245 }
246 }
247}
248
249#[derive(Debug, Clone, PartialEq)]
251pub struct RaimOptions {
252 pub p_fa: f64,
254 pub weights: RaimWeights,
256 pub n_systems: Option<isize>,
258}
259
260impl Default for RaimOptions {
261 fn default() -> Self {
262 Self {
263 p_fa: DEFAULT_P_FA,
264 weights: RaimWeights::Unit,
265 n_systems: None,
266 }
267 }
268}
269
270#[derive(Debug, Clone, PartialEq)]
272pub struct RaimInput {
273 pub used_sats: Vec<String>,
275 pub residuals_m: Vec<f64>,
277}
278
279pub trait RaimSolution {
281 fn raim_used_sats(&self) -> Vec<String>;
283 fn raim_residuals_m(&self) -> &[f64];
285}
286
287impl RaimSolution for ReceiverSolution {
288 fn raim_used_sats(&self) -> Vec<String> {
289 self.used_sats.iter().map(ToString::to_string).collect()
290 }
291
292 fn raim_residuals_m(&self) -> &[f64] {
293 &self.residuals_m
294 }
295}
296
297#[derive(Debug, Clone, PartialEq)]
299pub struct RaimResult {
300 pub fault_detected: bool,
302 pub test_statistic: f64,
304 pub threshold: Option<f64>,
306 pub dof: isize,
308 pub testable: bool,
310 pub normalized_residuals: BTreeMap<String, f64>,
312 pub worst_sat: Option<String>,
314}
315
316pub fn raim_for_solution<S: RaimSolution>(
318 solution: &S,
319 options: &RaimOptions,
320) -> Result<RaimResult, QualityError> {
321 raim(
322 &RaimInput {
323 used_sats: solution.raim_used_sats(),
324 residuals_m: solution.raim_residuals_m().to_vec(),
325 },
326 options,
327 )
328}
329
330pub fn raim(input: &RaimInput, options: &RaimOptions) -> Result<RaimResult, QualityError> {
332 validate_probability(options.p_fa)?;
333 options.weights.validate()?;
334 validate_raim_input(input)?;
335
336 let n_used = input.used_sats.len() as isize;
337 let n_systems = raim_system_count(input, options)?;
338 let dof = n_used - (3 + n_systems);
339
340 let mut test_statistic = 0.0;
341 let mut normalized_residuals = BTreeMap::new();
342 let mut worst_sat = None::<String>;
343 let mut worst_abs = f64::NEG_INFINITY;
344
345 for (satellite_id, residual_m) in input.used_sats.iter().zip(input.residuals_m.iter()) {
346 let weight = options.weights.weight_for(satellite_id);
347 let normalized = residual_m * weight.sqrt();
348 test_statistic += residual_m * residual_m * weight;
349 normalized_residuals.insert(satellite_id.clone(), normalized);
350 let abs_normalized = normalized.abs();
351 if abs_normalized > worst_abs {
352 worst_abs = abs_normalized;
353 worst_sat = Some(satellite_id.clone());
354 }
355 }
356
357 if dof <= 0 {
358 return Ok(RaimResult {
359 fault_detected: false,
360 test_statistic,
361 threshold: None,
362 dof,
363 testable: false,
364 normalized_residuals,
365 worst_sat,
366 });
367 }
368
369 let threshold = chi2_inv(1.0 - options.p_fa, dof as usize)?;
370 Ok(RaimResult {
371 fault_detected: test_statistic > threshold,
372 test_statistic,
373 threshold: Some(threshold),
374 dof,
375 testable: true,
376 normalized_residuals,
377 worst_sat,
378 })
379}
380
381fn validate_probability(p: f64) -> Result<(), QualityError> {
382 let p = validate::finite(p, "probability").map_err(|_| QualityError::InvalidProbability)?;
383 if p > 0.0 && p < 1.0 {
384 Ok(())
385 } else {
386 Err(QualityError::InvalidProbability)
387 }
388}
389
390fn validate_raim_input(input: &RaimInput) -> Result<(), QualityError> {
391 if input.used_sats.len() != input.residuals_m.len() {
392 return Err(QualityError::InvalidResiduals);
393 }
394 validate::finite_slice(&input.residuals_m, "raim residuals")
395 .map_err(|_| QualityError::InvalidResiduals)
396}
397
398fn raim_system_count(input: &RaimInput, options: &RaimOptions) -> Result<isize, QualityError> {
399 match options.n_systems {
400 Some(n_systems) if n_systems >= 1 => Ok(n_systems),
401 Some(_) => Err(QualityError::InvalidSystemCount),
402 None => Ok(distinct_systems(&input.used_sats)),
403 }
404}
405
406fn distinct_systems(used_sats: &[String]) -> isize {
407 used_sats
408 .iter()
409 .filter_map(|sat| sat.chars().next())
410 .collect::<BTreeSet<_>>()
411 .len() as isize
412}
413
414#[derive(Debug, Clone, PartialEq)]
416pub struct FdeResult<S> {
417 pub solution: S,
419 pub excluded: Vec<String>,
421 pub iterations: usize,
423}
424
425#[derive(Debug, Clone, PartialEq)]
427pub enum FdeError<E> {
428 FaultUnresolved(f64),
430 Solve(E),
432 Raim(QualityError),
434}
435
436#[derive(Debug, Clone, PartialEq)]
438pub struct FdeOptions {
439 pub raim: RaimOptions,
441 pub max_iterations: usize,
443}
444
445pub fn fde<S, E, F>(
447 observations: &[Observation],
448 options: &FdeOptions,
449 mut solve: F,
450) -> Result<FdeResult<S>, FdeError<E>>
451where
452 S: RaimSolution,
453 F: FnMut(&[Observation]) -> Result<S, E>,
454{
455 let mut remaining = observations.to_vec();
456 let mut excluded = Vec::new();
457 let mut iter = 0usize;
458
459 loop {
460 let solution = solve(&remaining).map_err(FdeError::Solve)?;
461 let result = raim_for_solution(&solution, &options.raim).map_err(FdeError::Raim)?;
462
463 if !result.fault_detected {
464 return Ok(FdeResult {
465 solution,
466 excluded,
467 iterations: iter,
468 });
469 }
470
471 let Some(worst) = result.worst_sat else {
472 return Err(FdeError::FaultUnresolved(result.test_statistic));
473 };
474
475 if iter >= options.max_iterations {
476 return Err(FdeError::FaultUnresolved(result.test_statistic));
477 }
478
479 remaining.retain(|ob| ob.satellite_id.to_string() != worst);
480 excluded.push(worst);
481 iter += 1;
482 }
483}
484
485#[derive(Debug, Clone, Copy, PartialEq)]
487pub struct SolutionValidationOptions {
488 pub max_pdop: Option<f64>,
490 pub min_plausible_radius_m: f64,
492 pub max_plausible_radius_m: f64,
494 pub max_converged_residual_rms_m: f64,
496}
497
498impl Default for SolutionValidationOptions {
499 fn default() -> Self {
500 Self {
501 max_pdop: None,
502 min_plausible_radius_m: 6_344_752.0,
503 max_plausible_radius_m: 8_378_137.0,
504 max_converged_residual_rms_m: 1.0e4,
505 }
506 }
507}
508
509#[derive(Debug, Clone, Copy, PartialEq)]
511pub enum SolutionValidationError {
512 InvalidOptions {
514 field: &'static str,
516 reason: &'static str,
518 },
519 DegenerateGeometryRankDeficient,
521 DegenerateGeometryPdop(f64),
523 ImplausiblePosition(f64),
525 InvalidResiduals,
527 NoConvergence(f64),
529}
530
531impl core::fmt::Display for SolutionValidationError {
532 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
533 match self {
534 Self::InvalidOptions { field, reason } => {
535 write!(f, "invalid receiver validation option {field}: {reason}")
536 }
537 Self::DegenerateGeometryRankDeficient => {
538 write!(f, "receiver geometry is rank deficient")
539 }
540 Self::DegenerateGeometryPdop(pdop) => {
541 write!(
542 f,
543 "receiver geometry PDOP {pdop} exceeds the configured limit"
544 )
545 }
546 Self::ImplausiblePosition(radius_m) => write!(
547 f,
548 "receiver geocentric radius {radius_m} m is outside the plausible range"
549 ),
550 Self::InvalidResiduals => {
551 write!(f, "converged solution residuals must be finite")
552 }
553 Self::NoConvergence(rms_m) => write!(
554 f,
555 "converged solution residual RMS {rms_m} m is implausibly large"
556 ),
557 }
558 }
559}
560
561impl std::error::Error for SolutionValidationError {}
562
563pub fn validate_receiver_solution(
565 solution: &ReceiverSolution,
566 options: SolutionValidationOptions,
567) -> Result<(), SolutionValidationError> {
568 validate_solution_validation_options(options)?;
569
570 let Some(dop) = solution.dop else {
571 return Err(SolutionValidationError::DegenerateGeometryRankDeficient);
572 };
573
574 if let Some(max_pdop) = options.max_pdop {
575 if dop.pdop > max_pdop {
576 return Err(SolutionValidationError::DegenerateGeometryPdop(dop.pdop));
577 }
578 }
579
580 let p = solution.position.as_array();
581 let radius_m = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
582 if radius_m < options.min_plausible_radius_m || radius_m > options.max_plausible_radius_m {
583 return Err(SolutionValidationError::ImplausiblePosition(radius_m));
584 }
585
586 if solution.metadata.converged {
587 if validate::finite_slice(&solution.residuals_m, "solution residuals").is_err() {
588 return Err(SolutionValidationError::InvalidResiduals);
589 }
590 let rms = residual_rms(&solution.residuals_m);
591 if !rms.is_finite() {
592 return Err(SolutionValidationError::InvalidResiduals);
593 }
594 if rms > options.max_converged_residual_rms_m {
595 return Err(SolutionValidationError::NoConvergence(rms));
596 }
597 }
598
599 Ok(())
600}
601
602fn validate_solution_validation_options(
603 options: SolutionValidationOptions,
604) -> Result<(), SolutionValidationError> {
605 if let Some(max_pdop) = options.max_pdop {
606 validate::finite_positive(max_pdop, "max_pdop").map_err(validation_option_error)?;
607 }
608 validate::finite_positive(options.min_plausible_radius_m, "min_plausible_radius_m")
609 .map_err(validation_option_error)?;
610 validate::finite_positive(options.max_plausible_radius_m, "max_plausible_radius_m")
611 .map_err(validation_option_error)?;
612 if options.min_plausible_radius_m >= options.max_plausible_radius_m {
613 return Err(invalid_validation_option(
614 "plausible_radius_m",
615 "must be increasing",
616 ));
617 }
618 validate::finite_positive(
619 options.max_converged_residual_rms_m,
620 "max_converged_residual_rms_m",
621 )
622 .map_err(validation_option_error)?;
623 Ok(())
624}
625
626fn validation_option_error(error: validate::FieldError) -> SolutionValidationError {
627 invalid_validation_option(error.field(), error.reason())
628}
629
630fn invalid_validation_option(field: &'static str, reason: &'static str) -> SolutionValidationError {
631 SolutionValidationError::InvalidOptions { field, reason }
632}
633
634fn residual_rms(residuals: &[f64]) -> f64 {
635 if residuals.is_empty() {
636 return 0.0;
637 }
638 let sum_sq = residuals.iter().map(|r| r * r).sum::<f64>();
639 (sum_sq / residuals.len() as f64).sqrt()
640}
641
642pub fn chi2_inv(p: f64, k: usize) -> Result<f64, QualityError> {
644 validate_probability(p)?;
645 if k == 0 {
646 return Err(QualityError::InvalidDof);
647 }
648 let a = 0.5 * k as f64;
649 let hi0 = (k as f64 + 10.0 * (2.0 * k as f64).sqrt()).max(1.0);
650 let hi = chi2_bracket_hi(p, a, hi0);
651 Ok(chi2_bisect(p, a, 0.0, hi, 0))
652}
653
654fn chi2_bracket_hi(p: f64, a: f64, hi: f64) -> f64 {
655 if chi2_cdf(hi, a) >= p {
656 hi
657 } else {
658 chi2_bracket_hi(p, a, hi * 2.0)
659 }
660}
661
662fn chi2_bisect(p: f64, a: f64, lo: f64, hi: f64, iter: usize) -> f64 {
663 if iter >= 120 {
664 return 0.5 * (lo + hi);
665 }
666 let mid = 0.5 * (lo + hi);
667 if chi2_cdf(mid, a) < p {
668 chi2_bisect(p, a, mid, hi, iter + 1)
669 } else {
670 chi2_bisect(p, a, lo, mid, iter + 1)
671 }
672}
673
674fn chi2_cdf(x: f64, a: f64) -> f64 {
675 regularized_gamma_p(a, 0.5 * x)
676}
677
678const GAMMA_EPS: f64 = 1.0e-15;
679const GAMMA_FPMIN: f64 = 1.0e-300;
680const GAMMA_ITMAX: usize = 1_000;
681
682fn regularized_gamma_p(a: f64, x: f64) -> f64 {
683 if x <= 0.0 {
684 return 0.0;
685 }
686
687 if x < a + 1.0 {
688 let gln = log_gamma(a);
689 let sum = gamma_series(x, 1.0 / a, 1.0 / a, a, 1);
690 sum * (-x + a * x.ln() - gln).exp()
691 } else {
692 let gln = log_gamma(a);
693 let q = gamma_continued_fraction(a, x) * (-x + a * x.ln() - gln).exp();
694 1.0 - q
695 }
696}
697
698fn gamma_series(x: f64, sum: f64, del: f64, ap: f64, n: usize) -> f64 {
699 if n > GAMMA_ITMAX {
700 return sum;
701 }
702 let ap = ap + 1.0;
703 let del = del * x / ap;
704 let sum = sum + del;
705 if del.abs() < sum.abs() * GAMMA_EPS {
706 sum
707 } else {
708 gamma_series(x, sum, del, ap, n + 1)
709 }
710}
711
712fn gamma_continued_fraction(a: f64, x: f64) -> f64 {
713 let b = x + 1.0 - a;
714 let c = 1.0 / GAMMA_FPMIN;
715 let d = 1.0 / safe_denominator(b);
716 gamma_cf_iter(a, b, c, d, d, 1)
717}
718
719fn gamma_cf_iter(a: f64, b: f64, c: f64, d: f64, h: f64, n: usize) -> f64 {
720 if n > GAMMA_ITMAX {
721 return h;
722 }
723
724 let an = -(n as f64) * (n as f64 - a);
725 let b = b + 2.0;
726 let d = 1.0 / safe_denominator(an * d + b);
727 let c = safe_denominator(b + an / c);
728 let delta = d * c;
729 let h = h * delta;
730
731 if (delta - 1.0).abs() < GAMMA_EPS {
732 h
733 } else {
734 gamma_cf_iter(a, b, c, d, h, n + 1)
735 }
736}
737
738fn safe_denominator(x: f64) -> f64 {
739 if x.abs() < GAMMA_FPMIN {
740 GAMMA_FPMIN
741 } else {
742 x
743 }
744}
745
746const LANCZOS: [f64; 9] = [
747 0.9999999999998099,
748 676.5203681218851,
749 -1259.1392167224028,
750 771.3234287776531,
751 -176.6150291621406,
752 12.507343278686905,
753 -0.13857109526572012,
754 9.984369578019572e-6,
755 1.5056327351493116e-7,
756];
757const SQRT_2PI: f64 = 2.5066282746310002;
758
759fn log_gamma(z: f64) -> f64 {
760 if z < 0.5 {
761 std::f64::consts::PI.ln() - (std::f64::consts::PI * z).sin().ln() - log_gamma(1.0 - z)
762 } else {
763 let z = z - 1.0;
764 let mut x = LANCZOS[0];
765 for (i, coef) in LANCZOS.iter().enumerate().skip(1) {
766 x += coef / (z + i as f64);
767 }
768 let t = z + 7.5;
769 SQRT_2PI.ln() + (z + 0.5) * t.ln() - t + x.ln()
770 }
771}
772
773#[cfg(test)]
774mod tests {
775 use super::*;
776 use crate::{GnssSatelliteId, GnssSystem};
777
778 #[derive(Debug, Clone)]
779 struct TestSolution {
780 used_sats: Vec<String>,
781 residuals_m: Vec<f64>,
782 }
783
784 impl RaimSolution for TestSolution {
785 fn raim_used_sats(&self) -> Vec<String> {
786 self.used_sats.clone()
787 }
788
789 fn raim_residuals_m(&self) -> &[f64] {
790 &self.residuals_m
791 }
792 }
793
794 fn gps(prn: u8) -> GnssSatelliteId {
795 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
796 }
797
798 fn valid_receiver_solution() -> ReceiverSolution {
799 ReceiverSolution {
800 position: crate::frame::ItrfPositionM::new(6_378_137.0, 0.0, 0.0).unwrap(),
801 geodetic: None,
802 rx_clock_s: 0.0,
803 system_clocks_s: vec![(GnssSystem::Gps, 0.0)],
804 dop: Some(crate::dop::Dop {
805 gdop: 2.5,
806 pdop: 2.0,
807 hdop: 1.5,
808 vdop: 1.0,
809 tdop: 0.5,
810 }),
811 residuals_m: vec![0.1, -0.1, 0.0, 0.05, -0.05],
812 used_sats: (1..=5).map(gps).collect(),
813 rejected_sats: Vec::new(),
814 metadata: crate::spp::SolutionMetadata {
815 iterations: 3,
816 converged: true,
817 status: crate::astro::math::least_squares::Status::StepTolerance,
818 ionosphere_applied: false,
819 troposphere_applied: false,
820 outer_iterations: 0,
821 final_robust_scale_m: None,
822 used_count: 5,
823 systems: vec![GnssSystem::Gps],
824 redundancy: 1,
825 raim_checkable: true,
826 },
827 }
828 }
829
830 #[test]
831 fn pseudorange_variance_matches_elevation_model() {
832 let opts = PseudorangeVarianceOptions::default();
833 let variance = pseudorange_variance(30.0, opts).unwrap();
834 assert!((variance - 0.45).abs() < 1.0e-15);
835 assert_eq!(
836 pseudorange_variance(0.0, opts),
837 Err(QualityError::InvalidElevation)
838 );
839 let horizon_opts = PseudorangeVarianceOptions { b_m: 0.0, ..opts };
840 assert_eq!(
841 pseudorange_variance(0.0, horizon_opts),
842 Ok(horizon_opts.a_m * horizon_opts.a_m)
843 );
844 assert_eq!(
845 pseudorange_variance(-90.0, horizon_opts),
846 Ok(horizon_opts.a_m * horizon_opts.a_m)
847 );
848 assert_eq!(
849 pseudorange_variance(90.1, horizon_opts),
850 Err(QualityError::InvalidElevation)
851 );
852 assert_eq!(
853 pseudorange_variance(f64::NAN, opts),
854 Err(QualityError::InvalidElevation)
855 );
856 }
857
858 #[test]
859 fn cn0_model_requires_cn0_and_adds_noise_term() {
860 let opts = PseudorangeVarianceOptions {
861 model: PseudorangeVarianceModel::ElevationCn0,
862 cn0_dbhz: None,
863 ..Default::default()
864 };
865 assert_eq!(
866 pseudorange_variance(30.0, opts),
867 Err(QualityError::MissingCn0)
868 );
869
870 let weak = pseudorange_variance(
871 30.0,
872 PseudorangeVarianceOptions {
873 cn0_dbhz: Some(30.0),
874 ..opts
875 },
876 )
877 .unwrap();
878 let strong = pseudorange_variance(
879 30.0,
880 PseudorangeVarianceOptions {
881 cn0_dbhz: Some(50.0),
882 ..opts
883 },
884 )
885 .unwrap();
886 assert!(strong < weak);
887 }
888
889 #[test]
890 fn pseudorange_variance_rejects_nonfinite_and_negative_parameters() {
891 let invalid_a = PseudorangeVarianceOptions {
892 a_m: f64::NAN,
893 ..Default::default()
894 };
895 assert_eq!(
896 pseudorange_variance(30.0, invalid_a),
897 Err(QualityError::InvalidParameter)
898 );
899
900 let invalid_b = PseudorangeVarianceOptions {
901 b_m: -1.0,
902 ..Default::default()
903 };
904 assert_eq!(
905 pseudorange_variance(30.0, invalid_b),
906 Err(QualityError::InvalidParameter)
907 );
908
909 let invalid_cn0_scale = PseudorangeVarianceOptions {
910 cn0_scale_m2: f64::INFINITY,
911 ..Default::default()
912 };
913 assert_eq!(
914 pseudorange_variance(30.0, invalid_cn0_scale),
915 Err(QualityError::InvalidParameter)
916 );
917
918 let invalid_cn0 = PseudorangeVarianceOptions {
919 model: PseudorangeVarianceModel::ElevationCn0,
920 cn0_dbhz: Some(f64::NAN),
921 ..Default::default()
922 };
923 assert_eq!(
924 pseudorange_variance(30.0, invalid_cn0),
925 Err(QualityError::InvalidParameter)
926 );
927 }
928
929 #[test]
930 fn pseudorange_variance_rejects_zero_total_variance() {
931 let zero_variance = PseudorangeVarianceOptions {
932 a_m: 0.0,
933 b_m: 0.0,
934 ..Default::default()
935 };
936 assert_eq!(
937 pseudorange_variance(30.0, zero_variance),
938 Err(QualityError::InvalidParameter)
939 );
940
941 let entries = vec![WeightEntry {
942 satellite_id: "G01".to_string(),
943 elevation_deg: 30.0,
944 cn0_dbhz: None,
945 }];
946 let weights = weight_vector(&entries, zero_variance);
947 assert!(
948 !weights.contains_key("G01"),
949 "zero variance must not produce an infinite inverse-variance weight"
950 );
951 }
952
953 #[test]
954 fn sigma_and_weight_maps_drop_invalid_entries() {
955 let entries = vec![
956 WeightEntry {
957 satellite_id: "G01".to_string(),
958 elevation_deg: 90.0,
959 cn0_dbhz: None,
960 },
961 WeightEntry {
962 satellite_id: "G02".to_string(),
963 elevation_deg: -91.0,
964 cn0_dbhz: None,
965 },
966 ];
967 let sigmas = sigmas(&entries, Default::default());
968 let weights = weight_vector(&entries, Default::default());
969 assert!(sigmas.contains_key("G01"));
970 assert!(!sigmas.contains_key("G02"));
971 assert_eq!(weights["G01"], 1.0 / (sigmas["G01"] * sigmas["G01"]));
972 }
973
974 #[test]
975 fn sigma_and_weight_maps_retain_horizon_entries_without_elevation_term() {
976 let entries = vec![
977 WeightEntry {
978 satellite_id: "G01".to_string(),
979 elevation_deg: 0.0,
980 cn0_dbhz: None,
981 },
982 WeightEntry {
983 satellite_id: "G02".to_string(),
984 elevation_deg: f64::NAN,
985 cn0_dbhz: None,
986 },
987 ];
988 let options = PseudorangeVarianceOptions {
989 b_m: 0.0,
990 ..Default::default()
991 };
992 let sigmas = sigmas(&entries, options);
993 let weights = weight_vector(&entries, options);
994 assert_eq!(sigmas["G01"], options.a_m);
995 assert_eq!(weights["G01"], 1.0 / (options.a_m * options.a_m));
996 assert!(!sigmas.contains_key("G02"));
997 assert!(!weights.contains_key("G02"));
998 }
999
1000 #[test]
1001 fn chi_square_inverse_matches_reference_values() {
1002 let refs = [
1003 (1, 10.828),
1004 (2, 13.816),
1005 (3, 16.266),
1006 (4, 18.467),
1007 (5, 20.515),
1008 ];
1009 for (dof, expected) in refs {
1010 let got = chi2_inv(0.999, dof).unwrap();
1011 assert!((got - expected).abs() < 1.0e-3);
1012 }
1013 assert_eq!(chi2_inv(1.0, 1), Err(QualityError::InvalidProbability));
1014 assert_eq!(chi2_inv(0.95, 0), Err(QualityError::InvalidDof));
1015 }
1016
1017 #[test]
1018 fn raim_reports_fault_and_worst_satellite() {
1019 let input = RaimInput {
1020 used_sats: ["G01", "G02", "G03", "G04", "G05"]
1021 .into_iter()
1022 .map(str::to_string)
1023 .collect(),
1024 residuals_m: vec![0.0, 0.0, 0.0, 0.0, 5.0],
1025 };
1026 let result = raim(&input, &RaimOptions::default()).unwrap();
1027 assert!(result.fault_detected);
1028 assert!(result.testable);
1029 assert_eq!(result.dof, 1);
1030 assert_eq!(result.test_statistic, 25.0);
1031 assert_eq!(result.worst_sat.as_deref(), Some("G05"));
1032 }
1033
1034 #[test]
1035 fn raim_dof_zero_is_not_testable() {
1036 let input = RaimInput {
1037 used_sats: ["G01", "G02", "G03", "G04"]
1038 .into_iter()
1039 .map(str::to_string)
1040 .collect(),
1041 residuals_m: vec![0.0, 0.0, 0.0, 0.0],
1042 };
1043 let result = raim(&input, &RaimOptions::default()).unwrap();
1044 assert!(!result.fault_detected);
1045 assert!(!result.testable);
1046 assert_eq!(result.threshold, None);
1047 assert_eq!(result.dof, 0);
1048 }
1049
1050 #[test]
1051 fn raim_rejects_nonpositive_system_overrides() {
1052 let input = RaimInput {
1053 used_sats: ["G01", "G02", "G03", "G04", "G05"]
1054 .into_iter()
1055 .map(str::to_string)
1056 .collect(),
1057 residuals_m: vec![0.0; 5],
1058 };
1059
1060 for n_systems in [0, -1] {
1061 let options = RaimOptions {
1062 n_systems: Some(n_systems),
1063 ..Default::default()
1064 };
1065 assert_eq!(
1066 raim(&input, &options),
1067 Err(QualityError::InvalidSystemCount)
1068 );
1069 }
1070 }
1071
1072 #[test]
1073 fn raim_positive_system_override_controls_dof() {
1074 let input = RaimInput {
1075 used_sats: ["G01", "G02", "G03", "G04", "G05", "G06"]
1076 .into_iter()
1077 .map(str::to_string)
1078 .collect(),
1079 residuals_m: vec![0.0; 6],
1080 };
1081 let options = RaimOptions {
1082 n_systems: Some(2),
1083 ..Default::default()
1084 };
1085
1086 let result = raim(&input, &options).unwrap();
1087 assert!(result.testable);
1088 assert_eq!(result.dof, 1);
1089 }
1090
1091 #[test]
1092 fn raim_rejects_misaligned_or_nonfinite_residuals() {
1093 let input = RaimInput {
1094 used_sats: ["G01", "G02"].into_iter().map(str::to_string).collect(),
1095 residuals_m: vec![1.0],
1096 };
1097 assert_eq!(
1098 raim(&input, &RaimOptions::default()),
1099 Err(QualityError::InvalidResiduals)
1100 );
1101
1102 let input = RaimInput {
1103 used_sats: ["G01", "G02"].into_iter().map(str::to_string).collect(),
1104 residuals_m: vec![1.0, f64::NAN],
1105 };
1106 assert_eq!(
1107 raim(&input, &RaimOptions::default()),
1108 Err(QualityError::InvalidResiduals)
1109 );
1110 }
1111
1112 #[test]
1113 fn raim_rejects_nonfinite_weights_and_probability() {
1114 let input = RaimInput {
1115 used_sats: ["G01", "G02", "G03", "G04", "G05"]
1116 .into_iter()
1117 .map(str::to_string)
1118 .collect(),
1119 residuals_m: vec![0.0; 5],
1120 };
1121 let mut weights = BTreeMap::new();
1122 weights.insert("G01".to_string(), f64::NAN);
1123 let options = RaimOptions {
1124 weights: RaimWeights::BySatellite(weights),
1125 ..Default::default()
1126 };
1127 assert_eq!(raim(&input, &options), Err(QualityError::InvalidWeight));
1128
1129 let options = RaimOptions {
1130 p_fa: f64::NAN,
1131 ..Default::default()
1132 };
1133 assert_eq!(
1134 raim(&input, &options),
1135 Err(QualityError::InvalidProbability)
1136 );
1137 }
1138
1139 #[test]
1140 fn fde_excludes_largest_normalized_residual() {
1141 let observations: Vec<Observation> = (1..=5)
1142 .map(|prn| Observation {
1143 satellite_id: gps(prn),
1144 pseudorange_m: prn as f64,
1145 })
1146 .collect();
1147
1148 let options = FdeOptions {
1149 raim: RaimOptions::default(),
1150 max_iterations: 1,
1151 };
1152 let result = fde(&observations, &options, |remaining| {
1153 let used_sats = remaining
1154 .iter()
1155 .map(|ob| ob.satellite_id.to_string())
1156 .collect::<Vec<_>>();
1157 let residuals_m = remaining
1158 .iter()
1159 .map(|ob| if ob.satellite_id == gps(5) { 5.0 } else { 0.0 })
1160 .collect::<Vec<_>>();
1161 Ok::<_, ()>(TestSolution {
1162 used_sats,
1163 residuals_m,
1164 })
1165 })
1166 .unwrap();
1167
1168 assert_eq!(result.excluded, vec!["G05".to_string()]);
1169 assert_eq!(result.iterations, 1);
1170 assert_eq!(result.solution.used_sats.len(), 4);
1171 }
1172
1173 #[test]
1174 fn fde_refuses_fault_when_budget_is_exhausted() {
1175 let observations: Vec<Observation> = (1..=5)
1176 .map(|prn| Observation {
1177 satellite_id: gps(prn),
1178 pseudorange_m: prn as f64,
1179 })
1180 .collect();
1181 let options = FdeOptions {
1182 raim: RaimOptions::default(),
1183 max_iterations: 0,
1184 };
1185 let err = fde(&observations, &options, |remaining| {
1186 Ok::<_, ()>(TestSolution {
1187 used_sats: remaining
1188 .iter()
1189 .map(|ob| ob.satellite_id.to_string())
1190 .collect(),
1191 residuals_m: vec![0.0, 0.0, 0.0, 0.0, 5.0],
1192 })
1193 })
1194 .unwrap_err();
1195
1196 assert_eq!(err, FdeError::FaultUnresolved(25.0));
1197 }
1198
1199 #[test]
1200 fn receiver_solution_validation_rejects_invalid_gate_options() {
1201 let solution = valid_receiver_solution();
1202 for (options, field, reason) in [
1203 (
1204 SolutionValidationOptions {
1205 max_pdop: Some(f64::NAN),
1206 ..Default::default()
1207 },
1208 "max_pdop",
1209 "not finite",
1210 ),
1211 (
1212 SolutionValidationOptions {
1213 max_pdop: Some(0.0),
1214 ..Default::default()
1215 },
1216 "max_pdop",
1217 "not positive",
1218 ),
1219 (
1220 SolutionValidationOptions {
1221 min_plausible_radius_m: 0.0,
1222 ..Default::default()
1223 },
1224 "min_plausible_radius_m",
1225 "not positive",
1226 ),
1227 (
1228 SolutionValidationOptions {
1229 max_plausible_radius_m: f64::INFINITY,
1230 ..Default::default()
1231 },
1232 "max_plausible_radius_m",
1233 "not finite",
1234 ),
1235 (
1236 SolutionValidationOptions {
1237 max_converged_residual_rms_m: f64::NAN,
1238 ..Default::default()
1239 },
1240 "max_converged_residual_rms_m",
1241 "not finite",
1242 ),
1243 ] {
1244 assert_eq!(
1245 validate_receiver_solution(&solution, options),
1246 Err(SolutionValidationError::InvalidOptions { field, reason })
1247 );
1248 }
1249
1250 let inverted_radius = SolutionValidationOptions {
1251 min_plausible_radius_m: 8_000_000.0,
1252 max_plausible_radius_m: 7_000_000.0,
1253 ..Default::default()
1254 };
1255 assert_eq!(
1256 validate_receiver_solution(&solution, inverted_radius),
1257 Err(SolutionValidationError::InvalidOptions {
1258 field: "plausible_radius_m",
1259 reason: "must be increasing",
1260 })
1261 );
1262 }
1263
1264 #[test]
1265 fn receiver_solution_validation_rejects_nonfinite_residuals() {
1266 let mut solution = valid_receiver_solution();
1267 solution.residuals_m[1] = f64::NAN;
1268 assert_eq!(
1269 validate_receiver_solution(&solution, SolutionValidationOptions::default()),
1270 Err(SolutionValidationError::InvalidResiduals)
1271 );
1272 }
1273}