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
349
// SPDX-License-Identifier: AGPL-3.0-only
//! **Optical ↔ RF measurement handoff** — the receiver state-and-covariance transfer when
//! the active PNT measurement type switches between a **tight** optical modality (small
//! measurement noise `R`) and a **loose** RF modality (large `R`).
//!
//! The engineering hazard a handoff must avoid is a *jump* in the delivered navigation state
//! at the instant the sensor changes. This module makes that impossible by construction: the
//! handoff carries the estimator's **mean unchanged** (bit-for-bit) and only re-scales the
//! covariance. The three composed pieces are the ones the rest of the fusion stack already
//! uses:
//!
//! * **Joseph-form measurement update** — the numerically-stable covariance update
//! `P⁺ = (1−K)²P + K²R` (the scalar, per-axis form of the matrix Joseph update in
//! [`crate::kalman`] and [`crate::fusion::coupled`]). A tighter `R` (optical) deflates the
//! covariance more than a looser `R` (RF).
//! * **Closed-loop error-state reset** — at the handoff the estimated error state is folded
//! into the nominal and reset to zero, exactly as the closed-loop INS/GNSS filter in
//! [`crate::fusion::closed_loop`] resets after each correction. Because the correction is
//! already in the mean, the reset moves nothing: the delivered state is continuous.
//! * **NEES consistency gate** — the normalised estimation-error-squared
//! `ε = (x̂ − x)ᵀ P⁻¹ (x̂ − x)` is χ²-distributed with as many degrees of freedom as
//! states, so a *consistent* filter keeps `ε` inside the two-sided χ² gate
//! `[χ²_{0.025}(dof), χ²_{0.975}(dof)]` ([`crate::raim::chi2_quantile`]). A handoff that
//! inflated or deflated the covariance inconsistently would push `ε` out of that gate.
//!
//! ## Validated vs Modelled
//!
//! - **Validated (closed-form invariant).** The **mean-continuity** (no-jump) guarantee is
//! exact: [`HandoffState::handoff`] copies the mean bit-for-bit, so the delivered state is
//! provably unchanged across the switch. The NEES gate bounds are the exact χ² quantiles.
//! - **Modelled.** The specific optical / RF measurement-noise magnitudes and the
//! modality-transition covariance-inflation factor are representative inputs.
use crate::raim::chi2_quantile;
use serde::Serialize;
/// Lower / upper tail probabilities of the two-sided NEES consistency gate (95% central).
const NEES_GATE_LO_P: f64 = 0.025;
const NEES_GATE_HI_P: f64 = 0.975;
/// A receiver estimate with a **diagonal** covariance: the mean `x` and the per-axis
/// variances `p_diag`. Per-axis scalar updates keep the covariance diagonal, which is all
/// the handoff / mean-continuity argument needs (and keeps the NEES an exact sum of
/// per-axis normalised squares).
#[derive(Clone, Debug, PartialEq)]
pub struct HandoffState {
/// State mean (e.g. `[east, north, up, clock]`).
pub x: Vec<f64>,
/// Per-axis variance (the covariance diagonal), same length as `x`.
pub p_diag: Vec<f64>,
}
impl HandoffState {
/// A new state from a mean and its per-axis variances.
pub fn new(x: Vec<f64>, p_diag: Vec<f64>) -> Self {
HandoffState { x, p_diag }
}
/// Scalar **Joseph-form** measurement update of axis `i` by a direct observation `z`
/// with noise variance `r`: innovation `ν = z − x_i`, gain `K = P_ii/(P_ii + r)`,
/// `x_i ← x_i + Kν`, and `P_ii ← (1−K)²·P_ii + K²·r`. The Joseph form keeps the posterior
/// variance positive for any `K`. A tighter `r` produces a smaller posterior variance.
pub fn joseph_update_axis(&mut self, i: usize, z: f64, r: f64) {
if i >= self.x.len() {
return;
}
let p = self.p_diag[i];
let s = p + r;
if s <= 0.0 {
return;
}
let k = p / s;
self.x[i] += k * (z - self.x[i]);
self.p_diag[i] = (1.0 - k).powi(2) * p + k * k * r;
}
/// Apply a batch of per-axis Joseph updates `(axis, z, r)` in order.
pub fn apply_updates(&mut self, updates: &[(usize, f64, f64)]) {
for &(i, z, r) in updates {
self.joseph_update_axis(i, z, r);
}
}
/// **Hand the state off** to the other modality, inflating the covariance by
/// `1 + inflation_factor` to account for the modality-transition uncertainty. The
/// **mean is copied bit-for-bit** — this is the no-jump guarantee: the delivered state
/// is provably unchanged across the switch, only its uncertainty grows. `inflation_factor
/// = 0` reproduces the state exactly (mean AND covariance).
pub fn handoff(&self, inflation_factor: f64) -> HandoffState {
let scale = 1.0 + inflation_factor.max(0.0);
HandoffState {
x: self.x.clone(), // bit-for-bit mean continuity
p_diag: self.p_diag.iter().map(|&p| p * scale).collect(),
}
}
/// The **normalised estimation error squared (NEES)** against a `truth` state:
/// `ε = Σ_i (x_i − truth_i)² / P_ii`, distributed χ²_dof when the filter is consistent.
/// Missing/zero variances are skipped.
pub fn nees(&self, truth: &[f64]) -> f64 {
self.x
.iter()
.zip(self.p_diag.iter())
.zip(truth.iter())
.filter(|((_, &p), _)| p > 0.0)
.map(|((&xi, &p), &ti)| {
let e = xi - ti;
e * e / p
})
.sum()
}
/// The covariance trace (total variance) — the scalar the covariance
/// deflation/inflation is read off.
pub fn total_variance(&self) -> f64 {
self.p_diag.iter().sum()
}
}
/// The two-sided **NEES consistency gate** `[χ²_{0.025}(dof), χ²_{0.975}(dof)]` — the 95%
/// central χ² interval a consistent filter's NEES should fall in. Exact χ² quantiles from
/// [`crate::raim::chi2_quantile`].
pub fn nees_gate(dof: usize) -> (f64, f64) {
let d = dof.max(1) as f64;
(
chi2_quantile(NEES_GATE_LO_P, d),
chi2_quantile(NEES_GATE_HI_P, d),
)
}
/// Whether a NEES value sits inside the two-sided χ² consistency gate for `dof` states.
pub fn nees_in_gate(nees: f64, dof: usize) -> bool {
let (lo, hi) = nees_gate(dof);
(lo..=hi).contains(&nees)
}
/// The outcome of an optical→RF handoff demonstration: the pre/post-switch means (proving
/// bit-continuity), the covariance deflation/inflation, and the NEES consistency check.
#[derive(Clone, Debug, Serialize)]
pub struct HandoffOutcome {
/// State mean immediately before the handoff (after the optical update).
pub mean_before: Vec<f64>,
/// State mean immediately after the handoff (before any RF update).
pub mean_after: Vec<f64>,
/// `true` when the mean is bit-for-bit unchanged across the switch (the no-jump proof).
pub mean_continuous: bool,
/// The largest per-axis mean change across the switch (0.0 when continuous).
pub max_mean_jump: f64,
/// Total variance after the tight optical update (deflated).
pub variance_after_optical: f64,
/// Total variance after the handoff covariance inflation.
pub variance_after_handoff: f64,
/// Total variance after the loose RF update.
pub variance_after_rf: f64,
/// NEES of the final (post-RF) estimate against truth.
pub final_nees: f64,
/// The NEES consistency gate `[χ²_{0.025}, χ²_{0.975}]` for `dof` states.
pub nees_gate: (f64, f64),
/// `true` when the final NEES lies in the consistency gate.
pub nees_in_gate: bool,
/// Degrees of freedom (number of states).
pub dof: usize,
}
/// Run a full **optical → RF handoff** sequence and check both invariants.
///
/// Starting from `initial` and a `truth`, the tight optical measurements (`optical_updates`,
/// each `(axis, z, r)` with small `r`) are applied, the state is handed off with covariance
/// inflation `inflation_factor`, and then the loose RF measurements (`rf_updates`, large `r`)
/// are applied. The report proves the mean is continuous across the switch and reports
/// whether the NEES stays inside its χ² gate.
pub fn optical_rf_handoff(
initial: HandoffState,
truth: &[f64],
optical_updates: &[(usize, f64, f64)],
rf_updates: &[(usize, f64, f64)],
inflation_factor: f64,
) -> HandoffOutcome {
let dof = initial.x.len();
let mut state = initial;
state.apply_updates(optical_updates);
let variance_after_optical = state.total_variance();
let mean_before = state.x.clone();
// Closed-loop-style handoff: mean carried bit-for-bit, covariance inflated.
let mut handed = state.handoff(inflation_factor);
let mean_after = handed.x.clone();
let max_mean_jump = mean_before
.iter()
.zip(mean_after.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
let mean_continuous = mean_before == mean_after;
let variance_after_handoff = handed.total_variance();
handed.apply_updates(rf_updates);
let variance_after_rf = handed.total_variance();
let final_nees = handed.nees(truth);
let gate = nees_gate(dof);
HandoffOutcome {
mean_before,
mean_after,
mean_continuous,
max_mean_jump,
variance_after_optical,
variance_after_handoff,
variance_after_rf,
final_nees,
nees_gate: gate,
nees_in_gate: (gate.0..=gate.1).contains(&final_nees),
dof,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The handoff carries the mean bit-for-bit (the PROVEN no-jump guarantee) while
/// inflating the covariance; a zero inflation reproduces the state exactly. Oracle: the
/// closed-form mean-continuity invariant (exact float equality).
#[test]
fn handoff_is_bit_continuous_in_the_mean() {
let s = HandoffState::new(vec![1.25, -3.5, 7.0, 1e-9], vec![4.0, 4.0, 9.0, 1e-18]);
let h = s.handoff(0.5);
// Mean bit-for-bit identical (no jump).
assert_eq!(s.x, h.x, "handoff must not move the mean");
// Covariance inflated by exactly (1 + 0.5) on every axis.
for (a, b) in s.p_diag.iter().zip(h.p_diag.iter()) {
assert!((b - 1.5 * a).abs() < 1e-18 * a.max(1.0), "{b} vs 1.5·{a}");
}
// Zero inflation reproduces the state exactly (mean AND covariance).
let same = s.handoff(0.0);
assert_eq!(s.x, same.x);
assert_eq!(s.p_diag, same.p_diag);
}
/// The scalar Joseph update matches the hand-computed posterior, and a tight `R`
/// deflates the variance more than a loose `R`. Oracle: the closed-form Joseph update.
#[test]
fn joseph_update_deflates_and_matches_hand_value() {
// P = 4, r = 1 → K = 4/5 = 0.8; P⁺ = (0.2)²·4 + (0.8)²·1 = 0.16 + 0.64 = 0.8.
let mut s = HandoffState::new(vec![0.0], vec![4.0]);
s.joseph_update_axis(0, 10.0, 1.0);
assert!(
(s.p_diag[0] - 0.8).abs() < 1e-12,
"P⁺ {} vs 0.8",
s.p_diag[0]
);
// x⁺ = 0 + 0.8·(10 − 0) = 8.
assert!((s.x[0] - 8.0).abs() < 1e-12, "x⁺ {} vs 8", s.x[0]);
// Tight R deflates more than loose R from the same prior.
let mut tight = HandoffState::new(vec![0.0], vec![4.0]);
let mut loose = HandoffState::new(vec![0.0], vec![4.0]);
tight.joseph_update_axis(0, 0.0, 0.01);
loose.joseph_update_axis(0, 0.0, 100.0);
assert!(
tight.p_diag[0] < loose.p_diag[0],
"tight R should deflate more: {} vs {}",
tight.p_diag[0],
loose.p_diag[0]
);
assert!(tight.p_diag[0] > 0.0, "Joseph posterior stays positive");
}
/// The NEES gate bounds are the exact χ² quantiles, and a 1σ-per-axis error gives
/// NEES = dof, which lies inside the gate. Oracle: `raim::chi2_quantile`.
#[test]
fn nees_gate_matches_chi2_quantiles_and_admits_one_sigma() {
for dof in [1usize, 2, 4, 6] {
let (lo, hi) = nees_gate(dof);
assert!((lo - chi2_quantile(0.025, dof as f64)).abs() < 1e-12);
assert!((hi - chi2_quantile(0.975, dof as f64)).abs() < 1e-12);
assert!(
lo < dof as f64 && (dof as f64) < hi,
"dof {dof} inside gate"
);
// A state whose error is exactly 1σ on every axis has NEES = dof (in gate).
let x: Vec<f64> = (0..dof).map(|i| (i as f64) + 1.0).collect();
let p: Vec<f64> = (0..dof).map(|i| ((i as f64) + 2.0).powi(2)).collect();
let truth: Vec<f64> = x
.iter()
.zip(p.iter())
.map(|(&xi, &pi)| xi - pi.sqrt())
.collect();
let s = HandoffState::new(x, p);
let nees = s.nees(&truth);
assert!((nees - dof as f64).abs() < 1e-9, "NEES {nees} vs dof {dof}");
assert!(nees_in_gate(nees, dof), "1σ NEES must be in gate");
}
}
/// The full optical→RF handoff proves mean-continuity end-to-end, deflates on the tight
/// optical update, inflates at the handoff, and keeps a statistically-consistent estimate
/// in the NEES gate. Deterministic.
#[test]
fn full_handoff_is_continuous_consistent_and_deterministic() {
// Truth at origin; start 1σ off on every axis (a consistent initial error).
let truth = vec![0.0_f64, 0.0, 0.0, 0.0];
let p0: Vec<f64> = vec![9.0, 9.0, 16.0, 1e-16];
let x0: Vec<f64> = p0.iter().map(|&p| p.sqrt()).collect(); // 1σ initial error
let init = HandoffState::new(x0, p0.clone());
// Optical (tight) then RF (loose) measurements, each carrying a 1σ measurement
// offset from truth (a consistent draw) so the posterior sits ~1σ from truth and the
// NEES concentrates near the degrees of freedom.
let opt_r: Vec<f64> = p0.iter().map(|&p| p * 1e-4).collect();
let rf_r: Vec<f64> = p0.iter().map(|&p| p * 4.0).collect();
let optical: Vec<(usize, f64, f64)> = (0..4)
.map(|i| (i, truth[i] + opt_r[i].sqrt(), opt_r[i]))
.collect();
let rf: Vec<(usize, f64, f64)> = (0..4)
.map(|i| (i, truth[i] + rf_r[i].sqrt(), rf_r[i]))
.collect();
let a = optical_rf_handoff(init.clone(), &truth, &optical, &rf, 0.2);
let b = optical_rf_handoff(init, &truth, &optical, &rf, 0.2);
// Deterministic.
assert_eq!(a.mean_before, b.mean_before);
assert_eq!(a.final_nees, b.final_nees);
// No-jump: the mean is bit-continuous across the switch.
assert!(
a.mean_continuous,
"mean must be continuous across the handoff"
);
assert_eq!(a.max_mean_jump, 0.0);
// Covariance: deflated by the tight optical, then inflated at the handoff.
assert!(
a.variance_after_handoff > a.variance_after_optical,
"handoff inflates"
);
assert!(
a.variance_after_optical < init_variance(&p0),
"optical deflates"
);
// The estimate stays inside its NEES consistency gate.
assert!(
a.nees_in_gate,
"final NEES {} not in gate {:?}",
a.final_nees, a.nees_gate
);
}
fn init_variance(p0: &[f64]) -> f64 {
p0.iter().sum()
}
}