Skip to main content

fusion_nav/
propagate.rs

1//! Propagation: the IMU sample, bias correction, the nominal kinematics and the covariance.
2//! Equations (9)–(22); see the equation-to-code table in `EQUATIONS.md`.
3//!
4//! The nominal state and `P` advance together in [`propagate`], and
5//! [`Eskf::predict`](crate::Eskf::predict) commits both or neither. Nothing here shrinks a
6//! covariance: (22) only adds, and the measurement update of (23)–(28) is what takes
7//! uncertainty back out.
8
9use nalgebra::{Matrix3, SMatrix, SVector, Vector3};
10
11use crate::config::{Coast, Config, ImuNoise};
12use crate::frames::Body;
13use crate::math::{enforce_symmetry, exp_quat, skew};
14use crate::state::{Covariance, ErrorState, Offset, STATES, State};
15use crate::units::{
16    Acceleration, AngularRate, Attitude, DeltaAngle, DeltaVelocity, Position, Seconds, Timestamp,
17    Velocity,
18};
19
20/// One IMU measurement, uncorrected: the rotation and the velocity increments over the
21/// intervals each was integrated across, and when they end. The filter subtracts its own
22/// bias estimates, equations (9) and (10).
23///
24/// PX4's `imuSample` (`src/modules/ekf2/EKF/common.h:182-189` at `c4e4ef98`), and
25/// ArduPilot's `imu_elements` (`libraries/AP_NavEKF3/AP_NavEKF3_core.h:598-604` at
26/// `368dc0c4`). Increments rather than rates, because an integrating driver produces
27/// increments and (13)–(15) consume them: a rate between the two is a division by the
28/// interval that the next line multiplies back. A rate source is the one that converts,
29/// through [`from_rates`](Self::from_rates).
30///
31/// Three times, where a single `dt` would collapse them. The two intervals are what each
32/// increment integrated, kept apart as both estimators keep them, since a driver may close
33/// the gyroscope's and the accelerometer's integrals at different moments. `time` is when
34/// the sample ends, and the step [`Eskf::predict`](crate::Eskf::predict) takes is the time
35/// since the previous sample's, which is neither interval: a logger that drops samples
36/// leaves an increment integrated over 2.5 ms arriving a second after the one before it, and
37/// only the timestamps see the second. Such a sample integrates what it measured and the rest
38/// of the step goes unintegrated: a gap longer than
39/// [`Config::max_predict_dt`](crate::Config::max_predict_dt) is coasted whole, and a shorter
40/// one is not priced, so a driver that drops samples should hand over the integral across the
41/// drop, as an integrating one does.
42///
43/// `Default` is a placeholder for struct-update syntax, not a sample: its intervals are zero,
44/// which [`Eskf::predict`](crate::Eskf::predict) and
45/// [`Eskf::initialize`](crate::Eskf::initialize) both refuse as an invalid interval.
46#[derive(Clone, Copy, Debug, Default, PartialEq)]
47pub struct ImuSample {
48    /// When both integration intervals end, on the clock every measurement is timed on.
49    pub time: Timestamp,
50    /// Rotation over [`angle_interval`](Self::angle_interval), body frame.
51    pub delta_angle: DeltaAngle<Body>,
52    /// The interval `delta_angle` integrates.
53    pub angle_interval: Seconds,
54    /// Specific force integrated over [`velocity_interval`](Self::velocity_interval), body
55    /// frame. A level, stationary vehicle gains `(0, 0, −γ Δt)`.
56    pub delta_velocity: DeltaVelocity<Body>,
57    /// The interval `delta_velocity` integrates.
58    pub velocity_interval: Seconds,
59}
60
61impl ImuSample {
62    /// From a rate gyroscope and an accelerometer read at `time`, each reading standing for
63    /// the `interval` since the previous one.
64    ///
65    /// Multiplies, which is the conversion a rate source owes and the only one: ekf2 does the
66    /// same with `sensor_combined` (`src/modules/ekf2/EKF2.cpp:703-707` at `c4e4ef98`).
67    pub fn from_rates(
68        time: Timestamp,
69        gyro: AngularRate<Body>,
70        accel: Acceleration<Body>,
71        interval: Seconds,
72    ) -> Self {
73        let dt = interval.as_secs();
74        Self {
75            time,
76            delta_angle: DeltaAngle::from_vector(gyro.vector() * dt),
77            angle_interval: interval,
78            delta_velocity: DeltaVelocity::from_vector(accel.vector() * dt),
79            velocity_interval: interval,
80        }
81    }
82
83    /// `self` and the `later` sample after it as one sample: increments and intervals summed,
84    /// timed at `later`'s end. What a driver reading a FIFO in batches, or running the filter
85    /// slower than its IMU, hands over.
86    ///
87    /// Summed rather than composed, which is exact for a vehicle that does not rotate across
88    /// the pair and first order for one that does. PX4's `ImuDownSampler` composes the
89    /// rotations as quaternions and rotates the velocity increment into the frame the batch
90    /// ends in (`src/modules/ekf2/EKF/imu_down_sampler/imu_down_sampler.cpp:25-37` at
91    /// `c4e4ef98e9`); summing leaves out `½ Δθ₁ × Δθ₂`, the coning term, and its sculling
92    /// counterpart in velocity. It is nonzero only while the rotation axis moves, and at most
93    /// 3e-6 rad at 1 rad/s across two 2.5 ms samples; an integrating driver that already
94    /// corrects for it should hand its own sum over instead.
95    ///
96    /// The times are not checked: [`Eskf::predict`](crate::Eskf::predict) refuses a sample
97    /// that is not after the last, and a pair out of order is that sample.
98    pub fn accumulate(self, later: ImuSample) -> ImuSample {
99        let sum = |a: Seconds, b: Seconds| Seconds::from_secs(a.as_secs() + b.as_secs());
100        ImuSample {
101            time: later.time,
102            delta_angle: DeltaAngle::from_vector(
103                self.delta_angle.vector() + later.delta_angle.vector(),
104            ),
105            angle_interval: sum(self.angle_interval, later.angle_interval),
106            delta_velocity: DeltaVelocity::from_vector(
107                self.delta_velocity.vector() + later.delta_velocity.vector(),
108            ),
109            velocity_interval: sum(self.velocity_interval, later.velocity_interval),
110        }
111    }
112
113    /// The average angular rate over the sample, `Δθ / Δt`: what initialization's
114    /// stationarity test and gyroscope bias read, both statements about a rate.
115    pub(crate) fn angular_rate(self) -> AngularRate<Body> {
116        AngularRate::from_vector(self.delta_angle.vector() / self.angle_interval.as_secs())
117    }
118
119    /// The average specific force over the sample, `Δv / Δt`; see
120    /// [`angular_rate`](Self::angular_rate).
121    pub(crate) fn specific_force(self) -> Acceleration<Body> {
122        Acceleration::from_vector(self.delta_velocity.vector() / self.velocity_interval.as_secs())
123    }
124
125    /// Whether every number in the sample is finite, intervals included.
126    ///
127    /// Written once and called from both places a sample enters the filter, so that
128    /// [`Eskf::initialize`](crate::Eskf::initialize) and
129    /// [`Eskf::predict`](crate::Eskf::predict) refuse the same sample.
130    pub(crate) fn is_finite(self) -> bool {
131        self.delta_angle.is_finite()
132            && self.delta_velocity.is_finite()
133            && self.angle_interval.as_secs().is_finite()
134            && self.velocity_interval.as_secs().is_finite()
135    }
136
137    /// The interval that is not a forward span of time of at least a microsecond, the
138    /// resolution a [`Timestamp`] keeps, if either is not. A zero one divides into a rate, a
139    /// negative one subtracts (21)'s process noise, and one of 1e-40 s divides an increment
140    /// into an infinity.
141    ///
142    /// The interval rather than a verdict, and written once, so that
143    /// [`Eskf::initialize`](crate::Eskf::initialize) and
144    /// [`Eskf::predict`](crate::Eskf::predict) refuse the same sample and name the same number.
145    pub(crate) fn unusable_interval(self) -> Option<Seconds> {
146        const LEAST: f32 = 1.0e-6;
147        [self.angle_interval, self.velocity_interval]
148            .into_iter()
149            .find(|interval| interval.as_secs().is_nan() || interval.as_secs() < LEAST)
150    }
151
152    /// The longer of the two intervals: the span the sample claims to describe.
153    pub(crate) fn longest_interval(self) -> Seconds {
154        if self.angle_interval > self.velocity_interval {
155            self.angle_interval
156        } else {
157            self.velocity_interval
158        }
159    }
160}
161
162/// Tests build samples from rates, the form most fixtures state a motion in, and time them
163/// where they are used.
164#[cfg(test)]
165impl ImuSample {
166    /// A sample reading `gyro` and `accel`, over one second until [`timed`](Self::timed).
167    pub(crate) fn reading(gyro: AngularRate<Body>, accel: Acceleration<Body>) -> Self {
168        Self::from_rates(Timestamp::ZERO, gyro, accel, Seconds::from_secs(1.0))
169    }
170
171    /// This sample with the gyroscope reading `gyro` over the same interval.
172    pub(crate) fn with_gyro(self, gyro: AngularRate<Body>) -> Self {
173        Self {
174            delta_angle: DeltaAngle::from_vector(gyro.vector() * self.angle_interval.as_secs()),
175            ..self
176        }
177    }
178
179    /// This sample with the accelerometer reading `accel` over the same interval.
180    pub(crate) fn with_accel(self, accel: Acceleration<Body>) -> Self {
181        Self {
182            delta_velocity: DeltaVelocity::from_vector(
183                accel.vector() * self.velocity_interval.as_secs(),
184            ),
185            ..self
186        }
187    }
188
189    /// The same rates over `interval`, ending at `time`. A sample with no interval, the
190    /// default, reads as zero rates.
191    pub(crate) fn timed(self, time: Timestamp, interval: Seconds) -> Self {
192        let rate = |increment: Vector3<f32>, over: Seconds| {
193            if over.as_secs() > 0.0 {
194                increment / over.as_secs()
195            } else {
196                Vector3::zeros()
197            }
198        };
199        Self::from_rates(
200            time,
201            AngularRate::from_vector(rate(self.delta_angle.vector(), self.angle_interval)),
202            Acceleration::from_vector(rate(self.delta_velocity.vector(), self.velocity_interval)),
203            interval,
204        )
205    }
206}
207
208/// An [`ImuSample`] with the filter's bias estimates removed: `ω Δt` and `a_b Δt` of equations
209/// (9) and (10), with the intervals they cover.
210///
211/// A type of its own rather than another [`ImuSample`], which carries the same fields in the
212/// same frames. What separates them is whether the bias has been taken off, and that is the
213/// claim that causes the bug: subtracting twice removes a bias the sample no longer carries,
214/// subtracting never hands (11) the raw measurement. Neither shows up in the numbers, both
215/// being small, plausible increments.
216#[derive(Clone, Copy, Debug, PartialEq)]
217pub(crate) struct Corrected {
218    /// `ω Δt_θ`, the rotation increment less `β̂_g Δt_θ`.
219    pub(crate) delta_angle: DeltaAngle<Body>,
220    /// `Δt_θ`.
221    pub(crate) angle_interval: Seconds,
222    /// `a_b Δt_v`, the velocity increment less `β̂_a Δt_v`.
223    pub(crate) delta_velocity: DeltaVelocity<Body>,
224    /// `Δt_v`.
225    pub(crate) velocity_interval: Seconds,
226}
227
228impl Corrected {
229    /// `ω` of (9), what [`Eskf::angular_rate`](crate::Eskf::angular_rate) reports.
230    pub(crate) fn omega(self) -> AngularRate<Body> {
231        AngularRate::from_vector(self.delta_angle.vector() / self.angle_interval.as_secs())
232    }
233}
234
235/// Subtract the filter's own bias estimates from a raw sample, each over its own increment's
236/// interval. Equations (9) and (10).
237pub(crate) fn corrected_imu(imu: ImuSample, state: &State) -> Corrected {
238    Corrected {
239        delta_angle: DeltaAngle::from_vector(
240            imu.delta_angle.vector() - state.gyro_bias.vector() * imu.angle_interval.as_secs(),
241        ),
242        angle_interval: imu.angle_interval,
243        delta_velocity: DeltaVelocity::from_vector(
244            imu.delta_velocity.vector()
245                - state.accel_bias.vector() * imu.velocity_interval.as_secs(),
246        ),
247        velocity_interval: imu.velocity_interval,
248    }
249}
250
251/// Advance the nominal state across one sample by dead reckoning. Equations (11) and
252/// (13)–(15), in increments.
253///
254/// Translation runs over the velocity increment's interval and rotation over the angle
255/// increment's, each the span its measurement covers: gravity is added over the same `Δt_v`
256/// the accelerometer integrated, so a vehicle at rest gains nothing whatever the two
257/// intervals are. PX4 integrates over the same two (`EKF/ekf.cpp:241-271` at `c4e4ef98`).
258///
259/// Position is evaluated **before** velocity, the one ordering constraint in (13)–(14):
260/// (13) reads the pre-update `v̂`, and applying (14) first adds a spurious `a_n Δt²` to
261/// position on every step — a bias that integrates without bound rather than averaging
262/// out, and one that no test of a single step can see.
263///
264/// The biases do not move. (12) models them as random walks, so propagation leaves the
265/// estimates where they are and only the covariance of (16)–(22) grows around them.
266///
267/// The caller owes a finite sample and a finite `dt`, and owes the check on the result:
268/// `a_n` is a product of finite numbers that can still overflow f32, and an infinity here
269/// reaches the quaternion and never leaves. There is no channel to refuse through from
270/// here, so [`Eskf::predict`](crate::Eskf::predict) is what declines to commit it, as
271/// [`Propagation::StateNotFinite`](crate::Propagation::StateNotFinite).
272pub(crate) fn propagate_nominal(state: State, imu: Corrected, gravity: f32) -> State {
273    let dt = imu.velocity_interval.as_secs();
274    let rotation = state.attitude.quaternion();
275
276    // (11), times `Δt`: the velocity increment into the navigation frame, gravity's added.
277    // A level vehicle at rest gains (0, 0, -γ Δt) and this is zero, which is the sign
278    // convention's own test — down-positive gravity against a down-negative specific force.
279    let delta_v = rotation * imu.delta_velocity.vector() + gravity_vector(gravity) * dt;
280
281    let velocity = state.velocity.vector();
282    let position = state.position.vector() + velocity * dt + 0.5 * delta_v * dt; // (13)
283    let velocity = velocity + delta_v; // (14), from the pre-update velocity above
284
285    // (15): the body-frame rotation increment composes on the right. `exp_quat` keeps the
286    // first-order term where `UnitQuaternion::from_scaled_axis` substitutes the identity,
287    // and the product of two unit quaternions drifts off the manifold in f32 over a
288    // flight. Not `renormalize_fast`: its first-order approximation saves a square root on
289    // a path that already evaluates a sine and a cosine per sample.
290    let mut attitude = rotation * exp_quat(imu.delta_angle.vector());
291    attitude.renormalize();
292
293    State {
294        attitude: Attitude::from_quaternion(attitude),
295        position: Position::from_vector(position),
296        velocity: Velocity::from_vector(velocity),
297        ..state
298    }
299}
300
301/// `F` of equation (20): 15 x 15, the same shape as the covariance it propagates.
302pub(crate) type Transition = SMatrix<f32, STATES, STATES>;
303
304/// The nominal state and the covariance after one step, before either is committed, with the
305/// rate the step integrated.
306///
307/// A pair rather than two return values because the commit is atomic: (11)–(14) and (22) can
308/// both overflow f32 from finite inputs, and a state written beside a covariance that was
309/// refused — or the reverse — is the poisoning that keeping the whole step out of the filter
310/// avoids. [`Eskf::predict`](crate::Eskf::predict) commits both or neither.
311pub(crate) struct Propagated {
312    /// The nominal state of (13)–(15).
313    pub(crate) state: State,
314    /// The covariance of (22).
315    pub(crate) covariance: Covariance,
316    /// The barometric offset's share of it, (30′).
317    pub(crate) offset: Offset,
318    /// `ω` of (9), what [`Eskf::angular_rate`](crate::Eskf::angular_rate) reports: `None`
319    /// from a coast, which reads no sample.
320    pub(crate) omega: Option<AngularRate<Body>>,
321}
322
323impl Propagated {
324    /// Whether every number in the step is finite.
325    pub(crate) fn is_finite(&self) -> bool {
326        self.state.is_finite()
327            && self.covariance.is_finite()
328            && self.offset.is_finite()
329            && self.omega.is_none_or(|omega| omega.is_finite())
330    }
331}
332
333/// Advance the nominal state and its covariance across one sample. Equations (9)–(22).
334///
335/// The bias correction of (9)–(10) is applied once here, and both halves read that one
336/// `Corrected` sample and the one attitude it arrived with. `F` is built before the nominal
337/// step runs, because (20) linearizes about the state at the **start** of the interval:
338/// building it afterwards would linearize about the state (15) has already rotated, and no
339/// test comparing `F` against the propagation would see it, since both sides would use the
340/// same wrong attitude.
341pub(crate) fn propagate(
342    state: State,
343    covariance: Covariance,
344    offset: Offset,
345    imu: ImuSample,
346    config: &Config,
347) -> Propagated {
348    let corrected = corrected_imu(imu, &state);
349    let transition = transition_matrix(&state, corrected);
350    let q = process_noise(&config.imu, corrected);
351
352    Propagated {
353        state: propagate_nominal(state, corrected, config.gravity),
354        covariance: propagate_covariance(covariance, &transition, q),
355        offset: propagate_offset(
356            offset,
357            &transition,
358            config.baro_offset_walk,
359            corrected.velocity_interval,
360        ),
361        omega: Some(corrected.omega()),
362    }
363}
364
365/// `P_xb ← F P_xb` and `P_bb ← P_bb + q_b² Δt`. Equation (30′).
366///
367/// The offset is a random walk with no dynamics of its own, so its block of the augmented
368/// transition is one and the cross-covariance moves only with the error state it is
369/// correlated with: a 15 × 15 by 15 × 1 product, where the augmented `F P Fᵀ` would repeat
370/// (22) at sixteen.
371fn propagate_offset(offset: Offset, f: &Transition, walk: f32, dt: Seconds) -> Offset {
372    Offset {
373        cross: f * offset.cross,
374        variance: offset.variance + walk * walk * dt.as_secs(),
375    }
376}
377
378/// `F`, the discrete state transition matrix of equation (20), linearized about the nominal
379/// state at the start of the interval.
380///
381/// The continuous error dynamics (16)–(19) that (20) discretizes, with the local attitude
382/// error `δθ` of equation (2), `q = q̂ ⊗ Exp(δθ)`:
383///
384/// ```text
385/// δṗ = δv                                            (16)
386/// δv̇ = −R(q̂)[a_b]ₓ δθ − R(q̂) δβa − R(q̂) w_a          (17)
387/// δθ̇ = −[ω]ₓ δθ − δβg − w_g                          (18)
388/// δβ̇a = w_βa,   δβ̇g = w_βg                           (19)
389/// ```
390///
391/// (17) is the coupling that motivates the whole filter: a tilt error rotates the measured
392/// specific force into the wrong navigation-frame direction, and the mistake integrates into
393/// velocity and then into position. Read backwards it is also what makes tilt observable at
394/// all — the reason a velocity innovation can correct an attitude no sensor measures
395/// directly.
396///
397/// Every block is first order in `Δt` except the attitude block, which is the exact solution
398/// of (18)'s homogeneous part, `R{ω Δt}ᵀ = exp(−[ω]ₓ Δt)`. Each `Δt` is the interval of the
399/// increment the block reads: `Δt_v` on the translational blocks, `Δt_θ` on the rotational,
400/// so `[a_b]ₓ Δt` is the corrected velocity increment's own skew and no rate is formed. (20) permits `I − [ω]ₓ Δt` where
401/// the exact form is not justified, and here it is: the exact block costs one [`exp_quat`] --
402/// a sine, a cosine, and a quaternion to matrix — against the roughly 6750 multiplications
403/// the two 15 x 15 products of (22) spend on the same sample. The approximation buys nothing
404/// measurable and gives up accuracy at high rotation rates, which is the regime a propagated
405/// covariance exists for.
406///
407/// `imu` is the sample the nominal step reads, and `state` is that step's input rather than
408/// its output; [`propagate`] holds both, which is why neither is looked up here.
409fn transition_matrix(state: &State, imu: Corrected) -> Transition {
410    let (dt_v, dt_theta) = (
411        imu.velocity_interval.as_secs(),
412        imu.angle_interval.as_secs(),
413    );
414    let rotation = state.attitude.quaternion().to_rotation_matrix();
415    let r = rotation.matrix();
416
417    // The identity supplies (16)'s and (19)'s diagonal blocks, and the `I` on the velocity
418    // row: a first-order discretization changes only the couplings written below.
419    let mut f = Transition::identity();
420    let (p, v, theta, beta_a, beta_g) = (
421        ErrorState::PositionNorth.index(),
422        ErrorState::VelocityNorth.index(),
423        ErrorState::AttitudeX.index(),
424        ErrorState::AccelBiasX.index(),
425        ErrorState::GyroBiasX.index(),
426    );
427
428    // (16): δp gains δv over the step.
429    f.fixed_view_mut::<3, 3>(p, v)
430        .copy_from(&(Matrix3::identity() * dt_v));
431    // (17): the two terms an accelerometer error enters velocity through.
432    f.fixed_view_mut::<3, 3>(v, theta)
433        .copy_from(&(-r * skew(imu.delta_velocity.vector())));
434    f.fixed_view_mut::<3, 3>(v, beta_a).copy_from(&(-r * dt_v));
435    // (18): exact in the attitude block, first order in the gyroscope bias. `R{ω Δt}ᵀ` is
436    // the exact solution of `δθ̇ = −[ω]ₓ δθ`, which is why (15)'s own increment builds it.
437    let increment = exp_quat(imu.delta_angle.vector()).to_rotation_matrix();
438    f.fixed_view_mut::<3, 3>(theta, theta)
439        .copy_from(&increment.matrix().transpose());
440    f.fixed_view_mut::<3, 3>(theta, beta_g)
441        .copy_from(&(-Matrix3::identity() * dt_theta));
442
443    f
444}
445
446/// `A`, the continuous error dynamics (16)–(19) as a matrix: `δẋ = A δx` with the noise left
447/// out. What (20) discretizes, taken here at the rates `ω` and `a_b` rather than a sample.
448pub(crate) fn error_dynamics(state: &State, omega: Vector3<f32>, a_b: Vector3<f32>) -> Transition {
449    let rotation = state.attitude.quaternion().to_rotation_matrix();
450    let r = rotation.matrix();
451    let mut a = Transition::zeros();
452    let (p, v, theta, beta_a, beta_g) = (
453        ErrorState::PositionNorth.index(),
454        ErrorState::VelocityNorth.index(),
455        ErrorState::AttitudeX.index(),
456        ErrorState::AccelBiasX.index(),
457        ErrorState::GyroBiasX.index(),
458    );
459    a.fixed_view_mut::<3, 3>(p, v)
460        .copy_from(&Matrix3::identity());
461    a.fixed_view_mut::<3, 3>(v, theta)
462        .copy_from(&(-r * skew(a_b)));
463    a.fixed_view_mut::<3, 3>(v, beta_a).copy_from(&(-r));
464    a.fixed_view_mut::<3, 3>(theta, theta)
465        .copy_from(&(-skew(omega)));
466    a.fixed_view_mut::<3, 3>(theta, beta_g)
467        .copy_from(&(-Matrix3::identity()));
468    a
469}
470
471/// `Q`, the discrete process noise of equation (21), as its diagonal, over the intervals the
472/// sample integrated: the accelerometer's densities over `Δt_v`, the gyroscope's over `Δt_θ`.
473///
474/// A diagonal rather than a 15 x 15 matrix because (21) has twelve nonzero entries: the matrix form
475/// would spend a 15 × 15 temporary and 225 additions to add twelve numbers, and "the diagonal of
476/// `Q` lands on the diagonal of `P`" is closer to the equation than a dense sum. What makes that
477/// legitimate is the isotropy below; a per-axis `Σ_a` is what would make this a matrix again.
478///
479/// **`Δt`, not `Δt²`.** [`ImuNoise`]'s four fields are spectral densities, and a density's
480/// contribution over a step is `σ² Δt` — for the white-noise blocks and the two random walks
481/// alike. A `Δt²` on the white-noise blocks is PX4's and ArduPilot's form,
482/// where the parameter is the σ of one sample's increment rather than a density: PX4
483/// `sq(dt) * accel_var` with `accel_var = sq(ekf2_acc_noise)`
484/// (`src/modules/ekf2/EKF/python/ekf_derivation/generated/predict_covariance.h:161-164`,
485/// `EKF/covariance.cpp:119-133`, at `c4e4ef98e9`), ArduPilot `dvxVar = sq(dt * _accNoise)`
486/// (`libraries/AP_NavEKF3/AP_NavEKF3_core.cpp:1177` at `368dc0c428`). That form ties `Q` to
487/// the IMU rate — the variance it adds over `T` seconds is `σ² Δt T`, so the same airframe
488/// logged at 400 Hz is given eight times less process noise than at 50 Hz — and the corpus in
489/// `data/manifest.txt` spans 50 Hz to 250 Hz with `Config` shared across all of it. `Δt` is
490/// rate-independent, and `EQUATIONS.md` (21) states it.
491///
492/// Reading [`ImuNoise`] as densities is what `examples/simulate.rs` does when it draws
493/// per-sample noise as `white / √Δt`, so filter and simulator agree on what the numbers
494/// mean. That file also states the consequence, which is the check on this decision: against
495/// its IMU table the filter's `Q` is two orders of magnitude conservative, so every scenario
496/// must come out *under*-confident, and a `nees_*` above one is a finding rather than a pass.
497///
498/// The velocity block of (21) is the rotated accelerometer noise `R Σ_a Rᵀ`, and writing it
499/// `σ_a² I` is exact only where `Σ_a` is isotropic, since `R (σ_a² I) Rᵀ = σ_a² I` for
500/// orthogonal `R`. Real IMUs are noisier about z. One scalar per sensor is what [`ImuNoise`]
501/// can express, so the conservatism is in the number rather than in the model: `σ_a` is the
502/// worst axis.
503fn process_noise(noise: &ImuNoise, imu: Corrected) -> [f32; STATES] {
504    let (dt_v, dt_theta) = (
505        imu.velocity_interval.as_secs(),
506        imu.angle_interval.as_secs(),
507    );
508    let densities = Densities::of(noise, None);
509    let velocity = densities.velocity * dt_v;
510    let attitude = densities.attitude * dt_theta;
511    let accel_bias = densities.accel_bias * dt_v;
512    let gyro_bias = densities.gyro_bias * dt_theta;
513
514    // In the `ErrorState` ordering: `[δp δv δθ δβa δβg]`. Position takes none of its own --
515    // (16) has no driving noise, and position error is what the velocity block integrates.
516    #[rustfmt::skip]
517    let diagonal = [
518        0.0,        0.0,        0.0,
519        velocity,   velocity,   velocity,
520        attitude,   attitude,   attitude,
521        accel_bias, accel_bias, accel_bias,
522        gyro_bias,  gyro_bias,  gyro_bias,
523    ];
524    diagonal
525}
526
527/// `P ← F P Fᵀ + Q`, equation (22), with the symmetry enforcement (42) asks for afterwards.
528///
529/// Written as (22) reads, which is the most expensive thing the filter does: three 15 × 15
530/// temporaries per IMU sample, at up to 400 Hz. (20) is sparse enough (two identity blocks, two
531/// zero rows) that a block-wise form would cut them, at the cost of the one equation a reader of
532/// this crate is most likely to have come for. Its stack frame is [published], and its
533/// arithmetic [counted]. The block-wise form is not built: [timed] on a 400 MHz Cortex-M7, a step
534/// sits well inside a 400 Hz period.
535///
536/// `Q` arrives as a diagonal and is added as one, which keeps those temporaries to three
537/// rather than four.
538///
539/// Out of line by attribute: inlined into `propagate`, its one caller, the three temporaries
540/// sit in `predict`'s own chain ([measured]).
541///
542/// [published]: https://github.com/wboayue/fusion-nav/blob/main/validation/cost.md#stack
543/// [counted]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#arithmetic
544/// [timed]: https://github.com/wboayue/fusion-nav/blob/main/validation/cost.md#time-on-a-cortex-m7
545/// [measured]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#stack-frames
546#[inline(never)]
547fn propagate_covariance(p: Covariance, f: &Transition, q: [f32; STATES]) -> Covariance {
548    let mut next = f * p.as_matrix() * f.transpose();
549
550    // Both indices are the same `i` below `STATES`, the dimension of `P`, so the indexing
551    // cannot be out of range: `nalgebra` panics there, and nothing in `src/` may.
552    for (i, variance) in q.iter().enumerate() {
553        next[(i, i)] += variance;
554    }
555
556    // (42). `F P Fᵀ` is symmetric in exact arithmetic and drifts off it in f32, and a `P`
557    // that is not symmetric is one whose innovation covariance can go indefinite later.
558    enforce_symmetry(&mut next);
559    Covariance::from_matrix(next)
560}
561
562/// Grow `P` over `horizon` as if nothing were measured and the vehicle stayed put.
563/// Equation (22′), without the unmeasured motion a gap allows.
564///
565/// This is the arming question's half of [`Eskf::predicted_validity`](crate::Eskf::predicted_validity):
566/// not *is the estimate good now*, but *will it still be good in `horizon` seconds if I take off
567/// and nothing aids it*. The covariance is the only thing that can answer that, and the answer
568/// keeps the correlations (17) and (20) build, which is where most of the growth a few seconds
569/// out comes from.
570///
571/// The IMU input is the one the question presumes: a vehicle on the ground, not rotating, so
572/// `ω = 0` and the specific force is `−R(q̂)ᵀ g`, what a stationary accelerometer reads at the
573/// current attitude. That is what makes the tilt-to-velocity coupling of (17) the gravity leak
574/// it is in flight, rather than zero. It is [`coast`]'s assumption with neither of [`Coast`]'s
575/// densities, since `F` does not read velocity and an unaccelerated vehicle and one standing
576/// still grow it alike, so both are [`unaccelerated_growth`]: one exact step at any horizon. The
577/// nominal state is untouched and no timer moves. A projection is not time passing.
578pub(crate) fn project(
579    state: &State,
580    covariance: Covariance,
581    horizon: Seconds,
582    config: &Config,
583) -> Covariance {
584    // A horizon that is not a positive duration projects nothing rather than projecting
585    // backwards: a negative `T` *subtracts* process noise and lands variances below zero, which
586    // `Validity` reads as an estimate better than any the filter could have. `Config::validate`
587    // refuses a negative or NaN `Accuracy::horizon`; zero, which it allows, arrives here.
588    let seconds = horizon.as_secs();
589    if seconds.is_nan() || seconds <= 0.0 {
590        return covariance;
591    }
592    let densities = Densities::of(&config.imu, None);
593    unaccelerated_growth(
594        state,
595        &covariance,
596        seconds,
597        config.gravity,
598        &densities,
599        None,
600    )
601}
602
603/// Advance the state and its covariance across a gap no IMU sample describes. Equation (22′).
604///
605/// The input assumed is [`unaccelerated_sample`]'s: no rotation, and the specific force that
606/// cancels gravity. Run through (13)–(15) it moves position by `v̂ T` and nothing else, and its
607/// error dynamics are [`unaccelerated_growth`]'s, so the state and the covariance describe one
608/// hypothesis. What the hypothesis leaves out, the vehicle's actual acceleration and rotation,
609/// enters as the white densities of [`Coast`] on the velocity and attitude blocks, beside (21)'s
610/// own noise.
611///
612/// One step at any gap, the nominal one because an unaccelerated vehicle's (13) is exact over
613/// any interval, and the covariance's because `ω = 0` makes the error dynamics nilpotent: the
614/// transition and the noise it integrates are polynomials in `T`, exact where (22) run in short
615/// steps understates. So a gap costs one (22)'s arithmetic, where the steps cost a long gap
616/// nearly two periods of a 400 Hz loop ([measured]).
617///
618/// A gap that is not a positive duration coasts nothing, for [`project`]'s reason.
619/// [`Eskf::predict`](crate::Eskf::predict) coasts only past a positive limit, so the guard
620/// holds a bound no caller in the crate crosses.
621///
622/// [measured]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#coast-and-projection
623pub(crate) fn coast(
624    state: State,
625    covariance: Covariance,
626    offset: Offset,
627    gap: Seconds,
628    config: &Config,
629    unmeasured: &Coast,
630) -> Propagated {
631    let seconds = gap.as_secs();
632    if seconds.is_nan() || seconds <= 0.0 {
633        return Propagated {
634            state,
635            covariance,
636            offset,
637            omega: None,
638        };
639    }
640    let densities = Densities::of(&config.imu, Some(unmeasured));
641    // (30′) across the whole gap at once: the offset has no dynamics, so its cross-covariance
642    // moves with the error state through `Φ`, and its variance walks by `q_b² T`.
643    let mut cross = offset.cross;
644    let covariance = unaccelerated_growth(
645        &state,
646        &covariance,
647        seconds,
648        config.gravity,
649        &densities,
650        Some(&mut cross),
651    );
652    let walk = config.baro_offset_walk;
653    Propagated {
654        state: propagate_nominal(
655            state,
656            unaccelerated_sample(&state, gap, config.gravity),
657            config.gravity,
658        ),
659        covariance,
660        offset: Offset {
661            cross,
662            variance: offset.variance + walk * walk * seconds,
663        },
664        omega: None,
665    }
666}
667
668/// The white densities driving the error, per axis: (21)'s, with [`Coast`]'s unmeasured
669/// acceleration and rotation added where a gap allows them.
670struct Densities {
671    velocity: f32,
672    attitude: f32,
673    accel_bias: f32,
674    gyro_bias: f32,
675}
676
677impl Densities {
678    fn of(noise: &ImuNoise, unmeasured: Option<&Coast>) -> Self {
679        let (acceleration, rotation) =
680            unmeasured.map_or((0.0, 0.0), |c| (c.acceleration, c.rotation));
681        Self {
682            velocity: noise.accel_white * noise.accel_white + acceleration * acceleration,
683            attitude: noise.gyro_white * noise.gyro_white + rotation * rotation,
684            accel_bias: noise.accel_bias_walk * noise.accel_bias_walk,
685            gyro_bias: noise.gyro_bias_walk * noise.gyro_bias_walk,
686        }
687    }
688}
689
690/// `Φ`, the exact transition over `T` seconds of an unaccelerated, unrotating vehicle.
691/// Equation (22′).
692///
693/// With `ω = 0` and `a_b = −R(q̂)ᵀ g`, the dynamics (16)–(19) are a chain, `δβg → δθ → δv → δp`,
694/// with `δβa` entering velocity, so `A⁴ = 0` and `Φ = exp(A T) = I + A T + A² T²/2 + A³ T³/6`
695/// has four terms; `EQUATIONS.md` (22′) writes it out. Built in blocks rather than as `A` and its
696/// powers: it has eleven nonzero blocks, each a scalar times `I`, `R` or `G`, where `G` is (17)'s
697/// `−R(q̂)[a_b]ₓ`, for this `a_b` the gravity leak `[g]ₓ R(q̂)`.
698fn unaccelerated_transition(state: &State, t: f32, gravity: f32) -> Transition {
699    let (r, g) = gravity_leak(state, gravity);
700    let i = Matrix3::<f32>::identity();
701    let t2 = t * t;
702    let (p, v, theta, beta_a, beta_g) = (
703        ErrorState::PositionNorth.index(),
704        ErrorState::VelocityNorth.index(),
705        ErrorState::AttitudeX.index(),
706        ErrorState::AccelBiasX.index(),
707        ErrorState::GyroBiasX.index(),
708    );
709    // Row by row: position, velocity, attitude; the biases' rows are the identity's.
710    let mut phi = Transition::identity();
711    phi.fixed_view_mut::<3, 3>(p, v).copy_from(&(i * t));
712    phi.fixed_view_mut::<3, 3>(p, theta)
713        .copy_from(&(g * (t2 / 2.0)));
714    phi.fixed_view_mut::<3, 3>(p, beta_a)
715        .copy_from(&(-r * (t2 / 2.0)));
716    phi.fixed_view_mut::<3, 3>(p, beta_g)
717        .copy_from(&(-g * (t2 * t / 6.0)));
718    phi.fixed_view_mut::<3, 3>(v, theta).copy_from(&(g * t));
719    phi.fixed_view_mut::<3, 3>(v, beta_a).copy_from(&(-r * t));
720    phi.fixed_view_mut::<3, 3>(v, beta_g)
721        .copy_from(&(-g * (t2 / 2.0)));
722    phi.fixed_view_mut::<3, 3>(theta, beta_g)
723        .copy_from(&(-i * t));
724    phi
725}
726
727/// `R(q̂)` and (17)'s `G = −R(q̂)[a_b]ₓ` at the specific force of an unaccelerated vehicle.
728fn gravity_leak(state: &State, gravity: f32) -> (Matrix3<f32>, Matrix3<f32>) {
729    let r = *state.attitude.quaternion().to_rotation_matrix().matrix();
730    let g = -r * skew(unaccelerated_force(state, gravity));
731    (r, g)
732}
733
734/// `P ← Φ P Φᵀ + Q_d` over `T` seconds of an unaccelerated, unrotating vehicle, exactly.
735/// Equation (22′), with `Φ` [`unaccelerated_transition`]'s.
736///
737/// `Q_d = ∫₀ᵀ Φ(s) Q_c Φ(s)ᵀ ds` integrates each density along the column of `Φ` it enters by, in
738/// closed form: twenty-one blocks counting both halves, each a scalar times `I`, `R`, `G` or
739/// `G Gᵀ`. `cross`, where given, is a column correlated with the error state, (30′)'s `P_xb`,
740/// carried through the same `Φ` here rather than handing `Φ` back: returned, it sat in every
741/// caller's frame, `predicted_validity`'s included ([measured]).
742///
743/// [measured]: https://github.com/wboayue/fusion-nav/blob/main/DESIGN.md#stack-frames
744fn unaccelerated_growth(
745    state: &State,
746    covariance: &Covariance,
747    t: f32,
748    gravity: f32,
749    q: &Densities,
750    cross: Option<&mut SVector<f32, STATES>>,
751) -> Covariance {
752    let (r, g) = gravity_leak(state, gravity);
753    let i = Matrix3::<f32>::identity();
754    let (t2, t3) = (t * t, t * t * t);
755    let (t4, t5) = (t3 * t, t3 * t2);
756    let (p, v, theta, beta_a, beta_g) = (
757        ErrorState::PositionNorth.index(),
758        ErrorState::VelocityNorth.index(),
759        ErrorState::AttitudeX.index(),
760        ErrorState::AccelBiasX.index(),
761        ErrorState::GyroBiasX.index(),
762    );
763
764    let phi = unaccelerated_transition(state, t, gravity);
765    if let Some(cross) = cross {
766        *cross = phi * *cross;
767    }
768    let mut next = phi * covariance.as_matrix() * phi.transpose();
769
770    // Q_d, the upper blocks; the lower are their transposes, written beside them. Each line
771    // sums the densities that reach that pair of states, with the integral of the product of
772    // the two columns they enter by.
773    let ggt = g * g.transpose();
774    let (qv, qt, qa, qg) = (q.velocity, q.attitude, q.accel_bias, q.gyro_bias);
775    // One statement per block, with the indices constants: a loop over `(row, column)` pairs
776    // leaves `fixed_view_mut`'s bounds check in, a panic path nothing in `src/` may carry.
777    macro_rules! add {
778        ($row:expr, $column:expr, $block:expr) => {{
779            let block: Matrix3<f32> = $block;
780            let mut upper = next.fixed_view_mut::<3, 3>($row, $column);
781            upper += block;
782            if $row != $column {
783                let mut lower = next.fixed_view_mut::<3, 3>($column, $row);
784                lower += block.transpose();
785            }
786        }};
787    }
788    add!(
789        p,
790        p,
791        i * (qv * t3 / 3.0 + qa * t5 / 20.0) + ggt * (qt * t5 / 20.0 + qg * t5 * t2 / 252.0)
792    );
793    add!(
794        p,
795        v,
796        i * (qv * t2 / 2.0 + qa * t4 / 8.0) + ggt * (qt * t4 / 8.0 + qg * t5 * t / 72.0)
797    );
798    add!(p, theta, g * (qt * t3 / 6.0 + qg * t5 / 30.0));
799    add!(p, beta_a, -r * (qa * t3 / 6.0));
800    add!(p, beta_g, -g * (qg * t4 / 24.0));
801    add!(
802        v,
803        v,
804        i * (qv * t + qa * t3 / 3.0) + ggt * (qt * t3 / 3.0 + qg * t5 / 20.0)
805    );
806    add!(v, theta, g * (qt * t2 / 2.0 + qg * t4 / 8.0));
807    add!(v, beta_a, -r * (qa * t2 / 2.0));
808    add!(v, beta_g, -g * (qg * t3 / 6.0));
809    add!(theta, theta, i * (qt * t + qg * t3 / 3.0));
810    add!(theta, beta_g, -i * (qg * t2 / 2.0));
811    add!(beta_a, beta_a, i * (qa * t));
812    add!(beta_g, beta_g, i * (qg * t));
813
814    // (42), for the rounding the products leave.
815    enforce_symmetry(&mut next);
816    Covariance::from_matrix(next)
817}
818
819/// What the IMU of an unaccelerated vehicle at `state`'s attitude reads over `dt`: no
820/// rotation, and the specific force `−R(q̂)ᵀ g` that holds it up. At rest or at constant
821/// velocity alike.
822///
823/// The inverse of the test (11) is written against — a level vehicle at rest reads
824/// `(0, 0, −γ)`, evaluated at an attitude that need not be level. [`coast`] moves the nominal
825/// state with it, and [`unaccelerated_growth`] linearizes about the same specific force.
826fn unaccelerated_sample(state: &State, dt: Seconds, gravity: f32) -> Corrected {
827    Corrected {
828        delta_angle: DeltaAngle::from_vector(Vector3::zeros()),
829        angle_interval: dt,
830        delta_velocity: DeltaVelocity::from_vector(
831            unaccelerated_force(state, gravity) * dt.as_secs(),
832        ),
833        velocity_interval: dt,
834    }
835}
836
837/// `−R(q̂)ᵀ g`, the specific force that holds an unaccelerated vehicle up, in body axes: what
838/// [`unaccelerated_sample`] reads and [`gravity_leak`] linearizes about.
839fn unaccelerated_force(state: &State, gravity: f32) -> Vector3<f32> {
840    state.attitude.quaternion().to_rotation_matrix().inverse() * -gravity_vector(gravity)
841}
842
843/// `g = [0, 0, γ]ᵀ`, the navigation-frame gravity vector of (11), for `γ` as
844/// [`Config::gravity`] holds it.
845pub(crate) fn gravity_vector(gravity: f32) -> Vector3<f32> {
846    Vector3::new(0.0, 0.0, gravity)
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852    use nalgebra::UnitQuaternion;
853
854    use crate::config::GRAVITY;
855
856    use crate::config::Initialization;
857    use crate::init;
858    use crate::state::{CovarianceMatrix, ErrorState};
859    use crate::units::Radians;
860
861    const DT: Seconds = Seconds::from_secs(0.005);
862
863    /// The defaults, with `noise` and the barometric offset's `walk` as a test sets them.
864    fn under(noise: ImuNoise, walk: f32) -> Config {
865        Config {
866            imu: noise,
867            baro_offset_walk: walk,
868            ..Config::default()
869        }
870    }
871
872    /// At the origin, level, at rest, unbiased.
873    fn at_rest() -> State {
874        State {
875            attitude: Attitude::level(),
876            ..State::default()
877        }
878    }
879
880    /// What a level vehicle at rest measures: gravity alone, down-negative.
881    fn holding_still() -> ImuSample {
882        ImuSample::reading(
883            AngularRate::body(0.0, 0.0, 0.0),
884            Acceleration::body(0.0, 0.0, -GRAVITY),
885        )
886    }
887
888    fn step(state: State, imu: ImuSample, dt: Seconds) -> State {
889        propagate_nominal(
890            state,
891            corrected_imu(imu.timed(Timestamp::ZERO, dt), &state),
892            GRAVITY,
893        )
894    }
895
896    fn run(mut state: State, imu: ImuSample, steps: u32) -> State {
897        for _ in 0..steps {
898            state = step(state, imu, DT);
899        }
900        state
901    }
902
903    /// (11) with the signs right: a level vehicle at rest reads `(0, 0, -γ)`, so `a_n` is
904    /// zero and nothing moves over a minute. One test, and it fails on a sign error in
905    /// (11), on a gravity vector pointing up, and on a body/navigation frame mix-up.
906    #[test]
907    fn a_level_vehicle_at_rest_stays_where_it_is() {
908        let state = run(at_rest(), holding_still(), 12_000);
909        assert!(state.position.vector().norm() < 1e-6, "{state:?}");
910        assert!(state.velocity.vector().norm() < 1e-6, "{state:?}");
911    }
912
913    /// (15) against the closed form: a constant rate about one axis for `T` seconds is a
914    /// rotation of `ωT` about that axis, and the quaternion stays unit the whole way.
915    #[test]
916    fn a_constant_body_rate_integrates_to_the_closed_form_rotation() {
917        let rate = 0.4;
918        let steps = 1_000;
919        let imu = holding_still().with_gyro(AngularRate::body(0.0, 0.0, rate));
920
921        let mut state = at_rest();
922        for _ in 0..steps {
923            state = step(state, imu, DT);
924            let q = state.attitude.quaternion().into_inner();
925            assert!((q.norm() - 1.0).abs() < 1e-6, "{q:?}");
926        }
927
928        let expected = rate * DT.as_secs() * steps as f32;
929        let (_, _, yaw) = state.attitude.euler_angles();
930        assert!((yaw - expected).abs() < 1e-3, "{yaw} vs {expected}");
931    }
932
933    /// The ordering hazard of (13)/(14), and the only test that sees it: a constant `a_n`
934    /// over `N` steps must match `p = ½at²`. Evaluating (14) first adds `N a Δt²` — 0.05 m
935    /// here against an answer of 25 m, which is inside any tolerance a single step would
936    /// justify and grows with the length of the flight.
937    #[test]
938    fn position_uses_the_pre_update_velocity() {
939        let accel = 2.0;
940        let seconds = 5.0;
941        let steps = (seconds / DT.as_secs()) as u32;
942        let imu = holding_still().with_accel(Acceleration::body(accel, 0.0, -GRAVITY));
943
944        let state = run(at_rest(), imu, steps);
945        let closed_form = 0.5 * accel * seconds * seconds;
946        assert!(
947            (state.position.vector().x - closed_form).abs() < 1e-2,
948            "{} vs {closed_form}",
949            state.position.vector().x
950        );
951
952        // What the fused order would have added, stated so the margin above is checkable
953        // rather than chosen: N a Δt² = a Δt T.
954        let fused_excess = accel * DT.as_secs() * seconds;
955        assert!((fused_excess - 0.05).abs() < 1e-6, "{fused_excess}");
956    }
957
958    /// (9): a gyroscope bias equal to the rate the sensor reports leaves the attitude
959    /// alone. Without the subtraction the vehicle turns at 0.4 rad/s sitting still.
960    #[test]
961    fn a_gyro_bias_equal_to_the_measured_rate_produces_no_rotation() {
962        let rate = 0.4;
963        let state = State {
964            gyro_bias: AngularRate::body(0.0, 0.0, rate),
965            ..at_rest()
966        };
967        let imu = holding_still().with_gyro(AngularRate::body(0.0, 0.0, rate));
968
969        let (_, _, yaw) = run(state, imu, 2_000).attitude.euler_angles();
970        assert!(yaw.abs() < 1e-6, "{yaw}");
971    }
972
973    /// (10): the same for the accelerometer. A bias `b` against a measurement of `-γ + b`
974    /// is a vehicle at rest, not one accelerating at `b`.
975    #[test]
976    fn an_accel_bias_equal_to_the_measured_offset_produces_no_velocity() {
977        let bias = 0.3;
978        let state = State {
979            accel_bias: Acceleration::body(bias, 0.0, 0.0),
980            ..at_rest()
981        };
982        let imu = holding_still().with_accel(Acceleration::body(bias, 0.0, -GRAVITY));
983
984        let velocity = run(state, imu, 2_000).velocity.vector().norm();
985        assert!(velocity < 1e-6, "{velocity}");
986    }
987
988    /// The biases are random walks in (12): propagation moves neither.
989    #[test]
990    fn propagation_leaves_the_biases_alone() {
991        let state = State {
992            accel_bias: Acceleration::body(0.1, -0.2, 0.3),
993            gyro_bias: AngularRate::body(0.01, 0.02, -0.03),
994            ..at_rest()
995        };
996        let after = step(state, holding_still(), DT);
997        assert_eq!(after.accel_bias, state.accel_bias);
998        assert_eq!(after.gyro_bias, state.gyro_bias);
999    }
1000
1001    /// Rotating the specific force is what makes (11) a navigation-frame equation, and the
1002    /// same body-frame numbers mean something else once the attitude moves. Rolled 90°
1003    /// right, what the accelerometer reads along body `-z` points east, so the vehicle
1004    /// accelerates east at γ — and falls at γ, because nothing is opposing gravity any
1005    /// more. Reading the unrotated sample instead leaves it hovering.
1006    #[test]
1007    fn specific_force_is_rotated_out_of_the_body_frame() {
1008        let state = State {
1009            attitude: Attitude::from_quaternion(UnitQuaternion::from_euler_angles(
1010                core::f32::consts::FRAC_PI_2,
1011                0.0,
1012                0.0,
1013            )),
1014            ..at_rest()
1015        };
1016
1017        let velocity = step(state, holding_still(), DT).velocity.vector();
1018        let free_fall = GRAVITY * DT.as_secs();
1019        assert!(velocity.x.abs() < 1e-4, "{velocity:?}");
1020        assert!((velocity.y - free_fall).abs() < 1e-4, "{velocity:?}");
1021        assert!((velocity.z - free_fall).abs() < 1e-4, "{velocity:?}");
1022    }
1023
1024    // --- (16)–(22): the covariance ---------------------------------------------------------
1025
1026    /// Tilted, turning, translating, and biased: nothing in the state or the sample below is
1027    /// zero, so no block of (20) can be checked against an accidental zero.
1028    fn tilted_and_moving() -> State {
1029        State {
1030            attitude: Attitude::from_quaternion(UnitQuaternion::from_euler_angles(0.2, -0.3, 0.7)),
1031            position: Position::ned(0.5, -0.25, 0.1),
1032            velocity: Velocity::ned(3.0, -1.0, 0.5),
1033            accel_bias: Acceleration::body(0.05, -0.08, 0.03),
1034            gyro_bias: AngularRate::body(0.004, -0.007, 0.002),
1035            ..State::default()
1036        }
1037    }
1038
1039    /// Maneuvering hard enough that `[a_b]ₓ` and `[ω]ₓ` are both far from zero.
1040    fn maneuvering() -> ImuSample {
1041        ImuSample::reading(
1042            AngularRate::body(0.15, -0.23, 0.31),
1043            Acceleration::body(0.8, -1.3, -9.2),
1044        )
1045    }
1046
1047    /// `x̂ ⊞ δx`: the error state applied to a nominal state, with the attitude composed on
1048    /// the **right**, which is what the local `δθ` of equation (2) means. Composing on the
1049    /// left is a global perturbation, and it is the mistake the attitude columns of the
1050    /// Jacobian test below are there to catch.
1051    fn perturb(state: State, dx: &[f32; STATES]) -> State {
1052        let part = |i: usize| Vector3::new(dx[i], dx[i + 1], dx[i + 2]);
1053        State {
1054            position: Position::from_vector(state.position.vector() + part(0)),
1055            velocity: Velocity::from_vector(state.velocity.vector() + part(3)),
1056            attitude: Attitude::from_quaternion(state.attitude.quaternion() * exp_quat(part(6))),
1057            accel_bias: Acceleration::from_vector(state.accel_bias.vector() + part(9)),
1058            gyro_bias: AngularRate::from_vector(state.gyro_bias.vector() + part(12)),
1059            ..state
1060        }
1061    }
1062
1063    /// `x ⊟ x̂`, the inverse of [`perturb`]: the error state between two nominal states.
1064    fn error_between(reference: &State, perturbed: &State) -> [f32; STATES] {
1065        let attitude = reference.attitude.quaternion().inverse() * perturbed.attitude.quaternion();
1066        let parts = [
1067            perturbed.position.vector() - reference.position.vector(),
1068            perturbed.velocity.vector() - reference.velocity.vector(),
1069            attitude.scaled_axis(),
1070            perturbed.accel_bias.vector() - reference.accel_bias.vector(),
1071            perturbed.gyro_bias.vector() - reference.gyro_bias.vector(),
1072        ];
1073
1074        let mut dx = [0.0; STATES];
1075        for (block, part) in parts.iter().enumerate() {
1076            for axis in 0..3 {
1077                dx[3 * block + axis] = part[axis];
1078            }
1079        }
1080        dx
1081    }
1082
1083    /// (20) against a numerical Jacobian of the propagation it linearizes: perturb the
1084    /// nominal state in each of the 15 directions, propagate the perturbed state and the
1085    /// reference with the same raw sample, and compare the error that comes out against
1086    /// `F δx`. It checks every block independently, needs no truth, and is the strongest
1087    /// check available before a measurement lands.
1088    ///
1089    /// The tolerance is what the discretization itself leaves behind rather than a fudge.
1090    /// (20) is first order in `Δt`, and the propagation is not: a tilt or accelerometer-bias
1091    /// error reaches position through the `½ a_n Δt²` of (13), which `F` omits, and at
1092    /// `|a_b| ≈ 9.3` that term is `½ |a_b| Δt² ≈ 4.6e-4` per unit of `δθ`. The second-order
1093    /// part of the quaternion composition contributes `O(δ)`, and f32 cancellation in the
1094    /// differences another `~2e-4` at this `δ`. 2e-3 clears all three and is still five times
1095    /// smaller than the smallest entry `F` carries here, `Δt = 0.01`, so a dropped or
1096    /// sign-flipped coupling cannot pass.
1097    /// Two samples accumulated are one over both intervals, timed at the second's end: a
1098    /// rate read over the sum is the rates' interval-weighted mean.
1099    #[test]
1100    fn two_samples_accumulate_into_one_over_both_intervals() {
1101        let first = maneuvering().timed(Timestamp::from_micros(2_500), Seconds::from_secs(0.0025));
1102        let second = ImuSample::reading(
1103            AngularRate::body(0.0, 0.0, 1.0),
1104            Acceleration::body(0.0, 0.0, -9.8),
1105        )
1106        .timed(Timestamp::from_micros(10_000), Seconds::from_secs(0.0075));
1107        let both = first.accumulate(second);
1108        assert_eq!(both.time, second.time);
1109        assert!((both.angle_interval.as_secs() - 0.01).abs() < 1e-7);
1110        assert!((both.velocity_interval.as_secs() - 0.01).abs() < 1e-7);
1111        let expected = (0.31 * 0.0025 + 1.0 * 0.0075) / 0.01;
1112        assert!((both.angular_rate().vector().z - expected).abs() < 1e-5);
1113        let expected = (-9.2 * 0.0025 - 9.8 * 0.0075) / 0.01;
1114        assert!((both.specific_force().vector().z - expected).abs() < 1e-4);
1115    }
1116
1117    /// `A` is written out beside `F` rather than derived from it, so the two are held together
1118    /// here: `F = I + A Δt` to first order, at the rates the same corrected sample carries. At
1119    /// 1 ms the second-order remainder is `|ω|² Δt² / 2`, under 1e-7 at this maneuver, so a
1120    /// block of `A` placed or signed differently from `F`'s, each at least `Δt`, cannot pass.
1121    #[test]
1122    fn the_error_dynamics_are_the_transition_matrix_per_unit_time() {
1123        let dt = Seconds::from_secs(0.001);
1124        let state = tilted_and_moving();
1125        let imu = corrected_imu(maneuvering().timed(Timestamp::ZERO, dt), &state);
1126        let omega = imu.omega().vector();
1127        let a_b = imu.delta_velocity.vector() / dt.as_secs();
1128
1129        let f = transition_matrix(&state, imu);
1130        let first_order =
1131            Transition::identity() + error_dynamics(&state, omega, a_b) * dt.as_secs();
1132        for row in 0..STATES {
1133            for column in 0..STATES {
1134                let (exact, linear) = (f[(row, column)], first_order[(row, column)]);
1135                assert!(
1136                    (exact - linear).abs() < 1.0e-5,
1137                    "[{row}][{column}]: F {exact}, I + A dt {linear}"
1138                );
1139            }
1140        }
1141    }
1142
1143    #[test]
1144    fn the_transition_matrix_matches_a_numerical_jacobian_of_the_nominal_step() {
1145        const DELTA: f32 = 1.0e-3;
1146        const TOLERANCE: f32 = 2.0e-3;
1147        let dt = Seconds::from_secs(0.01);
1148        let (state, imu) = (tilted_and_moving(), maneuvering());
1149
1150        let f = transition_matrix(
1151            &state,
1152            corrected_imu(imu.timed(Timestamp::ZERO, dt), &state),
1153        );
1154        let reference = propagate_nominal(
1155            state,
1156            corrected_imu(imu.timed(Timestamp::ZERO, dt), &state),
1157            GRAVITY,
1158        );
1159
1160        for column in 0..STATES {
1161            let mut dx = [0.0; STATES];
1162            dx[column] = DELTA;
1163            let perturbed = perturb(state, &dx);
1164
1165            // The perturbed biases change the corrected sample, which is how the bias
1166            // columns of (20) are exercised at all.
1167            let propagated = propagate_nominal(
1168                perturbed,
1169                corrected_imu(imu.timed(Timestamp::ZERO, dt), &perturbed),
1170                GRAVITY,
1171            );
1172            let numerical = error_between(&reference, &propagated);
1173
1174            for row in 0..STATES {
1175                let analytic = f[(row, column)];
1176                let measured = numerical[row] / DELTA;
1177                assert!(
1178                    (measured - analytic).abs() < TOLERANCE,
1179                    "F[{row}][{column}]: {analytic} analytic vs {measured} numerical",
1180                );
1181            }
1182        }
1183    }
1184
1185    /// (21) against the closed form its `Δt` is chosen for: with only accelerometer white
1186    /// noise, an unaided velocity variance grows as `σ_a² T` and position as `σ_a² T³ / 3`,
1187    /// the integrals of a density over the interval — independent of the rate the samples
1188    /// arrive at.
1189    ///
1190    /// This is the test that fails on the `Δt²` form of (21), and it fails by the whole
1191    /// factor `1/Δt`: at 200 Hz the velocity variance would come out 200 times too small.
1192    /// Run it at two rates and the same numbers come back, which is the property the
1193    /// rate-dependent form gives up and the corpus, at 50 Hz to 250 Hz on one `Config`,
1194    /// needs.
1195    #[test]
1196    fn unaided_growth_from_a_density_matches_the_analytic_integrals() {
1197        const SIGMA: f32 = 0.35;
1198        let noise = ImuNoise {
1199            accel_white: SIGMA,
1200            gyro_white: 0.0,
1201            accel_bias_walk: 0.0,
1202            gyro_bias_walk: 0.0,
1203        };
1204        let seconds = 10.0;
1205
1206        for rate in [50.0, 200.0] {
1207            let dt = Seconds::from_secs(1.0 / rate);
1208            let steps = (seconds * rate) as u32;
1209            let mut covariance = Covariance::zero();
1210            let mut state = at_rest();
1211
1212            for _ in 0..steps {
1213                let step = propagate(
1214                    state,
1215                    covariance,
1216                    Offset::default(),
1217                    holding_still().timed(Timestamp::ZERO, dt),
1218                    &under(noise, 0.0),
1219                );
1220                (state, covariance) = (step.state, step.covariance);
1221            }
1222
1223            let velocity = covariance.variance(ErrorState::VelocityNorth);
1224            let position = covariance.variance(ErrorState::PositionNorth);
1225            let expected_velocity = SIGMA * SIGMA * seconds;
1226            let expected_position = SIGMA * SIGMA * seconds * seconds * seconds / 3.0;
1227
1228            assert!(
1229                (velocity - expected_velocity).abs() / expected_velocity < 1.0e-3,
1230                "{rate} Hz: {velocity} vs {expected_velocity}",
1231            );
1232            // The discrete sum approaches `T³/3` from below as `O(1/steps)`, so this one is
1233            // a percent rather than a tenth of one.
1234            assert!(
1235                (position - expected_position).abs() / expected_position < 1.0e-2,
1236                "{rate} Hz: {position} vs {expected_position}",
1237            );
1238        }
1239    }
1240
1241    /// (42) after (22), and what a covariance owes over a long unaided run: `P` stays exactly
1242    /// symmetric, every variance stays positive, and the total uncertainty never falls, since
1243    /// nothing in propagation removes any.
1244    ///
1245    /// Total, not per axis, and the difference is the attitude block: `R{ω Δt}ᵀ` **rotates**
1246    /// it, so an anisotropic attitude prior — 0.02 rad of tilt against 0.35 of yaw — moves
1247    /// variance between body axes as the vehicle turns, and yaw's own variance falls while the
1248    /// block's trace does not. A per-axis monotonicity assertion here fails on correct code;
1249    /// [`the_attitude_block_rotates_with_the_body`] is what pins the rotation instead.
1250    #[test]
1251    fn an_unaided_covariance_stays_symmetric_and_never_loses_uncertainty() {
1252        let noise = ImuNoise::default();
1253        let dt = Seconds::from_secs(0.005);
1254        let mut state = tilted_and_moving();
1255        let mut covariance = init::initial_covariance(
1256            &Initialization::default(),
1257            &state.attitude,
1258            Radians::from_radians(0.02),
1259            Radians::from_radians(0.35),
1260            Some(0.0),
1261            Vector3::repeat(0.01),
1262            GRAVITY,
1263        );
1264
1265        for _ in 0..12_000 {
1266            let before = covariance.as_matrix().trace();
1267            let step = propagate(
1268                state,
1269                covariance,
1270                Offset::default(),
1271                maneuvering().timed(Timestamp::ZERO, dt),
1272                &under(noise, 0.0),
1273            );
1274            (state, covariance) = (step.state, step.covariance);
1275
1276            let p = covariance.as_matrix();
1277            assert!(
1278                p.trace() >= before,
1279                "trace fell from {before} to {}",
1280                p.trace()
1281            );
1282            for i in 0..STATES {
1283                assert!(p[(i, i)] > 0.0, "variance {i} is {}", p[(i, i)]);
1284                for j in (i + 1)..STATES {
1285                    assert_eq!(p[(i, j)], p[(j, i)], "asymmetric at ({i}, {j})");
1286                }
1287            }
1288        }
1289    }
1290
1291    /// The attitude block of (20) is a rotation, and that is a claim with a consequence: the
1292    /// covariance of a **body-frame** error rotates with the body. Ninety degrees about body x
1293    /// swaps what the y and z axes carry, and with no process noise the swap is exact and the
1294    /// block's trace is conserved.
1295    ///
1296    /// It is the same convention the Jacobian test checks from the other side, and the reason
1297    /// a yaw variance can fall while the filter learns nothing: after a quarter roll, the
1298    /// well-known tilt axis is where yaw's ignorance used to be.
1299    #[test]
1300    fn the_attitude_block_rotates_with_the_body() {
1301        let quiet = ImuNoise {
1302            gyro_white: 0.0,
1303            accel_white: 0.0,
1304            gyro_bias_walk: 0.0,
1305            accel_bias_walk: 0.0,
1306        };
1307        let (tilt, yaw) = (0.02, 0.35);
1308        // Every other prior zero, so the block below is `R P_θθ Rᵀ` and nothing else: the
1309        // gyroscope-bias prior would otherwise reach it through (20)'s `−I Δt`.
1310        #[rustfmt::skip]
1311        let sigmas = [
1312            0.0,  0.0,  0.0,
1313            0.0,  0.0,  0.0,
1314            tilt, tilt, yaw,
1315            0.0,  0.0,  0.0,
1316            0.0,  0.0,  0.0,
1317        ];
1318
1319        // A quarter turn about body x in one step: `Exp(ω Δt)` with `ω Δt = π/2 x̂`.
1320        let dt = Seconds::from_secs(1.0);
1321        let imu = ImuSample::reading(
1322            AngularRate::body(core::f32::consts::FRAC_PI_2, 0.0, 0.0),
1323            Acceleration::body(0.0, 0.0, -GRAVITY),
1324        );
1325
1326        let step = propagate(
1327            at_rest(),
1328            Covariance::from_sigmas(sigmas),
1329            Offset::default(),
1330            imu.timed(Timestamp::ZERO, dt),
1331            &under(quiet, 0.0),
1332        );
1333        let after = step.covariance;
1334
1335        assert!((after.variance(ErrorState::AttitudeX) - tilt * tilt).abs() < 1.0e-9);
1336        assert!((after.variance(ErrorState::AttitudeY) - yaw * yaw).abs() < 1.0e-6);
1337        assert!((after.variance(ErrorState::AttitudeZ) - tilt * tilt).abs() < 1.0e-6);
1338    }
1339
1340    /// The ordering hazard of (20), one level up from (13)/(14)'s: `F` linearizes about the
1341    /// attitude at the **start** of the step, because `R(q̂)` enters the velocity row and (15)
1342    /// has moved `q̂` by the time the nominal step returns.
1343    ///
1344    /// Stated as the two candidate orderings rather than as a tolerance, the way
1345    /// [`position_uses_the_pre_update_velocity`] states its excess: over a step carrying a
1346    /// radian of yaw, an accelerometer-bias prior on body x reaches north velocity through
1347    /// `−R Δt`, and the two linearization points put it in different places. The margin below
1348    /// is what that costs — the wrong ordering is not a rounding difference, and it grows with
1349    /// the rotation rate, which is exactly when a covariance is being relied on.
1350    #[test]
1351    fn the_transition_linearizes_about_the_start_of_the_step() {
1352        let quiet = ImuNoise {
1353            gyro_white: 0.0,
1354            accel_white: 0.0,
1355            gyro_bias_walk: 0.0,
1356            accel_bias_walk: 0.0,
1357        };
1358        // A radian of yaw in one step, and an accelerometer-bias prior on body x alone: the
1359        // only thing that differs between the two orderings is which way that axis points.
1360        let dt = Seconds::from_secs(0.1);
1361        let imu = ImuSample::reading(
1362            AngularRate::body(0.0, 0.0, 10.0),
1363            Acceleration::body(0.0, 0.0, -GRAVITY),
1364        );
1365        #[rustfmt::skip]
1366        let sigmas = [
1367            0.0, 0.0, 0.0,
1368            0.0, 0.0, 0.0,
1369            0.0, 0.0, 0.0,
1370            0.5, 0.0, 0.0,
1371            0.0, 0.0, 0.0,
1372        ];
1373        let prior = Covariance::from_sigmas(sigmas);
1374        let state = at_rest();
1375        let corrected = corrected_imu(imu.timed(Timestamp::ZERO, dt), &state);
1376        let q = process_noise(&quiet, corrected);
1377
1378        let before = propagate_covariance(prior, &transition_matrix(&state, corrected), q);
1379        let after = propagate_covariance(
1380            prior,
1381            &transition_matrix(&propagate_nominal(state, corrected, GRAVITY), corrected),
1382            q,
1383        );
1384
1385        let step = propagate(
1386            state,
1387            prior,
1388            Offset::default(),
1389            imu.timed(Timestamp::ZERO, dt),
1390            &under(quiet, 0.0),
1391        );
1392        assert_eq!(step.covariance, before);
1393
1394        // What the other ordering would have claimed about north velocity: `cos²(0)` of the
1395        // prior against `cos²(1 rad)`, which is 0.0025 m² s⁻² against 0.00073.
1396        let north = |p: &Covariance| p.variance(ErrorState::VelocityNorth);
1397        assert!(
1398            (north(&before) - 0.002_5).abs() < 1.0e-6,
1399            "{}",
1400            north(&before)
1401        );
1402        assert!(
1403            (north(&after) - 0.000_73).abs() < 1.0e-5,
1404            "{}",
1405            north(&after)
1406        );
1407    }
1408
1409    /// The covariance half of what (11)–(14) already owed: `F P Fᵀ` leaves f32's range from
1410    /// finite inputs, and the step reports it rather than committing an infinity that reaches
1411    /// every later `S` and never leaves.
1412    #[test]
1413    fn a_covariance_that_overflows_is_not_finite() {
1414        let enormous = Covariance::from_matrix(CovarianceMatrix::from_diagonal_element(f32::MAX));
1415        let step = propagate(
1416            tilted_and_moving(),
1417            enormous,
1418            Offset::default(),
1419            maneuvering().timed(Timestamp::ZERO, Seconds::from_secs(0.005)),
1420            &under(ImuNoise::default(), 0.0),
1421        );
1422
1423        assert!(step.state.is_finite(), "the state itself is fine");
1424        assert!(!step.covariance.is_finite());
1425        assert!(!step.is_finite());
1426    }
1427
1428    /// A finite sample can still overflow f32 on the way through (11)–(14), and it takes
1429    /// accumulation rather than one absurd step: even `f32::MAX` of specific force is only
1430    /// `1.7e36` of velocity over 5 ms, so the range survives a single step and gives out
1431    /// after a couple of hundred. Propagation has no channel to refuse through, so it
1432    /// produces the infinity and the caller declines to commit it; `Eskf::predict` has the
1433    /// test that it does.
1434    #[test]
1435    fn a_finite_but_enormous_sample_produces_a_state_that_is_not_finite() {
1436        let imu = holding_still().with_accel(Acceleration::body(f32::MAX, 0.0, -GRAVITY));
1437        assert!(imu.is_finite());
1438
1439        let state = run(at_rest(), imu, 1_000);
1440        assert!(!state.is_finite(), "{state:?}");
1441    }
1442
1443    #[test]
1444    fn the_offset_walks_and_its_correlations_move_with_the_state() {
1445        // `P_xb` correlated with vertical velocity only: one step of (20) carries it into
1446        // down position by `Δt`, and `P_bb` grows by `q_b² Δt` whatever the state did.
1447        let mut cross = nalgebra::SVector::<f32, STATES>::zeros();
1448        cross[ErrorState::VelocityDown.index()] = 1.0;
1449        let offset = Offset {
1450            cross,
1451            variance: 0.25,
1452        };
1453        let dt = Seconds::from_secs(0.01);
1454        let step = propagate(
1455            at_rest(),
1456            Covariance::from_sigmas([0.1; STATES]),
1457            offset,
1458            holding_still().timed(Timestamp::ZERO, dt),
1459            &under(ImuNoise::default(), 2.0),
1460        );
1461        assert!((step.offset.variance - (0.25 + 4.0 * 0.01)).abs() < 1e-6);
1462        assert!((step.offset.cross[ErrorState::PositionDown.index()] - 0.01).abs() < 1e-6);
1463        assert_eq!(step.offset.cross[ErrorState::VelocityDown.index()], 1.0);
1464    }
1465}
1466
1467#[cfg(test)]
1468mod unaccelerated {
1469    use super::*;
1470    use crate::config::{GRAVITY, Initialization};
1471    use crate::init;
1472    use crate::units::Radians;
1473    use nalgebra::UnitQuaternion;
1474
1475    type Matrix64 = SMatrix<f64, STATES, STATES>;
1476
1477    /// Tilted and turned, so `R` and the gravity leak `G` are full matrices and a block written
1478    /// with the wrong one, or transposed, reads differently.
1479    fn tilted() -> State {
1480        State {
1481            attitude: Attitude::from_quaternion(UnitQuaternion::from_euler_angles(0.3, -0.2, 1.1)),
1482            ..State::default()
1483        }
1484    }
1485
1486    fn densities() -> Densities {
1487        // Distinct, and large enough that each term clears f32's rounding of the others.
1488        Densities {
1489            velocity: 0.3,
1490            attitude: 0.02,
1491            accel_bias: 0.005,
1492            gyro_bias: 0.0007,
1493        }
1494    }
1495
1496    fn widen(m: &Transition) -> Matrix64 {
1497        m.map(f64::from)
1498    }
1499
1500    /// `A` of (16)–(19) at the unaccelerated input, from `error_dynamics`, the function (23′)
1501    /// reads: a second statement of the dynamics the closed form is checked against.
1502    fn a(state: &State) -> Matrix64 {
1503        let rotation = state.attitude.quaternion().to_rotation_matrix();
1504        let a_b = rotation.inverse() * -gravity_vector(GRAVITY);
1505        widen(&error_dynamics(state, Vector3::zeros(), a_b))
1506    }
1507
1508    fn exp(a: &Matrix64, t: f64) -> Matrix64 {
1509        let a2 = a * a;
1510        Matrix64::identity() + a * t + a2 * (t * t / 2.0) + a2 * a * (t * t * t / 6.0)
1511    }
1512
1513    #[test]
1514    fn the_dynamics_are_nilpotent_and_the_transition_is_their_exponential() {
1515        let state = tilted();
1516        let a = a(&state);
1517        let a4 = a * a * a * a;
1518        assert!(a4.amax() < 1e-9, "A⁴ is not zero: {}", a4.amax());
1519        assert!(
1520            (a * a * a).amax() > 1e-3,
1521            "A³ is zero, so the cubic term is untested"
1522        );
1523        for t in [0.01f32, 1.3, 6.4] {
1524            let phi = unaccelerated_transition(&state, t, GRAVITY);
1525            let difference = (widen(&phi) - exp(&a, f64::from(t))).amax();
1526            assert!(
1527                difference < 1e-4,
1528                "{t} s: Φ differs from exp(A T) by {difference}"
1529            );
1530        }
1531    }
1532
1533    /// `Q_d` against `∫ Φ(s) Q_c Φ(s)ᵀ ds` by Simpson's rule in `f64`, from `A` rather than the
1534    /// blocks: each of the twenty-one blocks, its sign and its power of `T`.
1535    #[test]
1536    fn the_noise_is_the_integral_of_the_densities_along_the_transition() {
1537        let state = tilted();
1538        let a = a(&state);
1539        let q = densities();
1540        let mut qc = Matrix64::zeros();
1541        for (first, density) in [
1542            (ErrorState::VelocityNorth, q.velocity),
1543            (ErrorState::AttitudeX, q.attitude),
1544            (ErrorState::AccelBiasX, q.accel_bias),
1545            (ErrorState::GyroBiasX, q.gyro_bias),
1546        ] {
1547            for k in 0..3 {
1548                qc[(first.index() + k, first.index() + k)] = f64::from(density);
1549            }
1550        }
1551        for t in [0.5f32, 4.0] {
1552            let n = 400;
1553            let h = f64::from(t) / f64::from(n);
1554            let integrand = |s: f64| {
1555                let phi = exp(&a, s);
1556                phi * qc * phi.transpose()
1557            };
1558            let mut integral = integrand(0.0) + integrand(f64::from(t));
1559            for k in 1..n {
1560                let weight = if k % 2 == 1 { 4.0 } else { 2.0 };
1561                integral += integrand(f64::from(k) * h) * weight;
1562            }
1563            integral *= h / 3.0;
1564
1565            let closed = unaccelerated_growth(&state, &Covariance::zero(), t, GRAVITY, &q, None);
1566            let closed = closed.as_matrix().map(f64::from);
1567            for row in 0..STATES {
1568                for column in 0..STATES {
1569                    let (got, want) = (closed[(row, column)], integral[(row, column)]);
1570                    let scale = (integral[(row, row)] * integral[(column, column)]).sqrt();
1571                    assert!(
1572                        (got - want).abs() <= 1e-4 * scale + 1e-9,
1573                        "{t} s, ({row}, {column}): {got} against {want}"
1574                    );
1575                }
1576            }
1577        }
1578    }
1579
1580    /// (22) run in short steps approaches the one step from below: a first-order step
1581    /// understates, and the shorter the steps the less. The one step is what they converge to,
1582    /// not a different model of the gap.
1583    #[test]
1584    fn short_steps_of_the_first_order_form_approach_it_from_below() {
1585        let state = tilted();
1586        let from = init::initial_covariance(
1587            &Initialization::default(),
1588            &state.attitude,
1589            Radians::from_radians(0.02),
1590            Radians::from_radians(0.35),
1591            Some(0.0),
1592            Vector3::repeat(0.01),
1593            GRAVITY,
1594        );
1595        let noise = ImuNoise::default();
1596        let seconds = 5.0;
1597        let exact = unaccelerated_growth(
1598            &state,
1599            &from,
1600            seconds,
1601            GRAVITY,
1602            &Densities::of(&noise, None),
1603            None,
1604        );
1605        let stepped = |rate: f32| {
1606            let dt = Seconds::from_secs(1.0 / rate);
1607            let unaccelerated = unaccelerated_sample(&state, dt, GRAVITY);
1608            let f = transition_matrix(&state, unaccelerated);
1609            let q = process_noise(&noise, unaccelerated);
1610            let mut p = from;
1611            for _ in 0..(seconds * rate) as usize {
1612                p = propagate_covariance(p, &f, q);
1613            }
1614            p.variance(ErrorState::PositionNorth) / exact.variance(ErrorState::PositionNorth)
1615        };
1616        let (coarse, fine) = (stepped(10.0), stepped(400.0));
1617        assert!(
1618            coarse < fine && fine <= 1.0001,
1619            "10 Hz {coarse}, 400 Hz {fine}"
1620        );
1621        assert!(fine > 0.995, "400 Hz {fine} of the exact position variance");
1622    }
1623
1624    /// The offset's correlation with the error state moves through the same `Φ` as `P` does: one
1625    /// with down velocity alone reaches down position by `T`, `Φ`'s `I T` block, and keeps its
1626    /// velocity share, since an unaccelerated vehicle's velocity row is the identity there.
1627    #[test]
1628    fn a_coast_carries_the_offsets_correlation_through_the_transition() {
1629        let mut cross = SVector::<f32, STATES>::zeros();
1630        cross[ErrorState::VelocityDown.index()] = 1.0;
1631        let offset = Offset {
1632            cross,
1633            variance: 0.25,
1634        };
1635        let coasted = coast(
1636            tilted(),
1637            Covariance::from_sigmas([0.5; STATES]),
1638            offset,
1639            Seconds::from_secs(2.5),
1640            &Config::default(),
1641            &Coast::default(),
1642        );
1643        let after = coasted.offset.cross;
1644        assert!(
1645            (after[ErrorState::PositionDown.index()] - 2.5).abs() < 1e-6,
1646            "{after}"
1647        );
1648        assert_eq!(after[ErrorState::VelocityDown.index()], 1.0);
1649    }
1650
1651    /// A projection grows a covariance and never shrinks one, whatever the horizon. The zero and
1652    /// negative cases are the ones a caller reaches by configuring a horizon nobody checked.
1653    #[test]
1654    fn a_horizon_that_is_not_positive_leaves_the_covariance_where_it_was() {
1655        let state = State::default();
1656        let from = Covariance::from_sigmas([0.5; STATES]);
1657        for seconds in [0.0f32, -1.0, f32::NAN] {
1658            let projected = project(
1659                &state,
1660                from,
1661                Seconds::from_secs(seconds),
1662                &Config::default(),
1663            );
1664            assert_eq!(projected.to_rows(), from.to_rows(), "{seconds} s");
1665        }
1666    }
1667}