Timing fundamentals
3# Ultrasonic & Timing
Time: 135 min
Learning Objectives
By the end of this lab you will be able to:
- Read distance measurements from an ultrasonic sensor (HC-SR04)
- Understand ultrasonic ranging principles (time-of-flight measurement)
- Use
time.ticks_ms(),time.ticks_diff(), andtime.sleep()for timing - Identify the "blocking problem" — when a slow operation freezes everything else
- Implement the non-blocking timing pattern:
if ticks_diff(now, last) >= period - Run multiple tasks at different rates (LED heartbeat + sensor reading)
You will first explore the ultrasonic sensor, then discover that its slow measurement blocks other tasks, and finally learn the non-blocking timing pattern that solves this problem.
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 GPIO & Sensors and Analog Sensors, you learned to read digital and analog sensors through GPIO pins. Those reads are fast — a few microseconds each. But not all sensors are fast.
The ultrasonic distance sensor takes up to 25 milliseconds per measurement — and physics prevents us from speeding this up. This lab explores what happens when a slow sensor blocks your code, and how to fix it with non-blocking timing patterns.
Why This Lab Matters
In real-time embedded systems, timing is everything. A motor control loop that runs at 1000 Hz is smooth; the same loop at 40 Hz is jerky. Understanding what "blocking" means and how to schedule tasks at different rates is a foundational skill for every embedded engineer. You'll use these patterns in every lab from here on.
Part 1: The Ultrasonic Sensor (15 min)
Background: Calibration and Measurement Error
Every sensor has two kinds of error: systematic (consistent offset — e.g., always reads 2 cm too far) and random (unpredictable noise around the true value). Calibration removes systematic error by comparing against known references. Averaging reduces random error. A third type — gross error (sensor disconnected, reads 0 or max) — must be caught with bounds checking before it crashes your control loop.
How Ultrasonic Ranging Works
Your robot has an HC-SR04 ultrasonic sensor on the front — the two silver cylinders that look like robot eyes.
It works like bat echolocation:
- Transmitter sends a burst of 40 kHz sound (above human hearing)
- Sound travels outward, bounces off an object, returns
- Receiver detects the echo
- Distance = speed of sound × time / 2
We divide by 2 because sound travels to the object AND back.

Interesting Fact: Speed of Sound
Sound travels at 343 m/s at 20°C, but this changes with temperature:
$\(v = 331.3 + 0.606 \times T_{celsius}\)$
At 0°C: 331 m/s. At 40°C: 355 m/s. This is a 7% variation!
Professional ultrasonic systems include temperature compensation. Your robot doesn't—but for obstacle detection, ±7% accuracy is perfectly acceptable.
Math Connection: Time-of-Flight Ranging
The formula \(d = \frac{v \cdot t}{2}\) is deceptively simple, but it's the foundation of many sensing technologies. The factor of 2 appears because the signal travels there and back—easy to forget!
This same principle powers RADAR (radio waves), LIDAR (laser light), GPS (satellite timing), and medical ultrasound. The only difference is the wave type and speed. Light travels at 300,000 km/s, so LIDAR can measure centimeters by timing nanoseconds—that's impressive engineering.
Try This!
While you can't hear 40 kHz (human hearing tops out around 20 kHz), some animals can. Dogs hear up to 65 kHz, and bats use similar frequencies for navigation.
The pulses are also visible to some cameras! Point your phone at the sensor while it's active—you might see faint flashes from the transmitter.
HC-SR04 Specifications
| Parameter | Value | Why It Matters |
|---|---|---|
| Operating frequency | 40 kHz | Above human hearing |
| Detection range | 2 cm – 4 m | Too close = echo overlap, too far = weak return |
| Beam angle | ~15° cone | Won't detect objects to the side |
| Typical accuracy | ±3 mm | At optimal distances |
| Measurement time | ~25 ms max | This will cause problems! |
Datasheet: HC-SR04 (Elec Freaks)
The Three-Signal Protocol
Understanding this protocol will help you debug problems when the sensor gives unexpected readings or no readings at all.

The HC-SR04 communicates through a strict three-step sequence:

- Trigger — your code sends a 10 µs HIGH pulse on the trigger pin. This tells the sensor "start a measurement now."
- Burst — the sensor emits 8 cycles of 40 kHz ultrasound from the transmitter (the left cylinder). This happens automatically — you do not control it.
- Echo — the sensor pulls the echo pin HIGH and holds it until the sound returns. The duration of this HIGH pulse is directly proportional to the round-trip distance.
The distance formula follows from the speed of sound:
At maximum range (4 m), the sound must travel 8 m round-trip, taking 8 / 343 = 23.3 ms. This is why each measurement blocks your code for up to 23.3 ms — physics sets the speed limit.
Watch what happens as the robot moves closer to the wall — the echo pulse gets shorter because sound has less distance to travel:

⚡Hands-on tasks
✅ Task 1 — Explore the Sensor
from picobot import Robot
import time
robot = Robot()
print("Wave your hand in front of the robot")
print("Watch how distance changes")
print()
while True:
distance = robot.read_distance()
# Visual bar
bars = int(min(distance, 50) / 2)
bar = "█" * bars + "░" * (25 - bars)
print(f"Distance: {distance:5.1f} cm |{bar}|")
time.sleep(0.1)
Test the sensor under different conditions:
| Test | Expected | Your Observation |
|---|---|---|
| Hand at 5 cm | ~5-8 cm | |
| Hand at 50 cm | ~45-55 cm | |
| No obstacle (pointing at ceiling) | ~200+ cm or erratic | |
| Move hand slowly | Smooth changes | |
| Soft fabric (sweater) | May read differently — absorbs some sound | |
| Angled surface | May miss (sound reflects away) | |
| Two hands (one behind other) | Closer one only — can't see through objects! |
Pay attention to how fast the readings update. There's a noticeable delay between moving your hand and seeing the change.
Checkpoint — Ultrasonic Responding
Hold your hand at arm's length (~50cm) and slowly move it closer. The distance reading should decrease smoothly. If it reads 0.0 constantly, check the Trigger/Echo pin connections.
Stuck?
- Reads
0.0or very large values constantly: Check wiring — Trigger is GP14, Echo is GP15. Ensure the sensor is powered. - Readings jump wildly: The beam has a ~15° cone. Make sure your target is directly in front, not at an angle.
Under the Hood: What read_distance() Does
The library hides the GPIO-level protocol. Here is what actually happens inside:
def read_distance(trigger, echo):
# 1. Send 10 µs trigger pulse
trigger.value(0)
time.sleep_us(2)
trigger.value(1)
time.sleep_us(10)
trigger.value(0)
# 2. Wait for echo (BLOCKS!)
pulse_us = time_pulse_us(echo, 1, 30000)
# 3. Convert to cm
if pulse_us < 0:
return -1 # timeout — no echo received
return pulse_us / 58 # speed of sound conversion
The time_pulse_us() call waits for the echo pin to go HIGH, then measures how long it stays HIGH. During this entire wait (up to 30 ms), your CPU is doing nothing else. This is the blocking problem you will explore next.
Part 2: Timing in MicroPython (10 min)
The Timing Functions
MicroPython provides precise timing through the time module:
import time
# Current time in milliseconds (wraps after ~49 days)
now = time.ticks_ms()
# Current time in microseconds (wraps faster)
now_us = time.ticks_us()
# ALWAYS use ticks_diff for time differences (handles wraparound!)
elapsed = time.ticks_diff(time.ticks_ms(), start_time)
# Pause execution
time.sleep(1.0) # 1 second
time.sleep_ms(100) # 100 milliseconds
time.sleep_us(10) # 10 microseconds
Never Subtract ticks Directly!
# WRONG - breaks after ~49 days when ticks_ms() wraps around
elapsed = time.ticks_ms() - start_time
# CORRECT - handles wraparound automatically
elapsed = time.ticks_diff(time.ticks_ms(), start_time)
ticks_diff() handles the case where the counter wraps from a large number back to 0. Always use it.
Why Wraparound Matters
MicroPython's tick counters are 30-bit unsigned integers. ticks_ms() wraps after approximately 12.4 days (2^30 ms ≈ 1,073,741 seconds). ticks_us() wraps much sooner — roughly every 17.9 minutes (2^30 µs ≈ 1,074 seconds).
If you subtract ticks directly (now - start), the result becomes incorrect (a huge number or negative) the moment the counter wraps. ticks_diff() uses modular arithmetic to handle this transparently.
For short-lived programs this may never matter. But a robot running for hours (or a data logger running for weeks) will eventually hit this boundary.
✅ Task 2 — Measure How Long Things Take
Let's measure how long different operations actually take:
import time
from picobot import Robot
robot = Robot()
# Measure line sensor read time
start = time.ticks_us()
for _ in range(100):
robot.sensors.line.read_raw()
elapsed = time.ticks_diff(time.ticks_us(), start)
print(f"Line sensor read: {elapsed // 100} µs per read")
# Measure ultrasonic read time
start = time.ticks_us()
for _ in range(10):
robot.read_distance()
elapsed = time.ticks_diff(time.ticks_us(), start)
print(f"Ultrasonic read: {elapsed // 10} µs per read")
Expected output (values will vary):
Record your measurements:
| Operation | Time per read | Reads per second |
|---|---|---|
| Line sensor read | ______ µs | ______ |
| Ultrasonic read | ______ µs | ______ |
The ultrasonic sensor is approximately 500× slower than the line sensors!
Checkpoint — Timing Measured
Your measurements should show the ultrasonic sensor takes ~20,000-25,000 µs (20-25 ms) per read, while line sensors take only ~50-100 µs. This difference is the root of the blocking problem you'll discover next.
Part 3: The Blocking Problem (15 min)
Why 25 Milliseconds Matters
The sensor doesn't choose to be slow — physics forces it. Sound must travel to maximum range and back:
- Sound speed: 343 m/s = 34,300 cm/s
- Max range: 4 meters = 400 cm
- Round trip: 800 cm
- Time needed: 800 cm ÷ 34,300 cm/s = 23.3 ms
There's no way to speed up sound!
Polling vs Interrupts
The simple driver polls (waits in a loop) for the echo to return. This blocks your code for the entire measurement time.
Professional systems use interrupts — the hardware generates a signal when the echo arrives, allowing the CPU to do other work while waiting. The RP2350 supports this, but it adds complexity.
Even with interrupts, you can't take measurements faster than physics allows. You just free up the CPU between measurements.
You'll build an interrupt-driven ultrasonic reader in State Machines — Part 7 and measure the difference. → Interrupts Reference
Seeing the Problem
Let's simulate what happens when you try to do two things at once — blink an LED and read distance:
✅ Task 3 — Blocking Demo
from picobot import Robot
import time
robot = Robot()
print("Heartbeat LED + Ultrasonic — watch the LED!")
print()
led_on = False
try:
while True:
# Toggle LED every iteration (should be fast heartbeat)
led_on = not led_on
if led_on:
robot.set_leds((0, 50, 0)) # Dim green
else:
robot.leds_off()
# Read ultrasonic sensor (THIS IS SLOW!)
distance = robot.read_distance()
print(f"Distance: {distance:5.1f} cm")
except KeyboardInterrupt:
robot.leds_off()
Watch the LEDs carefully. They should blink rapidly, but instead they blink slowly and unevenly.

Why? Each loop iteration takes ~25ms because read_distance() blocks — your entire program waits for the sound to travel and return. During those 25ms:
- LEDs aren't being updated
- No other sensors are being read
- The CPU is doing nothing useful
What Is "Blocking"?
A blocking operation makes your entire program wait until it completes. During a blocking call, nothing else can run — no sensor reads, no motor updates, no LED animations.
Imagine driving a car and having to close your eyes for half a second every time you check your mirrors. That's what blocking does to your robot.
Measure the Impact
✅ Task 4 — Measure Loop Speed
from picobot import Robot
import time
robot = Robot()
# Test 1: Loop WITHOUT ultrasonic
print("Loop WITHOUT ultrasonic:")
times = []
for i in range(100):
start = time.ticks_us()
# Just toggle LED
robot.set_leds((0, 50, 0)) if i % 2 else robot.leds_off()
loop_time = time.ticks_diff(time.ticks_us(), start)
times.append(loop_time)
avg = sum(times) // len(times)
print(f" Average: {avg} µs/loop ({1_000_000 // avg} Hz)")
# Test 2: Loop WITH ultrasonic
print("\nLoop WITH ultrasonic:")
times = []
for i in range(50):
start = time.ticks_us()
robot.set_leds((0, 50, 0)) if i % 2 else robot.leds_off()
distance = robot.read_distance() # THE SLOW PART
loop_time = time.ticks_diff(time.ticks_us(), start)
times.append(loop_time)
avg = sum(times) // len(times)
print(f" Average: {avg} µs/loop ({1_000_000 // avg} Hz)")
robot.leds_off()
Your measurements:
| Metric | Without Ultrasonic | With Ultrasonic |
|---|---|---|
| Loop time | ______ µs | ______ µs |
| Loop rate | ______ Hz | ______ Hz |
The ultrasonic reading slows your loop by approximately 50×!
Checkpoint — Blocking Problem Understood
Your measurements prove the ultrasonic sensor dramatically slows the loop. You understand that read_distance() blocks the CPU for ~25ms, preventing any other work during that time.
Part 4: The Non-Blocking Solution (20 min)
The Key Insight
Do you need to check distance 1000 times per second?
Think about it: - Your robot moves at maybe 30 cm/s - An obstacle 15 cm away takes 0.5 seconds to reach - Checking 10 times per second = 5 opportunities to detect it - That's plenty!
The solution: Do the slow thing less often, and use the cached (saved) result between measurements.
The Pattern

last_check = 0 # When did we last measure?
INTERVAL = 100 # Check every 100ms (10 Hz)
cached_value = 100 # Last known distance
while True:
now = time.ticks_ms()
# SLOW TASK: Only when enough time has passed
if time.ticks_diff(now, last_check) >= INTERVAL:
cached_value = read_distance() # 25ms, but only 10× per second
last_check = now
# FAST TASK: Every loop, using cached value
do_fast_thing(cached_value)
This is the non-blocking timing pattern. Instead of doing the slow thing every iteration, we check a timer and only do it when enough time has elapsed.
✅ Task 5 — Non-Blocking Heartbeat + Distance
from picobot import Robot
import time
robot = Robot()
# Timing variables for each task
last_distance = 0
DISTANCE_INTERVAL = 200 # Read distance every 200ms (5 Hz)
cached_distance = 100
last_heartbeat = 0
HEARTBEAT_INTERVAL = 500 # Toggle LED every 500ms (1 Hz blink)
led_on = False
print("Non-blocking: smooth heartbeat + distance reading")
print()
try:
while True:
now = time.ticks_ms()
# TASK 1: Distance reading (slow, every 200ms)
if time.ticks_diff(now, last_distance) >= DISTANCE_INTERVAL:
cached_distance = robot.read_distance()
last_distance = now
# TASK 2: Heartbeat LED (fast, every 500ms)
if time.ticks_diff(now, last_heartbeat) >= HEARTBEAT_INTERVAL:
led_on = not led_on
if led_on:
robot.set_leds((0, 50, 0)) # Dim green
else:
robot.leds_off()
last_heartbeat = now
# Print distance (using cached value — instant!)
bars = int(min(cached_distance, 50) / 2)
bar = "█" * bars + "░" * (25 - bars)
print(f"Distance: {cached_distance:5.1f} cm |{bar}|")
time.sleep(0.01) # Small sleep to avoid flooding the console
except KeyboardInterrupt:
robot.leds_off()
print("\nDone!")
Observe the LED carefully. It should now blink at a steady 1 Hz rhythm, regardless of what the ultrasonic sensor is doing.
Checkpoint — Non-Blocking Pattern Working
The heartbeat LED blinks steadily at 1 Hz while distance readings update at 5 Hz. Compare this to Task 3 where the LED was jerky. The non-blocking pattern keeps both tasks running smoothly.
Stuck?
- LED still blinks irregularly: Make sure each task has its own
last_timevariable. If they share one, they'll interfere with each other. - Distance never updates: Check that
DISTANCE_INTERVALis reasonable (100-500ms). If it's too large, you won't see updates. - Console output is overwhelming: Increase the
time.sleep()at the bottom, or only print when distance changes significantly.
Why This Works
Without non-blocking (every loop = 25ms):
[████████████████████████] 25ms ultrasonic
[████████████████████████] 25ms ultrasonic
[████████████████████████] 25ms ultrasonic
→ Everything runs at 40 Hz
With non-blocking (ultrasonic every 200ms):
[█] [█] [█] ... [█] [████████████████████████] [█] [█] [█] ...
↑ fast loops ↑ one slow loop ↑ fast loops
→ Most loops are fast, one slow loop every 200ms
✅ Task 6 — Three Tasks at Different Rates
Add a third task — a warning beep when an obstacle is close:
from picobot import Robot
import time
robot = Robot()
# Task timing
last_distance = 0
DISTANCE_INTERVAL = 200
cached_distance = 100
last_heartbeat = 0
HEARTBEAT_INTERVAL = 500
led_on = False
last_warning = 0
WARNING_INTERVAL = 100
DANGER_DISTANCE = 15
print("Three tasks: heartbeat + distance + warning beep")
print()
try:
while True:
now = time.ticks_ms()
# Task 1: Distance (5 Hz)
if time.ticks_diff(now, last_distance) >= DISTANCE_INTERVAL:
cached_distance = robot.read_distance()
last_distance = now
# Task 2: Heartbeat (1 Hz)
if time.ticks_diff(now, last_heartbeat) >= HEARTBEAT_INTERVAL:
led_on = not led_on
if cached_distance < DANGER_DISTANCE:
color = (255, 0, 0) if led_on else (50, 0, 0) # Red pulse
else:
color = (0, 50, 0) if led_on else (0, 0, 0) # Green pulse
robot.set_leds(color)
last_heartbeat = now
# Task 3: Warning beep (only when close)
if cached_distance < DANGER_DISTANCE:
if time.ticks_diff(now, last_warning) >= WARNING_INTERVAL:
robot.beep(880, 50)
last_warning = now
time.sleep(0.01)
except KeyboardInterrupt:
robot.leds_off()
print("Done!")
Move your hand toward the sensor. When it gets close: - LED changes from green to red - Beeps start every 100ms - Distance still updates smoothly
This Is How Real Systems Work
What you just built is a simple form of cooperative multitasking — different tasks running at different rates, sharing the CPU. Professional embedded systems use an RTOS (Real-Time Operating System) like FreeRTOS to automate this with task priorities and preemption.
Your MicroPython robot manages timing manually, but the principle is the same: know how fast each task needs to run, and schedule accordingly.
Rate Matching Principle
Match your checking frequency to how fast things can change:
| Task | Typical Rate | Why |
|---|---|---|
| Line sensors | 500-2000 Hz | Robot drifts off line in ~50ms |
| Motor PWM updates | 50-100 Hz | Smooth motion control |
| Ultrasonic | 5-20 Hz | Objects don't teleport |
| Battery voltage | 0.1-1 Hz | Chemistry changes slowly |
| Temperature | 0.01-0.1 Hz | Thermal mass = slow changes |
What You Discovered
The Core Problem
When one operation takes too long, everything else stops. This is the fundamental problem with blocking code in real-time systems.
The Non-Blocking Pattern
last_time = 0
INTERVAL = 100 # ms
cached_value = safe_default
while True:
now = time.ticks_ms()
# Slow thing: only when enough time has passed
if time.ticks_diff(now, last_time) >= INTERVAL:
cached_value = do_slow_thing()
last_time = now
# Fast thing: every loop, using cached value
do_fast_thing(cached_value)
This pattern appears in virtually every embedded system. Different tasks run at different rates based on their timing requirements.
Common Mistakes
| Mistake | What Happens | Fix |
|---|---|---|
Forgetting to update last_time |
Task runs every loop (defeats the purpose) | Always set last_time = now after the slow operation |
Using time.sleep() inside a timed task |
Blocks the whole loop | Remove sleep() from timed tasks; use only at the bottom of the main loop |
Calling ticks_ms() multiple times |
Each call returns a different value | Call once, store in now, use now everywhere |
| Subtracting ticks directly | Breaks after ~49 days | Always use ticks_diff() |
What's Next?
In Motor Control, you'll finally make the robot move under your control. Now that you understand GPIO, sensors, and timing, motor control concepts like PWM and differential drive will make much more sense. You'll also see the fundamental limitation of open-loop control — commanding motion without measuring the result.
Challenges (Optional)
Challenge: Distance-Based LED Bar
Map the ultrasonic distance to the 8 LEDs: close = all red, far = all green, medium = mix. Use the non-blocking pattern so the display updates smoothly.
Challenge: Reaction Timer
Measure human reaction time: show a random LED color, then measure how quickly the user places their hand in front of the sensor. Use ticks_diff() for precise timing.
Challenge: Parking Sensor
Build a parking sensor that beeps faster as an obstacle gets closer (like a car's reverse sensor). Closer = shorter interval between beeps.
Challenge: What If the CPU Didn't Have to Wait?
The non-blocking pattern reduces how often we block, but each read_distance() call still freezes the CPU for ~25 ms. What if the hardware could notify us when the echo returns, so the CPU never waits? You'll build exactly this using interrupts in State Machines — Part 7.
Recap
Ultrasonic sensing is slow because sound travels at only 343 m/s. Blocking calls freeze your entire program, but non-blocking timing patterns solve this by doing slow tasks less often and caching results. Each task gets its own timer and runs at an appropriate rate.
Key Code Reference
import time
# Timing
now = time.ticks_ms() # Current time (ms)
elapsed = time.ticks_diff(now, start) # Safe time difference
time.sleep(1.0) # Pause 1 second
time.sleep_ms(100) # Pause 100ms
# Ultrasonic sensor
from picobot import Robot
robot = Robot()
distance = robot.read_distance() # Blocking! ~25ms
# Non-blocking pattern
last_check = 0
INTERVAL = 200
cached = 100
while True:
now = time.ticks_ms()
if time.ticks_diff(now, last_check) >= INTERVAL:
cached = robot.read_distance()
last_check = now
# Use cached value for decisions