Control: closing the loop
420-302-VA · WEEK 10 · FALL 2026

Stage 4 of 6 · Lab · about 30 minutes

The controller

The theory becomes about thirty lines of MicroPython. The bench is unchanged from Week 8, LDR divider on GPIO 34, LED on GPIO 25, and the new code is one class that is Week 6's lesson in miniature: the controller's memory (integral, last measurement) lives in attributes, its law in a method, and the loop just calls it on a steady beat.

The class: state and behaviour, again

# pid.py: a PID controller with clamping, anti-windup and derivative on measurement
class PIDController:
    """Three-term controller. Call update(sp, pv, dt) once per cycle."""

    def __init__(self, kp, ki, kd, out_min=0.0, out_max=100.0):
        self.kp, self.ki, self.kd = kp, ki, kd
        self.out_min, self.out_max = out_min, out_max
        self.integral = 0.0          # the accumulated past
        self.prev_pv  = None         # for the slope; None = first call

    def update(self, sp, pv, dt):
        e = sp - pv
        cand = self.integral + e * dt          # candidate new accumulator
        if self.prev_pv is None:
            dpv = 0.0                          # no slope on the first call
        else:
            dpv = (pv - self.prev_pv) / dt     # measurement slope, not error slope
        self.prev_pv = pv

        u = self.kp * e + self.ki * cand - self.kd * dpv
        if u > self.out_max:
            u = self.out_max                   # clamp: the LED has no 130 %
        elif u < self.out_min:
            u = self.out_min
        else:
            self.integral = cand               # anti-windup: integrate only when unsaturated
        return u

Read it against the last page. The discrete sums are there verbatim (cand is Ik, dpv is the measurement slope, the minus sign makes it −dPV/dt, the kick-free derivative). Two decisions live in the clamping block: the output is clamped because the duty cannot leave 0–100, and the accumulator is committed only when the output stayed inside the limits, conditional integration, the anti-windup cure you will watch earn its keep below. One class, zero globals, resettable by constructing a fresh instance: the Week 6 argument for objects, now safety-relevant.

Honest time: measuring Δt

Week 5's rule said intervals come from a monotonic clock. MicroPython's monotonic clock is the tick pair ticks_ms/ticks_diff, the pair, because the tick counter wraps around and only ticks_diff subtracts correctly across the wrap. The loop keeps a fixed beat and measures what the beat actually was:

# main control loop: fixed period, measured dt
from time import ticks_ms, ticks_diff, sleep_ms
from pid import PIDController
from lightnode import LightNode

PERIOD_MS = 100                      # target: 10 cycles per second
SP = 50.0

node = LightNode(adc_pin=34, pwm_pin=25, dark=200, bright=3550)
pid  = PIDController(kp=1.8, ki=3.0, kd=0.05)

prev = ticks_ms()
while True:
    now = ticks_ms()
    dt  = ticks_diff(now, prev) / 1000   # seconds, as they really elapsed
    prev = now

    pv = node.read_pct()
    u  = pid.update(SP, pv, dt)
    node.set_brightness(u)               # Week 8 method: percent → duty

    sleep_ms(max(0, PERIOD_MS - ticks_diff(ticks_ms(), now)))

The last line sleeps the remainder of the period, so cycles that did extra work (printing, later MQTT) do not stretch the beat, and the measured dt catches whatever stretching survives. Period choice: 100 ms is comfortably faster than anything the eye or the averaged sensor can follow, and slow enough that a 240 MHz chip is mostly asleep. The rule of thumb to remember: sample several times faster than the fastest behaviour you care about, and keep the period boring and constant.

Saturation and windup: provoke it, cure it

Do this experiment for real: comment out the else so the integral always commits, set SP to 90 with the room lights on, and step the setpoint. The LED pins at 100 %, the error persists, and the accumulator climbs for seconds, buying nothing. When PV finally arrives, the controller cannot ease off: it must first burn down the surplus it stored, and PV sails far past the target. That is windup, and the computed pair shows both runs:

Two responses to a large setpoint step toward 88 on a slower plant. A red bar marks the long interval where the output is pinned at one hundred percent. The naive run, integrating throughout, overshoots far past the setpoint after the saturation ends and returns slowly. The cured run with conditional integration rises identically and settles at the setpoint with no tail.02550751000123456789secondslight level (%)SP = 88u pinned at 100 %: the integral has nothing useful to donaive: wound-up surplus repaid as overshootconditional integration: arrives and stays
Windup, provoked and cured on the same plant. During the marked saturation both controllers command 100 % and the plant gives all it has; the difference is bookkeeping. The naive integrator (copper) banks error the actuator can never deliver, and the debt is repaid as a deep, slow overshoot. Freezing the accumulator while pinned (green) changes nothing during the climb and everything after it. One else in the class is this entire figure.

The cure costs one else: while the output is pinned, more accumulation cannot help (the actuator is already giving everything), so the accumulator freezes until the output re-enters the honest range. Industrial controllers ship this or a sibling (back-calculation, integral clamping) as standard equipment; a PID without anti-windup is a student exercise, not a controller.

Noise and the D term

Last page priced derivative noise at 2πf. The bench sells the demonstration cheaply: point the sensor near a lamp, set a modest Kd, and log the D contribution with raw single reads versus Week 8's read_avg:

The derivative term's output over four seconds while the loop merely holds its setpoint. Computed from single raw reads it is a dense band of spikes of several percent either way. Computed from sixteen-sample averaged reads it is a calm line near zero.-15-10-505101501234secondsD-term output (% duty)single raw reads: Kd amplifies every flickerread_avg (n = 16) before the derivative: usable
The 2πf tax, paid on the bench. The loop is holding steady; nothing real is changing. Fed single reads (gray), the derivative converts invisible measurement flicker into percent-scale output jitter, the frequency-proportional amplification from the math, live. The same term fed Week 8's 16-sample average (green) is quiet. Smoothing before differentiating is not a nicety; it is what makes a D term usable at all.

Your two mitigations are already installed: read_pct built on the 16-sample average smooths PV before the derivative ever sees it, and the class differentiates the measurement, so setpoint steps cost no kick. If the output still shimmers, lower Kd first, and remember that a PI loop, Kd = 0, is a legitimate, industrially common answer for noisy plants.

First run: the loop holds

Start with P-only (ki=0, kd=0, say kp=2). Watch it park short of the setpoint, your ess formula live. Add ki=3 and watch the gap close to zero over a second or two. Then the moment this week is named for: shine a flashlight at the LDR. The reading spikes, the error goes negative, the controller cuts duty, and the light level walks back to 50 % while the flashlight is still on; pull it away and the LED breathes back up. Cup your hand over the sensor and watch the opposite. Nothing anywhere in your thirty lines mentions flashlights or hands. The loop does not model disturbances; it outlives them.

The verification protocol

Week 5's discipline, applied to a controller. Run and log all four; the tuning log on the next page records the numbers:

TestProcedurePass looks like
Step upSP 30 → 60, log the curveRise under ~1 s, overshoot under ~15 %, settles in the ±5 % band, ess ≈ 0
Step downSP 60 → 30Same grades; asymmetry noted if the plant turns off faster than on
DisturbanceHold SP; flashlight 3 s, then hand-shade 3 sRecovers to the band within ~1–2 s each way, no ringing
SaturationSP near the plant's ceiling, then backNo windup tail: the return starts promptly, no deep overshoot

Checklist for this stage

Check yourself

Why is the integral committed in the else branch rather than before the clamp?
Committing before the clamp lets the accumulator grow while the actuator is pinned, stored push that the LED cannot deliver and must later be unwound through overshoot: windup. Committing only when the raw output stayed in range freezes history exactly when history cannot help, which is the anti-windup contract.
Why ticks_diff(now, prev) instead of now − prev?
The millisecond tick counter wraps to zero periodically; plain subtraction across a wrap yields a huge negative number, and Δt poisons the I and D terms with it. ticks_diff is defined to subtract correctly modulo the wrap, which is why MicroPython ships the pair. It is Week 5's "monotonic for intervals" rule in its embedded dialect.
The sleep line computes the remainder of the period. What failure does max(0, …) prevent?
A cycle that overran the whole period would make the remainder negative, and sleeping a negative time is an error (or nonsense). max(0, …) degrades gracefully: the loop runs the next cycle immediately, late but alive, and the measured Δt tells the math the truth about the overrun.
Your tuned loop holds 50 % perfectly, yet the LED sits at a different duty tonight than this afternoon. Is something wrong?
No, that is the point. Ambient light d changed, so the standing duty the plant needs changed, and the integral quietly re-found it. PV at SP with u wherever-it-takes is the signature of integral action working; constant u across different rooms would be the suspicious outcome.
Setpoint 95 % with bright room light, and the loop never settles while the duty sits at 100 %. Controller bug?
Not a bug: an infeasible setpoint. The plant's ceiling (full LED plus ambient) is below 95 % tonight, so no controller can get there; the clamp holds 100 % and anti-windup keeps the accumulator from inflating meanwhile. The fix is a reachable SP, or more actuator. Recognizing "saturated and short" as a plant limit, not a code fault, is a professional diagnosis.