--- jupyter: description: 'Ackermann steering with planex2d: a steering trapezoid of two arms and a tie rod as one constraint sketch, the outer wheel''s angle solved for every inner wheel angle and checked against a construction from two circles, its error against the Ackermann condition cot δo − cot δi = w/l, and the arm angle that makes the error smallest.' --- # 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 $$\cot\delta_o - \cot\delta_i = \frac{w}{l}$$ 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. ![The steering trapezoid at an inner wheel angle of 30°, with the axes of the front wheels meeting the line of the rear axle](../_static/gallery/ackermann-steering.png){.result} ```python import math from scipy.optimize import minimize_scalar import planex as px ``` ```python nbsphinx="hidden" from docs_figures import Chart, GUIDE, INK, Row ``` ## 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): ```python 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) ``` ```python nbsphinx="hide-input" Row((steering, "The trapezoid straight ahead, with the arms at the rule-of-thumb angle", {"constraints": True}), caption="The steering trapezoid") ``` ## 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: ```python 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: ```python 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") ``` ## 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: ```python 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)) ``` 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: ```python 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)) ``` 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). ```python nbsphinx="hide-input" fine = range(0, 41) Chart("inner wheel angle δi (°)", "δo − Ackermann (°)", curves={f"β = {best:.1f}° (best)": (list(fine), [do - ackermann(di) for di, do in zip(fine, turned(trapezoid(best), best, fine))])}, lines={f"β = {rule:.1f}° (rule of thumb)": (list(fine), [do - ackermann(di) for di, do in zip(fine, turned(trapezoid(rule), rule, fine))]), "Ackermann": ([0, 40], [0, 0])}, title="The outer wheel's error against the Ackermann condition") ``` 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): ```python 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") ``` ```python nbsphinx="hide-input" def wheel(center, angle): """a wheel of 600 by 200 seen from above, turned by angle degrees from straight ahead""" t = math.radians(angle) ahead, side = (-math.sin(t), math.cos(t)), (math.cos(t), math.sin(t)) corners = [(center[0] + 300 * f * ahead[0] + 100 * s * side[0], center[1] + 300 * f * ahead[1] + 100 * s * side[1]) for f, s in ((1, 1), (-1, 1), (-1, -1), (1, -1))] return px.FromPolyline(corners, closed=True).Face() def top_view(beta, di=30): sk = trapezoid(beta) do = turned(sk, beta, [di])[0] p, q = axes(trapezoid(beta), beta, di) wheels = px.Compound([wheel((-w / 2, 0), di), wheel((w / 2, 0), do), wheel((-w / 2, -l), 0), wheel((w / 2, -l), 0)]) lines = [px.Line((-w / 2, 0), p), px.Line((w / 2, 0), q), px.Line((min(p[0], q[0]), -l), (w / 2, -l))] return sk, f"β = {beta:.1f}°, the axes {abs(q[0] - p[0]):.0f} mm apart", { "names": False, "fill": False, "fills": [(wheels, INK)], "dashed": lines, "marks": [p, q]} Row(top_view(rule), top_view(best), caption="The front wheels' axes at an inner wheel angle of 30°") ``` See [Mechanisms: the sketch as a solver](../core/constraints/mechanisms.md) for linkages and [Changing dimensions](../core/constraints/update.md) for driving dimensions. ```python nbsphinx="hidden" import sys import matplotlib.pyplot as plt sys.path.insert(0, "..") from gallery import save_figure sk, text, options = top_view(rule) fig, ax = plt.subplots(figsize=(9, 4.5)) for line in options["dashed"]: line.Plot(ax=ax, line_col=GUIDE, legend=False) options["fills"][0][0].Plot(ax=ax, face_col=INK, line_col=INK, legend=False) for part in ("axle", "left_arm", "right_arm", "tie_rod"): ax.plot(*zip(*[sk.Location(f"{part}.{end}")[:2] for end in ("start", "end")]), color=INK, lw=2) ax.plot(*zip(*options["marks"]), "x", color=GUIDE, ms=9, mew=2) ax.set_aspect("equal") ax.axis("off") save_figure(fig, "ackermann-steering") plt.close(fig) ```