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