fusion_nav/init.rs
1//! Initialization: the attitude and biases the window yields, classifying the window, the
2//! initial covariance, and the barometric reference. Equations (5)–(8), and `α₀` of (30).
3//!
4//! The entry points are methods on [`Eskf`](crate::Eskf) — `initialize`,
5//! `initialize_coarse`, `initialize_from`, and `alignment_of` — which call into the pure
6//! functions here and then commit the result to the filter.
7
8use nalgebra::{ComplexField, Matrix3, RealField, Rotation3, UnitQuaternion, Vector3};
9
10use crate::config::{Config, Initialization};
11use crate::display::{Decimals, Fixed};
12use crate::frames::{Body, Ned};
13use crate::geodetic::EARTH_RATE;
14use crate::math::{skew, wrap_pi};
15use crate::propagate::ImuSample;
16use crate::state::{AttitudeVariance, Covariance, State};
17use crate::units::{
18 Acceleration, Altitude, AltitudeNoise, AngularRate, Attitude, MagField, MetersPerSecond2,
19 Position, Radians, RadiansPerSecond, Seconds, Timestamp, Velocity,
20};
21
22/// One sample from the quasi-static initialization window.
23///
24/// The magnetometer is optional: without it nothing observes the rotation about gravity,
25/// `ψ₀` of equation (6) stays zero, and the first accepted heading, magnetic, GNSS or
26/// course, is what establishes it. A window with none anywhere in it says so:
27/// [`Validity::heading`](crate::Validity) stays false until a heading source accepts one,
28/// because
29/// [`sigma_yaw`](crate::Initialization::sigma_yaw) is a prior and would otherwise read as
30/// an estimate of a quantity nothing measured.
31///
32/// The barometer is optional in the same way, but less forgivingly: its reference is
33/// established by a start and only refined after one (30′), so a window carrying none leaves
34/// nothing for a later altitude to be relative to and barometric fusion is refused for the
35/// whole flight.
36///
37/// GNSS velocity is the one field a window taken **in motion** can use, and the only
38/// reason this type is not simply an IMU sample plus what stillness needs: see
39/// [`velocity`](Self::velocity).
40#[derive(Clone, Copy, Debug, Default, PartialEq)]
41pub struct StaticSample {
42 /// IMU measurement.
43 pub imu: ImuSample,
44 /// Magnetometer measurement, if the vehicle has one.
45 ///
46 /// Averaged over the window to fix `ψ₀`, the initial heading (equation (6)), and
47 /// averaged rather than taken from one sample for the reason the barometer and the
48 /// gyroscope bias are: a single reading carries the sensor's full noise.
49 pub mag: Option<MagField<Body>>,
50 /// Barometric altitude, if the vehicle has a barometer.
51 ///
52 /// Averaged over the window to fix `α₀`, the barometer's reference at the navigation
53 /// origin (equation (30)). Averaged rather than taken from one sample for the same
54 /// reason the gyroscope bias is: a single reading carries the sensor's full noise.
55 ///
56 /// Without it the window fixes no reference, and
57 /// [`Eskf::fuse_baro_altitude`](crate::Eskf::fuse_baro_altitude) reads one from the
58 /// estimate at the first altitude instead. `α₀` is refined in flight by (30′), and the
59 /// scatter of these readings is the variance it starts with, so a window needs two of
60 /// them.
61 pub baro: Option<Altitude>,
62 /// GNSS velocity in the navigation frame, if the vehicle has a receiver.
63 ///
64 /// Differenced across the window for `ā_n`, the vehicle's own acceleration
65 /// (equation (5′)). At rest that term is zero and equation (5) reads tilt straight
66 /// off the accelerometer; in motion the accelerometer reads specific force, and
67 /// `ā_n` is what separates the two. It is the whole of what a moving window has that
68 /// a still one does not need.
69 ///
70 /// Attach it to the epoch it arrived on rather than holding it across the IMU epochs
71 /// that follow, as `mag` and `baro` may be held: those are averaged, and an average
72 /// survives a repeated value, while a difference is dated by the samples carrying it
73 /// and a held reading dates from before the epoch it sits on. Holding costs up to one
74 /// GNSS interval of span, in either direction.
75 ///
76 /// **Stub.** Measured and reported on [`Coarse::NotStationary`](Coarse::NotStationary); nothing
77 /// levels with it. Not built: equation (5′), #59.
78 pub velocity: Option<Velocity<Ned>>,
79}
80
81impl StaticSample {
82 /// Whether every number in the sample is finite, the optional ones included.
83 pub(crate) fn is_finite(&self) -> bool {
84 self.imu.is_finite()
85 && self.mag.is_none_or(|field| field.is_finite())
86 && self.baro.is_none_or(|b| b.as_meters().is_finite())
87 && self.velocity.is_none_or(|v| v.is_finite())
88 }
89}
90
91/// The initialization window, accumulated one sample at a time.
92///
93/// [`Eskf::initialize`](crate::Eskf::initialize) needs only what the window reduces to: sums,
94/// peaks, the span, the barometer's scatter and the first and last GNSS velocity. So the samples
95/// are folded in as they arrive rather than buffered. A buffered window at the default
96/// [`Initialization::min_duration`] is more RAM than a Cortex-M0 has; this one is the same size at
97/// any rate and any length ([measured]).
98///
99/// ```
100/// # use fusion_nav::prelude::*;
101/// # let dt = Seconds::from_secs(0.0025);
102/// # let gravity = Acceleration::body(0.0, 0.0, -GRAVITY);
103/// # let sample = |i: u64| StaticSample {
104/// # imu: ImuSample::from_rates(Timestamp::from_micros(2_500 * i), AngularRate::zero(), gravity, dt),
105/// # ..StaticSample::default()
106/// # };
107/// let mut filter = Eskf::default();
108/// let config = *filter.config();
109/// let mut window = StaticWindow::new();
110/// let mut i = 0;
111/// while !window.is_long_enough(&config) {
112/// i += 1;
113/// // A refused sample leaves the window as it was: drop it and go on.
114/// if window.push(sample(i)).is_err() {
115/// continue;
116/// }
117/// // Waiting for stillness: a vehicle that moved starts the wait over.
118/// if !window.is_at_rest(&config) {
119/// window = StaticWindow::new();
120/// }
121/// }
122/// assert_eq!(filter.initialize(&window)?, Alignment::Static);
123/// # Ok::<(), InitError>(())
124/// ```
125///
126/// Waiting for stillness restarts the window, as above; [`is_at_rest`](Self::is_at_rest) says
127/// why it cannot slide. A buffered slice can, at the cost of the buffer, and
128/// [`try_extend`](Self::try_extend) or [`TryFrom`] builds a window from one.
129///
130/// Each [`push`](Self::push) costs a few dozen floating-point operations, some in `f64`
131/// ([counted]). On a core with no floating-point unit every one is a library call, inside the loop
132/// that is already reading the IMU: the price of not buffering, paid only until the window commits.
133/// `level_scatter` and `BaroReadings` say why their sums need `f64`. For the averages it is
134/// precaution: summing a few thousand readings near `γ` in `f32` costs on the order of 10⁻⁵ rad of
135/// tilt, against a 0.02 rad prior.
136///
137/// [measured]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#measured-cost-by-function
138/// [counted]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#arithmetic
139#[derive(Clone, Debug)]
140pub struct StaticWindow {
141 /// `Σ f fᵀ`, for the window's scatter about `f̄`; see [`level_scatter`].
142 outer: Matrix3<f64>,
143 peaks: Peaks,
144 /// The last sample's time: the start of the filter's clock, and what the next sample's
145 /// step is differenced against.
146 end: Option<Timestamp>,
147 /// `Σ f` and `Σ m`, the sums (5) and (6) average, kept in blocks for the halves.
148 sums: BlockSums,
149 velocities: Velocities,
150 baro: BaroReadings,
151 /// The gyroscope's and the accelerometer's white noise; see [`Density`].
152 gyro_noise: Density,
153 accel_noise: Density,
154}
155
156impl Default for StaticWindow {
157 fn default() -> Self {
158 Self::new()
159 }
160}
161
162impl StaticWindow {
163 /// An empty window.
164 #[must_use]
165 pub fn new() -> Self {
166 Self {
167 outer: Matrix3::zeros(),
168 peaks: Peaks::NONE,
169 end: None,
170 sums: BlockSums::new(),
171 velocities: Velocities::default(),
172 baro: BaroReadings::default(),
173 gyro_noise: Density::EMPTY,
174 accel_noise: Density::EMPTY,
175 }
176 }
177
178 /// Add one sample to the window.
179 ///
180 /// Every check initialization makes on raw input is here, so that nothing downstream
181 /// can be handed an unusable interval, a clock that does not run forward, or a value
182 /// that is not a number. A refused sample leaves the window as it was, so the caller
183 /// can drop it and go on, or start over. The clock is checked because it starts at the
184 /// last sample's time: a window timed anywhere but forward would start it somewhere the
185 /// samples do not describe, and the first `predict` would coast the whole flight so far.
186 ///
187 /// # Errors
188 ///
189 /// [`SampleRefusal`], naming which check the sample failed.
190 pub fn push(&mut self, sample: StaticSample) -> Result<(), SampleRefusal> {
191 if !sample.is_finite() {
192 return Err(SampleRefusal::NotFinite);
193 }
194 if let Some(interval) = sample.imu.unusable_interval() {
195 return Err(SampleRefusal::InvalidInterval { interval });
196 }
197 if let Some(end) = self.end {
198 let dt = sample.imu.time.since(end);
199 if !dt.is_usable_step() {
200 return Err(SampleRefusal::InvalidStep { dt });
201 }
202 }
203
204 // Rates, one division per sample: every statistic here is a statement about a
205 // rate or a force.
206 let (accel, gyro) = (
207 sample.imu.specific_force().vector(),
208 sample.imu.angular_rate().vector(),
209 );
210 self.peaks.push(accel, gyro);
211 let interval = f64::from(sample.imu.angle_interval.as_secs());
212 let velocity_interval = f64::from(sample.imu.velocity_interval.as_secs());
213 let accel = widen(accel);
214 self.outer += accel * accel.transpose();
215 self.gyro_noise
216 .push(widen(sample.imu.delta_angle.vector()), interval);
217 self.accel_noise
218 .push(widen(sample.imu.delta_velocity.vector()), velocity_interval);
219 self.end = Some(sample.imu.time);
220 self.sums.push(
221 accel,
222 sample.mag.map(|measurement| widen(measurement.vector())),
223 );
224 self.velocities.push(sample.velocity, interval);
225 self.baro.push(sample.baro);
226 Ok(())
227 }
228
229 /// Push every sample `samples` yields, in order, stopping at the first one refused.
230 ///
231 /// The samples before it stay in the window, and the refused one and those after it are
232 /// not taken. A caller building one start from a buffer discards the window on an error,
233 /// as [`TryFrom`] does: a sample the buffer cannot supply says it is not the window the
234 /// caller thought it was.
235 ///
236 /// # Errors
237 ///
238 /// As [`push`](Self::push), for the first sample refused.
239 pub fn try_extend(
240 &mut self,
241 samples: impl IntoIterator<Item = StaticSample>,
242 ) -> Result<(), SampleRefusal> {
243 samples.into_iter().try_for_each(|sample| self.push(sample))
244 }
245
246 /// Whether the window's peak motion so far is within the tolerances of a still one.
247 ///
248 /// The test [`Alignment::Static`] and the barometric reference rest on, asked of the
249 /// peaks alone, so it is cheap after every sample where
250 /// [`alignment_of`](crate::Eskf::alignment_of) measures the whole window. An empty window
251 /// has not moved.
252 ///
253 /// A window only grows, and peaks do not fall, so once this is false it stays false: an
254 /// application waiting for stillness starts a new window rather than sliding this one.
255 #[must_use]
256 pub fn is_at_rest(&self, config: &Config) -> bool {
257 at_rest(self.peaks, &config.init, config.gravity)
258 }
259
260 /// Whether the window holds a sample and spans [`Initialization::min_duration`]: the
261 /// condition a collection loop runs until.
262 ///
263 /// The sample is asked for as well as the span, so that a `min_duration` of zero still
264 /// ends the loop on a window [`Eskf::initialize`](crate::Eskf::initialize) accepts rather
265 /// than on an empty one it refuses.
266 #[must_use]
267 pub fn is_long_enough(&self, config: &Config) -> bool {
268 self.end.is_some() && long_enough(self.span(), &config.init)
269 }
270
271 /// The time the window's samples integrated: the sum of their angle intervals, and what
272 /// [`Initialization::min_duration`] is checked against.
273 ///
274 /// Integrated time rather than the distance between the first and last timestamps,
275 /// because it is what the window observed: a window a logger dropped samples from covers
276 /// the samples it kept, and a stillness test over time nobody measured would pass on
277 /// nothing. The angle interval rather than the velocity one for no reason but one: the
278 /// two cover the same sample and differ only by when a driver closed each integral.
279 ///
280 /// Summed in `f64`: in `f32`, 100 intervals of 20 ms come to 1.9999987 s, and a window of
281 /// exactly `min_duration` would read as too short by the rounding. That is
282 /// `data/flight.csv`'s window: summed in `f32`, it starts `short`.
283 #[must_use]
284 pub fn span(&self) -> Seconds {
285 Seconds::from_secs(self.gyro_noise.span() as f32)
286 }
287
288 /// What the window measured, or [`InitError::NoSamples`] for a window with nothing in it.
289 pub(crate) fn measured(&self) -> Result<Measured, InitError> {
290 let end = self.end.ok_or(InitError::NoSamples)?;
291 let (first, second) = self.sums.split();
292 let whole = first.merged(second);
293 Ok(Measured {
294 force: Acceleration::from_vector(mean(whole.force, whole.samples)),
295 rate: AngularRate::from_vector(self.gyro_noise.mean()),
296 field: (whole.fields > 0)
297 .then(|| MagField::from_vector(mean(whole.field, whole.fields))),
298 inertial_accel: self.velocities.inertial_acceleration(),
299 peaks: self.peaks,
300 span: self.span(),
301 end,
302 halves: Halves::of(first, second),
303 level_scatter: level_scatter(self.outer, whole.force, whole.samples),
304 rate_variance: self.gyro_noise.mean_variance(),
305 })
306 }
307
308 /// `α₀` of equation (30) and the variance of that mean, `P_bb` of (30′), as the window's
309 /// barometer readings give them; see [`BaroReadings`].
310 pub(crate) fn alpha0(&self) -> Option<(Altitude, f32)> {
311 self.baro.reference()
312 }
313
314 /// The white noise this window measured on each sensor, a floor under the noise the
315 /// filter should be told; [`WindowNoise`] says why a floor. Equation (8″).
316 ///
317 /// On the window rather than the filter, because the figure is for building a
318 /// [`Config`] and so comes before any filter exists; it reads the same tolerances
319 /// [`is_at_rest`](Self::is_at_rest) does. Measured and
320 /// reported, never applied: a figure changes the filter only if the caller writes it into
321 /// the `Config` (`GOALS.md` differentiator 7, where derived is not adaptive).
322 ///
323 /// `None` unless the window is at rest, the test the barometric reference rests on, and
324 /// spans [`WindowNoise::MIN_READINGS`] of the [`WindowNoise::BLOCK`]s the IMU's densities
325 /// are read over: a moving window's scatter is its motion. The barometer's figure takes
326 /// the same count of its own readings.
327 #[must_use]
328 pub fn noise(&self, config: &Config) -> Option<WindowNoise> {
329 if !self.is_at_rest(config) {
330 return None;
331 }
332 Some(WindowNoise {
333 gyro_white: self.gyro_noise.density()?.into(),
334 accel_white: self.accel_noise.density()?.into(),
335 baro: self.baro.noise(),
336 blocks: self.gyro_noise.blocks.min(self.accel_noise.blocks),
337 baro_readings: self.baro.count,
338 })
339 }
340}
341
342/// A window from samples already buffered, through [`StaticWindow::try_extend`]: the first
343/// refused refuses the window. An empty slice is an empty window, which
344/// [`Eskf::initialize`](crate::Eskf::initialize) refuses as [`InitError::NoSamples`].
345impl TryFrom<&[StaticSample]> for StaticWindow {
346 type Error = SampleRefusal;
347
348 fn try_from(samples: &[StaticSample]) -> Result<Self, SampleRefusal> {
349 let mut window = Self::new();
350 window.try_extend(samples.iter().copied())?;
351 Ok(window)
352 }
353}
354
355/// The white noise a still window measured on each sensor, as [`StaticWindow::noise`] reports
356/// it: a floor under the noise the filter should be told, never the noise itself.
357///
358/// A floor because the window is the quietest the sensors will be in the air. A vehicle sitting
359/// still sees no propeller wash on its static port and no dynamic pressure, and vibration only
360/// what its motors make at idle; several corpus windows carry some, from props spinning on the
361/// ground or aliased near the sample rate. In flight each of these grows. [`ImuNoise::default`]'s
362/// white noise is ten times PX4's density for that reason and others its doc comment measures,
363/// so a figure here copied into [`Config::imu`](crate::Config::imu) is the datasheet-grade `Q`
364/// that default exists to avoid. What the figure is for: a default below it is wrong, and a
365/// default far above it is a statement about the airframe rather than the sensor.
366///
367/// A density `N` is what [`ImuNoise`] takes: an increment over `Δt` carries `N √Δt` of noise,
368/// and (16)–(21) add `N² Δt` to `Q`, so a sample's rates scatter by `N / √Δt`, twenty times `N`
369/// at 400 Hz. Reading that scatter as the density is the mistake (8″) avoids.
370///
371/// Per axis, in body axes; [`ImuNoise`] takes the worst, which
372/// [`worst_gyro_white`](Self::worst_gyro_white) and
373/// [`worst_accel_white`](Self::worst_accel_white) give. The bias random walks and each source's
374/// correlation time are not here: both need hours of data rather than seconds, an Allan
375/// variance and a replay log's autocorrelation, and belong to the replay harness's `--derive`. How well
376/// a figure is known is [`MIN_READINGS`](Self::MIN_READINGS)'s and [`BLOCK`](Self::BLOCK)'s to
377/// say, and the block length is the larger share.
378///
379/// [`ImuNoise`]: crate::ImuNoise
380/// [`ImuNoise::default`]: crate::ImuNoise#impl-Default-for-ImuNoise
381#[derive(Clone, Copy, Debug, PartialEq)]
382#[non_exhaustive]
383pub struct WindowNoise {
384 /// Gyroscope white noise per body axis, rad s⁻¹ / √Hz: a floor under
385 /// [`ImuNoise::gyro_white`](crate::ImuNoise::gyro_white).
386 pub gyro_white: [f32; 3],
387 /// Accelerometer white noise per body axis, m s⁻² / √Hz: a floor under
388 /// [`ImuNoise::accel_white`](crate::ImuNoise::accel_white).
389 pub accel_white: [f32; 3],
390 /// The barometer readings' variance: a floor under the `R_m` a barometric altitude is
391 /// fused with, the white part (24′) multiplies by its correlation factor. `None` under
392 /// [`MIN_READINGS`](Self::MIN_READINGS) distinct readings, where the IMU's figures can
393 /// still be taken and the window can still have set `α₀`, which takes two.
394 ///
395 /// A held reading counts once, as it does for `α₀`, and two genuine readings that agree
396 /// are merged by the same test, so a barometer quantized coarsely enough to repeat itself
397 /// reads a variance larger than its own.
398 ///
399 /// Fused as `R_m` it is too small, which is what calling it a floor claims: on five of the six
400 /// real logs that report it, `nis_baro` reads past 1 ([evidence]).
401 ///
402 /// [evidence]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#windownoise
403 pub baro: Option<AltitudeNoise>,
404 /// The [`BLOCK`](Self::BLOCK)s the densities were taken over, the fewer of the two
405 /// sensors'.
406 pub blocks: u64,
407 /// The distinct barometer readings `baro` was taken over.
408 pub baro_readings: u64,
409}
410
411#[cfg(feature = "defmt")]
412impl defmt::Format for WindowNoise {
413 fn format(&self, f: defmt::Formatter<'_>) {
414 let (g, a) = (self.gyro_white, self.accel_white);
415 defmt::write!(
416 f,
417 "WindowNoise {{ gyro_white: ({=f32}, {=f32}, {=f32}), accel_white: ({=f32}, {=f32}, {=f32}), baro: {}, blocks: {=u64}, baro_readings: {=u64} }}",
418 g[0],
419 g[1],
420 g[2],
421 a[0],
422 a[1],
423 a[2],
424 self.baro.map(AltitudeNoise::variance),
425 self.blocks,
426 self.baro_readings
427 )
428 }
429}
430
431impl WindowNoise {
432 /// The fewest readings a sensor's figure is taken from, a barometer's readings or an IMU's
433 /// [`BLOCK`](Self::BLOCK)s: under it, [`StaticWindow::noise`] reports none for that sensor.
434 ///
435 /// A standard deviation taken from `n` readings is known to about `1/√(2(n − 1))` of itself,
436 /// so nine is ±25 % and a 2 s window's 40 blocks ±11 %, and a variance, the barometer's, to
437 /// twice that. Fewer is the two-sample variance a window too short to measure anything
438 /// would otherwise report as a measurement.
439 ///
440 /// The barometric reference `α₀` does not wait for it, and takes a variance from two readings:
441 /// that variance starts an estimate (30′) goes on refining, where a figure reported here is
442 /// final. Holding `α₀` to nine would leave the corpus no real vehicle whose short still start
443 /// sets its own reference ([evidence]).
444 ///
445 /// [evidence]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#windownoise
446 pub const MIN_READINGS: u64 = 9;
447
448 /// The span an IMU's increments are summed over before their scatter is taken: the time
449 /// scale its white-noise density is read at, `T` of (8″).
450 ///
451 /// Long enough to average out what a still airframe adds near the sample rate, and short enough
452 /// that a 2 s window holds 40 blocks. Shorter still reads the aliasing; longer does not settle
453 /// the figure either, which moves by up to 2.3× between 50 and 125 ms on the corpus: that is
454 /// its real uncertainty, larger than the ±11 % of its 40 blocks ([evidence]). A time rather
455 /// than a count, so the figure means the same at every IMU rate.
456 ///
457 /// [evidence]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#windownoise
458 pub const BLOCK: Seconds = Seconds::from_secs(0.05);
459
460 /// The gyroscope's worst axis, the one density [`ImuNoise::gyro_white`] takes.
461 ///
462 /// [`ImuNoise::gyro_white`]: crate::ImuNoise::gyro_white
463 #[must_use]
464 pub fn worst_gyro_white(&self) -> f32 {
465 Vector3::from(self.gyro_white).max()
466 }
467
468 /// The accelerometer's worst axis, the one density [`ImuNoise::accel_white`] takes.
469 ///
470 /// [`ImuNoise::accel_white`]: crate::ImuNoise::accel_white
471 #[must_use]
472 pub fn worst_accel_white(&self) -> f32 {
473 Vector3::from(self.accel_white).max()
474 }
475}
476
477/// What the initialization window measures: the averages equations (5)–(7) level from, the
478/// peaks [`at_rest`] judges, and the span [`classify`] measures.
479///
480/// One value rather than a function per quantity, because the state and its covariance
481/// have to describe the *same* average. [`nominal_state`] levels from `force` and
482/// [`attitude_sigmas`] bounds how well it leveled; two readings of the window would be two
483/// definitions of `f̄`, free to drift apart while each still looked right on its own.
484#[derive(Clone, Copy, Debug, PartialEq)]
485pub(crate) struct Measured {
486 /// `f̄`, the averaged specific force (5) levels from. Every sample carries one.
487 pub force: Acceleration<Body>,
488 /// `ω̄ = ΣΔθ / ΣT`, the window's mean angular rate, which (7) weighs as a measurement
489 /// of the gyroscope bias. Every sample carries one.
490 pub rate: AngularRate<Body>,
491 /// `m̄`, the averaged magnetic field (6) takes a heading from, over the samples that
492 /// carry one. `None` if none do, which is a vehicle with no magnetometer.
493 pub field: Option<MagField<Body>>,
494 /// `ā_n` of equation (5′); see [`Velocities`].
495 pub inertial_accel: Option<Acceleration<Ned>>,
496 /// The window's largest motion, which [`at_rest`] judges.
497 pub peaks: Peaks,
498 /// The time the window's samples integrated, [`StaticWindow::span`].
499 pub span: Seconds,
500 /// The last sample's time, where the filter's clock starts.
501 pub end: Timestamp,
502 /// The same averages over each half of the window, for [`window_drift`]. `None` for
503 /// a window of one, which has no halves to disagree.
504 pub halves: Option<Halves>,
505 /// How well `f̄` itself is known across gravity: the variance, m² s⁻⁴ per horizontal
506 /// axis, of the mean of the samples' specific force. Over `γ²` it is the part of (5)'s
507 /// level error that is not the accelerometer bias, measured by the window's own scatter;
508 /// see [`initial_covariance`]. `None` for a window of one, or one whose average is zero,
509 /// which measure no scatter. `f64`, because the division by `γ²` waits for the `γ` the
510 /// filter is configured with.
511 pub level_scatter: Option<f64>,
512 /// How well `ω̄` itself is known: per axis, the variance of the mean from the window's own
513 /// blocks of (8″), `Density::mean_variance`. `None` under `Density::FEWEST_FOR_MEAN`; see
514 /// [`gyro_bias`](Self::gyro_bias).
515 pub rate_variance: Option<Vector3<f32>>,
516}
517
518/// The averages of equations (5)–(6) taken over each half of the window separately, so
519/// that the attitude the first half yields can be compared with the second half's.
520#[derive(Clone, Copy, Debug, PartialEq)]
521pub(crate) struct Halves {
522 /// `f̄` over the first and the second half.
523 force: [Acceleration<Body>; 2],
524 /// `m̄` over each half, and `None` unless *both* halves carried a field: a heading
525 /// the two can be compared on is the whole point, and one half alone yields nothing
526 /// to compare.
527 field: Option<[MagField<Body>; 2]>,
528}
529
530impl Halves {
531 /// The averages of two parts of the window, and `None` unless both hold a sample.
532 fn of(first: Block, second: Block) -> Option<Self> {
533 (first.samples > 0 && second.samples > 0).then(|| Self {
534 force: [
535 Acceleration::from_vector(mean(first.force, first.samples)),
536 Acceleration::from_vector(mean(second.force, second.samples)),
537 ],
538 field: (first.fields > 0 && second.fields > 0).then(|| {
539 [
540 MagField::from_vector(mean(first.field, first.fields)),
541 MagField::from_vector(mean(second.field, second.fields)),
542 ]
543 }),
544 })
545 }
546}
547
548/// The window's largest motion: what [`at_rest`] judges and
549/// [`Coarse::NotStationary`] reports.
550///
551/// The specific force is kept as its smallest and largest magnitude rather than as a
552/// departure from gravity, because the window is folded before it meets the `γ` a
553/// [`Config`] names; [`Peaks::deviation`] takes the departure then, and the largest
554/// `|‖f‖ − γ|` over the samples is exactly the larger of the two ends' departures.
555#[derive(Clone, Copy, Debug, PartialEq)]
556pub(crate) struct Peaks {
557 /// Largest angular rate magnitude.
558 pub gyro: RadiansPerSecond,
559 /// Smallest and largest specific-force magnitude, m s⁻².
560 least: f32,
561 most: f32,
562}
563
564impl Peaks {
565 /// No motion at all: what an empty window has seen.
566 const NONE: Self = Self {
567 gyro: RadiansPerSecond::from_rad_per_s(0.0),
568 least: f32::INFINITY,
569 most: f32::NEG_INFINITY,
570 };
571
572 fn push(&mut self, accel: Vector3<f32>, gyro: Vector3<f32>) {
573 let norm = accel.norm();
574 self.gyro = RadiansPerSecond::from_rad_per_s(self.gyro.as_rad_per_s().max(gyro.norm()));
575 self.least = self.least.min(norm);
576 self.most = self.most.max(norm);
577 }
578
579 /// Largest departure of the specific-force magnitude from `gravity`; zero for an empty
580 /// window.
581 pub(crate) fn deviation(&self, gravity: f32) -> MetersPerSecond2 {
582 let deviation = (self.most - gravity).max(gravity - self.least).max(0.0);
583 MetersPerSecond2::from_m_per_s2(deviation)
584 }
585}
586
587impl Measured {
588 /// The gyroscope bias (7) commits and its prior `σ_βg,0` of (8), per body axis.
589 ///
590 /// At rest, `ω̄` is a measurement of the bias with the variance `R` of a mean, and it is
591 /// weighed against [`Initialization::sigma_gyro_bias`] by the two variances, rather than
592 /// replacing it: `β̂ = ω̄ σ² / (σ² + R)`, `P = σ² R / (σ² + R)`. A still window's own
593 /// blocks keep `R` well under the prior's, and `ω̄` is taken nearly whole ([evidence]); one
594 /// 5 ms sample, under the configured [`ImuNoise::gyro_white`] since it has no blocks, is
595 /// worth less than the prior, and a bias taken from it whole would start 0.2 rad/s
596 /// uncertain. An axis whose blocks did not scatter at all, a held or coarsely quantized
597 /// stream, has measured nothing either, and takes the configured density too: zero would
598 /// claim the bias known to the Earth's rotation. In motion, `ω̄` is the
599 /// vehicle's rotation and nothing is weighed: zero under the prior, since seeding a turn
600 /// rate would subtract it from every later measurement as though it were a sensor error.
601 /// Neither production estimator averages at all, ArduPilot zeroing the bias at bootstrap
602 /// (`libraries/AP_NavEKF3/AP_NavEKF3_core.cpp:546`, `368dc0c4`) and PX4 refusing to
603 /// initialize outside 0.8–1.2 g and 15°/s (`src/modules/ekf2/EKF/ekf.cpp:213-227`,
604 /// `c4e4ef98`), so weighing is this crate's, taken only where their precondition holds.
605 ///
606 /// Plus [`EARTH_RATE`]'s square at rest: the filter does not model the Earth's rotation, so
607 /// `ω̄` holds its projection on body axes, and a turn moves that projection by as much. On
608 /// eight of the nine real logs that start at rest, `ω̄` agrees with EKF2's bias 10 s later to
609 /// 3.4 × 10⁻⁴ rad/s RMS, under EKF2's own σ, so the corpus bounds the floor without resolving
610 /// it ([evidence]).
611 ///
612 /// [`ImuNoise::gyro_white`]: crate::ImuNoise::gyro_white
613 /// [evidence]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#initialization
614 pub(crate) fn gyro_bias(&self, at_rest: bool, config: &Config) -> GyroBias {
615 let prior = config.init.sigma_gyro_bias.as_rad_per_s();
616 if !at_rest {
617 return GyroBias {
618 estimate: AngularRate::zero(),
619 sigmas: Vector3::repeat(prior),
620 };
621 }
622 let prior = prior * prior;
623 let unmeasured = config.imu.gyro_white.powi(2) / self.span.as_secs();
624 let r = self.rate_variance.map_or(Vector3::repeat(unmeasured), |r| {
625 r.map(|r| if r > 0.0 { r } else { unmeasured })
626 });
627 let floor = EARTH_RATE.as_rad_per_s();
628 let gain = r.map(|r| prior / (prior + r));
629 GyroBias {
630 estimate: AngularRate::from_vector(self.rate.vector().component_mul(&gain)),
631 sigmas: r.zip_map(&gain, |r, k| ComplexField::sqrt(k * r + floor * floor)),
632 }
633 }
634
635 /// The direction equation (5) called down, `−f̄ / ‖f̄‖`: the axis the tilt bound and
636 /// the dip are both measured about.
637 ///
638 /// `None` for an average of zero, which is a stopped or disconnected accelerometer:
639 /// that window has no vertical to measure anything about. [`level_from_accel`] levels
640 /// it to zero rather than to NaN and [`coarse_sigmas`] keeps the same promise, falling
641 /// back to bounds that do not need a direction rather than dividing by the norm.
642 fn down(&self) -> Option<Vector3<f32>> {
643 self.force
644 .vector()
645 .try_normalize(f32::MIN_POSITIVE)
646 .map(|f| -f)
647 }
648}
649
650/// How many blocks [`BlockSums`] keeps.
651///
652/// The split lands within half a block of the middle, and at least `BLOCKS / 2` blocks are
653/// full, so the halves are within `n / BLOCKS` samples of equal: 37.5 % to 62.5 % of the
654/// window at worst. A steady turn is still charged exactly: the halves' centers sit half the
655/// window apart wherever the split falls.
656///
657/// Eight is measured against an exact split: the halves' disagreement moves by about 4° on the
658/// coarse starts, and the outputs barely do, because it reaches only [`coarse_sigmas`] and only
659/// where it is the largest bound ([evidence]).
660///
661/// [evidence]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#blocks
662const BLOCKS: usize = 8;
663
664/// The window's `Σ f` and `Σ m`, kept so that [`Halves`] can be taken from them in a window
665/// whose length is not known until it ends.
666///
667/// The halves are the only statistic here a single pass cannot take exactly, because the
668/// middle of a window moves as it grows. So the window is kept as up to [`BLOCKS`] blocks of
669/// equal size, each summing its own samples; when the last fills, adjacent pairs merge and
670/// the size doubles. [`split`](Self::split) cuts at the block boundary nearest the middle,
671/// within the bound [`BLOCKS`] states. The whole window's sums are the two halves' total
672/// rather than a running total kept beside them, so the two cannot disagree.
673#[derive(Clone, Copy, Debug)]
674struct BlockSums {
675 blocks: [Block; BLOCKS],
676 /// Samples a block holds before the next one starts.
677 size: u64,
678 /// The block now filling.
679 current: usize,
680}
681
682/// One block of [`BlockSums`].
683#[derive(Clone, Copy, Debug)]
684struct Block {
685 force: Vector3<f64>,
686 samples: u64,
687 field: Vector3<f64>,
688 fields: u64,
689}
690
691impl Block {
692 const EMPTY: Self = Self {
693 force: Vector3::new(0.0, 0.0, 0.0),
694 samples: 0,
695 field: Vector3::new(0.0, 0.0, 0.0),
696 fields: 0,
697 };
698
699 fn total(blocks: &[Self]) -> Self {
700 blocks.iter().fold(Self::EMPTY, |sum, b| sum.merged(*b))
701 }
702
703 fn merged(self, other: Self) -> Self {
704 Self {
705 force: self.force + other.force,
706 samples: self.samples + other.samples,
707 field: self.field + other.field,
708 fields: self.fields + other.fields,
709 }
710 }
711}
712
713impl BlockSums {
714 const fn new() -> Self {
715 Self {
716 blocks: [Block::EMPTY; BLOCKS],
717 size: 1,
718 current: 0,
719 }
720 }
721
722 fn push(&mut self, force: Vector3<f64>, field: Option<Vector3<f64>>) {
723 if self
724 .blocks
725 .get(self.current)
726 .is_some_and(|b| b.samples >= self.size)
727 {
728 if self.current + 1 < BLOCKS {
729 self.current += 1;
730 } else {
731 self.halve();
732 }
733 }
734 if let Some(block) = self.blocks.get_mut(self.current) {
735 block.force += force;
736 block.samples += 1;
737 if let Some(field) = field {
738 block.field += field;
739 block.fields += 1;
740 }
741 }
742 }
743
744 /// Merge adjacent pairs, which halves the blocks in use and doubles their size, and start
745 /// filling the first block the merge left empty.
746 fn halve(&mut self) {
747 // In place, since pair `i` is read from `2i` and `2i + 1`, never from below `i`: a copy of
748 // the blocks would be a stack temporary.
749 for pair in 0..BLOCKS / 2 {
750 if let (Some(&first), Some(&second)) =
751 (self.blocks.get(2 * pair), self.blocks.get(2 * pair + 1))
752 && let Some(merged) = self.blocks.get_mut(pair)
753 {
754 *merged = first.merged(second);
755 }
756 }
757 for empty in self.blocks.iter_mut().skip(BLOCKS / 2) {
758 *empty = Block::EMPTY;
759 }
760 self.size *= 2;
761 self.current = BLOCKS / 2;
762 }
763
764 /// The window's two halves, split at the block boundary nearest the middle, the earlier
765 /// of two equally near: for a window of `n` blocks of one sample, that is `n / 2`.
766 fn split(&self) -> (Block, Block) {
767 let blocks = self.blocks.get(..=self.current).unwrap_or_default();
768 let total: u64 = blocks.iter().map(|b| b.samples).sum();
769 // Twice the distance from the middle, in samples, so an odd window stays integral.
770 let (mut split, mut nearest, mut before) = (0, total, 0u64);
771 for (index, block) in blocks.iter().enumerate() {
772 before += block.samples;
773 let distance = (2 * before).abs_diff(total);
774 if distance < nearest {
775 (split, nearest) = (index + 1, distance);
776 }
777 }
778 let (first, second) = blocks.split_at(split.min(blocks.len()));
779 (Block::total(first), Block::total(second))
780 }
781}
782
783/// The first and last GNSS velocity in the window, for `ā_n` of equation (5′), the term
784/// in-motion leveling subtracts from the averaged specific force.
785///
786/// Endpoints only; the velocities in between are not differenced at all. The mean of a
787/// derivative *is* its endpoint difference over the span, and the mean is what is wanted,
788/// because (5) levels the averaged specific force and the term to subtract is therefore
789/// the averaged acceleration. Differencing consecutive samples and averaging those gives
790/// the same number with the intermediate noise added back — which matters here, since
791/// this is the noisiest part of in-motion leveling: a receiver's velocity error divided
792/// by a span, and a 1 Hz receiver over a 2 s window divides it by very little.
793///
794/// The span is the time the samples after the first velocity's, up to the last's,
795/// integrated, so it is only as honest as the dating of the window; see
796/// [`StaticSample::velocity`].
797#[derive(Clone, Copy, Debug, Default)]
798struct Velocities {
799 first: Option<Velocity<Ned>>,
800 last: Option<Velocity<Ned>>,
801 /// Integrated since the first velocity's sample.
802 since_first: f64,
803 /// `since_first` as of the last velocity's sample.
804 span: f64,
805}
806
807impl Velocities {
808 fn push(&mut self, velocity: Option<Velocity<Ned>>, interval: f64) {
809 if self.first.is_some() {
810 self.since_first += interval;
811 }
812 if let Some(velocity) = velocity {
813 if self.first.is_none() {
814 self.first = Some(velocity);
815 } else {
816 self.last = Some(velocity);
817 self.span = self.since_first;
818 }
819 }
820 }
821
822 /// `ā_n`. `None` unless two samples separated in time carry a velocity.
823 fn inertial_acceleration(&self) -> Option<Acceleration<Ned>> {
824 let (first, last) = (self.first?, self.last?);
825 let span = self.span as f32;
826 // `push` refuses an interval under a microsecond, so two velocities are at least that
827 // far apart and this never refuses one. It stays because this is the only division
828 // here, and a difference over zero seconds is an infinity, not an acceleration.
829 (span > 0.0).then(|| Acceleration::from_vector((last.vector() - first.vector()) / span))
830 }
831}
832
833/// One sensor's white-noise density per axis, equation (8″): the increments it integrated,
834/// summed into blocks of [`WindowNoise::BLOCK`], and their scatter weighted by each block's
835/// length.
836///
837/// Blocks rather than samples because a still airframe's samples are not white, and one sample's
838/// scatter then measures the noise at the sample rate rather than the density that integrates into
839/// attitude and velocity error. On the corpus's windows the lag-one autocorrelation runs from −0.98
840/// to +0.96 across the axes, and one sample's scatter misreads the blocks' figure by up to 6× high
841/// and 2.9× low; correcting it by the lag-one `(1 + ρ)/(1 − ρ)` of (24′) still misses by up to 3.3×
842/// ([evidence]).
843///
844/// About zero rather than about the first increment, unlike [`BaroReadings`]: the loss is at
845/// most `J ε γ² T / N²` of the scatter, under 10⁻⁷ in `f64` even at a datasheet
846/// accelerometer's 7 × 10⁻⁴ m s⁻²/√Hz, where the barometer's meters above sea level cost
847/// 5 × 10⁻⁴. A constant in the rate, the bias, gravity, the Earth's rotation, cancels in the
848/// scatter.
849///
850/// [evidence]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#windownoise
851#[derive(Clone, Copy, Debug)]
852struct Density {
853 /// The increment and interval of the block filling.
854 open: Vector3<f64>,
855 open_interval: f64,
856 /// `J`, the blocks closed.
857 blocks: u64,
858 /// `Σ T` over the closed blocks.
859 interval: f64,
860 /// `Σ B`.
861 sum: Vector3<f64>,
862 /// `Σ B² / T`.
863 squares: Vector3<f64>,
864}
865
866impl Density {
867 const EMPTY: Self = Self {
868 open: Vector3::new(0.0, 0.0, 0.0),
869 open_interval: 0.0,
870 blocks: 0,
871 interval: 0.0,
872 sum: Vector3::new(0.0, 0.0, 0.0),
873 squares: Vector3::new(0.0, 0.0, 0.0),
874 };
875
876 /// Add a sample's increment over its interval, closing the block at the sample boundary
877 /// nearest [`WindowNoise::BLOCK`], the earlier of two equally near: a boundary exactly half
878 /// an interval short of it closes. Whether a tie is exact depends on how the intervals
879 /// round: exact 20 ms steps would close at 40 ms, but `0.02` in `f32` sits just under, so
880 /// 20 ms samples close 60 ms blocks, while 25 ms ones close 50 ms blocks. The weighting
881 /// takes either as it comes. The last block, still filling, is left out of the figure.
882 fn push(&mut self, increment: Vector3<f64>, interval: f64) {
883 self.open += increment;
884 self.open_interval += interval;
885 if self.open_interval + interval / 2.0 >= f64::from(WindowNoise::BLOCK.as_secs()) {
886 let (block, length) = (self.open, self.open_interval);
887 self.blocks += 1;
888 self.interval += length;
889 self.sum += block;
890 // One division rather than three: each is a library call on a core with no FPU.
891 self.squares += block.component_mul(&block) * (1.0 / length);
892 (self.open, self.open_interval) = (Vector3::zeros(), 0.0);
893 }
894 }
895
896 /// `N` per axis, and `None` under [`WindowNoise::MIN_READINGS`] blocks.
897 fn density(&self) -> Option<Vector3<f32>> {
898 if self.blocks < WindowNoise::MIN_READINGS {
899 return None;
900 }
901 Some(self.variance().map(|n2| ComplexField::sqrt(n2) as f32))
902 }
903
904 /// `N̂²` per axis, (8″), over the closed blocks; at least two, which callers check.
905 fn variance(&self) -> Vector3<f64> {
906 let j = self.blocks as f64;
907 let axis =
908 |squares: f64, sum: f64| (scatter(squares, sum, self.interval) / (j - 1.0)).max(0.0);
909 Vector3::new(
910 axis(self.squares.x, self.sum.x),
911 axis(self.squares.y, self.sum.y),
912 axis(self.squares.z, self.sum.z),
913 )
914 }
915
916 /// The time the increments span, the open block's included.
917 fn span(&self) -> f64 {
918 self.interval + self.open_interval
919 }
920
921 /// `ω̄ = ΣΔθ / ΣT`, the mean rate over every increment, the open block's included: (7) in
922 /// increments, which weighs each sample by the time it integrated, as `N̂² / T` assumes.
923 fn mean(&self) -> Vector3<f32> {
924 let span = self.span();
925 if span <= 0.0 {
926 return Vector3::zeros();
927 }
928 ((self.sum + self.open) / span).map(|rate| rate as f32)
929 }
930
931 /// The fewest blocks [`mean_variance`](Self::mean_variance) takes a variance from.
932 ///
933 /// `N̂²` is a `χ²` of `J − 1` degrees of freedom, and dividing by it is what an inverse
934 /// variance does, so unscaled the expected NEES of the committed bias is `(J − 1)/(J − 3)`:
935 /// 1.40 at eight blocks, infinite at three. Four is the fewest where it is finite.
936 const FEWEST_FOR_MEAN: u64 = 4;
937
938 /// The variance of [`mean`](Self::mean) per axis, `N̂² / T` scaled by `(J − 1)/(J − 3)` so
939 /// that the expected NEES is one, and `None` under
940 /// [`FEWEST_FOR_MEAN`](Self::FEWEST_FOR_MEAN) blocks.
941 fn mean_variance(&self) -> Option<Vector3<f32>> {
942 if self.blocks < Self::FEWEST_FOR_MEAN {
943 return None;
944 }
945 let j = self.blocks as f64;
946 let scale = (j - 1.0) / (j - 3.0) / self.span();
947 Some(self.variance().map(|n2| (n2 * scale) as f32))
948 }
949}
950
951/// The barometer readings in the window, for `α₀` of equation (30) and the variance of that
952/// mean, `P_bb` of (30′).
953///
954/// A reading is a value that differs from the previous sample's, because
955/// [`StaticSample::baro`] may be held across IMU epochs: a 20 Hz barometer held on a 200 Hz
956/// IMU would otherwise count each reading ten times and report a variance ten times too
957/// small, and one reading held across the whole window would pass for many with no scatter.
958/// Two genuine readings that happen to agree are merged by the same test, which can only
959/// make the variance larger.
960///
961/// The variance is the window's own: the sample variance of the readings over their count,
962/// the standard error of a mean. It is measured rather than asked for, because the window
963/// is the one moment the barometer's scatter can be read with the vehicle known to be still
964/// (GOALS.md differentiator 7), and a reading's `R` on the corpus is a constant the
965/// converter substitutes. It assumes the readings independent; a correlated barometer
966/// averages down more slowly than this says.
967///
968/// Summed about the first reading, in `f64`. The textbook one-pass form, `Σa² − (Σa)²/n`,
969/// differences two squares of meters above mean sea level down to a scatter of centimeters,
970/// and at 10 km over 400 readings 3 cm apart it is 5 × 10⁻⁴ out
971/// (`the_barometer_scatter_is_the_two_pass_one_at_any_altitude`); about the first reading
972/// the squares are of the scatter itself. Welford's running mean avoids the same loss at a
973/// division per reading.
974#[derive(Clone, Copy, Debug, Default)]
975struct BaroReadings {
976 /// The previous sample's reading, fresh or held.
977 previous: Option<Altitude>,
978 count: u64,
979 /// The first reading, which the sums below are taken about.
980 first: f64,
981 /// `Σ (a − a₁)`.
982 sum: f64,
983 /// `Σ (a − a₁)²`.
984 squares: f64,
985}
986
987impl BaroReadings {
988 fn push(&mut self, baro: Option<Altitude>) {
989 let fresh = baro.filter(|altitude| Some(*altitude) != self.previous);
990 self.previous = baro;
991 if let Some(altitude) = fresh {
992 let a = f64::from(altitude.as_meters());
993 if self.count == 0 {
994 self.first = a;
995 }
996 let d = a - self.first;
997 self.count += 1;
998 self.sum += d;
999 self.squares += d * d;
1000 }
1001 }
1002
1003 /// The mean and its variance, and `None` for fewer than two readings. One reading is
1004 /// refused rather than given a variance: it has no scatter to measure, and a reference
1005 /// whose error is invented is what (30′) exists to stop.
1006 fn reference(&self) -> Option<(Altitude, f32)> {
1007 if self.count < 2 {
1008 return None;
1009 }
1010 let n = self.count as f64;
1011 let mean = self.first + self.sum / n;
1012 Some((
1013 Altitude::from_meters(mean as f32),
1014 (self.variance() / n) as f32,
1015 ))
1016 }
1017
1018 /// The readings' sample variance, the floor [`WindowNoise::baro`] reports, and `None`
1019 /// under [`WindowNoise::MIN_READINGS`].
1020 fn noise(&self) -> Option<AltitudeNoise> {
1021 (self.count >= WindowNoise::MIN_READINGS)
1022 .then(|| AltitudeNoise::from_variance(self.variance() as f32))
1023 }
1024
1025 /// The readings' sample variance, for two or more.
1026 fn variance(&self) -> f64 {
1027 let n = self.count as f64;
1028 scatter(self.squares, self.sum, n) / (n - 1.0)
1029 }
1030}
1031
1032/// `Σ w x² − (Σ w x)² / Σ w`, the weighted sum of squared deviations from the mean, for
1033/// [`Density`] and [`BaroReadings`], each saying why its sums hold the precision.
1034fn scatter(squares: f64, sum: f64, weight: f64) -> f64 {
1035 squares - sum * sum / weight
1036}
1037
1038/// [`Measured::level_scatter`], from the window's `Σ f` and `Σ f fᵀ`.
1039///
1040/// The scatter across `f̄` over the sample count is how far the average could sit from the
1041/// window's true mean force: vibration, a vehicle rocking on its gear, sensor noise. Divided
1042/// by `γ²` it is a tilt, and it is what `P₀` keeps independent of the bias. It assumes the
1043/// samples independent, so vibration slower than the sample rate is undercounted, and it is
1044/// a floor rather than the whole independent share for that reason.
1045fn level_scatter(outer: Matrix3<f64>, sum: Vector3<f64>, count: u64) -> Option<f64> {
1046 if count < 2 {
1047 return None;
1048 }
1049 let n = count as f64;
1050 let mean = sum / n;
1051 let down = mean.try_normalize(f64::MIN_POSITIVE)?;
1052 // Sums near `γ²` per sample, differenced down to a scatter many orders smaller: the
1053 // subtraction that needs f64.
1054 let scatter = (outer - mean * mean.transpose() * n) / (n - 1.0);
1055 let across = scatter.trace() - down.dot(&(scatter * down));
1056 Some((across / 2.0).max(0.0) / n)
1057}
1058
1059/// Widen a measurement for accumulation; see [`StaticWindow`].
1060fn widen(measurement: Vector3<f32>) -> Vector3<f64> {
1061 Vector3::new(
1062 f64::from(measurement.x),
1063 f64::from(measurement.y),
1064 f64::from(measurement.z),
1065 )
1066}
1067
1068/// An accumulated sum as its mean, and zero for nothing accumulated — which
1069/// [`StaticWindow::measured`] refuses before any caller sees it.
1070fn mean(sum: Vector3<f64>, count: u64) -> Vector3<f32> {
1071 if count == 0 {
1072 return Vector3::zeros();
1073 }
1074 let n = count as f64;
1075 Vector3::new((sum.x / n) as f32, (sum.y / n) as f32, (sum.z / n) as f32)
1076}
1077
1078/// What [`Eskf::initialize`](crate::Eskf::initialize) achieved.
1079///
1080/// A window that is short or moving is not a failure — it is a coarser start, and the
1081/// filter says which it got rather than refusing to run. See
1082/// [`Status::Aligning`](crate::Status::Aligning).
1083#[must_use = "whether the filter aligned or only started coarsely changes what the estimate is worth"]
1084#[cfg_attr(feature = "defmt", derive(defmt::Format))]
1085#[derive(Clone, Copy, Debug, PartialEq)]
1086pub enum Alignment {
1087 /// The window was long enough and genuinely still: tilt from averaged gravity, gyro
1088 /// bias from the averaged rate, and the covariance
1089 /// [`Initialization`](crate::Initialization) describes. Equations (5)–(8).
1090 Static,
1091 /// The window was usable but not a static interval, so the covariance says what that
1092 /// window supports rather than what a still one would. The filter runs and reports
1093 /// [`Status::Aligning`](crate::Status::Aligning) until tilt and heading uncertainty
1094 /// first come within [`ALIGNED_TILT`](crate::ALIGNED_TILT) and
1095 /// [`ALIGNED_HEADING`](crate::ALIGNED_HEADING).
1096 Coarse(Coarse),
1097 /// The state came from [`Eskf::initialize_from`](crate::Eskf::initialize_from) rather
1098 /// than from a window. Whether it counts as aligned is a question for the covariance
1099 /// the caller supplied, not for this value.
1100 Seeded,
1101}
1102
1103impl Alignment {
1104 /// Whether this was a full static alignment.
1105 pub const fn is_static(self) -> bool {
1106 matches!(self, Self::Static)
1107 }
1108}
1109
1110impl core::fmt::Display for Alignment {
1111 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1112 match self {
1113 Self::Static => f.write_str("static"),
1114 Self::Coarse(coarse) => write!(f, "coarse, {coarse}"),
1115 Self::Seeded => f.write_str("seeded"),
1116 }
1117 }
1118}
1119
1120/// Why alignment was coarse rather than static.
1121///
1122/// Carries what was measured, so "not stationary" is diagnosable rather than a bare
1123/// verdict: an integrator tuning
1124/// [`Initialization`](crate::Initialization) needs to know by how much.
1125#[cfg_attr(feature = "defmt", derive(defmt::Format))]
1126#[derive(Clone, Copy, Debug, PartialEq)]
1127pub enum Coarse {
1128 /// The vehicle held still, but the window spans less than
1129 /// [`Initialization::min_duration`](crate::Initialization::min_duration).
1130 ///
1131 /// Still is measured before length, so a short window that moved is
1132 /// [`NotStationary`](Self::NotStationary) instead: this variant is a parked vehicle
1133 /// that has not been parked for long, and the filter trusts its position, velocity,
1134 /// barometric reference and heading as it would a static window's.
1135 WindowTooShort {
1136 /// Duration the configuration requires.
1137 required: Seconds,
1138 /// Duration the window covers: the time its samples integrated.
1139 provided: Seconds,
1140 },
1141 /// The vehicle was moving: angular rate or specific force left the tolerance
1142 /// [`Initialization`](crate::Initialization) allows.
1143 NotStationary {
1144 /// Largest angular rate magnitude in the window.
1145 peak_gyro: RadiansPerSecond,
1146 /// Largest departure of the specific-force magnitude from gravity.
1147 peak_accel_deviation: MetersPerSecond2,
1148 /// How long the window spanned. With `peak_gyro`, this bounds how far the
1149 /// vehicle turned while its gravity vector was being averaged, which is the
1150 /// other way a moving window spoils tilt.
1151 span: Seconds,
1152 /// Mean navigation-frame acceleration over the window, `ā_n` of equation (5′),
1153 /// differenced from [`StaticSample::velocity`]. `None` when no two samples
1154 /// carried one.
1155 ///
1156 /// What separates a vehicle that is accelerating from an accelerometer that is
1157 /// lying: `peak_accel_deviation` measures specific force that is not gravity,
1158 /// and this is the part of it GNSS can account for. A launch off a moving deck
1159 /// reads both, and only one of them spoils tilt.
1160 ///
1161 /// **Stub.** Reported, not subtracted (equation (5′), #59), so it narrows nothing:
1162 /// the tilt prior of (8′) is bounded by how far the window's *averaged* specific force
1163 /// is from gravity, and a real `ā_n` is part of what puts it there.
1164 inertial_accel: Option<Acceleration<Ned>>,
1165 },
1166}
1167
1168/// What was measured, in the units [`Initialization`](crate::Initialization) is tuned in.
1169/// `inertial_accel` is left out while (5′) only reports it.
1170impl Coarse {
1171 /// The coarse start a window that moved gives, reporting what it measured.
1172 pub(crate) fn not_stationary(measured: &Measured, gravity: f32) -> Self {
1173 Self::NotStationary {
1174 peak_gyro: measured.peaks.gyro,
1175 peak_accel_deviation: measured.peaks.deviation(gravity),
1176 span: measured.span,
1177 inertial_accel: measured.inertial_accel,
1178 }
1179 }
1180}
1181
1182impl core::fmt::Display for Coarse {
1183 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1184 let three = |value| Fixed::new(value, Decimals::Three);
1185 match *self {
1186 Self::WindowTooShort { required, provided } => write!(
1187 f,
1188 "window of {} s, {} s required",
1189 three(provided.as_secs()),
1190 three(required.as_secs())
1191 ),
1192 Self::NotStationary {
1193 peak_gyro,
1194 peak_accel_deviation,
1195 span,
1196 ..
1197 } => write!(
1198 f,
1199 "moving: peak rate {} rad/s, specific force {} m/s² off gravity, over {} s",
1200 three(peak_gyro.as_rad_per_s()),
1201 three(peak_accel_deviation.as_m_per_s2()),
1202 three(span.as_secs())
1203 ),
1204 }
1205 }
1206}
1207
1208/// Why initialization could not run at all.
1209///
1210/// Distinct from [`Coarse`]: these are inputs the filter can make nothing of, not starts
1211/// of lower quality. Every one of them is the caller handing over something broken, which
1212/// is why they are checked rather than trusted — a seed in particular crosses a boundary
1213/// the filter does not control, arriving from another estimator or from storage that may
1214/// be stale or corrupt.
1215#[cfg_attr(feature = "defmt", derive(defmt::Format))]
1216#[derive(Clone, Copy, Debug, PartialEq)]
1217pub enum InitError {
1218 /// The window held no samples, so there is nothing to align from.
1219 NoSamples,
1220 /// A sample's integration interval was under a microsecond, so it covers no span of time
1221 /// to measure the window over or divide its increments by. One that is not a number is
1222 /// [`NotFinite`](Self::NotFinite).
1223 InvalidInterval {
1224 /// The interval offered.
1225 interval: Seconds,
1226 },
1227 /// A sample was timed no later than the one before it, so the window's timestamps say
1228 /// nothing about when it ended, which is where the filter's clock starts. As
1229 /// [`Propagation::InvalidStep`](crate::Propagation::InvalidStep) for a step.
1230 InvalidStep {
1231 /// The time from the previous sample to this one.
1232 dt: Seconds,
1233 },
1234 /// A measurement, state, or covariance carried a value that is not finite.
1235 NotFinite,
1236 /// A seed covariance had a variance on its diagonal that no prior has: below the floor
1237 /// of equation (42′), which includes zero and negative.
1238 ///
1239 /// Zero is the one that arrives in practice, from a warm start deserialized out of
1240 /// storage that never populated the diagonal. It reads as a tight prior and is not
1241 /// one: it claims perfect knowledge, so the gain `K = P Hᵀ S⁻¹` of equation (25) is
1242 /// zero for that quantity and no measurement ever corrects it, while
1243 /// [`Validity`](crate::Validity) compares the zero variance against
1244 /// [`Config::accuracy`](crate::Config::accuracy) and reports the quantity good from
1245 /// the first read. A negative variance claims better than perfect.
1246 ///
1247 /// The bar is the floor rather than zero because zero is only the tidiest member of
1248 /// that class: the same accident with an exponent left in it arrives at 1e-30 and is
1249 /// indistinguishable in f32 — the gain is still zero and the quantity still reads good
1250 /// from the first epoch. A seed is the one path that writes a covariance in whole, so
1251 /// it is refused here where a `reset_*_to` writing one block is floored instead. See
1252 /// [`Fusion::InvalidNoise`](crate::Fusion::InvalidNoise) for the bar every `fuse_*`
1253 /// puts on `R`, which is still strict positivity.
1254 ///
1255 /// Symmetry is imposed by (42) rather than checked, and positive-definiteness is not
1256 /// checked: that is a factorization on the caller's data, not a guard.
1257 InvalidVariance,
1258}
1259
1260impl core::fmt::Display for InitError {
1261 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1262 match self {
1263 Self::NoSamples => write!(f, "initialization window held no samples"),
1264 Self::InvalidInterval { interval } => {
1265 let interval = Fixed::new(interval.as_secs(), Decimals::Three);
1266 write!(f, "initialization interval of {interval} s is not usable")
1267 }
1268 Self::InvalidStep { dt } => {
1269 let dt = Fixed::new(dt.as_secs(), Decimals::Three);
1270 write!(f, "initialization window stepped {dt} s, not forward")
1271 }
1272 Self::NotFinite => write!(f, "initialization input was not finite"),
1273 Self::InvalidVariance => {
1274 write!(f, "seed covariance had a variance below the floor of (42′)")
1275 }
1276 }
1277 }
1278}
1279
1280impl core::error::Error for InitError {}
1281
1282/// Why [`StaticWindow::push`] refused a sample.
1283///
1284/// The three of [`InitError`]'s variants a sample can cause, so a caller handling a refused
1285/// sample matches on what a sample can be wrong about and not on an empty window or a bad
1286/// seed. It converts into [`InitError`], so `?` carries it out of a function that initializes
1287/// the filter.
1288#[cfg_attr(feature = "defmt", derive(defmt::Format))]
1289#[derive(Clone, Copy, Debug, PartialEq)]
1290pub enum SampleRefusal {
1291 /// The sample carried a value that is not finite: [`InitError::NotFinite`].
1292 NotFinite,
1293 /// Its integration interval was under a microsecond: [`InitError::InvalidInterval`].
1294 InvalidInterval {
1295 /// The interval offered.
1296 interval: Seconds,
1297 },
1298 /// It was timed no later than the sample before it: [`InitError::InvalidStep`].
1299 InvalidStep {
1300 /// The time from the previous sample to this one.
1301 dt: Seconds,
1302 },
1303}
1304
1305impl From<SampleRefusal> for InitError {
1306 fn from(refusal: SampleRefusal) -> Self {
1307 match refusal {
1308 SampleRefusal::NotFinite => Self::NotFinite,
1309 SampleRefusal::InvalidInterval { interval } => Self::InvalidInterval { interval },
1310 SampleRefusal::InvalidStep { dt } => Self::InvalidStep { dt },
1311 }
1312 }
1313}
1314
1315impl core::fmt::Display for SampleRefusal {
1316 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1317 InitError::from(*self).fmt(f)
1318 }
1319}
1320
1321impl core::error::Error for SampleRefusal {}
1322
1323/// Classify a measured window as a static or a coarse start.
1324///
1325/// The test behind [`Eskf::alignment_of`](crate::Eskf::alignment_of): still enough, then
1326/// long enough, against the tolerances in `init`. Total, because [`StaticWindow::push`] has
1327/// refused every sample that can be refused and [`StaticWindow::measured`] an empty window.
1328///
1329/// Motion is tested first so that each coarse variant answers one question. A window that
1330/// moved is [`Coarse::NotStationary`] whatever its length, and its `span` says how long it
1331/// was; a short one is [`Coarse::WindowTooShort`] only if it was still. Tested the other
1332/// way round, a short window said nothing about motion, and a caller holding a still
1333/// window that had not yet reached `min_duration` could not tell from the outcome that it
1334/// was still — which is what deciding to initialize at the onset of motion asks.
1335pub(crate) fn classify(measured: &Measured, init: &Initialization, gravity: f32) -> Alignment {
1336 if !at_rest(measured.peaks, init, gravity) {
1337 return Alignment::Coarse(Coarse::not_stationary(measured, gravity));
1338 }
1339 if !long_enough(measured.span, init) {
1340 return Alignment::Coarse(Coarse::WindowTooShort {
1341 required: init.min_duration,
1342 provided: measured.span,
1343 });
1344 }
1345 Alignment::Static
1346}
1347
1348/// Whether a window spanning `span` is long enough to align from: the half of [`classify`]
1349/// that [`StaticWindow::is_long_enough`] asks too, so a collection loop stops on the window
1350/// `classify` calls long enough and no other.
1351fn long_enough(span: Seconds, init: &Initialization) -> bool {
1352 span >= init.min_duration
1353}
1354
1355/// The nominal state a window yields. Equation (7), from the attitude of (5)–(6).
1356///
1357/// `gyro_bias` is [`Measured::gyro_bias`]'s estimate, taken once so that the covariance
1358/// prices the bias this commits.
1359///
1360/// Every value read here is finite and the window is non-empty: [`StaticWindow`] refuses
1361/// both before any of this is reached.
1362pub(crate) fn nominal_state(
1363 measured: &Measured,
1364 declination: Radians,
1365 gyro_bias: AngularRate<Body>,
1366) -> State {
1367 let (roll, pitch) = level_from_accel(measured.force);
1368 // Stillness observes tilt and never the rotation about it, so a window with no
1369 // magnetometer anywhere in it keeps ψ₀ = 0 — a stated direction rather than a
1370 // measured one, which is what `Unestablished::heading` records.
1371 let yaw = measured.field.map_or(Radians::ZERO, |field| {
1372 heading_from_mag(field, roll, pitch, declination)
1373 });
1374
1375 State {
1376 // q_ZYX(ψ₀, θ₀, φ₀) of (7): `from_euler_angles` composes Rz(ψ) Ry(θ) Rx(φ), the
1377 // sequence (6) levels with and `Attitude::euler_angles` reads back.
1378 attitude: Attitude::from_quaternion(UnitQuaternion::from_euler_angles(
1379 roll.as_radians(),
1380 pitch.as_radians(),
1381 yaw.as_radians(),
1382 )),
1383 position: Position::zero(),
1384 velocity: Velocity::zero(),
1385 // β̂_a,0 = 0. At rest an accelerometer bias is indistinguishable from a tilt —
1386 // it leans the measured gravity vector and (5) has already read that lean as
1387 // attitude — so there is nothing left for this to hold.
1388 accel_bias: Acceleration::zero(),
1389 gyro_bias,
1390 // `status` and `validity` are inert in the stored state; `Eskf::state`
1391 // overwrites both on every read.
1392 ..State::default()
1393 }
1394}
1395
1396/// Roll and pitch from the averaged specific force. Equation (5).
1397///
1398/// The signs are the down-positive convention's: a level, stationary accelerometer reads
1399/// `f = [0, 0, -γ]ᵀ`, so `-f_z` is `+γ`, `-f_y` is zero, and both angles come out zero.
1400///
1401/// Written with `atan2` rather than the `asin` on a normalized vector ArduPilot takes
1402/// (`libraries/AP_NavEKF3/AP_NavEKF3_core.cpp:527-536`, `368dc0c4`). The two agree; this
1403/// form never divides by `‖f‖`, so it needs neither the normalization nor the
1404/// `length() > 0.001` guard standing in front of it there, and a stopped or
1405/// disconnected accelerometer reading zero levels to zero rather than to NaN. PX4 builds
1406/// the same tilt differently again, as the shortest rotation carrying `f` onto `-e₃`
1407/// (`src/modules/ekf2/EKF/ekf.cpp:224`, `c4e4ef98`), which is this attitude with the yaw
1408/// left at zero; (6) needs the angles themselves.
1409pub(crate) fn level_from_accel(specific_force: Acceleration<Body>) -> (Radians, Radians) {
1410 let f = specific_force.vector();
1411 let roll = RealField::atan2(-f.y, -f.z);
1412 let pitch = RealField::atan2(f.x, ComplexField::sqrt(f.y * f.y + f.z * f.z));
1413 (Radians::from_radians(roll), Radians::from_radians(pitch))
1414}
1415
1416/// True heading from the averaged magnetic field, leveled by (5)'s roll and pitch.
1417/// Equation (6).
1418///
1419/// The leveling is what makes this a heading rather than a projection. `R₀` is (7)'s
1420/// attitude with the yaw left out, so `m̃` sits in a frame differing from NED by the yaw
1421/// alone. Reading `atan2(m_y, m_x)` off the body field instead is correct only for a
1422/// vehicle already level and wrong by about `tan(dip)` times the tilt everywhere else:
1423/// at the 1.107 rad of dip the corpus carries, that factor is 1.96, so a 10° roll is
1424/// worth 19° of heading. A test at zero tilt cannot tell the two apart.
1425///
1426/// `declination` is east-positive, and adding it is what turns magnetic heading into
1427/// true.
1428pub(crate) fn heading_from_mag(
1429 field: MagField<Body>,
1430 roll: Radians,
1431 pitch: Radians,
1432 declination: Radians,
1433) -> Radians {
1434 // R₀ = R_y(θ₀) R_x(φ₀), which is the ZYX composition of (7) with its yaw set to zero.
1435 let leveled =
1436 Rotation3::from_euler_angles(roll.as_radians(), pitch.as_radians(), 0.0) * field.vector();
1437 // Wrapped because `D_m − atan2(·)` reaches π + |D_m|, and (7) is read back as Euler
1438 // angles that a test compares against a heading in range.
1439 Radians::from_radians(wrap_pi(
1440 declination.as_radians() - RealField::atan2(leveled.y, leveled.x),
1441 ))
1442}
1443
1444/// What the window's two halves disagree about: the tilt between the attitudes they
1445/// yield, and the heading between them. The drift terms of equation (8′).
1446///
1447/// (5)–(6) commit one attitude for the whole window, and the filter propagates from the
1448/// window's **end**. A window whose halves disagree cannot vouch for either, and the
1449/// disagreement is how far the attitude moved while it was being averaged — measured on
1450/// the very vectors being averaged, by the very equations that average them.
1451///
1452/// For a turn at a steady rate it is the error exactly: the whole-window mean lands
1453/// mid-window, the state starts half a turn later at the end, and the two half-means sit
1454/// that same half-turn apart. For a vehicle that swings out and comes back it is the
1455/// excursion, which is what the net rotation `ω̄ · T` cannot see — that cancels to nothing
1456/// and charges nothing, while the attitude the window commits is the middle of an arc the
1457/// vehicle has already left.
1458///
1459/// Vibration averages out of each half, which is what keeps this off a vehicle sitting
1460/// with its props spinning: `2c42096b`'s 0.80 s window disagrees by 0.18° of tilt and 0.09°
1461/// of heading, both under the configured priors, where integrating `‖ω_⊥‖` over the same
1462/// window accumulates 0.66° — the peak's mistake in another coat, since a path length
1463/// does not average either.
1464///
1465/// The heading is `None` unless both halves carried a field. Declination cancels in the
1466/// difference, so none is applied.
1467fn window_drift(halves: &Halves) -> (f32, Option<f32>) {
1468 let [first, second] = halves.force;
1469 let tilt = angle_between(first.vector(), second.vector());
1470 let heading = halves.field.map(|[first_field, second_field]| {
1471 let of = |force: Acceleration<Body>, field| {
1472 let (roll, pitch) = level_from_accel(force);
1473 heading_from_mag(field, roll, pitch, Radians::ZERO)
1474 };
1475 wrap_pi(of(second, second_field).as_radians() - of(first, first_field).as_radians()).abs()
1476 });
1477 (tilt, heading)
1478}
1479
1480/// Angle between two vectors, and zero where either has no direction — the same answer
1481/// [`Measured::down`] gives for an average of zero, and for the same reason.
1482fn angle_between(first: Vector3<f32>, second: Vector3<f32>) -> f32 {
1483 let norms = first.norm() * second.norm();
1484 if norms <= 0.0 {
1485 return 0.0;
1486 }
1487 // Clamped because a dot product of parallel vectors overshoots 1 in f32, and `acos`
1488 // of 1.000001 is NaN.
1489 ComplexField::acos((first.dot(&second) / norms).clamp(-1.0, 1.0))
1490}
1491
1492/// How much a tilt error leaks into the heading of (6): `tan δ` of equation (8′), measured
1493/// off the window's own field rather than configured.
1494///
1495/// (6) levels `m̄` and takes the `atan2` of what is left horizontal, so an error in the
1496/// tilt it leveled by tips the field and turns that horizontal part. The leak is the
1497/// ratio of the field's vertical component to its horizontal one, which is `tan(dip)`:
1498/// 1.96 at the 1.107 rad of dip [`heading_from_mag`] cites, and 1.22 on the window
1499/// `2c42096b` starts from. Both components are taken about `down`, the direction (5)
1500/// leveled to, so no Euler angles enter and the ratio does not depend on the frame they
1501/// would be read in.
1502///
1503/// `None` where the horizontal part is zero: a field pointing straight down observes no
1504/// heading at any tilt, so there is no error to scale rather than an infinite one.
1505///
1506/// Shared with [`observation::mag`](crate::observation::mag), which needs the same `tan δ`
1507/// for the leveling variance of (36′), and the direction the field lies in as well: only
1508/// tilt about that horizontal direction leaks. The ratio is between the field and a
1509/// direction, so it is the same number in any frame the two are expressed in together:
1510/// the window passes the body-frame gravity direction, and the update passes the
1511/// body-frame navigation down axis.
1512pub(crate) fn heading_sensitivity(
1513 field: MagField<Body>,
1514 down: Vector3<f32>,
1515) -> Option<HeadingSensitivity> {
1516 let field = field.vector();
1517 let vertical = field.dot(&down);
1518 let horizontal = field - down * vertical;
1519 let norm = horizontal.norm();
1520 (norm > 0.0).then(|| HeadingSensitivity {
1521 tan_dip: vertical.abs() / norm,
1522 horizontal: horizontal / norm,
1523 })
1524}
1525
1526/// What [`heading_sensitivity`] measures of a field about a down axis.
1527#[derive(Clone, Copy, Debug, PartialEq)]
1528pub(crate) struct HeadingSensitivity {
1529 /// `tan δ`, the field's vertical component over its horizontal one.
1530 pub(crate) tan_dip: f32,
1531 /// `f̂`, the unit horizontal direction of the field, in the frame `down` was given in.
1532 pub(crate) horizontal: Vector3<f32>,
1533}
1534
1535/// Initial tilt and yaw standard deviations for an alignment: the attitude block of
1536/// equation (8).
1537///
1538/// A static start gets the configured figures. A coarse one gets what its own window
1539/// supports, equation (8′); see [`coarse_sigmas`].
1540///
1541/// `gyro_bias` is the one [`nominal_state`] committed from the same window, and the
1542/// coarse bound reads the window's rotation net of it. Passed rather than re-derived so
1543/// that the two cannot disagree about what the average held.
1544pub(crate) fn attitude_sigmas(
1545 init: &Initialization,
1546 alignment: Alignment,
1547 measured: &Measured,
1548 gyro_bias: AngularRate<Body>,
1549 gravity: f32,
1550) -> (Radians, Radians) {
1551 match alignment {
1552 Alignment::Static | Alignment::Seeded => (init.sigma_tilt, init.sigma_yaw),
1553 Alignment::Coarse(_) => coarse_sigmas(init, measured, gyro_bias, gravity),
1554 }
1555}
1556
1557/// What a window that is not a static interval supports. Equation (8′): the widest of the
1558/// configured tilt, how far the window's average is from gravity, how far the vehicle
1559/// turned across gravity, and how far its two halves disagree — with a heading the dip
1560/// scales that tilt into, plus the turn about gravity and the halves' own disagreement.
1561///
1562/// Equations (5)–(6) level the *averaged* specific force, so what bounds the attitude
1563/// they yield is how far that average is from what a still vehicle reads — not how far
1564/// the worst sample in the window was. The two differed by two orders of magnitude on the
1565/// moving window `2c42096b` offered at `PATIENCE` (#77): its peak `|f| − γ` was 5.46 m/s²
1566/// (31.9°) on a vehicle vibrating in place, while the mean vector sat 0.06 m/s² (0.33°)
1567/// off gravity and leveled to roll 0.42° and pitch −0.89°, under a degree off plumb.
1568/// Vibration averages out; a peak does not know that.
1569///
1570/// A prior that wide is not free. [`Status::Aligning`](crate::Status::Aligning) outranks
1571/// `Degraded`, so a start that cannot resolve masks every aiding transition behind it: on
1572/// that log the peak's 0.557 rad of tilt and full-circle yaw are worth 888 transitions
1573/// masked over 7127 s, where the averages resolve the start 0.20 s in.
1574///
1575/// Two measures of how far the attitude moved during the window, and both are needed
1576/// because each is blind to what the other catches. The net rotation `ω̄ · T` cancels for
1577/// a vehicle that swings out and comes back, and charges nothing where the attitude (5)
1578/// commits is the middle of an arc already left. The disagreement between the window's
1579/// halves never cancels — but it cannot see a *coordinated* turn, where the specific
1580/// force stays put in body axes while the vehicle banks, so every average in the window
1581/// agrees and the tilt is wrong by the bank angle. `moving_start` is that case, and it
1582/// is measured: on the drift alone the filter reads `nees_att=2.83` and 626 falsely valid
1583/// quantity-epochs — overconfident, the failure a bound exists to prevent — against 0.50
1584/// and 108 with both (`data/scenarios.txt`). A bound is not tighter for dropping the term
1585/// that was holding it up.
1586///
1587/// The rotation is `ω̄ − β̂_g`, not `ω̄`, because (7) has already taken part of that average
1588/// out of the measurement: a window at rest commits the whole of it as
1589/// [`State::gyro_bias`], so charging it again as motion bounds the attitude by an error
1590/// the state has just removed. A parked vehicle whose gyroscope reads 0.05 rad/s —
1591/// ordinary for a MEMS part, and well inside the 0.262 rad/s `max_gyro_rate` — over a
1592/// 1.6 s window that is coarse only for being short otherwise starts at 0.080 rad of
1593/// tilt against an [`Accuracy::tilt`](crate::Accuracy::tilt) of 0.052. That start never
1594/// resolves: the alignment latch only promotes, so
1595/// [`Status::Aligning`](crate::Status::Aligning) masks every aiding transition for the
1596/// flight — the same cost the averages above exist to avoid, from a vehicle that never
1597/// moved.
1598///
1599/// [`Coarse`]'s own payload is not read here. It reports why [`classify`] refused the
1600/// window as static, which is a question about peaks; this is a question about averages,
1601/// and the same window answers the two differently.
1602fn coarse_sigmas(
1603 init: &Initialization,
1604 measured: &Measured,
1605 gyro_bias: AngularRate<Body>,
1606 gravity: f32,
1607) -> (Radians, Radians) {
1608 // Small-angle, as (5) reads it: an average that is not gravity leans the leveled
1609 // vertical by the fraction of `γ` it is out by.
1610 let from_force = (measured.force.vector().norm() - gravity).abs() / gravity;
1611
1612 // What the gyroscope says the vehicle did, split about the vertical (5) leveled to:
1613 // rotation across gravity moves that vector and spoils the tilt, rotation about it
1614 // leaves the vector alone and spoils the heading of (6) instead.
1615 let net = (measured.rate.vector() - gyro_bias.vector()) * measured.span.as_secs();
1616 let down = measured.down();
1617 let across = down.map_or(net.norm(), |down| (net - down * net.dot(&down)).norm());
1618 let about = down.map_or(0.0, |down| net.dot(&down).abs());
1619
1620 // And what the window's own halves say, which is a different question with the same
1621 // units; see `window_drift`.
1622 let (tilt_drift, heading_drift) = measured.halves.as_ref().map_or((0.0, None), window_drift);
1623
1624 let tilt = init
1625 .sigma_tilt
1626 .as_radians()
1627 .max(from_force)
1628 .max(across)
1629 .max(tilt_drift);
1630
1631 // (6) levels the field by that tilt, so the tilt error leaks into the heading scaled
1632 // by the dip — and the heading has its own drift besides. Both saturate at the circle,
1633 // since `π/√3` is the widest standard deviation a heading can have and a bound past it
1634 // claims a spread the quantity does not have. A window no magnetometer observed gets
1635 // the circle outright, as does one whose field observes no heading to be wrong about.
1636 let circle = UNKNOWN_HEADING_SIGMA.as_radians();
1637 let yaw = down
1638 .zip(measured.field)
1639 .and_then(|(down, field)| heading_sensitivity(field, down))
1640 .map_or(circle, |sensitivity| {
1641 init.sigma_yaw
1642 .as_radians()
1643 .max(sensitivity.tan_dip * tilt)
1644 .max(about)
1645 .max(heading_drift.unwrap_or(0.0))
1646 .min(circle)
1647 });
1648
1649 (Radians::from_radians(tilt), Radians::from_radians(yaw))
1650}
1651
1652/// The initial covariance `P₀`. Equation (8).
1653///
1654/// Position, velocity and bias sigmas come from `init`; the attitude sigmas from
1655/// [`attitude_sigmas`], because they depend on how good the alignment was.
1656///
1657/// Tilt and yaw are uncertainties about navigation axes, and the block they land in is the
1658/// covariance of the body-frame `δθ`, so they are rotated in through `attitude`, the
1659/// alignment's own. Diagonal only at level: on a vehicle standing on its tail, body x points
1660/// up and the yaw prior belongs on `δθ_x`.
1661///
1662/// The tilt and the accelerometer bias are **one error**, not two. (5) levels the window's
1663/// average specific force, which reads a horizontal accelerometer bias as gravity leaning, so
1664/// the committed attitude is wrong by `δθ = −[d̂]× δβa / γ` with `d̂` navigation down in body
1665/// axes (EQUATIONS.md, (8)). A block-diagonal `P₀` says the two are independent: when
1666/// velocity fusion then learns the bias, nothing tells the tilt it caused to follow, and the
1667/// attitude is overconfident by exactly the share of it the bias explains. Measured on
1668/// `harsh_imu`, 50 seeds with every source fused white (`Correlation::WHITE`, where the
1669/// inflation of (24′) cannot hide it): 2281 epochs over the family-wise bound with the
1670/// block diagonal, 214 at the wider [`Initialization::sigma_accel_bias`] alone, none with
1671/// the correlation as well.
1672///
1673/// So the tilt prior is the bias's share, `σ_βa² / γ²`, carried as correlation, plus an
1674/// independent share of `max(σ_tilt² − σ_βa² / γ², level)`: `sigma_tilt` is the whole tilt
1675/// uncertainty where it covers the bias, and a bias prior wider than it raises it rather than
1676/// being claimed away. Adding all of `σ_tilt²` instead counts the bias's share twice, and it
1677/// costs: `f16771dd` lost its unaided tilt at 3.41 s against 3.84, and `tilt` rose on ten of
1678/// the eleven scenarios (`harsh_imu` 1.022° against 0.828).
1679///
1680/// `level` is the window's own [`Measured::level_scatter`] over `γ²`, and it is what keeps the
1681/// independent share from reaching zero. At the defaults the bias's share, 0.0204 rad, is
1682/// over `sigma_tilt`'s 0.02, so without it `P₀` would claim every tilt error is the bias and
1683/// be singular across the two tilt directions: once velocity fusion knew the bias, nothing
1684/// would be left for the vibration or noise the average leveled through. A window of one
1685/// measures no scatter, and keeps the whole of `σ_tilt²` independent instead.
1686///
1687/// Writing the blocks after construction costs a copy of `P`, on a chain that stays well under
1688/// `update`'s, so the crate's peak does not move ([measured]).
1689///
1690/// [measured]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#measured-cost-by-function
1691pub(crate) fn initial_covariance(
1692 init: &Initialization,
1693 attitude: &Attitude,
1694 sigma_tilt: Radians,
1695 sigma_yaw: Radians,
1696 level_scatter: Option<f64>,
1697 gyro_bias: Vector3<f32>,
1698 gravity: f32,
1699) -> Covariance {
1700 let position = init.sigma_position.as_meters();
1701 let velocity = init.sigma_velocity.as_m_per_s();
1702 let (tilt, yaw) = (sigma_tilt.as_radians(), sigma_yaw.as_radians());
1703 let accel_bias = init.sigma_accel_bias.as_m_per_s2();
1704 // In the `ErrorState` ordering: `[δp δv δθ δβa δβg]`.
1705 #[rustfmt::skip]
1706 let sigmas = [
1707 position, position, position,
1708 velocity, velocity, velocity,
1709 0.0, 0.0, 0.0,
1710 accel_bias, accel_bias, accel_bias,
1711 gyro_bias.x, gyro_bias.y, gyro_bias.z,
1712 ];
1713 let explained = accel_bias / gravity;
1714 let gamma = f64::from(gravity);
1715 let level_variance = level_scatter.map(|scatter| (scatter / (gamma * gamma)) as f32);
1716 let independent = level_variance.map_or(tilt * tilt, |level| {
1717 (tilt * tilt - explained * explained).max(level)
1718 });
1719 let tilt_variance = explained * explained + independent;
1720 let mut covariance = Covariance::from_sigmas(sigmas);
1721 covariance.set_attitude_block(
1722 AttitudeVariance {
1723 tilt_north: tilt_variance,
1724 tilt_east: tilt_variance,
1725 heading: yaw * yaw,
1726 }
1727 .in_body(attitude),
1728 );
1729 // `P_θβa = E[δθ δβaᵀ] = −[d̂]× σ_βa² / γ`, from `δθ = −[d̂]× δβa / γ` and `δβa` of
1730 // variance `σ_βa² I`. Horizontal only: `[d̂]×` has no component along `d̂`, and a bias
1731 // along gravity moves `‖f̄‖`, not the direction (5) levels to.
1732 let down = attitude
1733 .quaternion()
1734 .inverse_transform_vector(&Vector3::z());
1735 covariance.set_attitude_accel_bias_block(skew(down) * (-accel_bias * explained));
1736 covariance
1737}
1738
1739/// The gyroscope bias a start commits, (7), and the standard deviation (8) gives it per body
1740/// axis; [`Measured::gyro_bias`] says how both are taken.
1741#[derive(Clone, Copy, Debug, PartialEq)]
1742pub(crate) struct GyroBias {
1743 /// `β̂_g,0`.
1744 pub estimate: AngularRate<Body>,
1745 /// `σ_βg,0` per body axis.
1746 pub sigmas: Vector3<f32>,
1747}
1748
1749/// Standard deviation of a heading known only to lie somewhere on the circle: `π / √3`,
1750/// the standard deviation of a uniform distribution over `[-π, π]`.
1751///
1752/// Honest, and at the same time a number the error state cannot really carry — the
1753/// three-component attitude error of equation (2) is a small-angle quantity, and a yaw
1754/// error of a radian is not small. It stands in until a heading source arrives, and the
1755/// right response to that source is a yaw **reset** rather than a gradual correction.
1756/// This is why PX4 and ArduPilot align yaw with a bank of hypotheses rather than one wide
1757/// prior; see `GOALS.md`.
1758const UNKNOWN_HEADING_SIGMA: Radians = Radians::from_radians(1.813_799_4);
1759
1760/// Whether the window's peak motion is within the tolerances that make it a still one.
1761///
1762/// Half of what [`classify`] asks, and all of what the barometric reference and what the
1763/// start establishes ask, which is why it is separate: a window can be too short to align
1764/// an attitude from and still be a window of a vehicle sitting on the ground, which is
1765/// what [`Coarse::WindowTooShort`] reports.
1766///
1767/// GNSS velocity does not enter, however well it explains the specific force. The things
1768/// that hang on this answer — [`Alignment::Static`], the barometric reference, and
1769/// whether position and velocity were established at all — all mean *the vehicle was on
1770/// the ground*, and a deck accelerating under it is not that however precisely the
1771/// acceleration is known.
1772///
1773/// It takes the peaks rather than a [`Measured`] so that [`StaticWindow::is_at_rest`] can ask
1774/// it after every sample without measuring the window.
1775pub(crate) fn at_rest(peaks: Peaks, init: &Initialization, gravity: f32) -> bool {
1776 peaks.gyro <= init.max_gyro_rate && peaks.deviation(gravity) <= init.max_accel_deviation
1777}
1778
1779#[cfg(test)]
1780pub(crate) mod tests {
1781 use std::format;
1782
1783 use super::*;
1784 use crate::config::GRAVITY;
1785 use crate::state::ErrorState;
1786
1787 /// What a buffered window measures: every sample pushed in order, as
1788 /// [`StaticWindow::try_from`] does.
1789 fn measure(window: &[StaticSample]) -> Result<Measured, InitError> {
1790 StaticWindow::try_from(window)?.measured()
1791 }
1792
1793 /// `α₀` and its variance over `window`, each sample `DT` after the last.
1794 fn alpha0(window: &[StaticSample]) -> Option<(Altitude, f32)> {
1795 StaticWindow::try_from(spaced(window, DT).as_slice())
1796 .expect("a usable window")
1797 .alpha0()
1798 }
1799
1800 /// `ā_n` over a window already spaced.
1801 fn inertial_acceleration(window: &[StaticSample]) -> Option<Acceleration<Ned>> {
1802 measure(window).expect("a usable window").inertial_accel
1803 }
1804
1805 #[test]
1806 fn a_coarse_start_reads_as_what_was_measured() {
1807 let short = Alignment::Coarse(Coarse::WindowTooShort {
1808 required: Seconds::from_secs(2.0),
1809 provided: Seconds::from_secs(0.8),
1810 });
1811 assert_eq!(
1812 format!("{short}"),
1813 "coarse, window of 0.800 s, 2.000 s required"
1814 );
1815 assert_eq!(
1816 format!(
1817 "{}",
1818 InitError::InvalidInterval {
1819 interval: Seconds::from_secs(-0.0025)
1820 }
1821 ),
1822 "initialization interval of -0.003 s is not usable"
1823 );
1824 }
1825
1826 /// A sample from a vehicle genuinely sitting still: no rotation, gravity the only
1827 /// specific force. `StaticSample::default()` is not this — its zero acceleration is
1828 /// a full `g` away from anything the world does — so the stationarity check reads it
1829 /// as motion, correctly.
1830 pub(crate) fn still() -> StaticSample {
1831 StaticSample {
1832 imu: ImuSample::reading(
1833 AngularRate::body(0.0, 0.0, 0.0),
1834 Acceleration::body(0.0, 0.0, -GRAVITY),
1835 ),
1836 ..StaticSample::default()
1837 }
1838 }
1839
1840 /// 8 samples at 4 Hz: exactly the default 2 s `min_duration`.
1841 const DT: Seconds = Seconds::from_secs(0.25);
1842
1843 /// `window` with each sample integrated over `dt` and timed `dt` after the last, the
1844 /// first ending at `dt`: the fixtures are written as rates, and this is where they get a
1845 /// clock.
1846 pub(crate) fn spaced(window: &[StaticSample], dt: Seconds) -> std::vec::Vec<StaticSample> {
1847 let mut time = Timestamp::ZERO;
1848 window
1849 .iter()
1850 .map(|sample| {
1851 time = time.after(dt);
1852 StaticSample {
1853 imu: sample.imu.timed(time, dt),
1854 ..*sample
1855 }
1856 })
1857 .collect()
1858 }
1859
1860 fn classify_default(window: &[StaticSample], dt: Seconds) -> Result<Alignment, InitError> {
1861 Ok(classify(
1862 &measure(&spaced(window, dt))?,
1863 &Initialization::default(),
1864 GRAVITY,
1865 ))
1866 }
1867
1868 /// The nominal state a window yields at the default 4 Hz.
1869 fn nominal(window: &[StaticSample], declination: Radians, at_rest: bool) -> State {
1870 let measured = measure(&spaced(window, DT)).expect("a usable window");
1871 let bias = measured.gyro_bias(at_rest, &Config::default()).estimate;
1872 nominal_state(&measured, declination, bias)
1873 }
1874
1875 /// The tilt and yaw sigmas a window would start with, in radians.
1876 ///
1877 /// The gyroscope bias comes from (7) on the same window, the way `apply_alignment`
1878 /// supplies it, so a test sees the pair as the filter commits them.
1879 fn sigmas(window: &[StaticSample], dt: Seconds) -> (f32, f32) {
1880 let init = Initialization::default();
1881 let measured = measure(&spaced(window, dt)).expect("a usable window");
1882 let bias = measured
1883 .gyro_bias(at_rest(measured.peaks, &init, GRAVITY), &Config::default())
1884 .estimate;
1885 let state = nominal_state(&measured, Radians::ZERO, bias);
1886 let (tilt, yaw) = attitude_sigmas(
1887 &init,
1888 classify(&measured, &init, GRAVITY),
1889 &measured,
1890 state.gyro_bias,
1891 GRAVITY,
1892 );
1893 (tilt.as_radians(), yaw.as_radians())
1894 }
1895
1896 /// The tilt sigma alone, at the default 4 Hz, which most cases here are about.
1897 fn coarse_tilt(window: &[StaticSample]) -> f32 {
1898 sigmas(window, DT).0
1899 }
1900
1901 /// `tan` of the dip [`INCLINATION`] writes, which is what a tilt error costs a
1902 /// heading. No `f32::tan` here: the crate is `no_std`.
1903 fn dip_gain() -> f32 {
1904 let (sin, cos) = ComplexField::sin_cos(INCLINATION);
1905 sin / cos
1906 }
1907
1908 /// Magnetic inclination, rad, down-positive: the mid-latitude dip the corpus carries
1909 /// and `examples/simulate.rs` writes its field with.
1910 const INCLINATION: f32 = 1.107;
1911
1912 /// Both signs on each axis, and the two together. A down-positive convention inverts
1913 /// one sign at a time, so a case list that leans one way passes with the sign flipped.
1914 const TILTS: [(f32, f32); 6] = [
1915 (0.0, 0.0),
1916 (0.3, 0.0),
1917 (-0.3, 0.0),
1918 (0.0, 0.2),
1919 (0.0, -0.2),
1920 (0.4, -0.25),
1921 ];
1922
1923 /// `R = Rz(ψ) Ry(θ) Rx(φ)`, the attitude of equation (7).
1924 fn attitude_of(roll: f32, pitch: f32, yaw: f32) -> Rotation3<f32> {
1925 Rotation3::from_euler_angles(roll, pitch, yaw)
1926 }
1927
1928 /// What a vehicle held at this attitude and sitting still reads on its
1929 /// accelerometer: equation (11) with `a_n = 0`, so `f = Rᵀ(−g)`.
1930 pub(crate) fn gravity_at(roll: f32, pitch: f32, yaw: f32) -> Acceleration<Body> {
1931 let f = attitude_of(roll, pitch, yaw).inverse() * Vector3::new(0.0, 0.0, -GRAVITY);
1932 Acceleration::from_vector(f)
1933 }
1934
1935 /// What it reads on its magnetometer, for a site whose field dips by [`INCLINATION`]
1936 /// and points `declination` east of true north.
1937 fn field_at(roll: f32, pitch: f32, yaw: f32, declination: f32) -> MagField<Body> {
1938 let (sin_dip, cos_dip) = ComplexField::sin_cos(INCLINATION);
1939 let north = attitude_of(0.0, 0.0, declination) * Vector3::new(cos_dip, 0.0, sin_dip);
1940 MagField::from_vector(attitude_of(roll, pitch, yaw).inverse() * north)
1941 }
1942
1943 /// Eight samples of a vehicle turning at a steady rate about one body axis, each
1944 /// reading the gravity and the field that attitude produces.
1945 ///
1946 /// Consistent by construction, which the drift terms of (8′) need: [`window_drift`]
1947 /// reads how far the attitude moved off the specific force and the field themselves,
1948 /// so a gyroscope sample set without moving the accelerometer under it describes a
1949 /// vehicle that is not turning — which is what it is.
1950 ///
1951 /// Over eight samples at [`DT`] the half-means sit at 0.375 and 1.375 sample periods,
1952 /// so a rate of `ω` drifts by exactly `ω · 1 s` between them, whatever the axis.
1953 pub(crate) fn turning(roll_rate: f32, pitch_rate: f32, yaw_rate: f32) -> [StaticSample; 8] {
1954 let mut window = [still(); 8];
1955 for (index, sample) in window.iter_mut().enumerate() {
1956 let t = index as f32 * DT.as_secs();
1957 let (roll, pitch, yaw) = (roll_rate * t, pitch_rate * t, yaw_rate * t);
1958 sample.imu = sample.imu.with_accel(gravity_at(roll, pitch, yaw));
1959 // Exact while one axis turns at a time, which is all this builds.
1960 sample.imu = sample
1961 .imu
1962 .with_gyro(AngularRate::body(roll_rate, pitch_rate, yaw_rate));
1963 sample.mag = Some(field_at(roll, pitch, yaw, 0.0));
1964 }
1965 window
1966 }
1967
1968 /// A still window of `[still(); 8]` reading this specific force and this field.
1969 fn window_at(roll: f32, pitch: f32, yaw: f32, declination: f32) -> [StaticSample; 8] {
1970 [StaticSample {
1971 imu: still().imu.with_accel(gravity_at(roll, pitch, yaw)),
1972 mag: Some(field_at(roll, pitch, yaw, declination)),
1973 ..still()
1974 }; 8]
1975 }
1976
1977 #[test]
1978 fn the_tilt_prior_predicts_the_level_error_a_biased_accelerometer_causes() {
1979 // What (8)'s cross-covariance claims, checked against what (5) actually commits:
1980 // told the bias, `P₀` must predict the tilt error, `E[δθ | δβa] = P_θβa P_βaβa⁻¹ δβa`.
1981 // Flipping the sign of `P_θβa` fails every case here; so does leaving it out.
1982 let init = Initialization::default();
1983 let variance = init.sigma_accel_bias.as_m_per_s2().powi(2);
1984 let bias = Vector3::new(0.15, -0.1, 0.05);
1985 for (roll, pitch) in TILTS {
1986 let truth = attitude_of(roll, pitch, 0.7);
1987 let window = [StaticSample {
1988 imu: still().imu.with_accel(Acceleration::from_vector(
1989 gravity_at(roll, pitch, 0.7).vector() + bias,
1990 )),
1991 mag: Some(field_at(roll, pitch, 0.7, 0.0)),
1992 ..still()
1993 }; 8];
1994 let state = nominal(&window, Radians::ZERO, true);
1995 let committed = state.attitude.quaternion();
1996 // `q = q̂ ⊗ δq`, the local error of equation (2).
1997 let error =
1998 (committed.inverse() * UnitQuaternion::from_rotation_matrix(&truth)).scaled_axis();
1999
2000 let measured = measure(&spaced(&window, DT)).expect("a usable window");
2001 let p = initial_covariance(
2002 &init,
2003 &state.attitude,
2004 init.sigma_tilt,
2005 init.sigma_yaw,
2006 measured.level_scatter,
2007 Vector3::repeat(init.sigma_gyro_bias.as_rad_per_s()),
2008 GRAVITY,
2009 );
2010 let theta = ErrorState::AttitudeX.index();
2011 let beta = ErrorState::AccelBiasX.index();
2012 let predicted = p.as_matrix().fixed_view::<3, 3>(theta, beta) * bias / variance;
2013
2014 // (5) observes no heading, so only the error across gravity is predictable.
2015 let down = committed.inverse_transform_vector(&Vector3::z());
2016 let across = |v: Vector3<f32>| v - down * v.dot(&down);
2017 assert!(
2018 (across(error) - across(predicted)).norm() < 2e-4,
2019 "roll {roll} pitch {pitch}: leveled {:?}, predicted {:?}",
2020 across(error),
2021 across(predicted)
2022 );
2023 }
2024 }
2025
2026 #[test]
2027 fn the_level_variance_is_the_scatter_across_gravity_over_the_count() {
2028 // ±0.5 m/s² on body x, alternating, so the mean is gravity and the sample variance
2029 // across it is 0.25 · 8/7. Half of it per horizontal axis, over 8 samples, over γ².
2030 // Scatter along gravity is not a tilt: the same shake on z adds nothing.
2031 let mut window = [still(); 8];
2032 for (index, sample) in window.iter_mut().enumerate() {
2033 let shake = if index % 2 == 0 { 0.5 } else { -0.5 };
2034 sample.imu = sample
2035 .imu
2036 .with_accel(Acceleration::body(shake, 0.0, -GRAVITY + shake));
2037 }
2038 let level = measure(&spaced(&window, DT))
2039 .expect("a usable window")
2040 .level_scatter
2041 .expect("eight samples scatter");
2042 let expected = 0.25 * 8.0 / 7.0 / 2.0 / 8.0;
2043 assert!(
2044 (level - expected).abs() < 1e-3 * expected,
2045 "expected {expected}, got {level}"
2046 );
2047 assert_eq!(
2048 measure(&spaced(&[still()], DT))
2049 .expect("one sample")
2050 .level_scatter,
2051 None
2052 );
2053 }
2054
2055 /// `window`, each sample `dt` long, with the gyroscope reading `rate(index)`.
2056 fn rates(n: usize, dt: f32, rate: impl Fn(usize) -> [f32; 3]) -> Measured {
2057 let window: std::vec::Vec<_> = (0..n)
2058 .map(|index| {
2059 let [x, y, z] = rate(index);
2060 StaticSample {
2061 imu: still().imu.with_gyro(AngularRate::body(x, y, z)),
2062 ..still()
2063 }
2064 })
2065 .collect();
2066 measure(&spaced(&window, Seconds::from_secs(dt))).expect("a usable window")
2067 }
2068
2069 #[test]
2070 fn a_still_window_weighs_its_mean_rate_by_the_scatter_of_its_blocks() {
2071 // 2.03 s at 100 Hz: forty closed 50 ms blocks, each holding one rate about `c`, the
2072 // blocks alternating in sign, and three samples left open; the worst axis is in the
2073 // middle. The closed blocks' scatter gives `N̂² = b² · 0.05 · 40/39`, the mean's
2074 // variance divides it by all 2.03 s and scales it by 39/37, and `ω̄` counts the open
2075 // samples too. One sample's scatter over the count, taking each block's five samples for
2076 // five readings, would say 2.3 times too little; dividing by the closed 2.0 s, a little
2077 // too much.
2078 let (b, c) = ([1e-3f32, 4e-3, 2e-3], 3e-3f32);
2079 let sign = |index: usize| {
2080 if (index / 5).is_multiple_of(2) {
2081 1.0
2082 } else {
2083 -1.0
2084 }
2085 };
2086 let measured = rates(203, 0.01, |index| b.map(|b| c + sign(index) * b));
2087 let config = Config::default();
2088 let prior = f64::from(config.init.sigma_gyro_bias.as_rad_per_s()).powi(2);
2089 let floor = f64::from(EARTH_RATE.as_rad_per_s());
2090 let bias = measured.gyro_bias(true, &config);
2091 for (axis, b) in b.into_iter().enumerate() {
2092 let b = f64::from(b);
2093 let r = b * b * 0.05 * 40.0 / 39.0 * 39.0 / 37.0 / 2.03;
2094 let k = prior / (prior + r);
2095 let mean = f64::from(c) + b * 0.03 / 2.03;
2096 let sigma = (k * r + floor * floor).sqrt();
2097 assert!(
2098 (f64::from(bias.sigmas[axis]) - sigma).abs() < 1e-3 * sigma,
2099 "axis {axis}: sigma {sigma}, got {}",
2100 bias.sigmas[axis]
2101 );
2102 let estimate = f64::from(bias.estimate.vector()[axis]);
2103 assert!(
2104 (estimate - k * mean).abs() < 1e-3 * mean,
2105 "axis {axis}: estimate {}, got {estimate}",
2106 k * mean
2107 );
2108 }
2109
2110 // In motion the window measured no bias, and the prior stands.
2111 let moving = measured.gyro_bias(false, &config);
2112 assert_eq!(moving.estimate, AngularRate::zero());
2113 assert_eq!(
2114 moving.sigmas,
2115 Vector3::repeat(config.init.sigma_gyro_bias.as_rad_per_s())
2116 );
2117 }
2118
2119 #[test]
2120 fn a_window_of_few_blocks_is_charged_for_how_little_it_knows_its_scatter() {
2121 // Four 50 ms blocks alternating ±b: `N̂² = b² · 0.05 · 4/3`, and the mean's variance
2122 // scales it by 3/1, the `(J − 1)/(J − 3)` that makes the expected NEES one. Three
2123 // blocks measure nothing, and the configured density stands in.
2124 let b = 2e-3f64;
2125 let config = Config::default();
2126 let prior = f64::from(config.init.sigma_gyro_bias.as_rad_per_s()).powi(2);
2127 let floor = f64::from(EARTH_RATE.as_rad_per_s());
2128 let alternating = |index: usize| [(if index.is_multiple_of(2) { b } else { -b }) as f32; 3];
2129 let sigma = |r: f64| (prior * r / (prior + r) + floor * floor).sqrt();
2130
2131 let four = rates(4, 0.05, alternating).gyro_bias(true, &config);
2132 let expected = sigma(b * b * 0.05 * 4.0 / 3.0 * 3.0 / 0.2);
2133 assert!(
2134 (f64::from(four.sigmas.x) - expected).abs() < 1e-3 * expected,
2135 "{} against {expected}",
2136 four.sigmas.x
2137 );
2138
2139 let three = rates(3, 0.05, alternating).gyro_bias(true, &config);
2140 let white = f64::from(config.imu.gyro_white);
2141 let expected = sigma(white * white / 0.15);
2142 assert!(
2143 (f64::from(three.sigmas.x) - expected).abs() < 1e-3 * expected,
2144 "{} against {expected}",
2145 three.sigmas.x
2146 );
2147 }
2148
2149 #[test]
2150 fn one_sample_is_weighed_by_its_own_noise_and_left_mostly_to_the_prior() {
2151 // A 5 ms reading under the configured density is worth 0.21 rad/s, twenty times the
2152 // prior: weighed, the bias stays near zero and the prior near itself, where taking the
2153 // reading whole would start the bias 0.2 rad/s uncertain.
2154 let config = Config::default();
2155 let measured = rates(1, 0.005, |_| [0.05; 3]);
2156 let bias = measured.gyro_bias(true, &config);
2157 let prior = config.init.sigma_gyro_bias.as_rad_per_s();
2158 assert!(
2159 bias.sigmas.x < prior && bias.sigmas.x > 0.99 * prior,
2160 "{:?}",
2161 bias.sigmas
2162 );
2163 assert!(bias.estimate.vector().x.abs() < 1e-3, "{:?}", bias.estimate);
2164 }
2165
2166 #[test]
2167 fn the_mean_rate_weighs_each_sample_by_the_time_it_integrated() {
2168 // 0.01 rad/s for 1 ms and nothing for 9 ms, alternating: 0.001 over the time, where an
2169 // average of the samples' rates says 0.005.
2170 let window: std::vec::Vec<_> = (0..40)
2171 .map(|index| {
2172 let (rate, dt) = if index % 2 == 0 {
2173 (0.01, 0.001)
2174 } else {
2175 (0.0, 0.009)
2176 };
2177 StaticSample {
2178 imu: still()
2179 .imu
2180 .with_gyro(AngularRate::body(rate, rate, rate))
2181 .timed(
2182 Timestamp::from_micros(index as u64 * 10_000 + 10_000),
2183 Seconds::from_secs(dt),
2184 ),
2185 ..still()
2186 }
2187 })
2188 .collect();
2189 let rate = measure(&window).expect("a usable window").rate.vector().x;
2190 assert!((rate - 0.001).abs() < 1e-6, "got {rate}");
2191 }
2192
2193 #[test]
2194 fn a_scattered_window_keeps_tilt_uncertain_once_the_bias_is_known() {
2195 // At the defaults the bias explains all of `sigma_tilt`, so the tilt variance left
2196 // once the bias is known is the window's own scatter and nothing else. Zero there is
2197 // the singular `P₀` that claims every level error is the bias.
2198 let init = Initialization::default();
2199 let level = 4e-6;
2200 let p = initial_covariance(
2201 &init,
2202 &Attitude::default(),
2203 init.sigma_tilt,
2204 init.sigma_yaw,
2205 // The scatter whose tilt is `level`.
2206 Some(f64::from(level) * f64::from(GRAVITY) * f64::from(GRAVITY)),
2207 Vector3::repeat(init.sigma_gyro_bias.as_rad_per_s()),
2208 GRAVITY,
2209 );
2210 let m = p.as_matrix();
2211 let (theta, beta) = (
2212 ErrorState::AttitudeX.index(),
2213 ErrorState::AccelBiasX.index(),
2214 );
2215 let p_tb = m.fixed_view::<3, 3>(theta, beta);
2216 let p_bb = m.fixed_view::<3, 3>(beta, beta);
2217 let given_bias = m.fixed_view::<3, 3>(theta, theta)
2218 - p_tb * p_bb.try_inverse().expect("a bias prior") * p_tb.transpose();
2219 for axis in 0..2 {
2220 let left = given_bias[(axis, axis)];
2221 assert!((left - level).abs() < 1e-7, "axis {axis}: {left}");
2222 }
2223 }
2224
2225 /// The roll, pitch and yaw equation (7) committed, in radians.
2226 fn committed_angles(state: &State) -> (f32, f32, f32) {
2227 state.attitude.euler_angles()
2228 }
2229
2230 #[test]
2231 fn leveling_recovers_the_tilt_gravity_was_rotated_by() {
2232 for (roll, pitch) in TILTS {
2233 // A yaw the answer must not depend on: gravity says nothing about rotation
2234 // about itself, so `Rz(ψ)ᵀ` leaves the specific force where it was.
2235 let (measured_roll, measured_pitch) = level_from_accel(gravity_at(roll, pitch, 0.9));
2236 assert!(
2237 (measured_roll.as_radians() - roll).abs() < 1e-5
2238 && (measured_pitch.as_radians() - pitch).abs() < 1e-5,
2239 "({roll}, {pitch}) read back as ({:?}, {:?})",
2240 measured_roll,
2241 measured_pitch
2242 );
2243 }
2244 }
2245
2246 #[test]
2247 fn the_committed_attitude_carries_the_specific_force_back_onto_gravity() {
2248 // (5) and (7) round-tripped: the attitude the filter commits must rotate the
2249 // vector it was derived from back onto `−g`, or the two disagree about which
2250 // sequence they mean.
2251 for (roll, pitch) in TILTS {
2252 let window = window_at(roll, pitch, 0.0, 0.0);
2253 let state = nominal(&window, Radians::ZERO, true);
2254 let navigation = state.attitude.quaternion() * window[0].imu.specific_force().vector();
2255 assert!(
2256 (navigation - Vector3::new(0.0, 0.0, -GRAVITY)).norm() < 1e-4,
2257 "({roll}, {pitch}) rotated back to {navigation:?}"
2258 );
2259 }
2260 }
2261
2262 #[test]
2263 fn heading_survives_the_tilt_it_is_leveled_by() {
2264 // The test that fails if the leveling is dropped: a field synthesized for a
2265 // known yaw at a tilt that is not zero must still give that yaw back.
2266 const DECLINATION: f32 = -0.06;
2267 for (roll, pitch) in TILTS {
2268 for yaw in [0.0, 0.9, -2.5, 3.0] {
2269 let state = nominal(
2270 &window_at(roll, pitch, yaw, DECLINATION),
2271 Radians::from_radians(DECLINATION),
2272 true,
2273 );
2274 let (_, _, committed) = committed_angles(&state);
2275 assert!(
2276 wrap_pi(committed - yaw).abs() < 1e-4,
2277 "yaw {yaw} at ({roll}, {pitch}) read back as {committed}"
2278 );
2279 }
2280 }
2281 }
2282
2283 #[test]
2284 fn an_unleveled_heading_is_wrong_by_the_dip() {
2285 // What the leveling is worth, and why a zero-tilt test proves nothing: at 10°
2286 // of roll the raw body field gives a heading 19° from the true one, because the
2287 // dip leans into the horizontal axes. `tan(1.107)` is 1.96.
2288 let roll = 10.0f32.to_radians();
2289 let field = field_at(roll, 0.0, 0.0, 0.0).vector();
2290 let unleveled = RealField::atan2(field.y, field.x);
2291 assert!(
2292 (unleveled.to_degrees().abs() - 19.1).abs() < 0.1,
2293 "expected about 19 deg of error, got {}",
2294 unleveled.to_degrees()
2295 );
2296
2297 let leveled = heading_from_mag(
2298 MagField::from_vector(field),
2299 Radians::from_radians(roll),
2300 Radians::ZERO,
2301 Radians::ZERO,
2302 );
2303 assert!(leveled.as_radians().abs() < 1e-6, "{leveled:?}");
2304 }
2305
2306 #[test]
2307 fn declination_moves_the_heading_by_exactly_itself() {
2308 // The same field, read at two sites: true heading differs from magnetic by the
2309 // declination and by nothing else.
2310 let (roll, pitch) = (0.2, -0.1);
2311 let field = field_at(roll, pitch, 0.7, 0.0);
2312 let heading = |declination: f32| {
2313 heading_from_mag(
2314 field,
2315 Radians::from_radians(roll),
2316 Radians::from_radians(pitch),
2317 Radians::from_radians(declination),
2318 )
2319 .as_radians()
2320 };
2321 assert!((heading(0.35) - heading(0.0) - 0.35).abs() < 1e-6);
2322 assert!((heading(-0.06) - heading(0.0) + 0.06).abs() < 1e-6);
2323 }
2324
2325 #[test]
2326 fn a_window_with_no_magnetometer_keeps_a_heading_of_zero() {
2327 // Stillness observes tilt and never the rotation about it. Zero is a stated
2328 // direction rather than a measured one, which `Unestablished::heading` records.
2329 let state = nominal(&[still(); 8], Radians::from_radians(0.35), true);
2330 assert_eq!(committed_angles(&state).2, 0.0);
2331 }
2332
2333 #[test]
2334 fn a_still_window_takes_its_gyroscope_bias_from_the_average() {
2335 // A gyroscope reading a constant offset while the vehicle does not turn is
2336 // reading its own bias, and at rest that is the one place it is observable.
2337 // About the offset by ±10⁻⁴ rad/s, so the window measures how well it knows it.
2338 let offset = AngularRate::body(0.01, -0.02, 0.003);
2339 let window: [StaticSample; 8] = core::array::from_fn(|index| {
2340 let noise = if index % 2 == 0 { 1e-4 } else { -1e-4 };
2341 StaticSample {
2342 imu: still()
2343 .imu
2344 .with_gyro(AngularRate::from_vector(offset.vector().add_scalar(noise))),
2345 ..still()
2346 }
2347 });
2348 let state = nominal(&window, Radians::ZERO, true);
2349 assert!(
2350 (state.gyro_bias.vector() - offset.vector()).norm() < 1e-5,
2351 "{:?}",
2352 state.gyro_bias
2353 );
2354 }
2355
2356 #[test]
2357 fn a_window_taken_in_motion_takes_no_gyroscope_bias_at_all() {
2358 // The average is the vehicle turning, not the sensor lying, and seeding it would
2359 // subtract a turn rate from every later measurement as a sensor error.
2360 let window = [StaticSample {
2361 imu: still().imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0)),
2362 ..still()
2363 }; 8];
2364 let state = nominal(&window, Radians::ZERO, false);
2365 assert_eq!(state.gyro_bias, AngularRate::zero());
2366 }
2367
2368 #[test]
2369 fn a_still_window_of_min_duration_is_static() {
2370 assert_eq!(classify_default(&[still(); 8], DT), Ok(Alignment::Static));
2371 }
2372
2373 #[test]
2374 fn the_static_window_is_measured_in_seconds_not_samples() {
2375 // The same 8 samples, now spanning 0.8 s instead of 2 s.
2376 let alignment = classify_default(&[still(); 8], Seconds::from_secs(0.1));
2377 assert!(matches!(
2378 alignment,
2379 Ok(Alignment::Coarse(Coarse::WindowTooShort { .. }))
2380 ));
2381 }
2382
2383 #[test]
2384 fn a_short_window_that_moved_is_not_stationary_rather_than_too_short() {
2385 // Motion is measured before length, so `WindowTooShort` only ever means still.
2386 let mut window = [still(); 8];
2387 window[3].imu = window[3].imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
2388 let alignment = classify_default(&window, Seconds::from_secs(0.1));
2389 let Ok(Alignment::Coarse(Coarse::NotStationary { span, .. })) = alignment else {
2390 panic!("0.8 s and moving: {alignment:?}");
2391 };
2392 assert!((span.as_secs() - 0.8).abs() < 1e-6, "got {span:?}");
2393 }
2394
2395 #[test]
2396 fn a_moving_window_is_coarse_and_reports_what_it_measured() {
2397 let mut window = [still(); 8];
2398 window[3].imu = window[3].imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
2399 let alignment = classify_default(&window, DT).expect("moving, not unusable");
2400 let Alignment::Coarse(Coarse::NotStationary { peak_gyro, .. }) = alignment else {
2401 panic!("0.4 rad/s is over the 0.262 default: {alignment:?}");
2402 };
2403 let peak_gyro = peak_gyro.as_rad_per_s();
2404 assert!((peak_gyro - 0.4).abs() < 1e-6, "got {peak_gyro}");
2405 }
2406
2407 #[test]
2408 fn a_sample_short_of_gravity_moves_the_window_as_one_over_it_does() {
2409 // 3 m/s² light on one sample and 1 m/s² heavy on another: the light one is the peak,
2410 // and against a different `γ` the same window departs by a different amount.
2411 let mut window = [still(); 8];
2412 window[2].imu = window[2]
2413 .imu
2414 .with_accel(Acceleration::body(0.0, 0.0, -GRAVITY + 3.0));
2415 window[5].imu = window[5]
2416 .imu
2417 .with_accel(Acceleration::body(0.0, 0.0, -GRAVITY - 1.0));
2418 let measured = measure(&spaced(&window, DT)).expect("a usable window");
2419 let deviation = measured.peaks.deviation(GRAVITY).as_m_per_s2();
2420 assert!((deviation - 3.0).abs() < 1e-5, "{deviation}");
2421 let lighter = measured.peaks.deviation(GRAVITY - 0.5).as_m_per_s2();
2422 assert!((lighter - 2.5).abs() < 1e-5, "{lighter}");
2423 assert_eq!(Peaks::NONE.deviation(GRAVITY).as_m_per_s2(), 0.0);
2424 }
2425
2426 #[test]
2427 fn a_coarse_start_widens_tilt_in_proportion_to_the_motion_it_saw() {
2428 let mut window = [still(); 8];
2429 // 2.94 m/s^2 of unexplained specific force on one sample of eight: over the
2430 // stationarity tolerance, so the window is coarse, and a mean 0.368 m/s^2 out.
2431 window[0].imu =
2432 window[0]
2433 .imu
2434 .with_accel(Acceleration::body(0.0, 0.0, -GRAVITY - 2.941_995));
2435 let tilt = coarse_tilt(&window);
2436 assert!(
2437 (tilt - 0.0375).abs() < 1e-4,
2438 "an eighth of the spike is 0.0375 rad; charging the peak reads 0.3, got {tilt}"
2439 );
2440 }
2441
2442 #[test]
2443 fn vibration_averages_out_of_the_tilt_where_a_peak_charges_all_of_it() {
2444 // The shape `2c42096b` has: a vehicle sitting with its props spinning, whose
2445 // specific force is never gravity and whose average is. Alternating, so every
2446 // sample is over the 1.961 m/s^2 tolerance and the mean cancels exactly.
2447 let mut window = [still(); 8];
2448 for (index, sample) in window.iter_mut().enumerate() {
2449 let shake = if index % 2 == 0 { 3.0 } else { -3.0 };
2450 sample.imu = sample
2451 .imu
2452 .with_accel(Acceleration::body(0.0, 0.0, -GRAVITY + shake));
2453 }
2454 let tilt = coarse_tilt(&window);
2455 assert!(
2456 (tilt - Initialization::default().sigma_tilt.as_radians()).abs() < 1e-6,
2457 "nothing above the floor; the peak of 3 m/s^2 would charge 0.306, got {tilt}"
2458 );
2459 }
2460
2461 #[test]
2462 fn a_turn_about_gravity_charges_the_heading_and_leaves_the_tilt_alone() {
2463 // Yawing: |a| stays exactly g and the gravity vector does not move in body axes
2464 // at all, so the tilt its average yields is as good as a still window's. The
2465 // heading is what moves, and it is charged the 0.8 rad the vehicle turned.
2466 let (tilt, yaw) = sigmas(&turning(0.0, 0.0, 0.4), DT);
2467 assert!(
2468 (tilt - Initialization::default().sigma_tilt.as_radians()).abs() < 1e-6,
2469 "a turn about gravity is worth no tilt; without the split about `down` this \
2470 reads 0.8, got {tilt}"
2471 );
2472 assert!(
2473 (yaw - 0.8).abs() < 1e-4,
2474 "0.4 rad/s over a 2 s window, all of it heading, got {yaw}"
2475 );
2476 }
2477
2478 #[test]
2479 fn a_coordinated_turn_is_charged_although_its_averages_all_agree() {
2480 // The case the halves cannot see, and what `moving_start` is: the vehicle banks
2481 // into a turn, the specific force it measures stays put in body axes, and every
2482 // average in the window agrees while the tilt is wrong by the bank angle. Only
2483 // the gyroscope witnesses it.
2484 let mut window = [still(); 8];
2485 for sample in &mut window {
2486 sample.imu = sample.imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
2487 }
2488 let tilt = coarse_tilt(&window);
2489 assert!(
2490 (tilt - 0.8).abs() < 1e-4,
2491 "0.4 rad/s over a 2 s window is 0.8 rad of turn across gravity, and the \
2492 window's own averages say nothing at all, got {tilt}"
2493 );
2494 }
2495
2496 #[test]
2497 fn a_window_that_swings_out_and_back_is_charged_the_excursion() {
2498 // The case the gyroscope cannot see: the vehicle ends where it started, `ω̄ · T`
2499 // cancels to nothing, and the attitude (5) commits is the middle of an arc it has
2500 // already left. The halves disagree by 0.4 rad and say so.
2501 const PITCH: [f32; 8] = [0.0, 0.2, 0.4, 0.2, 0.0, -0.2, -0.4, -0.2];
2502 // dθ/dt between samples, so the gyroscope agrees with the attitude above and its
2503 // mean really is zero. The last repeats the previous, having no successor.
2504 const RATE: [f32; 8] = [0.8, 0.8, -0.8, -0.8, -0.8, -0.8, 0.8, 0.8];
2505 let mut window = [still(); 8];
2506 for ((sample, pitch), rate) in window.iter_mut().zip(PITCH).zip(RATE) {
2507 sample.imu = sample.imu.with_accel(gravity_at(0.0, pitch, 0.0));
2508 sample.imu = sample.imu.with_gyro(AngularRate::body(0.0, rate, 0.0));
2509 }
2510 let tilt = coarse_tilt(&window);
2511 assert!(
2512 (tilt - 0.4).abs() < 1e-4,
2513 "the halves mean +0.2 and -0.2 rad; on the net rotation alone this reads the \
2514 0.0297 rad the shortened average is worth, got {tilt}"
2515 );
2516 }
2517
2518 #[test]
2519 fn a_still_short_window_keeps_the_heading_its_magnetometer_observed() {
2520 // The window of #85: still, carrying a field, and coarse only because it is
2521 // short. `classify` calls it short before it measures motion, so nothing about
2522 // that verdict says the heading is worth less than a 2 s window's.
2523 let window = window_at(0.0, 0.0, 0.9, 0.0);
2524 let (tilt, yaw) = sigmas(&window, Seconds::from_secs(0.1));
2525 let init = Initialization::default();
2526 assert!(
2527 (tilt - init.sigma_tilt.as_radians()).abs() < 1e-6,
2528 "got {tilt}"
2529 );
2530 assert!(
2531 (yaw - init.sigma_yaw.as_radians()).abs() < 1e-6,
2532 "the configured prior, not the 1.81 rad of a heading nothing observed, got {yaw}"
2533 );
2534 }
2535
2536 #[test]
2537 fn a_parked_vehicles_gyroscope_bias_is_not_also_charged_as_motion() {
2538 // (7) commits ω̄ as the gyroscope bias of a window at rest, so the same rotation
2539 // cannot also bound the attitude — it is not motion the window failed to see, it
2540 // is a sensor offset the state now carries. 0.05 rad/s is ordinary for a MEMS
2541 // part and well inside the 0.262 rad/s tolerance; eight samples at 0.2 s is a
2542 // 1.6 s window, still, and coarse only for being short.
2543 let mut window = window_at(0.0, 0.0, 0.0, 0.0);
2544 for (index, sample) in window.iter_mut().enumerate() {
2545 let noise = if index % 2 == 0 { 1e-4 } else { -1e-4 };
2546 sample.imu = sample
2547 .imu
2548 .with_gyro(AngularRate::body(0.05 + noise, noise, noise));
2549 }
2550 let dt = Seconds::from_secs(0.2);
2551 let init = Initialization::default();
2552 let measured = measure(&spaced(&window, dt)).expect("a usable window");
2553 assert!(
2554 at_rest(measured.peaks, &init, GRAVITY),
2555 "0.05 rad/s is inside 0.262"
2556 );
2557 let bias = measured
2558 .gyro_bias(true, &Config::default())
2559 .estimate
2560 .vector();
2561 assert!(
2562 (bias.x - 0.05).abs() < 1e-5,
2563 "(7) commits the average, got {bias:?}"
2564 );
2565
2566 let (tilt, yaw) = sigmas(&window, dt);
2567 assert!(
2568 (tilt - init.sigma_tilt.as_radians()).abs() < 1e-6,
2569 "charging ω̄ rather than ω̄ − β̂_g reads 0.08 rad here, over the 0.0524 (3°) of \
2570 `ALIGNED_TILT`, and latches `Aligning` for the flight; got {tilt}"
2571 );
2572 assert!(
2573 (yaw - init.sigma_yaw.as_radians()).abs() < 1e-6,
2574 "the configured prior; a bias about gravity would charge the heading the \
2575 same way, got {yaw}"
2576 );
2577 }
2578
2579 #[test]
2580 fn a_window_no_magnetometer_observed_gets_the_circle_however_still_it_was() {
2581 // Stillness observes tilt and never the rotation about it.
2582 let (tilt, yaw) = sigmas(&[still(); 8], Seconds::from_secs(0.1));
2583 assert!(
2584 (tilt - Initialization::default().sigma_tilt.as_radians()).abs() < 1e-6,
2585 "got {tilt}"
2586 );
2587 assert!(
2588 (yaw - UNKNOWN_HEADING_SIGMA.as_radians()).abs() < 1e-6,
2589 "got {yaw}"
2590 );
2591 }
2592
2593 #[test]
2594 fn a_window_charges_its_heading_the_tilt_it_leveled_by() {
2595 // The dip couples them: (6) levels the field by the tilt of (5), so a tilt this
2596 // window cannot vouch for is a heading error scaled by `tan(dip)`. Steady
2597 // unexplained specific force rather than a turn, so that the halves agree and the
2598 // dip is the only thing charging the heading.
2599 let mut window = window_at(0.0, 0.0, 0.9, 0.0);
2600 for sample in &mut window {
2601 let leaning = sample.imu.specific_force().vector() - Vector3::new(0.0, 0.0, 2.941_995);
2602 sample.imu = sample.imu.with_accel(Acceleration::from_vector(leaning));
2603 }
2604 let (tilt, yaw) = sigmas(&window, DT);
2605 assert!((tilt - 0.3).abs() < 1e-4, "2.942 / g, got {tilt}");
2606 assert!(
2607 (yaw - dip_gain() * tilt).abs() < 1e-4,
2608 "tan(dip) is {}, so the yaw is {}, got {yaw}",
2609 dip_gain(),
2610 dip_gain() * tilt
2611 );
2612 }
2613
2614 #[test]
2615 fn a_heading_bound_saturates_at_the_circle_it_cannot_be_wider_than() {
2616 // 2 rad/s of yaw drifts the halves 2 rad apart, which is more spread than a
2617 // heading on a circle can have.
2618 let (_, yaw) = sigmas(&turning(0.0, 0.0, 2.0), DT);
2619 assert!(
2620 (yaw - UNKNOWN_HEADING_SIGMA.as_radians()).abs() < 1e-6,
2621 "pi/sqrt(3) is the widest a heading gets, got {yaw}"
2622 );
2623 }
2624
2625 #[test]
2626 fn the_heading_sensitivity_is_the_tangent_of_the_dip_at_any_tilt() {
2627 for (roll, pitch) in TILTS {
2628 let down = -gravity_at(roll, pitch, 0.4).vector().normalize();
2629 let measured = heading_sensitivity(field_at(roll, pitch, 0.4, 0.0), down)
2630 .expect("a field with a horizontal part")
2631 .tan_dip;
2632 assert!(
2633 (measured - dip_gain()).abs() < 1e-4,
2634 "at ({roll}, {pitch}) got {measured}, want {}",
2635 dip_gain()
2636 );
2637 }
2638 }
2639
2640 #[test]
2641 fn a_field_straight_down_observes_no_heading_at_any_tilt() {
2642 // `tan(dip)` is unbounded there, and a bound that divided by the horizontal part
2643 // would put that infinity into `P` through `initial_covariance`.
2644 let down = Vector3::new(0.0, 0.0, 1.0);
2645 assert_eq!(
2646 heading_sensitivity(MagField::body(0.0, 0.0, 0.5), down),
2647 None
2648 );
2649 assert_eq!(
2650 heading_sensitivity(MagField::body(0.0, 0.0, 0.0), down),
2651 None
2652 );
2653 }
2654
2655 #[test]
2656 fn an_accelerometer_averaging_to_zero_is_bounded_and_not_made_a_nan() {
2657 // Stopped or disconnected: `Measured::down` reports no vertical, so the turn
2658 // cannot be split about one and the whole of it is charged to tilt.
2659 //
2660 // The rate is 0.6 rad/s for a reason. At 1.2 rad it is the widest of the bounds,
2661 // so that fallback has to carry it: with `normalize()` in place of
2662 // `try_normalize` this reads the 1.0 rad of `|0 − g| / g` instead, because the
2663 // NaN that makes is dropped by `f32::max` rather than surfacing. A slower turn
2664 // passes either way. The magnetometer is there so that the circle the heading
2665 // falls back to is the missing vertical's doing and not its own.
2666 let mut window = [still(); 8];
2667 for sample in &mut window {
2668 sample.imu = sample.imu.with_accel(Acceleration::body(0.0, 0.0, 0.0));
2669 sample.imu = sample.imu.with_gyro(AngularRate::body(0.0, 0.6, 0.0));
2670 sample.mag = Some(MagField::body(0.22, 0.0, 0.44));
2671 }
2672 let (tilt, yaw) = sigmas(&window, DT);
2673 assert!(
2674 (tilt - 1.2).abs() < 1e-6,
2675 "0.6 rad/s over 2 s, above the 1.0 rad that |0 - g| / g is worth, got {tilt}"
2676 );
2677 assert!(
2678 (yaw - UNKNOWN_HEADING_SIGMA.as_radians()).abs() < 1e-6,
2679 "no down means no dip to scale by, got {yaw}"
2680 );
2681 }
2682
2683 /// [`StaticWindow::span`]'s `f64`: the sum in `f32` falls short of the product `n Δt`.
2684 #[test]
2685 fn a_window_of_exactly_the_minimum_duration_is_long_enough_at_any_rate() {
2686 for (dt, samples) in [(0.02, 100), (0.01, 200), (0.005, 400), (0.0025, 800)] {
2687 let window = std::vec![still(); samples];
2688 assert_eq!(
2689 classify_default(&window, Seconds::from_secs(dt)),
2690 Ok(Alignment::Static),
2691 "{samples} samples of {dt} s"
2692 );
2693 }
2694 }
2695
2696 #[test]
2697 fn an_empty_window_or_an_unusable_interval_is_still_an_error() {
2698 assert_eq!(classify_default(&[], DT), Err(InitError::NoSamples));
2699 let zero = Seconds::from_secs(0.0);
2700 assert_eq!(
2701 classify_default(&[still(); 8], zero),
2702 Err(InitError::InvalidInterval { interval: zero })
2703 );
2704 // The interval named is the one that is unusable, whichever of the two it is.
2705 let mut window = spaced(&[still(); 8], DT);
2706 let backwards = Seconds::from_secs(-0.1);
2707 window[5].imu.velocity_interval = backwards;
2708 assert_eq!(
2709 measure(&window).map(|_| ()),
2710 Err(InitError::InvalidInterval {
2711 interval: backwards
2712 })
2713 );
2714 let mut poisoned = [still(); 8];
2715 poisoned[2].imu = poisoned[2]
2716 .imu
2717 .with_accel(Acceleration::body(f32::NAN, 0.0, 0.0));
2718 assert_eq!(classify_default(&poisoned, DT), Err(InitError::NotFinite));
2719 }
2720
2721 /// The clock starts at the last sample's time, so a window whose times do not run forward
2722 /// is refused rather than aligned: stamped all at zero, it would start the clock at zero
2723 /// and the first `predict` would coast the whole flight so far.
2724 #[test]
2725 fn a_window_timed_other_than_forward_is_refused() {
2726 let unstamped: std::vec::Vec<_> = spaced(&[still(); 8], DT)
2727 .into_iter()
2728 .map(|s| StaticSample {
2729 imu: s.imu.timed(Timestamp::ZERO, DT),
2730 ..s
2731 })
2732 .collect();
2733 assert_eq!(
2734 measure(&unstamped).map(|_| ()),
2735 Err(InitError::InvalidStep {
2736 dt: Seconds::from_secs(0.0)
2737 })
2738 );
2739 let mut reversed = spaced(&[still(); 8], DT);
2740 reversed.reverse();
2741 assert_eq!(
2742 measure(&reversed).map(|_| ()),
2743 Err(InitError::InvalidStep {
2744 dt: Seconds::from_secs(-0.25)
2745 })
2746 );
2747 }
2748
2749 #[test]
2750 fn the_baro_reference_is_the_mean_over_the_window() {
2751 let window =
2752 [99.0, 101.0, 100.0, 99.5, 100.5, 100.0, 99.8, 100.2].map(|altitude| StaticSample {
2753 baro: Some(Altitude::from_meters(altitude)),
2754 ..still()
2755 });
2756 let (reference, variance) = alpha0(&window).expect("the window carried barometer samples");
2757 // Squared deviations sum to 2.58 m², over 7 degrees of freedom and then 8 readings.
2758 assert!((variance - 2.58 / 7.0 / 8.0).abs() < 1e-6, "{variance}");
2759 assert!(
2760 (reference.as_meters() - 100.0).abs() < 1e-4,
2761 "mean of the window is 100 m, got {}",
2762 reference.as_meters()
2763 );
2764 }
2765
2766 #[test]
2767 fn samples_without_a_barometer_stay_out_of_the_mean() {
2768 // A barometer runs slower than the IMU, so most samples in a real window carry
2769 // nothing. Counting those as zero would drag the reference to the ground.
2770 let mut window = [still(); 8];
2771 window[0].baro = Some(Altitude::from_meters(10.0));
2772 window[7].baro = Some(Altitude::from_meters(20.0));
2773 assert_eq!(alpha0(&window), Some((Altitude::from_meters(15.0), 25.0)));
2774 }
2775
2776 #[test]
2777 fn a_held_reading_counts_once() {
2778 // Two readings at 20 Hz on an IMU four times faster, each held until the next: the
2779 // mean and variance of two readings, not of eight samples.
2780 let window =
2781 [10.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0, 20.0].map(|altitude| StaticSample {
2782 baro: Some(Altitude::from_meters(altitude)),
2783 ..still()
2784 });
2785 assert_eq!(alpha0(&window), Some((Altitude::from_meters(15.0), 25.0)));
2786
2787 // One reading held across the window is one reading, however many samples carry it.
2788 let held = [StaticSample {
2789 baro: Some(Altitude::from_meters(10.0)),
2790 ..still()
2791 }; 8];
2792 assert_eq!(alpha0(&held), None);
2793 }
2794
2795 #[test]
2796 fn one_reading_is_no_reference_because_it_has_no_scatter_to_measure() {
2797 let mut window = [still(); 8];
2798 window[3].baro = Some(Altitude::from_meters(10.0));
2799 assert_eq!(alpha0(&window), None);
2800 }
2801
2802 #[test]
2803 fn the_barometer_scatter_is_the_two_pass_one_at_any_altitude() {
2804 // Centimeters of scatter at 10 km, on 101 levels, each reading different from the one
2805 // before.
2806 let window: std::vec::Vec<_> = (0..400)
2807 .map(|i| StaticSample {
2808 baro: Some(Altitude::from_meters(
2809 10_000.0 + 0.001 * ((i * 37 % 101) as f32 - 50.0),
2810 )),
2811 ..still()
2812 })
2813 .collect();
2814 let readings: std::vec::Vec<f64> = window
2815 .iter()
2816 .filter_map(|s| s.baro)
2817 .map(|a| f64::from(a.as_meters()))
2818 .collect();
2819 let n = readings.len() as f64;
2820 let mean = readings.iter().sum::<f64>() / n;
2821 let scatter = readings
2822 .iter()
2823 .map(|a| (a - mean) * (a - mean))
2824 .sum::<f64>()
2825 / (n - 1.0);
2826 // The textbook one-pass form misses here by more than the tolerance below, so this
2827 // test can tell a single pass that cancels from one that does not.
2828 let squares = readings.iter().map(|a| a * a).sum::<f64>();
2829 let naive = (squares - n * mean * mean) / (n - 1.0);
2830 let lost = ((naive - scatter) / scatter).abs();
2831 assert!(lost > 1e-5, "{lost}");
2832 let (reference, variance) = alpha0(&window).expect("400 readings");
2833 assert_eq!(reference, Altitude::from_meters(mean as f32));
2834 let expected = (scatter / n) as f32;
2835 assert!(
2836 ((variance - expected) / expected).abs() < 1e-6,
2837 "{variance} against {expected}"
2838 );
2839 }
2840
2841 /// What the window reports under the default tolerances.
2842 fn noise(window: &[StaticSample]) -> Option<WindowNoise> {
2843 StaticWindow::try_from(window)
2844 .expect("a usable window")
2845 .noise(&Config::default())
2846 }
2847
2848 /// `n` samples of a still vehicle, the `i`th integrating `gyro(i)` over `angle` and
2849 /// `accel(i)`, gravity added, over `velocity`.
2850 fn built(
2851 n: usize,
2852 angle: f32,
2853 velocity: f32,
2854 gyro: impl Fn(usize) -> Vector3<f32>,
2855 accel: impl Fn(usize) -> Vector3<f32>,
2856 ) -> std::vec::Vec<StaticSample> {
2857 let mut time = Timestamp::ZERO;
2858 (0..n)
2859 .map(|i| {
2860 time = time.after(Seconds::from_secs(angle));
2861 let force = accel(i) + Vector3::new(0.0, 0.0, -GRAVITY);
2862 StaticSample {
2863 imu: ImuSample {
2864 time,
2865 delta_angle: crate::units::DeltaAngle::from_vector(gyro(i) * angle),
2866 angle_interval: Seconds::from_secs(angle),
2867 delta_velocity: crate::units::DeltaVelocity::from_vector(force * velocity),
2868 velocity_interval: Seconds::from_secs(velocity),
2869 },
2870 ..still()
2871 }
2872 })
2873 .collect()
2874 }
2875
2876 /// `+1` or `−1`, flipping every `every` samples.
2877 fn flip(i: usize, every: usize) -> f32 {
2878 if (i / every).is_multiple_of(2) {
2879 1.0
2880 } else {
2881 -1.0
2882 }
2883 }
2884
2885 fn assert_close(got: [f32; 3], want: Vector3<f32>, tolerance: f32) {
2886 for (g, w) in got.iter().zip(want.iter()) {
2887 assert!(((g - w) / w).abs() < tolerance, "{got:?} against {want:?}");
2888 }
2889 }
2890
2891 #[test]
2892 fn the_density_is_the_block_scatter_times_the_root_of_the_block() {
2893 // Ten 50 ms blocks of five 10 ms samples, the rate ±a flipping block by block: each
2894 // block's increment is ±a T, so N² = J a² T / (J − 1) and N = a √(T J / (J − 1)).
2895 // Dropping the `1/T` reads 4.5× low, and J for J − 1 reads 5 % low.
2896 let (gyro, accel) = (Vector3::new(0.01, 0.02, 0.03), Vector3::new(0.1, 0.2, 0.3));
2897 let window = built(
2898 50,
2899 0.01,
2900 0.01,
2901 |i| gyro * flip(i, 5),
2902 |i| accel * flip(i, 5),
2903 );
2904 let reported = noise(&window).expect("ten still blocks");
2905 let factor = (0.05f32 * 10.0 / 9.0).sqrt();
2906 assert_close(reported.gyro_white, gyro * factor, 1e-5);
2907 assert_close(reported.accel_white, accel * factor, 1e-5);
2908 assert_eq!(reported.blocks, 10);
2909 }
2910
2911 #[test]
2912 fn a_rate_alternating_sample_by_sample_cancels_within_a_block() {
2913 // Vibration aliased to the sample rate, which integrates to nothing: every 50 ms block
2914 // of ten 5 ms samples sums to zero. One sample's scatter would read it as a √Δt, 0.07 a.
2915 let a = Vector3::new(0.01, 0.02, 0.03);
2916 let window = built(
2917 200,
2918 0.005,
2919 0.005,
2920 |i| a * flip(i, 1),
2921 |i| a * 10.0 * flip(i, 1),
2922 );
2923 let reported = noise(&window).expect("twenty still blocks");
2924 assert!(
2925 Vector3::from(reported.gyro_white).max() < 1e-6,
2926 "{:?}",
2927 reported.gyro_white
2928 );
2929 assert!(
2930 Vector3::from(reported.accel_white).max() < 1e-5,
2931 "{:?}",
2932 reported.accel_white
2933 );
2934 }
2935
2936 #[test]
2937 fn each_sensor_is_blocked_by_its_own_interval() {
2938 // A gyroscope at 10 ms and an accelerometer at 30 ms, each flipping sign every block
2939 // of its own: five gyroscope samples to a 50 ms block and two accelerometer ones to a
2940 // 60 ms block, no boundary landing on a tie. Blocking the accelerometer by the angle
2941 // interval puts five of its samples in a block that flips every two, and closing a
2942 // block a whole interval early gives it 30 ms ones; both read it wrong.
2943 let c = Vector3::new(1.0e-3, 2.0e-3, 3.0e-3);
2944 let window = built(100, 0.01, 0.03, |i| c * flip(i, 5), |i| c * flip(i, 2));
2945 let reported = noise(&window).expect("still");
2946 assert_close(
2947 reported.gyro_white,
2948 c * (0.05f32 * 20.0 / 19.0).sqrt(),
2949 1e-5,
2950 );
2951 // The increments are `f32`, and gravity's 0.3 m s⁻¹ rounds `c`'s 10⁻⁴ by that much.
2952 assert_close(
2953 reported.accel_white,
2954 c * (0.06f32 * 50.0 / 49.0).sqrt(),
2955 1e-3,
2956 );
2957 assert_eq!(reported.blocks, 20);
2958 }
2959
2960 #[test]
2961 fn the_worst_axis_is_the_largest_whichever_it_is() {
2962 // What `ImuNoise` takes, one density per sensor for all three axes: the middle axis
2963 // here, so neither the first nor the last is mistaken for it.
2964 let (gyro, accel) = (Vector3::new(0.02, 0.03, 0.01), Vector3::new(0.3, 0.1, 0.2));
2965 let window = built(
2966 50,
2967 0.01,
2968 0.01,
2969 |i| gyro * flip(i, 5),
2970 |i| accel * flip(i, 5),
2971 );
2972 let reported = noise(&window).expect("ten still blocks");
2973 assert_eq!(reported.worst_gyro_white(), reported.gyro_white[1]);
2974 assert_eq!(reported.worst_accel_white(), reported.accel_white[0]);
2975 }
2976
2977 /// `seconds` of white noise of density `n_gyro` and `n_accel` on every axis, as a sensor
2978 /// integrating over `dt` would hand it over: each increment carries `N √Δt` of noise.
2979 fn white(seconds: f32, dt: f32, n_gyro: f32, n_accel: f32) -> std::vec::Vec<StaticSample> {
2980 // A 64-bit LCG and Box–Muller: deterministic, with no dependency.
2981 let mut state: u64 = 0x2545_f491_4f6c_dd1d;
2982 let mut uniform = move || {
2983 state = state
2984 .wrapping_mul(6_364_136_223_846_793_005)
2985 .wrapping_add(1_442_695_040_888_963_407);
2986 ((state >> 11) as f64 + 0.5) / (1u64 << 53) as f64
2987 };
2988 let mut gaussian = move || {
2989 let (u, v) = (uniform(), uniform());
2990 ((-2.0 * u.ln()).sqrt() * (2.0 * core::f64::consts::PI * v).cos()) as f32
2991 };
2992 let noise: std::vec::Vec<_> = (0..(seconds / dt) as usize)
2993 .map(|_| {
2994 let mut draw = || Vector3::new(gaussian(), gaussian(), gaussian());
2995 (draw() * n_gyro, draw() * n_accel)
2996 })
2997 .collect();
2998 let scale = 1.0 / dt.sqrt();
2999 built(
3000 noise.len(),
3001 dt,
3002 dt,
3003 |i| noise[i].0 * scale,
3004 |i| noise[i].1 * scale,
3005 )
3006 }
3007
3008 #[test]
3009 fn a_sensors_white_noise_density_is_recovered_at_any_rate() {
3010 // Ten seconds at the simulator's IMU densities at 50, 100 and 400 Hz, where the
3011 // per-sample noise differs by a factor of three and the density does not. At 50 Hz a block holds three
3012 // samples, 60 ms, which the weighting takes as it is. 200 blocks read N to ±5 %.
3013 let (n_gyro, n_accel) = (2.6e-4, 2.0e-3);
3014 for rate in [50.0f32, 100.0, 400.0] {
3015 let reported = noise(&white(10.0, 1.0 / rate, n_gyro, n_accel)).expect("still");
3016 assert_close(reported.gyro_white, Vector3::repeat(n_gyro), 0.15);
3017 assert_close(reported.accel_white, Vector3::repeat(n_accel), 0.15);
3018 }
3019 }
3020
3021 #[test]
3022 fn the_barometer_floor_is_the_variance_of_its_distinct_readings() {
3023 // Nine readings held three samples each: the variance of nine readings, 7.5 m² about
3024 // their mean of 5 m, not of 27 samples.
3025 let window: std::vec::Vec<_> = (0..27)
3026 .map(|i| StaticSample {
3027 baro: Some(Altitude::from_meters((i / 3) as f32 + 1.0)),
3028 ..still()
3029 })
3030 .collect();
3031 let reported = noise(&spaced(&window, WindowNoise::BLOCK)).expect("still");
3032 assert_eq!(reported.baro, Some(AltitudeNoise::from_variance(7.5)));
3033 assert_eq!(reported.baro_readings, 9);
3034 }
3035
3036 #[test]
3037 fn too_few_readings_report_nothing_for_that_sensor() {
3038 // Seven barometer readings: the IMU's 27 blocks still report, the barometer does not.
3039 let window: std::vec::Vec<_> = (0..27)
3040 .map(|i| StaticSample {
3041 baro: Some(Altitude::from_meters((i / 4) as f32)),
3042 ..still()
3043 })
3044 .collect();
3045 let reported = noise(&spaced(&window, WindowNoise::BLOCK)).expect("still");
3046 assert_eq!((reported.baro, reported.baro_readings), (None, 7));
3047
3048 let few = [still(); (WindowNoise::MIN_READINGS - 1) as usize];
3049 assert_eq!(noise(&spaced(&few, WindowNoise::BLOCK)), None);
3050 let enough = [still(); WindowNoise::MIN_READINGS as usize];
3051 assert!(noise(&spaced(&enough, WindowNoise::BLOCK)).is_some());
3052 }
3053
3054 #[test]
3055 fn a_window_that_moved_reports_no_noise() {
3056 // Its scatter would be the motion.
3057 let mut window = [still(); 20];
3058 window[10].imu = ImuSample::reading(
3059 AngularRate::body(0.0, 0.0, 1.0),
3060 Acceleration::body(0.0, 0.0, -GRAVITY),
3061 );
3062 assert_eq!(noise(&spaced(&window, WindowNoise::BLOCK)), None);
3063 }
3064
3065 /// The window's halves over samples whose specific force is `forces` along body x.
3066 fn halves_of(forces: &[f64]) -> Option<Halves> {
3067 let mut sums = BlockSums::new();
3068 for &force in forces {
3069 sums.push(Vector3::new(force, 0.0, 0.0), None);
3070 }
3071 let (first, second) = sums.split();
3072 Halves::of(first, second)
3073 }
3074
3075 #[test]
3076 fn a_window_within_the_blocks_splits_at_the_middle_rounded_down() {
3077 // Seven samples in seven blocks of one: 3 and 4, as `n / 2` splits them.
3078 let halves = halves_of(&[1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0]).expect("two halves");
3079 assert_eq!(
3080 halves.force,
3081 [
3082 Acceleration::body(1.0, 0.0, 0.0),
3083 Acceleration::body(5.0, 0.0, 0.0)
3084 ]
3085 );
3086 }
3087
3088 #[test]
3089 fn a_window_past_the_blocks_splits_at_the_boundary_nearest_the_middle() {
3090 // Twenty samples merge twice, into five blocks of four. The middle, 10, is two from
3091 // the boundaries at 8 and 12, and the earlier is taken; the later would average
3092 // four 3s into the first half.
3093 let forces: std::vec::Vec<f64> = (0..20).map(|i| if i < 8 { 1.0 } else { 3.0 }).collect();
3094 let halves = halves_of(&forces).expect("two halves");
3095 assert_eq!(
3096 halves.force,
3097 [
3098 Acceleration::body(1.0, 0.0, 0.0),
3099 Acceleration::body(3.0, 0.0, 0.0)
3100 ]
3101 );
3102 }
3103
3104 #[test]
3105 fn the_halves_are_within_an_eighth_of_the_window_of_equal_at_every_length() {
3106 // Sample `i` reads `i`, so a first half of `k` samples averages `(k − 1) / 2`: the
3107 // count comes back out of the mean. Blocks left unequal by a merge would let the
3108 // first half grow past the bound `BLOCKS` sets.
3109 for n in 2..=300u32 {
3110 let forces: std::vec::Vec<f64> = (0..n).map(f64::from).collect();
3111 let halves = halves_of(&forces).expect("two halves");
3112 let first = 2.0 * halves.force[0].vector().x + 1.0;
3113 let n = n as f32;
3114 assert!(
3115 first >= 3.0 * n / 8.0 - 0.5 && first <= 5.0 * n / 8.0 + 0.5,
3116 "a first half of {first} samples in {n}"
3117 );
3118 }
3119 }
3120
3121 #[test]
3122 fn a_window_of_one_has_no_halves() {
3123 assert_eq!(halves_of(&[1.0]), None);
3124 }
3125
3126 #[test]
3127 fn a_window_is_at_rest_until_it_moves_and_never_again_after() {
3128 let config = Config::default();
3129 let mut samples = spaced(&[still(); 6], DT);
3130 // Over the 0.262 rad/s default.
3131 samples[2].imu = samples[2].imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
3132 let mut window = StaticWindow::new();
3133 assert!(window.is_at_rest(&config), "an empty window has not moved");
3134 for (index, sample) in samples.into_iter().enumerate() {
3135 assert_eq!(window.push(sample), Ok(()));
3136 assert_eq!(
3137 window.is_at_rest(&config),
3138 index < 2,
3139 "after sample {index}"
3140 );
3141 // The same verdict `alignment_of` reaches by measuring the whole window.
3142 let moved = matches!(
3143 classify(
3144 &window.measured().expect("a sample"),
3145 &config.init,
3146 config.gravity
3147 ),
3148 Alignment::Coarse(Coarse::NotStationary { .. })
3149 );
3150 assert_eq!(moved, !window.is_at_rest(&config), "after sample {index}");
3151 }
3152 }
3153
3154 #[test]
3155 fn a_window_is_long_enough_only_once_it_holds_a_sample() {
3156 // A `min_duration` of zero is met by any span, the empty window's included, and an
3157 // empty window is the one `initialize` refuses.
3158 let mut config = Config::default();
3159 config.init.min_duration = Seconds::from_secs(0.0);
3160 let mut window = StaticWindow::new();
3161 assert!(!window.is_long_enough(&config));
3162 assert_eq!(window.push(spaced(&[still()], DT)[0]), Ok(()));
3163 assert!(window.is_long_enough(&config));
3164
3165 let two_seconds = Config::default();
3166 let mut window = StaticWindow::new();
3167 let samples = spaced(&[still(); 8], Seconds::from_secs(0.25));
3168 for (index, sample) in samples.into_iter().enumerate() {
3169 assert!(
3170 !window.is_long_enough(&two_seconds),
3171 "after {index} samples"
3172 );
3173 assert_eq!(window.push(sample), Ok(()));
3174 }
3175 assert!(window.is_long_enough(&two_seconds), "8 samples of 0.25 s");
3176 }
3177
3178 #[test]
3179 fn extending_a_window_stops_at_the_first_sample_refused() {
3180 let mut samples = spaced(&[still(); 5], DT);
3181 samples[2].imu = samples[2]
3182 .imu
3183 .with_accel(Acceleration::body(f32::NAN, 0.0, 0.0));
3184 let mut window = StaticWindow::new();
3185 assert_eq!(
3186 window.try_extend(samples.clone()),
3187 Err(SampleRefusal::NotFinite)
3188 );
3189 // The two before it are in, and the two after it, usable as they are, are not.
3190 assert_eq!(window.measured(), measure(&samples[..2]));
3191 }
3192
3193 #[test]
3194 fn a_refused_sample_leaves_the_window_as_it_was() {
3195 // With a barometer, whose readings a refused sample must not reach either: `measured`
3196 // does not read them.
3197 let mut good = spaced(&[still(); 4], DT);
3198 for (index, sample) in good.iter_mut().enumerate() {
3199 sample.baro = Some(Altitude::from_meters(100.0 + index as f32));
3200 }
3201 let mut window = StaticWindow::new();
3202 assert_eq!(window.push(good[0]), Ok(()));
3203 assert_eq!(window.push(good[1]), Ok(()));
3204 // Refused for being earlier, and the clock stays where it was: a sample between the
3205 // refused time and the last accepted is still behind the window.
3206 assert!(matches!(
3207 window.push(good[0]),
3208 Err(SampleRefusal::InvalidStep { .. })
3209 ));
3210 assert!(matches!(
3211 window.push(good[1]),
3212 Err(SampleRefusal::InvalidStep { .. })
3213 ));
3214 let mut poisoned = good[1];
3215 poisoned.imu = poisoned
3216 .imu
3217 .with_accel(Acceleration::body(f32::NAN, 0.0, 0.0));
3218 poisoned.baro = Some(Altitude::from_meters(500.0));
3219 assert_eq!(window.push(poisoned), Err(SampleRefusal::NotFinite));
3220 assert_eq!(window.push(good[2]), Ok(()));
3221 assert_eq!(window.push(good[3]), Ok(()));
3222 let clean = StaticWindow::try_from(good.as_slice()).expect("usable");
3223 assert_eq!(window.measured(), clean.measured());
3224 assert_eq!(window.alpha0(), clean.alpha0());
3225 }
3226
3227 /// A window whose GNSS reports the vehicle gaining 4 m/s of north velocity over the
3228 /// four samples — one second at `DT` — that separate the two fixes.
3229 fn accelerating_window() -> [StaticSample; 8] {
3230 let mut window = [still(); 8];
3231 window[1].velocity = Some(Velocity::ned(1.0, 0.0, 0.0));
3232 window[5].velocity = Some(Velocity::ned(5.0, 0.0, 0.0));
3233 window
3234 }
3235
3236 #[test]
3237 fn the_window_acceleration_is_the_endpoint_difference_over_the_span() {
3238 let measured = inertial_acceleration(&spaced(&accelerating_window(), DT))
3239 .expect("two samples a second apart carry a velocity");
3240 assert_eq!(measured, Acceleration::ned(4.0, 0.0, 0.0));
3241 }
3242
3243 #[test]
3244 fn velocity_between_the_endpoints_does_not_reach_the_mean() {
3245 // The mean of a derivative is its endpoint difference, so a noisy fix in the
3246 // middle of the window is not averaged in — it is not read at all.
3247 let mut window = accelerating_window();
3248 window[3].velocity = Some(Velocity::ned(-40.0, 12.0, 7.0));
3249 assert_eq!(
3250 inertial_acceleration(&spaced(&window, DT)),
3251 Some(Acceleration::ned(4.0, 0.0, 0.0))
3252 );
3253 }
3254
3255 #[test]
3256 fn a_window_with_no_two_dated_velocities_reports_no_acceleration() {
3257 // Nothing to difference, and a difference over zero seconds is an infinity
3258 // rather than an acceleration.
3259 assert_eq!(inertial_acceleration(&spaced(&[still(); 8], DT)), None);
3260 let mut one = [still(); 8];
3261 one[4].velocity = Some(Velocity::ned(9.0, 0.0, 0.0));
3262 assert_eq!(inertial_acceleration(&spaced(&one, DT)), None);
3263 }
3264
3265 #[test]
3266 fn a_moving_window_reports_the_acceleration_gnss_accounts_for() {
3267 let mut window = accelerating_window();
3268 // Over the 0.262 rad/s default, so the window classifies as moving.
3269 window[3].imu = window[3].imu.with_gyro(AngularRate::body(0.0, 0.4, 0.0));
3270 let alignment = classify_default(&window, DT).expect("moving, not unusable");
3271 let Alignment::Coarse(Coarse::NotStationary { inertial_accel, .. }) = alignment else {
3272 panic!("0.4 rad/s is over the default: {alignment:?}");
3273 };
3274 assert_eq!(inertial_accel, Some(Acceleration::ned(4.0, 0.0, 0.0)));
3275 }
3276
3277 #[test]
3278 fn a_vehicle_gnss_explains_entirely_is_still_not_at_rest() {
3279 // A launch off a deck accelerating north at a steady 8 m/s^2, level and not
3280 // turning: |f| is sqrt(8^2 + g^2), 2.85 m/s^2 off gravity, and GNSS accounts for
3281 // every bit of it. The window is still not one taken on the ground, which is
3282 // what `Alignment::Static` and the barometric reference both mean.
3283 let mut window = [still(); 8];
3284 for sample in &mut window {
3285 sample.imu = sample
3286 .imu
3287 .with_accel(Acceleration::body(8.0, 0.0, -GRAVITY));
3288 }
3289 window[1].velocity = Some(Velocity::ned(1.0, 0.0, 0.0));
3290 window[5].velocity = Some(Velocity::ned(9.0, 0.0, 0.0));
3291
3292 let alignment = classify_default(&window, DT).expect("moving, not unusable");
3293 let Alignment::Coarse(Coarse::NotStationary { inertial_accel, .. }) = alignment else {
3294 panic!("2.85 m/s^2 is over the 1.961 default: {alignment:?}");
3295 };
3296 assert_eq!(inertial_accel, Some(Acceleration::ned(8.0, 0.0, 0.0)));
3297 }
3298
3299 #[test]
3300 fn a_velocity_that_is_not_a_number_is_refused_with_the_rest() {
3301 let mut window = accelerating_window();
3302 window[5].velocity = Some(Velocity::ned(f32::NAN, 0.0, 0.0));
3303 assert_eq!(classify_default(&window, DT), Err(InitError::NotFinite));
3304 }
3305}