Precise Turns
Time: 135 min
Learning Objectives
By the end of this lab you will be able to:
- Read angular velocity data from a MEMS gyroscope and understand what it measures
- Integrate angular velocity into a heading estimate using numerical methods
- Calibrate the gyroscope to eliminate bias drift
- Connect proportional control from line following to gyro-based turning
- Formalize the P-control equation: u(t) = Kp * e(t)
- Use data logging and systematic testing to find an optimal Kp
- Select control parameters based on measured evidence, not guessing
You will go from failed time-based turns to precise, data-tuned gyroscope turns. Along the way you will formalize the proportional control you discovered intuitively in line following and learn the engineering process of metric-driven tuning.
Lab Setup
Connect your Pico via USB, then from the picobot/ directory:
Verify in REPL: from picobot import Robot; Robot() → "Robot ready!"
First time? See the full setup guide.
Why can't we just time the turn?
Cast your mind back to Make It Move. You tried to drive a square by running the motors for a fixed duration. The turns varied by 15 degrees or more, and the robot never came back to where it started. Time-based turns are unreliable because motor speed depends on battery voltage, friction, surface, and temperature.
You solved a similar problem in Seeing the Line by adding sensors and feedback. Line sensors told the robot where the line was, and proportional control kept it centered.
What sensor could measure rotation directly?
Straight-line driving used optical sensors pointed at the ground. What kind of sensor could tell the robot how much it has rotated? Think about what physical quantity changes during a turn.
The answer is a gyroscope -- a sensor that measures angular velocity, or how fast you are spinning. Your robot has one built into its IMU (Inertial Measurement Unit), and today you will learn to use it.
Background: The Experimental Method for Embedded Systems
Engineering tuning is not trial-and-error — it follows a cycle: HYPOTHESIS (predict what \(K_p\) should work) → EXPERIMENT (run N trials under controlled conditions) → DATA (log mean error, std dev, max error) → ANALYSIS (compare against success criteria defined BEFORE testing) → ITERATE. Change ONE variable at a time. Define "success" before you start measuring. If your coefficient of variation (std dev / mean) is below 5%, your results are repeatable.
Part 1: Meet the Gyroscope (~15 min)
What is an IMU?
Your robot has an IMU (Inertial Measurement Unit) -- the same type of sensor found in your smartphone, drones, and aircraft. It contains:
- Accelerometer -- measures linear acceleration (which way is down?)
- Gyroscope -- measures angular velocity (how fast am I spinning?)
Today we focus on the gyroscope.
How Does a MEMS Gyroscope Work?
Your gyroscope is a MEMS (Micro-Electro-Mechanical System) device -- a tiny mechanical structure etched into silicon, smaller than a grain of rice.
MEMS Gyroscope: The Coriolis Effect
Inside the chip, a tiny mass vibrates back and forth (driven by electrostatic forces, like a tuning fork). When the sensor rotates, the Coriolis effect pushes the vibrating mass sideways. Capacitive plates measure this deflection.
The physics: $\(F_{Coriolis} = 2m(\vec{v} \times \vec{\omega})\)$
Where \(m\) is mass, \(\vec{v}\) is the velocity of vibration, and \(\vec{\omega}\) is the angular velocity we want to measure.
The faster the rotation, the stronger the sideways push, the larger the deflection, the bigger the electrical signal.
Read the Raw Data
Upload this script, then pick up the robot and spin it gently by hand:
from picobot import Robot
import time
robot = Robot()
print("Spin the robot by hand and watch the Z-axis values")
print("Z = rotation around the vertical axis")
print()
while True:
gz = robot.imu.gyro_z()
# Visual bar centered at rest
bar_center = 30
bar_pos = bar_center + int(gz / 5)
bar_pos = max(0, min(60, bar_pos))
bar = " " * bar_pos + "█"
print(f"gz = {gz:+7.1f} °/s |{bar:60}|")
time.sleep(0.05)
Spin the robot by hand and fill in what you observe:
| Action | What gz shows |
|---|---|
| Spin left (counter-clockwise) | Positive values (+50 to +200) |
| Spin right (clockwise) | Negative values (-50 to -200) |
| Sitting perfectly still | Should be ~0, but... |
Wait -- It's Not Zero When Still!
Leave the robot perfectly still on the table and watch the reading for a few seconds.
Your observation: gz = ______ degrees/s (it should be 0, but it probably is not)
This small constant offset is called bias. Every gyroscope has one, and it is about to cause us serious trouble.
Checkpoint -- Gyro Responding
Spinning the robot left should show positive gz values (+50 to +200), right should show negative. When still, gz should be close to zero (within about 2 degrees/s). If gz is always exactly 0, check the I2C connection to the IMU.
Stuck?
- gz always reads 0: IMU may not be connected. Try
robot.imu.who_am_i()in the REPL. - Values are huge (>500 degrees/s) when still: Sensor range may be misconfigured. Ask your instructor.
- Values jump randomly: Ensure the robot is on a stable surface.
Background: Integration Amplifies Errors
A gyroscope measures angular velocity (deg/s), not angle. To get heading, you integrate: \(\theta_{new} = \theta_{old} + \omega \times \Delta t\) (Euler integration). The problem: even a tiny bias (0.3 deg/s) accumulates to 18° error per minute. Calibration (measuring bias while stationary and subtracting it) removes this systematic error. Random noise still grows as \(\sqrt{N}\) readings, but calibration eliminates the dominant drift.
Part 2: From Speed to Angle (~15 min)
The Mathematical Problem
The gyroscope tells you angular velocity \(\omega\) (degrees per second) -- how fast you are rotating right now. But you want angular position \(\theta\) (degrees) -- how much you have rotated in total.
The relationship is calculus:
In words: total angle equals the sum of all the tiny rotation speeds over time.
Numerical Integration: The Euler Method
We cannot do continuous calculus on a microcontroller -- we have discrete samples arriving at fixed intervals. So we approximate the integral:
In plain English: new heading = old heading + (rotation speed x time since last reading).
Euler Method Visualization
Imagine rotation speed as a curve over time. The total rotation is the area under that curve. We approximate it by summing thin rectangles: each has width dt and height omega, so its area is the angle traveled in that interval. At 50 Hz (dt = 0.02 s) we get 50 tiny rectangles per second -- close enough to the true curve.
This is the Euler method -- the simplest numerical integration. Better methods exist (Runge-Kutta, Trapezoidal), but Euler works well when the time step is small.
TRY: Basic Integration
from picobot import Robot
import time
robot = Robot()
heading = 0.0
last_time = time.ticks_us()
print("Rotate the robot by hand, watch the heading accumulate")
while True:
now = time.ticks_us()
dt = time.ticks_diff(now, last_time) / 1_000_000 # microseconds to seconds
last_time = now
gz = robot.imu.gyro_z()
heading = heading + (gz * dt)
print(f"Heading: {heading:+7.1f}° (gz = {gz:+6.1f} °/s)")
time.sleep(0.02)
Start with the robot facing forward (heading = 0), rotate it 90 degrees left by hand, and check whether heading shows approximately 90. Then leave the robot perfectly still for 30 seconds.
MEASURE: How Much Drift?
Your observation: Drift after 30 seconds = ______ degrees
The heading changes even though the robot is not moving! This is integration drift -- the bias gets accumulated every frame, forever.
Drift Accumulates Linearly
If bias = 0.5 degrees/s: after 10 s the error is 5 degrees, after 60 s it is 30 degrees, after 10 minutes it reaches 300 degrees -- almost a full rotation!
This is why inertial navigation alone fails over long periods. Submarines surface for GPS fixes, aircraft use GPS+IMU fusion, and your phone combines gyro with magnetometer and accelerometer.
FIX: Calibrate the Bias
The fix is simple: before you start moving, measure the bias while the robot is still, then subtract it from every future reading.
from picobot import Robot
import time
robot = Robot()
# --- CALIBRATION: measure bias while stationary ---
print("Calibrating... DON'T MOVE THE ROBOT!")
readings = []
for i in range(100):
readings.append(robot.imu.gyro_z())
time.sleep(0.02)
bias = sum(readings) / len(readings)
print(f"Measured bias: {bias:+.4f} °/s")
# --- TRACKING (with calibration) ---
heading = 0.0
last_time = time.ticks_us()
print("Now rotate the robot -- drift should be much less!")
while True:
now = time.ticks_us()
dt = time.ticks_diff(now, last_time) / 1_000_000
last_time = now
gz_corrected = robot.imu.gyro_z() - bias # SUBTRACT THE BIAS
heading = heading + (gz_corrected * dt)
print(f"Heading: {heading:+7.1f}°")
time.sleep(0.02)
MEASURE Again
Leave the robot still for 60 seconds and compare:
| Condition | Drift after 60 seconds |
|---|---|
| Without calibration | ______ degrees |
| With calibration | ______ degrees |
Much better! The picobot library wraps this same logic into a single call: robot.imu.calibrate(). From now on, always calibrate at the start of your program.
Why Does Drift Exist at All?
| Error Source | Cause | Behavior |
|---|---|---|
| Bias | Manufacturing imperfections | Constant offset (e.g., reads 0.3 degrees/s when still) |
| Bias drift | Temperature changes | Bias shifts slowly over time |
| Scale factor | Calibration inaccuracy | 90 degree rotation reads as 88 or 92 |
| Noise | Electronic and thermal noise | Random fluctuations around the true value |
Calibration removes the constant bias but cannot eliminate bias drift or noise. For our short turns (a few seconds each), the residual errors are small enough to ignore.
Real-World Sensor Grades
| Grade | Typical Bias | Use Case | Cost |
|---|---|---|---|
| Consumer (your robot) | 1-10 degrees/s | Phones, toys | 1-5 USD |
| Industrial | 0.01-0.1 degrees/s | Drones, robotics | 50-500 USD |
| Tactical | 0.001-0.01 degrees/s | Military, aerospace | 1,000-10,000 USD |
| Navigation | < 0.001 degrees/s | Aircraft, submarines | 10,000+ USD |
Checkpoint -- Calibration Working
After calibration, resting gz should read close to 0.0 (within about 0.5 degrees/s). Drift over 60 seconds should be under a few degrees, compared to tens of degrees without calibration.
Part 3: Same Pattern, Different Sensor (~20 min)
This is the most important section of today's lab. The control technique you already know from line following applies directly to gyroscope turns -- same equation, different sensor.
Remember Line Following?
In Seeing the Line, you wrote a control loop like this:
error = robot.sensors.line.get_error() # How far from center?
correction = Kp * error # How hard to correct?
robot.set_motors(speed + correction, speed - correction)
You found a Kp value that made the robot follow the line smoothly. The key insight was: bigger error means bigger correction.
The Same Equation for Turning
Now consider turning. You have a gyroscope that tells you your current heading, and you want to reach a target angle. The structure is identical:
Line following: error = line_position - center
correction = Kp * error
Gyro turning: error = target_angle - current_angle
power = Kp * error
Can you see why these are the same?
In both cases, you measure where you are, compare it to where you want to be, and apply a correction proportional to the difference. The sensor changes (line sensor vs gyroscope), the actuator changes (steering correction vs turning speed), but the control law is identical.
Formalizing Proportional Control
What you have been doing -- in line following and now in turning -- is proportional control:
Where:
- \(u(t)\) is the control output (motor correction in line following, turning power in gyro turns)
- \(K_p\) is the proportional gain (the tuning knob you adjust)
- \(e(t)\) is the error (difference between where you are and where you want to be)
This is the "P" in PID control. You discovered it intuitively by tuning line following. Now you have the formal equation.
The Same Equation Everywhere
This exact equation controls:
| System | Error signal | Control output |
|---|---|---|
| Line following | line position - center | Steering correction |
| Gyro turning | target angle - current angle | Motor power |
| Cruise control | set speed - actual speed | Throttle adjustment |
| Thermostat | set temperature - room temperature | Heater power |
| Drone altitude | target height - current height | Rotor thrust |
TRY: A Simple Threshold Turn
Before applying P-control, let's try the naive approach: spin at constant speed and stop when the heading reaches the target.
from picobot import Robot
import time
robot = Robot()
robot.imu.calibrate()
def turn_simple(target):
"""Turn using a simple threshold: stop when heading >= target."""
robot.imu.reset_heading()
if target > 0:
robot.set_motors(-80, 80)
else:
robot.set_motors(80, -80)
while True:
robot.imu.update()
if abs(robot.imu.heading) >= abs(target):
break
time.sleep(0.01)
robot.stop()
return robot.imu.heading
input("Press Enter to turn 90° left...")
actual = turn_simple(90)
print(f"Target: 90°, Actual: {actual:.1f}°")
Run it several times and record the results:
| Trial | Target | Actual | Error |
|---|---|---|---|
| 1 | 90 | ______ | ______ |
| 2 | 90 | ______ | ______ |
| 3 | 90 | ______ | ______ |
The robot overshoots. It was spinning at full speed when it crossed 90 degrees, and inertia carried it past the target. You need to slow down before you arrive.
FIX: P-Control Turn
Now apply proportional control. Instead of full speed the whole time, motor power is proportional to the remaining error. When the error is large, spin fast. As you approach the target, slow down naturally:
from picobot import Robot
import time
robot = Robot()
robot.imu.calibrate()
KP = 15 # Start with this, we will tune it later
MIN_POWER = 25 # Minimum to overcome friction
def turn_p_control(target, kp=KP):
"""Turn using P-control: slow down as you approach the target."""
robot.imu.reset_heading()
while True:
robot.imu.update()
error = target - robot.imu.heading
power = kp * error
if abs(power) < MIN_POWER and abs(error) > 1:
power = MIN_POWER if power > 0 else -MIN_POWER
elif abs(error) <= 1:
break # Close enough
power = max(-255, min(255, power))
robot.set_motors(int(-power), int(power))
time.sleep(0.01)
robot.stop()
return robot.imu.heading
input("Press Enter to turn 90° with P-control...")
actual = turn_p_control(90)
print(f"Target: 90°, Actual: {actual:.1f}°")
Run it several times:
| Trial | Target | Actual | Error |
|---|---|---|---|
| 1 | 90 | ______ | ______ |
| 2 | 90 | ______ | ______ |
| 3 | 90 | ______ | ______ |
Compare the two approaches:
| Method | Average error | Consistency |
|---|---|---|
| Simple threshold | ______ degrees | High variance |
| P-control | ______ degrees | Low variance |
The P-control version decelerates smoothly as it approaches the target, dramatically reducing overshoot.
Checkpoint -- P-Control Turn Working
The robot should turn close to 90 degrees and stop smoothly. Run it 3 times -- the results should be consistent (within about 3 degrees). Compare this to both the threshold turn and the time-based turns from Make It Move.
Stuck?
- Robot spins forever: Check that the error sign is correct -- error should become negative once heading passes the target.
- Robot overshoots: Lower Kp or increase MIN_POWER. The robot has inertia.
- Turn angle varies wildly: Recalibrate -- bias changes with temperature.
- Robot vibrates near the target: Kp too high, causing oscillation. Lower it.
Part 4: Data-Driven Tuning (~25 min)
In line following, you found Kp by feel: "this looks smooth, that wobbles too much." But can you prove Kp = 15 is better than Kp = 18? Engineering decisions require measured evidence.
Define a Metric
Before optimizing, you need to decide what "good" means. For turning, the most natural metric is average absolute turn error over multiple trials. For each Kp value, command five 90-degree turns and record how far the actual angle is from 90 degrees each time. The average of those five errors is the metric for that Kp.
Why five trials instead of one?
A single trial could be an outlier -- the wheels slipped, or a vibration disturbed the gyro. Multiple trials reveal the typical performance. More trials give a more reliable estimate.
Systematic Kp Sweep
Test a range of Kp values. The key principle is change only one variable at a time -- keep target angle, surface, and battery level constant.
from picobot import Robot
import time
robot = Robot()
robot.imu.calibrate()
MIN_POWER = 25
TARGET = 90
TRIALS = 5
def turn_p(target, kp):
"""Turn using P-control. Returns actual heading."""
robot.imu.reset_heading()
while True:
robot.imu.update()
heading = robot.imu.heading
error = target - heading
power = kp * error
if abs(power) < MIN_POWER and abs(error) > 1:
power = MIN_POWER if power > 0 else -MIN_POWER
elif abs(error) <= 1:
break
power = max(-255, min(255, power))
robot.set_motors(int(-power), int(power))
time.sleep(0.01)
robot.stop()
return heading
kp_values = [5, 10, 15, 20, 25, 30]
all_results = {}
for kp in kp_values:
errors = []
print(f"\nKp = {kp}:")
for trial in range(TRIALS):
time.sleep(0.5)
actual = turn_p(TARGET, kp)
err = abs(actual - TARGET)
errors.append(err)
print(f" Trial {trial+1}: {actual:.1f}° (error: {err:.1f}°)")
turn_p(-TARGET, kp) # Return to start
time.sleep(0.5)
avg_err = sum(errors) / len(errors)
all_results[kp] = {'avg': avg_err, 'max': max(errors)}
print(f" → Average error: {avg_err:.1f}°")
# Summary
print(f"\n{'Kp':>4} | {'Avg Error':>10} | {'Max Error':>10}")
print("-" * 35)
for kp in kp_values:
r = all_results[kp]
print(f"{kp:4d} | {r['avg']:8.1f}° | {r['max']:8.1f}°")
best_kp = min(all_results, key=lambda k: all_results[k]['avg'])
print(f"\nBest Kp = {best_kp} (lowest average error)")
Record your results:
| Kp | Trial 1 | Trial 2 | Trial 3 | Trial 4 | Trial 5 | Avg Error |
|---|---|---|---|---|---|---|
| 5 | ||||||
| 10 | ||||||
| 15 | ||||||
| 20 | ||||||
| 25 | ||||||
| 30 |
The U-Shaped Curve
Plot average error against Kp (even a rough sketch on paper helps). You should see a U-shaped relationship:
Kp too low: The robot turns slowly, friction dominates, and it often stops short of the target. The correction is too weak to overcome inertia.
Kp too high: The robot spins fast, overshoots the target, and may oscillate back and forth. The correction is so aggressive the system cannot settle.
Kp in the sweet spot: The robot approaches 90 degrees smoothly, decelerates at the right moment, and stops close to the target.
This is the same U-shaped trade-off you saw when tuning Kp for line following -- a universal property of proportional control.
The Goldilocks Problem in Control
- Kp too low = sluggish, undershoot, high error
- Kp too high = oscillation, overshoot, high error
- Kp just right = smooth approach, low error
Finding "just right" is what control engineering is about. You are now doing it with data instead of gut feeling.
Checkpoint -- U-Shaped Curve Visible
Your results table should show error decreasing as Kp increases from low values, reaching a minimum, then increasing again at high Kp. If all errors look similar, widen the Kp range or increase the number of trials.
Log Data to CSV for Analysis
For deeper analysis, log the turn data to a CSV file on the Pico and download it to your PC.
from picobot import Robot, DataLogger
import time
robot = Robot()
robot.imu.calibrate()
KP = 15 # Use your best Kp from the sweep
TARGET = 90
MIN_POWER = 25
logger = DataLogger("turn_data.csv")
logger.start("time_ms", "heading", "error", "power")
robot.imu.reset_heading()
start = time.ticks_ms()
while True:
robot.imu.update()
heading = robot.imu.heading
error = TARGET - heading
power = KP * error
if abs(power) < MIN_POWER and abs(error) > 1:
power = MIN_POWER if power > 0 else -MIN_POWER
elif abs(error) <= 1:
break
power = max(-255, min(255, power))
robot.set_motors(int(-power), int(power))
logger.log(time.ticks_diff(time.ticks_ms(), start), heading, error, power)
time.sleep(0.01)
robot.stop()
logger.stop()
print(f"Final heading: {heading:.1f}°")
print("Download: mpremote cp :turn_data.csv .")
Download to your PC with: mpremote cp :turn_data.csv .
Analyze on Your PC
Simple Python Analysis Script
# analyze_turn.py -- run on your PC, not the Pico
import csv
with open('turn_data.csv', 'r') as f:
data = list(csv.DictReader(f))
errors = [abs(float(row['error'])) for row in data]
print(f"Samples: {len(data)}")
print(f"Final heading: {float(data[-1]['heading']):.1f}°")
print(f"Average |error|: {sum(errors)/len(errors):.2f}°")
print(f"Max |error|: {max(errors):.2f}°")
Stuck?
- "FileNotFoundError" on PC: Make sure you ran
mpremote cp :turn_data.csv .to download the file from the Pico first. - CSV is empty or has only headers: The robot may not have collected data. Check that the turn loop actually ran.
- mpremote not found: Install it with
pip install mpremote. Make sure only one serial connection to the Pico is open.
Part 5: The Perfect Square (~10 min)
You now have calibrated gyro turns with a data-tuned Kp. Time to go back to the challenge that started it all: driving a square.
Compare to Make It Move
from picobot import Robot
import time
robot = Robot()
robot.imu.calibrate()
input("Mark start position with tape. Press Enter to begin...")
for i in range(4):
print(f"Side {i+1}")
robot.forward(speed=80, duration=1.0)
time.sleep(0.3)
robot.turn_degrees(-90) # Gyro P-control right turn
time.sleep(0.3)
robot.stop()
print("Square complete!")
Mark the starting position and orientation with tape. Run the program three times and measure:
| Run | Distance from start (cm) | Final heading error (degrees) | Shape quality (1-5) |
|---|---|---|---|
| 1 | |||
| 2 | |||
| 3 |
Compare to your results from Make It Move. The improvement should be dramatic -- the time-based square was barely recognizable as a square, while the gyro-controlled version should actually close.
You Did It!
Remember the square that was not a square? That problem is now solved.
- Make It Move -- discovered that open-loop control fails
- Seeing the Line -- learned feedback control intuitively with line sensors
- Precise Turns -- formalized P-control, applied it to rotation, tuned it with data
You did not just learn to use a gyroscope. You learned the engineering process: identify a problem, choose a sensor, apply a control law, and tune it with measured evidence.
Wrap-Up
The Engineering Process
Today you followed a process that applies far beyond this robot:
- Define the problem -- time-based turns are unreliable
- Choose a sensor -- gyroscope measures rotation directly
- Handle the sensor's limitations -- calibrate to remove bias
- Apply a control law -- P-control (same as line following)
- Define a metric -- average turn error over 5 trials
- Test systematically -- sweep Kp values, record data
- Select based on evidence -- choose the Kp with lowest error
- Document -- save optimal values for reuse
One Equation, Two Applications
| Application | Error | Kp found by |
|---|---|---|
| Line following | line position - center | Intuition + feel |
| Gyro turning | target angle - current heading | Systematic data sweep |
Both are: \(u(t) = K_p \cdot e(t)\). The data-driven approach is more work upfront, but it produces a defensible result. When someone asks "why did you choose Kp = 15?", you can show the table.
Save Your Optimal Values
# my_config.py -- My robot's calibrated values
LINE_KP = 30 # From line-following tuning
TURN_KP = 15 # From today's Kp sweep
BASE_SPEED = 80
TURN_TOLERANCE = 1 # degrees
What Is Next
In State Machines, you will combine line following and turning into a structured program that can make decisions -- turn left at the first junction, go straight at the second, and so on. The state machine gives your robot a plan; the control loops you have built give it the ability to execute that plan reliably.
Challenges (Optional)
Challenge: Heading-Controlled Straight Driving
Make the robot drive straight using the gyroscope: target heading = 0, correct left/right based on drift. Compare to open-loop driving.
Challenge: Polygons
Modify the square to drive a triangle (120-degree turns), pentagon (72-degree), or hexagon (60-degree). How does cumulative error change with more sides?
Challenge: Speed vs Accuracy
Does optimal Kp change with higher motor speeds? Repeat the Kp sweep with MIN_POWER = 40 and MIN_POWER = 60.
Challenge: Temperature Drift
Calibrate, wait 5 minutes for electronics to warm up, then test turns again. Does accuracy degrade? How often should you recalibrate?
Recap
Gyroscopes measure rotation rate, not angle. Angle is computed by integrating rate over time using the Euler method. Bias causes drift, which calibration reduces but cannot eliminate completely. Proportional control -- the same equation you used in line following -- makes turns precise by slowing down as you approach the target. And data-driven tuning (systematic Kp sweep, measured metrics, evidence-based selection) replaces guessing with engineering.
Key Code Reference
from picobot import Robot, DataLogger
robot = Robot()
robot.imu.calibrate() # Measure and subtract bias (do at startup)
gz = robot.imu.gyro_z() # Raw angular velocity (°/s)
robot.imu.update() # Update heading from gyro (call regularly)
heading = robot.imu.heading # Current heading in degrees
robot.imu.reset_heading() # Reset heading to zero
robot.turn_degrees(90) # Built-in P-control turn (blocking)
logger = DataLogger("data.csv")
logger.start("col1", "col2") # Create CSV with headers
logger.log(val1, val2) # Write a row
logger.stop() # Flush and close