Skip to main content

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}