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