Building the Pure Pursuit Node¶
With waypoints recorded, the next step is to build the node that reads them and drives the car autonomously. This tutorial walks through the Pure Pursuit algorithm and the ROS 2 infrastructure around it.
Note
This tutorial covers the concepts behind the code. For the graded assignment with deliverables, see Lab 6b - Pure Pursuit in the Weber Assignments.
How Pure Pursuit Works¶
Pure Pursuit is a geometric path tracking algorithm. The idea is simple: pick a point on the path some distance ahead of the car (the lookahead point), then steer along a circular arc to reach it.
On each update cycle:
Read the car’s current pose from the particle filter
Find the closest waypoint to the car
Search forward along the path to find the first waypoint at least
lookahead_distanceaway — this is the lookahead pointTransform the lookahead point into the car’s local frame
Compute the steering angle using the Pure Pursuit formula
Publish a drive command
The lookahead distance is the key tuning parameter. A shorter lookahead tracks the path more tightly but can cause oscillation. A longer lookahead produces smoother driving but cuts corners.
Step 1 — Create the Config File¶
ROS 2 parameters let you change behavior without editing code. Create ~/f1tenth_ws/src/pure_pursuit/config/pure_pursuit.yaml:
pure_pursuit_node:
ros__parameters:
lookahead_distance: 1.5
speed: 1.0
waypoint_file: "waypoints.csv"
Important
The top-level key pure_pursuit_node: must match the node name in three places:
super().__init__('pure_pursuit_node')in your Python classname='pure_pursuit_node'in the launch fileThe YAML key shown above
If any of these don’t match, ROS 2 silently ignores the parameters and uses defaults.
Parameter |
Purpose |
|---|---|
|
How far ahead on the path to steer toward (meters). Start at 1.5 and tune from there. |
|
Forward drive speed in m/s. Start low (0.5) for safety. |
|
Name of the CSV file in the |
Step 2 — Create the Launch File¶
A launch file lets you start the node with its config in one command. Create ~/f1tenth_ws/src/pure_pursuit/launch/pure_pursuit_launch.py:
from launch import LaunchDescription
from launch_ros.actions import Node
from ament_index_python.packages import get_package_share_directory
import os
def generate_launch_description():
config = os.path.join(
get_package_share_directory('pure_pursuit'),
'config',
'pure_pursuit.yaml'
)
return LaunchDescription([
Node(
package='pure_pursuit',
executable='pure_pursuit_node',
name='pure_pursuit_node',
parameters=[config],
output='screen'
)
])
Key points:
get_package_share_directory()finds the installed config path (insideinstall/, notsrc/)parameters=[config]loads the YAML file as node parametersoutput='screen'sends log output to the terminal
Step 3 — Update setup.py¶
Tell the build system to install the config, launch, and map files. Add these to the data_files list in setup.py:
(os.path.join('share', package_name, 'config'), glob('config/*.yaml')),
(os.path.join('share', package_name, 'launch'), glob('launch/*.py')),
(os.path.join('share', package_name, 'maps'), glob('maps/*.csv')),
Also add the Pure Pursuit node entry point alongside the existing waypoint logger:
entry_points={
'console_scripts': [
'waypoint_logger = pure_pursuit.waypoint_logger:main',
'pure_pursuit_node = pure_pursuit.pure_pursuit_node:main',
],
},
Step 4 — Write the Node¶
Create ~/f1tenth_ws/src/pure_pursuit/pure_pursuit/pure_pursuit_node.py. The node has several components:
Loading Parameters¶
Declare and read parameters from the config file:
self.declare_parameter('lookahead_distance', 1.5)
self.declare_parameter('speed', 1.0)
self.declare_parameter('waypoint_file', 'waypoints.csv')
self.lookahead = self.get_parameter('lookahead_distance').value
self.speed = self.get_parameter('speed').value
Loading Waypoints from CSV¶
Read the waypoint file at startup and store the poses as a list:
import csv
waypoints = []
with open(filepath, 'r') as f:
reader = csv.reader(f)
for row in reader:
waypoints.append((float(row[0]), float(row[1]),
float(row[2]), float(row[3])))
Each row contains (x, y, orientation_z, orientation_w) — the same four values your waypoint logger recorded.
Subscriber and Publisher¶
The node subscribes to the particle filter for the car’s position and publishes drive commands:
from nav_msgs.msg import Odometry
from ackermann_msgs.msg import AckermannDriveStamped
# Subscriber: car's current pose
self.create_subscription(Odometry, '/pf/pose/odom', self.pose_callback, 10)
# Publisher: drive commands
self.drive_pub = self.create_publisher(AckermannDriveStamped, '/drive', 10)
The Pure Pursuit Algorithm¶
Inside the callback, implement the algorithm in four stages:
1. Find the closest waypoint
Compute the Euclidean distance from the car to every waypoint and find the nearest one:
import math
# Car's current position
cx = msg.pose.pose.position.x
cy = msg.pose.pose.position.y
# Find closest waypoint index
min_dist = float('inf')
closest_idx = 0
for i, (wx, wy, _, _) in enumerate(self.waypoints):
dist = math.sqrt((wx - cx)**2 + (wy - cy)**2)
if dist < min_dist:
min_dist = dist
closest_idx = i
2. Find the lookahead point
Starting from the closest waypoint, search forward along the path until you find a waypoint at least lookahead_distance away:
lookahead_point = None
n = len(self.waypoints)
for i in range(n):
idx = (closest_idx + i) % n # wrap around the path
wx, wy = self.waypoints[idx][0], self.waypoints[idx][1]
dist = math.sqrt((wx - cx)**2 + (wy - cy)**2)
if dist >= self.lookahead:
lookahead_point = (wx, wy)
break
The modulo % n wraps the search back to the beginning of the waypoint list, allowing the car to drive continuous laps.
3. Transform to the car’s local frame
The car needs to know if the lookahead point is to its left or right. Transform from the map frame to the car’s frame:
# Get car's heading from quaternion
qz = msg.pose.pose.orientation.z
qw = msg.pose.pose.orientation.w
heading = 2.0 * math.atan2(qz, qw)
# Translate
dx = lookahead_point[0] - cx
dy = lookahead_point[1] - cy
# Rotate into car's local frame
local_x = dx * math.cos(-heading) - dy * math.sin(-heading)
local_y = dx * math.sin(-heading) + dy * math.cos(-heading)
After this transform:
local_xis how far ahead the point islocal_yis the lateral offset (positive = left, negative = right)
4. Compute steering angle and publish
The Pure Pursuit formula computes the curvature of the arc that connects the car to the lookahead point:
steering_angle = 2 * y / L^2
Where L is the distance to the lookahead point and y is the lateral offset in the car’s frame:
L = math.sqrt(local_x**2 + local_y**2)
steering_angle = 2.0 * local_y / (L ** 2)
# Publish the drive command
drive_msg = AckermannDriveStamped()
drive_msg.drive.steering_angle = steering_angle
drive_msg.drive.speed = self.speed
self.drive_pub.publish(drive_msg)
This formula comes from the geometry of a circular arc. When local_y is zero (the lookahead point is straight ahead), the steering angle is zero. When the point is to the left, the car steers left, and vice versa.
Step 5 — Build and Test¶
cd ~/f1tenth_ws
colcon build --packages-select pure_pursuit
source install/setup.bash
Verify the launch file works:
ros2 launch pure_pursuit pure_pursuit_launch.py
If the node starts without errors (it will wait for /pf/pose/odom messages), the build is correct. See Running Pure Pursuit for full instructions on running it with the particle filter on the robot.
Understanding the Math¶
The Pure Pursuit steering formula 2y / L^2 is derived from the geometry of a circular arc connecting the car’s rear axle to the lookahead point.
Given a circle of radius R passing through the origin (car) and the lookahead point at distance L with lateral offset y:
curvature = 1/R = 2y / L^2
The steering angle is then proportional to this curvature. The Ackermann steering model converts curvature to a wheel angle, but for a simple implementation the curvature value works directly as the steering command.
Intuition:
Far lookahead (large
L) + smally= gentle steering = smooth drivingClose lookahead (small
L) + largey= aggressive steering = tight tracking
This is why lookahead_distance is the primary tuning knob.
Key Concepts Recap¶
Concept |
What You Learned |
|---|---|
ROS 2 Parameters |
Declaring, loading from YAML, and using runtime parameters |
Launch Files |
Starting nodes with config files using |
|
Installing config, launch, and data files with the package |
Pure Pursuit Algorithm |
Closest waypoint search, lookahead point, coordinate transforms, steering formula |
Coordinate Transforms |
Converting map-frame points to the car’s local frame using rotation |
|
Publishing steering angle and speed commands to |