Skip to content

Commit 64a2e00

Browse files
committed
Refactor UKF Kalman gain calculation and add robust SPD solver
1 parent 3ec6b49 commit 64a2e00

2 files changed

Lines changed: 82 additions & 19 deletions

File tree

core/src/filter.rs

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
//! the UKF and particle filter. This model is used to update the state based on position
1616
//! measurements in the local level frame (i.e. a GPS fix).
1717
use crate::earth::METERS_TO_DEGREES;
18-
use crate::linalg::matrix_square_root;
18+
use crate::linalg::{matrix_square_root, symmetrize, robust_spd_solve};
1919
use crate::{IMUData, StrapdownState, forward, wrap_to_2pi};
2020

2121
use std::fmt::Debug;
@@ -505,30 +505,34 @@ impl UKF {
505505
let state_diff = sigma_points.column(i) - &self.mean_state;
506506
cross_covariance += self.weights_cov[i] * state_diff * measurement_diff.transpose();
507507
}
508-
// Calculate the Kalman gain
509-
let s_inv = match s.clone().try_inverse() {
510-
Some(inv) => inv,
511-
None => panic!("Innovation matrix is singular"),
512-
};
513-
let k = &cross_covariance * &s_inv;
514-
// check that the kalman gain and measurement diff are compatible to multiply
515-
if k.ncols() != measurement.get_dimension() {
516-
panic!("Kalman gain and measurement differential are not compatible");
517-
}
508+
// // Calculate the Kalman gain
509+
// let s_inv = match s.clone().try_inverse() {
510+
// Some(inv) => inv,
511+
// None => panic!("Innovation matrix is singular"),
512+
// };
513+
// let k = &cross_covariance * &s_inv;
514+
// // check that the kalman gain and measurement diff are compatible to multiply
515+
// if k.ncols() != measurement.get_dimension() {
516+
// panic!("Kalman gain and measurement differential are not compatible");
517+
// }
518+
// K = P_xz * S^{-1} without forming S^{-1}
519+
let k = self.robust_kalman_gain(&cross_covariance, &s);
520+
// Update the mean and covariance
518521
self.mean_state += &k * (measurement.get_vector() - &z_hat);
519522
// wrap attitude angles to 2pi
520523
// TODO: #30 Refactor attitude angles to use a more robust representation
521524
self.mean_state[6] = wrap_to_2pi(self.mean_state[6]);
522525
self.mean_state[7] = wrap_to_2pi(self.mean_state[7]);
523526
self.mean_state[8] = wrap_to_2pi(self.mean_state[8]);
524-
// Switch to Joseph Form update here
525-
// P = (I - K * H) P (I - K H)^T + K R K^T
526-
// UKF form:
527-
// P -= K * S * K^T + K R K^T
528-
self.covariance -= &k * s * &k.transpose();
527+
self.covariance -= &k * &s * &k.transpose();
529528
// Re-symmetrize to fight round-off
530529
self.covariance = 0.5 * (&self.covariance + self.covariance.transpose());
531-
// self.covariance -= &k * s * &k.transpose();
530+
}
531+
fn robust_kalman_gain(&mut self, cross_covariance: &DMatrix<f64>, s: &DMatrix<f64>) -> DMatrix<f64> {
532+
533+
// Solve S Kᵀ = P_xzᵀ => K = (S^{-1} P_xz)ᵀ
534+
let kt = robust_spd_solve(&symmetrize(s), &cross_covariance.transpose());
535+
kt.transpose()
532536
}
533537
}
534538
#[derive(Clone, Debug, Default)]

core/src/linalg.rs

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ pub fn matrix_square_root(matrix: &DMatrix<f64>) -> DMatrix<f64> {
6868
///
6969
/// # Returns
7070
/// A symmetrized version of the input matrix.
71-
fn symmetrize(m: &DMatrix<f64>) -> DMatrix<f64> {
71+
#[inline]
72+
pub fn symmetrize(m: &DMatrix<f64>) -> DMatrix<f64> {
7273
0.5 * (m + m.transpose())
7374
}
7475
/// Plain Cholesky square root
@@ -77,7 +78,7 @@ fn symmetrize(m: &DMatrix<f64>) -> DMatrix<f64> {
7778
/// This is a quick way to initially attempt to calculate a matrix square root.
7879
///
7980
/// # Arguments
80-
/// * `p` - the matrix to factor
81+
/// * ``p` - the matrix to factor
8182
///
8283
/// # Returns
8384
/// A lower triangular matrix L such that P ≈ L Lᵀ, or None if it fails.
@@ -131,6 +132,64 @@ fn evd_symmetric_sqrt_with_floor(p: &DMatrix<f64>, floor: f64) -> DMatrix<f64> {
131132

132133
}
133134

135+
#[derive(Debug, Clone, Copy)]
136+
pub struct SolveOptions {
137+
pub initial_jitter: f64, // e.g., 1e-12
138+
pub max_jitter: f64, // e.g., 1e-6
139+
pub max_tries: usize, // e.g., 6
140+
}
141+
142+
impl Default for SolveOptions {
143+
fn default() -> Self {
144+
Self { initial_jitter: 1e-12, max_jitter: 1e-6, max_tries: 6 }
145+
}
146+
}
147+
/// Solve A X = B for SPD-ish A via Cholesky, with jitter retries.
148+
/// Returns None if all attempts fail.
149+
pub fn chol_solve_spd(
150+
a: &DMatrix<f64>,
151+
b: &DMatrix<f64>,
152+
opt: SolveOptions,
153+
) -> Option<DMatrix<f64>> {
154+
assert!(a.is_square(), "chol_solve_spd: A must be square");
155+
assert_eq!(a.nrows(), b.nrows(), "chol_solve_spd: A and B incompatible");
156+
157+
// Symmetrize first (SPD drift is common).
158+
let a_sym = symmetrize(a);
159+
160+
// Try plain Cholesky
161+
if let Some(ch) = Cholesky::new(a_sym.clone()) {
162+
return Some(ch.solve(b));
163+
}
164+
165+
// Jitter ramp
166+
let n = a_sym.nrows();
167+
let mut jitter = opt.initial_jitter;
168+
for _ in 0..opt.max_tries {
169+
let mut a_j = a_sym.clone();
170+
for i in 0..n { a_j[(i, i)] += jitter; }
171+
if let Some(ch) = Cholesky::new(a_j) {
172+
return Some(ch.solve(b));
173+
}
174+
jitter *= 10.0;
175+
if jitter > opt.max_jitter { break; }
176+
}
177+
None
178+
}
179+
180+
/// Robust SPD solve with sane defaults:
181+
/// - Cholesky + jitter (preferred)
182+
/// - Last resort: explicit inverse
183+
pub fn robust_spd_solve(a: &DMatrix<f64>, b: &DMatrix<f64>) -> DMatrix<f64> {
184+
if let Some(x) = chol_solve_spd(a, b, SolveOptions::default()) {
185+
x
186+
} else if let Some(inv) = symmetrize(a).try_inverse() {
187+
&inv * b
188+
} else {
189+
panic!("robust_spd_solve: A is not invertible (even after jitter).");
190+
}
191+
}
192+
134193
/* =============================== Tests ==================================== */
135194

136195
#[cfg(test)]

0 commit comments

Comments
 (0)