Docs Autonomy Workspace repo
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.csv

Drive 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

Speed along one lap: the cornering limit and the finished profile Two lines along a 21.4 m lap with two straights and two corners. The dashed line is the cornering limit alone: 4.0 m/s on the straights and 1.94 m/s in the corners, with instant steps between them. The solid line is the finished profile, which slows down gradually before each corner and speeds up gradually after it. CORNER CORNER 0 m/s 1 m/s 2 m/s 3 m/s 4 m/s 0 m 5 m 10 m 15 m 20 m DISTANCE ALONG THE LAP cornering limit only finished profile
How to read it: the horizontal axis is distance along one lap of a stadium-shaped test track, two 6 m straights joined by half circles of radius 1.5 m. The vertical axis is speed. Grey bands mark the two corners. The dashed line is the cornering limit alone: full speed on the straights and 1.94 m/s in the corners, with instant steps between them. The solid line is the finished profile, which brakes gradually before each corner and accelerates gradually out of it. The lap starts just after a corner, so the solid line starts at corner speed. Computed with 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 kappa

np.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(), curvature

Minimizing 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.