use nalgebra::Vector3;
use crate::config::Hold;
use crate::frames::Ned;
use crate::health::Fusion;
use crate::math::correlation_inflation;
use crate::observation::hold;
use crate::units::{Radians, Seconds, Timestamp, VelocityNoise};
use crate::update::update;
use super::Eskf;
use super::fuse::refuse;
const HOLD_INTERVAL: Seconds = Seconds::from_secs(0.2);
const HOLD_TILT: Radians = Radians::from_degrees(3.0);
const HOLD_TAU: Seconds = Seconds::from_secs(2.0);
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct HoldState {
anchor: Option<Vector3<f32>>,
fused: Option<Timestamp>,
position: bool,
velocity: bool,
}
impl HoldState {
pub(super) fn holds_position(&self) -> bool {
self.position
}
pub(super) fn holds_velocity(&self) -> bool {
self.velocity
}
pub(super) fn end_position(&mut self) {
self.position = false;
self.anchor = None;
}
pub(super) fn end_velocity(&mut self) {
self.velocity = false;
self.anchor = None;
}
}
pub(super) fn recovery_after(held: bool, after: Option<Seconds>) -> Option<Seconds> {
if held {
after.map(|_| Seconds::ZERO)
} else {
after
}
}
impl Eskf {
pub fn fuse_stationary(&mut self, time: Timestamp, noise: VelocityNoise<Ned>) -> Fusion {
if let Err(refusal) = self.admit(time) {
return refuse(&mut self.diagnostics.stationary, refusal);
}
self.diagnostics.stationary.note_arrival(time);
if !noise.is_finite() {
return refuse(&mut self.diagnostics.stationary, Fusion::NotFinite);
}
if !noise.is_positive() {
return refuse(&mut self.diagnostics.stationary, Fusion::InvalidNoise);
}
let variance = Vector3::from(noise.variance());
let observation = self.observe(time, |past, _| hold::zero_velocity(past, variance));
let outcome = update(
self.estimate.state(),
&self.covariance,
&self.offset,
&observation,
self.config.gates.stationary,
);
let fusion = self.apply_or_recover(outcome, |d| &mut d.stationary, None, |_| false);
if fusion.is_accepted() {
self.hold.end_velocity();
}
fusion
}
#[inline(never)]
pub(super) fn hold_if_unaided(&mut self) {
let Some(Hold { sigma }) = self.config.hold else {
return;
};
if !self.is_unaided() {
self.hold.anchor = None;
self.hold.fused = None;
return;
}
if self.tilt_within(&self.covariance, HOLD_TILT) {
self.hold.anchor = None;
return;
}
if self
.hold
.fused
.is_some_and(|fused| self.time.since(fused) < HOLD_INTERVAL)
{
return;
}
let position = *self
.hold
.anchor
.get_or_insert_with(|| self.estimate.state().position.vector());
self.hold.fused = Some(self.time);
self.diagnostics.position_hold.note_arrival(self.time);
let variance = sigma.as_meters() * sigma.as_meters();
let observation = hold::position_hold(self.estimate.state(), position, variance)
.correlated(correlation_inflation(
self.diagnostics.position_hold.since_measured,
Some(HOLD_TAU),
));
let outcome = update(
self.estimate.state(),
&self.covariance,
&self.offset,
&observation,
self.config.gates.position_hold,
);
let fusion = self.apply_or_recover(outcome, |d| &mut d.position_hold, None, |_| false);
if fusion.is_accepted() {
self.hold.position = true;
self.hold.velocity = true;
}
}
fn is_unaided(&self) -> bool {
let after = self.config.timeouts.dead_reckoning_after;
let d = &self.diagnostics;
!d.horizontal().any(|source| source.judged_within(after))
&& !d.stationary.accepted_within(after)
}
}
#[cfg(test)]
mod tests {
use super::super::fixtures::*;
use crate::config::{Config, Recovery};
use crate::eskf::Eskf;
use crate::health::{Fusion, Propagation, Refusal, Status};
use crate::init::tests::still;
use crate::units::{Position, Seconds, VelocityNoise};
fn fix_at_origin(filter: &mut Eskf) -> crate::GnssFusion {
filter.fuse_gnss_position(
filter.now(),
Position::zero(),
one_metre(),
Position::zero(),
)
}
fn holds(filter: &Eskf) -> u32 {
let hold = filter.diagnostics().position_hold;
hold.accepted + hold.rejected
}
fn stationary() -> VelocityNoise<crate::frames::Ned> {
VelocityNoise::from_speed_accuracy(0.1)
}
#[test]
fn a_stationary_claim_holds_tilt_on_a_bench_with_no_aiding() {
let (mut told, mut unaided) = (unheld(), unheld());
let mut claims = 0;
hold(&mut told, 30.0, 10, |filter| {
let outcome = filter.fuse_stationary(filter.now(), stationary());
assert!(outcome.is_accepted(), "{outcome:?}");
claims += 1;
});
hold(&mut unaided, 30.0, 10, |_| {});
assert_eq!(claims, 300);
assert!(told.validity().tilt);
assert!(!unaided.validity().tilt);
assert_eq!(told.state().status, Status::DeadReckoning);
}
#[test]
fn a_stationary_claim_the_gate_turns_down_is_never_adopted() {
let mut filter = flying(Config {
hold: None,
..Config::default()
});
let velocity = filter.state().velocity;
let tight = VelocityNoise::from_speed_accuracy(0.01);
let dt = Seconds::from_secs(0.1);
for _ in 0..80 {
let _ = filter.step(still().imu, dt);
let outcome = filter.fuse_stationary(filter.now(), tight);
assert!(matches!(outcome, Fusion::Rejected { .. }), "{outcome:?}");
}
let health = filter.diagnostics().stationary;
assert_eq!(
(health.rejected, health.adopted, health.recovered),
(80, 0, 0)
);
assert!(filter.state().velocity.to_array()[0] > velocity.to_array()[0] - 1.0);
}
fn engaged() -> Eskf {
let mut filter = initialized();
for _ in 0..2000 {
if holds(&filter) > 0 {
return filter;
}
assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
}
panic!("no hold in 20 s unaided");
}
#[test]
fn the_hold_bounds_tilt_through_an_unaided_minute_and_claims_nothing_else() {
let (mut held, mut unaided) = (initialized(), unheld());
hold(&mut held, 60.0, 1, |_| {});
hold(&mut unaided, 60.0, 1, |_| {});
let sigma = |filter: &Eskf| {
let v = filter.attitude_variance();
v.tilt_north.max(v.tilt_east).sqrt()
};
assert!(
sigma(&held) < 0.5 * sigma(&unaided),
"held {} rad against {} unaided",
sigma(&held),
sigma(&unaided)
);
let (roll, pitch, _) = held.state().attitude.euler_angles();
assert!(roll.abs().max(pitch.abs()) < 3.0 * sigma(&held));
assert_eq!(held.diagnostics().position_hold.rejected, 0);
let validity = held.validity();
assert!(!validity.horizontal_position && !validity.horizontal_velocity);
assert_eq!(held.state().status, Status::DeadReckoning);
}
#[test]
fn a_hold_waits_for_the_tilt_to_need_it() {
let mut filter = initialized();
hold(&mut filter, 1.0, 1, |_| {});
assert_eq!(holds(&filter), 0);
assert!(filter.tilt_within(filter.covariance(), super::HOLD_TILT));
let _ = engaged();
}
#[test]
fn the_hold_waits_out_the_dead_reckoning_timeout_after_the_last_fix() {
let mut filter = engaged();
hold(&mut filter, 2.0, 20, |filter| {
assert!(fix_at_origin(filter).horizontal.is_accepted());
});
let before = holds(&filter);
hold(&mut filter, 4.9, 1, |_| {});
assert_eq!(holds(&filter), before);
hold(&mut filter, 0.2, 1, |_| {});
assert_eq!(holds(&filter), before + 1);
}
#[test]
fn a_receiver_the_gate_turns_down_keeps_the_hold_off() {
let mut filter = engaged();
let far = Position::ned(500.0, 0.0, 0.0);
let _ = filter.fuse_gnss_position(filter.now(), far, one_metre(), Position::zero());
let before = holds(&filter);
let elsewhere = Position::ned(-500.0, 0.0, 0.0);
hold(&mut filter, 6.0, 20, |filter| {
let outcome =
filter.fuse_gnss_position(filter.now(), elsewhere, one_metre(), Position::zero());
assert!(
matches!(outcome.horizontal, Fusion::Rejected { .. }),
"{outcome:?}"
);
});
assert_eq!(holds(&filter), before);
}
#[test]
fn an_accepted_fix_ends_the_hold_and_the_next_outage_anchors_afresh() {
let mut filter = engaged();
assert!(!filter.validity().horizontal_position);
assert!(fix_at_origin(&mut filter).horizontal.is_accepted());
assert!(filter.validity().horizontal_position);
let before = holds(&filter);
hold(&mut filter, 1.0, 20, |filter| {
assert!(fix_at_origin(filter).horizontal.is_accepted());
});
assert_eq!(holds(&filter), before);
assert_eq!(filter.hold, super::HoldState::default());
}
#[test]
fn the_first_fix_after_a_hold_that_the_gate_turns_down_is_adopted_at_once() {
let far = Position::ned(500.0, 0.0, 0.0);
let mut filter = engaged();
let outcome = filter.fuse_gnss_position(filter.now(), far, one_metre(), Position::zero());
assert_eq!(outcome.horizontal, Fusion::Reset);
assert_eq!(filter.diagnostics().gnss_position.recovered, 1);
assert!(filter.validity().horizontal_position);
let mut filter = unheld();
hold(&mut filter, 5.0, 1, |_| {});
let outcome = filter.fuse_gnss_position(filter.now(), far, one_metre(), Position::zero());
assert!(matches!(outcome.horizontal, Fusion::Rejected { .. }));
let mut filter = Eskf::new(Config {
recovery: Recovery::OFF,
..Config::default()
})
.unwrap();
let _ = filter.initialize_over(&[still(); 8], Seconds::from_secs(0.25));
hold(&mut filter, 20.0, 1, |_| {});
assert!(holds(&filter) > 0);
let outcome = filter.fuse_gnss_position(filter.now(), far, one_metre(), Position::zero());
assert!(matches!(outcome.horizontal, Fusion::Rejected { .. }));
}
#[test]
fn a_stationary_claim_stands_the_hold_down() {
let mut filter = engaged();
let before = holds(&filter);
hold(&mut filter, 10.0, 10, |filter| {
let _ = filter.fuse_stationary(filter.now(), stationary());
});
assert!(holds(&filter) <= before + 1);
assert_eq!((filter.hold.anchor, filter.hold.fused), (None, None));
}
fn held_under_loose_bars(recovery: Recovery) -> Eskf {
let mut filter = Eskf::new(Config {
accuracy: crate::config::Accuracy {
position: crate::units::Meters::from_meters(1000.0),
velocity: crate::units::MetersPerSecond::from_m_per_s(1000.0),
..crate::config::Accuracy::default()
},
recovery,
..Config::default()
})
.unwrap();
let _ = filter.initialize_over(&[still(); 8], Seconds::from_secs(0.25));
hold(&mut filter, 60.0, 1, |_| {});
assert!(holds(&filter) > 0);
filter
}
fn horizontal(filter: &Eskf) -> (bool, bool) {
let validity = filter.validity();
(validity.horizontal_position, validity.horizontal_velocity)
}
#[test]
fn the_end_of_an_outage_validates_only_what_was_measured() {
let mut filter = held_under_loose_bars(Recovery::default());
hold(&mut filter, 1.0, 10, |filter| {
let outcome = filter.fuse_stationary(filter.now(), stationary());
assert!(outcome.is_accepted(), "{outcome:?}");
});
assert_eq!(horizontal(&filter), (false, true));
let mut filter = held_under_loose_bars(Recovery::OFF);
let far = Position::ned(500.0, 0.0, 0.0);
hold(&mut filter, 1.0, 10, |filter| {
let outcome =
filter.fuse_gnss_position(filter.now(), far, one_metre(), Position::zero());
assert!(matches!(outcome.horizontal, Fusion::Rejected { .. }));
});
assert_eq!(horizontal(&filter), (false, false));
assert_eq!(filter.hold.anchor, None);
let mut filter = held_under_loose_bars(Recovery::default());
hold_velocity(&mut filter, crate::units::Velocity::zero());
hold(&mut filter, 1.0, 1, |_| {});
assert_eq!(horizontal(&filter), (false, true));
assert!(fix_at_origin(&mut filter).horizontal.is_accepted());
assert_eq!(horizontal(&filter), (true, true));
}
#[test]
fn a_velocity_reset_ends_the_hold_on_velocity_alone() {
let mut filter = held_under_loose_bars(Recovery::default());
let noise = VelocityNoise::from_speed_accuracy(0.3);
assert!(filter.reset_velocity_to(crate::units::Velocity::zero(), noise));
assert_eq!(horizontal(&filter), (false, true));
assert_eq!(filter.hold.anchor, None);
}
#[test]
fn a_new_start_forgets_the_hold() {
let mut filter = held_under_loose_bars(Recovery::default());
assert_eq!(horizontal(&filter), (false, false));
let _ = filter.initialize_over(&[still(); 8], Seconds::from_secs(0.25));
assert_eq!(filter.hold, super::HoldState::default());
assert_eq!(horizontal(&filter), (true, true));
}
#[test]
fn a_second_fix_after_a_hold_checks_the_velocity_between_them() {
let mut filter = held_under_loose_bars(Recovery::default());
assert!(matches!(
fix_at_origin(&mut filter).horizontal,
Fusion::Accepted { .. }
));
assert_eq!(horizontal(&filter), (true, false));
hold(&mut filter, 0.2, 1, |_| {});
assert!(fix_at_origin(&mut filter).horizontal.is_accepted());
assert_eq!(horizontal(&filter), (true, true));
}
#[test]
fn the_first_velocity_after_a_hold_that_the_gate_turns_down_is_adopted_at_once() {
let fast = crate::units::Velocity::ned(50.0, 0.0, 0.0);
let noise = VelocityNoise::from_speed_accuracy(0.3);
let mut filter = engaged();
let outcome = filter.fuse_gnss_velocity(filter.now(), fast, noise, Position::zero());
assert_eq!(outcome, Fusion::Reset);
assert_eq!(filter.diagnostics().gnss_velocity.recovered, 1);
let mut filter = unheld();
hold(&mut filter, 5.0, 1, |_| {});
let outcome = filter.fuse_gnss_velocity(filter.now(), fast, noise, Position::zero());
assert!(matches!(outcome, Fusion::Rejected { .. }), "{outcome:?}");
}
#[test]
fn a_height_recovery_does_not_validate_horizontal_position() {
let mut filter = held_under_loose_bars(Recovery {
gnss_position: None,
..Recovery::default()
});
let far = Position::ned(500.0, 0.0, -500.0);
hold(&mut filter, 8.0, 10, |filter| {
let outcome =
filter.fuse_gnss_position(filter.now(), far, one_metre(), Position::zero());
assert!(matches!(outcome.horizontal, Fusion::Rejected { .. }));
});
assert_eq!(filter.diagnostics().gnss_height.recovered, 1);
assert_eq!(horizontal(&filter), (false, false));
}
#[test]
fn a_stationary_claim_is_gated_at_its_own_gate() {
let mut filter = flying(Config {
hold: None,
gates: crate::config::Gates {
stationary: crate::config::Gate::new(1.0e9).unwrap(),
..crate::config::Gates::default()
},
..Config::default()
});
let outcome = filter.fuse_stationary(filter.now(), stationary());
assert!(outcome.is_accepted(), "{outcome:?}");
}
#[test]
fn a_stationary_claim_before_a_start_or_from_the_future_is_refused() {
let mut filter = Eskf::default();
let outcome = filter.fuse_stationary(crate::units::Timestamp::ZERO, stationary());
assert_eq!(outcome, Fusion::NotInitialized);
let mut filter = initialized();
hold(&mut filter, 10.0, 1, |_| {});
let outcome = filter.fuse_stationary(crate::units::Timestamp::ZERO, stationary());
assert!(
matches!(outcome, Fusion::OutOfHorizon { .. }),
"{outcome:?}"
);
assert_eq!(filter.diagnostics().stationary.refused, 1);
}
#[test]
fn a_coasted_step_runs_the_hold() {
let mut filter = initialized();
let gap = Seconds::from_secs(0.5);
for _ in 0..40 {
let outcome = filter.step(still().imu, gap);
assert!(
matches!(outcome, Propagation::Coasted { .. }),
"{outcome:?}"
);
}
assert!(holds(&filter) > 0);
}
#[test]
fn a_reset_ends_the_hold_rather_than_being_pulled_back_by_it() {
let mut filter = initialized();
hold(&mut filter, 40.0, 1, |_| {});
let there = Position::ned(100.0, 0.0, 0.0);
assert!(filter.reset_position_to(there, one_metre()));
hold(&mut filter, 20.0, 1, |_| {});
let north = filter.state().position.to_array()[0];
assert!(north > 95.0, "pulled back to {north} m");
}
#[test]
fn position_and_velocity_stay_invalid_through_the_whole_outage() {
let mut filter = Eskf::new(Config {
accuracy: crate::config::Accuracy {
position: crate::units::Meters::from_meters(1000.0),
velocity: crate::units::MetersPerSecond::from_m_per_s(1000.0),
..crate::config::Accuracy::default()
},
..Config::default()
})
.unwrap();
let _ = filter.initialize_over(&[still(); 8], Seconds::from_secs(0.25));
let mut held = 0;
hold(&mut filter, 60.0, 1, |filter| {
if holds(filter) > 0 {
let validity = filter.validity();
assert!(!validity.horizontal_position && !validity.horizontal_velocity);
held += 1;
}
});
assert!(held > 5000, "{held} steps under the hold");
}
#[test]
fn the_hold_fuses_no_faster_than_every_two_tenths_across_releases() {
let mut filter = initialized();
let (mut last, mut shortest, mut count) = (None, f32::INFINITY, holds(&filter));
hold(&mut filter, 60.0, 1, |filter| {
if holds(filter) != count {
count = holds(filter);
let now = filter.now();
if let Some(last) = last {
shortest = shortest.min(now.since(last).as_secs());
}
last = Some(now);
}
});
assert!(count > 10, "{count} holds");
assert!(shortest >= 0.2 - 1e-4, "{shortest} s between two holds");
}
#[test]
fn a_stationary_claim_with_no_usable_noise_is_refused() {
let mut filter = initialized();
let now = filter.now();
let nan = VelocityNoise::from_speed_accuracy(f32::NAN);
assert_eq!(filter.fuse_stationary(now, nan), Fusion::NotFinite);
let zero = VelocityNoise::from_speed_accuracy(0.0);
assert_eq!(filter.fuse_stationary(now, zero), Fusion::InvalidNoise);
let health = filter.diagnostics().stationary;
assert_eq!(health.refused, 2);
assert_eq!(health.last_refusal, Some(Refusal::InvalidNoise));
}
}