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