Skip to main content

sidereon_core/araim/
mhss.rs

1use std::collections::BTreeSet;
2
3use super::fault_modes::{enumerate_fault_modes_checked, FaultHypothesis};
4use super::ism::Ism;
5use super::protection::{
6    gain_matrix_enu, gain_matrix_enu_for_clock_systems, k_false_alert, metric_bias, metric_sigma,
7    separation_sigma, solve_protection_level, FalseAlertAxis, ProtectionEquationTerm,
8    ProtectionLevelSolution,
9};
10use super::{
11    clock_system_for_row, validate_probability, AraimError, AraimGeometry, IntegrityAllocation,
12};
13use crate::id::{GnssSatelliteId, GnssSystem};
14
15// WG-C Reference ADD v3.0 Table 3 and Appendix B, Eq. (68): TOLPL is 5e-2 m.
16const HORIZONTAL_PL_TOL_M: f64 = 5.0e-2;
17const VERTICAL_PL_TOL_M: f64 = 1.0e-4;
18
19/// Per-hypothesis ARAIM monitor data.
20#[derive(Debug, Clone, PartialEq)]
21pub struct FaultMode {
22    /// Satellites excluded by this mode.
23    pub excluded: Vec<GnssSatelliteId>,
24    /// Constellation excluded by this mode, if any.
25    pub excluded_constellation: Option<GnssSystem>,
26    /// Fault prior probability for this mode.
27    pub prior: f64,
28    /// Integrity sigma in local `[east, north, up]`, meters.
29    pub sigma_int_enu_m: [f64; 3],
30    /// Nominal bias bound in local `[east, north, up]`, meters.
31    pub bias_enu_m: [f64; 3],
32    /// Separation monitor threshold in local `[east, north, up]`, meters.
33    pub threshold_enu_m: [f64; 3],
34    /// True when the subset geometry is full-rank.
35    pub monitorable: bool,
36}
37
38/// ARAIM protection-level result.
39#[derive(Debug, Clone, PartialEq)]
40pub struct AraimResult {
41    /// True when ARAIM met the allocation and all PL roots converged.
42    pub available: bool,
43    /// Horizontal protection level, meters.
44    pub hpl_m: f64,
45    /// Vertical protection level, meters.
46    pub vpl_m: f64,
47    /// All-in-view horizontal accuracy sigma, meters.
48    pub sigma_acc_h_m: f64,
49    /// All-in-view vertical accuracy sigma, meters.
50    pub sigma_acc_v_m: f64,
51    /// Effective monitor threshold, meters.
52    pub emt_m: f64,
53    /// Per-mode monitor data, including the fault-free mode first.
54    pub fault_modes: Vec<FaultMode>,
55    /// Unenumerated plus unmonitorable fault probability mass.
56    pub p_unmonitored: f64,
57    /// True when ARAIM met the allocation and all PL roots converged.
58    ///
59    /// Prefer [`AraimResult::available`]. This field is kept as an alias for
60    /// existing callers.
61    pub availability: bool,
62}
63
64/// Run the ARAIM MHSS protection-level algorithm.
65pub fn araim(
66    geometry: &AraimGeometry,
67    ism: &Ism,
68    allocation: &IntegrityAllocation,
69) -> Result<AraimResult, AraimError> {
70    validate_geometry(geometry)?;
71    validate_allocation(allocation)?;
72    ism.validate()?;
73
74    let effective = geometry
75        .rows
76        .iter()
77        .map(|row| ism.effective_for(row))
78        .collect::<Result<Vec<_>, _>>()?;
79    let sigma_int_m: Vec<f64> = effective.iter().map(|model| model.sigma_int_m).collect();
80    let sigma_acc_m: Vec<f64> = effective.iter().map(|model| model.sigma_acc_m).collect();
81    let bias_m: Vec<f64> = effective.iter().map(|model| model.b_nom_m).collect();
82    let weights_int: Vec<f64> = sigma_int_m
83        .iter()
84        .map(|sigma| 1.0 / (sigma * sigma))
85        .collect();
86
87    let enumeration = enumerate_fault_modes_checked(geometry, ism, allocation)?;
88    let n_fault_modes = enumeration.modes.len().saturating_sub(1);
89    let k_h = k_false_alert(
90        allocation.pfa_hor,
91        n_fault_modes,
92        FalseAlertAxis::Horizontal,
93    )?;
94    let k_v = k_false_alert(allocation.pfa_vert, n_fault_modes, FalseAlertAxis::Vertical)?;
95
96    let fault_free_int = gain_matrix_enu(geometry, &weights_int)?;
97    let sigma_acc_e = metric_sigma(&fault_free_int.enu_rows[0], &sigma_acc_m);
98    let sigma_acc_n = metric_sigma(&fault_free_int.enu_rows[1], &sigma_acc_m);
99    let sigma_acc_u = metric_sigma(&fault_free_int.enu_rows[2], &sigma_acc_m);
100
101    let mut p_unmonitored = enumeration.p_unenumerated;
102    let mut fault_modes = Vec::with_capacity(enumeration.modes.len());
103    for (mode_idx, hypothesis) in enumeration.modes.iter().enumerate() {
104        let mode = if mode_idx == 0 {
105            compute_monitorable_mode(
106                hypothesis,
107                MonitorInputs {
108                    gain_int: &fault_free_int,
109                    fault_free_int: &fault_free_int,
110                    sigma_int_m: &sigma_int_m,
111                    sigma_acc_m: &sigma_acc_m,
112                    bias_m: &bias_m,
113                    k: [0.0, 0.0, 0.0],
114                },
115            )
116        } else {
117            let weights_int_k = zeroed_weights(geometry, &weights_int, hypothesis);
118            let clock_systems_k = active_clock_systems(geometry, hypothesis);
119            match gain_matrix_enu_for_clock_systems(geometry, &weights_int_k, &clock_systems_k) {
120                Ok(gain_int) => compute_monitorable_mode(
121                    hypothesis,
122                    MonitorInputs {
123                        gain_int: &gain_int,
124                        fault_free_int: &fault_free_int,
125                        sigma_int_m: &sigma_int_m,
126                        sigma_acc_m: &sigma_acc_m,
127                        bias_m: &bias_m,
128                        k: [k_h, k_h, k_v],
129                    },
130                ),
131                _ => {
132                    p_unmonitored += hypothesis.prior;
133                    unmonitorable_mode(hypothesis)
134                }
135            }
136        };
137        fault_modes.push(mode);
138    }
139
140    if p_unmonitored > allocation.p_threshold_unmonitored {
141        return Ok(unavailable_result(
142            fault_modes,
143            p_unmonitored,
144            sigma_acc_e,
145            sigma_acc_n,
146            sigma_acc_u,
147            allocation,
148        ));
149    }
150
151    let budget_scale = match integrity_budget_scale(allocation, p_unmonitored) {
152        Ok(scale) => scale,
153        Err(AraimError::UnmonitorableFaultMass) => {
154            return Ok(unavailable_result(
155                fault_modes,
156                p_unmonitored,
157                sigma_acc_e,
158                sigma_acc_n,
159                sigma_acc_u,
160                allocation,
161            ));
162        }
163        Err(error) => return Err(error),
164    };
165    let pl_e = solve_coord_pl(
166        &fault_modes,
167        0,
168        0.5 * allocation.phmi_hor * budget_scale,
169        HORIZONTAL_PL_TOL_M,
170    )?;
171    let pl_n = solve_coord_pl(
172        &fault_modes,
173        1,
174        0.5 * allocation.phmi_hor * budget_scale,
175        HORIZONTAL_PL_TOL_M,
176    )?;
177    let pl_u = solve_coord_pl(
178        &fault_modes,
179        2,
180        allocation.phmi_vert * budget_scale,
181        VERTICAL_PL_TOL_M,
182    )?;
183    let roots_converged = pl_e.converged && pl_n.converged && pl_u.converged;
184    let emt_m = fault_modes
185        .iter()
186        .filter(|mode| mode.monitorable)
187        .filter(|mode| mode.prior >= allocation.p_emt)
188        .map(|mode| mode.threshold_enu_m[2])
189        .fold(0.0_f64, f64::max);
190    let fault_free_full_rank = fault_modes
191        .first()
192        .map(|mode| mode.monitorable)
193        .unwrap_or(false);
194
195    let available = fault_free_full_rank && roots_converged;
196    Ok(AraimResult {
197        available,
198        hpl_m: (pl_e.value_m * pl_e.value_m + pl_n.value_m * pl_n.value_m).sqrt(),
199        vpl_m: pl_u.value_m,
200        sigma_acc_h_m: (sigma_acc_e * sigma_acc_e + sigma_acc_n * sigma_acc_n).sqrt(),
201        sigma_acc_v_m: sigma_acc_u,
202        emt_m,
203        fault_modes,
204        p_unmonitored,
205        availability: available,
206    })
207}
208
209fn unavailable_result(
210    fault_modes: Vec<FaultMode>,
211    p_unmonitored: f64,
212    sigma_acc_e: f64,
213    sigma_acc_n: f64,
214    sigma_acc_u: f64,
215    allocation: &IntegrityAllocation,
216) -> AraimResult {
217    let emt_m = fault_modes
218        .iter()
219        .filter(|mode| mode.monitorable)
220        .filter(|mode| mode.prior >= allocation.p_emt)
221        .map(|mode| mode.threshold_enu_m[2])
222        .fold(0.0_f64, f64::max);
223    AraimResult {
224        available: false,
225        hpl_m: f64::INFINITY,
226        vpl_m: f64::INFINITY,
227        sigma_acc_h_m: (sigma_acc_e * sigma_acc_e + sigma_acc_n * sigma_acc_n).sqrt(),
228        sigma_acc_v_m: sigma_acc_u,
229        emt_m,
230        fault_modes,
231        p_unmonitored,
232        availability: false,
233    }
234}
235
236struct MonitorInputs<'a> {
237    gain_int: &'a super::protection::GainMatrix,
238    fault_free_int: &'a super::protection::GainMatrix,
239    sigma_int_m: &'a [f64],
240    sigma_acc_m: &'a [f64],
241    bias_m: &'a [f64],
242    k: [f64; 3],
243}
244
245fn compute_monitorable_mode(hypothesis: &FaultHypothesis, inputs: MonitorInputs<'_>) -> FaultMode {
246    let mut sigma_int_enu_m = [0.0_f64; 3];
247    let mut bias_enu_m = [0.0_f64; 3];
248    let mut threshold_enu_m = [0.0_f64; 3];
249    for coord in 0..3 {
250        sigma_int_enu_m[coord] = metric_sigma(&inputs.gain_int.enu_rows[coord], inputs.sigma_int_m);
251        bias_enu_m[coord] = metric_bias(&inputs.gain_int.enu_rows[coord], inputs.bias_m);
252        threshold_enu_m[coord] = inputs.k[coord]
253            * separation_sigma(
254                &inputs.gain_int.enu_rows[coord],
255                &inputs.fault_free_int.enu_rows[coord],
256                inputs.sigma_acc_m,
257            );
258    }
259
260    FaultMode {
261        excluded: hypothesis.excluded.clone(),
262        excluded_constellation: hypothesis.excluded_constellation,
263        prior: hypothesis.prior,
264        sigma_int_enu_m,
265        bias_enu_m,
266        threshold_enu_m,
267        monitorable: true,
268    }
269}
270
271fn unmonitorable_mode(hypothesis: &FaultHypothesis) -> FaultMode {
272    FaultMode {
273        excluded: hypothesis.excluded.clone(),
274        excluded_constellation: hypothesis.excluded_constellation,
275        prior: hypothesis.prior,
276        sigma_int_enu_m: [f64::INFINITY; 3],
277        bias_enu_m: [f64::INFINITY; 3],
278        threshold_enu_m: [f64::INFINITY; 3],
279        monitorable: false,
280    }
281}
282
283fn zeroed_weights(
284    geometry: &AraimGeometry,
285    weights: &[f64],
286    hypothesis: &FaultHypothesis,
287) -> Vec<f64> {
288    geometry
289        .rows
290        .iter()
291        .zip(weights)
292        .map(|(row, &weight)| {
293            if hypothesis.excludes_satellite(row.id, row.system) {
294                0.0
295            } else {
296                weight
297            }
298        })
299        .collect()
300}
301
302fn active_clock_systems(geometry: &AraimGeometry, hypothesis: &FaultHypothesis) -> Vec<GnssSystem> {
303    geometry
304        .clock_systems
305        .iter()
306        .copied()
307        .filter(|&clock_system| {
308            geometry.rows.iter().any(|row| {
309                !hypothesis.excludes_satellite(row.id, row.system)
310                    && clock_system_for_row(row.system) == clock_system
311            })
312        })
313        .collect()
314}
315
316fn solve_coord_pl(
317    modes: &[FaultMode],
318    coord: usize,
319    integrity_target: f64,
320    tolerance_m: f64,
321) -> Result<ProtectionLevelSolution, AraimError> {
322    let fault_free = modes.first().ok_or(AraimError::NumericalFailure)?;
323    if !fault_free.monitorable {
324        return Err(AraimError::InsufficientGeometry);
325    }
326    let fault_free_term = ProtectionEquationTerm {
327        prior: 1.0,
328        sigma_m: fault_free.sigma_int_enu_m[coord],
329        bias_m: fault_free.bias_enu_m[coord],
330        threshold_m: 0.0,
331    };
332    let fault_terms: Vec<ProtectionEquationTerm> = modes
333        .iter()
334        .skip(1)
335        .filter(|mode| mode.monitorable)
336        .map(|mode| ProtectionEquationTerm {
337            prior: mode.prior,
338            sigma_m: mode.sigma_int_enu_m[coord],
339            bias_m: mode.bias_enu_m[coord],
340            threshold_m: mode.threshold_enu_m[coord],
341        })
342        .collect();
343    solve_protection_level(fault_free_term, &fault_terms, integrity_target, tolerance_m)
344}
345
346fn integrity_budget_scale(
347    allocation: &IntegrityAllocation,
348    p_unmonitored: f64,
349) -> Result<f64, AraimError> {
350    let phmi_split = allocation.phmi_vert + allocation.phmi_hor;
351    let scale = 1.0 - p_unmonitored / phmi_split;
352    if scale > 0.0 && scale.is_finite() {
353        Ok(scale)
354    } else {
355        Err(AraimError::UnmonitorableFaultMass)
356    }
357}
358
359fn validate_geometry(geometry: &AraimGeometry) -> Result<(), AraimError> {
360    if geometry.clock_systems.is_empty() {
361        return Err(AraimError::InsufficientGeometry);
362    }
363    let n_state = 3 + geometry.clock_systems.len();
364    if geometry.rows.len() < n_state {
365        return Err(AraimError::InsufficientGeometry);
366    }
367    let mut clock_systems = BTreeSet::new();
368    for &system in &geometry.clock_systems {
369        if !clock_systems.insert(system) {
370            return Err(AraimError::InsufficientGeometry);
371        }
372    }
373
374    let mut ids = BTreeSet::new();
375    for row in &geometry.rows {
376        if row.id.system != row.system || !ids.insert(row.id) {
377            return Err(AraimError::InsufficientGeometry);
378        }
379        if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2)
380            .contains(&row.elevation_rad)
381        {
382            return Err(AraimError::InsufficientGeometry);
383        }
384        let los = row.line_of_sight;
385        let norm = (los.e_x * los.e_x + los.e_y * los.e_y + los.e_z * los.e_z).sqrt();
386        if !norm.is_finite() || (norm - 1.0).abs() > 1.0e-3 {
387            return Err(AraimError::InsufficientGeometry);
388        }
389    }
390    Ok(())
391}
392
393fn validate_allocation(allocation: &IntegrityAllocation) -> Result<(), AraimError> {
394    let phmi_split = allocation.phmi_vert + allocation.phmi_hor;
395    let phmi_split_tolerance = allocation.phmi_total * 16.0 * f64::EPSILON;
396    let valid = validate_probability(allocation.phmi_total, false)
397        && validate_probability(allocation.phmi_vert, false)
398        && validate_probability(allocation.phmi_hor, false)
399        && validate_probability(allocation.pfa_vert, false)
400        && validate_probability(allocation.pfa_hor, false)
401        && validate_probability(allocation.p_threshold_unmonitored, true)
402        && validate_probability(allocation.p_emt, false)
403        && phmi_split <= allocation.phmi_total + phmi_split_tolerance;
404    if valid {
405        Ok(())
406    } else {
407        Err(AraimError::InvalidAllocation)
408    }
409}