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}