use nalgebra::{ComplexField, Matrix3, RealField, Rotation3, UnitQuaternion, Vector3};
use crate::config::{Config, Initialization};
use crate::display::{Decimals, Fixed};
use crate::frames::{Body, Ned};
use crate::geodetic::EARTH_RATE;
use crate::math::{skew, wrap_pi};
use crate::propagate::ImuSample;
use crate::state::{AttitudeVariance, Covariance, State};
use crate::units::{
Acceleration, Altitude, AltitudeNoise, AngularRate, Attitude, MagField, MetersPerSecond2,
Position, Radians, RadiansPerSecond, Seconds, Timestamp, Velocity,
};
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct StaticSample {
pub imu: ImuSample,
pub mag: Option<MagField<Body>>,
pub baro: Option<Altitude>,
pub velocity: Option<Velocity<Ned>>,
}
impl StaticSample {
pub(crate) fn is_finite(&self) -> bool {
self.imu.is_finite()
&& self.mag.is_none_or(|field| field.is_finite())
&& self.baro.is_none_or(|b| b.as_meters().is_finite())
&& self.velocity.is_none_or(|v| v.is_finite())
}
}
#[derive(Clone, Debug)]
pub struct StaticWindow {
outer: Matrix3<f64>,
peaks: Peaks,
end: Option<Timestamp>,
sums: BlockSums,
velocities: Velocities,
baro: BaroReadings,
gyro_noise: Density,
accel_noise: Density,
}
impl Default for StaticWindow {
fn default() -> Self {
Self::new()
}
}
impl StaticWindow {
#[must_use]
pub fn new() -> Self {
Self {
outer: Matrix3::zeros(),
peaks: Peaks::NONE,
end: None,
sums: BlockSums::new(),
velocities: Velocities::default(),
baro: BaroReadings::default(),
gyro_noise: Density::EMPTY,
accel_noise: Density::EMPTY,
}
}
pub fn push(&mut self, sample: StaticSample) -> Result<(), SampleRefusal> {
if !sample.is_finite() {
return Err(SampleRefusal::NotFinite);
}
if let Some(interval) = sample.imu.unusable_interval() {
return Err(SampleRefusal::InvalidInterval { interval });
}
if let Some(end) = self.end {
let dt = sample.imu.time.since(end);
if !dt.is_usable_step() {
return Err(SampleRefusal::InvalidStep { dt });
}
}
let (accel, gyro) = (
sample.imu.specific_force().vector(),
sample.imu.angular_rate().vector(),
);
self.peaks.push(accel, gyro);
let interval = f64::from(sample.imu.angle_interval.as_secs());
let velocity_interval = f64::from(sample.imu.velocity_interval.as_secs());
let accel = widen(accel);
self.outer += accel * accel.transpose();
self.gyro_noise
.push(widen(sample.imu.delta_angle.vector()), interval);
self.accel_noise
.push(widen(sample.imu.delta_velocity.vector()), velocity_interval);
self.end = Some(sample.imu.time);
self.sums.push(
accel,
sample.mag.map(|measurement| widen(measurement.vector())),
);
self.velocities.push(sample.velocity, interval);
self.baro.push(sample.baro);
Ok(())
}
pub fn try_extend(
&mut self,
samples: impl IntoIterator<Item = StaticSample>,
) -> Result<(), SampleRefusal> {
samples.into_iter().try_for_each(|sample| self.push(sample))
}
#[must_use]
pub fn is_at_rest(&self, config: &Config) -> bool {
at_rest(self.peaks, &config.init, config.gravity)
}
#[must_use]
pub fn is_long_enough(&self, config: &Config) -> bool {
self.end.is_some() && long_enough(self.span(), &config.init)
}
#[must_use]
pub fn span(&self) -> Seconds {
Seconds::from_secs(self.gyro_noise.span() as f32)
}
pub(crate) fn measured(&self) -> Result<Measured, InitError> {
let end = self.end.ok_or(InitError::NoSamples)?;
let (first, second) = self.sums.split();
let whole = first.merged(second);
Ok(Measured {
force: Acceleration::from_vector(mean(whole.force, whole.samples)),
rate: AngularRate::from_vector(self.gyro_noise.mean()),
field: (whole.fields > 0)
.then(|| MagField::from_vector(mean(whole.field, whole.fields))),
inertial_accel: self.velocities.inertial_acceleration(),
peaks: self.peaks,
span: self.span(),
end,
halves: Halves::of(first, second),
level_scatter: level_scatter(self.outer, whole.force, whole.samples),
rate_variance: self.gyro_noise.mean_variance(),
})
}
pub(crate) fn alpha0(&self) -> Option<(Altitude, f32)> {
self.baro.reference()
}
#[must_use]
pub fn noise(&self, config: &Config) -> Option<WindowNoise> {
if !self.is_at_rest(config) {
return None;
}
Some(WindowNoise {
gyro_white: self.gyro_noise.density()?.into(),
accel_white: self.accel_noise.density()?.into(),
baro: self.baro.noise(),
blocks: self.gyro_noise.blocks.min(self.accel_noise.blocks),
baro_readings: self.baro.count,
})
}
}
impl TryFrom<&[StaticSample]> for StaticWindow {
type Error = SampleRefusal;
fn try_from(samples: &[StaticSample]) -> Result<Self, SampleRefusal> {
let mut window = Self::new();
window.try_extend(samples.iter().copied())?;
Ok(window)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub struct WindowNoise {
pub gyro_white: [f32; 3],
pub accel_white: [f32; 3],
pub baro: Option<AltitudeNoise>,
pub blocks: u64,
pub baro_readings: u64,
}
#[cfg(feature = "defmt")]
impl defmt::Format for WindowNoise {
fn format(&self, f: defmt::Formatter<'_>) {
let (g, a) = (self.gyro_white, self.accel_white);
defmt::write!(
f,
"WindowNoise {{ gyro_white: ({=f32}, {=f32}, {=f32}), accel_white: ({=f32}, {=f32}, {=f32}), baro: {}, blocks: {=u64}, baro_readings: {=u64} }}",
g[0],
g[1],
g[2],
a[0],
a[1],
a[2],
self.baro.map(AltitudeNoise::variance),
self.blocks,
self.baro_readings
)
}
}
impl WindowNoise {
pub const MIN_READINGS: u64 = 9;
pub const BLOCK: Seconds = Seconds::from_secs(0.05);
#[must_use]
pub fn worst_gyro_white(&self) -> f32 {
Vector3::from(self.gyro_white).max()
}
#[must_use]
pub fn worst_accel_white(&self) -> f32 {
Vector3::from(self.accel_white).max()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Measured {
pub force: Acceleration<Body>,
pub rate: AngularRate<Body>,
pub field: Option<MagField<Body>>,
pub inertial_accel: Option<Acceleration<Ned>>,
pub peaks: Peaks,
pub span: Seconds,
pub end: Timestamp,
pub halves: Option<Halves>,
pub level_scatter: Option<f64>,
pub rate_variance: Option<Vector3<f32>>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Halves {
force: [Acceleration<Body>; 2],
field: Option<[MagField<Body>; 2]>,
}
impl Halves {
fn of(first: Block, second: Block) -> Option<Self> {
(first.samples > 0 && second.samples > 0).then(|| Self {
force: [
Acceleration::from_vector(mean(first.force, first.samples)),
Acceleration::from_vector(mean(second.force, second.samples)),
],
field: (first.fields > 0 && second.fields > 0).then(|| {
[
MagField::from_vector(mean(first.field, first.fields)),
MagField::from_vector(mean(second.field, second.fields)),
]
}),
})
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Peaks {
pub gyro: RadiansPerSecond,
least: f32,
most: f32,
}
impl Peaks {
const NONE: Self = Self {
gyro: RadiansPerSecond::from_rad_per_s(0.0),
least: f32::INFINITY,
most: f32::NEG_INFINITY,
};
fn push(&mut self, accel: Vector3<f32>, gyro: Vector3<f32>) {
let norm = accel.norm();
self.gyro = RadiansPerSecond::from_rad_per_s(self.gyro.as_rad_per_s().max(gyro.norm()));
self.least = self.least.min(norm);
self.most = self.most.max(norm);
}
pub(crate) fn deviation(&self, gravity: f32) -> MetersPerSecond2 {
let deviation = (self.most - gravity).max(gravity - self.least).max(0.0);
MetersPerSecond2::from_m_per_s2(deviation)
}
}
impl Measured {
pub(crate) fn gyro_bias(&self, at_rest: bool, config: &Config) -> GyroBias {
let prior = config.init.sigma_gyro_bias.as_rad_per_s();
if !at_rest {
return GyroBias {
estimate: AngularRate::zero(),
sigmas: Vector3::repeat(prior),
};
}
let prior = prior * prior;
let unmeasured = config.imu.gyro_white.powi(2) / self.span.as_secs();
let r = self.rate_variance.map_or(Vector3::repeat(unmeasured), |r| {
r.map(|r| if r > 0.0 { r } else { unmeasured })
});
let floor = EARTH_RATE.as_rad_per_s();
let gain = r.map(|r| prior / (prior + r));
GyroBias {
estimate: AngularRate::from_vector(self.rate.vector().component_mul(&gain)),
sigmas: r.zip_map(&gain, |r, k| ComplexField::sqrt(k * r + floor * floor)),
}
}
fn down(&self) -> Option<Vector3<f32>> {
self.force
.vector()
.try_normalize(f32::MIN_POSITIVE)
.map(|f| -f)
}
}
const BLOCKS: usize = 8;
#[derive(Clone, Copy, Debug)]
struct BlockSums {
blocks: [Block; BLOCKS],
size: u64,
current: usize,
}
#[derive(Clone, Copy, Debug)]
struct Block {
force: Vector3<f64>,
samples: u64,
field: Vector3<f64>,
fields: u64,
}
impl Block {
const EMPTY: Self = Self {
force: Vector3::new(0.0, 0.0, 0.0),
samples: 0,
field: Vector3::new(0.0, 0.0, 0.0),
fields: 0,
};
fn total(blocks: &[Self]) -> Self {
blocks.iter().fold(Self::EMPTY, |sum, b| sum.merged(*b))
}
fn merged(self, other: Self) -> Self {
Self {
force: self.force + other.force,
samples: self.samples + other.samples,
field: self.field + other.field,
fields: self.fields + other.fields,
}
}
}
impl BlockSums {
const fn new() -> Self {
Self {
blocks: [Block::EMPTY; BLOCKS],
size: 1,
current: 0,
}
}
fn push(&mut self, force: Vector3<f64>, field: Option<Vector3<f64>>) {
if self
.blocks
.get(self.current)
.is_some_and(|b| b.samples >= self.size)
{
if self.current + 1 < BLOCKS {
self.current += 1;
} else {
self.halve();
}
}
if let Some(block) = self.blocks.get_mut(self.current) {
block.force += force;
block.samples += 1;
if let Some(field) = field {
block.field += field;
block.fields += 1;
}
}
}
fn halve(&mut self) {
for pair in 0..BLOCKS / 2 {
if let (Some(&first), Some(&second)) =
(self.blocks.get(2 * pair), self.blocks.get(2 * pair + 1))
&& let Some(merged) = self.blocks.get_mut(pair)
{
*merged = first.merged(second);
}
}
for empty in self.blocks.iter_mut().skip(BLOCKS / 2) {
*empty = Block::EMPTY;
}
self.size *= 2;
self.current = BLOCKS / 2;
}
fn split(&self) -> (Block, Block) {
let blocks = self.blocks.get(..=self.current).unwrap_or_default();
let total: u64 = blocks.iter().map(|b| b.samples).sum();
let (mut split, mut nearest, mut before) = (0, total, 0u64);
for (index, block) in blocks.iter().enumerate() {
before += block.samples;
let distance = (2 * before).abs_diff(total);
if distance < nearest {
(split, nearest) = (index + 1, distance);
}
}
let (first, second) = blocks.split_at(split.min(blocks.len()));
(Block::total(first), Block::total(second))
}
}
#[derive(Clone, Copy, Debug, Default)]
struct Velocities {
first: Option<Velocity<Ned>>,
last: Option<Velocity<Ned>>,
since_first: f64,
span: f64,
}
impl Velocities {
fn push(&mut self, velocity: Option<Velocity<Ned>>, interval: f64) {
if self.first.is_some() {
self.since_first += interval;
}
if let Some(velocity) = velocity {
if self.first.is_none() {
self.first = Some(velocity);
} else {
self.last = Some(velocity);
self.span = self.since_first;
}
}
}
fn inertial_acceleration(&self) -> Option<Acceleration<Ned>> {
let (first, last) = (self.first?, self.last?);
let span = self.span as f32;
(span > 0.0).then(|| Acceleration::from_vector((last.vector() - first.vector()) / span))
}
}
#[derive(Clone, Copy, Debug)]
struct Density {
open: Vector3<f64>,
open_interval: f64,
blocks: u64,
interval: f64,
sum: Vector3<f64>,
squares: Vector3<f64>,
}
impl Density {
const EMPTY: Self = Self {
open: Vector3::new(0.0, 0.0, 0.0),
open_interval: 0.0,
blocks: 0,
interval: 0.0,
sum: Vector3::new(0.0, 0.0, 0.0),
squares: Vector3::new(0.0, 0.0, 0.0),
};
fn push(&mut self, increment: Vector3<f64>, interval: f64) {
self.open += increment;
self.open_interval += interval;
if self.open_interval + interval / 2.0 >= f64::from(WindowNoise::BLOCK.as_secs()) {
let (block, length) = (self.open, self.open_interval);
self.blocks += 1;
self.interval += length;
self.sum += block;
self.squares += block.component_mul(&block) * (1.0 / length);
(self.open, self.open_interval) = (Vector3::zeros(), 0.0);
}
}
fn density(&self) -> Option<Vector3<f32>> {
if self.blocks < WindowNoise::MIN_READINGS {
return None;
}
Some(self.variance().map(|n2| ComplexField::sqrt(n2) as f32))
}
fn variance(&self) -> Vector3<f64> {
let j = self.blocks as f64;
let axis =
|squares: f64, sum: f64| (scatter(squares, sum, self.interval) / (j - 1.0)).max(0.0);
Vector3::new(
axis(self.squares.x, self.sum.x),
axis(self.squares.y, self.sum.y),
axis(self.squares.z, self.sum.z),
)
}
fn span(&self) -> f64 {
self.interval + self.open_interval
}
fn mean(&self) -> Vector3<f32> {
let span = self.span();
if span <= 0.0 {
return Vector3::zeros();
}
((self.sum + self.open) / span).map(|rate| rate as f32)
}
const FEWEST_FOR_MEAN: u64 = 4;
fn mean_variance(&self) -> Option<Vector3<f32>> {
if self.blocks < Self::FEWEST_FOR_MEAN {
return None;
}
let j = self.blocks as f64;
let scale = (j - 1.0) / (j - 3.0) / self.span();
Some(self.variance().map(|n2| (n2 * scale) as f32))
}
}
#[derive(Clone, Copy, Debug, Default)]
struct BaroReadings {
previous: Option<Altitude>,
count: u64,
first: f64,
sum: f64,
squares: f64,
}
impl BaroReadings {
fn push(&mut self, baro: Option<Altitude>) {
let fresh = baro.filter(|altitude| Some(*altitude) != self.previous);
self.previous = baro;
if let Some(altitude) = fresh {
let a = f64::from(altitude.as_meters());
if self.count == 0 {
self.first = a;
}
let d = a - self.first;
self.count += 1;
self.sum += d;
self.squares += d * d;
}
}
fn reference(&self) -> Option<(Altitude, f32)> {
if self.count < 2 {
return None;
}
let n = self.count as f64;
let mean = self.first + self.sum / n;
Some((
Altitude::from_meters(mean as f32),
(self.variance() / n) as f32,
))
}
fn noise(&self) -> Option<AltitudeNoise> {
(self.count >= WindowNoise::MIN_READINGS)
.then(|| AltitudeNoise::from_variance(self.variance() as f32))
}
fn variance(&self) -> f64 {
let n = self.count as f64;
scatter(self.squares, self.sum, n) / (n - 1.0)
}
}
fn scatter(squares: f64, sum: f64, weight: f64) -> f64 {
squares - sum * sum / weight
}
fn level_scatter(outer: Matrix3<f64>, sum: Vector3<f64>, count: u64) -> Option<f64> {
if count < 2 {
return None;
}
let n = count as f64;
let mean = sum / n;
let down = mean.try_normalize(f64::MIN_POSITIVE)?;
let scatter = (outer - mean * mean.transpose() * n) / (n - 1.0);
let across = scatter.trace() - down.dot(&(scatter * down));
Some((across / 2.0).max(0.0) / n)
}
fn widen(measurement: Vector3<f32>) -> Vector3<f64> {
Vector3::new(
f64::from(measurement.x),
f64::from(measurement.y),
f64::from(measurement.z),
)
}
fn mean(sum: Vector3<f64>, count: u64) -> Vector3<f32> {
if count == 0 {
return Vector3::zeros();
}
let n = count as f64;
Vector3::new((sum.x / n) as f32, (sum.y / n) as f32, (sum.z / n) as f32)
}
#[must_use = "whether the filter aligned or only started coarsely changes what the estimate is worth"]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Alignment {
Static,
Coarse(Coarse),
Seeded,
}
impl Alignment {
pub const fn is_static(self) -> bool {
matches!(self, Self::Static)
}
}
impl core::fmt::Display for Alignment {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Static => f.write_str("static"),
Self::Coarse(coarse) => write!(f, "coarse, {coarse}"),
Self::Seeded => f.write_str("seeded"),
}
}
}
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Coarse {
WindowTooShort {
required: Seconds,
provided: Seconds,
},
NotStationary {
peak_gyro: RadiansPerSecond,
peak_accel_deviation: MetersPerSecond2,
span: Seconds,
inertial_accel: Option<Acceleration<Ned>>,
},
}
impl Coarse {
pub(crate) fn not_stationary(measured: &Measured, gravity: f32) -> Self {
Self::NotStationary {
peak_gyro: measured.peaks.gyro,
peak_accel_deviation: measured.peaks.deviation(gravity),
span: measured.span,
inertial_accel: measured.inertial_accel,
}
}
}
impl core::fmt::Display for Coarse {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let three = |value| Fixed::new(value, Decimals::Three);
match *self {
Self::WindowTooShort { required, provided } => write!(
f,
"window of {} s, {} s required",
three(provided.as_secs()),
three(required.as_secs())
),
Self::NotStationary {
peak_gyro,
peak_accel_deviation,
span,
..
} => write!(
f,
"moving: peak rate {} rad/s, specific force {} m/s² off gravity, over {} s",
three(peak_gyro.as_rad_per_s()),
three(peak_accel_deviation.as_m_per_s2()),
three(span.as_secs())
),
}
}
}
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum InitError {
NoSamples,
InvalidInterval {
interval: Seconds,
},
InvalidStep {
dt: Seconds,
},
NotFinite,
InvalidVariance,
}
impl core::fmt::Display for InitError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::NoSamples => write!(f, "initialization window held no samples"),
Self::InvalidInterval { interval } => {
let interval = Fixed::new(interval.as_secs(), Decimals::Three);
write!(f, "initialization interval of {interval} s is not usable")
}
Self::InvalidStep { dt } => {
let dt = Fixed::new(dt.as_secs(), Decimals::Three);
write!(f, "initialization window stepped {dt} s, not forward")
}
Self::NotFinite => write!(f, "initialization input was not finite"),
Self::InvalidVariance => {
write!(f, "seed covariance had a variance below the floor of (42′)")
}
}
}
}
impl core::error::Error for InitError {}
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum SampleRefusal {
NotFinite,
InvalidInterval {
interval: Seconds,
},
InvalidStep {
dt: Seconds,
},
}
impl From<SampleRefusal> for InitError {
fn from(refusal: SampleRefusal) -> Self {
match refusal {
SampleRefusal::NotFinite => Self::NotFinite,
SampleRefusal::InvalidInterval { interval } => Self::InvalidInterval { interval },
SampleRefusal::InvalidStep { dt } => Self::InvalidStep { dt },
}
}
}
impl core::fmt::Display for SampleRefusal {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
InitError::from(*self).fmt(f)
}
}
impl core::error::Error for SampleRefusal {}
pub(crate) fn classify(measured: &Measured, init: &Initialization, gravity: f32) -> Alignment {
if !at_rest(measured.peaks, init, gravity) {
return Alignment::Coarse(Coarse::not_stationary(measured, gravity));
}
if !long_enough(measured.span, init) {
return Alignment::Coarse(Coarse::WindowTooShort {
required: init.min_duration,
provided: measured.span,
});
}
Alignment::Static
}
fn long_enough(span: Seconds, init: &Initialization) -> bool {
span >= init.min_duration
}
pub(crate) fn nominal_state(
measured: &Measured,
declination: Radians,
gyro_bias: AngularRate<Body>,
) -> State {
let (roll, pitch) = level_from_accel(measured.force);
let yaw = measured.field.map_or(Radians::ZERO, |field| {
heading_from_mag(field, roll, pitch, declination)
});
State {
attitude: Attitude::from_quaternion(UnitQuaternion::from_euler_angles(
roll.as_radians(),
pitch.as_radians(),
yaw.as_radians(),
)),
position: Position::zero(),
velocity: Velocity::zero(),
accel_bias: Acceleration::zero(),
gyro_bias,
..State::default()
}
}
pub(crate) fn level_from_accel(specific_force: Acceleration<Body>) -> (Radians, Radians) {
let f = specific_force.vector();
let roll = RealField::atan2(-f.y, -f.z);
let pitch = RealField::atan2(f.x, ComplexField::sqrt(f.y * f.y + f.z * f.z));
(Radians::from_radians(roll), Radians::from_radians(pitch))
}
pub(crate) fn heading_from_mag(
field: MagField<Body>,
roll: Radians,
pitch: Radians,
declination: Radians,
) -> Radians {
let leveled =
Rotation3::from_euler_angles(roll.as_radians(), pitch.as_radians(), 0.0) * field.vector();
Radians::from_radians(wrap_pi(
declination.as_radians() - RealField::atan2(leveled.y, leveled.x),
))
}
fn window_drift(halves: &Halves) -> (f32, Option<f32>) {
let [first, second] = halves.force;
let tilt = angle_between(first.vector(), second.vector());
let heading = halves.field.map(|[first_field, second_field]| {
let of = |force: Acceleration<Body>, field| {
let (roll, pitch) = level_from_accel(force);
heading_from_mag(field, roll, pitch, Radians::ZERO)
};
wrap_pi(of(second, second_field).as_radians() - of(first, first_field).as_radians()).abs()
});
(tilt, heading)
}
fn angle_between(first: Vector3<f32>, second: Vector3<f32>) -> f32 {
let norms = first.norm() * second.norm();
if norms <= 0.0 {
return 0.0;
}
ComplexField::acos((first.dot(&second) / norms).clamp(-1.0, 1.0))
}
pub(crate) fn heading_sensitivity(
field: MagField<Body>,
down: Vector3<f32>,
) -> Option<HeadingSensitivity> {
let field = field.vector();
let vertical = field.dot(&down);
let horizontal = field - down * vertical;
let norm = horizontal.norm();
(norm > 0.0).then(|| HeadingSensitivity {
tan_dip: vertical.abs() / norm,
horizontal: horizontal / norm,
})
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct HeadingSensitivity {
pub(crate) tan_dip: f32,
pub(crate) horizontal: Vector3<f32>,
}
pub(crate) fn attitude_sigmas(
init: &Initialization,
alignment: Alignment,
measured: &Measured,
gyro_bias: AngularRate<Body>,
gravity: f32,
) -> (Radians, Radians) {
match alignment {
Alignment::Static | Alignment::Seeded => (init.sigma_tilt, init.sigma_yaw),
Alignment::Coarse(_) => coarse_sigmas(init, measured, gyro_bias, gravity),
}
}
fn coarse_sigmas(
init: &Initialization,
measured: &Measured,
gyro_bias: AngularRate<Body>,
gravity: f32,
) -> (Radians, Radians) {
let from_force = (measured.force.vector().norm() - gravity).abs() / gravity;
let net = (measured.rate.vector() - gyro_bias.vector()) * measured.span.as_secs();
let down = measured.down();
let across = down.map_or(net.norm(), |down| (net - down * net.dot(&down)).norm());
let about = down.map_or(0.0, |down| net.dot(&down).abs());
let (tilt_drift, heading_drift) = measured.halves.as_ref().map_or((0.0, None), window_drift);
let tilt = init
.sigma_tilt
.as_radians()
.max(from_force)
.max(across)
.max(tilt_drift);
let circle = UNKNOWN_HEADING_SIGMA.as_radians();
let yaw = down
.zip(measured.field)
.and_then(|(down, field)| heading_sensitivity(field, down))
.map_or(circle, |sensitivity| {
init.sigma_yaw
.as_radians()
.max(sensitivity.tan_dip * tilt)
.max(about)
.max(heading_drift.unwrap_or(0.0))
.min(circle)
});
(Radians::from_radians(tilt), Radians::from_radians(yaw))
}
pub(crate) fn initial_covariance(
init: &Initialization,
attitude: &Attitude,
sigma_tilt: Radians,
sigma_yaw: Radians,
level_scatter: Option<f64>,
gyro_bias: Vector3<f32>,
gravity: f32,
) -> Covariance {
let position = init.sigma_position.as_meters();
let velocity = init.sigma_velocity.as_m_per_s();
let (tilt, yaw) = (sigma_tilt.as_radians(), sigma_yaw.as_radians());
let accel_bias = init.sigma_accel_bias.as_m_per_s2();
#[rustfmt::skip]
let sigmas = [
position, position, position,
velocity, velocity, velocity,
0.0, 0.0, 0.0,
accel_bias, accel_bias, accel_bias,
gyro_bias.x, gyro_bias.y, gyro_bias.z,
];
let explained = accel_bias / gravity;
let gamma = f64::from(gravity);
let level_variance = level_scatter.map(|scatter| (scatter / (gamma * gamma)) as f32);
let independent = level_variance.map_or(tilt * tilt, |level| {
(tilt * tilt - explained * explained).max(level)
});
let tilt_variance = explained * explained + independent;
let mut covariance = Covariance::from_sigmas(sigmas);
covariance.set_attitude_block(
AttitudeVariance {
tilt_north: tilt_variance,
tilt_east: tilt_variance,
heading: yaw * yaw,
}
.in_body(attitude),
);
let down = attitude
.quaternion()
.inverse_transform_vector(&Vector3::z());
covariance.set_attitude_accel_bias_block(skew(down) * (-accel_bias * explained));
covariance
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct GyroBias {
pub estimate: AngularRate<Body>,
pub sigmas: Vector3<f32>,
}
const UNKNOWN_HEADING_SIGMA: Radians = Radians::from_radians(1.813_799_4);
pub(crate) fn at_rest(peaks: Peaks, init: &Initialization, gravity: f32) -> bool {
peaks.gyro <= init.max_gyro_rate && peaks.deviation(gravity) <= init.max_accel_deviation
}
#[cfg(test)]
pub(crate) mod tests {
use std::format;
use super::*;
use crate::config::GRAVITY;
use crate::state::ErrorState;
fn measure(window: &[StaticSample]) -> Result<Measured, InitError> {
StaticWindow::try_from(window)?.measured()
}
fn alpha0(window: &[StaticSample]) -> Option<(Altitude, f32)> {
StaticWindow::try_from(spaced(window, DT).as_slice())
.expect("a usable window")
.alpha0()
}
fn inertial_acceleration(window: &[StaticSample]) -> Option<Acceleration<Ned>> {
measure(window).expect("a usable window").inertial_accel
}
#[test]
fn a_coarse_start_reads_as_what_was_measured() {
let short = Alignment::Coarse(Coarse::WindowTooShort {
required: Seconds::from_secs(2.0),
provided: Seconds::from_secs(0.8),
});
assert_eq!(
format!("{short}"),
"coarse, window of 0.800 s, 2.000 s required"
);
assert_eq!(
format!(
"{}",
InitError::InvalidInterval {
interval: Seconds::from_secs(-0.0025)
}
),
"initialization interval of -0.003 s is not usable"
);
}
pub(crate) fn still() -> StaticSample {
StaticSample {
imu: ImuSample::reading(
AngularRate::body(0.0, 0.0, 0.0),
Acceleration::body(0.0, 0.0, -GRAVITY),
),
..StaticSample::default()
}
}
const DT: Seconds = Seconds::from_secs(0.25);
pub(crate) fn spaced(window: &[StaticSample], dt: Seconds) -> std::vec::Vec<StaticSample> {
let mut time = Timestamp::ZERO;
window
.iter()
.map(|sample| {
time = time.after(dt);
StaticSample {
imu: sample.imu.timed(time, dt),
..*sample
}
})
.collect()
}
fn classify_default(window: &[StaticSample], dt: Seconds) -> Result<Alignment, InitError> {
Ok(classify(
&measure(&spaced(window, dt))?,
&Initialization::default(),
GRAVITY,
))
}
fn nominal(window: &[StaticSample], declination: Radians, at_rest: bool) -> State {
let measured = measure(&spaced(window, DT)).expect("a usable window");
let bias = measured.gyro_bias(at_rest, &Config::default()).estimate;
nominal_state(&measured, declination, bias)
}
fn sigmas(window: &[StaticSample], dt: Seconds) -> (f32, f32) {
let init = Initialization::default();
let measured = measure(&spaced(window, dt)).expect("a usable window");
let bias = measured
.gyro_bias(at_rest(measured.peaks, &init, GRAVITY), &Config::default())
.estimate;
let state = nominal_state(&measured, Radians::ZERO, bias);
let (tilt, yaw) = attitude_sigmas(
&init,
classify(&measured, &init, GRAVITY),
&measured,
state.gyro_bias,
GRAVITY,
);
(tilt.as_radians(), yaw.as_radians())
}
fn coarse_tilt(window: &[StaticSample]) -> f32 {
sigmas(window, DT).0
}
fn dip_gain() -> f32 {
let (sin, cos) = ComplexField::sin_cos(INCLINATION);
sin / cos
}
const INCLINATION: f32 = 1.107;
const TILTS: [(f32, f32); 6] = [
(0.0, 0.0),
(0.3, 0.0),
(-0.3, 0.0),
(0.0, 0.2),
(0.0, -0.2),
(0.4, -0.25),
];
fn attitude_of(roll: f32, pitch: f32, yaw: f32) -> Rotation3<f32> {
Rotation3::from_euler_angles(roll, pitch, yaw)
}
pub(crate) fn gravity_at(roll: f32, pitch: f32, yaw: f32) -> Acceleration<Body> {
let f = attitude_of(roll, pitch, yaw).inverse() * Vector3::new(0.0, 0.0, -GRAVITY);
Acceleration::from_vector(f)
}
fn field_at(roll: f32, pitch: f32, yaw: f32, declination: f32) -> MagField<Body> {
let (sin_dip, cos_dip) = ComplexField::sin_cos(INCLINATION);
let north = attitude_of(0.0, 0.0, declination) * Vector3::new(cos_dip, 0.0, sin_dip);
MagField::from_vector(attitude_of(roll, pitch, yaw).inverse() * north)
}
pub(crate) fn turning(roll_rate: f32, pitch_rate: f32, yaw_rate: f32) -> [StaticSample; 8] {
let mut window = [still(); 8];
for (index, sample) in window.iter_mut().enumerate() {
let t = index as f32 * DT.as_secs();
let (roll, pitch, yaw) = (roll_rate * t, pitch_rate * t, yaw_rate * t);
sample.imu = sample.imu.with_accel(gravity_at(roll, pitch, yaw));
sample.imu = sample
.imu
.with_gyro(AngularRate::body(roll_rate, pitch_rate, yaw_rate));
sample.mag = Some(field_at(roll, pitch, yaw, 0.0));
}
window
}
fn window_at(roll: f32, pitch: f32, yaw: f32, declination: f32) -> [StaticSample; 8] {
[StaticSample {
imu: still().imu.with_accel(gravity_at(roll, pitch, yaw)),
mag: Some(field_at(roll, pitch, yaw, declination)),
..still()
}; 8]
}
#[test]
fn the_tilt_prior_predicts_the_level_error_a_biased_accelerometer_causes() {
let init = Initialization::default();
let variance = init.sigma_accel_bias.as_m_per_s2().powi(2);
let bias = Vector3::new(0.15, -0.1, 0.05);
for (roll, pitch) in TILTS {
let truth = attitude_of(roll, pitch, 0.7);
let window = [StaticSample {
imu: still().imu.with_accel(Acceleration::from_vector(
gravity_at(roll, pitch, 0.7).vector() + bias,
)),
mag: Some(field_at(roll, pitch, 0.7, 0.0)),
..still()
}; 8];
let state = nominal(&window, Radians::ZERO, true);
let committed = state.attitude.quaternion();
let error =
(committed.inverse() * UnitQuaternion::from_rotation_matrix(&truth)).scaled_axis();
let measured = measure(&spaced(&window, DT)).expect("a usable window");
let p = initial_covariance(
&init,
&state.attitude,
init.sigma_tilt,
init.sigma_yaw,
measured.level_scatter,
Vector3::repeat(init.sigma_gyro_bias.as_rad_per_s()),
GRAVITY,
);
let theta = ErrorState::AttitudeX.index();
let beta = ErrorState::AccelBiasX.index();
let predicted = p.as_matrix().fixed_view::<3, 3>(theta, beta) * bias / variance;
let down = committed.inverse_transform_vector(&Vector3::z());
let across = |v: Vector3<f32>| v - down * v.dot(&down);
assert!(
(across(error) - across(predicted)).norm() < 2e-4,
"roll {roll} pitch {pitch}: leveled {:?}, predicted {:?}",
across(error),
across(predicted)
);
}
}
#[test]
fn the_level_variance_is_the_scatter_across_gravity_over_the_count() {
let mut window = [still(); 8];
for (index, sample) in window.iter_mut().enumerate() {
let shake = if index % 2 == 0 { 0.5 } else { -0.5 };
sample.imu = sample
.imu
.with_accel(Acceleration::body(shake, 0.0, -GRAVITY + shake));
}
let level = measure(&spaced(&window, DT))
.expect("a usable window")
.level_scatter
.expect("eight samples scatter");
let expected = 0.25 * 8.0 / 7.0 / 2.0 / 8.0;
assert!(
(level - expected).abs() < 1e-3 * expected,
"expected {expected}, got {level}"
);
assert_eq!(
measure(&spaced(&[still()], DT))
.expect("one sample")
.level_scatter,
None
);
}
fn rates(n: usize, dt: f32, rate: impl Fn(usize) -> [f32; 3]) -> Measured {
let window: std::vec::Vec<_> = (0..n)
.map(|index| {
let [x, y, z] = rate(index);
StaticSample {
imu: still().imu.with_gyro(AngularRate::body(x, y, z)),
..still()
}
})
.collect();
measure(&spaced(&window, Seconds::from_secs(dt))).expect("a usable window")
}
#[test]
fn a_still_window_weighs_its_mean_rate_by_the_scatter_of_its_blocks() {
let (b, c) = ([1e-3f32, 4e-3, 2e-3], 3e-3f32);
let sign = |index: usize| {
if (index / 5).is_multiple_of(2) {
1.0
} else {
-1.0
}
};
let measured = rates(203, 0.01, |index| b.map(|b| c + sign(index) * b));
let config = Config::default();
let prior = f64::from(config.init.sigma_gyro_bias.as_rad_per_s()).powi(2);
let floor = f64::from(EARTH_RATE.as_rad_per_s());
let bias = measured.gyro_bias(true, &config);
for (axis, b) in b.into_iter().enumerate() {
let b = f64::from(b);
let r = b * b * 0.05 * 40.0 / 39.0 * 39.0 / 37.0 / 2.03;
let k = prior / (prior + r);
let mean = f64::from(c) + b * 0.03 / 2.03;
let sigma = (k * r + floor * floor).sqrt();
assert!(
(f64::from(bias.sigmas[axis]) - sigma).abs() < 1e-3 * sigma,
"axis {axis}: sigma {sigma}, got {}",
bias.sigmas[axis]
);
let estimate = f64::from(bias.estimate.vector()[axis]);
assert!(
(estimate - k * mean).abs() < 1e-3 * mean,
"axis {axis}: estimate {}, got {estimate}",
k * mean
);
}
let moving = measured.gyro_bias(false, &config);
assert_eq!(moving.estimate, AngularRate::zero());
assert_eq!(
moving.sigmas,
Vector3::repeat(config.init.sigma_gyro_bias.as_rad_per_s())
);
}
#[test]
fn a_window_of_few_blocks_is_charged_for_how_little_it_knows_its_scatter() {
let b = 2e-3f64;
let config = Config::default();
let prior = f64::from(config.init.sigma_gyro_bias.as_rad_per_s()).powi(2);
let floor = f64::from(EARTH_RATE.as_rad_per_s());
let alternating = |index: usize| [(if index.is_multiple_of(2) { b } else { -b }) as f32; 3];
let sigma = |r: f64| (prior * r / (prior + r) + floor * floor).sqrt();
let four = rates(4, 0.05, alternating).gyro_bias(true, &config);
let expected = sigma(b * b * 0.05 * 4.0 / 3.0 * 3.0 / 0.2);
assert!(
(f64::from(four.sigmas.x) - expected).abs() < 1e-3 * expected,
"{} against {expected}",
four.sigmas.x
);
let three = rates(3, 0.05, alternating).gyro_bias(true, &config);
let white = f64::from(config.imu.gyro_white);
let expected = sigma(white * white / 0.15);
assert!(
(f64::from(three.sigmas.x) - expected).abs() < 1e-3 * expected,
"{} against {expected}",
three.sigmas.x
);
}
#[test]
fn one_sample_is_weighed_by_its_own_noise_and_left_mostly_to_the_prior() {
let config = Config::default();
let measured = rates(1, 0.005, |_| [0.05; 3]);
let bias = measured.gyro_bias(true, &config);
let prior = config.init.sigma_gyro_bias.as_rad_per_s();
assert!(
bias.sigmas.x < prior && bias.sigmas.x > 0.99 * prior,
"{:?}",
bias.sigmas
);
assert!(bias.estimate.vector().x.abs() < 1e-3, "{:?}", bias.estimate);
}
#[test]
fn the_mean_rate_weighs_each_sample_by_the_time_it_integrated() {
let window: std::vec::Vec<_> = (0..40)
.map(|index| {
let (rate, dt) = if index % 2 == 0 {
(0.01, 0.001)
} else {
(0.0, 0.009)
};
StaticSample {
imu: still()
.imu
.with_gyro(AngularRate::body(rate, rate, rate))
.timed(
Timestamp::from_micros(index as u64 * 10_000 + 10_000),
Seconds::from_secs(dt),
),
..still()
}
})
.collect();
let rate = measure(&window).expect("a usable window").rate.vector().x;
assert!((rate - 0.001).abs() < 1e-6, "got {rate}");
}
#[test]
fn a_scattered_window_keeps_tilt_uncertain_once_the_bias_is_known() {
let init = Initialization::default();
let level = 4e-6;
let p = initial_covariance(
&init,
&Attitude::default(),
init.sigma_tilt,
init.sigma_yaw,
Some(f64::from(level) * f64::from(GRAVITY) * f64::from(GRAVITY)),
Vector3::repeat(init.sigma_gyro_bias.as_rad_per_s()),
GRAVITY,
);
let m = p.as_matrix();
let (theta, beta) = (
ErrorState::AttitudeX.index(),
ErrorState::AccelBiasX.index(),
);
let p_tb = m.fixed_view::<3, 3>(theta, beta);
let p_bb = m.fixed_view::<3, 3>(beta, beta);
let given_bias = m.fixed_view::<3, 3>(theta, theta)
- p_tb * p_bb.try_inverse().expect("a bias prior") * p_tb.transpose();
for axis in 0..2 {
let left = given_bias[(axis, axis)];
assert!((left - level).abs() < 1e-7, "axis {axis}: {left}");
}
}
fn committed_angles(state: &State) -> (f32, f32, f32) {
state.attitude.euler_angles()
}
#[test]
fn leveling_recovers_the_tilt_gravity_was_rotated_by() {
for (roll, pitch) in TILTS {
let (measured_roll, measured_pitch) = level_from_accel(gravity_at(roll, pitch, 0.9));
assert!(
(measured_roll.as_radians() - roll).abs() < 1e-5
&& (measured_pitch.as_radians() - pitch).abs() < 1e-5,
"({roll}, {pitch}) read back as ({:?}, {:?})",
measured_roll,
measured_pitch
);
}
}
#[test]
fn the_committed_attitude_carries_the_specific_force_back_onto_gravity() {
for (roll, pitch) in TILTS {
let window = window_at(roll, pitch, 0.0, 0.0);
let state = nominal(&window, Radians::ZERO, true);
let navigation = state.attitude.quaternion() * window[0].imu.specific_force().vector();
assert!(
(navigation - Vector3::new(0.0, 0.0, -GRAVITY)).norm() < 1e-4,
"({roll}, {pitch}) rotated back to {navigation:?}"
);
}
}
#[test]
fn heading_survives_the_tilt_it_is_leveled_by() {
const DECLINATION: f32 = -0.06;
for (roll, pitch) in TILTS {
for yaw in [0.0, 0.9, -2.5, 3.0] {
let state = nominal(
&window_at(roll, pitch, yaw, DECLINATION),
Radians::from_radians(DECLINATION),
true,
);
let (_, _, committed) = committed_angles(&state);
assert!(
wrap_pi(committed - yaw).abs() < 1e-4,
"yaw {yaw} at ({roll}, {pitch}) read back as {committed}"
);
}
}
}
#[test]
fn an_unleveled_heading_is_wrong_by_the_dip() {
let roll = 10.0f32.to_radians();
let field = field_at(roll, 0.0, 0.0, 0.0).vector();
let unleveled = RealField::atan2(field.y, field.x);
assert!(
(unleveled.to_degrees().abs() - 19.1).abs() < 0.1,
"expected about 19 deg of error, got {}",
unleveled.to_degrees()
);
let leveled = heading_from_mag(
MagField::from_vector(field),
Radians::from_radians(roll),
Radians::ZERO,
Radians::ZERO,
);
assert!(leveled.as_radians().abs() < 1e-6, "{leveled:?}");
}
#[test]
fn declination_moves_the_heading_by_exactly_itself() {
let (roll, pitch) = (0.2, -0.1);
let field = field_at(roll, pitch, 0.7, 0.0);
let heading = |declination: f32| {
heading_from_mag(
field,
Radians::from_radians(roll),
Radians::from_radians(pitch),
Radians::from_radians(declination),
)
.as_radians()
};
assert!((heading(0.35) - heading(0.0) - 0.35).abs() < 1e-6);
assert!((heading(-0.06) - heading(0.0) + 0.06).abs() < 1e-6);
}
#[test]
fn a_window_with_no_magnetometer_keeps_a_heading_of_zero() {
let state = nominal(&[still(); 8], Radians::from_radians(0.35), true);
assert_eq!(committed_angles(&state).2, 0.0);
}
#[test]
fn a_still_window_takes_its_gyroscope_bias_from_the_average() {
let offset = AngularRate::body(0.01, -0.02, 0.003);
let window: [StaticSample; 8] = core::array::from_fn(|index| {
let noise = if index % 2 == 0 { 1e-4 } else { -1e-4 };
StaticSample {
imu: still()
.imu
.with_gyro(AngularRate::from_vector(offset.vector().add_scalar(noise))),
..still()
}
});
let state = nominal(&window, Radians::ZERO, true);
assert!(
(state.gyro_bias.vector() - offset.vector()).norm() < 1e-5,
"{:?}",
state.gyro_bias
);
}
#[test]
fn a_window_taken_in_motion_takes_no_gyroscope_bias_at_all() {
let window = [StaticSample {
imu: still().imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0)),
..still()
}; 8];
let state = nominal(&window, Radians::ZERO, false);
assert_eq!(state.gyro_bias, AngularRate::zero());
}
#[test]
fn a_still_window_of_min_duration_is_static() {
assert_eq!(classify_default(&[still(); 8], DT), Ok(Alignment::Static));
}
#[test]
fn the_static_window_is_measured_in_seconds_not_samples() {
let alignment = classify_default(&[still(); 8], Seconds::from_secs(0.1));
assert!(matches!(
alignment,
Ok(Alignment::Coarse(Coarse::WindowTooShort { .. }))
));
}
#[test]
fn a_short_window_that_moved_is_not_stationary_rather_than_too_short() {
let mut window = [still(); 8];
window[3].imu = window[3].imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
let alignment = classify_default(&window, Seconds::from_secs(0.1));
let Ok(Alignment::Coarse(Coarse::NotStationary { span, .. })) = alignment else {
panic!("0.8 s and moving: {alignment:?}");
};
assert!((span.as_secs() - 0.8).abs() < 1e-6, "got {span:?}");
}
#[test]
fn a_moving_window_is_coarse_and_reports_what_it_measured() {
let mut window = [still(); 8];
window[3].imu = window[3].imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
let alignment = classify_default(&window, DT).expect("moving, not unusable");
let Alignment::Coarse(Coarse::NotStationary { peak_gyro, .. }) = alignment else {
panic!("0.4 rad/s is over the 0.262 default: {alignment:?}");
};
let peak_gyro = peak_gyro.as_rad_per_s();
assert!((peak_gyro - 0.4).abs() < 1e-6, "got {peak_gyro}");
}
#[test]
fn a_sample_short_of_gravity_moves_the_window_as_one_over_it_does() {
let mut window = [still(); 8];
window[2].imu = window[2]
.imu
.with_accel(Acceleration::body(0.0, 0.0, -GRAVITY + 3.0));
window[5].imu = window[5]
.imu
.with_accel(Acceleration::body(0.0, 0.0, -GRAVITY - 1.0));
let measured = measure(&spaced(&window, DT)).expect("a usable window");
let deviation = measured.peaks.deviation(GRAVITY).as_m_per_s2();
assert!((deviation - 3.0).abs() < 1e-5, "{deviation}");
let lighter = measured.peaks.deviation(GRAVITY - 0.5).as_m_per_s2();
assert!((lighter - 2.5).abs() < 1e-5, "{lighter}");
assert_eq!(Peaks::NONE.deviation(GRAVITY).as_m_per_s2(), 0.0);
}
#[test]
fn a_coarse_start_widens_tilt_in_proportion_to_the_motion_it_saw() {
let mut window = [still(); 8];
window[0].imu =
window[0]
.imu
.with_accel(Acceleration::body(0.0, 0.0, -GRAVITY - 2.941_995));
let tilt = coarse_tilt(&window);
assert!(
(tilt - 0.0375).abs() < 1e-4,
"an eighth of the spike is 0.0375 rad; charging the peak reads 0.3, got {tilt}"
);
}
#[test]
fn vibration_averages_out_of_the_tilt_where_a_peak_charges_all_of_it() {
let mut window = [still(); 8];
for (index, sample) in window.iter_mut().enumerate() {
let shake = if index % 2 == 0 { 3.0 } else { -3.0 };
sample.imu = sample
.imu
.with_accel(Acceleration::body(0.0, 0.0, -GRAVITY + shake));
}
let tilt = coarse_tilt(&window);
assert!(
(tilt - Initialization::default().sigma_tilt.as_radians()).abs() < 1e-6,
"nothing above the floor; the peak of 3 m/s^2 would charge 0.306, got {tilt}"
);
}
#[test]
fn a_turn_about_gravity_charges_the_heading_and_leaves_the_tilt_alone() {
let (tilt, yaw) = sigmas(&turning(0.0, 0.0, 0.4), DT);
assert!(
(tilt - Initialization::default().sigma_tilt.as_radians()).abs() < 1e-6,
"a turn about gravity is worth no tilt; without the split about `down` this \
reads 0.8, got {tilt}"
);
assert!(
(yaw - 0.8).abs() < 1e-4,
"0.4 rad/s over a 2 s window, all of it heading, got {yaw}"
);
}
#[test]
fn a_coordinated_turn_is_charged_although_its_averages_all_agree() {
let mut window = [still(); 8];
for sample in &mut window {
sample.imu = sample.imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
}
let tilt = coarse_tilt(&window);
assert!(
(tilt - 0.8).abs() < 1e-4,
"0.4 rad/s over a 2 s window is 0.8 rad of turn across gravity, and the \
window's own averages say nothing at all, got {tilt}"
);
}
#[test]
fn a_window_that_swings_out_and_back_is_charged_the_excursion() {
const PITCH: [f32; 8] = [0.0, 0.2, 0.4, 0.2, 0.0, -0.2, -0.4, -0.2];
const RATE: [f32; 8] = [0.8, 0.8, -0.8, -0.8, -0.8, -0.8, 0.8, 0.8];
let mut window = [still(); 8];
for ((sample, pitch), rate) in window.iter_mut().zip(PITCH).zip(RATE) {
sample.imu = sample.imu.with_accel(gravity_at(0.0, pitch, 0.0));
sample.imu = sample.imu.with_gyro(AngularRate::body(0.0, rate, 0.0));
}
let tilt = coarse_tilt(&window);
assert!(
(tilt - 0.4).abs() < 1e-4,
"the halves mean +0.2 and -0.2 rad; on the net rotation alone this reads the \
0.0297 rad the shortened average is worth, got {tilt}"
);
}
#[test]
fn a_still_short_window_keeps_the_heading_its_magnetometer_observed() {
let window = window_at(0.0, 0.0, 0.9, 0.0);
let (tilt, yaw) = sigmas(&window, Seconds::from_secs(0.1));
let init = Initialization::default();
assert!(
(tilt - init.sigma_tilt.as_radians()).abs() < 1e-6,
"got {tilt}"
);
assert!(
(yaw - init.sigma_yaw.as_radians()).abs() < 1e-6,
"the configured prior, not the 1.81 rad of a heading nothing observed, got {yaw}"
);
}
#[test]
fn a_parked_vehicles_gyroscope_bias_is_not_also_charged_as_motion() {
let mut window = window_at(0.0, 0.0, 0.0, 0.0);
for (index, sample) in window.iter_mut().enumerate() {
let noise = if index % 2 == 0 { 1e-4 } else { -1e-4 };
sample.imu = sample
.imu
.with_gyro(AngularRate::body(0.05 + noise, noise, noise));
}
let dt = Seconds::from_secs(0.2);
let init = Initialization::default();
let measured = measure(&spaced(&window, dt)).expect("a usable window");
assert!(
at_rest(measured.peaks, &init, GRAVITY),
"0.05 rad/s is inside 0.262"
);
let bias = measured
.gyro_bias(true, &Config::default())
.estimate
.vector();
assert!(
(bias.x - 0.05).abs() < 1e-5,
"(7) commits the average, got {bias:?}"
);
let (tilt, yaw) = sigmas(&window, dt);
assert!(
(tilt - init.sigma_tilt.as_radians()).abs() < 1e-6,
"charging ω̄ rather than ω̄ − β̂_g reads 0.08 rad here, over the 0.0524 (3°) of \
`ALIGNED_TILT`, and latches `Aligning` for the flight; got {tilt}"
);
assert!(
(yaw - init.sigma_yaw.as_radians()).abs() < 1e-6,
"the configured prior; a bias about gravity would charge the heading the \
same way, got {yaw}"
);
}
#[test]
fn a_window_no_magnetometer_observed_gets_the_circle_however_still_it_was() {
let (tilt, yaw) = sigmas(&[still(); 8], Seconds::from_secs(0.1));
assert!(
(tilt - Initialization::default().sigma_tilt.as_radians()).abs() < 1e-6,
"got {tilt}"
);
assert!(
(yaw - UNKNOWN_HEADING_SIGMA.as_radians()).abs() < 1e-6,
"got {yaw}"
);
}
#[test]
fn a_window_charges_its_heading_the_tilt_it_leveled_by() {
let mut window = window_at(0.0, 0.0, 0.9, 0.0);
for sample in &mut window {
let leaning = sample.imu.specific_force().vector() - Vector3::new(0.0, 0.0, 2.941_995);
sample.imu = sample.imu.with_accel(Acceleration::from_vector(leaning));
}
let (tilt, yaw) = sigmas(&window, DT);
assert!((tilt - 0.3).abs() < 1e-4, "2.942 / g, got {tilt}");
assert!(
(yaw - dip_gain() * tilt).abs() < 1e-4,
"tan(dip) is {}, so the yaw is {}, got {yaw}",
dip_gain(),
dip_gain() * tilt
);
}
#[test]
fn a_heading_bound_saturates_at_the_circle_it_cannot_be_wider_than() {
let (_, yaw) = sigmas(&turning(0.0, 0.0, 2.0), DT);
assert!(
(yaw - UNKNOWN_HEADING_SIGMA.as_radians()).abs() < 1e-6,
"pi/sqrt(3) is the widest a heading gets, got {yaw}"
);
}
#[test]
fn the_heading_sensitivity_is_the_tangent_of_the_dip_at_any_tilt() {
for (roll, pitch) in TILTS {
let down = -gravity_at(roll, pitch, 0.4).vector().normalize();
let measured = heading_sensitivity(field_at(roll, pitch, 0.4, 0.0), down)
.expect("a field with a horizontal part")
.tan_dip;
assert!(
(measured - dip_gain()).abs() < 1e-4,
"at ({roll}, {pitch}) got {measured}, want {}",
dip_gain()
);
}
}
#[test]
fn a_field_straight_down_observes_no_heading_at_any_tilt() {
let down = Vector3::new(0.0, 0.0, 1.0);
assert_eq!(
heading_sensitivity(MagField::body(0.0, 0.0, 0.5), down),
None
);
assert_eq!(
heading_sensitivity(MagField::body(0.0, 0.0, 0.0), down),
None
);
}
#[test]
fn an_accelerometer_averaging_to_zero_is_bounded_and_not_made_a_nan() {
let mut window = [still(); 8];
for sample in &mut window {
sample.imu = sample.imu.with_accel(Acceleration::body(0.0, 0.0, 0.0));
sample.imu = sample.imu.with_gyro(AngularRate::body(0.0, 0.6, 0.0));
sample.mag = Some(MagField::body(0.22, 0.0, 0.44));
}
let (tilt, yaw) = sigmas(&window, DT);
assert!(
(tilt - 1.2).abs() < 1e-6,
"0.6 rad/s over 2 s, above the 1.0 rad that |0 - g| / g is worth, got {tilt}"
);
assert!(
(yaw - UNKNOWN_HEADING_SIGMA.as_radians()).abs() < 1e-6,
"no down means no dip to scale by, got {yaw}"
);
}
#[test]
fn a_window_of_exactly_the_minimum_duration_is_long_enough_at_any_rate() {
for (dt, samples) in [(0.02, 100), (0.01, 200), (0.005, 400), (0.0025, 800)] {
let window = std::vec![still(); samples];
assert_eq!(
classify_default(&window, Seconds::from_secs(dt)),
Ok(Alignment::Static),
"{samples} samples of {dt} s"
);
}
}
#[test]
fn an_empty_window_or_an_unusable_interval_is_still_an_error() {
assert_eq!(classify_default(&[], DT), Err(InitError::NoSamples));
let zero = Seconds::from_secs(0.0);
assert_eq!(
classify_default(&[still(); 8], zero),
Err(InitError::InvalidInterval { interval: zero })
);
let mut window = spaced(&[still(); 8], DT);
let backwards = Seconds::from_secs(-0.1);
window[5].imu.velocity_interval = backwards;
assert_eq!(
measure(&window).map(|_| ()),
Err(InitError::InvalidInterval {
interval: backwards
})
);
let mut poisoned = [still(); 8];
poisoned[2].imu = poisoned[2]
.imu
.with_accel(Acceleration::body(f32::NAN, 0.0, 0.0));
assert_eq!(classify_default(&poisoned, DT), Err(InitError::NotFinite));
}
#[test]
fn a_window_timed_other_than_forward_is_refused() {
let unstamped: std::vec::Vec<_> = spaced(&[still(); 8], DT)
.into_iter()
.map(|s| StaticSample {
imu: s.imu.timed(Timestamp::ZERO, DT),
..s
})
.collect();
assert_eq!(
measure(&unstamped).map(|_| ()),
Err(InitError::InvalidStep {
dt: Seconds::from_secs(0.0)
})
);
let mut reversed = spaced(&[still(); 8], DT);
reversed.reverse();
assert_eq!(
measure(&reversed).map(|_| ()),
Err(InitError::InvalidStep {
dt: Seconds::from_secs(-0.25)
})
);
}
#[test]
fn the_baro_reference_is_the_mean_over_the_window() {
let window =
[99.0, 101.0, 100.0, 99.5, 100.5, 100.0, 99.8, 100.2].map(|altitude| StaticSample {
baro: Some(Altitude::from_meters(altitude)),
..still()
});
let (reference, variance) = alpha0(&window).expect("the window carried barometer samples");
assert!((variance - 2.58 / 7.0 / 8.0).abs() < 1e-6, "{variance}");
assert!(
(reference.as_meters() - 100.0).abs() < 1e-4,
"mean of the window is 100 m, got {}",
reference.as_meters()
);
}
#[test]
fn samples_without_a_barometer_stay_out_of_the_mean() {
let mut window = [still(); 8];
window[0].baro = Some(Altitude::from_meters(10.0));
window[7].baro = Some(Altitude::from_meters(20.0));
assert_eq!(alpha0(&window), Some((Altitude::from_meters(15.0), 25.0)));
}
#[test]
fn a_held_reading_counts_once() {
let window =
[10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0].map(|altitude| StaticSample {
baro: Some(Altitude::from_meters(altitude)),
..still()
});
assert_eq!(alpha0(&window), Some((Altitude::from_meters(15.0), 25.0)));
let held = [StaticSample {
baro: Some(Altitude::from_meters(10.0)),
..still()
}; 8];
assert_eq!(alpha0(&held), None);
}
#[test]
fn one_reading_is_no_reference_because_it_has_no_scatter_to_measure() {
let mut window = [still(); 8];
window[3].baro = Some(Altitude::from_meters(10.0));
assert_eq!(alpha0(&window), None);
}
#[test]
fn the_barometer_scatter_is_the_two_pass_one_at_any_altitude() {
let window: std::vec::Vec<_> = (0..400)
.map(|i| StaticSample {
baro: Some(Altitude::from_meters(
10_000.0 + 0.001 * ((i * 37 % 101) as f32 - 50.0),
)),
..still()
})
.collect();
let readings: std::vec::Vec<f64> = window
.iter()
.filter_map(|s| s.baro)
.map(|a| f64::from(a.as_meters()))
.collect();
let n = readings.len() as f64;
let mean = readings.iter().sum::<f64>() / n;
let scatter = readings
.iter()
.map(|a| (a - mean) * (a - mean))
.sum::<f64>()
/ (n - 1.0);
let squares = readings.iter().map(|a| a * a).sum::<f64>();
let naive = (squares - n * mean * mean) / (n - 1.0);
let lost = ((naive - scatter) / scatter).abs();
assert!(lost > 1e-5, "{lost}");
let (reference, variance) = alpha0(&window).expect("400 readings");
assert_eq!(reference, Altitude::from_meters(mean as f32));
let expected = (scatter / n) as f32;
assert!(
((variance - expected) / expected).abs() < 1e-6,
"{variance} against {expected}"
);
}
fn noise(window: &[StaticSample]) -> Option<WindowNoise> {
StaticWindow::try_from(window)
.expect("a usable window")
.noise(&Config::default())
}
fn built(
n: usize,
angle: f32,
velocity: f32,
gyro: impl Fn(usize) -> Vector3<f32>,
accel: impl Fn(usize) -> Vector3<f32>,
) -> std::vec::Vec<StaticSample> {
let mut time = Timestamp::ZERO;
(0..n)
.map(|i| {
time = time.after(Seconds::from_secs(angle));
let force = accel(i) + Vector3::new(0.0, 0.0, -GRAVITY);
StaticSample {
imu: ImuSample {
time,
delta_angle: crate::units::DeltaAngle::from_vector(gyro(i) * angle),
angle_interval: Seconds::from_secs(angle),
delta_velocity: crate::units::DeltaVelocity::from_vector(force * velocity),
velocity_interval: Seconds::from_secs(velocity),
},
..still()
}
})
.collect()
}
fn flip(i: usize, every: usize) -> f32 {
if (i / every).is_multiple_of(2) {
1.0
} else {
-1.0
}
}
fn assert_close(got: [f32; 3], want: Vector3<f32>, tolerance: f32) {
for (g, w) in got.iter().zip(want.iter()) {
assert!(((g - w) / w).abs() < tolerance, "{got:?} against {want:?}");
}
}
#[test]
fn the_density_is_the_block_scatter_times_the_root_of_the_block() {
let (gyro, accel) = (Vector3::new(0.01, 0.02, 0.03), Vector3::new(0.1, 0.2, 0.3));
let window = built(
50,
0.01,
0.01,
|i| gyro * flip(i, 5),
|i| accel * flip(i, 5),
);
let reported = noise(&window).expect("ten still blocks");
let factor = (0.05f32 * 10.0 / 9.0).sqrt();
assert_close(reported.gyro_white, gyro * factor, 1e-5);
assert_close(reported.accel_white, accel * factor, 1e-5);
assert_eq!(reported.blocks, 10);
}
#[test]
fn a_rate_alternating_sample_by_sample_cancels_within_a_block() {
let a = Vector3::new(0.01, 0.02, 0.03);
let window = built(
200,
0.005,
0.005,
|i| a * flip(i, 1),
|i| a * 10.0 * flip(i, 1),
);
let reported = noise(&window).expect("twenty still blocks");
assert!(
Vector3::from(reported.gyro_white).max() < 1e-6,
"{:?}",
reported.gyro_white
);
assert!(
Vector3::from(reported.accel_white).max() < 1e-5,
"{:?}",
reported.accel_white
);
}
#[test]
fn each_sensor_is_blocked_by_its_own_interval() {
let c = Vector3::new(1.0e-3, 2.0e-3, 3.0e-3);
let window = built(100, 0.01, 0.03, |i| c * flip(i, 5), |i| c * flip(i, 2));
let reported = noise(&window).expect("still");
assert_close(
reported.gyro_white,
c * (0.05f32 * 20.0 / 19.0).sqrt(),
1e-5,
);
assert_close(
reported.accel_white,
c * (0.06f32 * 50.0 / 49.0).sqrt(),
1e-3,
);
assert_eq!(reported.blocks, 20);
}
#[test]
fn the_worst_axis_is_the_largest_whichever_it_is() {
let (gyro, accel) = (Vector3::new(0.02, 0.03, 0.01), Vector3::new(0.3, 0.1, 0.2));
let window = built(
50,
0.01,
0.01,
|i| gyro * flip(i, 5),
|i| accel * flip(i, 5),
);
let reported = noise(&window).expect("ten still blocks");
assert_eq!(reported.worst_gyro_white(), reported.gyro_white[1]);
assert_eq!(reported.worst_accel_white(), reported.accel_white[0]);
}
fn white(seconds: f32, dt: f32, n_gyro: f32, n_accel: f32) -> std::vec::Vec<StaticSample> {
let mut state: u64 = 0x2545_f491_4f6c_dd1d;
let mut uniform = move || {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((state >> 11) as f64 + 0.5) / (1u64 << 53) as f64
};
let mut gaussian = move || {
let (u, v) = (uniform(), uniform());
((-2.0 * u.ln()).sqrt() * (2.0 * core::f64::consts::PI * v).cos()) as f32
};
let noise: std::vec::Vec<_> = (0..(seconds / dt) as usize)
.map(|_| {
let mut draw = || Vector3::new(gaussian(), gaussian(), gaussian());
(draw() * n_gyro, draw() * n_accel)
})
.collect();
let scale = 1.0 / dt.sqrt();
built(
noise.len(),
dt,
dt,
|i| noise[i].0 * scale,
|i| noise[i].1 * scale,
)
}
#[test]
fn a_sensors_white_noise_density_is_recovered_at_any_rate() {
let (n_gyro, n_accel) = (2.6e-4, 2.0e-3);
for rate in [50.0f32, 100.0, 400.0] {
let reported = noise(&white(10.0, 1.0 / rate, n_gyro, n_accel)).expect("still");
assert_close(reported.gyro_white, Vector3::repeat(n_gyro), 0.15);
assert_close(reported.accel_white, Vector3::repeat(n_accel), 0.15);
}
}
#[test]
fn the_barometer_floor_is_the_variance_of_its_distinct_readings() {
let window: std::vec::Vec<_> = (0..27)
.map(|i| StaticSample {
baro: Some(Altitude::from_meters((i / 3) as f32 + 1.0)),
..still()
})
.collect();
let reported = noise(&spaced(&window, WindowNoise::BLOCK)).expect("still");
assert_eq!(reported.baro, Some(AltitudeNoise::from_variance(7.5)));
assert_eq!(reported.baro_readings, 9);
}
#[test]
fn too_few_readings_report_nothing_for_that_sensor() {
let window: std::vec::Vec<_> = (0..27)
.map(|i| StaticSample {
baro: Some(Altitude::from_meters((i / 4) as f32)),
..still()
})
.collect();
let reported = noise(&spaced(&window, WindowNoise::BLOCK)).expect("still");
assert_eq!((reported.baro, reported.baro_readings), (None, 7));
let few = [still(); (WindowNoise::MIN_READINGS - 1) as usize];
assert_eq!(noise(&spaced(&few, WindowNoise::BLOCK)), None);
let enough = [still(); WindowNoise::MIN_READINGS as usize];
assert!(noise(&spaced(&enough, WindowNoise::BLOCK)).is_some());
}
#[test]
fn a_window_that_moved_reports_no_noise() {
let mut window = [still(); 20];
window[10].imu = ImuSample::reading(
AngularRate::body(0.0, 0.0, 1.0),
Acceleration::body(0.0, 0.0, -GRAVITY),
);
assert_eq!(noise(&spaced(&window, WindowNoise::BLOCK)), None);
}
fn halves_of(forces: &[f64]) -> Option<Halves> {
let mut sums = BlockSums::new();
for &force in forces {
sums.push(Vector3::new(force, 0.0, 0.0), None);
}
let (first, second) = sums.split();
Halves::of(first, second)
}
#[test]
fn a_window_within_the_blocks_splits_at_the_middle_rounded_down() {
let halves = halves_of(&[1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0]).expect("two halves");
assert_eq!(
halves.force,
[
Acceleration::body(1.0, 0.0, 0.0),
Acceleration::body(5.0, 0.0, 0.0)
]
);
}
#[test]
fn a_window_past_the_blocks_splits_at_the_boundary_nearest_the_middle() {
let forces: std::vec::Vec<f64> = (0..20).map(|i| if i < 8 { 1.0 } else { 3.0 }).collect();
let halves = halves_of(&forces).expect("two halves");
assert_eq!(
halves.force,
[
Acceleration::body(1.0, 0.0, 0.0),
Acceleration::body(3.0, 0.0, 0.0)
]
);
}
#[test]
fn the_halves_are_within_an_eighth_of_the_window_of_equal_at_every_length() {
for n in 2..=300u32 {
let forces: std::vec::Vec<f64> = (0..n).map(f64::from).collect();
let halves = halves_of(&forces).expect("two halves");
let first = 2.0 * halves.force[0].vector().x + 1.0;
let n = n as f32;
assert!(
first >= 3.0 * n / 8.0 - 0.5 && first <= 5.0 * n / 8.0 + 0.5,
"a first half of {first} samples in {n}"
);
}
}
#[test]
fn a_window_of_one_has_no_halves() {
assert_eq!(halves_of(&[1.0]), None);
}
#[test]
fn a_window_is_at_rest_until_it_moves_and_never_again_after() {
let config = Config::default();
let mut samples = spaced(&[still(); 6], DT);
samples[2].imu = samples[2].imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
let mut window = StaticWindow::new();
assert!(window.is_at_rest(&config), "an empty window has not moved");
for (index, sample) in samples.into_iter().enumerate() {
assert_eq!(window.push(sample), Ok(()));
assert_eq!(
window.is_at_rest(&config),
index < 2,
"after sample {index}"
);
let moved = matches!(
classify(
&window.measured().expect("a sample"),
&config.init,
config.gravity
),
Alignment::Coarse(Coarse::NotStationary { .. })
);
assert_eq!(moved, !window.is_at_rest(&config), "after sample {index}");
}
}
#[test]
fn a_window_is_long_enough_only_once_it_holds_a_sample() {
let mut config = Config::default();
config.init.min_duration = Seconds::from_secs(0.0);
let mut window = StaticWindow::new();
assert!(!window.is_long_enough(&config));
assert_eq!(window.push(spaced(&[still()], DT)[0]), Ok(()));
assert!(window.is_long_enough(&config));
let two_seconds = Config::default();
let mut window = StaticWindow::new();
let samples = spaced(&[still(); 8], Seconds::from_secs(0.25));
for (index, sample) in samples.into_iter().enumerate() {
assert!(
!window.is_long_enough(&two_seconds),
"after {index} samples"
);
assert_eq!(window.push(sample), Ok(()));
}
assert!(window.is_long_enough(&two_seconds), "8 samples of 0.25 s");
}
#[test]
fn extending_a_window_stops_at_the_first_sample_refused() {
let mut samples = spaced(&[still(); 5], DT);
samples[2].imu = samples[2]
.imu
.with_accel(Acceleration::body(f32::NAN, 0.0, 0.0));
let mut window = StaticWindow::new();
assert_eq!(
window.try_extend(samples.clone()),
Err(SampleRefusal::NotFinite)
);
assert_eq!(window.measured(), measure(&samples[..2]));
}
#[test]
fn a_refused_sample_leaves_the_window_as_it_was() {
let mut good = spaced(&[still(); 4], DT);
for (index, sample) in good.iter_mut().enumerate() {
sample.baro = Some(Altitude::from_meters(100.0 + index as f32));
}
let mut window = StaticWindow::new();
assert_eq!(window.push(good[0]), Ok(()));
assert_eq!(window.push(good[1]), Ok(()));
assert!(matches!(
window.push(good[0]),
Err(SampleRefusal::InvalidStep { .. })
));
assert!(matches!(
window.push(good[1]),
Err(SampleRefusal::InvalidStep { .. })
));
let mut poisoned = good[1];
poisoned.imu = poisoned
.imu
.with_accel(Acceleration::body(f32::NAN, 0.0, 0.0));
poisoned.baro = Some(Altitude::from_meters(500.0));
assert_eq!(window.push(poisoned), Err(SampleRefusal::NotFinite));
assert_eq!(window.push(good[2]), Ok(()));
assert_eq!(window.push(good[3]), Ok(()));
let clean = StaticWindow::try_from(good.as_slice()).expect("usable");
assert_eq!(window.measured(), clean.measured());
assert_eq!(window.alpha0(), clean.alpha0());
}
fn accelerating_window() -> [StaticSample; 8] {
let mut window = [still(); 8];
window[1].velocity = Some(Velocity::ned(1.0, 0.0, 0.0));
window[5].velocity = Some(Velocity::ned(5.0, 0.0, 0.0));
window
}
#[test]
fn the_window_acceleration_is_the_endpoint_difference_over_the_span() {
let measured = inertial_acceleration(&spaced(&accelerating_window(), DT))
.expect("two samples a second apart carry a velocity");
assert_eq!(measured, Acceleration::ned(4.0, 0.0, 0.0));
}
#[test]
fn velocity_between_the_endpoints_does_not_reach_the_mean() {
let mut window = accelerating_window();
window[3].velocity = Some(Velocity::ned(-40.0, 12.0, 7.0));
assert_eq!(
inertial_acceleration(&spaced(&window, DT)),
Some(Acceleration::ned(4.0, 0.0, 0.0))
);
}
#[test]
fn a_window_with_no_two_dated_velocities_reports_no_acceleration() {
assert_eq!(inertial_acceleration(&spaced(&[still(); 8], DT)), None);
let mut one = [still(); 8];
one[4].velocity = Some(Velocity::ned(9.0, 0.0, 0.0));
assert_eq!(inertial_acceleration(&spaced(&one, DT)), None);
}
#[test]
fn a_moving_window_reports_the_acceleration_gnss_accounts_for() {
let mut window = accelerating_window();
window[3].imu = window[3].imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
let alignment = classify_default(&window, DT).expect("moving, not unusable");
let Alignment::Coarse(Coarse::NotStationary { inertial_accel, .. }) = alignment else {
panic!("0.4 rad/s is over the default: {alignment:?}");
};
assert_eq!(inertial_accel, Some(Acceleration::ned(4.0, 0.0, 0.0)));
}
#[test]
fn a_vehicle_gnss_explains_entirely_is_still_not_at_rest() {
let mut window = [still(); 8];
for sample in &mut window {
sample.imu = sample
.imu
.with_accel(Acceleration::body(8.0, 0.0, -GRAVITY));
}
window[1].velocity = Some(Velocity::ned(1.0, 0.0, 0.0));
window[5].velocity = Some(Velocity::ned(9.0, 0.0, 0.0));
let alignment = classify_default(&window, DT).expect("moving, not unusable");
let Alignment::Coarse(Coarse::NotStationary { inertial_accel, .. }) = alignment else {
panic!("2.85 m/s^2 is over the 1.961 default: {alignment:?}");
};
assert_eq!(inertial_accel, Some(Acceleration::ned(8.0, 0.0, 0.0)));
}
#[test]
fn a_velocity_that_is_not_a_number_is_refused_with_the_rest() {
let mut window = accelerating_window();
window[5].velocity = Some(Velocity::ned(f32::NAN, 0.0, 0.0));
assert_eq!(classify_default(&window, DT), Err(InitError::NotFinite));
}
}