Skip to main content

fusion_nav/eskf/
fuse.rs

1//! The update every source shares, (23)–(27) at the measurement's own time, (23′), and at its
2//! equivalent white noise, (24′), then the sources that use it directly: GNSS position, (28) and
3//! (28′), converted from geodetic by (43) about the origin `site.rs` places by (44), GNSS
4//! velocity, (29) and (29′), and barometric altitude, (30) and (30′).
5//!
6//! Entry points: [`Eskf::fuse_gnss_position`], [`Eskf::fuse_gnss_geodetic`],
7//! [`Eskf::fuse_gnss_velocity`] and [`Eskf::fuse_baro_altitude`]. The shared path comes first,
8//! beside its first caller; `heading.rs` holds the sources that correct heading.
9
10use crate::config::LATENCY_HORIZON;
11use crate::frames::{Body, Ned};
12use crate::geodetic::{Geodetic, LocalOrigin};
13use crate::health::{Diagnostics, Fusion, GnssFusion, SourceHealth};
14use crate::math::correlation_inflation;
15use crate::observation::{baro, gnss};
16use crate::propagate;
17use crate::state::State;
18use crate::units::{
19    Altitude, AltitudeNoise, AngularRate, Position, PositionNoise, Seconds, Timestamp, Velocity,
20    VelocityNoise,
21};
22use crate::update::{Observation, Update, update};
23
24use super::Eskf;
25use super::adopt::{HORIZONTAL, POSITION};
26use super::hold::recovery_after;
27
28impl Eskf {
29    /// Commit what an update produced and record it against its source, handing the outcome
30    /// back. Equations (23)–(41) are `update`'s; this is only the bookkeeping.
31    ///
32    /// One place for the reason [`refuse`] is one place: every `fuse_*` that runs an update
33    /// ends here, so none can commit a state without the covariance that goes with it, record
34    /// an acceptance without restarting the timer, or forget [`note_alignment`] — which an
35    /// update owes as much as a reset does, since a position fix narrows the attitude block
36    /// through the correlations (17) builds.
37    ///
38    /// [`note_alignment`]: Self::note_alignment
39    fn apply(
40        &mut self,
41        outcome: Update,
42        source: fn(&mut Diagnostics) -> &mut SourceHealth,
43    ) -> Fusion {
44        match outcome {
45            Update::Accepted {
46                state,
47                covariance,
48                offset,
49                offset_correction,
50                ratio,
51                innovation,
52            } => {
53                self.estimate.commit(state);
54                self.commit_covariance(covariance, offset);
55                // (30′): `b` is the error in `α₀`, so its estimate comes off it.
56                if let Some(reference) = self.baro_reference {
57                    self.baro_reference = Some(Altitude::from_meters(
58                        reference.as_meters() - offset_correction,
59                    ));
60                }
61                source(&mut self.diagnostics).record_accepted(ratio, Some(innovation));
62                self.note_alignment();
63                Fusion::Accepted { test_ratio: ratio }
64            }
65            Update::Rejected { ratio, innovation } => {
66                source(&mut self.diagnostics).record_rejected(ratio, innovation);
67                Fusion::Rejected { test_ratio: ratio }
68            }
69            Update::Invalid => refuse(source(&mut self.diagnostics), Fusion::StateInvalid),
70        }
71    }
72
73    /// [`apply`](Self::apply), unless the gate rejected a source locked out for `after`, in
74    /// which case `adopt` takes the measurement instead: recovery from gate lockout, see
75    /// [`Recovery`](crate::Recovery).
76    ///
77    /// One place, for the reason `apply` is one place: every source recovers through here, so
78    /// none can adopt without counting it or forget [`note_alignment`](Self::note_alignment).
79    ///
80    /// `adopt` returns whether it adopted. One that would write a value that is not finite
81    /// commits nothing and the measurement is refused as [`Fusion::NotFinite`]: a finite fix
82    /// carried to now by (28′) can still overflow, a lever arm of `f32::MAX` rotated.
83    ///
84    /// Out of line for the reason [`observe`](Self::observe) is: inlined, the `Update` it takes
85    /// sits in each `fuse_*` frame above the update, and a geodetic fix's path then becomes the
86    /// crate's high-water mark ([measured]).
87    ///
88    /// [measured]:
89    /// https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#measured-cost-by-function
90    #[inline(never)]
91    pub(super) fn apply_or_recover(
92        &mut self,
93        outcome: Update,
94        source: fn(&mut Diagnostics) -> &mut SourceHealth,
95        after: Option<Seconds>,
96        adopt: impl FnOnce(&mut Self) -> bool,
97    ) -> Fusion {
98        let since_initialized = self.diagnostics.since_initialized;
99        let locked_out = source(&mut self.diagnostics).locked_out(after, since_initialized);
100        if !(matches!(outcome, Update::Rejected { .. }) && locked_out) {
101            return self.apply(outcome, source);
102        }
103        if !adopt(self) {
104            return refuse(source(&mut self.diagnostics), Fusion::NotFinite);
105        }
106        source(&mut self.diagnostics).record_recovered();
107        self.note_alignment();
108        Fusion::Reset
109    }
110
111    /// Fuse a position fix already expressed in NED meters about the filter's origin.
112    /// Equation (28).
113    ///
114    /// For a receiver reporting latitude and longitude, use
115    /// [`fuse_gnss_geodetic`](Self::fuse_gnss_geodetic), which converts about the origin
116    /// the filter holds. This one is for a caller that owns the conversion, or whose
117    /// positions were never geodetic — a local RTK base, motion capture. Mixing the two is
118    /// only right if the caller's origin is [`origin`](Self::origin).
119    ///
120    /// After a coarse start the first fix is adopted rather than fused; see
121    /// [`Fusion::Reset`].
122    ///
123    /// `noise` is the receiver's own accuracy where it reports one, bounded before it
124    /// arrives: [`PositionNoise::clamped`](crate::PositionNoise::clamped) takes `eph` and
125    /// `epv` and holds each within its own floor and cap, for the reasons recorded there. A
126    /// two-dimensional fix instead goes through
127    /// [`PositionNoise::horizontal_vertical`](crate::PositionNoise::horizontal_vertical),
128    /// which leaves the vertical σ where the caller put it: `clamped` caps both axes, so
129    /// it would turn a declined height back into a measurement.
130    ///
131    /// The filter applies no bound of its own, because `R` describes the measurement and
132    /// belongs with it rather than in [`Config`](crate::Config). A caller handing over a raw `eph`
133    /// is therefore trusting the receiver further than either production autopilot does. Nor does
134    /// it smooth one: ArduPilot runs each accuracy through a decaying envelope with a 5 s time
135    /// constant before bounding it (`AP_NavEKF3_Measurements.cpp:609-633` at `368dc0c4`), so a
136    /// spike in `eph` deweights the fixes after it for seconds there and only its own fix here. The
137    /// replay harness takes the same stance, for the reasons `data/README.md` gives under
138    /// `r_policy=`. What the filter does add is the receiver's rather than the fix's: a fix's error
139    /// persists into the next one, and the update is computed at the variance that leaves, equation
140    /// (24′), with the gate still reading `noise` itself. See
141    /// [`Config::correlation`](crate::Config::correlation).
142    ///
143    /// The interval (24′) reads is the time since the previous fix fused through this method,
144    /// so it assumes one receiver. Fixes from a second one, or from motion capture,
145    /// interleaved with the first read as the same error arriving sooner and are deweighted
146    /// though their errors are independent.
147    ///
148    /// The fix is two measurements, gated and reported apart: north and east at
149    /// [`Gates::gnss_position`](crate::Gates), then down at
150    /// [`Gates::gnss_height`](crate::Gates), against the state the first left. A half
151    /// inconsistent with the estimate is [`Fusion::Rejected`] and changes nothing but its
152    /// own health, so a height the estimate disagrees with costs no horizontal aiding; see
153    /// [`GnssFusion`] for the measurement behind that. A half whose numbers are unusable is
154    /// refused alone for the same reason — a 2D fix reporting `epv = 0` still fuses its
155    /// horizontal position.
156    ///
157    /// The adoption after a coarse start is the exception, and takes the fix whole: it writes
158    /// all three axes onto the covariance, so any unusable number refuses both halves.
159    ///
160    /// `antenna` is where the antenna sits relative to the IMU in body axes, forward, right,
161    /// down ([`Position::body`](crate::Position)), and [`Position::zero`](crate::Position) for
162    /// one on top of it. The fix is the antenna's, `p + R r` of (28′), and the estimate stays
163    /// the IMU's. Unlike PX4, which subtracts `R̂ r` from the fix and keeps `H` as it was
164    /// (`EKF/aid_sources/gnss/gps_control.cpp:351-354` at `c4e4ef98`), the update carries the
165    /// arm's dependence on attitude, `−R̂[r]×`, so a fix observes heading through a long mast;
166    /// see `observation/gnss.rs` for what that was measured against. An argument, as `noise`
167    /// is and as PX4 carries it on each GNSS message (`antenna_offset_x/y/z`), because a
168    /// second receiver has its own; from PX4's parameters it is `SENS_GPS0_OFF*` less
169    /// `EKF2_IMU_POS*`.
170    ///
171    /// `time` is when the measurement was taken, on the clock the IMU's samples are timed on;
172    /// [`Fusion::OutOfHorizon`] says which times cannot be placed.
173    pub fn fuse_gnss_position(
174        &mut self,
175        time: Timestamp,
176        position: Position<Ned>,
177        noise: PositionNoise<Ned>,
178        antenna: Position<Body>,
179    ) -> GnssFusion {
180        if let Err(refusal) = self.admit(time) {
181            return self.refuse_gnss(refusal);
182        }
183        self.diagnostics.gnss_position.note_arrival(time);
184        self.diagnostics.gnss_height.note_arrival(time);
185        // An arm that is not a number spoils both halves, and an adoption would write it into
186        // the state.
187        if !antenna.is_finite() {
188            return self.refuse_gnss(Fusion::NotFinite);
189        }
190        if self.unestablished.position {
191            if !position.is_finite() || !noise.is_finite() {
192                return self.refuse_gnss(Fusion::NotFinite);
193            }
194            if !noise.is_positive() {
195                return self.refuse_gnss(Fusion::InvalidNoise);
196            }
197            let Some(adopted) = self.carried_position(position, antenna, time) else {
198                return self.refuse_gnss(Fusion::NotFinite);
199            };
200            self.adopt_position(adopted, noise, POSITION);
201            self.unestablished.position = false;
202            self.diagnostics.gnss_position.record_adopted();
203            self.diagnostics.gnss_height.record_adopted();
204            return GnssFusion::both(Fusion::Reset);
205        }
206
207        let (z, r) = (position.vector(), noise.variance());
208        let horizontal = match screen(&[z[0], z[1]], &[r[0], r[1]]) {
209            Some(refusal) => refuse(&mut self.diagnostics.gnss_position, refusal),
210            None => {
211                let observation = self
212                    .observe(time, |past, _| {
213                        gnss::horizontal_observation(past, position, noise, antenna)
214                    })
215                    .correlated(correlation_inflation(
216                        self.diagnostics.gnss_position.since_measured,
217                        self.config.correlation.gnss_position,
218                    ));
219                let outcome = update(
220                    self.estimate.state(),
221                    &self.covariance,
222                    &self.offset,
223                    &observation,
224                    self.config.gates.gnss_position,
225                );
226                self.apply_or_recover(
227                    outcome,
228                    |diagnostics| &mut diagnostics.gnss_position,
229                    recovery_after(
230                        self.hold.holds_position() || self.yaw_replaced.owes_position(),
231                        self.config.recovery.gnss_position,
232                    ),
233                    |filter| {
234                        let adopted = filter.carried_position(position, antenna, time);
235                        adopted
236                            .map(|adopted| filter.adopt_position(adopted, noise, HORIZONTAL))
237                            .is_some()
238                    },
239                )
240            }
241        };
242        // An adoption ended the hold's claim on position on its way. A fix the gate passes
243        // measures position too, and one it passes against a position already measured since
244        // the hold has checked the velocity that carried the estimate there.
245        if matches!(horizontal, Fusion::Accepted { .. }) {
246            self.yaw_replaced.settle_position();
247        }
248        if matches!(horizontal, Fusion::Accepted { .. }) {
249            if !self.hold.holds_position() {
250                self.hold.end_velocity();
251            }
252            self.hold.end_position();
253        }
254        let height = match screen(&[z[2]], &[r[2]]) {
255            Some(refusal) => refuse(&mut self.diagnostics.gnss_height, refusal),
256            None => {
257                let observation = self
258                    .observe(time, |past, _| {
259                        gnss::height_observation(past, position, noise, antenna)
260                    })
261                    .correlated(correlation_inflation(
262                        self.diagnostics.gnss_height.since_measured,
263                        self.config.correlation.gnss_height,
264                    ));
265                let outcome = update(
266                    self.estimate.state(),
267                    &self.covariance,
268                    &self.offset,
269                    &observation,
270                    self.config.gates.gnss_height,
271                );
272                self.apply_or_recover(
273                    outcome,
274                    |diagnostics| &mut diagnostics.gnss_height,
275                    self.config.recovery.gnss_height,
276                    |filter| {
277                        let adopted = filter.carried_position(position, antenna, time);
278                        adopted
279                            .map(|adopted| filter.adopt_height(adopted, noise))
280                            .is_some()
281                    },
282                )
283            }
284        };
285        GnssFusion { horizontal, height }
286    }
287
288    /// Whether a measurement taken at `time` can be fused, or the refusal that says why not:
289    /// [`Fusion::NotInitialized`], or [`Fusion::OutOfHorizon`] for one older than
290    /// [`LATENCY_HORIZON`], later than the state by more than
291    /// [`Config::max_predict_dt`](crate::Config::max_predict_dt), or taken before a start
292    /// that did not show the vehicle at rest.
293    ///
294    /// The history begins at the start, so a measurement from before it would be placed at
295    /// the start's state. After a still window that is where the vehicle was; after a start in
296    /// motion, a seed or a single sample, it is `v τ` from where the vehicle was, 1.6 m for a
297    /// fix 110 ms old at 15 m/s, and the next measurement is a better use of the source.
298    ///
299    /// The filter's time is its last IMU sample's, so a measurement timed between that sample
300    /// and the next arrives ahead of it, which is the ordinary case for a caller fusing a sensor
301    /// the moment it reads; [`past`](Self::past) carries the state forward to it. The bound is
302    /// the one step (22′) would coast rather than integrate, the longest the filter extrapolates
303    /// on one sample.
304    pub(super) fn admit(&self, time: Timestamp) -> Result<(), Fusion> {
305        if !self.initialized {
306            return Err(Fusion::NotInitialized);
307        }
308        let age = self.time.since(time);
309        if age > LATENCY_HORIZON
310            || -age.as_secs() > self.config.max_predict_dt.as_secs()
311            || time < self.earliest
312        {
313            return Err(Fusion::OutOfHorizon { age });
314        }
315        Ok(())
316    }
317
318    /// The state at `time`, from the [`History`](crate::history::History), and how long before the
319    /// present it was placed: no further back than the history reaches, so that a measurement older
320    /// than the initialization is placed at the start rather than described by one time and
321    /// linearized at another.
322    pub(super) fn past(&self, time: Timestamp) -> (State, Seconds) {
323        let omega = self
324            .angular_rate
325            .unwrap_or_else(|| AngularRate::body(0.0, 0.0, 0.0));
326        let (past, placed) = self.estimate.at(time, self.time, omega);
327        (past, self.time.since(placed))
328    }
329
330    /// Form an observation of the state at `time`, expressed in today's error.
331    /// Equation (23′).
332    ///
333    /// `build` is the observation module's own function, handed the past state instead of the
334    /// present: the innovation of (23) is then the measurement against where the vehicle was
335    /// when it was taken, and a fix 150 ms old on a vehicle at 20 m/s is not 3 m of error
336    /// handed to the gate as truth. The past is read from the [`History`](crate::history::History)
337    /// rather than extrapolated from the present, and its `H` is carried to today's error through
338    /// (16)–(19) at the mean rates over the age, which the same history gives.
339    ///
340    /// Out of line, so that it sits beside `update` rather than beneath it: inlined into
341    /// `fuse_gnss_velocity`, it raises the crate's high-water mark ([measured]).
342    ///
343    /// [measured]:
344    /// https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#measured-cost-by-function
345    #[inline(never)]
346    pub(super) fn observe<const M: usize>(
347        &self,
348        time: Timestamp,
349        build: impl FnOnce(&State, AngularRate<Body>) -> Observation<M>,
350    ) -> Observation<M> {
351        if time == self.time {
352            return build(
353                self.estimate.state(),
354                self.mean_rate(self.estimate.state(), 0.0),
355            );
356        }
357        let (past, age) = self.past(time);
358        let tau = age.as_secs();
359        let omega = self.mean_rate(&past, tau);
360        if tau == 0.0 {
361            return build(&past, omega);
362        }
363        let now = self.estimate.state().attitude.quaternion();
364        // Mean rates over the age, rather than the last sample's: one sample's specific force
365        // carries the airframe's vibration, which the velocities either side of it average out.
366        let a_n = (self.estimate.state().velocity.vector() - past.velocity.vector()) / tau;
367        let a_b = now.inverse() * (a_n - propagate::gravity_vector(self.config.gravity));
368        let a = propagate::error_dynamics(self.estimate.state(), omega.vector(), a_b);
369        build(&past, omega).delayed(age, &a)
370    }
371
372    /// The body rate between `past`, `tau` before now, and now: the rotation the attitude
373    /// made, over the time it took. Bias-corrected by construction, since it is the estimate's
374    /// own turn. With no interval, the last sample's `ω` of (9), or none after a coast.
375    ///
376    /// What (23′) carries `H` with, and the rate a GNSS velocity's antenna turned at, (29′):
377    /// a solution 110 ms old is the antenna's motion then, not now.
378    fn mean_rate(&self, past: &State, tau: f32) -> AngularRate<Body> {
379        if tau == 0.0 {
380            return self.angular_rate.unwrap_or_default();
381        }
382        let (now, then) = (
383            self.estimate.state().attitude.quaternion(),
384            past.attitude.quaternion(),
385        );
386        AngularRate::from_vector((then.inverse() * now).scaled_axis() / tau)
387    }
388
389    /// A fix of the antenna taken at `time`, as the IMU's position now: referred to the IMU
390    /// by the attitude then, `z − R̂(t − τ) r` of (28′), and carried to now by the state's own
391    /// motion since, `+ x̂ − x̂(t − τ)`. For an adoption, which writes the measurement as the
392    /// state. A fix 110 ms old on a vehicle at 30 m/s, adopted as it stands, puts the estimate
393    /// 3.3 m behind.
394    ///
395    /// `None` where the sum overflows, which finite inputs can: an adoption would write it
396    /// into the state.
397    fn carried_position(
398        &self,
399        taken: Position<Ned>,
400        antenna: Position<Body>,
401        time: Timestamp,
402    ) -> Option<Position<Ned>> {
403        let (past, _) = self.past(time);
404        let arm = past.attitude.quaternion() * antenna.vector();
405        let moved = self.estimate.state().position.vector() - past.position.vector();
406        let carried = Position::from_vector(taken.vector() - arm + moved);
407        carried.is_finite().then_some(carried)
408    }
409
410    /// [`carried_position`](Self::carried_position) for a velocity, referred to the IMU by
411    /// (29′) at the mean rate over the measurement's age, or the last sample's for one taken
412    /// now.
413    pub(super) fn carried_velocity(
414        &self,
415        taken: Velocity<Ned>,
416        antenna: Position<Body>,
417        time: Timestamp,
418    ) -> Option<Velocity<Ned>> {
419        let (past, age) = self.past(time);
420        let omega = self.mean_rate(&past, age.as_secs());
421        let turning = past.attitude.quaternion() * omega.vector().cross(&antenna.vector());
422        let moved = self.estimate.state().velocity.vector() - past.velocity.vector();
423        let carried = Velocity::from_vector(taken.vector() - turning + moved);
424        carried.is_finite().then_some(carried)
425    }
426
427    /// Refuse both halves of a GNSS fix for one reason.
428    fn refuse_gnss(&mut self, outcome: Fusion) -> GnssFusion {
429        refuse(&mut self.diagnostics.gnss_position, outcome);
430        GnssFusion::both(refuse(&mut self.diagnostics.gnss_height, outcome))
431    }
432
433    /// Fuse a GNSS fix given as latitude, longitude and height. Equations (43), (44),
434    /// then (28).
435    ///
436    /// The filter holds the navigation origin and converts about it, so the fix and the
437    /// estimate are relative to the same point by construction. The first fix places the
438    /// origin, unless [`set_origin`](Self::set_origin) already has:
439    ///
440    /// * With a position estimate — a static start, a seed — the origin goes where that
441    ///   estimate says the vehicle started, so the fix lands on the estimate and nothing
442    ///   steps. Equation (44). The fix is not fused: it was spent placing the origin, and
443    ///   fusing it as well would count it twice. What it does settle is the position
444    ///   uncertainty. About the new origin the position error *is* the fix's error, so
445    ///   the position covariance block becomes `noise` and its correlations are dropped,
446    ///   as [`reset_position_to`](Self::reset_position_to) does, with the value unchanged.
447    ///   Reported as accepted with a zero test ratio: nothing was inconsistent.
448    /// * Without one — after a coarse start — the origin goes at the fix, and the fix is
449    ///   adopted as position zero; see [`Fusion::Reset`].
450    ///
451    /// Check the receiver's fix type before calling. Many report latitude and longitude
452    /// zero until they have a fix, and a finite zero is a usable origin: the first one
453    /// would put the navigation frame in the Gulf of Guinea for the rest of the flight.
454    ///
455    /// `noise` and `antenna` are as for [`fuse_gnss_position`](Self::fuse_gnss_position), floor
456    /// included, and so is the split into two gated halves. The origin (44) places goes under
457    /// the estimate of the antenna, since the fix is the antenna's.
458    ///
459    /// A fix that is not a number is refused with [`Fusion::NotFinite`], both halves, since
460    /// no conversion survives one; so is a noise that is not, where the fix would place the
461    /// origin, which writes all three axes. A fix
462    /// with a latitude beyond ±90° cannot place an origin, nor can one near a pole that no
463    /// origin puts at the estimate (see [`LocalOrigin::placing`]): with none held it is
464    /// refused with [`Fusion::NoReference`], and the next usable fix places it instead.
465    ///
466    /// `time` is when the measurement was taken, on the clock the IMU's samples are timed on;
467    /// [`Fusion::OutOfHorizon`] says which times cannot be placed.
468    pub fn fuse_gnss_geodetic(
469        &mut self,
470        time: Timestamp,
471        fix: Geodetic,
472        noise: PositionNoise<Ned>,
473        antenna: Position<Body>,
474    ) -> GnssFusion {
475        if let Err(refusal) = self.admit(time) {
476            return self.refuse_gnss(refusal);
477        }
478        // Noted again by `fuse_gnss_position` when it delegates, which the same time ignores.
479        self.diagnostics.gnss_position.note_arrival(time);
480        self.diagnostics.gnss_height.note_arrival(time);
481        if !fix.is_finite() || !antenna.is_finite() {
482            return self.refuse_gnss(Fusion::NotFinite);
483        }
484        if let Some(origin) = self.origin {
485            return self.fuse_gnss_position(time, origin.to_ned(fix), noise, antenna);
486        }
487        if !noise.is_finite() {
488            return self.refuse_gnss(Fusion::NotFinite);
489        }
490        if !noise.is_positive() {
491            return self.refuse_gnss(Fusion::InvalidNoise);
492        }
493
494        if self.unestablished.position {
495            let Some(origin) = LocalOrigin::new(fix) else {
496                return self.refuse_gnss(Fusion::NoReference);
497            };
498            // The adoption below refuses an arm that overflows, and placing the origin turns
499            // the heading, so ask first. The turn is about down and keeps the arm's length.
500            if self
501                .carried_position(Position::zero(), antenna, time)
502                .is_none()
503            {
504                return self.refuse_gnss(Fusion::NotFinite);
505            }
506            self.place_origin(origin);
507            return self.fuse_gnss_position(time, Position::zero(), noise, antenna);
508        }
509
510        // Equation (44): the origin under the estimate when the fix was taken, and the fix's
511        // error as the position's.
512        if !self.place_origin_under_estimate(fix, antenna, time) {
513            return self.refuse_gnss(Fusion::NoReference);
514        }
515        let placed = self.reset_position_to(self.estimate.state().position, noise);
516        debug_assert!(
517            placed,
518            "the estimate and the fix's noise are both already checked"
519        );
520        self.diagnostics.gnss_position.record_accepted(0.0, None);
521        self.diagnostics.gnss_height.record_accepted(0.0, None);
522        GnssFusion::both(Fusion::Accepted { test_ratio: 0.0 })
523    }
524
525    /// Fuse a GNSS velocity solution. Equation (29).
526    ///
527    /// After a coarse start the first solution is adopted rather than fused; see
528    /// [`Fusion::Reset`].
529    ///
530    /// `noise` is the receiver's speed accuracy, `sacc`, bounded the way
531    /// [`fuse_gnss_position`](Self::fuse_gnss_position)'s is:
532    /// [`VelocityNoise::clamped`](crate::VelocityNoise::clamped). Where the solution
533    /// carries no usable vertical velocity, or the receiver reports the axes separately,
534    /// [`VelocityNoise::horizontal_vertical`](crate::VelocityNoise::horizontal_vertical)
535    /// is the constructor to reach for: all three axes are fused or none, and a per-axis
536    /// σ is what says which of them the receiver actually measured.
537    ///
538    /// A solution inconsistent with the estimate at
539    /// [`Gates::gnss_velocity`](crate::Gates) is [`Fusion::Rejected`] and changes nothing
540    /// but the source's health. One joint test over all three axes, as (28)'s is.
541    ///
542    /// Fused at the variance (24′) leaves for a solution whose error persists from the last,
543    /// [`Config::correlation`](crate::Config::correlation)'s `gnss_velocity`;
544    /// the gate reads `noise` itself.
545    ///
546    /// `antenna` is as for [`fuse_gnss_position`](Self::fuse_gnss_position): the solution is
547    /// the antenna's velocity, `v + R(ω × r)` of (29′), with `ω` the rate the vehicle turned
548    /// at over the solution's age, read off the state's own history rather than the last
549    /// sample.
550    ///
551    /// `time` is when the measurement was taken, on the clock the IMU's samples are timed on;
552    /// [`Fusion::OutOfHorizon`] says which times cannot be placed.
553    pub fn fuse_gnss_velocity(
554        &mut self,
555        time: Timestamp,
556        velocity: Velocity<Ned>,
557        noise: VelocityNoise<Ned>,
558        antenna: Position<Body>,
559    ) -> Fusion {
560        if let Err(refusal) = self.admit(time) {
561            return refuse(&mut self.diagnostics.gnss_velocity, refusal);
562        }
563        self.diagnostics.gnss_velocity.note_arrival(time);
564        if !velocity.is_finite() || !noise.is_finite() || !antenna.is_finite() {
565            return refuse(&mut self.diagnostics.gnss_velocity, Fusion::NotFinite);
566        }
567        if !noise.is_positive() {
568            return refuse(&mut self.diagnostics.gnss_velocity, Fusion::InvalidNoise);
569        }
570        self.weigh_yaw(time, velocity, noise, antenna);
571        self.adopt_first_yaw();
572        if self.unestablished.velocity {
573            let Some(adopted) = self.carried_velocity(velocity, antenna, time) else {
574                return refuse(&mut self.diagnostics.gnss_velocity, Fusion::NotFinite);
575            };
576            self.adopt_velocity(adopted, noise);
577            self.unestablished.velocity = false;
578            self.diagnostics.gnss_velocity.record_adopted();
579            return Fusion::Reset;
580        }
581        let observation = self
582            .observe(time, |past, omega| {
583                gnss::velocity_observation(past, velocity, noise, antenna, omega)
584            })
585            .correlated(correlation_inflation(
586                self.diagnostics.gnss_velocity.since_measured,
587                self.config.correlation.gnss_velocity,
588            ));
589        let outcome = update(
590            self.estimate.state(),
591            &self.covariance,
592            &self.offset,
593            &observation,
594            self.config.gates.gnss_velocity,
595        );
596        if matches!(outcome, Update::Rejected { .. }) {
597            self.replace_failed_yaw();
598        }
599        let fusion = self.apply_or_recover(
600            outcome,
601            |diagnostics| &mut diagnostics.gnss_velocity,
602            recovery_after(
603                self.hold.holds_velocity() || self.yaw_replaced.owes_velocity(),
604                self.config.recovery.gnss_velocity,
605            ),
606            |filter| {
607                let adopted = filter.carried_velocity(velocity, antenna, time);
608                adopted
609                    .map(|adopted| filter.adopt_velocity(adopted, noise))
610                    .is_some()
611            },
612        );
613        // An adoption ended the hold's claim on velocity in `adopt_velocity`.
614        if matches!(fusion, Fusion::Accepted { .. }) {
615            self.hold.end_velocity();
616        }
617        fusion
618    }
619
620    /// Fuse a barometric altitude. Equation (30).
621    ///
622    /// The measurement is `z = -(α - α₀)`, so it needs a reference: one
623    /// [`initialize`](Self::initialize) derived from a window taken at rest, one
624    /// [`set_baro_reference`](Self::set_baro_reference) named, or — failing both — one this
625    /// call establishes from the estimate, below.
626    ///
627    /// `noise` being per call is what lets it carry a condition neither platform's
628    /// parameter can: ArduPilot multiplies the barometer variance by 4 in ground effect
629    /// (`gndEffectBaroScaler`, `AP_NavEKF3.h:511`, applied at
630    /// `AP_NavEKF3_PosVelFusion.cpp:1419`), and PX4 runs a deadzone around the same
631    /// condition. An application here inflates σ on the calls it applies to, and says in
632    /// its own code when that is.
633    ///
634    /// An altitude inconsistent with the estimate at
635    /// [`Gates::baro_altitude`](crate::Gates) is [`Fusion::Rejected`] and changes nothing
636    /// but the source's health. One degree of freedom, so the threshold is a `Gate<1>` and
637    /// a `Gate<3>` in that field does not compile.
638    ///
639    /// With no reference held, the first altitude offered once position is established
640    /// sets one, `α̂₀ = α + p̂_D`, so that it lands on the estimate — PX4's
641    /// `baro_height_control.cpp:79` at `c4e4ef98`. That covers every start that leaves no
642    /// reference: one in motion after its first GNSS fix, a window with no barometer in it,
643    /// and a seed. Leaving it to the caller costs `cd7e0001`, a coarse start, all 3530 of its
644    /// altitudes, with nothing but [`Fusion::NoReference`] on a source nobody reads to say
645    /// so. [`Config::baro_reference_from_estimate`](crate::Config::baro_reference_from_estimate)
646    /// turns it off for a caller that names its own.
647    ///
648    /// The reference inherits the height error of the estimate it was read against, so it is
649    /// seeded correlated with it, `P_bb = P_DD + R_m` and `P_xb = −P[:, D]` of (30′), rather
650    /// than as an independent σ. That is what keeps the error the first fix left in `α̂₀`
651    /// from reading as a hundred barometer readings' worth of agreement: held constant and
652    /// uncorrelated, it took `moving_start`'s `nees_pos` to 112.59.
653    ///
654    /// The altitude is spent placing the reference rather than fused, as a first fix is
655    /// spent placing the origin in [`fuse_gnss_geodetic`](Self::fuse_gnss_geodetic), and is
656    /// reported the same way: accepted with a zero test ratio, since nothing was
657    /// inconsistent. Fusing it as well would count its noise twice, once in `P_bb`.
658    ///
659    /// Until position is established there is no estimate to read the reference against,
660    /// and the altitude is refused with [`Fusion::NoReference`] rather than adopted: a
661    /// height is not a quantity this source can establish, because `α₀` is what relates
662    /// it to the origin.
663    ///
664    /// Fused at the variance (24′) leaves for an altitude whose error persists from the last,
665    /// [`Config::correlation`](crate::Config::correlation)'s `baro_altitude`;
666    /// the gate reads `noise` itself.
667    ///
668    /// `time` is when the measurement was taken, on the clock the IMU's samples are timed on;
669    /// [`Fusion::OutOfHorizon`] says which times cannot be placed.
670    pub fn fuse_baro_altitude(
671        &mut self,
672        time: Timestamp,
673        altitude: Altitude,
674        noise: AltitudeNoise,
675    ) -> Fusion {
676        if let Err(refusal) = self.admit(time) {
677            return refuse(&mut self.diagnostics.baro_altitude, refusal);
678        }
679        self.diagnostics.baro_altitude.note_arrival(time);
680        if !altitude.as_meters().is_finite() || !noise.is_finite() {
681            return refuse(&mut self.diagnostics.baro_altitude, Fusion::NotFinite);
682        }
683        if !noise.is_positive() {
684            return refuse(&mut self.diagnostics.baro_altitude, Fusion::InvalidNoise);
685        }
686        let Some(reference) = self.baro_reference else {
687            if self.unestablished.position || !self.config.baro_reference_from_estimate {
688                return refuse(&mut self.diagnostics.baro_altitude, Fusion::NoReference);
689            }
690            self.reference_from_estimate(altitude, noise, time);
691            self.diagnostics.baro_altitude.record_accepted(0.0, None);
692            return Fusion::Accepted { test_ratio: 0.0 };
693        };
694        let observation = self
695            .observe(time, |past, _| {
696                baro::altitude_observation(past, altitude, reference, noise)
697            })
698            .correlated(correlation_inflation(
699                self.diagnostics.baro_altitude.since_measured,
700                self.config.correlation.baro_altitude,
701            ));
702        let outcome = update(
703            self.estimate.state(),
704            &self.covariance,
705            &self.offset,
706            &observation,
707            self.config.gates.baro_altitude,
708        );
709        // Read against the estimate, so only once there is an estimate to read it against, and
710        // never over a reference the caller owns.
711        let after = if self.unestablished.position || !self.config.baro_reference_from_estimate {
712            None
713        } else {
714            self.config.recovery.baro_altitude
715        };
716        self.apply_or_recover(
717            outcome,
718            |diagnostics| &mut diagnostics.baro_altitude,
719            after,
720            |filter| {
721                filter.reference_from_estimate(altitude, noise, time);
722                true
723            },
724        )
725    }
726}
727
728/// Why one half of a GNSS fix cannot be judged, or `None` if it can: the checks every
729/// `fuse_*` makes of a whole measurement, made of the components that half reads.
730fn screen(values: &[f32], variances: &[f32]) -> Option<Fusion> {
731    if !values.iter().chain(variances).all(|v| v.is_finite()) {
732        return Some(Fusion::NotFinite);
733    }
734    if !variances.iter().all(|v| *v > 0.0) {
735        return Some(Fusion::InvalidNoise);
736    }
737    None
738}
739
740/// Record a refusal against the source that produced it, and hand the outcome back to the
741/// caller.
742///
743/// One place maps an outcome to what `Diagnostics` stores, so a `fuse_*` that grows another
744/// guard cannot forget to count it. A refusal moves no timer; see
745/// [`SourceHealth::record_refused`].
746pub(super) fn refuse(source: &mut SourceHealth, outcome: Fusion) -> Fusion {
747    if let Some(refusal) = outcome.refusal() {
748        source.record_refused(refusal);
749    }
750    outcome
751}
752
753#[cfg(test)]
754mod tests {
755
756    use crate::config::{Correlation, GRAVITY, LATENCY_HORIZON};
757    use crate::eskf::Eskf;
758    use crate::eskf::fixtures::*;
759    use crate::frames::Ned;
760    use crate::geodetic::Geodetic;
761    use crate::health::{Fusion, GnssFusion, Propagation, Refusal};
762    use crate::init::tests::still;
763
764    use crate::observation::mag::tests::{attitude_of, measured};
765
766    use crate::propagate::ImuSample;
767    use crate::state::{Covariance, ErrorState, STATES, State};
768    use crate::units::{
769        Acceleration, Altitude, AltitudeNoise, AngularRate, HeadingNoise, MagField, Position,
770        PositionNoise, Radians, Seconds, Timestamp, Velocity, VelocityNoise,
771    };
772
773    /// The horizon, both ends, at the barometer: a reading older than [`LATENCY_HORIZON`] or
774    /// further ahead of the state than a step is refused, counted as a refusal and moves no
775    /// timer, and one inside either bound reaches the gate.
776    #[test]
777    fn a_measurement_the_filter_cannot_place_in_time_is_refused() {
778        let mut filter = aided();
779        let now = filter.now();
780        let altitude = Altitude::from_meters(100.0);
781        let noise = AltitudeNoise::from_sigma(2.0);
782        let before = filter.diagnostics().baro_altitude;
783
784        let past = Seconds::from_secs(LATENCY_HORIZON.as_secs() + 0.001);
785        let ahead = Seconds::from_secs(filter.config().max_predict_dt.as_secs() + 0.001);
786        for (time, age) in [
787            (now.before(past), past.as_secs()),
788            (now.after(ahead), -ahead.as_secs()),
789        ] {
790            let Fusion::OutOfHorizon { age: refused } =
791                filter.fuse_baro_altitude(time, altitude, noise)
792            else {
793                panic!("not refused");
794            };
795            assert!(
796                (refused.as_secs() - age).abs() < 1e-6,
797                "{refused:?} against {age}"
798            );
799        }
800        let health = filter.diagnostics().baro_altitude;
801        assert_eq!(health.refused, before.refused + 2);
802        assert_eq!(health.last_refusal, Some(Refusal::OutOfHorizon));
803        assert_eq!(health.time_since_accepted, before.time_since_accepted);
804
805        let inside = Seconds::from_secs(LATENCY_HORIZON.as_secs() - 0.001);
806        assert!(
807            filter
808                .fuse_baro_altitude(now.before(inside), altitude, noise)
809                .is_accepted()
810        );
811        assert!(
812            filter
813                .fuse_baro_altitude(now.after(DT), altitude, noise)
814                .is_accepted()
815        );
816    }
817
818    #[test]
819    fn a_refused_measurement_is_visible_in_diagnostics_not_only_in_the_return_value() {
820        // The failure this guards: a miswired sensor feeding NaN reads as `never accepted`,
821        // exactly like one that was never connected, unless the refusal is counted.
822        let mut filter = initialized();
823        let nan = Velocity::ned(f32::NAN, 0.0, 0.0);
824        let noise = VelocityNoise::from_speed_accuracy(0.3);
825
826        assert_eq!(
827            filter.fuse_gnss_velocity(filter.now(), nan, noise, Position::zero()),
828            Fusion::NotFinite
829        );
830        assert_eq!(
831            filter.fuse_gnss_velocity(
832                filter.now(),
833                Velocity::ned(1.0, 0.0, 0.0),
834                VelocityNoise::from_variance(0.0, 1.0, 1.0),
835                Position::zero()
836            ),
837            Fusion::InvalidNoise
838        );
839
840        let health = filter.diagnostics().gnss_velocity;
841        assert_eq!(health.refused, 2);
842        assert_eq!(health.last_refusal, Some(Refusal::InvalidNoise));
843        assert_eq!(health.accepted, 0);
844        assert_eq!(health.rejected, 0, "neither reached the gate");
845        assert_eq!(
846            health.time_since_accepted, None,
847            "a refusal is not aiding, so no timer starts"
848        );
849        assert!(!health.has_been_used());
850    }
851
852    #[test]
853    fn a_refusal_names_which_kind_it_was() {
854        let mut filter = coarse();
855        assert_eq!(
856            filter.fuse_baro_altitude(
857                filter.now(),
858                Altitude::from_meters(60.0),
859                AltitudeNoise::from_sigma(2.0)
860            ),
861            Fusion::NoReference
862        );
863        assert_eq!(
864            filter.diagnostics().baro_altitude.last_refusal,
865            Some(Refusal::NoReference),
866            "a missing reference is a different problem from a bad number"
867        );
868
869        let mut fresh = Eskf::default();
870        assert_eq!(
871            fresh.fuse_baro_altitude(
872                fresh.now(),
873                Altitude::from_meters(60.0),
874                AltitudeNoise::from_sigma(2.0)
875            ),
876            Fusion::NotInitialized
877        );
878        assert_eq!(
879            fresh.diagnostics().baro_altitude.last_refusal,
880            Some(Refusal::NotInitialized)
881        );
882    }
883
884    #[test]
885    fn an_altitude_above_the_reference_pulls_the_estimate_up_and_moves_nothing_sideways() {
886        let mut filter = Eskf::default();
887        let _ = filter
888            .initialize_over(&window_at(100.0), Seconds::from_secs(0.25))
889            .expect("a 2 s window of stillness");
890
891        // Two meters above the reference the window fixed, on a sensor claiming 0.5 m.
892        assert!(
893            filter
894                .fuse_baro_altitude(
895                    filter.now(),
896                    Altitude::from_meters(102.0),
897                    AltitudeNoise::from_sigma(0.5)
898                )
899                .is_accepted()
900        );
901
902        let position = filter.state().position.vector();
903        assert!(
904            position[2] < -1.0,
905            "up is negative down: {} should be near -2 m",
906            position[2]
907        );
908        assert_eq!(
909            (position[0], position[1]),
910            (0.0, 0.0),
911            "(30) observes p_D alone, and a still start correlates it with nothing"
912        );
913    }
914
915    #[test]
916    fn an_altitude_the_gate_turns_down_leaves_the_estimate_where_it_was() {
917        let mut filter = Eskf::default();
918        let _ = filter
919            .initialize_over(&window_at(100.0), Seconds::from_secs(0.25))
920            .expect("a 2 s window of stillness");
921        let before = filter.state().position;
922
923        // A hundred meters of climb the instant the window closed, on a 0.5 m sensor: the
924        // shape of a pressure transient, and what `Gates::baro_altitude` is there for.
925        let outcome = filter.fuse_baro_altitude(
926            filter.now(),
927            Altitude::from_meters(200.0),
928            AltitudeNoise::from_sigma(0.5),
929        );
930        assert!(matches!(outcome, Fusion::Rejected { .. }), "{outcome:?}");
931        assert_eq!(filter.state().position, before);
932        assert_eq!(filter.diagnostics().baro_altitude.rejected, 1);
933    }
934
935    /// Equation (23′): a fix 250 ms old on a vehicle at 20 m/s is where the vehicle was, 5 m
936    /// back along its track. Taken at its own time it agrees with the estimate; taken as
937    /// current it is 5 m of error the gate turns down.
938    #[test]
939    fn an_old_fix_is_judged_against_where_the_vehicle_was() {
940        let flying = || {
941            // The hold would pull a vehicle nothing aids back toward where it started.
942            let mut filter = Eskf::new(crate::Config {
943                hold: None,
944                ..crate::Config::default()
945            })
946            .unwrap();
947            let state = State {
948                velocity: Velocity::ned(20.0, 0.0, 0.0),
949                ..State::default()
950            };
951            let _ = filter
952                .seed(state, Covariance::from_sigmas([0.5; STATES]))
953                .expect("a sane seed");
954            for _ in 0..50 {
955                assert!(filter.step(still().imu, DT).is_propagated());
956            }
957            filter
958        };
959        let age = Seconds::from_secs(0.25);
960        let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
961
962        let mut filter = flying();
963        let there = Position::ned(filter.state().position.x() - 5.0, 0.0, 0.0);
964        let taken = filter.now().before(age);
965        let aged = filter
966            .fuse_gnss_position(taken, there, noise, Position::zero())
967            .horizontal;
968        assert!(
969            aged.test_ratio().is_some_and(|ratio| ratio < 1.0e-3),
970            "{aged:?}"
971        );
972
973        let mut filter = flying();
974        let now = filter.now();
975        let current = filter
976            .fuse_gnss_position(now, there, noise, Position::zero())
977            .horizontal;
978        assert!(matches!(current, Fusion::Rejected { .. }), "{current:?}");
979    }
980
981    /// A start in motion says nothing about where the vehicle was before it, so a fix taken
982    /// before a seed is refused; a still window says the vehicle was where it started, so one
983    /// taken before that start is placed there and fused.
984    #[test]
985    fn a_fix_from_before_the_start_is_placed_only_after_a_start_at_rest() {
986        let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
987        let before = Seconds::from_secs(0.05);
988
989        let mut seeded = Eskf::default();
990        let (state, covariance) = seed();
991        let start = Timestamp::from_micros(1_000_000);
992        let _ = seeded
993            .initialize_from(state, covariance, start)
994            .expect("a sane seed");
995        let taken = start.before(before);
996        let refused =
997            seeded.fuse_gnss_position(taken, Position::ned(0.0, 0.0, 0.0), noise, Position::zero());
998        assert_eq!(
999            refused,
1000            GnssFusion::both(Fusion::OutOfHorizon { age: before })
1001        );
1002
1003        let mut still = initialized();
1004        let taken = still.now().before(before);
1005        let fused =
1006            still.fuse_gnss_position(taken, Position::ned(0.0, 0.0, 0.0), noise, Position::zero());
1007        assert!(fused.is_accepted(), "{fused:?}");
1008    }
1009
1010    /// A correction reaches the past it was propagated from, so a second fix of the same moment
1011    /// is judged against the corrected past and agrees with it better than the first did. Were
1012    /// the history left behind, the second would innovate as the first did against a covariance
1013    /// the first had already narrowed: 1.33 against 0.12, where this reads 0.08.
1014    #[test]
1015    fn a_correction_reaches_the_history_the_next_old_fix_is_judged_against() {
1016        let mut filter = Eskf::default();
1017        let _ = filter
1018            .seed(State::default(), Covariance::from_sigmas([2.0; STATES]))
1019            .expect("a sane seed");
1020        for _ in 0..50 {
1021            assert!(filter.step(still().imu, DT).is_propagated());
1022        }
1023        let taken = filter.now().before(Seconds::from_secs(0.2));
1024        let off = Position::ned(3.0, 0.0, 0.0);
1025        let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
1026        let first = filter
1027            .fuse_gnss_position(taken, off, noise, Position::zero())
1028            .horizontal;
1029        let second = filter
1030            .fuse_gnss_position(taken, off, noise, Position::zero())
1031            .horizontal;
1032        let (Some(first), Some(second)) = (first.test_ratio(), second.test_ratio()) else {
1033            panic!("both fixes reach the gate: {first:?}, {second:?}");
1034        };
1035        assert!(second < first, "{second} against {first}");
1036    }
1037
1038    /// A fix timed ahead of the last IMU sample is where the vehicle will be: carried forward
1039    /// on the velocity rather than taken as current.
1040    #[test]
1041    fn a_fix_ahead_of_the_state_is_judged_against_where_the_vehicle_will_be() {
1042        let mut filter = Eskf::default();
1043        let state = State {
1044            velocity: Velocity::ned(20.0, 0.0, 0.0),
1045            ..State::default()
1046        };
1047        let _ = filter
1048            .seed(state, Covariance::from_sigmas([0.5; STATES]))
1049            .expect("a sane seed");
1050        for _ in 0..50 {
1051            assert!(filter.step(still().imu, DT).is_propagated());
1052        }
1053        let lead = Seconds::from_secs(0.05);
1054        let there = Position::ned(filter.state().position.x() + 1.0, 0.0, 0.0);
1055        let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
1056        let taken = filter.now().after(lead);
1057        let outcome = filter
1058            .fuse_gnss_position(taken, there, noise, Position::zero())
1059            .horizontal;
1060        assert!(
1061            outcome.test_ratio().is_some_and(|ratio| ratio < 1.0e-3),
1062            "{outcome:?}"
1063        );
1064    }
1065
1066    /// A heading timed ahead of the last IMU sample is judged against the attitude the turn
1067    /// will have reached, carried forward on the last sample's rate. Taken against the present
1068    /// instead, 90 ms into a 90°/s turn is 8° of error, which the gate turns down.
1069    #[test]
1070    fn a_heading_ahead_of_the_state_is_judged_against_the_turn() {
1071        let rate = core::f32::consts::FRAC_PI_2;
1072        let mut sigmas = [0.5; STATES];
1073        // Attitude and gyroscope bias tight, so that the covariance cannot absorb the turn.
1074        for axis in [
1075            ErrorState::AttitudeX,
1076            ErrorState::AttitudeY,
1077            ErrorState::AttitudeZ,
1078        ] {
1079            sigmas[axis.index()] = 0.005;
1080        }
1081        for axis in [
1082            ErrorState::GyroBiasX,
1083            ErrorState::GyroBiasY,
1084            ErrorState::GyroBiasZ,
1085        ] {
1086            sigmas[axis.index()] = 0.001;
1087        }
1088        let mut filter = Eskf::default();
1089        let _ = filter
1090            .seed(State::default(), Covariance::from_sigmas(sigmas))
1091            .expect("a sane seed");
1092        let turning = still().imu.with_gyro(AngularRate::body(0.0, 0.0, rate));
1093        for _ in 0..20 {
1094            assert!(filter.step(turning, DT).is_propagated());
1095        }
1096        let lead = Seconds::from_secs(0.09);
1097        let (_, _, yaw) = filter.state().attitude.euler_angles();
1098        let field_at = |yaw: f32| MagField::body(0.22 * yaw.cos(), -0.22 * yaw.sin(), 0.44);
1099        let noise = HeadingNoise::from_sigma(0.02);
1100
1101        let mut judged = filter.clone();
1102        let ahead = field_at(yaw + rate * lead.as_secs());
1103        let outcome = judged.fuse_mag_heading(filter.now().after(lead), ahead, noise);
1104        assert!(
1105            outcome.test_ratio().is_some_and(|ratio| ratio < 0.05),
1106            "{outcome:?}"
1107        );
1108        let current = filter.fuse_mag_heading(filter.now(), ahead, noise);
1109        assert!(matches!(current, Fusion::Rejected { .. }), "{current:?}");
1110    }
1111
1112    #[test]
1113    fn a_run_of_correlated_fixes_leaves_more_uncertainty_than_a_run_of_white_ones() {
1114        // Twenty-five fixes at 5 Hz. As white, (24) averages them down; at (24′)'s default,
1115        // each is worth 1/42 of one horizontally and 1/140 in height, since the receiver's
1116        // error has barely moved between them. Worth less is not worth nothing: each half reads
1117        // its own clock, and one read off the other would see the horizontal half restart it
1118        // an instant before and take every height for the same error twice.
1119        let mut white = aided();
1120        white.config.correlation = Correlation::WHITE;
1121        let (mut correlated, mut unaided) = (aided(), aided());
1122        for step in 0..500 {
1123            for filter in [&mut white, &mut correlated, &mut unaided] {
1124                assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
1125            }
1126            if step % 20 == 0 {
1127                for filter in [&mut white, &mut correlated] {
1128                    let _ = filter.fuse_gnss_position(
1129                        filter.now(),
1130                        Position::ned(0.0, 0.0, 0.0),
1131                        PositionNoise::from_sigma(1.0, 1.0, 1.0),
1132                        Position::zero(),
1133                    );
1134                }
1135            }
1136        }
1137        for axis in [ErrorState::PositionNorth, ErrorState::PositionDown] {
1138            let variance = |filter: &Eskf| filter.covariance().variance(axis);
1139            let (w, c, u) = (variance(&white), variance(&correlated), variance(&unaided));
1140            assert!(c > 3.0 * w, "{axis:?}: {c} against {w} white");
1141            assert!(c < 0.95 * u, "{axis:?}: {c} against {u} with no fix at all");
1142        }
1143    }
1144
1145    #[test]
1146    fn every_source_is_fused_at_its_own_correlation() {
1147        // Velocity at 5 Hz and heading at 20 Hz for 5 s: as white, (24) averages each down;
1148        // at their defaults each solution is worth about a fifth, each heading about a
1149        // thirty-fifth. Each source reads its own `τ` and its own clock.
1150        let mut white = initialized();
1151        white.config.correlation = Correlation::WHITE;
1152        let mut correlated = initialized();
1153        for step in 0..500 {
1154            for filter in [&mut white, &mut correlated] {
1155                assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
1156                if step % 20 == 0 {
1157                    let _ = filter.fuse_gnss_velocity(
1158                        filter.now(),
1159                        Velocity::ned(0.0, 0.0, 0.0),
1160                        VelocityNoise::from_speed_accuracy(0.3),
1161                        Position::zero(),
1162                    );
1163                }
1164                if step % 5 == 0 {
1165                    let _ = filter.fuse_mag_heading(
1166                        filter.now(),
1167                        measured(attitude_of(0.0, 0.0, 0.0), 0.0),
1168                        HeadingNoise::from_sigma(0.05),
1169                    );
1170                }
1171            }
1172        }
1173        for axis in [ErrorState::VelocityNorth, ErrorState::AttitudeZ] {
1174            let (w, c) = (
1175                white.covariance().variance(axis),
1176                correlated.covariance().variance(axis),
1177            );
1178            assert!(c > 2.0 * w, "{axis:?}: {c} against {w} white");
1179        }
1180    }
1181
1182    #[test]
1183    fn a_refused_fix_does_not_restart_the_interval_of_24_prime() {
1184        // A receiver interleaving unusable fixes with good ones carries no error in the bad
1185        // ones, so the good ones are as far apart as they were.
1186        let good = |filter: &mut Eskf| {
1187            let _ = filter.fuse_gnss_position(
1188                filter.now(),
1189                Position::ned(0.0, 0.0, 0.0),
1190                PositionNoise::from_sigma(1.0, 1.0, 1.0),
1191                Position::zero(),
1192            );
1193        };
1194        let (mut interleaved, mut clean) = (aided(), aided());
1195        for filter in [&mut interleaved, &mut clean] {
1196            good(filter);
1197            for _ in 0..10 {
1198                assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
1199            }
1200        }
1201        let refused = interleaved.fuse_gnss_position(
1202            interleaved.now(),
1203            Position::ned(f32::NAN, f32::NAN, f32::NAN),
1204            PositionNoise::from_sigma(1.0, 1.0, 1.0),
1205            Position::zero(),
1206        );
1207        assert_eq!(refused, GnssFusion::both(Fusion::NotFinite));
1208        for filter in [&mut interleaved, &mut clean] {
1209            for _ in 0..10 {
1210                assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
1211            }
1212            good(filter);
1213        }
1214        let north = ErrorState::PositionNorth;
1215        assert_eq!(
1216            interleaved.covariance().variance(north),
1217            clean.covariance().variance(north)
1218        );
1219    }
1220
1221    #[test]
1222    fn a_rejected_fix_or_a_refused_half_leaves_the_interval_alone() {
1223        // (24′) discounts a fix for the error it shares with those already fused, and neither a
1224        // rejected fix nor a refused half fused anything: the next is timed from the last fix
1225        // accepted, 110 ms before it, not from the one turned away 10 ms before. The half that
1226        // was accepted does restart its own clock, which the two halves keep apart.
1227        let fix = |filter: &mut Eskf, position: Position<Ned>, noise: PositionNoise<Ned>| {
1228            let _ = filter.fuse_gnss_position(filter.now(), position, noise, Position::zero());
1229        };
1230        let one = PositionNoise::from_sigma(1.0, 1.0, 1.0);
1231        let steps = |filter: &mut Eskf, n: usize| {
1232            for _ in 0..n {
1233                assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
1234            }
1235        };
1236        let (mut rejected, mut half, mut clean) = (aided(), aided(), aided());
1237        for filter in [&mut rejected, &mut half, &mut clean] {
1238            fix(filter, Position::ned(0.0, 0.0, 0.0), one);
1239            steps(filter, 10);
1240        }
1241        let outcome = rejected.fuse_gnss_position(
1242            rejected.now(),
1243            Position::ned(1000.0, 0.0, 1000.0),
1244            one,
1245            Position::zero(),
1246        );
1247        assert!(
1248            matches!(outcome.horizontal, Fusion::Rejected { .. }),
1249            "{outcome:?}"
1250        );
1251        let outcome = half.fuse_gnss_position(
1252            half.now(),
1253            Position::ned(0.0, 0.0, 0.0),
1254            PositionNoise::horizontal_vertical(1.0, f32::NAN),
1255            Position::zero(),
1256        );
1257        assert_eq!(outcome.height, Fusion::NotFinite);
1258        for filter in [&mut rejected, &mut half, &mut clean] {
1259            steps(filter, 1);
1260            fix(filter, Position::ned(0.0, 0.0, 0.0), one);
1261        }
1262        let (north, down) = (ErrorState::PositionNorth, ErrorState::PositionDown);
1263        let variance = |filter: &Eskf, axis| filter.covariance().variance(axis);
1264        assert_eq!(variance(&rejected, north), variance(&clean, north));
1265        assert_eq!(variance(&half, down), variance(&clean, down));
1266    }
1267
1268    #[test]
1269    fn the_interval_of_24_prime_restarts_with_the_filter() {
1270        // The filter's clock restarts at initialization, so the fix before it is not the
1271        // previous one. Were it kept, the first fix after 0.2 s of the new clock would be
1272        // fused at 1/42 of its weight.
1273        let fix = |filter: &mut Eskf| {
1274            let _ = filter.fuse_gnss_position(
1275                filter.now(),
1276                Position::ned(0.0, 0.0, 0.0),
1277                PositionNoise::from_sigma(1.0, 1.0, 1.0),
1278                Position::zero(),
1279            );
1280        };
1281        let mut restarted = aided();
1282        fix(&mut restarted);
1283        let _ = restarted
1284            .initialize_over(&window_at(100.0), Seconds::from_secs(0.25))
1285            .expect("a 2 s window of stillness");
1286        let mut fresh = aided();
1287        for filter in [&mut restarted, &mut fresh] {
1288            for _ in 0..20 {
1289                assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
1290            }
1291            fix(filter);
1292        }
1293        let north = ErrorState::PositionNorth;
1294        assert_eq!(
1295            restarted.covariance().variance(north),
1296            fresh.covariance().variance(north)
1297        );
1298    }
1299
1300    #[test]
1301    fn a_fix_that_agrees_with_the_estimate_narrows_its_uncertainty() {
1302        let mut filter = initialized();
1303        let before = filter.covariance().variance(ErrorState::PositionNorth);
1304        let outcome = filter.fuse_gnss_position(
1305            filter.now(),
1306            Position::ned(0.5, -0.5, 0.2),
1307            PositionNoise::horizontal_vertical(1.0, 1.0),
1308            Position::zero(),
1309        );
1310
1311        let Fusion::Accepted { test_ratio } = outcome.horizontal else {
1312            panic!("expected an acceptance, got {outcome:?}");
1313        };
1314        assert!(test_ratio > 0.0 && test_ratio <= 1.0);
1315        assert!(filter.covariance().variance(ErrorState::PositionNorth) < before);
1316        assert!(filter.state().position.x() > 0.0, "moved toward the fix");
1317        let health = filter.diagnostics().gnss_position;
1318        assert_eq!(health.test_ratio, Some(test_ratio));
1319        assert!(health.innovation.is_some());
1320    }
1321
1322    #[test]
1323    fn a_rejected_fix_changes_nothing_but_the_sources_record_of_it() {
1324        let mut filter = initialized();
1325        let noise = PositionNoise::horizontal_vertical(1.0, 1.0);
1326        assert!(
1327            filter
1328                .fuse_gnss_position(
1329                    filter.now(),
1330                    Position::ned(0.1, 0.0, 0.0),
1331                    noise,
1332                    Position::zero()
1333                )
1334                .is_accepted()
1335        );
1336        assert!(filter.step(still().imu, DT).is_propagated());
1337        let (state, covariance) = (filter.state(), *filter.covariance());
1338        let timer = filter.diagnostics().gnss_position.time_since_accepted;
1339
1340        // A kilometer out in both halves, so neither changes anything.
1341        let both = filter.fuse_gnss_position(
1342            filter.now(),
1343            Position::ned(1000.0, 0.0, 1000.0),
1344            noise,
1345            Position::zero(),
1346        );
1347        assert!(matches!(both.height, Fusion::Rejected { .. }), "{both:?}");
1348        let outcome = both.horizontal;
1349        assert!(matches!(outcome, Fusion::Rejected { test_ratio } if test_ratio > 1.0));
1350        assert_eq!(filter.state(), state);
1351        assert_eq!(filter.covariance(), &covariance);
1352        let health = filter.diagnostics().gnss_position;
1353        assert_eq!(health.time_since_accepted, timer);
1354        assert_eq!((health.rejected, health.consecutive_rejections), (1, 1));
1355        assert_eq!(health.test_ratio, outcome.test_ratio());
1356    }
1357
1358    #[test]
1359    fn a_covariance_that_is_no_longer_one_refuses_the_update() {
1360        let mut filter = initialized();
1361        let mut p = *filter.covariance().as_matrix();
1362        p[(0, 0)] = -10.0;
1363        filter.covariance = Covariance::from_matrix(p);
1364        let state = filter.state();
1365
1366        let outcome = filter.fuse_gnss_position(
1367            filter.now(),
1368            Position::ned(0.1, 0.0, 0.0),
1369            PositionNoise::horizontal_vertical(1.0, 1.0),
1370            Position::zero(),
1371        );
1372        assert_eq!(outcome.horizontal, Fusion::StateInvalid);
1373        let health = filter.diagnostics().gnss_position;
1374        assert_eq!(health.last_refusal, Some(Refusal::StateInvalid));
1375        assert_eq!((health.accepted, health.rejected), (0, 0));
1376        // The broken variance is north's, which a height update never reads, so the height
1377        // half is judged against a block that is still a covariance.
1378        assert!(outcome.height.is_accepted(), "{outcome:?}");
1379        assert_eq!(
1380            filter.state().position.vector()[0],
1381            state.position.vector()[0]
1382        );
1383    }
1384
1385    #[test]
1386    fn a_height_the_estimate_disagrees_with_costs_no_horizontal_aiding() {
1387        // The failure the split exists for: a receiver 50 m off in height on a 1 m σ,
1388        // with a horizontal fix that agrees.
1389        let mut filter = initialized();
1390        let outcome = filter.fuse_gnss_position(
1391            filter.now(),
1392            Position::ned(0.2, -0.1, -50.0),
1393            PositionNoise::horizontal_vertical(1.0, 1.0),
1394            Position::zero(),
1395        );
1396        assert!(
1397            matches!(outcome.horizontal, Fusion::Accepted { .. }),
1398            "{outcome:?}"
1399        );
1400        assert!(
1401            matches!(outcome.height, Fusion::Rejected { .. }),
1402            "{outcome:?}"
1403        );
1404        let d = filter.diagnostics();
1405        assert_eq!((d.gnss_position.accepted, d.gnss_position.rejected), (1, 0));
1406        assert_eq!((d.gnss_height.accepted, d.gnss_height.rejected), (0, 1));
1407        assert!(
1408            filter.state().position.vector()[2].abs() < 1e-3,
1409            "height untouched"
1410        );
1411    }
1412
1413    #[test]
1414    fn a_2d_fix_with_no_usable_height_still_fuses_its_horizontal_position() {
1415        let mut filter = initialized();
1416        let outcome = filter.fuse_gnss_position(
1417            filter.now(),
1418            Position::ned(0.2, -0.1, 0.0),
1419            PositionNoise::<Ned>::from_variance(1.0, 1.0, 0.0),
1420            Position::zero(),
1421        );
1422        assert!(
1423            matches!(outcome.horizontal, Fusion::Accepted { .. }),
1424            "{outcome:?}"
1425        );
1426        assert_eq!(outcome.height, Fusion::InvalidNoise);
1427        assert_eq!(
1428            filter.diagnostics().gnss_height.last_refusal,
1429            Some(Refusal::InvalidNoise)
1430        );
1431    }
1432
1433    /// Nose east and level, so body forward is navigation east: an antenna 1 m forward and
1434    /// 0.5 m up is 1 m east of the IMU and 0.5 m above it, worked by hand.
1435    fn nose_east() -> Eskf {
1436        seeded(
1437            attitude_of(0.0, 0.0, core::f32::consts::FRAC_PI_2),
1438            AngularRate::zero(),
1439        )
1440    }
1441
1442    #[test]
1443    fn a_fix_of_the_antenna_where_the_estimate_puts_it_moves_nothing() {
1444        let mut filter = nose_east();
1445        let before = filter.state().position;
1446        let at_antenna = Position::ned(0.0, 1.0, -0.5);
1447        let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
1448        let fused = filter.fuse_gnss_position(filter.now(), at_antenna, noise, mast());
1449        assert!(
1450            fused
1451                .horizontal
1452                .test_ratio()
1453                .is_some_and(|ratio| ratio < 1e-9),
1454            "{fused:?}"
1455        );
1456        assert!(fused.height.test_ratio().is_some_and(|ratio| ratio < 1e-9));
1457        assert!(near(filter.state().position, before));
1458
1459        // The same fix read as the IMU's is a meter east of the estimate.
1460        let mut unarmed = nose_east();
1461        let fused = unarmed.fuse_gnss_position(unarmed.now(), at_antenna, noise, Position::zero());
1462        assert!(
1463            fused
1464                .horizontal
1465                .test_ratio()
1466                .is_some_and(|ratio| ratio > 0.1)
1467        );
1468    }
1469
1470    #[test]
1471    fn a_velocity_of_the_antenna_includes_its_swing_about_the_imu() {
1472        // Yawing at 0.5 rad/s, nose east: an antenna 1 m forward swings south at 0.5 m/s on
1473        // top of whatever the IMU is doing.
1474        let mut filter = nose_east();
1475        let imu = ImuSample::reading(
1476            AngularRate::body(0.0, 0.0, 0.5),
1477            Acceleration::body(0.0, 0.0, -GRAVITY),
1478        );
1479        assert!(filter.step(imu, DT).is_propagated());
1480        let v = filter.state().velocity.vector();
1481        let swung = Velocity::ned(v.x - 0.5, v.y, v.z);
1482        let noise = VelocityNoise::from_speed_accuracy(0.1);
1483        let fused = filter.fuse_gnss_velocity(filter.now(), swung, noise, mast());
1484        let ratio = fused.test_ratio().expect("fused");
1485        assert!(ratio < 1e-3, "ratio {ratio}");
1486    }
1487
1488    #[test]
1489    fn an_antenna_that_is_not_a_number_is_refused_before_it_reaches_the_state() {
1490        let broken = Position::body(f32::NAN, 0.0, 0.0);
1491        let mut filter = coarse();
1492        let before = filter.state();
1493        let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
1494        let fused = filter.fuse_gnss_position(filter.now(), Position::zero(), noise, broken);
1495        assert_eq!(fused, GnssFusion::both(Fusion::NotFinite));
1496        let noise = VelocityNoise::from_speed_accuracy(0.3);
1497        let fused = filter.fuse_gnss_velocity(filter.now(), Velocity::zero(), noise, broken);
1498        assert_eq!(fused, Fusion::NotFinite);
1499        let noise = PositionNoise::horizontal_vertical(0.5, 0.5);
1500        let fused = filter.fuse_gnss_geodetic(filter.now(), zurich(), noise, broken);
1501        assert_eq!(fused, GnssFusion::both(Fusion::NotFinite));
1502        assert_eq!(filter.state().position, before.position);
1503        assert_eq!(filter.state().velocity, before.velocity);
1504        assert_eq!(filter.origin(), None);
1505    }
1506
1507    #[test]
1508    fn a_fix_that_is_not_a_number_is_refused_even_with_an_origin_held() {
1509        // The case that matters: an origin held and position unestablished, where a NaN
1510        // would otherwise be adopted outright.
1511        let mut filter = initialized();
1512        assert!(filter.set_origin(zurich()));
1513        let mut window = [still(); 8];
1514        window[3].imu = window[3].imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
1515        let _ = filter
1516            .initialize_over(&window, Seconds::from_secs(0.25))
1517            .expect("moving, so position is unestablished");
1518        let noise = PositionNoise::horizontal_vertical(1.5, 1.5);
1519
1520        let nonsense = Geodetic::from_degrees(f64::NAN, 8.5, 488.0);
1521        assert_eq!(
1522            filter.fuse_gnss_geodetic(filter.now(), nonsense, noise, Position::zero()),
1523            GnssFusion::both(Fusion::NotFinite)
1524        );
1525        assert_eq!(
1526            filter.fuse_gnss_geodetic(
1527                filter.now(),
1528                zurich(),
1529                PositionNoise::from_sigma(f32::NAN, 1.5, 1.5),
1530                Position::zero()
1531            ),
1532            GnssFusion::both(Fusion::NotFinite)
1533        );
1534        assert!(filter.state().position.is_finite());
1535        assert!(
1536            filter
1537                .fuse_gnss_geodetic(filter.now(), zurich(), noise, Position::zero())
1538                .is_reset()
1539        );
1540    }
1541
1542    #[test]
1543    fn every_source_refuses_a_measurement_that_is_not_a_number() {
1544        let mut filter = initialized();
1545        assert!(filter.set_baro_reference(
1546            Altitude::from_meters(52.0),
1547            AltitudeNoise::from_sigma(0.001)
1548        ));
1549        let nan = f32::NAN;
1550        assert_eq!(
1551            filter
1552                .fuse_gnss_position(
1553                    filter.now(),
1554                    Position::ned(nan, 0.0, 0.0),
1555                    PositionNoise::horizontal_vertical(1.5, 1.5),
1556                    Position::zero()
1557                )
1558                .horizontal,
1559            Fusion::NotFinite
1560        );
1561        assert_eq!(
1562            filter.fuse_gnss_velocity(
1563                filter.now(),
1564                Velocity::ned(0.0, 0.0, 0.0),
1565                VelocityNoise::from_speed_accuracy(nan),
1566                Position::zero()
1567            ),
1568            Fusion::NotFinite
1569        );
1570        assert_eq!(
1571            filter.fuse_baro_altitude(
1572                filter.now(),
1573                Altitude::from_meters(nan),
1574                AltitudeNoise::from_sigma(2.0)
1575            ),
1576            Fusion::NotFinite
1577        );
1578        assert_eq!(
1579            filter.fuse_mag_heading(
1580                filter.now(),
1581                MagField::body(0.2, 0.0, 0.4),
1582                HeadingNoise::from_variance(f32::INFINITY)
1583            ),
1584            Fusion::NotFinite
1585        );
1586        assert_eq!(
1587            filter.fuse_stationary(filter.now(), VelocityNoise::from_speed_accuracy(nan)),
1588            Fusion::NotFinite
1589        );
1590        assert_eq!(filter.diagnostics().gnss_position.accepted, 0);
1591    }
1592
1593    #[test]
1594    fn every_source_measures_its_period_from_what_it_offers() {
1595        // Refused, every one of them: a period is how often the sensor speaks, not how often
1596        // it is believed, so a source turning out NaN is still timed against its own rate.
1597        let mut filter = initialized();
1598        let nan = f32::NAN;
1599        hold(&mut filter, 4.0, 20, |filter| {
1600            let now = filter.now();
1601            let noise = PositionNoise::horizontal_vertical(1.5, 1.5);
1602            let _ = filter.fuse_gnss_position(
1603                now,
1604                Position::ned(nan, nan, nan),
1605                noise,
1606                Position::zero(),
1607            );
1608            let _ = filter.fuse_gnss_velocity(
1609                now,
1610                Velocity::ned(nan, 0.0, 0.0),
1611                VelocityNoise::from_speed_accuracy(0.3),
1612                Position::zero(),
1613            );
1614            let _ = filter.fuse_baro_altitude(
1615                now,
1616                Altitude::from_meters(nan),
1617                AltitudeNoise::from_sigma(2.0),
1618            );
1619            let heading = HeadingNoise::from_variance(nan);
1620            let _ = filter.fuse_mag_heading(now, MagField::body(0.2, 0.0, 0.4), heading);
1621            let _ = filter.fuse_gnss_heading(now, Radians::from_radians(0.0), heading);
1622            let _ = filter.fuse_course(now, heading);
1623            let _ = filter.fuse_stationary(now, VelocityNoise::from_speed_accuracy(nan));
1624        });
1625        // The hold offers nothing; the filter makes it, and `hold.rs` times it. Nor does the
1626        // yaw estimator, which weighs the velocities the filter screened: `yaw.rs` times it.
1627        let offered = filter.diagnostics().sources();
1628        let offered = offered
1629            .iter()
1630            .filter(|&&(name, _)| !matches!(name, "position_hold" | "yaw_estimator"));
1631        for &(name, source) in offered {
1632            assert_eq!(source.accepted, 0, "{name}");
1633            let period = source.period().map(Seconds::as_secs);
1634            assert!(
1635                period.is_some_and(|period| (period - 0.2).abs() < 1e-4),
1636                "{name}: {period:?}"
1637            );
1638        }
1639    }
1640
1641    #[test]
1642    fn every_source_refuses_a_variance_no_sensor_could_have() {
1643        let mut filter = initialized();
1644        assert!(filter.set_baro_reference(
1645            Altitude::from_meters(52.0),
1646            AltitudeNoise::from_sigma(0.001)
1647        ));
1648        assert_eq!(
1649            filter.fuse_gnss_position(
1650                filter.now(),
1651                Position::ned(1.0, 2.0, 3.0),
1652                PositionNoise::<Ned>::from_variance(0.0, 1.0, -1.0),
1653                Position::zero(),
1654            ),
1655            GnssFusion::both(Fusion::InvalidNoise),
1656            "zero variance claims a perfect measurement and makes S singular"
1657        );
1658        assert_eq!(
1659            filter.fuse_gnss_velocity(
1660                filter.now(),
1661                Velocity::ned(0.0, 0.0, 0.0),
1662                VelocityNoise::<Ned>::from_variance(1.0, -4.0, 1.0),
1663                Position::zero(),
1664            ),
1665            Fusion::InvalidNoise
1666        );
1667        assert_eq!(
1668            filter.fuse_baro_altitude(
1669                filter.now(),
1670                Altitude::from_meters(60.0),
1671                AltitudeNoise::from_variance(0.0)
1672            ),
1673            Fusion::InvalidNoise
1674        );
1675        assert_eq!(
1676            filter.fuse_mag_heading(
1677                filter.now(),
1678                MagField::body(0.2, 0.0, 0.4),
1679                HeadingNoise::from_variance(-1.0)
1680            ),
1681            Fusion::InvalidNoise
1682        );
1683        assert_eq!(
1684            filter.fuse_stationary(
1685                filter.now(),
1686                VelocityNoise::<Ned>::from_variance(1.0, 1.0, 0.0)
1687            ),
1688            Fusion::InvalidNoise
1689        );
1690        assert_eq!(
1691            filter.fuse_gnss_geodetic(
1692                filter.now(),
1693                zurich(),
1694                PositionNoise::<Ned>::from_variance(1.0, 1.0, 0.0),
1695                Position::zero()
1696            ),
1697            GnssFusion::both(Fusion::InvalidNoise)
1698        );
1699        assert_eq!(filter.origin(), None, "and no origin was placed on the way");
1700
1701        for (name, source) in filter.diagnostics().sources() {
1702            assert_eq!(source.accepted, 0, "{name} counted a refused measurement");
1703            assert_eq!(source.time_since_accepted, None, "{name} started aiding");
1704        }
1705    }
1706}