MLTE03 · Week 8 · Lab (second half of session)

State Estimation & Sensor Fusion — fly on the estimate

Your cascade has only ever seen the true state. Now feed it a noisy IMU + GPS, fuse them, and fly on \( \hat{\mathbf{x}} \).
~75 min 🧩 Use quadsim/estimators.py 📦 Deliverable: script + estimation-error plot Graded checkpoint

← Back to the Week 8 slides Course home

Goal Wrap the reference CascadePID in EstimatedStateController so it acts on a fused estimate from a noisy IMU + low-rate GPS — not the truth. Fly a step command, confirm it still reaches the target with final error < 0.2 m, and produce an estimation-error vs ground-truth plot.

1 · The 5-minute recap

Real flight controllers never see the true state \( \mathbf{x} \). They get a high-rate, noisy IMU (gyro + accelerometer, ~200 Hz) and a low-rate, noisy GPS (~5 Hz), and must fuse them.

Complementary filter (attitude). Integrate the gyro through the Euler kinematics, then nudge roll/pitch toward the accelerometer's gravity direction:

\[ \hat\phi = \alpha\,\phi_{\text{gyro}} + (1-\alpha)\,\phi_{\text{acc}},\qquad \tau=\frac{\Delta t}{1-\alpha} \]

Trust the gyro fast, the accelerometer slow. Use \( \alpha=0.999 \) at 200 Hz (\( \tau=5 \) s). A low \( \alpha \) lets the vehicle's own maneuver acceleration fool the tilt estimate and destabilizes the loop — you will test this.

Position Kalman filter. Predict with world acceleration rebuilt from the IMU and estimated attitude (z-up, gravity \( -z \)), and correct when a GPS fix arrives:

\[ \mathbf{a}_w = R\,\mathbf{a}_{\text{body}} + \begin{bmatrix}0\\0\\-g\end{bmatrix},\qquad \hat{\mathbf{x}} = \hat{\mathbf{x}}^- + K\,(\mathbf{z}_{\text{gps}} - H\hat{\mathbf{x}}^-) \]

EstimatedStateController closes the loop on this estimate. A monolithic 15-state EKF (PX4 EKF2) is the production version; the cascade in quadsim/estimators.py is the readable one.

Predict before you code Drop \( \alpha \) to 0.9 and the complementary filter will chase the quad's own acceleration — tilt estimate goes bad, the loop fights a phantom, and flight degrades. The default \( \alpha=0.999 \) keeps the accelerometer correcting only slow drift. That is the whole intuition of the lab.

2 · Setup (2 min)

From the simulator/ directory, in your virtual environment:

# one-time
python -m venv .venv && source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -r requirements.txt
# every session — so `import quadsim` works
export PYTHONPATH=.                                # Windows: set PYTHONPATH=.

The estimators already live in quadsim/estimators.py (ComplementaryFilter, PositionKF, INSGPS, EstimatedStateController) and the sensor models in quadsim/sensors.py. This week you wire them up and fly on the estimate — read them first.

3 · Fly on the estimate

Work through the steps. You build a short script that closes the loop on \( \hat{\mathbf{x}} \) and measures the estimation error. Recall the 12-state layout: x[0:3] position, x[3:6] velocity, x[6:9] Euler \((\phi,\theta,\psi)\), x[9:12] body rates.

1 Read the estimator cascade orient

Open quadsim/estimators.py. INSGPS.step(t, x_true, u, dt) synthesizes sensor readings from the truth, fuses them, and returns a 12-state estimate. The output never peeks at the truth except a one-time launch-pose init. Note how the world accel is rebuilt:

R = rotation_matrix(euler[0], euler[1], euler[2])
a_world = R @ imu["acc"] + np.array([0.0, 0.0, -self.p.g])   # z-up: gravity is -z
2 Wrap CascadePID in the estimator the core

One line is the whole point of the week: the base controller is unchanged, but it now acts on the estimate. The wrapper reads sensors each step (using the last applied wrench for the IMU) and fuses with INSGPS:

from quadsim.controllers import CascadePID
from quadsim.estimators import EstimatedStateController

ctrl = EstimatedStateController(CascadePID(p), p)   # fly on x̂, not x
3 Run a step command under sensor noise fly

Use a step trajectory and a 10 s flight. The simulator feeds the true state to the sensors only; the controller sees the fused estimate.

from quadsim import trajectories as traj
target = [1.0, -1.0, 1.5]
log = sim.run(hover_state(position=(0, 0, 1.0)), ctrl,
              reference=traj.step(position=target, start=(0, 0, 1.0), t_step=0.5),
              t_final=10.0)
4 Pull the estimate log & plot the error measure

The wrapper records (t, x_hat) in ctrl.est_log. Line it up against the true log and hand both to plot_estimation_error(t, x_true, x_est, ...):

import numpy as np
est  = np.array([xh for _, xh in ctrl.est_log])
true = log.x[:len(est)]
t    = log.t[:len(est)]

from quadsim import plotting as viz
viz.plot_estimation_error(t, true, est, save="estimation_error.png", show=False)
5 Break it, then fix it tune alpha

Pass a low alpha into the estimator and watch the attitude error blow up — proof the accelerometer is being fooled by maneuver acceleration. Then restore the default.

from quadsim.estimators import INSGPS
bad = EstimatedStateController(CascadePID(p), p, estimator=INSGPS(p=p, alpha=0.9))
# re-run and compare the attitude-error curve to alpha=0.999
Stuck? Reveal the full Week-8 script
Try first You learn fusion by wiring it. Peek only after a genuine attempt.
# examples/wk8_estimation.py — fly on the estimate under sensor noise
import numpy as np
from quadsim import Simulator, QuadParams
from quadsim.controllers import CascadePID
from quadsim.estimators import EstimatedStateController
from quadsim.dynamics import hover_state
from quadsim import trajectories as traj

p = QuadParams(); sim = Simulator(p)
ctrl = EstimatedStateController(CascadePID(p), p)      # fly on x̂, not x
target = [1.0, -1.0, 1.5]
log = sim.run(hover_state(position=(0, 0, 1.0)), ctrl,
              reference=traj.step(position=target, start=(0, 0, 1.0), t_step=0.5),
              t_final=10.0)

est  = np.array([xh for _, xh in ctrl.est_log])
true = log.x[:len(est)]
t    = log.t[:len(est)]
print(f"final position error: {np.linalg.norm(log.position[-1] - target):.3f} m")

from quadsim import plotting as viz
viz.plot_estimation_error(t, true, est, save="estimation_error.png", show=False)
print("saved estimation_error.png")

4 · Run & verify

You can run the shipped reference driver, or your own examples/wk8_estimation.py from the step above:

export PYTHONPATH=.
python examples/07_estimation.py            # shipped reference
python examples/07_estimation.py --show     # pop the plot window
Expected — your graded checkpoint ⭐

Self-check

5 · Going further (optional)

6 · Deliverable & submission

CriterionWhat we look forWeight
Closed loop on estimateCascadePID wrapped in EstimatedStateController; flies under noise, final error < 0.2 m50%
Estimation-error plotThe plot_estimation_error figure with small, bounded position & attitude error25%
UnderstandingOne short paragraph: why \( \tau=\Delta t/(1-\alpha) \) and what a low \( \alpha \) does25%

📤 Submit via the MUST LMS

This submission is graded (5%). Full requirements & rubric: Lab 5 assignment brief.

What
wk8_estimation.py + estimation_error.png + a 3–5 line note (one PDF or zip)
Filename
MLTE03_Wk8_<studentID>.zip
Where
MUST LMS → MLTE03 → Week 8 Lab dropbox LMS link — TO FILL
Deadline
date / time — TO FILL (before Week 9)

Submissions are handled entirely in the MUST LMS — nothing is uploaded to this site.

Reading: Beard & McLain UAVbook (estimation) · rlabbe Kalman-and-Bayesian-Filters-in-Python. Next week → the intelligent-control block, now that you can trust a fused state.