Level 06 · Signals, Systems & Control

State and Estimation

State, estimation and Kalman filters, pole placement and LQR.

Level 6 · Signals, Systems & Control: State and Estimation

Level 5 ended by writing a mass on a spring as a vector, its loop's poles the eigenvalues of one matrix. This last level works that way throughout. A system's state is everything about its past that matters for its future, and x_{k+1} = A x_k + B u_k steps any linear system forward with one matrix product. A controller that feeds back the whole state can put the poles where it likes, or, with LQR, where a stated price of error and effort says they belong.

Sensors see only part of the state, with noise, so the second half estimates the rest. The Kalman filter blends a model's prediction with each reading in proportion to how far each can be trusted. Last comes the model itself, fitted to a log by least squares, and refitted over a moving window when the system changes.

The other paths meet here. The Kalman filter is the Statistics path's estimation: it weighs two estimates by the inverses of their variances. LQR minimises a quadratic cost, as the Machine Learning path's optimisers do, and system identification is that path's least-squares regression.

State: everything the future needs

A system in state-space form is

x_{k+1} = A x_k + B u_k, \qquad y_k = C x_k

The first equation moves the state on: x_k is a column of n numbers, A an n \times n matrix and B a column. The second is what the sensor reads, with C a row. Row i of A says how the next value of state i depends on all of today's, x_{k+1,i} = \sum_j A_{ij} x_{k,j} + B_i u_k, which numpy writes A @ x + B * u. Two tanks in a row, a pump filling the upper one and a sensor only in the lower:

import numpy as np
A = np.array([[0.9, 0.0], [0.08, 0.95]])      # per minute: the upper tank keeps 90 %, 8 % falls into the lower
B = np.array([0.5, 0.0])                      # the pump fills the upper tank
C = np.array([0.0, 1.0])                      # only the lower tank has a sensor
x = np.zeros(2)
ys = [float(C @ x)]
for u in [1] * 5 + [0] * 35:                  # the pump runs for 5 minutes
    x = A @ x + B * u
    ys.append(float(C @ x))
print(np.round(ys[:7], 3).tolist(), f"peak {max(ys):.3f} m at minute {int(np.argmax(ys))}")
plot(ys, kind="step", xlabel="minute", ylabel="lower tank (m)")
[0.0, 0.0, 0.04, 0.114, 0.217, 0.343, 0.49] peak 1.031 m at minute 16

The lower tank peaks eleven minutes after the pump stops: the unmeasured upper tank is still draining. Build the new state entirely from the old; with lists, never overwrite x[0] while x[1] still needs it.

Practice: A Lift's State, One Step at a Time

Companion form, and back

An equation of order n needs n states. With a[0] = 1 (divide through first) and b[0] = 0, the companion form puts the negated a[1:] in the first row of A, ones just below the diagonal to shift the memory down, B = [1, 0, ...] and C = b[1:]. A shock absorber rig, y[k] = 1.2 y[k-1] - 0.5 y[k-2] + 0.3 u[k-1] + 0.1 u[k-2], kicked once:

import numpy as np
a, b = [1, -1.2, 0.5], [0, 0.3, 0.1]          # sampled every 5 ms
A = np.array([[-a[1], -a[2]], [1.0, 0.0]])
B, C = np.array([1.0, 0.0]), np.array(b[1:])
x, ys = np.zeros(2), []
for u in [1, 0, 0, 0, 0, 0]:
    ys.append(float(C @ x))
    x = A @ x + B * u
print(np.round(ys, 4).tolist(), np.round(np.poly(A), 4).tolist())
[0.0, 0.3, 0.46, 0.402, 0.2524, 0.1019] [1.0, -1.2, 0.5]

The equation gives the same: 0.3, then 1.2 * 0.3 + 0.1 = 0.46, and so on. Back again, for any A: the denominator is np.poly(A), the characteristic polynomial \det(zI - A). The numerator comes from the impulse response h_0 = 0, h_m = C A^{m-1} B (a kick puts B into the state, and A carries it on), since a convolved with h is b. For the tanks, a = [1, -1.85, 0.855] and h starts 0, 0, 0.04, so b = [0, 0, 0.04]: two samples of dead time.

Common mistakes: forgetting the minus signs on a; dividing a but not b by a[0].

Practice: A Flight Log's Equation in Companion Form, The Crane Model Back as a Difference Equation

Eigenvalues: poles and modes

The eigenvalues of A are the poles, and each comes with an eigenvector: a state along eigenvector v stays along it, multiplied by the eigenvalue z every step. Any start is a mix of eigenvectors, x_0 = \sum_i c_i v_i, so

x_k = \sum_i c_i \, z_i^k \, v_i,

with eigenvalues z_i and eigenvectors v_i (the columns of V): each mode shrinks by |z_i| and turns by \angle z_i per step, independently. In numpy the sum is V @ (c * z ** k). A complex pair rings at \angle z / (2\pi\,\Delta t) hertz.

import numpy as np
dt = 0.005
A = np.array([[1.2, -0.5], [1.0, 0.0]])       # the rig
z, V = np.linalg.eig(A)                       # eigenvalues; eigenvectors as columns
for p in z[z.imag > 0]:
    print(f"pole {p:.4f}: radius {abs(p):.4f}, rings at {np.angle(p) / (2 * np.pi * dt):.1f} Hz")
x0 = np.array([1.0, 0.0])
c = np.linalg.solve(V, x0)                    # x0 as a mix of the modes
print(np.round((V @ (c * z ** 7)).real, 6).tolist(), np.round(np.linalg.matrix_power(A, 7) @ x0, 6).tolist())
pole 0.6000+0.3742j: radius 0.7071, rings at 17.7 Hz
[-0.161779, -0.163016] [-0.161779, -0.163016]

An eigenvalue of exactly 1 is something that can drift without limit, such as a position.

Practice: How a Train of Wagons Rings

Controllability and stabilisability

Can the input reach every mode? From rest, one step reaches multiples of B, two add A B, and after n steps nothing new appears. So the controllability matrix

W = \begin{bmatrix} B & AB & A^2 B & \cdots & A^{n-1} B \end{bmatrix}

(those vectors as columns) must have rank n. Chlorine dosed into a swimming pool that overflows into a paddling pool:

import numpy as np
B = np.array([1.0, 0.0])                      # dosed into the main pool only
for pipe in (0.04, 0.0):                      # the overflow open or shut
    A = np.array([[0.95 - pipe, 0.0], [pipe, 0.97]])
    W = np.column_stack([B, A @ B])
    print(f"pipe {pipe}: W = {np.round(W, 4).tolist()}, rank {np.linalg.matrix_rank(W)},"
          f" singular values {np.round(np.linalg.svd(W)[1], 4).tolist()}")
pipe 0.04: W = [[1.0, 0.91], [0.0, 0.04]], rank 2, singular values [1.3523, 0.0296]
pipe 0.0: W = [[1.0, 0.95], [0.0, 0.0]], rank 1, singular values [1.3793, 0.0]

The rank counts the singular values clearly above zero, relative to the largest; the small 0.0296 warns that steering through a thin pipe takes large doses. With the pipe shut, the paddling pool's chlorine decays by 0.97 a step whatever the dosing: an uncontrollable mode. The columns U2 of U (from np.linalg.svd(W)) beyond the rank span the unreachable directions, and the eigenvalues of U2.T @ A @ U2 are the uncontrollable modes. If they lie inside the unit circle they fade alone and the system is stabilisable; otherwise no controller can save it.

Practice: Two Rooms, One Radiator

State feedback: A - B K

State feedback sets u_k = -K x_k, one gain per state. Then

x_{k+1} = A x_k - B K x_k = (A - BK)\, x_k:

one closed-loop matrix, whose eigenvalues are the loop's poles. BK is the outer product, entry (i, j) equal to B_i K_j. A robot arm's joint:

import numpy as np
A = np.array([[1.0, 0.05], [0.0, 0.9]])       # [angle (rad), rate (rad/s)], dt = 0.05 s
B = np.array([0.001, 0.04])                   # a torque command
for K in ([0, 0], [20, 2], [100, 8], [600, 20]):
    z = np.linalg.eigvals(A - np.outer(B, K))
    r, deg = max(abs(z)), max(abs(np.degrees(np.angle(z))))
    print(f"K = {str(K):9}: largest radius {r:.3f}, angle {deg:4.1f} degrees")
K = [0, 0]   : largest radius 1.000, angle  0.0 degrees
K = [20, 2]  : largest radius 0.918, angle 11.2 degrees
K = [100, 8] : largest radius 0.831, angle 27.0 degrees
K = [600, 20]: largest radius 0.872, angle 73.3 degrees

Without feedback the angle has a pole at 1: it stays wherever it is pushed. Stiffer gains are faster, then ring; the largest radius, the spectral radius, must stay below 1. K[0] is a spring and K[1] a brake, as in Level 4's PD.

Common mistakes: A + B K. np.outer(K, B), the transpose. B * K, element by element.

Practice: The Drone's Height Loop as One Matrix

Placing two poles

Now choose the poles and find the gains. A 2 by 2 matrix M has the characteristic polynomial z^2 - \operatorname{tr}(M)\, z + \det(M); poles p_1, p_2 want z^2 - (p_1 + p_2)\, z + p_1 p_2. trace(A - B K) and det(A - B K) are each a constant plus multiples of k1 and k2 (the k1 k2 terms cancel), so matching them gives two linear equations. numpy can find the multiples by trying each gain alone:

import numpy as np
A = np.array([[1.0, 0.05], [0.0, 0.9]])
B = np.array([0.001, 0.04])
coef = lambda K: np.poly(A - np.outer(B, K))[1:]              # [-trace, det]
c0 = coef([0, 0])
M = np.column_stack([coef([1, 0]) - c0, coef([0, 1]) - c0])   # what each gain adds
want = np.real(np.poly([0.8 + 0.1j, 0.8 - 0.1j]))[1:]         # z^2 - 1.6 z + 0.65
K = np.linalg.solve(M, want - c0)
print(np.round(K, 4).tolist(), np.round(np.linalg.eigvals(A - np.outer(B, K)), 6).tolist())
[23.8095, 6.9048] [(0.8+0.1j), (0.8-0.1j)]

By hand, write A - B K out entry by entry and solve the pair with Cramer's rule; a zero determinant means a mode is out of reach. Poles at 0 give deadbeat control: on target after n steps, with large commands.

Practice: Placing the Lift's Two Poles by Hand

Ackermann's formula

For n states, Ackermann's formula matches every coefficient at once:

K = \begin{bmatrix} 0 & \cdots & 0 & 1 \end{bmatrix} W^{-1} \varphi(A),

with W the controllability matrix and \varphi the polynomial with the wanted poles. \varphi(A) is the polynomial evaluated at the matrix, powers of A for powers of z and the constant times the identity; Horner's rule starts from zeros and sets P = P @ A + c * I for each coefficient c. A check: by the Cayley-Hamilton theorem every matrix satisfies its own characteristic polynomial, so phi = np.poly(A) gives all zeros. Find the last row of W^-1 by solving W.T q = [0, ..., 0, 1], after testing the rank of W.

Practice: Ackermann's Formula for the Drone and the Pendulum

Tracking a reference

To hold the output at r instead of 0, add feedforward: u_k = -K x_k + N r. A stable loop settles where x = (A - BK)\,x + BNr, so

x_{\text{ss}} = (I - A + BK)^{-1} B N r, \qquad y_{\text{ss}} = g N r, \qquad g = C\,(I - A + BK)^{-1} B,

and N = 1 / g lands exactly on r. The arm, now with a return spring:

import numpy as np
A = np.array([[0.995, 0.05], [-0.2, 0.9]])    # a spring pulls the arm back to 0
B, C = np.array([0.001, 0.04]), np.array([1.0, 0.0])
K, r = np.array([100.0, 8.0]), 0.5            # go to 0.5 rad
g = C @ np.linalg.solve(np.eye(2) - A + np.outer(B, K), B)
for N in (0.0, K[0], 1 / g):
    x = np.zeros(2)
    for k in range(200):                      # 10 seconds
        x = A @ x + B * (N * r - K @ x)
    print(f"N = {N:5.1f}: angle after 10 s {x[0]:.4f} rad")
N =   0.0: angle after 10 s 0.0000 rad
N = 100.0: angle after 10 s 0.4762 rad
N = 105.0: angle after 10 s 0.5000 rad

N = K[0] cancels the feedback's spring but not the real one; the extra 5 is the torque that holds the arm against it. If g is 0, no N helps. A wrong model makes N wrong, which is why real loops add Level 3's integral.

Practice: Sending the Lift to the Third Floor

LQR: the Riccati iteration

The linear-quadratic regulator asks not where the poles go but what you care about, and minimises

J = \sum_{k=0}^{\infty} \left( x_k^\top Q \, x_k + u_k^\top R \, u_k \right)

Q prices error in each state, R prices effort; only their ratio matters. A stable loop's cost from x_0 is x_0^\top P x_0, and the best P is the fixed point of the discrete Riccati equation, iterated from P = Q:

K = (R + B^\top P B)^{-1} B^\top P A, \qquad P \leftarrow Q + A^\top P \,(A - BK)

The first is the best gain if the future costs x^\top P x; the second is one step's cost plus that future.

Each round looks one step further ahead, until nothing changes. With one state it is arithmetic. A maglev's air gap error, which grows 10 % a step if left alone:

a, b, q = 1.1, 0.5, 1.0
for r in (0.01, 1.0, 100.0):
    P = q
    for _ in range(500):
        K = a * b * P / (r + b * b * P)
        P = q + a * P * (a - b * K)
    K = a * b * P / (r + b * b * P)
    print(f"r = {r:6}: K = {K:.4f}, pole {a - b * K:.4f}, cost from x0 = 1: {P:.3f}")
r =   0.01: K = 2.1190, pole 0.0405, cost from x0 = 1: 1.047
r =    1.0: K = 0.9643, pole 0.6179, cost from x0 = 1: 3.121
r =  100.0: K = 0.4021, pole 0.8989, cost from x0 = 1: 89.471

Cheap effort puts the pole near 0; dear effort gives the gentlest gain that still works, its pole near 1 / a, the unstable pole reflected inside the circle. Any other gain costs more: price it by iterating P \leftarrow S + A_{\text{cl}}^\top P A_{\text{cl}}, with A_{\text{cl}} = A - BK and S = Q + K^\top R K.

Practice: The Best Gain for the Lift, by Riccati, Hold the Drone Still in a Gusty Hall

Looking ahead: a tiny MPC

LQR assumes any command is possible. A model-predictive controller tries plans u_0, \dots, u_{H-1} of H steps within the limits, predicts and prices each with the model, \sum_{i=1}^{H} \left( (h_i - h_{\text{want}})^2 + \rho\, u_{i-1}^2 \right) here, and keeps the cheapest. A water tower with zero, one or two pumps:

import itertools
def cost(h, plan, rho=0.01):                  # the town draws 0.25 m a step; each pump adds 0.2 m
    total = 0.0
    for u in plan:
        h = h + 0.2 * u - 0.25
        total += (h - 3.0) ** 2 + rho * u * u # 3 m is wanted
    return total
for h in (2.0, 2.9, 3.5):
    best = min(itertools.product((0, 1, 2), repeat=4), key=lambda plan: cost(h, plan))
    print(f"level {h} m: plan {best}, predicted cost {cost(h, best):.3f}")
level 2.0 m: plan (2, 2, 2, 2), predicted cost 1.835
level 2.9 m: plan (2, 1, 1, 1), predicted cost 0.085
level 3.5 m: plan (0, 0, 1, 1), predicted cost 0.095

Only the plan's first move is applied; then the controller measures and plans afresh. This receding horizon corrects disturbances as feedback does. Too short a horizon cannot see that braking must start early; too long costs levels ** H predictions a step, and real MPC optimises instead.

Practice: A Camera Dolly That Looks Ahead

The Kalman filter in one dimension

The estimate x comes with its variance p, the square of how unsure it is. Each step has two halves. Predict: x becomes the model's next value and p \leftarrow p + q, where q is how much the truth can change in a step. Update with a reading z whose variance is r:

K = \frac{p}{p + r}, \qquad x \leftarrow x + K\,(z - x), \qquad p \leftarrow (1 - K)\, p

The gain K lies between 0 and 1: the estimate moves part of the way to the reading, and ends more certain than either source.

A quantity that stays put predicts itself. The update is the Statistics rule for combining two independent estimates, weighted by the inverses of their variances, \hat{x} = \frac{x/p + z/r}{1/p + 1/r} with variance \frac{1}{1/p + 1/r}:

x, p = 14.0, 0.5                              # a buoy's sea temperature (°C) and its variance
z, r = 14.6, 0.3                              # a reading and the sensor's variance
K = p / (p + r)
print(f"gain {K:.3f}: x = {x + K * (z - x):.4f}, p = {(1 - K) * p:.4f}")
w = (x / p + z / r) / (1 / p + 1 / r)
print(f"inverse-variance weights: x = {w:.4f}, p = {1 / (1 / p + 1 / r):.4f}")
gain 0.625: x = 14.3750, p = 0.1875
inverse-variance weights: x = 14.3750, p = 0.1875

For a constant (q = 0) and a start that knows nothing (p huge), the filter is the running mean, with p = r / n after n readings: the squared standard error.

Practice: A Kitchen Scale That Grows More Sure

The steady-state gain

The lines for p never use the readings, so the gains can be computed in advance, and they settle. At steady state the predicted variance m returns after one update and one prediction: m = \frac{m r}{m + r} + q, so m^2 - q m - q r = 0 and

m = \frac{q + \sqrt{q^2 + 4 q r}}{2}, \qquad K = \frac{m}{m + r}

From then on the filter is x \leftarrow x + K\,(z - x), Level 2's exponential average with its weight chosen by the noise. The gain depends only on the ratio q / r: 0.031 at 0.001, 0.095 at 0.01, 0.270 at 0.1, 0.618 at 1 and 0.916 at 10. Iterating the three lines from any start converges to the same value:

q, r = 0.01, 1.0
for p0 in (0.0, 5.0):                         # two different starting variances
    p, gains = p0, []
    for k in range(40):
        p = p + q                             # predict
        K = p / (p + r)
        gains.append(K)
        p = (1 - K) * p                       # update
    plot(gains, label=f"p0 = {p0}", xlabel="reading", ylabel="gain K")

Press Run: one start knows the value, the other knows almost nothing, and after about 30 readings both gains are close to 0.095, the formula's value for q / r = 0.01.

Practice: How Far to Trust the Cellar Thermometer

Model against sensor: q and r

q says how far the model may be wrong in a step, r how far the sensor may be. A large q ("the truth wanders") keeps the gain high: the filter follows the readings, noise and all. A large r ("the sensor is poor") keeps it low: the filter smooths, and lags. With q = 0 the gain falls towards 0 for ever: told that nothing changes, the filter stops listening. r can be measured on something known to be still; q is the tuning knob, and too small a q, which looks smooth, is the commoner mistake.

Missing readings

When a reading does not come, predict and skip the update: p grows by q, so the next reading gets a larger gain. A river gauge with q = 0.02, r = 0.5 settles at K = 0.181; after three lost readings the predicted variance is 0.170 instead of 0.111, and the next gain is 0.254. Never feed a missing reading in as 0.

Practice: Following a Greenhouse as It Warms

Complementary filtering

A gyroscope gives a smooth turn rate, but its small bias integrates into drift; a compass never drifts, but jumps about. The complementary filter takes the gyro's short-term change and the compass's long-term level:

\psi_k = \alpha\,(\psi_{k-1} + \omega_k \, \Delta t) + (1 - \alpha)\, c_k,

with heading \psi_k, gyro rate \omega_k, compass reading c_k and a weight \alpha just below 1. It low-passes the compass and high-passes the summed gyro, with time constant \alpha\,\Delta t / (1 - \alpha); a gyro bias b leaves a steady error of \alpha b\,\Delta t / (1 - \alpha). A dinghy tacking:

import math, random
rng = random.Random(3)
dt, n = 0.1, 1200                             # two minutes at 10 Hz
true = [40 + 30 * math.sin(2 * math.pi * k * dt / 40) for k in range(n)]
gyro = [(true[k] - true[k - 1]) / dt + 0.5 + rng.gauss(0, 0.2) if k else 0.0 for k in range(n)]   # bias 0.5 °/s
compass = [v + rng.gauss(0, 4) for v in true]
for alpha in (0.0, 0.9, 0.98, 0.995, 1.0):    # 0: the compass alone; 1: the gyro alone
    h, sq = compass[0], 0.0
    for k in range(1, n):
        h = alpha * (h + gyro[k] * dt) + (1 - alpha) * compass[k]
        sq += (h - true[k]) ** 2
    print(f"alpha {alpha:5}: RMS error {math.sqrt(sq / (n - 1)):5.2f} degrees")
alpha   0.0: RMS error  4.01 degrees
alpha   0.9: RMS error  1.21 degrees
alpha  0.98: RMS error  2.66 degrees
alpha 0.995: RMS error  8.25 degrees
alpha   1.0: RMS error 27.65 degrees

The bias costs 0.45° at alpha = 0.9 but 9.95° at 0.995; with a better gyro a larger alpha would win.

Common mistakes: blending headings across 359° and 0° as plain numbers. Mixing degrees and radians.

Practice: Tilt of a Balancing Robot From Two Cheap Sensors

Gating outliers by the innovation

The innovation z - x is what a reading says that the prediction did not. If the model is right its variance is S = p + r, so the normalised innovation d = (z - x) / \sqrt{S} rarely leaves -3 to 3. A reading beyond the gate is rejected: the prediction stands, with no update.

import math
x, p, r = 142.0, 4.0, 9.0                     # a heart-rate strap: predicted 142 bpm
S = p + r
for z in (145, 151, 284):                     # 284: every beat counted twice
    d = (z - x) / math.sqrt(S)
    print(f"reading {z}: innovation {z - x:+6.1f}, d = {d:+6.2f} ->", "rejected" if abs(d) > 3 else "used")
reading 145: innovation   +3.0, d =  +0.83 -> used
reading 151: innovation   +9.0, d =  +2.50 -> used
reading 284: innovation +142.0, d = +39.38 -> rejected

Test with the predicted p, before updating. With bell-curve noise a gate of 3 rejects about 3 good readings in 1000. While readings are rejected p keeps growing, so a genuine jump is eventually let through.

Practice: The Tank Gauge That Sometimes Hears the Wrong Echo

Two states: position and velocity

A Kalman filter can estimate a state that nothing measures. In a constant-velocity model the state is [position, velocity] and everything is a matrix:

x \leftarrow F x, \qquad P \leftarrow F P F^\top + Q, \qquad F = \begin{bmatrix} 1 & \Delta t \\ 0 & 1 \end{bmatrix}, \qquad Q = q \begin{bmatrix} \Delta t^3/3 & \Delta t^2/2 \\ \Delta t^2/2 & \Delta t \end{bmatrix}
S = H P H^\top + r, \qquad K = \frac{P H^\top}{S}, \qquad x \leftarrow x + K\,(z - H x), \qquad P \leftarrow (I - K H)\, P

with H = \begin{bmatrix} 1 & 0 \end{bmatrix}: only the position is read.

P is the covariance matrix: variances on the diagonal, and off it how the two errors move together.

import numpy as np
dt, q, r = 0.2, 0.05, 0.0025                  # a curling stone filmed from above
F = np.array([[1, dt], [0, 1]])
Q = q * np.array([[dt ** 3 / 3, dt ** 2 / 2], [dt ** 2 / 2, dt]])
P = np.diag([0.0004, 0.25])                   # position known to 2 cm, speed to 0.5 m/s
for k in range(3):                            # the camera blocked for three frames: predict only
    P = F @ P @ F.T + Q
    print(f"frame {k + 1}: P = {np.round(P, 4).tolist()}, correlation {P[0, 1] / np.sqrt(P[0, 0] * P[1, 1]):.3f}")
K = P[:, 0] / (P[0, 0] + r)                   # the gain for the next reading
print(f"gain {np.round(K, 3).tolist()}: a reading 0.1 m beyond the prediction adds {0.1 * K[1]:.3f} m/s")
frame 1: P = [[0.0105, 0.051], [0.051, 0.26]], correlation 0.975
frame 2: P = [[0.0415, 0.104], [0.104, 0.27]], correlation 0.983
frame 3: P = [[0.094, 0.159], [0.159, 0.28]], correlation 0.980
gain [0.974, 1.648]: a reading 0.1 m beyond the prediction adds 0.165 m/s

Prediction correlates the errors: a stone further on than expected is probably faster too. That covariance gives the velocity a gain, so a position reading corrects the speed.

Practice: Reading a Lift's Speed From a Rangefinder

Dead reckoning and fusion

Dead reckoning adds up measured motion, such as wheel turns: smooth and always available, but every error accumulates. A position fix is noisy but does not drift. Fusion predicts with the measured motion, x \leftarrow x + v\,\Delta t and p \leftarrow p + q, and updates with each fix:

q, r = 0.02, 0.09                             # a robot mower: odometry adds 0.02 m² a second; a beacon fix has 0.09 m²
p, before, trace = 0.0, [], []
for t in range(1, 61):
    p += q
    if t % 15 == 0:                           # a fix every 15 s
        before.append(round(p, 3))
        p = p * r / (p + r)                   # (1 - K) p
    trace.append(p)
print("variance at each fix", before, "after it", round(p, 3))
print("dead reckoning with a 3 % scale error at 0.5 m/s is off by", [round(0.015 * t, 2) for t in (15, 30, 45, 60)], "m")
plot(range(1, 61), trace, kind="step", xlabel="seconds", ylabel="variance (m²)")
variance at each fix [0.3, 0.369, 0.372, 0.372] after it 0.072
dead reckoning with a 3 % scale error at 0.5 m/s is off by [0.22, 0.45, 0.67, 0.9] m

Press Run for the sawtooth: the fused uncertainty is cut at each fix and stays bounded, while dead reckoning's error grows without limit.

Practice: A Cyclist in a Tunnel: Wheel Count or GPS?

Identifying a model by least squares

Level 2 fitted a model from two pairs of samples; with noise, use them all. Each k says "row [y[k-1], u[k-1]] times [a, b] equals y[k]", and np.linalg.lstsq finds the a and b that minimise the squared misses,

\sum_{k=1}^{N-1} \left( y_k - a\,y_{k-1} - b\,u_{k-1} \right)^2:

the Machine Learning path's linear regression. Judge the model on a different log, one step ahead from measured outputs and in simulation from its own:

import numpy as np, random
def log(n, seed):                             # an e-bike on a test roller: assist level u, speed y (m/s)
    rng, v, u, y = random.Random(seed), 0.0, [], []
    for k in range(n):
        u.append(rng.choice((0, 1, 2, 3)) if k % 10 == 0 else u[-1])
        y.append(round(v + rng.gauss(0, 0.05), 2))
        v = 0.85 * v + 0.6 * u[-1]            # the truth, hidden from the fit
    return np.array(u), np.array(y)
u, y = log(200, 1)
(a, b), *_ = np.linalg.lstsq(np.column_stack([y[:-1], u[:-1]]), y[1:], rcond=None)
print(f"a = {a:.4f}, b = {b:.4f}")
u, y = log(100, 2)                            # a fresh day
one = a * y[:-1] + b * u[:-1]
sim = [y[0]]
for k in range(1, len(y)):
    sim.append(a * sim[-1] + b * u[k - 1])
rms = lambda e: float(np.sqrt(np.mean(e ** 2)))
print(f"one-step RMS {rms(one - y[1:]):.3f} m/s, simulation RMS {rms(np.array(sim[1:]) - y[1:]):.3f} m/s")
a = 0.8493, b = 0.6028
one-step RMS 0.071 m/s, simulation RMS 0.053 m/s

The fit recovers 0.85 and 0.6. With the right model the simulation is off only by the sensor noise, while one step ahead also inherits the noise in y[k-1]; a wrong model looks fine one step ahead and piles up errors in simulation. The input must excite the system: a log in which it hardly moves fits numbers to noise.

Practice: A Model of the Hot-Water Cylinder, Tested on a New Day

Moving-horizon estimation

When a parameter changes, one fit over the whole log averages old and new into a value wrong for both. A moving-horizon estimate refits over only the latest w samples at every step. A scooter battery's voltage is V = E - R\,i for a current i, and the cold raises R:

import numpy as np, random
rng = random.Random(4)
amps = np.array([rng.uniform(0, 5) for _ in range(200)])           # once a second
R_true = np.where(np.arange(200) < 100, 0.050, 0.080)              # cold from 100 s on
volts = np.array([3.7 - R * i + rng.gauss(0, 0.005) for R, i in zip(R_true, amps)])
rows = np.column_stack([np.ones(200), -amps])                      # volts = E * 1 + R * (-amps)
def fit(lo, hi):
    return np.linalg.lstsq(rows[lo:hi], volts[lo:hi], rcond=None)[0]
for end in (90, 105, 110, 120, 200):
    print(f"window ending at {end:3} s: R = {fit(end - 20, end)[1] * 1000:5.1f} mΩ")
print(f"one fit over everything:   R = {fit(0, 200)[1] * 1000:5.1f} mΩ")
window ending at  90 s: R =  50.1 mΩ
window ending at 105 s: R =  55.8 mΩ
window ending at 110 s: R =  62.9 mΩ
window ending at 120 s: R =  79.9 mΩ
window ending at 200 s: R =  80.5 mΩ
one fit over everything:   R =  67.8 mΩ

After 20 s the window has moved over entirely; a threshold on the estimate makes an alarm. A short window follows quickly but is noisy, Kalman's trade again, and a Kalman filter whose state holds the parameters, with q > 0, is the same idea without the hard edge.

Practice: Spotting the Open Window From the Heating Log

Where to go next

The path ends here; the subject does not. For nonlinear systems, the extended and unscented Kalman filters linearise around the estimate, and a particle filter carries a cloud of samples, the Statistics path's Monte Carlo. Robust control asks how wrong a model may be before a loop fails, and reinforcement learning, in the Machine Learning path, is optimal control when nobody gives you A and B. Packages such as scipy.signal and python-control do each idea of this level in a line; you now know what that line does.

Practice

These problems are required on this path at this level; the path adds one problem from each of the level's topics, picked for you and kept.

Checklist

You can now:

  • Simulate x[k+1] = A x + B u, y = C x, and convert to and from a difference equation.
  • Read poles and modes from eigenvalues, and test controllability and stabilisability.
  • Close a loop with u = -K x, place two poles by matching coefficients, and use Ackermann's formula.
  • Add a feedforward gain, find an LQR gain by the Riccati iteration, and plan with a receding horizon.
  • Run a 1D Kalman filter, find its steady-state gain, weigh q against r, and survive missing readings.
  • Blend two sensors with a complementary filter, and gate outliers by the innovation.
  • Track position and velocity with a 2-state filter, and compare dead reckoning with fusion.
  • Identify a model by least squares, test it on fresh data, and follow a changing parameter with a moving window.