Skip to main content

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}