Ackermann steering
When a car turns slowly, how far must the outer front wheel turn when the inner one turns by \(\delta_i\)? The wheels roll without slipping sideways only if they all turn about one point. The rear wheels do not steer, so that point lies on the line of the rear axle, and the inner front wheel must turn more than the outer one. For the wheelbase \(l\) and the distance \(w\) between the two steering axes, the outer wheel’s angle \(\delta_o\) follows from the Ackermann condition
Most cars steer with a trapezoid of two steering arms and a tie rod, which meets this condition only approximately. A rule of thumb aims the steering arms at the middle of the rear axle. The example draws the trapezoid as a constraint sketch and turns the inner wheel up to 40°. The outer wheel’s angle from the solver must agree with a construction of the same position from two circles, and its difference from the Ackermann condition shows how good the rule of thumb is. A search over the arm angle then finds the trapezoid that comes closest.

[1]:
import math
from scipy.optimize import minimize_scalar
import planex as px
The steering trapezoid
The example is a car with a wheelbase of \(l = 2600\,\text{mm}\) and \(w = 1400\,\text{mm}\) between the steering axes. The steering arms are \(a = 150\,\text{mm}\) long and lie behind the front axle, at the angle \(\beta\) to the direction of travel, and the tie rod joins their ends. trapezoid draws the axle with both ends fixed, two construction lines that point straight back from the steering axes, the two arms and the tie rod, straight ahead, and dimensions the three
lengths. The angle from the left construction line to the left arm, steer, is \(\beta\), the one driving dimension left. The same angle on the right, outer, is a reference dimension, which the solver reports but does not hold. planex2d measures angles counterclockwise, so outer is \(-\beta\).
By the rule of thumb, the arms point at the middle of the rear axle, so \(\tan\beta = (w/2)/l\), and \(\beta\) is about 15° (Figure 1):
[3]:
l = 2600
w = 1400
a = 150
def trapezoid(beta):
"""the front axle, the steering arms at beta degrees to the direction of travel and the tie rod, straight ahead"""
across = a * math.sin(math.radians(beta))
back = -a * math.cos(math.radians(beta))
sk = px.Sketch()
sk.Move((-w / 2, 0), px.Fix).Line(w, 0, id="axle")
sk.Constrain("axle.end", px.Fix)
sk.Move("axle.start", px.Coincident).Line(0, -a, id="left_back", construction=True)
sk.Move("axle.end", px.Coincident).Line(0, -a, id="right_back", construction=True)
sk.Constrain("left_back.end", px.Fix)
sk.Constrain("right_back.end", px.Fix)
sk.Move("axle.start", px.Coincident).Line(across, back, id="left_arm")
sk.Move("axle.end", px.Coincident).Line(-across, back, id="right_arm")
sk.Move("left_arm.end", px.Coincident).Line(w - 2 * across, 0, id="tie_rod")
sk.Constrain("tie_rod.end", "right_arm.end", px.Coincident)
sk.Constrain("left_arm", px.Distance, a, id="left")
sk.Constrain("right_arm", px.Distance, a, id="right")
sk.Constrain("tie_rod", px.Distance, w - 2 * across, id="tie")
sk.Constrain("left_back", "left_arm", px.Angle, beta, id="steer")
sk.Constrain("right_back", "right_arm", px.Angle, id="outer", reference=True)
return sk
rule = math.degrees(math.atan(w / 2 / l))
steering = trapezoid(rule)
print("beta", round(rule, 2), "| status", steering.status, "| degrees of freedom", steering.dof)
beta 15.07 | status full | degrees of freedom 0
Turning the wheels
In a left turn, the left wheel is the inner one. Each wheel turns with its arm, so turning the inner wheel by \(\delta_i\) adds \(\delta_i\) to steer, and the outer wheel’s angle \(\delta_o\) is the change of outer. Update solves each position from the one before:
[5]:
def turned(sk, beta, inner):
"""the outer wheel's angles for the inner wheel's angles, in degrees"""
start = sk["outer"]
return [sk.Update({"steer": beta + di})["outer"] - start for di in inner]
inner = range(0, 41, 5)
outer = turned(steering, rule, inner)
The same position follows from two circles. The right arm’s end lies on the circle of the tie rod’s length around the left arm’s end and on the circle of the arm’s length around the right steering axis. At every angle, the solver’s \(\delta_o\) is compared with this construction:
[6]:
def constructed(beta, di):
"""the outer wheel's angle where the two circles meet"""
turn = math.radians(beta - 90 + di)
end = (-w / 2 + a * math.cos(turn), a * math.sin(turn))
tie = w - 2 * a * math.sin(math.radians(beta))
meet = max(px.Circle(end, tie).Intersections(px.Circle((w / 2, 0), a)), key=lambda p: p.x)
return math.degrees(math.atan2(meet.y, meet.x - w / 2)) + 90 + beta
print("largest difference", max(abs(do - constructed(rule, di)) for di, do in zip(inner, outer)), "degrees")
largest difference 2.0250467969162855e-13 degrees
Against the Ackermann condition
The Ackermann condition gives the outer wheel’s angle that turns all wheels about one point. The trapezoid’s \(\delta_o\) is compared with it:
[7]:
def ackermann(di):
"""the outer wheel's angle for the inner wheel's angle di, all wheels turning about one point"""
return math.degrees(math.atan(1 / (1 / math.tan(math.radians(di)) + w / l))) if di else 0.0
for di, do in zip(inner, outer):
print("inner", di, "| outer", round(do, 3), "| Ackermann", round(ackermann(di), 3), "| error", round(do - ackermann(di), 3))
inner 0 | outer 0.0 | Ackermann 0.0 | error 0.0
inner 5 | outer 4.878 | Ackermann 4.776 | error 0.102
inner 10 | outer 9.519 | Ackermann 9.148 | error 0.371
inner 15 | outer 13.926 | Ackermann 13.179 | error 0.747
inner 20 | outer 18.093 | Ackermann 16.926 | error 1.167
inner 25 | outer 22.008 | Ackermann 20.442 | error 1.567
inner 30 | outer 25.651 | Ackermann 23.77 | error 1.881
inner 35 | outer 28.998 | Ackermann 26.953 | error 2.045
inner 40 | outer 32.018 | Ackermann 30.026 | error 1.991
The trapezoid of the rule of thumb turns the outer wheel too far. Its error grows to about 2° at an inner angle of 35°. Already at 30°, the front wheels’ axes meet the line of the rear axle 489 mm apart, so the outer wheel has to slip.
A better arm angle
The arm angle \(\beta\) is the trapezoid’s one free choice. No trapezoid meets the Ackermann condition at every angle, so the search aims at the smallest largest error over the range. largest_error builds the trapezoid for an arm angle, turns the inner wheel from 0° to 40° and returns the largest difference from the Ackermann condition, and SciPy’s minimize_scalar searches the arm angle that makes it smallest:
[8]:
def largest_error(beta):
"""the largest difference from the Ackermann condition over the inner angles, in degrees"""
return max(abs(do - ackermann(di)) for di, do in zip(inner, turned(trapezoid(beta), beta, inner)))
best = minimize_scalar(largest_error, bounds=(10, 30), method="bounded", options={"xatol": 1e-4}).x
print("rule of thumb: beta", round(rule, 2), "| largest error", round(largest_error(rule), 3))
print("best: beta", round(best, 2), "| largest error", round(largest_error(best), 3))
rule of thumb: beta 15.07 | largest error 2.045
best: beta 20.39 | largest error 0.518
The best arms are angled about 5° more than the rule of thumb’s, and they bring the largest error down to about a quarter (Figure 2).
With all four wheels drawn at an inner angle of 30°, the axis of each front wheel runs through its steering axis, square to the wheel. Under the Ackermann condition, both axes meet the line of the rear axle in one point, the center of the turn (Figure 3):
[10]:
def axes(sk, beta, di):
"""the points where the front wheels' axes meet the line of the rear axle, at the inner angle di"""
do = turned(sk, beta, [di])[0]
return (-w / 2 - l / math.tan(math.radians(di)), -l), (w / 2 - l / math.tan(math.radians(do)), -l)
for name, beta in (("rule of thumb", rule), ("best", best)):
p, q = axes(trapezoid(beta), beta, 30)
print(name, "| the axes meet the rear axle's line", round(q[0] - p[0]), "mm apart")
rule of thumb | the axes meet the rear axle's line 489 mm apart
best | the axes meet the rear axle's line 112 mm apart
(a) β = 15.1°, the axes 489 mm apart
(b) β = 20.4°, the axes 112 mm apart
See Mechanisms: the sketch as a solver for linkages and Changing dimensions for driving dimensions.