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