The remarkable result: the optimal law is plain linear full-state feedback, \( \mathbf{u}=-K\mathbf{x} \).
No gain scheduling, no lookup table — one constant matrix \(K\).
How K is computed
The Riccati equation gives the gain
The optimal cost-to-go is \( \mathbf{x}^\top P\,\mathbf{x} \). The matrix \(P=P^\top\succeq 0\) solves the
continuous-time algebraic Riccati equation (CARE):
\[ A^\top P + P A - P B R^{-1} B^\top P + Q = 0 \]
quadsim solves CARE with the Hamiltonian eigenvector method (pure NumPy):
form \( H=\begin{bmatrix} A & -BR^{-1}B^\top \\ -Q & -A^\top \end{bmatrix} \), take the \(n\) eigenvectors
with \( \mathrm{Re}(\lambda)<0 \), and \( P = U_{21}U_{11}^{-1} \). No SciPy required.
The design knobs
\(Q\) and \(R\): the whole tuning story
\(Q\) bigger ⇒ penalize state error harder ⇒ faster, more aggressive response, more effort.
Only the ratio \(Q/R\) matters; scale both, \(K\) is unchanged.
Diagonal \(Q,R\) = one weight per state / per channel — interpretable and enough here.
A sensible hover starting point (from examples/04_analysis.py):
Q = diag(10,10,10, 1,1,1, 10,10,10, 1,1,1) R = diag(1, 10,10,10) order: pos, vel, Euler, body-rates · wrench \([\,T,\tau_x,\tau_y,\tau_z\,]\)
Does it stabilize?
Poles march into the left half-plane
Closed loop is \( \dot{\mathbf{x}} = (A-BK)\,\mathbf{x} \). LQR guarantees every eigenvalue of
\(A-BK\) has negative real part (for stabilizable \(A,B\) and sensible \(Q,R\)).
Im
^ | UNSTABLE (Re>0)
o | | shaded red
o | o |
-------+----X---X---+------------------> Re
o | X X | 0
o | X |
| |
LQR closed loop (X): all Re < 0 open loop (o): poles at origin
verified max Re(pole) ~ -1.74 (marginally stable, must feed back)
With the \(Q,R\) above, cl.real.max() ≈ -1.74 — a comfortable stability margin, no hand-tuning.
In the simulator
Eight lines from model to gain
Re-use the Week-4 linearization, then call analysis.lqr:
import numpy as np
from quadsim import QuadParams, analysis as an
p = QuadParams()
A, B = an.linearize(p) # Wk-4 linear model about hover (12x12, 12x4)
ol = an.poles(A) # open-loop poles — sit at the origin
Q = np.diag([10,10,10, 1,1,1, 10,10,10, 1,1,1.0])
R = np.diag([1.0, 10, 10, 10])
K, P, cl = an.lqr(A, B, Q, R) # solves CARE, returns K, P, closed-loop poles
print("max Re(closed-loop pole) =", cl.real.max()) # ~ -1.74, all < 0
print("stable:", np.all(cl.real < 0))
Apply it about hover as \( \mathbf{u}=\mathbf{u}_{\text{hover}}-K(\mathbf{x}-\mathbf{x}_{\text{eq}}) \). See examples/04_analysis.py.
Compare
LQR vs the PID you already built
PID (Wk 5–6)
Tuned per loop, by hand.
Local, decoupled axes.
Intuitive; no model needed.
LQR (today)
One gain, solved from a cost.
Couples all 12 states at once.
Optimal & stable — but needs \(A,B\).
In the lab you'll put them head to head: overshoot, settling time, control effort on the same step.
Same plant, two philosophies. Neither is "right" — LQR shines when you trust the model and care about effort.
Second half · hands-on
Now you build it
Open the Week 7 lab sheet → design an LQR gain with quadsim.analysis.lqr.
Plot open-loop vs LQR closed-loop poles with plotting.plot_poles.
Run export PYTHONPATH=. && python examples/04_analysis.py.
Checkpoint: all closed-loop poles have \( \mathrm{Re}<0 \) (max \( \approx -1.74 \)). ⭐ graded.