Skip to content

Make It Move

Time: 120 min

Learning Objectives

By the end of this lab you will be able to:

  • Create and configure PWM signals using machine.PWM
  • Control LED brightness, buzzer pitch, and motor speed with duty cycle and frequency
  • Find a motor's dead zone experimentally
  • Characterize motor behavior with logged data and host-side plotting
  • Understand differential drive kinematics (how two-wheeled robots steer)
  • Build a closed-loop distance controller using the ultrasonic sensor
  • Explain the difference between open-loop and closed-loop control
  • Understand why wheel encoders improve motor control

You will build PWM signals from scratch, systematically characterize your motors with data logging, then build your first closed-loop controller using the ultrasonic sensor.

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.


Introduction

In Robot Unboxing you tried to drive a square — and watched the robot fail to return to its starting position. This lab explains why that happened and shows you how to fix it.

You'll start with PWM — the technique that lets a digital pin control analog power — then systematically measure your motors' behavior with data logging, and finally build your first closed-loop controller using the ultrasonic sensor. By the end, you'll understand the difference between hoping your robot does the right thing and knowing it does.


Why This Lab Matters

PWM is the most important output technique in embedded systems — it controls LED brightness, buzzer pitch, motor speed, and servo position from a digital pin that can only be HIGH or LOW. But controlling an actuator is only half the story. This lab bridges from "how to drive a motor" to "how to drive a motor reliably" — the leap from open-loop to closed-loop control that underpins all of robotics.


Part 1: PWM — Controlling Power with a Digital Pin (25 min)

A digital GPIO pin can only output 3.3V (HIGH) or 0V (LOW). There's no "half voltage." So how do you dim an LED or slow down a motor?

The answer is: you don't. You switch the pin on and off rapidly. This is Pulse Width Modulation (PWM) — and it's the most important output technique in embedded systems.

Two parameters define a PWM signal:

  • Frequency — how fast it switches (Hz). 1000 Hz = 1000 on/off cycles per second.
  • Duty cycle — what fraction of each cycle is HIGH. 0% = always off, 100% = always on.
Why 16-bit Duty Cycle?

MicroPython's duty_u16() takes values from 0 to 65535 (16-bit range). This gives fine-grained control: duty_u16(32768) = 50%, duty_u16(655) ≈ 1%. The 16-bit range is the same across all MicroPython platforms, so your code is portable.

→ Deep dive: PWM Fundamentals

How PWM Works in Hardware

Your Python code calls pwm.freq(1000) and pwm.duty_u16(32768) — but what actually happens inside the RP2350?

The chip has dedicated PWM hardware — 12 independent PWM "slices," each with two channels. When you configure PWM:

  1. A counter counts from 0 up to a "wrap" value (which sets the frequency)
  2. A comparator checks if the counter is below the duty threshold
  3. If below → pin HIGH. If above → pin LOW.

This runs entirely in hardware — the CPU doesn't toggle the pin. Once configured, the PWM signal continues perfectly even while your Python code is doing something else (reading sensors, printing, sleeping). That's why PWM is rock-solid: hardware timing, not software timing.

Each PWM slice shares a frequency between its two channels (A and B), but each channel has its own duty cycle. Your robot uses two slices — one per motor. The set_motors() library function configures the right pins and duty cycles for you.

⚡Hands-on tasks

✅ Task 1 — From Blinking to Light

The onboard LED is connected to the "LED" pin. Run this and watch what happens at each stage:

from machine import Pin, PWM
import time

led_pwm = PWM(Pin("LED"))
led_pwm.duty_u16(32768)    # 50% duty cycle for all three stages

# Stage 1: SLOW — you clearly see ON/OFF blinking
print("10 Hz — watch the blinks")
led_pwm.freq(10)
time.sleep(4)

# Stage 2: FASTER — flicker starts to blur
print("50 Hz — can you still see individual blinks?")
led_pwm.freq(50)
time.sleep(4)

# Stage 3: FAST — looks like constant dim light
print("1000 Hz — appears to be steady light")
led_pwm.freq(1000)
time.sleep(4)

led_pwm.duty_u16(0)
print("Done! The LED never dimmed — it was always fully ON or fully OFF.")

From blinking to continuous — same signal, just faster switching

The LED never dims. It's always fully ON or fully OFF — the exact same 50% duty signal at all three stages. What changes is only the frequency. So why does it look dimmer at 1000 Hz?

How "Brightness" Actually Works

There's no physical averaging happening in the LED circuit. The LED is either at full current or zero current — switching thousands of times per second. But your eye has a response time (~50 ms). When the switching is faster than your eye can track, your visual system averages the light over time and perceives a steady brightness proportional to the fraction of time the LED is ON.

This is perception-based averaging: the LED doesn't produce "half brightness" — your brain does.

Now here's the key: the duty cycle controls how much of each cycle the LED is ON, so it controls the perceived brightness. But frequency must be high enough for the illusion to work — below ~50 Hz, you see flicker.

Parameter Effect on LED
Duty cycle Controls perceived brightness (fraction of time ON)
Frequency Must be high enough (>50 Hz) to avoid visible flicker

PWM sweep — duty cycle changes continuously, watch the average voltage follow

This is very different from what happens in a motor, where the coil's inductance physically smooths the current — that's real electrical averaging, not a perception trick. We'll see that in Part 4.

✅ Task 2 — Duty Cycle Sweep: The "Breathing" LED

Now keep the frequency high (eye can't see blinking) and sweep the duty cycle. The LED appears to smoothly brighten and dim — but remember, it's just switching ON/OFF faster or slower within each cycle:

from machine import Pin, PWM
import time

led_pwm = PWM(Pin("LED"))
led_pwm.freq(1000)  # Fast enough — no visible flicker

# Breathe: dark → bright → dark
for duty in range(0, 65536, 655):      # 0% → 100%
    led_pwm.duty_u16(duty)
    time.sleep_ms(20)

for duty in range(65535, -1, -655):    # 100% → 0%
    led_pwm.duty_u16(duty)
    time.sleep_ms(20)

led_pwm.duty_u16(0)
Think about it

At 50% duty cycle, is the LED actually at half brightness? No — it's at full brightness half the time and zero the other half. Your eye averages it. A motor's inductance does the same thing physically — it smooths the current. The result looks the same (proportional control), but the physics is completely different.

✅ Task 3 — Buzzer: Frequency Becomes Sound

The buzzer is on GP22. The physics here is completely different from the LED.

How a Piezo Buzzer Works

Your robot's buzzer is a piezoelectric disc — a ceramic material that physically bends when voltage is applied. Apply 3.3V → it bends one way. Apply 0V → it relaxes back. Switch rapidly → it vibrates → air moves → you hear sound.

The frequency of this vibration determines pitch (what note you hear). The amplitude of the vibration determines loudness (how much air it pushes).

Here's how duty cycle affects loudness: at 50% duty, the disc spends equal time bent and relaxed — maximum displacement, maximum air movement, loudest sound. At 1% duty, it snaps up for a tiny instant then stays relaxed for 99% of the cycle — barely moves, very quiet. At 99% duty, it stays bent almost the whole time and only briefly relaxes — also barely moves.

Duty Cycle What Happens to the Disc Loudness
1% Tiny snap, long rest Very quiet
25% Short pulse, longer rest Moderate
50% Equal bend/relax — max movement Loudest
75% Long bend, short relax Moderate
99% Stays bent, tiny relax Very quiet

Test it yourself — sweep frequency from low rumble to ultrasonic:

from machine import Pin, PWM
import time

buzzer = PWM(Pin(22))
buzzer.duty_u16(32768)  # 50% duty — maximum loudness

# Sweep from 100 Hz to 20 kHz — listen to when you stop hearing it
print("Frequency sweep: 100 Hz → 20 kHz")
print("Human hearing range is roughly 20 Hz to 20,000 Hz\n")

freq = 100
while freq <= 20000:
    buzzer.freq(freq)
    print(f"  {freq:>5} Hz — can you hear it?")
    time.sleep_ms(200)
    freq = int(freq * 1.15)  # ~15% steps = smooth exponential sweep

buzzer.duty_u16(0)  # Silence
print("\nAt what frequency did the sound disappear?")
print("Most people stop hearing around 15-17 kHz.")

Now sweep duty cycle smoothly at fixed pitch:

from machine import Pin, PWM
import time

buzzer = PWM(Pin(22))
buzzer.freq(440)  # A4 note — pitch stays constant

# Smooth sweep from 0% to 100% duty cycle
print("Duty cycle sweep at 440 Hz — listen to loudness change")
print("Loudest at 50%, quiet at both extremes\n")

for duty_percent in range(1, 100, 2):
    buzzer.duty_u16(int(65535 * duty_percent / 100))
    print(f"  {duty_percent:>2}% {'█' * (duty_percent // 4)}")
    time.sleep_ms(100)

buzzer.duty_u16(0)
print("\nNotice: pitch stayed at 440 Hz the whole time!")
print("Duty cycle controls loudness, not pitch.")

Notice: the pitch stays at 440 Hz throughout — duty cycle doesn't change it. But loudness peaks at 50% and drops at both extremes. You may also notice the timbre (harshness) changes — narrow pulses create a sharper, buzzier sound because they contain more harmonics.

PWM frequency becomes pitch — duty cycle controls loudness and waveform shape

Play a scale:

from machine import Pin, PWM
from time import sleep_ms

buzzer = PWM(Pin(22))

#         C4   D4   E4   F4   G4   A4   B4   C5
notes = [262, 294, 330, 349, 392, 440, 494, 523]

for note in notes:
    buzzer.freq(note)
    buzzer.duty_u16(32768)  # 50% for max volume
    sleep_ms(200)
    buzzer.duty_u16(0)      # Brief silence between notes
    sleep_ms(50)

buzzer.duty_u16(0)
Three Actuators, Three Different Physics
Actuator Frequency controls Duty cycle controls How averaging works
LED Must be >50 Hz (no flicker) Perceived brightness Your eye averages (perception)
Buzzer Pitch (what note) Loudness (peaks at 50%) No averaging — disc vibrates at PWM frequency
Motor Audible whine below ~5 kHz Speed / torque Inductance smooths current (physical)

Same PWM hardware, three completely different physical responses. Understanding the physics of each actuator tells you which parameter matters and why.

Extra: PWM Can Create Analog Signals

If you change the duty cycle fast enough — for example, following a sine pattern — and pass the signal through a low-pass filter (like a capacitor or the motor's own inductance), the output becomes a smooth analog waveform. This is how cheap DACs (Digital-to-Analog Converters) work: fast PWM + filtering = analog output from a digital pin.

PWM with sinusoidally varying duty cycle produces a sine wave after filtering

✅ Task 4 — Motor PWM: Find the Dead Zone

Now let's apply PWM directly to a motor. The picobot library's set_motors() does this internally, but let's see what's really happening:

from picobot import Robot
import time

robot = Robot()

print("Sweeping motor speed from 0 to 120...")
print("Watch carefully — when does the wheel START moving?")

for speed in range(0, 121, 5):
    robot.set_motors(speed, 0)  # Left motor only
    time.sleep(0.4)
    print(f"  Speed: {speed}")

robot.stop()

Record when each motor starts moving:

Motor First movement at speed This is the "dead zone"
Left 0 to _____
Right 0 to _____

Now test the right motor:

for speed in range(0, 121, 5):
    robot.set_motors(0, speed)  # Right motor only
    time.sleep(0.4)
    print(f"  Speed: {speed}")
robot.stop()

✅ Task 5 — Motor PWM Frequency: Whine, Hum, Silence

In Task 4 you changed the motor's duty cycle (speed). Now let's change the frequency — how fast the PWM switches. Listen carefully:

from picobot import Robot
import time

robot = Robot()

SPEED = 80  # Keep speed constant

print("Listen to the motor at different PWM frequencies!")
print("Hold the robot so wheels spin freely.\n")

for freq in [100, 250, 500, 1000, 2000, 5000, 10000, 20000]:
    robot.motors.set_freq(freq)
    robot.set_motors(SPEED, SPEED)
    print(f"  {freq:>5} Hz — listen...")
    time.sleep(1.5)
    robot.stop()
    time.sleep(0.3)

robot.stop()
robot.motors.set_freq(1000)  # Restore default

Record what you hear and feel:

Frequency Sound Motor Feel
100 Hz
500 Hz
1000 Hz (default)
5000 Hz
20000 Hz

What you should notice:

  • 100–500 Hz: Loud buzzing/whining — you can hear each PWM pulse as the motor coil vibrates
  • 1000 Hz: Faint hum — the default is a compromise between noise and efficiency
  • 5000–20000 Hz: Silent or nearly silent — above human hearing range
  • The motor may feel slightly different at very low or very high frequencies
Why Not Always Use 20 kHz?

Higher frequency means the MOSFET transistors in the H-bridge switch more often. Each switch wastes a tiny amount of energy as heat (switching losses). At 20 kHz, that adds up. Also, at very high frequencies the PWM pulses become so short that the motor's inductance doesn't have time to build up current — the effective torque drops, especially at low duty cycles.

The default 1000 Hz is a practical compromise: low enough for good efficiency, high enough that the whine isn't too annoying. Professional motor controllers use 16–20 kHz (just above hearing) with carefully matched gate drivers to minimize switching losses.

Now test if frequency affects the dead zone:

from picobot import Robot
import time

robot = Robot()

for freq in [500, 1000, 5000, 20000]:
    robot.motors.set_freq(freq)
    print(f"\n--- {freq} Hz ---")

    for speed in range(20, 81, 10):
        robot.set_motors(speed, 0)  # Left motor only
        time.sleep(0.5)
        moving = "✓ moving" if speed > 40 else "  check..."
        print(f"  Speed {speed:>3}: {moving}")

    robot.stop()
    time.sleep(0.3)

robot.stop()
robot.motors.set_freq(1000)  # Restore default
Frequency Dead Zone Starts Moving At
500 Hz speed _____
1000 Hz speed _____
5000 Hz speed _____
20000 Hz speed _____
What's Happening Physically

At low frequencies (e.g. 500 Hz), each pulse is long enough for the motor coil to fully energize — you get strong current pulses that can overcome static friction. At very high frequencies, the pulses are so short that current barely ramps up before the next off-cycle. The motor's inductance acts as a low-pass filter: it smooths high-frequency PWM into a lower average current. This is why the dead zone may shift at different frequencies.

Checkpoint — PWM Understood

You've created PWM signals from scratch and used them to control three different actuators: LED (duty cycle → brightness), buzzer (frequency → pitch), and motor (duty cycle → speed). You've also explored how PWM frequency affects motor behavior — noise, efficiency, and dead zone. The motor has a dead zone where PWM is too low to overcome friction — this is a physical reality, not a bug.

Prediction Challenge

Before running each test, predict the outcome. Then verify.

Setting Your Prediction Actual Result
LED at duty_u16(0)
LED at duty_u16(32768) (50%)
Buzzer at freq(100)
Buzzer at freq(4000)
Motor at speed 30 (below dead zone)
Motor at speed 30 vs speed 120

Background: Power System Awareness

Motors are the biggest power consumers on your robot. When motors draw current, battery voltage drops (\(V_{actual} = V_{battery} - I \times R_{internal}\)), which can cause sensor glitches, ADC spikes, and even MCU resets. If your robot behaves erratically only when motors are running, suspect electrical noise before rewriting your code.

→ Deep dive: Robot Electronics

Part 2: Motor Testing with a Guide Line (25 min)

The square failed in Week 1 because the motors aren't matched — but we couldn't measure how much they differ because the robot just veered off into the void. We need the robot to drive straight first, and for that we need feedback.

The Idea: A Paper Track

Draw or print a thick black line (about 2 cm wide) down the middle of an A4 paper. Place it on your desk. The robot drives along this line — as long as the line sensor sees black, the robot is on track. When it drifts off, the sensor sees white.

A4 paper on desk:

    ░░░░░░██████░░░░░░
    ░░░░░░██████░░░░░░     Robot drives this way
    ░░░░░░██████░░░░░░          ↓↓↓
    ░░░░░░██████░░░░░░
    ░░░░░░██████░░░░░░     Line sensor under robot
    ░░░░░░██████░░░░░░     sees black = on track
    ░░░░░░██████░░░░░░     sees white = drifted off!
    ░░░░░░██████░░░░░░

This gives you an instant, measurable answer to "how straight does the robot drive?" — count how many milliseconds it stays on the line before drifting off. Longer = straighter.

Making the Track
  • Option A: Print the straight_line.pdf from the lab materials
  • Option B: Use black electrical tape (~2 cm wide) on white paper
  • Option C: Draw a thick line with a wide marker

The line should be straight, about 2 cm wide, and at least 20 cm long (the length of A4).

Run Without USB Cable!

The USB cable drags the robot sideways, changing the drift every run. For these tasks, save the script to the Pico and run on battery power:

mpremote cp your_script.py :main.py

Then unplug USB and press the reset button (or power cycle). The Pico runs main.py automatically on boot. Results are saved to CSV files on the Pico — download them after reconnecting USB:

mpremote cp :drift_test.csv .

⚡Hands-on tasks

✅ Task 6 — Open-Loop vs Closed-Loop on the Line

Place the paper track on your desk. This task runs two tests back-to-back so you can see the difference immediately.

Test 1: Open-loop — equal speed to both motors, no sensor feedback. The robot will drift off the line within seconds.

Test 2: Closed-loop — same speed, but the sensors steer the robot back when it starts drifting.

from picobot import Robot
import time

robot = Robot()

# Adjust these based on what works on your paper track!
SPEED = 80          # Forward speed
TURN_SPEED = -80    # Correction: reverse the other motor to pivot sharply

# ── Test 1: Open-loop (no feedback) ──
print("=" * 40)
print("TEST 1: Open-Loop — no sensor feedback")
print("The robot will drift off the line!")
print("=" * 40)

for countdown in range(5, 0, -1):
    robot.set_leds((255, 255, 0))
    time.sleep(0.5)
    robot.leds_off()
    time.sleep(0.5)
robot.set_leds((255, 0, 0))  # Red = open-loop

robot.set_motors(SPEED, SPEED)  # Equal speed, hope for the best
start = time.ticks_ms()

while time.ticks_diff(time.ticks_ms(), start) < 3000:
    reading = robot.sensors.line.read_raw()[1]
    elapsed = time.ticks_diff(time.ticks_ms(), start)
    if reading != 0:  # Lost the line
        robot.stop()
        print(f"  Drifted off after {elapsed} ms!")
        break
    time.sleep_ms(10)
else:
    robot.stop()
    print(f"  Stayed on for 3000 ms (lucky!)")

robot.stop()
robot.leds_off()
time.sleep(2)

# ── Test 2: Closed-loop (sensor feedback) ──
print()
print("=" * 40)
print("TEST 2: Closed-Loop — sensor steers back")
print("Watch — it corrects itself!")
print("=" * 40)

for countdown in range(5, 0, -1):
    robot.set_leds((255, 255, 0))
    time.sleep(0.5)
    robot.leds_off()
    time.sleep(0.5)
robot.set_leds((0, 255, 0))  # Green = closed-loop

start = time.ticks_ms()

while time.ticks_diff(time.ticks_ms(), start) < 3000:
    readings = robot.sensors.line.read_raw()
    left_sees = readings[1] == 0   # 0 = black = on line
    right_sees = readings[2] == 0

    if left_sees and right_sees:
        # Both sensors on line — go straight at full speed
        robot.set_motors(SPEED, SPEED)
    elif left_sees:
        # Drifting right — slow right motor to steer left
        robot.set_motors(TURN_SPEED, SPEED)
    elif right_sees:
        # Drifting left — slow left motor to steer right
        robot.set_motors(SPEED, TURN_SPEED)
    else:
        # Lost the line completely — stop
        robot.stop()

    time.sleep_ms(10)

robot.stop()
robot.set_leds((0, 0, 255))  # Blue = done

print(f"\n  Closed-loop stayed on the line for 5 seconds!")
print("  Same motors, same battery, same surface.")
print("  The only difference: sensor feedback.")
Tuning SPEED and TURN_SPEED

On a narrow paper line, the robot needs to pivot sharply to stay on. A negative TURN_SPEED reverses one motor — this makes the robot spin in place instead of taking a wide curve. Try different values:

TURN_SPEED Correction style
40 Gentle curve — works on wide tracks
0 One wheel stops — medium turn
-80 Pivot in place — works on narrow paper lines

If the robot overshoots the line and oscillates, reduce SPEED.

Checkpoint — Open-Loop vs Closed-Loop

The open-loop test drifted off in seconds. The closed-loop test stayed on for the full 5 seconds — same motors, same battery, same surface. The only difference: the sensor tells the robot where it is, and the code acts on that information. This is the fundamental idea behind all control systems.

Notice the Jerkiness

The closed-loop robot lurches left, right, left, right. Every correction is full strength regardless of how far off the line it is. What if the correction were proportional to the error? Smaller drift → smaller correction? You'll discover this in Seeing the Line.

Why Not Just Calibrate the Motors?

You might think: "just find the right speed offset so both motors match." But motor mismatch changes with speed, battery voltage, surface friction, temperature, and tire wear. An offset that works at speed 60 won't work at speed 100. An offset that works on tile won't work on carpet. Calibration is a losing battle — feedback always wins over open-loop calibration. That's why real systems use sensors, not lookup tables.

Plotting on Your Computer (not the Pico!)

From here on, some tasks produce CSV data files on the Pico. You download them and plot on your computer using Python + matplotlib.

First time? Follow the Host Plotting Setup Guide to install Python and matplotlib on your computer.

For each plot: 1. Download the CSV: mpremote cp :filename.csv . 2. Save the plot script as a .py file (e.g. plot_bangbang.py) 3. Run it: python plot_bangbang.py (or python3 on some systems) 4. A window opens with the chart. Close it to continue.

✅ Task 7 — Log the Bang-Bang Controller (Data Logging)

Run the closed-loop line follower again, but this time log what the controller does — every sensor reading and every motor command, timestamped. This is your first experience with the log on device → download → plot on PC workflow that you'll use throughout the course.

Why Log and Plot?

In embedded systems, "it seems to work" is not engineering. Logging data lets you: - See patterns invisible to the naked eye (how often does it correct? how long does it stay centered?) - Compare before and after changes quantitatively - Share results with others (a plot is worth a thousand "it felt jerky")

This workflow — log on device → download → plot on PC — is standard practice in industry.

from picobot import Robot, DataLogger
import time

robot = Robot()
logger = DataLogger("bangbang.csv")

SPEED = 80
TURN_SPEED = -80
MAX_TIME = 5000  # Safety timeout

print("Bang-Bang Logger — place robot on H-track start line")
print("Robot follows the line and stops at the end line.\n")

logger.start("time_ms", "state")

for countdown in range(5, 0, -1):
    robot.set_leds((255, 255, 0))
    time.sleep(0.5)
    robot.leds_off()
    time.sleep(0.5)
robot.set_leds((0, 255, 0))

start = time.ticks_ms()
finished = False

while time.ticks_diff(time.ticks_ms(), start) < MAX_TIME:
    readings = robot.sensors.line.read_raw()
    elapsed = time.ticks_diff(time.ticks_ms(), start)

    # Detect end line: ALL 4 sensors see black
    if readings[0] == 0 and readings[1] == 0 and readings[2] == 0 and readings[3] == 0:
        if elapsed > 300:  # Ignore start line
            robot.stop()
            finished = True
            logger.log(elapsed, 3)  # state 3 = finished
            print(f"  Reached end line in {elapsed} ms!")
            break

    # Bang-bang line following
    left_sees = readings[1] == 0
    right_sees = readings[2] == 0

    if left_sees and right_sees:
        robot.set_motors(SPEED, SPEED)
        state = 0   # Centered
    elif left_sees:
        robot.set_motors(TURN_SPEED, SPEED)
        state = -1  # Steering left
    elif right_sees:
        robot.set_motors(SPEED, TURN_SPEED)
        state = 1   # Steering right
    else:
        robot.stop()
        state = 2   # Lost

    logger.log(elapsed, state)
    time.sleep_ms(10)

robot.stop()
if not finished:
    print(f"  Lost the line or timed out!")
logger.stop()
robot.set_leds((0, 0, 255))
print("\nDownload and plot:")
print("  mpremote cp :bangbang.csv .")

Download and plot:

mpremote cp :bangbang.csv .

Save as plot_bangbang.py and run on your host PC (python plot_bangbang.py):

import csv
import matplotlib.pyplot as plt
from matplotlib.patches import Patch

times, states = [], []
with open("bangbang.csv") as f:
    for row in csv.DictReader(f):
        times.append(int(row["time_ms"]))
        states.append(int(row["state"]))

total = len(states)
left_count = sum(1 for s in states if s == -1)
centered_count = sum(1 for s in states if s == 0)
right_count = sum(1 for s in states if s == 1)
lost_count = sum(1 for s in states if s == 2)
finished = 3 in states

duration_sec = (times[-1] - times[0]) / 1000
transitions = sum(1 for i in range(1, len(states)) if states[i] != states[i-1])

print(f"Left: {100*left_count//total}%  Centered: {100*centered_count//total}%  "
      f"Right: {100*right_count//total}%  Lost: {100*lost_count//total}%")
print(f"{'Finished!' if finished else 'Did not finish'} in {times[-1]} ms")
print(f"Corrections: {transitions/duration_sec:.0f} Hz")

if left_count > right_count * 1.5:
    print(f"→ Robot drifts RIGHT")
elif right_count > left_count * 1.5:
    print(f"→ Robot drifts LEFT")

# Plot: timeline + balance bar
colors = {-1: 'dodgerblue', 0: 'limegreen', 1: 'tomato', 2: 'black', 3: 'gold'}
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 3.5),
                                gridspec_kw={'height_ratios': [3, 1]})

for i in range(len(times) - 1):
    ax1.axvspan(times[i], times[i+1], color=colors.get(states[i], 'gray'), alpha=0.7)
ax1.set_yticks([])
ax1.set_xlabel("Time (ms)")
ax1.set_title(f"Bang-Bang Controller — {'Finished' if finished else 'Lost'} in {times[-1]} ms")

ax2.barh([''], [left_count], color='dodgerblue')
ax2.barh([''], [centered_count], left=[left_count], color='limegreen')
ax2.barh([''], [right_count], left=[left_count + centered_count], color='tomato')
if lost_count > 0:
    ax2.barh([''], [lost_count], left=[left_count + centered_count + right_count], color='black')

fig.legend(handles=[
    Patch(color='dodgerblue', label=f'Left {100*left_count//total}%'),
    Patch(color='limegreen', label=f'Center {100*centered_count//total}%'),
    Patch(color='tomato', label=f'Right {100*right_count//total}%'),
    Patch(color='black', label=f'Lost {100*lost_count//total}%'),
    Patch(color='gold', label='Finished'),
], loc='lower center', ncol=5, fontsize=8)

plt.tight_layout(rect=[0, 0.08, 1, 1])
plt.savefig("bangbang_analysis.png", dpi=150)
plt.show()

What to look for in your plot:

  • Timeline (top): Is it mostly green (centered) or a constant mix of blue/red? If it's alternating rapidly, that's the jerkiness you feel — the robot is always correcting.
  • Balance bar (bottom): Is it symmetric? If one side dominates, that motor is faster — the robot keeps drifting that way. The balance bar is your robot's "motor fingerprint."
  • Correction rate (printed in console): How many corrections per second? Bang-bang is typically 30-60 Hz. In the next lab, P-control should bring this down significantly.

This data proves bang-bang control is jerky by design — it's always full correction or nothing. In Seeing the Line, you'll replace this with proportional control and log that too, so you can compare the plots side by side.

✅ Task 8 — Speed vs Balance (Does Calibration Hold?)

Does the motor balance stay the same at different speeds? Run the bang-bang controller at multiple speeds and compare.

Setup: the H-track

Use a paper with a horizontal line and two vertical lines at each end — shaped like the letter H. The robot starts on one vertical line, follows the horizontal line, and stops when it hits the other vertical line (all 4 sensors see black = end detected).

     ║                    ║
     ║                    ║
     ║════════════════════║    ← robot follows this line
     ║                    ║
     ║  START             ║  STOP (all sensors black)

How the test works: 1. Yellow blink = pick up the robot and place it on the START line 2. Green = running. The robot follows the line using bang-bang control 3. When all 4 sensors see black (the STOP line), the robot stops and records the travel time 4. If the robot loses the line or times out (5s), it's marked as "lost" 5. After all speeds, LEDs turn blue — download the CSV

Try including a speed that's too fast — you'll see it lose the line, and the plot shows it clearly.

from picobot import Robot, DataLogger
import time

robot = Robot()

TURN_SPEED = -80
SPEEDS = [70, 80, 100, 120]  # Include a fast one to see it fail!
MAX_TIME = 3000  # Safety timeout (ms)

print("Speed vs Balance Test")
print("Using the H-track: robot stops when it hits the end line.")
print("Between speeds: yellow blink = place robot on start line\n")

logger = DataLogger("speed_balance.csv")
logger.start("speed", "time_ms", "state", "travel_time")

for speed in SPEEDS:
    # Wait for repositioning — yellow blink = place robot on line
    print(f"\n  Next: speed {speed} — place robot on START line!")
    for countdown in range(5, 0, -1):
        robot.set_leds((255, 255, 0))
        time.sleep(0.5)
        robot.leds_off()
        time.sleep(0.5)

    robot.set_leds((0, 255, 0))  # Green = go
    print(f"  Running at speed {speed}...", end="")
    start = time.ticks_ms()
    finished = False

    while time.ticks_diff(time.ticks_ms(), start) < MAX_TIME:
        readings = robot.sensors.line.read_raw()
        elapsed = time.ticks_diff(time.ticks_ms(), start)

        # Detect end line: ALL sensors see black = crossed the stop line
        if readings[0] == 0 and readings[1] == 0 and readings[2] == 0 and readings[3] == 0:
            if elapsed > 300:  # Ignore the start line (first 300ms)
                robot.stop()
                finished = True
                print(f" reached end in {elapsed} ms!")
                logger.log(speed, elapsed, 3, elapsed)  # state 3 = finished
                break

        # Normal bang-bang line following
        left_sees = readings[1] == 0
        right_sees = readings[2] == 0

        if left_sees and right_sees:
            robot.set_motors(speed, speed)
            state = 0
        elif left_sees:
            robot.set_motors(TURN_SPEED, speed)
            state = -1
        elif right_sees:
            robot.set_motors(speed, TURN_SPEED)
            state = 1
        else:
            robot.stop()
            state = 2

        logger.log(speed, elapsed, state, 0)
        time.sleep_ms(10)

    robot.stop()

    if not finished:
        elapsed = time.ticks_diff(time.ticks_ms(), start)
        print(f" LOST the line or timed out ({elapsed} ms)")
        logger.log(speed, elapsed, 2, -1)  # travel_time=-1 = failed

    time.sleep(0.5)

logger.stop()
robot.set_leds((0, 0, 255))
print("\nDone! Download:")
print("  mpremote cp :speed_balance.csv .")

Download and plot — save as plot_speed_balance.py:

mpremote cp :speed_balance.csv .
import csv
import matplotlib.pyplot as plt
from matplotlib.patches import Patch

# Read all data from one CSV
data = {}  # speed → list of (time_ms, state)
with open("speed_balance.csv") as f:
    for row in csv.DictReader(f):
        speed = int(row["speed"])
        time_ms = int(row["time_ms"])
        state = int(row["state"])
        if speed not in data:
            data[speed] = ([], [])
        data[speed][0].append(time_ms)
        data[speed][1].append(state)

speeds = sorted(data.keys())
num = len(speeds)
colors = {-1: 'dodgerblue', 0: 'limegreen', 1: 'tomato', 2: 'black', 3: 'gold'}

fig, axes = plt.subplots(num, 1, figsize=(10, 1.5 * num + 1), sharex=True)
if num == 1:
    axes = [axes]

for idx, speed in enumerate(speeds):
    times, states = data[speed]
    total = len(states)
    left = sum(1 for s in states if s == -1)
    center = sum(1 for s in states if s == 0)
    right = sum(1 for s in states if s == 1)
    lost = sum(1 for s in states if s == 2)

    # Timeline
    ax = axes[idx]
    for i in range(len(times) - 1):
        ax.axvspan(times[i], times[i+1], color=colors.get(states[i], 'gray'), alpha=0.7)
    ax.set_yticks([])
    ax.set_ylabel(f"{speed}", fontsize=12, rotation=0, labelpad=30,
                  color='red' if 100*lost//total > 10 else 'black')

    # Summary text
    summary = f"L={100*left//total}% C={100*center//total}% R={100*right//total}%"
    if lost > 0:
        summary += f" Lost={100*lost//total}%"
    ax.text(0.99, 0.5, summary, transform=ax.transAxes, ha='right', va='center',
            fontsize=8, bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.8))

    print(f"Speed {speed}: {summary}")

axes[-1].set_xlabel("Time (ms)")
fig.suptitle("Bang-Bang at Different Speeds (PWM)", fontsize=14)
fig.legend(handles=[
    Patch(color='dodgerblue', label='Steer Left'),
    Patch(color='limegreen', label='Centered'),
    Patch(color='tomato', label='Steer Right'),
    Patch(color='black', label='Lost'),
    Patch(color='gold', label='Finished'),
], loc='lower center', ncol=5, fontsize=8)
plt.tight_layout(rect=[0, 0.06, 1, 0.95])
plt.savefig("speed_balance.png", dpi=150)
plt.show()

What to look for: - The balance shifts between speeds — the left/right ratio changes. A correction that works at 60 doesn't work at 100. - Black at high speed = the robot lost the line. Bang-bang can't correct fast enough at this speed — the title turns red. - The sweet spot — the highest speed with no black. Above that, you need a smarter controller.

Checkpoint — Speed-Dependent Mismatch

The data proves motor balance changes with speed. No single offset can fix it. In Seeing the Line, proportional control will push the speed limit higher because corrections are smoother.

✅ Task 9 — Try to Balance the Motors (Optional)

Now that you can SEE the bias in your data, try to compensate. Put the robot on the oval track — the longer track gives more time to observe.

from picobot import Robot
import time

robot = Robot()

SPEED = 80
BALANCE = 0      # ← Adjust! Positive = boost right, negative = boost left
TURN_SPEED = -80

print(f"Balance Test — SPEED={SPEED}, BALANCE={BALANCE}")
print("Place robot on the oval track.\n")

for countdown in range(5, 0, -1):
    robot.set_leds((255, 255, 0))
    time.sleep(0.5)
    robot.leds_off()
    time.sleep(0.5)
robot.set_leds((0, 255, 0))

start = time.ticks_ms()

while time.ticks_diff(time.ticks_ms(), start) < 15000:  # 15 seconds
    readings = robot.sensors.line.read_raw()
    left_sees = readings[1] == 0
    right_sees = readings[2] == 0

    if left_sees and right_sees:
        robot.set_motors(SPEED, SPEED + BALANCE)  # Balance on straights
    elif left_sees:
        robot.set_motors(TURN_SPEED, SPEED)
    elif right_sees:
        robot.set_motors(SPEED, TURN_SPEED)
    else:
        robot.stop()
    time.sleep_ms(10)

robot.stop()
robot.set_leds((0, 0, 255))

How to tune: 1. Your Task 7 plot tells you which way the robot drifts — use that to choose the BALANCE direction 2. If the plot showed more "Steer Left" → robot drifts right → increase BALANCE (+5, +10...) 3. If more "Steer Right" → robot drifts left → decrease BALANCE (-5, -10...) 4. A good BALANCE makes the oval smoother — less jerky on the straights

Then experiment: - Does your BALANCE still work at SPEED = 60? At SPEED = 120? (Check Task 8's data — the balance shifts!) - Wait 10 minutes and try again — does it still work?

The Lesson

The BALANCE offset helps at one speed, on one surface, with one battery level. Change any of those and the robot drifts differently. The bang-bang controller handles all of this automatically — it doesn't care why the robot drifts, it just corrects. That's the power of feedback.


Part 3: Why the Square Failed — Physics (10 min)

Differential Drive

Your robot uses differential steering—two independently driven wheels plus a passive caster wheel for balance. This is the same steering system used in tank treads, warehouse robots, and many research platforms.

How Differential Drive Works

By varying the speed of each wheel independently, you can achieve any motion:

Left Motor Right Motor Result
Forward Forward (same speed) Straight ahead
Forward Forward (slower) Curves RIGHT
Forward Forward (faster) Curves LEFT
Forward Backward Spins RIGHT in place
Backward Forward Spins LEFT in place

No steering mechanism needed—just two motors!

The mathematics: $\(v = \frac{v_{left} + v_{right}}{2}\)$ $\(\omega = \frac{v_{right} - v_{left}}{L}\)$

Where \(v\) is forward velocity, \(\omega\) is angular velocity, and \(L\) is the wheel base width.

Math Connection: Two Wheels, Infinite Motions

The formulas above reveal something elegant: forward motion comes from the average of wheel speeds, while rotation comes from the difference. This means any combination of translation and rotation can be achieved by just two numbers (left speed, right speed).

This is the foundation of kinematics—the mathematics of motion without considering forces. Warehouse robots, Mars rovers, and robot vacuums all use these same equations. If you add wheel encoders to measure actual rotation, you can integrate these velocities over time to estimate position—that's called odometry or dead reckoning.

📚 Differential Drive (Wikipedia) · Mobile Robot Kinematics

Differential drive: forward, curve, and spin from two wheel speeds

How the H-Bridge Controls Your Motors

Your robot's AM1016A uses four transistor switches per motor, arranged in an "H" pattern. By choosing which pair to close, current flows through the motor in either direction:

H-Bridge Forward

Forward: Q1 and Q4 close. Current flows from V+ through Q1, through the motor, through Q4 to GND.

H-Bridge Reverse

Reverse: Q2 and Q3 close. Current reverses through the motor.

The AM1016A handles the switching automatically — when you call robot.set_motors(80, -80), the library sets the direction pins and PWM duty cycle for each channel. You control what the motor does; the H-bridge handles how.

Why PWM Works on Motors

A motor's coil has inductance — it resists sudden changes in current. When PWM switches on, current ramps up. When it switches off, the inductance keeps current flowing briefly (through the H-bridge's flyback diodes). The result: the motor sees a smooth average current, not choppy pulses.

Motor inductance smooths the PWM pulses into steady current

At startup, the motor draws maximum current (stall current) because there's no back-EMF yet. As it spins up, back-EMF opposes the supply voltage and current drops to the running level:

Motor startup — high current spike settles as the motor reaches speed

Why Motors Aren't Equal

Even with identical PWM signals, motors produce different speeds — your Part 2 data proved this. The causes:

Factor What Happens Typical Variation
Manufacturing tolerance Gearbox friction, coil resistance ±5-10%
Battery voltage Drops as battery discharges -10% over discharge
Temperature Motor resistance changes ±3% per 10°C
Load Surface friction varies ±5-15%
Wear Brushes wear, gears wear Increases over time

The Torque-Speed Trade-Off

DC Motor Torque-Speed Curve

This curve explains observations you may have already made:

  • On carpet vs. tile: Carpet creates more friction (higher load), so the motor operates further down the torque-speed curve — more torque, less speed. The same PWM produces slower movement on carpet.
  • Fresh vs. depleted battery: Lower voltage shifts the entire curve downward. Every operating point gives less speed AND less torque.
  • Pushing against a wall: The motor approaches stall, drawing maximum current. This is when the H-bridge works hardest and when power supply sag is worst.
Motor Datasheet: 12GAN20-298
Parameter Value
Rated voltage 6 V
No-load speed 300 RPM
No-load current 80 mA
Stall torque 1.2 kg·cm
Stall current 1.2 A
Gear ratio 1:20

Notice that stall current (1.2 A) is 15× higher than no-load current (80 mA). This is why the robot draws the most power during startup and when pushing against obstacles.

Why Time-Based Turns Fail

When you command time.sleep(0.5), you're saying:

"Apply torque for 0.5 seconds and hope the robot turns 90°"

But time ≠ angle. The actual rotation depends on:

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

Where \(\omega\) depends on voltage, friction, load, temperature...

You can't control what you don't measure.

This Is "Open-Loop" Control
  • Open-loop: Command → Execute → Hope
  • Closed-loop: Command → Execute → Measure → Correct

Time-based control is open-loop. The robot has no idea how much it actually turned.


Part 4: The Big Picture — Feedback Everywhere (5 min)

In Task 7 you experienced the difference between open-loop and closed-loop control firsthand. Let's zoom out.

What You Just Did

In Task 6, you measured how straight the robot drives — open-loop (BALANCE offset, no sensor). In Task 7, you added line sensor feedback — closed-loop (5 lines of correction code). The result:

Open-Loop (Task 6) Closed-Loop (Task 7)
Stays on line? Drifts off in seconds Stays on indefinitely
Handles bumps? No Yes — corrects itself
Battery changes? Needs recalibration Still works
Code complexity set_motors(SPEED, SPEED + BALANCE) 5 extra lines

The line sensor closes the loop on direction (left/right steering). But the distance traveled still varies between runs (Task 8) because there's no feedback on speed. That's what a wheel encoder would add.

What If There's No Line?

The line sensor closes the loop on direction — but only works where there's a line. Other sensors close other loops:

  • Ultrasonic → distance to a wall (see Challenge: Wall Approach below)
  • Gyroscope → heading angle (you'll use this in Precise Turns)
  • Wheel encoder → distance traveled and speed

But there's one sensor we don't have yet that would solve the speed problem — a wheel encoder.

How Encoders Work

An encoder is a disc with slots or magnets attached to the motor shaft. A sensor (optical or Hall effect) counts the slots as the wheel turns:

Motor shaft → [Encoder disc with slots] → Sensor counts pulses
               ||||||||||||||||
              Each slot = known fraction of a revolution
              = known distance traveled

With encoders, you can close the loop on: - Distance: "drive until 200 pulses" = known cm - Speed: "maintain 150 pulses/second" = constant RPM - Direction: count up vs down = forward vs backward

Our robot doesn't have encoders yet — but the closed-loop principle is identical to what you just did with the ultrasonic sensor. When encoders are added, see Advanced: Encoders & Speed Control.

The Big Picture

Approach Sensor Feedback Limitation
Open-loop (time-based) None None — just hope Unreliable
Ultrasonic closed-loop Ultrasonic Distance to wall Needs a wall
Encoder closed-loop Wheel encoder Distance / speed Needs encoder hardware
IMU closed-loop Gyroscope Heading angle Heading only, no distance
Line-sensor closed-loop Line sensors Position on line Needs a line on the floor

Every precision system uses sensors to close the loop:

System What It Measures Why
Car odometer Wheel rotations Know distance traveled
Drone Gyroscope + accelerometer Know orientation
CNC machine Encoder on each axis Know exact position
Your phone GPS + IMU Know location and heading

In Seeing the Line, you'll close the loop on line position. In Precise Turns, you'll close the loop on heading angle with the gyroscope. Every time, the same pattern: measure, compare, correct.


What You Discovered

Concept What You Observed
PWM Duty cycle controls power, frequency controls smoothness
Motor mismatch Left and right motors behave differently — calibration can't fix it
Open-loop Equal PWM to both motors → drifts off the line in seconds (Task 6)
Closed-loop Sensor feedback keeps the robot on the line indefinitely (Task 6)
Repeatability Even with feedback, distance varies — speed has no feedback (Task 7)
Differential drive Speed sum = forward, speed difference = turn
Encoders (concept) Would close the loop on speed, not just direction

Challenges (Optional)

Practice data logging and try new ideas on the oval track.

Useful references: PWM Fundamentals · Motor Basics · Key Code Reference

✅ Challenge 1 — Oval Lap Timer

How fast can your bang-bang controller complete a full lap of the oval track? Log the time for 5 laps and plot consistency.

Hints
  • Detect a "landmark" on the track (a piece of tape across the line, or a junction) to count laps
  • Log: logger.log(lap_number, lap_time_ms)
  • Try different speeds — what's the fastest speed that completes all 5 laps without losing the line?
  • Plot lap times as a bar chart — are they consistent?

✅ Challenge 2 — Wall Approach (Ultrasonic Closed-Loop)

Drive toward a wall and stop at exactly 10 cm using ultrasonic feedback. Log distance and speed, plot the approach curve.

Hints
  • Read robot.read_distance() in a loop — it takes ~25ms (blocking), no extra sleep() needed
  • Proportional: speed = min(80, max(30, int((distance - target) * 3)))
  • Log: logger.log(time.ticks_ms(), distance, speed) — plot distance + speed vs time
  • The plot should show speed ramping down as the robot approaches — that's proportional control

✅ Challenge 3 — Battery Voltage Under Load

Log battery voltage at rest vs during driving. How much does it sag?

Hints
  • Read battery: from machine import ADC, Pin; adc = ADC(Pin(28)); voltage = adc.read_u16() * 3.3 / 65535 * 3
  • Measure at rest, then at speed 60, 80, 100, 120 while on the oval track
  • Plot voltage drop vs speed — explain why the motors slow down as the battery discharges

✅ Challenge 4 — Preview: Proportional Control

Replace bang-bang with proportional correction and compare the data from Task 7.

Hints
  • Replace the if/elif with: error = robot.sensors.line.get_error(); correction = int(20 * error); robot.set_motors(SPEED + correction, SPEED - correction)
  • Log: logger.log(time.ticks_ms(), error, correction) — plot error over time
  • Compare to your Task 7 bang-bang plot — is the correction rate lower? Is the robot smoother?
  • Try different multiplier values (10, 20, 40) — which feels best?
  • This is exactly what Seeing the Line formalizes as P-control

Competition: H-Track Speed Run

Now that you understand PWM, feedback control, and data logging — who can cross the H-track fastest without losing the line?

Rules

  1. Robot must start on the START line and stop at the END line (all 4 sensors detect black)
  2. Robot must stay on the line the entire run (no manual intervention)
  3. Time is measured automatically (logged in CSV)
  4. You can tune: SPEED, TURN_SPEED, and any control logic you want
  5. Bonus points: log your run data and show your timeline plot

Leaderboard

Rank Name Time (ms) Speed Strategy
1
2
3

Tips

  • Start with Task 8's code — it already has the H-track auto-detect
  • Higher speed = faster but more likely to lose the line
  • The balance between SPEED and TURN_SPEED is the key
  • Data logging helps: compare your timeline plots to find where you're losing time
The Engineering Lesson

In real embedded systems, optimization is always a tradeoff. Faster response = more aggressive control = higher risk of instability. Finding the sweet spot is what embedded engineers do every day.


Debug Challenge: What's Wrong With This Code?

Can you find the bugs? Each one is a real embedded mistake.

Bug 1: "My robot doesn't move"

from picobot import Robot
robot = Robot()
robot.set_motors(15, 15)  # Why doesn't it move?
time.sleep(5)
robot.stop()
Answer

Speed 15 is below the dead zone (Task 4). The motor needs at least ~30-40 PWM to overcome static friction.

Bug 2: "My control loop is super slow"

while True:
    error = robot.sensors.line.get_error()
    if error is not None:
        correction = int(30 * error)
        robot.set_motors(80 + correction, 80 - correction)
    distance = robot.read_distance()
    print(f"Error: {error}, Distance: {distance}")
    time.sleep(0.02)
Answer

Three problems: (1) read_distance() blocks for ~25ms EVERY loop, (2) print() blocks for ~5ms, (3) time.sleep(0.02) adds another 20ms. Total loop time: ~50ms = 20 Hz instead of 100 Hz. Fix: remove read_distance() from the control loop, log instead of print, reduce sleep.

Bug 3: "The motors run but I can't stop them"

from machine import Pin, PWM
motor = PWM(Pin(13))
motor.freq(1000)
motor.duty_u16(40000)
time.sleep(2)
# Motor should stop now... but it doesn't!
Answer

Setting duty to 0 doesn't stop the motor — motor.duty_u16(0) is needed. Also, only pin 13 (forward) is controlled. Pin 12 (backward) is floating — it might have residual charge. The library handles this properly with robot.stop() which sets both pins LOW.


Recap

You explored PWM from the ground up — LED brightness, buzzer pitch, motor speed, and PWM frequency effects. Then you discovered that motors without feedback are unreliable (the Week 1 square fails because of motor mismatch), and that even simple bang-bang sensor feedback keeps the robot on a line indefinitely. Data logging let you SEE the controller's behavior quantitatively.

What's Next
  • In Seeing the Line, you'll replace bang-bang with proportional control — smoother, faster, and more robust. You'll tune Kp experimentally and log the results.
  • In Precise Turns, you'll close the loop on heading angle with the gyroscope — finally making the square work.
  • When encoders are added to the robot, see Advanced: Encoders & Speed Control for closed-loop speed and distance control.

Key Code Reference

from machine import Pin, PWM
from picobot import Robot
import time

# --- Raw PWM ---
pwm = PWM(Pin(22))        # Create PWM on any GPIO pin
pwm.freq(1000)            # Set frequency (Hz)
pwm.duty_u16(32768)       # Set duty cycle (0–65535 = 0–100%)
pwm.duty_u16(0)           # Turn off (0% duty)
pwm.deinit()              # Release the pin

# --- Robot motor control ---
robot = Robot()
robot.set_motors(left, right)  # -255 to 255
robot.motors.set_freq(20000)   # PWM frequency (Hz) — both motors
robot.motors.left.set_freq(1000)  # Per-motor frequency
print(robot.motors.left.freq)  # Read current frequency
robot.stop()

# Convenience methods (time-based, unreliable!)
robot.forward(speed, duration)
robot.backward(speed, duration)
robot.turn_left(speed, duration)   # Time-based, no feedback!
robot.turn_right(speed, duration)  # Calibrate by tweaking duration!

# --- Ultrasonic distance ---
distance = robot.read_distance()     # Returns cm (blocking: ~25ms)

# --- Data logging ---
from picobot import DataLogger
logger = DataLogger("experiment.csv")
logger.start("speed", "distance")    # CSV column names
logger.log(80, 15.3)                 # Log a row
logger.stop()                        # Close file
# Download: mpremote cp :experiment.csv .

# --- Buzzer ---
robot.beep(440, 100)      # Frequency (Hz), duration (ms)

← Ultrasonic & Timing | Labs Overview | Next: Seeing the Line →