fusion_nav/eskf/hold.rs
1//! Holding tilt without aiding: the stationary claim a caller asserts, (29″), and the position hold
2//! the filter fuses itself while nothing aids it, (28″).
3//!
4//! Entry point: [`Eskf::fuse_stationary`]. The hold has none; `predict` runs it.
5
6use nalgebra::Vector3;
7
8use crate::config::Hold;
9use crate::frames::Ned;
10use crate::health::Fusion;
11use crate::math::correlation_inflation;
12use crate::observation::hold;
13use crate::units::{Radians, Seconds, Timestamp, VelocityNoise};
14use crate::update::update;
15
16use super::Eskf;
17use super::fuse::refuse;
18
19/// How often the hold is fused while it lasts: PX4's 5 Hz
20/// (`fake_pos_control.cpp:47-48` at `c4e4ef98`).
21///
22/// A rate rather than every step, because each fusion is a reading of the same assumption, and
23/// the update counts it as independent evidence: at the IMU's rate the hold would average its
24/// own `σ` down hundreds of times faster than at a sensor's.
25const HOLD_INTERVAL: Seconds = Seconds::from_secs(0.2);
26
27/// The tilt σ, per navigation axis, above which an unaided filter holds its position: PX4's 3°
28/// (`fake_pos_control.cpp:79-82` at `c4e4ef98`).
29///
30/// A hold is an assumption, and a filter whose tilt is still good has nothing to gain from it
31/// and a moving vehicle to misread. Held from the first unaided step instead, and fused as
32/// white, `gnss_outage`'s 20 s gap over the circuit's fastest turns read 3092 epochs of tilt
33/// claimed valid and wrong, tilt error 0.340° → 2.68° RMS, where behind this bar the hold
34/// barely engages there ([measured]).
35///
36/// The same 3° as [`Accuracy::tilt`](crate::Accuracy::tilt)'s default, so a hold fuses on the
37/// step whose tilt σ crossed the bar and pulls it back toward it: the time an unaided start keeps
38/// valid tilt under the hold is when one fusion stops being enough, not a better attitude.
39///
40/// [measured]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#hold
41const HOLD_TILT: Radians = Radians::from_degrees(3.0);
42
43/// The correlation time the hold is fused at, equation (24′): one assumption read every 0.2 s is
44/// one error, not independent ones.
45///
46/// Fused as white, as PX4 and ArduPilot fuse theirs, five readings a second average `S` down
47/// around an error that persists for the whole outage, and the covariance follows them:
48/// `hover_outage`'s ensemble read `anees_pos` 61 against a bound it meets at 2 s, the shortest
49/// `τ` measured that passes ([measured]).
50///
51/// [measured]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#hold
52const HOLD_TAU: Seconds = Seconds::from_secs(2.0);
53
54/// The position hold's state: where and when it last held, and which quantities still carry the
55/// covariance it shaped.
56#[derive(Clone, Copy, Debug, Default, PartialEq)]
57pub(super) struct HoldState {
58 /// Where the hold holds, NED meters about the origin: the estimate when it last engaged, and
59 /// `None` while released below [`HOLD_TILT`] or while something aids the filter.
60 anchor: Option<Vector3<f32>>,
61 /// When it last fused, kept across a release so that re-engaging waits out
62 /// [`HOLD_INTERVAL`] like any other fusion.
63 fused: Option<Timestamp>,
64 /// Whether the hold has bounded horizontal position since anything measured it. Set by each
65 /// hold the gate passes, and cleared only by a position the filter accepts or adopts, or a
66 /// caller's reset: the end of the outage does not clear it, since a fix the gate turns down,
67 /// a velocity or a stationary claim leaves the position covariance the one the assumption shaped.
68 position: bool,
69 /// The same for horizontal velocity. Cleared by a velocity accepted, adopted or reset, by a
70 /// stationary claim accepted, and by a position the gate passes against a position already
71 /// measured since the hold: the estimate was carried between the two on the velocity, so
72 /// the second fix has checked it, which is all a receiver reporting no velocity can offer.
73 velocity: bool,
74}
75
76impl HoldState {
77 /// Whether horizontal position's covariance is one the hold bounded, with no position
78 /// accepted, adopted or reset since. What [`Eskf::validity`] reads to keep the quantity
79 /// invalid, as it reads a start that never established it: the assumption tightened that
80 /// variance, not a sensor.
81 pub(super) fn holds_position(&self) -> bool {
82 self.position
83 }
84
85 /// [`holds_position`](Self::holds_position) for horizontal velocity.
86 pub(super) fn holds_velocity(&self) -> bool {
87 self.velocity
88 }
89
90 /// Horizontal position has been measured or set: its covariance is no longer the hold's,
91 /// and an anchor taken before it would pull the estimate back.
92 ///
93 /// After a caller's reset nothing aids the filter still, so the hold engages again within
94 /// [`HOLD_INTERVAL`] where the reset put the estimate, and position reads held again.
95 pub(super) fn end_position(&mut self) {
96 self.position = false;
97 self.anchor = None;
98 }
99
100 /// [`end_position`](Self::end_position) for horizontal velocity.
101 pub(super) fn end_velocity(&mut self) {
102 self.velocity = false;
103 self.anchor = None;
104 }
105}
106
107/// The lockout timeout a horizontal source recovers after: none at all while the hold's
108/// covariance stands on the quantity it measures (`held`), `after` otherwise, and never where
109/// `after` turns recovery off for the source.
110///
111/// The hold's covariance describes the assumption, not the vehicle, so a fix it turns down is
112/// judged against a position nobody measured; waiting out [`Config::recovery`] would hold a
113/// vehicle away from its first fix for seconds. PX4 resets to a GNSS position that fails its test
114/// when fusion starts (`gps_control.cpp:224-245` at `c4e4ef98`).
115///
116/// [`Config::recovery`]: crate::Config::recovery
117pub(super) fn recovery_after(held: bool, after: Option<Seconds>) -> Option<Seconds> {
118 if held {
119 after.map(|_| Seconds::ZERO)
120 } else {
121 after
122 }
123}
124
125impl Eskf {
126 /// Fuse the caller's claim that the vehicle is still: zero velocity on all three axes.
127 /// Equation (29″).
128 ///
129 /// Without horizontal aiding nothing observes tilt, so a vehicle on the bench with no GNSS
130 /// loses its tilt on the schedule [`ImuNoise`](crate::ImuNoise) sets: with the position hold
131 /// off, [`Validity::tilt`] goes false 4.84 to 10.3 s into an unaided start, and the hold only
132 /// slows that. A zero velocity is what holds it: tilt
133 /// error times gravity lands in velocity at once, and the update takes it back out. PX4 and
134 /// ArduPilot hold the attitude on the ground the same way.
135 ///
136 /// Zero velocity rather than PX4's constant position (`EKF2_POS_LOCK`): what the caller
137 /// knows is that the vehicle is not moving, which needs no anchor and observes tilt one
138 /// integration nearer than a position does. ArduPilot fuses zero velocity for the same case.
139 ///
140 /// The filter cannot know the vehicle is still; the caller asserts it, as it asserts the
141 /// static window's stillness, from a landed detector, a disarmed state or its own test. A
142 /// claim made while the vehicle moves reads its acceleration as tilt, and the gate is what
143 /// stands between that and the estimate.
144 ///
145 /// `noise` is how still: the velocity the vibration and the claim's own error leave. Upstream
146 /// chooses far apart, ArduPilot 1 m/s for its synthetic zero velocity
147 /// (`AP_NavEKF3_PosVelFusion.cpp:769-775` at `368dc0c4`), PX4 0.01 m on the position it holds
148 /// at rest (`fake_pos_control.cpp:57-60` at `c4e4ef98`). A tighter σ levels faster and lets
149 /// less vibration through as tilt.
150 ///
151 /// A claim inconsistent with the estimate at [`Gates::stationary`](crate::Gates) is
152 /// [`Fusion::Rejected`] and changes nothing. It is never adopted, whatever
153 /// [`Config::recovery`](crate::Config::recovery) says: a stationary claim the gate keeps turning down
154 /// is a caller wrong about the vehicle, and adopting it would write that into the state. Nor
155 /// does it establish velocity after a coarse start, which only a sensor does; it does end the
156 /// position hold's claim on velocity, whose covariance the stationary claim then bounds, and never
157 /// its claim on position. No
158 /// [`Config::correlation`](crate::Config::correlation) either: its error is vibration, not
159 /// a receiver's error persisting from one solution to the next.
160 ///
161 /// Not a source [`Status`](crate::Status) counts: it says where the vehicle is not going,
162 /// not where it is, so a bench with no GNSS stays `DeadReckoning` while its tilt holds.
163 ///
164 /// [`Validity::tilt`]: crate::Validity::tilt
165 pub fn fuse_stationary(&mut self, time: Timestamp, noise: VelocityNoise<Ned>) -> Fusion {
166 if let Err(refusal) = self.admit(time) {
167 return refuse(&mut self.diagnostics.stationary, refusal);
168 }
169 self.diagnostics.stationary.note_arrival(time);
170 if !noise.is_finite() {
171 return refuse(&mut self.diagnostics.stationary, Fusion::NotFinite);
172 }
173 if !noise.is_positive() {
174 return refuse(&mut self.diagnostics.stationary, Fusion::InvalidNoise);
175 }
176 let variance = Vector3::from(noise.variance());
177 let observation = self.observe(time, |past, _| hold::zero_velocity(past, variance));
178 let outcome = update(
179 self.estimate.state(),
180 &self.covariance,
181 &self.offset,
182 &observation,
183 self.config.gates.stationary,
184 );
185 // With no timeout `apply_or_recover` is `apply`, out of line, and never adopts.
186 let fusion = self.apply_or_recover(outcome, |d| &mut d.stationary, None, |_| false);
187 // The claim, not the hold, now bounds velocity. It says nothing of where the vehicle is.
188 if fusion.is_accepted() {
189 self.hold.end_velocity();
190 }
191 fusion
192 }
193
194 /// Fuse the position hold if the filter is unaided and one is due. Equation (28″).
195 ///
196 /// Called by [`predict`](Self::predict) after each committed step, since nothing else is
197 /// guaranteed to run while nothing aids the filter. Unaided is
198 /// [`is_unaided`](Self::is_unaided): no horizontal measurement judged within
199 /// [`Timeouts::dead_reckoning_after`](crate::Timeouts), and no stationary claim accepted either, a
200 /// caller's claim that the vehicle is still being the better constraint. The anchor is the
201 /// estimate on the step the hold engages, re-taken after each release, as PX4 takes its
202 /// `_last_known_gpos` from the estimate when its fake position starts
203 /// (`fake_pos_control.cpp:97-105` at `c4e4ef98`).
204 ///
205 /// Out of line so that `predict`'s own frame does not grow by an update's: the update's
206 /// frame then sits beside the propagation's rather than above it.
207 #[inline(never)]
208 pub(super) fn hold_if_unaided(&mut self) {
209 let Some(Hold { sigma }) = self.config.hold else {
210 return;
211 };
212 if !self.is_unaided() {
213 // The latches outlive the outage: only a measurement of each quantity clears it.
214 self.hold.anchor = None;
215 self.hold.fused = None;
216 return;
217 }
218 // Released as PX4 releases it, so the next engagement anchors afresh where the
219 // estimate has got to.
220 if self.tilt_within(&self.covariance, HOLD_TILT) {
221 self.hold.anchor = None;
222 return;
223 }
224 if self
225 .hold
226 .fused
227 .is_some_and(|fused| self.time.since(fused) < HOLD_INTERVAL)
228 {
229 return;
230 }
231 let position = *self
232 .hold
233 .anchor
234 .get_or_insert_with(|| self.estimate.state().position.vector());
235 self.hold.fused = Some(self.time);
236 self.diagnostics.position_hold.note_arrival(self.time);
237 let variance = sigma.as_meters() * sigma.as_meters();
238 let observation = hold::position_hold(self.estimate.state(), position, variance)
239 .correlated(correlation_inflation(
240 self.diagnostics.position_hold.since_measured,
241 Some(HOLD_TAU),
242 ));
243 let outcome = update(
244 self.estimate.state(),
245 &self.covariance,
246 &self.offset,
247 &observation,
248 self.config.gates.position_hold,
249 );
250 // Through `apply_or_recover`, out of line, so the `Update` is not copied into this frame;
251 // with no timeout it never adopts.
252 let fusion = self.apply_or_recover(outcome, |d| &mut d.position_hold, None, |_| false);
253 // A hold the gate turned down shaped nothing.
254 if fusion.is_accepted() {
255 self.hold.position = true;
256 self.hold.velocity = true;
257 }
258 }
259
260 /// Whether nothing holds horizontal position or velocity: no horizontal measurement judged,
261 /// and no stationary claim accepted, within [`Timeouts::dead_reckoning_after`](crate::Timeouts).
262 ///
263 /// Judged rather than accepted, as PX4 starts its fake position only once GNSS fusion has
264 /// stopped rather than while its fixes fail the gate: a receiver the gate keeps turning down
265 /// is [`Recovery`](crate::Recovery)'s, and a hold engaged under it would adopt the next fix at
266 /// once. On `7ce66f0d`, whose fixes are rejected for seconds at a time, a white hold from the
267 /// first unaided step, engaged on acceptance, took recoveries from 27 to 62 ([measured]).
268 ///
269 /// [measured]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#hold
270 fn is_unaided(&self) -> bool {
271 let after = self.config.timeouts.dead_reckoning_after;
272 let d = &self.diagnostics;
273 !d.horizontal().any(|source| source.judged_within(after))
274 && !d.stationary.accepted_within(after)
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::super::fixtures::*;
281 use crate::config::{Config, Recovery};
282 use crate::eskf::Eskf;
283 use crate::health::{Fusion, Propagation, Refusal, Status};
284 use crate::init::tests::still;
285 use crate::units::{Position, Seconds, VelocityNoise};
286
287 fn fix_at_origin(filter: &mut Eskf) -> crate::GnssFusion {
288 filter.fuse_gnss_position(
289 filter.now(),
290 Position::zero(),
291 one_metre(),
292 Position::zero(),
293 )
294 }
295
296 fn holds(filter: &Eskf) -> u32 {
297 let hold = filter.diagnostics().position_hold;
298 hold.accepted + hold.rejected
299 }
300
301 fn stationary() -> VelocityNoise<crate::frames::Ned> {
302 VelocityNoise::from_speed_accuracy(0.1)
303 }
304
305 /// The control and the claim from one start, the hold off in both: an unaided bench loses
306 /// tilt about 5 s in from a window whose gyroscope never scattered, and the same bench told
307 /// it is still keeps it.
308 #[test]
309 fn a_stationary_claim_holds_tilt_on_a_bench_with_no_aiding() {
310 let (mut told, mut unaided) = (unheld(), unheld());
311 let mut claims = 0;
312 hold(&mut told, 30.0, 10, |filter| {
313 let outcome = filter.fuse_stationary(filter.now(), stationary());
314 assert!(outcome.is_accepted(), "{outcome:?}");
315 claims += 1;
316 });
317 hold(&mut unaided, 30.0, 10, |_| {});
318 assert_eq!(claims, 300);
319 assert!(told.validity().tilt);
320 assert!(!unaided.validity().tilt);
321 // Nothing aids it: a claim is not a sensor.
322 assert_eq!(told.state().status, Status::DeadReckoning);
323 }
324
325 /// Survives `apply_or_recover` in place of `apply`: the default recovery adopts a horizontal
326 /// source after 7 s of rejections, and these run for 8.
327 #[test]
328 fn a_stationary_claim_the_gate_turns_down_is_never_adopted() {
329 // The hold would pull an unaided 20 m/s toward zero and let a claim in.
330 let mut filter = flying(Config {
331 hold: None,
332 ..Config::default()
333 });
334 let velocity = filter.state().velocity;
335 let tight = VelocityNoise::from_speed_accuracy(0.01);
336 let dt = Seconds::from_secs(0.1);
337 for _ in 0..80 {
338 let _ = filter.step(still().imu, dt);
339 let outcome = filter.fuse_stationary(filter.now(), tight);
340 assert!(matches!(outcome, Fusion::Rejected { .. }), "{outcome:?}");
341 }
342 let health = filter.diagnostics().stationary;
343 assert_eq!(
344 (health.rejected, health.adopted, health.recovered),
345 (80, 0, 0)
346 );
347 // Ten seconds of coasting moved it a little; a claim adopted would have stopped it.
348 assert!(filter.state().velocity.to_array()[0] > velocity.to_array()[0] - 1.0);
349 }
350
351 /// The filter [`initialized`] starts, stepped unaided until the hold first fuses: once its
352 /// tilt σ has passed [`HOLD_TILT`](super::HOLD_TILT), a few seconds in.
353 fn engaged() -> Eskf {
354 let mut filter = initialized();
355 for _ in 0..2000 {
356 if holds(&filter) > 0 {
357 return filter;
358 }
359 assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
360 }
361 panic!("no hold in 20 s unaided");
362 }
363
364 /// The hold's own control: the same unaided start, with and without it, a minute in. The
365 /// vehicle is level throughout, so the estimate's tilt is its error.
366 #[test]
367 fn the_hold_bounds_tilt_through_an_unaided_minute_and_claims_nothing_else() {
368 let (mut held, mut unaided) = (initialized(), unheld());
369 hold(&mut held, 60.0, 1, |_| {});
370 hold(&mut unaided, 60.0, 1, |_| {});
371 let sigma = |filter: &Eskf| {
372 let v = filter.attitude_variance();
373 v.tilt_north.max(v.tilt_east).sqrt()
374 };
375 assert!(
376 sigma(&held) < 0.5 * sigma(&unaided),
377 "held {} rad against {} unaided",
378 sigma(&held),
379 sigma(&unaided)
380 );
381 // Bounded honestly: the level truth sits inside the σ it claims.
382 let (roll, pitch, _) = held.state().attitude.euler_angles();
383 assert!(roll.abs().max(pitch.abs()) < 3.0 * sigma(&held));
384 assert_eq!(held.diagnostics().position_hold.rejected, 0);
385 let validity = held.validity();
386 assert!(!validity.horizontal_position && !validity.horizontal_velocity);
387 assert_eq!(held.state().status, Status::DeadReckoning);
388 }
389
390 /// Survives the gate removed: the tilt σ just after a start is well under 3°, so an ungated
391 /// hold fuses on the first step.
392 #[test]
393 fn a_hold_waits_for_the_tilt_to_need_it() {
394 let mut filter = initialized();
395 hold(&mut filter, 1.0, 1, |_| {});
396 assert_eq!(holds(&filter), 0);
397 assert!(filter.tilt_within(filter.covariance(), super::HOLD_TILT));
398 // And once the σ has grown past it, the hold fuses: `engaged` panics otherwise.
399 let _ = engaged();
400 }
401
402 #[test]
403 fn the_hold_waits_out_the_dead_reckoning_timeout_after_the_last_fix() {
404 let mut filter = engaged();
405 hold(&mut filter, 2.0, 20, |filter| {
406 assert!(fix_at_origin(filter).horizontal.is_accepted());
407 });
408 let before = holds(&filter);
409 // Inside the 5 s still aided; past it, held. Neither lands on the boundary.
410 hold(&mut filter, 4.9, 1, |_| {});
411 assert_eq!(holds(&filter), before);
412 hold(&mut filter, 0.2, 1, |_| {});
413 assert_eq!(holds(&filter), before + 1);
414 }
415
416 /// Survives the condition read on acceptance instead: a fix the gate turns down is still a
417 /// receiver speaking, and holding through it would hand every rejection to the hold.
418 #[test]
419 fn a_receiver_the_gate_turns_down_keeps_the_hold_off() {
420 let mut filter = engaged();
421 let far = Position::ned(500.0, 0.0, 0.0);
422 // The first is adopted, the hold being engaged; the rest are rejected for 6 s.
423 let _ = filter.fuse_gnss_position(filter.now(), far, one_metre(), Position::zero());
424 let before = holds(&filter);
425 let elsewhere = Position::ned(-500.0, 0.0, 0.0);
426 hold(&mut filter, 6.0, 20, |filter| {
427 let outcome =
428 filter.fuse_gnss_position(filter.now(), elsewhere, one_metre(), Position::zero());
429 assert!(
430 matches!(outcome.horizontal, Fusion::Rejected { .. }),
431 "{outcome:?}"
432 );
433 });
434 assert_eq!(holds(&filter), before);
435 }
436
437 #[test]
438 fn an_accepted_fix_ends_the_hold_and_the_next_outage_anchors_afresh() {
439 let mut filter = engaged();
440 assert!(!filter.validity().horizontal_position);
441 assert!(fix_at_origin(&mut filter).horizontal.is_accepted());
442 // Over at once, before the step that clears the anchor.
443 assert!(filter.validity().horizontal_position);
444 let before = holds(&filter);
445 hold(&mut filter, 1.0, 20, |filter| {
446 assert!(fix_at_origin(filter).horizontal.is_accepted());
447 });
448 assert_eq!(holds(&filter), before);
449 assert_eq!(filter.hold, super::HoldState::default());
450 }
451
452 /// Each filter is the control for the one before: the hold's own adoption, the lockout
453 /// timeout it skips, and the switch that turns both off.
454 #[test]
455 fn the_first_fix_after_a_hold_that_the_gate_turns_down_is_adopted_at_once() {
456 let far = Position::ned(500.0, 0.0, 0.0);
457 let mut filter = engaged();
458 let outcome = filter.fuse_gnss_position(filter.now(), far, one_metre(), Position::zero());
459 assert_eq!(outcome.horizontal, Fusion::Reset);
460 assert_eq!(filter.diagnostics().gnss_position.recovered, 1);
461 assert!(filter.validity().horizontal_position);
462
463 // Without the hold the same fix waits out `Recovery::gnss_position`.
464 let mut filter = unheld();
465 hold(&mut filter, 5.0, 1, |_| {});
466 let outcome = filter.fuse_gnss_position(filter.now(), far, one_metre(), Position::zero());
467 assert!(matches!(outcome.horizontal, Fusion::Rejected { .. }));
468
469 // And a source whose recovery is off is never adopted, hold or not.
470 let mut filter = Eskf::new(Config {
471 recovery: Recovery::OFF,
472 ..Config::default()
473 })
474 .unwrap();
475 let _ = filter.initialize_over(&[still(); 8], Seconds::from_secs(0.25));
476 hold(&mut filter, 20.0, 1, |_| {});
477 assert!(holds(&filter) > 0);
478 let outcome = filter.fuse_gnss_position(filter.now(), far, one_metre(), Position::zero());
479 assert!(matches!(outcome.horizontal, Fusion::Rejected { .. }));
480 }
481
482 #[test]
483 fn a_stationary_claim_stands_the_hold_down() {
484 let mut filter = engaged();
485 let before = holds(&filter);
486 hold(&mut filter, 10.0, 10, |filter| {
487 let _ = filter.fuse_stationary(filter.now(), stationary());
488 });
489 // The steps before the first claim are the only ones that could hold.
490 assert!(holds(&filter) <= before + 1);
491 assert_eq!((filter.hold.anchor, filter.hold.fused), (None, None));
492 }
493
494 /// A filter whose bars the hold's covariance passes with room, held for a minute: what is
495 /// left when the outage ends is the covariance the assumption shaped.
496 fn held_under_loose_bars(recovery: Recovery) -> Eskf {
497 let mut filter = Eskf::new(Config {
498 accuracy: crate::config::Accuracy {
499 position: crate::units::Meters::from_meters(1000.0),
500 velocity: crate::units::MetersPerSecond::from_m_per_s(1000.0),
501 ..crate::config::Accuracy::default()
502 },
503 recovery,
504 ..Config::default()
505 })
506 .unwrap();
507 let _ = filter.initialize_over(&[still(); 8], Seconds::from_secs(0.25));
508 hold(&mut filter, 60.0, 1, |_| {});
509 assert!(holds(&filter) > 0);
510 filter
511 }
512
513 fn horizontal(filter: &Eskf) -> (bool, bool) {
514 let validity = filter.validity();
515 (validity.horizontal_position, validity.horizontal_velocity)
516 }
517
518 /// Survives either latch cleared when the outage ends, as one `held` flag read with
519 /// `is_unaided` was: each of these ends the outage and measures no position, and the 1000 m
520 /// bar would pass the hold's σ.
521 #[test]
522 fn the_end_of_an_outage_validates_only_what_was_measured() {
523 // A stationary claim bounds velocity and says nothing of position.
524 let mut filter = held_under_loose_bars(Recovery::default());
525 hold(&mut filter, 1.0, 10, |filter| {
526 let outcome = filter.fuse_stationary(filter.now(), stationary());
527 assert!(outcome.is_accepted(), "{outcome:?}");
528 });
529 assert_eq!(horizontal(&filter), (false, true));
530
531 // A fix the gate turns down, with no recovery to adopt it, measures neither.
532 let mut filter = held_under_loose_bars(Recovery::OFF);
533 let far = Position::ned(500.0, 0.0, 0.0);
534 hold(&mut filter, 1.0, 10, |filter| {
535 let outcome =
536 filter.fuse_gnss_position(filter.now(), far, one_metre(), Position::zero());
537 assert!(matches!(outcome.horizontal, Fusion::Rejected { .. }));
538 });
539 assert_eq!(horizontal(&filter), (false, false));
540 // Nor does the outage ending that way keep an anchor for the next one.
541 assert_eq!(filter.hold.anchor, None);
542
543 // A velocity measures velocity, and a position after it position.
544 let mut filter = held_under_loose_bars(Recovery::default());
545 hold_velocity(&mut filter, crate::units::Velocity::zero());
546 hold(&mut filter, 1.0, 1, |_| {});
547 assert_eq!(horizontal(&filter), (false, true));
548 assert!(fix_at_origin(&mut filter).horizontal.is_accepted());
549 assert_eq!(horizontal(&filter), (true, true));
550 }
551
552 /// Survives `end_velocity` removed from `adopt_velocity`: the reset is the only thing
553 /// here that could clear the latch, and the validity is read before the next hold sets it.
554 #[test]
555 fn a_velocity_reset_ends_the_hold_on_velocity_alone() {
556 let mut filter = held_under_loose_bars(Recovery::default());
557 let noise = VelocityNoise::from_speed_accuracy(0.3);
558 assert!(filter.reset_velocity_to(crate::units::Velocity::zero(), noise));
559 assert_eq!(horizontal(&filter), (false, true));
560 assert_eq!(filter.hold.anchor, None);
561 }
562
563 /// Survives the hold kept across a start: the second window is the only thing
564 /// between the hold and the validity read.
565 #[test]
566 fn a_new_start_forgets_the_hold() {
567 let mut filter = held_under_loose_bars(Recovery::default());
568 assert_eq!(horizontal(&filter), (false, false));
569 let _ = filter.initialize_over(&[still(); 8], Seconds::from_secs(0.25));
570 assert_eq!(filter.hold, super::HoldState::default());
571 assert_eq!(horizontal(&filter), (true, true));
572 }
573
574 /// Survives the velocity cleared by the first fix after a hold, which the gate passes
575 /// against the hold's own covariance, or never cleared by a position at all: a receiver
576 /// reporting no velocity then reads it invalid for the rest of the run.
577 #[test]
578 fn a_second_fix_after_a_hold_checks_the_velocity_between_them() {
579 let mut filter = held_under_loose_bars(Recovery::default());
580 assert!(matches!(
581 fix_at_origin(&mut filter).horizontal,
582 Fusion::Accepted { .. }
583 ));
584 assert_eq!(horizontal(&filter), (true, false));
585 hold(&mut filter, 0.2, 1, |_| {});
586 assert!(fix_at_origin(&mut filter).horizontal.is_accepted());
587 assert_eq!(horizontal(&filter), (true, true));
588 }
589
590 /// Survives the velocity's recovery read without the hold's latch: without it the first
591 /// velocity the gate turns down waits out `Recovery::gnss_velocity`.
592 #[test]
593 fn the_first_velocity_after_a_hold_that_the_gate_turns_down_is_adopted_at_once() {
594 let fast = crate::units::Velocity::ned(50.0, 0.0, 0.0);
595 let noise = VelocityNoise::from_speed_accuracy(0.3);
596 let mut filter = engaged();
597 let outcome = filter.fuse_gnss_velocity(filter.now(), fast, noise, Position::zero());
598 assert_eq!(outcome, Fusion::Reset);
599 assert_eq!(filter.diagnostics().gnss_velocity.recovered, 1);
600
601 let mut filter = unheld();
602 hold(&mut filter, 5.0, 1, |_| {});
603 let outcome = filter.fuse_gnss_velocity(filter.now(), fast, noise, Position::zero());
604 assert!(matches!(outcome, Fusion::Rejected { .. }), "{outcome:?}");
605 }
606
607 /// Survives `adopt_position` ending the hold on any axis: horizontal recovery is off and
608 /// every fix is 500 m away, so the height's recovery is the only adoption.
609 #[test]
610 fn a_height_recovery_does_not_validate_horizontal_position() {
611 let mut filter = held_under_loose_bars(Recovery {
612 gnss_position: None,
613 ..Recovery::default()
614 });
615 let far = Position::ned(500.0, 0.0, -500.0);
616 hold(&mut filter, 8.0, 10, |filter| {
617 let outcome =
618 filter.fuse_gnss_position(filter.now(), far, one_metre(), Position::zero());
619 assert!(matches!(outcome.horizontal, Fusion::Rejected { .. }));
620 });
621 assert_eq!(filter.diagnostics().gnss_height.recovered, 1);
622 assert_eq!(horizontal(&filter), (false, false));
623 }
624
625 /// Survives the stationary claim gated at another source's gate: at `Gates::default()` the two
626 /// three-axis gates are equal, so this one sets the stationary claim's apart, and a claim made at
627 /// 20 m/s passes only its own.
628 #[test]
629 fn a_stationary_claim_is_gated_at_its_own_gate() {
630 let mut filter = flying(Config {
631 hold: None,
632 gates: crate::config::Gates {
633 stationary: crate::config::Gate::new(1.0e9).unwrap(),
634 ..crate::config::Gates::default()
635 },
636 ..Config::default()
637 });
638 let outcome = filter.fuse_stationary(filter.now(), stationary());
639 assert!(outcome.is_accepted(), "{outcome:?}");
640 }
641
642 /// Survives `admit` removed: an update would otherwise run on a filter with no state.
643 #[test]
644 fn a_stationary_claim_before_a_start_or_from_the_future_is_refused() {
645 let mut filter = Eskf::default();
646 let outcome = filter.fuse_stationary(crate::units::Timestamp::ZERO, stationary());
647 assert_eq!(outcome, Fusion::NotInitialized);
648 let mut filter = initialized();
649 hold(&mut filter, 10.0, 1, |_| {});
650 let outcome = filter.fuse_stationary(crate::units::Timestamp::ZERO, stationary());
651 assert!(
652 matches!(outcome, Fusion::OutOfHorizon { .. }),
653 "{outcome:?}"
654 );
655 assert_eq!(filter.diagnostics().stationary.refused, 1);
656 }
657
658 /// Survives `Coasted` dropped from `predict`'s match: every step here is past
659 /// `max_predict_dt`, so no other outcome runs the hold.
660 #[test]
661 fn a_coasted_step_runs_the_hold() {
662 let mut filter = initialized();
663 let gap = Seconds::from_secs(0.5);
664 for _ in 0..40 {
665 let outcome = filter.step(still().imu, gap);
666 assert!(
667 matches!(outcome, Propagation::Coasted { .. }),
668 "{outcome:?}"
669 );
670 }
671 assert!(holds(&filter) > 0);
672 }
673
674 /// Survives the reset leaving the hold alone: forty seconds unaided widen the covariance
675 /// enough that the stale anchor passes the gate and pulls the estimate back to 2.4 m, from
676 /// the 100 the caller set. Right after an engagement it would not: the anchor is rejected.
677 #[test]
678 fn a_reset_ends_the_hold_rather_than_being_pulled_back_by_it() {
679 let mut filter = initialized();
680 hold(&mut filter, 40.0, 1, |_| {});
681 let there = Position::ned(100.0, 0.0, 0.0);
682 assert!(filter.reset_position_to(there, one_metre()));
683 hold(&mut filter, 20.0, 1, |_| {});
684 let north = filter.state().position.to_array()[0];
685 assert!(north > 95.0, "pulled back to {north} m");
686 }
687
688 /// Survives either guard in `validity_of` removed: the bar is loose enough that the
689 /// covariance alone would call both valid.
690 #[test]
691 fn position_and_velocity_stay_invalid_through_the_whole_outage() {
692 let mut filter = Eskf::new(Config {
693 accuracy: crate::config::Accuracy {
694 position: crate::units::Meters::from_meters(1000.0),
695 velocity: crate::units::MetersPerSecond::from_m_per_s(1000.0),
696 ..crate::config::Accuracy::default()
697 },
698 ..Config::default()
699 })
700 .unwrap();
701 let _ = filter.initialize_over(&[still(); 8], Seconds::from_secs(0.25));
702 let mut held = 0;
703 hold(&mut filter, 60.0, 1, |filter| {
704 if holds(filter) > 0 {
705 let validity = filter.validity();
706 assert!(!validity.horizontal_position && !validity.horizontal_velocity);
707 held += 1;
708 }
709 });
710 assert!(held > 5000, "{held} steps under the hold");
711 }
712
713 /// Survives the interval read off the anchor, which a release clears: re-engaging then fuses
714 /// on the next step.
715 #[test]
716 fn the_hold_fuses_no_faster_than_every_two_tenths_across_releases() {
717 let mut filter = initialized();
718 let (mut last, mut shortest, mut count) = (None, f32::INFINITY, holds(&filter));
719 hold(&mut filter, 60.0, 1, |filter| {
720 if holds(filter) != count {
721 count = holds(filter);
722 let now = filter.now();
723 if let Some(last) = last {
724 shortest = shortest.min(now.since(last).as_secs());
725 }
726 last = Some(now);
727 }
728 });
729 assert!(count > 10, "{count} holds");
730 assert!(shortest >= 0.2 - 1e-4, "{shortest} s between two holds");
731 }
732
733 #[test]
734 fn a_stationary_claim_with_no_usable_noise_is_refused() {
735 let mut filter = initialized();
736 let now = filter.now();
737 let nan = VelocityNoise::from_speed_accuracy(f32::NAN);
738 assert_eq!(filter.fuse_stationary(now, nan), Fusion::NotFinite);
739 let zero = VelocityNoise::from_speed_accuracy(0.0);
740 assert_eq!(filter.fuse_stationary(now, zero), Fusion::InvalidNoise);
741 let health = filter.diagnostics().stationary;
742 assert_eq!(health.refused, 2);
743 assert_eq!(health.last_refusal, Some(Refusal::InvalidNoise));
744 }
745}