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:
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.
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:
- A counter counts from 0 up to a "wrap" value (which sets the frequency)
- A comparator checks if the counter is below the duty threshold
- 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.")

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 |

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.

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.

✅ 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.
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.pdffrom 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:
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:
⚡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:
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).
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:
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.

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:

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

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.

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:

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

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:
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 extrasleep()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/elifwith: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
- Robot must start on the START line and stop at the END line (all 4 sensors detect black)
- Robot must stay on the line the entire run (no manual intervention)
- Time is measured automatically (logged in CSV)
- You can tune: SPEED, TURN_SPEED, and any control logic you want
- 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 →