Skip to main content

fusion_nav/eskf/
adopt.rs

1//! Adoption: a measurement taken whole rather than fused, for a quantity the start never
2//! established or a source locked out past [`Config::recovery`](crate::Config::recovery), and the
3//! caller's own resets. The writers here are what an adoption commits; the decision to adopt is
4//! each source's, in `fuse.rs` and `heading.rs`. Also the barometric reference a first altitude
5//! reads from the estimate, (30) solved for `α₀` with its row of (30′).
6//!
7//! Entry points: [`Eskf::reset_position_to`] and [`Eskf::reset_velocity_to`].
8
9use crate::frames::Ned;
10use crate::state::{ErrorState, Offset, State};
11use crate::units::{
12    Altitude, AltitudeNoise, Position, PositionNoise, Timestamp, Velocity, VelocityNoise,
13};
14
15use super::Eskf;
16
17impl Eskf {
18    /// Force position to an external fix and reset its covariance block.
19    ///
20    /// The position becomes the fix, its variances become the fix's noise, and its
21    /// correlations with the rest of the state are dropped — the new error came from the
22    /// measurement and has nothing to do with the errors that preceded it.
23    ///
24    /// For an application that owns recovery: the filter does the same on its own, per
25    /// source, unless [`Config::recovery`](crate::Config::recovery) turns it off; see
26    /// [`Recovery`](crate::Recovery).
27    ///
28    /// Returns `false`, changing nothing, for a fix or a noise that is not a number, or a
29    /// variance that is not positive — the bar every `fuse_*` applies, and it matters more
30    /// here: this writes `noise` straight onto the covariance diagonal, with no gate and
31    /// no innovation to dilute it. See [`Fusion::NotFinite`](crate::Fusion::NotFinite) and
32    /// [`Fusion::InvalidNoise`](crate::Fusion::InvalidNoise).
33    ///
34    /// A reset is not aiding. While the position hold of [`Config::hold`](crate::Config::hold)
35    /// is engaged it fuses again within 0.2 s, about the position set here, and
36    /// [`Validity`](crate::Validity) reads the quantity invalid again: a position that is to
37    /// stay valid without a sensor wants the hold off.
38    #[must_use = "a refused reset leaves the estimate where it was, still dead-reckoning"]
39    pub fn reset_position_to(
40        &mut self,
41        position: Position<Ned>,
42        noise: PositionNoise<Ned>,
43    ) -> bool {
44        if !position.is_finite() || !noise.is_finite() || !noise.is_positive() {
45            return false;
46        }
47        self.adopt_position(position, noise, POSITION);
48        self.unestablished.position = false;
49        true
50    }
51
52    /// Force velocity to an external solution and reset its covariance block.
53    ///
54    /// See [`reset_position_to`](Self::reset_position_to), including what is refused.
55    #[must_use = "a refused reset leaves the estimate where it was, still dead-reckoning"]
56    pub fn reset_velocity_to(
57        &mut self,
58        velocity: Velocity<Ned>,
59        noise: VelocityNoise<Ned>,
60    ) -> bool {
61        if !velocity.is_finite() || !noise.is_finite() || !noise.is_positive() {
62            return false;
63        }
64        self.adopt_velocity(velocity, noise);
65        self.unestablished.velocity = false;
66        true
67    }
68
69    /// Take the `axes` of a checked fix as the position, with the fix's variances on them:
70    /// every axis on a first adoption or a caller's reset, north and east or down alone on a
71    /// recovery, since a fix is two sources gated apart.
72    // Out of line, as `adopt_velocity`: inlined, its copy of `P` lands in the `fuse_*` frame that
73    // `update` then stacks on. Called after `update` returns, it adds nothing to the deepest path.
74    #[inline(never)]
75    pub(super) fn adopt_position<const N: usize>(
76        &mut self,
77        position: Position<Ned>,
78        noise: PositionNoise<Ned>,
79        axes: [ErrorState; N],
80    ) {
81        let (z, r) = (position.vector(), noise.variance());
82        let mut adopted = self.estimate.state().position.vector();
83        let mut variances = [0.0; N];
84        for (axis, variance) in axes.iter().zip(&mut variances) {
85            // `get` rather than indexing: an out-of-range index is a panic, and every axis
86            // passed here is a position state, so this never misses.
87            let i = axis
88                .index()
89                .saturating_sub(ErrorState::PositionNorth.index());
90            if let (Some(adopted), Some(z), Some(r)) = (adopted.get_mut(i), z.get(i), r.get(i)) {
91                *adopted = *z;
92                *variance = *r;
93            }
94        }
95        self.estimate.commit(State {
96            position: Position::ned(adopted[0], adopted[1], adopted[2]),
97            ..*self.estimate.state()
98        });
99        self.reset_block(axes, variances);
100        if axes.contains(&ErrorState::PositionNorth) {
101            self.hold.end_position();
102            self.yaw_replaced.settle_position();
103        }
104    }
105
106    /// Recover GNSS height: adopt the down axis, and let the barometer read its reference
107    /// again against it.
108    ///
109    /// A lockout of GNSS height is a barometer holding the height somewhere the receiver
110    /// disagrees with, so the reference that put it there is dropped with the height it
111    /// described, and the next altitude reads one from the estimate as a start that left
112    /// none does — where
113    /// [`Config::baro_reference_from_estimate`](crate::Config::baro_reference_from_estimate) allows
114    /// it. Where it does not, the caller owns the reference and it is kept, only decorrelated from
115    /// the height `reset_block` replaced.
116    pub(super) fn adopt_height(&mut self, position: Position<Ned>, noise: PositionNoise<Ned>) {
117        self.adopt_position(position, noise, [ErrorState::PositionDown]);
118        if self.config.baro_reference_from_estimate {
119            self.establish_reference(None);
120        }
121    }
122
123    /// Take a checked velocity solution as the velocity, all three axes.
124    // Out of line for the reason `adopt_position` is.
125    #[inline(never)]
126    pub(super) fn adopt_velocity(&mut self, velocity: Velocity<Ned>, noise: VelocityNoise<Ned>) {
127        self.estimate.commit(State {
128            velocity,
129            ..*self.estimate.state()
130        });
131        self.reset_block(
132            [
133                ErrorState::VelocityNorth,
134                ErrorState::VelocityEast,
135                ErrorState::VelocityDown,
136            ],
137            noise.variance(),
138        );
139        self.hold.end_velocity();
140        self.yaw_replaced.settle_velocity();
141    }
142
143    /// Read `α₀` from the estimate at one altitude, `α̂₀ = α + p̂_D` — (30) solved for α₀ with
144    /// `z = p̂_D`, so that the altitude lands on the estimate — correlated with the height it
145    /// was read against, `P_bb = P_DD + R_m` and `P_xb = −P[:, D]` of (30′). See
146    /// [`fuse_baro_altitude`](Self::fuse_baro_altitude) for why.
147    pub(super) fn reference_from_estimate(
148        &mut self,
149        altitude: Altitude,
150        noise: AltitudeNoise,
151        time: Timestamp,
152    ) {
153        // Against the height when the altitude was read, as (23′) reads every measurement.
154        let (past, _) = self.past(time);
155        let reference = Altitude::from_meters(altitude.as_meters() + past.position.vector()[2]);
156        let offset = Offset::from_estimate(&self.covariance, noise.variance());
157        self.establish_reference(Some((reference, offset)));
158    }
159
160    /// Give three states the variances of a measurement adopted for them, dropping their
161    /// correlations with everything else — the barometric offset of (30′) included, since the
162    /// new error is the measurement's and has nothing to do with the reference's.
163    fn reset_block<const N: usize>(&mut self, states: [ErrorState; N], variances: [f32; N]) {
164        let mut covariance = self.covariance;
165        covariance.reset_block(states, variances);
166        let mut offset = self.offset;
167        for state in states {
168            offset.decorrelate(state);
169        }
170        self.commit_covariance(covariance, offset);
171    }
172}
173
174/// The position axes, as a first adoption and a caller's reset take them.
175pub(super) const POSITION: [ErrorState; 3] = [
176    ErrorState::PositionNorth,
177    ErrorState::PositionEast,
178    ErrorState::PositionDown,
179];
180
181/// The horizontal half of a GNSS fix, as its recovery adopts it.
182pub(super) const HORIZONTAL: [ErrorState; 2] =
183    [ErrorState::PositionNorth, ErrorState::PositionEast];
184
185#[cfg(test)]
186mod tests {
187
188    use crate::config::{Config, GRAVITY, Recovery};
189    use crate::eskf::Eskf;
190    use crate::eskf::fixtures::*;
191    use crate::frames::Ned;
192
193    use crate::health::{Fusion, GnssFusion, Propagation};
194    use crate::init::tests::still;
195
196    use crate::propagate::ImuSample;
197    use crate::state::{Covariance, ErrorState};
198    use crate::units::{
199        Acceleration, Altitude, AltitudeNoise, AngularRate, Position, PositionNoise, Seconds,
200        Velocity, VelocityNoise,
201    };
202
203    #[test]
204    fn an_adopted_measurement_is_counted_apart_from_an_ordinary_acceptance() {
205        let mut filter = coarse();
206        let noise = PositionNoise::horizontal_vertical(1.5, 1.5);
207        assert_eq!(
208            filter.fuse_gnss_position(
209                filter.now(),
210                Position::ned(120.0, -40.0, -75.0),
211                noise,
212                Position::zero()
213            ),
214            GnssFusion::both(Fusion::Reset)
215        );
216        // The second fix has an estimate to be judged against, so it is fused, not adopted.
217        assert!(
218            filter
219                .fuse_gnss_position(
220                    filter.now(),
221                    Position::ned(121.0, -40.0, -75.0),
222                    noise,
223                    Position::zero()
224                )
225                .is_accepted()
226        );
227
228        let health = filter.diagnostics().gnss_position;
229        assert_eq!(health.adopted, 1, "adoption happens once per quantity");
230        assert_eq!(health.accepted, 2, "and counts as an acceptance besides");
231    }
232
233    #[test]
234    fn an_adopted_position_carries_no_correlation_with_the_reference() {
235        let mut filter = aided();
236        for _ in 0..100 {
237            assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
238        }
239        let _ = filter.fuse_baro_altitude(
240            filter.now(),
241            Altitude::from_meters(100.0),
242            AltitudeNoise::from_sigma(0.5),
243        );
244        let down = ErrorState::PositionDown.index();
245        assert!(
246            filter.offset.cross[down] != 0.0,
247            "the altitude correlated the two"
248        );
249
250        assert!(filter.reset_position_to(
251            Position::ned(1.0, 2.0, -3.0),
252            PositionNoise::from_sigma(1.0, 1.0, 1.0)
253        ));
254        assert_eq!(filter.offset.cross.fixed_rows::<3>(0).norm(), 0.0);
255    }
256
257    #[test]
258    fn a_reset_below_the_floor_is_floored_and_counted_rather_than_refused() {
259        // The seed is refused because it writes the covariance in whole; a reset writes one
260        // block against an estimate that exists, so the floor repairs it instead. This is
261        // what keeps `floored` reachable from the public API at all, since
262        // `initialize_from` turns the other path away.
263        let mut filter = aided();
264        assert_eq!(filter.diagnostics().floored, 0);
265
266        assert!(filter.reset_position_to(
267            Position::ned(10.0, 20.0, -5.0),
268            PositionNoise::from_sigma(1e-15, 1e-15, 1e-15),
269        ));
270
271        assert_eq!(filter.diagnostics().floored, 3);
272        assert!(filter.covariance().variance(ErrorState::PositionNorth) >= 1e-6);
273    }
274
275    #[test]
276    fn after_a_coarse_start_the_first_fix_is_adopted_not_fused() {
277        let mut filter = coarse();
278        let fix = Position::ned(120.0, -40.0, -75.0);
279        let outcome = filter.fuse_gnss_position(
280            filter.now(),
281            fix,
282            PositionNoise::horizontal_vertical(1.5, 1.5),
283            Position::zero(),
284        );
285
286        assert_eq!(outcome, GnssFusion::both(Fusion::Reset));
287        assert!(outcome.is_accepted(), "the measurement was used");
288        assert!(outcome.is_reset(), "and it stepped the state");
289        assert_eq!(filter.state().position, fix);
290        assert!(
291            (filter.covariance().variance(ErrorState::PositionNorth) - 2.25).abs() < 1e-6,
292            "the fix's own variance, not the configured prior"
293        );
294
295        // Once is once: there is now an estimate for a gate to judge against, and the same
296        // fix again is fused against it rather than adopted a second time.
297        let outcome = filter.fuse_gnss_position(
298            filter.now(),
299            fix,
300            PositionNoise::horizontal_vertical(1.5, 1.5),
301            Position::zero(),
302        );
303        assert!(
304            matches!(outcome.horizontal, Fusion::Accepted { .. })
305                && matches!(outcome.height, Fusion::Accepted { .. }),
306            "{outcome:?}"
307        );
308        assert_eq!(filter.diagnostics().gnss_position.adopted, 1);
309    }
310
311    #[test]
312    fn velocity_is_adopted_too_and_the_two_are_independent() {
313        let mut filter = coarse();
314        let velocity = Velocity::ned(18.0, 1.0, -0.5);
315        assert!(
316            filter
317                .fuse_gnss_velocity(
318                    filter.now(),
319                    velocity,
320                    VelocityNoise::from_speed_accuracy(0.3),
321                    Position::zero()
322                )
323                .is_reset()
324        );
325        assert_eq!(filter.state().velocity, velocity);
326
327        // Adopting velocity says nothing about position, which is still unknown.
328        assert!(
329            filter
330                .fuse_gnss_position(
331                    filter.now(),
332                    Position::ned(1.0, 2.0, 3.0),
333                    PositionNoise::horizontal_vertical(1.5, 1.5),
334                    Position::zero()
335                )
336                .is_reset()
337        );
338    }
339
340    #[test]
341    fn a_static_start_knows_where_it_is_so_its_first_fix_is_fused() {
342        let mut filter = initialized();
343        let outcome = filter.fuse_gnss_position(
344            filter.now(),
345            Position::ned(0.2, -0.1, 0.0),
346            PositionNoise::horizontal_vertical(1.5, 1.5),
347            Position::zero(),
348        );
349        assert!(
350            !outcome.is_reset(),
351            "the origin is where it was, by definition"
352        );
353        assert!(outcome.is_accepted());
354    }
355
356    #[test]
357    fn a_reset_drops_the_correlations_the_old_estimate_had() {
358        let mut filter = coarse();
359        // Give the covariance a correlation to destroy.
360        let mut matrix = *filter.covariance().as_matrix();
361        let (p_n, v_n) = (
362            ErrorState::PositionNorth.index(),
363            ErrorState::VelocityNorth.index(),
364        );
365        matrix[(p_n, v_n)] = 0.5;
366        matrix[(v_n, p_n)] = 0.5;
367        filter.covariance = Covariance::from_matrix(matrix);
368
369        assert!(filter.reset_position_to(
370            Position::ned(10.0, 0.0, 0.0),
371            PositionNoise::horizontal_vertical(1.0, 1.0),
372        ));
373
374        let after = filter.covariance().as_matrix();
375        assert_eq!(after[(p_n, v_n)], 0.0, "the new error came from the fix");
376        assert_eq!(after[(v_n, p_n)], 0.0);
377        assert_eq!(after[(p_n, p_n)], 1.0);
378        assert!(
379            after[(v_n, v_n)] > 0.0,
380            "resetting position must not disturb velocity"
381        );
382    }
383
384    #[test]
385    fn a_seed_is_trusted_and_is_never_overwritten_by_a_fix() {
386        let (state, covariance) = seed();
387        let mut filter = Eskf::default();
388        let _ = filter.seed(state, covariance).expect("a sane seed");
389        let outcome = filter.fuse_gnss_velocity(
390            filter.now(),
391            Velocity::ned(0.0, 0.0, 0.0),
392            VelocityNoise::from_speed_accuracy(0.3),
393            Position::zero(),
394        );
395        assert!(
396            !outcome.is_reset(),
397            "the caller vouched for this velocity; its covariance says how far"
398        );
399        assert_eq!(filter.state().velocity, state.velocity);
400    }
401
402    #[test]
403    fn an_adopted_fix_is_referred_to_the_imu() {
404        let mut filter = coarse();
405        let yaw = filter.state().attitude.quaternion();
406        let at_antenna = Position::ned(10.0, 20.0, -5.0);
407        let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
408        let adopted = filter.fuse_gnss_position(filter.now(), at_antenna, noise, mast());
409        assert!(adopted.is_reset(), "{adopted:?}");
410        let imu = at_antenna.vector() - yaw * mast().vector();
411        assert!(near(filter.state().position, Position::from_vector(imu)));
412    }
413
414    #[test]
415    fn an_adopted_velocity_is_the_imus_not_the_antennas_swing() {
416        let mut filter = coarse();
417        let imu = ImuSample::reading(
418            AngularRate::body(0.0, 0.0, 0.5),
419            Acceleration::body(0.0, 0.0, -GRAVITY),
420        );
421        assert!(filter.step(imu, DT).is_propagated());
422        let rotation = filter.state().attitude.quaternion();
423        let omega = filter
424            .angular_rate()
425            .expect("a step was integrated")
426            .vector();
427        let at_antenna = Velocity::ned(3.0, -2.0, 0.5);
428        let noise = VelocityNoise::from_speed_accuracy(0.3);
429        let adopted = filter.fuse_gnss_velocity(filter.now(), at_antenna, noise, mast());
430        assert!(adopted.is_reset(), "{adopted:?}");
431        let imu = at_antenna.vector() - rotation * omega.cross(&mast().vector());
432        let got = filter.state().velocity.vector();
433        assert!((got - imu).norm() < 1e-4, "{got} against {imu}");
434    }
435
436    #[test]
437    fn a_coarse_start_does_not_adopt_a_fix_whose_noise_is_impossible() {
438        // Where the check matters most: adoption writes `noise` onto the covariance
439        // diagonal with no gate in the way, and a negative variance there passes
440        // `validity`'s `variance <= sigma^2` — position would be reported valid.
441        let mut filter = coarse();
442        let fix = Position::ned(120.0, -40.0, -75.0);
443        assert_eq!(
444            filter.fuse_gnss_position(
445                filter.now(),
446                fix,
447                PositionNoise::<Ned>::from_variance(-1.0, -1.0, -1.0),
448                Position::zero()
449            ),
450            GnssFusion::both(Fusion::InvalidNoise)
451        );
452        assert_eq!(filter.state().position, Position::zero(), "nothing adopted");
453        assert!(!filter.validity().horizontal_position);
454        assert!(
455            filter
456                .fuse_gnss_position(
457                    filter.now(),
458                    fix,
459                    PositionNoise::horizontal_vertical(1.5, 1.5),
460                    Position::zero()
461                )
462                .is_reset(),
463            "the adoption is still owed to the first usable fix"
464        );
465    }
466
467    #[test]
468    fn an_external_reset_refuses_what_would_poison_the_state() {
469        let mut filter = initialized();
470        assert!(!filter.reset_position_to(
471            Position::ned(f32::NAN, 0.0, 0.0),
472            PositionNoise::horizontal_vertical(1.5, 1.5)
473        ));
474        assert!(!filter.reset_position_to(
475            Position::ned(10.0, 0.0, 0.0),
476            PositionNoise::<Ned>::from_variance(1.0, 0.0, 1.0)
477        ));
478        assert_eq!(
479            filter.state().position,
480            Position::zero(),
481            "a refused reset changes nothing"
482        );
483
484        assert!(!filter.reset_velocity_to(
485            Velocity::ned(1.0, 0.0, 0.0),
486            VelocityNoise::<Ned>::from_variance(1.0, 1.0, -0.25)
487        ));
488        assert_eq!(filter.state().velocity, Velocity::zero());
489
490        assert!(!filter.set_baro_reference(
491            Altitude::from_meters(f32::NAN),
492            AltitudeNoise::from_sigma(0.001)
493        ));
494        assert_eq!(
495            filter.baro_reference(),
496            None,
497            "a NaN alpha_0 would end barometric aiding for the flight, not one update"
498        );
499        assert!(filter.set_baro_reference(
500            Altitude::from_meters(52.0),
501            AltitudeNoise::from_sigma(0.001)
502        ));
503    }
504
505    // ---- recovery from gate lockout ----
506
507    /// A kilometer north of a vehicle that has not moved.
508    fn far() -> Position<Ned> {
509        Position::ned(1000.0, 0.0, 0.0)
510    }
511
512    #[test]
513    fn a_fix_rejected_past_the_timeout_is_adopted_and_its_height_is_left_to_its_own_gate() {
514        let mut filter = unheld();
515        // Rejected for everything short of `Recovery::gnss_position`, counted from
516        // initialization since nothing was ever accepted.
517        hold(&mut filter, 6.9, 100, |filter| {
518            let outcome =
519                filter.fuse_gnss_position(filter.now(), far(), one_metre(), Position::zero());
520            assert!(
521                matches!(outcome.horizontal, Fusion::Rejected { .. }),
522                "{outcome:?}"
523            );
524        });
525        hold(&mut filter, 0.1, 10, |_| {});
526
527        // Half a meter down: inside the height gate, and where an adoption of all three axes
528        // would put the estimate exactly.
529        let fix = Position::ned(1000.0, 0.0, 0.5);
530        let outcome = filter.fuse_gnss_position(filter.now(), fix, one_metre(), Position::zero());
531        assert_eq!(outcome.horizontal, Fusion::Reset);
532        assert!(
533            matches!(outcome.height, Fusion::Accepted { .. }),
534            "the height agreed, so it is fused rather than adopted: {outcome:?}"
535        );
536        let position = filter.state().position.vector();
537        assert_eq!((position[0], position[1]), (1000.0, 0.0));
538        assert!(
539            position[2] < 0.4,
540            "fused part of the way, not adopted: {}",
541            position[2]
542        );
543        assert!(
544            (filter.covariance().variance(ErrorState::PositionNorth) - 1.0).abs() < 1e-6,
545            "the fix's variance, which is what undoes the lockout"
546        );
547        let d = filter.diagnostics();
548        assert_eq!((d.gnss_position.recovered, d.gnss_position.adopted), (1, 1));
549        assert_eq!(d.gnss_height.recovered, 0);
550
551        // Recovered, so the next fix is judged again rather than adopted.
552        let outcome = filter.fuse_gnss_position(filter.now(), far(), one_metre(), Position::zero());
553        assert!(
554            matches!(outcome.horizontal, Fusion::Accepted { .. }),
555            "{outcome:?}"
556        );
557    }
558
559    #[test]
560    fn a_fix_that_agrees_after_a_long_silence_is_fused_not_adopted() {
561        // Past the timeout is not enough: only a measurement the gate rejects is a lockout.
562        let mut filter = initialized();
563        hold(&mut filter, 10.0, 1000, |_| {});
564        let outcome = filter.fuse_gnss_position(
565            filter.now(),
566            Position::zero(),
567            one_metre(),
568            Position::zero(),
569        );
570        assert!(
571            matches!(outcome.horizontal, Fusion::Accepted { .. })
572                && matches!(outcome.height, Fusion::Accepted { .. }),
573            "{outcome:?}"
574        );
575        assert_eq!(filter.diagnostics().gnss_position.adopted, 0);
576    }
577
578    #[test]
579    fn a_named_reference_is_kept_through_a_barometer_lockout() {
580        // `baro_reference_from_estimate` off says the caller owns `α₀` — a surveyed pad — so
581        // a barometer that disagrees for longer than the timeout stays rejected.
582        let mut filter = Eskf::new(Config {
583            baro_reference_from_estimate: false,
584            ..Config::default()
585        })
586        .unwrap();
587        let _ = filter
588            .initialize_over(&[still(); 8], Seconds::from_secs(0.25))
589            .expect("a 2 s window of stillness");
590        assert!(
591            filter.set_baro_reference(Altitude::from_meters(100.0), AltitudeNoise::from_sigma(0.1))
592        );
593        let mut step = 0;
594        hold(&mut filter, 8.0, 10, |filter| {
595            step += 1;
596            if step % 10 == 0 {
597                let _ = filter.fuse_gnss_position(
598                    filter.now(),
599                    Position::zero(),
600                    one_metre(),
601                    Position::zero(),
602                );
603            }
604            let outcome = filter.fuse_baro_altitude(
605                filter.now(),
606                Altitude::from_meters(150.0),
607                AltitudeNoise::from_sigma(0.5),
608            );
609            assert!(matches!(outcome, Fusion::Rejected { .. }), "{outcome:?}");
610        });
611        let reference = filter.baro_reference().expect("named").as_meters();
612        assert!((reference - 100.0).abs() < 0.5, "α₀ = {reference}");
613        assert_eq!(filter.diagnostics().baro_altitude.recovered, 0);
614    }
615
616    #[test]
617    fn a_barometer_is_not_re_referenced_before_position_is_established() {
618        // A restart in motion keeps the flight's reference with no position to read a new
619        // one against, so a barometer rejected past the timeout stays rejected.
620        let mut filter = aided();
621        let mut window = [still(); 8];
622        window[3].imu = window[3].imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
623        let _ = filter
624            .initialize_over(&window, Seconds::from_secs(0.25))
625            .expect("moving, not unusable");
626        hold(&mut filter, 8.0, 10, |filter| {
627            let outcome = filter.fuse_baro_altitude(
628                filter.now(),
629                Altitude::from_meters(5000.0),
630                AltitudeNoise::from_sigma(0.5),
631            );
632            assert!(matches!(outcome, Fusion::Rejected { .. }), "{outcome:?}");
633        });
634        assert_eq!(filter.baro_reference(), Some(Altitude::from_meters(100.0)));
635    }
636
637    #[test]
638    fn with_recovery_off_a_locked_out_source_is_rejected_for_good() {
639        let mut filter = Eskf::new(Config {
640            recovery: Recovery::OFF,
641            ..Config::default()
642        })
643        .unwrap();
644        let _ = filter
645            .initialize_over(&[still(); 8], Seconds::from_secs(0.25))
646            .expect("a 2 s window of stillness");
647        // Twice the timeout, and short of where dead reckoning alone grows `P` enough to
648        // take a kilometer back in, which is about 30 s at rest.
649        hold(&mut filter, 15.0, 100, |filter| {
650            let outcome =
651                filter.fuse_gnss_position(filter.now(), far(), one_metre(), Position::zero());
652            assert!(
653                matches!(outcome.horizontal, Fusion::Rejected { .. }),
654                "{outcome:?}"
655            );
656        });
657        assert_eq!(filter.diagnostics().gnss_position.adopted, 0);
658        assert!(filter.state().position.vector()[0].abs() < 1.0);
659    }
660
661    #[test]
662    fn only_a_rejection_recovers_and_a_refusal_never_does() {
663        let mut filter = initialized();
664        let nan = PositionNoise::from_sigma(f32::NAN, f32::NAN, f32::NAN);
665        hold(&mut filter, 10.0, 100, |filter| {
666            assert_eq!(
667                filter.fuse_gnss_position(filter.now(), far(), nan, Position::zero()),
668                GnssFusion::both(Fusion::NotFinite)
669            );
670        });
671        assert!(filter.state().position.vector()[0].abs() < 1.0);
672
673        // Nothing was accepted through all of that either, so the first fix the gate can
674        // judge and rejects is a lockout already: PX4 counts from `time_last_fuse` too.
675        let outcome = filter.fuse_gnss_position(filter.now(), far(), one_metre(), Position::zero());
676        assert_eq!(outcome.horizontal, Fusion::Reset);
677    }
678
679    #[test]
680    fn a_locked_out_velocity_is_adopted() {
681        let mut filter = unheld();
682        let velocity = Velocity::ned(20.0, 0.0, 0.0);
683        let noise = VelocityNoise::from_speed_accuracy(0.3);
684        hold(&mut filter, 6.9, 100, |filter| {
685            let outcome =
686                filter.fuse_gnss_velocity(filter.now(), velocity, noise, Position::zero());
687            assert!(matches!(outcome, Fusion::Rejected { .. }), "{outcome:?}");
688        });
689        hold(&mut filter, 0.1, 10, |_| {});
690        assert_eq!(
691            filter.fuse_gnss_velocity(filter.now(), velocity, noise, Position::zero()),
692            Fusion::Reset
693        );
694        assert_eq!(filter.state().velocity, velocity);
695        assert_eq!(filter.diagnostics().gnss_velocity.recovered, 1);
696    }
697
698    #[test]
699    fn an_adopted_velocity_is_established_and_the_next_solution_is_fused() {
700        let mut filter = coarse();
701        let velocity = Velocity::ned(18.0, 1.0, -0.5);
702        let noise = VelocityNoise::from_speed_accuracy(0.3);
703        assert!(
704            filter
705                .fuse_gnss_velocity(filter.now(), velocity, noise, Position::zero())
706                .is_reset()
707        );
708        assert!(
709            matches!(
710                filter.fuse_gnss_velocity(filter.now(), velocity, noise, Position::zero()),
711                Fusion::Accepted { .. }
712            ),
713            "once is once"
714        );
715        assert_eq!(filter.diagnostics().gnss_velocity.adopted, 1);
716    }
717
718    #[test]
719    fn a_locked_out_height_is_adopted_and_the_barometer_reads_its_reference_again() {
720        // The barometer holds the height at the window's while the receiver says 20 m up:
721        // GNSS height is rejected until `Recovery::gnss_height`, then adopted.
722        let mut filter = aided();
723        let fix = Position::ned(0.0, 0.0, -20.0);
724        let baro = AltitudeNoise::from_sigma(0.5);
725        let mut adopted_at = None;
726        let mut step = 0;
727        hold(&mut filter, 6.0, 10, |filter| {
728            step += 1;
729            if adopted_at.is_some() {
730                return;
731            }
732            if step % 10 == 0 {
733                let outcome =
734                    filter.fuse_gnss_position(filter.now(), fix, one_metre(), Position::zero());
735                if outcome.height == Fusion::Reset {
736                    adopted_at.get_or_insert(step);
737                    assert_eq!(filter.state().position.vector()[2], -20.0);
738                    assert_eq!(filter.baro_reference(), None, "dropped with the height");
739                    // The next altitude reads it again, against the adopted height.
740                    assert_eq!(
741                        filter.fuse_baro_altitude(filter.now(), Altitude::from_meters(100.0), baro),
742                        Fusion::Accepted { test_ratio: 0.0 }
743                    );
744                    let reference = filter.baro_reference().expect("read again").as_meters();
745                    assert!((reference - 80.0).abs() < 1e-3, "α₀ = {reference}");
746                    return;
747                }
748                assert!(
749                    matches!(outcome.height, Fusion::Rejected { .. }),
750                    "{outcome:?}"
751                );
752            }
753            let _ = filter.fuse_baro_altitude(filter.now(), Altitude::from_meters(100.0), baro);
754        });
755        let at = adopted_at.expect("recovered within 6 s") as f32 * 0.1;
756        assert!((5.0..=5.1).contains(&at), "at {at} s");
757        assert_eq!(filter.diagnostics().gnss_height.recovered, 1);
758        assert_eq!(filter.diagnostics().gnss_position.recovered, 0);
759    }
760
761    #[test]
762    fn a_locked_out_barometer_reads_its_reference_again_and_moves_nothing() {
763        let mut filter = aided();
764        let baro = AltitudeNoise::from_sigma(0.5);
765        let mut step = 0;
766        let mut recovered = false;
767        hold(&mut filter, 6.0, 10, |filter| {
768            step += 1;
769            if step % 10 == 0 {
770                let _ = filter.fuse_gnss_position(
771                    filter.now(),
772                    Position::zero(),
773                    one_metre(),
774                    Position::zero(),
775                );
776            }
777            if recovered {
778                return;
779            }
780            let before = filter.state();
781            match filter.fuse_baro_altitude(filter.now(), Altitude::from_meters(150.0), baro) {
782                Fusion::Rejected { .. } => {}
783                Fusion::Reset => {
784                    recovered = true;
785                    assert_eq!(filter.state(), before, "a reference, not a state");
786                    let reference = filter.baro_reference().expect("read again").as_meters();
787                    let down = before.position.vector()[2];
788                    assert!(
789                        (reference - (150.0 + down)).abs() < 1e-3,
790                        "α₀ = {reference}"
791                    );
792                }
793                outcome => panic!("{outcome:?}"),
794            }
795        });
796        assert!(recovered);
797        assert_eq!(filter.diagnostics().baro_altitude.recovered, 1);
798    }
799}