Bicycle Kinematic Model: First Principles
The Vehicle Dynamics Foundation for the reference airside AV stack's Simulation and Control
1. Derivation from First Principles
1.1 Assumptions
- Rigid body: Vehicle doesn't deform
- No tire slip: Tire velocity is aligned with tire heading (kinematic, not dynamic)
- Planar motion: Vehicle moves in the x-y plane only (no pitch, roll)
- Two-wheel equivalent: Left and right wheels collapsed to single front and rear wheel on centerline
1.2 Geometry
δ (steering angle)
↗
┌──────●──────┐ Front axle (steered)
│ │ │
│ │ L │ L = wheelbase = 3.15m (third-generation tug)
│ │ │
│ ● │ Rear axle (reference point)
└─────────────┘
↑
(x, y, θ) — state: position and heading of rear axle1.3 Kinematic Equations (Continuous Time)
The rear axle traces a path. The front axle steers.
ẋ = v · cos(θ) — x velocity
ẏ = v · sin(θ) — y velocity
θ̇ = v · tan(δ) / L — yaw rate (turning)
where:
(x, y) — rear axle position [m]
θ — heading angle [rad], 0 = east, π/2 = north
v — velocity at rear axle [m/s]
δ — front wheel steering angle [rad]
L — wheelbase [m] (distance between front and rear axles)Derivation of θ̇:
The instantaneous turning radius R is related to steering angle by:
tan(δ) = L / R
The yaw rate is:
θ̇ = v / R = v · tan(δ) / L1.4 Discrete-Time Model (Euler Integration)
the reference airside AV stack's pysim uses dt = 0.02s (50Hz):
python
def kinematic_step(x, y, theta, v, delta, L=3.15, dt=0.02):
"""One step of the kinematic bicycle model."""
x_new = x + v * math.cos(theta) * dt
y_new = y + v * math.sin(theta) * dt
theta_new = theta + (v * math.tan(delta) / L) * dt
# Normalize theta to [-π, π]
theta_new = (theta_new + math.pi) % (2 * math.pi) - math.pi
return x_new, y_new, theta_newFor better accuracy (RK4 integration):
python
def kinematic_step_rk4(x, y, theta, v, delta, L=3.15, dt=0.02):
"""Fourth-order Runge-Kutta integration."""
def f(state):
x, y, theta = state
return np.array([
v * np.cos(theta),
v * np.sin(theta),
v * np.tan(delta) / L
])
state = np.array([x, y, theta])
k1 = f(state)
k2 = f(state + dt/2 * k1)
k3 = f(state + dt/2 * k2)
k4 = f(state + dt * k3)
state_new = state + dt/6 * (k1 + 2*k2 + 2*k3 + k4)
return state_new[0], state_new[1], state_new[2]2. third-generation tug Parameters
python
# From airside_python_sim and av_comms config
third-generation tug_PARAMS = {
'wheelbase': 3.15, # meters (L_eff, validated from bag data)
'max_steering_angle': 0.8762, # radians (50.2 degrees)
'max_speed': 6.67, # m/s (24 km/h)
'max_acceleration': 1.0, # m/s² (approximate)
'max_deceleration': 2.0, # m/s² (hydraulic braking)
'track_width': 1.8, # meters (approximate)
'vehicle_length': 5.5, # meters (approximate)
'vehicle_width': 2.0, # meters (approximate)
'turning_radius_min': 3.15 / math.tan(0.8762), # = 2.63m at max steer
}2.1 Effective Wheelbase (L_eff)
The kinematic bicycle model uses an "effective" wheelbase that may differ from the physical wheelbase due to:
- Tire compliance (cornering stiffness)
- Steering geometry (Ackermann approximation error)
- Load distribution
python
# From fit_wheelbase.py — fitted to real third-generation tug bag data
# Method: minimize error between model prediction and GPS/odometry trajectory
# Result: L_eff = 3.15m
# Validation error: mean 4.76m over 116m trajectory (4.1%)2.2 Minimum Turning Radius
R_min = L / tan(δ_max) = 3.15 / tan(0.8762) = 3.15 / 1.198 = 2.63m
This is the minimum turning radius of the rear axle.
The outer front wheel traces a larger radius:
R_outer = sqrt(R_min² + L²) = sqrt(2.63² + 3.15²) = 4.10m
The vehicle body sweeps an area between R_inner ≈ 1.73m and R_outer ≈ 4.10m3. Ackermann Steering Geometry
3.1 Ideal Ackermann
In reality, the inner and outer wheels must steer at different angles to avoid tire scrub:
δ_outer δ_inner
↗ ↗
●────────────────────● Front axle
│ L │
│ │
●────────────────────● Rear axle
R (turning radius)
Ideal Ackermann:
cot(δ_outer) - cot(δ_inner) = W / L
where W = track width = 1.8m, L = wheelbase = 3.15m
For the bicycle model, δ is the equivalent single-wheel angle:
δ ≈ (δ_inner + δ_outer) / 2 (approximation)
More precisely: tan(δ) = L / R3.2 third-generation tug Steering Conversion
From airside_av_comms:
Steering command (ROS): angular.z in radians
→ Convert: angle_rad → sign-inverted percentage counts
→ third-generation tug: ±95 counts = ±50.2° (0.8762 rad)
→ Scale: counts = angle_rad * (95 / 0.8762) = angle_rad * 108.4
Feedback: steering position sensor → percentage counts → radians4. Crab Steering (third-generation tug Side Drive)
4.1 Crab Mode Kinematics
In crab mode, all four wheels steer in the same direction:
δ_crab δ_crab
↗ ↗
●────────────────────● Front axle
│ │
│ │
●────────────────────● Rear axle
↗ ↗
δ_crab δ_crab
Kinematic equations in crab mode:
ẋ = v · cos(θ + δ_crab) — moves sideways!
ẏ = v · sin(θ + δ_crab)
θ̇ = 0 — heading doesn't change
This is pure translation at angle δ_crab relative to heading.4.2 Transition Mode
third-generation tug can transition between Ackermann and crab steering:
State machine: ACKERMANN → TRANSITION → CRAB → TRANSITION → ACKERMANN
During transition, front and rear wheels steer at different rates
to smoothly change from differential to parallel steering.5. Model Predictive Control (MPC) with Bicycle Model
5.1 Linearized State Space
For MPC, linearize around the current state:
State: x = [X, Y, θ, v]ᵀ
Input: u = [a, δ]ᵀ (acceleration, steering)
Continuous: ẋ = f(x, u)
Linearized: ẋ ≈ A·x + B·u
A = ∂f/∂x = [0 0 -v·sin(θ) cos(θ)]
[0 0 v·cos(θ) sin(θ)]
[0 0 0 tan(δ)/L]
[0 0 0 0 ]
B = ∂f/∂u = [0 0 ]
[0 0 ]
[0 v/(L·cos²(δ))]
[1 0 ]
Discretized: x_{k+1} = A_d·x_k + B_d·u_k
A_d = I + A·dt, B_d = B·dt (Euler)5.2 MPC Formulation
min_{u_0,...,u_{N-1}} Σ_{k=0}^{N-1} [x_k^T Q x_k + u_k^T R u_k] + x_N^T P x_N
subject to:
x_{k+1} = f(x_k, u_k) — bicycle model dynamics
|δ| ≤ δ_max = 0.8762 rad — steering limits
|v| ≤ v_max = 6.67 m/s — speed limits
|a| ≤ a_max = 2.0 m/s² — acceleration limits
|δ̇| ≤ δ̇_max (steering rate limit) — actuator rate limit
Q = diag(q_x, q_y, q_θ, q_v) — state tracking weights
R = diag(r_a, r_δ) — input effort weights
P = solution of discrete-time algebraic Riccati equation (terminal cost)6. Integration with World Model
6.1 Physics Prior for Predictions
The bicycle model provides a physics prior for the world model:
python
def enforce_kinematic_feasibility(predicted_trajectory, dt=0.2):
"""Check if a world model prediction is kinematically feasible."""
for t in range(1, len(predicted_trajectory)):
x0, y0, theta0 = predicted_trajectory[t-1]
x1, y1, theta1 = predicted_trajectory[t]
# Implied velocity
v = math.sqrt((x1-x0)**2 + (y1-y0)**2) / dt
# Implied steering angle
dtheta = theta1 - theta0
if abs(v) > 0.01:
delta = math.atan(dtheta * L / (v * dt))
else:
delta = 0
# Check feasibility
if abs(v) > third-generation tug_PARAMS['max_speed']:
return False, f"Speed {v:.1f} exceeds max {third-generation tug_PARAMS['max_speed']}"
if abs(delta) > third-generation tug_PARAMS['max_steering_angle']:
return False, f"Steering {delta:.2f} exceeds max {third-generation tug_PARAMS['max_steering_angle']}"
return True, "Feasible"6.2 World Model Loss Regularization
Add a kinematic feasibility loss to world model training:
python
def kinematic_loss(predicted_ego_trajectory, dt=0.2, L=3.15):
"""Penalize kinematically infeasible ego predictions."""
positions = predicted_ego_trajectory[:, :2] # (T, 2)
headings = predicted_ego_trajectory[:, 2] # (T,)
velocities = torch.diff(positions, dim=0) / dt # (T-1, 2)
speeds = velocities.norm(dim=1) # (T-1,)
heading_rates = torch.diff(headings) / dt # (T-1,)
# Implied steering angles
implied_steering = torch.atan(heading_rates * L / (speeds + 1e-6))
# Penalize exceeding limits
speed_violation = F.relu(speeds - 6.67)
steering_violation = F.relu(implied_steering.abs() - 0.8762)
return speed_violation.mean() + steering_violation.mean()7. Validation: the reference airside AV stack's 4.1% Error Result
Test: Replay real third-generation tug bag data commands through kinematic bicycle model
Input: Recorded steering and velocity commands from CAN
Output: Predicted trajectory from bicycle model
Ground truth: GPS/odometry trajectory from bag
Result:
Total distance: 116m
Mean position error: 4.76m (4.1%)
Max position error: ~8m at end of trajectory (accumulated)
Error sources:
1. No tire slip modeling (dynamic effects at turns)
2. Hydraulic steering actuator delay (not modeled)
3. Effective wheelbase varies with load
4. GPS/odometry ground truth has its own errors
5. Euler integration numerical error (small at 50Hz)4.1% is good enough for:
- Nav stack testing (trajectory following, behavior FSM)
- Scenario-based validation (waypoint reaching, obstacle response)
- World model training data augmentation (approximate physics)
Not good enough for:
- Precise docking (< 5cm accuracy needed)
- Safety-critical collision prediction (need better dynamic model)
- Crab steering mode (different kinematics not in bicycle model)
Sources
- Rajamani, R. "Vehicle Dynamics and Control." Springer, 2012
- Kong et al. "Kinematic and Dynamic Vehicle Models for Autonomous Driving Control Design." IV, 2015
- Werling et al. "Optimal Trajectory Generation for Dynamic Street Scenarios in a Frenet Frame." ICRA, 2010
- reference airside AV stack airside_python_sim source code (validated model)
- reference airside AV stack fit_wheelbase.py (parameter identification)