1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
// SPDX-License-Identifier: AGPL-3.0-only
//! **Cross-modality RAIM / protection level** — receiver-autonomous integrity for a
//! *heterogeneous* pair of PNT solutions. Where [`crate::raim`] monitors many homogeneous
//! RF pseudoranges of one constellation, this module treats an **RF** PVT/time solution and
//! an **optical** PVT/time solution as two *independent* measurements of the same underlying
//! geometry, with **disparate covariances** (RF loose — metres, nanoseconds; optical tight —
//! centimetres, picoseconds), and monitors their consistency.
//!
//! ## The detector (solution separation over two modalities)
//!
//! On each state axis the two modalities report `y_rf ~ N(x, σ_rf²)` and
//! `y_opt ~ N(x, σ_opt²)`. Under the fault-free hypothesis their **separation**
//! `Δ = y_rf − y_opt` is zero-mean Gaussian with variance `σ_rf² + σ_opt²`, so the
//! normalised statistic
//!
//! ```text
//! T = Σ_axes Δ_axis² / (σ_rf,axis² + σ_opt,axis²)
//! ```
//!
//! is χ² with as many degrees of freedom as monitored axes. A fault is declared when `T`
//! exceeds the exact quantile `χ²_{1−P_fa}(dof)` ([`crate::raim::chi2_quantile`]) — the same
//! closed-form χ² law the homogeneous RAIM uses.
//!
//! ## The protection level (position AND timing)
//!
//! The delivered solution is the inverse-variance (minimum-variance) fusion of the two
//! modalities. Per axis the cross-modality protection level is the solution-separation bound
//!
//! ```text
//! PL_axis = K_fa · √(σ_rf² + σ_opt²) · max(w_rf, w_opt) + K_md · σ_fused,
//! ```
//!
//! with `w_k = σ_fused²/σ_k²` the fusion weight of modality `k` (`σ_fused² = 1/(1/σ_rf² +
//! 1/σ_opt²)`), `K_fa = Φ⁻¹(1 − P_fa/2)` and `K_md = Φ⁻¹(1 − P_md)`
//! ([`crate::raim::normal_quantile`]). The first term is the largest fused-estimate error a
//! bias just below the separation-detection threshold can inject; the second covers the
//! fused noise. Horizontal, vertical and **timing** protection levels are read off the
//! axis roles.
//!
//! ## Validated vs Modelled
//!
//! - **Validated (closed form).** The χ² detection threshold (`chi2_cdf(threshold, dof) =
//! 1 − P_fa` to ~1e-12), the quadratic-form separation statistic, the inverse-variance
//! fusion, and the `K_fa/K_md` normal quantiles are exact analytic identities checked
//! against independently-computed hand values.
//! - **Modelled.** The specific RF and optical 1σ *magnitudes* are the (illustrative)
//! inputs; the horizontal radial RSS of two axes is the same deliberately-conservative
//! simplification the homogeneous RAIM uses.
//!
//! ## References
//! * Brown, *A Baseline GPS RAIM Scheme…* (NAVIGATION, 1992) — the χ² separation detector.
//! * Blanch et al., *Baseline Advanced RAIM User Algorithm* — the solution-separation
//! protection-level form generalised here to the two-modality case.
use crate::raim::{chi2_quantile, normal_quantile};
use serde::Serialize;
/// The role a monitored axis plays in the horizontal / vertical / timing protection split.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AxisRole {
/// A horizontal position component (east or north) — combined radially into the HPL.
Horizontal,
/// The vertical position component (up) — the VPL.
Vertical,
/// The clock / time axis (seconds) — the timing protection level.
Timing,
}
/// One monitored axis: the RF and optical estimates and their 1σ (disparate) uncertainties.
/// Position axes carry metres; the timing axis carries seconds.
#[derive(Clone, Debug, PartialEq)]
pub struct CrossAxis {
/// A short axis label (e.g. `"east"`, `"up"`, `"clock"`).
pub name: String,
/// The axis role (sets which protection level it feeds).
pub role: AxisRole,
/// RF (loose) estimate on this axis.
pub rf_value: f64,
/// RF 1σ (> 0).
pub rf_sigma: f64,
/// Optical (tight) estimate on this axis.
pub opt_value: f64,
/// Optical 1σ (> 0).
pub opt_sigma: f64,
}
/// Inverse-variance (minimum-variance) fusion of two independent scalar estimates:
/// `x̂ = (y1/σ1² + y2/σ2²)/(1/σ1² + 1/σ2²)`, `σ_fused = √(1/(1/σ1² + 1/σ2²))`. Returns
/// `(x̂, σ_fused)`. Non-positive input σ are floored to a tiny positive value so the fusion
/// stays finite.
pub fn inverse_variance_fuse(y1: f64, s1: f64, y2: f64, s2: f64) -> (f64, f64) {
let w1 = 1.0 / s1.max(f64::MIN_POSITIVE).powi(2);
let w2 = 1.0 / s2.max(f64::MIN_POSITIVE).powi(2);
let xhat = (y1 * w1 + y2 * w2) / (w1 + w2);
let sigma = (1.0 / (w1 + w2)).sqrt();
(xhat, sigma)
}
/// The single-axis normalised separation statistic `Δ²/(σ_rf² + σ_opt²)`, distributed χ²₁
/// under the fault-free hypothesis. Summing this over axes gives the χ²_dof detector.
pub fn separation_statistic_axis(
rf_value: f64,
opt_value: f64,
rf_sigma: f64,
opt_sigma: f64,
) -> f64 {
let delta = rf_value - opt_value;
let var = rf_sigma.powi(2) + opt_sigma.powi(2);
delta * delta / var.max(f64::MIN_POSITIVE)
}
/// The single-axis cross-modality protection level
/// `PL = K_fa·√(σ_rf² + σ_opt²)·max(w_rf, w_opt) + K_md·σ_fused` (see the module docs).
/// `p_fa` / `p_md` are the false-alarm / missed-detection probabilities.
pub fn axis_protection_level(rf_sigma: f64, opt_sigma: f64, p_fa: f64, p_md: f64) -> f64 {
let k_fa = normal_quantile(1.0 - p_fa / 2.0);
let k_md = normal_quantile(1.0 - p_md);
let (_, sigma_f) = inverse_variance_fuse(0.0, rf_sigma, 0.0, opt_sigma);
let sigma_sum = (rf_sigma.powi(2) + opt_sigma.powi(2)).sqrt();
let sf2 = sigma_f * sigma_f;
let w_rf = sf2 / rf_sigma.max(f64::MIN_POSITIVE).powi(2);
let w_opt = sf2 / opt_sigma.max(f64::MIN_POSITIVE).powi(2);
k_fa * sigma_sum * w_rf.max(w_opt) + k_md * sigma_f
}
/// The per-axis outcome: the fused estimate, its σ, the separation statistic, and the axis
/// protection level.
#[derive(Clone, Debug, Serialize)]
pub struct CrossAxisResult {
/// The axis label.
pub name: String,
/// The axis role.
pub role: AxisRole,
/// Fused (minimum-variance) estimate.
pub fused_value: f64,
/// Fused 1σ.
pub fused_sigma: f64,
/// Normalised separation statistic `Δ²/(σ_rf² + σ_opt²)` (χ²₁ under the null).
pub separation_statistic: f64,
/// The cross-modality protection level on this axis (metres for position, seconds for
/// the timing axis).
pub protection_level: f64,
}
/// The cross-modality RAIM outcome for one epoch.
#[derive(Clone, Debug, Serialize)]
pub struct CrossRaimResult {
/// Number of monitored axes (= χ² degrees of freedom).
pub n_axes: usize,
/// The χ² separation statistic `Σ Δ²/(σ_rf² + σ_opt²)`.
pub chi2_statistic: f64,
/// The detection threshold `χ²_{1−P_fa}(n_axes)`.
pub chi2_threshold: f64,
/// `true` when the statistic exceeds the threshold (the modalities disagree).
pub fault_detected: bool,
/// Horizontal protection level (m): the radial RSS of the horizontal-axis PLs.
pub hpl_m: f64,
/// Vertical protection level (m): the vertical-axis PL (0 if no vertical axis).
pub vpl_m: f64,
/// Timing protection level (s): the timing-axis PL (0 if no timing axis).
pub tpl_s: f64,
/// Per-axis detail.
pub axes: Vec<CrossAxisResult>,
}
/// Run cross-modality RAIM over `axes` at false-alarm `p_fa` and missed-detection `p_md`.
/// Forms the χ² separation detector across all axes and the per-axis solution-separation
/// protection levels, then rolls the axis roles up into the horizontal / vertical / timing
/// protection levels.
pub fn run_cross_raim(axes: &[CrossAxis], p_fa: f64, p_md: f64) -> CrossRaimResult {
let mut chi2 = 0.0;
let mut hpl_sq = 0.0;
let mut vpl = 0.0;
let mut tpl = 0.0;
let mut out = Vec::with_capacity(axes.len());
for ax in axes {
let stat = separation_statistic_axis(ax.rf_value, ax.opt_value, ax.rf_sigma, ax.opt_sigma);
chi2 += stat;
let (fused_value, fused_sigma) =
inverse_variance_fuse(ax.rf_value, ax.rf_sigma, ax.opt_value, ax.opt_sigma);
let pl = axis_protection_level(ax.rf_sigma, ax.opt_sigma, p_fa, p_md);
match ax.role {
AxisRole::Horizontal => hpl_sq += pl * pl,
AxisRole::Vertical => vpl = pl,
AxisRole::Timing => tpl = pl,
}
out.push(CrossAxisResult {
name: ax.name.clone(),
role: ax.role,
fused_value,
fused_sigma,
separation_statistic: stat,
protection_level: pl,
});
}
let dof = axes.len().max(1) as f64;
let threshold = chi2_quantile(1.0 - p_fa, dof);
CrossRaimResult {
n_axes: axes.len(),
chi2_statistic: chi2,
chi2_threshold: threshold,
fault_detected: chi2 > threshold,
hpl_m: hpl_sq.sqrt(),
vpl_m: vpl,
tpl_s: tpl,
axes: out,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::raim::chi2_cdf;
/// A representative heterogeneous solution: loose RF (metres, ns) and tight optical
/// (cm, ps) on east/north/up + a clock axis, in agreement (no fault).
fn consistent_axes() -> Vec<CrossAxis> {
let mk = |name: &str, role, rf_s, opt_s| CrossAxis {
name: name.to_string(),
role,
rf_value: 0.0,
rf_sigma: rf_s,
opt_value: 0.0,
opt_sigma: opt_s,
};
vec![
mk("east", AxisRole::Horizontal, 3.0, 0.02),
mk("north", AxisRole::Horizontal, 3.0, 0.02),
mk("up", AxisRole::Vertical, 5.0, 0.03),
mk("clock", AxisRole::Timing, 1.0e-8, 1.0e-11),
]
}
/// The detection threshold is the EXACT χ² quantile: feeding it back through the χ² CDF
/// recovers `1 − P_fa` to ~1e-9, at several degrees of freedom. Oracle: the validated
/// `raim::{chi2_cdf, chi2_quantile}` inverse pair.
#[test]
fn threshold_is_the_exact_chi2_quantile() {
for p_fa in [1e-2, 1e-4, 1e-6] {
for dof in [1.0, 2.0, 4.0] {
let thr = chi2_quantile(1.0 - p_fa, dof);
let back = chi2_cdf(thr, dof);
assert!(
(back - (1.0 - p_fa)).abs() < 1e-9,
"chi2_cdf(thr={thr}, dof={dof}) = {back} vs 1-P_fa = {}",
1.0 - p_fa
);
}
}
}
/// The separation statistic is the analytic quadratic form `Δ²/(σ_rf² + σ_opt²)`, and
/// the whole-detector statistic is the sum over axes. Oracle: the closed-form χ²
/// quadratic form.
#[test]
fn separation_statistic_matches_the_quadratic_form() {
// Δ = 6 m, σ_rf = 3, σ_opt = 4 → Δ²/(9+16) = 36/25 = 1.44.
let s = separation_statistic_axis(6.0, 0.0, 3.0, 4.0);
assert!((s - 1.44).abs() < 1e-12, "statistic {s} vs 1.44");
// Sum over a two-axis run.
let axes = vec![
CrossAxis {
name: "a".into(),
role: AxisRole::Horizontal,
rf_value: 6.0,
rf_sigma: 3.0,
opt_value: 0.0,
opt_sigma: 4.0,
},
CrossAxis {
name: "b".into(),
role: AxisRole::Vertical,
rf_value: 0.0,
rf_sigma: 3.0,
opt_value: 0.0,
opt_sigma: 4.0,
},
];
let r = run_cross_raim(&axes, 1e-3, 1e-4);
assert!((r.chi2_statistic - 1.44).abs() < 1e-12);
assert_eq!(r.n_axes, 2);
}
/// Inverse-variance fusion matches the hand formula: the fused variance is the harmonic
/// combination, and the tighter (optical) modality dominates the estimate. Oracle: the
/// closed-form minimum-variance combination.
#[test]
fn fusion_matches_the_minimum_variance_formula() {
// σ1 = 3, σ2 = 4 → σ_f² = 1/(1/9 + 1/16) = 144/25 = 5.76 → σ_f = 2.4.
let (_x, sf) = inverse_variance_fuse(0.0, 3.0, 0.0, 4.0);
assert!((sf - 2.4).abs() < 1e-12, "σ_f {sf} vs 2.4");
// The tight modality pulls the estimate: y_rf = 10 (σ=3), y_opt = 0 (σ=0.1).
let (x, sf2) = inverse_variance_fuse(10.0, 3.0, 0.0, 0.1);
assert!(
x.abs() < 0.02,
"fused estimate {x} should sit near the tight optical 0"
);
assert!(sf2 < 0.1, "fused σ {sf2} must beat the tighter input");
}
/// A consistent heterogeneous solution is NOT flagged and its protection levels are
/// finite; a large optical-vs-RF disagreement IS flagged. Position and timing PLs are
/// both produced.
#[test]
fn detects_disagreement_and_produces_position_and_timing_pls() {
let r = run_cross_raim(&consistent_axes(), 1e-4, 1e-4);
assert!(!r.fault_detected, "consistent modalities must not fault");
assert!(r.hpl_m > 0.0 && r.vpl_m > 0.0 && r.tpl_s > 0.0);
// Integrity is limited by the loose RF *reference*: a large optical fault can hide
// under the RF separation noise, so the protection level sits on the RF-noise scale
// (≈ K_fa·σ_rf), far above the tight fused σ — the honest cross-check bound.
let (_x, sigma_f) = inverse_variance_fuse(0.0, 3.0, 0.0, 0.02);
assert!(
r.hpl_m > sigma_f && (5.0..40.0).contains(&r.hpl_m),
"HPL {} m should be RF-reference-limited (fused σ {sigma_f} m)",
r.hpl_m
);
// Inject a 50 m optical-vs-RF disagreement on the up axis → the detector trips.
let mut faulted = consistent_axes();
faulted[2].opt_value = 50.0;
let rf = run_cross_raim(&faulted, 1e-4, 1e-4);
assert!(
rf.fault_detected,
"a 50 m modality disagreement must be detected"
);
assert!(rf.chi2_statistic > rf.chi2_threshold);
}
/// The protection level tightens with the optical σ (a better optical modality lowers
/// the fused bound) and the whole result is deterministic.
#[test]
fn pl_tightens_with_optical_and_is_deterministic() {
let base = run_cross_raim(&consistent_axes(), 1e-4, 1e-4);
let again = run_cross_raim(&consistent_axes(), 1e-4, 1e-4);
assert_eq!(base.hpl_m, again.hpl_m);
assert_eq!(base.tpl_s, again.tpl_s);
let mut tighter = consistent_axes();
for ax in &mut tighter {
ax.opt_sigma *= 0.5;
}
let t = run_cross_raim(&tighter, 1e-4, 1e-4);
assert!(t.hpl_m < base.hpl_m, "tighter optical must lower the HPL");
assert!(t.tpl_s < base.tpl_s, "tighter optical must lower the TPL");
}
}