Skip to main content

sidereon_core/precise_positioning/
raim.rs

1//! Snapshot RAIM tests for PPP float residuals.
2//!
3//! ```
4//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
5//! # use std::collections::BTreeMap;
6//! # use sidereon_core::constants::F_L1_HZ;
7//! # use sidereon_core::observables::{
8//! #     predict, ObservableEphemerisSource, ObservableState, ObservablesError, PredictOptions,
9//! # };
10//! # use sidereon_core::ppp_corrections::CivilDateTime;
11//! # use sidereon_core::precise_positioning::{
12//! #     solve_float_epoch_with_raim, FloatEpoch, FloatObservation, FloatSolveConfig,
13//! #     FloatSolveOptions, FloatState, MeasurementWeights, RaimConfig, RangeCorrections,
14//! #     TroposphereOptions,
15//! # };
16//! # use sidereon_core::{GnssSatelliteId, GnssSystem};
17//! #
18//! # #[derive(Debug, Clone)]
19//! # struct Source {
20//! #     states: BTreeMap<GnssSatelliteId, [f64; 3]>,
21//! # }
22//! #
23//! # impl ObservableEphemerisSource for Source {
24//! #     fn observable_state_at_j2000_s(
25//! #         &self,
26//! #         sat: GnssSatelliteId,
27//! #         _t_j2000_s: f64,
28//! #     ) -> Result<ObservableState, ObservablesError> {
29//! #         Ok(ObservableState {
30//! #             position_ecef_m: *self.states.get(&sat).ok_or(ObservablesError::NoEphemeris)?,
31//! #             clock_s: Some(0.0),
32//! #         })
33//! #     }
34//! # }
35//! #
36//! # let sat_positions = [
37//! #     (1u8, [20_200_000.0, 13_000_000.0, 21_500_000.0]),
38//! #     (2, [-21_300_000.0, 14_500_000.0, 20_700_000.0]),
39//! #     (3, [15_200_000.0, -22_000_000.0, 19_500_000.0]),
40//! #     (4, [-18_200_000.0, -16_000_000.0, 21_000_000.0]),
41//! #     (5, [22_000_000.0, -12_000_000.0, 20_200_000.0]),
42//! #     (6, [-12_000_000.0, 23_000_000.0, 18_000_000.0]),
43//! # ];
44//! # let ids = sat_positions
45//! #     .iter()
46//! #     .map(|(prn, _)| GnssSatelliteId::new(GnssSystem::Gps, *prn))
47//! #     .collect::<Result<Vec<_>, _>>()?;
48//! # let source = Source {
49//! #     states: ids
50//! #         .iter()
51//! #         .zip(sat_positions.iter())
52//! #         .map(|(id, (_, position))| (*id, *position))
53//! #         .collect(),
54//! # };
55//! # let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
56//! # let clock_m = 12.5;
57//! # let ambiguities_m = ids
58//! #     .iter()
59//! #     .enumerate()
60//! #     .map(|(idx, id)| (id.to_string(), 0.25 + idx as f64 * 0.1))
61//! #     .collect::<BTreeMap<_, _>>();
62//! # let observations = ids
63//! #     .iter()
64//! #     .map(|id| {
65//! #         let satellite_id = id.to_string();
66//! #         let prediction = predict(
67//! #             &source,
68//! #             *id,
69//! #             truth,
70//! #             0.0,
71//! #             PredictOptions {
72//! #                 carrier_hz: F_L1_HZ,
73//! #                 light_time: true,
74//! #                 sagnac: true,
75//! #             },
76//! #         )?;
77//! #         let code_bias_m = if satellite_id == "G05" { 50.0 } else { 0.0 };
78//! #         let code_m = prediction.geometric_range_m + clock_m + code_bias_m;
79//! #         let ambiguity_m = ambiguities_m.get(&satellite_id).copied().unwrap();
80//! #         Ok(FloatObservation {
81//! #             sat: *id,
82//! #             satellite_id: satellite_id.clone(),
83//! #             ambiguity_id: satellite_id,
84//! #             code_m,
85//! #             phase_m: prediction.geometric_range_m + clock_m + ambiguity_m,
86//! #             freq1_hz: 0.0,
87//! #             freq2_hz: 0.0,
88//! #             glonass_channel: None,
89//! #         })
90//! #     })
91//! #     .collect::<Result<Vec<_>, ObservablesError>>()?;
92//! # let initial_ambiguities = observations
93//! #     .iter()
94//! #     .map(|obs| (obs.ambiguity_id.clone(), obs.phase_m - obs.code_m))
95//! #     .collect();
96//! # let epoch = FloatEpoch {
97//! #     epoch: CivilDateTime {
98//! #         year: 2020,
99//! #         month: 6,
100//! #         day: 24,
101//! #         hour: 12,
102//! #         minute: 0,
103//! #         second: 0.0,
104//! #     },
105//! #     jd_whole: 2_459_024.5,
106//! #     jd_fraction: 0.5,
107//! #     t_rx_j2000_s: 0.0,
108//! #     observations,
109//! # };
110//! # let initial_state = FloatState {
111//! #     position_m: [truth[0] + 80.0, truth[1] - 60.0, truth[2] + 40.0],
112//! #     clocks_m: vec![0.0],
113//! #     ambiguities_m: initial_ambiguities,
114//! #     ztd_m: 0.0,
115//! #     tropo_gradient_north_m: 0.0,
116//! #     tropo_gradient_east_m: 0.0,
117//! #     residual_ionosphere_m: BTreeMap::new(),
118//! # };
119//! # let solve_config = FloatSolveConfig {
120//! #     weights: MeasurementWeights {
121//! #         code: 1.0,
122//! #         phase: 1.0,
123//! #         elevation_weighting: false,
124//! #     },
125//! #     tropo: TroposphereOptions::disabled(),
126//! #     corrections: RangeCorrections::disabled(),
127//! #     opts: FloatSolveOptions {
128//! #         max_iterations: 50,
129//! #         position_tolerance_m: 1.0e-7,
130//! #         clock_tolerance_m: 1.0e-7,
131//! #         ambiguity_tolerance_m: 1.0e-7,
132//! #         ztd_tolerance_m: 1.0e-7,
133//! #     },
134//! #     residual_screen: false,
135//! #     elevation_cutoff_deg: None,
136//! #     estimate_residual_ionosphere: false,
137//! # };
138//! let result = solve_float_epoch_with_raim(
139//! #   &source,
140//! #   epoch,
141//! #   initial_state,
142//! #   solve_config,
143//!     RaimConfig {
144//!         chi_square_threshold: Some(10.0),
145//!         ..RaimConfig::default()
146//!     },
147//! )?;
148//! assert_eq!(result.excluded_sats, vec!["G05".to_string()]);
149//! # Ok(())
150//! # }
151//! ```
152
153use crate::astro::frames::transforms::itrs_to_geodetic_compute;
154use crate::astro::math::linear::{invert_matrix_last_tie, invert_symmetric_pd};
155use crate::constants::F_L1_HZ;
156use crate::estimation::substrate::parameters::{
157    undifferenced_design_row, UndifferencedDesignOptions,
158};
159use crate::geometry::{dop, DopError, LineOfSight, Wgs84Geodetic};
160use crate::observables::{predict, ObservableEphemerisSource, PredictOptions};
161use crate::quality::{chi2_inv, DEFAULT_P_FA};
162use crate::validate;
163
164use super::{
165    no_ephemeris, solve_float_epoch, state_from_solution, ztd_unknown_count, FloatEpoch,
166    FloatResidual, FloatSolution, FloatSolveConfig, FloatSolveError, FloatState,
167    TroposphereOptions,
168};
169
170const DEFAULT_MISSED_DETECTION_PROBABILITY: f64 = 1.0e-3;
171const DEFAULT_MEASUREMENT_SIGMA_M: f64 = 1.0;
172const RESIDUAL_COMPONENTS_PER_ROW: usize = 2;
173const SNAPSHOT_BASE_STATES: usize = 4;
174const LEVERAGE_TOLERANCE: f64 = 1.0e-12;
175const HIGH_LEVERAGE_RESIDUAL_ZERO_TOLERANCE: f64 = 1.0e-8;
176const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
177
178/// Configuration for PPP snapshot RAIM.
179#[derive(Debug, Clone, Copy, PartialEq)]
180pub struct RaimConfig {
181    /// False-alarm probability used to derive the chi-square threshold.
182    pub false_alarm_probability: f64,
183    /// Missed-detection probability reserved for protection-level scaling.
184    pub missed_detection_probability: f64,
185    /// Scalar residual sigma, meters, applied after each residual row's solver weight.
186    pub measurement_sigma_m: f64,
187    /// Optional fixed chi-square threshold. When present, it overrides
188    /// [`false_alarm_probability`](Self::false_alarm_probability).
189    pub chi_square_threshold: Option<f64>,
190}
191
192impl Default for RaimConfig {
193    fn default() -> Self {
194        Self {
195            false_alarm_probability: DEFAULT_P_FA,
196            missed_detection_probability: DEFAULT_MISSED_DETECTION_PROBABILITY,
197            measurement_sigma_m: DEFAULT_MEASUREMENT_SIGMA_M,
198            chi_square_threshold: None,
199        }
200    }
201}
202
203/// Status returned by a PPP RAIM global test.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum RaimStatus {
206    /// The geometry had enough redundancy and the global test passed.
207    Passed,
208    /// The global test statistic exceeded the chi-square threshold.
209    FaultDetected,
210    /// The residual set had zero or negative redundancy, so no test was run.
211    NotEnoughRedundancy,
212}
213
214/// Result of a PPP RAIM global fault-detection test.
215#[derive(Debug, Clone, PartialEq)]
216pub struct RaimResult {
217    /// Summary status for the test.
218    pub status: RaimStatus,
219    /// True when the test statistic exceeds the chi-square threshold.
220    pub detected: bool,
221    /// Sum of squared weighted residuals.
222    pub test_statistic: f64,
223    /// Chi-square threshold used by the test, absent when redundancy is not positive.
224    pub threshold: Option<f64>,
225    /// Redundancy of the residual set, `n_obs - n_states`.
226    pub redundancy: isize,
227    /// Per-satellite standardized residual statistics.
228    pub satellite_statistics: Vec<SatelliteTestStatistic>,
229    /// Satellite with the largest standardized residual statistic.
230    pub most_likely_fault: Option<String>,
231    /// Horizontal protection level, meters, when geometry is available.
232    pub hpl_m: Option<f64>,
233    /// Vertical protection level, meters, when geometry is available.
234    pub vpl_m: Option<f64>,
235}
236
237/// Geometry row used by PPP snapshot RAIM.
238#[derive(Debug, Clone, PartialEq)]
239pub struct RaimGeometryRow {
240    /// Satellite token, matching [`FloatResidual::satellite_id`].
241    pub satellite_id: String,
242    /// Receiver-to-satellite line of sight in ECEF.
243    pub line_of_sight: LineOfSight,
244}
245
246/// Per-satellite standardized residual statistics.
247#[derive(Debug, Clone, PartialEq)]
248pub struct SatelliteTestStatistic {
249    /// Satellite token.
250    pub satellite_id: String,
251    /// Standardized absolute code residual.
252    pub code: f64,
253    /// Standardized absolute phase residual.
254    pub phase: f64,
255    /// Satellite statistic, currently `max(code, phase)`.
256    pub statistic: f64,
257}
258
259/// Result of per-satellite RAIM residual identification.
260#[derive(Debug, Clone, PartialEq)]
261pub struct RaimIdentification {
262    /// Standardized residual statistic for each satellite.
263    pub statistics: Vec<SatelliteTestStatistic>,
264    /// Satellite with the largest statistic.
265    pub most_likely_fault: Option<String>,
266}
267
268/// Horizontal and vertical protection levels.
269#[derive(Debug, Clone, Copy, PartialEq)]
270pub struct ProtectionLevels {
271    /// Horizontal protection level, meters.
272    pub hpl_m: f64,
273    /// Vertical protection level, meters.
274    pub vpl_m: f64,
275}
276
277/// Terminal status of a PPP RAIM/FDE loop.
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub enum RaimFdeStatus {
280    /// The first solve passed RAIM without exclusions.
281    Clean,
282    /// At least one satellite was excluded and the final solve passed RAIM.
283    Restored,
284    /// A fault was detected, but excluding the candidate would exhaust redundancy.
285    CannotExclude,
286    /// A fault was detected, but no valid exclusion restored integrity.
287    IntegrityNotRestored,
288}
289
290/// Result of a PPP RAIM/FDE exclusion loop.
291#[derive(Debug, Clone, PartialEq)]
292pub struct RaimFdeResult {
293    /// Final attempted float solution.
294    pub solution: FloatSolution,
295    /// RAIM result for the final attempted solution.
296    pub raim: RaimResult,
297    /// Satellites excluded in exclusion order.
298    pub excluded_sats: Vec<String>,
299    /// Terminal FDE status.
300    pub status: RaimFdeStatus,
301}
302
303/// Error returned by a PPP RAIM/FDE exclusion loop.
304#[derive(Debug, Clone, PartialEq)]
305pub enum RaimFdeError {
306    /// The underlying float solve or geometry prediction failed.
307    Solve(FloatSolveError),
308    /// RAIM configuration, residuals, or geometry were invalid.
309    Raim(RaimError),
310}
311
312impl core::fmt::Display for RaimFdeError {
313    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
314        match self {
315            Self::Solve(error) => write!(f, "PPP FDE solve failed: {error}"),
316            Self::Raim(error) => write!(f, "PPP FDE RAIM check failed: {error}"),
317        }
318    }
319}
320
321impl std::error::Error for RaimFdeError {}
322
323/// Error returned when PPP RAIM inputs are invalid.
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325pub enum RaimError {
326    /// A configuration field was outside its valid range.
327    InvalidConfig {
328        /// Name of the invalid field.
329        field: &'static str,
330        /// Short reason for the failure.
331        reason: &'static str,
332    },
333    /// A residual row had a non-finite residual or non-positive weight.
334    InvalidResidual {
335        /// Name of the invalid residual field.
336        field: &'static str,
337    },
338    /// Geometry rows were malformed or inconsistent with residual rows.
339    InvalidGeometry {
340        /// Name of the invalid geometry field.
341        field: &'static str,
342        /// Short reason for the failure.
343        reason: &'static str,
344    },
345    /// DOP geometry could not produce finite protection-level scaling.
346    Dop(DopError),
347    /// The geometry normal matrix could not be inverted.
348    SingularGeometry,
349}
350
351impl core::fmt::Display for RaimError {
352    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
353        match self {
354            Self::InvalidConfig { field, reason } => {
355                write!(f, "invalid RAIM configuration {field}: {reason}")
356            }
357            Self::InvalidResidual { field } => write!(f, "invalid RAIM residual {field}"),
358            Self::InvalidGeometry { field, reason } => {
359                write!(f, "invalid RAIM geometry {field}: {reason}")
360            }
361            Self::Dop(error) => write!(f, "RAIM DOP failed: {error}"),
362            Self::SingularGeometry => write!(f, "singular RAIM geometry"),
363        }
364    }
365}
366
367impl std::error::Error for RaimError {}
368
369/// Run the PPP RAIM global chi-square test over post-fit float residual rows.
370///
371/// Each [`FloatResidual`] contributes one code residual and one phase residual,
372/// so `n_obs` is twice `residuals.len()`. The residual row weights are treated as
373/// inverse-sigma solver weights, then divided by
374/// [`RaimConfig::measurement_sigma_m`] before accumulating the SSE.
375pub fn global_test(
376    residuals: &[FloatResidual],
377    n_states: usize,
378    config: RaimConfig,
379) -> Result<RaimResult, RaimError> {
380    validate_config(config)?;
381    let test_statistic = weighted_sse(residuals, config.measurement_sigma_m)?;
382    let n_obs = residuals
383        .len()
384        .checked_mul(RESIDUAL_COMPONENTS_PER_ROW)
385        .ok_or(RaimError::InvalidConfig {
386            field: "residuals",
387            reason: "too many residual rows",
388        })?;
389    let redundancy = n_obs as isize - n_states as isize;
390
391    if redundancy <= 0 {
392        return Ok(RaimResult {
393            status: RaimStatus::NotEnoughRedundancy,
394            detected: false,
395            test_statistic,
396            threshold: None,
397            redundancy,
398            satellite_statistics: Vec::new(),
399            most_likely_fault: None,
400            hpl_m: None,
401            vpl_m: None,
402        });
403    }
404
405    let threshold =
406        match config.chi_square_threshold {
407            Some(threshold) => threshold,
408            None => chi2_inv(1.0 - config.false_alarm_probability, redundancy as usize).map_err(
409                |_| RaimError::InvalidConfig {
410                    field: "false_alarm_probability",
411                    reason: "cannot derive chi-square threshold",
412                },
413            )?,
414        };
415    validate::finite_positive(threshold, "chi_square_threshold").map_err(|_| {
416        RaimError::InvalidConfig {
417            field: "chi_square_threshold",
418            reason: "must be positive and finite",
419        }
420    })?;
421    let detected = test_statistic > threshold;
422    Ok(RaimResult {
423        status: if detected {
424            RaimStatus::FaultDetected
425        } else {
426            RaimStatus::Passed
427        },
428        detected,
429        test_statistic,
430        threshold: Some(threshold),
431        redundancy,
432        satellite_statistics: Vec::new(),
433        most_likely_fault: None,
434        hpl_m: None,
435        vpl_m: None,
436    })
437}
438
439/// Run the PPP RAIM global test and per-satellite residual identification.
440///
441/// This snapshot geometry uses the state vector `[x, y, z, clock,
442/// ambiguity_0..ambiguity_n]`: code rows carry position/clock columns and phase
443/// rows carry position/clock plus their satellite's ambiguity column.
444pub fn global_test_with_geometry(
445    residuals: &[FloatResidual],
446    geometry: &[RaimGeometryRow],
447    config: RaimConfig,
448) -> Result<RaimResult, RaimError> {
449    let n_states = snapshot_state_count_for_geometry(geometry)?;
450    let mut result = global_test(residuals, n_states, config)?;
451    if result.status == RaimStatus::NotEnoughRedundancy {
452        return Ok(result);
453    }
454    let identification = per_satellite_statistics(residuals, geometry, config)?;
455    result.satellite_statistics = identification.statistics;
456    result.most_likely_fault = identification.most_likely_fault;
457    Ok(result)
458}
459
460/// Compute per-satellite standardized residual statistics for PPP RAIM.
461///
462/// The returned statistic is the larger of the code and phase standardized
463/// residual magnitudes for each satellite using the snapshot PPP
464/// `[x, y, z, clock, ambiguity_0..ambiguity_n]` design. The `most_likely_fault`
465/// is the satellite with the largest statistic.
466pub fn per_satellite_statistics(
467    residuals: &[FloatResidual],
468    geometry: &[RaimGeometryRow],
469    config: RaimConfig,
470) -> Result<RaimIdentification, RaimError> {
471    per_satellite_statistics_with_ztd(residuals, geometry, None, config)
472}
473
474fn per_satellite_statistics_with_ztd(
475    residuals: &[FloatResidual],
476    geometry: &[RaimGeometryRow],
477    ztd_mappings: Option<&[f64]>,
478    config: RaimConfig,
479) -> Result<RaimIdentification, RaimError> {
480    validate_config(config)?;
481    validate_geometry(residuals, geometry)?;
482    if let Some(mappings) = ztd_mappings {
483        validate_ztd_mappings(residuals.len(), mappings)?;
484    }
485    let rows = snapshot_design_rows(
486        residuals,
487        geometry,
488        ztd_mappings,
489        config.measurement_sigma_m,
490    )?;
491    let normal = normal_matrix(&rows)?;
492    let q = invert_matrix_last_tie(&normal).ok_or(RaimError::SingularGeometry)?;
493
494    let mut statistics = Vec::with_capacity(residuals.len());
495    let mut most_likely_fault = None;
496    let mut worst = f64::NEG_INFINITY;
497    for (idx, residual) in residuals.iter().enumerate() {
498        let code = standardized_abs(&rows[2 * idx], &q)?;
499        let phase = standardized_abs(&rows[2 * idx + 1], &q)?;
500        let statistic = code.max(phase);
501        if !code.is_finite() || !phase.is_finite() || !statistic.is_finite() {
502            return Err(RaimError::SingularGeometry);
503        }
504        if statistic > worst {
505            worst = statistic;
506            most_likely_fault = Some(residual.satellite_id.clone());
507        }
508        statistics.push(SatelliteTestStatistic {
509            satellite_id: residual.satellite_id.clone(),
510            code,
511            phase,
512            statistic,
513        });
514    }
515
516    Ok(RaimIdentification {
517        statistics,
518        most_likely_fault,
519    })
520}
521
522/// Compute geometry-based PPP RAIM protection levels.
523///
524/// This uses the DOP horizontal and vertical geometry factors as slope terms and
525/// scales them by `measurement_sigma_m * sqrt(chi2_inv(1 - p_md, 1))`, where
526/// `p_md` is [`RaimConfig::missed_detection_probability`].
527pub fn protection_levels(
528    geometry: &[RaimGeometryRow],
529    receiver: Wgs84Geodetic,
530    config: RaimConfig,
531) -> Result<ProtectionLevels, RaimError> {
532    validate_config(config)?;
533    validate_geometry_only(geometry)?;
534    let los = geometry
535        .iter()
536        .map(|row| row.line_of_sight)
537        .collect::<Vec<_>>();
538    let weights = vec![1.0; los.len()];
539    let d = dop(&los, &weights, receiver).map_err(RaimError::Dop)?;
540    let k_md = missed_detection_multiplier(config)?;
541    let hpl_m = k_md * config.measurement_sigma_m * d.hdop;
542    let vpl_m = k_md * config.measurement_sigma_m * d.vdop;
543    if hpl_m.is_finite() && hpl_m > 0.0 && vpl_m.is_finite() && vpl_m > 0.0 {
544        Ok(ProtectionLevels { hpl_m, vpl_m })
545    } else {
546        Err(RaimError::SingularGeometry)
547    }
548}
549
550fn protection_levels_with_ztd(
551    geometry: &[RaimGeometryRow],
552    ztd_mappings: Option<&[f64]>,
553    receiver: Wgs84Geodetic,
554    config: RaimConfig,
555) -> Result<ProtectionLevels, RaimError> {
556    let Some(ztd_mappings) = ztd_mappings else {
557        return protection_levels(geometry, receiver, config);
558    };
559
560    validate_config(config)?;
561    validate_geometry_only(geometry)?;
562    validate_ztd_mappings(geometry.len(), ztd_mappings)?;
563
564    let q = protection_position_covariance_with_ztd(geometry, ztd_mappings)?;
565    let enu = rotate_position_covariance_to_enu(&q, receiver);
566    scaled_protection_levels(enu[0][0] + enu[1][1], enu[2][2], config)
567}
568
569/// Run PPP snapshot fault detection and exclusion for one float epoch.
570///
571/// The loop solves the current observation set, runs the global RAIM test with
572/// per-satellite identification, excludes the most likely faulty satellite, and
573/// repeats until RAIM passes or exclusion would leave no positive redundancy.
574pub fn fde_float_epoch(
575    source: &dyn ObservableEphemerisSource,
576    epoch: FloatEpoch,
577    initial_state: FloatState,
578    solve_config: FloatSolveConfig,
579    raim_config: RaimConfig,
580) -> Result<RaimFdeResult, RaimFdeError> {
581    let mut current_epoch = epoch;
582    let mut seed_state = initial_state;
583    let mut excluded_sats = Vec::new();
584
585    loop {
586        let solution = solve_float_epoch(
587            source,
588            current_epoch.clone(),
589            seed_state.clone(),
590            solve_config.clone(),
591        )
592        .map_err(RaimFdeError::Solve)?;
593        let raim = raim_for_solution(
594            source,
595            &current_epoch,
596            &solution,
597            solve_config.tropo,
598            raim_config,
599        )
600        .map_err(RaimFdeError::Raim)?;
601
602        if raim.status == RaimStatus::NotEnoughRedundancy {
603            return Ok(RaimFdeResult {
604                solution,
605                raim,
606                excluded_sats,
607                status: RaimFdeStatus::CannotExclude,
608            });
609        }
610
611        if !raim.detected {
612            return Ok(RaimFdeResult {
613                solution,
614                raim,
615                status: if excluded_sats.is_empty() {
616                    RaimFdeStatus::Clean
617                } else {
618                    RaimFdeStatus::Restored
619                },
620                excluded_sats,
621            });
622        }
623
624        let Some(candidate) = raim.most_likely_fault.clone() else {
625            return Ok(RaimFdeResult {
626                solution,
627                raim,
628                excluded_sats,
629                status: RaimFdeStatus::IntegrityNotRestored,
630            });
631        };
632        let Some(next_epoch) = exclude_satellite(&current_epoch, &candidate) else {
633            return Ok(RaimFdeResult {
634                solution,
635                raim,
636                excluded_sats,
637                status: RaimFdeStatus::IntegrityNotRestored,
638            });
639        };
640        if !has_positive_redundancy_after_exclusion(
641            next_epoch.observations.len(),
642            solve_config.tropo,
643        )
644        .map_err(RaimFdeError::Raim)?
645        {
646            return Ok(RaimFdeResult {
647                solution,
648                raim,
649                excluded_sats,
650                status: RaimFdeStatus::CannotExclude,
651            });
652        }
653
654        excluded_sats.push(candidate);
655        seed_state = state_from_solution(&solution, &seed_state);
656        current_epoch = next_epoch;
657    }
658}
659
660/// Solve one PPP float epoch and run RAIM/FDE on the result.
661///
662/// This is the public driver for snapshot PPP integrity: it preserves the
663/// existing float-solve behavior, then performs RAIM fault detection and, when
664/// needed, fault detection and exclusion over reduced observation sets.
665pub fn solve_float_epoch_with_raim(
666    source: &dyn ObservableEphemerisSource,
667    epoch: FloatEpoch,
668    initial_state: FloatState,
669    solve_config: FloatSolveConfig,
670    raim_config: RaimConfig,
671) -> Result<RaimFdeResult, RaimFdeError> {
672    fde_float_epoch(source, epoch, initial_state, solve_config, raim_config)
673}
674
675fn validate_config(config: RaimConfig) -> Result<(), RaimError> {
676    validate_probability(config.false_alarm_probability, "false_alarm_probability")?;
677    validate_probability(
678        config.missed_detection_probability,
679        "missed_detection_probability",
680    )?;
681    validate::finite_positive(config.measurement_sigma_m, "measurement_sigma_m")
682        .map(|_| ())
683        .map_err(|_| RaimError::InvalidConfig {
684            field: "measurement_sigma_m",
685            reason: "must be positive and finite",
686        })?;
687    if let Some(threshold) = config.chi_square_threshold {
688        validate::finite_positive(threshold, "chi_square_threshold")
689            .map(|_| ())
690            .map_err(|_| RaimError::InvalidConfig {
691                field: "chi_square_threshold",
692                reason: "must be positive and finite",
693            })?;
694    }
695    Ok(())
696}
697
698fn validate_probability(value: f64, field: &'static str) -> Result<(), RaimError> {
699    let value = validate::finite(value, field).map_err(|_| RaimError::InvalidConfig {
700        field,
701        reason: "must be finite",
702    })?;
703    if value > 0.0 && value < 1.0 {
704        Ok(())
705    } else {
706        Err(RaimError::InvalidConfig {
707            field,
708            reason: "must be inside (0, 1)",
709        })
710    }
711}
712
713fn weighted_sse(residuals: &[FloatResidual], measurement_sigma_m: f64) -> Result<f64, RaimError> {
714    let mut sse = 0.0;
715    for row in residuals {
716        let code_m = validate_residual(row.code_m, "code_m")?;
717        let phase_m = validate_residual(row.phase_m, "phase_m")?;
718        let code_weight = validate_weight(row.code_weight, "code_weight")?;
719        let phase_weight = validate_weight(row.phase_weight, "phase_weight")?;
720        let code = code_m * code_weight / measurement_sigma_m;
721        let phase = phase_m * phase_weight / measurement_sigma_m;
722        if !code.is_finite() || !phase.is_finite() {
723            return Err(RaimError::InvalidResidual {
724                field: "weighted_residual",
725            });
726        }
727        sse += code * code + phase * phase;
728        if !sse.is_finite() {
729            return Err(RaimError::InvalidResidual {
730                field: "weighted_sse",
731            });
732        }
733    }
734    Ok(sse)
735}
736
737fn validate_residual(value: f64, field: &'static str) -> Result<f64, RaimError> {
738    validate::finite(value, field).map_err(|_| RaimError::InvalidResidual { field })
739}
740
741fn validate_weight(value: f64, field: &'static str) -> Result<f64, RaimError> {
742    validate::finite_positive(value, field).map_err(|_| RaimError::InvalidResidual { field })
743}
744
745fn missed_detection_multiplier(config: RaimConfig) -> Result<f64, RaimError> {
746    chi2_inv(1.0 - config.missed_detection_probability, 1)
747        .map(f64::sqrt)
748        .map_err(|_| RaimError::InvalidConfig {
749            field: "missed_detection_probability",
750            reason: "cannot derive missed-detection multiplier",
751        })
752}
753
754fn raim_for_solution(
755    source: &dyn ObservableEphemerisSource,
756    epoch: &FloatEpoch,
757    solution: &FloatSolution,
758    tropo: TroposphereOptions,
759    config: RaimConfig,
760) -> Result<RaimResult, RaimError> {
761    let geometry = geometry_for_solution(source, epoch, solution, tropo).map_err(|_| {
762        RaimError::InvalidGeometry {
763            field: "solution",
764            reason: "could not build line-of-sight geometry",
765        }
766    })?;
767    validate_geometry(&solution.residuals_m, &geometry.rows)?;
768    let n_states = snapshot_state_count_for_solution(solution)?;
769    let mut result = global_test(&solution.residuals_m, n_states, config)?;
770    if result.status != RaimStatus::NotEnoughRedundancy {
771        let identification = per_satellite_statistics_with_ztd(
772            &solution.residuals_m,
773            &geometry.rows,
774            geometry.ztd_mappings.as_deref(),
775            config,
776        )?;
777        result.satellite_statistics = identification.statistics;
778        result.most_likely_fault = identification.most_likely_fault;
779    }
780    if result.status != RaimStatus::NotEnoughRedundancy {
781        let receiver = receiver_geodetic(solution.position_m);
782        let levels = protection_levels_with_ztd(
783            &geometry.rows,
784            geometry.ztd_mappings.as_deref(),
785            receiver,
786            config,
787        )?;
788        result.hpl_m = Some(levels.hpl_m);
789        result.vpl_m = Some(levels.vpl_m);
790    }
791    Ok(result)
792}
793
794#[derive(Debug, Clone)]
795struct RaimSolutionGeometry {
796    rows: Vec<RaimGeometryRow>,
797    ztd_mappings: Option<Vec<f64>>,
798}
799
800fn geometry_for_solution(
801    source: &dyn ObservableEphemerisSource,
802    epoch: &FloatEpoch,
803    solution: &FloatSolution,
804    tropo: TroposphereOptions,
805) -> Result<RaimSolutionGeometry, FloatSolveError> {
806    let mut rows = Vec::with_capacity(solution.residuals_m.len());
807    let mut ztd_mappings = solution
808        .ztd_residual_m
809        .is_some()
810        .then(|| Vec::with_capacity(solution.residuals_m.len()));
811    for residual in &solution.residuals_m {
812        let obs = epoch
813            .observations
814            .iter()
815            .find(|obs| obs.satellite_id == residual.satellite_id)
816            .ok_or(FloatSolveError::InvalidInput {
817                field: "raim geometry satellite_id",
818                reason: "residual satellite missing from epoch",
819            })?;
820        let pred = predict(
821            source,
822            obs.sat,
823            solution.position_m,
824            epoch.t_rx_j2000_s,
825            PredictOptions {
826                carrier_hz: F_L1_HZ,
827                light_time: true,
828                sagnac: true,
829            },
830        )
831        .map_err(|error| no_ephemeris(obs, error))?;
832        validate::finite_vec3(pred.los_unit, "raim geometry los_unit").map_err(|error| {
833            FloatSolveError::InvalidInput {
834                field: error.field(),
835                reason: error.reason(),
836            }
837        })?;
838        if let Some(mappings) = &mut ztd_mappings {
839            let tropo_model =
840                super::model::model_troposphere(&pred, solution.position_m, epoch, tropo)?;
841            validate::finite(tropo_model.ztd_mapping, "raim geometry ztd_mapping").map_err(
842                |error| FloatSolveError::InvalidInput {
843                    field: error.field(),
844                    reason: error.reason(),
845                },
846            )?;
847            mappings.push(tropo_model.ztd_mapping);
848        }
849        rows.push(RaimGeometryRow {
850            satellite_id: residual.satellite_id.clone(),
851            line_of_sight: LineOfSight::new(pred.los_unit[0], pred.los_unit[1], pred.los_unit[2]),
852        });
853    }
854    Ok(RaimSolutionGeometry { rows, ztd_mappings })
855}
856
857fn exclude_satellite(epoch: &FloatEpoch, satellite_id: &str) -> Option<FloatEpoch> {
858    let mut next = epoch.clone();
859    let before = next.observations.len();
860    next.observations
861        .retain(|obs| obs.satellite_id != satellite_id);
862    (next.observations.len() < before).then_some(next)
863}
864
865fn has_positive_redundancy_after_exclusion(
866    n_sats_after: usize,
867    tropo: super::TroposphereOptions,
868) -> Result<bool, RaimError> {
869    let n_obs = n_sats_after * RESIDUAL_COMPONENTS_PER_ROW;
870    let n_states = snapshot_state_count_from_parts(1, ztd_unknown_count(tropo), n_sats_after)?;
871    Ok(n_obs > n_states)
872}
873
874fn receiver_geodetic(position_m: [f64; 3]) -> Wgs84Geodetic {
875    let (lat_deg, lon_deg, height_km) = itrs_to_geodetic_compute(
876        position_m[0] / 1000.0,
877        position_m[1] / 1000.0,
878        position_m[2] / 1000.0,
879    )
880    .expect("valid receiver ITRS coordinates");
881    Wgs84Geodetic::new(
882        lat_deg * DEG_TO_RAD,
883        lon_deg * DEG_TO_RAD,
884        height_km * 1000.0,
885    )
886    .expect("valid receiver geodetic coordinates")
887}
888
889fn snapshot_state_count_for_geometry(geometry: &[RaimGeometryRow]) -> Result<usize, RaimError> {
890    snapshot_state_count_from_parts(1, 0, geometry.len())
891}
892
893fn snapshot_state_count_for_solution(solution: &FloatSolution) -> Result<usize, RaimError> {
894    snapshot_state_count_from_parts(
895        solution.epoch_clocks_m.len(),
896        usize::from(solution.ztd_residual_m.is_some()),
897        solution.ambiguities_m.len(),
898    )
899}
900
901fn snapshot_state_count_from_parts(
902    n_clocks: usize,
903    n_ztd: usize,
904    n_ambiguities: usize,
905) -> Result<usize, RaimError> {
906    SNAPSHOT_BASE_STATES
907        .checked_add(n_clocks.saturating_sub(1))
908        .and_then(|count| count.checked_add(n_ztd))
909        .and_then(|count| count.checked_add(n_ambiguities))
910        .ok_or(RaimError::InvalidGeometry {
911            field: "geometry",
912            reason: "too many rows",
913        })
914}
915
916fn validate_geometry(
917    residuals: &[FloatResidual],
918    geometry: &[RaimGeometryRow],
919) -> Result<(), RaimError> {
920    if residuals.len() != geometry.len() {
921        return Err(RaimError::InvalidGeometry {
922            field: "geometry",
923            reason: "length must match residuals",
924        });
925    }
926    for (residual, row) in residuals.iter().zip(geometry) {
927        if residual.satellite_id != row.satellite_id {
928            return Err(RaimError::InvalidGeometry {
929                field: "satellite_id",
930                reason: "order must match residuals",
931            });
932        }
933        validate_geometry_row(row)?;
934    }
935    Ok(())
936}
937
938fn validate_geometry_only(geometry: &[RaimGeometryRow]) -> Result<(), RaimError> {
939    for row in geometry {
940        validate_geometry_row(row)?;
941    }
942    Ok(())
943}
944
945fn validate_geometry_row(row: &RaimGeometryRow) -> Result<(), RaimError> {
946    validate::finite(row.line_of_sight.e_x, "line_of_sight.e_x").map_err(|_| {
947        RaimError::InvalidGeometry {
948            field: "line_of_sight.e_x",
949            reason: "must be finite",
950        }
951    })?;
952    validate::finite(row.line_of_sight.e_y, "line_of_sight.e_y").map_err(|_| {
953        RaimError::InvalidGeometry {
954            field: "line_of_sight.e_y",
955            reason: "must be finite",
956        }
957    })?;
958    validate::finite(row.line_of_sight.e_z, "line_of_sight.e_z").map_err(|_| {
959        RaimError::InvalidGeometry {
960            field: "line_of_sight.e_z",
961            reason: "must be finite",
962        }
963    })?;
964    Ok(())
965}
966
967fn validate_ztd_mappings(expected_len: usize, mappings: &[f64]) -> Result<(), RaimError> {
968    if mappings.len() != expected_len {
969        return Err(RaimError::InvalidGeometry {
970            field: "ztd_mapping",
971            reason: "length must match geometry",
972        });
973    }
974    for &mapping in mappings {
975        validate::finite(mapping, "ztd_mapping").map_err(|_| RaimError::InvalidGeometry {
976            field: "ztd_mapping",
977            reason: "must be finite",
978        })?;
979    }
980    Ok(())
981}
982
983fn protection_position_covariance_with_ztd(
984    geometry: &[RaimGeometryRow],
985    ztd_mappings: &[f64],
986) -> Result<[[f64; 3]; 3], RaimError> {
987    const PROTECTION_STATES_WITH_ZTD: usize = SNAPSHOT_BASE_STATES + 1;
988    let mut normal = vec![vec![0.0_f64; PROTECTION_STATES_WITH_ZTD]; PROTECTION_STATES_WITH_ZTD];
989
990    for (row, &ztd_mapping) in geometry.iter().zip(ztd_mappings) {
991        let h = [
992            -row.line_of_sight.e_x,
993            -row.line_of_sight.e_y,
994            -row.line_of_sight.e_z,
995            1.0,
996            ztd_mapping,
997        ];
998        for (i, normal_row) in normal.iter_mut().enumerate() {
999            let h_i = h[i];
1000            for (j, normal_ij) in normal_row.iter_mut().enumerate() {
1001                *normal_ij += h_i * h[j];
1002            }
1003        }
1004    }
1005
1006    let q = invert_symmetric_pd(&normal).ok_or(RaimError::SingularGeometry)?;
1007    Ok([
1008        [q[0][0], q[0][1], q[0][2]],
1009        [q[1][0], q[1][1], q[1][2]],
1010        [q[2][0], q[2][1], q[2][2]],
1011    ])
1012}
1013
1014#[allow(clippy::needless_range_loop)]
1015fn rotate_position_covariance_to_enu(q: &[[f64; 3]; 3], receiver: Wgs84Geodetic) -> [[f64; 3]; 3] {
1016    let sphi = receiver.lat_rad.sin();
1017    let cphi = receiver.lat_rad.cos();
1018    let slam = receiver.lon_rad.sin();
1019    let clam = receiver.lon_rad.cos();
1020    let r = [
1021        [-slam, clam, 0.0],
1022        [-sphi * clam, -sphi * slam, cphi],
1023        [cphi * clam, cphi * slam, sphi],
1024    ];
1025
1026    let mut rq = [[0.0_f64; 3]; 3];
1027    for i in 0..3 {
1028        for j in 0..3 {
1029            let mut s = 0.0_f64;
1030            for k in 0..3 {
1031                s += r[i][k] * q[k][j];
1032            }
1033            rq[i][j] = s;
1034        }
1035    }
1036    let mut enu = [[0.0_f64; 3]; 3];
1037    for i in 0..3 {
1038        for j in 0..3 {
1039            let mut s = 0.0_f64;
1040            for k in 0..3 {
1041                s += rq[i][k] * r[j][k];
1042            }
1043            enu[i][j] = s;
1044        }
1045    }
1046    enu
1047}
1048
1049fn scaled_protection_levels(
1050    hdop_arg: f64,
1051    vdop_arg: f64,
1052    config: RaimConfig,
1053) -> Result<ProtectionLevels, RaimError> {
1054    for arg in [hdop_arg, vdop_arg] {
1055        #[allow(clippy::neg_cmp_op_on_partial_ord)]
1056        let negative_or_nan = !(arg >= 0.0);
1057        if negative_or_nan || !arg.is_finite() {
1058            return Err(RaimError::SingularGeometry);
1059        }
1060    }
1061
1062    let k_md = missed_detection_multiplier(config)?;
1063    let hpl_m = k_md * config.measurement_sigma_m * hdop_arg.sqrt();
1064    let vpl_m = k_md * config.measurement_sigma_m * vdop_arg.sqrt();
1065    if hpl_m.is_finite() && hpl_m > 0.0 && vpl_m.is_finite() && vpl_m > 0.0 {
1066        Ok(ProtectionLevels { hpl_m, vpl_m })
1067    } else {
1068        Err(RaimError::SingularGeometry)
1069    }
1070}
1071
1072#[derive(Debug, Clone)]
1073struct StandardizationRow {
1074    h: Vec<f64>,
1075    residual: f64,
1076}
1077
1078fn snapshot_design_rows(
1079    residuals: &[FloatResidual],
1080    geometry: &[RaimGeometryRow],
1081    ztd_mappings: Option<&[f64]>,
1082    measurement_sigma_m: f64,
1083) -> Result<Vec<StandardizationRow>, RaimError> {
1084    let mut rows = Vec::with_capacity(residuals.len() * RESIDUAL_COMPONENTS_PER_ROW);
1085    let n_ambiguities = residuals.len();
1086    for (ambiguity_idx, (residual, geometry)) in residuals.iter().zip(geometry).enumerate() {
1087        let code_residual = validate_residual(residual.code_m, "code_m")?;
1088        let phase_residual = validate_residual(residual.phase_m, "phase_m")?;
1089        let code_weight =
1090            validate_weight(residual.code_weight, "code_weight")? / measurement_sigma_m;
1091        let phase_weight =
1092            validate_weight(residual.phase_weight, "phase_weight")? / measurement_sigma_m;
1093        if !code_weight.is_finite() || !phase_weight.is_finite() {
1094            return Err(RaimError::InvalidResidual {
1095                field: "weighted_residual",
1096            });
1097        }
1098        let ztd_mapping = ztd_mappings.map(|mappings| mappings[ambiguity_idx]);
1099        let code = code_residual * code_weight;
1100        let phase = phase_residual * phase_weight;
1101        if !code.is_finite() || !phase.is_finite() {
1102            return Err(RaimError::InvalidResidual {
1103                field: "weighted_residual",
1104            });
1105        }
1106        rows.push(StandardizationRow {
1107            h: weighted_snapshot_row(
1108                geometry.line_of_sight,
1109                code_weight,
1110                ztd_mapping,
1111                n_ambiguities,
1112                None,
1113            ),
1114            residual: code,
1115        });
1116        rows.push(StandardizationRow {
1117            h: weighted_snapshot_row(
1118                geometry.line_of_sight,
1119                phase_weight,
1120                ztd_mapping,
1121                n_ambiguities,
1122                Some(ambiguity_idx),
1123            ),
1124            residual: phase,
1125        });
1126    }
1127    Ok(rows)
1128}
1129
1130fn weighted_snapshot_row(
1131    line_of_sight: LineOfSight,
1132    weight: f64,
1133    ztd_mapping: Option<f64>,
1134    n_ambiguities: usize,
1135    active_ambiguity: Option<usize>,
1136) -> Vec<f64> {
1137    let mut row = undifferenced_design_row(
1138        [-line_of_sight.e_x, -line_of_sight.e_y, -line_of_sight.e_z],
1139        0,
1140        1,
1141        UndifferencedDesignOptions {
1142            ztd_mapping,
1143            tropo_gradient_mapping: None,
1144            residual_ionosphere_columns: 0,
1145            active_residual_ionosphere: None,
1146            ambiguity_columns: n_ambiguities,
1147            active_ambiguity,
1148        },
1149    );
1150    for value in &mut row {
1151        *value *= weight;
1152    }
1153    row
1154}
1155
1156fn normal_matrix(rows: &[StandardizationRow]) -> Result<Vec<Vec<f64>>, RaimError> {
1157    let n = rows.first().map(|row| row.h.len()).unwrap_or(0);
1158    if n == 0 {
1159        return Err(RaimError::SingularGeometry);
1160    }
1161    let mut normal = vec![vec![0.0; n]; n];
1162    for row in rows {
1163        for (i, normal_row) in normal.iter_mut().enumerate() {
1164            let h_i = row.h[i];
1165            for (j, normal_ij) in normal_row.iter_mut().enumerate() {
1166                *normal_ij += h_i * row.h[j];
1167            }
1168        }
1169    }
1170    Ok(normal)
1171}
1172
1173fn standardized_abs(row: &StandardizationRow, q: &[Vec<f64>]) -> Result<f64, RaimError> {
1174    let leverage = row_leverage(&row.h, q)?;
1175    let variance_factor = 1.0 - leverage;
1176    if !variance_factor.is_finite() {
1177        return Err(RaimError::SingularGeometry);
1178    }
1179    if variance_factor.abs() <= LEVERAGE_TOLERANCE {
1180        return if row.residual.abs() <= HIGH_LEVERAGE_RESIDUAL_ZERO_TOLERANCE {
1181            Ok(0.0)
1182        } else {
1183            Err(RaimError::SingularGeometry)
1184        };
1185    }
1186    if variance_factor < 0.0 {
1187        return Err(RaimError::SingularGeometry);
1188    }
1189    let standardized = row.residual.abs() / variance_factor.sqrt();
1190    if standardized.is_finite() {
1191        Ok(standardized)
1192    } else {
1193        Err(RaimError::SingularGeometry)
1194    }
1195}
1196
1197fn row_leverage(row: &[f64], q: &[Vec<f64>]) -> Result<f64, RaimError> {
1198    if q.len() != row.len() || q.iter().any(|q_row| q_row.len() != row.len()) {
1199        return Err(RaimError::SingularGeometry);
1200    }
1201    let mut value = 0.0;
1202    for i in 0..row.len() {
1203        for j in 0..row.len() {
1204            value += row[i] * q[i][j] * row[j];
1205        }
1206    }
1207    if value.is_finite() {
1208        Ok(value)
1209    } else {
1210        Err(RaimError::SingularGeometry)
1211    }
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216    use super::*;
1217    use std::collections::BTreeMap;
1218
1219    use crate::geometry::line_of_sight_from_az_el_deg;
1220    use crate::observables::{ObservableState, ObservablesError};
1221    use crate::ppp_corrections::CivilDateTime;
1222    use crate::{GnssSatelliteId, GnssSystem};
1223
1224    fn residual(satellite_id: &str, code_m: f64, phase_m: f64) -> FloatResidual {
1225        FloatResidual {
1226            epoch_index: 0,
1227            satellite_id: satellite_id.to_string(),
1228            code_m,
1229            phase_m,
1230            code_weight: 1.0,
1231            phase_weight: 1.0,
1232        }
1233    }
1234
1235    #[derive(Debug, Clone)]
1236    struct TestSource {
1237        states: BTreeMap<GnssSatelliteId, [f64; 3]>,
1238    }
1239
1240    impl ObservableEphemerisSource for TestSource {
1241        fn observable_state_at_j2000_s(
1242            &self,
1243            sat: GnssSatelliteId,
1244            _t_j2000_s: f64,
1245        ) -> Result<ObservableState, ObservablesError> {
1246            Ok(ObservableState {
1247                position_ecef_m: *self.states.get(&sat).ok_or(ObservablesError::NoEphemeris)?,
1248                clock_s: Some(0.0),
1249            })
1250        }
1251    }
1252
1253    fn geometry(satellite_id: &str, line_of_sight: LineOfSight) -> RaimGeometryRow {
1254        RaimGeometryRow {
1255            satellite_id: satellite_id.to_string(),
1256            line_of_sight,
1257        }
1258    }
1259
1260    fn clean_residuals() -> Vec<FloatResidual> {
1261        vec![
1262            residual("G01", 0.1, -0.1),
1263            residual("G02", -0.1, 0.1),
1264            residual("G03", 0.05, -0.05),
1265            residual("G04", -0.05, 0.05),
1266        ]
1267    }
1268
1269    fn test_geometry() -> Vec<RaimGeometryRow> {
1270        vec![
1271            geometry("G01", LineOfSight::new(1.0, 0.0, 0.0)),
1272            geometry("G02", LineOfSight::new(-1.0, 0.0, 0.0)),
1273            geometry("G03", LineOfSight::new(0.0, 1.0, 0.0)),
1274            geometry("G04", LineOfSight::new(0.0, 0.0, 1.0)),
1275            geometry(
1276                "G05",
1277                LineOfSight::new(
1278                    1.0 / 3.0_f64.sqrt(),
1279                    1.0 / 3.0_f64.sqrt(),
1280                    1.0 / 3.0_f64.sqrt(),
1281                ),
1282            ),
1283        ]
1284    }
1285
1286    fn protection_receiver() -> Wgs84Geodetic {
1287        Wgs84Geodetic::new(0.7, -1.2, 0.0).expect("valid geodetic receiver")
1288    }
1289
1290    fn protection_geometry(points: &[(f64, f64)]) -> Vec<RaimGeometryRow> {
1291        points
1292            .iter()
1293            .enumerate()
1294            .map(|(idx, &(azimuth_deg, elevation_deg))| {
1295                geometry(
1296                    &format!("G{:02}", idx + 1),
1297                    line_of_sight_from_az_el_deg(azimuth_deg, elevation_deg, protection_receiver())
1298                        .expect("valid protection geometry"),
1299                )
1300            })
1301            .collect()
1302    }
1303
1304    fn fde_config() -> FloatSolveConfig {
1305        FloatSolveConfig {
1306            weights: super::super::MeasurementWeights {
1307                code: 1.0,
1308                phase: 1.0,
1309                elevation_weighting: false,
1310            },
1311            tropo: super::super::TroposphereOptions::disabled(),
1312            corrections: super::super::RangeCorrections::disabled(),
1313            opts: super::super::FloatSolveOptions {
1314                max_iterations: 50,
1315                position_tolerance_m: 1.0e-7,
1316                clock_tolerance_m: 1.0e-7,
1317                ambiguity_tolerance_m: 1.0e-7,
1318                ztd_tolerance_m: 1.0e-7,
1319            },
1320            elevation_cutoff_deg: None,
1321            residual_screen: false,
1322            estimate_residual_ionosphere: false,
1323        }
1324    }
1325
1326    fn fde_raim_config() -> RaimConfig {
1327        RaimConfig {
1328            chi_square_threshold: Some(10.0),
1329            ..RaimConfig::default()
1330        }
1331    }
1332
1333    fn synthetic_case(
1334        n_sats: usize,
1335        biased_satellite: Option<&str>,
1336    ) -> (TestSource, FloatEpoch, FloatState) {
1337        let sat_positions = [
1338            (1u8, [20_200_000.0, 13_000_000.0, 21_500_000.0]),
1339            (2, [-21_300_000.0, 14_500_000.0, 20_700_000.0]),
1340            (3, [15_200_000.0, -22_000_000.0, 19_500_000.0]),
1341            (4, [-18_200_000.0, -16_000_000.0, 21_000_000.0]),
1342            (5, [22_000_000.0, -12_000_000.0, 20_200_000.0]),
1343            (6, [-12_000_000.0, 23_000_000.0, 18_000_000.0]),
1344        ];
1345        let ids = sat_positions
1346            .iter()
1347            .take(n_sats)
1348            .map(|(prn, _)| {
1349                GnssSatelliteId::new(GnssSystem::Gps, *prn).expect("valid satellite id")
1350            })
1351            .collect::<Vec<_>>();
1352        let source = TestSource {
1353            states: ids
1354                .iter()
1355                .zip(sat_positions.iter())
1356                .map(|(id, (_, position))| (*id, *position))
1357                .collect(),
1358        };
1359        let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
1360        let clock_m = 12.5;
1361        let ambiguities_m = ids
1362            .iter()
1363            .enumerate()
1364            .map(|(idx, id)| (id.to_string(), 0.25 + idx as f64 * 0.1))
1365            .collect::<BTreeMap<_, _>>();
1366        let observations = ids
1367            .iter()
1368            .map(|id| {
1369                let satellite_id = id.to_string();
1370                let prediction = predict(
1371                    &source,
1372                    *id,
1373                    truth,
1374                    0.0,
1375                    PredictOptions {
1376                        carrier_hz: F_L1_HZ,
1377                        light_time: true,
1378                        sagnac: true,
1379                    },
1380                )
1381                .expect("synthetic prediction");
1382                let bias = if Some(satellite_id.as_str()) == biased_satellite {
1383                    50.0
1384                } else {
1385                    0.0
1386                };
1387                let code_m = prediction.geometric_range_m + clock_m + bias;
1388                let ambiguity_m = ambiguities_m.get(&satellite_id).copied().unwrap();
1389                super::super::FloatObservation {
1390                    sat: *id,
1391                    satellite_id: satellite_id.clone(),
1392                    ambiguity_id: satellite_id,
1393                    code_m,
1394                    phase_m: prediction.geometric_range_m + clock_m + ambiguity_m,
1395                    freq1_hz: 0.0,
1396                    freq2_hz: 0.0,
1397                    glonass_channel: None,
1398                }
1399            })
1400            .collect::<Vec<_>>();
1401        let initial_ambiguities = observations
1402            .iter()
1403            .map(|obs| (obs.ambiguity_id.clone(), obs.phase_m - obs.code_m))
1404            .collect();
1405        let epoch = FloatEpoch {
1406            epoch: CivilDateTime {
1407                year: 2020,
1408                month: 6,
1409                day: 24,
1410                hour: 12,
1411                minute: 0,
1412                second: 0.0,
1413            },
1414            jd_whole: 2_459_024.5,
1415            jd_fraction: 0.5,
1416            t_rx_j2000_s: 0.0,
1417            observations,
1418        };
1419        let state = FloatState {
1420            position_m: [truth[0] + 80.0, truth[1] - 60.0, truth[2] + 40.0],
1421            clocks_m: vec![0.0],
1422            ambiguities_m: initial_ambiguities,
1423            ztd_m: 0.0,
1424            tropo_gradient_north_m: 0.0,
1425            tropo_gradient_east_m: 0.0,
1426            residual_ionosphere_m: BTreeMap::new(),
1427        };
1428        (source, epoch, state)
1429    }
1430
1431    fn assert_position_close(actual: [f64; 3], expected: [f64; 3], tolerance_m: f64) {
1432        for idx in 0..3 {
1433            assert!(
1434                (actual[idx] - expected[idx]).abs() <= tolerance_m,
1435                "axis {idx}: got {}, expected {}",
1436                actual[idx],
1437                expected[idx]
1438            );
1439        }
1440    }
1441
1442    #[test]
1443    fn clean_residuals_pass_global_test() {
1444        let result = global_test(&clean_residuals(), 7, RaimConfig::default()).unwrap();
1445        assert_eq!(result.status, RaimStatus::Passed);
1446        assert!(!result.detected);
1447        assert_eq!(result.redundancy, 1);
1448        assert!(result.threshold.expect("threshold") > result.test_statistic);
1449    }
1450
1451    #[test]
1452    fn injected_bias_trips_global_test() {
1453        let mut residuals = clean_residuals();
1454        residuals[2].code_m = 5.0;
1455
1456        let result = global_test(&residuals, 7, RaimConfig::default()).unwrap();
1457        assert_eq!(result.status, RaimStatus::FaultDetected);
1458        assert!(result.detected);
1459        assert_eq!(result.redundancy, 1);
1460        assert!(result.test_statistic > result.threshold.expect("threshold"));
1461    }
1462
1463    #[test]
1464    fn global_test_rejects_overflowed_weighted_sse() {
1465        let mut residuals = clean_residuals();
1466        residuals[0].code_m = f64::MAX;
1467
1468        assert_eq!(
1469            global_test(&residuals, 7, RaimConfig::default()),
1470            Err(RaimError::InvalidResidual {
1471                field: "weighted_sse",
1472            })
1473        );
1474    }
1475
1476    #[test]
1477    fn raim_redundancy_counts_estimated_ztd_state() {
1478        let (source, epoch, state) = synthetic_case(6, None);
1479        let mut solve_config = fde_config();
1480        let mut tropo = super::super::TroposphereOptions::disabled();
1481        tropo.enabled = true;
1482        tropo.estimate_ztd = true;
1483        solve_config.tropo = tropo;
1484        let solve_tropo = solve_config.tropo;
1485        let solution = solve_float_epoch(&source, epoch.clone(), state, solve_config)
1486            .expect("ZTD-estimated solve");
1487
1488        assert!(solution.ztd_residual_m.is_some());
1489        let raim = raim_for_solution(
1490            &source,
1491            &epoch,
1492            &solution,
1493            solve_tropo,
1494            RaimConfig::default(),
1495        )
1496        .expect("RAIM for ZTD-estimated solution");
1497        let expected_states = 3 + solution.epoch_clocks_m.len() + 1 + solution.ambiguities_m.len();
1498        let expected_redundancy = (solution.residuals_m.len() * RESIDUAL_COMPONENTS_PER_ROW)
1499            as isize
1500            - expected_states as isize;
1501        let expected = global_test(
1502            &solution.residuals_m,
1503            expected_states,
1504            RaimConfig::default(),
1505        )
1506        .expect("expected-dof global test");
1507        let without_ztd = global_test(
1508            &solution.residuals_m,
1509            expected_states - 1,
1510            RaimConfig::default(),
1511        )
1512        .expect("old-dof global test");
1513
1514        assert_eq!(expected_states, 11);
1515        assert_eq!(expected_redundancy, 1);
1516        assert_eq!(raim.redundancy, expected_redundancy);
1517        assert_eq!(
1518            raim.threshold.map(f64::to_bits),
1519            expected.threshold.map(f64::to_bits)
1520        );
1521        assert_ne!(
1522            raim.threshold.map(f64::to_bits),
1523            without_ztd.threshold.map(f64::to_bits)
1524        );
1525    }
1526
1527    #[test]
1528    fn ztd_identification_uses_estimated_state_projection() {
1529        let (source, epoch, state) = synthetic_case(6, Some("G02"));
1530        let mut solve_config = fde_config();
1531        let mut tropo = super::super::TroposphereOptions::disabled();
1532        tropo.enabled = true;
1533        tropo.estimate_ztd = true;
1534        solve_config.tropo = tropo;
1535        let solve_tropo = solve_config.tropo;
1536        let mut solution =
1537            solve_float_epoch(&source, epoch.clone(), state, solve_config).expect("solve");
1538        assert!(solution.ztd_residual_m.is_some());
1539
1540        for residual in &mut solution.residuals_m {
1541            residual.code_m = if residual.satellite_id == "G02" {
1542                1.0
1543            } else if residual.satellite_id == "G05" {
1544                2.0
1545            } else {
1546                0.0
1547            };
1548            residual.phase_m = 0.0;
1549        }
1550
1551        let geometry =
1552            geometry_for_solution(&source, &epoch, &solution, solve_tropo).expect("geometry");
1553        let without_ztd =
1554            per_satellite_statistics(&solution.residuals_m, &geometry.rows, RaimConfig::default())
1555                .expect("without ztd");
1556        assert_eq!(without_ztd.most_likely_fault.as_deref(), Some("G05"));
1557
1558        let raim = raim_for_solution(
1559            &source,
1560            &epoch,
1561            &solution,
1562            solve_tropo,
1563            RaimConfig::default(),
1564        )
1565        .expect("RAIM with ZTD projection");
1566        assert_eq!(raim.most_likely_fault.as_deref(), Some("G02"));
1567        let g02 = raim
1568            .satellite_statistics
1569            .iter()
1570            .find(|stat| stat.satellite_id == "G02")
1571            .expect("G02 statistic");
1572        let g05 = raim
1573            .satellite_statistics
1574            .iter()
1575            .find(|stat| stat.satellite_id == "G05")
1576            .expect("G05 statistic");
1577        assert!(g02.statistic > 10.0 * g05.statistic);
1578    }
1579
1580    #[test]
1581    fn nonpositive_redundancy_returns_status() {
1582        let residuals = vec![residual("G01", 100.0, 0.0), residual("G02", 0.0, 0.0)];
1583
1584        let zero = global_test(&residuals, 4, RaimConfig::default()).unwrap();
1585        assert_eq!(zero.status, RaimStatus::NotEnoughRedundancy);
1586        assert!(!zero.detected);
1587        assert_eq!(zero.threshold, None);
1588        assert_eq!(zero.redundancy, 0);
1589
1590        let negative = global_test(&residuals, 5, RaimConfig::default()).unwrap();
1591        assert_eq!(negative.status, RaimStatus::NotEnoughRedundancy);
1592        assert!(!negative.detected);
1593        assert_eq!(negative.threshold, None);
1594        assert_eq!(negative.redundancy, -1);
1595    }
1596
1597    #[test]
1598    fn injected_outlier_has_largest_normalized_residual() {
1599        let mut residuals = clean_residuals();
1600        residuals.push(residual("G05", 0.0, 0.0));
1601        for residual in &mut residuals {
1602            residual.phase_m = 0.0;
1603        }
1604        residuals[3].code_m = 5.0;
1605
1606        let identification =
1607            per_satellite_statistics(&residuals, &test_geometry(), RaimConfig::default()).unwrap();
1608
1609        assert_eq!(identification.most_likely_fault.as_deref(), Some("G04"));
1610        let g04 = identification
1611            .statistics
1612            .iter()
1613            .find(|stat| stat.satellite_id == "G04")
1614            .expect("G04 statistic");
1615        assert_eq!(g04.statistic, g04.code);
1616        for stat in &identification.statistics {
1617            if stat.satellite_id != "G04" {
1618                assert!(g04.statistic > stat.statistic);
1619            }
1620        }
1621    }
1622
1623    #[test]
1624    fn phase_outlier_with_ambiguity_columns_is_unobservable() {
1625        let mut residuals = clean_residuals();
1626        residuals.push(residual("G05", 0.0, 0.0));
1627        for residual in &mut residuals {
1628            residual.code_m = 0.0;
1629            residual.phase_m = 0.0;
1630        }
1631        residuals[2].phase_m = 5.0;
1632
1633        assert_eq!(
1634            per_satellite_statistics(&residuals, &test_geometry(), RaimConfig::default()),
1635            Err(RaimError::SingularGeometry)
1636        );
1637    }
1638
1639    #[test]
1640    fn protection_levels_are_finite_and_positive() {
1641        let geometry = protection_geometry(&[
1642            (0.0, 70.0),
1643            (72.0, 55.0),
1644            (144.0, 50.0),
1645            (216.0, 45.0),
1646            (288.0, 40.0),
1647            (45.0, 65.0),
1648        ]);
1649
1650        let levels =
1651            protection_levels(&geometry, protection_receiver(), RaimConfig::default()).unwrap();
1652
1653        assert!(levels.hpl_m.is_finite() && levels.hpl_m > 0.0);
1654        assert!(levels.vpl_m.is_finite() && levels.vpl_m > 0.0);
1655    }
1656
1657    #[test]
1658    fn protection_levels_without_ztd_match_public_dop_path() {
1659        let geometry = protection_geometry(&[
1660            (0.0, 70.0),
1661            (72.0, 55.0),
1662            (144.0, 50.0),
1663            (216.0, 45.0),
1664            (288.0, 40.0),
1665            (45.0, 65.0),
1666        ]);
1667
1668        let public =
1669            protection_levels(&geometry, protection_receiver(), RaimConfig::default()).unwrap();
1670        let internal = protection_levels_with_ztd(
1671            &geometry,
1672            None,
1673            protection_receiver(),
1674            RaimConfig::default(),
1675        )
1676        .unwrap();
1677
1678        assert_eq!(internal.hpl_m.to_bits(), public.hpl_m.to_bits());
1679        assert_eq!(internal.vpl_m.to_bits(), public.vpl_m.to_bits());
1680    }
1681
1682    #[test]
1683    fn ztd_protection_levels_use_estimated_state_projection() {
1684        let (source, epoch, state) = synthetic_case(6, None);
1685        let mut solve_config = fde_config();
1686        let mut tropo = super::super::TroposphereOptions::disabled();
1687        tropo.enabled = true;
1688        tropo.estimate_ztd = true;
1689        solve_config.tropo = tropo;
1690        let solve_tropo = solve_config.tropo;
1691        let solution = solve_float_epoch(&source, epoch.clone(), state, solve_config)
1692            .expect("ZTD-estimated solve");
1693        let geometry =
1694            geometry_for_solution(&source, &epoch, &solution, solve_tropo).expect("geometry");
1695        let receiver = receiver_geodetic(solution.position_m);
1696
1697        let without_ztd =
1698            protection_levels(&geometry.rows, receiver, RaimConfig::default()).unwrap();
1699        let with_ztd = protection_levels_with_ztd(
1700            &geometry.rows,
1701            geometry.ztd_mappings.as_deref(),
1702            receiver,
1703            RaimConfig::default(),
1704        )
1705        .unwrap();
1706        let raim = raim_for_solution(
1707            &source,
1708            &epoch,
1709            &solution,
1710            solve_tropo,
1711            RaimConfig::default(),
1712        )
1713        .expect("RAIM with ZTD protection levels");
1714
1715        assert!(with_ztd.hpl_m.is_finite() && with_ztd.hpl_m > 0.0);
1716        assert!(with_ztd.vpl_m.is_finite() && with_ztd.vpl_m > 0.0);
1717        assert_ne!(with_ztd.hpl_m.to_bits(), without_ztd.hpl_m.to_bits());
1718        assert_ne!(with_ztd.vpl_m.to_bits(), without_ztd.vpl_m.to_bits());
1719        assert_eq!(raim.hpl_m.expect("HPL").to_bits(), with_ztd.hpl_m.to_bits());
1720        assert_eq!(raim.vpl_m.expect("VPL").to_bits(), with_ztd.vpl_m.to_bits());
1721    }
1722
1723    #[test]
1724    fn protection_levels_grow_when_geometry_degrades() {
1725        let normal = protection_geometry(&[
1726            (0.0, 70.0),
1727            (72.0, 55.0),
1728            (144.0, 50.0),
1729            (216.0, 45.0),
1730            (288.0, 40.0),
1731            (45.0, 65.0),
1732        ]);
1733        let degraded = protection_geometry(&[
1734            (0.0, 25.0),
1735            (8.0, 28.0),
1736            (16.0, 30.0),
1737            (24.0, 32.0),
1738            (32.0, 35.0),
1739            (40.0, 38.0),
1740        ]);
1741
1742        let normal =
1743            protection_levels(&normal, protection_receiver(), RaimConfig::default()).unwrap();
1744        let degraded =
1745            protection_levels(&degraded, protection_receiver(), RaimConfig::default()).unwrap();
1746
1747        assert!(degraded.hpl_m > normal.hpl_m);
1748        assert!(degraded.vpl_m > normal.vpl_m);
1749    }
1750
1751    #[test]
1752    fn ztd_protection_levels_grow_when_geometry_degrades() {
1753        let normal = protection_geometry(&[
1754            (0.0, 70.0),
1755            (72.0, 55.0),
1756            (144.0, 50.0),
1757            (216.0, 45.0),
1758            (288.0, 40.0),
1759            (45.0, 65.0),
1760        ]);
1761        let normal_mappings = [1.064, 1.221, 1.305, 1.414, 1.556, 1.103];
1762        let degraded = protection_geometry(&[
1763            (0.0, 25.0),
1764            (8.0, 28.0),
1765            (16.0, 30.0),
1766            (24.0, 32.0),
1767            (32.0, 35.0),
1768            (40.0, 38.0),
1769        ]);
1770        let degraded_mappings = [2.366, 2.134, 2.0, 1.887, 1.743, 1.624];
1771
1772        let normal = protection_levels_with_ztd(
1773            &normal,
1774            Some(&normal_mappings),
1775            protection_receiver(),
1776            RaimConfig::default(),
1777        )
1778        .unwrap();
1779        let degraded = protection_levels_with_ztd(
1780            &degraded,
1781            Some(&degraded_mappings),
1782            protection_receiver(),
1783            RaimConfig::default(),
1784        )
1785        .unwrap();
1786
1787        assert!(normal.hpl_m.is_finite() && normal.hpl_m > 0.0);
1788        assert!(normal.vpl_m.is_finite() && normal.vpl_m > 0.0);
1789        assert!(degraded.hpl_m.is_finite() && degraded.hpl_m > 0.0);
1790        assert!(degraded.vpl_m.is_finite() && degraded.vpl_m > 0.0);
1791        assert!(degraded.hpl_m > normal.hpl_m);
1792        assert!(degraded.vpl_m > normal.vpl_m);
1793    }
1794
1795    #[test]
1796    fn fde_excludes_faulted_satellite_and_restores_solution() {
1797        let (clean_source, clean_epoch, clean_state) = synthetic_case(6, None);
1798        let clean = solve_float_epoch(&clean_source, clean_epoch, clean_state, fde_config())
1799            .expect("clean solve");
1800        let (biased_source, biased_epoch, biased_state) = synthetic_case(6, Some("G05"));
1801
1802        let fde = fde_float_epoch(
1803            &biased_source,
1804            biased_epoch,
1805            biased_state,
1806            fde_config(),
1807            fde_raim_config(),
1808        )
1809        .expect("FDE");
1810
1811        assert_eq!(fde.status, RaimFdeStatus::Restored);
1812        assert_eq!(fde.excluded_sats, vec!["G05".to_string()]);
1813        assert!(fde.raim.hpl_m.expect("HPL") > 0.0);
1814        assert!(fde.raim.vpl_m.expect("VPL") > 0.0);
1815        assert_position_close(fde.solution.position_m, clean.position_m, 1.0e-3);
1816    }
1817
1818    #[test]
1819    fn fde_refuses_exclusion_when_redundancy_would_be_exhausted() {
1820        let (source, epoch, state) = synthetic_case(5, Some("G05"));
1821
1822        let fde =
1823            fde_float_epoch(&source, epoch, state, fde_config(), fde_raim_config()).expect("FDE");
1824
1825        assert_eq!(fde.status, RaimFdeStatus::CannotExclude);
1826        assert!(fde.excluded_sats.is_empty());
1827        assert!(fde.raim.detected);
1828    }
1829}