FixTheRobot Explore Robotics / Systems Engineering / Engineering Guides / Module 14

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.

  • Intermediate
  • Module 14: Motion Control & Actuator Integration
  • Written for ROS 2 Jazzy where ROS 2 is used
  • Sources checked 2026-11-06

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.

Overview
StepActionWatch for
1Set I and D to zero, raise P until the response is quickOscillation or noise at the motor
2Add I until the steady-state error vanishes within the required timeOvershoot and slow oscillation
3Add a little D (or filter the derivative) only if neededNoise amplification, motor heat
4Limit the output and the integrator (anti-windup)Overshoot after saturation
5Validate with the real load and battery rangeGains 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 limited

7. Implementation

  1. Make the safe step: wheels off the ground, low limit, easy stop.
  2. Tune P, then I, then D if needed, recording step responses each time.
  3. Add the output limit and anti-windup and test against saturation (below).
  4. Test with the real load.
  5. 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

  1. Record a step response and look at overshoot, rise and steady-state error.
  2. If it oscillates, reduce P or increase the loop rate.
  3. 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.

Related on FixTheRobot