Skip to content

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:

mpremote run clean_pico.py          # optional: wipe old files
mpremote cp -r lib :               # upload libraries

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.

→ Deep dive: Data Analysis Overview


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.

→ Deep dive: IMU Reference

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:

\[\theta(t) = \int_0^t \omega(\tau) \, d\tau\]

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:

\[\theta_{n+1} = \theta_n + \omega_n \cdot \Delta t\]

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:

\[u(t) = K_p \cdot e(t)\]

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.

  1. Make It Move -- discovered that open-loop control fails
  2. Seeing the Line -- learned feedback control intuitively with line sensors
  3. 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:

  1. Define the problem -- time-based turns are unreliable
  2. Choose a sensor -- gyroscope measures rotation directly
  3. Handle the sensor's limitations -- calibrate to remove bias
  4. Apply a control law -- P-control (same as line following)
  5. Define a metric -- average turn error over 5 trials
  6. Test systematically -- sweep Kp values, record data
  7. Select based on evidence -- choose the Kp with lowest error
  8. 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

← Seeing the Line | Labs Overview | Next: State Machines →