use super::tightly_coupled::Sat;
use crate::fusion::ukf::Ukf;
const P: usize = 0; const V: usize = 3; const PSI: usize = 6; const BA: usize = 9; const BG: usize = 12; const CB: usize = 15; const CD: usize = 16; pub const N: usize = 17;
pub fn pseudorange(state: &[f64], sat: &Sat) -> f64 {
let dx = [
state[P] - sat.pos[0],
state[P + 1] - sat.pos[1],
state[P + 2] - sat.pos[2],
];
(dx[0] * dx[0] + dx[1] * dx[1] + dx[2] * dx[2]).sqrt() + state[CB]
}
pub fn range_rate(state: &[f64], sat: &Sat) -> f64 {
let dx = [
state[P] - sat.pos[0],
state[P + 1] - sat.pos[1],
state[P + 2] - sat.pos[2],
];
let rng = (dx[0] * dx[0] + dx[1] * dx[1] + dx[2] * dx[2]).sqrt();
let dv = [
state[V] - sat.vel[0],
state[V + 1] - sat.vel[1],
state[V + 2] - sat.vel[2],
];
(dx[0] * dv[0] + dx[1] * dv[1] + dx[2] * dv[2]) / rng + state[CD]
}
pub struct TightlyCoupled17 {
pub ukf: Ukf,
pub q: Vec<Vec<f64>>,
pub gravity: [f64; 3],
}
impl TightlyCoupled17 {
pub fn new(x0: Vec<f64>, p0: Vec<Vec<f64>>, q: Vec<Vec<f64>>, gravity: [f64; 3]) -> Self {
assert_eq!(x0.len(), N);
let mut ukf = Ukf::new(x0, p0);
ukf.alpha = 1.0;
TightlyCoupled17 { ukf, q, gravity }
}
pub fn propagate_imu(&mut self, dt: f64, f_b: [f64; 3], omega_b: [f64; 3]) -> bool {
let g = self.gravity;
let f = move |x: &[f64]| -> Vec<f64> {
let psi = [x[PSI], x[PSI + 1], x[PSI + 2]];
let fc = [f_b[0] - x[BA], f_b[1] - x[BA + 1], f_b[2] - x[BA + 2]];
let cross = [
psi[1] * fc[2] - psi[2] * fc[1],
psi[2] * fc[0] - psi[0] * fc[2],
psi[0] * fc[1] - psi[1] * fc[0],
];
let a = [
fc[0] + cross[0] + g[0],
fc[1] + cross[1] + g[1],
fc[2] + cross[2] + g[2],
];
let mut out = vec![0.0; N];
for k in 0..3 {
out[P + k] = x[P + k] + x[V + k] * dt + 0.5 * a[k] * dt * dt;
out[V + k] = x[V + k] + a[k] * dt;
out[PSI + k] = x[PSI + k] + (omega_b[k] - x[BG + k]) * dt;
out[BA + k] = x[BA + k];
out[BG + k] = x[BG + k];
}
out[CB] = x[CB] + x[CD] * dt;
out[CD] = x[CD];
out
};
self.ukf.predict(f, &self.q)
}
pub fn update_gnss(
&mut self,
sats: &[Sat],
pr: &[f64],
rr: &[f64],
sigma_pr: f64,
sigma_rr: f64,
) -> bool {
self.update_gnss_nis(sats, pr, rr, sigma_pr, sigma_rr)
.is_some()
}
pub fn update_gnss_nis(
&mut self,
sats: &[Sat],
pr: &[f64],
rr: &[f64],
sigma_pr: f64,
sigma_rr: f64,
) -> Option<f64> {
let m = sats.len() * 2;
let sats = sats.to_vec();
let h = move |x: &[f64]| -> Vec<f64> {
let mut z = Vec::with_capacity(m);
for s in &sats {
z.push(pseudorange(x, s));
z.push(range_rate(x, s));
}
z
};
let mut z = Vec::with_capacity(m);
for i in 0..pr.len() {
z.push(pr[i]);
z.push(rr[i]);
}
let mut r = vec![vec![0.0; m]; m];
for i in 0..sats_len_from(m) {
r[2 * i][2 * i] = sigma_pr * sigma_pr;
r[2 * i + 1][2 * i + 1] = sigma_rr * sigma_rr;
}
self.ukf.update_stats(h, &z, &r)
}
pub fn nees(&self, x_true: &[f64]) -> Option<f64> {
let idx: Vec<usize> = (0..N).collect();
self.nees_subset(x_true, &idx)
}
pub fn nees_subset(&self, x_true: &[f64], idx: &[usize]) -> Option<f64> {
assert_eq!(x_true.len(), N);
let k = idx.len();
let e: Vec<f64> = idx.iter().map(|&i| x_true[i] - self.ukf.x[i]).collect();
let mut sub = vec![vec![0.0; k]; k];
for (a, &i) in idx.iter().enumerate() {
for (b, &j) in idx.iter().enumerate() {
sub[a][b] = self.ukf.p[i][j];
}
}
let p_inv = super::ukf::inverse(&sub)?;
let pe = (0..k)
.map(|a| (0..k).map(|b| p_inv[a][b] * e[b]).sum::<f64>())
.collect::<Vec<_>>();
Some(e.iter().zip(&pe).map(|(&a, &b)| a * b).sum())
}
pub fn position_error(&self, truth: [f64; 3]) -> f64 {
let x = &self.ukf.x;
let d = [x[P] - truth[0], x[P + 1] - truth[1], x[P + 2] - truth[2]];
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
}
}
fn sats_len_from(m: usize) -> usize {
m / 2
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inertial::quantum_imu::CaiAccelerometer;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
const G: f64 = 9.81;
fn p0_truth() -> [f64; 3] {
[6.378_137e6, 0.0, 0.0]
}
fn gravity() -> [f64; 3] {
[-G, 0.0, 0.0]
}
fn sats() -> Vec<Sat> {
let r = 2.6e7;
let dirs: [[f64; 3]; 6] = [
[0.9, 0.3, 0.3],
[0.8, -0.4, 0.45],
[0.85, 0.1, -0.5],
[0.7, 0.5, -0.5],
[0.95, -0.2, -0.24],
[0.75, -0.5, 0.43],
];
dirs.iter()
.map(|d| {
let n = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
Sat {
pos: [r * d[0] / n, r * d[1] / n, r * d[2] / n],
vel: [0.0, 0.0, 0.0],
}
})
.collect()
}
fn truth_state(t: f64, v0: [f64; 3], ba: [f64; 3], bg: [f64; 3]) -> Vec<f64> {
let p = p0_truth();
let mut x = vec![0.0; N];
x[P..P + 3].copy_from_slice(&[p[0] + v0[0] * t, p[1] + v0[1] * t, p[2] + v0[2] * t]);
x[V..V + 3].copy_from_slice(&v0);
x[BA..BA + 3].copy_from_slice(&ba);
x[BG..BG + 3].copy_from_slice(&bg);
x
}
fn diag(vals: &[f64]) -> Vec<Vec<f64>> {
let n = vals.len();
let mut m = vec![vec![0.0; n]; n];
for (i, &v) in vals.iter().enumerate() {
m[i][i] = v;
}
m
}
#[test]
fn measurement_model_is_consistent_with_a_known_state() {
let mut x = vec![0.0; N];
x[P] = 6.4e6;
x[V + 1] = 100.0; x[CB] = 30.0; x[CD] = 2.0; let s = Sat {
pos: [2.6e7, 0.0, 0.0],
vel: [0.0, 0.0, 0.0],
};
assert!((pseudorange(&x, &s) - (1.96e7 + 30.0)).abs() < 1e-6);
assert!((range_rate(&x, &s) - 2.0).abs() < 1e-9);
}
#[test]
fn dead_reckoning_holds_constant_velocity_with_perfect_imu() {
let v0 = [0.0, 150.0, 0.0];
let x0 = truth_state(0.0, v0, [0.0; 3], [0.0; 3]);
let p0 = diag(&[1e-6; N]);
let q = diag(&[0.0; N]);
let mut nav = TightlyCoupled17::new(x0, p0, q, gravity());
let f_b = [G, 0.0, 0.0]; let dt = 1.0;
for _ in 0..120 {
assert!(nav.propagate_imu(dt, f_b, [0.0; 3]));
}
let err = nav.position_error([p0_truth()[0], p0_truth()[1] + 150.0 * 120.0, p0_truth()[2]]);
assert!(err < 1e-2, "perfect-IMU CV drift {err} m");
}
#[test]
fn gnss_update_pulls_the_estimate_toward_truth() {
let v0 = [0.0, 100.0, 0.0];
let ba = [0.02, 0.0, 0.0];
let bg = [0.0; 3];
let mut x0 = truth_state(0.0, v0, [0.0; 3], bg);
x0[P] += 50.0;
let p0 = diag(&[
1e4, 1e4, 1e4, 1.0, 1.0, 1.0, 1e-4, 1e-4, 1e-4, 1e-2, 1e-2, 1e-2, 1e-8, 1e-8, 1e-8,
1e2, 1.0,
]);
let q = diag(&[1e-4; N]);
let mut nav = TightlyCoupled17::new(x0, p0, q, gravity());
let sats = sats();
let f_b = [G + ba[0], ba[1], ba[2]];
let err_before = nav.position_error(p0_truth());
let dt = 0.5;
for i in 1..=40 {
assert!(nav.propagate_imu(dt, f_b, bg));
let t = i as f64 * dt;
let xt = truth_state(t, v0, ba, bg);
let pr: Vec<f64> = sats.iter().map(|s| pseudorange(&xt, s)).collect();
let rr: Vec<f64> = sats.iter().map(|s| range_rate(&xt, s)).collect();
assert!(nav.update_gnss(&sats, &pr, &rr, 1.0, 0.05));
}
let truth_end = [p0_truth()[0], p0_truth()[1] + 100.0 * 20.0, p0_truth()[2]];
let err_after = nav.position_error(truth_end);
assert!(err_after < err_before, "{err_after} !< {err_before}");
assert!(err_after < 10.0, "post-aiding error {err_after} m");
}
#[test]
fn estimates_accelerometer_bias_during_aiding() {
let v0 = [0.0, 120.0, 0.0];
let ba = [0.03, -0.02, 0.015]; let bg = [0.0; 3];
let mut x0 = truth_state(0.0, v0, [0.0; 3], bg); x0[CB] = 0.0;
let p0 = diag(&[
1e2, 1e2, 1e2, 1.0, 1.0, 1.0, 1e-4, 1e-4, 1e-4, 1e-1, 1e-1, 1e-1, 1e-8, 1e-8, 1e-8,
1e2, 1.0,
]);
let q = diag(&[1e-5; N]);
let mut nav = TightlyCoupled17::new(x0, p0, q, gravity());
let sats = sats();
let f_b = [G + ba[0], ba[1], ba[2]];
let dt = 0.5;
for i in 1..=200 {
assert!(nav.propagate_imu(dt, f_b, bg));
let t = i as f64 * dt;
let xt = truth_state(t, v0, ba, bg);
let pr: Vec<f64> = sats.iter().map(|s| pseudorange(&xt, s)).collect();
let rr: Vec<f64> = sats.iter().map(|s| range_rate(&xt, s)).collect();
assert!(nav.update_gnss(&sats, &pr, &rr, 1.0, 0.05));
}
for (k, &b) in ba.iter().enumerate() {
let e = (nav.ukf.x[BA + k] - b).abs();
assert!(e < 5e-3, "accel bias[{k}] error {e} m/s²");
}
}
#[test]
fn nees_matches_hand_derived_value_for_a_diagonal_covariance() {
let mut x0 = vec![0.0; N];
let p0 = diag(&[1e30; N]); let q = diag(&[0.0; N]);
let mut nav = TightlyCoupled17::new(x0.clone(), p0, q, gravity());
nav.ukf.p[P][P] = 4.0; nav.ukf.p[V][V] = 1.0; nav.ukf.p[CB][CB] = 9.0; x0[P] = 6.0;
x0[V] = 2.0;
x0[CB] = 3.0;
let nees = nav.nees(&x0).expect("P invertible");
assert!((nees - 14.0).abs() < 1e-6, "NEES = {nees}, expected 14");
}
#[test]
fn nees_subset_uses_only_the_chosen_block() {
let mut x0 = vec![0.0; N];
let p0 = diag(&[1.0; N]);
let q = diag(&[0.0; N]);
let mut nav = TightlyCoupled17::new(x0.clone(), p0, q, gravity());
nav.ukf.p[P][P] = 4.0;
nav.ukf.p[V][V] = 1.0;
x0[P] = 6.0; x0[V] = 2.0; x0[PSI] = 1e6; let nees = nav.nees_subset(&x0, &[P, V]).expect("block invertible");
assert!(
(nees - 13.0).abs() < 1e-9,
"subset NEES = {nees}, expected 13"
);
}
#[test]
fn update_gnss_nis_is_returned_and_nonnegative() {
let v0 = [0.0, 50.0, 0.0];
let x0 = truth_state(0.0, v0, [0.0; 3], [0.0; 3]);
let p0 = diag(&[1.0; N]);
let q = diag(&[0.0; N]);
let mut nav = TightlyCoupled17::new(x0.clone(), p0, q, gravity());
let sats = sats();
let pr: Vec<f64> = sats.iter().map(|s| pseudorange(&x0, s)).collect();
let rr: Vec<f64> = sats.iter().map(|s| range_rate(&x0, s)).collect();
let nis = nav
.update_gnss_nis(&sats, &pr, &rr, 1.0, 0.05)
.expect("update succeeds");
assert!(nis.is_finite() && nis >= 0.0, "NIS = {nis}");
assert!(nis < 12.0, "noiseless NIS should be small, got {nis}");
}
#[test]
fn cai_dead_reckoning_bounds_a_120s_gnss_outage() {
let cai = CaiAccelerometer {
wavelength_m: 780.241_209e-9,
pulse_sep_t: 0.02,
atom_number: 1.0e6,
contrast: 0.5,
cycle_time_s: 0.5,
};
let q_va = cai.q_va(); assert!(q_va > 0.0 && q_va < 1e-8, "CAI q_va = {q_va}");
let v0 = [0.0, 200.0, 0.0];
let ba = [0.01, 0.006, -0.008];
let bg = [1e-6, -1e-6, 2e-6];
let mut x0 = truth_state(0.0, v0, [0.0; 3], [0.0; 3]); x0[P] += 5.0;
let p0 = diag(&[
1e2, 1e2, 1e2, 1.0, 1.0, 1.0, 1e-6, 1e-6, 1e-6, 1e-2, 1e-2, 1e-2, 1e-10, 1e-10, 1e-10,
1e2, 1.0,
]);
let dt = 0.5;
let mut qd = vec![1e-9; N];
for k in 0..3 {
qd[V + k] = q_va * dt; qd[BA + k] = 1e-10;
qd[PSI + k] = 1e-12;
}
let q = diag(&qd);
let mut nav = TightlyCoupled17::new(x0, p0, q, gravity());
let sats = sats();
let mut rng = ChaCha8Rng::seed_from_u64(0x17_5747);
let n_pr = Normal::new(0.0, 1.0).unwrap();
let n_rr = Normal::new(0.0, 0.05).unwrap();
let sig_a = (q_va / dt).sqrt();
let n_a = Normal::new(0.0, sig_a).unwrap();
let imu = |rng: &mut ChaCha8Rng| -> [f64; 3] {
[
G + ba[0] + n_a.sample(rng),
ba[1] + n_a.sample(rng),
ba[2] + n_a.sample(rng),
]
};
for i in 1..=200 {
assert!(nav.propagate_imu(dt, imu(&mut rng), bg));
let t = i as f64 * dt;
let xt = truth_state(t, v0, ba, bg);
let pr: Vec<f64> = sats
.iter()
.map(|s| pseudorange(&xt, s) + n_pr.sample(&mut rng))
.collect();
let rr: Vec<f64> = sats
.iter()
.map(|s| range_rate(&xt, s) + n_rr.sample(&mut rng))
.collect();
assert!(nav.update_gnss(&sats, &pr, &rr, 1.0, 0.05));
}
let t_out_start = 100.0;
let n_coast = 240; for j in 1..=n_coast {
assert!(nav.propagate_imu(dt, imu(&mut rng), bg));
let _ = j;
}
let t_end = t_out_start + 120.0;
let truth_end = [
p0_truth()[0] + v0[0] * t_end,
p0_truth()[1] + v0[1] * t_end,
p0_truth()[2] + v0[2] * t_end,
];
let err = nav.position_error(truth_end);
assert!(
err < 200.0,
"120-s CAI dead-reckoning outage drift {err} m (expected ≲ 200 m)"
);
}
}