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