#![allow(clippy::needless_range_loop)]
use crate::core::matrix::{matmul, matvec, Matrix};
use crate::core::scalar::ControlScalar;
#[derive(Debug, Clone, Copy)]
pub struct HuberKalmanFilter<S: ControlScalar, const N: usize, const M: usize> {
x: [S; N],
p: Matrix<S, N, N>,
pub q: Matrix<S, N, N>,
pub r: Matrix<S, M, M>,
pub huber_threshold: S,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HuberKfError {
SingularMatrix,
NotPositiveDefinite,
InvalidThreshold,
}
impl core::fmt::Display for HuberKfError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
HuberKfError::SingularMatrix => write!(f, "HuberKalmanFilter: singular matrix"),
HuberKfError::NotPositiveDefinite => {
write!(f, "HuberKalmanFilter: covariance not positive definite")
}
HuberKfError::InvalidThreshold => {
write!(f, "HuberKalmanFilter: threshold must be > 0")
}
}
}
}
impl<S: ControlScalar, const N: usize, const M: usize> HuberKalmanFilter<S, N, M> {
pub fn new(
x0: [S; N],
p0: Matrix<S, N, N>,
q: Matrix<S, N, N>,
r: Matrix<S, M, M>,
threshold: S,
) -> Result<Self, HuberKfError> {
if threshold <= S::ZERO {
return Err(HuberKfError::InvalidThreshold);
}
Ok(Self {
x: x0,
p: p0,
q,
r,
huber_threshold: threshold,
})
}
pub fn predict(
&mut self,
f_mat: &Matrix<S, N, N>,
control: Option<(&Matrix<S, N, 1>, &[S; 1])>,
) -> Result<(), HuberKfError> {
let fx = matvec(f_mat, &self.x);
self.x = if let Some((b, u)) = control {
let bu = matvec(b, u);
core::array::from_fn(|i| fx[i] + bu[i])
} else {
fx
};
let fp = matmul(f_mat, &self.p);
let ft = f_mat.transpose();
let fpft = matmul(&fp, &ft);
self.p = fpft.add_mat(&self.q);
Ok(())
}
pub fn update(&mut self, h_mat: &Matrix<S, M, N>, z: &[S; M]) -> Result<(), HuberKfError> {
let hx: [S; M] = matvec(h_mat, &self.x);
let nu: [S; M] = core::array::from_fn(|i| z[i] - hx[i]);
let c = self.huber_threshold;
let mut weights = [S::ONE; M];
for i in 0..M {
let abs_nu = num_traits::Float::abs(nu[i]);
if abs_nu > c {
weights[i] = c / abs_nu;
}
}
let mut r_eff = self.r;
for i in 0..M {
for j in 0..M {
let w_ij = num_traits::Float::sqrt(weights[i] * weights[j]);
if w_ij > S::ZERO {
r_eff.data[i][j] = self.r.data[i][j] / w_ij;
}
}
}
let ht = h_mat.transpose();
let ph_t = matmul(&self.p, &ht); let h_p_ht = matmul(h_mat, &ph_t); let s_innov = h_p_ht.add_mat(&r_eff);
let s_inv = s_innov.inv().ok_or(HuberKfError::SingularMatrix)?;
let k = matmul(&ph_t, &s_inv);
let k_nu = matvec(&k, &nu);
for i in 0..N {
self.x[i] += k_nu[i];
}
let kh = matmul(&k, h_mat); let kh_p = matmul(&kh, &self.p);
self.p = self.p.sub_mat(&kh_p);
Ok(())
}
pub fn state(&self) -> &[S; N] {
&self.x
}
pub fn covariance(&self) -> &Matrix<S, N, N> {
&self.p
}
pub fn reset(&mut self, x0: [S; N], p0: Matrix<S, N, N>) {
self.x = x0;
self.p = p0;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_scalar_filter(threshold: f64) -> HuberKalmanFilter<f64, 1, 1> {
let x0 = [0.0_f64];
let p0 = Matrix::<f64, 1, 1> { data: [[100.0]] };
let q = Matrix::<f64, 1, 1> { data: [[1e-4]] };
let r = Matrix::<f64, 1, 1> { data: [[1.0]] };
HuberKalmanFilter::new(x0, p0, q, r, threshold).expect("threshold > 0")
}
fn build_pv_filter(threshold: f64) -> HuberKalmanFilter<f64, 2, 1> {
let x0 = [0.0_f64; 2];
let p0 = Matrix::<f64, 2, 2>::identity().scale(100.0);
let q = Matrix::<f64, 2, 2>::identity().scale(1e-4);
let r = Matrix::<f64, 1, 1> { data: [[0.1]] };
HuberKalmanFilter::new(x0, p0, q, r, threshold).expect("threshold > 0")
}
fn pv_transition() -> Matrix<f64, 2, 2> {
let dt = 0.01_f64;
Matrix::<f64, 2, 2> {
data: [[1.0, dt], [0.0, 1.0]],
}
}
fn pv_h() -> Matrix<f64, 1, 2> {
Matrix::<f64, 1, 2> { data: [[1.0, 0.0]] }
}
#[test]
fn invalid_threshold_rejected() {
let x0 = [0.0_f64];
let p0 = Matrix::<f64, 1, 1> { data: [[1.0]] };
let q = Matrix::<f64, 1, 1> { data: [[1e-4]] };
let r = Matrix::<f64, 1, 1> { data: [[1.0]] };
assert!(HuberKalmanFilter::new(x0, p0, q, r, 0.0_f64).is_err());
assert!(HuberKalmanFilter::new(x0, p0, q, r, -1.0_f64).is_err());
}
#[test]
fn predict_update_cycle_completes() {
let mut f = build_scalar_filter(1.5);
let f_mat = Matrix::<f64, 1, 1> { data: [[1.0]] };
let h_mat = Matrix::<f64, 1, 1> { data: [[1.0]] };
assert!(f.predict(&f_mat, None).is_ok());
assert!(f.update(&h_mat, &[1.0]).is_ok());
}
#[test]
fn tracks_constant_signal() {
let mut f = build_pv_filter(3.0);
let f_mat = pv_transition();
let h_mat = pv_h();
let true_pos = 5.0_f64;
for _ in 0..500 {
f.predict(&f_mat, None).expect("predict");
f.update(&h_mat, &[true_pos]).expect("update");
}
let x = f.state();
assert!(
(x[0] - true_pos).abs() < 0.5,
"Expected position ~{true_pos}, got {}",
x[0]
);
}
#[test]
fn outlier_is_downweighted() {
let f_mat = pv_transition();
let h_mat = pv_h();
let mut robust = build_pv_filter(1.0);
let mut standard = build_pv_filter(1e9);
for _ in 0..200 {
robust.predict(&f_mat, None).expect("predict");
robust.update(&h_mat, &[0.0]).expect("update");
standard.predict(&f_mat, None).expect("predict");
standard.update(&h_mat, &[0.0]).expect("update");
}
let outlier = [100.0_f64];
robust.predict(&f_mat, None).expect("predict");
robust.update(&h_mat, &outlier).expect("update");
standard.predict(&f_mat, None).expect("predict");
standard.update(&h_mat, &outlier).expect("update");
let robust_pos = robust.state()[0].abs();
let standard_pos = standard.state()[0].abs();
assert!(
robust_pos < standard_pos,
"Robust filter ({robust_pos}) should be less affected than standard ({standard_pos})"
);
}
#[test]
fn covariance_decreases_over_updates() {
let mut f = build_pv_filter(2.0);
let f_mat = pv_transition();
let h_mat = pv_h();
let initial_trace = f.covariance().trace();
for i in 0..100 {
f.predict(&f_mat, None).expect("predict");
f.update(&h_mat, &[i as f64 * 0.01]).expect("update");
}
let final_trace = f.covariance().trace();
assert!(
final_trace < initial_trace,
"Trace should decrease: {initial_trace} → {final_trace}"
);
}
#[test]
fn reset_restores_initial_state() {
let mut f = build_pv_filter(2.0);
let f_mat = pv_transition();
let h_mat = pv_h();
for _ in 0..50 {
f.predict(&f_mat, None).expect("predict");
f.update(&h_mat, &[3.0]).expect("update");
}
let p0 = Matrix::<f64, 2, 2>::identity().scale(100.0);
f.reset([0.0_f64; 2], p0);
assert!(
f.state()[0].abs() < 1e-12,
"State should be 0 after reset, got {}",
f.state()[0]
);
}
#[test]
fn with_control_input() {
let dt = 0.01_f64;
let mut f = build_pv_filter(3.0);
let f_mat = pv_transition();
let h_mat = pv_h();
let b_mat = Matrix::<f64, 2, 1> {
data: [[0.5 * dt * dt], [dt]],
};
let u = [1.0_f64]; for _ in 0..50 {
f.predict(&f_mat, Some((&b_mat, &u))).expect("predict");
f.update(&h_mat, &[0.0]).expect("update");
}
assert!(
f.state()[1].abs() > 0.0,
"Velocity should be non-zero with acceleration input"
);
}
}