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}