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

Trajectory Tracking — fly a figure-eight

Track a moving reference with feedforward, add wind, and report the tracking RMSE.
~75 min 🧩 Run examples/03_figure8.py + a short script 📦 Deliverable: strobe + error plot + RMSE numbers Graded checkpoint

← Back to the Week 9 slides Course home

Goal Make the quad fly a smooth figure-eight instead of holding a point. Using the reference autopilot's velocity/acceleration feedforward, the tracking error should be small (~0.037 m RMSE with no wind). Then turn on wind, watch the RMSE rise (to ~0.48 m at 2 N), and render the 3-D flight strobe and the tracking-error plot. This is also the week you consolidate Weeks 1–9 for the midterm.

1 · The 5-minute recap

A reference trajectory is not just a point — it carries position and its derivatives: \( \mathbf p_r(t),\ \mathbf v_r(t),\ \mathbf a_r(t) \). The figure-eight is the classic multirotor benchmark (world is z-up / ENU, so height is \(+z\)):

\[ \mathbf p_r(t)=\begin{bmatrix} a\sin\omega t \\ b\sin 2\omega t \\ h \end{bmatrix},\qquad \omega=\frac{2\pi}{T},\qquad \mathbf v_r=\dot{\mathbf p}_r,\quad \mathbf a_r=\ddot{\mathbf p}_r. \]

Chasing a moving target with feedback alone always lags. The cure is feedforward: feed the reference acceleration straight into the desired world acceleration, so feedback only corrects what's left:

\[ \mathbf a_{\text{des}} \;=\; \mathbf a_r \;+\; K_p\,(\mathbf p_r-\mathbf p)\;+\;K_d\,(\mathbf v_r-\mathbf v). \]

That is exactly the line already in quadsim/controllers/cascade_pid.py: a_des = acc_ref + self.kp_pos * e_pos + self.kd_pos * e_vel. Thrust then solves the vertical accel, \( T=m\,(g+a_{\text{des},z}) \) — gravity is \(-z\), thrust is \(+\)body-\(z\).

Convention check (CONVENTIONS.md) Keep the course conventions straight: z-up ENU (don't lift z-down NED signs from Beard), intrinsic ZYX Euler, and the X-frame mixer (every motor contributes to roll and pitch — not plus-frame). You don't touch the mixer today, but these are midterm traps.

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=.

This week you mostly run the reference autopilot (CascadePID) on a trajectory rather than editing it — the goal is to see feedforward and wind in action and to read the tracking numbers. The ready-made example is examples/03_figure8.py.

3 · Track the figure-eight

Work through the steps. The reference generators live in quadsim/trajectories.py; each returns a function ref(t) → dict(pos, vel, acc, yaw) you can hand straight to sim.run(...).

1 Build the reference trajectory trajectories.py

The figure-eight carries pos, vel and acc — that's what makes feedforward possible:

from quadsim import trajectories as traj
ref = traj.figure_eight(a=1.0, b=0.5, height=1.5, period=10.0)
# ref(t) -> {"pos": [...], "vel": [...], "acc": [...], "yaw": 0.0}
2 Fly it with the reference autopilot no wind

Start at the trajectory's height and run for two full periods (20 s). CascadePID already uses the acc/vel feedforward, so tracking should be tight:

from quadsim import Simulator, QuadParams
from quadsim.controllers import CascadePID
from quadsim.dynamics import hover_state

sim = Simulator(QuadParams())
log = sim.run(x0=hover_state(position=(0, 0, 1.5)),
              controller=CascadePID(sim.params),
              reference=ref, t_final=20.0)
print(f"no wind  : RMSE = {log.position_rmse():.3f} m")   # ~0.037 m
3 Add wind & re-measure disturbance

Wind is an extra world-frame force (N) passed to sim.run(..., wind=...). A constant gust along world-x lets feedback (not feedforward) earn its keep — the RMSE should rise:

import numpy as np
wind = lambda t: np.array([2.0, 0.0, 0.0])     # 2 N along world +x
log_w = sim.run(x0=hover_state(position=(0, 0, 1.5)),
                controller=CascadePID(sim.params),
                reference=ref, t_final=20.0, wind=wind)
print(f"with wind: RMSE = {log_w.position_rmse():.3f} m")
4 Render the strobe & the tracking-error plot deliverable

Two figures from quadsim.plotting: the long-exposure 3-D strobe (X-frame drawn at intervals, colour = time, reference dashed) and the tracking error (per-axis + norm + RMSE):

from quadsim.plotting import plot_pose_strobe, plot_tracking_error
plot_pose_strobe(log,   save="wk9_strobe.png",      show=False)
plot_tracking_error(log, save="wk9_tracking.png",   show=False)

The error plot prints the RMSE in its title — that's the number you report.

The fast path: one shell command
Use the script examples/03_figure8.py already wires all of the above. Read it once, then run it both ways.
export PYTHONPATH=.
python examples/03_figure8.py --plot                 # no wind  → ~0.037 m
python examples/03_figure8.py --plot --wind 2.0      # with wind → ~0.48 m

It prints trajectory tracking RMSE: ... m for each run and saves a 3-D path PNG. Use the script for the RMSE numbers, and the short snippet above to add the strobe figure for your report.

4 · Run & verify

Save this as examples/wk9_trajectory.py. It flies the figure-eight twice — clean air and a wind gust — prints both RMSEs, and saves the two deliverable figures:

# examples/wk9_trajectory.py — Week 9 checkpoint: track a figure-eight, no-wind vs wind
import numpy as np
from quadsim import Simulator, QuadParams
from quadsim.controllers import CascadePID
from quadsim.dynamics import hover_state
from quadsim import trajectories as traj
from quadsim.plotting import plot_pose_strobe, plot_tracking_error

sim = Simulator(QuadParams())
ref = traj.figure_eight(a=1.0, b=0.5, height=1.5, period=10.0)

# 1) no wind
log = sim.run(x0=hover_state(position=(0, 0, 1.5)),
              controller=CascadePID(sim.params), reference=ref, t_final=20.0)
print(f"no wind  : RMSE = {log.position_rmse():.3f} m")

# 2) constant 2 N world-x wind
wind = lambda t: np.array([2.0, 0.0, 0.0])
log_w = sim.run(x0=hover_state(position=(0, 0, 1.5)),
                controller=CascadePID(sim.params), reference=ref, t_final=20.0, wind=wind)
print(f"with wind: RMSE = {log_w.position_rmse():.3f} m")

# 3) deliverable figures (from the no-wind run)
plot_pose_strobe(log,    save="wk9_strobe.png",   show=False)
plot_tracking_error(log, save="wk9_tracking.png", show=False)
print("saved wk9_strobe.png and wk9_tracking.png")
export PYTHONPATH=.
python examples/wk9_trajectory.py
Expected — your graded checkpoint ⭐

Self-check

5 · Going further (optional)

6 · Deliverable & submission

CriterionWhat we look forWeight
Tracking resultNo-wind figure-eight RMSE ≈ 0.037 m; the strobe shows the path on the reference40%
Wind comparisonWith-wind RMSE reported and clearly larger; tracking-error plot included30%
UnderstandingOne short paragraph: why feedforward beats pure feedback, and which conventions (z-up, X-frame) you kept30%

📤 Submit via the MUST LMS

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

What
wk9_strobe.png + wk9_tracking.png + the two RMSE numbers + a 3–5 line note (one PDF or zip)
Filename
MLTE03_Wk9_<studentID>.zip
Where
MUST LMS → MLTE03 → Week 9 Lab dropbox LMS link — TO FILL
Deadline
date / time — TO FILL (before Week 10)

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

Next week → Week 10: the intelligent-control half begins. The ~0.037 m figure-eight RMSE you just measured is the baseline that MPC and learning-based controllers will try to beat. Reference: Beard & McLain UAVbook (mavsim_public) and simulator/CONVENTIONS.md.