use super::geometry::{range_rate_and_accel, Kinematics, Vec3};
use super::C_M_S;
use crate::raim::{chi2_quantile, noncentral_chi2_cdf, normal_cdf, normal_quantile};
fn sub(a: Vec3, b: Vec3) -> Vec3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn dot(a: Vec3, b: Vec3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
pub fn fll_frequency_jitter_hz(cn0_dbhz: f64, bn_hz: f64, t_s: f64) -> f64 {
let cn0 = 10f64.powf(cn0_dbhz / 10.0);
(4.0 * bn_hz / cn0 * (1.0 + 1.0 / (t_s * cn0))).sqrt() / (std::f64::consts::TAU * t_s)
}
pub fn range_rate_jitter_m_s(f_hz: f64, cn0_dbhz: f64, bn_hz: f64, t_s: f64) -> f64 {
fll_frequency_jitter_hz(cn0_dbhz, bn_hz, t_s) * C_M_S / f_hz
}
pub fn range_rate(sat: &Kinematics, user: &Kinematics) -> f64 {
range_rate_and_accel(sat, user).1
}
pub fn line_of_sight(sat: &Kinematics, user: &Kinematics) -> Vec3 {
let d = sub(sat.r, user.r);
let rho = dot(d, d).sqrt();
[d[0] / rho, d[1] / rho, d[2] / rho]
}
pub fn range_rate_position_gradient(sat: &Kinematics, user: &Kinematics) -> Vec3 {
let d = sub(sat.r, user.r);
let rho = dot(d, d).sqrt();
let u = [d[0] / rho, d[1] / rho, d[2] / rho];
let dv = sub(sat.v, user.v);
let rdot = dot(u, dv);
[
-(dv[0] - rdot * u[0]) / rho,
-(dv[1] - rdot * u[1]) / rho,
-(dv[2] - rdot * u[2]) / rho,
]
}
fn spd_solve(a: &[f64], n: usize, b: &[f64]) -> Option<Vec<f64>> {
let mut l = vec![0.0; n * n];
for i in 0..n {
for j in 0..=i {
let mut s = a[i * n + j];
for k in 0..j {
s -= l[i * n + k] * l[j * n + k];
}
if i == j {
if s.is_nan() || s <= 0.0 {
return None;
}
l[i * n + i] = s.sqrt();
} else {
l[i * n + j] = s / l[j * n + j];
}
}
}
let mut y = vec![0.0; n];
for i in 0..n {
let mut s = b[i];
for k in 0..i {
s -= l[i * n + k] * y[k];
}
y[i] = s / l[i * n + i];
}
let mut x = vec![0.0; n];
for i in (0..n).rev() {
let mut s = y[i];
for k in i + 1..n {
s -= l[k * n + i] * x[k];
}
x[i] = s / l[i * n + i];
}
Some(x)
}
#[derive(Clone, Copy, Debug)]
pub struct DopplerChannel {
pub residual_m_s: f64,
pub sigma_m_s: f64,
pub gradient: Vec3,
pub los: Vec3,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Prior {
pub position_sigma_m: f64,
pub velocity_sigma_m_s: f64,
pub drift_sigma_m_s: Option<f64>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ConsistencyTest {
pub n: usize,
pub dof: usize,
pub noncentrality: f64,
pub threshold: f64,
pub p_detect: f64,
}
pub fn doppler_consistency_window(
epochs: &[&[DopplerChannel]],
prior: &Prior,
p_fa: f64,
) -> Option<ConsistencyTest> {
let epochs: Vec<&[DopplerChannel]> = epochs.iter().copied().filter(|e| !e.is_empty()).collect();
let n: usize = epochs.iter().map(|e| e.len()).sum();
let k = epochs.len();
let dof = if prior.drift_sigma_m_s.is_some() {
n
} else {
n.checked_sub(k)?
};
if dof == 0 {
return None;
}
let use_p = prior.position_sigma_m > 0.0;
let use_v = prior.velocity_sigma_m_s > 0.0;
let nx = 3 * (use_p as usize) + 3 * (use_v as usize);
let m = nx + k;
let mut a = vec![0.0; m * m];
let mut bvec = vec![0.0; m];
let mut rdr = 0.0;
for (ei, e) in epochs.iter().enumerate() {
for ch in e.iter() {
let w = 1.0 / (ch.sigma_m_s * ch.sigma_m_s);
let mut h = vec![0.0; m];
let mut col = 0;
if use_p {
h[..3].copy_from_slice(&ch.gradient);
col = 3;
}
if use_v {
h[col] = -ch.los[0];
h[col + 1] = -ch.los[1];
h[col + 2] = -ch.los[2];
}
h[nx + ei] = 1.0;
for i in 0..m {
if h[i] == 0.0 {
continue;
}
bvec[i] += w * h[i] * ch.residual_m_s;
for j in 0..m {
a[i * m + j] += w * h[i] * h[j];
}
}
rdr += w * ch.residual_m_s * ch.residual_m_s;
}
}
let mut col = 0;
if use_p {
let ip = 1.0 / (prior.position_sigma_m * prior.position_sigma_m);
for i in 0..3 {
a[i * m + i] += ip;
}
col = 3;
}
if use_v {
let iv = 1.0 / (prior.velocity_sigma_m_s * prior.velocity_sigma_m_s);
for i in col..col + 3 {
a[i * m + i] += iv;
}
}
if let Some(sd) = prior.drift_sigma_m_s {
let id = 1.0 / (sd * sd);
for i in nx..m {
a[i * m + i] += id;
}
}
let sol = spd_solve(&a, m, &bvec)?;
let lambda = (rdr - bvec.iter().zip(&sol).map(|(x, y)| x * y).sum::<f64>()).max(0.0);
let threshold = chi2_quantile(1.0 - p_fa, dof as f64);
Some(ConsistencyTest {
n,
dof,
noncentrality: lambda,
threshold,
p_detect: 1.0 - noncentral_chi2_cdf(threshold, dof as f64, lambda),
})
}
pub fn doppler_consistency(
ch: &[DopplerChannel],
prior: &Prior,
p_fa: f64,
) -> Option<ConsistencyTest> {
doppler_consistency_window(&[ch], prior, p_fa)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CrossBandTest {
pub step_m: f64,
pub sigma_m: f64,
pub threshold_m: f64,
pub p_detect: f64,
}
pub fn cross_band_step(
step_m: f64,
sigma_1_m: f64,
sigma_2_m: f64,
iono_rate_m_s: f64,
dt_s: f64,
p_fa: f64,
) -> CrossBandTest {
let sigma = (2.0 * (sigma_1_m * sigma_1_m + sigma_2_m * sigma_2_m)).sqrt();
let z = normal_quantile(1.0 - 0.5 * p_fa);
let thr = z * sigma + iono_rate_m_s.abs() * dt_s;
let p = if sigma > 0.0 {
normal_cdf((step_m - thr) / sigma) + normal_cdf((-step_m - thr) / sigma)
} else if step_m.abs() > thr {
1.0
} else {
0.0
};
CrossBandTest {
step_m,
sigma_m: sigma,
threshold_m: thr,
p_detect: p.clamp(0.0, 1.0),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn kin(r: Vec3, v: Vec3) -> Kinematics {
Kinematics { r, v, a: [0.0; 3] }
}
#[test]
fn the_gradient_is_the_derivative_of_the_range_rate() {
let sat = kin([7.0e6, 1.0e6, 2.0e5], [100.0, 7400.0, 900.0]);
let user = kin([6.37e6, 0.0, 1.0e5], [0.0, 465.0, 0.0]);
let g = range_rate_position_gradient(&sat, &user);
for (k, gk) in g.iter().enumerate() {
let h = 1.0;
let mut up = user;
let mut dn = user;
up.r[k] += h;
dn.r[k] -= h;
let num = (range_rate(&sat, &up) - range_rate(&sat, &dn)) / (2.0 * h);
assert!((num - gk).abs() < 1e-9, "axis {k}: {num} vs {gk}");
}
}
#[test]
fn fll_jitter_matches_the_formula_by_hand() {
let s = fll_frequency_jitter_hz(50.0, 2.0, 0.02);
assert!((s - 0.071_194).abs() < 1e-5, "{s}");
}
#[test]
fn a_common_mode_residual_is_a_clock_drift_and_is_not_detected() {
let ch: Vec<DopplerChannel> = (0..5)
.map(|i| DopplerChannel {
residual_m_s: 0.3,
sigma_m_s: 0.05 + 0.01 * i as f64,
gradient: [1e-3 * i as f64, -2e-3, 5e-4],
los: [0.6, 0.0, 0.8],
})
.collect();
let prior = Prior {
position_sigma_m: 10.0,
velocity_sigma_m_s: 0.1,
drift_sigma_m_s: None,
};
let t = doppler_consistency(&ch, &prior, 1e-5).unwrap();
assert_eq!(t.dof, 4);
assert!(t.noncentrality < 1e-12, "{}", t.noncentrality);
assert!((t.p_detect - 1e-5).abs() < 1e-6, "{}", t.p_detect);
}
#[test]
fn with_no_prior_and_equal_noise_the_statistic_is_the_scatter_about_the_mean() {
let r = [0.1, -0.2, 0.05, 0.3];
let s = 0.1;
let ch: Vec<DopplerChannel> = r
.iter()
.map(|&x| DopplerChannel {
residual_m_s: x,
sigma_m_s: s,
gradient: [0.0; 3],
los: [1.0, 0.0, 0.0],
})
.collect();
let mut prior = Prior {
position_sigma_m: 0.0,
velocity_sigma_m_s: 0.0,
drift_sigma_m_s: None,
};
let t = doppler_consistency(&ch, &prior, 1e-3).unwrap();
let mean = r.iter().sum::<f64>() / 4.0;
let want: f64 = r.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (s * s);
assert!(
(t.noncentrality - want).abs() < 1e-9,
"{} vs {want}",
t.noncentrality
);
prior.drift_sigma_m_s = Some(1e-9);
let t2 = doppler_consistency(&ch, &prior, 1e-3).unwrap();
let want2: f64 = r.iter().map(|x| x * x).sum::<f64>() / (s * s);
assert_eq!(t2.dof, 4);
assert!((t2.noncentrality - want2).abs() < 1e-6 * want2);
}
#[test]
fn a_push_the_prior_explains_is_not_detected() {
let grads = [
[7e-3, 0.0, 0.0],
[0.0, 7e-3, 0.0],
[0.0, 0.0, 7e-3],
[-5e-3, 5e-3, 0.0],
];
let dx = [1.0, 0.0, 0.0];
let ch: Vec<DopplerChannel> = grads
.iter()
.map(|g| DopplerChannel {
residual_m_s: dot(*g, dx),
sigma_m_s: 1e-4,
gradient: *g,
los: [0.0, 0.0, 1.0],
})
.collect();
let prior = Prior {
position_sigma_m: 10.0,
velocity_sigma_m_s: 0.0,
drift_sigma_m_s: Some(1e-9),
};
let t = doppler_consistency(&ch, &prior, 1e-5).unwrap();
assert!(t.noncentrality <= 0.01 + 1e-9, "{}", t.noncentrality);
assert!(t.p_detect < 1e-4);
}
#[test]
fn a_velocity_push_is_hidden_by_a_loose_velocity_prior_only() {
let los = [
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
[0.6, 0.8, 0.0],
];
let dv = [1.0, 0.0, 0.0];
let ch: Vec<DopplerChannel> = los
.iter()
.map(|u| DopplerChannel {
residual_m_s: -dot(*u, dv),
sigma_m_s: 0.01,
gradient: [0.0; 3],
los: *u,
})
.collect();
let mut prior = Prior {
position_sigma_m: 0.0,
velocity_sigma_m_s: 0.01,
drift_sigma_m_s: None,
};
assert!(doppler_consistency(&ch, &prior, 1e-5).unwrap().p_detect > 0.999);
prior.velocity_sigma_m_s = 100.0;
assert!(doppler_consistency(&ch, &prior, 1e-5).unwrap().p_detect < 1e-4);
}
#[test]
fn the_information_form_equals_the_covariance_form() {
let ch: Vec<DopplerChannel> = (0..5)
.map(|i| {
let x = i as f64;
let u = [0.3 * x.cos(), 0.4 * x.sin(), 0.8];
let nu = (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt();
DopplerChannel {
residual_m_s: 0.02 * (x - 1.7),
sigma_m_s: 0.01 + 0.002 * x,
gradient: [1e-3 * (x - 2.0), 2e-3 * x.sin(), -1e-3],
los: [u[0] / nu, u[1] / nu, u[2] / nu],
}
})
.collect();
let prior = Prior {
position_sigma_m: 5.0,
velocity_sigma_m_s: 0.2,
drift_sigma_m_s: Some(0.05),
};
let t = doppler_consistency(&ch, &prior, 1e-3).unwrap();
let n = ch.len();
let mut c = vec![0.0; n * n];
for i in 0..n {
for j in 0..n {
c[i * n + j] = 25.0 * dot(ch[i].gradient, ch[j].gradient)
+ 0.04 * dot(ch[i].los, ch[j].los)
+ 0.0025
+ if i == j { ch[i].sigma_m_s.powi(2) } else { 0.0 };
}
}
let r: Vec<f64> = ch.iter().map(|x| x.residual_m_s).collect();
let cr = spd_solve(&c, n, &r).unwrap();
let want: f64 = r.iter().zip(&cr).map(|(a, b)| a * b).sum();
assert!(
(t.noncentrality - want).abs() < 1e-9 * want.max(1.0),
"{} vs {want}",
t.noncentrality
);
let jump: Vec<DopplerChannel> = ch
.iter()
.map(|x| DopplerChannel {
residual_m_s: dot(x.gradient, [20.0, 0.0, 0.0]),
..*x
})
.collect();
let one = doppler_consistency(&jump, &prior, 1e-3).unwrap();
let two = doppler_consistency_window(&[&jump, &jump], &prior, 1e-3).unwrap();
assert!(two.noncentrality > one.noncentrality);
assert_eq!(two.dof, 2 * one.dof);
}
#[test]
fn the_cross_band_threshold_and_power() {
let t = cross_band_step(0.0, 0.3, 0.4, 0.01, 5.0, 1e-3);
let sig = 0.5_f64.sqrt();
assert!((t.sigma_m - sig).abs() < 1e-12);
assert!((t.threshold_m - (3.290_527 * sig + 0.05)).abs() < 1e-5);
assert!(t.p_detect < 1e-3);
let big = cross_band_step(10.0, 0.3, 0.4, 0.01, 5.0, 1e-3);
assert!(big.p_detect > 0.999_999);
}
}