Skip to main content

iris/kalman/
mod.rs

1#![allow(non_snake_case)]
2
3use crate::error::{IrisError, Result};
4
5/// A discrete linear Kalman filter for tracking state estimates through noisy measurements.
6///
7/// The state evolves as:  x(k) = F * x(k-1) + B * u(k) + w(k),  w ~ N(0, Q)
8/// Measurements are:      z(k) = H * x(k) + v(k),                 v ~ N(0, R)
9pub struct KalmanFilter {
10    /// State dimension.
11    state_dim: usize,
12    /// Measurement dimension.
13    measure_dim: usize,
14    /// State transition matrix [state_dim x state_dim].
15    F: Vec<Vec<f64>>,
16    /// Measurement matrix [measure_dim x state_dim].
17    H: Vec<Vec<f64>>,
18    /// Process noise covariance [state_dim x state_dim].
19    Q: Vec<Vec<f64>>,
20    /// Measurement noise covariance [measure_dim x measure_dim].
21    R: Vec<Vec<f64>>,
22    /// Current state estimate [state_dim x 1].
23    x: Vec<f64>,
24    /// Current error covariance [state_dim x state_dim].
25    P: Vec<Vec<f64>>,
26}
27
28impl KalmanFilter {
29    /// Creates a new Kalman filter with the given state and measurement dimensions.
30    ///
31    /// All matrices are initialized to sensible defaults:
32    /// - `F` = identity
33    /// - `H` = identity (top-left sub-block) or zero-padded
34    /// - `Q`, `R` = small diagonal noise
35    /// - `x` = zero vector
36    /// - `P` = identity (high initial uncertainty)
37    pub fn new(state_dim: usize, measure_dim: usize) -> Self {
38        let F = Self::eye(state_dim);
39        let H = Self::default_h(state_dim, measure_dim);
40        let Q = Self::scaled_eye(state_dim, 1e-2);
41        let R = Self::scaled_eye(measure_dim, 1e-1);
42        let x = vec![0.0; state_dim];
43        let P = Self::eye(state_dim);
44
45        Self {
46            state_dim,
47            measure_dim,
48            F,
49            H,
50            Q,
51            R,
52            x,
53            P,
54        }
55    }
56
57    /// Sets the state transition matrix `F`.
58    pub fn set_transition(&mut self, f: Vec<Vec<f64>>) -> Result<()> {
59        if f.len() != self.state_dim || f.iter().any(|row| row.len() != self.state_dim) {
60            return Err(IrisError::InvalidParameter(format!(
61                "F must be [{0} x {0}]",
62                self.state_dim
63            )));
64        }
65        self.F = f;
66        Ok(())
67    }
68
69    /// Sets the measurement matrix `H`.
70    pub fn set_measurement(&mut self, h: Vec<Vec<f64>>) -> Result<()> {
71        if h.len() != self.measure_dim || h.iter().any(|row| row.len() != self.state_dim) {
72            return Err(IrisError::InvalidParameter(format!(
73                "H must be [{0} x {1}]",
74                self.measure_dim, self.state_dim
75            )));
76        }
77        self.H = h;
78        Ok(())
79    }
80
81    /// Sets the process noise covariance `Q`.
82    pub fn set_process_noise(&mut self, q: Vec<Vec<f64>>) -> Result<()> {
83        if q.len() != self.state_dim || q.iter().any(|row| row.len() != self.state_dim) {
84            return Err(IrisError::InvalidParameter(format!(
85                "Q must be [{0} x {0}]",
86                self.state_dim
87            )));
88        }
89        self.Q = q;
90        Ok(())
91    }
92
93    /// Sets the measurement noise covariance `R`.
94    pub fn set_measurement_noise(&mut self, r: Vec<Vec<f64>>) -> Result<()> {
95        if r.len() != self.measure_dim || r.iter().any(|row| row.len() != self.measure_dim) {
96            return Err(IrisError::InvalidParameter(format!(
97                "R must be [{0} x {0}]",
98                self.measure_dim
99            )));
100        }
101        self.R = r;
102        Ok(())
103    }
104
105    /// Sets the initial state vector.
106    pub fn set_initial_state(&mut self, x: Vec<f64>) -> Result<()> {
107        if x.len() != self.state_dim {
108            return Err(IrisError::InvalidParameter(format!(
109                "State vector must have length {0}",
110                self.state_dim
111            )));
112        }
113        self.x = x;
114        Ok(())
115    }
116
117    /// Prediction step: x̂ = F * x̂,  P = F * P * F^T + Q.
118    pub fn predict(&mut self) {
119        // x = F * x
120        self.x = mat_vec_mul(&self.F, &self.x);
121
122        // P = F * P * F^T + Q
123        let fp = mat_mul(&self.F, &self.P);
124        let f_t = transpose(&self.F);
125        let fpft = mat_mul(&fp, &f_t);
126        self.P = mat_add(&fpft, &self.Q);
127    }
128
129    /// Update step incorporating a measurement z.
130    ///
131    /// Computes:
132    /// - y   = z - H * x̂               (innovation)
133    /// - S   = H * P * H^T + R         (innovation covariance)
134    /// - K   = P * H^T * S^{-1}        (Kalman gain)
135    /// - x̂   = x̂ + K * y              (updated state)
136    /// - P   = (I - K * H) * P         (updated covariance)
137    pub fn update(&mut self, z: &[f64]) -> Result<()> {
138        if z.len() != self.measure_dim {
139            return Err(IrisError::InvalidParameter(format!(
140                "Measurement vector must have length {0}",
141                self.measure_dim
142            )));
143        }
144
145        // y = z - H * x
146        let hx = mat_vec_mul(&self.H, &self.x);
147        let y: Vec<f64> = z.iter().zip(hx.iter()).map(|(zi, hxi)| zi - hxi).collect();
148
149        // S = H * P * H^T + R
150        let hp = mat_mul(&self.H, &self.P);
151        let ht = transpose(&self.H);
152        let hpht = mat_mul(&hp, &ht);
153        let s = mat_add(&hpht, &self.R);
154
155        // K = P * H^T * S^{-1}
156        let s_inv = inverse(&s)?;
157        let pht = mat_mul(&self.P, &ht);
158        let k = mat_mul(&pht, &s_inv);
159
160        // x = x + K * y
161        let ky = mat_vec_mul(&k, &y);
162        self.x = vec_add(&self.x, &ky);
163
164        // P = (I - K * H) * P
165        let kh = mat_mul(&k, &self.H);
166        let i_kh = mat_sub(&Self::eye(self.state_dim), &kh);
167        self.P = mat_mul(&i_kh, &self.P);
168
169        Ok(())
170    }
171
172    /// Returns the current state estimate.
173    pub fn state(&self) -> &[f64] {
174        &self.x
175    }
176
177    /// Returns the current error covariance matrix (flattened row-major).
178    pub fn covariance_flat(&self) -> Vec<f64> {
179        self.P.iter().flat_map(|row| row.iter().copied()).collect()
180    }
181
182    /// Returns a reference to the covariance matrix.
183    pub fn covariance(&self) -> &[Vec<f64>] {
184        &self.P
185    }
186
187    // ── Matrix helpers ──────────────────────────────────────────────
188
189    fn eye(n: usize) -> Vec<Vec<f64>> {
190        (0..n)
191            .map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
192            .collect()
193    }
194
195    fn scaled_eye(n: usize, scale: f64) -> Vec<Vec<f64>> {
196        (0..n)
197            .map(|i| (0..n).map(|j| if i == j { scale } else { 0.0 }).collect())
198            .collect()
199    }
200
201    fn default_h(state_dim: usize, measure_dim: usize) -> Vec<Vec<f64>> {
202        let mut h = vec![vec![0.0; state_dim]; measure_dim];
203        let m = measure_dim.min(state_dim);
204        for i in 0..m {
205            h[i][i] = 1.0;
206        }
207        h
208    }
209}
210
211// ── Linear algebra functions for small matrices (Vec<Vec<f64>>) ────
212
213fn mat_mul(a: &[Vec<f64>], b: &[Vec<f64>]) -> Vec<Vec<f64>> {
214    let rows = a.len();
215    let cols = b[0].len();
216    let k = b.len();
217    let mut result = vec![vec![0.0; cols]; rows];
218    for i in 0..rows {
219        for j in 0..cols {
220            let mut sum = 0.0;
221            for p in 0..k {
222                sum += a[i][p] * b[p][j];
223            }
224            result[i][j] = sum;
225        }
226    }
227    result
228}
229
230fn mat_vec_mul(m: &[Vec<f64>], v: &[f64]) -> Vec<f64> {
231    m.iter()
232        .map(|row| row.iter().zip(v.iter()).map(|(a, b)| a * b).sum())
233        .collect()
234}
235
236fn mat_add(a: &[Vec<f64>], b: &[Vec<f64>]) -> Vec<Vec<f64>> {
237    a.iter()
238        .zip(b.iter())
239        .map(|(ra, rb)| ra.iter().zip(rb.iter()).map(|(ai, bi)| ai + bi).collect())
240        .collect()
241}
242
243fn mat_sub(a: &[Vec<f64>], b: &[Vec<f64>]) -> Vec<Vec<f64>> {
244    a.iter()
245        .zip(b.iter())
246        .map(|(ra, rb)| ra.iter().zip(rb.iter()).map(|(ai, bi)| ai - bi).collect())
247        .collect()
248}
249
250fn transpose(m: &[Vec<f64>]) -> Vec<Vec<f64>> {
251    if m.is_empty() {
252        return vec![];
253    }
254    let rows = m.len();
255    let cols = m[0].len();
256    (0..cols)
257        .map(|j| (0..rows).map(|i| m[i][j]).collect())
258        .collect()
259}
260
261fn vec_add(a: &[f64], b: &[f64]) -> Vec<f64> {
262    a.iter().zip(b.iter()).map(|(ai, bi)| ai + bi).collect()
263}
264
265/// Invert a small square matrix via Gauss-Jordan elimination with partial pivoting.
266fn inverse(m: &[Vec<f64>]) -> Result<Vec<Vec<f64>>> {
267    let n = m.len();
268    if n == 0 || m.iter().any(|row| row.len() != n) {
269        return Err(IrisError::InvalidParameter(
270            "Matrix must be square and non-empty".into(),
271        ));
272    }
273
274    // Build augmented matrix [M | I]
275    let mut aug: Vec<Vec<f64>> = (0..n)
276        .map(|i| {
277            let mut row = m[i].clone();
278            row.extend((0..n).map(|j| if i == j { 1.0 } else { 0.0 }));
279            row
280        })
281        .collect();
282
283    for col in 0..n {
284        // Partial pivoting: find row with largest absolute value in this column
285        let mut max_val = aug[col][col].abs();
286        let mut max_row = col;
287        for row in (col + 1)..n {
288            if aug[row][col].abs() > max_val {
289                max_val = aug[row][col].abs();
290                max_row = row;
291            }
292        }
293        if max_val < 1e-12 {
294            return Err(IrisError::Tensor(
295                "Matrix is singular and cannot be inverted".into(),
296            ));
297        }
298        aug.swap(col, max_row);
299
300        let pivot = aug[col][col];
301        for j in 0..(2 * n) {
302            aug[col][j] /= pivot;
303        }
304
305        for row in 0..n {
306            if row == col {
307                continue;
308            }
309            let factor = aug[row][col];
310            for j in 0..(2 * n) {
311                aug[row][j] -= factor * aug[col][j];
312            }
313        }
314    }
315
316    // Extract inverse from right half
317    let inv: Vec<Vec<f64>> = (0..n).map(|i| aug[i][n..(2 * n)].to_vec()).collect();
318
319    Ok(inv)
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_kalman_identity_tracking() {
328        // 1D constant-position model: state = [position, velocity], measurement = [position]
329        let mut kf = KalmanFilter::new(2, 1);
330
331        // Set F for constant velocity: pos' = pos + vel, vel' = vel
332        kf.set_transition(vec![vec![1.0, 1.0], vec![0.0, 1.0]])
333            .unwrap();
334        kf.set_measurement(vec![vec![1.0, 0.0]]).unwrap();
335        kf.set_process_noise(vec![vec![1e-4, 0.0], vec![0.0, 1e-4]])
336            .unwrap();
337        kf.set_measurement_noise(vec![vec![0.1]]).unwrap();
338
339        // True position is 0, moving at velocity 1.0 per step
340        kf.set_initial_state(vec![0.0, 0.0]).unwrap();
341
342        let mut last_pos = 0.0f64;
343        for step in 1..=10 {
344            kf.predict();
345
346            // Noisy measurement of true position = step
347            let true_pos = step as f64;
348            let measurement = [true_pos + 0.05]; // small noise
349            kf.update(&measurement).unwrap();
350
351            let state = kf.state();
352            // State should track near true position
353            assert!(
354                (state[0] - true_pos).abs() < 2.0,
355                "Step {step}: expected ~{true_pos}, got {}",
356                state[0]
357            );
358            last_pos = state[0];
359        }
360        // After 10 steps, last position estimate should be close to 10
361        assert!((last_pos - 10.0).abs() < 2.0);
362    }
363
364    #[test]
365    fn test_kalman_predict_only() {
366        let mut kf = KalmanFilter::new(2, 1);
367        kf.set_transition(vec![vec![1.0, 1.0], vec![0.0, 1.0]])
368            .unwrap();
369        kf.set_initial_state(vec![0.0, 1.0]).unwrap();
370
371        kf.predict();
372        let s = kf.state();
373        // pos = 0 + 1 = 1, vel = 1
374        assert!((s[0] - 1.0).abs() < 1e-10);
375        assert!((s[1] - 1.0).abs() < 1e-10);
376
377        kf.predict();
378        let s = kf.state();
379        // pos = 1 + 1 = 2, vel = 1
380        assert!((s[0] - 2.0).abs() < 1e-10);
381        assert!((s[1] - 1.0).abs() < 1e-10);
382    }
383
384    #[test]
385    fn test_kalman_covariance_dims() {
386        let kf = KalmanFilter::new(3, 2);
387        let cov = kf.covariance_flat();
388        assert_eq!(cov.len(), 9); // 3x3
389    }
390
391    #[test]
392    fn test_inverse_2x2() {
393        let m = vec![vec![4.0, 7.0], vec![2.0, 6.0]];
394        let inv = inverse(&m).unwrap();
395        // Check M * M^{-1} ≈ I
396        let product = mat_mul(&m, &inv);
397        for i in 0..2 {
398            for j in 0..2 {
399                let expected = if i == j { 1.0 } else { 0.0 };
400                assert!(
401                    (product[i][j] - expected).abs() < 1e-10,
402                    "({i},{j}): got {}",
403                    product[i][j]
404                );
405            }
406        }
407    }
408
409    #[test]
410    fn test_inverse_singular() {
411        let m = vec![vec![1.0, 2.0], vec![2.0, 4.0]];
412        assert!(inverse(&m).is_err());
413    }
414
415    #[test]
416    fn test_measurement_mismatch() {
417        let mut kf = KalmanFilter::new(2, 1);
418        assert!(kf.update(&[1.0, 2.0]).is_err());
419    }
420}