Zero2FSD portal
Module 07Reading notes
3. Planning & Control•Module 07

Closed-Loop Control & Kinematics

Steer a simple car model to follow a planned path

Reading notes on this site; run the Colab notebook and use the full reference code in the repo.

This page shows the notebook with saved outputs. Open it in Colab to run every cell yourself.

Tutorial

Read the explanation, work the examples, then run the module notebook or code.

A planner can hand you a list of (x, y) points. That list is not a drive. The car still needs a steering angle every few hundredths of a second, and the kinematic bicycle in modules/07_control_sim only moves when you pass steer and acceleration into KinematicBicycleModel.step.

This notebook builds closed-loop tracking, then stares at the place a simple rule fails. Pointing the wheels at the next point cuts the corner. Stanley is heading error plus how far you are off the line, scaled by speed. Too much gain wobbles.

Each idea shows up three times: a picture, a handful of numbers, then a few lines of code. Predict first, then run the cell. The paragraph after the cell says what was surprising, and what it would mean for a car.

In [1]
import os, subprocess, sys
from pathlib import Path

import matplotlib
import matplotlib.pyplot as plt
import numpy as np

def keep_inline():
    matplotlib.use("module://matplotlib_inline.backend_inline", force=True)
    ip = None
    try:
        from IPython import get_ipython
        ip = get_ipython()
    except Exception:
        ip = None
    if ip is not None:
        ip.run_line_magic("matplotlib", "inline")

%matplotlib inline

def find_repo(start: Path) -> Path:
    for p in [start, *start.parents]:
        if (p / "modules" / "07_control_sim" / "bicycle_model.py").is_file():
            return p.resolve()
    return start.resolve()

REPO = find_repo(Path.cwd())
if not (REPO / "modules" / "07_control_sim" / "bicycle_model.py").is_file():
    dest = Path.cwd() / "self-driving-ai-course"
    if not (dest / "modules" / "07_control_sim" / "bicycle_model.py").is_file():
        subprocess.run(
            ["git", "clone", "--depth", "1", "https://github.com/vvknyn/self-driving-ai-course.git", str(dest)],
            check=True,
        )
    REPO = dest.resolve()

os.chdir(REPO)
sys.path.insert(0, str(REPO / "modules" / "07_control_sim"))

from bicycle_model import KinematicBicycleModel, VehicleState
from controllers import PIDLongitudinalController, PurePursuitController, StanleyController
from simulator import generate_curved_track

try:
    import pytest  # noqa: F401
except ImportError:
    subprocess.run([sys.executable, "-m", "pip", "install", "-q", "pytest"], check=True)

def wrap_angle(angle: float) -> float:
    return float((angle + np.pi) % (2.0 * np.pi) - np.pi)

print("Repo:", REPO)
print("wheelbase:", KinematicBicycleModel().L, "m")
Output
Repo: /tmp/sdc
wheelbase: 2.8 m

Repo path and wheelbase 2.8 m come from the module defaults. The rest of the notebook imports controllers from the same folder and stays on CPU.

1. The destination

Three panels, one story. Left: closed loop on the S-curve. Stanley steering plus a speed command, and the bicycle trace on the planned path. Middle: aim at the next waypoint on a quarter turn, and the shortcut inside the curve. Right: the same straight line with a modest Stanley gain versus a huge one, and the lateral wobble.

You do not need the control law yet. This is where the notebook lands.

Predict: the middle trace sits inside the arc. The right panel with large k oscillates across y = 0 while the modest gain settles near the line.

In [2]
keep_inline()

def aim_at_next(state, path_x, path_y, car):
    dist = np.hypot(path_x - state.x, path_y - state.y)
    nearest = int(np.argmin(dist))
    idx = min(nearest + 1, len(path_x) - 1)
    alpha = wrap_angle(np.arctan2(path_y[idx] - state.y, path_x[idx] - state.x) - state.psi)
    delta = float(np.clip(alpha, -car.max_steer, car.max_steer))
    return delta, alpha, idx


def run_s_curve():
    car = KinematicBicycleModel(wheelbase=2.8, max_steer_deg=35.0)
    stanley = StanleyController(k_gain=0.75, soft_factor=1.0)
    pid = PIDLongitudinalController(kp=1.5, ki=0.05, kd=0.1)
    ref_x, ref_y, ref_psi = generate_curved_track(length_m=100.0)
    state = VehicleState(x=0.0, y=1.5, psi=0.0, v=8.0)
    dt = 0.05
    target_speed = 12.0
    ego_x, ego_y, ctes = [], [], []
    for step in range(180):
        front_x, front_y = car.front_axle_position(state)
        delta, cte, heading_err = stanley.compute_steering(
            front_x, front_y, state.psi, state.v, ref_x, ref_y, ref_psi
        )
        accel = pid.compute_acceleration(state.v, target_speed, dt)
        state = car.step(state, accel, delta, dt)
        ego_x.append(state.x)
        ego_y.append(state.y)
        ctes.append(abs(cte))
        if state.x >= ref_x[-1] - 5.0:
            break
    return ref_x, ref_y, ego_x, ego_y, np.array(ctes)


def run_corner_cut():
    R = 15.0
    step_deg = 30.0
    theta = np.deg2rad(np.arange(0.0, 90.0 + step_deg, step_deg))
    corner_x = R * np.sin(theta)
    corner_y = R * (1.0 - np.cos(theta))
    car = KinematicBicycleModel()
    state = VehicleState(x=0.0, y=0.0, psi=0.0, v=10.0)
    cut_x, cut_y = [], []
    for _ in range(120):
        delta, alpha, idx = aim_at_next(state, corner_x, corner_y, car)
        state = car.step(state, throttle_accel=0.0, steer_delta=delta, dt=0.05)
        cut_x.append(state.x)
        cut_y.append(state.y)
        if state.x > R * 0.95 and state.y > R * 0.65:
            break
    radius = np.hypot(np.array(cut_x), np.array(cut_y) - R)
    return corner_x, corner_y, cut_x, cut_y, R, radius.min()


def stanley_straight_y(k_gain, dt, seconds=8.0):
    path_x = np.arange(0.0, 200.0, 0.5)
    path_y = np.zeros_like(path_x)
    path_psi = np.zeros_like(path_x)
    car = KinematicBicycleModel()
    stanley = StanleyController(k_gain=k_gain, soft_factor=1.0)
    state = VehicleState(x=0.0, y=1.5, psi=0.0, v=8.0)
    ys = []
    steps = int(round(seconds / dt))
    for _ in range(steps):
        front_x, front_y = car.front_axle_position(state)
        delta, cte, heading_err = stanley.compute_steering(
            front_x, front_y, state.psi, state.v, path_x, path_y, path_psi
        )
        state = car.step(state, throttle_accel=0.0, steer_delta=delta, dt=dt)
        ys.append(state.y)
    ys = np.array(ys)
    crossings = int(np.sum(ys[1:] * ys[:-1] < 0))
    t = np.arange(1, len(ys) + 1) * dt
    return t, ys, crossings, ys.min()


ref_x, ref_y, ego_x, ego_y, ctes = run_s_curve()
corner_x, corner_y, cut_x, cut_y, R, closest_r = run_corner_cut()
t_lo, y_lo, cross_lo, min_lo = stanley_straight_y(0.75, 0.20)
t_hi, y_hi, cross_hi, min_hi = stanley_straight_y(15.0, 0.20)

print(f"S-curve steps {len(ctes)}  mean |CTE| {ctes.mean():.3f} m  max |CTE| {ctes.max():.3f} m")
print(f"corner radius {R:.1f} m  closest radius {closest_r:.3f} m")
print(f"k=0.75 crossings {cross_lo}  lowest y {min_lo:.3f} m")
print(f"k=15 crossings {cross_hi}  lowest y {min_hi:.3f} m")

fig, axes = plt.subplots(1, 3, figsize=(12.5, 3.4))
axes[0].plot(ref_x, ref_y, "k--", label="planned path")
axes[0].plot(ego_x, ego_y, label="Stanley + PID")
axes[0].set_xlabel("x (m)")
axes[0].set_ylabel("y (m)")
axes[0].set_title("S-curve closed loop")
axes[0].legend(fontsize=8)

arc = np.linspace(0.0, np.pi / 2, 200)
axes[1].plot(R * np.sin(arc), R * (1.0 - np.cos(arc)), color="0.75", label=f"{R:.0f} m arc")
axes[1].plot(corner_x, corner_y, "k.", ms=3, label="waypoints")
axes[1].plot(cut_x, cut_y, label="aim at next")
axes[1].set_aspect("equal", adjustable="box")
axes[1].set_xlabel("x (m)")
axes[1].set_ylabel("y (m)")
axes[1].set_title("Corner cut")
axes[1].legend(fontsize=8)

axes[2].plot(t_lo, y_lo, label="k = 0.75")
axes[2].plot(t_hi, y_hi, label="k = 15")
axes[2].axhline(0.0, color="k", lw=0.8)
axes[2].set_xlabel("time (s)")
axes[2].set_ylabel("y (m)")
axes[2].set_title("Gain wobble")
axes[2].legend(fontsize=8)
plt.tight_layout()
plt.show()
Output
S-curve steps 165  mean |CTE| 0.162 m  max |CTE| 0.733 m
corner radius 15.0 m  closest radius 14.540 m
k=0.75 crossings 0  lowest y 0.006 m
k=15 crossings 37  lowest y -0.271 m
Notebook output

The S-curve preview ran 165 steps with mean cross-track error 0.162 m and max 0.733 m. On the quarter turn, closest radius 14.540 m sits just inside the 15.0 m arc. On the straight line, k = 0.75 crosses y = 0 zero times with lowest y = 0.006 m, while k = 15 crosses 37 times and dips to -0.271 m.

For a car, the left panel is the planner handoff you want: tires on the drawn lane. The middle panel shows waypoint aiming barely clipping the arc. The right panel is why Stanley's cross-track gain stays modest.

2. A path on paper is not a drive

Module 06 ends with arrays of x and y. Those arrays are a drawing. The bicycle moves only when you pass a steering angle and an acceleration into KinematicBicycleModel.step.

Hold the wheel at 0 and the car travels along its current heading. The path can bend away from that line.

Predict: 8 m/s for 4 seconds, heading 0, steer 0. The car's y stays 0. Does the path y = 4 sin(x / 15) stay 0 at the car's x?

In [3]
keep_inline()
path_x = np.arange(0.0, 40.0, 0.5)
path_y = 4.0 * np.sin(path_x / 15.0)

car = KinematicBicycleModel()
state = VehicleState(x=0.0, y=0.0, psi=0.0, v=8.0)
dt = 0.05
xs, ys = [state.x], [state.y]
for _ in range(80):
    state = car.step(state, throttle_accel=0.0, steer_delta=0.0, dt=dt)
    xs.append(state.x)
    ys.append(state.y)

nearest = int(np.argmin(np.abs(path_x - state.x)))
print("speed 8.0 m/s for 4.00 s, steer 0 deg")
print(f"car x, y = {state.x:.3f}, {state.y:.3f}")
print(f"path y at that x = {path_y[nearest]:.3f}")

fig, ax = plt.subplots(figsize=(7, 3))
ax.plot(path_x, path_y, "k--", label="planned path")
ax.plot(xs, ys, label="steer held at 0")
ax.set_xlabel("x (m)")
ax.set_ylabel("y (m)")
ax.legend()
ax.set_title("A path does not steer the car")
plt.show()
Output
speed 8.0 m/s for 4.00 s, steer 0 deg
car x, y = 32.000, 0.000
path y at that x = 3.384
Notebook output

After 4.00 s the car is at x = 32.000, y = 0.000. The path at that x is y = 3.384. Straight integration never sees the sine bend.

On a real vehicle, this is what happens if you log a route and never close the loop on steering: the map line and the tires diverge.

3. One step of the bicycle

Rear-axle state is (x, y, psi, v). Heading psi is in radians, and 0 points along +x. L is the wheelbase, 2.8 m here. One forward-Euler step with steer delta updates heading by (v / L) * tan(delta) * dt.

Predict: at v = 8.0 m/s, delta = 10 deg, dt = 0.5 s, heading should move by roughly a quarter radian from psi = 0.4.

In [4]
keep_inline()
v = 8.0
delta = np.deg2rad(10.0)
dt = 0.5
x0, y0, psi0 = 0.0, 1.0, 0.4

car = KinematicBicycleModel()
heading_change = (v / car.L) * np.tan(delta) * dt
state = VehicleState(x=x0, y=y0, psi=psi0, v=v)
after = car.step(state, throttle_accel=0.0, steer_delta=delta, dt=dt)

print(f"v = {v:.1f} m/s")
print(f"delta = {np.degrees(delta):.2f} deg")
print(f"L = {car.L:.1f} m")
print(f"dt = {dt:.1f} s")
print(f"heading change formula {heading_change:.4f} rad")
print(f"psi before {psi0:.4f}  after {after.psi:.4f}")
print(f"x {x0:.3f} -> {after.x:.3f}  y {y0:.3f} -> {after.y:.3f}")
Output
v = 8.0 m/s
delta = 10.00 deg
L = 2.8 m
dt = 0.5 s
heading change formula 0.2519 rad
psi before 0.4000  after 0.6519
x 0.000 -> 3.684  y 1.000 -> 2.558

Heading change is 0.2519 rad, so the new heading is 0.6519. Position moves to (3.684, 2.558) because velocity projects along the old heading for one Euler step.

For a car, this is the discrete-time model the controller assumes every 50 ms tick.

4. Pointing at the next waypoint cuts the corner

First controller: take the nearest path point to the rear axle, then aim the wheels at the next waypoint. The command is the angle from the car heading to that point, clipped to the mechanical steer limit.

Predict: on a straight road starting 2 m left of center, the first aim angle will be large and negative. On a 15 m radius quarter turn, the trace will sit inside the arc.

In [5]
keep_inline()
straight_x = np.arange(0.0, 80.0, 0.5)
straight_y = np.zeros_like(straight_x)
car = KinematicBicycleModel()
state = VehicleState(x=0.0, y=2.0, psi=0.0, v=8.0)
aim_x, aim_y = [], []
for i in range(160):
    delta, alpha, idx = aim_at_next(state, straight_x, straight_y, car)
    if i == 0:
        first_alpha, first_delta = alpha, delta
    state = car.step(state, throttle_accel=0.0, steer_delta=delta, dt=0.05)
    aim_x.append(state.x)
    aim_y.append(state.y)
aim_y = np.array(aim_y)
crossings = int(np.sum(aim_y[1:] * aim_y[:-1] < 0))
print(f"first aim {np.degrees(first_alpha):.2f} deg")
print(f"clamped steer {np.degrees(first_delta):.2f} deg")
print(f"y crossings {crossings}")
print(f"lowest y {aim_y.min():.3f} m")

R = 15.0
step_deg = 30.0
theta = np.deg2rad(np.arange(0.0, 90.0 + step_deg, step_deg))
corner_x = R * np.sin(theta)
corner_y = R * (1.0 - np.cos(theta))
state = VehicleState(x=0.0, y=0.0, psi=0.0, v=10.0)
cut_x, cut_y = [], []
for _ in range(120):
    delta, alpha, idx = aim_at_next(state, corner_x, corner_y, car)
    state = car.step(state, throttle_accel=0.0, steer_delta=delta, dt=0.05)
    cut_x.append(state.x)
    cut_y.append(state.y)
    if state.x > R * 0.95 and state.y > R * 0.65:
        break
cut_x = np.array(cut_x)
cut_y = np.array(cut_y)
radius = np.hypot(cut_x, cut_y - R)
print(f"corner radius {R:.1f} m")
print(f"closest radius {radius.min():.3f} m")

fig, axes = plt.subplots(1, 2, figsize=(10, 3.6))
axes[0].plot(straight_x, straight_y, "k--", label="path")
axes[0].plot(aim_x, aim_y, label="aim at next waypoint")
axes[0].set_xlim(0, 40)
axes[0].set_ylim(-2, 2.5)
axes[0].set_xlabel("x (m)")
axes[0].set_ylabel("y (m)")
axes[0].set_title("Straight road, overshoot")
axes[0].legend()

arc = np.linspace(0.0, np.pi / 2, 200)
axes[1].plot(R * np.sin(arc), R * (1.0 - np.cos(arc)), color="0.75", label="15 m radius")
axes[1].plot(corner_x, corner_y, "k.", label="waypoints")
axes[1].plot(cut_x, cut_y, label="aim at next waypoint")
axes[1].set_aspect("equal", adjustable="box")
axes[1].set_xlabel("x (m)")
axes[1].set_ylabel("y (m)")
axes[1].set_title("Coarse corner, cut inside")
axes[1].legend()
plt.tight_layout()
plt.show()
Output
first aim -75.96 deg
clamped steer -35.00 deg
y crossings 15
lowest y -1.386 m
corner radius 15.0 m
closest radius 14.540 m
Notebook output

The first aim is -75.96 deg. The bicycle clips it to -35.00 deg, a full lock toward the line. y crosses 0 15 times and dips to -1.386 m. On the corner, closest radius 14.540 m is slightly inside the 15.0 m arc.

For a car, waypoint chasing is a useful baseline and a bad finish: it overshoots on straights and can hunt on curves.

5. Stanley: heading error plus offset scaled by speed

Stanley, as implemented in StanleyController.compute_steering, is two angles added together.

  1. Heading error: wrap path tangent minus car heading.
  2. Cross-track term: arctan2(k * (-cross_track), max(0.1, v) + soft).

Speed in the denominator is the scaling by speed: the same lateral offset commands a larger steer angle when you are slow.

Predict: with the front axle 2 m left of a straight path, heading aligned, v = 10 m/s, steer should be negative (turn right). At v = 2 m/s the same offset should demand a larger magnitude angle.

In [6]
keep_inline()
k_gain = 0.8
soft_factor = 1.0
vehicle_v = 10.0
cte_hand = 2.0
heading_hand = 0.0
denom = max(0.1, vehicle_v) + soft_factor
cross = float(np.arctan2(k_gain * (-cte_hand), denom))
steer_hand = heading_hand + cross

path_x = np.linspace(0.0, 50.0, 100)
path_y = np.zeros_like(path_x)
path_psi = np.zeros_like(path_x)
steer, cte, heading = StanleyController(k_gain=k_gain, soft_factor=soft_factor).compute_steering(
    front_x=5.0,
    front_y=2.0,
    vehicle_psi=0.0,
    vehicle_v=vehicle_v,
    path_x=path_x,
    path_y=path_y,
    path_psi=path_psi,
)
cross_slow = float(np.arctan2(k_gain * (-cte_hand), max(0.1, 2.0) + soft_factor))

print(f"cross-track error {cte_hand:.3f} m")
print(f"heading error {heading_hand:.3f} rad")
print(f"denominator {denom:.1f}")
print(f"cross-track angle {cross:.4f} rad ({np.degrees(cross):.3f} deg)")
print(f"hand steer {steer_hand:.4f} rad")
print(f"repo steer {steer:.4f} rad")
print(f"repo cross-track {cte:.3f} m")
print(f"repo heading error {heading:.3f} rad")
print(f"same error at 2 m/s: {np.degrees(cross_slow):.3f} deg")
assert abs(steer - steer_hand) < 1e-12
assert abs(cte - cte_hand) < 1e-12
Output
cross-track error 2.000 m
heading error 0.000 rad
denominator 11.0
cross-track angle -0.1444 rad (-8.276 deg)
hand steer -0.1444 rad
repo steer -0.1444 rad
repo cross-track 2.000 m
repo heading error 0.000 rad
same error at 2 m/s: -28.072 deg

Hand and repo agree: cross-track error 2.000 m, heading error 0.000 rad, denominator 11.0, steer -0.1444 rad (-8.276 deg). At 2 m/s the cross-track angle alone is -28.072 deg — much larger than the -8.276 deg term at the hand-step speed.

For a car, Stanley is how you pull back to the lane center without waypoint clipping: heading alignment plus a lateral correction that relaxes as speed rises.

6. Too much gain wobbles

Stanley's k scales the cross-track angle. Same straight path, same 0.20 s step, two gains.

Predict: k = 15 will overshoot the centerline and oscillate. k = 0.75 should ease in without crossing y = 0.

In [7]
keep_inline()
t_lo, y_lo, cross_lo, min_lo = stanley_straight_y(0.75, 0.20)
t_hi, y_hi, cross_hi, min_hi = stanley_straight_y(15.0, 0.20)
_, _, cross_fast, _ = stanley_straight_y(15.0, 0.05)
print(f"k=0.75 dt=0.20 s crossings={cross_lo} lowest y={min_lo:.3f} m")
print(f"k=15 dt=0.20 s crossings={cross_hi} lowest y={min_hi:.3f} m")
print(f"k=15 dt=0.05 s crossings={cross_fast}")

fig, ax = plt.subplots(figsize=(7.5, 3.2))
ax.plot(t_lo, y_lo, label="k = 0.75, dt = 0.20 s")
ax.plot(t_hi, y_hi, label="k = 15, dt = 0.20 s")
ax.axhline(0.0, color="k", lw=0.8)
ax.set_xlabel("time (s)")
ax.set_ylabel("y (m)")
ax.set_title("Same straight line, two gains")
ax.legend()
plt.show()
Output
k=0.75 dt=0.20 s crossings=0 lowest y=0.006 m
k=15 dt=0.20 s crossings=37 lowest y=-0.271 m
k=15 dt=0.05 s crossings=0
Notebook output

Gain 0.75 held for 8.00 s crosses the line 0 times. The lowest y is 0.006 m: it eases in and stays on the positive side. Gain 15 reports 37 crossings in eight seconds at dt = 0.20 s and reaches -0.271 m. Tightening the step to 0.05 s with k = 15 reports 0 crossings in the same window but harsher steering each tick.

For a car, this is the tuning trade: a large cross-track gain pulls in fast but excites lateral oscillation; production gains stay near the modest branch.

7. Closed loop on the S-curve

Now send Stanley's steer and a PID acceleration command back into step on every tick. The path is the S-curve from generate_curved_track. Start 1.5 m off the centerline at 8 m/s and ask for 12 m/s.

Predict: cross-track error should shrink after the first second. Speed should climb toward 12 m/s but hit the acceleration clip on the first tick.

In [8]
keep_inline()
car = KinematicBicycleModel(wheelbase=2.8, max_steer_deg=35.0)
stanley = StanleyController(k_gain=0.75, soft_factor=1.0)
pid = PIDLongitudinalController(kp=1.5, ki=0.05, kd=0.1)
ref_x, ref_y, ref_psi = generate_curved_track(length_m=100.0)
state = VehicleState(x=0.0, y=1.5, psi=0.0, v=8.0)
dt = 0.05
target_speed = 12.0

err0 = target_speed - state.v
p_term = pid.kp * err0
i_term = pid.ki * err0 * dt
d_term = pid.kd * err0 / dt
print(f"P {p_term:.3f}  I {i_term:.3f}  D {d_term:.3f}  sum {p_term + i_term + d_term:.3f}")

ctes = []
ego_x, ego_y = [], []
for step in range(180):
    front_x, front_y = car.front_axle_position(state)
    delta, cte, heading_err = stanley.compute_steering(
        front_x, front_y, state.psi, state.v, ref_x, ref_y, ref_psi
    )
    accel = pid.compute_acceleration(state.v, target_speed, dt)
    if step == 0:
        print(f"first accel request {accel:.3f} m/s^2")
        print(f"accel clip {car.max_accel:.1f} m/s^2")
    state = car.step(state, accel, delta, dt)
    ctes.append(abs(cte))
    ego_x.append(state.x)
    ego_y.append(state.y)
    if step in (0, 80, 160):
        print(f"t={step * dt:.2f} s  x={state.x:.3f}  y={state.y:.3f}  cte={cte:.3f} m")
    if state.x >= ref_x[-1] - 5.0:
        break

ctes = np.array(ctes)
print(f"steps {len(ctes)}")
print(f"mean |CTE| {ctes.mean():.3f} m")
print(f"max |CTE| {ctes.max():.3f} m")
print(f"steady-state |CTE| {ctes[-40:].mean():.3f} m")

fig, ax = plt.subplots(figsize=(7.5, 3.4))
ax.plot(ref_x, ref_y, "k--", label="planned path")
ax.plot(ego_x, ego_y, label="Stanley + speed controller")
ax.set_xlabel("x (m)")
ax.set_ylabel("y (m)")
ax.set_title("Closed loop on the S-curve")
ax.legend()
plt.show()
Output
P 6.000  I 0.010  D 8.000  sum 14.010
first accel request 14.010 m/s^2
accel clip 3.5 m/s^2
t=0.00 s  x=0.400  y=1.500  cte=0.733 m
t=4.00 s  x=45.059  y=0.642  cte=0.091 m
t=8.00 s  x=92.586  y=-0.476  cte=-0.039 m
steps 165
mean |CTE| 0.162 m
max |CTE| 0.733 m
steady-state |CTE| 0.047 m
Notebook output

The speed split is P 6.000, I 0.010, D 8.000, sum 14.010 m/s². The bicycle clips that request to 3.5 m/s². Cross-track error at t = 0.00 s is 0.733 m and falls to 0.091 m by t = 4.00 s. Mean absolute cross-track error over 165 steps is 0.162 m; the last forty steps average 0.047 m.

For a car, this is the full stack fragment: lateral Stanley on the front axle, longitudinal PID on speed, both feeding the same bicycle integrator the module tests exercise.

8. Exercises

Exercise — yaw_change. Return the heading change of one Euler step, (v / wheelbase) * tan(steer_rad) * dt, in radians.

In [9]
def yaw_change_student(v, steer_rad, wheelbase, dt):
    # TODO: (v / wheelbase) * tan(steer_rad) * dt
    raise NotImplementedError

def yaw_change_reference(v, steer_rad, wheelbase, dt):
    return (v / wheelbase) * np.tan(steer_rad) * dt

def get_yaw_change():
    try:
        yaw_change_student(1.0, 0.0, 2.8, 0.1)
    except NotImplementedError:
        print("Using reference yaw_change (TODO not implemented)")
        return yaw_change_reference
    return yaw_change_student

yaw_change = get_yaw_change()
before = VehicleState(x=0.0, y=1.0, psi=0.4, v=8.0)
after = KinematicBicycleModel().step(before, throttle_accel=0.0, steer_delta=np.deg2rad(10.0), dt=0.5)
expected_dyaw = after.psi - before.psi
got_dyaw = yaw_change(8.0, np.deg2rad(10.0), 2.8, 0.5)
print(f"yaw change {got_dyaw:.4f} rad")
assert abs(got_dyaw - expected_dyaw) < 1e-12
print("✅ correct: yaw_change =", f"{got_dyaw:.4f}")
Output
Using reference yaw_change (TODO not implemented)
yaw change 0.2519 rad
✅ correct: yaw_change = 0.2519

✅ correct: yaw_change = 0.2519 rad. The reference implementation ran because the TODO still raises NotImplementedError.

Filling in the student function makes the check use your code instead of the reference.

Solution
def yaw_change(v, steer_rad, wheelbase, dt):
    return (v / wheelbase) * np.tan(steer_rad) * dt

Exercise — stanley_steer. Return the repo's steer command in radians: heading error plus atan2(k * (-cross_track_error), max(0.1, speed) + soft_factor).

In [10]
def stanley_steer_student(heading_error, cross_track_error, speed, k_gain, soft_factor):
    # TODO: heading_error + atan2(k_gain * (-cross_track_error), max(0.1, speed) + soft_factor)
    raise NotImplementedError

def stanley_steer_reference(heading_error, cross_track_error, speed, k_gain, soft_factor):
    cross = np.arctan2(k_gain * (-cross_track_error), max(0.1, speed) + soft_factor)
    return float(heading_error + cross)

def get_stanley_steer():
    try:
        stanley_steer_student(0.0, 0.0, 1.0, 0.8, 1.0)
    except NotImplementedError:
        print("Using reference stanley_steer (TODO not implemented)")
        return stanley_steer_reference
    return stanley_steer_student

stanley_steer = get_stanley_steer()
path_x = np.linspace(0.0, 50.0, 100)
path_y = np.zeros_like(path_x)
path_psi = np.zeros_like(path_x)
expected_steer, expected_cte, expected_heading = StanleyController(0.8, 1.0).compute_steering(
    5.0, 2.0, 0.0, 10.0, path_x, path_y, path_psi
)
got_steer = stanley_steer(expected_heading, expected_cte, 10.0, 0.8, 1.0)
print(f"stanley steer {got_steer:.4f} rad ({np.degrees(got_steer):.3f} deg)")
assert abs(got_steer - expected_steer) < 1e-12
print("✅ correct: stanley_steer =", f"{got_steer:.4f}")
Output
Using reference stanley_steer (TODO not implemented)
stanley steer -0.1444 rad (-8.276 deg)
✅ correct: stanley_steer = -0.1444

✅ correct: stanley_steer = -0.1444 rad (-8.276 deg), matching the hand step in section 5. The reference implementation ran because the TODO still raises NotImplementedError.

Solution
def stanley_steer(heading_error, cross_track_error, speed, k_gain, soft_factor):
    cross = np.arctan2(k_gain * (-cross_track_error), max(0.1, speed) + soft_factor)
    return float(heading_error + cross)
In [11]
import pytest
print("pytest", pytest.__version__)
Output
pytest 9.1.1

pytest 9.1.1 is available if you want to run the module tests locally after filling the exercises.

9. Recap

  • A path is coordinates. With steer held at 0 for 4.00 s at 8 m/s, the car is at (32.000, 0.000) while the path is at y = 3.384.
  • One Euler step at 10 deg steer changes heading by 0.2519 rad to 0.6519.
  • Aim-at-next locks at -35.00 deg and cuts a 15.0 m corner to closest radius 14.540 m.
  • Stanley at 2 m left and 10 m/s commands -0.1444 rad; at 2 m/s the cross-track angle alone is -28.072 deg.
  • Gain 0.75 crosses 0 times on a straight line; gain 15 crosses 37 times.
  • Closed loop on the S-curve: mean |CTE| 0.162 m over 165 steps, steady-state 0.047 m.

The planner draws the lane. Closed-loop control is what makes the tires follow it.