Open-loop motor control — sending a fixed duty cycle and hoping the robot reaches the desired speed — works poorly in practice. Battery voltage drops as the battery discharges, changing motor speed at a given duty cycle. Floor surface changes affect friction. Wheel slip occurs on starts and stops. A feedback controller measures the actual output, compares it to the desired output, and adjusts the command to reduce the difference. The Proportional-Integral-Derivative (PID) controller is by far the most widely deployed feedback controller in robotics and industry.
A feedback control loop has four elements:
Without feedback (open loop), any disturbance to the plant causes a permanent error. With feedback, the controller continuously corrects for disturbances.
The PID controller output in continuous time is:
\[u(t) = K_p \, e(t) + K_i \int_0^t e(\tau)\,d\tau + K_d \, \frac{de}{dt}\]Proportional term $K_p \cdot e(t)$: output is proportional to current error. Responds immediately but leaves a steady-state error (the error must be non-zero to produce a non-zero output). Intuition: the present situation.
Integral term $K_i \int e \, dt$: accumulates past errors. Eliminates steady-state error by building up until the output is large enough to drive the plant to zero error. Intuition: the past record of error.
Derivative term $K_d \, de/dt$: reacts to the rate of change of error. Damps oscillations and provides predictive correction. Intuition: future trend of error.
In a digital system running at time step $\Delta t$:
\[u_k = K_p e_k + K_i \sum_{j=0}^{k} e_j \Delta t + K_d \frac{e_k - e_{k-1}}{\Delta t}\]This is the form implemented in code:
def update(self, measurement: float, setpoint: float, dt: float) -> float:
error = setpoint - measurement
self._integral += error * dt
self._integral = max(self._min_output,
min(self._max_output, self._integral))
derivative = (measurement - self._prev_measurement) / dt
output = (self.Kp * error
+ self.Ki * self._integral
- self.Kd * derivative)
self._prev_measurement = measurement
return max(self._min_output, min(self._max_output, output))
Note two important details: the integrator is clamped (anti-windup, explained next), and the derivative is computed from measurement rather than error.
If the setpoint $r$ changes instantaneously (a step input), the error $e = r - y$ also jumps instantaneously, giving a large spike in $de/dt$. This derivative kick sends a large transient command to the plant, which may saturate the actuator or cause mechanical shock.
The fix is simple: compute the derivative of the measurement instead of the error. Since $de/dt = dr/dt - dy/dt$ and $dr/dt = 0$ between setpoint changes, computing $-dy/dt$ is equivalent (the negative sign is absorbed in the minus sign in the code above). This eliminates the kick at setpoint changes while preserving the damping action during transients.
When the plant output saturates — for example, when the motor is already at full speed but the setpoint is still higher — the error remains positive. The integrator keeps accumulating this error, growing to a very large value (winding up). When the setpoint finally comes down, the large integrator value keeps driving the output high long after it should have decreased, causing overshoot and slow recovery.
Integrator clamping is the simplest anti-windup method: clamp the integral accumulator to the output range $[u_{min}, u_{max}]$. When the integrator is clamped, it cannot exceed the range that would be useful even without the proportional and derivative terms.
self._integral = max(self._min_output,
min(self._max_output, self._integral))
For a motor with duty cycle output in $[-1, 1]$, set $u_{min} = -1$ and $u_{max} = 1$.
The Ziegler-Nichols open-loop method provides a systematic starting point for gains:
| Controller | $K_p$ | $K_i$ | $K_d$ |
|---|---|---|---|
| P | $T / (K \cdot L)$ | — | — |
| PI | $0.9 T/(K L)$ | $0.27T/(KL^2)$ | — |
| PID | $1.2 T/(K L)$ | $0.6T/(KL^2)$ | $0.075T/K$ |
where $K$ is the process gain (steady-state output / input step size).
These values typically give a step response with about 25% overshoot. For smoother control, reduce $K_p$ by 20% and $K_i$ by 30% from the Z-N values.
The step-response logging script records error vs. time and prints rise time and overshoot:
Apply the PID controller to wheel speed control:
The update rate should be fast relative to the motor time constant. A control loop at 50–100 Hz is appropriate for small gear-motors with mechanical time constants of 50–200 ms.
See code/04_motor_speed_pid.py
A typical working set of gains for an N20 gear-motor at 12 V, encoding 360 CPR (counts per revolution):
$K_p = 0.40, \quad K_i = 1.20, \quad K_d = 0.008$
These are starting points only. Always verify on the actual hardware.
A single PID loop on wheel speed is sufficient for most tasks. For high-precision path following, a cascade (nested) control structure works better:
The outer loop runs at a slower rate (10–20 Hz) and sets the speed setpoints for the inner loops. The inner loops run at 50–100 Hz. This separation of timescales makes each loop easier to tune independently.
With PID control established, Chapter 5 introduces the encoder-based odometry system that provides the position feedback driving higher-level navigation.
Navigation: