The HC-SR04 ultrasound module is a low-cost distance sensor that works by timing the round-trip of a sound pulse. It is ubiquitous in hobbyist robotics for good reason: it is cheap, easy to interface, and gives reliable results in most indoor environments. Understanding its physical limitations — and how to filter its output — is essential before depending on it for obstacle avoidance.
The HC-SR04 has two transducers: a transmitter (T) and a receiver (R). The operating sequence is:
The speed of sound in air at 20 °C is approximately 343 m/s. For a measured ECHO pulse duration $t$ in seconds:
$d = t \times 343 / 2 = 171.5 \times t$ metres
Example: $t = 2.33$ ms → $d = 0.171 \times 2.33 = 0.40$ m.
Reading the ECHO pin with hardware precision requires either a GPIO interrupt (measuring the time between rising and falling edges) or hardware timer capture. The gpiozero library provides DistanceSensor which handles this automatically. On bare GPIO:
import time
GPIO.setup(TRIGGER, GPIO.OUT)
GPIO.setup(ECHO, GPIO.IN)
GPIO.output(TRIGGER, True)
time.sleep(10e-6)
GPIO.output(TRIGGER, False)
while not GPIO.input(ECHO): pass
t_start = time.monotonic()
while GPIO.input(ECHO): pass
t_end = time.monotonic()
distance_m = (t_end - t_start) * 343.0 / 2.0
Range: 2 cm to 400 cm. Below 2 cm the echo arrives before the module has finished transmitting. Above 400 cm, the echo is too faint to detect reliably.
Beam angle: approximately 15° half-angle cone. Objects outside this cone are not detected. This is both a feature (directional measurement) and a limitation (narrow FOV means objects to the side are missed).
Oblique surfaces: if the sensor is not perpendicular to the target surface, the reflected sound bounces away and the echo is not received, causing a missed reading (timeout, reported as maximum range). Smooth walls at angles greater than ~45° to the beam axis are unreliable.
Temperature dependence: the speed of sound changes with temperature ($c \approx 331.3 + 0.606 \times T$ m/s, where $T$ is Celsius). A 10 °C temperature change shifts readings by about 1.8%. For indoor navigation this is negligible.
Minimum cycle time: 60 ms between trigger pulses is recommended to allow the echo to decay before the next measurement. This gives a maximum measurement rate of ~17 Hz.
A single HC-SR04 reading can be wrong by several centimetres, and occasionally wildly wrong (a ghost echo from a wall behind the sensor, or crosstalk from nearby objects). The following filtering pipeline is effective:
Median filter. Take three readings in rapid succession (60 ms apart = 180 ms total) and return the median. This eliminates single-point outliers:
import statistics
def read_filtered(n=3) -> float:
readings = []
for _ in range(n):
readings.append(raw_read())
time.sleep(0.06)
return statistics.median(readings)
Exponential moving average (EMA). After the median filter, apply an EMA for smooth output: $d_{filtered} = \alpha \cdot d_{raw} + (1-\alpha) \cdot d_{prev}$
A typical value of $\alpha = 0.3$ provides good smoothing while still tracking step changes within a few samples.
Validity gate. Discard readings outside the valid range [0.02 m, 4.0 m]. Discard readings that change more than 0.5 m from the previous filtered value in a single step.
On the hardware platform, the ultrasound module (and camera) sit on a servo-driven pan head. Rotating the head to angle $\phi$ and measuring distance $d$ gives a polar measurement $(d, \phi)$ in the sensor head frame.
To convert to the robot frame, given that the sensor head is located at offset $(x_s, y_s) = (0.08, 0)$ m from the robot centre (mounted at the front):
$p_x^{robot} = x_s + d \cos(\phi)$ $p_y^{robot} = y_s + d \sin(\phi)$
By sweeping $\phi$ from $-90°$ to $+90°$ in steps of $10°$ and recording distances, the robot builds a 19-point scan of the environment in front of it. This is a minimal lidar-like scan, useful for both obstacle avoidance and occupancy grid construction (Chapter 12).
Threshold detection. The simplest approach: if the forward-facing distance drops below a threshold (e.g., 0.30 m), stop and re-plan. Effective for basic obstacle avoidance, but rigid.
Sector scan. Divide the scan into three sectors: left ($-90°$ to $-30°$), front ($-30°$ to $+30°$), right ($+30°$ to $+90°$). Report the minimum distance in each sector. If the front sector is blocked but left or right is clear, steer toward the clear side.
def classify_scan(scan: dict[float, float]) -> str:
left = min(d for a, d in scan.items() if a < -30)
front = min(d for a, d in scan.items() if -30 <= a <= 30)
right = min(d for a, d in scan.items() if a > 30)
if front < 0.30:
return "left" if left > right else "right"
return "clear"
Gap finding. Identify angular gaps (intervals where distance > threshold) in the scan. Navigate toward the largest gap. This is the basis of the Vector Field Histogram (VFH) algorithm, a precursor to the potential-field methods in Chapter 9.
The ultrasound sensor provides local obstacle information. In the full navigation stack (Chapter 15), obstacle readings feed into the occupancy grid and trigger reactive obstacle avoidance when a new obstacle appears that was not in the map. The key interface is:
(angle_deg, distance_m) pairs.At 17 Hz raw measurement rate, a 19-point scan takes about $19 \times 60$ ms $= 1.14$ s. This is too slow for fast obstacle avoidance. Practical approaches: scan only the frontal $\pm 30°$ (7 positions) for a 420 ms scan, or use the single forward-facing position only (60 ms) when in obstacle-avoidance mode.
Navigation: