Engineering Guides · Module 14
Tuning PID on a real robot
Raise the proportional gain until the response is quick, add integral for the steady-state error, and protect the integrator when the output saturates.
Assumptions: Numbers in the examples are illustrative and the code is executed by the site’s tests. Facts taken from a cited source are marked; other statements are general engineering knowledge and must be confirmed on the datasheet of the part you use.
1. Engineering problem
A wheel or joint velocity loop turns a speed command into motor effort. Too little gain is slow and leaves error; too much oscillates; an integrator that keeps accumulating while the motor is at its limit overshoots badly when the limit is released.
2. When is this needed?
- Commissioning a motor velocity or position loop.
- When a robot oscillates, lags or overshoots.
- After changing the motor, gearbox, load or battery.
3. Selection and design decisions
Terms: P acts on the present error (speed, stiffness); I removes steady-state error and adds lag and overshoot; D acts on the rate of change of error (damping, noise-sensitive). Many motor drivers already run a current loop and sometimes a velocity loop in firmware, so the outer loop you tune may be a position or velocity loop on top of it. The ros2_control stack provides the PID implementation in the control_toolbox package. Its Jazzy source (read 2026-11-06) shows the ROS parameters p, i, d, i_clamp_max, i_clamp_min, u_clamp_max, u_clamp_min, saturation, error_deadband, tracking_time_constant and antiwindup_strategy, whose valid values are none, conditional_integration (integrate only when certain conditions are met), back_calculation (uses the tracking time constant; 0.0 selects a recommended default) and legacy (deprecated, to be removed by the Kilted release). The simulation below uses conditional integration, the simplest of these ideas.
| Step | Action | Watch for |
|---|---|---|
| 1 | Set I and D to zero, raise P until the response is quick | Oscillation or noise at the motor |
| 2 | Add I until the steady-state error vanishes within the required time | Overshoot and slow oscillation |
| 3 | Add a little D (or filter the derivative) only if needed | Noise amplification, motor heat |
| 4 | Limit the output and the integrator (anti-windup) | Overshoot after saturation |
| 5 | Validate with the real load and battery range | Gains tuned unloaded do not hold loaded |
4. Advantages and disadvantages
- High P is stiff and noise-sensitive; high I removes error and slows settling and overshoots.
- Derivative adds damping and noise; filter it.
- Gain scheduling handles load changes at a cost in complexity.
5. Requirements and constraints
- The loop rate is high enough for the loop bandwidth (see the update-rate guide).
- Output limits are the real actuator limits.
- Tuning is repeated with the real payload and at low battery.
6. Architecture
setpoint ─▶ [+ error] ─▶ P + I + D ─▶ limit ─▶ driver/motor ─▶ speed ─▶ encoder ─▶ back to the error
anti-windup stops I growing while limited7. Implementation
- Make the safe step: wheels off the ground, low limit, easy stop.
- Tune P, then I, then D if needed, recording step responses each time.
- Add the output limit and anti-windup and test against saturation (below).
- Test with the real load.
- Record the final gains with the conditions.
def run(kp, ki, limit=None, antiwindup=True, dt=0.01, T=8.0, tau=0.5, gain=1.0):
y, integ, ys = 0.0, 0.0, []
for k in range(int(T / dt)):
e = 1.0 - y
u = kp * e + ki * integ
sat = u if limit is None else max(-limit, min(limit, u))
if limit is None or not antiwindup or u == sat or (u > sat) != (e > 0):
integ += e * dt
y += (gain * sat - y) / tau * dt
ys.append(y)
peak = max(ys)
rise = next((k * dt for k, v in enumerate(ys) if v >= 0.9), None)
return peak, rise
for kp, ki in ((2.0, 1.0), (2.0, 8.0), (8.0, 8.0)):
p, r = run(kp, ki)
rise = f"{r:.2f} s" if r is not None else "never"
print(f"kp {kp}, ki {ki}: overshoot {max(0, p - 1) * 100:4.1f} %, rise to 90 % at {rise}")
for aw in (False, True):
p, r = run(3.0, 6.0, limit=1.2, antiwindup=aw)
print(f"saturated at 1.2, anti-windup {'on ' if aw else 'off'}: overshoot {max(0, p - 1) * 100:4.1f} %")Printed (checked by the site’s tests):
kp 2.0, ki 1.0: overshoot 0.0 %, rise to 90 % at 3.19 s
kp 2.0, ki 8.0: overshoot 8.4 %, rise to 90 % at 0.34 s
kp 8.0, ki 8.0: overshoot 0.0 %, rise to 90 % at 0.16 s
saturated at 1.2, anti-windup off: overshoot 14.5 %
saturated at 1.2, anti-windup on : overshoot 0.0 %Reading it, on a simulated first-order motor (time constant 0.5 s) for a unit step: P = 2 with I = 1 is slow (90 % only at 3.19 s); P = 2, I = 8 reaches 90 % at 0.34 s with 8.4 % overshoot; P = 8, I = 8 reaches it at 0.16 s with no overshoot. With the output limited to 1.2, an integrator without anti-windup overshoots by 14.5 % and one with anti-windup by 0 %. The plant and gains are illustrative: real motors have delay, friction and a current loop.
8. Real examples
- AMR wheel loop: tuned on a stand, then retuned with the robot loaded at both ends of the battery range.
9. Failure scenarios
- Gains tuned unloaded that oscillate with a payload.
- An integrator that winds up while the wheel is blocked, then lunges.
- Derivative on noisy encoder data heating the motor.
10. Troubleshooting
- Record a step response and look at overshoot, rise and steady-state error.
- If it oscillates, reduce P or increase the loop rate.
- If it lunges after a block, add anti-windup.
11. Validation
- The simulation prints the lines shown (executed by the site tests).
- Step responses on the real robot meet the requirements loaded and unloaded.
12. Engineering checklist
13. Further references
- diff_drive_controller parameters (ros2_controllers, jazzy branch): diff_drive_controller_parameter.yaml, read 2026-11-06 (for the controller parameters that sit on top of the wheel loops).
- control_toolbox source (Jazzy): pid_ros.cpp and pid.hpp, read 2026-11-06.
- Control theory texts for tuning methods (for example Ziegler–Nichols); the sequence above is common practice and was not drawn from one cited source.