Skip to main content

Control and PID

Intermediate45 minBefore this:Dynamics essentials · Python and NumPy · Basic calculus

You command a robot arm to move to 1 radian. It overshoots to 1.2, swings back to 0.9, and wobbles for a few seconds before settling. Or worse, it never quite reaches 1 because of a stubborn gravity load. The motors are fine; the commands are just badly chosen. Control is the craft of choosing commands, many times per second, so the robot does what you asked and not something near it.

You will be able to

  • Explain open-loop versus closed-loop (feedback) control and why feedback wins in the real world.
  • Write a PID controller and test it against a simulated DC motor.
  • Describe what each of P, I and D does, tune them step by step, and read overshoot, settling time and steady-state error from a response.
  • Add anti-windup and a filtered derivative, and say why each matters.
  • Describe cascaded control and impedance control at a conceptual level.

Open loop versus closed loop​

An open-loop controller sends a command and hopes. "Run the motor at 6 V for 2 seconds" is open loop. It works only if the world is exactly as you assumed: no extra load, no friction change, a full battery. Real robots break those assumptions constantly.

A closed-loop or feedback controlcontrol that measures the result and uses the error between the goal and the measurement to adjust the next command system measures what actually happened and corrects. The error is

e(t)=r(t)−y(t)e(t) = r(t) - y(t)

where rr is the reference (the goal) and yy is the measured output. The controller turns ee into a command uu.

Feedback has one big gift: it makes a system robust. Whatever disturbs the output, the error grows and the controller pushes back. It also has one big danger: a loop with too much correction or too much delay can start to oscillate, or even run away. Most of control engineering is balancing those two.

The PID controller​

The most used controller in industry is almost embarrassingly simple. It combines three terms:

u(t)=Kp e(t)+Ki∫0te(τ) dτ+Kd dedtu(t) = K_p\,e(t) + K_i\int_0^t e(\tau)\,d\tau + K_d\,\frac{de}{dt}

Each term has a personality.

  • P (proportional) reacts to the error now. Big error, big push. Like a spring pulling you to the goal. Too little gives sluggishness and leaves a steady-state error under load; too much causes oscillation.
  • I (integral) reacts to the error's history. A small error that lasts keeps adding up until the push is big enough to remove it. It eliminates steady-state error but can add overshoot and, as we will see, windup.
  • D (derivative) reacts to how fast the error changes. It acts like a shock absorber: it predicts where you are heading and brakes early. It reduces overshoot but amplifies sensor noise.

A motor to control​

To practise safely we simulate a DC motor with a voltage input and shaft angle output. Electrically, the armature current is i=(V−Keω)/Ri = (V - K_e\omega)/R, where KeωK_e\omega is the back-EMF (the motor acts as a generator that opposes the voltage). The torque is KtiK_t i, and the rotor obeys Jω˙=Kti−bω−τloadJ\dot\omega = K_t i - b\omega - \tau_{\text{load}}. The code below integrates this with a small time step.

Then comes the controller, built the way real firmware builds it. Two practical touches are included from the start, and both are explained below the code: a filtered derivative on the measurement, and anti-windup by conditional integration.

pid_motor.py
import numpy as np


class DCMotor:
"""A DC motor + gearbox lumped into a few parameters. Voltage in, shaft angle out."""

def __init__(self, J=0.01, b=0.1, Kt=0.05, R=1.0, Ke=0.05):
self.J, self.b, self.Kt, self.R, self.Ke = J, b, Kt, R, Ke
self.theta = 0.0 # shaft angle (rad)
self.omega = 0.0 # shaft speed (rad/s)

def step(self, volts, dt, load_torque=0.0):
current = (volts - self.Ke * self.omega) / self.R
torque = self.Kt * current - load_torque
alpha = (torque - self.b * self.omega) / self.J
self.omega += alpha * dt
self.theta += self.omega * dt
return self.theta


class PID:
def __init__(self, kp, ki, kd, out_limit=12.0, dt=0.001, anti_windup=True, tau_d=0.01):
self.kp, self.ki, self.kd = kp, ki, kd
self.limit = out_limit # the supply voltage: the actuator cannot do more
self.dt = dt
self.anti_windup = anti_windup
self.tau_d = tau_d # derivative filter time constant (s)
self.integral = 0.0
self.prev_meas = None
self.d_filtered = 0.0

def update(self, setpoint, measurement):
error = setpoint - measurement

# Derivative on the MEASUREMENT (not the error) so a setpoint jump does not
# cause a huge spike, then low-pass filtered to tame sensor noise.
if self.prev_meas is None:
self.prev_meas = measurement
raw_d = -(measurement - self.prev_meas) / self.dt
alpha = self.dt / (self.tau_d + self.dt)
self.d_filtered += alpha * (raw_d - self.d_filtered)
self.prev_meas = measurement

unsaturated = self.kp * error + self.integral + self.kd * self.d_filtered
output = float(np.clip(unsaturated, -self.limit, self.limit))

# Conditional integration: only keep adding to the integral if the output is
# not saturated, or if the error is trying to pull the output back out of it.
saturated = output != unsaturated
if (not self.anti_windup) or (not saturated) or (error * unsaturated < 0):
self.integral += self.ki * error * self.dt
return output


def run(pid, motor, setpoint=1.0, t_end=3.0, dt=0.001, load=0.0, load_time=1.5):
"""Simulate; apply a constant load torque after load_time. Returns the angle history."""
n = int(t_end / dt)
history = np.zeros(n)
for i in range(n):
volts = pid.update(setpoint, motor.theta)
extra = load if i * dt > load_time else 0.0
motor.step(volts, dt, load_torque=extra)
history[i] = motor.theta
return history


def metrics(history, setpoint=1.0, dt=0.001):
overshoot = max(0.0, (history.max() - setpoint) / setpoint * 100.0)
outside = np.where(np.abs(history - setpoint) > 0.02 * setpoint)[0]
settle = (outside[-1] + 1) * dt if len(outside) else 0.0
return overshoot, settle, abs(setpoint - history[-1])


if __name__ == "__main__":
configs = {
"P only": (20.0, 0.0, 0.0),
"PD": (20.0, 0.0, 1.5),
"PID": (20.0, 10.0, 1.5),
}
print(f"{'controller':<8} {'overshoot %':>12} {'settle (s)':>11} {'final error':>12}")
for name, (kp, ki, kd) in configs.items():
history = run(PID(kp, ki, kd), DCMotor(), load=0.05)
os_, settle, err = metrics(history)
print(f"{name:<8} {os_:12.2f} {settle:11.3f} {err:12.4f}")

Run it. A load of 0.05 N·m hits the motor at 1.5 s. Look at the final error column: with P or PD the motor ends up slightly short of the goal because the spring term needs a persistent error to produce the holding voltage. With the integral term the error is far smaller and still shrinking when the run ends (give it more time and it reaches zero). That is the integral's whole job.

Tuning: a practical recipe​

There is theory for tuning (Ziegler-Nichols, loop shaping, model-based design), but a patient engineer with a plot can tune by hand using the personalities above.

  1. Start with Ki=Kd=0K_i = K_d = 0. Raise KpK_p until the response is fast but overshoots a bit.
  2. Add KdK_d until the overshoot shrinks to acceptable levels. If the output gets jittery, lower KdK_d or raise the derivative filter's time constant.
  3. Add KiK_i slowly, just enough to remove the steady-state error in the time you can afford. Too much brings back overshoot.
  4. Test with disturbances, not just step moves: a load, a payload change, a bump.

Read the response with three numbers: overshoot (how far past the goal), settling time (when it stays within a small band, often 2 percent), and steady-state error (what remains forever). Speed, smoothness and accuracy trade against each other, and the right balance depends on your robot.

Pro tip

Tune in the same control loop rate that you will deploy. A controller tuned at 1 kHz can go unstable at 100 Hz, because the delay between measuring and acting grew.

Integral windup and anti-windup​

Real actuators saturate: a 12 V supply cannot give 40 V. While the output is stuck at its limit, the error stays large, and a naive integral term keeps accumulating, "winding up" a huge value. When the robot finally reaches the goal, the stored integral keeps pushing it past, causing big overshoot.

Anti-windup stops that. Our code uses conditional integration: skip integrating while the output is saturated (unless the error would pull it back). Other methods include clamping the integral and "back-calculation". You can see the effect by commanding a large move:

windup_demo.py
from pid_motor import PID, DCMotor, run, metrics

big_move = 30.0 # rad: the 12 V supply saturates for a long time
for anti_windup in (False, True):
history = run(PID(20.0, 60.0, 1.0, anti_windup=anti_windup), DCMotor(),
setpoint=big_move, t_end=6.0)
overshoot, _, final_error = metrics(history, setpoint=big_move)
print(f"anti_windup={anti_windup!s:<5} overshoot {overshoot:5.1f} % final error {final_error:.3f} rad")

Without anti-windup the motor overshoots by many percent. With it, the overshoot nearly vanishes.

Beyond a single loop: cascaded control​

Real joints rarely use one loop. They nest several, each faster than the one outside it:

This is cascaded control. The inner current loop makes the motor behave like a clean torque source. The velocity loop above it makes speed follow its command. The position loop at the top makes the angle follow the goal. Each loop sees a simpler system below it, and the inner loops can reject disturbances (a sudden load) before they reach the outer one. The rule of thumb: each inner loop should be several times faster than the one it serves.

Impedance control: making a robot behave like a spring​

Position control tries to reach a point no matter what. That is dangerous when the robot touches things: a stiff controller will push through a person's hand or a fragile object. Impedance control instead gives the hand the behaviour of a virtual spring and damper:

F=K (xd−x)+D (x˙d−x˙)F = K\,(x_d - x) + D\,(\dot x_d - \dot x)

where KK is the stiffness and DD the damping. Using τ=JTF\tau = J^{\mathsf T}F from the dynamics lesson, you can turn this hand force into joint torques. Low stiffness makes the robot soft and compliant; high stiffness makes it precise. Collaborative robots, legged robots, and almost any robot that must touch the world use some version of this idea.

Hands-on lab​

You will tune the controller and see what each term does.

  1. Save pid_motor.py and run it. Record overshoot, settling time and final error for each controller.
  2. Change PID(20, 10, 1.5) to a higher proportional gain, such as 50. What happens to overshoot and to the noise in the derivative term? (Print np.std(np.diff(history)) as a quick roughness measure.)
  3. Make the derivative unfiltered by setting tau_d=1e-6, and add measurement noise: replace motor.theta with motor.theta + np.random.normal(0, 0.002) inside run. What does the voltage output look like?
  4. Save windup_demo.py and confirm the effect of anti-windup. Try an integral gain of 100.
  5. Open question: lower the loop rate by calling the PID every 10 ms instead of every 1 ms. Does the same tuning still behave?

You know it worked when you can predict, before running, whether a change to KpK_p, KiK_i or KdK_d will make the response faster, smoother or more accurate, and the plots or metrics agree.

Exercises​

Spot the controllerBeginner

A robot arm reaches its target but sags 3 degrees below it whenever it holds a heavy tool. Which PID term fixes that, and why?

Show solutionHide solution

The integral term. A constant disturbance (gravity on the tool) needs a constant holding torque. A P-only controller only produces that torque if a constant error remains. The integral accumulates the small error until the holding torque is supplied, driving the error to zero.

Why measure, not error, in the D term?Intermediate

When the operator suddenly changes the setpoint from 0 to 1, what happens to a derivative computed on the error, and why does the code differentiate the measurement instead?

Show solutionHide solution

The error jumps from 0 to 1 in a single step, so de/dtde/dt is enormous for one sample: a "derivative kick" that slams the actuator. The measurement does not jump (the motor has inertia), so differentiating it avoids the spike. In steady operation the two are the same except for sign, because e=r−ye = r - y and the reference is constant between changes.

Conditional integration by handAdvanced

Describe in words what the code in update does with error * unsaturated < 0, and why that condition is needed.

Show solutionHide solution

When the output is saturated the integrator is normally frozen. But if the error has the opposite sign from the unsaturated output (for example, the output is stuck at +12 V but the error has gone negative because we overshot), then integrating would help pull the output out of saturation, so the code allows it. Without that exception the integrator could stay frozen while the robot overshoots, and the output would never come off its limit.

Check your understanding​

1Which statement best describes why feedback beats open-loop control on real robots?
2Which PID term eliminates a steady-state error caused by a constant load?
3What is integral windup?
4What does impedance control make a robot behave like?
0 of 4 answered

Takeaways

  • Feedback control measures the output and corrects the error, which is how robots cope with disturbances and unknowns.
  • PID adds three behaviours: P is the spring, I removes steady-state error, D damps and predicts.
  • Tune step by step (P, then D, then I) and test with disturbances, judging overshoot, settling time and steady-state error.
  • Anti-windup, derivative on measurement and a filtered derivative are standard practice, not extras.
  • Real joints nest fast inner loops (current, velocity) inside slower outer ones (position).
  • Impedance control gives a robot a tunable spring and damper at the hand so that it can touch the world safely.

Stuck? Ask Rivet, the course assistant, bottom-right.

Go deeper​

Next: ROS 2 architecture and DDS

Finished the lab and the quiz?

Mark it done to track your progress through the course.