Docs menu
Racing line and speed profile
How the racing line is recorded, how every point gets a target speed from the car's grip and braking limits, and how the line can be reshaped to the least-curved path the track allows.
A racing line is the path the car follows, stored as a list of points in the map frame. A speed profile adds a target speed to each point: slow in corners, fast on straights, with braking that starts before each corner. pure_pursuit steers along the points and drives at their speeds.
A mapping run (map_course) records, profiles and optimizes the line automatically, so the tools on this page are needed only to redo one step by hand: to profile a lap again with different limits, or to pace a line driven manually. The mathematics is in racing_math.pyGitHub and raceline_optimizer.pyGitHub, and the code excerpts below are from those files with docstrings and input checks removed.
Topics
The line is built offline, from files, so most of this page involves no ROS topics. The one node is the waypoint recorder, and it only listens.
| Node | Subscribes to | Publishes |
|---|---|---|
waypoint_recorder_node |
/pf/viz/inferred_pose (geometry_msgs/PoseStamped), the particle filter’s pose |
Nothing. It writes CSV rows to output_file. |
pure_pursuit_node |
See Pure pursuit | /racing_line (std_msgs/String, JSON, latched): the line it is racing, for the dashboard |
During a mapping run, auto_map_race_node does the recording itself, from the SLAM pose, and hands the line to the controller. See Map a course and race.
File format
Lines are plain CSV files, one point per row, in metres in the map frame, measured at the rear axle.
| File | Columns | Written by |
|---|---|---|
*_raw.csv, raceline_raw.csv |
x, y |
The waypoint recorder, or a mapping run |
*_profiled.csv, raceline_profiled.csv |
x, y, speed (m/s) |
generate_velocity_profile, or a mapping run |
raceline_optimized.csv |
x, y, speed |
optimize_raceline, or a mapping run when the optimizer runs |
A sample is in example_stadium_raw.csvGitHub. The line is treated as a closed loop: the last point connects back to the first.
Record a line by hand
The waypoint recorder saves the car’s position every time it has moved 0.15 m (min_spacing_m). It needs localization running, and the car driven by hand. It writes each point to disk as it goes, so a crash mid-lap keeps everything recorded so far.
Terminal 3, with bringup, teleop and the particle filter running:
ros2 launch pure_pursuit waypoint_recorder_launch.py \
output_file:=$HOME/racerbot-ws/src/pure_pursuit/waypoints/my_track_raw.csvDrive one lap along the line the car should race, with smooth arcs and no sharp corrections, because the profile step paces this line without reshaping it. Press Ctrl+C near the start. The particle filter’s pose is the LiDAR’s position, so the recorder moves each point 0.26 m back to the rear axle before saving it (pose_frame: laser).
Compute the speed profile
racing_math on the race planner’s settings.generate_velocity_profile turns an x, y line into an x, y, speed line in three steps.
1. Curvature at every point
Curvature measures how sharply the line bends: the reciprocal of the radius of the circle that fits it at that point. For each point and its neighbours and , exactly one circle passes through all three. A triangle’s area equals , so
Where the formula comes from. Draw the circle through , and , with radius . By the inscribed angle theorem, the angle at between the chords to and sees the chord , so (the extended law of sines). The triangle’s area is half the product of two sides and the sine of the angle between them: . Eliminating between the two gives , and so the formula above. Three points in a straight line have zero area, so a straight has zero curvature.
The area itself comes from the cross product of the two edges out of , which is twice the triangle’s area:
def estimate_path_curvature(xy, closed=True):
prev_pts = np.roll(xy, 1, axis=0)
next_pts = np.roll(xy, -1, axis=0)
a = np.hypot(next_pts[:, 0] - xy[:, 0], next_pts[:, 1] - xy[:, 1]) # |B->C|
b = np.hypot(xy[:, 0] - prev_pts[:, 0], xy[:, 1] - prev_pts[:, 1]) # |A->B|
c = np.hypot(next_pts[:, 0] - prev_pts[:, 0], next_pts[:, 1] - prev_pts[:, 1]) # |A->C|
cross = ((xy[:, 0] - prev_pts[:, 0]) * (next_pts[:, 1] - prev_pts[:, 1]) -
(xy[:, 1] - prev_pts[:, 1]) * (next_pts[:, 0] - prev_pts[:, 0]))
area = 0.5 * np.abs(cross)
denom = a * b * c
kappa = np.zeros(len(xy))
valid = denom > 1e-9
kappa[valid] = 4.0 * area[valid] / denom[valid]
return kappanp.roll shifts the whole array by one place, so prev_pts[i] is point i - 1 and next_pts[i] is point i + 1, wrapping round the closed lap. Every point’s curvature is computed at once, with no loop.
This needs only three neighbouring points, so it works directly on a hand-driven recording. The line is first smoothed with a moving average over 3 points on each side (--smoothing-window), because a few centimetres of localization jitter at 0.15 m spacing would otherwise read as curvature and create braking zones on straights. The smoothed line is the one written out.
2. Cornering limit
A car following a curve of curvature at speed has lateral acceleration . Holding that under the grip limit gives the fastest speed at each point:
The lateral acceleration is the centripetal acceleration of circular motion, , written with . In the figure, the corners have , so m/s.
3. Acceleration and braking passes
The cornering limit alone asks the car to drop from full speed to corner speed between two adjacent points. Two passes limit how fast the speed may change over each segment of length :
Both come from the constant-acceleration equation : a car at speed that accelerates at over a distance reaches . Going forward, the speed at point can be at most the speed at plus what full acceleration adds over the segment between them. Going backward, the speed at can be at most the speed from which full braking reaches the speed at within the segment. For example, to brake from 4.0 m/s to the 1.94 m/s corner at 3.0 m/s², the car needs m, so the braking zone starts about 2 m before the corner.
The backward pass creates the braking zones: it carries each corner’s low speed back along the straight that leads into it, so the profile starts braking early enough. Both passes only lower speeds. On a closed loop the first point’s predecessor is the last point, so the pair of passes repeats five times (smoothing_passes) to let the start and finish agree.
Grip is shared between cornering and braking. While the car is cornering, the passes allow only the longitudinal acceleration left over, by the friction ellipse:
Solving for gives the budget left after cornering:
On a straight this leaves the full braking budget. At a corner already at its grip limit it leaves none. Halfway to the grip limit it leaves . This ellipse is on by default. --no-friction-ellipse turns it off.
def long_fraction(speed, kappa):
if not friction_ellipse:
return 1.0
lat_fraction = (speed * speed * kappa) / a_lat_max
return math.sqrt(max(0.0, 1.0 - lat_fraction * lat_fraction))
for _ in range(max(1, smoothing_passes)):
for i in range(n): # forward: acceleration
prev_i = (i - 1) % n
a_avail = a_accel_max * long_fraction(v[prev_i], curvature[prev_i])
v_cap = math.sqrt(v[prev_i] ** 2 + 2.0 * a_avail * seg_len[prev_i])
if v_cap < v[i]:
v[i] = v_cap
for i in range(n - 1, -1, -1): # backward: braking
next_i = (i + 1) % n
a_avail = a_brake_max * long_fraction(v[next_i], curvature[next_i])
v_cap = math.sqrt(v[next_i] ** 2 + 2.0 * a_avail * seg_len[i])
if v_cap < v[i]:
v[i] = v_cap
return np.clip(v, v_min, v_max)v starts at the cornering limit. Each sweep can only lower a value (if v_cap < v[i]), so repeating the passes never undoes earlier work. The budget is evaluated at the neighbour whose speed is already settled in that sweep.
Run it
ros2 run pure_pursuit generate_velocity_profile \
--input src/pure_pursuit/waypoints/my_track_raw.csv \
--output src/pure_pursuit/waypoints/my_track_profiled.csv| Symbol | Option | Tool default | Planner (auto_map_race.yaml) |
Unit |
|---|---|---|---|---|
--v-max |
4.0 | profile_max_speed 4.0 |
m/s | |
--v-min |
0.5 | profile_min_speed 0.5 |
m/s | |
--a-lat-max |
2.5 | profile_max_lateral_accel 2.5 |
m/s² | |
--a-accel-max |
3.0 | profile_max_accel 6.0 |
m/s² | |
--a-brake-max |
3.0 | profile_max_brake 3.0 |
m/s² | |
--smoothing-passes |
5 | profile_smoothing_passes 5 |
These are starting values, not measured tire limits. Tune them in this order: if the car slides or runs wide mid-corner, lower . If it brakes too late and runs wide at corner entry, lower . Raise only once cornering is reliable. A change to any of them means generating the profile again. pure_pursuit’s own max_speed and max_lateral_accel are separate ceilings applied on top of the profile while racing, and can be changed live from the dashboard.
Check that the car can follow it
A mapping run refuses a line the car cannot steer, instead of degrading on track. It compares the line’s curvature against the planner’s steering limit, profile_max_steering_angle (0.30 rad, a minimum radius of 1.16 m with m), and checks the line against the map’s walls with profile_wall_clearance (0.20 m). When it refuses, it reports the numbers. A refusal on a tight course is correct: the car cannot drive that line.
Optimize the line
The profile only paces the line it is given. optimize_raceline reshapes the line itself to the path with the least curvature that fits inside the track, and then profiles it. Lower curvature raises everywhere at once, so the least-curved line is close to the fastest one when grip is the limit.
The method is iterative minimum curvature, from Heilmeier et al., Minimum curvature trajectory planning and control for an autonomous race car (Vehicle System Dynamics, 2019), the method behind TUM’s global_racetrajectory_optimization. Each candidate line is a sideways offset from a centre line, and staying on the track is the bound . To first order the offset line’s curvature is
The three terms each have a plain meaning. is the bend already in the centre line. , the second derivative of the offset along the line, is the bend added by changing the offset, the way a lane change bends a straight path. is the effect of a fixed offset, with positive toward the inside of the bend. Offsetting a bend of radius by toward its inside makes its radius , and for a small . A fixed offset toward the inside tightens the bend and one toward the outside opens it, which is why the solution on a circular test track converges on the outer wall, the largest circle that fits.
With the line sampled every metres, at point is the second difference . The curvature at every point is then a matrix times the offsets plus a constant, , with the second-difference matrix plus on its diagonal:
def linearized_curvature_system(reference, h):
curvature = signed_curvature(reference)
n = len(reference)
a_matrix = (second_difference_operator(n, h)
+ sparse.diags(curvature ** 2))
return a_matrix.tocsr(), curvatureMinimizing is then a linear least-squares problem with bounds on , solved with scipy.optimize.lsq_linear. The formula is only accurate for small offsets, so the solution becomes the new reference line, is linearized again, and is solved again, 8 times (optimize_iterations). On a straight, where , only the term remains, and it costs nothing for to change linearly, so the cheapest line across a straight is a diagonal from the exit of one corner to the entry of the next, which is what a racing line does.
From a saved map and a recorded lap:
ros2 run pure_pursuit optimize_raceline \
--map maps/my_track.yaml \
--recorded-lap src/pure_pursuit/waypoints/my_track_raw.csv \
--max-steering-angle 0.30 \
--output src/pure_pursuit/waypoints/my_track_optimized.csv--max-steering-angle defaults to 0.30 rad, the same as the planner’s profile_max_steering_angle. The finished line’s tightest curvature is checked against it.
The recorded lap seeds the centre line: it is inside the track, goes round once, and runs in the racing direction. The tool measures the walls on each side of it and moves it to the middle, several times. --safety-margin (0.15 m beyond half the padded car width) keeps the line off the walls; the optimizer uses every centimetre it is given, so raise it rather than lower it. Mapping runs use optimize_safety_margin, 0.10 m. The finished line is checked independently against the map before it is written, and the tool refuses to write a line that fails.
The racing-line pipeline, including the measured gain on standard tracks, is documented in docs/racing-autonomy.mdGitHub.
Limitations
- The profile uses single numbers for grip and braking. It has no model of weight transfer, tire temperature or motor power.
- Profiles and optimized lines have been tested in the simulator, not yet raced on the cars.
- Racing lines recorded before August 24, 2026 used a LiDAR offset 0.07 m too far forward. Record them again rather than reuse them.
Checked against
car 1 commit 0265263
and
car 2 commit b81b291, before the two workspaces merged.