Pure pursuit path following explained

How pure pursuit steers a robot along a path: the look-ahead point, the arc formula 2 sin a / L, cross-track error and how to choose the look-ahead distance. Four live demos show smooth tracking, weaving, corner cutting and rejoining the path.

Guidefree, runs in your browser

Pure pursuit is a way of steering a robot or a car along a path. It picks a point on the path a fixed distance ahead of the robot, works out the curve that would carry the robot to that point, and steers along that curve for a moment before picking a new point. It came out of Carnegie Mellon University's self-driving vehicle work in the 1980s, and it is still the path follower in ROS 2's navigation stack (Nav2's Regulated Pure Pursuit controller) and on many FIRST Robotics Competition robots. On this page a small robot follows a taped path on a 2 metre mat, and each demo below is a real program you can change and run.

In the overhead view of each demo, the blue line is where the robot has been, the red dot is the point it is chasing (the look-ahead point) and the red curve is the arc it has been told to drive to reach it. The chart shows the cross-track error: how far the robot is from the path, in cm, plus when it is to the left of the path and minus when it is to the right. A good follower keeps that line close to zero.

The idea in one loop

each tick:
    find the nearest point on the path to the robot
    walk L further along the path: the look-ahead point
    find the arc that leaves the robot along its heading
        and passes through the look-ahead point
    drive along that arc until the next tick

L is the look-ahead distance, and it is the one number in pure pursuit you have to choose. Almost everything on this page is about what happens when it is too small or too big.

The name comes from the chase: the robot pursues a point that keeps moving away from it along the path, the way a dog chases a hare, and it never catches it.

The arc through the look-ahead point

The robot is facing along its heading. The look-ahead point is a distance L away from it, at an angle α (alpha) to that heading. There is exactly one circle that touches the robot's heading at the robot and also passes through the point, and pure pursuit drives round it.

Draw the centre of that circle, the robot and the point. The two radii make an isosceles triangle with the line from the robot to the point (a chord of length L) as its base. The radius at the robot is at right angles to the heading, so the angle between the chord and the radius is 90 − α, and the angle at the centre is . Half the chord is then R sin α, which gives:

L / 2 = R × sin α
R     = L / (2 sin α)
curvature = 1 / R = 2 sin α / L

Curvature is how sharply a path bends: one over the radius, so a straight line has curvature 0. To drive round a circle of curvature k at speed v, the robot turns at ω = v × k radians per second. A car turns its wheels to the angle atan(wheelbase × k) instead, and a two-wheeled robot sets its wheel speeds to v × (1 ± k × half the track width). Every version comes from the same curvature.

In the demos it is three lines:

aim = math.degrees(math.atan2(tx - x, ty - y))
alpha = (aim - heading() + 180) % 360 - 180
curve = 2 * math.sin(math.radians(alpha)) / L

atan2 gives the direction from the robot at (x, y) to the point at (tx, ty), taking away the heading gives α (wrapped to between −180 and 180 degrees), and the last line is the formula. There are no gains to tune. The geometry sets how hard the robot turns.

The BugBot can drive sideways as well as forwards, but on this page it drives like a car: forwards and turning only. That is the robot pure pursuit was designed for, and it shows the behaviour you will meet on real vehicles.

Finding the look-ahead point

Textbooks often find the look-ahead point as the place where a circle of radius L round the robot crosses the path. That works while the robot is close to the path, and fails when it is more than L away, because then the circle does not cross the path at all.

The demos do it in two steps instead. First they find the nearest point on the path to the robot, and how far along the path that point is (its arc length, s). Then they walk L further along the path and take the point there. That always gives an answer, however far off the path the robot is, and the point is always ahead of the robot's place on the path.

The path is a list of points joined by straight lines. The nearest point on each line comes from a dot product, and the arc length is a running total of the line lengths. The bend in the tape is nine points round a quarter circle, which is close enough to the real curve.

Cross-track error

The cross-track error is the distance from the robot to the nearest point on the path, measured at right angles to the path. It has a sign, so you can tell which side the robot is on. The demos work it out with a cross product: positive to the left of the path, negative to the right.

Pure pursuit never uses the cross-track error to steer. It only uses the look-ahead point. The demos plot the cross-track error because it is the fair way to judge any path follower: it measures how far off the tape the robot is, whatever method is steering it.

Pure pursuit on a bend

The tape runs 80 cm straight up the mat, turns right round a quarter circle of radius 40 cm, and runs 70 cm to the right. The robot starts on the tape, facing along it, and drives at 14 cm/s with a look-ahead of 18 cm.

L = 18 cm at 14 cm/s: the robot starts turning before the bend, cuts inside it by 2.4 cm at worst and ends within 1 cm of the tape.
The program
from bugbot import *
import math
connect()

# change these numbers and press Run
LOOK = 18        # look-ahead distance, cm
SPEED = 14       # cm/s

START = (40, 40)     # where the robot starts
# the tape: 80 cm straight up, a quarter circle
# of radius 40 round to the right, 70 cm on
PATH = [(40, 40)]
for k in range(9):
    a = math.radians(180 - k * 11.25)
    PATH.append((80 + 40 * math.cos(a),
                 120 + 40 * math.sin(a)))
PATH.append((150, 160))

# the arc length at each corner of the path
cum = [0]
for p, q in zip(PATH, PATH[1:]):
    cum.append(cum[-1] + math.dist(p, q))
TOTAL = cum[-1]

def project(x, y):
    # the nearest point on the path: how far along
    # it is, and the cross-track error in cm
    # (+ left of the path, - right of it)
    best, at, cross = 1e9, 0, 0
    for i in range(len(PATH) - 1):
        (ax, ay), (bx, by) = PATH[i], PATH[i + 1]
        vx, vy = bx - ax, by - ay
        seg = math.hypot(vx, vy)
        t = ((x - ax) * vx + (y - ay) * vy) / seg ** 2
        t = max(0, min(1, t))
        qx, qy = ax + t * vx, ay + t * vy
        d = math.hypot(x - qx, y - qy)
        if d < best:
            best, at = d, cum[i] + t * seg
            side = vx * (y - qy) - vy * (x - qx)
            cross = side / seg
    return at, cross

def point_at(s):
    # the point s cm along the path
    s = max(0, min(TOTAL, s))
    for i in range(len(PATH) - 1):
        if s <= cum[i + 1]:
            (ax, ay), (bx, by) = PATH[i], PATH[i + 1]
            u = (s - cum[i]) / (cum[i + 1] - cum[i])
            return (ax + u * (bx - ax),
                    ay + u * (by - ay))
    return PATH[-1]

def arc(x, y, h, alpha, L):
    # the arc from the robot to the point, to draw
    a, h = math.radians(alpha), math.radians(h)
    sh, ch = math.sin(h), math.cos(h)
    r = L / (2 * math.sin(a)) if abs(a) > 1e-3 else 0
    pts = []
    for i in range(13):
        t = 2 * a * i / 12
        f = r * math.sin(t) if r else L * i / 12
        s = r * (1 - math.cos(t))
        pts.append((x + f * sh + s * ch,
                    y + f * ch - s * sh))
    return pts

def push(p):
    # under 15 % the robot does not move at all
    if abs(p) < 5:
        return 0
    return math.copysign(max(17, min(100, abs(p))), p)

trail, worst = [], 0
while True:
    px, py = position()
    x, y = START[0] + px, START[1] + py
    s, cross = project(x, y)
    if s > TOTAL - 3:
        break
    # 1. the look-ahead point, LOOK cm further on
    tx, ty = point_at(s + LOOK)
    # 2. its angle from the way the robot faces
    aim = math.degrees(math.atan2(tx - x, ty - y))
    alpha = (aim - heading() + 180) % 360 - 180
    # 3. the arc through it: curvature 2 sin(a) / L
    L = math.hypot(tx - x, ty - y)
    curve = 2 * math.sin(math.radians(alpha)) / L
    v = min(SPEED, TOTAL - s)     # slow at the end
    turn = math.degrees(v * curve)        # deg/s
    # 20 cm/s and 120 deg/s are 100 %
    drive(push(v * 5), 0, push(turn / 1.2))

    trail.append((x, y))
    draw("robot", trail, "blue", "line")
    draw("arc", arc(x, y, heading(), alpha, L),
         "red", "line")
    draw("point", [(tx, ty)], "red", size=4)
    plot("cross-track cm", cross)
    worst = max(worst, abs(cross))
    wait(0.1)
stop()
print("worst cross-track:", round(worst, 1), "cm")
Put this demo on your own site

Paste it into a school website, Moodle, Google Sites or a blog. More options on the embed page.

Watch the red dot. It reaches the bend while the robot is still 18 cm short of it, and from then on the red arc curves to the right, so the robot starts turning before it reaches the bend, as a driver does. It cuts the inside of the bend a little, by 2.4 cm at worst, near the end of the bend, and then comes back to the tape.

The chart also shows the robot sitting just under 1 cm to the left of the tape on both straights. This robot drifts a little sideways as it drives. At L = 18 and 14 cm/s, being 1 cm off the tape asks for a turn of about 4 percent, and the push function rounds anything under 5 percent down to nothing, because the motors ignore small commands anyway. So the robot does not start to correct until it is about 1 cm off.

Too short: the robot weaves

The same program with L = 3.

L = 3 cm: from about 3.5 s the robot weaves across the tape, up to 4.1 cm either side, with the red arc swinging from hard left to hard right.
The program
from bugbot import *
import math
connect()

# change these numbers and press Run
LOOK = 3         # look-ahead distance, cm
SPEED = 14       # cm/s

START = (40, 40)     # where the robot starts
# the tape: 80 cm straight up, a quarter circle
# of radius 40 round to the right, 70 cm on
PATH = [(40, 40)]
for k in range(9):
    a = math.radians(180 - k * 11.25)
    PATH.append((80 + 40 * math.cos(a),
                 120 + 40 * math.sin(a)))
PATH.append((150, 160))

# the arc length at each corner of the path
cum = [0]
for p, q in zip(PATH, PATH[1:]):
    cum.append(cum[-1] + math.dist(p, q))
TOTAL = cum[-1]

def project(x, y):
    # the nearest point on the path: how far along
    # it is, and the cross-track error in cm
    # (+ left of the path, - right of it)
    best, at, cross = 1e9, 0, 0
    for i in range(len(PATH) - 1):
        (ax, ay), (bx, by) = PATH[i], PATH[i + 1]
        vx, vy = bx - ax, by - ay
        seg = math.hypot(vx, vy)
        t = ((x - ax) * vx + (y - ay) * vy) / seg ** 2
        t = max(0, min(1, t))
        qx, qy = ax + t * vx, ay + t * vy
        d = math.hypot(x - qx, y - qy)
        if d < best:
            best, at = d, cum[i] + t * seg
            side = vx * (y - qy) - vy * (x - qx)
            cross = side / seg
    return at, cross

def point_at(s):
    # the point s cm along the path
    s = max(0, min(TOTAL, s))
    for i in range(len(PATH) - 1):
        if s <= cum[i + 1]:
            (ax, ay), (bx, by) = PATH[i], PATH[i + 1]
            u = (s - cum[i]) / (cum[i + 1] - cum[i])
            return (ax + u * (bx - ax),
                    ay + u * (by - ay))
    return PATH[-1]

def arc(x, y, h, alpha, L):
    # the arc from the robot to the point, to draw
    a, h = math.radians(alpha), math.radians(h)
    sh, ch = math.sin(h), math.cos(h)
    r = L / (2 * math.sin(a)) if abs(a) > 1e-3 else 0
    pts = []
    for i in range(13):
        t = 2 * a * i / 12
        f = r * math.sin(t) if r else L * i / 12
        s = r * (1 - math.cos(t))
        pts.append((x + f * sh + s * ch,
                    y + f * ch - s * sh))
    return pts

def push(p):
    # under 15 % the robot does not move at all
    if abs(p) < 5:
        return 0
    return math.copysign(max(17, min(100, abs(p))), p)

trail, worst = [], 0
while True:
    px, py = position()
    x, y = START[0] + px, START[1] + py
    s, cross = project(x, y)
    if s > TOTAL - 3:
        break
    # 1. the look-ahead point, LOOK cm further on
    tx, ty = point_at(s + LOOK)
    # 2. its angle from the way the robot faces
    aim = math.degrees(math.atan2(tx - x, ty - y))
    alpha = (aim - heading() + 180) % 360 - 180
    # 3. the arc through it: curvature 2 sin(a) / L
    L = math.hypot(tx - x, ty - y)
    curve = 2 * math.sin(math.radians(alpha)) / L
    v = min(SPEED, TOTAL - s)     # slow at the end
    turn = math.degrees(v * curve)        # deg/s
    # 20 cm/s and 120 deg/s are 100 %
    drive(push(v * 5), 0, push(turn / 1.2))

    trail.append((x, y))
    draw("robot", trail, "blue", "line")
    draw("arc", arc(x, y, heading(), alpha, L),
         "red", "line")
    draw("point", [(tx, ty)], "red", size=4)
    plot("cross-track cm", cross)
    worst = max(worst, abs(cross))
    wait(0.1)
stop()
print("worst cross-track:", round(worst, 1), "cm")
Put this demo on your own site

Paste it into a school website, Moodle, Google Sites or a blog. More options on the embed page.

With a look-ahead of 3 cm, a small error asks for a huge turn. For a point y cm to the side, sin α is about y / L, so the curvature is 2y / L². At L = 3 that is 0.22 per cm of error; at L = 18 it is 0.006, 36 times gentler. With the point only 10 degrees off the heading, the program asks for 93 degrees a second of turn, over three quarters of what the robot can do.

A big gain on its own would be fine. The problem is the delay. This robot takes about a quarter of a second to respond to a new command, so by the time the turn has taken effect, the robot is already pointing past the tape. The next correction is the other way, and it overshoots again. From about 3.5 s the robot weaves the whole way, from one side to the other and back every 2 to 2.5 s, and the red arc swings between hard left and hard right. The same program on a robot with a 0.1 s delay stays within 0.2 cm of the tape at L = 3.

How short is too short depends on the speed. Change SPEED and LOOK in the demo above and the worst cross-track error, in cm, comes out as:

Speed L = 2 L = 3 L = 4 L = 5 L = 6
8 cm/s 1.8 0.4 0.3 0.3 0.6
11 cm/s 4.2 2.1 0.4 0.4 0.5
14 cm/s 6.8 4.1 1.6 0.4 0.3
17 cm/s 8.3 6.8 3.8 0.9 0.3
20 cm/s 11.1 9.5 7.0 3.2 0.4

The smallest look-ahead that keeps the robot within 1 cm grows with the speed: 3 cm at 8 cm/s, 6 cm at 20 cm/s. Each of those is 0.3 to 0.4 seconds of travel, a little more than the robot's own delay. That is the reason real controllers set the look-ahead from the speed, below.

Too long: the robot cuts the corner

The same program with L = 45.

L = 45 cm: the robot turns in long before the bend, cuts across the inside of it and is 9.8 cm off the tape at worst.
The program
from bugbot import *
import math
connect()

# change these numbers and press Run
LOOK = 45        # look-ahead distance, cm
SPEED = 14       # cm/s

START = (40, 40)     # where the robot starts
# the tape: 80 cm straight up, a quarter circle
# of radius 40 round to the right, 70 cm on
PATH = [(40, 40)]
for k in range(9):
    a = math.radians(180 - k * 11.25)
    PATH.append((80 + 40 * math.cos(a),
                 120 + 40 * math.sin(a)))
PATH.append((150, 160))

# the arc length at each corner of the path
cum = [0]
for p, q in zip(PATH, PATH[1:]):
    cum.append(cum[-1] + math.dist(p, q))
TOTAL = cum[-1]

def project(x, y):
    # the nearest point on the path: how far along
    # it is, and the cross-track error in cm
    # (+ left of the path, - right of it)
    best, at, cross = 1e9, 0, 0
    for i in range(len(PATH) - 1):
        (ax, ay), (bx, by) = PATH[i], PATH[i + 1]
        vx, vy = bx - ax, by - ay
        seg = math.hypot(vx, vy)
        t = ((x - ax) * vx + (y - ay) * vy) / seg ** 2
        t = max(0, min(1, t))
        qx, qy = ax + t * vx, ay + t * vy
        d = math.hypot(x - qx, y - qy)
        if d < best:
            best, at = d, cum[i] + t * seg
            side = vx * (y - qy) - vy * (x - qx)
            cross = side / seg
    return at, cross

def point_at(s):
    # the point s cm along the path
    s = max(0, min(TOTAL, s))
    for i in range(len(PATH) - 1):
        if s <= cum[i + 1]:
            (ax, ay), (bx, by) = PATH[i], PATH[i + 1]
            u = (s - cum[i]) / (cum[i + 1] - cum[i])
            return (ax + u * (bx - ax),
                    ay + u * (by - ay))
    return PATH[-1]

def arc(x, y, h, alpha, L):
    # the arc from the robot to the point, to draw
    a, h = math.radians(alpha), math.radians(h)
    sh, ch = math.sin(h), math.cos(h)
    r = L / (2 * math.sin(a)) if abs(a) > 1e-3 else 0
    pts = []
    for i in range(13):
        t = 2 * a * i / 12
        f = r * math.sin(t) if r else L * i / 12
        s = r * (1 - math.cos(t))
        pts.append((x + f * sh + s * ch,
                    y + f * ch - s * sh))
    return pts

def push(p):
    # under 15 % the robot does not move at all
    if abs(p) < 5:
        return 0
    return math.copysign(max(17, min(100, abs(p))), p)

trail, worst = [], 0
while True:
    px, py = position()
    x, y = START[0] + px, START[1] + py
    s, cross = project(x, y)
    if s > TOTAL - 3:
        break
    # 1. the look-ahead point, LOOK cm further on
    tx, ty = point_at(s + LOOK)
    # 2. its angle from the way the robot faces
    aim = math.degrees(math.atan2(tx - x, ty - y))
    alpha = (aim - heading() + 180) % 360 - 180
    # 3. the arc through it: curvature 2 sin(a) / L
    L = math.hypot(tx - x, ty - y)
    curve = 2 * math.sin(math.radians(alpha)) / L
    v = min(SPEED, TOTAL - s)     # slow at the end
    turn = math.degrees(v * curve)        # deg/s
    # 20 cm/s and 120 deg/s are 100 %
    drive(push(v * 5), 0, push(turn / 1.2))

    trail.append((x, y))
    draw("robot", trail, "blue", "line")
    draw("arc", arc(x, y, heading(), alpha, L),
         "red", "line")
    draw("point", [(tx, ty)], "red", size=4)
    plot("cross-track cm", cross)
    worst = max(worst, abs(cross))
    wait(0.1)
stop()
print("worst cross-track:", round(worst, 1), "cm")
Put this demo on your own site

Paste it into a school website, Moodle, Google Sites or a blog. More options on the embed page.

Now the red dot reaches the bend when the robot is still 45 cm short of it. The robot starts turning early, drifts inside the path before the bend has even begun, and cuts across the inside of the curve, 9.8 cm off the tape at worst. The look-ahead point is across the bend rather than round it, so the arc to it is a short cut.

The worst cross-track error grows quickly with L. At 14 cm/s on this tape:

Look-ahead 6 cm 10 cm 18 cm 30 cm 45 cm
Worst cross-track error 0.3 cm 0.9 cm 2.4 cm 5.6 cm 9.8 cm

A large look-ahead turns gently and never weaves. The price is corners. If a planner such as A* chose a path that squeezes round an obstacle, the cut is exactly where the obstacle is.

Getting back onto the path

A path follower also has to join a path it is not on: at the start, or after it has been pushed off. Here the robot starts 25 cm to the right of a straight piece of tape, facing along it.

L = 10 cm: the robot starts 25 cm right of the tape, turns in and is within 2 cm of it 26 cm up the tape, overshooting by 0.3 cm.
The program
from bugbot import *
import math
connect()

# change these numbers and press Run
LOOK = 10        # look-ahead distance, cm
SPEED = 14       # cm/s

START = (85, 30)     # where the robot starts
# the tape: straight up the mat at x = 60,
# 25 cm to the robot's left
PATH = [(60, 30), (60, 170)]

# the arc length at each corner of the path
cum = [0]
for p, q in zip(PATH, PATH[1:]):
    cum.append(cum[-1] + math.dist(p, q))
TOTAL = cum[-1]

def project(x, y):
    # the nearest point on the path: how far along
    # it is, and the cross-track error in cm
    # (+ left of the path, - right of it)
    best, at, cross = 1e9, 0, 0
    for i in range(len(PATH) - 1):
        (ax, ay), (bx, by) = PATH[i], PATH[i + 1]
        vx, vy = bx - ax, by - ay
        seg = math.hypot(vx, vy)
        t = ((x - ax) * vx + (y - ay) * vy) / seg ** 2
        t = max(0, min(1, t))
        qx, qy = ax + t * vx, ay + t * vy
        d = math.hypot(x - qx, y - qy)
        if d < best:
            best, at = d, cum[i] + t * seg
            side = vx * (y - qy) - vy * (x - qx)
            cross = side / seg
    return at, cross

def point_at(s):
    # the point s cm along the path
    s = max(0, min(TOTAL, s))
    for i in range(len(PATH) - 1):
        if s <= cum[i + 1]:
            (ax, ay), (bx, by) = PATH[i], PATH[i + 1]
            u = (s - cum[i]) / (cum[i + 1] - cum[i])
            return (ax + u * (bx - ax),
                    ay + u * (by - ay))
    return PATH[-1]

def arc(x, y, h, alpha, L):
    # the arc from the robot to the point, to draw
    a, h = math.radians(alpha), math.radians(h)
    sh, ch = math.sin(h), math.cos(h)
    r = L / (2 * math.sin(a)) if abs(a) > 1e-3 else 0
    pts = []
    for i in range(13):
        t = 2 * a * i / 12
        f = r * math.sin(t) if r else L * i / 12
        s = r * (1 - math.cos(t))
        pts.append((x + f * sh + s * ch,
                    y + f * ch - s * sh))
    return pts

def push(p):
    # under 15 % the robot does not move at all
    if abs(p) < 5:
        return 0
    return math.copysign(max(17, min(100, abs(p))), p)

trail, joined, over = [], None, 0
while True:
    px, py = position()
    x, y = START[0] + px, START[1] + py
    s, cross = project(x, y)
    if s > TOTAL - 3:
        break
    # 1. the look-ahead point, LOOK cm further on
    tx, ty = point_at(s + LOOK)
    # 2. its angle from the way the robot faces
    aim = math.degrees(math.atan2(tx - x, ty - y))
    alpha = (aim - heading() + 180) % 360 - 180
    # 3. the arc through it: curvature 2 sin(a) / L
    L = math.hypot(tx - x, ty - y)
    curve = 2 * math.sin(math.radians(alpha)) / L
    v = min(SPEED, TOTAL - s)     # slow at the end
    turn = math.degrees(v * curve)        # deg/s
    # 20 cm/s and 120 deg/s are 100 %
    drive(push(v * 5), 0, push(turn / 1.2))

    trail.append((x, y))
    draw("robot", trail, "blue", "line")
    draw("arc", arc(x, y, heading(), alpha, L),
         "red", "line")
    draw("point", [(tx, ty)], "red", size=4)
    plot("cross-track cm", cross)
    if joined is None and abs(cross) < 2:
        joined = s
    over = max(over, cross)
    wait(0.1)
stop()
print("within 2 cm at", round(joined), "cm up the tape")
print("overshoot:", round(over, 1), "cm")
Put this demo on your own site

Paste it into a school website, Moodle, Google Sites or a blog. More options on the embed page.

The red dot starts 10 cm up the tape, at 68 degrees to the robot's left, so the robot turns in hard. As it closes on the tape, the angle to the dot shrinks and the arc straightens. The robot is within 2 cm of the tape by the time it is 26 cm up it, and overshoots by 0.3 cm.

Try LOOK = 3: it gets within 2 cm sooner, 16 cm up the tape, and then weaves up to 3.7 cm either side of the tape for the rest of the run. Try LOOK = 18 and it takes 43 cm; at LOOK = 45 it takes 71 cm, a gentle merge that drifts 3.0 cm past the tape before settling. The look-ahead is the distance over which the robot plans to fix its error, so a longer one fixes it more slowly.

How to choose the look-ahead distance

  1. Start with about one second of travel at your cruising speed. For this robot at 14 cm/s that is 14 cm, and look-aheads from 10 to 18 cm kept it within 2.4 cm of the tape.
  2. If the robot weaves on the straights, the look-ahead is too short for the speed. Make it longer, or slow down.
  3. If it cuts corners by more than you can allow, the look-ahead is too long. Make it shorter, and slow down for the corners so that the shorter look-ahead does not weave.
  4. Better still, set it from the speed: L = L0 + k × v, with a small L0 for when the robot is nearly stopped. Here the weaving stopped at about 0.3 seconds of travel, so k somewhat more than 0.3 s is a sensible start. Nav2's Regulated Pure Pursuit controller does this, and it also slows down on tight curves and near obstacles.

Steering or pointing: a holonomic robot

A robot that can move sideways (holonomic, like the BugBot or a robot on omni wheels) does not need the arc. It can point its velocity straight at the look-ahead point and move there directly, which is how the University lessons below follow a path. The result is different in two ways. Run on the same tape at 14 cm/s, pointing the velocity at L = 3 stayed within 0.4 cm with no weaving, because there is no heading to overshoot. At L = 45 it came 14.9 cm off the tape in the bend instead of 9.8, because it drives the straight line to the point rather than an arc that starts along the path. Shorter look-aheads suit it better.

Pure pursuit and other path followers

Pure pursuit PID on cross-track error Stanley
Steers from a point on the path ahead the sideways error now the heading error and the sideways error at the front axle
Looks ahead yes, by L no no
Numbers to tune the look-ahead distance Kp, Ki, Kd one gain

A PID controller on the cross-track error steers from where the robot is now, so it only starts turning into a bend once the robot has already left the path. On a robot that steers, it has a harder problem too: turning changes the heading, and only the heading changes the sideways error, so every correction arrives late. The demo robot above, steered by a proportional controller alone (1 degree a second of turn for each cm of cross-track error) from the same start 25 cm to the right of the tape, swung 31 cm past the tape, then 38 cm back the other way, then 46 cm, getting worse each time. It needs a derivative term, or a heading term as Stanley has, before it will settle.

Pure pursuit steers from where the path is going, which is why it turns into bends early and, with a sensible look-ahead, has no such swing. For small errors the two are close relatives: pure pursuit acts like a proportional controller on the sideways error with gain 2 / L², with a preview of the path ahead built in.

Questions

How does pure pursuit work?

Each tick it finds the point on the path a fixed distance L ahead of the robot, the look-ahead point. It works out the circular arc that starts along the robot's heading and passes through that point, which has curvature 2 sin α / L where α is the angle between the heading and the point. The robot drives along that arc for one tick, then does the whole thing again with a new point.

What is the pure pursuit formula?

The curvature of the arc is k = 2 sin α / L, where L is the distance to the look-ahead point and α is its angle from the robot's heading. A robot that turns on the spot sets its turn rate to ω = v × k. A car sets its steering angle to atan(wheelbase × k), which is atan(2 × wheelbase × sin α / L).

How do you choose the lookahead distance?

Long enough that the robot does not weave, short enough that it does not cut corners too far. The lower limit comes from the robot's delay and speed: on this page the weaving stopped at about 0.3 seconds of travel, from 3 cm at 8 cm/s to 6 cm at 20 cm/s. The upper limit comes from the tightest bend: at 14 cm/s on a 40 cm radius bend, a look-ahead of 18 cm cut it by 2.4 cm and 45 cm by 9.8 cm. Many controllers set L from the speed so that it grows as the robot goes faster.

What is cross-track error?

It is the distance from the robot to the nearest point on the path, measured at right angles to the path, with a sign that says which side of the path the robot is on. It is the usual measure of how well a robot or vehicle is following a path. The distance along the path is a separate quantity, the along-track error, which says whether the robot is ahead of or behind where it should be.

What is the difference between pure pursuit and PID?

A PID controller steers from the error the robot has now, such as its cross-track error, and needs three gains tuning. Pure pursuit steers from a point on the path ahead, using geometry, and has one number, the look-ahead distance. Pure pursuit turns into bends early because it can see them coming; PID on cross-track error only reacts once the robot has left the path. Many robots use both: pure pursuit to choose the turn, and PID on each motor to make the wheels turn at the speed asked for.

How do you implement pure pursuit in Python?

Store the path as a list of (x, y) points and a running total of the distances between them. Each tick, find the nearest point on each line of the path with a dot product and keep the closest, which gives the arc length s. Walk to arc length s + L for the look-ahead point. Compute α with math.atan2 and the heading, set the curvature to 2 * math.sin(alpha) / L, and send speed × curvature as the turn rate. Every demo on this page is a complete Python program of about 100 lines that does this.

What is the difference between path following and trajectory tracking?

A path is a curve in space with no times on it. A trajectory is a path with a clock attached: it says where the robot should be at each moment. Path following, like pure pursuit, only cares about staying on the curve, and the robot can go at whatever speed suits it. Trajectory tracking also has to be at the right place at the right time, so it controls the along-track error as well, usually with feedforward and feedback on the speed.

Why does my pure pursuit robot oscillate?

The look-ahead distance is too short for the speed and the robot's delay. A short look-ahead turns a small sideways error into a big turn, and the robot overshoots before the correction takes effect. Make the look-ahead longer, slow down, or make it grow with speed. On this page, at 14 cm/s, a look-ahead of 3 cm weaved by 4.1 cm and one of 5 cm stayed within 0.4 cm.

Why does pure pursuit cut corners?

Before the robot reaches a bend, the look-ahead point is already round it, so the arc to it runs across the inside of the bend. The longer the look-ahead, the bigger the cut. Shorten the look-ahead near tight bends, slow down there, or plan the path with a margin on the inside of each corner.

What is the difference between pure pursuit and the Stanley controller?

Stanley, used by Stanford's car that won the 2005 DARPA Grand Challenge, steers by the heading error plus atan(k × e / v), where e is the cross-track error at the front axle and v the speed. Because it does not look ahead, it does not cut corners the way pure pursuit does. In exchange it depends on an accurate heading and position, and on a path whose direction changes smoothly. Pure pursuit has one number to tune that is easy to picture, and copes with starting well away from the path.

Is pure pursuit on the GCSE or A level specification?

Not by name. None of the GCSE or A level Computer Science specifications (AQA, OCR, Edexcel, Eduqas) include pure pursuit. The maths it uses is school maths: the GCSE circle theorem that a tangent meets the radius at right angles, and A level trigonometry, radians and vectors. It makes a good A level Computer Science programming project, alongside a route planner such as A* to make the path and dead reckoning to know where the robot is.

Learn it step by step

These lessons build the same ideas one at a time, each with tasks the simulator marks.

  1. U10.1 A path and a trajectory Following a trajectory, University
  2. U10.3 Cross-track error Following a trajectory, University
  3. U10.4 Pure pursuit Following a trajectory, University
  4. U10.5 Feedforward and feedback Following a trajectory, University
  5. U10.7 Project: drive the route Following a trajectory, University
Open the lessons