Skip to main content

fusion_nav/
eskf.rs

1//! The filter itself: [`Eskf`], the fields every method shares, and the invariants they keep.
2//!
3//! Every covariance the filter commits passes through [`Eskf::commit_covariance`], the floor of
4//! (42′), and every state through [`Estimate`]. The methods are split by topic into child
5//! modules, each an `impl Eskf` block, in the order a flight calls them: `start`, `site`,
6//! `predict`, `fuse`, `heading`, `adopt`, and `validity`, which with
7//! [`derive_status`](Eskf::derive_status) here answers what the estimate can claim.
8
9use crate::config::{Config, ConfigError};
10use crate::frames::Body;
11use crate::geodetic::LocalOrigin;
12use crate::gsf::YawEstimator;
13use crate::health::{Diagnostics, Status};
14use crate::math::{floor_diagonal, floor_offset};
15use crate::state::{Covariance, Offset, State};
16use crate::units::{Altitude, AngularRate, Radians, Timestamp};
17
18mod adopt;
19mod estimate;
20mod fuse;
21mod heading;
22mod hold;
23mod predict;
24mod site;
25mod start;
26mod validity;
27mod yaw;
28
29use estimate::Estimate;
30
31/// A 15-state error-state Kalman filter.
32///
33/// Every equation the filter needs is implemented: initialization, (5)–(8), propagation,
34/// (9)–(22), and the update of (23)–(41) for every source. The state dead reckons from
35/// where [`initialize`](Self::initialize) put it, the covariance grows around it, and
36/// [`fuse_gnss_position`](Self::fuse_gnss_position),
37/// [`fuse_gnss_geodetic`](Self::fuse_gnss_geodetic),
38/// [`fuse_gnss_velocity`](Self::fuse_gnss_velocity),
39/// [`fuse_baro_altitude`](Self::fuse_baro_altitude),
40/// [`fuse_mag_heading`](Self::fuse_mag_heading),
41/// [`fuse_gnss_heading`](Self::fuse_gnss_heading) and
42/// [`fuse_course`](Self::fuse_course) each correct both, or are turned down by the gate of
43/// (37)–(38).
44///
45/// # Example
46///
47/// A whole flight's worth of calls, in the order they happen, with what each one is allowed to
48/// answer. The README's quick start is the same loop without the assertions.
49///
50/// ```
51/// use fusion_nav::prelude::*;
52///
53/// let mut filter = Eskf::default();
54///
55/// // A vehicle sitting still: no rotation, gravity the only specific force, read by a
56/// // 400 Hz rate IMU whose clock counts microseconds.
57/// let still = |sample: u64| {
58///     ImuSample::from_rates(
59///         Timestamp::from_micros(2_500 * sample),
60///         AngularRate::body(0.0, 0.0, 0.0),
61///         Acceleration::body(0.0, 0.0, -GRAVITY),
62///         Seconds::from_secs(0.0025),
63///     )
64/// };
65///
66/// // Initialization reports what it achieved rather than refusing what it dislikes. A
67/// // window that is short or moving gives `Alignment::Coarse`, and the filter runs and
68/// // says `Status::Aligning` until attitude converges. 800 samples at 400 Hz is the 2 s
69/// // `Initialization::min_duration` wants, folded in one at a time rather than buffered.
70/// let mut window = StaticWindow::new();
71/// for sample in 1..=800 {
72///     window.push(StaticSample {
73///         imu: still(sample),
74///         ..StaticSample::default()
75///     })?;
76/// }
77/// assert_eq!(filter.initialize(&window)?, Alignment::Static);
78///
79/// // Nothing in that window carried a barometer, so it fixes no reference altitude, and
80/// // the first `fuse_baro_altitude` reads one from the estimate. See `StaticSample::baro`.
81///
82/// assert!(filter.predict(still(801)).is_propagated());
83///
84/// // Every measurement carries the time it was taken, on the IMU's clock: a receiver's fix
85/// // describes where the vehicle was when it was computed, not when it arrived. These were
86/// // all taken at the last sample.
87/// let now = Timestamp::from_micros(2_500 * 801);
88///
89/// // GNSS in latitude and longitude. The filter holds the navigation origin: the first
90/// // fix places it, under the estimate, so every later fix converts about the same point.
91/// // `clamped` bounds the receiver's own `eph` and `epv` the way both autopilots do.
92/// let outcome = filter.fuse_gnss_geodetic(
93///     now,
94///     Geodetic::from_degrees(47.397_742, 8.545_594, 488.0),
95///     PositionNoise::clamped(
96///         1.5,
97///         3.0,
98///         SigmaBounds::new(0.5, 100.0),
99///         SigmaBounds::new(0.75, 100.0),
100///     ), Position::zero(),
101/// );
102/// assert!(outcome.is_accepted());
103/// assert!(filter.origin().is_some());
104///
105/// // A solution whose vertical velocity the receiver did not measure: the down axis
106/// // carries a σ large enough that its gain is negligible, rather than a claim.
107/// let outcome = filter.fuse_gnss_velocity(
108///     now,
109///     Velocity::ned(0.0, 0.0, 0.0),
110///     VelocityNoise::horizontal_vertical(0.3, 1000.0), Position::zero(),
111/// );
112/// assert!(outcome.is_accepted());
113///
114/// // Nothing in that window carried a magnetometer either, so nothing observed the
115/// // rotation about gravity: heading is not valid, and `Aligning` says the attitude has
116/// // not converged rather than the aiding having failed.
117/// let state = filter.state();
118/// assert_eq!(state.status, Status::Aligning);
119/// assert!(state.validity.tilt && !state.validity.heading);
120///
121/// // The first accepted magnetic heading is what establishes yaw.
122/// let outcome = filter.fuse_mag_heading(
123///     now,
124///     MagField::body(0.22, 0.0, 0.44),
125///     HeadingNoise::from_sigma(0.1),
126/// );
127/// assert!(outcome.is_accepted());
128/// assert_eq!(filter.state().status, Status::Healthy);
129///
130/// // A vehicle without a magnetometer could have taken it from its course instead, once
131/// // moving: one sitting still goes nowhere, and the direction of no velocity is refused.
132/// let outcome = filter.fuse_course(now, HeadingNoise::from_sigma(0.05));
133/// assert_eq!(outcome, Fusion::Unobservable);
134///
135/// // Sitting still is a claim the application can make, and it holds the tilt.
136/// let outcome = filter.fuse_stationary(now, VelocityNoise::from_speed_accuracy(0.1));
137/// assert!(outcome.is_accepted());
138/// # Ok::<(), fusion_nav::InitError>(())
139/// ```
140#[derive(Clone, Debug)]
141pub struct Eskf {
142    config: Config,
143    estimate: Estimate,
144    covariance: Covariance,
145    diagnostics: Diagnostics,
146    baro_reference: Option<Altitude>,
147    /// The covariance of the error in `baro_reference`, (30′). Zero while there is no
148    /// reference; [`establish_reference`](Self::establish_reference) keeps the two together.
149    offset: Offset,
150    origin: Option<LocalOrigin>,
151    /// `D_m` of equations (6) and (35); see
152    /// [`set_magnetic_declination`](Self::set_magnetic_declination).
153    declination: Radians,
154    /// Whether the caller has set [`declination`](Self::declination), which the model the
155    /// filter reads at each origin then never overrides.
156    declination_set: bool,
157    /// Whether the heading held is referred to true north through the declination alone:
158    /// leveled from a window's magnetometer by (6) or adopted from `fuse_mag_heading`, with
159    /// no true heading (a seed, a GNSS heading, a course) accepted since. While it is, a
160    /// declination the filter learns turns the heading with it; see
161    /// [`place_origin`](Self::place_origin).
162    magnetic_north: bool,
163    /// `ω` of equation (9) from the last step integrated from a sample; see
164    /// [`angular_rate`](Self::angular_rate).
165    angular_rate: Option<AngularRate<Body>>,
166    /// The earliest time a measurement can be placed at: the start, or the epoch when the start
167    /// was shown at rest and its state also describes the time before it. See
168    /// [`admit`](Self::admit).
169    earliest: Timestamp,
170    unestablished: Unestablished,
171    /// Whether the attitude has ever met [`ALIGNED_TILT`](crate::config::ALIGNED_TILT) and
172    /// [`ALIGNED_HEADING`](crate::config::ALIGNED_HEADING) since initialization. Latched, and the
173    /// test behind [`Status::Aligning`]; see [`is_aligned`](Self::is_aligned) for why it is not
174    /// read live.
175    aligned: bool,
176    /// The position hold of (28″): its anchor, and which quantities still carry the covariance
177    /// it shaped; see [`hold_if_unaided`](Self::hold_if_unaided).
178    hold: hold::HoldState,
179    /// The yaw hypotheses of (45)–(52), stepped and weighed while
180    /// [`Config::yaw_estimator`] is on; see [`weigh_yaw`](Self::weigh_yaw).
181    yaw_estimator: YawEstimator,
182    /// What has yet to be judged against the yaw the estimator replaced; see
183    /// [`replace_failed_yaw`](Self::replace_failed_yaw).
184    yaw_replaced: yaw::YawReplaced,
185    initialized: bool,
186    /// The clock; see [`time`](Self::time). Meaningless until `initialized`.
187    time: Timestamp,
188}
189
190/// Quantities the start never established, which wait for the first measurement that
191/// observes them: position and velocity for the first GNSS fix after a coarse start (see
192/// [`Fusion::Reset`](crate::Fusion::Reset)), heading for the first heading any heading source
193/// offers.
194///
195/// The covariance cannot carry this on its own. Every entry on its diagonal is a prior,
196/// and a prior tight enough to pass [`Config::accuracy`](crate::Config::accuracy) or
197/// [`ALIGNED_HEADING`](crate::config::ALIGNED_HEADING) reads as an estimate whether or not anything
198/// ever measured the quantity. This is the flag that tells the two apart.
199#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
200struct Unestablished {
201    position: bool,
202    velocity: bool,
203    heading: bool,
204}
205
206impl Default for Eskf {
207    /// A filter on [`Config::default`], which validates (a test holds it to that).
208    fn default() -> Self {
209        Self::with(Config::default())
210    }
211}
212
213impl Eskf {
214    /// Create an uninitialized filter, or refuse a [`Config`] with a value outside its bound
215    /// ([`Config::validate`]). Measurements are refused with
216    /// [`Fusion::NotInitialized`](crate::Fusion::NotInitialized) until
217    /// [`initialize`](Self::initialize) succeeds.
218    pub fn new(config: Config) -> Result<Self, ConfigError> {
219        config.validate()?;
220        Ok(Self::with(config))
221    }
222
223    /// The filter [`new`](Self::new) returns, for a `config` already known to be valid.
224    fn with(config: Config) -> Self {
225        Self {
226            config,
227            estimate: Estimate::default(),
228            covariance: Covariance::zero(),
229            diagnostics: Diagnostics::default(),
230            baro_reference: None,
231            offset: Offset::default(),
232            origin: None,
233            declination: Radians::ZERO,
234            declination_set: false,
235            magnetic_north: false,
236            angular_rate: None,
237            earliest: Timestamp::ZERO,
238            unestablished: Unestablished::default(),
239            aligned: false,
240            hold: hold::HoldState::default(),
241            yaw_estimator: YawEstimator::default(),
242            yaw_replaced: yaw::YawReplaced::default(),
243            initialized: false,
244            time: Timestamp::ZERO,
245        }
246    }
247
248    /// The configuration this filter was built with.
249    pub const fn config(&self) -> &Config {
250        &self.config
251    }
252
253    /// Whether [`initialize`](Self::initialize) has succeeded.
254    pub const fn is_initialized(&self) -> bool {
255        self.initialized
256    }
257
258    /// The filter's clock: the [`time`](crate::ImuSample::time) of the last sample
259    /// [`predict`](Self::predict) was handed, or of the window or seed the filter started from.
260    /// `None` until initialized.
261    ///
262    /// Every later sample is differenced against it and every measurement's age read from it.
263    /// Only [`Propagation::InvalidStep`](crate::Propagation::InvalidStep), a sample not after it,
264    /// leaves it where it was; every other outcome moves it, integrated or not, since the time did
265    /// pass. So it is when the estimate is valid only while the samples are being integrated: after
266    /// a refused one the state is as old as the last that was.
267    pub const fn time(&self) -> Option<Timestamp> {
268        if self.initialized {
269            Some(self.time)
270        } else {
271            None
272        }
273    }
274
275    /// Store the offset's covariance, floored by (42′) as
276    /// [`commit_covariance`](Self::commit_covariance) floors the rest — while there is a
277    /// reference. Without one the offset is zero by definition and nothing reads it, so it is
278    /// stored as zero rather than as what (30′)'s walk would have grown it to.
279    fn commit_offset(&mut self, mut offset: Offset) {
280        if self.baro_reference.is_none() {
281            self.offset = Offset::default();
282            return;
283        }
284        if floor_offset(&mut offset) {
285            self.diagnostics.floored = self.diagnostics.floored.saturating_add(1);
286        }
287        self.offset = offset;
288    }
289
290    /// Store a covariance, applying the diagonal floor of equation (42′) and counting what it
291    /// raised.
292    ///
293    /// One place, so that the invariant is a property of the filter rather than of each
294    /// equation that produces a `P`: **every covariance the filter commits has been floored**,
295    /// and therefore so has every covariance it fuses against, tests [`Validity`](crate::Validity)
296    /// against, or reads back into the `S` of (24). Committed, not held: an `Eskf` that has not
297    /// initialized holds [`Covariance::zero`](crate::Covariance::zero) and
298    /// [`covariance`](Self::covariance) will hand it over, which is the one place a caller can read
299    /// a variance of zero off this filter. Nothing acts on it — every path that would is behind
300    /// `initialized` — and initializing is itself a commit.
301    ///
302    /// A floor applied inside (22) and (27) instead would be two call sites protecting the
303    /// two operations that shrink a variance, and would leave the ones that *write* one —
304    /// the adoption of
305    /// [`Fusion::Reset`](crate::Fusion::Reset), the `reset_*_to` methods, the initial
306    /// covariance of (8) — unprotected, each carrying a variance that came from outside the
307    /// filter.
308    ///
309    /// Symmetry stays where the algebra is, beside each product that can drift off it
310    /// (EQUATIONS.md's table lists them), and at `initialize_from`, where a caller's matrix
311    /// enters: (42)'s two halves answer different faults. `½(P + Pᵀ)` repairs drift a product
312    /// introduces, so it belongs to the product; the floor bounds a value, so it belongs to
313    /// the value.
314    fn commit_covariance(&mut self, covariance: Covariance, offset: Offset) {
315        let mut matrix = *covariance.as_matrix();
316        let raised = floor_diagonal(&mut matrix);
317        self.covariance = Covariance::from_matrix(matrix);
318        self.commit_offset(offset);
319        self.diagnostics.floored = self.diagnostics.floored.saturating_add(raised);
320    }
321
322    /// The current estimate, including its [`Status`].
323    ///
324    /// Status and validity are derived here rather than cached: they are pure functions
325    /// of [`diagnostics`](Self::diagnostics), the covariance, and [`Config`], so computing
326    /// them on read means there is no invariant for the mutating methods to maintain. The
327    /// cost is two rotations of the attitude block, one each for tilt and heading, six other
328    /// covariance entries and six source timers, compared.
329    pub fn state(&self) -> State {
330        // `self.estimate.state().status` and `.validity` are inert; the stored estimate never
331        // carries meaningful ones, and every read overwrites them.
332        let validity = self.validity();
333        let mut state = *self.estimate.state();
334        state.status = self.derive_status();
335        state.validity = validity;
336        state
337    }
338
339    /// The body angular rate with the estimated gyroscope bias removed, `ω` of equation (9),
340    /// from the last step [`predict`](Self::predict) integrated from a sample.
341    ///
342    /// For moving the estimate to a point other than the IMU. The GNSS `fuse_*` take their
343    /// antenna's offset and refer the fix to the IMU themselves, (28′) and (29′); the estimate
344    /// they correct stays the IMU's, and a point at `r` in body axes (the center of mass a
345    /// controller wants, a payload, a sensor this crate does not fuse) is at the IMU's position
346    /// plus `R r` and moves at its velocity plus `R (ω × r)`. PX4 reports its estimate at the
347    /// IMU the same way, and corrects each aiding source with this bias-corrected rate
348    /// (`EKF/aid_sources/gnss/gps_control.cpp:313-318` at `c4e4ef98`).
349    ///
350    /// The algebra is the application's own; `nalgebra` here, reached through arrays so that
351    /// its version is the application's too.
352    ///
353    /// ```
354    /// use fusion_nav::prelude::*;
355    /// use nalgebra::{Quaternion, UnitQuaternion, Vector3};
356    /// # let mut filter = Eskf::default();
357    /// # let dt = Seconds::from_secs(0.0025);
358    /// # let (level, gravity) = (AngularRate::zero(), Acceleration::body(0.0, 0.0, -GRAVITY));
359    /// # let at = |i: u64| Timestamp::from_micros(2_500 * i);
360    /// # let mut window = StaticWindow::new();
361    /// # for i in 1..=800 {
362    /// #     window.push(StaticSample {
363    /// #         imu: ImuSample::from_rates(at(i), level, gravity, dt),
364    /// #         ..StaticSample::default()
365    /// #     })?;
366    /// # }
367    /// # filter.initialize(&window)?;
368    /// # let imu = ImuSample::from_rates(at(801), AngularRate::body(0.0, 0.0, 0.5), gravity, dt);
369    /// # assert!(filter.predict(imu).is_propagated());
370    /// // The center of mass, 0.2 m behind the IMU: measured on the airframe.
371    /// let r = Vector3::from(Position::body(-0.2, 0.0, 0.0).to_array());
372    ///
373    /// // None before the first step and across a gap, where no sample says how fast the
374    /// // vehicle turned.
375    /// if let Some(omega) = filter.angular_rate() {
376    ///     let state = filter.state();
377    ///     let q = state.attitude.body_to_ned();
378    ///     let rotation = UnitQuaternion::new_normalize(Quaternion::new(q.w, q.x, q.y, q.z));
379    ///     let position = Vector3::from(state.position.to_array()) + rotation * r;
380    ///     let omega = Vector3::from(omega.to_array());
381    ///     let velocity = Vector3::from(state.velocity.to_array()) + rotation * omega.cross(&r);
382    ///     // Yawing at 0.5 rad/s, a point 0.2 m aft swings sideways at 0.1 m/s.
383    ///     assert!((velocity.norm() - 0.1).abs() < 1e-3);
384    /// #   let _ = position;
385    /// }
386    /// # Ok::<(), InitError>(())
387    /// ```
388    ///
389    /// Committed with the state, and only with it. `None` until a step is integrated, after
390    /// any initialization, and after a gap coasted past
391    /// [`Config::max_predict_dt`](crate::Config::max_predict_dt), until the next sample: a
392    /// coast reads no sample, and the rate before the gap is not the rate after it. A refused
393    /// step leaves the last rate in place, as it leaves the state.
394    pub const fn angular_rate(&self) -> Option<AngularRate<Body>> {
395        self.angular_rate
396    }
397
398    /// Per-source health. Off the hot path.
399    pub const fn diagnostics(&self) -> &Diagnostics {
400        &self.diagnostics
401    }
402
403    /// The 15 x 15 error covariance, in the error-state ordering.
404    pub const fn covariance(&self) -> &Covariance {
405        &self.covariance
406    }
407
408    /// Aggregate alignment and the per-source timers into one status, most severe first.
409    ///
410    /// [`DeadReckoning`](Status::DeadReckoning) reads horizontal aiding alone, GNSS position
411    /// or velocity within [`Timeouts::dead_reckoning_after`](crate::Timeouts): with GNSS gone
412    /// the barometer still holds height, and position drifts all the same. The rest reads
413    /// every source, each against its own period.
414    ///
415    /// Only sources that have ever been accepted count toward `Degraded`: a vehicle with no
416    /// magnetometer is not permanently `Degraded` for lacking one. The course constraint
417    /// never counts ([`Diagnostics::aiding`]): it aids nothing the GNSS velocity it needs does
418    /// not.
419    ///
420    /// Alignment enters as the latch of [`is_aligned`](Self::is_aligned) rather than as the
421    /// live [`Validity`](crate::Validity), which is the one thing here that is not derived on read.
422    fn derive_status(&self) -> Status {
423        let d = &self.diagnostics;
424        let horizontal = d
425            .horizontal()
426            .any(|source| source.accepted_within(self.config.timeouts.dead_reckoning_after));
427        if !horizontal {
428            // Position is unusable whatever the attitude is doing, so this outranks
429            // `Aligning`.
430            Status::DeadReckoning
431        } else if !self.aligned {
432            Status::Aligning
433        } else if d
434            .aiding()
435            .filter(|source| source.has_been_used())
436            .all(|source| source.is_fresh(&self.config.timeouts))
437        {
438            Status::Healthy
439        } else {
440            Status::Degraded
441        }
442    }
443}
444
445#[cfg(test)]
446mod adversarial;
447
448#[cfg(test)]
449mod fixtures;
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use crate::config::{Config, Recovery};
455    use crate::eskf::fixtures::*;
456
457    use crate::geodetic::Geodetic;
458    use crate::health::{Fusion, GnssFusion, Propagation, Refusal, Status};
459    use crate::init::tests::{spaced, still};
460    use crate::init::{Alignment, InitError, StaticSample, StaticWindow};
461
462    use crate::propagate::ImuSample;
463    use crate::state::{Covariance, Offset, State};
464    use crate::units::{
465        Acceleration, Altitude, AltitudeNoise, AngularRate, HeadingNoise, MagField, Position,
466        PositionNoise, Radians, Seconds, Timestamp, Velocity, VelocityNoise,
467    };
468
469    #[test]
470    fn a_config_outside_its_bounds_builds_no_filter() {
471        // A NaN timeout never compares true: built, this filter would never recover GNSS height.
472        let config = Config {
473            recovery: Recovery {
474                gnss_height: Some(Seconds::from_secs(f32::NAN)),
475                ..Recovery::default()
476            },
477            ..Config::default()
478        };
479        let refused = Eskf::new(config).err();
480        assert_eq!(
481            refused.map(|e| (e.field, e.bound)),
482            Some(("recovery.gnss_height", crate::ConfigBound::Positive))
483        );
484        assert!(refused.is_some_and(|e| e.value.is_nan()));
485        assert_eq!(Eskf::default().config(), &Config::default());
486    }
487
488    // Found by the adversarial suite, `adversarial.rs`, and kept as literals so each
489    // names the defect it guards.
490
491    #[test]
492    fn a_filter_with_no_barometric_reference_holds_no_offset() {
493        let mut filter = initialized();
494        assert_eq!(filter.baro_reference(), None);
495        for _ in 0..10 {
496            assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
497        }
498        assert_eq!(filter.offset, Offset::default());
499    }
500
501    #[test]
502    fn a_start_that_overflows_is_refused_and_commits_nothing() {
503        // Finite, so `push` takes it; (5) and (8) square it past `f32`.
504        let mut filter = Eskf::default();
505        let imu = ImuSample::from_rates(
506            Timestamp::from_micros(1_000_000),
507            AngularRate::body(0.0, 0.0, 0.0),
508            Acceleration::body(0.0, 0.0, f32::MAX),
509            DT,
510        );
511        assert_eq!(filter.initialize_coarse(imu), Err(InitError::NotFinite));
512        assert!(!filter.is_initialized());
513    }
514
515    #[test]
516    fn a_still_window_whose_barometric_reference_overflows_is_refused_whole() {
517        // One reading `push` takes as finite; the mean and scatter of (30) square it past `f32`.
518        let mut altitudes = [100.0, 100.2, 99.9, 100.1, 100.0, 99.8, 100.3, 100.0];
519        altitudes[3] = f32::MAX;
520        let window = StaticWindow::try_from(
521            spaced(&window_with_baro(altitudes), Seconds::from_secs(0.25)).as_slice(),
522        )
523        .expect("every sample is finite");
524        let mut filter = Eskf::default();
525        assert_eq!(filter.alignment_of(&window), Err(InitError::NotFinite));
526        assert_eq!(filter.initialize(&window), Err(InitError::NotFinite));
527        assert!(!filter.is_initialized());
528        assert_eq!(filter.baro_reference(), None);
529    }
530
531    #[cfg(feature = "magnetic-model")]
532    #[test]
533    fn a_geodetic_fix_with_no_origin_under_it_turns_no_heading() {
534        // A magnetometer-set heading is turned by the declination the first origin reads, so
535        // a fix whose origin cannot be placed must not have turned it on the way to refusal.
536        let mut filter = Eskf::default();
537        let _ = filter
538            .initialize_over(&window_with_mag(), Seconds::from_secs(0.25))
539            .expect("a still window");
540        // Three meters out, so (44) must place an origin that puts a fix 3.4e38 m up three
541        // meters away, which no `f64` resolves.
542        let noise = PositionNoise::horizontal_vertical(1.0, 1.0);
543        assert!(filter.reset_position_to(Position::ned(3.0, 0.0, 0.0), noise));
544        let (attitude, declination) = (filter.state().attitude, filter.magnetic_declination());
545        let outcome = filter.fuse_gnss_geodetic(
546            filter.now(),
547            Geodetic::from_degrees(56.0, 44.0, 3.4e38),
548            PositionNoise::horizontal_vertical(1.0, 1.0),
549            Position::zero(),
550        );
551        assert_eq!(outcome.horizontal, Fusion::NoReference);
552        assert_eq!(filter.state().attitude, attitude);
553        assert_eq!(filter.magnetic_declination(), declination);
554        assert_eq!(filter.origin(), None);
555    }
556
557    #[test]
558    fn an_adoption_that_would_overflow_is_refused_and_places_nothing() {
559        // After a coarse start the first fix is adopted, carried to the IMU by (28′). A finite
560        // arm of `f32::MAX` on every axis overflows once rotated, and a tilted start rotates it.
561        let arm = Position::body(f32::MAX, f32::MAX, f32::MAX);
562        let noise = PositionNoise::horizontal_vertical(1.0, 1.0);
563        let mut filter = Eskf::default();
564        let tilted = ImuSample::from_rates(
565            Timestamp::from_micros(1_000_000),
566            AngularRate::body(0.0, 0.0, 0.0),
567            Acceleration::body(3.0, -2.0, -9.0),
568            DT,
569        );
570        assert!(filter.initialize_coarse(tilted).is_ok());
571        let (state, declination) = (*filter.estimate.state(), filter.magnetic_declination());
572
573        let outcome = filter.fuse_gnss_position(filter.now(), Position::zero(), noise, arm);
574        assert_eq!(outcome, GnssFusion::both(Fusion::NotFinite));
575        let fix = Geodetic::from_degrees(56.0, 44.0, 100.0);
576        let outcome = filter.fuse_gnss_geodetic(filter.now(), fix, noise, arm);
577        assert_eq!(outcome, GnssFusion::both(Fusion::NotFinite));
578
579        assert_eq!(*filter.estimate.state(), state);
580        assert_eq!(filter.origin(), None);
581        assert_eq!(filter.magnetic_declination(), declination);
582        assert!(filter.unestablished.position);
583    }
584
585    #[test]
586    fn a_seed_that_is_not_symmetric_is_committed_symmetric() {
587        let mut p = crate::state::CovarianceMatrix::from_diagonal_element(0.1);
588        p[(0, 1)] = 0.01;
589        p[(1, 0)] = 0.03;
590        let mut filter = Eskf::default();
591        assert!(
592            filter
593                .seed(State::default(), Covariance::from_matrix(p))
594                .is_ok()
595        );
596        let committed = filter.covariance().as_matrix();
597        assert_eq!(*committed, committed.transpose());
598        assert_eq!(committed[(0, 1)], 0.02);
599    }
600
601    #[test]
602    fn a_heading_adoption_whose_variance_overflows_is_refused() {
603        // A coarse start charges its tilt prior the peak rate it saw; (36′) squares that tilt
604        // into the variance a first magnetic heading is adopted at.
605        let mut filter = Eskf::default();
606        let spinning = ImuSample::from_rates(
607            Timestamp::from_micros(1_000_000),
608            AngularRate::body(0.0, 0.0, 1.0e20),
609            Acceleration::body(0.3, 0.0, -9.3),
610            DT,
611        );
612        assert!(filter.initialize_coarse(spinning).is_ok());
613        let (state, covariance) = (*filter.estimate.state(), *filter.covariance());
614        let field = MagField::body(0.22, 0.0, 0.44);
615        let outcome = filter.fuse_mag_heading(filter.now(), field, HeadingNoise::from_sigma(0.02));
616        assert_eq!(outcome, Fusion::NotFinite);
617        assert_eq!(
618            (*filter.estimate.state(), *filter.covariance()),
619            (state, covariance)
620        );
621        assert!(filter.unestablished.heading);
622    }
623
624    #[test]
625    fn an_origin_the_position_cannot_be_written_about_is_refused() {
626        let mut filter = initialized();
627        assert!(filter.set_origin(Geodetic::from_degrees(47.4, 8.5, 488.0)));
628        let held = (filter.origin(), filter.state().position);
629        // A height `f64` holds and `f32` does not.
630        assert!(!filter.set_origin(Geodetic::from_degrees(47.4, 8.5, -1.0e39)));
631        assert_eq!((filter.origin(), filter.state().position), held);
632    }
633
634    /// The floor of (42′) exists to be unreachable by an honest source. CI asserts it here, on
635    /// one run whose every fix is exact, and in `adversarial.rs`'s `ordinary`, on generated
636    /// honest ones; `data/fetch.sh --check` pins `floored=0` on all thirteen corpus logs, and
637    /// needs a network and PX4 tooling, so it runs locally. The margin between the floor and
638    /// anything a filter that is propagating and fusing reaches is measured in `math.rs`'s
639    /// `FLOOR` — a count here means the floor is masking a collapse rather than preventing
640    /// one, and the `sigma_*` columns of `examples/replay/main.rs` say which state.
641    #[test]
642    fn an_ordinary_run_never_reaches_the_floor() {
643        let mut filter = aided();
644        for step in 0..400 {
645            assert_eq!(filter.step(still().imu, DT), Propagation::Propagated);
646            if step % 25 == 0 {
647                assert!(
648                    filter
649                        .fuse_gnss_position(
650                            filter.now(),
651                            Position::ned(0.0, 0.0, 0.0),
652                            PositionNoise::horizontal_vertical(1.5, 1.5),
653                            Position::zero(),
654                        )
655                        .is_accepted()
656                );
657                assert!(
658                    filter
659                        .fuse_baro_altitude(
660                            filter.now(),
661                            Altitude::from_meters(100.0),
662                            AltitudeNoise::from_sigma(2.0),
663                        )
664                        .is_accepted()
665                );
666            }
667        }
668        assert_eq!(filter.diagnostics().floored, 0);
669    }
670
671    /// Started still with a barometer at 0 m and a magnetometer, so that `Status` can reach
672    /// `Healthy`: without a heading the start stays `Aligning`.
673    fn aligned() -> Eskf {
674        let window = window_at(0.0).map(|sample| StaticSample {
675            mag: Some(MagField::body(0.22, 0.0, 0.44)),
676            ..sample
677        });
678        let mut filter = Eskf::default();
679        assert_eq!(
680            filter.initialize_over(&window, Seconds::from_secs(0.25)),
681            Ok(Alignment::Static)
682        );
683        assert!(filter.is_aligned());
684        filter
685    }
686
687    #[test]
688    fn a_fast_source_that_stops_degrades_the_status_on_its_own_schedule() {
689        // A 10 Hz barometer beside a 1 Hz GNSS: the barometer times out after 0.25 s of
690        // silence, where one threshold sized for the receiver would take 2.5 s to notice.
691        let mut filter = aligned();
692        let baro = |filter: &mut Eskf| {
693            let outcome = filter.fuse_baro_altitude(
694                filter.now(),
695                Altitude::from_meters(0.0),
696                AltitudeNoise::from_sigma(2.0),
697            );
698            assert!(outcome.is_accepted(), "{outcome:?}");
699        };
700        let gnss = |filter: &mut Eskf| {
701            hold_velocity(filter, Velocity::ned(0.0, 0.0, 0.0));
702        };
703        hold(&mut filter, 10.0, 10, |filter| {
704            baro(filter);
705            if filter.diagnostics().baro_altitude.accepted % 10 == 0 {
706                gnss(filter);
707            }
708        });
709        assert_eq!(filter.state().status, Status::Healthy);
710
711        hold(&mut filter, 0.4, 100, |_| {});
712        assert_eq!(filter.state().status, Status::Degraded);
713    }
714
715    #[test]
716    fn a_status_reads_dead_reckoning_once_gnss_stops_whatever_else_arrives() {
717        let mut filter = aligned();
718        hold(&mut filter, 2.0, 10, |filter| {
719            hold_velocity(filter, Velocity::ned(0.0, 0.0, 0.0));
720            let _ = filter.fuse_baro_altitude(
721                filter.now(),
722                Altitude::from_meters(0.0),
723                AltitudeNoise::from_sigma(2.0),
724            );
725        });
726        assert_eq!(filter.state().status, Status::Healthy);
727
728        // The barometer keeps arriving, and is still accepted, past `dead_reckoning_after`.
729        hold(&mut filter, 5.5, 10, |filter| {
730            let _ = filter.fuse_baro_altitude(
731                filter.now(),
732                Altitude::from_meters(0.0),
733                AltitudeNoise::from_sigma(2.0),
734            );
735        });
736        assert!(
737            filter
738                .diagnostics()
739                .baro_altitude
740                .is_fresh(&crate::Timeouts::default())
741        );
742        assert_eq!(filter.state().status, Status::DeadReckoning);
743    }
744
745    #[test]
746    fn a_course_refused_at_a_stop_does_not_degrade_the_status() {
747        // Two filters fed the same velocities and GNSS headings, one also fusing the course: when
748        // the vehicle stops and every course is refused, `Status` must read the same for both,
749        // and with every counted source fresh that is `Healthy`.
750        let east = Velocity::ned(0.0, 15.0, 0.0);
751        let stopped = Velocity::ned(0.0, 0.0, 0.0);
752        let heading = Radians::from_radians(core::f32::consts::FRAC_PI_2);
753        let noise = HeadingNoise::from_sigma(0.05);
754        let mut with = cruising(east);
755        let mut without = cruising(east);
756        for filter in [&mut with, &mut without] {
757            assert!(
758                filter
759                    .fuse_gnss_heading(filter.now(), heading, noise)
760                    .is_reset()
761            );
762        }
763        assert!(with.fuse_course(with.now(), noise).is_accepted());
764        for filter in [&mut with, &mut without] {
765            assert!(filter.reset_velocity_to(stopped, VelocityNoise::from_speed_accuracy(0.3)));
766            hold(filter, 4.0, 10, |filter| {
767                hold_velocity(filter, stopped);
768                let _ = filter.fuse_gnss_heading(filter.now(), heading, noise);
769                let _ = filter.fuse_course(filter.now(), noise);
770            });
771        }
772        assert_eq!(
773            with.diagnostics().course.last_refusal,
774            Some(Refusal::Unobservable)
775        );
776        assert_eq!(without.state().status, Status::Healthy);
777        assert_eq!(with.state().status, without.state().status);
778    }
779}