use nalgebra::{Matrix3, SMatrix, SVector, Vector3};
use crate::config::{Coast, Config, ImuNoise};
use crate::frames::Body;
use crate::math::{enforce_symmetry, exp_quat, skew};
use crate::state::{Covariance, ErrorState, Offset, STATES, State};
use crate::units::{
Acceleration, AngularRate, Attitude, DeltaAngle, DeltaVelocity, Position, Seconds, Timestamp,
Velocity,
};
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ImuSample {
pub time: Timestamp,
pub delta_angle: DeltaAngle<Body>,
pub angle_interval: Seconds,
pub delta_velocity: DeltaVelocity<Body>,
pub velocity_interval: Seconds,
}
impl ImuSample {
pub fn from_rates(
time: Timestamp,
gyro: AngularRate<Body>,
accel: Acceleration<Body>,
interval: Seconds,
) -> Self {
let dt = interval.as_secs();
Self {
time,
delta_angle: DeltaAngle::from_vector(gyro.vector() * dt),
angle_interval: interval,
delta_velocity: DeltaVelocity::from_vector(accel.vector() * dt),
velocity_interval: interval,
}
}
pub fn accumulate(self, later: ImuSample) -> ImuSample {
let sum = |a: Seconds, b: Seconds| Seconds::from_secs(a.as_secs() + b.as_secs());
ImuSample {
time: later.time,
delta_angle: DeltaAngle::from_vector(
self.delta_angle.vector() + later.delta_angle.vector(),
),
angle_interval: sum(self.angle_interval, later.angle_interval),
delta_velocity: DeltaVelocity::from_vector(
self.delta_velocity.vector() + later.delta_velocity.vector(),
),
velocity_interval: sum(self.velocity_interval, later.velocity_interval),
}
}
pub(crate) fn angular_rate(self) -> AngularRate<Body> {
AngularRate::from_vector(self.delta_angle.vector() / self.angle_interval.as_secs())
}
pub(crate) fn specific_force(self) -> Acceleration<Body> {
Acceleration::from_vector(self.delta_velocity.vector() / self.velocity_interval.as_secs())
}
pub(crate) fn is_finite(self) -> bool {
self.delta_angle.is_finite()
&& self.delta_velocity.is_finite()
&& self.angle_interval.as_secs().is_finite()
&& self.velocity_interval.as_secs().is_finite()
}
pub(crate) fn unusable_interval(self) -> Option<Seconds> {
const LEAST: f32 = 1.0e-6;
[self.angle_interval, self.velocity_interval]
.into_iter()
.find(|interval| interval.as_secs().is_nan() || interval.as_secs() < LEAST)
}
pub(crate) fn longest_interval(self) -> Seconds {
if self.angle_interval > self.velocity_interval {
self.angle_interval
} else {
self.velocity_interval
}
}
}
#[cfg(test)]
impl ImuSample {
pub(crate) fn reading(gyro: AngularRate<Body>, accel: Acceleration<Body>) -> Self {
Self::from_rates(Timestamp::ZERO, gyro, accel, Seconds::from_secs(1.0))
}
pub(crate) fn with_gyro(self, gyro: AngularRate<Body>) -> Self {
Self {
delta_angle: DeltaAngle::from_vector(gyro.vector() * self.angle_interval.as_secs()),
..self
}
}
pub(crate) fn with_accel(self, accel: Acceleration<Body>) -> Self {
Self {
delta_velocity: DeltaVelocity::from_vector(
accel.vector() * self.velocity_interval.as_secs(),
),
..self
}
}
pub(crate) fn timed(self, time: Timestamp, interval: Seconds) -> Self {
let rate = |increment: Vector3<f32>, over: Seconds| {
if over.as_secs() > 0.0 {
increment / over.as_secs()
} else {
Vector3::zeros()
}
};
Self::from_rates(
time,
AngularRate::from_vector(rate(self.delta_angle.vector(), self.angle_interval)),
Acceleration::from_vector(rate(self.delta_velocity.vector(), self.velocity_interval)),
interval,
)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Corrected {
pub(crate) delta_angle: DeltaAngle<Body>,
pub(crate) angle_interval: Seconds,
pub(crate) delta_velocity: DeltaVelocity<Body>,
pub(crate) velocity_interval: Seconds,
}
impl Corrected {
pub(crate) fn omega(self) -> AngularRate<Body> {
AngularRate::from_vector(self.delta_angle.vector() / self.angle_interval.as_secs())
}
}
pub(crate) fn corrected_imu(imu: ImuSample, state: &State) -> Corrected {
Corrected {
delta_angle: DeltaAngle::from_vector(
imu.delta_angle.vector() - state.gyro_bias.vector() * imu.angle_interval.as_secs(),
),
angle_interval: imu.angle_interval,
delta_velocity: DeltaVelocity::from_vector(
imu.delta_velocity.vector()
- state.accel_bias.vector() * imu.velocity_interval.as_secs(),
),
velocity_interval: imu.velocity_interval,
}
}
pub(crate) fn propagate_nominal(state: State, imu: Corrected, gravity: f32) -> State {
let dt = imu.velocity_interval.as_secs();
let rotation = state.attitude.quaternion();
let delta_v = rotation * imu.delta_velocity.vector() + gravity_vector(gravity) * dt;
let velocity = state.velocity.vector();
let position = state.position.vector() + velocity * dt + 0.5 * delta_v * dt; let velocity = velocity + delta_v;
let mut attitude = rotation * exp_quat(imu.delta_angle.vector());
attitude.renormalize();
State {
attitude: Attitude::from_quaternion(attitude),
position: Position::from_vector(position),
velocity: Velocity::from_vector(velocity),
..state
}
}
pub(crate) type Transition = SMatrix<f32, STATES, STATES>;
pub(crate) struct Propagated {
pub(crate) state: State,
pub(crate) covariance: Covariance,
pub(crate) offset: Offset,
pub(crate) omega: Option<AngularRate<Body>>,
}
impl Propagated {
pub(crate) fn is_finite(&self) -> bool {
self.state.is_finite()
&& self.covariance.is_finite()
&& self.offset.is_finite()
&& self.omega.is_none_or(|omega| omega.is_finite())
}
}
pub(crate) fn propagate(
state: State,
covariance: Covariance,
offset: Offset,
imu: ImuSample,
config: &Config,
) -> Propagated {
let corrected = corrected_imu(imu, &state);
let transition = transition_matrix(&state, corrected);
let q = process_noise(&config.imu, corrected);
Propagated {
state: propagate_nominal(state, corrected, config.gravity),
covariance: propagate_covariance(covariance, &transition, q),
offset: propagate_offset(
offset,
&transition,
config.baro_offset_walk,
corrected.velocity_interval,
),
omega: Some(corrected.omega()),
}
}
fn propagate_offset(offset: Offset, f: &Transition, walk: f32, dt: Seconds) -> Offset {
Offset {
cross: f * offset.cross,
variance: offset.variance + walk * walk * dt.as_secs(),
}
}
fn transition_matrix(state: &State, imu: Corrected) -> Transition {
let (dt_v, dt_theta) = (
imu.velocity_interval.as_secs(),
imu.angle_interval.as_secs(),
);
let rotation = state.attitude.quaternion().to_rotation_matrix();
let r = rotation.matrix();
let mut f = Transition::identity();
let (p, v, theta, beta_a, beta_g) = (
ErrorState::PositionNorth.index(),
ErrorState::VelocityNorth.index(),
ErrorState::AttitudeX.index(),
ErrorState::AccelBiasX.index(),
ErrorState::GyroBiasX.index(),
);
f.fixed_view_mut::<3, 3>(p, v)
.copy_from(&(Matrix3::identity() * dt_v));
f.fixed_view_mut::<3, 3>(v, theta)
.copy_from(&(-r * skew(imu.delta_velocity.vector())));
f.fixed_view_mut::<3, 3>(v, beta_a).copy_from(&(-r * dt_v));
let increment = exp_quat(imu.delta_angle.vector()).to_rotation_matrix();
f.fixed_view_mut::<3, 3>(theta, theta)
.copy_from(&increment.matrix().transpose());
f.fixed_view_mut::<3, 3>(theta, beta_g)
.copy_from(&(-Matrix3::identity() * dt_theta));
f
}
pub(crate) fn error_dynamics(state: &State, omega: Vector3<f32>, a_b: Vector3<f32>) -> Transition {
let rotation = state.attitude.quaternion().to_rotation_matrix();
let r = rotation.matrix();
let mut a = Transition::zeros();
let (p, v, theta, beta_a, beta_g) = (
ErrorState::PositionNorth.index(),
ErrorState::VelocityNorth.index(),
ErrorState::AttitudeX.index(),
ErrorState::AccelBiasX.index(),
ErrorState::GyroBiasX.index(),
);
a.fixed_view_mut::<3, 3>(p, v)
.copy_from(&Matrix3::identity());
a.fixed_view_mut::<3, 3>(v, theta)
.copy_from(&(-r * skew(a_b)));
a.fixed_view_mut::<3, 3>(v, beta_a).copy_from(&(-r));
a.fixed_view_mut::<3, 3>(theta, theta)
.copy_from(&(-skew(omega)));
a.fixed_view_mut::<3, 3>(theta, beta_g)
.copy_from(&(-Matrix3::identity()));
a
}
fn process_noise(noise: &ImuNoise, imu: Corrected) -> [f32; STATES] {
let (dt_v, dt_theta) = (
imu.velocity_interval.as_secs(),
imu.angle_interval.as_secs(),
);
let densities = Densities::of(noise, None);
let velocity = densities.velocity * dt_v;
let attitude = densities.attitude * dt_theta;
let accel_bias = densities.accel_bias * dt_v;
let gyro_bias = densities.gyro_bias * dt_theta;
#[rustfmt::skip]
let diagonal = [
0.0, 0.0, 0.0,
velocity, velocity, velocity,
attitude, attitude, attitude,
accel_bias, accel_bias, accel_bias,
gyro_bias, gyro_bias, gyro_bias,
];
diagonal
}
#[inline(never)]
fn propagate_covariance(p: Covariance, f: &Transition, q: [f32; STATES]) -> Covariance {
let mut next = f * p.as_matrix() * f.transpose();
for (i, variance) in q.iter().enumerate() {
next[(i, i)] += variance;
}
enforce_symmetry(&mut next);
Covariance::from_matrix(next)
}
pub(crate) fn project(
state: &State,
covariance: Covariance,
horizon: Seconds,
config: &Config,
) -> Covariance {
let seconds = horizon.as_secs();
if seconds.is_nan() || seconds <= 0.0 {
return covariance;
}
let densities = Densities::of(&config.imu, None);
unaccelerated_growth(
state,
&covariance,
seconds,
config.gravity,
&densities,
None,
)
}
pub(crate) fn coast(
state: State,
covariance: Covariance,
offset: Offset,
gap: Seconds,
config: &Config,
unmeasured: &Coast,
) -> Propagated {
let seconds = gap.as_secs();
if seconds.is_nan() || seconds <= 0.0 {
return Propagated {
state,
covariance,
offset,
omega: None,
};
}
let densities = Densities::of(&config.imu, Some(unmeasured));
let mut cross = offset.cross;
let covariance = unaccelerated_growth(
&state,
&covariance,
seconds,
config.gravity,
&densities,
Some(&mut cross),
);
let walk = config.baro_offset_walk;
Propagated {
state: propagate_nominal(
state,
unaccelerated_sample(&state, gap, config.gravity),
config.gravity,
),
covariance,
offset: Offset {
cross,
variance: offset.variance + walk * walk * seconds,
},
omega: None,
}
}
struct Densities {
velocity: f32,
attitude: f32,
accel_bias: f32,
gyro_bias: f32,
}
impl Densities {
fn of(noise: &ImuNoise, unmeasured: Option<&Coast>) -> Self {
let (acceleration, rotation) =
unmeasured.map_or((0.0, 0.0), |c| (c.acceleration, c.rotation));
Self {
velocity: noise.accel_white * noise.accel_white + acceleration * acceleration,
attitude: noise.gyro_white * noise.gyro_white + rotation * rotation,
accel_bias: noise.accel_bias_walk * noise.accel_bias_walk,
gyro_bias: noise.gyro_bias_walk * noise.gyro_bias_walk,
}
}
}
fn unaccelerated_transition(state: &State, t: f32, gravity: f32) -> Transition {
let (r, g) = gravity_leak(state, gravity);
let i = Matrix3::<f32>::identity();
let t2 = t * t;
let (p, v, theta, beta_a, beta_g) = (
ErrorState::PositionNorth.index(),
ErrorState::VelocityNorth.index(),
ErrorState::AttitudeX.index(),
ErrorState::AccelBiasX.index(),
ErrorState::GyroBiasX.index(),
);
let mut phi = Transition::identity();
phi.fixed_view_mut::<3, 3>(p, v).copy_from(&(i * t));
phi.fixed_view_mut::<3, 3>(p, theta)
.copy_from(&(g * (t2 / 2.0)));
phi.fixed_view_mut::<3, 3>(p, beta_a)
.copy_from(&(-r * (t2 / 2.0)));
phi.fixed_view_mut::<3, 3>(p, beta_g)
.copy_from(&(-g * (t2 * t / 6.0)));
phi.fixed_view_mut::<3, 3>(v, theta).copy_from(&(g * t));
phi.fixed_view_mut::<3, 3>(v, beta_a).copy_from(&(-r * t));
phi.fixed_view_mut::<3, 3>(v, beta_g)
.copy_from(&(-g * (t2 / 2.0)));
phi.fixed_view_mut::<3, 3>(theta, beta_g)
.copy_from(&(-i * t));
phi
}
fn gravity_leak(state: &State, gravity: f32) -> (Matrix3<f32>, Matrix3<f32>) {
let r = *state.attitude.quaternion().to_rotation_matrix().matrix();
let g = -r * skew(unaccelerated_force(state, gravity));
(r, g)
}
fn unaccelerated_growth(
state: &State,
covariance: &Covariance,
t: f32,
gravity: f32,
q: &Densities,
cross: Option<&mut SVector<f32, STATES>>,
) -> Covariance {
let (r, g) = gravity_leak(state, gravity);
let i = Matrix3::<f32>::identity();
let (t2, t3) = (t * t, t * t * t);
let (t4, t5) = (t3 * t, t3 * t2);
let (p, v, theta, beta_a, beta_g) = (
ErrorState::PositionNorth.index(),
ErrorState::VelocityNorth.index(),
ErrorState::AttitudeX.index(),
ErrorState::AccelBiasX.index(),
ErrorState::GyroBiasX.index(),
);
let phi = unaccelerated_transition(state, t, gravity);
if let Some(cross) = cross {
*cross = phi * *cross;
}
let mut next = phi * covariance.as_matrix() * phi.transpose();
let ggt = g * g.transpose();
let (qv, qt, qa, qg) = (q.velocity, q.attitude, q.accel_bias, q.gyro_bias);
macro_rules! add {
($row:expr, $column:expr, $block:expr) => {{
let block: Matrix3<f32> = $block;
let mut upper = next.fixed_view_mut::<3, 3>($row, $column);
upper += block;
if $row != $column {
let mut lower = next.fixed_view_mut::<3, 3>($column, $row);
lower += block.transpose();
}
}};
}
add!(
p,
p,
i * (qv * t3 / 3.0 + qa * t5 / 20.0) + ggt * (qt * t5 / 20.0 + qg * t5 * t2 / 252.0)
);
add!(
p,
v,
i * (qv * t2 / 2.0 + qa * t4 / 8.0) + ggt * (qt * t4 / 8.0 + qg * t5 * t / 72.0)
);
add!(p, theta, g * (qt * t3 / 6.0 + qg * t5 / 30.0));
add!(p, beta_a, -r * (qa * t3 / 6.0));
add!(p, beta_g, -g * (qg * t4 / 24.0));
add!(
v,
v,
i * (qv * t + qa * t3 / 3.0) + ggt * (qt * t3 / 3.0 + qg * t5 / 20.0)
);
add!(v, theta, g * (qt * t2 / 2.0 + qg * t4 / 8.0));
add!(v, beta_a, -r * (qa * t2 / 2.0));
add!(v, beta_g, -g * (qg * t3 / 6.0));
add!(theta, theta, i * (qt * t + qg * t3 / 3.0));
add!(theta, beta_g, -i * (qg * t2 / 2.0));
add!(beta_a, beta_a, i * (qa * t));
add!(beta_g, beta_g, i * (qg * t));
enforce_symmetry(&mut next);
Covariance::from_matrix(next)
}
fn unaccelerated_sample(state: &State, dt: Seconds, gravity: f32) -> Corrected {
Corrected {
delta_angle: DeltaAngle::from_vector(Vector3::zeros()),
angle_interval: dt,
delta_velocity: DeltaVelocity::from_vector(
unaccelerated_force(state, gravity) * dt.as_secs(),
),
velocity_interval: dt,
}
}
fn unaccelerated_force(state: &State, gravity: f32) -> Vector3<f32> {
state.attitude.quaternion().to_rotation_matrix().inverse() * -gravity_vector(gravity)
}
pub(crate) fn gravity_vector(gravity: f32) -> Vector3<f32> {
Vector3::new(0.0, 0.0, gravity)
}
#[cfg(test)]
mod tests {
use super::*;
use nalgebra::UnitQuaternion;
use crate::config::GRAVITY;
use crate::config::Initialization;
use crate::init;
use crate::state::{CovarianceMatrix, ErrorState};
use crate::units::Radians;
const DT: Seconds = Seconds::from_secs(0.005);
fn under(noise: ImuNoise, walk: f32) -> Config {
Config {
imu: noise,
baro_offset_walk: walk,
..Config::default()
}
}
fn at_rest() -> State {
State {
attitude: Attitude::level(),
..State::default()
}
}
fn holding_still() -> ImuSample {
ImuSample::reading(
AngularRate::body(0.0, 0.0, 0.0),
Acceleration::body(0.0, 0.0, -GRAVITY),
)
}
fn step(state: State, imu: ImuSample, dt: Seconds) -> State {
propagate_nominal(
state,
corrected_imu(imu.timed(Timestamp::ZERO, dt), &state),
GRAVITY,
)
}
fn run(mut state: State, imu: ImuSample, steps: u32) -> State {
for _ in 0..steps {
state = step(state, imu, DT);
}
state
}
#[test]
fn a_level_vehicle_at_rest_stays_where_it_is() {
let state = run(at_rest(), holding_still(), 12_000);
assert!(state.position.vector().norm() < 1e-6, "{state:?}");
assert!(state.velocity.vector().norm() < 1e-6, "{state:?}");
}
#[test]
fn a_constant_body_rate_integrates_to_the_closed_form_rotation() {
let rate = 0.4;
let steps = 1_000;
let imu = holding_still().with_gyro(AngularRate::body(0.0, 0.0, rate));
let mut state = at_rest();
for _ in 0..steps {
state = step(state, imu, DT);
let q = state.attitude.quaternion().into_inner();
assert!((q.norm() - 1.0).abs() < 1e-6, "{q:?}");
}
let expected = rate * DT.as_secs() * steps as f32;
let (_, _, yaw) = state.attitude.euler_angles();
assert!((yaw - expected).abs() < 1e-3, "{yaw} vs {expected}");
}
#[test]
fn position_uses_the_pre_update_velocity() {
let accel = 2.0;
let seconds = 5.0;
let steps = (seconds / DT.as_secs()) as u32;
let imu = holding_still().with_accel(Acceleration::body(accel, 0.0, -GRAVITY));
let state = run(at_rest(), imu, steps);
let closed_form = 0.5 * accel * seconds * seconds;
assert!(
(state.position.vector().x - closed_form).abs() < 1e-2,
"{} vs {closed_form}",
state.position.vector().x
);
let fused_excess = accel * DT.as_secs() * seconds;
assert!((fused_excess - 0.05).abs() < 1e-6, "{fused_excess}");
}
#[test]
fn a_gyro_bias_equal_to_the_measured_rate_produces_no_rotation() {
let rate = 0.4;
let state = State {
gyro_bias: AngularRate::body(0.0, 0.0, rate),
..at_rest()
};
let imu = holding_still().with_gyro(AngularRate::body(0.0, 0.0, rate));
let (_, _, yaw) = run(state, imu, 2_000).attitude.euler_angles();
assert!(yaw.abs() < 1e-6, "{yaw}");
}
#[test]
fn an_accel_bias_equal_to_the_measured_offset_produces_no_velocity() {
let bias = 0.3;
let state = State {
accel_bias: Acceleration::body(bias, 0.0, 0.0),
..at_rest()
};
let imu = holding_still().with_accel(Acceleration::body(bias, 0.0, -GRAVITY));
let velocity = run(state, imu, 2_000).velocity.vector().norm();
assert!(velocity < 1e-6, "{velocity}");
}
#[test]
fn propagation_leaves_the_biases_alone() {
let state = State {
accel_bias: Acceleration::body(0.1, -0.2, 0.3),
gyro_bias: AngularRate::body(0.01, 0.02, -0.03),
..at_rest()
};
let after = step(state, holding_still(), DT);
assert_eq!(after.accel_bias, state.accel_bias);
assert_eq!(after.gyro_bias, state.gyro_bias);
}
#[test]
fn specific_force_is_rotated_out_of_the_body_frame() {
let state = State {
attitude: Attitude::from_quaternion(UnitQuaternion::from_euler_angles(
core::f32::consts::FRAC_PI_2,
0.0,
0.0,
)),
..at_rest()
};
let velocity = step(state, holding_still(), DT).velocity.vector();
let free_fall = GRAVITY * DT.as_secs();
assert!(velocity.x.abs() < 1e-4, "{velocity:?}");
assert!((velocity.y - free_fall).abs() < 1e-4, "{velocity:?}");
assert!((velocity.z - free_fall).abs() < 1e-4, "{velocity:?}");
}
fn tilted_and_moving() -> State {
State {
attitude: Attitude::from_quaternion(UnitQuaternion::from_euler_angles(0.2, -0.3, 0.7)),
position: Position::ned(0.5, -0.25, 0.1),
velocity: Velocity::ned(3.0, -1.0, 0.5),
accel_bias: Acceleration::body(0.05, -0.08, 0.03),
gyro_bias: AngularRate::body(0.004, -0.007, 0.002),
..State::default()
}
}
fn maneuvering() -> ImuSample {
ImuSample::reading(
AngularRate::body(0.15, -0.23, 0.31),
Acceleration::body(0.8, -1.3, -9.2),
)
}
fn perturb(state: State, dx: &[f32; STATES]) -> State {
let part = |i: usize| Vector3::new(dx[i], dx[i + 1], dx[i + 2]);
State {
position: Position::from_vector(state.position.vector() + part(0)),
velocity: Velocity::from_vector(state.velocity.vector() + part(3)),
attitude: Attitude::from_quaternion(state.attitude.quaternion() * exp_quat(part(6))),
accel_bias: Acceleration::from_vector(state.accel_bias.vector() + part(9)),
gyro_bias: AngularRate::from_vector(state.gyro_bias.vector() + part(12)),
..state
}
}
fn error_between(reference: &State, perturbed: &State) -> [f32; STATES] {
let attitude = reference.attitude.quaternion().inverse() * perturbed.attitude.quaternion();
let parts = [
perturbed.position.vector() - reference.position.vector(),
perturbed.velocity.vector() - reference.velocity.vector(),
attitude.scaled_axis(),
perturbed.accel_bias.vector() - reference.accel_bias.vector(),
perturbed.gyro_bias.vector() - reference.gyro_bias.vector(),
];
let mut dx = [0.0; STATES];
for (block, part) in parts.iter().enumerate() {
for axis in 0..3 {
dx[3 * block + axis] = part[axis];
}
}
dx
}
#[test]
fn two_samples_accumulate_into_one_over_both_intervals() {
let first = maneuvering().timed(Timestamp::from_micros(2_500), Seconds::from_secs(0.0025));
let second = ImuSample::reading(
AngularRate::body(0.0, 0.0, 1.0),
Acceleration::body(0.0, 0.0, -9.8),
)
.timed(Timestamp::from_micros(10_000), Seconds::from_secs(0.0075));
let both = first.accumulate(second);
assert_eq!(both.time, second.time);
assert!((both.angle_interval.as_secs() - 0.01).abs() < 1e-7);
assert!((both.velocity_interval.as_secs() - 0.01).abs() < 1e-7);
let expected = (0.31 * 0.0025 + 1.0 * 0.0075) / 0.01;
assert!((both.angular_rate().vector().z - expected).abs() < 1e-5);
let expected = (-9.2 * 0.0025 - 9.8 * 0.0075) / 0.01;
assert!((both.specific_force().vector().z - expected).abs() < 1e-4);
}
#[test]
fn the_error_dynamics_are_the_transition_matrix_per_unit_time() {
let dt = Seconds::from_secs(0.001);
let state = tilted_and_moving();
let imu = corrected_imu(maneuvering().timed(Timestamp::ZERO, dt), &state);
let omega = imu.omega().vector();
let a_b = imu.delta_velocity.vector() / dt.as_secs();
let f = transition_matrix(&state, imu);
let first_order =
Transition::identity() + error_dynamics(&state, omega, a_b) * dt.as_secs();
for row in 0..STATES {
for column in 0..STATES {
let (exact, linear) = (f[(row, column)], first_order[(row, column)]);
assert!(
(exact - linear).abs() < 1.0e-5,
"[{row}][{column}]: F {exact}, I + A dt {linear}"
);
}
}
}
#[test]
fn the_transition_matrix_matches_a_numerical_jacobian_of_the_nominal_step() {
const DELTA: f32 = 1.0e-3;
const TOLERANCE: f32 = 2.0e-3;
let dt = Seconds::from_secs(0.01);
let (state, imu) = (tilted_and_moving(), maneuvering());
let f = transition_matrix(
&state,
corrected_imu(imu.timed(Timestamp::ZERO, dt), &state),
);
let reference = propagate_nominal(
state,
corrected_imu(imu.timed(Timestamp::ZERO, dt), &state),
GRAVITY,
);
for column in 0..STATES {
let mut dx = [0.0; STATES];
dx[column] = DELTA;
let perturbed = perturb(state, &dx);
let propagated = propagate_nominal(
perturbed,
corrected_imu(imu.timed(Timestamp::ZERO, dt), &perturbed),
GRAVITY,
);
let numerical = error_between(&reference, &propagated);
for row in 0..STATES {
let analytic = f[(row, column)];
let measured = numerical[row] / DELTA;
assert!(
(measured - analytic).abs() < TOLERANCE,
"F[{row}][{column}]: {analytic} analytic vs {measured} numerical",
);
}
}
}
#[test]
fn unaided_growth_from_a_density_matches_the_analytic_integrals() {
const SIGMA: f32 = 0.35;
let noise = ImuNoise {
accel_white: SIGMA,
gyro_white: 0.0,
accel_bias_walk: 0.0,
gyro_bias_walk: 0.0,
};
let seconds = 10.0;
for rate in [50.0, 200.0] {
let dt = Seconds::from_secs(1.0 / rate);
let steps = (seconds * rate) as u32;
let mut covariance = Covariance::zero();
let mut state = at_rest();
for _ in 0..steps {
let step = propagate(
state,
covariance,
Offset::default(),
holding_still().timed(Timestamp::ZERO, dt),
&under(noise, 0.0),
);
(state, covariance) = (step.state, step.covariance);
}
let velocity = covariance.variance(ErrorState::VelocityNorth);
let position = covariance.variance(ErrorState::PositionNorth);
let expected_velocity = SIGMA * SIGMA * seconds;
let expected_position = SIGMA * SIGMA * seconds * seconds * seconds / 3.0;
assert!(
(velocity - expected_velocity).abs() / expected_velocity < 1.0e-3,
"{rate} Hz: {velocity} vs {expected_velocity}",
);
assert!(
(position - expected_position).abs() / expected_position < 1.0e-2,
"{rate} Hz: {position} vs {expected_position}",
);
}
}
#[test]
fn an_unaided_covariance_stays_symmetric_and_never_loses_uncertainty() {
let noise = ImuNoise::default();
let dt = Seconds::from_secs(0.005);
let mut state = tilted_and_moving();
let mut covariance = init::initial_covariance(
&Initialization::default(),
&state.attitude,
Radians::from_radians(0.02),
Radians::from_radians(0.35),
Some(0.0),
Vector3::repeat(0.01),
GRAVITY,
);
for _ in 0..12_000 {
let before = covariance.as_matrix().trace();
let step = propagate(
state,
covariance,
Offset::default(),
maneuvering().timed(Timestamp::ZERO, dt),
&under(noise, 0.0),
);
(state, covariance) = (step.state, step.covariance);
let p = covariance.as_matrix();
assert!(
p.trace() >= before,
"trace fell from {before} to {}",
p.trace()
);
for i in 0..STATES {
assert!(p[(i, i)] > 0.0, "variance {i} is {}", p[(i, i)]);
for j in (i + 1)..STATES {
assert_eq!(p[(i, j)], p[(j, i)], "asymmetric at ({i}, {j})");
}
}
}
}
#[test]
fn the_attitude_block_rotates_with_the_body() {
let quiet = ImuNoise {
gyro_white: 0.0,
accel_white: 0.0,
gyro_bias_walk: 0.0,
accel_bias_walk: 0.0,
};
let (tilt, yaw) = (0.02, 0.35);
#[rustfmt::skip]
let sigmas = [
0.0, 0.0, 0.0,
0.0, 0.0, 0.0,
tilt, tilt, yaw,
0.0, 0.0, 0.0,
0.0, 0.0, 0.0,
];
let dt = Seconds::from_secs(1.0);
let imu = ImuSample::reading(
AngularRate::body(core::f32::consts::FRAC_PI_2, 0.0, 0.0),
Acceleration::body(0.0, 0.0, -GRAVITY),
);
let step = propagate(
at_rest(),
Covariance::from_sigmas(sigmas),
Offset::default(),
imu.timed(Timestamp::ZERO, dt),
&under(quiet, 0.0),
);
let after = step.covariance;
assert!((after.variance(ErrorState::AttitudeX) - tilt * tilt).abs() < 1.0e-9);
assert!((after.variance(ErrorState::AttitudeY) - yaw * yaw).abs() < 1.0e-6);
assert!((after.variance(ErrorState::AttitudeZ) - tilt * tilt).abs() < 1.0e-6);
}
#[test]
fn the_transition_linearizes_about_the_start_of_the_step() {
let quiet = ImuNoise {
gyro_white: 0.0,
accel_white: 0.0,
gyro_bias_walk: 0.0,
accel_bias_walk: 0.0,
};
let dt = Seconds::from_secs(0.1);
let imu = ImuSample::reading(
AngularRate::body(0.0, 0.0, 10.0),
Acceleration::body(0.0, 0.0, -GRAVITY),
);
#[rustfmt::skip]
let sigmas = [
0.0, 0.0, 0.0,
0.0, 0.0, 0.0,
0.0, 0.0, 0.0,
0.5, 0.0, 0.0,
0.0, 0.0, 0.0,
];
let prior = Covariance::from_sigmas(sigmas);
let state = at_rest();
let corrected = corrected_imu(imu.timed(Timestamp::ZERO, dt), &state);
let q = process_noise(&quiet, corrected);
let before = propagate_covariance(prior, &transition_matrix(&state, corrected), q);
let after = propagate_covariance(
prior,
&transition_matrix(&propagate_nominal(state, corrected, GRAVITY), corrected),
q,
);
let step = propagate(
state,
prior,
Offset::default(),
imu.timed(Timestamp::ZERO, dt),
&under(quiet, 0.0),
);
assert_eq!(step.covariance, before);
let north = |p: &Covariance| p.variance(ErrorState::VelocityNorth);
assert!(
(north(&before) - 0.002_5).abs() < 1.0e-6,
"{}",
north(&before)
);
assert!(
(north(&after) - 0.000_73).abs() < 1.0e-5,
"{}",
north(&after)
);
}
#[test]
fn a_covariance_that_overflows_is_not_finite() {
let enormous = Covariance::from_matrix(CovarianceMatrix::from_diagonal_element(f32::MAX));
let step = propagate(
tilted_and_moving(),
enormous,
Offset::default(),
maneuvering().timed(Timestamp::ZERO, Seconds::from_secs(0.005)),
&under(ImuNoise::default(), 0.0),
);
assert!(step.state.is_finite(), "the state itself is fine");
assert!(!step.covariance.is_finite());
assert!(!step.is_finite());
}
#[test]
fn a_finite_but_enormous_sample_produces_a_state_that_is_not_finite() {
let imu = holding_still().with_accel(Acceleration::body(f32::MAX, 0.0, -GRAVITY));
assert!(imu.is_finite());
let state = run(at_rest(), imu, 1_000);
assert!(!state.is_finite(), "{state:?}");
}
#[test]
fn the_offset_walks_and_its_correlations_move_with_the_state() {
let mut cross = nalgebra::SVector::<f32, STATES>::zeros();
cross[ErrorState::VelocityDown.index()] = 1.0;
let offset = Offset {
cross,
variance: 0.25,
};
let dt = Seconds::from_secs(0.01);
let step = propagate(
at_rest(),
Covariance::from_sigmas([0.1; STATES]),
offset,
holding_still().timed(Timestamp::ZERO, dt),
&under(ImuNoise::default(), 2.0),
);
assert!((step.offset.variance - (0.25 + 4.0 * 0.01)).abs() < 1e-6);
assert!((step.offset.cross[ErrorState::PositionDown.index()] - 0.01).abs() < 1e-6);
assert_eq!(step.offset.cross[ErrorState::VelocityDown.index()], 1.0);
}
}
#[cfg(test)]
mod unaccelerated {
use super::*;
use crate::config::{GRAVITY, Initialization};
use crate::init;
use crate::units::Radians;
use nalgebra::UnitQuaternion;
type Matrix64 = SMatrix<f64, STATES, STATES>;
fn tilted() -> State {
State {
attitude: Attitude::from_quaternion(UnitQuaternion::from_euler_angles(0.3, -0.2, 1.1)),
..State::default()
}
}
fn densities() -> Densities {
Densities {
velocity: 0.3,
attitude: 0.02,
accel_bias: 0.005,
gyro_bias: 0.0007,
}
}
fn widen(m: &Transition) -> Matrix64 {
m.map(f64::from)
}
fn a(state: &State) -> Matrix64 {
let rotation = state.attitude.quaternion().to_rotation_matrix();
let a_b = rotation.inverse() * -gravity_vector(GRAVITY);
widen(&error_dynamics(state, Vector3::zeros(), a_b))
}
fn exp(a: &Matrix64, t: f64) -> Matrix64 {
let a2 = a * a;
Matrix64::identity() + a * t + a2 * (t * t / 2.0) + a2 * a * (t * t * t / 6.0)
}
#[test]
fn the_dynamics_are_nilpotent_and_the_transition_is_their_exponential() {
let state = tilted();
let a = a(&state);
let a4 = a * a * a * a;
assert!(a4.amax() < 1e-9, "A⁴ is not zero: {}", a4.amax());
assert!(
(a * a * a).amax() > 1e-3,
"A³ is zero, so the cubic term is untested"
);
for t in [0.01f32, 1.3, 6.4] {
let phi = unaccelerated_transition(&state, t, GRAVITY);
let difference = (widen(&phi) - exp(&a, f64::from(t))).amax();
assert!(
difference < 1e-4,
"{t} s: Φ differs from exp(A T) by {difference}"
);
}
}
#[test]
fn the_noise_is_the_integral_of_the_densities_along_the_transition() {
let state = tilted();
let a = a(&state);
let q = densities();
let mut qc = Matrix64::zeros();
for (first, density) in [
(ErrorState::VelocityNorth, q.velocity),
(ErrorState::AttitudeX, q.attitude),
(ErrorState::AccelBiasX, q.accel_bias),
(ErrorState::GyroBiasX, q.gyro_bias),
] {
for k in 0..3 {
qc[(first.index() + k, first.index() + k)] = f64::from(density);
}
}
for t in [0.5f32, 4.0] {
let n = 400;
let h = f64::from(t) / f64::from(n);
let integrand = |s: f64| {
let phi = exp(&a, s);
phi * qc * phi.transpose()
};
let mut integral = integrand(0.0) + integrand(f64::from(t));
for k in 1..n {
let weight = if k % 2 == 1 { 4.0 } else { 2.0 };
integral += integrand(f64::from(k) * h) * weight;
}
integral *= h / 3.0;
let closed = unaccelerated_growth(&state, &Covariance::zero(), t, GRAVITY, &q, None);
let closed = closed.as_matrix().map(f64::from);
for row in 0..STATES {
for column in 0..STATES {
let (got, want) = (closed[(row, column)], integral[(row, column)]);
let scale = (integral[(row, row)] * integral[(column, column)]).sqrt();
assert!(
(got - want).abs() <= 1e-4 * scale + 1e-9,
"{t} s, ({row}, {column}): {got} against {want}"
);
}
}
}
}
#[test]
fn short_steps_of_the_first_order_form_approach_it_from_below() {
let state = tilted();
let from = init::initial_covariance(
&Initialization::default(),
&state.attitude,
Radians::from_radians(0.02),
Radians::from_radians(0.35),
Some(0.0),
Vector3::repeat(0.01),
GRAVITY,
);
let noise = ImuNoise::default();
let seconds = 5.0;
let exact = unaccelerated_growth(
&state,
&from,
seconds,
GRAVITY,
&Densities::of(&noise, None),
None,
);
let stepped = |rate: f32| {
let dt = Seconds::from_secs(1.0 / rate);
let unaccelerated = unaccelerated_sample(&state, dt, GRAVITY);
let f = transition_matrix(&state, unaccelerated);
let q = process_noise(&noise, unaccelerated);
let mut p = from;
for _ in 0..(seconds * rate) as usize {
p = propagate_covariance(p, &f, q);
}
p.variance(ErrorState::PositionNorth) / exact.variance(ErrorState::PositionNorth)
};
let (coarse, fine) = (stepped(10.0), stepped(400.0));
assert!(
coarse < fine && fine <= 1.0001,
"10 Hz {coarse}, 400 Hz {fine}"
);
assert!(fine > 0.995, "400 Hz {fine} of the exact position variance");
}
#[test]
fn a_coast_carries_the_offsets_correlation_through_the_transition() {
let mut cross = SVector::<f32, STATES>::zeros();
cross[ErrorState::VelocityDown.index()] = 1.0;
let offset = Offset {
cross,
variance: 0.25,
};
let coasted = coast(
tilted(),
Covariance::from_sigmas([0.5; STATES]),
offset,
Seconds::from_secs(2.5),
&Config::default(),
&Coast::default(),
);
let after = coasted.offset.cross;
assert!(
(after[ErrorState::PositionDown.index()] - 2.5).abs() < 1e-6,
"{after}"
);
assert_eq!(after[ErrorState::VelocityDown.index()], 1.0);
}
#[test]
fn a_horizon_that_is_not_positive_leaves_the_covariance_where_it_was() {
let state = State::default();
let from = Covariance::from_sigmas([0.5; STATES]);
for seconds in [0.0f32, -1.0, f32::NAN] {
let projected = project(
&state,
from,
Seconds::from_secs(seconds),
&Config::default(),
);
assert_eq!(projected.to_rows(), from.to_rows(), "{seconds} s");
}
}
}