use crate::integrator::Tolerance;
use crate::precise_od::{EmpiricalAccel, ForceModel, Observation};
type Vec3 = [f64; 3];
pub const MODULE_NAME: &str = "deepspace-od";
#[derive(Clone, Debug)]
pub struct Srif {
r: Vec<Vec<f64>>,
b: Vec<f64>,
n: usize,
}
impl Srif {
pub fn new(n: usize) -> Self {
Self {
r: vec![vec![0.0; n]; n],
b: vec![0.0; n],
n,
}
}
pub fn with_apriori(x0: &[f64], sigma0: &[f64]) -> Self {
let n = x0.len();
assert_eq!(sigma0.len(), n, "x0/sigma0 length mismatch");
let mut r = vec![vec![0.0; n]; n];
let mut b = vec![0.0; n];
for i in 0..n {
let info = if sigma0[i] > 0.0 {
1.0 / sigma0[i]
} else {
0.0
};
r[i][i] = info;
b[i] = info * x0[i];
}
Self { r, b, n }
}
pub fn dim(&self) -> usize {
self.n
}
pub fn information_sqrt(&self) -> &[Vec<f64>] {
&self.r
}
pub fn recenter(&mut self) {
for bi in self.b.iter_mut() {
*bi = 0.0;
}
}
pub fn measurement_update(&mut self, h_row: &[f64], z: f64, sigma: f64) {
assert_eq!(h_row.len(), self.n, "measurement row dimension mismatch");
if sigma <= 0.0 || sigma.is_nan() {
return;
}
let inv = 1.0 / sigma;
let mut aug = self.augmented_array();
let mut row = vec![0.0; self.n + 1];
for (rj, &hj) in row.iter_mut().zip(h_row) {
*rj = hj * inv;
}
row[self.n] = z * inv;
aug.push(row);
householder_triangularize(&mut aug, self.n);
self.store_augmented(&aug);
}
pub fn time_update(&mut self, stm: &[Vec<f64>], process_noise_std: &[f64]) {
assert_eq!(stm.len(), self.n, "stm dimension mismatch");
assert_eq!(
process_noise_std.len(),
self.n,
"process-noise dimension mismatch"
);
let phi_inv =
invert_lower_or_full(stm).expect("state-transition matrix must be invertible");
let mut r_new = vec![vec![0.0; self.n]; self.n];
for (r_new_row, r_row) in r_new.iter_mut().zip(&self.r) {
for (j, r_new_ij) in r_new_row.iter_mut().enumerate() {
let mut s = 0.0;
for (k, &r_ik) in r_row.iter().enumerate() {
s += r_ik * phi_inv[k][j];
}
*r_new_ij = s;
}
}
let noise_idx: Vec<usize> = process_noise_std
.iter()
.enumerate()
.filter_map(|(i, &q)| (q > 0.0).then_some(i))
.collect();
let p = noise_idx.len();
if p == 0 {
let mut aug: Vec<Vec<f64>> = Vec::with_capacity(self.n);
for (r_new_row, &bi) in r_new.iter().zip(&self.b) {
let mut row = vec![0.0; self.n + 1];
row[..self.n].copy_from_slice(r_new_row);
row[self.n] = bi;
aug.push(row);
}
householder_triangularize(&mut aug, self.n);
self.store_augmented(&aug);
return;
}
let ncol = p + self.n + 1;
let mut aug = vec![vec![0.0; ncol]; p + self.n];
for (j, &idx) in noise_idx.iter().enumerate() {
aug[j][j] = 1.0 / process_noise_std[idx];
}
for (ri, (r_new_row, &bi)) in r_new.iter().zip(&self.b).enumerate() {
let row = &mut aug[p + ri];
for (j, &idx) in noise_idx.iter().enumerate() {
row[j] = -r_new_row[idx];
}
row[p..p + self.n].copy_from_slice(r_new_row);
row[p + self.n] = bi;
}
householder_triangularize(&mut aug, p + self.n);
for (i, (r_row, bi)) in self.r.iter_mut().zip(self.b.iter_mut()).enumerate() {
for (j, r_ij) in r_row.iter_mut().enumerate() {
*r_ij = if j >= i { aug[p + i][p + j] } else { 0.0 };
}
*bi = aug[p + i][p + self.n];
}
}
pub fn solve(&self) -> (Vec<f64>, Vec<Vec<f64>>) {
let x = back_substitute(&self.r, &self.b);
let r_inv = invert_upper_triangular(&self.r);
let mut p = vec![vec![0.0; self.n]; self.n];
for (i, p_row) in p.iter_mut().enumerate() {
for (j, p_ij) in p_row.iter_mut().enumerate() {
let mut s = 0.0;
for (a, b) in r_inv[i].iter().zip(&r_inv[j]) {
s += a * b;
}
*p_ij = s;
}
}
(x, p)
}
fn augmented_array(&self) -> Vec<Vec<f64>> {
let mut aug = vec![vec![0.0; self.n + 1]; self.n];
for ((aug_row, r_row), &bi) in aug.iter_mut().zip(&self.r).zip(&self.b) {
aug_row[..self.n].copy_from_slice(r_row);
aug_row[self.n] = bi;
}
aug
}
fn store_augmented(&mut self, aug: &[Vec<f64>]) {
for (i, (r_row, bi)) in self.r.iter_mut().zip(self.b.iter_mut()).enumerate() {
for (j, r_ij) in r_row.iter_mut().enumerate() {
*r_ij = if j >= i { aug[i][j] } else { 0.0 };
}
*bi = aug[i][self.n];
}
}
}
fn householder_triangularize(a: &mut [Vec<f64>], n: usize) {
let m = a.len();
if m == 0 {
return;
}
let ncol = a[0].len();
for c in 0..n {
if c >= m {
break;
}
let mut sigma = 0.0;
for row in a.iter().take(m).skip(c) {
sigma += row[c] * row[c];
}
let sigma = sigma.sqrt();
if sigma < 1e-300 {
continue; }
let alpha = if a[c][c] >= 0.0 { -sigma } else { sigma };
let mut v = vec![0.0; m];
v[c] = a[c][c] - alpha;
for (i, vi) in v.iter_mut().enumerate().take(m).skip(c + 1) {
*vi = a[i][c];
}
let vtv: f64 = v.iter().skip(c).map(|&x| x * x).sum();
if vtv < 1e-300 {
continue;
}
let beta = 2.0 / vtv;
#[allow(clippy::needless_range_loop)]
for j in c..ncol {
let mut s = 0.0;
for i in c..m {
s += v[i] * a[i][j];
}
let s = beta * s;
for i in c..m {
a[i][j] -= s * v[i];
}
}
a[c][c] = alpha;
for row in a.iter_mut().take(m).skip(c + 1) {
row[c] = 0.0;
}
}
}
fn back_substitute(r: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
let n = b.len();
let mut x = vec![0.0; n];
for i in (0..n).rev() {
let mut s = b[i];
for j in (i + 1)..n {
s -= r[i][j] * x[j];
}
let d = r[i][i];
x[i] = if d.abs() > 1e-300 { s / d } else { 0.0 };
}
x
}
fn invert_upper_triangular(r: &[Vec<f64>]) -> Vec<Vec<f64>> {
let n = r.len();
let mut inv = vec![vec![0.0; n]; n];
#[allow(clippy::needless_range_loop)]
for col in 0..n {
for i in (0..n).rev() {
let mut s = if i == col { 1.0 } else { 0.0 };
for j in (i + 1)..n {
s -= r[i][j] * inv[j][col];
}
let d = r[i][i];
inv[i][col] = if d.abs() > 1e-300 { s / d } else { 0.0 };
}
}
inv
}
fn invert_lower_or_full(a: &[Vec<f64>]) -> Option<Vec<Vec<f64>>> {
let n = a.len();
let mut m: Vec<Vec<f64>> = a
.iter()
.enumerate()
.map(|(i, row)| {
let mut r = row.clone();
r.extend((0..n).map(|j| if i == j { 1.0 } else { 0.0 }));
r
})
.collect();
for col in 0..n {
let mut piv = col;
for r in (col + 1)..n {
if m[r][col].abs() > m[piv][col].abs() {
piv = r;
}
}
if m[piv][col].abs() < 1e-300 {
return None;
}
m.swap(col, piv);
let d = m[col][col];
for x in m[col].iter_mut() {
*x /= d;
}
let pivot_row = m[col].clone();
for (r, row) in m.iter_mut().enumerate() {
if r != col {
let f = row[col];
if f != 0.0 {
for (x, &pv) in row.iter_mut().zip(&pivot_row) {
*x -= f * pv;
}
}
}
}
}
Some(m.iter().map(|row| row[n..2 * n].to_vec()).collect())
}
const N_STATE: usize = 9;
#[derive(Clone, Copy, Debug)]
pub struct ReducedDynamicConfig {
pub dynamic_tightness: f64,
pub emp_correlation_time: f64,
pub emp_process_sigma_max: f64,
pub sigma_pos: f64,
pub sigma_vel: f64,
pub sigma_emp: f64,
pub tol: Tolerance,
}
impl Default for ReducedDynamicConfig {
fn default() -> Self {
Self {
dynamic_tightness: 0.5,
emp_correlation_time: 1.0e3,
emp_process_sigma_max: 1.0e-6,
sigma_pos: 1.0e3,
sigma_vel: 1.0e0,
sigma_emp: 1.0e-6,
tol: Tolerance {
rtol: 1e-11,
atol: 1e-9,
..Tolerance::default()
},
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct FilterStep {
pub t: f64,
pub r: Vec3,
pub v: Vec3,
pub emp: Vec3,
pub innovation_3d: f64,
pub residual_3d: f64,
}
#[derive(Clone, Debug)]
pub struct ReducedDynamicReport {
pub steps: Vec<FilterStep>,
pub innovation_rms: f64,
pub residual_rms: f64,
pub final_state: [f64; N_STATE],
pub final_cov: Vec<Vec<f64>>,
}
#[derive(Clone, Copy, Debug)]
pub struct RadiometricStep {
pub t: f64,
pub r: Vec3,
pub v: Vec3,
pub emp: Vec3,
}
#[derive(Clone, Debug)]
pub struct RadiometricReport {
pub steps: Vec<RadiometricStep>,
pub final_state: [f64; N_STATE],
pub final_cov: Vec<Vec<f64>>,
pub covariance_pd_throughout: bool,
}
#[derive(Clone, Debug)]
pub struct ReducedDynamicOd<F: ForceModel> {
fm: F,
cfg: ReducedDynamicConfig,
}
impl<F: ForceModel> ReducedDynamicOd<F> {
pub fn new(fm: F, cfg: ReducedDynamicConfig) -> Self {
Self { fm, cfg }
}
fn fm_with_emp(&self, emp: Vec3) -> F {
let mut fm = self.fm.clone();
fm.set_empirical(Some(EmpiricalAccel {
radial: [emp[0], 0.0, 0.0],
transverse: [emp[1], 0.0, 0.0],
normal: [emp[2], 0.0, 0.0],
..EmpiricalAccel::default()
}));
fm
}
fn propagate_segment(&self, r: Vec3, v: Vec3, emp: Vec3, dt: f64) -> (Vec3, Vec3) {
let fm = self.fm_with_emp(emp);
crate::precise_od::propagate(&fm, r, v, dt, &self.cfg.tol)
}
fn segment_stm(&self, r: Vec3, v: Vec3, emp: Vec3, dt: f64) -> Vec<Vec<f64>> {
let mut phi = vec![vec![0.0; N_STATE]; N_STATE];
let fm = self.fm_with_emp(emp);
let (_rf, _vf, phi6) = crate::precise_od::propagate_with_stm(&fm, r, v, dt, &self.cfg.tol);
for (i, row) in phi6.iter().enumerate() {
phi[i][..6].copy_from_slice(row);
}
let damp = 1.0e-9;
for k in 0..3 {
let (mut ep, mut em) = (emp, emp);
ep[k] += damp;
em[k] -= damp;
let (rp, vp) = self.propagate_segment(r, v, ep, dt);
let (rm, vm) = self.propagate_segment(r, v, em, dt);
for i in 0..3 {
phi[i][6 + k] = (rp[i] - rm[i]) / (2.0 * damp);
phi[3 + i][6 + k] = (vp[i] - vm[i]) / (2.0 * damp);
}
}
let decay = if self.cfg.emp_correlation_time > 0.0 {
(-dt / self.cfg.emp_correlation_time).exp()
} else {
0.0
};
for k in 0..3 {
phi[6 + k][6 + k] = decay;
}
phi
}
pub fn run(&self, r0: Vec3, v0: Vec3, obs: &[Observation]) -> Option<ReducedDynamicReport> {
if obs.len() < 2 {
return None;
}
let mut ord: Vec<usize> = (0..obs.len()).collect();
ord.sort_by(|&a, &b| {
obs[a]
.t
.partial_cmp(&obs[b].t)
.unwrap_or(std::cmp::Ordering::Equal)
});
let obs: Vec<Observation> = ord.iter().map(|&i| obs[i]).collect();
let sigma0 = [
self.cfg.sigma_pos,
self.cfg.sigma_pos,
self.cfg.sigma_pos,
self.cfg.sigma_vel,
self.cfg.sigma_vel,
self.cfg.sigma_vel,
self.cfg.sigma_emp,
self.cfg.sigma_emp,
self.cfg.sigma_emp,
];
let x0 = [r0[0], r0[1], r0[2], v0[0], v0[1], v0[2], 0.0, 0.0, 0.0];
let mut srif = Srif::with_apriori(&[0.0; N_STATE], &sigma0);
let mut state = x0;
let mut t_prev = 0.0;
let emp_q_rate = (self.cfg.emp_process_sigma_max * self.cfg.dynamic_tightness).max(0.0);
let mut steps = Vec::with_capacity(obs.len());
let mut sum_innov = 0.0;
let mut sum_resid = 0.0;
for ob in &obs {
let dt = ob.t - t_prev;
if dt > 0.0 {
let r = [state[0], state[1], state[2]];
let v = [state[3], state[4], state[5]];
let emp = [state[6], state[7], state[8]];
let phi = self.segment_stm(r, v, emp, dt);
let q_emp = emp_q_rate * dt.sqrt();
let q = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, q_emp, q_emp, q_emp];
srif.time_update(&phi, &q);
let (rf, vf) = self.propagate_segment(r, v, emp, dt);
let decay = if self.cfg.emp_correlation_time > 0.0 {
(-dt / self.cfg.emp_correlation_time).exp()
} else {
0.0
};
state = [
rf[0],
rf[1],
rf[2],
vf[0],
vf[1],
vf[2],
emp[0] * decay,
emp[1] * decay,
emp[2] * decay,
];
t_prev = ob.t;
}
let pred = [state[0], state[1], state[2]];
let innov = [
ob.pos[0] - pred[0],
ob.pos[1] - pred[1],
ob.pos[2] - pred[2],
];
let innov_3d = (innov[0] * innov[0] + innov[1] * innov[1] + innov[2] * innov[2]).sqrt();
for axis in 0..3 {
let mut h_row = [0.0; N_STATE];
h_row[axis] = 1.0;
srif.measurement_update(&h_row, innov[axis], ob.sigma);
}
let (dx, _p) = srif.solve();
for i in 0..N_STATE {
state[i] += dx[i];
}
srif.recenter();
let resid = [
ob.pos[0] - state[0],
ob.pos[1] - state[1],
ob.pos[2] - state[2],
];
let resid_3d = (resid[0] * resid[0] + resid[1] * resid[1] + resid[2] * resid[2]).sqrt();
sum_innov += innov_3d * innov_3d;
sum_resid += resid_3d * resid_3d;
steps.push(FilterStep {
t: ob.t,
r: [state[0], state[1], state[2]],
v: [state[3], state[4], state[5]],
emp: [state[6], state[7], state[8]],
innovation_3d: innov_3d,
residual_3d: resid_3d,
});
}
let n = steps.len().max(1) as f64;
let (_x, final_cov) = srif.solve();
Some(ReducedDynamicReport {
innovation_rms: (sum_innov / n).sqrt(),
residual_rms: (sum_resid / n).sqrt(),
final_state: state,
final_cov,
steps,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RadiometricKind {
Range,
RangeRate,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RadiometricMeas {
pub t: f64,
pub kind: RadiometricKind,
pub station_pos: Vec3,
pub station_vel: Vec3,
pub value: f64,
pub sigma: f64,
}
pub fn range_observable(r_sc: Vec3, station_pos: Vec3) -> (f64, [f64; N_STATE]) {
let d = [
r_sc[0] - station_pos[0],
r_sc[1] - station_pos[1],
r_sc[2] - station_pos[2],
];
let rho = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let mut h = [0.0; N_STATE];
if rho > 0.0 {
for k in 0..3 {
h[k] = d[k] / rho; }
}
(rho, h)
}
pub fn range_rate_observable(
r_sc: Vec3,
v_sc: Vec3,
station_pos: Vec3,
station_vel: Vec3,
) -> (f64, [f64; N_STATE]) {
let d = [
r_sc[0] - station_pos[0],
r_sc[1] - station_pos[1],
r_sc[2] - station_pos[2],
];
let rho = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let v_rel = [
v_sc[0] - station_vel[0],
v_sc[1] - station_vel[1],
v_sc[2] - station_vel[2],
];
let mut h = [0.0; N_STATE];
if rho <= 0.0 {
return (0.0, h);
}
let u = [d[0] / rho, d[1] / rho, d[2] / rho]; let rho_dot = u[0] * v_rel[0] + u[1] * v_rel[1] + u[2] * v_rel[2]; for k in 0..3 {
h[k] = (v_rel[k] - rho_dot * u[k]) / rho;
h[3 + k] = u[k];
}
(rho_dot, h)
}
pub fn doppler_clock_freq_partial() -> f64 {
crate::timegeo::C_M_PER_S
}
impl<F: ForceModel> ReducedDynamicOd<F> {
pub fn radiometric_update(
srif: &mut Srif,
state: [f64; N_STATE],
meas: &RadiometricMeas,
) -> [f64; N_STATE] {
let r_sc = [state[0], state[1], state[2]];
let v_sc = [state[3], state[4], state[5]];
let (predicted, h_row) = match meas.kind {
RadiometricKind::Range => range_observable(r_sc, meas.station_pos),
RadiometricKind::RangeRate => {
range_rate_observable(r_sc, v_sc, meas.station_pos, meas.station_vel)
}
};
srif.measurement_update(&h_row, meas.value - predicted, meas.sigma);
let (dx, _p) = srif.solve();
let mut out = state;
for i in 0..N_STATE {
out[i] += dx[i];
}
srif.recenter();
out
}
pub fn run_radiometric(
&self,
r0: Vec3,
v0: Vec3,
obs: &[RadiometricMeas],
) -> Option<RadiometricReport> {
if obs.len() < 2 {
return None;
}
let mut ord: Vec<usize> = (0..obs.len()).collect();
ord.sort_by(|&a, &b| {
obs[a]
.t
.partial_cmp(&obs[b].t)
.unwrap_or(std::cmp::Ordering::Equal)
});
let sigma0 = [
self.cfg.sigma_pos,
self.cfg.sigma_pos,
self.cfg.sigma_pos,
self.cfg.sigma_vel,
self.cfg.sigma_vel,
self.cfg.sigma_vel,
self.cfg.sigma_emp,
self.cfg.sigma_emp,
self.cfg.sigma_emp,
];
let mut srif = Srif::with_apriori(&[0.0; N_STATE], &sigma0);
let mut state = [r0[0], r0[1], r0[2], v0[0], v0[1], v0[2], 0.0, 0.0, 0.0];
let mut t_prev = 0.0;
let emp_q_rate = (self.cfg.emp_process_sigma_max * self.cfg.dynamic_tightness).max(0.0);
let mut steps: Vec<RadiometricStep> = Vec::new();
let mut covariance_pd_throughout = true;
for &i in &ord {
let ob = &obs[i];
let dt = ob.t - t_prev;
if dt > 0.0 {
let r = [state[0], state[1], state[2]];
let v = [state[3], state[4], state[5]];
let emp = [state[6], state[7], state[8]];
let phi = self.segment_stm(r, v, emp, dt);
let q_emp = emp_q_rate * dt.sqrt();
let q = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, q_emp, q_emp, q_emp];
srif.time_update(&phi, &q);
let (rf, vf) = self.propagate_segment(r, v, emp, dt);
let decay = if self.cfg.emp_correlation_time > 0.0 {
(-dt / self.cfg.emp_correlation_time).exp()
} else {
0.0
};
state = [
rf[0],
rf[1],
rf[2],
vf[0],
vf[1],
vf[2],
emp[0] * decay,
emp[1] * decay,
emp[2] * decay,
];
t_prev = ob.t;
}
state = Self::radiometric_update(&mut srif, state, ob);
let (_x, p) = srif.solve();
if !covariance_is_spd(&p) {
covariance_pd_throughout = false;
}
let step = RadiometricStep {
t: ob.t,
r: [state[0], state[1], state[2]],
v: [state[3], state[4], state[5]],
emp: [state[6], state[7], state[8]],
};
match steps.last_mut() {
Some(last) if (last.t - ob.t).abs() <= 1e-9 => *last = step,
_ => steps.push(step),
}
}
let (_x, final_cov) = srif.solve();
Some(RadiometricReport {
steps,
final_state: state,
final_cov,
covariance_pd_throughout,
})
}
}
fn covariance_is_spd(p: &[Vec<f64>]) -> bool {
let n = p.len();
for (i, row) in p.iter().enumerate() {
for (j, &pij) in row.iter().enumerate() {
let scale = p[i][i].abs().max(p[j][j].abs()).max(1e-300);
if (pij - p[j][i]).abs() > 1e-9 * scale {
return false;
}
}
}
let mut l = vec![vec![0.0; n]; n];
for i in 0..n {
for j in 0..=i {
let mut s = p[i][j];
#[allow(clippy::needless_range_loop)]
for k in 0..j {
s -= l[i][k] * l[j][k];
}
if i == j {
if s <= 0.0 {
return false;
}
l[i][j] = s.sqrt();
} else {
l[i][j] = s / l[j][j];
}
}
}
true
}
pub const N_FUSED: usize = 12;
const CLK0: usize = 9;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MeasWay {
TwoWay,
OneWay,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FusedMeas {
pub t: f64,
pub way: MeasWay,
pub kind: RadiometricKind,
pub station_pos: Vec3,
pub station_vel: Vec3,
pub value: f64,
pub sigma: f64,
}
pub fn fused_observable(state: [f64; N_FUSED], meas: &FusedMeas) -> (f64, [f64; N_FUSED]) {
let r_sc = [state[0], state[1], state[2]];
let v_sc = [state[3], state[4], state[5]];
let (geom, h9) = match meas.kind {
RadiometricKind::Range => range_observable(r_sc, meas.station_pos),
RadiometricKind::RangeRate => {
range_rate_observable(r_sc, v_sc, meas.station_pos, meas.station_vel)
}
};
let mut h = [0.0; N_FUSED];
h[..N_STATE].copy_from_slice(&h9);
match meas.way {
MeasWay::TwoWay => (geom, h),
MeasWay::OneWay => {
let c = doppler_clock_freq_partial(); match meas.kind {
RadiometricKind::Range => {
h[CLK0] = c;
(geom + c * state[CLK0], h)
}
RadiometricKind::RangeRate => {
h[CLK0 + 1] = c;
(geom + c * state[CLK0 + 1], h)
}
}
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct FusionConfig {
pub base: ReducedDynamicConfig,
pub clk_q_wf: f64,
pub clk_q_rw: f64,
pub clk_q_drift: f64,
pub sigma_clk_phase: f64,
pub sigma_clk_freq: f64,
pub sigma_clk_drift: f64,
}
impl FusionConfig {
pub fn from_clock_class(
base: ReducedDynamicConfig,
class: crate::clock_state::ClockClass,
) -> Self {
let (q_wf, q_rw, q_drift) = class.psds();
Self {
base,
clk_q_wf: q_wf,
clk_q_rw: q_rw,
clk_q_drift: q_drift,
sigma_clk_phase: 1.0e-6,
sigma_clk_freq: 1.0e-9,
sigma_clk_drift: 1.0e-13,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct FusionStep {
pub t: f64,
pub r: Vec3,
pub v: Vec3,
pub emp: Vec3,
pub clock: [f64; 3],
pub clock_freq_sigma: f64,
}
#[derive(Clone, Debug)]
pub struct FusionReport {
pub steps: Vec<FusionStep>,
pub final_state: [f64; N_FUSED],
pub final_cov: Vec<Vec<f64>>,
pub covariance_pd_throughout: bool,
}
#[derive(Clone, Debug)]
pub struct FusionOd<F: ForceModel> {
orbit: ReducedDynamicOd<F>,
cfg: FusionConfig,
}
impl<F: ForceModel> FusionOd<F> {
pub fn new(fm: F, cfg: FusionConfig) -> Self {
Self {
orbit: ReducedDynamicOd::new(fm, cfg.base),
cfg,
}
}
fn joint_stm(&self, r: Vec3, v: Vec3, emp: Vec3, dt: f64) -> Vec<Vec<f64>> {
let mut phi = vec![vec![0.0; N_FUSED]; N_FUSED];
let phi9 = self.orbit.segment_stm(r, v, emp, dt);
for (i, row) in phi9.iter().enumerate() {
phi[i][..N_STATE].copy_from_slice(row);
}
let half_dt2 = 0.5 * dt * dt;
phi[CLK0][CLK0] = 1.0;
phi[CLK0][CLK0 + 1] = dt;
phi[CLK0][CLK0 + 2] = half_dt2;
phi[CLK0 + 1][CLK0 + 1] = 1.0;
phi[CLK0 + 1][CLK0 + 2] = dt;
phi[CLK0 + 2][CLK0 + 2] = 1.0;
phi
}
pub fn run(&self, r0: Vec3, v0: Vec3, obs: &[FusedMeas]) -> Option<FusionReport> {
if obs.len() < 2 {
return None;
}
let mut ord: Vec<usize> = (0..obs.len()).collect();
ord.sort_by(|&a, &b| {
obs[a]
.t
.partial_cmp(&obs[b].t)
.unwrap_or(std::cmp::Ordering::Equal)
});
let base = &self.cfg.base;
let sigma0 = [
base.sigma_pos,
base.sigma_pos,
base.sigma_pos,
base.sigma_vel,
base.sigma_vel,
base.sigma_vel,
base.sigma_emp,
base.sigma_emp,
base.sigma_emp,
self.cfg.sigma_clk_phase,
self.cfg.sigma_clk_freq,
self.cfg.sigma_clk_drift,
];
let mut srif = Srif::with_apriori(&[0.0; N_FUSED], &sigma0);
let mut state = [
r0[0], r0[1], r0[2], v0[0], v0[1], v0[2], 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
];
let mut t_prev = 0.0;
let emp_q_rate = (base.emp_process_sigma_max * base.dynamic_tightness).max(0.0);
let mut steps: Vec<FusionStep> = Vec::new();
let mut covariance_pd_throughout = true;
for &i in &ord {
let ob = &obs[i];
let dt = ob.t - t_prev;
if dt > 0.0 {
let r = [state[0], state[1], state[2]];
let v = [state[3], state[4], state[5]];
let emp = [state[6], state[7], state[8]];
let phi = self.joint_stm(r, v, emp, dt);
let q = self.process_noise_std(dt, emp_q_rate);
srif.time_update(&phi, &q);
let (rf, vf) = self.orbit.propagate_segment(r, v, emp, dt);
let decay = if base.emp_correlation_time > 0.0 {
(-dt / base.emp_correlation_time).exp()
} else {
0.0
};
let (cp, cf, cd) = (state[CLK0], state[CLK0 + 1], state[CLK0 + 2]);
let half_dt2 = 0.5 * dt * dt;
state = [
rf[0],
rf[1],
rf[2],
vf[0],
vf[1],
vf[2],
emp[0] * decay,
emp[1] * decay,
emp[2] * decay,
cp + dt * cf + half_dt2 * cd,
cf + dt * cd,
cd,
];
t_prev = ob.t;
}
state = Self::fused_update(&mut srif, state, ob);
let (_x, p) = srif.solve();
if !covariance_is_spd(&p) {
covariance_pd_throughout = false;
}
let step = FusionStep {
t: ob.t,
r: [state[0], state[1], state[2]],
v: [state[3], state[4], state[5]],
emp: [state[6], state[7], state[8]],
clock: [state[CLK0], state[CLK0 + 1], state[CLK0 + 2]],
clock_freq_sigma: p[CLK0 + 1][CLK0 + 1].max(0.0).sqrt(),
};
match steps.last_mut() {
Some(last) if (last.t - ob.t).abs() <= 1e-9 => *last = step,
_ => steps.push(step),
}
}
let (_x, final_cov) = srif.solve();
Some(FusionReport {
steps,
final_state: state,
final_cov,
covariance_pd_throughout,
})
}
fn process_noise_std(&self, dt: f64, emp_q_rate: f64) -> [f64; N_FUSED] {
let q_emp = emp_q_rate * dt.sqrt();
let (dt3, dt5) = (dt.powi(3), dt.powi(5));
let (qwf, qrw, qd) = (self.cfg.clk_q_wf, self.cfg.clk_q_rw, self.cfg.clk_q_drift);
let q_phase_var = qwf * dt + qrw * dt3 / 3.0 + qd * dt5 / 20.0;
let q_freq_var = qrw * dt + qd * dt3 / 3.0;
let q_drift_var = qd * dt;
[
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
q_emp,
q_emp,
q_emp,
q_phase_var.max(0.0).sqrt(),
q_freq_var.max(0.0).sqrt(),
q_drift_var.max(0.0).sqrt(),
]
}
pub fn fused_update(
srif: &mut Srif,
state: [f64; N_FUSED],
meas: &FusedMeas,
) -> [f64; N_FUSED] {
let (predicted, h_row) = fused_observable(state, meas);
srif.measurement_update(&h_row, meas.value - predicted, meas.sigma);
let (dx, _p) = srif.solve();
let mut out = state;
for i in 0..N_FUSED {
out[i] += dx[i];
}
srif.recenter();
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::batch_ls::gauss_newton;
use crate::fusion::ukf::cholesky;
#[test]
fn srif_matches_batch_on_linear() {
let h: [[f64; 3]; 6] = [
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
[1.0, 1.0, 0.0],
[0.5, -1.0, 2.0],
[-1.0, 0.3, 1.5],
];
let truth = [2.0_f64, -1.0, 0.5];
let sig = [0.10_f64, 0.20, 0.15, 0.30, 0.25, 0.40];
let z: Vec<f64> = (0..6)
.map(|i| h[i][0] * truth[0] + h[i][1] * truth[1] + h[i][2] * truth[2])
.collect();
let mut srif = Srif::new(3);
for i in 0..6 {
srif.measurement_update(&h[i], z[i], sig[i]);
}
let (x_srif, p_srif) = srif.solve();
let h_owned = h;
let model = move |x: &[f64]| {
(0..6)
.map(|i| h_owned[i][0] * x[0] + h_owned[i][1] * x[1] + h_owned[i][2] * x[2])
.collect::<Vec<_>>()
};
let w: Vec<f64> = sig.iter().map(|s| 1.0 / (s * s)).collect();
let r = gauss_newton(model, &z, &w, &[0.0, 0.0, 0.0], 10, 1e-14).expect("solves");
for k in 0..3 {
assert!(
(x_srif[k] - r.x[k]).abs() < 1e-9,
"state[{k}] SRIF {} vs batch {}",
x_srif[k],
r.x[k]
);
assert!((x_srif[k] - truth[k]).abs() < 1e-9, "truth[{k}]");
}
let mut ata = [[0.0_f64; 3]; 3];
for i in 0..6 {
for p in 0..3 {
for q in 0..3 {
ata[p][q] += h[i][p] * w[i] * h[i][q];
}
}
}
let ata_v: Vec<Vec<f64>> = ata.iter().map(|r| r.to_vec()).collect();
let p_ref = invert_lower_or_full(&ata_v).expect("HtWH invertible");
for p in 0..3 {
for q in 0..3 {
assert!(
(p_srif[p][q] - p_ref[p][q]).abs() < 1e-9,
"cov[{p}][{q}] SRIF {} vs (HtWH)^-1 {}",
p_srif[p][q],
p_ref[p][q]
);
}
}
}
#[test]
fn srif_covariance_is_spd() {
let mut srif = Srif::with_apriori(&[0.0, 0.0, 0.0, 0.0], &[1e3, 1e3, 1e3, 1e3]);
let stm = vec![
vec![1.0, 0.10, 0.0, 0.0],
vec![0.0, 1.0, 0.05, 0.0],
vec![0.0, 0.0, 1.0, 0.20],
vec![0.02, 0.0, 0.0, 1.0],
];
let q = vec![1e-2, 1e-2, 1e-3, 1e-3];
let rows = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
[1.0, 1.0, 1.0, 1.0],
[1.0, -1.0, 0.5, -0.5],
];
for (k, row) in rows.iter().cycle().take(18).enumerate() {
srif.measurement_update(row, 0.3 * (k as f64 + 1.0).sin() + 1.0, 0.5);
if k % 3 == 2 {
srif.time_update(&stm, &q);
}
}
let (_x, p) = srif.solve();
for (i, row) in p.iter().enumerate() {
for (j, &pij) in row.iter().enumerate() {
assert!(
(pij - p[j][i]).abs() < 1e-12,
"asymmetry P[{i}][{j}]={} P[{j}][{i}]={}",
pij,
p[j][i]
);
}
}
assert!(
cholesky(&p).is_some(),
"covariance not positive-definite: {p:?}"
);
for (i, row) in p.iter().enumerate() {
assert!(row[i] > 0.0, "non-positive variance P[{i}][{i}]={}", row[i]);
}
}
#[test]
fn srif_information_accumulates() {
let mut srif = Srif::new(2);
let row_a = [1.0, 0.0];
let row_b = [0.0, 1.0];
let row_c = [1.0, 1.0];
let seq = [row_a, row_b, row_c, row_a, row_b, row_c];
let mut last_trace = f64::INFINITY;
let mut traces = Vec::new();
for (k, row) in seq.iter().enumerate() {
srif.measurement_update(row, 1.0, 0.5);
if k >= 1 {
let (_x, p) = srif.solve();
let trace = p[0][0] + p[1][1];
if k >= 2 {
assert!(
trace <= last_trace + 1e-12,
"trace increased at step {k}: {trace} > {last_trace}"
);
}
last_trace = trace;
traces.push(trace);
}
}
assert!(
*traces.last().unwrap() < traces[0] - 1e-9,
"information did not accumulate: {traces:?}"
);
}
use crate::precise_od::{propagate_samples, Observation, PreciseForceModel};
fn ref_state() -> (Vec3, Vec3) {
let mu = crate::forces::MU_EARTH;
let r0 = [7.0e6, 0.0, 0.0];
let speed = (mu / r0[0]).sqrt();
let v0 = [0.0, speed * 0.8, speed * 0.6]; (r0, v0)
}
fn template() -> PreciseForceModel {
PreciseForceModel::egm2008(0, 2_459_580.5)
}
fn truth_obs(emp_truth: Option<Vec3>, times: &[f64], sigma: f64) -> Vec<Observation> {
let (r0, v0) = ref_state();
let mut fm = template();
if let Some(e) = emp_truth {
fm = fm.with_empirical(EmpiricalAccel {
radial: [e[0], 0.0, 0.0],
transverse: [e[1], 0.0, 0.0],
normal: [e[2], 0.0, 0.0],
..EmpiricalAccel::default()
});
}
let tol = Tolerance {
rtol: 1e-11,
atol: 1e-9,
..Tolerance::default()
};
let pos = propagate_samples(&fm, r0, v0, times, &tol);
times
.iter()
.zip(pos)
.map(|(&t, p)| Observation { t, pos: p, sigma })
.collect()
}
fn truth_obs_stepped(
emp_a: Vec3,
emp_b: Vec3,
t_step: f64,
times: &[f64],
sigma: f64,
) -> Vec<Observation> {
let (r0, v0) = ref_state();
let tol = Tolerance {
rtol: 1e-11,
atol: 1e-9,
..Tolerance::default()
};
let with = |e: Vec3| {
template().with_empirical(EmpiricalAccel {
radial: [e[0], 0.0, 0.0],
transverse: [e[1], 0.0, 0.0],
normal: [e[2], 0.0, 0.0],
..EmpiricalAccel::default()
})
};
let mut out = Vec::with_capacity(times.len());
for &t in times {
let pos = if t <= t_step {
propagate_samples(&with(emp_a), r0, v0, &[t], &tol)[0]
} else {
let (rs, vs) = crate::precise_od::propagate(&with(emp_a), r0, v0, t_step, &tol);
propagate_samples(&with(emp_b), rs, vs, &[t - t_step], &tol)[0]
};
out.push(Observation { t, pos, sigma });
}
out
}
fn pseudo_noise(seed: u64, amp: f64) -> impl FnMut() -> f64 {
let mut s = seed.wrapping_mul(2_862_933_555_777_941_757).wrapping_add(1);
move || {
s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let u = ((s >> 11) as f64) / ((1u64 << 53) as f64); (u - 0.5) * 2.0 * amp
}
}
fn stepped_config(dynamic_tightness: f64) -> ReducedDynamicConfig {
ReducedDynamicConfig {
dynamic_tightness,
emp_correlation_time: 6.0e2,
emp_process_sigma_max: 5.0e-7,
sigma_pos: 1.0e2,
sigma_vel: 1.0e0,
sigma_emp: 5.0e-6,
..ReducedDynamicConfig::default()
}
}
#[test]
fn reduced_dynamic_tracks_maneuver() {
let emp_a = [1.0e-6, 1.0e-6, 0.0]; let emp_b = [6.0e-6, 9.0e-6, -4.0e-6]; let times: Vec<f64> = (1..=60).map(|k| k as f64 * 30.0).collect(); let t_step = 900.0; let obs = truth_obs_stepped(emp_a, emp_b, t_step, ×, 1.0);
let (r0, v0) = ref_state();
let kin = ReducedDynamicOd::new(template(), stepped_config(1.0))
.run(r0, v0, &obs)
.expect("kinematic run");
let dyn_ = ReducedDynamicOd::new(template(), stepped_config(0.0))
.run(r0, v0, &obs)
.expect("dynamic run");
assert!(
kin.residual_rms < dyn_.residual_rms * 0.5,
"kinematic residual {} not clearly < dynamic residual {}",
kin.residual_rms,
dyn_.residual_rms
);
let mut noise = pseudo_noise(0xC0FFEE, 5.0); let clean = truth_obs(None, ×, 5.0);
let noisy: Vec<Observation> = clean
.iter()
.map(|o| Observation {
t: o.t,
pos: [o.pos[0] + noise(), o.pos[1] + noise(), o.pos[2] + noise()],
sigma: 5.0,
})
.collect();
let est_error = |rep: &ReducedDynamicReport| -> f64 {
let mut s = 0.0;
for (step, c) in rep.steps.iter().zip(&clean) {
let d = [
step.r[0] - c.pos[0],
step.r[1] - c.pos[1],
step.r[2] - c.pos[2],
];
s += d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
}
(s / rep.steps.len() as f64).sqrt()
};
let smooth = ReducedDynamicOd::new(template(), stepped_config(0.0))
.run(r0, v0, &noisy)
.expect("smooth run");
let track = ReducedDynamicOd::new(template(), stepped_config(1.0))
.run(r0, v0, &noisy)
.expect("track run");
assert!(
est_error(&smooth) < est_error(&track),
"dynamic (smoothing) error {} not < kinematic (noise-tracking) error {}",
est_error(&smooth),
est_error(&track)
);
}
#[test]
fn tuning_is_a_continuum() {
let emp_a = [1.0e-6, 1.0e-6, 0.0];
let emp_b = [6.0e-6, 9.0e-6, -4.0e-6];
let times: Vec<f64> = (1..=60).map(|k| k as f64 * 30.0).collect();
let obs = truth_obs_stepped(emp_a, emp_b, 900.0, ×, 1.0);
let (r0, v0) = ref_state();
let tights = [0.0_f64, 0.25, 0.5, 0.75, 1.0];
let mut residuals = Vec::new();
for &dt in &tights {
let rep = ReducedDynamicOd::new(template(), stepped_config(dt))
.run(r0, v0, &obs)
.expect("run");
residuals.push(rep.residual_rms);
}
for w in residuals.windows(2) {
assert!(
w[1] <= w[0] * 1.0001 + 1e-9,
"residual not monotone with tightness: {residuals:?}"
);
}
assert!(
*residuals.first().unwrap() > *residuals.last().unwrap() * 1.5,
"tuning range too small: {residuals:?}"
);
}
fn radiometric_geometry() -> (Vec3, Vec3, Vec3, Vec3) {
let r_sc = [3.9e6, 1.1e6, -7.0e5]; let v_sc = [-1.2e3, 3.3e3, 2.5e2]; let station_pos = [2.1e6, -4.0e5, 9.0e5]; let station_vel = [3.0e1, 1.5e2, -2.0e1]; (r_sc, v_sc, station_pos, station_vel)
}
#[test]
fn range_partial_matches_finite_difference() {
let (r_sc, _v, sta, _sv) = radiometric_geometry();
let (_rho, h) = range_observable(r_sc, sta);
let rho_of = |r: Vec3| -> f64 { range_observable(r, sta).0 };
let step = 1.0; for k in 0..3 {
let (mut rp, mut rm) = (r_sc, r_sc);
rp[k] += step;
rm[k] -= step;
let fd = (rho_of(rp) - rho_of(rm)) / (2.0 * step);
let rel = (h[k] - fd).abs() / fd.abs().max(1e-12);
assert!(rel < 1e-6, "∂ρ/∂r[{k}] = {} vs FD {fd} (rel {rel:e})", h[k]);
}
for (k, &hk) in h.iter().enumerate().take(N_STATE).skip(3) {
assert_eq!(hk, 0.0, "range must have no ∂/∂(v,emp) at index {k}");
}
let n = (h[0] * h[0] + h[1] * h[1] + h[2] * h[2]).sqrt();
assert!(
(n - 1.0).abs() < 1e-12,
"∂ρ/∂r must be a unit vector, ‖‖ = {n}"
);
}
#[test]
fn range_rate_partials_match_finite_difference() {
let (r_sc, v_sc, sta, sv) = radiometric_geometry();
let (_rdot, h) = range_rate_observable(r_sc, v_sc, sta, sv);
let rdot_of = |r: Vec3, v: Vec3| -> f64 { range_rate_observable(r, v, sta, sv).0 };
let rstep = 1.0;
for k in 0..3 {
let (mut rp, mut rm) = (r_sc, r_sc);
rp[k] += rstep;
rm[k] -= rstep;
let fd = (rdot_of(rp, v_sc) - rdot_of(rm, v_sc)) / (2.0 * rstep);
let rel = (h[k] - fd).abs() / fd.abs().max(1e-12);
assert!(rel < 1e-6, "∂ρ̇/∂r[{k}] = {} vs FD {fd} (rel {rel:e})", h[k]);
}
let vstep = 1e-3;
for k in 0..3 {
let (mut vp, mut vm) = (v_sc, v_sc);
vp[k] += vstep;
vm[k] -= vstep;
let fd = (rdot_of(r_sc, vp) - rdot_of(r_sc, vm)) / (2.0 * vstep);
let rel = (h[3 + k] - fd).abs() / fd.abs().max(1e-12);
assert!(
rel < 1e-6,
"∂ρ̇/∂v[{k}] = {} vs FD {fd} (rel {rel:e})",
h[3 + k]
);
}
let nv = (h[3] * h[3] + h[4] * h[4] + h[5] * h[5]).sqrt();
assert!(
(nv - 1.0).abs() < 1e-12,
"∂ρ̇/∂v must be a unit vector, ‖‖ = {nv}"
);
for (k, &hk) in h.iter().enumerate().take(N_STATE).skip(6) {
assert_eq!(hk, 0.0, "range-rate must have no ∂/∂emp at index {k}");
}
}
#[test]
fn doppler_clock_freq_partial_is_speed_of_light() {
let c = doppler_clock_freq_partial();
assert_eq!(c, crate::timegeo::C_M_PER_S);
assert!(
(c - 299_792_458.0).abs() < 1e-6,
"clock-freq partial must be c"
);
}
#[test]
fn radiometric_update_reduces_covariance_in_observed_direction() {
let (r_sc, v_sc, sta, _sv) = radiometric_geometry();
let cfg = ReducedDynamicConfig {
sigma_pos: 1.0e3,
sigma_vel: 1.0,
sigma_emp: 1.0e-6,
..ReducedDynamicConfig::default()
};
let sigma0 = [
cfg.sigma_pos,
cfg.sigma_pos,
cfg.sigma_pos,
cfg.sigma_vel,
cfg.sigma_vel,
cfg.sigma_vel,
cfg.sigma_emp,
cfg.sigma_emp,
cfg.sigma_emp,
];
let state = [
r_sc[0], r_sc[1], r_sc[2], v_sc[0], v_sc[1], v_sc[2], 0.0, 0.0, 0.0,
];
let (_rho, h) = range_observable(r_sc, sta);
let los = [h[0], h[1], h[2]];
let var_along = |p: &[Vec<f64>]| -> f64 {
let mut pu = [0.0; 3];
for i in 0..3 {
for j in 0..3 {
pu[i] += p[i][j] * los[j];
}
}
los[0] * pu[0] + los[1] * pu[1] + los[2] * pu[2]
};
let mut srif = Srif::with_apriori(&[0.0; N_STATE], &sigma0);
let (_x0, p_before) = srif.solve();
let var_before = var_along(&p_before);
let predicted = range_observable(r_sc, sta).0;
let meas = RadiometricMeas {
t: 0.0,
kind: RadiometricKind::Range,
station_pos: sta,
station_vel: [0.0; 3],
value: predicted,
sigma: 1.0, };
let new_state =
ReducedDynamicOd::<PreciseForceModel>::radiometric_update(&mut srif, state, &meas);
let (_x1, p_after) = srif.solve();
let var_after = var_along(&p_after);
assert!(
var_after < var_before,
"range update did not shrink the LOS variance: {var_after} !< {var_before}"
);
assert!(
var_after < var_before * 1e-3,
"LOS variance barely moved: before {var_before}, after {var_after}"
);
for i in 0..N_STATE {
assert!(
(new_state[i] - state[i]).abs() < 1e-6 * state[i].abs().max(1.0),
"on-reference update moved state[{i}]: {} → {}",
state[i],
new_state[i]
);
}
assert!(
cholesky(&p_after).is_some(),
"covariance not PD after update"
);
}
#[test]
fn range_rate_update_observes_velocity() {
let (r_sc, v_sc, sta, sv) = radiometric_geometry();
let sigma0 = [1.0e3, 1.0e3, 1.0e3, 1.0, 1.0, 1.0, 1e-6, 1e-6, 1e-6];
let state = [
r_sc[0], r_sc[1], r_sc[2], v_sc[0], v_sc[1], v_sc[2], 0.0, 0.0, 0.0,
];
let (_rdot, h) = range_rate_observable(r_sc, v_sc, sta, sv);
let los_v = [h[3], h[4], h[5]];
let var_vel_along = |p: &[Vec<f64>]| -> f64 {
let mut pu = [0.0; 3];
for i in 0..3 {
for j in 0..3 {
pu[i] += p[3 + i][3 + j] * los_v[j];
}
}
los_v[0] * pu[0] + los_v[1] * pu[1] + los_v[2] * pu[2]
};
let mut srif = Srif::with_apriori(&[0.0; N_STATE], &sigma0);
let (_x0, p_before) = srif.solve();
let v_before = var_vel_along(&p_before);
let predicted = range_rate_observable(r_sc, v_sc, sta, sv).0;
let meas = RadiometricMeas {
t: 0.0,
kind: RadiometricKind::RangeRate,
station_pos: sta,
station_vel: sv,
value: predicted,
sigma: 1e-4, };
ReducedDynamicOd::<PreciseForceModel>::radiometric_update(&mut srif, state, &meas);
let (_x1, p_after) = srif.solve();
let v_after = var_vel_along(&p_after);
assert!(
v_after < v_before,
"Doppler update did not shrink the LOS velocity variance: {v_after} !< {v_before}"
);
assert!(
cholesky(&p_after).is_some(),
"covariance not PD after Doppler update"
);
}
fn fused_state() -> ([f64; N_FUSED], Vec3, Vec3) {
let (r_sc, v_sc, _sta, _sv) = radiometric_geometry();
let s = [
r_sc[0], r_sc[1], r_sc[2], v_sc[0], v_sc[1], v_sc[2], 0.0, 0.0, 0.0, 1.0e-6, 1.0e-9,
0.0,
];
(s, r_sc, v_sc)
}
#[test]
fn two_way_partial_has_zero_clock_columns() {
let (state, r_sc, v_sc) = fused_state();
let (_r, _v, sta, sv) = radiometric_geometry();
for kind in [RadiometricKind::Range, RadiometricKind::RangeRate] {
let meas = FusedMeas {
t: 0.0,
way: MeasWay::TwoWay,
kind,
station_pos: sta,
station_vel: sv,
value: 0.0,
sigma: 1.0,
};
let (pred, h) = fused_observable(state, &meas);
for (k, &hk) in h.iter().enumerate().take(N_FUSED).skip(CLK0) {
assert_eq!(hk, 0.0, "two-way {kind:?} must have zero clock column {k}");
}
let geom = match kind {
RadiometricKind::Range => range_observable(r_sc, sta).0,
RadiometricKind::RangeRate => range_rate_observable(r_sc, v_sc, sta, sv).0,
};
assert!(
(pred - geom).abs() < 1e-6,
"two-way {kind:?} predicted {pred} must equal bare geometry {geom}"
);
}
}
#[test]
fn one_way_partial_couples_the_clock() {
let (state, r_sc, v_sc) = fused_state();
let (_r, _v, sta, sv) = radiometric_geometry();
let c = crate::timegeo::C_M_PER_S;
let mr = FusedMeas {
t: 0.0,
way: MeasWay::OneWay,
kind: RadiometricKind::Range,
station_pos: sta,
station_vel: sv,
value: 0.0,
sigma: 1.0,
};
let (pred_r, hr) = fused_observable(state, &mr);
assert_eq!(hr[CLK0], c, "one-way range ∂/∂phase must be c");
assert_eq!(hr[CLK0 + 1], 0.0, "one-way range ∂/∂freq must be 0");
assert_eq!(hr[CLK0 + 2], 0.0, "one-way range ∂/∂drift must be 0");
let geom_r = range_observable(r_sc, sta).0;
assert!(
(pred_r - (geom_r + c * state[CLK0])).abs() < 1e-6,
"one-way range predicted {pred_r} must be geometry + c·phase"
);
let (_g, hgeom) = range_observable(r_sc, sta);
for k in 0..N_STATE {
assert_eq!(hr[k], hgeom[k], "one-way range orbit column {k} mismatch");
}
let md = FusedMeas {
t: 0.0,
way: MeasWay::OneWay,
kind: RadiometricKind::RangeRate,
station_pos: sta,
station_vel: sv,
value: 0.0,
sigma: 1e-4,
};
let (pred_d, hd) = fused_observable(state, &md);
assert_eq!(hd[CLK0], 0.0, "one-way Doppler ∂/∂phase must be 0");
assert_eq!(hd[CLK0 + 1], c, "one-way Doppler ∂/∂freq must be c");
assert_eq!(hd[CLK0 + 2], 0.0, "one-way Doppler ∂/∂drift must be 0");
let geom_d = range_rate_observable(r_sc, v_sc, sta, sv).0;
assert!(
(pred_d - (geom_d + c * state[CLK0 + 1])).abs() < 1e-9,
"one-way Doppler predicted {pred_d} must be geometry + c·freq"
);
}
#[test]
fn two_way_update_leaves_clock_cov_unchanged_one_way_shrinks_it() {
let (state, r_sc, v_sc) = fused_state();
let (_r, _v, sta, sv) = radiometric_geometry();
let sigma0 = [
1.0e3, 1.0e3, 1.0e3, 1.0, 1.0, 1.0, 1e-6, 1e-6, 1e-6, 1.0e-6, 1.0e-9, 1.0e-13, ];
let clock_var_trace = |p: &[Vec<f64>]| -> f64 {
p[CLK0][CLK0] + p[CLK0 + 1][CLK0 + 1] + p[CLK0 + 2][CLK0 + 2]
};
let mut srif_tw = Srif::with_apriori(&[0.0; N_FUSED], &sigma0);
let (_x0, p0) = srif_tw.solve();
let tw_meas = FusedMeas {
t: 0.0,
way: MeasWay::TwoWay,
kind: RadiometricKind::RangeRate,
station_pos: sta,
station_vel: sv,
value: range_rate_observable(r_sc, v_sc, sta, sv).0,
sigma: 1e-4,
};
FusionOd::<PreciseForceModel>::fused_update(&mut srif_tw, state, &tw_meas);
let (_x1, p_tw) = srif_tw.solve();
for i in CLK0..N_FUSED {
for j in CLK0..N_FUSED {
assert!(
(p_tw[i][j] - p0[i][j]).abs() <= 1e-12 * p0[i][i].abs().max(1e-30),
"two-way update changed clock cov [{i}][{j}]: {} → {}",
p0[i][j],
p_tw[i][j]
);
}
}
let mut srif_ow = Srif::with_apriori(&[0.0; N_FUSED], &sigma0);
let ow_meas = FusedMeas {
t: 0.0,
way: MeasWay::OneWay,
kind: RadiometricKind::RangeRate,
station_pos: sta,
station_vel: sv,
value: range_rate_observable(r_sc, v_sc, sta, sv).0
+ crate::timegeo::C_M_PER_S * state[CLK0 + 1],
sigma: 1e-4,
};
FusionOd::<PreciseForceModel>::fused_update(&mut srif_ow, state, &ow_meas);
let (_x2, p_ow) = srif_ow.solve();
assert!(
clock_var_trace(&p_ow) < clock_var_trace(&p0),
"one-way update did not shrink the clock covariance: {} !< {}",
clock_var_trace(&p_ow),
clock_var_trace(&p0)
);
assert!(cholesky(&p_tw).is_some(), "two-way: covariance not PD");
assert!(cholesky(&p_ow).is_some(), "one-way: covariance not PD");
}
}