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