Skip to main content

sidereon_core/
quality.rs

1//! Measurement-quality control for GNSS positioning.
2//!
3//! This module owns the language-independent RAIM/FDE decision logic and the
4//! standard pseudorange weighting primitives used by Sidereon' QC surface.
5
6use std::collections::{BTreeMap, BTreeSet};
7
8pub mod normality;
9
10pub use crate::araim::reliability::{
11    reliability_araim, reliability_design, wtest_noncentrality, wtest_noncentrality_components,
12    ObservationReliability, RangeReliabilityRow, ReliabilityOptions, ReliabilityReport,
13    ReliabilitySummary, WtestNoncentralityComponents,
14};
15
16use crate::astro::math::linear::{invert_symmetric_pd, normal_equations_weighted};
17use crate::constants::DEG_TO_RAD;
18use crate::spp::{
19    solve, EphemerisSource, Observation, ReceiverSolution, RobustConfig, SolveInputs, SppError,
20};
21use crate::validate;
22
23/// Default zenith-floor term for pseudorange variance, meters.
24pub const DEFAULT_VARIANCE_A_M: f64 = 0.3;
25/// Default elevation-scaled term for pseudorange variance, meters.
26pub const DEFAULT_VARIANCE_B_M: f64 = 0.3;
27/// Default false-alarm probability for RAIM.
28pub const DEFAULT_P_FA: f64 = 1.0e-3;
29
30/// Pseudorange variance model.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum PseudorangeVarianceModel {
33    /// Elevation-only `a^2 + b^2 / sin(el)^2`.
34    Elevation,
35    /// Elevation plus a C/N0 variance contribution.
36    ElevationCn0,
37}
38
39/// Options for [`pseudorange_variance`].
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct PseudorangeVarianceOptions {
42    /// Zenith-floor term, meters.
43    pub a_m: f64,
44    /// Elevation-scaled term, meters.
45    pub b_m: f64,
46    /// Selected variance model.
47    pub model: PseudorangeVarianceModel,
48    /// Carrier-to-noise density, dB-Hz, required by
49    /// [`PseudorangeVarianceModel::ElevationCn0`].
50    pub cn0_dbhz: Option<f64>,
51    /// C/N0 variance scale, square meters.
52    pub cn0_scale_m2: f64,
53}
54
55impl Default for PseudorangeVarianceOptions {
56    fn default() -> Self {
57        Self {
58            a_m: DEFAULT_VARIANCE_A_M,
59            b_m: DEFAULT_VARIANCE_B_M,
60            model: PseudorangeVarianceModel::Elevation,
61            cn0_dbhz: None,
62            cn0_scale_m2: 1.0,
63        }
64    }
65}
66
67impl PseudorangeVarianceOptions {
68    fn with_entry_cn0(self, cn0_dbhz: f64) -> Self {
69        Self {
70            model: PseudorangeVarianceModel::ElevationCn0,
71            cn0_dbhz: Some(cn0_dbhz),
72            ..self
73        }
74    }
75}
76
77/// One satellite/elevation entry used to build sigma or weight maps.
78#[derive(Debug, Clone, PartialEq)]
79pub struct WeightEntry {
80    /// Satellite token at the binding boundary, e.g. `"G01"`.
81    pub satellite_id: String,
82    /// Topocentric elevation, degrees.
83    pub elevation_deg: f64,
84    /// Optional C/N0 for this observation. When present, it selects the C/N0
85    /// model for this entry.
86    pub cn0_dbhz: Option<f64>,
87}
88
89/// Error from quality-control primitives.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum QualityError {
92    /// Elevation must be finite, inside `[-90, 90]`, and yield finite variance.
93    InvalidElevation,
94    /// The C/N0 model was selected without a C/N0 value.
95    MissingCn0,
96    /// Variance-model parameters must be finite and non-negative.
97    InvalidParameter,
98    /// Probability must be strictly inside `(0, 1)`.
99    InvalidProbability,
100    /// RAIM system-count override must be positive.
101    InvalidSystemCount,
102    /// Chi-square degrees of freedom must be positive.
103    InvalidDof,
104    /// RAIM weights must be positive finite values.
105    InvalidWeight,
106    /// Reliability parameter must be positive finite or inside its valid interval.
107    InvalidReliabilityParameter,
108    /// RAIM residuals must be finite and aligned with used satellites.
109    InvalidResiduals,
110    /// A linearized measurement set was empty, ragged, non-finite, or carried
111    /// fewer measurements than estimated state parameters.
112    InvalidDesign,
113    /// The weighted normal matrix `H^T W H` was singular or rank deficient, so
114    /// no protected state correction exists.
115    SingularGeometry,
116}
117
118impl core::fmt::Display for QualityError {
119    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
120        match self {
121            Self::InvalidElevation => write!(f, "invalid elevation"),
122            Self::MissingCn0 => write!(f, "missing C/N0"),
123            Self::InvalidParameter => write!(f, "invalid quality parameter"),
124            Self::InvalidProbability => write!(f, "invalid probability"),
125            Self::InvalidSystemCount => write!(f, "invalid RAIM system count"),
126            Self::InvalidDof => write!(f, "invalid degrees of freedom"),
127            Self::InvalidWeight => write!(f, "invalid RAIM weight"),
128            Self::InvalidReliabilityParameter => write!(f, "invalid reliability parameter"),
129            Self::InvalidResiduals => write!(f, "invalid RAIM residuals"),
130            Self::InvalidDesign => write!(f, "invalid linearized measurement design"),
131            Self::SingularGeometry => write!(f, "singular or rank-deficient geometry"),
132        }
133    }
134}
135
136impl std::error::Error for QualityError {}
137
138/// Pseudorange measurement variance, square meters.
139pub fn pseudorange_variance(
140    elevation_deg: f64,
141    options: PseudorangeVarianceOptions,
142) -> Result<f64, QualityError> {
143    validate_elevation_deg(elevation_deg)?;
144    validate_variance_options(options)?;
145
146    let mut elevation_var = options.a_m * options.a_m;
147    if options.b_m != 0.0 {
148        let sin_el = libm::sin(elevation_deg * DEG_TO_RAD);
149        let scaled = options.b_m * options.b_m / (sin_el * sin_el);
150        if !scaled.is_finite() {
151            return Err(QualityError::InvalidElevation);
152        }
153        elevation_var += scaled;
154    }
155
156    let variance = match options.model {
157        PseudorangeVarianceModel::Elevation => elevation_var,
158        PseudorangeVarianceModel::ElevationCn0 => {
159            let Some(cn0) = options.cn0_dbhz else {
160                return Err(QualityError::MissingCn0);
161            };
162            validate_nonneg_parameter(cn0, "cn0_dbhz")?;
163            elevation_var + options.cn0_scale_m2 * libm::pow(10.0_f64, -cn0 / 10.0)
164        }
165    };
166
167    validate_positive_variance(variance)?;
168    Ok(variance)
169}
170
171fn validate_elevation_deg(elevation_deg: f64) -> Result<(), QualityError> {
172    validate::finite(elevation_deg, "elevation_deg").map_err(|_| QualityError::InvalidElevation)?;
173    if (-90.0..=90.0).contains(&elevation_deg) {
174        Ok(())
175    } else {
176        Err(QualityError::InvalidElevation)
177    }
178}
179
180fn validate_variance_options(options: PseudorangeVarianceOptions) -> Result<(), QualityError> {
181    validate_nonneg_parameter(options.a_m, "variance a_m")?;
182    validate_nonneg_parameter(options.b_m, "variance b_m")?;
183    validate_nonneg_parameter(options.cn0_scale_m2, "variance cn0_scale_m2")
184}
185
186fn validate_nonneg_parameter(value: f64, field: &'static str) -> Result<(), QualityError> {
187    validate::finite_nonneg(value, field)
188        .map(|_| ())
189        .map_err(map_parameter_error)
190}
191
192fn validate_positive_variance(value: f64) -> Result<(), QualityError> {
193    validate::finite_positive(value, "pseudorange variance")
194        .map(|_| ())
195        .map_err(map_parameter_error)
196}
197
198fn map_parameter_error(_error: validate::FieldError) -> QualityError {
199    QualityError::InvalidParameter
200}
201
202/// Build a satellite-to-sigma map. Entries whose variance cannot be computed are
203/// dropped, matching the Sidereon public API.
204pub fn sigmas(
205    entries: &[WeightEntry],
206    options: PseudorangeVarianceOptions,
207) -> BTreeMap<String, f64> {
208    entries
209        .iter()
210        .filter_map(|entry| {
211            let opts = match entry.cn0_dbhz {
212                Some(cn0) => options.with_entry_cn0(cn0),
213                None => options,
214            };
215            pseudorange_variance(entry.elevation_deg, opts)
216                .ok()
217                .map(|var| (entry.satellite_id.clone(), var.sqrt()))
218        })
219        .collect()
220}
221
222/// Build a satellite-to-inverse-variance-weight map. Entries whose variance
223/// cannot be computed are dropped, matching the Sidereon public API.
224pub fn weight_vector(
225    entries: &[WeightEntry],
226    options: PseudorangeVarianceOptions,
227) -> BTreeMap<String, f64> {
228    entries
229        .iter()
230        .filter_map(|entry| {
231            let opts = match entry.cn0_dbhz {
232                Some(cn0) => options.with_entry_cn0(cn0),
233                None => options,
234            };
235            pseudorange_variance(entry.elevation_deg, opts)
236                .ok()
237                .map(|var| (entry.satellite_id.clone(), 1.0 / var))
238        })
239        .collect()
240}
241
242/// RAIM weighting mode.
243#[derive(Debug, Clone, PartialEq)]
244pub enum RaimWeights {
245    /// Unit weights, equivalent to sigma = 1 m for every satellite.
246    Unit,
247    /// Per-satellite inverse variance weights. Missing satellites default to
248    /// unit weight.
249    BySatellite(BTreeMap<String, f64>),
250}
251
252impl RaimWeights {
253    fn validate(&self) -> Result<(), QualityError> {
254        match self {
255            Self::Unit => Ok(()),
256            Self::BySatellite(weights) => weights
257                .values()
258                .try_for_each(|w| validate::finite_positive(*w, "raim weight").map(|_| ()))
259                .map_err(|_| QualityError::InvalidWeight),
260        }
261    }
262
263    fn weight_for(&self, satellite_id: &str) -> f64 {
264        match self {
265            Self::Unit => 1.0,
266            Self::BySatellite(weights) => weights.get(satellite_id).copied().unwrap_or(1.0),
267        }
268    }
269}
270
271/// Options for [`raim`].
272#[derive(Debug, Clone, PartialEq)]
273pub struct RaimOptions {
274    /// False-alarm probability.
275    pub p_fa: f64,
276    /// RAIM residual weights.
277    pub weights: RaimWeights,
278    /// Optional override for the number of distinct GNSS clock systems.
279    pub n_systems: Option<isize>,
280}
281
282impl Default for RaimOptions {
283    fn default() -> Self {
284        Self {
285            p_fa: DEFAULT_P_FA,
286            weights: RaimWeights::Unit,
287            n_systems: None,
288        }
289    }
290}
291
292/// Minimal solution view needed by RAIM.
293#[derive(Debug, Clone, PartialEq)]
294pub struct RaimInput {
295    /// Used satellite tokens, in residual order.
296    pub used_sats: Vec<String>,
297    /// Post-fit pseudorange residuals, meters.
298    pub residuals_m: Vec<f64>,
299}
300
301/// A solution that can feed the RAIM test.
302pub trait RaimSolution {
303    /// Used satellite tokens, in residual order.
304    fn raim_used_sats(&self) -> Vec<String>;
305    /// Post-fit residuals, meters, in used-satellite order.
306    fn raim_residuals_m(&self) -> &[f64];
307}
308
309impl RaimSolution for ReceiverSolution {
310    fn raim_used_sats(&self) -> Vec<String> {
311        self.used_sats.iter().map(ToString::to_string).collect()
312    }
313
314    fn raim_residuals_m(&self) -> &[f64] {
315        &self.residuals_m
316    }
317}
318
319/// Result of a residual chi-square RAIM test.
320#[derive(Debug, Clone, PartialEq)]
321pub struct RaimResult {
322    /// True when the test statistic exceeds the chi-square threshold.
323    pub fault_detected: bool,
324    /// Weighted residual sum of squares.
325    pub test_statistic: f64,
326    /// Chi-square threshold, absent when the geometry is not testable.
327    pub threshold: Option<f64>,
328    /// Degrees of freedom, `n_used - (3 + n_systems)`.
329    pub dof: isize,
330    /// False when `dof <= 0`.
331    pub testable: bool,
332    /// Per-satellite standardized residuals.
333    pub normalized_residuals: BTreeMap<String, f64>,
334    /// Satellite with the largest absolute standardized residual.
335    pub worst_sat: Option<String>,
336}
337
338/// Standalone post-fit residual diagnostics.
339#[derive(Debug, Clone, PartialEq)]
340pub struct ResidualDiagnostics {
341    /// Number of residuals.
342    pub n_residuals: usize,
343    /// Number of fitted parameters used to compute redundancy.
344    pub n_parameters: usize,
345    /// Redundancy / degrees of freedom: `n_residuals - n_parameters`.
346    pub degrees_of_freedom: isize,
347    /// Weighted residual sum of squares.
348    pub weighted_sum_squares: f64,
349    /// Root-mean-square residual in metres, unweighted.
350    pub rms_m: f64,
351    /// Residuals scaled by `sqrt(weight)`; unit weights when no weights are given.
352    pub normalized_residuals: Vec<f64>,
353    /// Index of the largest absolute normalized residual.
354    pub worst_index: Option<usize>,
355    /// Reduced chi-square, `weighted_sum_squares / degrees_of_freedom`, when
356    /// degrees of freedom are positive.
357    pub reduced_chi_square: Option<f64>,
358    /// Chi-square threshold for the requested false-alarm probability, when
359    /// requested and degrees of freedom are positive.
360    pub chi_square_threshold: Option<f64>,
361    /// Whether `weighted_sum_squares <= chi_square_threshold`, when a threshold
362    /// was requested and degrees of freedom are positive.
363    pub chi_square_consistent: Option<bool>,
364}
365
366/// Post-fit residual diagnostics from residuals and optional inverse-variance
367/// weights.
368///
369/// `n_parameters` is the number of estimated state parameters in the fit that
370/// produced `residuals_m`. `p_fa`, when supplied, requests a global chi-square
371/// consistency threshold at probability `1 - p_fa`.
372pub fn residual_diagnostics(
373    residuals_m: &[f64],
374    weights: Option<&[f64]>,
375    n_parameters: usize,
376    p_fa: Option<f64>,
377) -> Result<ResidualDiagnostics, QualityError> {
378    validate::finite_slice(residuals_m, "diagnostic residuals")
379        .map_err(|_| QualityError::InvalidResiduals)?;
380    let weights = match weights {
381        Some(weights) => {
382            if weights.len() != residuals_m.len() {
383                return Err(QualityError::InvalidWeight);
384            }
385            validate_weights_slice(weights)?;
386            Some(weights)
387        }
388        None => None,
389    };
390    if let Some(p_fa) = p_fa {
391        validate_probability(p_fa)?;
392    }
393
394    let degrees_of_freedom = residuals_m.len() as isize - n_parameters as isize;
395    let mut weighted_sum_squares = 0.0;
396    let mut normalized_residuals = Vec::with_capacity(residuals_m.len());
397    let mut worst_index = None;
398    let mut worst_abs = f64::NEG_INFINITY;
399    for (idx, residual_m) in residuals_m.iter().enumerate() {
400        let weight = weights.map(|w| w[idx]).unwrap_or(1.0);
401        let normalized = residual_m * weight.sqrt();
402        weighted_sum_squares += residual_m * residual_m * weight;
403        normalized_residuals.push(normalized);
404        let abs_normalized = normalized.abs();
405        if abs_normalized > worst_abs {
406            worst_abs = abs_normalized;
407            worst_index = Some(idx);
408        }
409    }
410
411    let rms_m = residual_rms(residuals_m);
412    let reduced_chi_square = if degrees_of_freedom > 0 {
413        Some(weighted_sum_squares / degrees_of_freedom as f64)
414    } else {
415        None
416    };
417    let chi_square_threshold = match (p_fa, degrees_of_freedom > 0) {
418        (Some(p_fa), true) => Some(chi2_inv(1.0 - p_fa, degrees_of_freedom as usize)?),
419        _ => None,
420    };
421    let chi_square_consistent =
422        chi_square_threshold.map(|threshold| weighted_sum_squares <= threshold);
423
424    Ok(ResidualDiagnostics {
425        n_residuals: residuals_m.len(),
426        n_parameters,
427        degrees_of_freedom,
428        weighted_sum_squares,
429        rms_m,
430        normalized_residuals,
431        worst_index,
432        reduced_chi_square,
433        chi_square_threshold,
434        chi_square_consistent,
435    })
436}
437
438/// Run RAIM over a generic solution.
439pub fn raim_for_solution<S: RaimSolution>(
440    solution: &S,
441    options: &RaimOptions,
442) -> Result<RaimResult, QualityError> {
443    raim(
444        &RaimInput {
445            used_sats: solution.raim_used_sats(),
446            residuals_m: solution.raim_residuals_m().to_vec(),
447        },
448        options,
449    )
450}
451
452/// Residual-based chi-square RAIM.
453pub fn raim(input: &RaimInput, options: &RaimOptions) -> Result<RaimResult, QualityError> {
454    validate_probability(options.p_fa)?;
455    options.weights.validate()?;
456    validate_raim_input(input)?;
457
458    let n_used = input.used_sats.len() as isize;
459    let n_systems = raim_system_count(input, options)?;
460    let dof = n_used - (3 + n_systems);
461
462    let mut test_statistic = 0.0;
463    let mut normalized_residuals = BTreeMap::new();
464    let mut worst_sat = None::<String>;
465    let mut worst_abs = f64::NEG_INFINITY;
466
467    for (satellite_id, residual_m) in input.used_sats.iter().zip(input.residuals_m.iter()) {
468        let weight = options.weights.weight_for(satellite_id);
469        let normalized = residual_m * weight.sqrt();
470        test_statistic += residual_m * residual_m * weight;
471        normalized_residuals.insert(satellite_id.clone(), normalized);
472        let abs_normalized = normalized.abs();
473        if abs_normalized > worst_abs {
474            worst_abs = abs_normalized;
475            worst_sat = Some(satellite_id.clone());
476        }
477    }
478
479    if dof <= 0 {
480        return Ok(RaimResult {
481            fault_detected: false,
482            test_statistic,
483            threshold: None,
484            dof,
485            testable: false,
486            normalized_residuals,
487            worst_sat,
488        });
489    }
490
491    let threshold = chi2_inv(1.0 - options.p_fa, dof as usize)?;
492    Ok(RaimResult {
493        fault_detected: test_statistic > threshold,
494        test_statistic,
495        threshold: Some(threshold),
496        dof,
497        testable: true,
498        normalized_residuals,
499        worst_sat,
500    })
501}
502
503fn validate_probability(p: f64) -> Result<(), QualityError> {
504    let p = validate::finite(p, "probability").map_err(|_| QualityError::InvalidProbability)?;
505    if p > 0.0 && p < 1.0 {
506        Ok(())
507    } else {
508        Err(QualityError::InvalidProbability)
509    }
510}
511
512fn validate_raim_input(input: &RaimInput) -> Result<(), QualityError> {
513    if input.used_sats.len() != input.residuals_m.len() {
514        return Err(QualityError::InvalidResiduals);
515    }
516    validate::finite_slice(&input.residuals_m, "raim residuals")
517        .map_err(|_| QualityError::InvalidResiduals)
518}
519
520fn validate_weights_slice(weights: &[f64]) -> Result<(), QualityError> {
521    weights
522        .iter()
523        .try_for_each(|w| validate::finite_positive(*w, "diagnostic weight").map(|_| ()))
524        .map_err(|_| QualityError::InvalidWeight)
525}
526
527fn raim_system_count(input: &RaimInput, options: &RaimOptions) -> Result<isize, QualityError> {
528    match options.n_systems {
529        Some(n_systems) if n_systems >= 1 => Ok(n_systems),
530        Some(_) => Err(QualityError::InvalidSystemCount),
531        None => Ok(distinct_systems(&input.used_sats)),
532    }
533}
534
535fn distinct_systems(used_sats: &[String]) -> isize {
536    used_sats
537        .iter()
538        .filter_map(|sat| sat.chars().next())
539        .collect::<BTreeSet<_>>()
540        .len() as isize
541}
542
543/// Result of a fault-detection-and-exclusion loop.
544#[derive(Debug, Clone, PartialEq)]
545pub struct FdeResult<S> {
546    /// Final accepted solution.
547    pub solution: S,
548    /// Excluded satellites in exclusion order.
549    pub excluded: Vec<String>,
550    /// Number of exclusions performed.
551    pub iterations: usize,
552}
553
554/// Error from [`fde`].
555#[derive(Debug, Clone, PartialEq)]
556pub enum FdeError<E> {
557    /// RAIM still flagged the set when the exclusion budget was exhausted.
558    FaultUnresolved(f64),
559    /// The supplied solve callback failed.
560    Solve(E),
561    /// RAIM configuration was invalid.
562    Raim(QualityError),
563}
564
565/// Options for [`fde`].
566#[derive(Debug, Clone, PartialEq)]
567pub struct FdeOptions {
568    /// RAIM options used after each solve.
569    pub raim: RaimOptions,
570    /// Maximum number of exclusions to attempt.
571    pub max_iterations: usize,
572}
573
574/// Fault detection and exclusion over a caller-supplied SPP solver.
575pub fn fde<S, E, F>(
576    observations: &[Observation],
577    options: &FdeOptions,
578    mut solve: F,
579) -> Result<FdeResult<S>, FdeError<E>>
580where
581    S: RaimSolution,
582    F: FnMut(&[Observation]) -> Result<S, E>,
583{
584    let mut remaining = observations.to_vec();
585    let mut excluded = Vec::new();
586    let mut iter = 0usize;
587
588    loop {
589        let solution = solve(&remaining).map_err(FdeError::Solve)?;
590        let result = raim_for_solution(&solution, &options.raim).map_err(FdeError::Raim)?;
591
592        if !result.fault_detected {
593            return Ok(FdeResult {
594                solution,
595                excluded,
596                iterations: iter,
597            });
598        }
599
600        let Some(worst) = result.worst_sat else {
601            return Err(FdeError::FaultUnresolved(result.test_statistic));
602        };
603
604        if iter >= options.max_iterations {
605            return Err(FdeError::FaultUnresolved(result.test_statistic));
606        }
607
608        remaining.retain(|ob| ob.satellite_id.to_string() != worst);
609        excluded.push(worst);
610        iter += 1;
611    }
612}
613
614// --- single-point-positioning FDE driver ----------------------------------
615
616/// Per-iteration failure carried out of the [`fde_spp`] solve closure: either
617/// the SPP [`solve`] failed for the current observation set, or the converged
618/// candidate failed [`validate_receiver_solution`].
619#[derive(Debug, Clone)]
620pub enum FdeSppError {
621    /// The SPP solve failed for the current observation set.
622    Spp(SppError),
623    /// The converged candidate failed solution validation.
624    Validation(SolutionValidationError),
625}
626
627impl core::fmt::Display for FdeSppError {
628    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
629        match self {
630            Self::Spp(err) => write!(f, "SPP solve failed: {err}"),
631            Self::Validation(err) => write!(f, "solution validation failed: {err}"),
632        }
633    }
634}
635
636impl std::error::Error for FdeSppError {}
637
638/// Options for [`fde_spp`]: the RAIM-gated exclusion loop plus the per-iteration
639/// solution-validation gates applied to each candidate solve.
640#[derive(Debug, Clone, PartialEq)]
641pub struct FdeSppOptions {
642    /// FDE loop options: the RAIM configuration and the exclusion budget.
643    pub fde: FdeOptions,
644    /// Per-iteration solution-validation gates (PDOP ceiling and plausibility
645    /// band) applied to each candidate solution.
646    pub validation: SolutionValidationOptions,
647}
648
649/// Run single-point positioning with RAIM fault detection and exclusion.
650///
651/// Solves [`solve`] over the input observation set, applies residual chi-square
652/// RAIM via [`fde`], and on a detected fault excludes the worst satellite and
653/// re-solves, repeating until the set is self-consistent or the exclusion budget
654/// in [`FdeSppOptions::fde`] is exhausted. Every candidate solution is screened
655/// with [`validate_receiver_solution`] using [`FdeSppOptions::validation`]. On
656/// success returns the protected [`FdeResult`]: the surviving
657/// [`ReceiverSolution`], the excluded satellite tokens in exclusion order, and
658/// the exclusion count.
659///
660/// This is the single core driver the language bindings reduce to. It chains the
661/// existing [`solve`], [`validate_receiver_solution`], and [`fde`] primitives and
662/// adds no detection, exclusion, or solve math of its own, so it is bit-for-bit
663/// identical to assembling that loop by hand around the same primitives.
664pub fn fde_spp(
665    eph: &dyn EphemerisSource,
666    inputs: &SolveInputs,
667    with_geodetic: bool,
668    options: &FdeSppOptions,
669) -> Result<FdeResult<ReceiverSolution>, FdeError<FdeSppError>> {
670    let observations = inputs.observations.clone();
671    fde(&observations, &options.fde, |remaining| {
672        let mut next = inputs.clone();
673        next.observations = remaining.to_vec();
674        let solution = solve(eph, &next, with_geodetic).map_err(FdeSppError::Spp)?;
675        validate_receiver_solution(&solution, options.validation)
676            .map_err(FdeSppError::Validation)?;
677        Ok(solution)
678    })
679}
680
681/// Run robust-reweighted SPP under the RAIM/FDE exclusion loop.
682///
683/// This is the robust-specific composition of [`RobustConfig`] and [`fde_spp`].
684/// It clones the inputs, installs `robust`, then delegates to [`fde_spp`], which
685/// delegates each candidate solve to [`solve`] and each exclusion step to
686/// [`fde`].
687pub fn spp_robust_fde_driver(
688    eph: &dyn EphemerisSource,
689    inputs: &SolveInputs,
690    with_geodetic: bool,
691    robust: RobustConfig,
692    options: &FdeSppOptions,
693) -> Result<FdeResult<ReceiverSolution>, FdeError<FdeSppError>> {
694    let mut robust_inputs = inputs.clone();
695    robust_inputs.robust = Some(robust);
696    fde_spp(eph, &robust_inputs, with_geodetic, options)
697}
698
699// --- generic range RAIM/FDE over a linearized measurement set -------------
700
701/// One linearized range measurement for [`raim_fde_design`].
702///
703/// The set `{ (design_row, residual_m, weight) }` is a single linearization of a
704/// range solve about a nominal state: `residual_m` is the observed-minus-computed
705/// range, `design_row` is that measurement's row of the design (geometry) matrix
706/// `H` (the partials of the predicted range with respect to the estimated state),
707/// and `weight` is the measurement's inverse-variance weight `1 / sigma^2`. Every
708/// row must carry the same `design_row` length, which is the number of estimated
709/// state parameters.
710#[derive(Debug, Clone, PartialEq)]
711pub struct RangeFdeRow {
712    /// Stable measurement identifier, e.g. a satellite token `"G01"`.
713    pub id: String,
714    /// Observed-minus-computed range residual, metres.
715    pub residual_m: f64,
716    /// Design-matrix row: partials of the predicted range with respect to each
717    /// estimated state parameter. Length equals the state dimension.
718    pub design_row: Vec<f64>,
719    /// Inverse-variance weight `1 / sigma^2`, square metres reciprocal. Must be
720    /// finite and strictly positive.
721    pub weight: f64,
722}
723
724/// Options for [`raim_fde_design`].
725#[derive(Debug, Clone, Copy, PartialEq)]
726pub struct RangeFdeOptions {
727    /// False-alarm probability for the global chi-square test. The detection
728    /// threshold is the `1 - p_fa` chi-square quantile at the redundancy
729    /// (degrees of freedom). RTKLIB demo5 uses `p_fa = 1.0e-3`.
730    pub p_fa: f64,
731    /// Maximum number of measurements the exclusion loop may remove.
732    pub max_exclusions: usize,
733    /// Minimum redundancy (degrees of freedom) that an exclusion must leave
734    /// behind. An exclusion is only attempted when the surviving set still has
735    /// at least `min_redundancy` more measurements than state parameters, so the
736    /// protected set stays testable. RTKLIB demo5's `nvsat >= 5` floor for a
737    /// four-state solve is `min_redundancy == 1`.
738    pub min_redundancy: usize,
739}
740
741impl Default for RangeFdeOptions {
742    fn default() -> Self {
743        Self {
744            p_fa: DEFAULT_P_FA,
745            max_exclusions: usize::MAX,
746            min_redundancy: 1,
747        }
748    }
749}
750
751/// Global chi-square consistency test over a protected measurement set.
752#[derive(Debug, Clone, Copy, PartialEq)]
753pub struct RangeChiSquareTest {
754    /// Weighted sum of squared post-fit residuals, `v^T W v`.
755    pub weighted_sum_squares: f64,
756    /// Redundancy: `n_used - n_state`.
757    pub dof: isize,
758    /// Chi-square threshold `chi2_inv(1 - p_fa, dof)`, absent when `dof <= 0`.
759    pub threshold: Option<f64>,
760    /// False when `dof <= 0` (no redundancy to test against).
761    pub testable: bool,
762    /// True when the test statistic exceeds the threshold (a fault remains).
763    pub fault_detected: bool,
764}
765
766/// Per-measurement diagnostics, in the caller's input order.
767#[derive(Debug, Clone, PartialEq)]
768pub struct RangeMeasurementDiagnostic {
769    /// Measurement identifier, echoed from the input row.
770    pub id: String,
771    /// Whether the FDE loop excluded this measurement from the protected solve.
772    pub excluded: bool,
773    /// Post-fit residual against the protected state correction, metres
774    /// (`residual_m - design_row . dx`). Computed for every input row, including
775    /// excluded ones, so a true outlier shows a large value here.
776    pub post_fit_residual_m: f64,
777    /// Standardized post-fit residual `post_fit_residual_m * sqrt(weight)`.
778    pub normalized_residual: f64,
779}
780
781/// Result of [`raim_fde_design`].
782#[derive(Debug, Clone, PartialEq)]
783pub struct RangeFdeResult {
784    /// Protected weighted-least-squares state correction `dx`, length `n_state`.
785    pub state_correction: Vec<f64>,
786    /// Protected state covariance `(H^T W H)^-1` for the accepted set.
787    pub state_covariance: Vec<Vec<f64>>,
788    /// Global chi-square consistency test for the accepted set.
789    pub global_test: RangeChiSquareTest,
790    /// Excluded measurement identifiers, in exclusion order.
791    pub excluded: Vec<String>,
792    /// Per-measurement diagnostics, in input order.
793    pub diagnostics: Vec<RangeMeasurementDiagnostic>,
794    /// Number of exclusions performed.
795    pub iterations: usize,
796}
797
798/// A weighted-least-squares fit of a linearized range set.
799struct WlsFit {
800    dx: Vec<f64>,
801    covariance: Vec<Vec<f64>>,
802}
803
804/// Standalone, composable range RAIM/FDE over a generic linearized measurement
805/// set, independent of any full positioning solve.
806///
807/// Given rows `{ (design_row, residual_m, weight) }` that linearize a range solve
808/// about a nominal state, this solves the protected weighted least squares
809/// `dx = (H^T W H)^-1 H^T W r` with covariance `(H^T W H)^-1`, runs the global
810/// chi-square consistency test, and, when a fault is detected, runs the fault
811/// detection and exclusion (FDE) loop.
812///
813/// # Algorithm
814///
815/// 1. Weighted least squares on the active set yields `dx`, the covariance, and
816///    post-fit residuals `v = r - H dx`. The test statistic is the weighted sum
817///    of squares `WSSR = v^T W v = sum_k w_k v_k^2`.
818/// 2. Global chi-square test: with redundancy `dof = n_used - n_state`, a fault
819///    is declared when `WSSR > chi2_inv(1 - p_fa, dof)`. This is the standard
820///    snapshot residual-based RAIM test and matches RTKLIB demo5's `valsol`
821///    chi-square gate (`pntpos.c`).
822/// 3. FDE exclusion loop (the RTKLIB demo5 `raim_fde` leave-one-out pattern,
823///    `pntpos.c`): while a fault is detected and the exclusion budget and
824///    redundancy floor allow, each active measurement is removed in turn, the set
825///    is re-solved, and the candidate whose removal yields the smallest reduced
826///    weighted post-fit residual RMS is excluded. The loop repeats so multiple
827///    outliers can be removed, stopping when the test passes, the budget is
828///    exhausted, or no further exclusion keeps the set testable.
829///
830/// The returned [`RangeChiSquareTest`] reports whether a fault still remains
831/// after the loop, so a caller can detect an unresolved fault without an error
832/// path. An error is returned only when the input is malformed or the initial
833/// geometry is rank deficient.
834///
835/// # References
836///
837/// - RTKLIB demo5, `pntpos.c` (`valsol` chi-square residual gate and `raim_fde`
838///   leave-one-out exclusion) and `rtkcmn.c` (`chisqr` table, `alpha = 0.001`).
839/// - Parkinson & Spilker, *Global Positioning System: Theory and Applications*,
840///   Vol. II, Ch. 5 (RAIM, integrity monitoring).
841/// - Kaplan & Hegarty, *Understanding GPS/GNSS: Principles and Applications*,
842///   3rd ed., receiver-autonomous-integrity-monitoring section.
843pub fn raim_fde_design(
844    rows: &[RangeFdeRow],
845    options: &RangeFdeOptions,
846) -> Result<RangeFdeResult, QualityError> {
847    validate_probability(options.p_fa)?;
848    let n_state = validate_range_rows(rows)?;
849
850    let mut active: Vec<usize> = (0..rows.len()).collect();
851    let mut excluded: Vec<String> = Vec::new();
852    let mut iterations = 0usize;
853
854    let mut fit = solve_range_wls(rows, &active, n_state)?;
855    loop {
856        let test = range_chi_square_test(rows, &active, &fit, n_state, options.p_fa)?;
857
858        if !test.fault_detected || excluded.len() >= options.max_exclusions {
859            return Ok(finish_range_fde(
860                rows, &active, &excluded, fit, test, iterations,
861            ));
862        }
863
864        // Leave-one-out: pick the exclusion that minimises the reduced weighted
865        // post-fit residual RMS while keeping the surviving set testable.
866        let Some((slot, candidate_fit)) =
867            best_range_exclusion(rows, &active, n_state, options.min_redundancy)
868        else {
869            return Ok(finish_range_fde(
870                rows, &active, &excluded, fit, test, iterations,
871            ));
872        };
873
874        excluded.push(rows[active[slot]].id.clone());
875        active.remove(slot);
876        fit = candidate_fit;
877        iterations += 1;
878    }
879}
880
881fn finish_range_fde(
882    rows: &[RangeFdeRow],
883    active: &[usize],
884    excluded: &[String],
885    fit: WlsFit,
886    test: RangeChiSquareTest,
887    iterations: usize,
888) -> RangeFdeResult {
889    let active_set: BTreeSet<usize> = active.iter().copied().collect();
890    let diagnostics = rows
891        .iter()
892        .enumerate()
893        .map(|(idx, row)| {
894            let post_fit = row.residual_m - dot(&row.design_row, &fit.dx);
895            RangeMeasurementDiagnostic {
896                id: row.id.clone(),
897                excluded: !active_set.contains(&idx),
898                post_fit_residual_m: post_fit,
899                normalized_residual: post_fit * row.weight.sqrt(),
900            }
901        })
902        .collect();
903
904    RangeFdeResult {
905        state_correction: fit.dx,
906        state_covariance: fit.covariance,
907        global_test: test,
908        excluded: excluded.to_vec(),
909        diagnostics,
910        iterations,
911    }
912}
913
914/// Find the single exclusion that minimises the reduced weighted post-fit RMS.
915/// Returns the slot (index into `active`) and the re-solved fit, or `None` when
916/// no exclusion keeps the surviving set solvable and testable.
917fn best_range_exclusion(
918    rows: &[RangeFdeRow],
919    active: &[usize],
920    n_state: usize,
921    min_redundancy: usize,
922) -> Option<(usize, WlsFit)> {
923    // The surviving set must keep at least `min_redundancy` redundancy.
924    if active.len() < n_state + min_redundancy + 1 {
925        return None;
926    }
927
928    let mut best: Option<(usize, WlsFit, f64)> = None;
929    let mut remaining: Vec<usize> = Vec::with_capacity(active.len() - 1);
930    for slot in 0..active.len() {
931        remaining.clear();
932        remaining.extend(active.iter().enumerate().filter_map(|(s, &idx)| {
933            if s == slot {
934                None
935            } else {
936                Some(idx)
937            }
938        }));
939
940        let Ok(candidate) = solve_range_wls(rows, &remaining, n_state) else {
941            continue;
942        };
943        let rms = reduced_weighted_rms(rows, &remaining, &candidate);
944
945        let better = match &best {
946            Some((_, _, best_rms)) => rms < *best_rms,
947            None => true,
948        };
949        if better {
950            best = Some((slot, candidate, rms));
951        }
952    }
953
954    best.map(|(slot, fit, _)| (slot, fit))
955}
956
957/// Reduced weighted post-fit residual RMS, `sqrt(WSSR / n)`. This is the RTKLIB
958/// demo5 `raim_fde` selection statistic in standardized (weighted) form.
959fn reduced_weighted_rms(rows: &[RangeFdeRow], active: &[usize], fit: &WlsFit) -> f64 {
960    if active.is_empty() {
961        return 0.0;
962    }
963    let mut wss = 0.0;
964    for &idx in active {
965        let row = &rows[idx];
966        let v = row.residual_m - dot(&row.design_row, &fit.dx);
967        wss += row.weight * v * v;
968    }
969    (wss / active.len() as f64).sqrt()
970}
971
972fn range_chi_square_test(
973    rows: &[RangeFdeRow],
974    active: &[usize],
975    fit: &WlsFit,
976    n_state: usize,
977    p_fa: f64,
978) -> Result<RangeChiSquareTest, QualityError> {
979    let mut weighted_sum_squares = 0.0;
980    for &idx in active {
981        let row = &rows[idx];
982        let v = row.residual_m - dot(&row.design_row, &fit.dx);
983        weighted_sum_squares += row.weight * v * v;
984    }
985
986    let dof = active.len() as isize - n_state as isize;
987    if dof <= 0 {
988        return Ok(RangeChiSquareTest {
989            weighted_sum_squares,
990            dof,
991            threshold: None,
992            testable: false,
993            fault_detected: false,
994        });
995    }
996
997    let threshold = chi2_inv(1.0 - p_fa, dof as usize)?;
998    Ok(RangeChiSquareTest {
999        weighted_sum_squares,
1000        dof,
1001        threshold: Some(threshold),
1002        testable: true,
1003        fault_detected: weighted_sum_squares > threshold,
1004    })
1005}
1006
1007/// Solve the protected weighted least squares over the active rows.
1008///
1009/// Reuses the shared weighted normal-equation accumulator and symmetric
1010/// positive-definite inverse: the row weight handed to
1011/// [`normal_equations_weighted`] is `sqrt(weight)`, so the normal matrix is
1012/// exactly `H^T W H` and the right-hand side `H^T W r`.
1013fn solve_range_wls(
1014    rows: &[RangeFdeRow],
1015    active: &[usize],
1016    n_state: usize,
1017) -> Result<WlsFit, QualityError> {
1018    let (ata, aty) = normal_equations_weighted(
1019        active.iter().map(|&idx| {
1020            let row = &rows[idx];
1021            (row.design_row.as_slice(), row.residual_m, row.weight.sqrt())
1022        }),
1023        n_state,
1024    )
1025    .ok_or(QualityError::InvalidDesign)?;
1026
1027    let covariance = invert_symmetric_pd(&ata).ok_or(QualityError::SingularGeometry)?;
1028    let dx = (0..n_state)
1029        .map(|i| (0..n_state).map(|j| covariance[i][j] * aty[j]).sum())
1030        .collect();
1031    Ok(WlsFit { dx, covariance })
1032}
1033
1034fn dot(a: &[f64], b: &[f64]) -> f64 {
1035    a.iter().zip(b).map(|(x, y)| x * y).sum()
1036}
1037
1038fn validate_range_rows(rows: &[RangeFdeRow]) -> Result<usize, QualityError> {
1039    let first = rows.first().ok_or(QualityError::InvalidDesign)?;
1040    let n_state = first.design_row.len();
1041    if n_state == 0 || rows.len() < n_state {
1042        return Err(QualityError::InvalidDesign);
1043    }
1044    for row in rows {
1045        if row.design_row.len() != n_state {
1046            return Err(QualityError::InvalidDesign);
1047        }
1048        validate::finite_slice(&row.design_row, "design row")
1049            .map_err(|_| QualityError::InvalidDesign)?;
1050        validate::finite(row.residual_m, "design residual")
1051            .map_err(|_| QualityError::InvalidResiduals)?;
1052        validate::finite_positive(row.weight, "design weight")
1053            .map_err(|_| QualityError::InvalidWeight)?;
1054    }
1055    Ok(n_state)
1056}
1057
1058/// Validation policy for receiver solutions returned by SPP.
1059#[derive(Debug, Clone, Copy, PartialEq)]
1060pub struct SolutionValidationOptions {
1061    /// Optional PDOP ceiling.
1062    pub max_pdop: Option<f64>,
1063    /// Minimum plausible geocentric radius, meters.
1064    pub min_plausible_radius_m: f64,
1065    /// Maximum plausible geocentric radius, meters.
1066    pub max_plausible_radius_m: f64,
1067    /// Maximum plausible RMS for a solution flagged converged, meters.
1068    pub max_converged_residual_rms_m: f64,
1069}
1070
1071impl Default for SolutionValidationOptions {
1072    fn default() -> Self {
1073        Self {
1074            max_pdop: None,
1075            min_plausible_radius_m: 6_344_752.0,
1076            max_plausible_radius_m: 8_378_137.0,
1077            max_converged_residual_rms_m: 1.0e4,
1078        }
1079    }
1080}
1081
1082/// Error from [`validate_receiver_solution`].
1083#[derive(Debug, Clone, Copy, PartialEq)]
1084pub enum SolutionValidationError {
1085    /// Validation gate options were malformed or degenerate.
1086    InvalidOptions {
1087        /// The invalid option field.
1088        field: &'static str,
1089        /// The validation failure category.
1090        reason: &'static str,
1091    },
1092    /// DOP could not be computed because the geometry was rank deficient.
1093    DegenerateGeometryRankDeficient,
1094    /// PDOP exceeded the caller's configured ceiling.
1095    DegenerateGeometryPdop(f64),
1096    /// Position geocentric radius was outside the physical receiver band.
1097    ImplausiblePosition(f64),
1098    /// Converged solution residuals were non-finite or produced non-finite RMS.
1099    InvalidResiduals,
1100    /// Converged solution had physically implausible post-fit residual RMS.
1101    NoConvergence(f64),
1102}
1103
1104impl core::fmt::Display for SolutionValidationError {
1105    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1106        match self {
1107            Self::InvalidOptions { field, reason } => {
1108                write!(f, "invalid receiver validation option {field}: {reason}")
1109            }
1110            Self::DegenerateGeometryRankDeficient => {
1111                write!(f, "receiver geometry is rank deficient")
1112            }
1113            Self::DegenerateGeometryPdop(pdop) => {
1114                write!(
1115                    f,
1116                    "receiver geometry PDOP {pdop} exceeds the configured limit"
1117                )
1118            }
1119            Self::ImplausiblePosition(radius_m) => write!(
1120                f,
1121                "receiver geocentric radius {radius_m} m is outside the plausible range"
1122            ),
1123            Self::InvalidResiduals => {
1124                write!(f, "converged solution residuals must be finite")
1125            }
1126            Self::NoConvergence(rms_m) => write!(
1127                f,
1128                "converged solution residual RMS {rms_m} m is implausibly large"
1129            ),
1130        }
1131    }
1132}
1133
1134impl std::error::Error for SolutionValidationError {}
1135
1136/// Apply the receiver-solution plausibility gates used by the Sidereon SPP API.
1137pub fn validate_receiver_solution(
1138    solution: &ReceiverSolution,
1139    options: SolutionValidationOptions,
1140) -> Result<(), SolutionValidationError> {
1141    validate_solution_validation_options(options)?;
1142
1143    let Some(dop) = solution.dop.as_ref() else {
1144        return Err(SolutionValidationError::DegenerateGeometryRankDeficient);
1145    };
1146
1147    if let Some(max_pdop) = options.max_pdop {
1148        if dop.pdop > max_pdop {
1149            return Err(SolutionValidationError::DegenerateGeometryPdop(dop.pdop));
1150        }
1151    }
1152
1153    let p = solution.position.as_array();
1154    let radius_m = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
1155    if radius_m < options.min_plausible_radius_m || radius_m > options.max_plausible_radius_m {
1156        return Err(SolutionValidationError::ImplausiblePosition(radius_m));
1157    }
1158
1159    if solution.metadata.converged {
1160        if validate::finite_slice(&solution.residuals_m, "solution residuals").is_err() {
1161            return Err(SolutionValidationError::InvalidResiduals);
1162        }
1163        let rms = residual_rms(&solution.residuals_m);
1164        if !rms.is_finite() {
1165            return Err(SolutionValidationError::InvalidResiduals);
1166        }
1167        if rms > options.max_converged_residual_rms_m {
1168            return Err(SolutionValidationError::NoConvergence(rms));
1169        }
1170    }
1171
1172    Ok(())
1173}
1174
1175fn validate_solution_validation_options(
1176    options: SolutionValidationOptions,
1177) -> Result<(), SolutionValidationError> {
1178    if let Some(max_pdop) = options.max_pdop {
1179        validate::finite_positive(max_pdop, "max_pdop").map_err(validation_option_error)?;
1180    }
1181    validate::finite_positive(options.min_plausible_radius_m, "min_plausible_radius_m")
1182        .map_err(validation_option_error)?;
1183    validate::finite_positive(options.max_plausible_radius_m, "max_plausible_radius_m")
1184        .map_err(validation_option_error)?;
1185    if options.min_plausible_radius_m >= options.max_plausible_radius_m {
1186        return Err(invalid_validation_option(
1187            "plausible_radius_m",
1188            "must be increasing",
1189        ));
1190    }
1191    validate::finite_positive(
1192        options.max_converged_residual_rms_m,
1193        "max_converged_residual_rms_m",
1194    )
1195    .map_err(validation_option_error)?;
1196    Ok(())
1197}
1198
1199fn validation_option_error(error: validate::FieldError) -> SolutionValidationError {
1200    invalid_validation_option(error.field(), error.reason())
1201}
1202
1203fn invalid_validation_option(field: &'static str, reason: &'static str) -> SolutionValidationError {
1204    SolutionValidationError::InvalidOptions { field, reason }
1205}
1206
1207fn residual_rms(residuals: &[f64]) -> f64 {
1208    if residuals.is_empty() {
1209        return 0.0;
1210    }
1211    let sum_sq = residuals.iter().map(|r| r * r).sum::<f64>();
1212    (sum_sq / residuals.len() as f64).sqrt()
1213}
1214
1215/// Chi-square inverse CDF.
1216pub fn chi2_inv(p: f64, k: usize) -> Result<f64, QualityError> {
1217    validate_probability(p)?;
1218    if k == 0 {
1219        return Err(QualityError::InvalidDof);
1220    }
1221    let a = 0.5 * k as f64;
1222    let hi0 = (k as f64 + 10.0 * (2.0 * k as f64).sqrt()).max(1.0);
1223    let hi = chi2_bracket_hi(p, a, hi0);
1224    Ok(chi2_bisect(p, a, 0.0, hi, 0))
1225}
1226
1227fn chi2_bracket_hi(p: f64, a: f64, hi: f64) -> f64 {
1228    if chi2_cdf(hi, a) >= p {
1229        hi
1230    } else {
1231        chi2_bracket_hi(p, a, hi * 2.0)
1232    }
1233}
1234
1235fn chi2_bisect(p: f64, a: f64, lo: f64, hi: f64, iter: usize) -> f64 {
1236    if iter >= 120 {
1237        return 0.5 * (lo + hi);
1238    }
1239    let mid = 0.5 * (lo + hi);
1240    if chi2_cdf(mid, a) < p {
1241        chi2_bisect(p, a, mid, hi, iter + 1)
1242    } else {
1243        chi2_bisect(p, a, lo, mid, iter + 1)
1244    }
1245}
1246
1247fn chi2_cdf(x: f64, a: f64) -> f64 {
1248    regularized_gamma_p(a, 0.5 * x)
1249}
1250
1251const GAMMA_EPS: f64 = 1.0e-15;
1252const GAMMA_FPMIN: f64 = 1.0e-300;
1253const GAMMA_ITMAX: usize = 1_000;
1254
1255fn regularized_gamma_p(a: f64, x: f64) -> f64 {
1256    if x <= 0.0 {
1257        return 0.0;
1258    }
1259
1260    if x < a + 1.0 {
1261        let gln = log_gamma(a);
1262        let sum = gamma_series(x, 1.0 / a, 1.0 / a, a, 1);
1263        sum * libm::exp(-x + a * libm::log(x) - gln)
1264    } else {
1265        let gln = log_gamma(a);
1266        let q = gamma_continued_fraction(a, x) * libm::exp(-x + a * libm::log(x) - gln);
1267        1.0 - q
1268    }
1269}
1270
1271fn gamma_series(x: f64, sum: f64, del: f64, ap: f64, n: usize) -> f64 {
1272    if n > GAMMA_ITMAX {
1273        return sum;
1274    }
1275    let ap = ap + 1.0;
1276    let del = del * x / ap;
1277    let sum = sum + del;
1278    if del.abs() < sum.abs() * GAMMA_EPS {
1279        sum
1280    } else {
1281        gamma_series(x, sum, del, ap, n + 1)
1282    }
1283}
1284
1285fn gamma_continued_fraction(a: f64, x: f64) -> f64 {
1286    let b = x + 1.0 - a;
1287    let c = 1.0 / GAMMA_FPMIN;
1288    let d = 1.0 / safe_denominator(b);
1289    gamma_cf_iter(a, b, c, d, d, 1)
1290}
1291
1292fn gamma_cf_iter(a: f64, b: f64, c: f64, d: f64, h: f64, n: usize) -> f64 {
1293    if n > GAMMA_ITMAX {
1294        return h;
1295    }
1296
1297    let an = -(n as f64) * (n as f64 - a);
1298    let b = b + 2.0;
1299    let d = 1.0 / safe_denominator(an * d + b);
1300    let c = safe_denominator(b + an / c);
1301    let delta = d * c;
1302    let h = h * delta;
1303
1304    if (delta - 1.0).abs() < GAMMA_EPS {
1305        h
1306    } else {
1307        gamma_cf_iter(a, b, c, d, h, n + 1)
1308    }
1309}
1310
1311fn safe_denominator(x: f64) -> f64 {
1312    if x.abs() < GAMMA_FPMIN {
1313        GAMMA_FPMIN
1314    } else {
1315        x
1316    }
1317}
1318
1319const LANCZOS: [f64; 9] = [
1320    0.9999999999998099,
1321    676.5203681218851,
1322    -1259.1392167224028,
1323    771.3234287776531,
1324    -176.6150291621406,
1325    12.507343278686905,
1326    -0.13857109526572012,
1327    9.984369578019572e-6,
1328    1.5056327351493116e-7,
1329];
1330const SQRT_2PI: f64 = 2.5066282746310002;
1331
1332fn log_gamma(z: f64) -> f64 {
1333    if z < 0.5 {
1334        libm::log(std::f64::consts::PI)
1335            - libm::log(libm::sin(std::f64::consts::PI * z))
1336            - log_gamma(1.0 - z)
1337    } else {
1338        let z = z - 1.0;
1339        let mut x = LANCZOS[0];
1340        for (i, coef) in LANCZOS.iter().enumerate().skip(1) {
1341            x += coef / (z + i as f64);
1342        }
1343        let t = z + 7.5;
1344        libm::log(SQRT_2PI) + (z + 0.5) * libm::log(t) - t + libm::log(x)
1345    }
1346}
1347
1348#[cfg(test)]
1349mod tests {
1350    use super::*;
1351    use crate::{GnssSatelliteId, GnssSystem};
1352
1353    use std::path::PathBuf;
1354
1355    use crate::rinex_nav::BroadcastStore;
1356    use crate::rinex_obs::{pseudoranges, RinexObs, SignalPolicy};
1357    use crate::spp::{Corrections, KlobucharCoeffs, RobustConfig, SurfaceMet};
1358
1359    fn fixture_path(name: &str) -> PathBuf {
1360        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1361            .join("tests/fixtures")
1362            .join(name)
1363    }
1364
1365    /// The real ESBC broadcast navigation store (day 177, GPS) used as a live,
1366    /// converging FDE ephemeris source.
1367    fn esbc_broadcast_store() -> BroadcastStore {
1368        let nav = std::fs::read_to_string(fixture_path("nav/ESBC00DNK_R_20201770000_01D_MN.rnx"))
1369            .expect("read ESBC broadcast NAV fixture");
1370        BroadcastStore::from_nav(&nav).expect("parse ESBC broadcast NAV")
1371    }
1372
1373    /// The real ESBC first-epoch GPS L1 pseudorange solve inputs.
1374    fn esbc_first_epoch_inputs() -> SolveInputs {
1375        let obs_text = std::fs::read_to_string(fixture_path(
1376            "obs/ESBC00DNK_R_20201770000_01D_30S_MO_trim.rnx",
1377        ))
1378        .expect("read ESBC OBS fixture");
1379        let obs = RinexObs::parse(&obs_text).expect("parse ESBC OBS fixture");
1380        let policy = SignalPolicy {
1381            codes: [(GnssSystem::Gps, vec!["C1C".to_string()])]
1382                .into_iter()
1383                .collect(),
1384        };
1385        let observations = pseudoranges(&obs, &obs.epochs()[0], &policy)
1386            .expect("valid pseudoranges")
1387            .into_iter()
1388            .map(|(satellite_id, pseudorange_m)| Observation {
1389                satellite_id,
1390                pseudorange_m,
1391            })
1392            .collect();
1393
1394        SolveInputs {
1395            observations,
1396            t_rx_j2000_s: 646_315_200.0,
1397            t_rx_second_of_day_s: 0.0,
1398            day_of_year: 177.0,
1399            initial_guess: [3_582_135.0, 532_569.0, 5_232_779.0, 0.0],
1400            corrections: Corrections {
1401                ionosphere: false,
1402                troposphere: true,
1403            },
1404            klobuchar: KlobucharCoeffs {
1405                alpha: [0.0; 4],
1406                beta: [0.0; 4],
1407            },
1408            beidou_klobuchar: None,
1409            galileo_nequick: None,
1410            sbas_iono: None,
1411            glonass_channels: std::collections::BTreeMap::new(),
1412            met: SurfaceMet {
1413                pressure_hpa: 1013.25,
1414                temperature_k: 288.15,
1415                relative_humidity: 0.5,
1416            },
1417            robust: None,
1418        }
1419    }
1420
1421    fn assert_receiver_solution_bits_eq(left: &ReceiverSolution, right: &ReceiverSolution) {
1422        assert_eq!(left.position.x_m.to_bits(), right.position.x_m.to_bits());
1423        assert_eq!(left.position.y_m.to_bits(), right.position.y_m.to_bits());
1424        assert_eq!(left.position.z_m.to_bits(), right.position.z_m.to_bits());
1425        assert_eq!(left.geodetic, right.geodetic);
1426        assert_eq!(left.rx_clock_s.to_bits(), right.rx_clock_s.to_bits());
1427        assert_eq!(left.rx_clock_drift_s_s, right.rx_clock_drift_s_s);
1428        assert_eq!(left.dop, right.dop);
1429        assert_eq!(
1430            left.position_covariance
1431                .ecef_m2
1432                .iter()
1433                .flatten()
1434                .map(|v| v.to_bits())
1435                .collect::<Vec<_>>(),
1436            right
1437                .position_covariance
1438                .ecef_m2
1439                .iter()
1440                .flatten()
1441                .map(|v| v.to_bits())
1442                .collect::<Vec<_>>()
1443        );
1444        assert_eq!(
1445            left.position_covariance
1446                .enu_m2
1447                .iter()
1448                .flatten()
1449                .map(|v| v.to_bits())
1450                .collect::<Vec<_>>(),
1451            right
1452                .position_covariance
1453                .enu_m2
1454                .iter()
1455                .flatten()
1456                .map(|v| v.to_bits())
1457                .collect::<Vec<_>>()
1458        );
1459        assert_eq!(
1460            left.residuals_m
1461                .iter()
1462                .map(|v| v.to_bits())
1463                .collect::<Vec<_>>(),
1464            right
1465                .residuals_m
1466                .iter()
1467                .map(|v| v.to_bits())
1468                .collect::<Vec<_>>()
1469        );
1470        assert_eq!(left.used_sats, right.used_sats);
1471        assert_eq!(left.rejected_sats, right.rejected_sats);
1472        assert_eq!(left.metadata, right.metadata);
1473    }
1474
1475    fn fde_spp_options(inputs: &SolveInputs) -> FdeSppOptions {
1476        FdeSppOptions {
1477            fde: FdeOptions {
1478                raim: RaimOptions::default(),
1479                max_iterations: inputs.observations.len().saturating_sub(4),
1480            },
1481            validation: SolutionValidationOptions::default(),
1482        }
1483    }
1484
1485    fn position_delta_m(left: &ReceiverSolution, right: &ReceiverSolution) -> f64 {
1486        ((left.position.x_m - right.position.x_m).powi(2)
1487            + (left.position.y_m - right.position.y_m).powi(2)
1488            + (left.position.z_m - right.position.z_m).powi(2))
1489        .sqrt()
1490    }
1491
1492    /// The `fde_spp` driver must equal the hand-assembled solve + validate + FDE
1493    /// loop the bindings each spell out, bit-for-bit, on a real converging
1494    /// scenario with one injected outlier that the loop detects and excludes.
1495    #[test]
1496    fn fde_spp_matches_manual_composition_bit_for_bit() {
1497        let store = esbc_broadcast_store();
1498        let with_geodetic = true;
1499
1500        // Solve the clean set first so the outlier is injected on a satellite the
1501        // solver actually uses (the real epoch drops three low-elevation GPS
1502        // satellites before RAIM ever sees them).
1503        let clean_inputs = esbc_first_epoch_inputs();
1504        let clean = solve(&store, &clean_inputs, with_geodetic).expect("clean solve converges");
1505        assert!(
1506            clean.used_sats.len() >= 6,
1507            "scenario needs redundancy for a testable RAIM exclusion"
1508        );
1509        let outlier_sat = *clean.used_sats.last().expect("a used satellite");
1510
1511        // Inject a gross 1 km bias on that used satellite so RAIM has a clear
1512        // worst residual to exclude.
1513        let mut inputs = clean_inputs;
1514        let outlier_obs = inputs
1515            .observations
1516            .iter_mut()
1517            .find(|obs| obs.satellite_id == outlier_sat)
1518            .expect("outlier satellite is present in the observation set");
1519        outlier_obs.pseudorange_m += 1000.0;
1520
1521        let options = fde_spp_options(&inputs);
1522
1523        // Driver path.
1524        let driver = fde_spp(&store, &inputs, with_geodetic, &options)
1525            .expect("driver FDE resolves the fault");
1526
1527        // Hand-assembled reference: exactly the loop the bindings reduce to.
1528        let observations = inputs.observations.clone();
1529        let reference = fde(&observations, &options.fde, |remaining| {
1530            let mut next = inputs.clone();
1531            next.observations = remaining.to_vec();
1532            let solution = solve(&store, &next, with_geodetic).map_err(FdeSppError::Spp)?;
1533            validate_receiver_solution(&solution, options.validation)
1534                .map_err(FdeSppError::Validation)?;
1535            Ok::<_, FdeSppError>(solution)
1536        })
1537        .expect("reference FDE resolves the fault");
1538
1539        // Bit-for-bit parity: the driver IS the hand-assembled loop.
1540        assert_eq!(driver.excluded, reference.excluded);
1541        assert_eq!(driver.iterations, reference.iterations);
1542        assert_receiver_solution_bits_eq(&driver.solution, &reference.solution);
1543
1544        // The fault drove the loop to detect, exclude, and re-solve: the
1545        // protected solution dropped at least one satellite and the surviving
1546        // set is self-consistent under RAIM. (The injected blunder is smeared by
1547        // unit-weight RAIM onto other residuals, so the excluded set is the
1548        // engine's own RAIM decision; the driver mirrors it exactly rather than
1549        // imposing any exclusion policy of its own.)
1550        assert!(driver.iterations >= 1, "the fault must drive an exclusion");
1551        assert!(!driver.excluded.is_empty());
1552        assert_eq!(driver.excluded.len(), driver.iterations);
1553        let surviving = raim_for_solution(&driver.solution, &options.fde.raim).expect("raim");
1554        assert!(
1555            !surviving.fault_detected,
1556            "the protected set must pass RAIM (or be untestable)"
1557        );
1558    }
1559
1560    /// A clean set converges with no exclusion, and the driver still equals the
1561    /// hand-assembled composition bit-for-bit.
1562    #[test]
1563    fn fde_spp_clean_set_takes_no_exclusion_and_matches_manual() {
1564        let store = esbc_broadcast_store();
1565        let inputs = esbc_first_epoch_inputs();
1566        let options = fde_spp_options(&inputs);
1567
1568        let driver = fde_spp(&store, &inputs, false, &options).expect("driver solves clean set");
1569
1570        let observations = inputs.observations.clone();
1571        let reference = fde(&observations, &options.fde, |remaining| {
1572            let mut next = inputs.clone();
1573            next.observations = remaining.to_vec();
1574            let solution = solve(&store, &next, false).map_err(FdeSppError::Spp)?;
1575            validate_receiver_solution(&solution, options.validation)
1576                .map_err(FdeSppError::Validation)?;
1577            Ok::<_, FdeSppError>(solution)
1578        })
1579        .expect("reference solves clean set");
1580
1581        assert_eq!(driver.iterations, 0);
1582        assert!(driver.excluded.is_empty());
1583        assert_eq!(driver.iterations, reference.iterations);
1584        assert_eq!(driver.excluded, reference.excluded);
1585        assert_receiver_solution_bits_eq(&driver.solution, &reference.solution);
1586    }
1587
1588    #[test]
1589    fn spp_robust_fde_driver_clean_set_uses_robust_solve_without_exclusion() {
1590        let store = esbc_broadcast_store();
1591        let inputs = esbc_first_epoch_inputs();
1592        let options = fde_spp_options(&inputs);
1593
1594        let driver =
1595            spp_robust_fde_driver(&store, &inputs, false, RobustConfig::default(), &options)
1596                .expect("robust FDE solves clean set");
1597
1598        assert_eq!(driver.iterations, 0);
1599        assert!(driver.excluded.is_empty());
1600        assert!(driver.solution.metadata.outer_iterations > 0);
1601        assert!(driver.solution.metadata.final_robust_scale_m.is_some());
1602        let surviving = raim_for_solution(&driver.solution, &options.fde.raim).expect("raim");
1603        assert!(!surviving.fault_detected);
1604    }
1605
1606    #[test]
1607    fn spp_robust_fde_driver_excludes_fault_and_recovers_solution() {
1608        let store = esbc_broadcast_store();
1609        let clean_inputs = esbc_first_epoch_inputs();
1610        let clean_options = fde_spp_options(&clean_inputs);
1611        let robust = RobustConfig::default();
1612        let clean = spp_robust_fde_driver(&store, &clean_inputs, false, robust, &clean_options)
1613            .expect("clean robust FDE solve");
1614        let outlier_sat = gps(15);
1615        assert!(clean.solution.used_sats.contains(&outlier_sat));
1616
1617        let mut faulty_inputs = clean_inputs.clone();
1618        let outlier_obs = faulty_inputs
1619            .observations
1620            .iter_mut()
1621            .find(|obs| obs.satellite_id == outlier_sat)
1622            .expect("outlier satellite is observed");
1623        outlier_obs.pseudorange_m += 1000.0;
1624        let faulty_options = fde_spp_options(&faulty_inputs);
1625
1626        let driver = spp_robust_fde_driver(&store, &faulty_inputs, false, robust, &faulty_options)
1627            .expect("robust FDE resolves fault");
1628
1629        assert_eq!(driver.iterations, 1);
1630        assert_eq!(driver.iterations, driver.excluded.len());
1631        assert_eq!(driver.excluded, vec![outlier_sat.to_string()]);
1632        assert!(driver.solution.metadata.outer_iterations > 0);
1633        assert!(driver.solution.metadata.final_robust_scale_m.is_some());
1634        let surviving = raim_for_solution(&driver.solution, &faulty_options.fde.raim)
1635            .expect("surviving set RAIM");
1636        assert!(!surviving.fault_detected);
1637        let recovered_delta_m = position_delta_m(&driver.solution, &clean.solution);
1638        assert!(
1639            recovered_delta_m < 1.0,
1640            "protected solution should stay close to the clean robust solution, got {recovered_delta_m} m with exclusions {:?}",
1641            driver.excluded
1642        );
1643    }
1644
1645    #[derive(Debug, Clone)]
1646    struct TestSolution {
1647        used_sats: Vec<String>,
1648        residuals_m: Vec<f64>,
1649    }
1650
1651    impl RaimSolution for TestSolution {
1652        fn raim_used_sats(&self) -> Vec<String> {
1653            self.used_sats.clone()
1654        }
1655
1656        fn raim_residuals_m(&self) -> &[f64] {
1657            &self.residuals_m
1658        }
1659    }
1660
1661    fn gps(prn: u8) -> GnssSatelliteId {
1662        GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
1663    }
1664
1665    fn valid_receiver_solution() -> ReceiverSolution {
1666        ReceiverSolution {
1667            position: crate::frame::ItrfPositionM::new(6_378_137.0, 0.0, 0.0).unwrap(),
1668            geodetic: None,
1669            rx_clock_s: 0.0,
1670            rx_clock_drift_s_s: None,
1671            system_clocks_s: vec![(GnssSystem::Gps, 0.0)],
1672            dop: Some(crate::dop::Dop {
1673                gdop: 2.5,
1674                pdop: 2.0,
1675                hdop: 1.5,
1676                vdop: 1.0,
1677                tdop: 0.5,
1678                system_tdops: vec![(GnssSystem::Gps, 0.5)],
1679            }),
1680            system_tdops: vec![(GnssSystem::Gps, 0.5)],
1681            position_covariance: crate::dop::PositionCovariance {
1682                ecef_m2: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1683                enu_m2: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1684            },
1685            residuals_m: vec![0.1, -0.1, 0.0, 0.05, -0.05],
1686            used_sats: (1..=5).map(gps).collect(),
1687            rejected_sats: Vec::new(),
1688            geometry_quality: crate::geometry_quality::GeometryQuality {
1689                tier: crate::geometry_quality::ObservabilityTier::Nominal,
1690                redundancy: 1,
1691                rank: 4,
1692                condition_number: 1.0,
1693                gdop: 2.5,
1694                raim_checkable: true,
1695                covariance_validated: true,
1696            },
1697            metadata: crate::spp::SolutionMetadata {
1698                iterations: 3,
1699                converged: true,
1700                status: crate::astro::math::least_squares::Status::StepTolerance,
1701                ionosphere_applied: false,
1702                troposphere_applied: false,
1703                outer_iterations: 0,
1704                final_robust_scale_m: None,
1705                used_count: 5,
1706                systems: vec![GnssSystem::Gps],
1707                redundancy: 1,
1708                raim_checkable: true,
1709            },
1710        }
1711    }
1712
1713    #[test]
1714    fn pseudorange_variance_matches_elevation_model() {
1715        let opts = PseudorangeVarianceOptions::default();
1716        let variance = pseudorange_variance(30.0, opts).unwrap();
1717        assert!((variance - 0.45).abs() < 1.0e-15);
1718        assert_eq!(
1719            pseudorange_variance(0.0, opts),
1720            Err(QualityError::InvalidElevation)
1721        );
1722        let horizon_opts = PseudorangeVarianceOptions { b_m: 0.0, ..opts };
1723        assert_eq!(
1724            pseudorange_variance(0.0, horizon_opts),
1725            Ok(horizon_opts.a_m * horizon_opts.a_m)
1726        );
1727        assert_eq!(
1728            pseudorange_variance(-90.0, horizon_opts),
1729            Ok(horizon_opts.a_m * horizon_opts.a_m)
1730        );
1731        assert_eq!(
1732            pseudorange_variance(90.1, horizon_opts),
1733            Err(QualityError::InvalidElevation)
1734        );
1735        assert_eq!(
1736            pseudorange_variance(f64::NAN, opts),
1737            Err(QualityError::InvalidElevation)
1738        );
1739    }
1740
1741    #[test]
1742    fn cn0_model_requires_cn0_and_adds_noise_term() {
1743        let opts = PseudorangeVarianceOptions {
1744            model: PseudorangeVarianceModel::ElevationCn0,
1745            cn0_dbhz: None,
1746            ..Default::default()
1747        };
1748        assert_eq!(
1749            pseudorange_variance(30.0, opts),
1750            Err(QualityError::MissingCn0)
1751        );
1752
1753        let weak = pseudorange_variance(
1754            30.0,
1755            PseudorangeVarianceOptions {
1756                cn0_dbhz: Some(30.0),
1757                ..opts
1758            },
1759        )
1760        .unwrap();
1761        let strong = pseudorange_variance(
1762            30.0,
1763            PseudorangeVarianceOptions {
1764                cn0_dbhz: Some(50.0),
1765                ..opts
1766            },
1767        )
1768        .unwrap();
1769        assert!(strong < weak);
1770    }
1771
1772    #[test]
1773    fn pseudorange_variance_rejects_nonfinite_and_negative_parameters() {
1774        let invalid_a = PseudorangeVarianceOptions {
1775            a_m: f64::NAN,
1776            ..Default::default()
1777        };
1778        assert_eq!(
1779            pseudorange_variance(30.0, invalid_a),
1780            Err(QualityError::InvalidParameter)
1781        );
1782
1783        let invalid_b = PseudorangeVarianceOptions {
1784            b_m: -1.0,
1785            ..Default::default()
1786        };
1787        assert_eq!(
1788            pseudorange_variance(30.0, invalid_b),
1789            Err(QualityError::InvalidParameter)
1790        );
1791
1792        let invalid_cn0_scale = PseudorangeVarianceOptions {
1793            cn0_scale_m2: f64::INFINITY,
1794            ..Default::default()
1795        };
1796        assert_eq!(
1797            pseudorange_variance(30.0, invalid_cn0_scale),
1798            Err(QualityError::InvalidParameter)
1799        );
1800
1801        let invalid_cn0 = PseudorangeVarianceOptions {
1802            model: PseudorangeVarianceModel::ElevationCn0,
1803            cn0_dbhz: Some(f64::NAN),
1804            ..Default::default()
1805        };
1806        assert_eq!(
1807            pseudorange_variance(30.0, invalid_cn0),
1808            Err(QualityError::InvalidParameter)
1809        );
1810    }
1811
1812    #[test]
1813    fn pseudorange_variance_rejects_zero_total_variance() {
1814        let zero_variance = PseudorangeVarianceOptions {
1815            a_m: 0.0,
1816            b_m: 0.0,
1817            ..Default::default()
1818        };
1819        assert_eq!(
1820            pseudorange_variance(30.0, zero_variance),
1821            Err(QualityError::InvalidParameter)
1822        );
1823
1824        let entries = vec![WeightEntry {
1825            satellite_id: "G01".to_string(),
1826            elevation_deg: 30.0,
1827            cn0_dbhz: None,
1828        }];
1829        let weights = weight_vector(&entries, zero_variance);
1830        assert!(
1831            !weights.contains_key("G01"),
1832            "zero variance must not produce an infinite inverse-variance weight"
1833        );
1834    }
1835
1836    #[test]
1837    fn sigma_and_weight_maps_drop_invalid_entries() {
1838        let entries = vec![
1839            WeightEntry {
1840                satellite_id: "G01".to_string(),
1841                elevation_deg: 90.0,
1842                cn0_dbhz: None,
1843            },
1844            WeightEntry {
1845                satellite_id: "G02".to_string(),
1846                elevation_deg: -91.0,
1847                cn0_dbhz: None,
1848            },
1849        ];
1850        let sigmas = sigmas(&entries, Default::default());
1851        let weights = weight_vector(&entries, Default::default());
1852        assert!(sigmas.contains_key("G01"));
1853        assert!(!sigmas.contains_key("G02"));
1854        assert_eq!(weights["G01"], 1.0 / (sigmas["G01"] * sigmas["G01"]));
1855    }
1856
1857    #[test]
1858    fn sigma_and_weight_maps_retain_horizon_entries_without_elevation_term() {
1859        let entries = vec![
1860            WeightEntry {
1861                satellite_id: "G01".to_string(),
1862                elevation_deg: 0.0,
1863                cn0_dbhz: None,
1864            },
1865            WeightEntry {
1866                satellite_id: "G02".to_string(),
1867                elevation_deg: f64::NAN,
1868                cn0_dbhz: None,
1869            },
1870        ];
1871        let options = PseudorangeVarianceOptions {
1872            b_m: 0.0,
1873            ..Default::default()
1874        };
1875        let sigmas = sigmas(&entries, options);
1876        let weights = weight_vector(&entries, options);
1877        assert_eq!(sigmas["G01"], options.a_m);
1878        assert_eq!(weights["G01"], 1.0 / (options.a_m * options.a_m));
1879        assert!(!sigmas.contains_key("G02"));
1880        assert!(!weights.contains_key("G02"));
1881    }
1882
1883    #[test]
1884    fn chi_square_inverse_matches_reference_values() {
1885        let refs = [
1886            (1, 10.828),
1887            (2, 13.816),
1888            (3, 16.266),
1889            (4, 18.467),
1890            (5, 20.515),
1891        ];
1892        for (dof, expected) in refs {
1893            let got = chi2_inv(0.999, dof).unwrap();
1894            assert!((got - expected).abs() < 1.0e-3);
1895        }
1896        assert_eq!(chi2_inv(1.0, 1), Err(QualityError::InvalidProbability));
1897        assert_eq!(chi2_inv(0.95, 0), Err(QualityError::InvalidDof));
1898    }
1899
1900    #[test]
1901    fn residual_diagnostics_reports_weighted_redundancy_and_reduced_chi_square() {
1902        let residuals = [1.0, -2.0, 0.5, 3.0, -1.5];
1903        let weights = [1.0, 0.25, 4.0, 1.0, 0.5];
1904        let diagnostics =
1905            residual_diagnostics(&residuals, Some(&weights), 3, Some(1.0e-3)).expect("diagnostics");
1906
1907        let wss = residuals
1908            .iter()
1909            .zip(weights)
1910            .map(|(r, w)| r * r * w)
1911            .sum::<f64>();
1912        assert_eq!(diagnostics.n_residuals, 5);
1913        assert_eq!(diagnostics.n_parameters, 3);
1914        assert_eq!(diagnostics.degrees_of_freedom, 2);
1915        assert_eq!(diagnostics.weighted_sum_squares.to_bits(), wss.to_bits());
1916        assert_eq!(
1917            diagnostics.reduced_chi_square.unwrap().to_bits(),
1918            (wss / 2.0).to_bits()
1919        );
1920        assert_eq!(
1921            diagnostics.normalized_residuals[1].to_bits(),
1922            (-1.0f64).to_bits()
1923        );
1924        assert_eq!(diagnostics.worst_index, Some(3));
1925        assert!(diagnostics.chi_square_threshold.unwrap().is_finite());
1926        assert_eq!(diagnostics.chi_square_consistent, Some(true));
1927    }
1928
1929    #[test]
1930    fn residual_diagnostics_handles_no_redundancy_and_rejects_bad_inputs() {
1931        let residuals = [1.0, -1.0];
1932        let diagnostics =
1933            residual_diagnostics(&residuals, None, 2, Some(1.0e-3)).expect("diagnostics");
1934        assert_eq!(diagnostics.degrees_of_freedom, 0);
1935        assert_eq!(diagnostics.reduced_chi_square, None);
1936        assert_eq!(diagnostics.chi_square_threshold, None);
1937        assert_eq!(diagnostics.chi_square_consistent, None);
1938
1939        assert_eq!(
1940            residual_diagnostics(&[1.0, f64::NAN], None, 1, None),
1941            Err(QualityError::InvalidResiduals)
1942        );
1943        assert_eq!(
1944            residual_diagnostics(&[1.0], Some(&[0.0]), 0, None),
1945            Err(QualityError::InvalidWeight)
1946        );
1947        assert_eq!(
1948            residual_diagnostics(&[1.0], None, 0, Some(1.0)),
1949            Err(QualityError::InvalidProbability)
1950        );
1951    }
1952
1953    #[test]
1954    fn raim_reports_fault_and_worst_satellite() {
1955        let input = RaimInput {
1956            used_sats: ["G01", "G02", "G03", "G04", "G05"]
1957                .into_iter()
1958                .map(str::to_string)
1959                .collect(),
1960            residuals_m: vec![0.0, 0.0, 0.0, 0.0, 5.0],
1961        };
1962        let result = raim(&input, &RaimOptions::default()).unwrap();
1963        assert!(result.fault_detected);
1964        assert!(result.testable);
1965        assert_eq!(result.dof, 1);
1966        assert_eq!(result.test_statistic, 25.0);
1967        assert_eq!(result.worst_sat.as_deref(), Some("G05"));
1968    }
1969
1970    #[test]
1971    fn raim_dof_zero_is_not_testable() {
1972        let input = RaimInput {
1973            used_sats: ["G01", "G02", "G03", "G04"]
1974                .into_iter()
1975                .map(str::to_string)
1976                .collect(),
1977            residuals_m: vec![0.0, 0.0, 0.0, 0.0],
1978        };
1979        let result = raim(&input, &RaimOptions::default()).unwrap();
1980        assert!(!result.fault_detected);
1981        assert!(!result.testable);
1982        assert_eq!(result.threshold, None);
1983        assert_eq!(result.dof, 0);
1984    }
1985
1986    #[test]
1987    fn raim_rejects_nonpositive_system_overrides() {
1988        let input = RaimInput {
1989            used_sats: ["G01", "G02", "G03", "G04", "G05"]
1990                .into_iter()
1991                .map(str::to_string)
1992                .collect(),
1993            residuals_m: vec![0.0; 5],
1994        };
1995
1996        for n_systems in [0, -1] {
1997            let options = RaimOptions {
1998                n_systems: Some(n_systems),
1999                ..Default::default()
2000            };
2001            assert_eq!(
2002                raim(&input, &options),
2003                Err(QualityError::InvalidSystemCount)
2004            );
2005        }
2006    }
2007
2008    #[test]
2009    fn raim_positive_system_override_controls_dof() {
2010        let input = RaimInput {
2011            used_sats: ["G01", "G02", "G03", "G04", "G05", "G06"]
2012                .into_iter()
2013                .map(str::to_string)
2014                .collect(),
2015            residuals_m: vec![0.0; 6],
2016        };
2017        let options = RaimOptions {
2018            n_systems: Some(2),
2019            ..Default::default()
2020        };
2021
2022        let result = raim(&input, &options).unwrap();
2023        assert!(result.testable);
2024        assert_eq!(result.dof, 1);
2025    }
2026
2027    #[test]
2028    fn raim_rejects_misaligned_or_nonfinite_residuals() {
2029        let input = RaimInput {
2030            used_sats: ["G01", "G02"].into_iter().map(str::to_string).collect(),
2031            residuals_m: vec![1.0],
2032        };
2033        assert_eq!(
2034            raim(&input, &RaimOptions::default()),
2035            Err(QualityError::InvalidResiduals)
2036        );
2037
2038        let input = RaimInput {
2039            used_sats: ["G01", "G02"].into_iter().map(str::to_string).collect(),
2040            residuals_m: vec![1.0, f64::NAN],
2041        };
2042        assert_eq!(
2043            raim(&input, &RaimOptions::default()),
2044            Err(QualityError::InvalidResiduals)
2045        );
2046    }
2047
2048    #[test]
2049    fn raim_rejects_nonfinite_weights_and_probability() {
2050        let input = RaimInput {
2051            used_sats: ["G01", "G02", "G03", "G04", "G05"]
2052                .into_iter()
2053                .map(str::to_string)
2054                .collect(),
2055            residuals_m: vec![0.0; 5],
2056        };
2057        let mut weights = BTreeMap::new();
2058        weights.insert("G01".to_string(), f64::NAN);
2059        let options = RaimOptions {
2060            weights: RaimWeights::BySatellite(weights),
2061            ..Default::default()
2062        };
2063        assert_eq!(raim(&input, &options), Err(QualityError::InvalidWeight));
2064
2065        let options = RaimOptions {
2066            p_fa: f64::NAN,
2067            ..Default::default()
2068        };
2069        assert_eq!(
2070            raim(&input, &options),
2071            Err(QualityError::InvalidProbability)
2072        );
2073    }
2074
2075    #[test]
2076    fn fde_excludes_largest_normalized_residual() {
2077        let observations: Vec<Observation> = (1..=5)
2078            .map(|prn| Observation {
2079                satellite_id: gps(prn),
2080                pseudorange_m: prn as f64,
2081            })
2082            .collect();
2083
2084        let options = FdeOptions {
2085            raim: RaimOptions::default(),
2086            max_iterations: 1,
2087        };
2088        let result = fde(&observations, &options, |remaining| {
2089            let used_sats = remaining
2090                .iter()
2091                .map(|ob| ob.satellite_id.to_string())
2092                .collect::<Vec<_>>();
2093            let residuals_m = remaining
2094                .iter()
2095                .map(|ob| if ob.satellite_id == gps(5) { 5.0 } else { 0.0 })
2096                .collect::<Vec<_>>();
2097            Ok::<_, ()>(TestSolution {
2098                used_sats,
2099                residuals_m,
2100            })
2101        })
2102        .unwrap();
2103
2104        assert_eq!(result.excluded, vec!["G05".to_string()]);
2105        assert_eq!(result.iterations, 1);
2106        assert_eq!(result.solution.used_sats.len(), 4);
2107    }
2108
2109    #[test]
2110    fn fde_refuses_fault_when_budget_is_exhausted() {
2111        let observations: Vec<Observation> = (1..=5)
2112            .map(|prn| Observation {
2113                satellite_id: gps(prn),
2114                pseudorange_m: prn as f64,
2115            })
2116            .collect();
2117        let options = FdeOptions {
2118            raim: RaimOptions::default(),
2119            max_iterations: 0,
2120        };
2121        let err = fde(&observations, &options, |remaining| {
2122            Ok::<_, ()>(TestSolution {
2123                used_sats: remaining
2124                    .iter()
2125                    .map(|ob| ob.satellite_id.to_string())
2126                    .collect(),
2127                residuals_m: vec![0.0, 0.0, 0.0, 0.0, 5.0],
2128            })
2129        })
2130        .unwrap_err();
2131
2132        assert_eq!(err, FdeError::FaultUnresolved(25.0));
2133    }
2134
2135    #[test]
2136    fn receiver_solution_validation_rejects_invalid_gate_options() {
2137        let solution = valid_receiver_solution();
2138        for (options, field, reason) in [
2139            (
2140                SolutionValidationOptions {
2141                    max_pdop: Some(f64::NAN),
2142                    ..Default::default()
2143                },
2144                "max_pdop",
2145                "not finite",
2146            ),
2147            (
2148                SolutionValidationOptions {
2149                    max_pdop: Some(0.0),
2150                    ..Default::default()
2151                },
2152                "max_pdop",
2153                "not positive",
2154            ),
2155            (
2156                SolutionValidationOptions {
2157                    min_plausible_radius_m: 0.0,
2158                    ..Default::default()
2159                },
2160                "min_plausible_radius_m",
2161                "not positive",
2162            ),
2163            (
2164                SolutionValidationOptions {
2165                    max_plausible_radius_m: f64::INFINITY,
2166                    ..Default::default()
2167                },
2168                "max_plausible_radius_m",
2169                "not finite",
2170            ),
2171            (
2172                SolutionValidationOptions {
2173                    max_converged_residual_rms_m: f64::NAN,
2174                    ..Default::default()
2175                },
2176                "max_converged_residual_rms_m",
2177                "not finite",
2178            ),
2179        ] {
2180            assert_eq!(
2181                validate_receiver_solution(&solution, options),
2182                Err(SolutionValidationError::InvalidOptions { field, reason })
2183            );
2184        }
2185
2186        let inverted_radius = SolutionValidationOptions {
2187            min_plausible_radius_m: 8_000_000.0,
2188            max_plausible_radius_m: 7_000_000.0,
2189            ..Default::default()
2190        };
2191        assert_eq!(
2192            validate_receiver_solution(&solution, inverted_radius),
2193            Err(SolutionValidationError::InvalidOptions {
2194                field: "plausible_radius_m",
2195                reason: "must be increasing",
2196            })
2197        );
2198    }
2199
2200    #[test]
2201    fn receiver_solution_validation_rejects_nonfinite_residuals() {
2202        let mut solution = valid_receiver_solution();
2203        solution.residuals_m[1] = f64::NAN;
2204        assert_eq!(
2205            validate_receiver_solution(&solution, SolutionValidationOptions::default()),
2206            Err(SolutionValidationError::InvalidResiduals)
2207        );
2208    }
2209
2210    // --- generic range RAIM/FDE -------------------------------------------
2211
2212    fn range_design_rows() -> Vec<[f64; 4]> {
2213        vec![
2214            [-0.10, -0.20, -0.97, 1.0],
2215            [0.50, -0.30, -0.81, 1.0],
2216            [-0.60, 0.40, -0.69, 1.0],
2217            [0.20, 0.80, -0.56, 1.0],
2218            [0.70, 0.50, -0.51, 1.0],
2219            [-0.50, -0.70, -0.51, 1.0],
2220            [0.30, -0.60, -0.74, 1.0],
2221            [-0.80, 0.10, -0.59, 1.0],
2222        ]
2223    }
2224
2225    fn range_rows(dx_true: [f64; 4]) -> Vec<RangeFdeRow> {
2226        range_design_rows()
2227            .iter()
2228            .enumerate()
2229            .map(|(i, h)| RangeFdeRow {
2230                id: format!("S{:02}", i + 1),
2231                residual_m: h.iter().zip(dx_true).map(|(a, b)| a * b).sum(),
2232                design_row: h.to_vec(),
2233                weight: 1.0,
2234            })
2235            .collect()
2236    }
2237
2238    fn assert_close(got: &[f64], want: &[f64], tol: f64) {
2239        assert_eq!(got.len(), want.len());
2240        for (g, w) in got.iter().zip(want) {
2241            assert!((g - w).abs() < tol, "got {g}, want {w}");
2242        }
2243    }
2244
2245    #[test]
2246    fn range_fde_clean_set_recovers_state_without_exclusions() {
2247        let dx_true = [1.0, -2.0, 0.5, 3.0];
2248        let rows = range_rows(dx_true);
2249        let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2250
2251        assert!(!result.global_test.fault_detected);
2252        assert!(result.global_test.testable);
2253        assert_eq!(result.global_test.dof, 4);
2254        assert!(result.excluded.is_empty());
2255        assert_eq!(result.iterations, 0);
2256        assert!(result.global_test.weighted_sum_squares < 1.0e-12);
2257        assert_close(&result.state_correction, &dx_true, 1.0e-9);
2258        assert_eq!(result.state_covariance.len(), 4);
2259    }
2260
2261    #[test]
2262    fn range_fde_detects_and_excludes_a_single_outlier() {
2263        let dx_true = [1.0, -2.0, 0.5, 3.0];
2264        let mut rows = range_rows(dx_true);
2265        rows[2].residual_m += 50.0; // inject a fault on S03
2266
2267        let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2268
2269        assert_eq!(result.excluded, vec!["S03".to_string()]);
2270        assert_eq!(result.iterations, 1);
2271        assert!(!result.global_test.fault_detected);
2272        assert_close(&result.state_correction, &dx_true, 1.0e-9);
2273
2274        let s03 = result
2275            .diagnostics
2276            .iter()
2277            .find(|d| d.id == "S03")
2278            .expect("S03 diagnostic");
2279        assert!(s03.excluded);
2280        // The excluded fault is large against the clean protected solution.
2281        assert!(s03.post_fit_residual_m.abs() > 40.0);
2282        // Surviving measurements are consistent.
2283        for d in result.diagnostics.iter().filter(|d| !d.excluded) {
2284            assert!(d.normalized_residual.abs() < 1.0e-6);
2285        }
2286    }
2287
2288    #[test]
2289    fn range_fde_excludes_multiple_outliers() {
2290        let dx_true = [0.5, 1.5, -1.0, 2.0];
2291        let mut rows = range_rows(dx_true);
2292        rows[2].residual_m += 50.0; // S03
2293        rows[5].residual_m -= 40.0; // S06
2294
2295        let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2296
2297        assert_eq!(result.iterations, 2);
2298        let mut excluded = result.excluded.clone();
2299        excluded.sort();
2300        assert_eq!(excluded, vec!["S03".to_string(), "S06".to_string()]);
2301        assert!(!result.global_test.fault_detected);
2302        assert_close(&result.state_correction, &dx_true, 1.0e-9);
2303    }
2304
2305    #[test]
2306    fn range_fde_respects_the_exclusion_budget() {
2307        let dx_true = [0.5, 1.5, -1.0, 2.0];
2308        let mut rows = range_rows(dx_true);
2309        rows[2].residual_m += 50.0;
2310        rows[5].residual_m -= 40.0;
2311
2312        let options = RangeFdeOptions {
2313            max_exclusions: 1,
2314            ..Default::default()
2315        };
2316        let result = raim_fde_design(&rows, &options).expect("fde");
2317
2318        // One exclusion used; the second fault is still flagged.
2319        assert_eq!(result.iterations, 1);
2320        assert_eq!(result.excluded.len(), 1);
2321        assert!(result.global_test.fault_detected);
2322    }
2323
2324    #[test]
2325    fn range_fde_rejects_rank_deficient_geometry() {
2326        let rows: Vec<RangeFdeRow> = (0..5)
2327            .map(|i| RangeFdeRow {
2328                id: format!("S{:02}", i + 1),
2329                residual_m: 1.0,
2330                design_row: vec![1.0, 0.0, 0.0, 1.0], // collinear: rank 2 of 4
2331                weight: 1.0,
2332            })
2333            .collect();
2334        assert_eq!(
2335            raim_fde_design(&rows, &RangeFdeOptions::default()),
2336            Err(QualityError::SingularGeometry)
2337        );
2338    }
2339
2340    #[test]
2341    fn range_fde_rejects_malformed_inputs() {
2342        assert_eq!(
2343            raim_fde_design(&[], &RangeFdeOptions::default()),
2344            Err(QualityError::InvalidDesign)
2345        );
2346
2347        // Fewer measurements than state parameters.
2348        let too_few = vec![RangeFdeRow {
2349            id: "S01".to_string(),
2350            residual_m: 0.0,
2351            design_row: vec![1.0, 0.0, 0.0, 1.0],
2352            weight: 1.0,
2353        }];
2354        assert_eq!(
2355            raim_fde_design(&too_few, &RangeFdeOptions::default()),
2356            Err(QualityError::InvalidDesign)
2357        );
2358
2359        // Ragged design rows.
2360        let mut ragged = range_rows([1.0, 0.0, 0.0, 0.0]);
2361        ragged[1].design_row.pop();
2362        assert_eq!(
2363            raim_fde_design(&ragged, &RangeFdeOptions::default()),
2364            Err(QualityError::InvalidDesign)
2365        );
2366
2367        // Non-positive weight and non-finite residual.
2368        let mut bad_weight = range_rows([1.0, 0.0, 0.0, 0.0]);
2369        bad_weight[0].weight = 0.0;
2370        assert_eq!(
2371            raim_fde_design(&bad_weight, &RangeFdeOptions::default()),
2372            Err(QualityError::InvalidWeight)
2373        );
2374
2375        let mut bad_residual = range_rows([1.0, 0.0, 0.0, 0.0]);
2376        bad_residual[0].residual_m = f64::NAN;
2377        assert_eq!(
2378            raim_fde_design(&bad_residual, &RangeFdeOptions::default()),
2379            Err(QualityError::InvalidResiduals)
2380        );
2381
2382        let rows = range_rows([1.0, 0.0, 0.0, 0.0]);
2383        let bad_p = RangeFdeOptions {
2384            p_fa: 1.0,
2385            ..Default::default()
2386        };
2387        assert_eq!(
2388            raim_fde_design(&rows, &bad_p),
2389            Err(QualityError::InvalidProbability)
2390        );
2391    }
2392
2393    #[test]
2394    fn chi_square_threshold_matches_rtklib_demo5_chisqr_table() {
2395        // RTKLIB demo5 chi-square detection thresholds, alpha = 0.001
2396        // (p_fa = 1e-3), from `rtkcmn.c:192` `chisqr[]`, dof 1..=20. The global
2397        // RAIM test compares the weighted residual sum of squares against this
2398        // quantile, so reproducing the table is the demo5 oracle for the
2399        // threshold side of the test.
2400        let table: [f64; 20] = [
2401            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,
2402            37.7, 39.3, 40.8, 42.3, 43.8, 45.3,
2403        ];
2404        for (i, &expected) in table.iter().enumerate() {
2405            let dof = i + 1;
2406            let got = chi2_inv(0.999, dof).expect("chi2 quantile");
2407            let tol = (0.01 * expected).max(0.05);
2408            assert!(
2409                (got - expected).abs() < tol,
2410                "dof {dof}: got {got}, demo5 chisqr {expected}"
2411            );
2412        }
2413    }
2414}