Docs menu
Pure pursuit
The default race controller: each tick it picks a point a speed-dependent distance ahead on the racing line and steers along the circle that reaches it.
pure_pursuit_node drives a saved racing line on a mapped course. Forty times a second it finds where the car is on the line, picks a point a short distance further along, steers along the circle that reaches that point, and sets the speed from the line’s profile. Pure pursuit is the default of the node’s four steering laws and the method taught in Lecture 10 (notes) of the course. Stanley, LQR and MPC replace only the steering step, and everything else on this page applies to them too.
The node needs two things running beneath it: bringup, and a localizer that publishes the car’s pose on the map. race_launch.py starts the particle filter and the node together. The ROS side is pure_pursuit_node.pyGitHub. The geometry is in racing_math.pyGitHub and the steering laws in tracking_laws.pyGitHub, both free of ROS so their tests run anywhere. Code excerpts on this page are from those two files, with docstrings and input checks removed.
Topics
Subscribes to
| Topic and type | Rate | Used for |
|---|---|---|
/pf/viz/inferred_pose, geometry_msgs/PoseStamped |
Each particle filter update | Where the car is on the map. The topic is pose_topic; with SLAM as the localizer it is /slam_pose. |
/odom, nav_msgs/Odometry |
Each VESC state message | The measured speed, for the lookahead and the frozen-pose check |
/scan, sensor_msgs/LaserScan |
About 40 Hz | The reactive safety net and opponent detection. Subscribed only with enable_lidar_safety (on). |
/joy, sensor_msgs/Joy |
20 Hz | The LB deadman. Subscribed only with enable_deadman, which is always on in shipped configuration. |
/map, nav_msgs/OccupancyGrid |
Latched | The map that opponent detection subtracts from the scan, so walls are not mistaken for cars |
/auto_map_race/localization_source, std_msgs/String |
Latched | Which localizer is in charge after the mapping-to-racing handover |
/auto_map_race/frozen_map, nav_msgs/OccupancyGrid |
Latched | The map as it stood at the handover |
The last three exist only with opponent_detection_mode: map, the default. “Latched” means the publisher keeps the last message (transient-local durability) and the node subscribes the same way, so it still receives a map that was published once, long before it started.
Publishes
| Topic and type | Rate | Used for |
|---|---|---|
/drive, ackermann_msgs/AckermannDriveStamped |
40 Hz (control_rate_hz) |
The steering angle and speed, to ackermann_mux |
/drive_intent, std_msgs/String (JSON) |
At most 20 Hz | The plan and the binding speed limit, for the dashboard |
/racing_line, std_msgs/String (JSON) |
Latched, once per profile | The line being raced, up to 2000 points, for the dashboard |
One tick, in order
The controller runs on a timer, not on incoming poses. If localization stopped, a loop driven by poses would stop running and leave its last command in force. A timer keeps running, keeps checking how old its data is, and stops the car when the pose goes stale.
Each tick goes through these checks and steps. The first one that fails stops the car for that tick and names the reason in the log:
- LB. No live LB hold means speed 0, before anything else is looked at (
deadman_released,joy_stale). - A racing line. In a mapping run the node starts without one and waits (
waiting_for_profile). - A fresh pose. Until the first pose arrives the state is
waiting_for_pose. After that the pose must be newer than 0.5 s (pose_timeout_sec) by both its own timestamp and its arrival time (pose_stale). The timestamp catches an old transform being republished at full rate. The arrival time catches the publisher going silent. - A pose that moves. If
/odomsays the car is moving at 0.3 m/s or more but the pose has not moved for 0.5 s, localization has stalled while the car keeps rolling (pose_frozen). A stalled SLAM put the car into a wall on July 27, 2026, which is why this check exists. - The nearest point on the line, and the car’s distance from it, the cross-track error.
- Still on the line. Further than 1.0 m (
max_cross_track_error) from the line, the node first searches the whole line once in case localization jumped. Between 1.0 and 2.0 m it crawls back at 0.30 m/s, aiming straight at the nearest point, for up to 5 s. Beyond 2.0 m, or after 5 s, it stops (off_racing_line). - Steering, from the chosen law.
- Speed, from the profile at the nearest point, then the speed caps.
- Overtaking, which may move the target to pass a slower car. See Safety net and overtaking.
- The reactive safety net, which has the final say over everything above.
- Rate limits on steering and speed, then publish. Stops skip the rate limit and go out at once.
- Intent, published last, so a failure in it can never delay the drive command.
The geometry
From the LiDAR to the rear axle
The particle filter estimates the pose of the LiDAR, because it ray-casts each particle’s scan from the sensor. The bicycle model and the racing line both describe the rear axle, 0.26 m behind it (laser_offset_x). With pose_frame: laser the node moves the pose back along the car’s heading :
def laser_pose_to_base_link(x, y, yaw, laser_offset_x, laser_offset_y):
cos_yaw = math.cos(yaw)
sin_yaw = math.sin(yaw)
return (x - (laser_offset_x * cos_yaw - laser_offset_y * sin_yaw),
y - (laser_offset_x * sin_yaw + laser_offset_y * cos_yaw),
yaw)Getting this wrong puts every target 0.26 m off, and the car tracks a parallel line beside the real one. With /slam_pose, which is already a rear-axle pose, pose_frame must be base_link.
Find the nearest point
The racing line is a list of points about 0.15 m apart. The node finds the point closest to the car, but it searches only the 40 points on either side of last tick’s answer (nearest_search_window). On a course that passes close to itself, such as a hairpin, the closest point overall can be on the other leg of the hairpin. A windowed search stays on the leg the car is on, and it is cheaper too.
Choose the lookahead
The lookahead distance grows with speed:
with = lookahead_speed_gain (0.15 s), = 0.6 m and = 1.5 m. At 4.0 m/s, m. The speed is the measured speed from /odom, not the profiled target, because the two differ most while braking into a corner. If /odom is older than 0.5 s, the profiled speed at the nearest point is used instead.
def adaptive_lookahead(speed, gain, min_lookahead, max_lookahead):
ld = gain * abs(speed) + min_lookahead
return float(np.clip(ld, min_lookahead, max_lookahead))Why the lookahead matters so much. Suppose the car is driving parallel to a straight line, metres to its left. The target ahead on the line is then at in the car’s frame, and the curvature formula below gives . The steering is , so
Pure pursuit behaves like a proportional controller on the cross-track error, with a gain of radians of steering per metre of error. At m that gain is 2.0 rad/m. At 1.5 m it is 0.32 rad/m, six times weaker. A short lookahead corrects hard, which tracks tight corners well at low speed but overshoots and weaves at high speed. A long lookahead corrects gently, which is smooth at speed but lets the car drift and start turning early. Scaling with speed takes each end where it works.
Find the target
From the nearest point, the node walks forward along the line, adding up the distance between points, until it has covered . The point it reaches is the target. This snaps the target to a recorded point instead of intersecting the line with a circle exactly, which is simpler and off by at most the 0.15 m point spacing.
def find_lookahead_index(seg_len, nearest_index, lookahead_dist, closed=True):
n = len(seg_len)
acc = 0.0
idx = nearest_index
for _ in range(n):
acc += seg_len[idx]
nxt = idx + 1
if nxt >= n:
if not closed:
return n - 1
nxt = 0
if acc >= lookahead_dist:
return nxt
idx = nxt
return idxseg_len[i] is the distance from point i to point i + 1. On a closed lap the walk wraps from the last point back to the first.
Rotate the target into the car’s frame
The target is known in map coordinates. Steering needs it relative to the car: forward and to the left. With the map offset from the rear axle to the target, rotating by gives
A quick check: with the car facing along the map’s axis (), a target to its left at gives . Left stays left.
Curvature of the arc
The car can only drive arcs that leave the rear axle in the direction it is facing, so the arc’s centre lies on the car’s axis, at . A positive puts the centre to the left, a left turn. The arc must also pass through the target , which is at distance from the centre:
Expanding , the terms cancel:
The curvature is . Since and , where is the target’s angle from the car’s heading:
A target to the left () gives positive curvature, which matches the drive message’s convention that a positive steering angle turns left. The code uses the real squared distance , not the requested , because the snapped target is never at exactly .
def steering_arc_curvature(x_body: float, y_body: float) -> float:
dist_sq = x_body * x_body + y_body * y_body
if dist_sq < 1e-9:
return 0.0
return 2.0 * y_body / dist_sqSteering angle
The kinematic bicycle model from Lecture 6 (notes) turns a curvature into a front-wheel angle. Collapse each pair of wheels into one wheel on the centre line. The rear wheel cannot slide sideways, so the car turns about a point on the rear axle’s line, at distance . The front wheel, further forward, must point at right angles to the line from that point. That makes a right-angled triangle with sides and , and the angle at the turning point is the steering angle:
with = 0.36 m. A worked example: at 4.0 m/s the lookahead is 1.2 m. A target 10° to the left gives , a 3.5 m radius, and rad, 5.9°.
def steering_from_curvature(kappa: float, wheelbase: float) -> float:
return math.atan(wheelbase * kappa)The whole steering law strings these together:
def steer(self, inp: TrackingInput, path: PathContext) -> SteeringResult:
lookahead = racing_math.adaptive_lookahead(
inp.speed, self.params.lookahead_speed_gain,
self.params.min_lookahead, self.params.max_lookahead)
target_idx = racing_math.find_lookahead_index(
path.seg_len, inp.nearest_idx, lookahead, closed=path.closed)
target_x, target_y = path.xy[target_idx]
x_body, y_body = racing_math.world_to_body(
float(target_x - inp.x), float(target_y - inp.y), inp.yaw)
kappa = racing_math.steering_arc_curvature(x_body, y_body)
unclipped = racing_math.steering_from_curvature(kappa, self.limits.wheelbase)
steering = float(np.clip(
unclipped, -self.limits.max_steering_angle, self.limits.max_steering_angle))
...The angle is clipped to max_steering_angle here, and its change is limited to max_steering_rate (1.0 rad/s) immediately before publishing.
Speed
The base speed is the profiled speed at the car’s nearest point, clipped to [min_speed, max_speed]. The profile already contains the braking zones, so the nearest point’s speed is the right one for where the car is now. The target’s speed would brake too early.
The profile knows only the recorded line. When the car steers off it, for a localization correction, a swerve or an overtake, a second limit applies, from the steering commanded on this tick. Its curvature is , and holding the sideways acceleration under the grip limit gives
with = max_lateral_accel (2.5 m/s²). On the recorded line this limit is already met, so it only acts when the car does something the profile did not plan.
The commanded speed may rise by at most max_acceleration (6.0 m/s²) per second, starting from the larger of the last command and the measured speed. Every stop goes out at once, without any rate limit. max_acceleration has a narrow working range: at 3.0 the car cannot speed up again after a safety stop behind a slower car, and at 7.0 or more it arrives behind that car too fast to commit to a pass.
Parameters
Set in pure_pursuit.yamlGitHub. The lookahead and speed values can also be changed while racing, from the dashboard.
| Symbol | Parameter | Default | Unit | Sets |
|---|---|---|---|---|
min_lookahead |
0.6 | m | Shortest lookahead | |
max_lookahead |
1.5 | m | Longest lookahead | |
lookahead_speed_gain |
0.15 | s | Lookahead added per m/s | |
nearest_search_window |
40 | points | Nearest-point search window | |
wheelbase |
0.36 | m | Bicycle-model wheelbase | |
max_steering_angle |
see below | rad | Steering limit | |
max_steering_rate |
1.0 | rad/s | Fastest steering change | |
max_speed |
4.0 | m/s | Ceiling on the profile | |
min_speed |
0.5 | m/s | Floor on the profile | |
max_lateral_accel |
2.5 | m/s² | Limit for off-line steering | |
max_acceleration |
6.0 | m/s² | Fastest speed increase | |
control_rate_hz |
40 | Hz | Control loop rate | |
pose_timeout_sec |
0.5 | s | Pose age that stops the car | |
max_cross_track_error |
1.0 | m | Distance from the line that starts recovery | |
pose_topic |
/pf/viz/inferred_pose |
Where the pose comes from | ||
pose_frame |
laser |
Which point on the car the pose describes | ||
steering_law |
pure_pursuit |
pure_pursuit, stanley, lqr or mpc |
Each car’s steering limit is loaded on top of the shared file from its profile’s calibration/pure_pursuit.yaml:
| Car 1 | Car 2 | |
|---|---|---|
max_steering_angle | 0.33 rad | 0.349 rad |
Run it
race_launch.py starts the particle filter and pure_pursuit_node together on a saved run. See Map a course and race.
ros2 launch racerbot_launch race_launch.py run_dir:=$HOME/racerbot-ws/data/<id>/racerbot_auto/<run>To run the controller alone, with localization already running and a profiled line:
ros2 launch pure_pursuit pure_pursuit_launch.py waypoints_file:=$HOME/racerbot-ws/src/pure_pursuit/waypoints/my_track_profiled.csvCheck it
ros2 topic hz /pf/viz/inferred_pose # the pose the controller follows
ros2 topic hz /drive # 40 Hz while the node runs
ros2 topic echo /drive --field drive
ros2 param get /pure_pursuit_node steering_lawThe terminal prints a decision line every second, in the same DRIVE [state] ... form as gap_follow. The line for normal driving includes the law’s own detail, for example pure_pursuit lookahead=1.20m target=412 kappa=+0.289/m: the lookahead, the index of the target point and the curvature asked for. With LB released the state is deadman_released and the speed is 0.00.
Tests
test_racing_math.pyGitHub checks each geometry function on this page against a closed form or an invariant, for example that a target to the left gives a left turn and that the curvature of a circle comes out as . test_tracking_laws.pyGitHub checks every steering law with no ROS, and test_localization_watchdogs.py starts the node and checks the pose watchdogs: a frozen pose stops the car, a stale timestamp is caught even on a message that has just arrived, and a parked car does not trip either. See Run the tests.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| The car weaves along straights | Lookahead too short for the speed | Raise min_lookahead or lookahead_speed_gain. |
| The car cuts the inside of corners | Lookahead too long | Lower max_lookahead. |
| The car drives confidently the wrong way | Localization started from a wrong pose | Stop. Start from the mapping spot, or seed the particle filter again. |
STOP [pose_stale] |
No pose for 0.5 s | Check ros2 topic hz /pf/viz/inferred_pose. |
STOP [pose_frozen] |
The car is moving but the pose is not | Localization has stalled. Check the particle filter’s terminal. |
STOP [off_racing_line] |
The car is more than 1.0 m from the line | Up to 2.0 m away it crawls back at 0.30 m/s first. Beyond that, check localization. |
| The car is consistently 0.26 m off the line | pose_frame does not match the pose topic |
Use laser with /pf/viz/inferred_pose and base_link with /slam_pose. |
| The car slows in the middle of an overtake | The off-line curvature limit | See Safety net and overtaking. |
Limitations
- The target is already round the bend before the car reaches it, so pure pursuit starts turning early and cuts the inside at corner entry. Stanley, LQR and MPC correct the error at the car instead, and trade this against sensitivity to delay and noise.
- It follows the recorded line exactly. It does not plan around the car’s limits beyond the speed profile and the off-line curvature limit.
Checked against
car 1 commit 0265263
and
car 2 commit b81b291, before the two workspaces merged.