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