use crate::detection::chi2_inv_cdf;
use crate::kalman::KalmanClock;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
use serde::Serialize;
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct FilterHealth {
pub nis_mean: f64,
pub nis_chi2_lower_95: f64,
pub nis_chi2_upper_95: f64,
pub nees_mean: f64,
pub nees_chi2_lower_95: f64,
pub nees_chi2_upper_95: f64,
pub consistent: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct HealthConfig {
pub q_wf: f64,
pub q_rw: f64,
pub r: f64,
pub dt: f64,
pub steps: usize,
pub seeds: usize,
pub q_factor: f64,
pub base_seed: u64,
}
const PHASE_VAR0: f64 = 1e-18; const FREQ_VAR0: f64 = 1e-24;
fn process_q(q_wf: f64, q_rw: f64, dt: f64) -> [[f64; 2]; 2] {
let (dt2, dt3) = (dt * dt, dt * dt * dt);
[
[q_wf * dt + q_rw * dt3 / 3.0, q_rw * dt2 / 2.0],
[q_rw * dt2 / 2.0, q_rw * dt],
]
}
fn cholesky_2x2(m: [[f64; 2]; 2]) -> [[f64; 2]; 2] {
let l00 = m[0][0].max(0.0).sqrt();
let l10 = if l00 > 0.0 { m[1][0] / l00 } else { 0.0 };
let l11 = (m[1][1] - l10 * l10).max(0.0).sqrt();
[[l00, 0.0], [l10, l11]]
}
fn nees(e: [f64; 2], p: [[f64; 2]; 2]) -> Option<f64> {
let det = p[0][0] * p[1][1] - p[0][1] * p[1][0];
if det.abs() <= 0.0 {
return None;
}
let i00 = p[1][1] / det;
let i01 = -p[0][1] / det;
let i10 = -p[1][0] / det;
let i11 = p[0][0] / det;
Some(e[0] * (i00 * e[0] + i01 * e[1]) + e[1] * (i10 * e[0] + i11 * e[1]))
}
pub fn assess(cfg: HealthConfig) -> FilterHealth {
let steps = cfg.steps.max(1);
let seeds = cfg.seeds.max(1);
let dt = cfg.dt.max(1e-12);
let r = cfg.r.max(1e-300);
let lq = cholesky_2x2(process_q(cfg.q_wf, cfg.q_rw, dt));
let l0 = cholesky_2x2([[PHASE_VAR0, 0.0], [0.0, FREQ_VAR0]]);
let n01 = Normal::new(0.0, 1.0).unwrap();
let meas = Normal::new(0.0, r.sqrt()).unwrap();
let mut nis_sum = 0.0;
let mut nis_n = 0u64;
let mut nees_sum = 0.0;
let mut nees_n = 0u64;
for s in 0..seeds {
let mut rng = ChaCha8Rng::seed_from_u64(
cfg.base_seed ^ (0x9E37_79B9_7F4A_7C15u64).wrapping_mul(s as u64 + 1),
);
let (z0, z1) = (n01.sample(&mut rng), n01.sample(&mut rng));
let mut x_true = [l0[0][0] * z0, l0[1][0] * z0 + l0[1][1] * z1];
let mut kf = KalmanClock::new(cfg.q_wf * cfg.q_factor, cfg.q_rw * cfg.q_factor, r)
.with_initial_cov(PHASE_VAR0, FREQ_VAR0);
for _ in 0..steps {
let (w0, w1) = (n01.sample(&mut rng), n01.sample(&mut rng));
x_true[0] += dt * x_true[1] + lq[0][0] * w0;
x_true[1] += lq[1][0] * w0 + lq[1][1] * w1;
kf.predict(dt);
let z = x_true[0] + meas.sample(&mut rng);
let innov = z - kf.phase_est();
let s_innov = kf.innovation_var(r);
if s_innov > 0.0 {
nis_sum += innov * innov / s_innov;
nis_n += 1;
}
kf.update_with_r(z, r);
let e = [x_true[0] - kf.phase_est(), x_true[1] - kf.freq_est()];
if let Some(v) = nees(e, kf.covariance()) {
nees_sum += v;
nees_n += 1;
}
}
}
let nis_mean = if nis_n > 0 {
nis_sum / nis_n as f64
} else {
0.0
};
let nees_mean = if nees_n > 0 {
nees_sum / nees_n as f64
} else {
0.0
};
let knis = nis_n as f64;
let nis_lo = chi2_inv_cdf(0.025, knis) / knis;
let nis_hi = chi2_inv_cdf(0.975, knis) / knis;
let dof_nees = 2.0 * seeds as f64;
let nees_lo = chi2_inv_cdf(0.025, dof_nees) / seeds as f64;
let nees_hi = chi2_inv_cdf(0.975, dof_nees) / seeds as f64;
let consistent =
nis_mean >= nis_lo && nis_mean <= nis_hi && nees_mean >= nees_lo && nees_mean <= nees_hi;
FilterHealth {
nis_mean,
nis_chi2_lower_95: nis_lo,
nis_chi2_upper_95: nis_hi,
nees_mean,
nees_chi2_lower_95: nees_lo,
nees_chi2_upper_95: nees_hi,
consistent,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(q_factor: f64) -> HealthConfig {
HealthConfig {
q_wf: 1e-18,
q_rw: 1e-26,
r: 1e-20,
dt: 1.0,
steps: 200,
seeds: 64,
q_factor,
base_seed: 20260604,
}
}
#[test]
fn matched_filter_is_consistent() {
let h = assess(cfg(1.0));
assert!(h.consistent, "matched filter flagged inconsistent: {h:?}");
assert!(
h.nis_mean > 0.9 && h.nis_mean < 1.1,
"nis_mean={}",
h.nis_mean
);
assert!(
h.nees_mean > 1.8 && h.nees_mean < 2.2,
"nees_mean={}",
h.nees_mean
);
assert!(h.nis_chi2_lower_95 < 1.0 && h.nis_chi2_upper_95 > 1.0);
assert!(h.nees_chi2_lower_95 < 2.0 && h.nees_chi2_upper_95 > 2.0);
}
#[test]
fn q_r_mismatch_sweep_flips_consistency() {
assert!(
assess(cfg(1.0)).consistent,
"factor 1.0 should be consistent"
);
for &f in &[0.1, 0.5, 2.0, 10.0] {
let h = assess(cfg(f));
assert!(!h.consistent, "factor {f} should be inconsistent: {h:?}");
}
assert!(assess(cfg(0.5)).nis_mean > assess(cfg(1.0)).nis_mean);
assert!(assess(cfg(2.0)).nis_mean < assess(cfg(1.0)).nis_mean);
}
#[test]
fn assess_is_deterministic_in_the_seed() {
assert_eq!(assess(cfg(1.0)), assess(cfg(1.0)));
}
}