Skip to main content

fusion_nav/eskf/
heading.rs

1//! The heading sources, (36): magnetic heading, (34)–(35), dual-antenna GNSS heading, (35′), and
2//! course over ground, (35″), with the adoption that turns attitude by (41).
3//!
4//! Entry points: [`Eskf::fuse_mag_heading`], [`Eskf::fuse_gnss_heading`] and
5//! [`Eskf::fuse_course`].
6
7use crate::config::Gate;
8use crate::frames::Body;
9use crate::health::{Diagnostics, Fusion, SourceHealth};
10use crate::math::{correlation_inflation, exp_quat};
11use crate::observation::{heading, mag};
12use crate::state::{Covariance, State};
13use crate::units::{Attitude, HeadingNoise, MagField, Radians, Seconds, Timestamp};
14use crate::update::{self, Observation, update};
15use nalgebra::Vector3;
16
17use super::Eskf;
18use super::fuse::refuse;
19
20impl Eskf {
21    /// Fuse magnetic heading from a calibrated three-axis magnetometer.
22    /// Equations (34)–(36).
23    ///
24    /// Heading only: the field is reduced to one scalar, so a magnetic disturbance can
25    /// corrupt yaw but cannot reach roll or pitch. `noise` is on the resulting
26    /// heading, not on the field components.
27    ///
28    /// This is also where heading becomes an estimate. A static window with no
29    /// magnetometer in it, and any coarse start, leave yaw unobserved — see
30    /// [`initialize`](Self::initialize) — and [`validity`](Self::validity) reports
31    /// `heading` false until the first heading is accepted, here or from
32    /// [`fuse_gnss_heading`](Self::fuse_gnss_heading) or [`fuse_course`](Self::fuse_course),
33    /// however tight [`sigma_yaw`](crate::Initialization::sigma_yaw) was.
34    ///
35    /// That first heading is the point `GOALS.md` names for a yaw **reset** rather than an
36    /// ordinary update: the error-state attitude of equation (2) is a small-angle
37    /// quantity, so a yaw error of a radian is wrong in a way no variance expresses, and
38    /// widening the prior does not fix it. It is adopted rather than gated, which is
39    /// [`Fusion::Reset`]'s documented shape — a quantity never established, with no
40    /// estimate to step away from. Gating it instead would judge the measurement against
41    /// a prior nothing ever measured: a static window with no magnetometer leaves
42    /// [`sigma_yaw`](crate::Initialization::sigma_yaw), 0.35 rad, on a yaw that is a
43    /// guess, and a correct heading more than about 64° from that guess would be
44    /// rejected — locking the filter out of what may be the one source that can ever
45    /// establish yaw.
46    ///
47    /// Ordinary updates thereafter, gated at [`Gates::mag_heading`](crate::Gates) with
48    /// one degree of freedom, until a run of rejections outlasts
49    /// [`Recovery::mag_heading`](crate::Recovery::mag_heading) while no GNSS position,
50    /// velocity or heading is arriving,
51    /// when the next heading is adopted the same way. The field is reduced to a scalar heading
52    /// before the gate sees it, so a disturbance is tested as the yaw error it is.
53    ///
54    /// The field must be calibrated: this crate corrects no hard- or soft-iron error and
55    /// carries no magnetic-field states to absorb one, so a bias in `field` is a bias in
56    /// heading. A field of exactly zero is finite, and reports the vehicle as `D_m` off
57    /// rather than producing NaN — gateable once yaw is established, and adopted where it
58    /// is not, which is the strongest reason to check a magnetometer before the first
59    /// call rather than after it.
60    ///
61    /// Fused at the variance (24′) leaves for a heading whose error persists from the last,
62    /// [`Config::correlation`](crate::Config::correlation)'s `mag_heading`;
63    /// the gate reads `noise` itself.
64    ///
65    /// `time` is when the measurement was taken, on the clock the IMU's samples are timed on;
66    /// [`Fusion::OutOfHorizon`] says which times cannot be placed.
67    pub fn fuse_mag_heading(
68        &mut self,
69        time: Timestamp,
70        field: MagField<Body>,
71        noise: HeadingNoise,
72    ) -> Fusion {
73        if let Err(refusal) = self.admit(time) {
74            return refuse(&mut self.diagnostics.mag_heading, refusal);
75        }
76        self.diagnostics.mag_heading.note_arrival(time);
77        if !field.is_finite() || !noise.is_finite() {
78            return refuse(&mut self.diagnostics.mag_heading, Fusion::NotFinite);
79        }
80        if !noise.is_positive() {
81            return refuse(&mut self.diagnostics.mag_heading, Fusion::InvalidNoise);
82        }
83        let declination = self.declination;
84        // With GNSS arriving, a magnetometer that disagrees this long is more likely disturbed
85        // than right: horizontal aiding is already correcting heading through (20), and a GNSS
86        // heading measures it outright.
87        let d = &self.diagnostics;
88        let recovery = self.unless_accepted(
89            self.config.recovery.mag_heading,
90            &[&d.gnss_position, &d.gnss_velocity, &d.gnss_heading],
91        );
92        // Nor after the yaw estimator replaced the heading, until a magnetic heading agrees
93        // with the new one: see `YawReplaced`.
94        let recovery = recovery.filter(|_| !self.yaw_replaced.overrules_magnetometer());
95        let source = HeadingSource {
96            health: |diagnostics| &mut diagnostics.mag_heading,
97            gate: self.config.gates.mag_heading,
98            correlation: self.config.correlation.mag_heading,
99            recovery,
100            magnetic: true,
101        };
102        let fusion = self.fuse_heading(time, source, 0.0, |past, covariance| {
103            mag::heading_observation(past, covariance, field, declination, noise)
104        });
105        if matches!(fusion, Fusion::Accepted { .. }) {
106            self.yaw_replaced.settle_magnetometer();
107        }
108        fusion
109    }
110
111    /// Fuse a true heading from a dual-antenna (moving-baseline) GNSS receiver.
112    /// Equations (35′) and (36).
113    ///
114    /// The heading of the line from the primary antenna to the secondary, which is taken to
115    /// lie along body x: a receiver whose antennas are mounted otherwise has the mounting
116    /// angle subtracted by the caller first, as PX4 and ArduPilot each do from a parameter.
117    /// True rather than magnetic, so no [declination](Self::set_magnetic_declination) applies,
118    /// and nothing is leveled with the estimated attitude, so the (36′) a magnetic heading
119    /// carries has no counterpart; see `observation/heading.rs` for the model and why its
120    /// Jacobian is (36) rather than the exact one PX4 differentiates.
121    ///
122    /// `noise` is the receiver's `heading_accuracy`, bounded before it arrives:
123    /// [`HeadingNoise::clamped`](crate::HeadingNoise::clamped) holds PX4's and ArduPilot's
124    /// floors, and both production estimators apply one because a moving-baseline solution is
125    /// as optimistic about itself as a position fix is.
126    ///
127    /// The first heading after any start that observed no yaw is adopted rather than gated,
128    /// as a magnetic one is — [`Fusion::Reset`], with this `noise` as the adopted variance —
129    /// and both production estimators align on the first sample the same way (PX4
130    /// `aid_sources/gnss/gnss_yaw_control.cpp:100-111` at `c4e4ef98`, ArduPilot
131    /// `AP_NavEKF3_MagFusion.cpp:404-410` at `368dc0c4`). Ordinary updates thereafter, gated at
132    /// [`Gates::gnss_heading`](crate::Gates), and recovered after
133    /// [`Recovery::gnss_heading`](crate::Recovery::gnss_heading).
134    ///
135    /// Fused beside a magnetometer rather than instead of one, where PX4 stops fusing the
136    /// magnetometer while GNSS yaw is active (`aid_sources/magnetometer/mag_control.cpp:189`):
137    /// each is gated against an estimate the other has corrected, and a magnetometer that
138    /// disagrees is not recovered while this is accepted. A body x within 30° of vertical has
139    /// no heading to compare, and is refused as [`Fusion::Unobservable`].
140    ///
141    /// `time` is when the measurement was taken, on the clock the IMU's samples are timed on;
142    /// [`Fusion::OutOfHorizon`] says which times cannot be placed.
143    pub fn fuse_gnss_heading(
144        &mut self,
145        time: Timestamp,
146        heading: Radians,
147        noise: HeadingNoise,
148    ) -> Fusion {
149        if let Err(refusal) = self.admit(time) {
150            return refuse(&mut self.diagnostics.gnss_heading, refusal);
151        }
152        self.diagnostics.gnss_heading.note_arrival(time);
153        if !heading.as_radians().is_finite() || !noise.is_finite() {
154            return refuse(&mut self.diagnostics.gnss_heading, Fusion::NotFinite);
155        }
156        if !noise.is_positive() {
157            return refuse(&mut self.diagnostics.gnss_heading, Fusion::InvalidNoise);
158        }
159        if !heading::has_heading(&self.past(time).0) {
160            return refuse(&mut self.diagnostics.gnss_heading, Fusion::Unobservable);
161        }
162        let source = HeadingSource {
163            health: |diagnostics| &mut diagnostics.gnss_heading,
164            gate: self.config.gates.gnss_heading,
165            correlation: self.config.correlation.gnss_heading,
166            recovery: self.config.recovery.gnss_heading,
167            magnetic: false,
168        };
169        self.fuse_heading(time, source, 0.0, |past, _| {
170            heading::gnss_observation(past, heading, noise)
171        })
172    }
173
174    /// Constrain heading to the direction of travel: the vehicle points along its estimated
175    /// velocity, to within `sideslip`. Equations (35″) and (36).
176    ///
177    /// Yaw from course over ground, for a vehicle with no magnetometer or one it cannot trust.
178    /// It is a constraint rather than a measurement: nothing new is read, and what the call
179    /// states is that the nose and the track agree, so it is only as true as the vehicle makes
180    /// it. A fixed-wing in coordinated flight and a ground vehicle on its wheels hold it to a
181    /// few degrees; a crosswind adds its crab angle to `sideslip`. **A multirotor holds no such
182    /// relation** — it hovers, and flies in any direction facing any other — and must not call
183    /// this.
184    ///
185    /// The velocity is the estimate's, not a GNSS velocity handed in: that velocity has already
186    /// been fused through [`fuse_gnss_velocity`](Self::fuse_gnss_velocity), and taking a course
187    /// from it again would count its cross-track error twice. Read off the state, the
188    /// velocity's uncertainty reaches the update through `P`, and the update corrects velocity
189    /// and heading together. So `time` is when the constraint is claimed to hold, and a caller
190    /// fusing one per GNSS velocity passes that fix's time.
191    ///
192    /// Refused as [`Fusion::NoReference`] where no fresh GNSS velocity holds the estimated one
193    /// ([`SourceHealth::is_fresh`](crate::SourceHealth::is_fresh)), which also covers one never
194    /// established, and as [`Fusion::Unobservable`] where it names no direction: too slow
195    /// against its own uncertainty, which is ArduPilot's 15° bar
196    /// on the course read from `P` — see `observation/heading.rs` — and so a speed threshold
197    /// the velocity's accuracy sets rather than a parameter. Refused the same way with body x
198    /// within 30° of vertical. ArduPilot's plane realigns yaw from course above 5 m/s
199    /// (`realignYawGPS`, `AP_NavEKF3_MagFusion.cpp:145-218` at `368dc0c4`); PX4 has no course
200    /// source and reaches the same vehicle through its GSF yaw estimator.
201    ///
202    /// The first course after a start that observed no yaw is adopted, as a first heading is,
203    /// at `sideslip`'s variance plus the course's own; ordinary updates thereafter, continuously
204    /// rather than ArduPilot's once, gated at [`Gates::course`](crate::Gates) and recovered
205    /// after [`Recovery::course`](crate::Recovery::course) while no other heading is accepted.
206    ///
207    /// The adoption keeps none of the heading's correlation with velocity, though the adopted
208    /// heading error is the course error, `∇χᵀδv`, plus the sideslip. Dropping it overstates
209    /// the next update's `S` by about `2σ_χ²`, which is conservative; what it costs is that a
210    /// velocity fix cannot correct the part of the heading error the velocity lent it until
211    /// propagation correlates the two again.
212    ///
213    /// Not a source [`Status`](crate::Status) counts: it reads the filter's own velocity, so it
214    /// aids nothing the GNSS velocity it needs does not, and a vehicle slowing to a stop, where
215    /// every course is refused, is not degraded for it.
216    pub fn fuse_course(&mut self, time: Timestamp, sideslip: HeadingNoise) -> Fusion {
217        if let Err(refusal) = self.admit(time) {
218            return refuse(&mut self.diagnostics.course, refusal);
219        }
220        self.diagnostics.course.note_arrival(time);
221        if !sideslip.is_finite() {
222            return refuse(&mut self.diagnostics.course, Fusion::NotFinite);
223        }
224        if !sideslip.is_positive() {
225            return refuse(&mut self.diagnostics.course, Fusion::InvalidNoise);
226        }
227        // A velocity no GNSS velocity is holding is a dead-reckoned one, and a course along it
228        // would read as aiding while it drifts. It also covers a velocity never established,
229        // which only a GNSS velocity's acceptance or `reset_velocity_to` establishes.
230        if !self
231            .diagnostics
232            .gnss_velocity
233            .is_fresh(&self.config.timeouts)
234        {
235            return refuse(&mut self.diagnostics.course, Fusion::NoReference);
236        }
237        // Screened on the state the observation is built on, the one at `time`.
238        let past = self.past(time).0;
239        let spread = heading::course_variance(&past, &self.covariance)
240            .filter(|_| heading::has_heading(&past));
241        let Some(spread) = spread else {
242            return refuse(&mut self.diagnostics.course, Fusion::Unobservable);
243        };
244        // With a heading source arriving, a course that disagrees this long is sideslip the
245        // caller did not allow for, not a wrong heading.
246        let d = &self.diagnostics;
247        let recovery = self.unless_accepted(
248            self.config.recovery.course,
249            &[&d.mag_heading, &d.gnss_heading],
250        );
251        let source = HeadingSource {
252            health: |diagnostics| &mut diagnostics.course,
253            gate: self.config.gates.course,
254            correlation: self.config.correlation.course,
255            recovery,
256            magnetic: false,
257        };
258        self.fuse_heading(time, source, spread, |past, _| {
259            heading::course_observation(past, sideslip)
260        })
261    }
262
263    /// The update every heading source shares, once its measurement has been screened:
264    /// formed at `time` by (23′), fused at (24′)'s variance, adopted where heading was never
265    /// established, and otherwise gated and recovered.
266    ///
267    /// `spread` is the variance an adoption carries beyond `R`: zero for a source whose `H`
268    /// reads attitude alone, and the course's own for the constraint, whose `H` reads velocity
269    /// too and whose adopted heading is only as good as the velocity it was taken along.
270    ///
271    /// Out of line for the reason [`observe`](Self::observe) is; the scalar sources' peak stays
272    /// under `fuse_gnss_velocity`'s ([measured]).
273    ///
274    /// [measured]:
275    /// https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#measured-cost-by-function
276    #[inline(never)]
277    fn fuse_heading(
278        &mut self,
279        time: Timestamp,
280        source: HeadingSource,
281        spread: f32,
282        build: impl FnOnce(&State, &Covariance) -> Observation<1>,
283    ) -> Fusion {
284        let since_measured = (source.health)(&mut self.diagnostics).since_measured;
285        let observation = self
286            .observe(time, |past, _| build(past, &self.covariance))
287            .correlated(correlation_inflation(since_measured, source.correlation));
288        if self.unestablished.heading {
289            if !self.adopt_heading(&observation, spread) {
290                return refuse((source.health)(&mut self.diagnostics), Fusion::NotFinite);
291            }
292            (source.health)(&mut self.diagnostics).record_adopted();
293            self.note_alignment();
294            self.magnetic_north = source.magnetic;
295            return Fusion::Reset;
296        }
297        let outcome = update(
298            self.estimate.state(),
299            &self.covariance,
300            &self.offset,
301            &observation,
302            source.gate,
303        );
304        let fused = self.apply_or_recover(outcome, source.health, source.recovery, |filter| {
305            filter.adopt_heading(&observation, spread)
306        });
307        // An adoption replaces the heading with this source's; a true heading accepted means
308        // the estimate is no longer referred to north through the declination alone.
309        match fused {
310            Fusion::Reset => self.magnetic_north = source.magnetic,
311            Fusion::Accepted { .. } if !source.magnetic => self.magnetic_north = false,
312            _ => {}
313        }
314        fused
315    }
316
317    /// `recovery`, unless any of `arbiters` was accepted recently: a source that disagrees
318    /// with better aiding that is arriving is the one at fault, and adopting it would step the
319    /// estimate away from what the others say.
320    pub(super) fn unless_accepted(
321        &self,
322        recovery: Option<Seconds>,
323        arbiters: &[&SourceHealth],
324    ) -> Option<Seconds> {
325        if arbiters
326            .iter()
327            .any(|arbiter| arbiter.is_fresh(&self.config.timeouts))
328        {
329            None
330        } else {
331            recovery
332        }
333    }
334
335    /// Adopt a heading: [`reset_heading_by`](Self::reset_heading_by) with the `y` and `R`
336    /// an ordinary update would read. (36′) is what makes that worth saying: the leveling
337    /// error is priced on the path where the tilt it comes from is worst.
338    ///
339    /// `spread` is added to `R`: see [`fuse_heading`](Self::fuse_heading).
340    ///
341    /// Returns whether it adopted: a variance or an innovation that is not finite commits
342    /// nothing. Both are finite products of a finite covariance, and can still overflow: the
343    /// leveling variance of (36′) squares a tilt σ a coarse start charged a 1e20 rad/s
344    /// gyroscope reading to.
345    fn adopt_heading(&mut self, observation: &Observation<1>, spread: f32) -> bool {
346        // Navigation down in body axes, `R(q̂)ᵀe₃`, of the present state: the axis the
347        // adoption turns about. Not read off the observation's attitude row, which (23′) carries
348        // through the error dynamics: a course's gains a tilt component from its velocity block,
349        // `∇χᵀR[a_b]× τ`, about 0.06 at 18 m/s and 110 ms, and stops being the unit vector
350        // `reset_attitude_direction` needs.
351        let down = self.estimate.state().attitude.quaternion().inverse() * Vector3::z();
352        let (y, variance) = (observation.y[0], observation.r_m[0] + spread);
353        if !(y.is_finite() && variance.is_finite()) {
354            return false;
355        }
356        self.reset_heading_by(y, variance, down);
357        true
358    }
359
360    /// Turn the estimate by a yaw error and give the result the measurement's variance:
361    /// the adoption behind [`Fusion::Reset`] for heading.
362    ///
363    /// `y` is the innovation of (35), (35′) or (35″), so the corrected attitude is `Exp(y e₃) ⊗ q̂`
364    /// — composed on the **left**, because `e₃` is the navigation down axis, where the `δθ` of (2)
365    /// that `update` injects is a body-frame rotation composed on the right. Tilt is untouched: a
366    /// rotation about navigation down moves the tilt axis and not the tilt angle, so the roll and
367    /// pitch gravity established survive a heading any heading source supplies.
368    ///
369    /// `Exp` charges its caller with a finite argument, which each innovation's wrap
370    /// discharges by construction: `y` is in `(-π, π]` whatever was measured and the attitude
371    /// were.
372    ///
373    /// `variance` is the `R` the source's update would read, plus what the heading inherits
374    /// that `R` does not carry: for the course, the uncertainty of the velocity it was taken
375    /// along ([`fuse_heading`](Self::fuse_heading)'s `spread`). For a magnetic heading it is
376    /// `R` from (36′) rather than the caller's `σ_ψ²` alone. The leveling
377    /// of (34) is done with the estimated attitude on this path too — on a coarse start,
378    /// with the worst tilt the filter ever holds — so an adoption that stored the
379    /// magnetometer's own number would report a heading good to
380    /// [`Accuracy::heading`](crate::Accuracy::heading) while carrying the window's
381    /// leveling error times `tan δ`. That is the falsely-valid attitude (36′) exists to
382    /// remove, and the adoption is where it is largest.
383    ///
384    /// The correlations go with it, which is what fusing against an infinitely uncertain
385    /// prior converges to — the same limit [`reset_position_to`](Self::reset_position_to)
386    /// takes, one component wide. That component is the rotation about navigation down,
387    /// `uᵀδθ` with `u = R(q̂)ᵀe₃` in body axes, not `δθ_z`: the two agree only while the
388    /// vehicle is level, and at 90° of pitch `δθ_z` is a tilt. The rotation above leaves
389    /// `e₃` where it was, so `u` is the same before the adoption and after it.
390    ///
391    /// What survives — the tilt block, the biases, and the correlations between them — is
392    /// reparameterized first, by the (41) of `update::reparameterize`. `δθ` is referenced
393    /// to the nominal's body axes, so `R(q̂⁺)ᵀ R(q̂)` is the exact change of frame this
394    /// rotation makes, and it is not the small one (41) approximates: a first heading can
395    /// turn the estimate by half a circle. The tilt block is near-isotropic and largely
396    /// survives it, but the bias blocks are in physical body axes that do **not** turn, so
397    /// their correlations with attitude transform on one side only — left unrotated, a
398    /// roll-error/gyro-bias-x correlation is read afterwards as roll-error/gyro-bias-y and
399    /// the next velocity update pushes the correction into the wrong axis.
400    pub(super) fn reset_heading_by(&mut self, y: f32, variance: f32, down: Vector3<f32>) {
401        let before = self
402            .estimate
403            .state()
404            .attitude
405            .quaternion()
406            .to_rotation_matrix();
407        let mut corrected =
408            exp_quat(Vector3::z() * y) * self.estimate.state().attitude.quaternion();
409        corrected.renormalize();
410        self.estimate.commit(State {
411            attitude: Attitude::from_quaternion(corrected),
412            ..*self.estimate.state()
413        });
414
415        let g_theta = corrected.to_rotation_matrix().inverse() * before;
416        let g_theta = g_theta.into_inner();
417        let mut covariance = update::reparameterize(*self.covariance.as_matrix(), g_theta);
418        let mut offset = update::reparameterize_offset(&self.offset, g_theta);
419        covariance.reset_attitude_direction(down, variance);
420        offset.decorrelate_attitude_direction(down);
421        self.commit_covariance(covariance, offset);
422        self.unestablished.heading = false;
423    }
424}
425
426/// What distinguishes one heading source from another inside
427/// [`Eskf::fuse_heading`]: where its health is kept, and the gate, `τ` and recovery
428/// [`Config`](crate::Config) gives it.
429struct HeadingSource {
430    health: fn(&mut Diagnostics) -> &mut SourceHealth,
431    gate: Gate<1>,
432    correlation: Option<Seconds>,
433    recovery: Option<Seconds>,
434    /// Whether the heading is magnetic, referred to true north through the declination:
435    /// what [`Eskf::place_origin`] reads to decide whether a learned declination turns it.
436    magnetic: bool,
437}
438
439#[cfg(test)]
440mod tests {
441
442    use crate::config::Correlation;
443    use crate::eskf::Eskf;
444    use crate::eskf::fixtures::*;
445    use crate::frames::Body;
446
447    use crate::health::{Fusion, Propagation, Refusal};
448    use crate::init::Alignment;
449    use crate::init::tests::{still, turning};
450
451    use crate::observation::mag::tests::{attitude_of, measured};
452    use crate::observation::{heading, mag};
453
454    use crate::state::{AttitudeVariance, Covariance, ErrorState, STATES, State};
455    use crate::units::{
456        Attitude, HeadingNoise, MagField, Position, Radians, Seconds, Velocity, VelocityNoise,
457    };
458
459    use nalgebra::Vector3;
460
461    #[test]
462    fn a_static_window_without_a_magnetometer_leaves_heading_unestablished() {
463        // The covariance on its own would say otherwise: `Initialization::sigma_yaw` is
464        // 0.35 rad against an `Accuracy::heading` of 0.52, so the prior clears the bar
465        // comfortably. It is a prior on a yaw nothing ever observed.
466        let filter = initialized();
467        let validity = filter.validity();
468        assert!(validity.tilt, "gravity pins roll and pitch");
469        assert!(!validity.heading, "nothing pins the rotation about gravity");
470        assert!(!filter.is_aligned());
471    }
472
473    #[test]
474    fn the_first_accepted_magnetic_heading_establishes_yaw() {
475        let mut filter = initialized();
476        assert!(
477            filter
478                .fuse_mag_heading(
479                    filter.now(),
480                    MagField::body(0.22, 0.0, 0.44),
481                    HeadingNoise::from_sigma(0.1),
482                )
483                .is_accepted()
484        );
485        assert!(filter.validity().heading, "something observed it at last");
486        assert!(filter.is_aligned());
487    }
488
489    #[test]
490    fn the_first_magnetic_heading_is_adopted_and_every_one_after_it_is_fused() {
491        // A quantity never established is adopted once; after that only a lockout adopts
492        // again, and a heading that agrees is no lockout.
493        let mut filter = initialized();
494        let field = measured(attitude_of(0.0, 0.0, 1.1), 0.0);
495        let noise = HeadingNoise::from_sigma(0.05);
496
497        assert!(
498            filter
499                .fuse_mag_heading(filter.now(), field, noise)
500                .is_reset()
501        );
502        assert_eq!(filter.diagnostics().mag_heading.adopted, 1);
503
504        let second = filter.fuse_mag_heading(filter.now(), field, noise);
505        assert!(!second.is_reset(), "adoption happens once: {second:?}");
506        assert!(matches!(second, Fusion::Accepted { .. }), "{second:?}");
507        assert_eq!(filter.diagnostics().mag_heading.adopted, 1);
508    }
509
510    #[test]
511    fn the_adopted_yaw_is_the_heading_the_field_reports() {
512        // The window leveled with no magnetometer, so yaw starts at zero against a
513        // field that says 1.1 rad. A gradual correction would leave it somewhere between
514        // the two; an adoption puts it at the measurement.
515        let mut filter = initialized();
516        assert!(
517            filter
518                .fuse_mag_heading(
519                    filter.now(),
520                    measured(attitude_of(0.0, 0.0, 1.1), 0.0),
521                    HeadingNoise::from_sigma(0.05),
522                )
523                .is_reset()
524        );
525        let (_, _, yaw) = filter.state().attitude.euler_angles();
526        assert!((yaw - 1.1).abs() < 1e-4, "adopted {yaw} rather than 1.1");
527    }
528
529    #[test]
530    fn the_adoption_turns_the_estimate_about_down_and_touches_nothing_else() {
531        // A heading is yaw and nothing else: the tilt gravity established and the biases
532        // the window measured are the same after it, and so are their variances. What
533        // makes this worth asserting is that the rotation is applied to the whole
534        // quaternion — about navigation down, where it moves the tilt axis and leaves
535        // the tilt angle — rather than to a yaw component.
536        let mut filter = initialized();
537        let before = filter.state();
538        let (roll, pitch, _) = before.attitude.euler_angles();
539        let tilt_variance = filter.covariance().variance(ErrorState::AttitudeX);
540
541        assert!(
542            filter
543                .fuse_mag_heading(
544                    filter.now(),
545                    measured(attitude_of(0.0, 0.0, -2.4), 0.0),
546                    HeadingNoise::from_sigma(0.05),
547                )
548                .is_reset()
549        );
550
551        let after = filter.state();
552        let (roll_after, pitch_after, _) = after.attitude.euler_angles();
553        assert!((roll_after - roll).abs() < 1e-5, "roll moved");
554        assert!((pitch_after - pitch).abs() < 1e-5, "pitch moved");
555        assert_eq!(after.gyro_bias, before.gyro_bias);
556        assert_eq!(after.accel_bias, before.accel_bias);
557        // Within an ulp rather than exactly: the adoption reparameterizes the attitude
558        // block by (41), and a 2.4 rad rotation of an isotropic `v I` reconstructs `v`
559        // through `v(c² + s²)`. A tolerance wide enough to hide a heading's variance
560        // landing on the tilt would be four orders of magnitude wider than this.
561        assert!(
562            (filter.covariance().variance(ErrorState::AttitudeX) - tilt_variance).abs() < 1e-9,
563            "the tilt's own uncertainty is not the heading's to replace"
564        );
565    }
566
567    #[test]
568    fn the_adoption_carries_the_attitude_correlations_into_the_new_body_axes() {
569        // (41) on the adoption path. The error state of (2) is referenced to the
570        // nominal's body axes, so turning the nominal turns the axes every attitude row
571        // is written in — and the bias states it is correlated with are in physical body
572        // axes that do not turn, so their cross-blocks transform on one side only.
573        //
574        // Propagation is what builds one: (20)'s `−I Δt` makes P_θ,βg ≈ −Δt·P_βg,βg, the
575        // same correlation that lets a velocity update correct the gyroscope bias. A
576        // quarter turn about a level vehicle's down axis maps the roll-error column onto
577        // the pitch-error one, so the correlation the x bias had with roll error is the
578        // one the y bias should hold afterwards. Skip the reparameterization and it stays
579        // where it was, and the next velocity update pushes the bias correction into the
580        // wrong axis.
581        let mut filter = initialized();
582        let holding_still = still().imu;
583        for _ in 0..2_000 {
584            assert_eq!(
585                filter.step(holding_still, Seconds::from_secs(0.005)),
586                Propagation::Propagated
587            );
588        }
589
590        let before = filter
591            .covariance()
592            .get(ErrorState::AttitudeX, ErrorState::GyroBiasX);
593        assert!(
594            before.abs() > 1e-9,
595            "10 s of (20) should build one: {before}"
596        );
597        assert!(
598            filter
599                .covariance()
600                .get(ErrorState::AttitudeX, ErrorState::GyroBiasY)
601                .abs()
602                < 1e-12,
603            "and nothing across the axes yet"
604        );
605
606        assert!(
607            filter
608                .fuse_mag_heading(
609                    filter.now(),
610                    measured(attitude_of(0.0, 0.0, core::f32::consts::FRAC_PI_2), 0.0),
611                    HeadingNoise::from_sigma(0.05),
612                )
613                .is_reset()
614        );
615
616        let after = filter.covariance();
617        assert!(
618            (after.get(ErrorState::AttitudeX, ErrorState::GyroBiasY) - before).abs()
619                < 1e-9 * before.abs().max(1.0),
620            "the quarter turn should have moved it to the y bias, got {}",
621            after.get(ErrorState::AttitudeX, ErrorState::GyroBiasY)
622        );
623        assert!(
624            after
625                .get(ErrorState::AttitudeX, ErrorState::GyroBiasX)
626                .abs()
627                < 1e-9,
628            "and off the x bias, got {}",
629            after.get(ErrorState::AttitudeX, ErrorState::GyroBiasX)
630        );
631    }
632
633    #[test]
634    fn a_refused_first_heading_establishes_nothing() {
635        // A refusal is not a measurement the filter chose to believe, so the quantity is
636        // still unestablished and the next usable field is still the one adopted.
637        let mut filter = initialized();
638        assert_eq!(
639            filter.fuse_mag_heading(
640                filter.now(),
641                MagField::body(f32::NAN, 0.0, 0.44),
642                HeadingNoise::from_sigma(0.05),
643            ),
644            Fusion::NotFinite
645        );
646        assert!(!filter.validity().heading);
647        assert_eq!(filter.diagnostics().mag_heading.adopted, 0);
648
649        assert!(
650            filter
651                .fuse_mag_heading(
652                    filter.now(),
653                    measured(attitude_of(0.0, 0.0, 1.1), 0.0),
654                    HeadingNoise::from_sigma(0.05),
655                )
656                .is_reset(),
657            "the adoption was waiting for a usable field, not spent on a refused one"
658        );
659    }
660
661    #[test]
662    fn a_heading_far_from_an_established_yaw_is_rejected_and_changes_nothing() {
663        // Once yaw is established the gate has a prior worth testing against, and this
664        // is the disturbance case: a field turned a quarter circle against a σ of
665        // 0.05 rad is hundreds of times the threshold of (37).
666        let mut filter = initialized();
667        assert!(
668            filter
669                .fuse_mag_heading(
670                    filter.now(),
671                    measured(attitude_of(0.0, 0.0, 0.0), 0.0),
672                    HeadingNoise::from_sigma(0.05),
673                )
674                .is_reset()
675        );
676        let before = filter.state().attitude;
677
678        let outcome = filter.fuse_mag_heading(
679            filter.now(),
680            measured(attitude_of(0.0, 0.0, core::f32::consts::FRAC_PI_2), 0.0),
681            HeadingNoise::from_sigma(0.05),
682        );
683        assert!(
684            matches!(outcome, Fusion::Rejected { test_ratio } if test_ratio > 1.0),
685            "{outcome:?}"
686        );
687        assert_eq!(filter.state().attitude, before, "a rejection costs nothing");
688        assert_eq!(filter.diagnostics().mag_heading.rejected, 1);
689    }
690
691    #[test]
692    fn an_ordinary_heading_moves_yaw_part_of_the_way_and_shrinks_its_variance() {
693        // The other side of the adoption: once established, a heading is fused rather
694        // than taken. The distance it moves is (25)'s gain with (36′) in both `P` and
695        // `R`, and the whole sum is checkable by hand — the window's tilt prior is
696        // `sigma_tilt` = 0.02 and the dip is 2.0, so the leveling is worth
697        // 2.0² · 0.02² = 0.0016 on every heading here. The adoption left yaw at
698        // 0.05² + 0.0016 = 0.0041, the second field arrives with σ = 0.02, so
699        // `R` = 0.02² + 0.0016 = 0.0020 and `K = 0.0041 / (0.0041 + 0.0020)` = 0.672:
700        // a 0.05 rad disagreement moves 0.0336.
701        //
702        // The two σ differ on purpose. Equal ones make `P` and `R` equal, `K` exactly a
703        // half, and the test blind to (36′) being dropped from either side — which is
704        // what it is here to notice. Unpriced in `R` this moves 0.0456, unpriced in both
705        // 0.0431. Both headings arrive with no step between them, which (24′) takes for the
706        // same error twice, so the sources are white here.
707        let mut filter = initialized();
708        filter.config.correlation = Correlation::WHITE;
709        assert!(
710            filter
711                .fuse_mag_heading(
712                    filter.now(),
713                    measured(attitude_of(0.0, 0.0, 0.0), 0.0),
714                    HeadingNoise::from_sigma(0.05),
715                )
716                .is_reset()
717        );
718
719        assert!(
720            filter
721                .fuse_mag_heading(
722                    filter.now(),
723                    measured(attitude_of(0.0, 0.0, 0.05), 0.0),
724                    HeadingNoise::from_sigma(0.02),
725                )
726                .is_accepted()
727        );
728        let (_, _, yaw) = filter.state().attitude.euler_angles();
729        assert!(
730            (yaw - 0.0336).abs() < 1e-3,
731            "expected 0.0336 by the arithmetic above, got {yaw}"
732        );
733        assert!(
734            filter.covariance().variance(ErrorState::AttitudeZ) < 0.0041,
735            "a fused heading leaves yaw better known than the adoption did"
736        );
737    }
738
739    #[test]
740    fn a_magnetometer_in_the_window_establishes_heading_at_once() {
741        let mut filter = Eskf::default();
742        let _ = filter
743            .initialize_over(&window_with_mag(), Seconds::from_secs(0.25))
744            .expect("a 2 s window of stillness");
745        assert!(filter.validity().heading);
746    }
747
748    #[test]
749    fn a_moving_start_takes_its_heading_from_the_first_magnetometer() {
750        // This window's halves level 0.4 rad apart, and the dip scales that into 0.78 rad
751        // of yaw — half as much again as `Accuracy::heading`, so the start is worth no
752        // heading at all. The first field is adopted rather than fused: a prior nothing
753        // measured is replaced, not averaged with.
754        let mut filter = Eskf::default();
755        let alignment = filter
756            .initialize_over(&turning(0.0, 0.4, 0.0), Seconds::from_secs(0.25))
757            .expect("moving, not unusable");
758        assert!(matches!(alignment, Alignment::Coarse(..)));
759        assert!(!filter.validity().heading, "the window was moving");
760
761        let field = MagField::body(0.22, 0.0, 0.44);
762        let noise = HeadingNoise::from_sigma(0.1);
763        assert!(
764            filter
765                .fuse_mag_heading(filter.now(), field, noise)
766                .is_reset()
767        );
768
769        // (36′) on the adoption path, which is where the term is worth the most: (34)
770        // levels with a tilt whose variance is 0.64 here, and the dip carries that into
771        // the heading. 0.582 rather than the measurement's own 0.01 — σ = 0.76 rad,
772        // outside `Accuracy::heading`. Storing the magnetometer's number alone is what
773        // makes the filter claim an attitude it does not have, on the one path where the
774        // tilt doing the leveling is worst.
775        let yaw_variance = filter.covariance().variance(ErrorState::AttitudeZ);
776        assert!(
777            (yaw_variance - 0.582).abs() < 1e-2,
778            "expected the leveling priced in, got {yaw_variance}"
779        );
780        assert!(
781            !filter.validity().heading,
782            "a heading leveled by this tilt is not good to Accuracy::heading"
783        );
784        assert!(!filter.validity().tilt, "the tilt is still the window's");
785
786        // Established all the same, which is the other half of the claim: the quantity
787        // has been observed, so the next field is fused rather than adopted. Established
788        // and good are two questions and only the covariance answers the second.
789        assert!(matches!(
790            filter.fuse_mag_heading(filter.now(), field, noise),
791            Fusion::Accepted { .. }
792        ));
793    }
794
795    #[test]
796    fn on_its_tail_a_heading_adoption_resets_heading_and_leaves_tilt() {
797        let mut filter = on_its_tail();
798        let before = filter.attitude_variance();
799        // The same vehicle turned 1.1 rad about down: a heading, and nothing else, differs.
800        let truth = Attitude::from_quaternion(
801            nalgebra::UnitQuaternion::from_axis_angle(&Vector3::z_axis(), 1.1)
802                * filter.state().attitude.quaternion(),
803        );
804        let field = measured(truth, 0.0);
805        let noise = HeadingNoise::from_sigma(0.05);
806        let adopted = mag::heading_observation(
807            filter.estimate.state(),
808            &filter.covariance,
809            field,
810            filter.declination,
811            noise,
812        )
813        .r_m[0];
814
815        assert!(
816            filter
817                .fuse_mag_heading(filter.now(), field, noise)
818                .is_reset()
819        );
820
821        let after = filter.attitude_variance();
822        assert!(
823            (after.heading - adopted).abs() < 1e-6 * adopted,
824            "heading carries the adopted variance: {after:?} against {adopted}"
825        );
826        assert!(
827            (after.tilt_north - before.tilt_north).abs() < 1e-9,
828            "{after:?}"
829        );
830        assert!(
831            (after.tilt_east - before.tilt_east).abs() < 1e-9,
832            "{after:?}"
833        );
834    }
835
836    /// An established yaw of zero, and a magnetometer turned a quarter circle from it.
837    fn disturbed_heading() -> (Eskf, MagField<Body>) {
838        let mut filter = initialized();
839        assert!(
840            filter
841                .fuse_mag_heading(
842                    filter.now(),
843                    measured(attitude_of(0.0, 0.0, 0.0), 0.0),
844                    HeadingNoise::from_sigma(0.05),
845                )
846                .is_reset()
847        );
848        let turned = measured(attitude_of(0.0, 0.0, core::f32::consts::FRAC_PI_2), 0.0);
849        (filter, turned)
850    }
851
852    #[test]
853    fn a_magnetometer_disagreeing_while_gnss_arrives_is_not_adopted() {
854        // The guard PX4's `mag_control.cpp` keeps: with horizontal aiding arriving, a heading
855        // that disagrees for this long is a disturbance, and adopting it would turn the
856        // estimate by it.
857        let (mut filter, turned) = disturbed_heading();
858        let mut step = 0;
859        hold(&mut filter, 20.0, 10, |filter| {
860            step += 1;
861            if step % 10 == 0 {
862                let _ = filter.fuse_gnss_position(
863                    filter.now(),
864                    Position::zero(),
865                    one_metre(),
866                    Position::zero(),
867                );
868            }
869            let outcome =
870                filter.fuse_mag_heading(filter.now(), turned, HeadingNoise::from_sigma(0.05));
871            assert!(matches!(outcome, Fusion::Rejected { .. }), "{outcome:?}");
872        });
873        assert_eq!(filter.diagnostics().mag_heading.recovered, 0);
874    }
875
876    #[test]
877    fn a_magnetometer_disagreeing_with_a_gnss_heading_is_not_adopted() {
878        // The receiver measures heading outright, so the magnetometer is the one disturbed.
879        let (mut filter, turned) = disturbed_heading();
880        let north = Radians::from_radians(0.0);
881        hold(&mut filter, 20.0, 10, |filter| {
882            let _ = filter.fuse_gnss_heading(filter.now(), north, HeadingNoise::from_sigma(0.05));
883            let outcome =
884                filter.fuse_mag_heading(filter.now(), turned, HeadingNoise::from_sigma(0.05));
885            assert!(matches!(outcome, Fusion::Rejected { .. }), "{outcome:?}");
886        });
887        assert_eq!(filter.diagnostics().mag_heading.recovered, 0);
888    }
889
890    #[test]
891    fn a_magnetometer_disagreeing_with_nothing_else_to_say_is_adopted() {
892        let (mut filter, turned) = disturbed_heading();
893        let mut recovered = false;
894        hold(&mut filter, 8.0, 10, |filter| {
895            if !recovered {
896                recovered = filter
897                    .fuse_mag_heading(filter.now(), turned, HeadingNoise::from_sigma(0.05))
898                    .is_reset();
899            }
900        });
901        assert!(recovered);
902        let (_, _, yaw) = filter.state().attitude.euler_angles();
903        assert!(
904            (yaw - core::f32::consts::FRAC_PI_2).abs() < 1e-3,
905            "yaw = {yaw}"
906        );
907        assert_eq!(filter.diagnostics().mag_heading.recovered, 1);
908    }
909
910    #[test]
911    fn the_first_gnss_heading_is_adopted_and_every_one_after_it_is_fused() {
912        let mut filter = initialized();
913        let noise = HeadingNoise::from_sigma(0.05);
914        let heading = Radians::from_radians(1.1);
915        assert!(
916            filter
917                .fuse_gnss_heading(filter.now(), heading, noise)
918                .is_reset()
919        );
920        let (_, _, yaw) = filter.state().attitude.euler_angles();
921        assert!((yaw - 1.1).abs() < 1e-5, "yaw = {yaw}");
922        assert!(filter.validity().heading);
923        assert!(filter.is_aligned(), "a heading source is a heading source");
924        assert_eq!(filter.diagnostics().gnss_heading.adopted, 1);
925
926        let second = filter.fuse_gnss_heading(filter.now(), heading, noise);
927        assert!(matches!(second, Fusion::Accepted { .. }), "{second:?}");
928    }
929
930    #[test]
931    fn a_gnss_heading_is_screened_before_anything_else() {
932        let mut filter = initialized();
933        let at = |r| Radians::from_radians(r);
934        assert_eq!(
935            filter.fuse_gnss_heading(filter.now(), at(f32::NAN), HeadingNoise::from_sigma(0.1)),
936            Fusion::NotFinite
937        );
938        assert_eq!(
939            filter.fuse_gnss_heading(filter.now(), at(0.3), HeadingNoise::from_variance(0.0)),
940            Fusion::InvalidNoise
941        );
942        assert_eq!(filter.diagnostics().gnss_heading.adopted, 0);
943        assert!(!filter.validity().heading, "a refusal establishes nothing");
944    }
945
946    #[test]
947    fn a_gnss_heading_is_true_heading_whatever_the_declination() {
948        let mut filter = initialized();
949        assert!(filter.set_magnetic_declination(Radians::from_radians(0.3)));
950        let _ = filter.fuse_gnss_heading(
951            filter.now(),
952            Radians::from_radians(1.1),
953            HeadingNoise::from_sigma(0.05),
954        );
955        let (_, _, yaw) = filter.state().attitude.euler_angles();
956        assert!((yaw - 1.1).abs() < 1e-5, "yaw = {yaw}");
957    }
958
959    #[test]
960    fn a_course_is_refused_without_speed_and_adopted_with_it() {
961        let slow = Velocity::ned(0.5, 0.5, 0.0);
962        let mut filter = cruising(slow);
963        assert_eq!(
964            filter.fuse_course(filter.now(), HeadingNoise::from_sigma(0.05)),
965            Fusion::Unobservable
966        );
967        assert_eq!(
968            filter.diagnostics().course.last_refusal,
969            Some(Refusal::Unobservable)
970        );
971
972        // North-east at 14 m/s: the course is π/4.
973        let mut filter = cruising(Velocity::ned(10.0, 10.0, 0.0));
974        assert!(
975            filter
976                .fuse_course(filter.now(), HeadingNoise::from_sigma(0.05))
977                .is_reset()
978        );
979        let (_, _, yaw) = filter.state().attitude.euler_angles();
980        assert!(
981            (yaw - core::f32::consts::FRAC_PI_4).abs() < 1e-5,
982            "yaw = {yaw}"
983        );
984        assert!(filter.is_aligned());
985        let next = filter.fuse_course(filter.now(), HeadingNoise::from_sigma(0.05));
986        assert!(matches!(next, Fusion::Accepted { .. }), "{next:?}");
987    }
988
989    #[test]
990    fn an_adopted_course_carries_the_velocity_s_uncertainty_as_well_as_the_sideslip() {
991        // About 0.2 m/s across 14.1 m/s of track, once the velocity fix has been fused, on top
992        // of 0.05 rad of sideslip.
993        let mut filter = cruising(Velocity::ned(10.0, 10.0, 0.0));
994        let course =
995            heading::course_variance(filter.estimate.state(), &filter.covariance).expect("moving");
996        assert!(course > 1e-5 && course < 0.09 / 200.0, "{course}");
997        let sideslip = HeadingNoise::from_sigma(0.05);
998        assert!(filter.fuse_course(filter.now(), sideslip).is_reset());
999        let heading = AttitudeVariance::of(&filter.state().attitude, filter.covariance()).heading;
1000        let expected = sideslip.variance() + course;
1001        assert!(
1002            (heading - expected).abs() < 1e-6,
1003            "heading variance {heading}, expected {expected}"
1004        );
1005    }
1006
1007    #[test]
1008    fn a_coarse_start_has_no_course_until_velocity_is_established() {
1009        let mut filter = coarse();
1010        assert_eq!(
1011            filter.fuse_course(filter.now(), HeadingNoise::from_sigma(0.05)),
1012            Fusion::NoReference
1013        );
1014    }
1015
1016    #[test]
1017    fn the_course_corrects_heading_and_velocity_together() {
1018        // Heading established at 0, then a course constraint along a velocity 0.2 rad east of
1019        // it: the update turns the nose toward the track and the track toward the nose, each
1020        // by its share of the uncertainty.
1021        let mut filter = cruising(Velocity::ned(20.0 * 0.2f32.cos(), 20.0 * 0.2f32.sin(), 0.0));
1022        let _ = filter.fuse_gnss_heading(
1023            filter.now(),
1024            Radians::from_radians(0.0),
1025            HeadingNoise::from_sigma(0.1),
1026        );
1027        let outcome = filter.fuse_course(filter.now(), HeadingNoise::from_sigma(0.1));
1028        assert!(matches!(outcome, Fusion::Accepted { .. }), "{outcome:?}");
1029        let state = filter.state();
1030        let (_, _, yaw) = state.attitude.euler_angles();
1031        let v = state.velocity.vector();
1032        let track = v.y.atan2(v.x);
1033        assert!(
1034            yaw > 0.0 && yaw < 0.2,
1035            "the nose turned toward the track: {yaw}"
1036        );
1037        assert!(
1038            track < 0.2 && track > yaw,
1039            "the track turned toward the nose: {track}"
1040        );
1041    }
1042
1043    #[test]
1044    fn a_course_disagreeing_while_another_heading_arrives_is_not_adopted() {
1045        // Nose north on a GNSS heading, tracking east: a 90° crab no sideslip allowed for.
1046        let mut filter = cruising(Velocity::ned(0.0, 15.0, 0.0));
1047        let north = Radians::from_radians(0.0);
1048        let noise = HeadingNoise::from_sigma(0.02);
1049        assert!(
1050            filter
1051                .fuse_gnss_heading(filter.now(), north, noise)
1052                .is_reset()
1053        );
1054        // Velocity aided too, or its uncertainty outgrows the speed and the course is refused.
1055        let east = Velocity::ned(0.0, 15.0, 0.0);
1056        hold(&mut filter, 10.0, 10, |filter| {
1057            let _ = filter.fuse_gnss_heading(filter.now(), north, noise);
1058            let _ = filter.fuse_gnss_velocity(
1059                filter.now(),
1060                east,
1061                VelocityNoise::from_speed_accuracy(0.3),
1062                Position::zero(),
1063            );
1064            let outcome = filter.fuse_course(filter.now(), HeadingNoise::from_sigma(0.02));
1065            assert!(matches!(outcome, Fusion::Rejected { .. }), "{outcome:?}");
1066        });
1067        assert_eq!(filter.diagnostics().course.recovered, 0);
1068    }
1069
1070    #[test]
1071    fn a_course_disagreeing_with_nothing_else_to_say_is_adopted() {
1072        let mut filter = cruising(Velocity::ned(0.0, 15.0, 0.0));
1073        let noise = HeadingNoise::from_sigma(0.02);
1074        assert!(
1075            filter
1076                .fuse_gnss_heading(filter.now(), Radians::from_radians(0.0), noise)
1077                .is_reset()
1078        );
1079        let mut recovered = false;
1080        hold(&mut filter, 8.0, 10, |filter| {
1081            hold_velocity(filter, Velocity::ned(0.0, 15.0, 0.0));
1082            if !recovered {
1083                recovered = filter.fuse_course(filter.now(), noise).is_reset();
1084            }
1085        });
1086        assert!(recovered);
1087        assert_eq!(filter.diagnostics().course.recovered, 1);
1088    }
1089
1090    #[test]
1091    fn a_course_along_a_dead_reckoned_velocity_is_refused() {
1092        // Moving fast enough, but a 10 Hz velocity has not held it for five of its periods,
1093        // twice its own timeout: a course along it would read as aiding while it drifts.
1094        let velocity = Velocity::ned(0.0, 15.0, 0.0);
1095        let mut filter = cruising(velocity);
1096        hold(&mut filter, 2.0, 10, |filter| {
1097            hold_velocity(filter, velocity)
1098        });
1099        hold(&mut filter, 0.5, 100, |_| {});
1100        assert_eq!(
1101            filter.fuse_course(filter.now(), HeadingNoise::from_sigma(0.05)),
1102            Fusion::NoReference
1103        );
1104    }
1105
1106    #[test]
1107    fn a_course_adopted_in_the_past_turns_about_down_and_leaves_tilt_alone() {
1108        // (23′) carries the course's row back through the error dynamics, and its velocity block
1109        // lends the attitude row a tilt component; the adoption must still turn about down.
1110        let mut filter = cruising(Velocity::ned(10.0, 10.0, 0.0));
1111        hold(&mut filter, 0.2, 10, |filter| {
1112            hold_velocity(filter, Velocity::ned(10.0, 10.0, 0.0))
1113        });
1114        let tilt = |filter: &Eskf| {
1115            let v = AttitudeVariance::of(&filter.state().attitude, filter.covariance());
1116            v.tilt_north + v.tilt_east
1117        };
1118        let before = tilt(&filter);
1119        let taken = filter.now().before(Seconds::from_secs(0.11));
1120        assert!(
1121            filter
1122                .fuse_course(taken, HeadingNoise::from_sigma(0.05))
1123                .is_reset()
1124        );
1125        let after = tilt(&filter);
1126        assert!(
1127            (after - before).abs() < 1e-3 * before,
1128            "tilt variance {before} -> {after}"
1129        );
1130    }
1131
1132    #[test]
1133    fn a_forward_axis_near_vertical_refuses_both_gnss_heading_and_course() {
1134        // A tailsitter hovering: body x 25° from the sky, moving at 15 m/s with its velocity
1135        // held, so nothing but the geometry refuses the course.
1136        let mut filter = Eskf::default();
1137        let state = State {
1138            attitude: attitude_of(0.0, 1.134, 0.0),
1139            velocity: Velocity::ned(15.0, 0.0, 0.0),
1140            ..State::default()
1141        };
1142        let _ = filter
1143            .seed(state, Covariance::from_sigmas([0.5; STATES]))
1144            .expect("a sane seed");
1145        hold_velocity(&mut filter, Velocity::ned(15.0, 0.0, 0.0));
1146        let noise = HeadingNoise::from_sigma(0.05);
1147        assert_eq!(
1148            filter.fuse_gnss_heading(filter.now(), Radians::from_radians(0.3), noise),
1149            Fusion::Unobservable
1150        );
1151        assert_eq!(
1152            filter.fuse_course(filter.now(), noise),
1153            Fusion::Unobservable
1154        );
1155    }
1156
1157    #[test]
1158    fn a_course_disagreeing_with_a_magnetometer_is_not_adopted() {
1159        // Nose north on the magnetometer, tracking east: a crab no sideslip allowed for.
1160        let east = Velocity::ned(0.0, 15.0, 0.0);
1161        let mut filter = cruising(east);
1162        let north = measured(attitude_of(0.0, 0.0, 0.0), 0.0);
1163        let noise = HeadingNoise::from_sigma(0.02);
1164        assert!(
1165            filter
1166                .fuse_mag_heading(filter.now(), north, noise)
1167                .is_reset()
1168        );
1169        hold(&mut filter, 10.0, 10, |filter| {
1170            let _ = filter.fuse_mag_heading(filter.now(), north, noise);
1171            hold_velocity(filter, east);
1172            let outcome = filter.fuse_course(filter.now(), noise);
1173            assert!(matches!(outcome, Fusion::Rejected { .. }), "{outcome:?}");
1174        });
1175        assert_eq!(filter.diagnostics().course.recovered, 0);
1176    }
1177}