Skip to content

Advanced 17: Precise Turns with Encoders

Prerequisites: Advanced 16 (Speed Control Lab) Time: ~75 min


Overview

In Module 15, your square's closure error came mostly from imprecise turns. Time-based turns are unreliable — battery voltage, surface friction, and weight distribution all change the turn rate. This module uses differential encoder kinematics to compute heading from wheel travel, giving you encoder-only turns. Then you'll compare them to IMU-based turns and optionally fuse both.

You'll learn:

  • Differential drive kinematics: how different wheel speeds create rotation
  • Computing heading change from encoder ticks: \(\Delta\theta = \frac{d_R - d_L}{W}\)
  • Building a P-controller for encoder-based turns
  • Comparing encoder turns vs IMU turns (accuracy and failure modes)
  • Why sensor fusion outperforms either alone

Part 1: Differential Drive Geometry (~15 min)

How Turning Works

Your robot has two independently driven wheels separated by a distance \(W\) (the wheelbase or track width). When both wheels travel the same distance, the robot goes straight. When they travel different distances, the robot turns.

         W (wheelbase)
    ◄──────────────►
    ┌───┐         ┌───┐
    │ L │         │ R │
    │   │    ●    │   │
    │   │ center  │   │
    └───┘         └───┘
      dL            dR

   If dR > dL → robot turns LEFT
   If dL > dR → robot turns RIGHT
   If dL = -dR → robot spins in place

The heading change is:

\[\Delta\theta = \frac{d_R - d_L}{W}\]

Where:

  • \(d_L\), \(d_R\) = distance traveled by left and right wheels (mm)
  • \(W\) = wheelbase — center-to-center distance between wheels (mm)
  • \(\Delta\theta\) = heading change (radians)
Radians vs Degrees

The formula gives radians. To convert: degrees = radians × 180 / π.

For a 90° turn: \(\Delta\theta = \frac{\pi}{2} \approx 1.571\) radians.

Task 1 — Measure Your Wheelbase

Measure the distance between the center of the left wheel and the center of the right wheel:

Your wheelbase W: ______ mm

Tip

Measure carefully — a 5% error in wheelbase translates directly to 5% error in turn angle.


Part 2: Encoder-Only Turns (~25 min)

Task 2 — Spin and Measure

Spin the robot in place (opposite wheel directions) and use encoders to measure the heading change:

from picobot import Robot
import time
import math

robot = Robot(encoders=True)

# === YOUR CALIBRATION ===
MM_PER_TICK = ___   # From Module 13
WHEELBASE_MM = ___  # From Task 1
# ========================

SPIN_PWM = 60
DURATION = 1.5  # seconds — adjust to get roughly 360°

robot.encoders.reset()

print("Spinning in place...")
robot.set_motors(SPIN_PWM, -SPIN_PWM)
time.sleep(DURATION)
robot.stop()
time.sleep(0.3)

d_left = robot.encoders.left.ticks * MM_PER_TICK
d_right = robot.encoders.right.ticks * MM_PER_TICK
theta_rad = (d_right - d_left) / WHEELBASE_MM
theta_deg = math.degrees(theta_rad)

print(f"Left:  {d_left:.0f} mm")
print(f"Right: {d_right:.0f} mm")
print(f"Heading change: {theta_deg:.1f}°")

Observation: Does the heading change match what you see? Mark the robot's nose direction before and after, and estimate the angle visually.

Task 3 — P-Controller for Exact Turns

Turn exactly 90° using encoder feedback:

from picobot import Robot
import time
import math

robot = Robot(encoders=True)

MM_PER_TICK = ___
WHEELBASE_MM = ___
TARGET_DEG = 90
TARGET_RAD = math.radians(TARGET_DEG)

KP_TURN = 80   # PWM per radian of error — start here, tune
MIN_PWM = 25   # Minimum to overcome static friction

print(f"Turning {TARGET_DEG}°")
input("Press Enter...")

robot.encoders.reset()

try:
    for i in range(200):  # Timeout: 10 seconds
        d_left = robot.encoders.left.ticks * MM_PER_TICK
        d_right = robot.encoders.right.ticks * MM_PER_TICK
        current_rad = (d_right - d_left) / WHEELBASE_MM
        current_deg = math.degrees(current_rad)

        error_rad = TARGET_RAD - current_rad

        # Are we close enough?
        if abs(error_rad) < math.radians(2):  # Within 2°
            break

        # P-controller: error in radians → PWM
        pwm = KP_TURN * error_rad
        pwm = max(MIN_PWM, min(100, abs(pwm)))
        if error_rad < 0:
            pwm = -pwm

        # Spin in place: left forward, right backward (or vice versa)
        robot.set_motors(int(-pwm), int(pwm))
        time.sleep(0.05)

finally:
    robot.stop()

# Final measurement
time.sleep(0.3)
d_left = robot.encoders.left.ticks * MM_PER_TICK
d_right = robot.encoders.right.ticks * MM_PER_TICK
final_deg = math.degrees((d_right - d_left) / WHEELBASE_MM)

print(f"Target: {TARGET_DEG}°")
print(f"Actual: {final_deg:.1f}°")
print(f"Error:  {final_deg - TARGET_DEG:+.1f}°")

Tune KP_TURN:

KP_TURN Final angle Error Behavior
40
80
120
160

Task 4 — Turn Repeatability

Run the 90° turn 5 times and measure consistency:

Trial Final angle (°) Error (°)
1
2
3
4
5
Std dev
Checkpoint — Encoder Turns Working

You should achieve ±3° accuracy for 90° turns. If you're worse, double-check your wheelbase measurement — it's the most common source of systematic error.


Part 3: Encoder vs IMU Turns (~15 min)

Task 5 — Compare Turn Methods

If you have the IMU working from the Precise Turns tutorial, compare three approaches:

from picobot import Robot
import time
import math

robot = Robot(encoders=True, imu=True)

MM_PER_TICK = ___
WHEELBASE_MM = ___

# --- Method 1: Time-based ---
def turn_time(degrees, pwm=60, duration=0.55):
    robot.set_motors(pwm, -pwm)
    time.sleep(duration)
    robot.stop()

# --- Method 2: Encoder-based ---
def turn_encoder(degrees, kp=80, min_pwm=25):
    target = math.radians(degrees)
    robot.encoders.reset()
    for _ in range(200):
        d_l = robot.encoders.left.ticks * MM_PER_TICK
        d_r = robot.encoders.right.ticks * MM_PER_TICK
        current = (d_r - d_l) / WHEELBASE_MM
        error = target - current
        if abs(error) < math.radians(2):
            break
        pwm = max(min_pwm, min(100, abs(kp * error)))
        if error < 0:
            pwm = -pwm
        robot.set_motors(int(-pwm), int(pwm))
        time.sleep(0.05)
    robot.stop()

# --- Method 3: IMU-based ---
def turn_imu(degrees, kp=3, min_pwm=30):
    robot.imu.reset_heading()
    for _ in range(200):
        robot.imu.update()
        current = robot.imu.heading
        error = degrees - current
        if abs(error) < 2:
            break
        pwm = max(min_pwm, min(100, abs(kp * error)))
        if error < 0:
            pwm = -pwm
        robot.set_motors(int(-pwm), int(pwm))
        time.sleep(0.05)
    robot.stop()

# Run each method 3 times, measure visually

Results (3 trials each, measure actual angle):

Method Trial 1 Trial 2 Trial 3 Mean error Std dev
Time-based
Encoder-based
IMU-based

Failure Modes

Each method has different weaknesses:

Method Works well when... Fails when...
Time-based Battery full, flat surface Battery drains, surface changes, wheels slip
Encoder-based Wheels grip, wheelbase is accurate Wheel slip (smooth floor, fast turns)
IMU-based Gyro calibrated, short-term Gyro drifts over time, vibration
Why Not Both?

Encoders and IMU have complementary failure modes: encoders fail on slip, IMU fails on drift. Combining them (sensor fusion) gives better results than either alone. This is exactly what Advanced 04: Sensor Fusion covers.


Part 4: The Improved Square (~15 min)

Task 6 — Encoder-Turn Square

Repeat the square from Module 15, now with encoder-based turns instead of time-based:

from picobot import Robot
import time
import math

robot = Robot(encoders=True)

MM_PER_TICK = ___
WHEELBASE_MM = ___
SIDE_LENGTH = 300
CRUISE_SPEED = 400
RAMP_DISTANCE = 80
KP_SPEED = ___
KP_TURN = ___

def drive_distance(mm):
    """From Module 15 — trapezoidal profile."""
    pwm_l, pwm_r = 0.0, 0.0
    robot.encoders.reset()
    while True:
        robot.encoders.update()
        avg = (robot.encoders.left.ticks + robot.encoders.right.ticks) / 2
        dist = avg * MM_PER_TICK
        remaining = mm - dist
        if remaining <= 0:
            break
        if dist < RAMP_DISTANCE:
            target = max(CRUISE_SPEED * (dist / RAMP_DISTANCE), CRUISE_SPEED * 0.2)
        elif remaining < RAMP_DISTANCE:
            target = max(CRUISE_SPEED * (remaining / RAMP_DISTANCE), CRUISE_SPEED * 0.15)
        else:
            target = CRUISE_SPEED
        err_l = target - robot.encoders.left.speed_tps
        pwm_l = max(0, min(255, pwm_l + KP_SPEED * err_l))
        err_r = target - robot.encoders.right.speed_tps
        pwm_r = max(0, min(255, pwm_r + KP_SPEED * err_r))
        robot.set_motors(int(pwm_l), int(pwm_r))
        time.sleep(0.05)
    robot.stop()
    time.sleep(0.2)

def turn_right_90():
    """Encoder-based 90° right turn."""
    target = math.radians(-90)  # Right turn = negative
    robot.encoders.reset()
    for _ in range(200):
        d_l = robot.encoders.left.ticks * MM_PER_TICK
        d_r = robot.encoders.right.ticks * MM_PER_TICK
        current = (d_r - d_l) / WHEELBASE_MM
        error = target - current
        if abs(error) < math.radians(2):
            break
        pwm = max(25, min(100, abs(KP_TURN * error)))
        if error < 0:
            pwm = -pwm
        robot.set_motors(int(pwm), int(-pwm))
        time.sleep(0.05)
    robot.stop()
    time.sleep(0.2)

# --- Drive the square ---
print("Encoder-based square: 4 × 300 mm")
input("Mark start. Press Enter...")

for side in range(4):
    print(f"Side {side + 1}...")
    drive_distance(SIDE_LENGTH)
    if side < 3:
        turn_right_90()

print("Done! Measure closure error.")

Compare closure errors:

Method Closure error (mm)
Time-based turns (Module 15)
Encoder-based turns
Checkpoint — The Square Closes

The encoder-based square should close significantly tighter than the time-based version. Typical results: 20-50 mm closure error with time-based turns, 5-15 mm with encoder-based turns.


What You Discovered

Concept What You Learned
Differential kinematics \(\Delta\theta = (d_R - d_L) / W\) — heading from wheel travel
Wheelbase calibration Wheelbase accuracy directly limits turn accuracy
Encoder vs IMU Different sensors, different failure modes
Sensor fusion motivation Combining complementary sensors beats using either alone
Square closure End-to-end test of distance + turn accuracy

What's Next?

You now have precise straight-line driving, accurate turns, and a data-driven tuning workflow. The final module layers encoder speed control under line following for the ultimate payoff: Closed-Loop Line Following — faster, more reliable lap times through cascaded control.


← Back to Advanced Topics