fusion_nav/eskf/validity.rs
1//! What the estimate can claim: per-quantity [`Validity`] from the covariance against
2//! [`Config::accuracy`](crate::Config::accuracy), the prediction an arming check reads, and the
3//! alignment latch behind [`Status::Aligning`](crate::Status::Aligning).
4//!
5//! Entry points: [`Eskf::validity`], [`Eskf::predicted_validity`], [`Eskf::attitude_variance`]
6//! and [`Eskf::is_aligned`].
7
8use crate::config::{ALIGNED_HEADING, ALIGNED_TILT};
9use crate::health::{SourceHealth, Validity};
10use crate::propagate::project;
11use crate::state::{AttitudeVariance, Covariance, ErrorState};
12use crate::units::Radians;
13
14use super::Eskf;
15
16impl Eskf {
17 /// How uncertain the attitude is about each navigation axis: tilt about north and east,
18 /// heading about down.
19 ///
20 /// The numbers [`validity`](Self::validity) tests against
21 /// [`Accuracy`](crate::Accuracy), and the ones to plot beside a tilt or a heading. The
22 /// attitude rows of [`covariance`](Self::covariance) are in body axes and mean tilt and
23 /// heading only while the vehicle is level; see [`AttitudeVariance`]. All zero before
24 /// the filter is initialized, which is the covariance it then holds.
25 pub fn attitude_variance(&self) -> AttitudeVariance {
26 AttitudeVariance::of(&self.estimate.state().attitude, &self.covariance)
27 }
28
29 /// Whether the attitude has **ever** met [`ALIGNED_TILT`] and [`ALIGNED_HEADING`] since
30 /// initialization — the test behind [`Status::Aligning`](crate::Status::Aligning).
31 ///
32 /// Promotion is measured, not timed: it reads the covariance against those two bars, and
33 /// holds heading to the rule [`Validity`] does, so it stays false on a vehicle with no
34 /// magnetometer — a heading nothing ever observed is not aligned however tight
35 /// [`sigma_yaw`](crate::Initialization::sigma_yaw) is. It does not read
36 /// [`Config::accuracy`](crate::Config::accuracy): how good the attitude must be for the
37 /// mission and whether the start has been resolved are two questions, and a bar serving
38 /// both has to be wrong for one of them. What it does not do is fall back:
39 /// alignment is an event, "the start has been resolved", where
40 /// [`validity`](Self::validity) is the live question, "is tilt good enough right now".
41 ///
42 /// Conflating the two is what a corpus replay showed costs. Read live, the bar is crossed
43 /// 703 times on `2c42096b`, a grounded vehicle under a poor sky view whose tilt σ sits
44 /// above [`ALIGNED_TILT`] for 79 % of two hours, and `7592c9b2` and `f16771dd` start
45 /// aligned and end `Aligning`. The counts are the same with tilt read on body axes or on
46 /// navigation ones. That is honest about tilt right now, which is [`Validity`]'s job, and
47 /// useless as a report that the filter has not finished starting up. PX4 and ArduPilot both
48 /// latch it for the same reason: `tilt_align` and `tiltAlignComplete` are only ever tested
49 /// while false (`src/modules/ekf2/EKF/control.cpp:73-78` at `c4e4ef98e9`,
50 /// `libraries/AP_NavEKF3/AP_NavEKF3_Control.cpp:520-525` at `368dc0c428`).
51 pub const fn is_aligned(&self) -> bool {
52 self.aligned
53 }
54
55 /// Latch [`is_aligned`](Self::is_aligned) if the attitude now meets the bar.
56 ///
57 /// Called from every path that can move the attitude covariance or establish heading.
58 /// A latch is state, so it cannot be derived on read the way [`Status`](crate::Status) is — and
59 /// reading it lazily would make the answer depend on whether anyone asked.
60 pub(super) fn note_alignment(&mut self) {
61 if self.initialized && !self.aligned {
62 self.aligned = self.tilt_within(&self.covariance, ALIGNED_TILT)
63 && self.heading_within(&self.covariance, ALIGNED_HEADING);
64 }
65 }
66
67 /// Which parts of the estimate are good enough to use, right now.
68 ///
69 /// Also carried on [`State::validity`](crate::State::validity), which is where most
70 /// callers will meet it.
71 pub fn validity(&self) -> Validity {
72 self.validity_of(&self.covariance)
73 }
74
75 /// [`validity`](Self::validity)'s question asked of any covariance, not only the one the
76 /// filter is holding.
77 ///
78 /// One definition, two covariances: the current one, and the one
79 /// [`predicted_validity`](Self::predicted_validity) projects to its horizon. Reading the
80 /// verdict off one and re-deriving the geometry for the other would be two
81 /// implementations of a single claim — the mistake `false_valid` made in the replay
82 /// harness, where a 2-D norm stood in for the per-axis test this actually performs.
83 fn validity_of(&self, p: &Covariance) -> Validity {
84 if !self.initialized {
85 return Validity::NONE;
86 }
87 let accuracy = &self.config.accuracy;
88 let position = accuracy.position.as_meters();
89 let velocity = accuracy.velocity.as_m_per_s();
90
91 Validity {
92 tilt: self.tilt_within(p, accuracy.tilt),
93 heading: self.heading_within(p, accuracy.heading),
94 // A quantity nothing ever established is not valid however tight the prior on
95 // it looks: nobody set that number.
96 // Nor one the position hold bounds: the assumption tightened it, not a sensor.
97 horizontal_position: !self.unestablished.position
98 && !self.hold.holds_position()
99 && within(p, ErrorState::PositionNorth, position)
100 && within(p, ErrorState::PositionEast, position),
101 vertical_position: !self.unestablished.position
102 && within(p, ErrorState::PositionDown, position),
103 horizontal_velocity: !self.unestablished.velocity
104 && !self.hold.holds_velocity()
105 && within(p, ErrorState::VelocityNorth, velocity)
106 && within(p, ErrorState::VelocityEast, velocity),
107 vertical_velocity: !self.unestablished.velocity
108 && within(p, ErrorState::VelocityDown, velocity),
109 }
110 }
111
112 /// Whether tilt about north and about east are both within `bar`, one standard
113 /// deviation per axis.
114 ///
115 /// Shared by [`validity`](Self::validity) and the alignment latch, which ask it against
116 /// different bars; one definition is what keeps the two claims the same shape. Read on
117 /// navigation axes through [`AttitudeVariance`], not off `δθ_x` and `δθ_y`, which are
118 /// tilt only while the vehicle is level.
119 pub(super) fn tilt_within(&self, p: &Covariance, bar: Radians) -> bool {
120 let sigma = bar.as_radians();
121 let variance = AttitudeVariance::of(&self.estimate.state().attitude, p);
122 variance.tilt_north <= sigma * sigma && variance.tilt_east <= sigma * sigma
123 }
124
125 /// Whether heading has been established and is within `bar`.
126 ///
127 /// A heading nothing observed fails whatever its variance: stillness never observes yaw,
128 /// and a prior on a yaw nobody measured is not an estimate of one.
129 fn heading_within(&self, p: &Covariance, bar: Radians) -> bool {
130 let sigma = bar.as_radians();
131 !self.unestablished.heading
132 && AttitudeVariance::of(&self.estimate.state().attitude, p).heading <= sigma * sigma
133 }
134
135 /// Which parts of the estimate the filter expects to be good **if the vehicle left
136 /// the ground now** — the arming question, rather than the current one.
137 ///
138 /// Sitting still, some states are simply unobservable: heading without a
139 /// magnetometer, horizontal position before the first fix is fused. Asking
140 /// [`validity`](Self::validity) at that moment says no, and says it about a filter
141 /// that would in fact be navigating a second after takeoff. A vehicle that refused to
142 /// arm on that answer would never arm at all.
143 ///
144 /// So a quantity counts here if it survives the horizon, **or** if a source that
145 /// constrains it is currently being accepted. The two halves answer the two ways an
146 /// arming check can be wrong. The projection is the pessimistic half: `P` is propagated
147 /// [`Accuracy::horizon`](crate::Accuracy::horizon) forward with nothing fusing, by the
148 /// (16)–(22) `predict` itself runs, and each quantity is tested at the far end — so a
149 /// tilt that is inside its bar now and will not be in a second reads false here and
150 /// true from [`validity`](Self::validity). The aiding clause is the optimistic half,
151 /// and it is what a projection cannot supply: before the first fix, horizontal position
152 /// has no estimate to propagate and the fact that fixes are arriving is the whole
153 /// answer. `pred_horiz_pos_rel` in ArduPilot's status word is that clause; PX4 has no
154 /// equivalent, and neither publishes the projection.
155 ///
156 /// Tilt is where the projection earns its place, because nothing aids it: a static
157 /// window brings it in and the gyroscope's noise and bias take it back out through (20),
158 /// on a schedule the covariance knows and no acceptance timer does. At
159 /// [`ImuNoise`](crate::ImuNoise)'s defaults an unaided start holds tilt for 10.3 s from a
160 /// window that measured its gyroscope and 4.84 s from one whose gyroscope never scattered,
161 /// so a horizon under that arms and one over it does not, which the current value alone
162 /// cannot say. The projection is of propagation alone: the position hold of
163 /// [`Config::hold`](crate::Config::hold), which engages at that bar, is not projected.
164 ///
165 /// The projection is (22′) in one exact step, so it is neither optimistic nor longer for a
166 /// longer horizon: one `Φ P Φᵀ` and its noise, about an IMU epoch's arithmetic.
167 pub fn predicted_validity(&self) -> Validity {
168 if !self.initialized {
169 return Validity::NONE;
170 }
171 let horizon = project(
172 self.estimate.state(),
173 self.covariance,
174 self.config.accuracy.horizon,
175 &self.config,
176 );
177 let ahead = self.validity_of(&horizon);
178
179 let fresh = |source: &SourceHealth| source.is_fresh(&self.config.timeouts);
180 let d = &self.diagnostics;
181 let (position, velocity) = (fresh(&d.gnss_position), fresh(&d.gnss_velocity));
182 let height = fresh(&d.gnss_height) || fresh(&d.baro_altitude);
183
184 Validity {
185 // Gravity is not an aiding source the filter tracks, so tilt has only the
186 // projection to speak for it -- which is the one quantity where that is the
187 // whole answer rather than half of it.
188 tilt: ahead.tilt,
189 heading: ahead.heading
190 || fresh(&d.mag_heading)
191 || fresh(&d.gnss_heading)
192 || fresh(&d.course),
193 horizontal_position: ahead.horizontal_position || position,
194 vertical_position: ahead.vertical_position || height,
195 horizontal_velocity: ahead.horizontal_velocity || velocity,
196 vertical_velocity: ahead.vertical_velocity || velocity,
197 }
198 }
199}
200
201/// Whether one error state's variance is within `sigma`, one standard deviation on that axis.
202///
203/// A free function rather than a method, because the covariance it reads is an argument: the
204/// filter asks this of the one it holds and of the one
205/// [`Eskf::predicted_validity`] projects, and a method taking `&self` would quietly answer
206/// for the wrong one.
207fn within(p: &Covariance, state: ErrorState, sigma: f32) -> bool {
208 p.variance(state) <= sigma * sigma
209}
210
211#[cfg(test)]
212mod tests {
213
214 use crate::config::{Accuracy, Config, GRAVITY};
215 use crate::eskf::Eskf;
216 use crate::eskf::fixtures::*;
217
218 use crate::health::{Propagation, Status, Validity};
219 use crate::init::tests::still;
220 use crate::init::{Alignment, Coarse, StaticSample};
221
222 use crate::observation::mag::tests::attitude_of;
223
224 use crate::state::{AttitudeVariance, Covariance, ErrorState, STATES, State};
225 use crate::units::{
226 Altitude, AltitudeNoise, AngularRate, HeadingNoise, MagField, Position, PositionNoise,
227 Radians, Seconds,
228 };
229
230 /// The margin [`Accuracy`]'s defaults buy, measured rather than derived, from a static start
231 /// with a magnetometer in the window at [`ImuNoise`](crate::ImuNoise)'s defaults: 10.3 s of
232 /// unaided tilt and 274 s of heading from a window that measured its gyroscope, 4.84 s and
233 /// 51.4 s from one whose gyroscope never scattered, whose `ω̄` is weighed under the
234 /// configured density.
235 ///
236 /// The gyroscope-bias prior reaches attitude through (20)'s `−I Δt` and grows as
237 /// `σ_βg² t²`, so the window sets both figures. Measured, it leaves tilt to the white
238 /// noise's `σ_g² t`, 10.4 s alone, and heading to that and the bias walk. A test rather than
239 /// a comment because [`Accuracy`] cites the numbers: change a bar or a density and this says
240 /// by how much the margin moved.
241 #[test]
242 fn an_unaided_start_holds_its_attitude_for_the_margin_the_defaults_buy() {
243 // Forty samples 50 ms apart, the gyroscope alternating ±1.9 × 10⁻³ rad/s on every
244 // axis: blocks enough for (8″), and `ω̄` known to 3 × 10⁻⁴ rad/s.
245 let mut measured = [StaticSample {
246 mag: Some(MagField::body(0.22, 0.0, 0.44)),
247 ..still()
248 }; 40];
249 for (index, sample) in measured.iter_mut().enumerate() {
250 let rate = if index % 2 == 0 { 1.9e-3 } else { -1.9e-3 };
251 sample.imu = sample.imu.with_gyro(AngularRate::body(rate, rate, rate));
252 }
253 let (tilt, heading) = held(&measured, Seconds::from_secs(0.05));
254 assert!((tilt - 10.3).abs() < 0.1, "tilt held {tilt} s");
255 assert!((heading - 274.0).abs() < 2.0, "heading held {heading} s");
256
257 // A gyroscope that never scatters, held or coarsely quantized, measures nothing of
258 // itself, so the configured density is weighed against the prior.
259 let (tilt, heading) = held(&window_with_mag(), Seconds::from_secs(0.25));
260 assert!((tilt - 4.84).abs() < 0.05, "tilt held {tilt} s");
261 assert!((heading - 51.4).abs() < 0.3, "heading held {heading} s");
262 }
263
264 /// How long a static start from `window` holds tilt and heading unaided.
265 fn held(window: &[StaticSample], dt: Seconds) -> (f32, f32) {
266 // Without the position hold, which exists to take this schedule away.
267 let mut filter = Eskf::new(Config {
268 hold: None,
269 ..Config::default()
270 })
271 .unwrap();
272 assert_eq!(filter.initialize_over(window, dt), Ok(Alignment::Static));
273
274 let dt = Seconds::from_secs(0.005);
275 let holding_still = still().imu;
276 let mut tilt_held = None;
277 // Past the 274 s the measured window buys, at 5 ms.
278 for step in 1..100_000 {
279 assert_eq!(filter.step(holding_still, dt), Propagation::Propagated);
280 let elapsed = step as f32 * dt.as_secs();
281 let validity = filter.validity();
282 if tilt_held.is_none() && !validity.tilt {
283 tilt_held = Some(elapsed);
284 }
285 if !validity.heading {
286 return (tilt_held.unwrap_or(f32::NAN), elapsed);
287 }
288 }
289 (tilt_held.unwrap_or(f32::NAN), f32::NAN)
290 }
291
292 /// Alignment does not fall back: once the attitude has met the bar, a covariance that
293 /// grows past it takes `Validity::tilt` away and leaves [`Status`] alone.
294 ///
295 /// Both halves matter, and the second is the one a latch could get wrong by reporting a
296 /// filter as started-up while its outputs are unusable. The live flag is what a
297 /// controller branches on, and it goes false here.
298 #[test]
299 fn a_covariance_growing_past_the_bar_ends_validity_but_not_alignment() {
300 // Aided *and* aligned: a GNSS velocity keeps the status out of `DeadReckoning`, and
301 // the magnetometer is what makes heading an estimate.
302 let window = window_at(100.0).map(|sample| StaticSample {
303 mag: Some(MagField::body(0.22, 0.0, 0.44)),
304 ..sample
305 });
306 let mut filter = Eskf::default();
307 assert_eq!(
308 filter.initialize_over(&window, Seconds::from_secs(0.25)),
309 Ok(Alignment::Static)
310 );
311 assert!(
312 filter
313 .fuse_baro_altitude(
314 filter.now(),
315 Altitude::from_meters(100.0),
316 AltitudeNoise::from_sigma(2.0)
317 )
318 .is_accepted()
319 );
320 assert!(filter.is_aligned());
321 assert!(filter.validity().tilt);
322
323 // Past the 4.84 s this unscattered window buys, with a GNSS fix arriving at 2 Hz so
324 // that aiding is never stale. A 100 m one, which holds the status out of `DeadReckoning`
325 // and tells tilt nothing: a velocity would, through the accelerometer bias (8)
326 // correlates it with.
327 for step in 1..=1_200 {
328 assert_eq!(
329 filter.step(still().imu, Seconds::from_secs(0.005),),
330 Propagation::Propagated
331 );
332 if step % 100 == 0 {
333 let fix = filter.fuse_gnss_position(
334 filter.now(),
335 Position::ned(0.0, 0.0, 0.0),
336 PositionNoise::from_sigma(100.0, 100.0, 100.0),
337 Position::zero(),
338 );
339 assert!(fix.horizontal.is_accepted(), "{fix:?}");
340 // And the barometer, whose silence would degrade the status by itself.
341 assert!(
342 filter
343 .fuse_baro_altitude(
344 filter.now(),
345 Altitude::from_meters(100.0),
346 AltitudeNoise::from_sigma(2.0),
347 )
348 .is_accepted()
349 );
350 }
351 }
352
353 assert!(
354 !filter.validity().tilt,
355 "tilt has grown past Accuracy::tilt"
356 );
357 assert!(
358 filter.is_aligned(),
359 "the start was resolved and stays resolved"
360 );
361 assert_eq!(filter.state().status, Status::Healthy);
362 }
363
364 #[test]
365 fn a_static_start_is_valid_in_every_part() {
366 let mut filter = Eskf::default();
367 let alignment = filter
368 .initialize_over(&window_with_mag(), Seconds::from_secs(0.25))
369 .expect("a 2 s window of stillness");
370 assert_eq!(alignment, Alignment::Static);
371
372 let validity = filter.state().validity;
373 assert!(validity.all(), "{validity:?}");
374 assert!(validity.attitude() && validity.navigation());
375 }
376
377 #[test]
378 fn a_still_short_window_calls_its_heading_valid_at_once() {
379 // #85. The same field, the same stillness, and the only thing wrong with the
380 // window is its length.
381 let mut filter = Eskf::default();
382 let alignment = filter
383 .initialize_over(&window_with_mag(), Seconds::from_secs(0.1))
384 .expect("short, so coarse");
385 assert!(matches!(
386 alignment,
387 Alignment::Coarse(Coarse::WindowTooShort { .. })
388 ));
389 assert!(
390 filter.validity().heading,
391 "a still window observed a heading whatever its length"
392 );
393 assert!(filter.is_aligned(), "and nothing is left to resolve");
394 }
395
396 #[test]
397 fn an_uninitialized_filter_claims_nothing() {
398 let filter = Eskf::default();
399 assert_eq!(filter.validity(), Validity::NONE);
400 assert_eq!(filter.predicted_validity(), Validity::NONE);
401 }
402
403 #[test]
404 fn a_coarse_start_has_attitude_invalid_and_position_unset() {
405 let filter = coarse();
406 let validity = filter.validity();
407 assert!(!validity.heading, "heading is somewhere on the circle");
408 assert!(
409 !validity.horizontal_position && !validity.horizontal_velocity,
410 "a tight prior on a number nobody set is not validity"
411 );
412 }
413
414 #[test]
415 fn adopting_a_fix_makes_position_valid_without_touching_attitude() {
416 let mut filter = coarse();
417 let before = filter.validity();
418 assert!(
419 filter
420 .fuse_gnss_position(
421 filter.now(),
422 Position::ned(120.0, -40.0, -75.0),
423 PositionNoise::horizontal_vertical(1.5, 1.5),
424 Position::zero(),
425 )
426 .is_reset()
427 );
428
429 let after = filter.validity();
430 assert!(
431 after.horizontal_position && after.vertical_position,
432 "position is now as good as the receiver"
433 );
434 assert_eq!(
435 (after.tilt, after.heading),
436 (before.tilt, before.heading),
437 "a position fix says nothing about attitude"
438 );
439 assert_eq!(
440 filter.state().status,
441 Status::Aligning,
442 "and the summary still says the worst of it"
443 );
444 }
445
446 #[test]
447 fn takeoff_prediction_counts_aiding_that_has_not_been_used_yet() {
448 let mut filter = coarse();
449 assert!(!filter.predicted_validity().horizontal_position);
450
451 // A fix arrives and is adopted, so position is valid outright...
452 let _ = filter.fuse_gnss_position(
453 filter.now(),
454 Position::ned(0.0, 0.0, 0.0),
455 PositionNoise::horizontal_vertical(1.5, 1.5),
456 Position::zero(),
457 );
458 // ...and the magnetometer is being accepted, so heading will come in even though
459 // it is worthless at this instant. A σ of 0.6 rad is what makes it worthless: the
460 // first heading is adopted and carries its own variance, so a measurement wider
461 // than `Accuracy::heading` (0.5236) establishes the quantity without making it
462 // good enough to fly on. That is the gap this method exists to report.
463 assert!(
464 filter
465 .fuse_mag_heading(
466 filter.now(),
467 MagField::body(0.22, 0.0, 0.44),
468 HeadingNoise::from_sigma(0.6),
469 )
470 .is_accepted()
471 );
472
473 let predicted = filter.predicted_validity();
474 assert!(!filter.validity().heading, "not yet");
475 assert!(predicted.heading, "but it is arriving");
476 assert!(
477 !predicted.tilt,
478 "tilt has no aiding path until in-motion leveling lands"
479 );
480 }
481
482 #[test]
483 fn a_vehicle_with_only_a_barometer_has_height_and_nothing_horizontal() {
484 let mut filter = Eskf::default();
485 // Moving, so that nothing but the barometer has established anything: a window
486 // taken at rest establishes its own position, short or not. A moving one
487 // establishes no barometric reference either, so the application names it.
488 let _ = filter
489 .initialize_over(&moving_window_at(100.0), Seconds::from_secs(0.25))
490 .expect("moving, not unusable");
491 assert!(filter.set_baro_reference(
492 Altitude::from_meters(100.0),
493 AltitudeNoise::from_sigma(0.001)
494 ));
495 assert!(
496 filter
497 .fuse_baro_altitude(
498 filter.now(),
499 Altitude::from_meters(100.0),
500 AltitudeNoise::from_sigma(2.0)
501 )
502 .is_accepted()
503 );
504
505 let predicted = filter.predicted_validity();
506 assert!(
507 predicted.vertical_position,
508 "the barometer constrains height"
509 );
510 assert!(
511 !predicted.horizontal_position,
512 "and says nothing about where it is"
513 );
514 }
515
516 #[test]
517 fn the_horizon_is_what_separates_predicted_validity_from_the_current_one() {
518 // The projection's whole point, on the quantity nothing aids. A static start levels
519 // tilt and, its gyroscope unscattered, holds it for 4.84 s unaided, so a
520 // horizon inside that arms and one outside it does not -- while `validity` says the
521 // same thing at both, because it is answering about now.
522 let ask = |seconds: f32| {
523 let config = Config {
524 accuracy: Accuracy {
525 horizon: Seconds::from_secs(seconds),
526 ..Accuracy::default()
527 },
528 ..Config::default()
529 };
530 let mut filter = Eskf::new(config).unwrap();
531 let _ = filter
532 .initialize_over(&window_with_mag(), Seconds::from_secs(0.25))
533 .expect("a 2 s window of stillness");
534 (filter.validity().tilt, filter.predicted_validity().tilt)
535 };
536
537 assert_eq!(ask(1.0), (true, true), "a second is inside the 4.84 s hold");
538 assert_eq!(ask(6.0), (true, false), "six seconds is outside it");
539 }
540
541 #[test]
542 fn a_horizon_of_zero_projects_nothing_and_leaves_the_aiding_clause_alone() {
543 // The documented boundary, and it is *not* "predicted_validity becomes validity":
544 // the aiding clause is unconditional, so a zero horizon leaves the optimistic half
545 // exactly where it was. Asserting the equality instead would pass on a start with no
546 // fresh source and say nothing, which is the shape of test this one replaces.
547 let zero_horizon = Config {
548 accuracy: Accuracy {
549 horizon: Seconds::from_secs(0.0),
550 ..Accuracy::default()
551 },
552 ..Config::default()
553 };
554
555 // With nothing being accepted, the two do agree: there is no aiding to widen by.
556 let mut quiet = Eskf::new(zero_horizon).unwrap();
557 let _ = quiet
558 .initialize_over(&window_with_mag(), Seconds::from_secs(0.25))
559 .expect("a 2 s window of stillness");
560 let (now, predicted) = (quiet.validity(), quiet.predicted_validity());
561 assert_eq!(now.tilt, predicted.tilt);
562 assert_eq!(now.heading, predicted.heading);
563
564 // With a source being accepted they do not, however short the horizon. A heading at
565 // σ 0.6 rad is wider than `Accuracy::heading` (0.5236), so it establishes yaw without
566 // making it good: invalid now, and predicted valid because the magnetometer is there.
567 let mut aided = Eskf::new(zero_horizon).unwrap();
568 let _ = aided
569 .initialize_over(&moving_window_at(100.0), Seconds::from_secs(0.25))
570 .expect("moving, not unusable");
571 assert!(
572 aided
573 .fuse_mag_heading(
574 aided.now(),
575 MagField::body(0.22, 0.0, 0.44),
576 HeadingNoise::from_sigma(0.6),
577 )
578 .is_accepted()
579 );
580 assert!(!aided.validity().heading, "0.6 rad is outside the bar");
581 assert!(
582 aided.predicted_validity().heading,
583 "a zero horizon must not take the aiding clause away"
584 );
585 }
586
587 #[test]
588 fn a_mission_bar_tighter_than_the_prior_leaves_alignment_alone() {
589 // `Accuracy` is the mission's question and `ALIGNED_*` the start's: a survey platform
590 // asking for 1° of tilt and 10° of heading gets an attitude that is never valid
591 // against a 20 mrad and 20° prior, and a filter that aligns anyway.
592 let mut filter = Eskf::new(Config {
593 accuracy: Accuracy {
594 tilt: Radians::from_degrees(1.0),
595 heading: Radians::from_degrees(10.0),
596 ..Accuracy::default()
597 },
598 ..Config::default()
599 })
600 .unwrap();
601 assert_eq!(
602 filter.initialize_over(&window_with_mag(), Seconds::from_secs(0.25)),
603 Ok(Alignment::Static)
604 );
605
606 assert!(filter.is_aligned(), "the start is resolved");
607 let validity = filter.validity();
608 assert!(
609 !validity.tilt && !validity.heading,
610 "and the mission bar is not met"
611 );
612 }
613
614 #[test]
615 fn a_confident_seed_is_aligned_and_a_vague_one_is_not() {
616 let (state, _) = seed();
617 let mut filter = Eskf::default();
618
619 let _ = filter
620 .seed(state, Covariance::from_sigmas([0.001; STATES]))
621 .expect("a sane seed");
622 assert!(filter.is_aligned());
623
624 let _ = filter
625 .seed(state, Covariance::from_sigmas([2.0; STATES]))
626 .expect("a sane seed");
627 assert!(!filter.is_aligned());
628 }
629
630 #[test]
631 fn validity_reads_tilt_and_heading_about_navigation_axes() {
632 // Tight in heading and in tilt about east, loose in tilt about north. On its tail,
633 // body x is heading and body z is tilt about north, so reading by body axis would
634 // call tilt valid and heading not — the opposite of the attitude it holds.
635 let attitude = attitude_of(0.0, core::f32::consts::FRAC_PI_2, 0.0);
636 let (tight, loose) = (1e-4, 0.25);
637 let block = AttitudeVariance {
638 tilt_north: loose,
639 tilt_east: tight,
640 heading: tight,
641 }
642 .in_body(&attitude);
643 let mut p = *Covariance::from_sigmas([0.1; STATES]).as_matrix();
644 let theta = ErrorState::AttitudeX.index();
645 p.fixed_view_mut::<3, 3>(theta, theta).copy_from(&block);
646
647 let mut filter = Eskf::default();
648 let _ = filter
649 .seed(
650 State {
651 attitude,
652 ..State::default()
653 },
654 Covariance::from_matrix(p),
655 )
656 .expect("a sane seed");
657
658 let validity = filter.validity();
659 assert!(validity.heading, "heading is known to 0.57°");
660 assert!(!validity.tilt, "tilt about north is 29° uncertain");
661 }
662
663 #[test]
664 fn on_its_tail_the_window_puts_the_yaw_prior_on_heading() {
665 // Equation (8)'s prior is about navigation axes, so on a vehicle standing on its
666 // tail it lands on body x, not z. Tilt is the wider of the configured figure and
667 // what the accelerometer-bias prior levels in.
668 let filter = on_its_tail();
669 let init = Config::default().init;
670 let variance = filter.attitude_variance();
671 let tilt = init
672 .sigma_tilt
673 .as_radians()
674 .max(init.sigma_accel_bias.as_m_per_s2() / GRAVITY);
675 let yaw = init.sigma_yaw.as_radians();
676 assert!((variance.heading - yaw * yaw).abs() < 1e-6, "{variance:?}");
677 assert!(
678 (variance.tilt_north - tilt * tilt).abs() < 1e-7,
679 "{variance:?}"
680 );
681 assert!(
682 (variance.tilt_east - tilt * tilt).abs() < 1e-7,
683 "{variance:?}"
684 );
685 }
686}