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:
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.