use crate::display::{Decimals, Fixed};
use crate::units::{Meters, MetersPerSecond, MetersPerSecond2, Radians, RadiansPerSecond, Seconds};
pub const GRAVITY: f32 = 9.806_65;
pub const ALIGNED_TILT: Radians = Radians::from_degrees(3.0);
pub const ALIGNED_HEADING: Radians = Radians::from_degrees(30.0);
pub const LATENCY_HORIZON: Seconds = Seconds::from_secs(0.3);
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ImuNoise {
pub gyro_white: f32,
pub accel_white: f32,
pub gyro_bias_walk: f32,
pub accel_bias_walk: f32,
}
impl Default for ImuNoise {
fn default() -> Self {
Self {
gyro_white: 1.5e-2,
accel_white: 3.5e-1,
gyro_bias_walk: 1.1e-4,
accel_bias_walk: 2.2e-3,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Percentile {
P95,
P99,
P999,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Gate<const DOF: usize>(f32);
impl<const DOF: usize> Gate<DOF> {
pub const fn new(threshold: f32) -> Option<Self> {
if threshold.is_finite() && threshold > 0.0 {
Some(Self(threshold))
} else {
None
}
}
pub const fn threshold(self) -> f32 {
self.0
}
}
impl Gate<1> {
pub const fn at(percentile: Percentile) -> Self {
Self(match percentile {
Percentile::P95 => 3.841_459,
Percentile::P99 => 6.634_897,
Percentile::P999 => 10.827_566,
})
}
}
impl Gate<2> {
pub const fn at(percentile: Percentile) -> Self {
Self(match percentile {
Percentile::P95 => 5.991_465,
Percentile::P99 => 9.210_34,
Percentile::P999 => 13.815_511,
})
}
}
impl Gate<3> {
pub const fn at(percentile: Percentile) -> Self {
Self(match percentile {
Percentile::P95 => 7.814_728,
Percentile::P99 => 11.344_867,
Percentile::P999 => 16.266_236,
})
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Gates {
pub gnss_position: Gate<2>,
pub gnss_height: Gate<1>,
pub gnss_velocity: Gate<3>,
pub baro_altitude: Gate<1>,
pub mag_heading: Gate<1>,
pub gnss_heading: Gate<1>,
pub course: Gate<1>,
pub stationary: Gate<3>,
pub position_hold: Gate<2>,
}
impl Gates {
pub const fn at(percentile: Percentile) -> Self {
Self {
gnss_position: Gate::<2>::at(percentile),
gnss_height: Gate::<1>::at(percentile),
gnss_velocity: Gate::<3>::at(percentile),
baro_altitude: Gate::<1>::at(percentile),
mag_heading: Gate::<1>::at(percentile),
gnss_heading: Gate::<1>::at(percentile),
course: Gate::<1>::at(percentile),
stationary: Gate::<3>::at(percentile),
position_hold: Gate::<2>::at(percentile),
}
}
}
impl Default for Gates {
fn default() -> Self {
Self::at(Percentile::P999)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Timeouts {
pub dead_reckoning_after: Seconds,
}
impl Default for Timeouts {
fn default() -> Self {
Self {
dead_reckoning_after: Seconds::from_secs(5.0),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Correlation {
pub gnss_position: Option<Seconds>,
pub gnss_height: Option<Seconds>,
pub gnss_velocity: Option<Seconds>,
pub baro_altitude: Option<Seconds>,
pub mag_heading: Option<Seconds>,
pub gnss_heading: Option<Seconds>,
pub course: Option<Seconds>,
}
impl Correlation {
pub const WHITE: Self = Self {
gnss_position: None,
gnss_height: None,
gnss_velocity: None,
baro_altitude: None,
mag_heading: None,
gnss_heading: None,
course: None,
};
}
impl Default for Correlation {
fn default() -> Self {
Self {
gnss_position: Some(Seconds::from_secs(8.5)),
gnss_height: Some(Seconds::from_secs(37.0)),
gnss_velocity: Some(Seconds::from_secs(0.5)),
baro_altitude: Some(Seconds::from_secs(0.26)),
mag_heading: Some(Seconds::from_secs(1.3)),
gnss_heading: Some(Seconds::from_secs(0.25)),
course: Some(Seconds::from_secs(1.4)),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Recovery {
pub gnss_position: Option<Seconds>,
pub gnss_height: Option<Seconds>,
pub gnss_velocity: Option<Seconds>,
pub baro_altitude: Option<Seconds>,
pub mag_heading: Option<Seconds>,
pub gnss_heading: Option<Seconds>,
pub course: Option<Seconds>,
pub yaw_estimator: Option<Seconds>,
}
impl Recovery {
pub const OFF: Self = Self {
gnss_position: None,
gnss_height: None,
gnss_velocity: None,
baro_altitude: None,
mag_heading: None,
gnss_heading: None,
course: None,
yaw_estimator: None,
};
}
impl Default for Recovery {
fn default() -> Self {
Self {
gnss_position: Some(Seconds::from_secs(7.0)),
gnss_height: Some(Seconds::from_secs(5.0)),
gnss_velocity: Some(Seconds::from_secs(7.0)),
baro_altitude: Some(Seconds::from_secs(5.0)),
mag_heading: Some(Seconds::from_secs(7.0)),
gnss_heading: Some(Seconds::from_secs(7.0)),
course: Some(Seconds::from_secs(7.0)),
yaw_estimator: Some(Seconds::from_secs(1.0)),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Coast {
pub acceleration: f32,
pub rotation: f32,
}
impl Default for Coast {
fn default() -> Self {
Self {
acceleration: 2.0,
rotation: 0.1,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Hold {
pub sigma: Meters,
}
impl Default for Hold {
fn default() -> Self {
Self {
sigma: Meters::from_meters(10.0),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Initialization {
pub min_duration: Seconds,
pub max_gyro_rate: RadiansPerSecond,
pub max_accel_deviation: MetersPerSecond2,
pub sigma_position: Meters,
pub sigma_velocity: MetersPerSecond,
pub sigma_tilt: Radians,
pub sigma_yaw: Radians,
pub sigma_accel_bias: MetersPerSecond2,
pub sigma_gyro_bias: RadiansPerSecond,
}
impl Default for Initialization {
fn default() -> Self {
Self {
min_duration: Seconds::from_secs(2.0),
max_gyro_rate: RadiansPerSecond::from_rad_per_s(0.262),
max_accel_deviation: MetersPerSecond2::from_m_per_s2(1.961),
sigma_position: Meters::from_meters(1.0),
sigma_velocity: MetersPerSecond::from_m_per_s(0.1),
sigma_tilt: Radians::from_radians(0.02),
sigma_yaw: Radians::from_radians(0.35),
sigma_accel_bias: MetersPerSecond2::from_m_per_s2(0.2),
sigma_gyro_bias: RadiansPerSecond::from_rad_per_s(0.01),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Accuracy {
pub tilt: Radians,
pub heading: Radians,
pub position: Meters,
pub velocity: MetersPerSecond,
pub horizon: Seconds,
}
impl Default for Accuracy {
fn default() -> Self {
Self {
tilt: ALIGNED_TILT,
heading: ALIGNED_HEADING,
position: Meters::from_meters(5.0),
velocity: MetersPerSecond::from_m_per_s(1.0),
horizon: Seconds::from_secs(1.0),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Config {
pub imu: ImuNoise,
pub gates: Gates,
pub timeouts: Timeouts,
pub recovery: Recovery,
pub correlation: Correlation,
pub init: Initialization,
pub accuracy: Accuracy,
pub gravity: f32,
pub max_predict_dt: Seconds,
pub coast: Option<Coast>,
pub hold: Option<Hold>,
pub baro_offset_walk: f32,
pub baro_reference_from_estimate: bool,
pub yaw_estimator: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
imu: ImuNoise::default(),
gates: Gates::default(),
timeouts: Timeouts::default(),
recovery: Recovery::default(),
correlation: Correlation::default(),
init: Initialization::default(),
accuracy: Accuracy::default(),
gravity: GRAVITY,
max_predict_dt: Seconds::from_secs(0.1),
coast: Some(Coast::default()),
hold: Some(Hold::default()),
baro_offset_walk: 0.13,
baro_reference_from_estimate: true,
yaw_estimator: true,
}
}
}
impl Config {
pub fn validate(&self) -> Result<(), ConfigError> {
use ConfigBound::{NonNegative, Positive};
let Config {
imu:
ImuNoise {
gyro_white,
accel_white,
gyro_bias_walk,
accel_bias_walk,
},
gates: _,
timeouts: Timeouts {
dead_reckoning_after,
},
recovery,
correlation,
init:
Initialization {
min_duration,
max_gyro_rate,
max_accel_deviation,
sigma_position,
sigma_velocity,
sigma_tilt,
sigma_yaw,
sigma_accel_bias,
sigma_gyro_bias,
},
accuracy:
Accuracy {
tilt,
heading,
position,
velocity,
horizon,
},
gravity,
max_predict_dt,
coast,
hold,
baro_offset_walk,
baro_reference_from_estimate: _,
yaw_estimator: _,
} = *self;
let check = |field, value: f32, bound: ConfigBound| {
if bound.holds(value) {
Ok(())
} else {
Err(ConfigError {
field,
value,
bound,
})
}
};
check("imu.gyro_white", gyro_white, NonNegative)?;
check("imu.accel_white", accel_white, NonNegative)?;
check("imu.gyro_bias_walk", gyro_bias_walk, NonNegative)?;
check("imu.accel_bias_walk", accel_bias_walk, NonNegative)?;
let dead_reckoning_after = dead_reckoning_after.as_secs();
check(
"timeouts.dead_reckoning_after",
dead_reckoning_after,
Positive,
)?;
for (field, after) in recovery.named().into_iter().chain(correlation.named()) {
if let Some(after) = after {
check(field, after.as_secs(), Positive)?;
}
}
check("init.min_duration", min_duration.as_secs(), NonNegative)?;
check(
"init.max_gyro_rate",
max_gyro_rate.as_rad_per_s(),
NonNegative,
)?;
let deviation = max_accel_deviation.as_m_per_s2();
check("init.max_accel_deviation", deviation, NonNegative)?;
check("init.sigma_position", sigma_position.as_meters(), Positive)?;
check("init.sigma_velocity", sigma_velocity.as_m_per_s(), Positive)?;
check("init.sigma_tilt", sigma_tilt.as_radians(), Positive)?;
check("init.sigma_yaw", sigma_yaw.as_radians(), Positive)?;
check(
"init.sigma_accel_bias",
sigma_accel_bias.as_m_per_s2(),
Positive,
)?;
check(
"init.sigma_gyro_bias",
sigma_gyro_bias.as_rad_per_s(),
Positive,
)?;
check("accuracy.tilt", tilt.as_radians(), Positive)?;
check("accuracy.heading", heading.as_radians(), Positive)?;
check("accuracy.position", position.as_meters(), Positive)?;
check("accuracy.velocity", velocity.as_m_per_s(), Positive)?;
check("accuracy.horizon", horizon.as_secs(), NonNegative)?;
check("gravity", gravity, ConfigBound::Gravity)?;
check("max_predict_dt", max_predict_dt.as_secs(), Positive)?;
if let Some(Coast {
acceleration,
rotation,
}) = coast
{
check("coast.acceleration", acceleration, NonNegative)?;
check("coast.rotation", rotation, NonNegative)?;
}
if let Some(Hold { sigma }) = hold {
check("hold.sigma", sigma.as_meters(), Positive)?;
}
check("baro_offset_walk", baro_offset_walk, NonNegative)
}
}
impl Recovery {
fn named(self) -> [(&'static str, Option<Seconds>); 8] {
let Self {
gnss_position,
gnss_height,
gnss_velocity,
baro_altitude,
mag_heading,
gnss_heading,
course,
yaw_estimator,
} = self;
[
("recovery.gnss_position", gnss_position),
("recovery.gnss_height", gnss_height),
("recovery.gnss_velocity", gnss_velocity),
("recovery.baro_altitude", baro_altitude),
("recovery.mag_heading", mag_heading),
("recovery.gnss_heading", gnss_heading),
("recovery.course", course),
("recovery.yaw_estimator", yaw_estimator),
]
}
}
impl Correlation {
fn named(self) -> [(&'static str, Option<Seconds>); 7] {
let Self {
gnss_position,
gnss_height,
gnss_velocity,
baro_altitude,
mag_heading,
gnss_heading,
course,
} = self;
[
("correlation.gnss_position", gnss_position),
("correlation.gnss_height", gnss_height),
("correlation.gnss_velocity", gnss_velocity),
("correlation.baro_altitude", baro_altitude),
("correlation.mag_heading", mag_heading),
("correlation.gnss_heading", gnss_heading),
("correlation.course", course),
]
}
}
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ConfigError {
pub field: &'static str,
pub value: f32,
pub bound: ConfigBound,
}
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConfigBound {
Positive,
NonNegative,
Gravity,
}
impl ConfigBound {
fn holds(self, value: f32) -> bool {
value.is_finite()
&& match self {
Self::Positive => value > 0.0,
Self::NonNegative => value >= 0.0,
Self::Gravity => (9.7..=9.9).contains(&value),
}
}
}
impl core::fmt::Display for ConfigError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let bound = match self.bound {
ConfigBound::Positive => "finite and positive",
ConfigBound::NonNegative => "finite and not negative",
ConfigBound::Gravity => "Earth's normal gravity, 9.7 to 9.9",
};
let value = Fixed::new(self.value, Decimals::Three);
write!(f, "config {} is {value}, and must be {bound}", self.field)
}
}
impl core::error::Error for ConfigError {}
#[cfg(test)]
mod tests {
use super::*;
use nalgebra::ComplexField;
fn chi_square_cdf(dof: i32, x: f64) -> f64 {
const STEPS: usize = 10_000;
let upper = ComplexField::sqrt(x);
let h = upper / STEPS as f64;
let density = |t: f64| t.powi(dof - 1) * ComplexField::exp(-t * t / 2.0);
let interior: f64 = (1..STEPS)
.map(|i| density(i as f64 * h) * if i % 2 == 1 { 4.0 } else { 2.0 })
.sum();
let integral = h / 3.0 * (density(0.0) + interior + density(upper));
ComplexField::sqrt(2.0 / core::f64::consts::PI) * integral
}
const PERCENTILES: [(Percentile, f64); 3] = [
(Percentile::P95, 0.95),
(Percentile::P99, 0.99),
(Percentile::P999, 0.999),
];
#[test]
fn each_threshold_is_the_quantile_it_names() {
for (percentile, p) in PERCENTILES {
let one = Gate::<1>::at(percentile).threshold();
let three = Gate::<3>::at(percentile).threshold();
let two = Gate::<2>::at(percentile).threshold();
let f1 = chi_square_cdf(1, f64::from(one));
let f2 = 1.0 - ComplexField::exp(-f64::from(two) / 2.0);
let f3 = chi_square_cdf(3, f64::from(three));
assert!((f1 - p).abs() < 1e-6, "1 dof at {p}: F({one}) = {f1}");
assert!((f2 - p).abs() < 1e-6, "2 dof at {p}: F({two}) = {f2}");
assert!((f3 - p).abs() < 1e-6, "3 dof at {p}: F({three}) = {f3}");
}
}
#[test]
fn a_threshold_that_would_fail_silently_is_refused() {
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, -0.0, -1.0] {
assert_eq!(Gate::<1>::new(bad), None, "{bad}");
}
assert_eq!(Gate::<3>::new(11.34).map(Gate::threshold), Some(11.34));
assert_eq!(
Gate::<1>::new(f32::MIN_POSITIVE).map(Gate::threshold),
Some(f32::MIN_POSITIVE)
);
}
#[test]
fn the_default_is_the_999th_percentile() {
assert_eq!(Gates::default(), Gates::at(Percentile::P999));
assert_eq!(
Gates::default().gnss_position,
Gate::<2>::at(Percentile::P999)
);
assert_eq!(
Gates::default().baro_altitude,
Gate::<1>::at(Percentile::P999)
);
}
#[allow(clippy::type_complexity)]
const FIELDS: [(&str, fn(&mut Config, f32), ConfigBound); 40] = {
use ConfigBound::{NonNegative, Positive};
[
("imu.gyro_white", |c, v| c.imu.gyro_white = v, NonNegative),
("imu.accel_white", |c, v| c.imu.accel_white = v, NonNegative),
(
"imu.gyro_bias_walk",
|c, v| c.imu.gyro_bias_walk = v,
NonNegative,
),
(
"imu.accel_bias_walk",
|c, v| c.imu.accel_bias_walk = v,
NonNegative,
),
(
"timeouts.dead_reckoning_after",
|c, v| c.timeouts.dead_reckoning_after = Seconds::from_secs(v),
Positive,
),
(
"recovery.gnss_position",
|c, v| c.recovery.gnss_position = Some(Seconds::from_secs(v)),
Positive,
),
(
"recovery.gnss_height",
|c, v| c.recovery.gnss_height = Some(Seconds::from_secs(v)),
Positive,
),
(
"recovery.gnss_velocity",
|c, v| c.recovery.gnss_velocity = Some(Seconds::from_secs(v)),
Positive,
),
(
"recovery.baro_altitude",
|c, v| c.recovery.baro_altitude = Some(Seconds::from_secs(v)),
Positive,
),
(
"recovery.mag_heading",
|c, v| c.recovery.mag_heading = Some(Seconds::from_secs(v)),
Positive,
),
(
"recovery.gnss_heading",
|c, v| c.recovery.gnss_heading = Some(Seconds::from_secs(v)),
Positive,
),
(
"recovery.course",
|c, v| c.recovery.course = Some(Seconds::from_secs(v)),
Positive,
),
(
"recovery.yaw_estimator",
|c, v| c.recovery.yaw_estimator = Some(Seconds::from_secs(v)),
Positive,
),
(
"correlation.gnss_position",
|c, v| c.correlation.gnss_position = Some(Seconds::from_secs(v)),
Positive,
),
(
"correlation.gnss_height",
|c, v| c.correlation.gnss_height = Some(Seconds::from_secs(v)),
Positive,
),
(
"correlation.gnss_velocity",
|c, v| c.correlation.gnss_velocity = Some(Seconds::from_secs(v)),
Positive,
),
(
"correlation.baro_altitude",
|c, v| c.correlation.baro_altitude = Some(Seconds::from_secs(v)),
Positive,
),
(
"correlation.mag_heading",
|c, v| c.correlation.mag_heading = Some(Seconds::from_secs(v)),
Positive,
),
(
"correlation.gnss_heading",
|c, v| c.correlation.gnss_heading = Some(Seconds::from_secs(v)),
Positive,
),
(
"correlation.course",
|c, v| c.correlation.course = Some(Seconds::from_secs(v)),
Positive,
),
(
"init.min_duration",
|c, v| c.init.min_duration = Seconds::from_secs(v),
NonNegative,
),
(
"init.max_gyro_rate",
|c, v| c.init.max_gyro_rate = RadiansPerSecond::from_rad_per_s(v),
NonNegative,
),
(
"init.max_accel_deviation",
|c, v| c.init.max_accel_deviation = MetersPerSecond2::from_m_per_s2(v),
NonNegative,
),
(
"init.sigma_position",
|c, v| c.init.sigma_position = Meters::from_meters(v),
Positive,
),
(
"init.sigma_velocity",
|c, v| c.init.sigma_velocity = MetersPerSecond::from_m_per_s(v),
Positive,
),
(
"init.sigma_tilt",
|c, v| c.init.sigma_tilt = Radians::from_radians(v),
Positive,
),
(
"init.sigma_yaw",
|c, v| c.init.sigma_yaw = Radians::from_radians(v),
Positive,
),
(
"init.sigma_accel_bias",
|c, v| c.init.sigma_accel_bias = MetersPerSecond2::from_m_per_s2(v),
Positive,
),
(
"init.sigma_gyro_bias",
|c, v| c.init.sigma_gyro_bias = RadiansPerSecond::from_rad_per_s(v),
Positive,
),
(
"accuracy.tilt",
|c, v| c.accuracy.tilt = Radians::from_radians(v),
Positive,
),
(
"accuracy.heading",
|c, v| c.accuracy.heading = Radians::from_radians(v),
Positive,
),
(
"accuracy.position",
|c, v| c.accuracy.position = Meters::from_meters(v),
Positive,
),
(
"accuracy.velocity",
|c, v| c.accuracy.velocity = MetersPerSecond::from_m_per_s(v),
Positive,
),
(
"accuracy.horizon",
|c, v| c.accuracy.horizon = Seconds::from_secs(v),
NonNegative,
),
("gravity", |c, v| c.gravity = v, ConfigBound::Gravity),
(
"max_predict_dt",
|c, v| c.max_predict_dt = Seconds::from_secs(v),
Positive,
),
(
"coast.acceleration",
|c, v| c.coast.get_or_insert_default().acceleration = v,
NonNegative,
),
(
"coast.rotation",
|c, v| c.coast.get_or_insert_default().rotation = v,
NonNegative,
),
(
"hold.sigma",
|c, v| c.hold.get_or_insert_default().sigma = Meters::from_meters(v),
Positive,
),
(
"baro_offset_walk",
|c, v| c.baro_offset_walk = v,
NonNegative,
),
]
};
#[test]
fn the_defaults_and_every_switch_off_validate() {
assert_eq!(Config::default().validate(), Ok(()));
let off = Config {
recovery: Recovery::OFF,
correlation: Correlation::WHITE,
coast: None,
hold: None,
..Config::default()
};
assert_eq!(off.validate(), Ok(()));
}
#[test]
fn each_field_outside_its_bound_is_refused_by_name() {
for (field, set, bound) in FIELDS {
let bad: &[f32] = match bound {
ConfigBound::Gravity => &[f32::NAN, f32::INFINITY, 0.0, 1.0, 9.69, 9.91, 32.174],
_ => &[
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
-1.0,
-f32::MIN_POSITIVE,
0.0,
-0.0,
],
};
for &value in bad
.iter()
.filter(|v| **v != 0.0 || bound != ConfigBound::NonNegative)
{
let mut config = Config::default();
set(&mut config, value);
let refused = config.validate().err();
assert_eq!(
refused.map(|e| (e.field, e.bound)),
Some((field, bound)),
"{field} = {value}"
);
}
}
}
#[test]
fn each_field_inside_its_bound_is_accepted() {
for (field, set, bound) in FIELDS {
let (least, most) = match bound {
ConfigBound::Positive => (f32::MIN_POSITIVE, 1.0e6),
ConfigBound::NonNegative => (0.0, 1.0e6),
ConfigBound::Gravity => (9.7, 9.9),
};
for value in [least, most] {
let mut config = Config::default();
set(&mut config, value);
assert_eq!(config.validate(), Ok(()), "{field} = {value}");
}
}
}
}