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:
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:
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:
| Test | Procedure | Pass looks like |
|---|---|---|
| Step up | SP 30 → 60, log the curve | Rise under ~1 s, overshoot under ~15 %, settles in the ±5 % band, ess ≈ 0 |
| Step down | SP 60 → 30 | Same grades; asymmetry noted if the plant turns off faster than on |
| Disturbance | Hold SP; flashlight 3 s, then hand-shade 3 s | Recovers to the band within ~1–2 s each way, no ringing |
| Saturation | SP near the plant's ceiling, then back | No 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?
Why ticks_diff(now, prev) instead of now − prev?
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?
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.