intermediate 20 min read

PID Control

What you'll learn

  • Understand what PID stands for and why it matters in robotics
  • Implement a proportional controller for driving and turning
  • Add integral and derivative terms for precision
  • Tune PID constants for your specific robot
  • Build reusable pid_drive() and pid_turn() functions

Why Timing Is Not Enough

You have probably seen this before: you program your robot to drive forward for 1 second, and it works perfectly in practice. Then at the competition, you run it on the same field and the robot ends up 6 inches short. What happened?

The field tiles had a slightly different texture. The battery was at 85% instead of 100%. One of the drive motors had a tiny bit more friction than yesterday.

Timing-based autonomous code fails in competition because it makes one dangerous assumption: your robot will always move at the same speed. It never does.

Sensor-based control is the answer. Instead of saying “run for X seconds,” you say “run until you have traveled X inches” — and you check an encoder to know when you are there. But that introduces a new problem: how fast should you move as you approach the target?

If you just stop when you hit the target, you overshoot. If you slow down too early, you waste time. PID control solves this elegantly.

What PID Stands For

PID stands for Proportional, Integral, Derivative. These are three mathematical terms that together calculate the right motor power at any moment. Each term looks at the error — the difference between where you are and where you want to be.

error = target - current_value

If your target heading is 90° and your current heading is 75°, your error is 15°.

Visual Explanation

If the math feels abstract, this 9-minute video by MATLAB makes it click. It is one of the clearest visual introductions to PID you will find anywhere:

Video thumbnail: What Is PID Control? — MATLAB / Brian Douglas

What Is PID Control? — MATLAB / Brian Douglas

For hands-on learning, try adjusting P, I, and D values yourself in this interactive PID simulator — you can see exactly how each term affects the system response in real time.

The three terms each respond to error differently:

  • P (Proportional): Apply power proportional to the current error. Large error = large power. Small error = small power. Simple and effective for most situations.
  • I (Integral): Apply power proportional to the accumulated error over time. Fixes the problem where P alone never quite reaches the target.
  • D (Derivative): Apply power opposing the rate of change of error. Acts like a brake — prevents overshoot and oscillation.

Start With Just P

Do not implement all three terms at once. Start with just proportional control, get it working, then add I and D if needed.

Here is a complete proportional drive example:

Try this on your robot. You will immediately notice the behavior: the robot glides smoothly to a stop, decelerating naturally as it approaches the target. This is the magic of proportional control.

Tuning kp

The kp value controls how aggressively the robot responds to error:

  • Too low (e.g., 0.1): Robot moves slowly, may not reach target, takes forever
  • Too high (e.g., 5.0): Robot overshoots, oscillates back and forth
  • Just right: Smooth deceleration, stops at target without overshooting

Starting procedure:

  1. Set kp = 0.5
  2. Run and watch the behavior
  3. If it oscillates: lower kp
  4. If it barely moves or undershoots: raise kp
  5. Typical working range: 0.3 to 1.5 for distance, 0.8 to 2.5 for turning

The Problem With P Alone: Steady-State Error

Proportional control has a subtle flaw called steady-state error. Here is why it happens:

As the robot approaches the target, the error shrinks. When error is very small, the proportional output is also very small. At some point, the motor output is so low that it cannot overcome static friction in the motors and gears. The robot stops close to the target but not at the target.

You might notice your robot consistently stopping 0.5 inches short of 24 inches. That is steady-state error.

Adding I: The Integral Term

The integral term accumulates error over time. Every loop iteration, you add the current error to a running total. If the robot is stuck near the target, error keeps accumulating, and eventually the integral term adds enough extra push to overcome friction.

def pi_drive(target_inches, kp=0.8, ki=0.002):
    """
    Drive with PROPORTIONAL + INTEGRAL (PI) control.

    Problem P alone has: "steady-state error"
    Near the target, error is tiny -> P output is tiny -> too weak
    to overcome friction -> robot stops slightly short. Every time.

    The fix - Integral term:
    Each loop, we ADD the current error to a running total (integral).
    The longer the robot sits close-but-not-quite-there, the bigger
    the integral grows, providing an ever-increasing extra push until
    friction is overcome and the robot finally hits the target exactly.

    Analogy: Imagine pushing a heavy box. P is your initial shove.
    I is the extra effort you keep adding when the box barely moves.
    """
    left_motor.reset_position()
    right_motor.reset_position()

    wheel_circumference = 7.874
    target_degrees = (target_inches / wheel_circumference) * 360

    # ── INTEGRAL STATE ────────────────────────────────────────────────
    # This variable ACCUMULATES error across every loop iteration
    integral = 0
    # Hard cap to prevent "integral windup" (explained below!)
    max_integral = 50

    while True:
        left_pos  = left_motor.position(DEGREES)
        right_pos = right_motor.position(DEGREES)
        current   = (left_pos + right_pos) / 2
        error     = target_degrees - current

        # Tighter threshold than P-only (3 degrees vs 5) because
        # the I term gives us the extra push to get really close
        if abs(error) < 3:
            break

        # ── THE I TERM ────────────────────────────────────────────────
        # Add this loop's error to the running total
        # Think: "total un-corrected error accumulated so far"
        integral += error

        # ⚠ INTEGRAL WINDUP PROTECTION (this is critical!)
        # Without a cap: if the robot is blocked for 5 seconds,
        # integral grows to thousands. When it finally moves, the
        # huge I term rockets the robot way past the target.
        # Clamping to +-50 keeps the integral's influence safe.
        integral = max(-max_integral, min(max_integral, integral))

        # ── COMBINED P + I OUTPUT ─────────────────────────────────────
        # P term: fast response to current position error
        # I term: slow correction for accumulated past error
        # Together: precise AND fast
        power = (kp * error) + (ki * integral)
        power = max(-100, min(100, power))

        left_motor.spin(FORWARD, power, PERCENT)
        right_motor.spin(FORWARD, power, PERCENT)
        wait(20, MSEC)

    left_motor.stop(BRAKE)
    right_motor.stop(BRAKE)

Integral Windup Warning

Notice the max_integral = 50 cap. Without it, if the robot is blocked or starts far from the target, the integral accumulates to a huge number. When the robot finally moves, it blasts past the target. This is called integral windup and it makes the robot behave erratically.

Always cap your integral. A good starting cap is 30-100 depending on your kp and ki values.

Adding D: The Derivative Term

The derivative term looks at how fast the error is changing. If error is shrinking quickly, the robot is approaching fast and needs to brake. The D term applies a counterforce proportional to the rate of change.

def pid_drive(target_inches, kp=0.8, ki=0.002, kd=0.1):
    """
    Full PID (Proportional + Integral + Derivative) drive controller.

    Problem PI can still have: overshoot on fast approaches.
    PI doesn't "know" how fast the robot is moving. If the robot
    is flying toward the target at high speed, PI only sees the
    current error shrinking - it doesn't apply any braking force.
    Result: the robot shoots past the target.

    The fix - Derivative term:
    D measures HOW FAST the error is changing each loop.
    If error is dropping quickly (robot approaching fast),
    D applies a counterforce - like a brake pedal.
    Result: smooth, damped approach with no overshoot.

    Car analogy:
      P = press gas harder when farther from your exit
      I = add a little extra gas when you've been barely creeping
      D = ease off the gas when you see the exit coming up fast
    """
    left_motor.reset_position()
    right_motor.reset_position()

    wheel_circumference = 7.874
    target_degrees = (target_inches / wheel_circumference) * 360

    integral  = 0
    prev_error = 0   # We need last loop's error to calculate rate of change
    max_integral = 50

    while True:
        left_pos  = left_motor.position(DEGREES)
        right_pos = right_motor.position(DEGREES)
        current   = (left_pos + right_pos) / 2
        error     = target_degrees - current

        if abs(error) < 3:
            break

        # ── I TERM: Accumulated past error (fixes steady-state) ───────
        integral += error
        integral  = max(-max_integral, min(max_integral, integral))

        # ── D TERM: Rate of change of error (prevents overshoot) ──────
        # derivative = how much error changed since last loop
        # Negative derivative = error is shrinking = robot is approaching
        # The larger the negative value, the faster the approach
        # kd * derivative will subtract from power -> automatic braking!
        derivative = error - prev_error
        prev_error = error  # Save for next loop's calculation

        # ── FULL PID OUTPUT ───────────────────────────────────────────
        # Each term plays a role:
        #   kp * error       -> fix current position error (fast)
        #   ki * integral    -> eliminate steady-state error (slow, persistent)
        #   kd * derivative  -> dampen rapid changes (smooth approach)
        power = (kp * error) + (ki * integral) + (kd * derivative)
        power = max(-100, min(100, power))

        left_motor.spin(FORWARD, power, PERCENT)
        right_motor.spin(FORWARD, power, PERCENT)
        wait(20, MSEC)

    left_motor.stop(BRAKE)
    right_motor.stop(BRAKE)

Building a Reusable PID Class

Instead of rewriting the PID math for every function, build a class:

from vex import *

brain = Brain()
left_motor = Motor(Ports.PORT1, False)
right_motor = Motor(Ports.PORT6, True)
inertial = Inertial(Ports.PORT3)

class PIDController:
    def __init__(self, kp, ki, kd, max_integral=50):
        self.kp = kp
        self.ki = ki
        self.kd = kd
        self.max_integral = max_integral
        self.integral = 0
        self.prev_error = 0

    def calculate(self, error):
        self.integral += error
        self.integral = max(-self.max_integral, min(self.max_integral, self.integral))
        derivative = error - self.prev_error
        self.prev_error = error
        return self.kp * error + self.ki * self.integral + self.kd * derivative

    def reset(self):
        self.integral = 0
        self.prev_error = 0


# Create PID controllers for driving and turning
drive_pid = PIDController(kp=0.8, ki=0.002, kd=0.1)
turn_pid = PIDController(kp=1.5, ki=0.003, kd=0.2)


def pid_drive(target_inches):
    """Drive forward target_inches using PID."""
    drive_pid.reset()
    left_motor.reset_position()
    right_motor.reset_position()

    wheel_circumference = 7.874
    target_degrees = (target_inches / wheel_circumference) * 360

    while True:
        current = (left_motor.position(DEGREES) + right_motor.position(DEGREES)) / 2
        error = target_degrees - current

        if abs(error) < 3:
            break

        power = drive_pid.calculate(error)
        power = max(-100, min(100, power))

        left_motor.spin(FORWARD, power, PERCENT)
        right_motor.spin(FORWARD, power, PERCENT)
        wait(20, MSEC)

    left_motor.stop(BRAKE)
    right_motor.stop(BRAKE)


def pid_turn(target_heading):
    """Turn to an absolute heading (0-360) using PID."""
    turn_pid.reset()

    while True:
        current_heading = inertial.heading()

        # Calculate shortest path to target (handle 0/360 wrap)
        error = target_heading - current_heading
        if error > 180:
            error -= 360
        elif error < -180:
            error += 360

        if abs(error) < 1.5:
            break

        power = turn_pid.calculate(error)
        power = max(-100, min(100, power))

        # Turn: left motor forward, right motor backward (or vice versa)
        left_motor.spin(FORWARD, power, PERCENT)
        right_motor.spin(REVERSE, power, PERCENT)
        wait(20, MSEC)

    left_motor.stop(BRAKE)
    right_motor.stop(BRAKE)


# --- Autonomous Routine ---
inertial.calibrate()
wait(2, SECONDS)

pid_drive(24)     # Drive 24 inches
pid_turn(90)      # Turn to face 90 degrees
pid_drive(12)     # Drive 12 more inches
pid_turn(0)       # Turn back to 0 degrees

Tuning Guide

Tuning PID is part science, part art. Follow this order:

Step 1: Tune kp first

Set ki=0, kd=0. Increase kp until the robot overshoots slightly when approaching the target.

Step 2: Add kd to reduce overshoot

Increase kd gradually until the overshoot goes away. The robot should arrive smoothly.

Step 3: Add ki only if needed

If the robot consistently stops slightly short of the target, add a small ki (start at 0.001). Increase slowly.

Typical Starting Values

Applicationkpkikd
Drive distance0.6-1.00.001-0.0050.05-0.2
Turn to heading1.0-2.00.002-0.0080.1-0.4

Common Mistakes

Mistake 1: Forgetting to calibrate the inertial sensor The inertial sensor takes 2 seconds to calibrate. Always call inertial.calibrate() then wait(2, SECONDS) at the start of your program — before any movement.

Mistake 2: Not resetting encoders If you run pid_drive(24) twice without resetting, the second call starts from where the first ended. Always reset motor positions before each move.

Mistake 3: Loop running too fast The wait(20, MSEC) in the loop is important. Without it, the loop runs thousands of times per second, derivative values become huge, and control becomes unstable.

Mistake 4: Setting kp too high immediately Start low and increase slowly. An unstable robot is hard to debug.

Next Steps

With PID working, you are ready for:

  • Gyro Turns — using PID specifically for precise heading control
  • Odometry Basics — tracking your robot’s position on the field