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}