use nalgebra::{SMatrix, SVector};
use crate::config::Timeouts;
use crate::display::{Decimals, Fixed};
use crate::units::{Seconds, Timestamp};
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Status {
Healthy,
Degraded,
Aligning,
#[default]
DeadReckoning,
}
impl core::fmt::Display for Status {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(match self {
Self::Healthy => "healthy",
Self::Degraded => "degraded",
Self::Aligning => "aligning",
Self::DeadReckoning => "dead reckoning",
})
}
}
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Fusion {
Accepted {
test_ratio: f32,
},
Reset,
Rejected {
test_ratio: f32,
},
NotInitialized,
NoReference,
Unobservable,
NotFinite,
InvalidNoise,
StateInvalid,
OutOfHorizon {
age: Seconds,
},
}
impl Fusion {
pub const fn is_accepted(self) -> bool {
matches!(self, Self::Accepted { .. } | Self::Reset)
}
pub const fn is_reset(self) -> bool {
matches!(self, Self::Reset)
}
pub const fn refusal(self) -> Option<Refusal> {
match self {
Self::NotInitialized => Some(Refusal::NotInitialized),
Self::NoReference => Some(Refusal::NoReference),
Self::Unobservable => Some(Refusal::Unobservable),
Self::NotFinite => Some(Refusal::NotFinite),
Self::InvalidNoise => Some(Refusal::InvalidNoise),
Self::StateInvalid => Some(Refusal::StateInvalid),
Self::OutOfHorizon { .. } => Some(Refusal::OutOfHorizon),
Self::Accepted { .. } | Self::Rejected { .. } | Self::Reset => None,
}
}
pub const fn test_ratio(self) -> Option<f32> {
match self {
Self::Accepted { test_ratio } | Self::Rejected { test_ratio } => Some(test_ratio),
Self::Reset
| Self::NotInitialized
| Self::NoReference
| Self::Unobservable
| Self::NotFinite
| Self::InvalidNoise
| Self::StateInvalid
| Self::OutOfHorizon { .. } => None,
}
}
}
impl core::fmt::Display for Fusion {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match *self {
Self::Accepted { test_ratio } => write!(f, "accepted, ratio {}", ratio(test_ratio)),
Self::Reset => f.write_str("adopted"),
Self::Rejected { test_ratio } => write!(f, "rejected, ratio {}", ratio(test_ratio)),
Self::NotInitialized => write!(f, "refused, {}", Refusal::NotInitialized),
Self::NoReference => write!(f, "refused, {}", Refusal::NoReference),
Self::Unobservable => write!(f, "refused, {}", Refusal::Unobservable),
Self::NotFinite => write!(f, "refused, {}", Refusal::NotFinite),
Self::InvalidNoise => write!(f, "refused, {}", Refusal::InvalidNoise),
Self::StateInvalid => write!(f, "refused, {}", Refusal::StateInvalid),
Self::OutOfHorizon { age } => write!(
f,
"refused, {}, age {} s",
Refusal::OutOfHorizon,
seconds(age)
),
}
}
}
fn ratio(test_ratio: f32) -> Fixed {
Fixed::new(test_ratio, Decimals::Two)
}
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GnssFusion {
pub horizontal: Fusion,
pub height: Fusion,
}
impl GnssFusion {
pub(crate) const fn both(fusion: Fusion) -> Self {
Self {
horizontal: fusion,
height: fusion,
}
}
pub const fn is_accepted(self) -> bool {
self.horizontal.is_accepted() && self.height.is_accepted()
}
pub const fn is_reset(self) -> bool {
self.horizontal.is_reset()
}
}
impl core::fmt::Display for GnssFusion {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
if self.horizontal == self.height {
write!(f, "{}", self.horizontal)
} else {
write!(f, "horizontal {}; height {}", self.horizontal, self.height)
}
}
}
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Refusal {
NotInitialized,
NoReference,
Unobservable,
NotFinite,
InvalidNoise,
StateInvalid,
OutOfHorizon,
}
impl core::fmt::Display for Refusal {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(match self {
Self::NotInitialized => "filter not initialized",
Self::NoReference => "no reference to measure against",
Self::Unobservable => "geometry observes nothing",
Self::NotFinite => "measurement or noise not finite",
Self::InvalidNoise => "noise variance not positive",
Self::StateInvalid => "filter state cannot support an update",
Self::OutOfHorizon => "measurement time outside the horizon",
})
}
}
#[must_use = "a refused propagation leaves the state stale unless the outcome is inspected"]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Propagation {
Propagated,
Coasted {
dt: Seconds,
},
StepTooLong {
dt: Seconds,
limit: Seconds,
},
InvalidStep {
dt: Seconds,
},
InvalidInterval {
interval: Seconds,
},
NotFinite,
StateNotFinite,
NotInitialized,
}
impl Propagation {
pub const fn is_propagated(self) -> bool {
matches!(self, Self::Propagated)
}
}
impl core::fmt::Display for Propagation {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match *self {
Self::Propagated => f.write_str("propagated"),
Self::Coasted { dt } => write!(f, "gap of {} s coasted", seconds(dt)),
Self::StepTooLong { dt, limit } => write!(
f,
"step of {} s over the {} s limit, not propagated",
seconds(dt),
seconds(limit)
),
Self::InvalidStep { dt } => {
write!(f, "step of {} s not usable, not propagated", seconds(dt))
}
Self::InvalidInterval { interval } => write!(
f,
"imu interval of {} s not usable, not propagated",
seconds(interval)
),
Self::NotFinite => f.write_str("imu sample not finite, not propagated"),
Self::StateNotFinite => f.write_str("propagated state not finite, not committed"),
Self::NotInitialized => f.write_str("filter not initialized, not propagated"),
}
}
}
fn seconds(value: Seconds) -> Fixed {
Fixed::new(value.as_secs(), Decimals::Three)
}
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Validity {
pub tilt: bool,
pub heading: bool,
pub horizontal_position: bool,
pub vertical_position: bool,
pub horizontal_velocity: bool,
pub vertical_velocity: bool,
}
impl Validity {
pub const NONE: Self = Self {
tilt: false,
heading: false,
horizontal_position: false,
vertical_position: false,
horizontal_velocity: false,
vertical_velocity: false,
};
pub const fn all(self) -> bool {
self.tilt
&& self.heading
&& self.horizontal_position
&& self.vertical_position
&& self.horizontal_velocity
&& self.vertical_velocity
}
pub const fn attitude(self) -> bool {
self.tilt && self.heading
}
pub const fn navigation(self) -> bool {
self.horizontal_position
&& self.vertical_position
&& self.horizontal_velocity
&& self.vertical_velocity
}
}
impl core::fmt::Display for Validity {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let flag = |valid: bool, letter: &'static str| if valid { letter } else { "-" };
write!(
f,
"att:{}{} pos:{}{} vel:{}{}",
flag(self.tilt, "T"),
flag(self.heading, "H"),
flag(self.horizontal_position, "H"),
flag(self.vertical_position, "V"),
flag(self.horizontal_velocity, "H"),
flag(self.vertical_velocity, "V"),
)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Innovation {
nu: [f32; 3],
variance: [f32; 3],
dimension: usize,
}
#[cfg(feature = "defmt")]
impl defmt::Format for Innovation {
fn format(&self, f: defmt::Formatter<'_>) {
defmt::write!(
f,
"Innovation {{ values: {}, variances: {} }}",
self.values(),
self.variances()
)
}
}
impl Innovation {
pub(crate) fn new<const M: usize>(y: &SVector<f32, M>, s: &SMatrix<f32, M, M>) -> Self {
let mut innovation = Self {
dimension: M.min(3),
..Self::default()
};
for (slot, value) in innovation.nu.iter_mut().zip(y.iter()) {
*slot = *value;
}
for (slot, value) in innovation.variance.iter_mut().zip(s.diagonal().iter()) {
*slot = *value;
}
innovation
}
pub fn values(&self) -> &[f32] {
self.nu.get(..self.dimension).unwrap_or_default()
}
pub fn variances(&self) -> &[f32] {
self.variance.get(..self.dimension).unwrap_or_default()
}
}
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct SourceHealth {
pub test_ratio: Option<f32>,
pub innovation: Option<Innovation>,
pub time_since_accepted: Option<Seconds>,
pub consecutive_rejections: u16,
pub accepted: u32,
pub rejected: u32,
pub refused: u32,
pub last_refusal: Option<Refusal>,
pub adopted: u32,
pub recovered: u32,
pub(crate) since_measured: Option<Seconds>,
pub(crate) since_judged: Option<Seconds>,
pub(crate) cadence: Cadence,
}
impl SourceHealth {
pub const fn has_been_used(&self) -> bool {
self.accepted > 0
}
pub(crate) fn judged_within(&self, timeout: Seconds) -> bool {
self.since_judged.is_some_and(|elapsed| elapsed <= timeout)
}
pub fn accepted_within(&self, timeout: Seconds) -> bool {
self.time_since_accepted
.is_some_and(|elapsed| elapsed <= timeout)
}
pub fn period(&self) -> Option<Seconds> {
self.cadence.period()
}
pub fn timeout(&self, timeouts: &Timeouts) -> Seconds {
self.period()
.map_or(timeouts.dead_reckoning_after, |period| {
Seconds::from_secs(MISSED_UPDATES * period.as_secs())
})
}
pub fn is_fresh(&self, timeouts: &Timeouts) -> bool {
self.accepted_within(self.timeout(timeouts))
}
pub(crate) fn note_arrival(&mut self, time: Timestamp) {
self.cadence.note(time);
}
pub(crate) fn advance(&mut self, dt: Seconds) {
for clock in [
&mut self.time_since_accepted,
&mut self.since_measured,
&mut self.since_judged,
] {
if let Some(elapsed) = *clock {
*clock = Some(Seconds::from_secs(elapsed.as_secs() + dt.as_secs()));
}
}
}
pub(crate) fn record_accepted(&mut self, test_ratio: f32, innovation: Option<Innovation>) {
self.test_ratio = Some(test_ratio);
self.innovation = innovation;
self.time_since_accepted = Some(Seconds::ZERO);
self.since_measured = Some(Seconds::ZERO);
self.since_judged = Some(Seconds::ZERO);
self.consecutive_rejections = 0;
self.accepted = self.accepted.saturating_add(1);
}
pub(crate) fn record_adopted(&mut self) {
self.record_accepted(0.0, None);
self.adopted = self.adopted.saturating_add(1);
}
pub(crate) fn record_recovered(&mut self) {
self.record_adopted();
self.recovered = self.recovered.saturating_add(1);
}
pub(crate) fn locked_out(&self, after: Option<Seconds>, since_initialized: Seconds) -> bool {
after.is_some_and(|after| self.time_since_accepted.unwrap_or(since_initialized) >= after)
}
pub(crate) fn record_rejected(&mut self, test_ratio: f32, innovation: Innovation) {
self.test_ratio = Some(test_ratio);
self.innovation = Some(innovation);
self.since_judged = Some(Seconds::ZERO);
self.consecutive_rejections = self.consecutive_rejections.saturating_add(1);
self.rejected = self.rejected.saturating_add(1);
}
pub(crate) fn record_refused(&mut self, refusal: Refusal) {
self.refused = self.refused.saturating_add(1);
self.last_refusal = Some(refusal);
}
}
const MISSED_UPDATES: f32 = 2.5;
const CADENCE: u16 = 15;
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct Cadence {
last: Option<Timestamp>,
mean: f32,
count: u16,
}
impl Cadence {
fn note(&mut self, time: Timestamp) {
let interval = match self.last {
Some(last) if time > last => time.since(last).as_secs(),
Some(_) => return,
None => {
self.last = Some(time);
return;
}
};
self.last = Some(time);
self.count = self.count.saturating_add(1);
let weight = 1.0 / f32::from(self.count.min(CADENCE));
self.mean += weight * (interval - self.mean);
}
fn period(&self) -> Option<Seconds> {
(self.count >= CADENCE).then(|| Seconds::from_secs(self.mean))
}
}
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct PropagationHealth {
pub coasted: u32,
pub refused_too_long: u32,
pub refused_invalid: u32,
pub refused_not_finite: u32,
pub refused_state_not_finite: u32,
pub longest_gap: Option<Seconds>,
}
impl PropagationHealth {
pub(crate) fn record(&mut self, outcome: Propagation) {
match outcome {
Propagation::Coasted { .. } => {
self.coasted = self.coasted.saturating_add(1);
}
Propagation::StepTooLong { .. } => {
self.refused_too_long = self.refused_too_long.saturating_add(1);
}
Propagation::InvalidStep { .. } | Propagation::InvalidInterval { .. } => {
self.refused_invalid = self.refused_invalid.saturating_add(1);
}
Propagation::NotFinite => {
self.refused_not_finite = self.refused_not_finite.saturating_add(1);
}
Propagation::StateNotFinite => {
self.refused_state_not_finite = self.refused_state_not_finite.saturating_add(1);
}
Propagation::Propagated | Propagation::NotInitialized => {}
}
}
pub(crate) fn note_gap(&mut self, dt: Seconds) {
if self.longest_gap.is_none_or(|worst| dt > worst) {
self.longest_gap = Some(dt);
}
}
}
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct Diagnostics {
pub gnss_position: SourceHealth,
pub gnss_height: SourceHealth,
pub gnss_velocity: SourceHealth,
pub baro_altitude: SourceHealth,
pub mag_heading: SourceHealth,
pub gnss_heading: SourceHealth,
pub course: SourceHealth,
pub stationary: SourceHealth,
pub position_hold: SourceHealth,
pub yaw_estimator: SourceHealth,
pub propagation: PropagationHealth,
pub floored: u32,
pub(crate) since_initialized: Seconds,
}
impl Diagnostics {
pub const fn sources(&self) -> [(&'static str, &SourceHealth); 10] {
[
("gnss_position", &self.gnss_position),
("gnss_height", &self.gnss_height),
("gnss_velocity", &self.gnss_velocity),
("baro_altitude", &self.baro_altitude),
("mag_heading", &self.mag_heading),
("gnss_heading", &self.gnss_heading),
("course", &self.course),
("stationary", &self.stationary),
("position_hold", &self.position_hold),
("yaw_estimator", &self.yaw_estimator),
]
}
pub(crate) fn aiding(&self) -> impl Iterator<Item = &SourceHealth> {
self.sources()
.into_iter()
.filter(|&(name, _)| {
!matches!(
name,
"course" | "stationary" | "position_hold" | "yaw_estimator"
)
})
.map(|(_, health)| health)
}
pub(crate) fn horizontal(&self) -> impl Iterator<Item = &SourceHealth> {
[&self.gnss_position, &self.gnss_velocity].into_iter()
}
pub(crate) fn advance(&mut self, dt: Seconds) {
self.since_initialized =
Seconds::from_secs(self.since_initialized.as_secs() + dt.as_secs());
self.gnss_position.advance(dt);
self.gnss_height.advance(dt);
self.gnss_velocity.advance(dt);
self.baro_altitude.advance(dt);
self.mag_heading.advance(dt);
self.gnss_heading.advance(dt);
self.course.advance(dt);
self.stationary.advance(dt);
self.position_hold.advance(dt);
self.yaw_estimator.advance(dt);
}
}
#[cfg(test)]
mod tests {
use std::format;
use super::*;
const fn secs(s: f32) -> Seconds {
Seconds::from_secs(s)
}
fn at(ms: u64) -> Timestamp {
Timestamp::from_micros(ms * 1000)
}
fn arrived(times: &[u64]) -> SourceHealth {
let mut source = SourceHealth::default();
for &t in times {
source.note_arrival(at(t));
}
source
}
#[test]
fn a_period_is_the_mean_interval_once_fifteen_have_arrived() {
let times: [u64; 15] = core::array::from_fn(|i| 200 * i as u64);
assert_eq!(arrived(×).period(), None);
let times: [u64; 16] = core::array::from_fn(|i| 400 * (i as u64 / 2) + 2 * (i as u64 % 2));
let period = arrived(×).period().expect("fifteen intervals");
assert!((period.as_secs() - 0.1867).abs() < 1e-3, "{period:?}");
}
#[test]
fn a_time_not_after_the_last_is_not_an_interval() {
let times: [u64; 32] = core::array::from_fn(|i| 1000 * (i as u64 / 2));
let period = arrived(×).period().expect("fifteen intervals");
assert!((period.as_secs() - 1.0).abs() < 1e-6, "{period:?}");
let mut times: [u64; 17] = core::array::from_fn(|i| 1000 * i as u64);
times[16] = 500;
let mut source = arrived(×);
source.note_arrival(at(16_000));
let period = source.period().expect("sixteen intervals");
assert!((period.as_secs() - 1.0).abs() < 1e-6, "{period:?}");
}
#[test]
fn advancing_ages_every_source_there_is() {
let mut diagnostics = Diagnostics::default();
diagnostics.gnss_position.record_accepted(0.0, None);
diagnostics.gnss_height.record_accepted(0.0, None);
diagnostics.gnss_velocity.record_accepted(0.0, None);
diagnostics.baro_altitude.record_accepted(0.0, None);
diagnostics.mag_heading.record_accepted(0.0, None);
diagnostics.gnss_heading.record_accepted(0.0, None);
diagnostics.course.record_accepted(0.0, None);
diagnostics.stationary.record_accepted(0.0, None);
diagnostics.position_hold.record_accepted(0.0, None);
diagnostics.yaw_estimator.record_accepted(0.0, None);
diagnostics.advance(secs(0.5));
for (name, source) in diagnostics.sources() {
assert_eq!(source.time_since_accepted, Some(secs(0.5)), "{name}");
}
}
#[test]
fn a_timeout_is_two_and_a_half_periods_or_dead_reckoning_before_one() {
let timeouts = Timeouts::default();
assert_eq!(
SourceHealth::default().timeout(&timeouts),
timeouts.dead_reckoning_after,
"no period yet"
);
let times: [u64; 16] = core::array::from_fn(|i| 200 * i as u64);
let timeout = arrived(×).timeout(&timeouts).as_secs();
assert!((timeout - 0.5).abs() < 1e-6, "{timeout}");
let times: [u64; 16] = core::array::from_fn(|i| 3000 * i as u64);
let timeout = arrived(×).timeout(&timeouts).as_secs();
assert!((timeout - 7.5).abs() < 1e-5, "{timeout}");
}
#[test]
fn an_outcome_reads_as_an_operator_log_line() {
assert_eq!(format!("{}", Status::DeadReckoning), "dead reckoning");
assert_eq!(
format!("{}", Fusion::Rejected { test_ratio: 2.7 }),
"rejected, ratio 2.70"
);
assert_eq!(
format!("{}", Fusion::Accepted { test_ratio: 0.314 }),
"accepted, ratio 0.31"
);
assert_eq!(format!("{}", Fusion::Reset), "adopted");
assert_eq!(
format!("{}", Fusion::NoReference),
"refused, no reference to measure against"
);
assert_eq!(
format!("{}", Fusion::Unobservable),
"refused, geometry observes nothing"
);
assert_eq!(
format!(
"{}",
Propagation::StepTooLong {
dt: secs(0.15),
limit: secs(0.1)
}
),
"step of 0.150 s over the 0.100 s limit, not propagated"
);
assert_eq!(
format!("{}", Propagation::Coasted { dt: secs(1.2) }),
"gap of 1.200 s coasted"
);
}
#[test]
fn status_counts_every_source_but_those_no_sensor_measures() {
let diagnostics = Diagnostics::default();
let excluded = ["course", "stationary", "position_hold", "yaw_estimator"];
assert_eq!(
diagnostics.aiding().count(),
diagnostics.sources().len() - excluded.len()
);
for name in excluded {
assert!(
diagnostics
.sources()
.iter()
.any(|&(source, _)| source == name),
"{name}"
);
}
}
#[test]
fn a_fix_names_its_halves_only_when_they_disagree() {
assert_eq!(format!("{}", GnssFusion::both(Fusion::Reset)), "adopted");
let split = GnssFusion {
horizontal: Fusion::Accepted { test_ratio: 0.5 },
height: Fusion::InvalidNoise,
};
assert_eq!(
format!("{split}"),
"horizontal accepted, ratio 0.50; height refused, noise variance not positive"
);
}
#[test]
fn validity_is_a_letter_per_usable_quantity() {
assert_eq!(format!("{}", Validity::NONE), "att:-- pos:-- vel:--");
let tilt_and_navigation = Validity {
tilt: true,
heading: false,
horizontal_position: true,
vertical_position: true,
horizontal_velocity: true,
vertical_velocity: true,
};
assert_eq!(format!("{tilt_and_navigation}"), "att:T- pos:HV vel:HV");
}
#[test]
fn lockout_counts_from_the_last_acceptance_or_from_initialization() {
let mut source = SourceHealth::default();
assert!(!source.locked_out(Some(secs(7.0)), secs(6.9)));
assert!(source.locked_out(Some(secs(7.0)), secs(7.0)));
source.record_accepted(0.5, None);
source.advance(secs(6.9));
assert!(
!source.locked_out(Some(secs(7.0)), secs(100.0)),
"since the acceptance"
);
source.advance(secs(0.1));
assert!(source.locked_out(Some(secs(7.0)), secs(100.0)));
assert!(!source.locked_out(None, secs(100.0)), "off is off");
}
}