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 = (elevation_deg * DEG_TO_RAD).sin();
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 * 10.0_f64.powf(-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 * (-x + a * x.ln() - gln).exp()
1264    } else {
1265        let gln = log_gamma(a);
1266        let q = gamma_continued_fraction(a, x) * (-x + a * x.ln() - gln).exp();
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        std::f64::consts::PI.ln() - (std::f64::consts::PI * z).sin().ln() - log_gamma(1.0 - z)
1335    } else {
1336        let z = z - 1.0;
1337        let mut x = LANCZOS[0];
1338        for (i, coef) in LANCZOS.iter().enumerate().skip(1) {
1339            x += coef / (z + i as f64);
1340        }
1341        let t = z + 7.5;
1342        SQRT_2PI.ln() + (z + 0.5) * t.ln() - t + x.ln()
1343    }
1344}
1345
1346#[cfg(test)]
1347mod tests {
1348    use super::*;
1349    use crate::{GnssSatelliteId, GnssSystem};
1350
1351    use std::path::PathBuf;
1352
1353    use crate::rinex_nav::BroadcastStore;
1354    use crate::rinex_obs::{pseudoranges, RinexObs, SignalPolicy};
1355    use crate::spp::{Corrections, KlobucharCoeffs, RobustConfig, SurfaceMet};
1356
1357    fn fixture_path(name: &str) -> PathBuf {
1358        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1359            .join("tests/fixtures")
1360            .join(name)
1361    }
1362
1363    /// The real ESBC broadcast navigation store (day 177, GPS) used as a live,
1364    /// converging FDE ephemeris source.
1365    fn esbc_broadcast_store() -> BroadcastStore {
1366        let nav = std::fs::read_to_string(fixture_path("nav/ESBC00DNK_R_20201770000_01D_MN.rnx"))
1367            .expect("read ESBC broadcast NAV fixture");
1368        BroadcastStore::from_nav(&nav).expect("parse ESBC broadcast NAV")
1369    }
1370
1371    /// The real ESBC first-epoch GPS L1 pseudorange solve inputs.
1372    fn esbc_first_epoch_inputs() -> SolveInputs {
1373        let obs_text = std::fs::read_to_string(fixture_path(
1374            "obs/ESBC00DNK_R_20201770000_01D_30S_MO_trim.rnx",
1375        ))
1376        .expect("read ESBC OBS fixture");
1377        let obs = RinexObs::parse(&obs_text).expect("parse ESBC OBS fixture");
1378        let policy = SignalPolicy {
1379            codes: [(GnssSystem::Gps, vec!["C1C".to_string()])]
1380                .into_iter()
1381                .collect(),
1382        };
1383        let observations = pseudoranges(&obs, &obs.epochs()[0], &policy)
1384            .expect("valid pseudoranges")
1385            .into_iter()
1386            .map(|(satellite_id, pseudorange_m)| Observation {
1387                satellite_id,
1388                pseudorange_m,
1389            })
1390            .collect();
1391
1392        SolveInputs {
1393            observations,
1394            t_rx_j2000_s: 646_315_200.0,
1395            t_rx_second_of_day_s: 0.0,
1396            day_of_year: 177.0,
1397            initial_guess: [3_582_135.0, 532_569.0, 5_232_779.0, 0.0],
1398            corrections: Corrections {
1399                ionosphere: false,
1400                troposphere: true,
1401            },
1402            klobuchar: KlobucharCoeffs {
1403                alpha: [0.0; 4],
1404                beta: [0.0; 4],
1405            },
1406            beidou_klobuchar: None,
1407            galileo_nequick: None,
1408            sbas_iono: None,
1409            glonass_channels: std::collections::BTreeMap::new(),
1410            met: SurfaceMet {
1411                pressure_hpa: 1013.25,
1412                temperature_k: 288.15,
1413                relative_humidity: 0.5,
1414            },
1415            robust: None,
1416        }
1417    }
1418
1419    fn assert_receiver_solution_bits_eq(left: &ReceiverSolution, right: &ReceiverSolution) {
1420        assert_eq!(left.position.x_m.to_bits(), right.position.x_m.to_bits());
1421        assert_eq!(left.position.y_m.to_bits(), right.position.y_m.to_bits());
1422        assert_eq!(left.position.z_m.to_bits(), right.position.z_m.to_bits());
1423        assert_eq!(left.geodetic, right.geodetic);
1424        assert_eq!(left.rx_clock_s.to_bits(), right.rx_clock_s.to_bits());
1425        assert_eq!(left.dop, right.dop);
1426        assert_eq!(
1427            left.residuals_m
1428                .iter()
1429                .map(|v| v.to_bits())
1430                .collect::<Vec<_>>(),
1431            right
1432                .residuals_m
1433                .iter()
1434                .map(|v| v.to_bits())
1435                .collect::<Vec<_>>()
1436        );
1437        assert_eq!(left.used_sats, right.used_sats);
1438        assert_eq!(left.rejected_sats, right.rejected_sats);
1439        assert_eq!(left.metadata, right.metadata);
1440    }
1441
1442    fn fde_spp_options(inputs: &SolveInputs) -> FdeSppOptions {
1443        FdeSppOptions {
1444            fde: FdeOptions {
1445                raim: RaimOptions::default(),
1446                max_iterations: inputs.observations.len().saturating_sub(4),
1447            },
1448            validation: SolutionValidationOptions::default(),
1449        }
1450    }
1451
1452    fn position_delta_m(left: &ReceiverSolution, right: &ReceiverSolution) -> f64 {
1453        ((left.position.x_m - right.position.x_m).powi(2)
1454            + (left.position.y_m - right.position.y_m).powi(2)
1455            + (left.position.z_m - right.position.z_m).powi(2))
1456        .sqrt()
1457    }
1458
1459    /// The `fde_spp` driver must equal the hand-assembled solve + validate + FDE
1460    /// loop the bindings each spell out, bit-for-bit, on a real converging
1461    /// scenario with one injected outlier that the loop detects and excludes.
1462    #[test]
1463    fn fde_spp_matches_manual_composition_bit_for_bit() {
1464        let store = esbc_broadcast_store();
1465        let with_geodetic = true;
1466
1467        // Solve the clean set first so the outlier is injected on a satellite the
1468        // solver actually uses (the real epoch drops three low-elevation GPS
1469        // satellites before RAIM ever sees them).
1470        let clean_inputs = esbc_first_epoch_inputs();
1471        let clean = solve(&store, &clean_inputs, with_geodetic).expect("clean solve converges");
1472        assert!(
1473            clean.used_sats.len() >= 6,
1474            "scenario needs redundancy for a testable RAIM exclusion"
1475        );
1476        let outlier_sat = *clean.used_sats.last().expect("a used satellite");
1477
1478        // Inject a gross 1 km bias on that used satellite so RAIM has a clear
1479        // worst residual to exclude.
1480        let mut inputs = clean_inputs;
1481        let outlier_obs = inputs
1482            .observations
1483            .iter_mut()
1484            .find(|obs| obs.satellite_id == outlier_sat)
1485            .expect("outlier satellite is present in the observation set");
1486        outlier_obs.pseudorange_m += 1000.0;
1487
1488        let options = fde_spp_options(&inputs);
1489
1490        // Driver path.
1491        let driver = fde_spp(&store, &inputs, with_geodetic, &options)
1492            .expect("driver FDE resolves the fault");
1493
1494        // Hand-assembled reference: exactly the loop the bindings reduce to.
1495        let observations = inputs.observations.clone();
1496        let reference = fde(&observations, &options.fde, |remaining| {
1497            let mut next = inputs.clone();
1498            next.observations = remaining.to_vec();
1499            let solution = solve(&store, &next, with_geodetic).map_err(FdeSppError::Spp)?;
1500            validate_receiver_solution(&solution, options.validation)
1501                .map_err(FdeSppError::Validation)?;
1502            Ok::<_, FdeSppError>(solution)
1503        })
1504        .expect("reference FDE resolves the fault");
1505
1506        // Bit-for-bit parity: the driver IS the hand-assembled loop.
1507        assert_eq!(driver.excluded, reference.excluded);
1508        assert_eq!(driver.iterations, reference.iterations);
1509        assert_receiver_solution_bits_eq(&driver.solution, &reference.solution);
1510
1511        // The fault drove the loop to detect, exclude, and re-solve: the
1512        // protected solution dropped at least one satellite and the surviving
1513        // set is self-consistent under RAIM. (The injected blunder is smeared by
1514        // unit-weight RAIM onto other residuals, so the excluded set is the
1515        // engine's own RAIM decision; the driver mirrors it exactly rather than
1516        // imposing any exclusion policy of its own.)
1517        assert!(driver.iterations >= 1, "the fault must drive an exclusion");
1518        assert!(!driver.excluded.is_empty());
1519        assert_eq!(driver.excluded.len(), driver.iterations);
1520        let surviving = raim_for_solution(&driver.solution, &options.fde.raim).expect("raim");
1521        assert!(
1522            !surviving.fault_detected,
1523            "the protected set must pass RAIM (or be untestable)"
1524        );
1525    }
1526
1527    /// A clean set converges with no exclusion, and the driver still equals the
1528    /// hand-assembled composition bit-for-bit.
1529    #[test]
1530    fn fde_spp_clean_set_takes_no_exclusion_and_matches_manual() {
1531        let store = esbc_broadcast_store();
1532        let inputs = esbc_first_epoch_inputs();
1533        let options = fde_spp_options(&inputs);
1534
1535        let driver = fde_spp(&store, &inputs, false, &options).expect("driver solves clean set");
1536
1537        let observations = inputs.observations.clone();
1538        let reference = fde(&observations, &options.fde, |remaining| {
1539            let mut next = inputs.clone();
1540            next.observations = remaining.to_vec();
1541            let solution = solve(&store, &next, false).map_err(FdeSppError::Spp)?;
1542            validate_receiver_solution(&solution, options.validation)
1543                .map_err(FdeSppError::Validation)?;
1544            Ok::<_, FdeSppError>(solution)
1545        })
1546        .expect("reference solves clean set");
1547
1548        assert_eq!(driver.iterations, 0);
1549        assert!(driver.excluded.is_empty());
1550        assert_eq!(driver.iterations, reference.iterations);
1551        assert_eq!(driver.excluded, reference.excluded);
1552        assert_receiver_solution_bits_eq(&driver.solution, &reference.solution);
1553    }
1554
1555    #[test]
1556    fn spp_robust_fde_driver_clean_set_uses_robust_solve_without_exclusion() {
1557        let store = esbc_broadcast_store();
1558        let inputs = esbc_first_epoch_inputs();
1559        let options = fde_spp_options(&inputs);
1560
1561        let driver =
1562            spp_robust_fde_driver(&store, &inputs, false, RobustConfig::default(), &options)
1563                .expect("robust FDE solves clean set");
1564
1565        assert_eq!(driver.iterations, 0);
1566        assert!(driver.excluded.is_empty());
1567        assert!(driver.solution.metadata.outer_iterations > 0);
1568        assert!(driver.solution.metadata.final_robust_scale_m.is_some());
1569        let surviving = raim_for_solution(&driver.solution, &options.fde.raim).expect("raim");
1570        assert!(!surviving.fault_detected);
1571    }
1572
1573    #[test]
1574    fn spp_robust_fde_driver_excludes_fault_and_recovers_solution() {
1575        let store = esbc_broadcast_store();
1576        let clean_inputs = esbc_first_epoch_inputs();
1577        let clean_options = fde_spp_options(&clean_inputs);
1578        let robust = RobustConfig::default();
1579        let clean = spp_robust_fde_driver(&store, &clean_inputs, false, robust, &clean_options)
1580            .expect("clean robust FDE solve");
1581        let outlier_sat = gps(15);
1582        assert!(clean.solution.used_sats.contains(&outlier_sat));
1583
1584        let mut faulty_inputs = clean_inputs.clone();
1585        let outlier_obs = faulty_inputs
1586            .observations
1587            .iter_mut()
1588            .find(|obs| obs.satellite_id == outlier_sat)
1589            .expect("outlier satellite is observed");
1590        outlier_obs.pseudorange_m += 1000.0;
1591        let faulty_options = fde_spp_options(&faulty_inputs);
1592
1593        let driver = spp_robust_fde_driver(&store, &faulty_inputs, false, robust, &faulty_options)
1594            .expect("robust FDE resolves fault");
1595
1596        assert_eq!(driver.iterations, 1);
1597        assert_eq!(driver.iterations, driver.excluded.len());
1598        assert_eq!(driver.excluded, vec![outlier_sat.to_string()]);
1599        assert!(driver.solution.metadata.outer_iterations > 0);
1600        assert!(driver.solution.metadata.final_robust_scale_m.is_some());
1601        let surviving = raim_for_solution(&driver.solution, &faulty_options.fde.raim)
1602            .expect("surviving set RAIM");
1603        assert!(!surviving.fault_detected);
1604        let recovered_delta_m = position_delta_m(&driver.solution, &clean.solution);
1605        assert!(
1606            recovered_delta_m < 1.0,
1607            "protected solution should stay close to the clean robust solution, got {recovered_delta_m} m with exclusions {:?}",
1608            driver.excluded
1609        );
1610    }
1611
1612    #[derive(Debug, Clone)]
1613    struct TestSolution {
1614        used_sats: Vec<String>,
1615        residuals_m: Vec<f64>,
1616    }
1617
1618    impl RaimSolution for TestSolution {
1619        fn raim_used_sats(&self) -> Vec<String> {
1620            self.used_sats.clone()
1621        }
1622
1623        fn raim_residuals_m(&self) -> &[f64] {
1624            &self.residuals_m
1625        }
1626    }
1627
1628    fn gps(prn: u8) -> GnssSatelliteId {
1629        GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
1630    }
1631
1632    fn valid_receiver_solution() -> ReceiverSolution {
1633        ReceiverSolution {
1634            position: crate::frame::ItrfPositionM::new(6_378_137.0, 0.0, 0.0).unwrap(),
1635            geodetic: None,
1636            rx_clock_s: 0.0,
1637            system_clocks_s: vec![(GnssSystem::Gps, 0.0)],
1638            dop: Some(crate::dop::Dop {
1639                gdop: 2.5,
1640                pdop: 2.0,
1641                hdop: 1.5,
1642                vdop: 1.0,
1643                tdop: 0.5,
1644                system_tdops: vec![(GnssSystem::Gps, 0.5)],
1645            }),
1646            system_tdops: vec![(GnssSystem::Gps, 0.5)],
1647            residuals_m: vec![0.1, -0.1, 0.0, 0.05, -0.05],
1648            used_sats: (1..=5).map(gps).collect(),
1649            rejected_sats: Vec::new(),
1650            geometry_quality: crate::geometry_quality::GeometryQuality {
1651                tier: crate::geometry_quality::ObservabilityTier::Nominal,
1652                redundancy: 1,
1653                rank: 4,
1654                condition_number: 1.0,
1655                gdop: 2.5,
1656                raim_checkable: true,
1657                covariance_validated: true,
1658            },
1659            metadata: crate::spp::SolutionMetadata {
1660                iterations: 3,
1661                converged: true,
1662                status: crate::astro::math::least_squares::Status::StepTolerance,
1663                ionosphere_applied: false,
1664                troposphere_applied: false,
1665                outer_iterations: 0,
1666                final_robust_scale_m: None,
1667                used_count: 5,
1668                systems: vec![GnssSystem::Gps],
1669                redundancy: 1,
1670                raim_checkable: true,
1671            },
1672        }
1673    }
1674
1675    #[test]
1676    fn pseudorange_variance_matches_elevation_model() {
1677        let opts = PseudorangeVarianceOptions::default();
1678        let variance = pseudorange_variance(30.0, opts).unwrap();
1679        assert!((variance - 0.45).abs() < 1.0e-15);
1680        assert_eq!(
1681            pseudorange_variance(0.0, opts),
1682            Err(QualityError::InvalidElevation)
1683        );
1684        let horizon_opts = PseudorangeVarianceOptions { b_m: 0.0, ..opts };
1685        assert_eq!(
1686            pseudorange_variance(0.0, horizon_opts),
1687            Ok(horizon_opts.a_m * horizon_opts.a_m)
1688        );
1689        assert_eq!(
1690            pseudorange_variance(-90.0, horizon_opts),
1691            Ok(horizon_opts.a_m * horizon_opts.a_m)
1692        );
1693        assert_eq!(
1694            pseudorange_variance(90.1, horizon_opts),
1695            Err(QualityError::InvalidElevation)
1696        );
1697        assert_eq!(
1698            pseudorange_variance(f64::NAN, opts),
1699            Err(QualityError::InvalidElevation)
1700        );
1701    }
1702
1703    #[test]
1704    fn cn0_model_requires_cn0_and_adds_noise_term() {
1705        let opts = PseudorangeVarianceOptions {
1706            model: PseudorangeVarianceModel::ElevationCn0,
1707            cn0_dbhz: None,
1708            ..Default::default()
1709        };
1710        assert_eq!(
1711            pseudorange_variance(30.0, opts),
1712            Err(QualityError::MissingCn0)
1713        );
1714
1715        let weak = pseudorange_variance(
1716            30.0,
1717            PseudorangeVarianceOptions {
1718                cn0_dbhz: Some(30.0),
1719                ..opts
1720            },
1721        )
1722        .unwrap();
1723        let strong = pseudorange_variance(
1724            30.0,
1725            PseudorangeVarianceOptions {
1726                cn0_dbhz: Some(50.0),
1727                ..opts
1728            },
1729        )
1730        .unwrap();
1731        assert!(strong < weak);
1732    }
1733
1734    #[test]
1735    fn pseudorange_variance_rejects_nonfinite_and_negative_parameters() {
1736        let invalid_a = PseudorangeVarianceOptions {
1737            a_m: f64::NAN,
1738            ..Default::default()
1739        };
1740        assert_eq!(
1741            pseudorange_variance(30.0, invalid_a),
1742            Err(QualityError::InvalidParameter)
1743        );
1744
1745        let invalid_b = PseudorangeVarianceOptions {
1746            b_m: -1.0,
1747            ..Default::default()
1748        };
1749        assert_eq!(
1750            pseudorange_variance(30.0, invalid_b),
1751            Err(QualityError::InvalidParameter)
1752        );
1753
1754        let invalid_cn0_scale = PseudorangeVarianceOptions {
1755            cn0_scale_m2: f64::INFINITY,
1756            ..Default::default()
1757        };
1758        assert_eq!(
1759            pseudorange_variance(30.0, invalid_cn0_scale),
1760            Err(QualityError::InvalidParameter)
1761        );
1762
1763        let invalid_cn0 = PseudorangeVarianceOptions {
1764            model: PseudorangeVarianceModel::ElevationCn0,
1765            cn0_dbhz: Some(f64::NAN),
1766            ..Default::default()
1767        };
1768        assert_eq!(
1769            pseudorange_variance(30.0, invalid_cn0),
1770            Err(QualityError::InvalidParameter)
1771        );
1772    }
1773
1774    #[test]
1775    fn pseudorange_variance_rejects_zero_total_variance() {
1776        let zero_variance = PseudorangeVarianceOptions {
1777            a_m: 0.0,
1778            b_m: 0.0,
1779            ..Default::default()
1780        };
1781        assert_eq!(
1782            pseudorange_variance(30.0, zero_variance),
1783            Err(QualityError::InvalidParameter)
1784        );
1785
1786        let entries = vec![WeightEntry {
1787            satellite_id: "G01".to_string(),
1788            elevation_deg: 30.0,
1789            cn0_dbhz: None,
1790        }];
1791        let weights = weight_vector(&entries, zero_variance);
1792        assert!(
1793            !weights.contains_key("G01"),
1794            "zero variance must not produce an infinite inverse-variance weight"
1795        );
1796    }
1797
1798    #[test]
1799    fn sigma_and_weight_maps_drop_invalid_entries() {
1800        let entries = vec![
1801            WeightEntry {
1802                satellite_id: "G01".to_string(),
1803                elevation_deg: 90.0,
1804                cn0_dbhz: None,
1805            },
1806            WeightEntry {
1807                satellite_id: "G02".to_string(),
1808                elevation_deg: -91.0,
1809                cn0_dbhz: None,
1810            },
1811        ];
1812        let sigmas = sigmas(&entries, Default::default());
1813        let weights = weight_vector(&entries, Default::default());
1814        assert!(sigmas.contains_key("G01"));
1815        assert!(!sigmas.contains_key("G02"));
1816        assert_eq!(weights["G01"], 1.0 / (sigmas["G01"] * sigmas["G01"]));
1817    }
1818
1819    #[test]
1820    fn sigma_and_weight_maps_retain_horizon_entries_without_elevation_term() {
1821        let entries = vec![
1822            WeightEntry {
1823                satellite_id: "G01".to_string(),
1824                elevation_deg: 0.0,
1825                cn0_dbhz: None,
1826            },
1827            WeightEntry {
1828                satellite_id: "G02".to_string(),
1829                elevation_deg: f64::NAN,
1830                cn0_dbhz: None,
1831            },
1832        ];
1833        let options = PseudorangeVarianceOptions {
1834            b_m: 0.0,
1835            ..Default::default()
1836        };
1837        let sigmas = sigmas(&entries, options);
1838        let weights = weight_vector(&entries, options);
1839        assert_eq!(sigmas["G01"], options.a_m);
1840        assert_eq!(weights["G01"], 1.0 / (options.a_m * options.a_m));
1841        assert!(!sigmas.contains_key("G02"));
1842        assert!(!weights.contains_key("G02"));
1843    }
1844
1845    #[test]
1846    fn chi_square_inverse_matches_reference_values() {
1847        let refs = [
1848            (1, 10.828),
1849            (2, 13.816),
1850            (3, 16.266),
1851            (4, 18.467),
1852            (5, 20.515),
1853        ];
1854        for (dof, expected) in refs {
1855            let got = chi2_inv(0.999, dof).unwrap();
1856            assert!((got - expected).abs() < 1.0e-3);
1857        }
1858        assert_eq!(chi2_inv(1.0, 1), Err(QualityError::InvalidProbability));
1859        assert_eq!(chi2_inv(0.95, 0), Err(QualityError::InvalidDof));
1860    }
1861
1862    #[test]
1863    fn residual_diagnostics_reports_weighted_redundancy_and_reduced_chi_square() {
1864        let residuals = [1.0, -2.0, 0.5, 3.0, -1.5];
1865        let weights = [1.0, 0.25, 4.0, 1.0, 0.5];
1866        let diagnostics =
1867            residual_diagnostics(&residuals, Some(&weights), 3, Some(1.0e-3)).expect("diagnostics");
1868
1869        let wss = residuals
1870            .iter()
1871            .zip(weights)
1872            .map(|(r, w)| r * r * w)
1873            .sum::<f64>();
1874        assert_eq!(diagnostics.n_residuals, 5);
1875        assert_eq!(diagnostics.n_parameters, 3);
1876        assert_eq!(diagnostics.degrees_of_freedom, 2);
1877        assert_eq!(diagnostics.weighted_sum_squares.to_bits(), wss.to_bits());
1878        assert_eq!(
1879            diagnostics.reduced_chi_square.unwrap().to_bits(),
1880            (wss / 2.0).to_bits()
1881        );
1882        assert_eq!(
1883            diagnostics.normalized_residuals[1].to_bits(),
1884            (-1.0f64).to_bits()
1885        );
1886        assert_eq!(diagnostics.worst_index, Some(3));
1887        assert!(diagnostics.chi_square_threshold.unwrap().is_finite());
1888        assert_eq!(diagnostics.chi_square_consistent, Some(true));
1889    }
1890
1891    #[test]
1892    fn residual_diagnostics_handles_no_redundancy_and_rejects_bad_inputs() {
1893        let residuals = [1.0, -1.0];
1894        let diagnostics =
1895            residual_diagnostics(&residuals, None, 2, Some(1.0e-3)).expect("diagnostics");
1896        assert_eq!(diagnostics.degrees_of_freedom, 0);
1897        assert_eq!(diagnostics.reduced_chi_square, None);
1898        assert_eq!(diagnostics.chi_square_threshold, None);
1899        assert_eq!(diagnostics.chi_square_consistent, None);
1900
1901        assert_eq!(
1902            residual_diagnostics(&[1.0, f64::NAN], None, 1, None),
1903            Err(QualityError::InvalidResiduals)
1904        );
1905        assert_eq!(
1906            residual_diagnostics(&[1.0], Some(&[0.0]), 0, None),
1907            Err(QualityError::InvalidWeight)
1908        );
1909        assert_eq!(
1910            residual_diagnostics(&[1.0], None, 0, Some(1.0)),
1911            Err(QualityError::InvalidProbability)
1912        );
1913    }
1914
1915    #[test]
1916    fn raim_reports_fault_and_worst_satellite() {
1917        let input = RaimInput {
1918            used_sats: ["G01", "G02", "G03", "G04", "G05"]
1919                .into_iter()
1920                .map(str::to_string)
1921                .collect(),
1922            residuals_m: vec![0.0, 0.0, 0.0, 0.0, 5.0],
1923        };
1924        let result = raim(&input, &RaimOptions::default()).unwrap();
1925        assert!(result.fault_detected);
1926        assert!(result.testable);
1927        assert_eq!(result.dof, 1);
1928        assert_eq!(result.test_statistic, 25.0);
1929        assert_eq!(result.worst_sat.as_deref(), Some("G05"));
1930    }
1931
1932    #[test]
1933    fn raim_dof_zero_is_not_testable() {
1934        let input = RaimInput {
1935            used_sats: ["G01", "G02", "G03", "G04"]
1936                .into_iter()
1937                .map(str::to_string)
1938                .collect(),
1939            residuals_m: vec![0.0, 0.0, 0.0, 0.0],
1940        };
1941        let result = raim(&input, &RaimOptions::default()).unwrap();
1942        assert!(!result.fault_detected);
1943        assert!(!result.testable);
1944        assert_eq!(result.threshold, None);
1945        assert_eq!(result.dof, 0);
1946    }
1947
1948    #[test]
1949    fn raim_rejects_nonpositive_system_overrides() {
1950        let input = RaimInput {
1951            used_sats: ["G01", "G02", "G03", "G04", "G05"]
1952                .into_iter()
1953                .map(str::to_string)
1954                .collect(),
1955            residuals_m: vec![0.0; 5],
1956        };
1957
1958        for n_systems in [0, -1] {
1959            let options = RaimOptions {
1960                n_systems: Some(n_systems),
1961                ..Default::default()
1962            };
1963            assert_eq!(
1964                raim(&input, &options),
1965                Err(QualityError::InvalidSystemCount)
1966            );
1967        }
1968    }
1969
1970    #[test]
1971    fn raim_positive_system_override_controls_dof() {
1972        let input = RaimInput {
1973            used_sats: ["G01", "G02", "G03", "G04", "G05", "G06"]
1974                .into_iter()
1975                .map(str::to_string)
1976                .collect(),
1977            residuals_m: vec![0.0; 6],
1978        };
1979        let options = RaimOptions {
1980            n_systems: Some(2),
1981            ..Default::default()
1982        };
1983
1984        let result = raim(&input, &options).unwrap();
1985        assert!(result.testable);
1986        assert_eq!(result.dof, 1);
1987    }
1988
1989    #[test]
1990    fn raim_rejects_misaligned_or_nonfinite_residuals() {
1991        let input = RaimInput {
1992            used_sats: ["G01", "G02"].into_iter().map(str::to_string).collect(),
1993            residuals_m: vec![1.0],
1994        };
1995        assert_eq!(
1996            raim(&input, &RaimOptions::default()),
1997            Err(QualityError::InvalidResiduals)
1998        );
1999
2000        let input = RaimInput {
2001            used_sats: ["G01", "G02"].into_iter().map(str::to_string).collect(),
2002            residuals_m: vec![1.0, f64::NAN],
2003        };
2004        assert_eq!(
2005            raim(&input, &RaimOptions::default()),
2006            Err(QualityError::InvalidResiduals)
2007        );
2008    }
2009
2010    #[test]
2011    fn raim_rejects_nonfinite_weights_and_probability() {
2012        let input = RaimInput {
2013            used_sats: ["G01", "G02", "G03", "G04", "G05"]
2014                .into_iter()
2015                .map(str::to_string)
2016                .collect(),
2017            residuals_m: vec![0.0; 5],
2018        };
2019        let mut weights = BTreeMap::new();
2020        weights.insert("G01".to_string(), f64::NAN);
2021        let options = RaimOptions {
2022            weights: RaimWeights::BySatellite(weights),
2023            ..Default::default()
2024        };
2025        assert_eq!(raim(&input, &options), Err(QualityError::InvalidWeight));
2026
2027        let options = RaimOptions {
2028            p_fa: f64::NAN,
2029            ..Default::default()
2030        };
2031        assert_eq!(
2032            raim(&input, &options),
2033            Err(QualityError::InvalidProbability)
2034        );
2035    }
2036
2037    #[test]
2038    fn fde_excludes_largest_normalized_residual() {
2039        let observations: Vec<Observation> = (1..=5)
2040            .map(|prn| Observation {
2041                satellite_id: gps(prn),
2042                pseudorange_m: prn as f64,
2043            })
2044            .collect();
2045
2046        let options = FdeOptions {
2047            raim: RaimOptions::default(),
2048            max_iterations: 1,
2049        };
2050        let result = fde(&observations, &options, |remaining| {
2051            let used_sats = remaining
2052                .iter()
2053                .map(|ob| ob.satellite_id.to_string())
2054                .collect::<Vec<_>>();
2055            let residuals_m = remaining
2056                .iter()
2057                .map(|ob| if ob.satellite_id == gps(5) { 5.0 } else { 0.0 })
2058                .collect::<Vec<_>>();
2059            Ok::<_, ()>(TestSolution {
2060                used_sats,
2061                residuals_m,
2062            })
2063        })
2064        .unwrap();
2065
2066        assert_eq!(result.excluded, vec!["G05".to_string()]);
2067        assert_eq!(result.iterations, 1);
2068        assert_eq!(result.solution.used_sats.len(), 4);
2069    }
2070
2071    #[test]
2072    fn fde_refuses_fault_when_budget_is_exhausted() {
2073        let observations: Vec<Observation> = (1..=5)
2074            .map(|prn| Observation {
2075                satellite_id: gps(prn),
2076                pseudorange_m: prn as f64,
2077            })
2078            .collect();
2079        let options = FdeOptions {
2080            raim: RaimOptions::default(),
2081            max_iterations: 0,
2082        };
2083        let err = fde(&observations, &options, |remaining| {
2084            Ok::<_, ()>(TestSolution {
2085                used_sats: remaining
2086                    .iter()
2087                    .map(|ob| ob.satellite_id.to_string())
2088                    .collect(),
2089                residuals_m: vec![0.0, 0.0, 0.0, 0.0, 5.0],
2090            })
2091        })
2092        .unwrap_err();
2093
2094        assert_eq!(err, FdeError::FaultUnresolved(25.0));
2095    }
2096
2097    #[test]
2098    fn receiver_solution_validation_rejects_invalid_gate_options() {
2099        let solution = valid_receiver_solution();
2100        for (options, field, reason) in [
2101            (
2102                SolutionValidationOptions {
2103                    max_pdop: Some(f64::NAN),
2104                    ..Default::default()
2105                },
2106                "max_pdop",
2107                "not finite",
2108            ),
2109            (
2110                SolutionValidationOptions {
2111                    max_pdop: Some(0.0),
2112                    ..Default::default()
2113                },
2114                "max_pdop",
2115                "not positive",
2116            ),
2117            (
2118                SolutionValidationOptions {
2119                    min_plausible_radius_m: 0.0,
2120                    ..Default::default()
2121                },
2122                "min_plausible_radius_m",
2123                "not positive",
2124            ),
2125            (
2126                SolutionValidationOptions {
2127                    max_plausible_radius_m: f64::INFINITY,
2128                    ..Default::default()
2129                },
2130                "max_plausible_radius_m",
2131                "not finite",
2132            ),
2133            (
2134                SolutionValidationOptions {
2135                    max_converged_residual_rms_m: f64::NAN,
2136                    ..Default::default()
2137                },
2138                "max_converged_residual_rms_m",
2139                "not finite",
2140            ),
2141        ] {
2142            assert_eq!(
2143                validate_receiver_solution(&solution, options),
2144                Err(SolutionValidationError::InvalidOptions { field, reason })
2145            );
2146        }
2147
2148        let inverted_radius = SolutionValidationOptions {
2149            min_plausible_radius_m: 8_000_000.0,
2150            max_plausible_radius_m: 7_000_000.0,
2151            ..Default::default()
2152        };
2153        assert_eq!(
2154            validate_receiver_solution(&solution, inverted_radius),
2155            Err(SolutionValidationError::InvalidOptions {
2156                field: "plausible_radius_m",
2157                reason: "must be increasing",
2158            })
2159        );
2160    }
2161
2162    #[test]
2163    fn receiver_solution_validation_rejects_nonfinite_residuals() {
2164        let mut solution = valid_receiver_solution();
2165        solution.residuals_m[1] = f64::NAN;
2166        assert_eq!(
2167            validate_receiver_solution(&solution, SolutionValidationOptions::default()),
2168            Err(SolutionValidationError::InvalidResiduals)
2169        );
2170    }
2171
2172    // --- generic range RAIM/FDE -------------------------------------------
2173
2174    fn range_design_rows() -> Vec<[f64; 4]> {
2175        vec![
2176            [-0.10, -0.20, -0.97, 1.0],
2177            [0.50, -0.30, -0.81, 1.0],
2178            [-0.60, 0.40, -0.69, 1.0],
2179            [0.20, 0.80, -0.56, 1.0],
2180            [0.70, 0.50, -0.51, 1.0],
2181            [-0.50, -0.70, -0.51, 1.0],
2182            [0.30, -0.60, -0.74, 1.0],
2183            [-0.80, 0.10, -0.59, 1.0],
2184        ]
2185    }
2186
2187    fn range_rows(dx_true: [f64; 4]) -> Vec<RangeFdeRow> {
2188        range_design_rows()
2189            .iter()
2190            .enumerate()
2191            .map(|(i, h)| RangeFdeRow {
2192                id: format!("S{:02}", i + 1),
2193                residual_m: h.iter().zip(dx_true).map(|(a, b)| a * b).sum(),
2194                design_row: h.to_vec(),
2195                weight: 1.0,
2196            })
2197            .collect()
2198    }
2199
2200    fn assert_close(got: &[f64], want: &[f64], tol: f64) {
2201        assert_eq!(got.len(), want.len());
2202        for (g, w) in got.iter().zip(want) {
2203            assert!((g - w).abs() < tol, "got {g}, want {w}");
2204        }
2205    }
2206
2207    #[test]
2208    fn range_fde_clean_set_recovers_state_without_exclusions() {
2209        let dx_true = [1.0, -2.0, 0.5, 3.0];
2210        let rows = range_rows(dx_true);
2211        let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2212
2213        assert!(!result.global_test.fault_detected);
2214        assert!(result.global_test.testable);
2215        assert_eq!(result.global_test.dof, 4);
2216        assert!(result.excluded.is_empty());
2217        assert_eq!(result.iterations, 0);
2218        assert!(result.global_test.weighted_sum_squares < 1.0e-12);
2219        assert_close(&result.state_correction, &dx_true, 1.0e-9);
2220        assert_eq!(result.state_covariance.len(), 4);
2221    }
2222
2223    #[test]
2224    fn range_fde_detects_and_excludes_a_single_outlier() {
2225        let dx_true = [1.0, -2.0, 0.5, 3.0];
2226        let mut rows = range_rows(dx_true);
2227        rows[2].residual_m += 50.0; // inject a fault on S03
2228
2229        let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2230
2231        assert_eq!(result.excluded, vec!["S03".to_string()]);
2232        assert_eq!(result.iterations, 1);
2233        assert!(!result.global_test.fault_detected);
2234        assert_close(&result.state_correction, &dx_true, 1.0e-9);
2235
2236        let s03 = result
2237            .diagnostics
2238            .iter()
2239            .find(|d| d.id == "S03")
2240            .expect("S03 diagnostic");
2241        assert!(s03.excluded);
2242        // The excluded fault is large against the clean protected solution.
2243        assert!(s03.post_fit_residual_m.abs() > 40.0);
2244        // Surviving measurements are consistent.
2245        for d in result.diagnostics.iter().filter(|d| !d.excluded) {
2246            assert!(d.normalized_residual.abs() < 1.0e-6);
2247        }
2248    }
2249
2250    #[test]
2251    fn range_fde_excludes_multiple_outliers() {
2252        let dx_true = [0.5, 1.5, -1.0, 2.0];
2253        let mut rows = range_rows(dx_true);
2254        rows[2].residual_m += 50.0; // S03
2255        rows[5].residual_m -= 40.0; // S06
2256
2257        let result = raim_fde_design(&rows, &RangeFdeOptions::default()).expect("fde");
2258
2259        assert_eq!(result.iterations, 2);
2260        let mut excluded = result.excluded.clone();
2261        excluded.sort();
2262        assert_eq!(excluded, vec!["S03".to_string(), "S06".to_string()]);
2263        assert!(!result.global_test.fault_detected);
2264        assert_close(&result.state_correction, &dx_true, 1.0e-9);
2265    }
2266
2267    #[test]
2268    fn range_fde_respects_the_exclusion_budget() {
2269        let dx_true = [0.5, 1.5, -1.0, 2.0];
2270        let mut rows = range_rows(dx_true);
2271        rows[2].residual_m += 50.0;
2272        rows[5].residual_m -= 40.0;
2273
2274        let options = RangeFdeOptions {
2275            max_exclusions: 1,
2276            ..Default::default()
2277        };
2278        let result = raim_fde_design(&rows, &options).expect("fde");
2279
2280        // One exclusion used; the second fault is still flagged.
2281        assert_eq!(result.iterations, 1);
2282        assert_eq!(result.excluded.len(), 1);
2283        assert!(result.global_test.fault_detected);
2284    }
2285
2286    #[test]
2287    fn range_fde_rejects_rank_deficient_geometry() {
2288        let rows: Vec<RangeFdeRow> = (0..5)
2289            .map(|i| RangeFdeRow {
2290                id: format!("S{:02}", i + 1),
2291                residual_m: 1.0,
2292                design_row: vec![1.0, 0.0, 0.0, 1.0], // collinear: rank 2 of 4
2293                weight: 1.0,
2294            })
2295            .collect();
2296        assert_eq!(
2297            raim_fde_design(&rows, &RangeFdeOptions::default()),
2298            Err(QualityError::SingularGeometry)
2299        );
2300    }
2301
2302    #[test]
2303    fn range_fde_rejects_malformed_inputs() {
2304        assert_eq!(
2305            raim_fde_design(&[], &RangeFdeOptions::default()),
2306            Err(QualityError::InvalidDesign)
2307        );
2308
2309        // Fewer measurements than state parameters.
2310        let too_few = vec![RangeFdeRow {
2311            id: "S01".to_string(),
2312            residual_m: 0.0,
2313            design_row: vec![1.0, 0.0, 0.0, 1.0],
2314            weight: 1.0,
2315        }];
2316        assert_eq!(
2317            raim_fde_design(&too_few, &RangeFdeOptions::default()),
2318            Err(QualityError::InvalidDesign)
2319        );
2320
2321        // Ragged design rows.
2322        let mut ragged = range_rows([1.0, 0.0, 0.0, 0.0]);
2323        ragged[1].design_row.pop();
2324        assert_eq!(
2325            raim_fde_design(&ragged, &RangeFdeOptions::default()),
2326            Err(QualityError::InvalidDesign)
2327        );
2328
2329        // Non-positive weight and non-finite residual.
2330        let mut bad_weight = range_rows([1.0, 0.0, 0.0, 0.0]);
2331        bad_weight[0].weight = 0.0;
2332        assert_eq!(
2333            raim_fde_design(&bad_weight, &RangeFdeOptions::default()),
2334            Err(QualityError::InvalidWeight)
2335        );
2336
2337        let mut bad_residual = range_rows([1.0, 0.0, 0.0, 0.0]);
2338        bad_residual[0].residual_m = f64::NAN;
2339        assert_eq!(
2340            raim_fde_design(&bad_residual, &RangeFdeOptions::default()),
2341            Err(QualityError::InvalidResiduals)
2342        );
2343
2344        let rows = range_rows([1.0, 0.0, 0.0, 0.0]);
2345        let bad_p = RangeFdeOptions {
2346            p_fa: 1.0,
2347            ..Default::default()
2348        };
2349        assert_eq!(
2350            raim_fde_design(&rows, &bad_p),
2351            Err(QualityError::InvalidProbability)
2352        );
2353    }
2354
2355    #[test]
2356    fn chi_square_threshold_matches_rtklib_demo5_chisqr_table() {
2357        // RTKLIB demo5 chi-square detection thresholds, alpha = 0.001
2358        // (p_fa = 1e-3), from `rtkcmn.c:192` `chisqr[]`, dof 1..=20. The global
2359        // RAIM test compares the weighted residual sum of squares against this
2360        // quantile, so reproducing the table is the demo5 oracle for the
2361        // threshold side of the test.
2362        let table: [f64; 20] = [
2363            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,
2364            37.7, 39.3, 40.8, 42.3, 43.8, 45.3,
2365        ];
2366        for (i, &expected) in table.iter().enumerate() {
2367            let dof = i + 1;
2368            let got = chi2_inv(0.999, dof).expect("chi2 quantile");
2369            let tol = (0.01 * expected).max(0.05);
2370            assert!(
2371                (got - expected).abs() < tol,
2372                "dof {dof}: got {got}, demo5 chisqr {expected}"
2373            );
2374        }
2375    }
2376}