Skip to content

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(), and time.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:

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 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.

→ Deep dive: ADC Basics | → Filtering Techniques

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:

  1. Transmitter sends a burst of 40 kHz sound (above human hearing)
  2. Sound travels outward, bounces off an object, returns
  3. Receiver detects the echo
  4. Distance = speed of sound × time / 2
\[\text{distance} = \frac{t \times 343 \text{ m/s}}{2}\]

We divide by 2 because sound travels to the object AND back.

Ultrasonic Pulse-Echo Animation

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.

📚 Time of Flight (Wikipedia) · How Ultrasonic Sensors Work

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.

Ultrasonic Principle

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

Ultrasonic Timing Diagram

  1. Trigger — your code sends a 10 µs HIGH pulse on the trigger pin. This tells the sensor "start a measurement now."
  2. Burst — the sensor emits 8 cycles of 40 kHz ultrasound from the transmitter (the left cylinder). This happens automatically — you do not control it.
  3. 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:

\[d = \frac{v \cdot t}{2} = \frac{343 \text{ m/s} \times t}{2}\]

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:

Ultrasonic Timing — Distance Changes

⚡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.0 or 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):

Line sensor read: 71 µs per read
Ultrasonic read:  17342 µs per read

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.

Blocking Problem Demo

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.

Timeline of blocking read_distance():

0ms     ─┬─ Start read_distance()
          │  ... waiting for sound to travel ...
          │  ... CPU doing nothing useful ...
          │  ... robot driving blind ...
25ms    ─┴─ Finally returns!

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

Blocking vs Non-Blocking Timeline

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_time variable. If they share one, they'll interfere with each other.
  • Distance never updates: Check that DISTANCE_INTERVAL is 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

← OLED Display | Labs Overview | Next: Motor Control →