Skip to content

Commit 5e83463

Browse files
committed
feat(kalman): add exogenous-input support to ExtendedKalmanFilter
EkfModel::f/h only see the state (plus dt for f), which doesn't fit models whose process or measurement equations depend on an input that isn't part of the state (a commanded control input, or a measured value needed for a correction term). Add f_with_input/h_with_input (and their Jacobian counterparts) as default-implemented trait methods, plus predict_with_input/update_with_input on ExtendedKalmanFilter to drive them. Defaults defer to the existing f/h/Jacobians, so no existing EkfModel implementation is affected.
1 parent ceef0be commit 5e83463

5 files changed

Lines changed: 259 additions & 26 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "embedded-dsp"
3-
version = "0.2.1"
3+
version = "0.2.2"
44
edition = "2021"
55
authors = ["Gerzain Mata <leftger@gmail.com>"]
66
description = "A no_std Rust digital signal processing library for microcontrollers, embedded systems, and real-time signals."

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ A **`#![no_std]` Rust Digital Signal Processing library** designed for microcont
2222
5. **Filter Design**: Biquad Low-Pass, High-Pass, Band-Pass, Notch, Peaking EQ, All-Pass, and multi-stage Butterworth design.
2323
6. **Audio & TinyML**: Goertzel single-frequency detector, Envelope follower (peak & RMS), Mel filterbank, and MFCC feature extraction.
2424
7. **Resampling & Multi-rate**: Cascaded Integrator-Comb (CIC) Decimator & Interpolator, linear fractional resampler.
25-
8. **Kalman Filtering**: 1D/2D helpers, const-generic linear `KalmanFilter<N, M>`, and trait-based Extended Kalman Filter (`EkfModel`).
25+
8. **Kalman Filtering**: 1D/2D helpers, const-generic linear `KalmanFilter<N, M>`, and trait-based Extended Kalman Filter (`EkfModel`), with `_with_input` variants for models driven by an exogenous input outside the state.
2626
9. **Const Generics**: Compile-time fixed-size `FirFilter<N>`, `BiquadCascade<COEFFS, STATE>`, and `Matrix<R, C, N>`.
2727
10. **Transforms**: In-place Complex FFT (`cfft`), Real FFT (`rfft`), Discrete Cosine Transform (`dct4`), Fixed-Point FFT (`cfft_q15`/`cfft_q31`).
2828
11. **Matrix Operations**: Matrix addition, subtraction, multiplication, scaling, transpose, Gauss-Jordan inversion.

src/kalman.rs

Lines changed: 143 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44
//! [`ExtendedKalmanFilter`] driven by a user [`EkfModel`]. Measurement dimension `M` must be
55
//! ≤ 16 (same limit as [`crate::matrix::mat_inverse_f32`]). Covariance updates use
66
//! `P ← (I − KH) P`; a Joseph-form update may be added later for improved numerical stability.
7+
//!
8+
//! `EkfModel::f`/`h` only see the state (plus `dt` for `f`), which doesn't fit models whose
9+
//! process or measurement equations depend on an exogenous input that isn't part of the state
10+
//! (a commanded actuation, a measured current used for an IR-drop correction, etc). For that,
11+
//! implement the `_with_input` trait methods and drive the filter with
12+
//! [`ExtendedKalmanFilter::predict_with_input`] / [`ExtendedKalmanFilter::update_with_input`].
13+
//! Their default implementations ignore `u` and defer to `f`/`h`/the Jacobians, so existing
14+
//! [`EkfModel`] implementations keep compiling unchanged.
715
816
use crate::matrix::{mat_inverse_f32, MatrixInstance, MatrixInstanceMut};
917
use crate::types::Status;
@@ -417,6 +425,61 @@ pub trait EkfModel<const N: usize, const M: usize> {
417425

418426
/// Measurement Jacobian `H = ∂h/∂x` evaluated at `x` (`M×N`).
419427
fn jacobian_h(&self, x: &[f32; N], out: &mut [[f32; N]; M]);
428+
429+
/// Process model with an explicit exogenous input `u` (a control input,
430+
/// measured disturbance, or anything else that drives `f` but isn't
431+
/// part of the state): `out = f(x, u, dt)`.
432+
///
433+
/// Default: ignores `u` and defers to [`EkfModel::f`], so models that
434+
/// don't need an input compile unchanged.
435+
fn f_with_input<const U: usize>(
436+
&self,
437+
x: &[f32; N],
438+
u: &[f32; U],
439+
dt: f32,
440+
out: &mut [f32; N],
441+
) {
442+
let _ = u;
443+
self.f(x, dt, out)
444+
}
445+
446+
/// Process Jacobian for [`EkfModel::f_with_input`], `F = ∂f/∂x` evaluated at `(x, u)`.
447+
///
448+
/// Default: defers to [`EkfModel::jacobian_f`], which is exact whenever `u` enters `f`
449+
/// affinely (so it doesn't change the derivative with respect to `x`).
450+
fn jacobian_f_with_input<const U: usize>(
451+
&self,
452+
x: &[f32; N],
453+
u: &[f32; U],
454+
dt: f32,
455+
out: &mut [[f32; N]; N],
456+
) {
457+
let _ = u;
458+
self.jacobian_f(x, dt, out)
459+
}
460+
461+
/// Measurement model with an explicit exogenous input `u` (e.g. a measured current used
462+
/// for an IR-drop correction that isn't part of the state): `out = h(x, u)`.
463+
///
464+
/// Default: ignores `u` and defers to [`EkfModel::h`].
465+
fn h_with_input<const U: usize>(&self, x: &[f32; N], u: &[f32; U], out: &mut [f32; M]) {
466+
let _ = u;
467+
self.h(x, out)
468+
}
469+
470+
/// Measurement Jacobian for [`EkfModel::h_with_input`], `H = ∂h/∂x` evaluated at `(x, u)`.
471+
///
472+
/// Default: defers to [`EkfModel::jacobian_h`], which is exact whenever `u` enters `h`
473+
/// affinely.
474+
fn jacobian_h_with_input<const U: usize>(
475+
&self,
476+
x: &[f32; N],
477+
u: &[f32; U],
478+
out: &mut [[f32; N]; M],
479+
) {
480+
let _ = u;
481+
self.jacobian_h(x, out)
482+
}
420483
}
421484

422485
/// Extended Kalman filter with compile-time dimensions and a user [`EkfModel`].
@@ -476,42 +539,99 @@ impl<const N: usize, const M: usize, Model: EkfModel<N, M>> ExtendedKalmanFilter
476539

477540
let mut x_new = [0.0f32; N];
478541
self.model.f(&self.x, dt, &mut x_new);
479-
self.x = x_new;
480542

481-
let mut fp = [[0.0f32; N]; N];
482-
mat_mul(&f_jac, &self.p, &mut fp);
483-
let mut p_new = [[0.0f32; N]; N];
484-
mat_mul_bt(&fp, &f_jac, &mut p_new);
485-
mat_add_inplace_nn(&mut p_new, &self.q);
486-
self.p = p_new;
543+
ekf_predict_apply(&mut self.x, &mut self.p, &self.q, &f_jac, x_new);
544+
}
545+
546+
/// EKF predict with an exogenous input `u`, via [`EkfModel::f_with_input`] /
547+
/// [`EkfModel::jacobian_f_with_input`]. See the [module docs](self) for when this is
548+
/// needed instead of [`ExtendedKalmanFilter::predict`].
549+
pub fn predict_with_input<const U: usize>(&mut self, dt: f32, u: &[f32; U]) {
550+
let mut f_jac = [[0.0f32; N]; N];
551+
self.model.jacobian_f_with_input(&self.x, u, dt, &mut f_jac);
552+
553+
let mut x_new = [0.0f32; N];
554+
self.model.f_with_input(&self.x, u, dt, &mut x_new);
555+
556+
ekf_predict_apply(&mut self.x, &mut self.p, &self.q, &f_jac, x_new);
487557
}
488558

489559
/// EKF update with measurement `z`. Linearizes `h` at the current estimate.
490560
///
491561
/// On singular innovation covariance or `M > 16`, returns an error and leaves state unchanged.
492562
pub fn update(&mut self, z: &[f32; M]) -> Status {
493-
if M > 16 {
494-
return Status::ArgumentError;
495-
}
496-
if M == 0 {
497-
return Status::SizeMismatch;
498-
}
499-
500563
let mut h_jac = [[0.0f32; N]; M];
501564
self.model.jacobian_h(&self.x, &mut h_jac);
502565

503566
let mut hx = [0.0f32; M];
504567
self.model.h(&self.x, &mut hx);
505568

506-
// Reuse linear update with innovation z' = z - h(x) + H x so that
507-
// y = z' - H x = z - h(x).
508-
let mut z_equiv = [0.0f32; M];
509-
let mut hx_lin = [0.0f32; M];
510-
mat_vec_mul(&h_jac, &self.x, &mut hx_lin);
511-
for i in 0..M {
512-
z_equiv[i] = z[i] - hx[i] + hx_lin[i];
513-
}
569+
ekf_update_apply(&mut self.x, &mut self.p, &self.r, &h_jac, &hx, z)
570+
}
571+
572+
/// EKF update with an exogenous input `u`, via [`EkfModel::h_with_input`] /
573+
/// [`EkfModel::jacobian_h_with_input`]. See the [module docs](self) for when this is
574+
/// needed instead of [`ExtendedKalmanFilter::update`].
575+
///
576+
/// On singular innovation covariance or `M > 16`, returns an error and leaves state unchanged.
577+
pub fn update_with_input<const U: usize>(&mut self, z: &[f32; M], u: &[f32; U]) -> Status {
578+
let mut h_jac = [[0.0f32; N]; M];
579+
self.model.jacobian_h_with_input(&self.x, u, &mut h_jac);
514580

515-
kf_update_core(&mut self.x, &mut self.p, &self.r, &h_jac, &z_equiv)
581+
let mut hx = [0.0f32; M];
582+
self.model.h_with_input(&self.x, u, &mut hx);
583+
584+
ekf_update_apply(&mut self.x, &mut self.p, &self.r, &h_jac, &hx, z)
516585
}
517586
}
587+
588+
/// Shared EKF predict math: `x ← x_new`, `P ← F P Fᵀ + Q`. Factored out so
589+
/// [`ExtendedKalmanFilter::predict`] and [`ExtendedKalmanFilter::predict_with_input`] (which
590+
/// differ only in how `x_new`/`f_jac` are computed) don't duplicate the covariance propagation.
591+
fn ekf_predict_apply<const N: usize>(
592+
x: &mut [f32; N],
593+
p: &mut [[f32; N]; N],
594+
q: &[[f32; N]; N],
595+
f_jac: &[[f32; N]; N],
596+
x_new: [f32; N],
597+
) {
598+
*x = x_new;
599+
600+
let mut fp = [[0.0f32; N]; N];
601+
mat_mul(f_jac, p, &mut fp);
602+
let mut p_new = [[0.0f32; N]; N];
603+
mat_mul_bt(&fp, f_jac, &mut p_new);
604+
mat_add_inplace_nn(&mut p_new, q);
605+
*p = p_new;
606+
}
607+
608+
/// Shared EKF update math: linearizes around `hx = h(x)` and reuses the linear-filter update
609+
/// core. Factored out so [`ExtendedKalmanFilter::update`] and
610+
/// [`ExtendedKalmanFilter::update_with_input`] (which differ only in how `hx`/`h_jac` are
611+
/// computed) don't duplicate the linearization.
612+
fn ekf_update_apply<const N: usize, const M: usize>(
613+
x: &mut [f32; N],
614+
p: &mut [[f32; N]; N],
615+
r: &[[f32; M]; M],
616+
h_jac: &[[f32; N]; M],
617+
hx: &[f32; M],
618+
z: &[f32; M],
619+
) -> Status {
620+
if M > 16 {
621+
return Status::ArgumentError;
622+
}
623+
if M == 0 {
624+
return Status::SizeMismatch;
625+
}
626+
627+
// Reuse linear update with innovation z' = z - h(x) + H x so that
628+
// y = z' - H x = z - h(x).
629+
let mut z_equiv = [0.0f32; M];
630+
let mut hx_lin = [0.0f32; M];
631+
mat_vec_mul(h_jac, x, &mut hx_lin);
632+
for i in 0..M {
633+
z_equiv[i] = z[i] - hx[i] + hx_lin[i];
634+
}
635+
636+
kf_update_core(x, p, r, h_jac, &z_equiv)
637+
}

tests/dsp_tests.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,119 @@ fn test_ekf_range_measurement() {
612612
assert!(((ekf.x[0] * ekf.x[0] + ekf.x[1] * ekf.x[1]).sqrt() - 5.0).abs() < 0.5);
613613
}
614614

615+
/// Constant-acceleration model driven by a commanded acceleration `u = [accel]` that isn't
616+
/// part of the state, and measured through a position sensor with a known offset `u = [bias]`
617+
/// that also isn't part of the state. Exercises `EkfModel::f_with_input`/`h_with_input` and
618+
/// `ExtendedKalmanFilter::predict_with_input`/`update_with_input`.
619+
#[derive(Debug, Clone, Copy)]
620+
struct ControlledPositionModel;
621+
622+
impl EkfModel<2, 1> for ControlledPositionModel {
623+
fn f(&self, x: &[f32; 2], dt: f32, out: &mut [f32; 2]) {
624+
out[0] = x[0] + dt * x[1];
625+
out[1] = x[1];
626+
}
627+
628+
fn h(&self, x: &[f32; 2], out: &mut [f32; 1]) {
629+
out[0] = x[0];
630+
}
631+
632+
fn jacobian_f(&self, _x: &[f32; 2], dt: f32, out: &mut [[f32; 2]; 2]) {
633+
*out = [[1.0, dt], [0.0, 1.0]];
634+
}
635+
636+
fn jacobian_h(&self, _x: &[f32; 2], out: &mut [[f32; 2]; 1]) {
637+
*out = [[1.0, 0.0]];
638+
}
639+
640+
fn f_with_input<const U: usize>(
641+
&self,
642+
x: &[f32; 2],
643+
u: &[f32; U],
644+
dt: f32,
645+
out: &mut [f32; 2],
646+
) {
647+
let accel = u[0];
648+
out[0] = x[0] + dt * x[1] + 0.5 * dt * dt * accel;
649+
out[1] = x[1] + dt * accel;
650+
}
651+
652+
// Jacobian w.r.t. x is unchanged by u: accel enters f affinely, so the default
653+
// `jacobian_f_with_input` (which defers to `jacobian_f`) is already exact here. Implemented
654+
// explicitly anyway so the test exercises the override path, not just the default.
655+
fn jacobian_f_with_input<const U: usize>(
656+
&self,
657+
x: &[f32; 2],
658+
_u: &[f32; U],
659+
dt: f32,
660+
out: &mut [[f32; 2]; 2],
661+
) {
662+
self.jacobian_f(x, dt, out)
663+
}
664+
665+
fn h_with_input<const U: usize>(&self, x: &[f32; 2], u: &[f32; U], out: &mut [f32; 1]) {
666+
out[0] = x[0] + u[0];
667+
}
668+
669+
fn jacobian_h_with_input<const U: usize>(
670+
&self,
671+
x: &[f32; 2],
672+
_u: &[f32; U],
673+
out: &mut [[f32; 2]; 1],
674+
) {
675+
self.jacobian_h(x, out)
676+
}
677+
}
678+
679+
#[test]
680+
fn test_ekf_predict_with_input_matches_manual_integration() {
681+
let mut ekf = ExtendedKalmanFilter::<2, 1, _>::from_variances(
682+
[0.0, 0.0],
683+
1.0,
684+
1e-4,
685+
0.01,
686+
ControlledPositionModel,
687+
);
688+
689+
let accel = 2.0f32;
690+
let dt = 0.5f32;
691+
for _ in 0..10 {
692+
ekf.predict_with_input(dt, &[accel]);
693+
}
694+
695+
let t = 10.0 * dt;
696+
let expected_velocity = accel * t;
697+
let expected_position = 0.5 * accel * t * t;
698+
assert!((ekf.x[1] - expected_velocity).abs() < 1e-3);
699+
assert!((ekf.x[0] - expected_position).abs() < 1e-2);
700+
}
701+
702+
#[test]
703+
fn test_ekf_update_with_input_compensates_known_bias() {
704+
let mut ekf = ExtendedKalmanFilter::<2, 1, _>::from_variances(
705+
[0.0, 0.0],
706+
4.0,
707+
0.0,
708+
0.01,
709+
ControlledPositionModel,
710+
);
711+
712+
let true_position = 10.0f32;
713+
let sensor_bias = 3.0f32;
714+
// The raw sensor reading is offset by `sensor_bias`; feeding it through plain `update`
715+
// (which ignores the bias) would converge to the biased reading instead of the truth.
716+
let biased_reading = true_position + sensor_bias;
717+
718+
for _ in 0..20 {
719+
assert_eq!(
720+
ekf.update_with_input(&[biased_reading], &[sensor_bias]),
721+
Status::Success
722+
);
723+
}
724+
725+
assert!((ekf.x[0] - true_position).abs() < 0.5);
726+
}
727+
615728
#[test]
616729
fn test_kalman_update_singular_leaves_state() {
617730
let mut kf = KalmanFilter::<1, 1>::new([1.0], [[0.0]], [[0.0]], [[0.0]]);

0 commit comments

Comments
 (0)