Skip to main content

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}