use crate::forces::gravity_accel;
use crate::fusion::ukf::Ukf;
use crate::integrator::rk4_step;
#[derive(Clone, Copy, Debug)]
pub struct Sat {
pub pos: [f64; 3],
pub vel: [f64; 3],
}
fn range(state: &[f64], sat: &Sat) -> f64 {
let d = [
state[0] - sat.pos[0],
state[1] - sat.pos[1],
state[2] - sat.pos[2],
];
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
}
pub fn pseudorange(state: &[f64], sat: &Sat) -> f64 {
range(state, sat) + state[6]
}
pub fn range_rate(state: &[f64], sat: &Sat) -> f64 {
let d = [
state[0] - sat.pos[0],
state[1] - sat.pos[1],
state[2] - sat.pos[2],
];
let r = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let vrel = [
state[3] - sat.vel[0],
state[4] - sat.vel[1],
state[5] - sat.vel[2],
];
(d[0] * vrel[0] + d[1] * vrel[1] + d[2] * vrel[2]) / r + state[7]
}
pub struct TightlyCoupled {
pub ukf: Ukf,
pub q: Vec<Vec<f64>>,
}
impl TightlyCoupled {
pub fn new(x0: Vec<f64>, p0: Vec<Vec<f64>>, q: Vec<Vec<f64>>) -> Self {
let mut ukf = Ukf::new(x0, p0);
ukf.alpha = 1.0;
ukf.kappa = 0.0;
Self { ukf, q }
}
pub fn propagate(&mut self, dt: f64) -> bool {
let f = move |s: &[f64]| {
vec![
s[0] + s[3] * dt,
s[1] + s[4] * dt,
s[2] + s[5] * dt,
s[3],
s[4],
s[5],
s[6] + s[7] * dt,
s[7],
]
};
self.ukf.predict(f, &self.q)
}
pub fn propagate_orbital(&mut self, dt: f64) -> bool {
let f = move |s: &[f64]| {
let deriv = |_t: f64, y: &[f64]| {
let a = gravity_accel([y[0], y[1], y[2]]);
vec![y[3], y[4], y[5], a[0], a[1], a[2]]
};
let pv = rk4_step(&deriv, 0.0, &s[..6], dt);
vec![
pv[0],
pv[1],
pv[2],
pv[3],
pv[4],
pv[5],
s[6] + s[7] * dt,
s[7],
]
};
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 {
let k = sats.len();
let sats_owned = sats.to_vec();
let h = move |s: &[f64]| {
let mut z = Vec::with_capacity(2 * sats_owned.len());
for sat in &sats_owned {
z.push(pseudorange(s, sat));
}
for sat in &sats_owned {
z.push(range_rate(s, sat));
}
z
};
let mut z = Vec::with_capacity(2 * k);
z.extend_from_slice(pr);
z.extend_from_slice(rr);
let mut r = vec![vec![0.0; 2 * k]; 2 * k];
for i in 0..k {
r[i][i] = sigma_pr * sigma_pr;
r[k + i][k + i] = sigma_rr * sigma_rr;
}
self.ukf.update(h, &z, &r)
}
pub fn position_error(&self, truth: [f64; 3]) -> f64 {
let x = &self.ukf.x;
let d = [x[0] - truth[0], x[1] - truth[1], x[2] - truth[2]];
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
fn constellation() -> Vec<Sat> {
vec![
Sat {
pos: [2.00e7, 1.00e7, 1.50e7],
vel: [-1500.0, 2200.0, 600.0],
},
Sat {
pos: [1.50e7, -1.20e7, 1.80e7],
vel: [1800.0, 1500.0, -700.0],
},
Sat {
pos: [2.20e7, 0.50e7, -1.00e7],
vel: [-900.0, -2000.0, 1200.0],
},
Sat {
pos: [1.00e7, 1.80e7, -1.50e7],
vel: [2100.0, -800.0, -1000.0],
},
Sat {
pos: [2.50e7, -0.80e7, 0.60e7],
vel: [-1200.0, 1700.0, 1400.0],
},
]
}
fn truth_at(t: f64) -> [f64; 3] {
[7.0e6, 7.5e3 * t, 0.0]
}
fn truth_state(t: f64) -> [f64; 8] {
[7.0e6, 7.5e3 * t, 0.0, 0.0, 7.5e3, 0.0, 30.0 + 0.1 * t, 0.1]
}
fn measurements(t: f64, sats: &[Sat]) -> (Vec<f64>, Vec<f64>) {
let s = truth_state(t);
let pr = sats.iter().map(|sat| pseudorange(&s, sat)).collect();
let rr = sats.iter().map(|sat| range_rate(&s, sat)).collect();
(pr, rr)
}
fn init_navigator() -> TightlyCoupled {
let x0 = vec![
7.0e6 + 150.0,
-120.0,
90.0,
2.0,
7.5e3 - 1.5,
1.0,
38.0,
0.15,
];
let p0diag = [1.0e4, 1.0e4, 1.0e4, 1.0e2, 1.0e2, 1.0e2, 1.0e4, 1.0e0];
let qdiag = [
1.0e-2, 1.0e-2, 1.0e-2, 1.0e-3, 1.0e-3, 1.0e-3, 1.0e-2, 1.0e-4,
];
let p0 = diag(&p0diag);
let q = diag(&qdiag);
TightlyCoupled::new(x0, p0, q)
}
fn diag(d: &[f64]) -> Vec<Vec<f64>> {
let n = d.len();
let mut m = vec![vec![0.0; n]; n];
for (i, &v) in d.iter().enumerate() {
m[i][i] = v;
}
m
}
#[test]
fn pseudorange_and_range_rate_match_geometry() {
let sat = Sat {
pos: [3.0, 4.0, 0.0],
vel: [0.0, 0.0, 0.0],
};
let still = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
assert!((pseudorange(&still, &sat) - 5.0).abs() < 1e-12);
assert!(range_rate(&still, &sat).abs() < 1e-12);
let clocked = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 10.0, 2.0];
assert!((pseudorange(&clocked, &sat) - 15.0).abs() < 1e-12);
assert!((range_rate(&clocked, &sat) - 2.0).abs() < 1e-12);
let sat_x = Sat {
pos: [10.0, 0.0, 0.0],
vel: [0.0, 0.0, 0.0],
};
let moving = [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0];
assert!((range_rate(&moving, &sat_x) - (-1.0)).abs() < 1e-12);
}
#[test]
fn noiseless_full_geometry_converges_to_truth() {
let sats = constellation();
let mut nav = init_navigator();
for step in 1..=60 {
let t = step as f64;
nav.propagate(1.0);
let (pr, rr) = measurements(t, &sats);
assert!(nav.update_gnss(&sats, &pr, &rr, 1.0, 0.05));
}
let err = nav.position_error(truth_at(60.0));
assert!(err < 1.0, "converged position error = {err} m");
}
#[test]
fn tracks_with_fewer_than_four_satellites() {
let sats = &constellation()[..3];
let mut nav = init_navigator();
for step in 1..=120 {
let t = step as f64;
nav.propagate(1.0);
let (pr, rr) = measurements(t, sats);
assert!(nav.update_gnss(sats, &pr, &rr, 1.0, 0.05));
}
let err = nav.position_error(truth_at(120.0));
assert!(err < 20.0, "sub-4-satellite position error = {err} m");
}
#[test]
fn survives_120s_gnss_outage_within_50m() {
let sats = constellation();
let mut nav = init_navigator();
let mut rng = ChaCha8Rng::seed_from_u64(0xC0FFEE);
let n_pr = Normal::new(0.0, 1.0).unwrap(); let n_rr = Normal::new(0.0, 0.05).unwrap(); for step in 1..=60 {
let t = step as f64;
nav.propagate(1.0);
let (mut pr, mut rr) = measurements(t, &sats);
for v in pr.iter_mut() {
*v += n_pr.sample(&mut rng);
}
for v in rr.iter_mut() {
*v += n_rr.sample(&mut rng);
}
assert!(nav.update_gnss(&sats, &pr, &rr, 1.0, 0.05));
}
let converged = nav.position_error(truth_at(60.0));
for step in 61..=180 {
nav.propagate(1.0);
let _ = step;
}
let after = nav.position_error(truth_at(180.0));
assert!(
after < 50.0,
"post-outage error = {after} m (converged was {converged} m)"
);
}
fn truth_orbit(n: usize) -> Vec<[f64; 8]> {
use crate::forces::MU_EARTH;
let r = 7.0e6_f64;
let v = (MU_EARTH / r).sqrt(); let inc = 28.5_f64.to_radians(); let mut pv = vec![r, 0.0, 0.0, 0.0, v * inc.cos(), v * inc.sin()];
let deriv = |_t: f64, y: &[f64]| {
let a = gravity_accel([y[0], y[1], y[2]]);
vec![y[3], y[4], y[5], a[0], a[1], a[2]]
};
let mut out = Vec::with_capacity(n + 1);
for k in 0..=n {
let t = k as f64;
out.push([
pv[0],
pv[1],
pv[2],
pv[3],
pv[4],
pv[5],
30.0 + 0.1 * t,
0.1,
]);
pv = rk4_step(&deriv, t, &pv, 1.0);
}
out
}
#[test]
fn force_model_coast_holds_50m_through_outage_on_a_curving_leo_pass() {
let sats = constellation();
let n = 1800; let truth = truth_orbit(n);
let s0 = truth[0];
let x0 = vec![
s0[0] + 150.0,
s0[1] - 120.0,
s0[2] + 90.0,
s0[3] + 2.0,
s0[4] - 1.5,
s0[5] + 1.0,
s0[6] + 8.0,
s0[7] + 0.05,
];
let p0 = diag(&[1.0e4, 1.0e4, 1.0e4, 1.0e2, 1.0e2, 1.0e2, 1.0e4, 1.0e0]);
let q = diag(&[
1.0e-2, 1.0e-2, 1.0e-2, 1.0e-3, 1.0e-3, 1.0e-3, 1.0e-2, 1.0e-4,
]);
let mut nav = TightlyCoupled::new(x0, p0, q);
let mut rng = ChaCha8Rng::seed_from_u64(0x5EED_0042);
let n_pr = Normal::new(0.0, 1.0).unwrap();
let n_rr = Normal::new(0.0, 0.05).unwrap();
let outage = 900..1020; let mut sumsq = 0.0;
let mut count = 0.0;
let mut max_outage_err = 0.0_f64;
for (step, st) in truth.iter().enumerate().take(n + 1).skip(1) {
nav.propagate_orbital(1.0);
if !outage.contains(&step) {
let mut pr: Vec<f64> = sats.iter().map(|s| pseudorange(st, s)).collect();
let mut rr: Vec<f64> = sats.iter().map(|s| range_rate(st, s)).collect();
for v in pr.iter_mut() {
*v += n_pr.sample(&mut rng);
}
for v in rr.iter_mut() {
*v += n_rr.sample(&mut rng);
}
assert!(nav.update_gnss(&sats, &pr, &rr, 1.0, 0.05));
}
let err = nav.position_error([st[0], st[1], st[2]]);
if outage.contains(&step) || step == 1020 {
max_outage_err = max_outage_err.max(err);
}
if step >= 60 {
sumsq += err * err;
count += 1.0;
}
}
let rms = (sumsq / count).sqrt();
assert!(rms < 50.0, "pass RMS = {rms} m");
assert!(
max_outage_err < 50.0,
"worst outage error = {max_outage_err} m"
);
}
}