Advanced 4: Sensor Fusion
Prerequisites: Lab 4 (IMU & Heading)
Overview
In Lab 4, you used the gyroscope for heading control. But gyroscopes drift over time, and accelerometers are noisy. This module shows how to combine multiple imperfect sensors into something better.
You'll learn: - Why single sensors fail over time - Complementary filter: the practical solution - Kalman filter: the optimal solution (conceptual) - Odometry and why dead reckoning drifts
Part 1: The Sensor Fusion Problem
Every Sensor is Wrong
| Sensor | Good At | Bad At |
|---|---|---|
| Gyroscope | Fast changes | Drifts over time |
| Accelerometer | Absolute tilt | Noisy, affected by motion |
| Magnetometer | Absolute heading | Magnetic interference |
| Wheel encoder | Short distances | Slips, accumulates error |
| GPS | Absolute position | Slow update, poor indoors |
The insight: Different sensors fail in different ways. Combine them!
Short-Term vs Long-Term
Gyroscope error over time:
Error
│ ╱
│ ╱
│ ╱
│ ╱ ← Drift accumulates!
│ ╱
│ ╱
└──╱──────────────► Time
Accelerometer error over time:
Error
│ ╲ ╱╲ ╱╲ ╱╲
│ ╲╱ ╲╱ ╲╱ ╲ ← Noisy but no drift
│
└──────────────────► Time
Strategy: - Trust gyro for short-term (fast, accurate) - Trust accelerometer for long-term (no drift)
Part 2: Complementary Filter
The Idea
Combine high-pass filtered gyro with low-pass filtered accelerometer:
Where: - \(\theta\) = estimated angle - \(\omega\) = gyroscope angular velocity - \(\theta_{accel}\) = angle from accelerometer - \(\alpha\) = filter coefficient (typically 0.95-0.98) - \(dt\) = time step
Intuition
┌─────────────────┐
Gyroscope ──────────►│ High-Pass Filter├──┐
(good short-term) └─────────────────┘ │
▼
┌───┐
│ + │────► Fused Angle
└───┘
┌─────────────────┐ ▲
Accelerometer ──────►│ Low-Pass Filter ├──┘
(good long-term) └─────────────────┘
Implementation
import time
import math
class ComplementaryFilter:
def __init__(self, alpha=0.96):
self.alpha = alpha
self.angle = 0
self.last_time = time.ticks_us()
def update(self, gyro_rate, accel_angle):
"""
Update filter with new measurements.
Args:
gyro_rate: Angular velocity from gyroscope (deg/s)
accel_angle: Angle calculated from accelerometer (deg)
Returns:
Fused angle estimate (deg)
"""
# Calculate time step
now = time.ticks_us()
dt = time.ticks_diff(now, self.last_time) / 1_000_000 # seconds
self.last_time = now
# Prevent huge jumps on first call or after pause
if dt > 0.1:
dt = 0.01
# Complementary filter equation
# Gyro: integrate rate to get angle change
# Accel: direct angle measurement
gyro_angle = self.angle + gyro_rate * dt
self.angle = self.alpha * gyro_angle + (1 - self.alpha) * accel_angle
return self.angle
def reset(self, angle=0):
self.angle = angle
self.last_time = time.ticks_us()
Calculating Angle from Accelerometer
For pitch (tilt forward/back):
For roll (tilt left/right):
def accel_to_angles(ax, ay, az):
"""Convert accelerometer readings to angles (degrees)."""
# Avoid division by zero
if az == 0:
az = 0.001
pitch = math.atan2(ax, math.sqrt(ay*ay + az*az)) * 180 / math.pi
roll = math.atan2(ay, math.sqrt(ax*ax + az*az)) * 180 / math.pi
return pitch, roll
Complete Example
from machine import I2C, Pin
import time
import math
# Assuming you have IMU reading functions
# from your_imu_library import read_gyro, read_accel
class IMUFusion:
def __init__(self, i2c, alpha=0.96):
self.i2c = i2c
self.alpha = alpha
# Initialize complementary filters for pitch and roll
self.pitch_filter = ComplementaryFilter(alpha)
self.roll_filter = ComplementaryFilter(alpha)
# Calibration offsets (measure when stationary)
self.gyro_offset = [0, 0, 0]
self.calibrate()
def calibrate(self, samples=100):
"""Calibrate gyroscope offset."""
print("Calibrating... keep IMU still")
sums = [0, 0, 0]
for _ in range(samples):
gx, gy, gz = self.read_gyro_raw()
sums[0] += gx
sums[1] += gy
sums[2] += gz
time.sleep_ms(10)
self.gyro_offset = [s / samples for s in sums]
print(f"Gyro offset: {self.gyro_offset}")
def read_gyro_raw(self):
"""Read raw gyroscope (implement for your IMU)."""
# Return gx, gy, gz in deg/s
pass
def read_accel_raw(self):
"""Read raw accelerometer (implement for your IMU)."""
# Return ax, ay, az in g
pass
def update(self):
"""Read sensors and update fused angles."""
# Read sensors
gx, gy, gz = self.read_gyro_raw()
ax, ay, az = self.read_accel_raw()
# Apply gyro calibration
gx -= self.gyro_offset[0]
gy -= self.gyro_offset[1]
gz -= self.gyro_offset[2]
# Calculate accelerometer angles
accel_pitch, accel_roll = accel_to_angles(ax, ay, az)
# Update complementary filters
pitch = self.pitch_filter.update(gy, accel_pitch) # gy for pitch
roll = self.roll_filter.update(gx, accel_roll) # gx for roll
return pitch, roll
# Usage
# imu = IMUFusion(i2c, alpha=0.96)
# while True:
# pitch, roll = imu.update()
# print(f"Pitch: {pitch:6.1f} Roll: {roll:6.1f}")
# time.sleep_ms(10)
Choosing Alpha
| Alpha | Trust Gyro | Trust Accel | Best For |
|---|---|---|---|
| 0.99 | 99% | 1% | Very noisy accel, fast motions |
| 0.96 | 96% | 4% | Typical robots (good default) |
| 0.90 | 90% | 10% | More accel trust, slower motions |
| 0.80 | 80% | 20% | Static applications |
Rule of thumb: Higher alpha = more responsive, more drift. Lower alpha = more stable, more noise.
Part 3: Gyroscope Drift Analysis
Why Gyroscopes Drift
- Bias offset: Non-zero output when stationary
- Bias drift: Offset changes over time (temperature, aging)
- Integration accumulates error: Small errors add up!
Quantifying Drift
def measure_gyro_drift(duration_sec=60):
"""Measure gyroscope drift over time."""
print(f"Measuring drift for {duration_sec} seconds...")
print("Keep IMU completely still!")
integrated_angle = 0
last_time = time.ticks_us()
samples = []
start = time.ticks_ms()
while time.ticks_diff(time.ticks_ms(), start) < duration_sec * 1000:
# Read gyro
_, _, gz = read_gyro() # Assuming Z is yaw axis
# Integrate
now = time.ticks_us()
dt = time.ticks_diff(now, last_time) / 1_000_000
last_time = now
integrated_angle += gz * dt
# Record every second
if len(samples) < time.ticks_diff(time.ticks_ms(), start) // 1000:
samples.append(integrated_angle)
print(f"{len(samples):3d}s: {integrated_angle:8.2f} deg")
time.sleep_ms(10)
drift_rate = integrated_angle / duration_sec
print(f"\nTotal drift: {integrated_angle:.2f} deg")
print(f"Drift rate: {drift_rate:.3f} deg/sec = {drift_rate*60:.1f} deg/min")
return drift_rate
Typical Drift Values
| IMU | Typical Drift | Notes |
|---|---|---|
| MPU6050 | 0.5-2 deg/sec | Consumer grade |
| BMI160 | 0.1-0.5 deg/sec | Better consumer |
| ADXRS450 | 0.01 deg/sec | Industrial grade |
| Fiber optic gyro | 0.001 deg/hour | Navigation grade ($$$) |
For your robot at 1 deg/sec drift: - After 1 minute: 60° error! - After 10 minutes: 600° error (multiple full rotations)
Part 4: Kalman Filter (Conceptual)
Why Kalman?
The complementary filter is simple but not optimal. The Kalman filter: - Adapts to changing conditions - Provides uncertainty estimates - Is mathematically optimal (under assumptions)
The Kalman Idea
Predict: Use model to predict next state Update: Correct prediction using measurement
┌────────────────────────────────────────────────┐
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Predict │────────►│ State │◄───┐ │
│ └─────────┘ └─────────┘ │ │
│ ▲ │ │
│ │ ┌─────┴─────┐ │
│ ┌────┴────┐ │ Update │ │
│ │ Model │ └───────────┘ │
│ └─────────┘ ▲ │
│ │ │
│ ┌───────┴─────┐ │
│ │ Measurement │ │
│ └─────────────┘ │
│ │
└───────────────────────────────────────────────┘
Simplified 1D Kalman
For angle estimation:
class SimpleKalman:
def __init__(self, q=0.001, r=0.03):
"""
Args:
q: Process noise (how much we trust the model)
r: Measurement noise (how much we trust sensors)
"""
self.q = q # Process variance
self.r = r # Measurement variance
self.x = 0 # State estimate
self.p = 1 # Estimate uncertainty
def update(self, measurement, control_input=0, dt=0.01):
"""
Update filter with measurement.
Args:
measurement: Sensor reading
control_input: Known input (e.g., gyro rate)
dt: Time step
"""
# Predict
self.x = self.x + control_input * dt # Predicted state
self.p = self.p + self.q # Predicted uncertainty
# Update
k = self.p / (self.p + self.r) # Kalman gain
self.x = self.x + k * (measurement - self.x) # Updated state
self.p = (1 - k) * self.p # Updated uncertainty
return self.x
Full Kalman for IMU
A proper IMU Kalman filter estimates: - Angle (pitch, roll, yaw) - Angular velocity - Gyro bias (learns the drift!)
This requires matrix math - beyond our scope, but libraries exist:
- MicroPython: fusion library
- Arduino: Kalman library
- Python: filterpy library
Complementary vs Kalman
| Aspect | Complementary | Kalman |
|---|---|---|
| Simplicity | Very simple | Complex |
| Tuning | One parameter (α) | Multiple parameters |
| Adaptivity | Fixed | Adapts to conditions |
| Bias estimation | No | Yes (can learn drift) |
| Uncertainty | No estimate | Provides estimate |
| CPU usage | Minimal | Higher |
| Performance | Good | Optimal |
For your robot: Complementary filter is usually sufficient!
Part 5: Odometry and Dead Reckoning
What is Odometry?
Estimating position from motion measurements:
The Math
Given wheel velocities \(v_L\) and \(v_R\):
def update_odometry(v_left, v_right, dt, wheelbase):
"""
Update robot position estimate.
Args:
v_left, v_right: Wheel velocities (m/s)
dt: Time step (s)
wheelbase: Distance between wheels (m)
Returns:
dx, dy, dtheta: Position change
"""
# Average velocity and angular velocity
v = (v_left + v_right) / 2
omega = (v_right - v_left) / wheelbase
# Position change in robot frame
if abs(omega) < 0.001:
# Straight line
dx_robot = v * dt
dy_robot = 0
else:
# Arc
radius = v / omega
dx_robot = radius * math.sin(omega * dt)
dy_robot = radius * (1 - math.cos(omega * dt))
dtheta = omega * dt
return dx_robot, dy_robot, dtheta
Why Dead Reckoning Drifts
Errors accumulate because: 1. Wheel slip: Wheels don't grip perfectly 2. Measurement noise: Encoder resolution limits 3. Systematic errors: Wheel diameter differences 4. Integration: Errors compound over time
Reducing Odometry Drift
- Calibrate carefully: Measure actual wheel diameters
- Use encoders: Better than timing-based estimates
- Fuse with absolute sensors: IMU for heading, landmarks for position
- Periodic reset: Return to known position
class RobotOdometry:
def __init__(self, wheelbase, wheel_radius):
self.wheelbase = wheelbase
self.wheel_radius = wheel_radius
self.x = 0
self.y = 0
self.theta = 0 # Heading from IMU (not integrated from wheels!)
def update(self, left_encoder, right_encoder, imu_heading, dt):
"""Update position using encoders + IMU heading."""
# Wheel velocities from encoders
v_left = left_encoder.velocity()
v_right = right_encoder.velocity()
# Use IMU for heading (more accurate than wheel-based)
self.theta = imu_heading
# Forward velocity
v = (v_left + v_right) / 2
# Update position
self.x += v * math.cos(self.theta) * dt
self.y += v * math.sin(self.theta) * dt
def reset(self, x=0, y=0, theta=0):
self.x = x
self.y = y
self.theta = theta
Part 6: Multi-Sensor Fusion Architecture
Typical Robot Sensor Suite
┌─────────────────────────────────────────────────────────┐
│ Robot Sensors │
├─────────────┬─────────────┬─────────────┬──────────────┤
│ Encoders │ IMU │ Ultrasonic │ Line Sensors │
│ (velocity) │ (orientation)│ (distance) │ (position) │
└──────┬──────┴──────┬──────┴──────┬──────┴──────┬───────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Sensor Fusion │
│ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │
│ │ Odometry │ │Comp. Filter│ │ Obstacle Detection │ │
│ │ (position) │ │ (heading) │ │ (distance) │ │
│ └─────┬──────┘ └─────┬──────┘ └─────────┬──────────┘ │
└────────┼───────────────┼───────────────────┼────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Robot State │
│ x, y, theta, velocity, obstacles nearby │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Control System │
│ (uses fused state for decisions) │
└──────────────────────────────────────────────────────────┘
Sensor Trust Levels
Assign confidence based on conditions:
class AdaptiveFusion:
def __init__(self):
self.accel_trust = 1.0
def update_trust(self, ax, ay, az):
"""Reduce accelerometer trust during high acceleration."""
# Calculate total acceleration magnitude
accel_mag = math.sqrt(ax*ax + ay*ay + az*az)
# If accelerating hard, don't trust accel for angle
# (should be ~1g when stationary)
if abs(accel_mag - 1.0) > 0.2:
self.accel_trust = 0.5 # Reduce trust
else:
self.accel_trust = 1.0 # Full trust
def fuse(self, gyro_angle, accel_angle):
"""Fuse with adaptive trust."""
# Adjust alpha based on trust
effective_alpha = 0.96 + 0.03 * (1 - self.accel_trust)
return effective_alpha * gyro_angle + (1 - effective_alpha) * accel_angle
Mini-Project: Stable Heading System
Part A: Implement Complementary Filter
- Implement the
ComplementaryFilterclass - Connect to your IMU
- Test with manual rotation
- Compare raw gyro integration vs fused angle
Part B: Measure Drift
- Run gyro-only integration for 60 seconds
- Measure total drift
- Run complementary filter for 60 seconds
- Compare drift reduction
Part C: Tune Alpha
- Test alpha values: 0.90, 0.95, 0.98, 0.99
- Measure noise (standard deviation when still)
- Measure drift (error after 60 seconds)
- Find optimal alpha for your IMU
Part D: Heading-Controlled Maneuvers
- Implement heading control using fused angle
- Drive a square (90° turns)
- Measure final position error
- Compare with gyro-only heading
Deliverable
Report including: - Complementary filter implementation - Drift comparison (gyro vs fused) - Alpha tuning results table - Square maneuver accuracy data - Conclusions on sensor fusion benefits
Key Takeaways
- No sensor is perfect - all have different failure modes
- Combine short-term and long-term - gyro + accel = stable angle
- Complementary filter is simple and effective - great for robots
- Kalman is optimal but complex - use libraries if needed
- Dead reckoning drifts - fuse with absolute references
- Adapt trust to conditions - don't trust accel during acceleration
Further Reading
- Complementary Filter Tutorial - Practical implementation guide
- Kalman Filter Explained - Intuitive visual explanation
- Sensor Fusion on Mobile Robots - CMU robotics chapter
← Back: Control Theory | Advanced Track Overview | Next: Software Architecture →