use nalgebra::{Cholesky, Const, Matrix3, SMatrix, SVector, Vector3};
use crate::config::Gate;
use crate::health::Innovation;
use crate::math::{enforce_symmetry, exp_quat, skew};
use crate::propagate::Transition;
use crate::state::{Covariance, CovarianceMatrix, ErrorState, Offset, STATES, State};
use crate::units::{Acceleration, AngularRate, Attitude, Position, Seconds, Velocity};
pub(crate) struct Observation<const M: usize> {
pub(crate) y: SVector<f32, M>,
pub(crate) h: SMatrix<f32, M, STATES>,
pub(crate) h_b: SVector<f32, M>,
pub(crate) r_m: SVector<f32, M>,
pub(crate) r_gain: SVector<f32, M>,
}
impl<const M: usize> Observation<M> {
pub(crate) fn correlated(self, inflation: f32) -> Self {
Self {
r_gain: self.r_m * inflation,
..self
}
}
pub(crate) fn delayed(self, age: Seconds, a: &Transition) -> Self {
let tau = age.as_secs();
let ha = self.h * a;
let haa = ha * a;
Self {
h: self.h - ha * tau + haa * (0.5 * tau * tau),
..self
}
}
}
#[allow(
clippy::large_enum_variant,
reason = "no allocator to box into, and it is returned once per measurement"
)]
pub(crate) enum Update {
Accepted {
state: State,
covariance: Covariance,
offset: Offset,
offset_correction: f32,
ratio: f32,
innovation: Innovation,
},
Rejected { ratio: f32, innovation: Innovation },
Invalid,
}
pub(crate) fn update<const M: usize>(
state: &State,
covariance: &Covariance,
offset: &Offset,
observation: &Observation<M>,
gate: Gate<M>,
) -> Update {
let p = covariance.as_matrix();
let Offset {
cross: c,
variance: v,
} = *offset;
let Observation {
y,
h,
h_b,
r_m,
r_gain,
} = observation;
let correlated = r_gain != r_m;
let (r_m, r_gain) = (
SMatrix::<f32, M, M>::from_diagonal(r_m),
SMatrix::<f32, M, M>::from_diagonal(r_gain),
);
let hp_x = h * p + h_b * c.transpose();
let hp_b = h * c + h_b * v;
let hph = hp_x * h.transpose() + hp_b * h_b.transpose();
let s = hph + r_m; let Some(gate_factor) = Cholesky::new(s) else {
return Update::Invalid;
};
let innovation = Innovation::new(y, &s);
let ratio = test_ratio(nis(y, &gate_factor), gate);
if ratio > 1.0 {
return Update::Rejected { ratio, innovation };
}
let s_factor = if correlated {
let Some(factor) = Cholesky::new(hph + r_gain) else {
return Update::Invalid;
};
factor
} else {
gate_factor
};
let k_x = s_factor.solve(&hp_x).transpose(); let k_b = s_factor.solve(&hp_b).transpose();
let dx = k_x * y; let db = k_b.dot(&y.transpose());
let a_xx = CovarianceMatrix::identity() - k_x * h;
let a_xb = -(k_x * h_b);
let a_bx = -(k_b * h);
let a_bb = 1.0 - k_b.dot(&h_b.transpose());
let mut ap_xx = a_xx * p;
ap_xx.ger(1.0, &a_xb, &c, 1.0);
let ap_xb = a_xx * c + a_xb * v;
let ap_bx = a_bx * p + a_bb * c.transpose();
let ap_bb = a_bx.dot(&c.transpose()) + a_bb * v;
let kr_x = k_x * r_gain;
let kr_b = k_b * r_gain;
let cross = ap_xx * a_bx.transpose() + ap_xb * a_bb + kr_x * k_b.transpose();
let variance = ap_bx.dot(&a_bx) + ap_bb * a_bb + kr_b.dot(&k_b);
let mut p = ap_xx * a_xx.transpose() + kr_x * k_x.transpose();
p.ger(1.0, &ap_xb, &a_xb, 1.0);
if !dx.iter().all(|value| value.is_finite()) || !db.is_finite() {
return Update::Invalid;
}
let state = inject(state, &dx);
let g_theta = reset_jacobian(attitude_error(&dx));
let offset = reparameterize_offset(&Offset { cross, variance }, g_theta);
let covariance = reparameterize(p, g_theta);
if !state.is_finite() || !covariance.is_finite() || !offset.is_finite() {
return Update::Invalid;
}
Update::Accepted {
state,
covariance,
offset,
offset_correction: db,
ratio,
innovation,
}
}
fn nis<const M: usize>(y: &SVector<f32, M>, s_factor: &Cholesky<f32, Const<M>>) -> f32 {
let epsilon = y.dot(&s_factor.solve(y));
if epsilon.is_nan() {
f32::INFINITY
} else {
epsilon
}
}
fn test_ratio<const M: usize>(epsilon: f32, gate: Gate<M>) -> f32 {
epsilon / gate.threshold()
}
fn inject(state: &State, dx: &SVector<f32, STATES>) -> State {
let block = |at: ErrorState| -> Vector3<f32> { dx.fixed_rows::<3>(at.index()).clone_owned() };
let mut attitude = state.attitude.quaternion() * exp_quat(block(ErrorState::AttitudeX));
attitude.renormalize();
State {
attitude: Attitude::from_quaternion(attitude),
position: Position::from_vector(state.position.vector() + block(ErrorState::PositionNorth)),
velocity: Velocity::from_vector(state.velocity.vector() + block(ErrorState::VelocityNorth)),
accel_bias: Acceleration::from_vector(
state.accel_bias.vector() + block(ErrorState::AccelBiasX),
),
gyro_bias: AngularRate::from_vector(
state.gyro_bias.vector() + block(ErrorState::GyroBiasX),
),
..*state
}
}
fn attitude_error(dx: &SVector<f32, STATES>) -> Vector3<f32> {
dx.fixed_rows::<3>(ErrorState::AttitudeX.index())
.clone_owned()
}
fn reset_jacobian(delta_theta: Vector3<f32>) -> Matrix3<f32> {
Matrix3::identity() - skew(0.5 * delta_theta)
}
pub(crate) fn reparameterize(mut p: CovarianceMatrix, g_theta: Matrix3<f32>) -> Covariance {
let theta = ErrorState::AttitudeX.index();
let rows = g_theta * p.fixed_rows::<3>(theta);
p.fixed_rows_mut::<3>(theta).copy_from(&rows);
let columns = p.fixed_columns::<3>(theta) * g_theta.transpose();
p.fixed_columns_mut::<3>(theta).copy_from(&columns);
enforce_symmetry(&mut p);
Covariance::from_matrix(p)
}
pub(crate) fn reparameterize_offset(offset: &Offset, g_theta: Matrix3<f32>) -> Offset {
let theta = ErrorState::AttitudeX.index();
let mut offset = *offset;
let cross = g_theta * offset.cross.fixed_rows::<3>(theta);
offset.cross.fixed_rows_mut::<3>(theta).copy_from(&cross);
offset
}
#[cfg(test)]
mod tests {
use super::*;
use nalgebra::UnitQuaternion;
const TOLERANCE: f32 = 1.0e-6;
fn diagonal(variance: f32) -> Covariance {
Covariance::from_matrix(CovarianceMatrix::from_diagonal_element(variance))
}
fn observes_position() -> SMatrix<f32, 3, STATES> {
let mut h = SMatrix::<f32, 3, STATES>::zeros();
h.fixed_view_mut::<3, 3>(0, ErrorState::PositionNorth.index())
.copy_from(&Matrix3::identity());
h
}
fn observes_down() -> SMatrix<f32, 1, STATES> {
let mut h = SMatrix::<f32, 1, STATES>::zeros();
h[(0, ErrorState::PositionDown.index())] = 1.0;
h
}
fn position(y: [f32; 3], r_m: f32) -> Observation<3> {
Observation {
y: SVector::from(y),
h: observes_position(),
h_b: SVector::zeros(),
r_m: SVector::from([r_m; 3]),
r_gain: SVector::from([r_m; 3]),
}
}
fn gate<const M: usize>(threshold: f32) -> Gate<M> {
Gate::new(threshold).expect("a positive, finite threshold")
}
fn is_symmetric(p: &CovarianceMatrix) -> bool {
(p - p.transpose()).iter().all(|entry| *entry == 0.0)
}
fn accepted(outcome: Update) -> (State, Covariance, f32) {
match outcome {
Update::Accepted {
state,
covariance,
ratio,
..
} => (state, covariance, ratio),
Update::Rejected { ratio, .. } => panic!("rejected at r = {ratio}"),
Update::Invalid => panic!("invalid"),
}
}
#[test]
fn a_consistent_measurement_shrinks_what_it_observes_and_leaves_p_symmetric() {
let prior = diagonal(4.0);
let outcome = update(
&State::default(),
&prior,
&Offset::default(),
&position([0.5, -0.5, 0.2], 1.0),
gate(7.8),
);
let (state, covariance, _) = accepted(outcome);
for axis in [
ErrorState::PositionNorth,
ErrorState::PositionEast,
ErrorState::PositionDown,
] {
assert!(
(covariance.variance(axis) - 0.8).abs() < TOLERANCE,
"{axis:?}"
);
}
assert_eq!(covariance.variance(ErrorState::VelocityNorth), 4.0);
assert!(is_symmetric(covariance.as_matrix()));
assert!((state.position.vector() - Vector3::new(0.4, -0.4, 0.16)).norm() < TOLERANCE);
}
#[test]
fn the_gate_reads_the_fixs_own_variance_and_the_gain_the_larger_one() {
let prior = diagonal(1.0);
let own = position([1.5, 0.0, 0.0], 1.0);
let correlated = Observation {
r_gain: SVector::from([9.0; 3]),
..position([1.5, 0.0, 0.0], 1.0)
};
let (_, _, white_ratio) = accepted(update(
&State::default(),
&prior,
&Offset::default(),
&own,
gate(7.8),
));
let (state, covariance, ratio) = accepted(update(
&State::default(),
&prior,
&Offset::default(),
&correlated,
gate(7.8),
));
assert_eq!(ratio, white_ratio);
assert!((state.position.vector()[0] - 0.15).abs() < TOLERANCE);
assert!((covariance.variance(ErrorState::PositionNorth) - 0.9).abs() < TOLERANCE);
}
#[test]
fn a_correlation_carries_the_correction_to_what_was_not_observed() {
let mut p = *diagonal(1.0).as_matrix();
let (pn, vn) = (
ErrorState::PositionNorth.index(),
ErrorState::VelocityNorth.index(),
);
p[(pn, vn)] = 0.9;
p[(vn, pn)] = 0.9;
let (state, covariance, _) = accepted(update(
&State::default(),
&Covariance::from_matrix(p),
&Offset::default(),
&position([1.0, 0.0, 0.0], 1.0),
gate(7.8),
));
assert!((state.velocity.x() - 0.45).abs() < TOLERANCE); assert!(covariance.variance(ErrorState::VelocityNorth) < 1.0);
}
#[test]
fn zero_innovation_moves_nothing_and_still_shrinks_p() {
let start = State::default();
let prior = diagonal(4.0);
let (state, covariance, ratio) = accepted(update(
&start,
&prior,
&Offset::default(),
&position([0.0; 3], 1.0),
gate(7.8),
));
assert_eq!(state, start);
assert_eq!(ratio, 0.0);
assert!(covariance.variance(ErrorState::PositionNorth) < 4.0);
}
#[test]
fn joseph_form_survives_an_ill_conditioned_p_that_the_short_form_does_not() {
let mut p = *diagonal(1.0e6).as_matrix();
let (pn, vn) = (
ErrorState::PositionNorth.index(),
ErrorState::VelocityNorth.index(),
);
p[(pn, vn)] = 999_999.0;
p[(vn, pn)] = 999_999.0;
let prior = Covariance::from_matrix(p);
let observation = Observation {
y: SVector::from([0.0]),
h: {
let mut h = SMatrix::<f32, 1, STATES>::zeros();
h[(0, pn)] = 1.0;
h
},
h_b: SVector::zeros(),
r_m: SVector::from([1.0e-6]),
r_gain: SVector::from([1.0e-6]),
};
let (_, joseph, _) = accepted(update(
&State::default(),
&prior,
&Offset::default(),
&observation,
gate::<1>(3.84),
));
let joseph = joseph.as_matrix();
assert!(is_symmetric(joseph));
assert!(
Cholesky::new(*joseph).is_some(),
"Joseph form stays positive-definite"
);
let s = observation.h * p * observation.h.transpose()
+ SMatrix::<f32, 1, 1>::from_diagonal(&observation.r_m);
let k = p * observation.h.transpose() * s.try_inverse().expect("S is 1 x 1 and > 0");
let short = (CovarianceMatrix::identity() - k * observation.h) * p;
assert!(
Cholesky::new(short).is_none(),
"the short form stays positive-definite here, so this fixture shows nothing"
);
}
#[test]
fn the_gate_boundary_is_a_ratio_of_exactly_one() {
let scalar = Observation {
y: SVector::from([2.0]),
h: observes_down(),
h_b: SVector::zeros(),
r_m: SVector::from([0.5]),
r_gain: SVector::from([0.5]),
};
let prior = diagonal(0.5);
let (_, _, ratio) = accepted(update(
&State::default(),
&prior,
&Offset::default(),
&scalar,
gate::<1>(4.0),
));
assert_eq!(ratio, 1.0);
let vector = position([1.0, 2.0, 2.0], 0.5);
let (_, _, ratio) = accepted(update(
&State::default(),
&prior,
&Offset::default(),
&vector,
gate::<3>(9.0),
));
assert_eq!(ratio, 1.0);
assert!(matches!(
update(&State::default(), &prior, &Offset::default(), &scalar, gate::<1>(3.99)),
Update::Rejected { ratio, .. } if ratio > 1.0
));
assert!(matches!(
update(&State::default(), &prior, &Offset::default(), &vector, gate::<3>(8.99)),
Update::Rejected { ratio, .. } if ratio > 1.0
));
}
#[test]
fn an_innovation_whose_test_overflows_is_rejected_at_an_infinite_ratio() {
let mut p = *diagonal(1.0).as_matrix();
p[(0, 1)] = 0.9;
p[(1, 0)] = 0.9;
let prior = Covariance::from_matrix(p);
let outcome = update(
&State::default(),
&prior,
&Offset::default(),
&position([3.0e19, 1.0e19, 0.0], 0.1),
gate(7.8),
);
assert!(
matches!(outcome, Update::Rejected { ratio, .. } if ratio == f32::INFINITY),
"accepted or rejected at a ratio that is not infinite"
);
}
#[test]
fn the_test_ratio_is_joint_over_the_correlated_innovation() {
let mut p = *diagonal(1.0).as_matrix();
p[(0, 1)] = 0.9;
p[(1, 0)] = 0.9;
let prior = Covariance::from_matrix(p);
let ratio = |y| match update(
&State::default(),
&prior,
&Offset::default(),
&position(y, 0.1),
gate(7.8),
) {
Update::Accepted { ratio, .. } | Update::Rejected { ratio, .. } => ratio,
Update::Invalid => panic!("invalid"),
};
assert!(ratio([1.0, -1.0, 0.0]) > 5.0 * ratio([1.0, 1.0, 0.0]));
}
#[test]
fn the_published_innovation_is_y_and_the_diagonal_of_s() {
let prior = diagonal(4.0);
let Update::Accepted { innovation, .. } = update(
&State::default(),
&prior,
&Offset::default(),
&position([0.5, -0.5, 0.2], 1.0),
gate(7.8),
) else {
panic!("expected an acceptance");
};
assert_eq!(innovation.values(), &[0.5, -0.5, 0.2]);
assert_eq!(innovation.variances(), &[5.0, 5.0, 5.0]);
}
#[test]
fn a_rejection_carries_its_ratio_and_innovation() {
let prior = diagonal(1.0);
let Update::Rejected { ratio, innovation } = update(
&State::default(),
&prior,
&Offset::default(),
&position([10.0, 0.0, 0.0], 1.0),
gate(7.8),
) else {
panic!("expected a rejection");
};
assert!((ratio - 50.0 / 7.8).abs() < 1.0e-4); assert_eq!(innovation.values(), &[10.0, 0.0, 0.0]);
}
#[test]
fn an_indefinite_s_is_invalid_rather_than_gated() {
let mut p = *diagonal(1.0).as_matrix();
p[(2, 2)] = -2.0;
let outcome = update(
&State::default(),
&Covariance::from_matrix(p),
&Offset::default(),
&Observation {
y: SVector::from([0.1]),
h: observes_down(),
h_b: SVector::zeros(),
r_m: SVector::from([1.0]),
r_gain: SVector::from([1.0]),
},
gate::<1>(3.84),
);
assert!(matches!(outcome, Update::Invalid));
}
#[test]
fn a_correction_that_overflows_is_invalid_rather_than_committed() {
let start = State {
position: Position::ned(f32::MAX, 0.0, 0.0),
..State::default()
};
let outcome = update(
&start,
&diagonal(1.0e36),
&Offset::default(),
&position([1.0e32, 0.0, 0.0], 1.0e36),
Gate::<3>::new(f32::MAX).expect("finite"),
);
assert!(matches!(outcome, Update::Invalid));
}
#[test]
fn injection_composes_attitude_on_the_right_and_adds_the_rest() {
let start = State {
attitude: Attitude::from_quaternion(UnitQuaternion::from_euler_angles(0.0, 0.0, 0.5)),
..State::default()
};
let mut dx = SVector::<f32, STATES>::zeros();
dx[ErrorState::AttitudeX.index()] = 0.01; dx[ErrorState::GyroBiasZ.index()] = 0.002;
dx[ErrorState::AccelBiasY.index()] = -0.03;
let injected = inject(&start, &dx);
let expected = start.attitude.quaternion() * exp_quat(Vector3::new(0.01, 0.0, 0.0));
assert!(injected.attitude.quaternion().angle_to(&expected) < TOLERANCE);
assert_eq!(injected.gyro_bias.z(), 0.002);
assert_eq!(injected.accel_bias.y(), -0.03);
}
#[test]
fn the_reset_jacobian_is_the_identity_without_an_attitude_correction() {
let p = *diagonal(2.0).as_matrix();
assert_eq!(
reparameterize(p, reset_jacobian(Vector3::zeros())).as_matrix(),
&p
);
}
#[test]
fn the_reset_jacobian_rotates_only_the_attitude_block() {
let mut p = *diagonal(2.0).as_matrix();
let (theta, pn) = (
ErrorState::AttitudeX.index(),
ErrorState::PositionNorth.index(),
);
p[(theta, pn)] = 0.5;
p[(pn, theta)] = 0.5;
let reset = reparameterize(p, reset_jacobian(Vector3::new(0.0, 0.0, 0.2)));
let reset = reset.as_matrix();
assert_eq!(reset[(theta, pn)], 0.5);
assert!((reset[(theta + 1, pn)] - (-0.1 * 0.5)).abs() < TOLERANCE);
assert_eq!(reset[(pn, pn)], 2.0);
assert!(is_symmetric(reset));
}
fn correlated_with_the_offset() -> (Covariance, Offset) {
let mut p = *diagonal(4.0).as_matrix();
let (pd, vd) = (
ErrorState::PositionDown.index(),
ErrorState::VelocityDown.index(),
);
p[(pd, vd)] = 1.5;
p[(vd, pd)] = 1.5;
let mut cross = SVector::<f32, STATES>::zeros();
cross[pd] = -2.0;
cross[vd] = 0.5;
(
Covariance::from_matrix(p),
Offset {
cross,
variance: 3.0,
},
)
}
fn augmented_joseph<const M: usize>(
covariance: &Covariance,
offset: &Offset,
observation: &Observation<M>,
) -> (SMatrix<f32, 16, 16>, SVector<f32, 16>) {
let mut p = SMatrix::<f32, 16, 16>::zeros();
p.fixed_view_mut::<STATES, STATES>(0, 0)
.copy_from(covariance.as_matrix());
p.fixed_view_mut::<STATES, 1>(0, STATES)
.copy_from(&offset.cross);
p.fixed_view_mut::<1, STATES>(STATES, 0)
.copy_from(&offset.cross.transpose());
p[(STATES, STATES)] = offset.variance;
let mut h = SMatrix::<f32, M, 16>::zeros();
h.fixed_columns_mut::<STATES>(0).copy_from(&observation.h);
h.set_column(STATES, &observation.h_b);
let r = SMatrix::<f32, M, M>::from_diagonal(&observation.r_m);
let s = h * p * h.transpose() + r;
let k = p * h.transpose() * s.try_inverse().expect("S is positive-definite");
let i_kh = SMatrix::<f32, 16, 16>::identity() - k * h;
(
i_kh * p * i_kh.transpose() + k * r * k.transpose(),
k * observation.y,
)
}
fn assert_matches_augmented<const M: usize>(observation: &Observation<M>, gate: Gate<M>) {
let (prior, offset) = correlated_with_the_offset();
let (want, dx) = augmented_joseph(&prior, &offset, observation);
let Update::Accepted {
covariance,
offset,
offset_correction,
..
} = update(&State::default(), &prior, &offset, observation, gate)
else {
panic!("a consistent measurement");
};
let close = |a: f32, b: f32| (a - b).abs() < 1e-4 * (1.0 + b.abs());
for i in 0..STATES {
for j in 0..STATES {
assert!(
close(covariance.as_matrix()[(i, j)], want[(i, j)]),
"P[{i},{j}]"
);
}
assert!(close(offset.cross[i], want[(i, STATES)]), "P_xb[{i}]");
}
assert!(close(offset.variance, want[(STATES, STATES)]), "P_bb");
assert!(close(offset_correction, dx[STATES]), "δb");
}
#[test]
fn the_block_update_is_the_sixteen_state_joseph_update() {
let mut h = SMatrix::<f32, 1, STATES>::zeros();
h[(0, ErrorState::PositionDown.index())] = 1.0;
let baro = Observation {
y: SVector::from([0.7]),
h,
h_b: SVector::from([1.0]),
r_m: SVector::from([0.5]),
r_gain: SVector::from([0.5]),
};
assert_matches_augmented(&baro, gate::<1>(100.0));
assert_matches_augmented(&position([0.3, -0.2, 0.9], 1.0), gate::<3>(100.0));
}
#[test]
fn an_offset_reset_rotates_its_attitude_entries_and_nothing_else() {
let (theta, pd) = (
ErrorState::AttitudeX.index(),
ErrorState::PositionDown.index(),
);
let mut cross = SVector::<f32, STATES>::zeros();
cross[theta] = 0.5;
cross[pd] = -2.0;
let offset = Offset {
cross,
variance: 3.0,
};
let reset = reparameterize_offset(&offset, reset_jacobian(Vector3::new(0.0, 0.0, 0.2)));
assert_eq!(reset.cross[theta], 0.5);
assert!((reset.cross[theta + 1] - (-0.1 * 0.5)).abs() < TOLERANCE);
assert_eq!(reset.cross[pd], -2.0);
assert_eq!(reset.variance, 3.0);
}
}