Mobile Robots and Odometry
How wheeled robots move and estimate where they are: differential drive and holonomic robots, wheel odometry from encoder ticks, and why dead reckoning always drifts.
What is a mobile robot?
A mobile robot is a robot that moves its whole body through its surroundings, instead of working from a fixed base like an industrial arm. Warehouse robots that carry shelves, robot vacuum cleaners, lawn mowers, hospital delivery carts, inspection robots on legs and drones are all mobile robots. The kinds and their markets are on What Is Robotics? and Robots at Work.
Being mobile adds a question that a bolted-down arm never has to ask: where am I? An arm knows where its base is because someone measured it once. A mobile robot must keep estimating its own pose while it drives: on a flat floor that is its position (x, y) and its heading \theta (theta), the direction it faces, in radians, measured counterclockwise from the x-axis. This page covers the first and cheapest way to do that, counting wheel turns. The next pages correct its errors (Localization and Mapping) and use the result to plan a route (Path Planning).
Most indoor mobile robots roll on wheels, because wheels are efficient and simple to control on flat floors. Legs handle stairs and rubble but need far more control effort, and tracks suit soft ground. The rest of this page is about wheels.
Differential drive
The most common wheeled design is the differential drive: two driven wheels on one axle, each with its own motor, plus one or two free-turning caster wheels for balance. There is no steering mechanism. To turn, the robot drives one wheel faster than the other, exactly like a tank or a wheelchair. Robot vacuum cleaners, many warehouse robots and nearly every hobby robot car work this way.
From wheel speeds to robot motion
Two numbers describe the robot: the wheel radius r and the track width b, the distance between the two wheels' contact points. If the wheels turn at \omega_L and \omega_R radians per second (ω is the Greek letter omega), the robot's forward speed v and its turn rate \omega are:
The forward speed is the average of the two wheels' ground speeds. The turn rate is their difference spread over the track width: a difference of 1 m/s across a 0.16 m axle turns the robot at 6.25 radians per second. A positive \omega is a left turn (counterclockwise seen from above), the convention ROS uses too. While both speeds stay constant the robot drives a circle of radius R = v/\omega.
The examples on this page use a small robot with realistic numbers: wheels of 0.033 m radius, a track width of 0.16 m, and encoders that count 1440 ticks per wheel revolution.
R = 0.033 # wheel radius in meters
B = 0.16 # track width: distance between the two wheels, in meters
def body_velocity(w_left, w_right):
"""Wheel speeds (rad/s) -> forward speed v (m/s) and turn rate w (rad/s)."""
v = R * (w_right + w_left) / 2
w = R * (w_right - w_left) / B
return v, w
for w_left, w_right in [(10, 10), (10, 12), (0, 10), (-5, 5)]:
v, w = body_velocity(w_left, w_right)
if w == 0:
motion = "straight line"
elif v == 0:
motion = "turns in place"
else:
motion = f"circle of radius {v / w:.3f} m"
print(f"wL={w_left:3} wR={w_right:3} rad/s -> v={v:.4f} m/s w={w:.4f} rad/s {motion}")
wL= 10 wR= 10 rad/s -> v=0.3300 m/s w=0.0000 rad/s straight line wL= 10 wR= 12 rad/s -> v=0.3630 m/s w=0.4125 rad/s circle of radius 0.880 m wL= 0 wR= 10 rad/s -> v=0.1650 m/s w=2.0625 rad/s circle of radius 0.080 m wL= -5 wR= 5 rad/s -> v=0.0000 m/s w=2.0625 rad/s turns in place
The last two lines show the two special cases. With one wheel stopped the robot pivots around that wheel, so its center moves on a circle of half the track width (0.08 m). With the wheels turning in opposite directions at the same speed it spins on the spot, which is why differential-drive robots are so handy in narrow aisles.
Commanding a robot
Navigation software thinks the other way around: it decides "drive at 0.2 m/s and turn at 0.5 rad/s" and needs the two wheel speeds that produce that motion. Solving the two equations above for the wheels gives:
In ROS 2 this pair of numbers, a forward speed and a turn rate, is typically what a navigation stack or a joystick sends to the robot's base; the base driver converts it to wheel speeds with these formulas and hands them to the motor controllers (see ROS 2 and Motion Control and PID).
import math
R = 0.033
B = 0.16
def wheel_speeds(v, w):
"""Forward speed v (m/s) and turn rate w (rad/s) -> wheel speeds (rad/s)."""
w_left = (v - w * B / 2) / R
w_right = (v + w * B / 2) / R
return w_left, w_right
def rpm(rad_per_s):
return rad_per_s * 60 / (2 * math.pi)
for v, w in [(0.2, 0.0), (0.2, 0.5), (0.0, 1.0)]:
wl, wr = wheel_speeds(v, w)
print(f"v={v} m/s w={w} rad/s -> left {wl:6.3f} rad/s ({rpm(wl):6.1f} rpm), "
f"right {wr:6.3f} rad/s ({rpm(wr):6.1f} rpm)")
v=0.2 m/s w=0.0 rad/s -> left 6.061 rad/s ( 57.9 rpm), right 6.061 rad/s ( 57.9 rpm) v=0.2 m/s w=0.5 rad/s -> left 4.848 rad/s ( 46.3 rpm), right 7.273 rad/s ( 69.4 rpm) v=0.0 m/s w=1.0 rad/s -> left -2.424 rad/s ( -23.1 rpm), right 2.424 rad/s ( 23.1 rpm)
A real base also has limits: each motor has a top speed, so a request for a fast forward speed and a sharp turn may need both numbers scaled down together to keep the curve's shape. Wheel speeds in rpm are useful for picking a gear motor (see Motors and Drives).
Holonomic and non-holonomic robots
A differential-drive robot can drive forward and turn, but it cannot slide sideways: its wheels would have to skid. A robot with that kind of restriction on its velocity (not on where it can end up) is called non-holonomic. It can still reach every position and heading on the floor, just not in a straight line: to move half a meter to its left it must turn, drive and turn back, or make a series of shuffles. Cars are the familiar example, and parallel parking is the price.
A holonomic robot can move in any direction at any moment, sideways included, while turning or not. On the ground this needs special wheels:
- Mecanum wheels carry small rollers at 45° around their rim. Four of them, each with its own motor, let the robot drive forward, sideways or diagonally depending on which way each wheel turns. They are common on factory carts that must slide into tight stations.
- Omni wheels have rollers at 90°, so a wheel can roll freely sideways; three or four of them at different angles make a holonomic base.
Holonomic bases are easier to plan for (any straight line is a possible move) but cost more, slip more and dislike rough floors. A third design, Ackermann steering, is the car layout: steered front wheels, with the drive on the front or rear axle. It is non-holonomic too, and it cannot turn in place, so its planner must respect a minimum turning radius.
Wheel odometry
Odometry is estimating how far and in which direction a robot has moved from measurements of its own motion. The cheapest source is wheel odometry: an encoder on each wheel (or on each motor, before the gearbox) counts ticks as the wheel turns (see Sensors for Robots). Every few milliseconds the robot reads how many ticks each wheel has moved since the last reading and turns them into distances:
where N is the number of ticks per wheel revolution and n_L, n_R the ticks counted. With r = 0.033 m and N = 1440, one tick is about 0.144 mm of travel. From the two distances follow the distance the center moved and the change in heading, and from those the new pose:
The cosine and sine split the distance into an x and a y part (see sine, cosine and tangent). The heading used is the average of the old and new heading, \theta + \Delta\theta/2: during a short update the robot drives a small arc, and its average direction is the middle one. This is called midpoint integration.
import math
R = 0.033 # wheel radius, m
B = 0.16 # track width, m
TICKS = 1440 # encoder counts per wheel revolution
M_PER_TICK = 2 * math.pi * R / TICKS
print(f"one tick = {M_PER_TICK * 1000:.4f} mm, one meter = {1 / M_PER_TICK:.1f} ticks")
def odometry_step(pose, ticks_left, ticks_right):
"""Update the pose (x, y, theta) from the ticks counted since the last update."""
x, y, theta = pose
d_left = ticks_left * M_PER_TICK
d_right = ticks_right * M_PER_TICK
d_center = (d_left + d_right) / 2
d_theta = (d_right - d_left) / B
x += d_center * math.cos(theta + d_theta / 2) # midpoint: average heading
y += d_center * math.sin(theta + d_theta / 2)
return (x, y, theta + d_theta)
def show(pose):
x, y, theta = pose
return f"x={x:.4f} m y={y:.4f} m theta={math.degrees(theta):.2f} deg"
print("both wheels 1 rev: ", show(odometry_step((0.0, 0.0, 0.0), 1440, 1440)))
print("right wheel 1 rev: ", show(odometry_step((0.0, 0.0, 0.0), 0, 1440)))
one tick = 0.1440 mm, one meter = 6944.9 ticks both wheels 1 rev: x=0.2073 m y=0.0000 m theta=0.00 deg right wheel 1 rev: x=0.0827 m y=0.0626 m theta=74.25 deg
Why the updates must be small
The second result above is slightly wrong, and the reason matters. The formulas treat each update as one short arc. A whole wheel revolution in one update is not short: the robot really pivoted around its left wheel through 74°, and its center ended up somewhere else. Split the same ticks into more updates and the answer converges on the exact arc:
import math
R, B, TICKS = 0.033, 0.16, 1440
M_PER_TICK = 2 * math.pi * R / TICKS
def odometry_step(pose, ticks_left, ticks_right):
x, y, theta = pose
d_left, d_right = ticks_left * M_PER_TICK, ticks_right * M_PER_TICK
d_center, d_theta = (d_left + d_right) / 2, (d_right - d_left) / B
x += d_center * math.cos(theta + d_theta / 2)
y += d_center * math.sin(theta + d_theta / 2)
return (x, y, theta + d_theta)
# The left wheel stands still while the right wheel turns once: in reality the
# robot pivots around its left wheel, so the center moves on a circle of B/2.
angle = 2 * math.pi * R / B
exact = (B / 2 * math.sin(angle), B / 2 * (1 - math.cos(angle)))
print(f"exact arc: x={exact[0]:.4f} y={exact[1]:.4f}")
for updates in (1, 10, 100):
pose = (0.0, 0.0, 0.0)
for _ in range(updates):
pose = odometry_step(pose, 0, 1440 / updates)
error_mm = math.dist(pose[:2], exact) * 1000
print(f"{updates:3} update(s): x={pose[0]:.4f} y={pose[1]:.4f} error {error_mm:.3f} mm")
exact arc: x=0.0770 y=0.0583 1 update(s): x=0.0827 y=0.0626 error 7.104 mm 10 update(s): x=0.0771 y=0.0583 error 0.068 mm 100 update(s): x=0.0770 y=0.0583 error 0.001 mm
This is the same idea as approximating an area with many thin rectangles (see Riemann sums): odometry is an integral of the wheel speeds, computed in small steps. Robot bases therefore update odometry tens to hundreds of times per second, so each update covers a fraction of a degree.
Dead reckoning
Wheel odometry is a form of dead reckoning: estimating the current position from a known starting point plus the measured motion since then, without looking at the surroundings. Sailors did it for centuries with a compass, a log line and a clock; an inertial navigation system does it with accelerometers and gyroscopes; a robot does it with wheel encoders, often helped by the gyroscope in an inertial measurement unit (IMU) for the heading.
Dead reckoning has two great strengths. It needs nothing outside the robot, so it works in the dark, in fog and in a featureless corridor. And it is smooth: the estimate changes a little at a time and never jumps, which is exactly what a speed or heading controller needs over the next fraction of a second. Its weakness is the subject of the next section.
Why odometry drifts
Every odometry update carries a small error, and the next update starts from the result. Errors are never taken back, only added to. That growth of the error with distance driven is odometry drift, and it has two kinds of causes:
- Random errors: a wheel slips on a smooth tile, spins on a cable, rolls over a crumb or a floor joint; the encoder counts ticks the floor never saw, or misses movement.
- Systematic errors: the real wheel radius differs from the number in the software (worn or soft tires, unequal wheels, a heavier load), or the real track width differs (wide tires touch the floor across a band, not at a point). These errors have the same sign every time.
Random errors pile up
The example drives a 1 m square four times. The controller stops each move when the encoders report the right number of ticks, so odometry believes the robot made perfect squares. The "actual" robot gets a small random slip on every wheel reading (2% standard deviation, seeded so the result is repeatable, see random seeds). Then the same experiment runs 100 times to show the average trend:
import math
import random
import statistics
R, B, TICKS = 0.033, 0.16, 1440
M_PER_TICK = 2 * math.pi * R / TICKS
def move(pose, d_left, d_right):
"""Midpoint pose update from the distances the two wheels rolled."""
x, y, theta = pose
d_center, d_theta = (d_left + d_right) / 2, (d_right - d_left) / B
x += d_center * math.cos(theta + d_theta / 2)
y += d_center * math.sin(theta + d_theta / 2)
return (x, y, theta + d_theta)
def pieces(total, sign_left):
"""Split a move into encoder readings of at most 20 ticks per wheel."""
out = []
while total > 0:
n = min(20, total)
out.append((sign_left * n, n))
total -= n
return out
# One lap of a 1 m square: 1 m straight, then 90 degrees in place, four times.
STRAIGHT = round(1.0 / M_PER_TICK) # 6945 ticks per wheel
TURN = round(B / 2 * math.pi / 2 / M_PER_TICK) # 873 ticks per wheel
LAP = (pieces(STRAIGHT, +1) + pieces(TURN, -1)) * 4
def drive(laps, slip_sd):
"""Error (m) between what odometry believes and where the robot is, per lap."""
believed = actual = (0.0, 0.0, 0.0)
errors = []
for _ in range(laps):
for ticks_left, ticks_right in LAP:
d_left, d_right = ticks_left * M_PER_TICK, ticks_right * M_PER_TICK
believed = move(believed, d_left, d_right)
# The encoders count ticks; the floor decides how far the wheel really rolled.
actual = move(actual, d_left * (1 + random.gauss(0, slip_sd)),
d_right * (1 + random.gauss(0, slip_sd)))
errors.append(math.dist(believed[:2], actual[:2]))
return errors
random.seed(7)
one_run = drive(4, 0.02)
print("one run, error after laps 1-4:", ", ".join(f"{e:.3f} m" for e in one_run))
runs = [drive(8, 0.02) for _ in range(100)]
for lap in (1, 2, 4, 8):
mean = statistics.mean(run[lap - 1] for run in runs)
print(f"mean error over 100 runs after {lap} lap(s), {4 * lap:2} m driven: {mean:.3f} m")
one run, error after laps 1-4: 0.022 m, 0.030 m, 0.039 m, 0.040 m mean error over 100 runs after 1 lap(s), 4 m driven: 0.017 m mean error over 100 runs after 2 lap(s), 8 m driven: 0.022 m mean error over 100 runs after 4 lap(s), 16 m driven: 0.031 m mean error over 100 runs after 8 lap(s), 32 m driven: 0.045 m
A single run wanders: random errors sometimes cancel. On average the error keeps growing, roughly with the square root of the distance (eight times the distance gives about 2.6 times the error here), the typical behavior of a sum of independent random errors (see spread). Nothing in the robot can notice it: the odometry still reports a perfect square.
Systematic errors are worse
A systematic error has the same sign on every update, so it never cancels. Two classic cases: one tire is 0.5% larger than the software thinks, and the track width is 2% wider than the number in the configuration:
import math
B = 0.16 # the track width the software assumes, m
def move(pose, d_left, d_right, track=B):
x, y, theta = pose
d_center, d_theta = (d_left + d_right) / 2, (d_right - d_left) / track
x += d_center * math.cos(theta + d_theta / 2)
y += d_center * math.sin(theta + d_theta / 2)
return (x, y, theta + d_theta)
# 1. The right tire is 0.5% larger than the software thinks. The controller drives
# both wheels the same number of ticks, so odometry believes it goes straight.
for meters in (1, 2, 5, 10):
pose = (0.0, 0.0, 0.0)
for _ in range(meters * 100): # 1 cm pieces
pose = move(pose, 0.01, 0.01 * 1.005)
print(f"{meters:2} m 'straight': {pose[1]:.3f} m to the left, "
f"heading {math.degrees(pose[2]):.1f} deg")
# 2. The real track width is 2% wider than assumed. Odometry counts a perfect
# 90-degree turn; the robot really turns less.
wheel_arc = B / 2 * math.pi / 2 # what each wheel rolls
pose = move((0.0, 0.0, 0.0), -wheel_arc, wheel_arc, track=B * 1.02)
print(f"commanded 90.0 deg, turned {math.degrees(pose[2]):.1f} deg; "
f"after four turns {4 * (90 - math.degrees(pose[2])):.1f} deg short")
1 m 'straight': 0.016 m to the left, heading 1.8 deg 2 m 'straight': 0.063 m to the left, heading 3.6 deg 5 m 'straight': 0.391 m to the left, heading 9.0 deg 10 m 'straight': 1.554 m to the left, heading 17.9 deg commanded 90.0 deg, turned 88.2 deg; after four turns 7.1 deg short
A tire difference you could not see by eye puts the robot more than 1.5 m off course after 10 m. Notice how the sideways error grows with the square of the distance: the heading error grows with distance, and the sideways error is the heading error accumulated over the distance again. Roughly, a heading error \delta\theta held over a distance d moves the robot sideways by
so 1° of heading error over 10 m is already 0.17 m. This is why heading matters most, why robots often fuse wheel odometry with a gyroscope (which measures turning directly and does not care about tire size), and why calibrating wheel radius and track width is worth an afternoon. A common way to calibrate the track width is to let the robot spin in place ten times on a marked floor and compare the real angle with the odometry's angle.
Odometry in a real robot
Because odometry is smooth but drifts, while position fixes from a map are accurate but jumpy, robots keep both and separate them in their frames. ROS describes this in REP 105, its convention for mobile-robot frames (see Coordinate Frames and Transforms):
base_linkis attached to the robot's body.-
odomis a world-fixed frame in which the robot's pose moves continuously, without jumps, as odometry reports it. REP 105 accepts that it drifts. -
mapis a world-fixed frame in which the robot's pose should stay accurate over time. The localization system, which compares sensor readings with a map, publishes the offset betweenmapandodom, and that offset is allowed to jump when it corrects the accumulated drift.
A speed controller works in odom (smooth); a planner
that must reach a door works in map (accurate). How the
localization system finds the correction is the subject of
Localization and Mapping.
Getting better odometry
Odometry can never be made drift-free, but it can be made good enough for the gaps between corrections:
- Calibrate the wheel radius (drive a measured straight line) and the track width (spin in place several times), and re-check after changing tires or load.
- Measure heading with a gyroscope. An IMU's gyroscope drifts too, but slowly, and it is immune to wheel slip and tire size; fusing both is common practice.
- Use high-resolution encoders and frequent updates so that quantization and the arc approximation stay negligible.
- Drive gently. Hard acceleration and braking make wheels slip; a smooth velocity profile (see Motion Control and PID) helps odometry as much as it helps the motors.
- Add other motion sources. Visual odometry tracks how camera images shift between frames, and lidar odometry matches successive scans (see Robot Vision and Sensors for Robots). They drift too, but differently, so combining them helps.