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}