Skip to main content

fusion_nav/eskf/
start.rs

1//! Starting the filter, from a still window, one sample or a caller's seed. Equations (5)–(8),
2//! the reference `α₀` of (30) a window at rest measures, and (42) on a seed.
3//!
4//! Entry points: [`Eskf::initialize`], [`Eskf::initialize_coarse`], [`Eskf::initialize_from`]
5//! and [`Eskf::alignment_of`]. A start is worked out whole ([`Startup`]), checked, then
6//! committed, so a refused one commits nothing.
7
8use crate::health::Diagnostics;
9use crate::init::{
10    self, Alignment, Coarse, GyroBias, InitError, Measured, StaticSample, StaticWindow,
11};
12use crate::math::{below_floor, enforce_symmetry};
13use crate::propagate::ImuSample;
14use crate::state::{Covariance, Offset, State};
15use crate::units::{Altitude, Timestamp};
16
17use super::hold::HoldState;
18use super::yaw::YawReplaced;
19use super::{Eskf, Unestablished};
20
21impl Eskf {
22    /// Align from a window of samples taken while the vehicle was, ideally, still.
23    /// Equations (5)–(8), and `α₀` of (30).
24    ///
25    /// The window does not have to be a genuine static interval. If it is — long enough
26    /// and within the tolerances [`Initialization`](crate::Initialization) sets — the
27    /// result is [`Alignment::Static`] and the filter starts with the covariance that
28    /// configuration describes. If it is not, the result is [`Alignment::Coarse`],
29    /// carrying what was measured: the filter still runs, with attitude uncertainty
30    /// inflated to match, and reports [`Status::Aligning`](crate::Status::Aligning) until that
31    /// uncertainty comes down. Refusing instead would be a launch restriction, and a filter that
32    /// will not start is worth less than one that starts and says how much to trust it.
33    ///
34    /// Where no window exists at all, use [`initialize_coarse`](Self::initialize_coarse)
35    /// or [`initialize_from`](Self::initialize_from).
36    ///
37    /// `α₀`, the barometric reference of equation (30), is taken from the window whenever
38    /// the window was taken **at rest**, which is not the same test as the alignment: a
39    /// window too short to align an attitude from is still a window of a vehicle sitting on
40    /// the ground, and the altitude it read is what zero will mean. A window that was
41    /// moving keeps the reference the flight already has, as
42    /// [`initialize_coarse`](Self::initialize_coarse) does — a restart at 100 m must not
43    /// call its own altitude the ground.
44    ///
45    /// Position and velocity start at the configured priors either way, which assume the
46    /// origin is here and the vehicle is at rest. A launch that knows better — off a
47    /// moving deck, say — should say so through [`initialize_from`](Self::initialize_from)
48    /// rather than let the first GNSS fix arrive as a large innovation.
49    ///
50    /// Whether those priors count as *established* is the window's answer, not the
51    /// alignment's: a vehicle that held still through it was where the origin says and
52    /// was not moving, whether or not the window was long enough to align an attitude
53    /// from. A window taken in motion establishes neither, and the first fix is adopted
54    /// rather than fused — see [`Fusion::Reset`](crate::Fusion::Reset).
55    ///
56    /// Heading is the one thing stillness cannot supply. Gravity pins roll and pitch;
57    /// nothing pins the rotation about it, so a window carrying no magnetometer leaves
58    /// yaw unobserved however long and however still it was, and
59    /// [`validity`](Self::validity) withholds `heading` until the first heading from
60    /// [`fuse_mag_heading`](Self::fuse_mag_heading),
61    /// [`fuse_gnss_heading`](Self::fuse_gnss_heading) or [`fuse_course`](Self::fuse_course)
62    /// is accepted. The covariance alone
63    /// cannot say that: [`sigma_yaw`](crate::Initialization::sigma_yaw) is a prior on a
64    /// number nobody measured.
65    ///
66    /// The window's span, which [`min_duration`](crate::Initialization::min_duration) is
67    /// checked against, is the time its samples integrated; the filter's clock starts at the
68    /// last sample's [`time`](ImuSample::time). As everywhere else, the filter never reads a
69    /// clock.
70    ///
71    /// # Errors
72    ///
73    /// [`InitError::NoSamples`] for an empty window. A sample nothing can be made of is
74    /// refused as it is [pushed](StaticWindow::push), so a window holds none.
75    pub fn initialize(&mut self, window: &StaticWindow) -> Result<Alignment, InitError> {
76        let startup = self.startup(window)?;
77        let alignment = startup.alignment;
78        self.commit_startup(startup);
79        Ok(alignment)
80    }
81
82    /// The start `window` gives, worked out and checked but not committed: what
83    /// [`initialize`](Self::initialize) commits and [`alignment_of`](Self::alignment_of)
84    /// reports, so the two cannot disagree.
85    fn startup(&self, window: &StaticWindow) -> Result<Startup, InitError> {
86        let measured = window.measured()?;
87        let alignment = init::classify(&measured, &self.config.init, self.config.gravity);
88        // One answer to "was the vehicle on the ground", read by the gyroscope bias of
89        // (7), the barometric reference of (30), and what this start establishes.
90        // Measured from the window rather than read off `alignment`, because a window too
91        // short to align an attitude from can still be a window of a parked vehicle.
92        let at_rest = init::at_rest(measured.peaks, &self.config.init, self.config.gravity);
93        let gyro_bias = measured.gyro_bias(at_rest, &self.config);
94        let reference = at_rest.then(|| {
95            window
96                .alpha0()
97                .map(|(reference, variance)| (reference, Offset::independent(variance)))
98        });
99        self.checked_startup(
100            alignment,
101            &measured,
102            gyro_bias,
103            at_rest,
104            measured.end,
105            reference,
106        )
107    }
108
109    /// What [`initialize`](Self::initialize) would make of this window, without touching
110    /// the filter.
111    ///
112    /// For the application that would rather wait for stillness than start coarsely:
113    /// restart the window, or slide a buffered one forward, until this reports
114    /// [`Alignment::Static`], then commit; [`StaticWindow`] says which suits which.
115    /// The filter cannot do that waiting itself — it does not know whether the vehicle is
116    /// about to launch or has been sitting on the bench for an hour.
117    ///
118    /// # Errors
119    ///
120    /// As [`initialize`](Self::initialize), whose work it does up to the commit.
121    pub fn alignment_of(&self, window: &StaticWindow) -> Result<Alignment, InitError> {
122        self.startup(window).map(|startup| startup.alignment)
123    }
124
125    /// Start from a single IMU sample, with no window at all.
126    ///
127    /// For the launch that never offers one: a hand launch, a deck that is always moving,
128    /// a restart at altitude. Tilt comes from one accelerometer reading, which carries the
129    /// sensor's full noise and whatever the vehicle's own acceleration was at that
130    /// instant, so the covariance is inflated accordingly and the filter reports
131    /// [`Status::Aligning`](crate::Status::Aligning).
132    ///
133    /// Prefer [`initialize_from`](Self::initialize_from) where the application has an
134    /// attitude from somewhere — a companion AHRS, the last flight — since a real estimate
135    /// beats one sample of gravity.
136    ///
137    /// The sample carries no magnetometer, so heading starts at zero and stays
138    /// unestablished whatever the vehicle was doing: this entry point levels, and
139    /// nothing more.
140    ///
141    /// # Errors
142    ///
143    /// [`InitError::NotFinite`] if the sample carries a value that is not a number, and
144    /// [`InitError::InvalidInterval`] for an integration interval under a microsecond.
145    pub fn initialize_coarse(&mut self, imu: ImuSample) -> Result<Alignment, InitError> {
146        // Treated as a window of one, so the same finiteness, motion and averaging measures apply —
147        // an average of one sample being that sample. The window is most of this frame, which stays
148        // well under `update`'s.
149        let mut window = StaticWindow::new();
150        window.push(StaticSample {
151            imu,
152            ..StaticSample::default()
153        })?;
154        // A window of one: its own average, with no rotation to smear it and no second
155        // velocity to difference against — a caller with GNSS in hand has a window, not
156        // this entry point.
157        let measured = window.measured()?;
158        let alignment = Alignment::Coarse(Coarse::not_stationary(&measured, self.config.gravity));
159        // The same rule `initialize` applies: the gyroscope bias is weighed only where the
160        // sample says the vehicle was on the ground, and one reading is weighed by its own
161        // noise, which at a sample's interval leaves it mostly to the prior.
162        let at_rest = init::at_rest(measured.peaks, &self.config.init, self.config.gravity);
163        let gyro_bias = measured.gyro_bias(at_rest, &self.config);
164        // That reading establishes nothing, which is why it is not passed on as one. A
165        // window shows rest by holding still over a span of time and this one spans none:
166        // an accelerometer reading `γ` for an instant is a hover as readily as a vehicle
167        // on the ground, and this entry point exists for the launches that are moving.
168        // The barometric reference is left alone: one sample does not establish one, and
169        // a restart at altitude should keep the reference the flight began with.
170        let startup =
171            self.checked_startup(alignment, &measured, gyro_bias, false, imu.time, None)?;
172        self.commit_startup(startup);
173        Ok(alignment)
174    }
175
176    /// Initialize from an estimate the application already holds, rather than from a
177    /// static window.
178    ///
179    /// [`initialize`](Self::initialize) is the better path wherever stillness is
180    /// available: it averages sensor noise away, and gyroscope bias is observable at
181    /// rest. This one exists for the launches that never offer stillness — a moving deck,
182    /// a hand launch, a restart at altitude — and for a warm start from the last flight,
183    /// where a saved gyroscope bias is worth far more to a moving vehicle than zero is.
184    ///
185    /// The caller owns the seed's quality and `covariance` is how it says so: an attitude
186    /// from a companion AHRS deserves the uncertainty that AHRS reports, not the
187    /// static-window figures in [`Initialization`](crate::Initialization). Seeding a
188    /// coarse attitude with a confident covariance is the one way to misuse this, and it
189    /// produces a filter that gates out the measurements that would have corrected it.
190    ///
191    /// `state.attitude` names its own convention, through whichever
192    /// [`Attitude`](crate::Attitude) constructor built it — PX4 and ArduPilot quaternions
193    /// through [`from_body_to_ned`](crate::Attitude::from_body_to_ned), a ROS one through
194    /// [`from_flu_to_enu`](crate::Attitude::from_flu_to_enu), which is where the conventions and
195    /// what a wrong one costs are written down. It is the one error on this path no check
196    /// downstream can reach.
197    ///
198    /// `state.status` is ignored: status is derived from aiding, never asserted.
199    /// [`baro_reference`](Self::baro_reference) is left alone, so re-initializing in
200    /// flight keeps the reference the flight began with; a filter that never had one takes
201    /// it from the first altitude, read against the seeded position (see
202    /// [`fuse_baro_altitude`](Self::fuse_baro_altitude)). [`origin`](Self::origin) is left
203    /// alone for the same reason, and
204    /// `state.position` is taken as relative to it — or, with no origin yet, to the one
205    /// the first [`fuse_gnss_geodetic`](Self::fuse_gnss_geodetic) will place around it.
206    ///
207    /// # Errors
208    ///
209    /// [`InitError::NotFinite`] if `state` or `covariance` carries a value that is not a
210    /// number, and [`InitError::InvalidVariance`] for a variance on the diagonal that is
211    /// not strictly positive — the bar every `fuse_*` puts on `R`, applied here because a
212    /// seed is the one path that writes a covariance in whole. A rejected seed leaves the
213    /// filter uninitialized rather than poisoned.
214    ///
215    /// The bar is the floor of (42′) rather than zero, because zero is the tidy member of the
216    /// class it is there to catch. A diagonal that was never populated arrives as exactly
217    /// zero from a `memset`; the same accident with an exponent left in it — dropped in
218    /// deserialization, or a variance scaled by its own units twice — arrives at 1e-30 and
219    /// does the identical damage, since `1e-30 / (1e-30 + R)` is zero in f32 and
220    /// [`validity`](Self::validity) calls the quantity good on the first read. Refusing one
221    /// and accepting the other would catch the legible failure and wave through the messy
222    /// ones. The floor is three to five decades below anything the filter itself reaches, so
223    /// no seed anybody meant is near it.
224    ///
225    /// Whether the seed counts as aligned is the covariance's answer, not this one's: a
226    /// confident seed reports [`Status::Healthy`](crate::Status::Healthy) straight away, a coarse
227    /// one [`Status::Aligning`](crate::Status::Aligning) until it converges.
228    ///
229    /// `time` is when the seed is valid, and starts the clock the next
230    /// [`predict`](Self::predict) is differenced against.
231    pub fn initialize_from(
232        &mut self,
233        state: State,
234        covariance: Covariance,
235        time: Timestamp,
236    ) -> Result<Alignment, InitError> {
237        if !state.is_finite() || !covariance.is_finite() {
238            return Err(InitError::NotFinite);
239        }
240        if below_floor(covariance.as_matrix()) {
241            return Err(InitError::InvalidVariance);
242        }
243        // (42): a seed built by another filter's arithmetic differs from its transpose in the
244        // last bit, and nothing after a start repairs it before the first gate reads it.
245        let mut seed = *covariance.as_matrix();
246        enforce_symmetry(&mut seed);
247        // Nothing a seed carries is unestablished: the caller vouched for every quantity,
248        // heading included, so no first measurement overwrites one.
249        self.start(
250            state,
251            Covariance::from_matrix(seed),
252            Unestablished::default(),
253            time,
254            false,
255        );
256        self.note_alignment();
257        Ok(Alignment::Seeded)
258    }
259
260    /// Work out a start, the nominal state of (7) with `gyro_bias` and the covariance of (8),
261    /// and check the whole of it before anything is committed. `reference` is the barometric
262    /// reference the start sets: `None` leaves the one held alone, `Some(None)` clears it.
263    ///
264    /// Refused as [`InitError::NotFinite`] when the state, the covariance or the reference is
265    /// not finite. Every sample was finite, but their averages can still overflow what (5),
266    /// (8) and (30) square: one accelerometer reading of `f32::MAX` levels to a NaN tilt
267    /// variance, and one barometer reading of it gives an infinite `P_bb`.
268    fn checked_startup(
269        &self,
270        alignment: Alignment,
271        measured: &Measured,
272        gyro_bias: GyroBias,
273        settled: bool,
274        time: Timestamp,
275        reference: Option<Option<(Altitude, Offset)>>,
276    ) -> Result<Startup, InitError> {
277        let state = init::nominal_state(measured, self.declination, gyro_bias.estimate);
278        // The bias of (7) as committed, so that what it absorbed is not charged a second
279        // time as motion the window could not vouch for; see `init::coarse_sigmas`.
280        let (sigma_tilt, sigma_yaw) = init::attitude_sigmas(
281            &self.config.init,
282            alignment,
283            measured,
284            state.gyro_bias,
285            self.config.gravity,
286        );
287        let covariance = init::initial_covariance(
288            &self.config.init,
289            &state.attitude,
290            sigma_tilt,
291            sigma_yaw,
292            measured.level_scatter,
293            gyro_bias.sigmas,
294            self.config.gravity,
295        );
296        let reference_finite = reference.flatten().is_none_or(|(altitude, offset)| {
297            altitude.as_meters().is_finite() && offset.variance.is_finite()
298        });
299        if !(state.is_finite() && covariance.is_finite() && reference_finite) {
300            return Err(InitError::NotFinite);
301        }
302        Ok(Startup {
303            alignment,
304            state,
305            covariance,
306            unestablished: Unestablished::after(settled, measured.field.is_some()),
307            time,
308            settled,
309            magnetic_north: measured.field.is_some(),
310            reference,
311        })
312    }
313
314    /// Commit a start: the nominal state equation (7) built, the covariance of (8), fresh
315    /// health, and the barometric reference the start sets, if any.
316    ///
317    /// A start the window showed at rest clears the origin, on the same evidence that
318    /// establishes its position: both are the claim *zero is here*, and an origin held
319    /// from before says zero is somewhere else. The next geodetic fix places a new one.
320    /// A start taken in motion keeps it, because its position is unestablished and the
321    /// first fix is adopted about the origin the flight already has. The two move
322    /// together or a still short window would report an established position of `(0,0,0)`
323    /// about an origin nothing put under it.
324    fn commit_startup(&mut self, startup: Startup) {
325        self.start(
326            startup.state,
327            startup.covariance,
328            startup.unestablished,
329            startup.time,
330            startup.settled,
331        );
332        // (6) leveled the heading from the window's field with the declination it held.
333        self.magnetic_north = startup.magnetic_north;
334        if startup.settled {
335            self.origin = None;
336        }
337        if let Some(reference) = startup.reference {
338            self.establish_reference(reference);
339        }
340        self.note_alignment();
341    }
342
343    /// Begin a new life at `time`: the state and covariance a start committed, fresh health,
344    /// and no past. What both entry points share; the barometric reference and the origin are
345    /// each start's own decision.
346    ///
347    /// `at_rest` is whether the start showed the vehicle still, which is what lets a
348    /// measurement taken before `time` be placed at all: see [`admit`](Self::admit).
349    fn start(
350        &mut self,
351        state: State,
352        covariance: Covariance,
353        unestablished: Unestablished,
354        time: Timestamp,
355        at_rest: bool,
356    ) {
357        self.estimate.restart(time, state);
358        // Diagnostics first: `commit_covariance` counts into them, and a start sitting on the
359        // floor is a fact about this filter's life rather than the last one's.
360        self.diagnostics = Diagnostics::default();
361        self.commit_covariance(covariance, self.surviving_offset());
362        self.unestablished = unestablished;
363        self.magnetic_north = false;
364        self.angular_rate = None;
365        self.initialized = true;
366        self.time = time;
367        self.earliest = if at_rest { Timestamp::ZERO } else { time };
368        // A fresh start is unaligned until its own covariance says otherwise, which
369        // `note_alignment` reads at the end of each entry point.
370        self.aligned = false;
371        // A hold belongs to the life it held; the next outage anchors afresh.
372        self.hold = HoldState::default();
373        // Settled from the start, at rest or not, as PX4's first bank is: DESIGN.md,
374        // "`YawEstimator`", has what settling after a start in motion cost `7ce66f0d`.
375        self.yaw_estimator.clear();
376        self.yaw_replaced = YawReplaced::default();
377    }
378}
379
380/// A start from a window or a sample, worked out and checked but not yet committed; see
381/// [`Eskf::checked_startup`].
382struct Startup {
383    alignment: Alignment,
384    state: State,
385    covariance: Covariance,
386    unestablished: Unestablished,
387    time: Timestamp,
388    /// Whether the start showed the vehicle at rest.
389    settled: bool,
390    /// Whether (6) leveled the heading from a magnetometer, so it is referred to north
391    /// through the declination alone.
392    magnetic_north: bool,
393    /// The barometric reference the start sets: `None` keeps the one held, `Some(None)`
394    /// clears it.
395    reference: Option<Option<(Altitude, Offset)>>,
396}
397
398impl Unestablished {
399    /// What a start leaves unestablished, read off what its window showed rather than off
400    /// which [`Alignment`] it earned.
401    ///
402    /// `settled` is [`init::at_rest`]'s verdict. A vehicle that held still through the
403    /// window is where the origin says it is and is not moving, which is the whole of
404    /// what a static start ever claimed about position and velocity — and a window too
405    /// short to align an attitude from claims it just as honestly, which is
406    /// [`Coarse::WindowTooShort`]. A window taken in motion establishes neither: the vehicle
407    /// passed through somewhere the filter cannot name. Those wait for the first fix.
408    ///
409    /// Heading needs a magnetometer in the window on top of stillness. Gravity pins tilt and nothing
410    /// pins the rotation about it, so a window carrying no field leaves yaw a prior
411    /// however long and however still it was —
412    /// [`Initialization::sigma_yaw`](crate::Initialization::sigma_yaw) is 0.35 rad
413    /// against an [`Accuracy::heading`](crate::Accuracy::heading) of 0.52, so the
414    /// covariance alone would report a yaw nobody measured as good.
415    const fn after(settled: bool, observed_field: bool) -> Self {
416        Self {
417            position: !settled,
418            velocity: !settled,
419            heading: !(settled && observed_field),
420        }
421    }
422}
423
424#[cfg(test)]
425mod tests {
426
427    use crate::config::{Config, GRAVITY};
428    use crate::eskf::Eskf;
429    use crate::eskf::fixtures::*;
430
431    use crate::health::Status;
432    use crate::init::tests::{gravity_at, still, turning};
433    use crate::init::{Alignment, Coarse, InitError, StaticSample, StaticWindow};
434
435    use crate::state::{Covariance, ErrorState, State};
436    use crate::units::{
437        Acceleration, Altitude, AltitudeNoise, AngularRate, Attitude, Position, PositionNoise,
438        Quaternion, Seconds, Timestamp, Velocity, VelocityNoise,
439    };
440
441    /// That `initialize_coarse` weighs its one reading, `Measured::gyro_bias`, rather than
442    /// taking it whole; the weighing itself is tested in `init.rs`.
443    #[test]
444    fn one_still_sample_starts_the_gyroscope_bias_no_less_certain_than_the_prior() {
445        let mut filter = Eskf::default();
446        let sample = still()
447            .imu
448            .with_gyro(AngularRate::body(0.05, 0.05, 0.05))
449            .timed(Timestamp::from_micros(5_000), Seconds::from_secs(0.005));
450        assert!(filter.initialize_coarse(sample).is_ok());
451        let prior = Config::default().init.sigma_gyro_bias.as_rad_per_s();
452        let sigma = filter.covariance().variance(ErrorState::GyroBiasX).sqrt();
453        assert!(sigma <= prior && sigma > 0.99 * prior, "{sigma}");
454        assert!(filter.state().gyro_bias.vector().x.abs() < 1e-3);
455    }
456
457    #[test]
458    fn a_window_is_still_against_the_gravity_it_is_configured_with() {
459        // Short of the default `γ` by just over `max_accel_deviation`, 1.961, and short of
460        // 9.79 by just under it: moving at the default, at rest at the site, and only a start
461        // at rest takes the window's barometric reference.
462        let force = GRAVITY - 1.966;
463        let window = window_at(100.0).map(|sample| StaticSample {
464            imu: sample.imu.with_accel(Acceleration::body(0.0, 0.0, -force)),
465            ..sample
466        });
467        let start = |config| {
468            let mut filter = Eskf::new(config).unwrap();
469            let alignment = filter
470                .initialize_over(&window, Seconds::from_secs(0.25))
471                .expect("a usable window");
472            (alignment, filter.baro_reference().is_some())
473        };
474        assert_eq!(start(at_site()), (Alignment::Static, true));
475        assert!(matches!(
476            start(Config::default()),
477            (Alignment::Coarse(Coarse::NotStationary { .. }), false)
478        ));
479    }
480
481    #[test]
482    fn the_tilt_a_biased_accelerometer_levels_in_is_the_bias_over_gravity() {
483        // (8): `P_θβa = −(σ_βa² / γ)[d̂]×`, so the correlation scales as 1/γ.
484        let cross = |config| {
485            let mut filter = Eskf::new(config).unwrap();
486            let _ = filter
487                .initialize_over(&[still(); 8], Seconds::from_secs(0.25))
488                .expect("a still window");
489            let m = *filter.covariance().as_matrix();
490            m.fixed_view::<3, 3>(
491                ErrorState::AttitudeX.index(),
492                ErrorState::AccelBiasX.index(),
493            )
494            .abs()
495            .max()
496        };
497        let ratio = cross(at_site()) / cross(Config::default());
498        assert!((ratio - GRAVITY / SITE_GRAVITY).abs() < 1e-5, "{ratio}");
499    }
500
501    #[test]
502    fn a_seed_becomes_the_state_and_the_covariance() {
503        let mut filter = Eskf::default();
504        let (state, covariance) = seed();
505        let _ = filter.seed(state, covariance).expect("a sane seed");
506        assert!(filter.is_initialized());
507        assert_eq!(filter.state().velocity, state.velocity);
508        assert_eq!(filter.state().gyro_bias, state.gyro_bias);
509        assert_eq!(filter.covariance(), &covariance);
510    }
511
512    #[test]
513    fn a_seed_that_is_not_finite_is_refused_and_nothing_is_initialized() {
514        let (state, covariance) = seed();
515        let poisoned = State {
516            position: Position::ned(f32::NAN, 0.0, 0.0),
517            ..state
518        };
519        let mut filter = Eskf::default();
520        assert_eq!(filter.seed(poisoned, covariance), Err(InitError::NotFinite));
521        assert!(!filter.is_initialized(), "a refused seed leaves no state");
522
523        // The same check on the covariance, which is where a stale warm start off
524        // storage tends to arrive broken.
525        let mut matrix = *covariance.as_matrix();
526        matrix[(
527            ErrorState::VelocityNorth.index(),
528            ErrorState::VelocityNorth.index(),
529        )] = -1.0;
530        assert_eq!(
531            filter.seed(state, Covariance::from_matrix(matrix)),
532            Err(InitError::InvalidVariance)
533        );
534        matrix[(
535            ErrorState::VelocityNorth.index(),
536            ErrorState::VelocityNorth.index(),
537        )] = f32::NAN;
538        assert_eq!(
539            filter.seed(state, Covariance::from_matrix(matrix)),
540            Err(InitError::NotFinite)
541        );
542        assert!(!filter.is_initialized());
543    }
544
545    #[test]
546    fn a_seed_quaternion_is_normalized_and_a_zero_one_is_refused() {
547        let (state, covariance) = seed();
548        // A zero quaternion is no rotation.
549        let zero = State {
550            attitude: Attitude::from_body_to_ned(Quaternion {
551                w: 0.0,
552                x: 0.0,
553                y: 0.0,
554                z: 0.0,
555            }),
556            ..state
557        };
558        let mut filter = Eskf::default();
559        assert_eq!(filter.seed(zero, covariance), Err(InitError::NotFinite));
560        assert!(!filter.is_initialized());
561
562        // One `f32::MAX` component overflows the norm, which `nalgebra` alone normalizes to a
563        // finite zero the seed would accept. It is the half turn about z.
564        let huge = State {
565            attitude: Attitude::from_body_to_ned(Quaternion {
566                w: 0.0,
567                x: 0.0,
568                y: 0.0,
569                z: f32::MAX,
570            }),
571            ..state
572        };
573        assert!(filter.seed(huge, covariance).is_ok());
574        assert_eq!(
575            filter.state().attitude.body_to_ned(),
576            Quaternion {
577                w: 0.0,
578                x: 0.0,
579                y: 0.0,
580                z: 1.0
581            }
582        );
583    }
584
585    #[test]
586    fn a_seed_certain_of_a_quantity_it_cannot_be_certain_of_is_refused() {
587        // The failure this catches is a warm start off storage whose diagonal was never
588        // populated, so it arrives all zeros rather than obviously broken. A zero
589        // variance is not a tight prior: the gain is zero for that quantity, so nothing
590        // ever corrects it, and `validity`'s `variance <= sigma^2` calls it good on the
591        // first read.
592        let (state, _) = seed();
593        let mut filter = Eskf::default();
594        assert_eq!(
595            filter.seed(state, Covariance::zero()),
596            Err(InitError::InvalidVariance)
597        );
598        assert!(!filter.is_initialized(), "a refused seed leaves no state");
599        assert!(!filter.validity().horizontal_position);
600
601        // One zero entry is enough, and it is enough alone: the rest of the diagonal is
602        // a covariance a caller could have meant.
603        let (_, covariance) = seed();
604        let mut matrix = *covariance.as_matrix();
605        matrix[(ErrorState::GyroBiasZ.index(), ErrorState::GyroBiasZ.index())] = 0.0;
606        assert_eq!(
607            filter.seed(state, Covariance::from_matrix(matrix)),
608            Err(InitError::InvalidVariance)
609        );
610    }
611
612    #[test]
613    fn a_seed_below_the_floor_of_42_is_refused_like_a_zero() {
614        // 1e-30 (rad/s)² is a gyroscope bias the seed claims to know to 1e-15 rad/s. It
615        // clears a `> 0` test and does exactly what a zero does — the gain is zero in f32
616        // and `validity` calls the quantity good on the first read — so the bar is the
617        // floor, not zero, and the two are one check rather than two 24 decades apart.
618        let (state, covariance) = seed();
619        let mut matrix = *covariance.as_matrix();
620        matrix[(ErrorState::GyroBiasZ.index(), ErrorState::GyroBiasZ.index())] = 1e-30;
621
622        let mut filter = Eskf::default();
623        assert_eq!(
624            filter.seed(state, Covariance::from_matrix(matrix)),
625            Err(InitError::InvalidVariance)
626        );
627        assert!(!filter.is_initialized(), "a refused seed leaves no state");
628
629        // Just above its own floor is accepted, and untouched: the bar is per state group,
630        // so a gyroscope bias at 1e-8 passes a floor of 1e-9 while a position at 1e-8 would
631        // not clear its own 1e-6.
632        let mut matrix = *covariance.as_matrix();
633        matrix[(ErrorState::GyroBiasZ.index(), ErrorState::GyroBiasZ.index())] = 1e-8;
634        let _ = filter
635            .seed(state, Covariance::from_matrix(matrix))
636            .expect("above the floor");
637        assert_eq!(filter.covariance().variance(ErrorState::GyroBiasZ), 1e-8);
638        assert_eq!(filter.diagnostics().floored, 0);
639
640        let mut matrix = *covariance.as_matrix();
641        matrix[(
642            ErrorState::PositionNorth.index(),
643            ErrorState::PositionNorth.index(),
644        )] = 1e-8;
645        assert_eq!(
646            filter.seed(state, Covariance::from_matrix(matrix)),
647            Err(InitError::InvalidVariance)
648        );
649    }
650
651    #[test]
652    fn with_no_window_at_all_one_sample_still_starts_the_filter() {
653        let mut filter = Eskf::default();
654        let alignment = filter
655            .initialize_coarse(still().imu)
656            .expect("a finite sample");
657        assert!(matches!(alignment, Alignment::Coarse(..)));
658        assert!(filter.is_initialized());
659        assert!(!filter.is_aligned(), "one sample cannot settle heading");
660    }
661
662    #[test]
663    fn a_coarse_start_reports_aligning_once_horizontal_aiding_arrives() {
664        let mut filter = Eskf::default();
665        let _ = filter
666            .initialize_over(&window_at(100.0), Seconds::from_secs(0.1))
667            .expect("short, not unusable");
668        assert_eq!(
669            filter.state().status,
670            Status::DeadReckoning,
671            "nothing is holding position, which outranks Aligning"
672        );
673
674        assert!(
675            filter
676                .fuse_baro_altitude(
677                    filter.now(),
678                    Altitude::from_meters(100.0),
679                    AltitudeNoise::from_sigma(2.0)
680                )
681                .is_accepted()
682        );
683        assert_eq!(
684            filter.state().status,
685            Status::DeadReckoning,
686            "a barometer holds height, and position drifts all the same"
687        );
688
689        let velocity = filter.fuse_gnss_velocity(
690            filter.now(),
691            Velocity::ned(0.0, 0.0, 0.0),
692            VelocityNoise::from_speed_accuracy(0.3),
693            Position::zero(),
694        );
695        assert!(velocity.is_accepted(), "{velocity:?}");
696        assert_eq!(filter.state().status, Status::Aligning);
697    }
698
699    #[test]
700    fn a_coarse_window_still_starts_the_filter_with_its_tilt_widened() {
701        // Classification itself is tested in `init`; this is what the filter does with it.
702        let mut filter = Eskf::default();
703        let alignment = filter
704            .initialize_over(&[still(); 8], Seconds::from_secs(0.1))
705            .expect("a short window is a coarse start, not a refusal");
706        assert!(matches!(alignment, Alignment::Coarse(..)));
707        assert!(
708            filter.is_initialized(),
709            "refusing to run is the old behavior"
710        );
711        assert!(!filter.is_aligned());
712
713        let _ = filter
714            .initialize_over(&turning(0.0, 0.4, 0.0), Seconds::from_secs(0.25))
715            .expect("moving, not unusable");
716        // On navigation axes: the window is tilted, so body x carries some of the yaw prior.
717        let tilt = filter.attitude_variance().tilt_north;
718        assert!(
719            (tilt - 0.8 * 0.8).abs() < 1e-4,
720            "the covariance carries the widened tilt, got sigma^2 {tilt}"
721        );
722    }
723
724    #[test]
725    fn a_static_window_commits_the_attitude_it_leveled() {
726        // Equations (5)–(7) are `init`'s to test; this is that the filter starts at the
727        // attitude they computed rather than level.
728        let mut filter = Eskf::default();
729        assert_eq!(
730            filter
731                .initialize_over(&window_tilted(0.25, -0.1), Seconds::from_secs(0.25))
732                .expect("a parked vehicle reads exactly g, however it is standing"),
733            Alignment::Static
734        );
735
736        let (roll, pitch, yaw) = filter.state().attitude.euler_angles();
737        assert!(
738            (roll - 0.25).abs() < 1e-5 && (pitch + 0.1).abs() < 1e-5,
739            "leveled to ({roll}, {pitch})"
740        );
741        assert_eq!(yaw, 0.0, "no magnetometer observed the rotation about it");
742    }
743
744    #[test]
745    fn a_still_window_commits_its_gyroscope_bias_and_a_moving_one_does_not() {
746        // About the offset by ±10⁻⁴ rad/s, so the window measures how well it knows it.
747        let offset = AngularRate::body(0.01, -0.02, 0.003);
748        let mut window = [still(); 8];
749        for (index, sample) in window.iter_mut().enumerate() {
750            let noise = if index % 2 == 0 { 1e-4 } else { -1e-4 };
751            sample.imu = sample
752                .imu
753                .with_gyro(AngularRate::from_vector(offset.vector().add_scalar(noise)));
754        }
755
756        let mut filter = Eskf::default();
757        assert_eq!(
758            filter
759                .initialize_over(&window, Seconds::from_secs(0.25))
760                .expect("0.022 rad/s is well inside the tolerance"),
761            Alignment::Static
762        );
763        assert!(
764            (filter.state().gyro_bias.vector() - offset.vector()).norm() < 1e-5,
765            "{:?}",
766            filter.state().gyro_bias
767        );
768
769        // Over the stationarity tolerance, so the same average is the vehicle turning
770        // rather than the sensor lying, and taking it would subtract a turn rate from
771        // every later measurement as though it were a sensor error.
772        window[3].imu = window[3].imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
773        let _ = filter
774            .initialize_over(&window, Seconds::from_secs(0.25))
775            .expect("moving, not unusable");
776        assert_eq!(filter.state().gyro_bias, AngularRate::zero());
777    }
778
779    #[test]
780    fn one_sample_is_leveled_like_a_window_of_one() {
781        let mut filter = Eskf::default();
782        let _ = filter
783            .initialize_coarse(still().imu.with_accel(gravity_at(0.0, 0.35, 0.0)))
784            .expect("a finite sample");
785
786        let (roll, pitch, _) = filter.state().attitude.euler_angles();
787        assert!(
788            roll.abs() < 1e-6 && (pitch - 0.35).abs() < 1e-5,
789            "leveled to ({roll}, {pitch})"
790        );
791    }
792
793    /// An empty window is the one `initialize` can refuse: a sample nothing can be made of
794    /// never reaches the filter, because the window refuses it as it is pushed.
795    #[test]
796    fn an_empty_window_leaves_the_filter_uninitialized() {
797        let mut filter = Eskf::default();
798        let empty = StaticWindow::new();
799        assert_eq!(filter.alignment_of(&empty), Err(InitError::NoSamples));
800        assert_eq!(filter.initialize(&empty), Err(InitError::NoSamples));
801        assert!(!filter.is_initialized());
802    }
803
804    #[test]
805    fn a_still_short_window_establishes_where_it_sat() {
806        // The other half of #85, on the same evidence: a vehicle that held still was
807        // where the origin says and was not moving, so there is an estimate for the first
808        // fix to be gated against rather than adopted over.
809        let mut filter = Eskf::default();
810        let _ = filter
811            .initialize_over(&window_with_mag(), Seconds::from_secs(0.1))
812            .expect("short, so coarse");
813        assert!(filter.validity().horizontal_position);
814        assert!(filter.validity().horizontal_velocity);
815        let outcome = filter.fuse_gnss_position(
816            filter.now(),
817            Position::ned(0.2, -0.1, 0.0),
818            PositionNoise::horizontal_vertical(1.5, 1.5),
819            Position::zero(),
820        );
821        assert!(
822            !outcome.is_reset(),
823            "adoption is for a start that established nothing"
824        );
825        assert!(outcome.is_accepted());
826    }
827}