IEKF on the microcontroller¶
The filter of Attitude IEKF was developed in MATLAB. This page lists
what had to change, and what was added, to run it on the STM32WB55 at 960 Hz in
float32, with the real sensors. The code is in App/filters/iekf/ and
App/app/est_pipeline.c.
flowchart LR
FIFO["LSM6DSV16X FIFO<br/>gyro + acc 960 Hz,<br/>mag via sensor hub"] --> G["gyro samples<br/>Δθ = Σ ω Δt<br/>(Δt measured)"]
G --> P["propagation<br/>R ← R Exp(Δθ − b h)<br/>960 Hz"]
P --> RATE["ctrl_rate_step()"]
FIFO --> AW["accwin: acc samples<br/>rotated to a common frame"]
P -. "rotation of each step" .-> AW
AW -- "every 4 steps" --> AU["acc update<br/>240 Hz, NIS gating,<br/>stillness detector"]
AU --> ATT["ctrl_attitude_step()<br/>telemetry, altitude KF"]
FIFO --> MG["mag sample<br/>age compensated with ω"]
MG --> MU["mag update<br/>~100 Hz, dip check"]
Each item lists why it was needed. Unless stated otherwise all numbers come from the PC simulation of the full firmware pipeline (same C code, synthetic LSM6DSV16X FIFO encoded byte by byte).
Multi-rate structure with angle increments¶
| Function | Rate |
|---|---|
| Gyro + acc sampling (FIFO) | 960 Hz (1920 Hz with I²C Fast-mode Plus) |
FIFO-threshold interrupt → propagation, ctrl_rate_step() |
960 Hz |
Accelerometer update, ctrl_attitude_step(), telemetry |
240 Hz |
| Magnetometer update (LIS2MDL 100 Hz, sensor hub 120 Hz) | on new data |
When a propagation step spans several gyro samples, the increments are summed without loss of information:
\(\Delta t\) is measured by the MCU (DWT cycle counter between interrupts), which absorbs the tolerance of the sensor's internal oscillator.
Rotation-compensated accelerometer mean (accwin)¶
Averaging the acc over a 4 ms window while rotating at 360 °/s smears the direction and lags by half a window (0.66° error). Each sample is moved to the body frame at the start of the window with the already-propagated increments \(E_j\), averaged, and moved back:
Error after compensation: 0.000°.
Static calibration and levelled world frame¶
At start-up the board must stay still for ~2 s (calib.c, restarts if it moves):
- bias: \(\hat b_0\) = mean gyro;
- noise: \(\sigma_{acc}, \sigma_{mag}\) from the direction scatter
(port of
directionNoiseStd.m), with a floorIEKF_SIGMA_VEC_MIN = 0.01; - world frame: levelled NWU (x = horizontal magnetic north, y = west, z = up)
built by TRIAD, instead of "body at \(t=0\)" used by
runRealData.m:
The tilt-compensated magnetometer update assumes a vertical world \(z\); with "body at \(t=0\)" and a board that is not perfectly level that assumption fails.
Magnetometer through the sensor hub, with latency compensation¶
In IKS4A1 Mode 3 the LIS2MDL is behind the LSM6DSV16X and its samples land in the same FIFO, time-aligned with gyro and acc. The hub re-reads the LIS2MDL faster than its ODR (duplicates discarded), so a new sample has a random age \(\tau \sim U(0, 1/f_{sh})\). The mean age is compensated by rotating the measurement forward with the gyro, the random part enters the noise:
| Magnetometer | Attitude RMSE (fast rotations) |
|---|---|
| 50/60 Hz, no compensation | 1.150° |
| 50/60 Hz, compensated | 0.165° |
| 100/120 Hz, compensated | 0.095° |
Single-precision numerics¶
so3_expswitches to a 4th-order Taylor series below 0.05 rad: in float32, \((1-\cos\theta)/\theta^2\) loses all digits for \(\theta \sim 10^{-3}\), the typical \(\omega h\).- \(\hat R\) is re-orthonormalised (Gram–Schmidt) after every product.
- Covariance conditioning after every propagation and update: exact symmetrisation and a floor on the diagonal (\(10^{-12}\) rad², \(10^{-16}\) (rad/s)²). The Joseph form guarantees \(P \succeq 0\) only in exact arithmetic.
- The 6×6 covariance algebra uses CMSIS-DSP (
arm_mat_*); static workspaces keep large matrices off the RTOS task stack.
Error reset after the retraction¶
After \(\hat R \leftarrow \hat R\,\mathrm{Exp}(\delta\theta)\) the error is defined w.r.t. the new estimate, \(\delta' \approx \delta - \delta\theta - \tfrac12[\delta\theta]_\times\delta\), hence
Negligible at steady state, relevant for large corrections (IEKF_RESET_JACOBIAN).
Robustness to external accelerations¶
The magnitude test of Vector measurements misses accelerations perpendicular to gravity: 0.4 g sideways changes \(\|a\|\) by a few percent but rotates the measured direction by >20°. The tilt error then leaks into yaw through the tilt-compensated compass (× tan of the magnetic dip, ~1.6 in Italy).
NIS-driven inflation with memory (accelerometer):
with \(S_0\) computed with the nominal \(R_m\), \(\gamma\) = IEKF_ACC_NIS_GATE (4),
\(\lambda = e^{-h_{acc}/\tau}\) with \(\tau\) = IEKF_INFL_TAU_S (0.5 s),
\(g_{max}\) = IEKF_INFL_MAX (100).
Magnetic dip check (magnetometer): the angle between the measured field and the measured gravity is compared with its calibration value; it does not depend on the estimate, so it cannot mistake a true yaw error for a disturbance:
Stillness detector: \(\big|\|a\|/a_{nom} - 1\big| < 0.02\) and \(\|\omega_{raw}\| < 3\) °/s for 0.3 s ⇒ an external acceleration is impossible ⇒ the next acc update is not inflated.
Lockout protection. If the estimate is truly wrong, the innovation is large because of the estimate, NIS inflates \(R\) forever and the filter stops correcting (recovery > 16 s in simulation). With a correct gyro, a true attitude error is fixed in the world frame, while a hand shaking the board produces a zero-mean innovation. The world-frame innovation is tracked:
and if the acc has been inflated for IEKF_LOCKOUT_S (0.5 s) and
\(\|m\|^2 > \rho\,p\) and \(\|m\| > m_{min}\) (persistent), then
\(P_{aa} \leftarrow P_{aa} + \sigma_{bump}^2 I\) (10°) and the acc is trusted for
IEKF_GRACE_S (0.25 s).
| Simulation (0.4 g lateral for 10 s) | yaw max | tilt max | recovery from 37° error in motion |
|---|---|---|---|
| magnitude test only (original) | 14.9° | 20.3° | 0.26 s |
| NIS without lockout protection | 2.56° | 1.72° | 16.6 s |
| final configuration | 2.40° | 2.32° | 0.95 s |
Nominal accuracy is unchanged (RMSE 0.095° at 360 °/s peak). Known limit: a sustained, constant external acceleration (steady turn) is as persistent as a true error and is accepted after 0.5 s. Only a thrust model (phase 3) can separate the two.
Tried and left disabled: gain projection so that acc corrects only tilt and mag
only yaw (IEKF_DECOUPLE: tilt max 3.3° vs 2.3°); NIS inflation on the
magnetometer (IEKF_MAG_NIS_GATE: a high mag NIS is often a true yaw error).
Tangential innovation¶
IEKF_TANGENT_INNOV (on by default) keeps only the part of \(y - \hat y\)
orthogonal to \(\hat y\), in the update and in the persistence test of the
lockout protection (Update and retraction
explains why). It has no effect on the state for an isotropic \(R_m\) (\(K\hat y = 0\);
checked: C, Python and MATLAB give the same estimate with it on or off when the
NIS inflation is off). It only removes the radial term
\((1-\cos\varepsilon)^2/\sigma^2\) from the NIS: about 2 at 10°, 36 at 20°,
10 000 at 90° with \(\sigma_{acc}\) = 0.01, so above ~15° every accelerometer update
was inflated by the error alone.
In the firmware the measured gain is small, because the inflation is softer than a \(\chi^2\) rejection (the c4dynamics notebook, which rejects, never converged from 90° without the projection) and because two other mechanisms already cover large errors: the stillness detector (no inflation at rest) and the grace period of a re-initialisation (below). It shortens the 179° cases a little and is never worse; it is kept because it is the correct innovation.
Cold start and re-initialisation¶
calib_finish() seeds \(\hat R_0\) from gravity and the field measured at rest,
with IEKF_P0_ATT = (2°)²: the initial error is small and so is \(P_0\). Any other
initialisation (estimator reset after a failsafe, re-init in the air, hand
launch) must use iekf_reset_attitude(f, R0, p0_att):
- \(P_{aa} = p_0 I\) with no attitude-bias correlation: \(p_0\) must be as large as the possible error, otherwise the filter is confidently wrong;
- the NIS inflation and the persistence memory are cleared and the
accelerometer is trusted for
IEKF_GRACE_S(acc_grace), so the measurements the filter needs are not de-weighted while \(P_{aa}\) shrinks; - the bias estimate and its covariance are kept.
Cold-start test (Tests/host/test_iekf.c, make iekf_test): the production
pipeline (est_pipeline.c with app_est_config(), every app_config.h value)
on simulated sensors (gyro 960 Hz with bias and noise, mag 100 Hz with its
latency, dip 58°). After the static calibration the attitude is re-initialised
with an error of 30°, 90°, 150° or 179° about 6 axes, on a static and on a moving
recording (rates up to 1 rad/s); converged = below 5° for good within 10 s.
| re-initialisation | motion | 30° | 90° | 150° | 179° |
|---|---|---|---|---|---|
iekf_reset_attitude, honest \(P_0\) |
static | 6/6, 0.01 s | 6/6, 0.15 s | 6/6, 0.57 s | 6/6, 1.23 s |
| moving | 6/6, 0.01 s | 6/6, 0.16 s | 6/6, 1.34 s | 6/6, 1.61 s (5 lockouts) | |
| same, full innovation | moving | 6/6, 0.01 s | 6/6, 0.16 s | 6/6, 1.52 s | 6/6, 1.92 s (4 lockouts) |
| honest \(P_0\), no grace period | moving | 6/6, 0.01 s | 6/6, 1.54 s (3 lockouts) | 6/6, 2.08 s (6) | 6/6, 3.66 s (8) |
| production \(P_0\) (2°), with grace | moving | 6/6, 0.03 s | 6/6, 0.21 s | 6/6, 1.34 s (1) | 6/6, 2.96 s (6) |
Times are the worst of the 6 axes; final errors are 2° at most. The grace period is what matters most in motion: without it the inflation acts from the first update and only the lockout protection gets the filter out. The test checks that 30° and 90° converge within 1 s and 2 s with no lockout, that at least 5 of 6 converge from 150°, and that the tangential innovation is never worse than the full one. 179° is reported, not required.
Tuning notes¶
- 6 states, not 3. With a realistic bias: RMSE 0.095° (6 states) vs 0.184°
(3 states).
IEKF_ESTIMATE_BIAS = 0reduces the filter exactly to 3 states. IEKF_SIGMA_GYRO = 0.5 °/s/√Hz. It must cover what the model ignores (scale factor, misalignment, g-sensitivity), not just the sensor noise (~0.003 °/s/√Hz). With 1 % scale error and 1° misalignment: σ = 0.1 → RMSE 1.17°, σ = 0.5 → 0.42°. Lower it only after calibrating the gyro.
MATLAB mirror of the C code¶
IEKF_embeddedVersion/iekfAttitudeEmb.m is the extended MATLAB filter: same
signature and options as iekfAttitude.m plus the extensions above
(accNisGate, inflTau, dipTol, decouple, resetJacobian, inflMax,
lockoutTime, …). Its maths goes through iekfCore*.m, written line by line
after iekf.c. As in the firmware, the inflation memory and the lockout counters
run per accelerometer update; their exact per-update values (inflDecay,
lockoutUpdates, graceUpdates, persistDecay) and the per-sample inputs
produced by the firmware pipeline (accTrusted from the stillness detector,
magExtraVar from the mag-age model) can be passed directly. A 4th output
reports lockouts and accInflMax. With every extension off it matches the
original within 10⁻¹² deg.