Skip to content

Repository files navigation

kerbhopping

tests

A live track-driving simulator built on a minimal, defensible planar vehicle model: per circuit a minimum-time racing line (CasADi/IPOPT optimal control) that you can watch, race and re-tune in your browser, with the headline cars calibrated against real GPS+IMU lap telemetry from Ring Knutstorp. The physics is a faithful JavaScript port of an OpenModelica model. The project began as a study of ISO 3888-1 double lane changes — that study lives on further down, and its plant is where the "tricycle" got its name.

Live simulator

→ pfyhr.github.io/kerbhopping — runs in the browser, nothing to install.

Live in-browser simulator

The same 3-DOF plant, lookahead driver and quasi-steady speed profile, ported to JavaScript: a full lap solves in a few milliseconds, so there is no backend and nothing to wait for. It opens in real-time driving — the car laps the circuit under the live physics, and the sliders (mass, power, grip μ, downforce, aero balance) take effect from wherever the car is right now, no re-solve and no jump back to the line. The aero you dial in is drawn on the car: a rear wing and front splitter appear and grow with C_lA, split per the balance slider (the Clubman's stock kit is its baseline 0.5 m² at 40 % front). A translucent baseline "ghost" of the car as it was before you started tuning laps alongside for comparison: drawn on the track when it is near, always a second dot on the minimap, and an edge arrow with the gap in metres when it is off-camera. The lap time shown is the actual time the car records crossing start/finish (marked * while you are mid-adjustment — let it complete one clean lap for a representative time). A dropdown switches between the five circuits; the chase-camera follow view carries a speed / g-g / steering / sideslip HUD, and a replay toggle drops back to a scrubbable pre-solved lap. Drive hands you the wheel — arrow keys or WASD on desktop, touch-drag on a phone — racing the AI ghost under the same physics, and ⟲ restart launches you and the ghost together from the line with whatever car you have built on the sliders.

build_webgui.py bakes each circuit's minimum-time OCP racing line and geometry into a single self-contained outputs/index.html, published to GitHub Pages on every push to main.

The vehicle model (what actually runs in your browser)

Everything below lives in webgui_template.html as ~160 lines of physics — a faithful port of the Modelica Track.TrackTricycle plant (validated below; see the Modelica origin chapter for its heritage), integrated with fixed-step RK4 at dt = 6 ms. A full lap is ~12 000 steps and solves in a few milliseconds, which is what makes live re-tuning and real-time driving free.

The control structure at a glance — every block below exists identically in the Modelica plant and the JS port:

flowchart LR
  T["<b>baked per-track tables</b><br/>racing line: n_ref, psi_ref,<br/>kappa_line, delta_FF<br/>speed profile: v_ref(s)"]
  subgraph D["driver — one law in both plants"]
    FF["steer feedforward<br/>(L + K_us u^2)*kappa_line + delta_FF<br/>read u*0.6 s ahead"]
    FB["lookahead feedback: -g_hi*K_LA*e_LA<br/>e_LA = (n - n_ref) + x_LA*(dpsi - psi_ref)<br/>x_LA capped, gain knee g_hi"]
    DM["yaw damper<br/>-K_r*(r - kappa_line*u)"]
    ST["sum, tanh clamp +/-20 deg"]
    SP["speed preview<br/>(v_ref(s + u*1s)^2 - u^2)/(2 u*1s)"]
    BT["far-horizon brake trigger<br/>commit band ~0.4 mu g x g_hi, brake x1.25,<br/>friction-circle shares, hold-speed floor"]
    FF --> ST
    FB --> ST
    DM --> ST
    SP --> BT
  end
  subgraph P["plant — 9-state tricycle"]
    AC["steer actuator<br/>tau = 0.10 s"]
    AL["force allocation<br/>power cap P/u, brake split k_Bf,<br/>friction-ellipse remainder"]
    TY["brush tyres FL / FR / rear<br/>(Pacejka ch. 3)"]
    LT["load-transfer lags<br/>roll 0.15 s, pitch 0.20 s"]
    CH["chassis + Frenet kinematics<br/>states s, n, dpsi, u, v_y, r"]
    AC --> TY
    AL --> TY
    LT --> TY
    TY --> CH
    CH --> LT
  end
  T --> FF & FB & SP & BT
  ST -- "delta_cmd" --> AC
  BT -- "a_x,cmd" --> AL
  CH -- "state feedback: s, n, dpsi, u, v_y, r" --> FB & DM & SP & BT
Loading

Plant: 9 states, three wheels

The chassis is a planar two-track-front / lumped-rear "tricycle" in track (Frenet) coordinates. State vector:

# State Meaning
1 s distance along the centerline
2 n lateral offset from the centerline
3 Δψ heading error to the centerline tangent
4 u longitudinal (body) speed
5 v_y lateral (body) speed
6 r yaw rate
7 δ actual road-wheel steer (first-order actuator, τ = 0.10 s)
8 ΔF_z lateral load transfer across the front axle (roll-mode lag, τ = 0.15 s)
9 ΔF_zx longitudinal load transfer (pitch-mode lag, τ = 0.20 s)

Path kinematics couple the chassis to the road: ṡ = (u·cosΔψ − v_y·sinΔψ)/(1 − n·κ) and Δψ̇ = r − κ·ṡ, with κ the local centerline curvature. The two front wheels carry their own slip angles (they differ by the yaw-rate term ± r·t_f/2 in the velocity at each contact patch), their own normal loads, and their own aligning moments — which is why lateral load transfer genuinely costs front grip and the car pushes when loaded. The rear axle is one lumped wheel. Load transfer acts in both axes: the lateral state splits the front pair across the track width, and the longitudinal state shifts m·a_x·h/L between the axles — braking loads the front axle (that extra front grip is what makes trail-braking rotate the car, while the lightened rear is why the brake split kBf matters), and acceleration plants the driven rear (RWD traction on corner exit).

Tyres: analytic brush model

Each wheel runs the classic brush tyre model (the analytic form of Pacejka, Tyre and Vehicle Dynamics ch. 3) — smooth, event-free, cheap, and the same expressions to the last epsilon in the Modelica plant and the JS port:

  • cornering stiffness with degressive load sensitivity: C_α(F_z) = c1·sin(2·atan(F_z/c2)) — doubling the load less-than-doubles the stiffness, so transferring load across an axle always loses net grip;
  • lateral force F_y = µF_z·(1 − λ³)·sgn(α) where λ collapses from 1 to 0 as the slip approaches the friction limit (θ = C_α/(3µF_z));
  • aligning moment from a pneumatic trail a_p(F_z) = a_p0·√(F_z/F_znom) that collapses to zero at the limit — the steering "goes light" exactly when the front axle saturates.

These are the exact Chapter-3 forms — λ = 1 − θ|σ| (3.8), F_y = µF_z(1 − λ³) (3.11), M_z = −µF_z·a·λ³(1 − λ) (3.12), trail t₀ = a/3 at vanishing slip (3.14) — with Pacejka's Magic-Formula load function supplying the C_α(F_z) that Chapter 3 keeps constant. The calibrated Elise's tyres, plotted in those conventions (tyre_curves.py):

Elise brush-tyre characteristics

Left: the load sensitivity that makes lateral transfer cost front grip. Middle: the aligning-moment dip. Right: the pneumatic trail falling from a/3 toward zero as the contact patch saturates — the "steering goes light" signal a driver feels at the limit.

Longitudinal force is allocated after lateral: the drive/brake demand is clipped per axle by the friction-ellipse remainder √((µF_z)² − F_y²) — an idealized TC/ABS. Drive is rear-only and additionally capped by engine power P/u; brakes split kBf to the front axle.

Loads and aero

Static axle loads come from the weight split; on top of them sits downforce. C_lA is the lift area — the dimensionless lift coefficient times the reference area, the exact aerodynamic counterpart of the drag area C_dA — so the force is simply F_down = ½ρ·C_lA·u² in newtons, no separate area or coefficient to bookkeep. It is the honest single number for "how much wing". Calibrating intuition: the Clubman's wing + floor at C_lA = 0.5 m² make ~0.23 kN at 100 km/h and ~0.9 kN at 200 km/h (16 % of the car's weight); the slider maximum of 5 m² is formula-car territory — ~9 kN at 200 km/h, more than an Elise weighs. In the model the downforce (i) splits aeroBal front/rear onto the axle loads — the aero balance slider; Katz (2006, Table 1, after Page 2000) gives ~45 % front as the desirable road-course split, 33–35 % for ovals — (ii) inflates the cornering budget with speed — the friction ellipse literally grows as you go faster, see the speed-reference section — and (iii) is not free: the package pays the classical lift-induced drag of two span-limited wings, C_dA,i = [(bal·C_lA)² + ((1−bal)·C_lA)²] / (π·e·b²) with the span b capped at the car's width — race wings are width-limited, which is why they run aspect ratios near 2 (Katz 2006) — and e ≈ 0.9 for the endplate gain (Hoerner via Katz). Quadratic in lift: the Clubman's 0.5 m² costs a negligible 0.015 m² (package L/D ≈ 33, the efficient wing-plus-floor end that Zhang, Toet & Zerihan 2006 describe for ground-effect parts), while a slider-maxed 5 m² costs ~1.5 m² (L/D ≈ 3.4 — whole-package F1 efficiency), which is why max downforce visibly trims top speed. Splitting the lift across both wings is part of the physics: an all-rear setting concentrates lift on one span-limited surface and pays more induced drag than a balanced one. The two load-transfer states relax toward ξ_F·m·a_y·h/t_f (front share of the roll couple) and m·a_x·h/L with their respective time constants — so a snap of steering or brakes takes ~0.15–0.2 s to fully load the outside/front tyres, exactly the transient that punishes "stab-the-pedal" inputs.

The driver: two channels, calibrated against real laps

Steering is the Kapania–Gerdes lookahead law: a feedforward (L + K_us·u²)·κ_line evaluated a preview time ahead, plus the OCP line's own optimal steer passed through as a dynamic feedforward, minus lookahead-error feedback K_LA·e_LA with e_LA = (n − n_ref) + x_LA·(Δψ − ψ_ref) — the path error a distance x_LA = 5 m + 0.25·u ahead (capped at u = 38 m/s). The feedback gain rolls off above a speed knee (u ≈ 43 m/s) — quick hands don't work at 200 km/h, and without the rolloff the wheel saws on fast straights. A yaw-damping term K_r·(r − κ·u) and a tanh saturation at the 20° steering stop close the loop. The command then drives the road wheel through a first-order actuator, τ·δ̇ + δ = δ_cmd with τ = 0.10 s (state 7 of the plant) — the lumped driver-arm-plus-linkage response: a step command reaches 63 % in 0.1 s, during which the car covers 4–6 m at corner speeds. It is the dominant lag in the steering loop — the speed-growing lookahead's phase lead exists precisely to compensate it — and it low-passes the feedback before it reaches the tyres, which is why gains that are stable here start ringing the moment a second lag (tyre relaxation) is added to the loop.

Pedals track the reference speed through a ~1 s preview: a_x = (v_ref(s + u·1s)² − u²)/(2·u·1s), a constant-acceleration law that reproduces the profile's own acceleration when the car is exactly on it and self-corrects when it is not. Corners beyond that window are watched by a far horizon — every 6 m out to ~2.2 s ahead, each point's kinematic need a(d) = (v_ref(s+d)² − u²)/(2d), i.e. the constant braking that would just meet it. The crucial distinction is what to do with that number. A plan brakes at the worst need as soon as any point needs anything — a feather-light brake from 100 m out, ramping gently: that was the original implementation, it never exceeded 0.6 g, and it lapped 5 s slower than the logs. A trigger instead holds speed (right foot down, no lift) while the need grows as the corner approaches — the need rises roughly as 1/d — and only when it climbs into a smooth commit band around 0.4 µg (scaled down by the steering-gain knee where the hands are slow) does the driver commit: braking ~25 % harder than the kinematic need, keeping reserve for the turn-in transient, then trailing off as the planned path curvature claims its share of the friction circle. The band is smooth rather than a hard threshold because a threshold chatters — the need hovers at the boundary and the foot stabs (measured: bursts of 0.13 s brake applications). This late-hard shape came straight from the logged laps (real braking p90 = 0.90 g). Symmetrically, the throttle gets no more than the friction-circle remainder of the worse of planned curvature and measured |r|·u — no full power until the car is unwound, which is what keeps corner-exit power-on understeer from running the car wide. Where the steering gain has rolled off, the driver also carries a few percent of speed margin (slow hands need slack).

Speed reference and racing line

The reference v_ref(s) is the classic quasi-steady profile on the racing line: corner-speed limit from the lateral budget (growing with downforce as a_y,cap = a_yF·(1 + ρC_lA·v²/2mg)), then a power/traction-limited forward pass and a braking-limited backward pass, both shaped by the friction ellipse.

The line itself is a genuine minimum-time trajectory: per track, a 3-DOF min-time OCP (CasADi + IPOPT, direct collocation — details in the OCP section below) solved once for the calibrated Elise and baked into the page as n_ref / ψ_ref / κ_line / δ_FF tables. The corridor lets the line put the car's edge at the white line; a small speed-scaled driver margin (larger only on the Nordschleife's 200 km/h kinks) absorbs the lookahead driver's overshoot so the apexes land on the kerbs, not the grass — matching the logged laps, which run 20–25 m shorter than the centerline because real drivers cut over them.

Calibrated against real telemetry

The headline presets are fitted to the owner's RaceChrono/VBOX logs (tracks/racelogs.py parses whole sessions and splits laps; the committed overlay traces are in tracks/telemetry/). Fit inputs: speed-vs-distance of the best real laps at Ring Knutstorp, lap time, sustained lateral g, and braking rate. Mass and power were not fitted — they are the known car specs; the fit adjusted tyre µ and the used lateral budget.

real (logged) simulated
Lotus Elise best lap, Knutstorp 1:09.5 1:10.0
Mazda MX-5 best lap, Knutstorp 1:08.0 1:09.0
sustained lateral (p99) 1.62 / 1.67 g ~1.5 g
braking (p90) 0.90 g ~0.7 g
top speed on track 163 / 167 km/h 169 / 173 km/h
speed-trace error v(s), rms — 7.6 km/h

The remaining ~0.5-1 s sits in the line model, not the car: the sim's track has no kerbs to cut and its driver keeps a small tracking margin a professional would not. The four cars:

Preset m P µ provenance
Lotus Elise 862 kg 88 kW 1.80 fitted to the owner's logged laps
Mazda MX-5 "Oskar" 980 kg 104 kW 1.82 fitted to Oskar's logged lap 19
BMW M140i 1530 kg 250 kW 1.30 spec-based; µ matches its logged 1.28 g
Clubman Racer 580 kg 116 kW 1.75 Swedish Clubman class figures, slicks + wing

When you drive yourself (arrow keys / WASD / touch), the same plant runs with your steer and pedal inputs replacing the driver law — plus a soft-ground penalty (rolling drag up, grip trimmed) once you put wheels past the white line.

Is the port faithful?

Claim tested, not assumed: port_validation.py takes the baked Knutstorp tables and the Elise's reference speed straight out of the built page, laps the OpenModelica TrackTricycle on them, and laps the JavaScript plant on the identical inputs. The driver is ONE law in both plants — the telemetry-calibrated steering (lookahead cap, high-speed gain knee) and pedals (late-hard brake commit, friction-circle shares, hold-speed-to-the-braking-point) were developed in the JS against the live sim and telemetry, then backported to the Modelica TrackDriver; the track plant runs relaxation-free by default to match the port (a relaxOn switch restores it — with it on, the same driver gains ring at high speed, which is how the two loop dynamics were shown to agree in the first place).

Port validation: Modelica vs JS, same car, same line

Lap time 70.0 s vs 70.0 s (Δ0.1 s); over the full lap the speed traces agree to 0.3 km/h rms, the road-wheel steer to 0.39° rms, and the driven line to 0.05 m rms — the two curves are indistinguishable at plot scale.

The third trace in the lap figure is the real lap (69.5 s): the logged GPS is rigidly fitted onto the track frame (the driven lap is the track, so three closest-point Procrustes rounds recover the projection), giving the real speed and the real driven line on the same s-axis. The real speed matches the sims corner-for-corner — and shows exactly where the sim line is still conservative (the fast left at s ≈ 950 where the real driver carries ~15 km/h more). The real line swings the same apex pattern with slightly more amplitude (kerbs). One honest footnote: the fit also measures the OSM centerline's own lateral error — a slowly varying ~3 m mean bias (10 m worst) that the whole track frame inherits; it is removed (100 m high-pass, labeled) before the line comparison, and it means the logged laps could eventually be used to correct the track geometry itself.

And the origin story closes its loop: both plants driving the ISO 3888-1 double lane change the project began with — same Elise, same driver law, the JavaScript car drawn as a translucent ghost over the Modelica one (port_dlc.py):

Both plants, same double lane change, JS as ghost

The two cars move as one — peak CG separation over the whole maneuver is 3 mm:

CG trajectory, both plants

Track sim: minimum-time laps of planar circuits

The Tricycle.Track sub-package of the Modelica model (modelica/Tricycle.mo; the package's origin is the DLC study below) re-expresses the tricycle in track (Frenet) coordinates (s, n, Δψ) and adds a longitudinal degree of freedom: rear-wheel drive limited by engine power (P_max/u) and by the rear friction-ellipse remainder √((μF_z)² − F_y²), brakes split front/rear under the same per-axle ellipse limit ("ideal TC/ABS"), aero drag, rolling resistance, and pitch-lagged longitudinal load transfer. Centerlines come from OpenStreetMap (elevation dropped — planar by design; © OpenStreetMap contributors, ODbL), smoothed and tabulated as κ(s) in tracks/<key>.csv.

The driver follows a racing line, not the centerline. For a track corridor of half-width w (from the track width minus the car and a margin), the minimum-curvature line — the offset profile n_ref(s) that flattens the corners as much as the asphalt allows — is computed by a fast regularized solve (tracks/racing_line.py; Braghin et al. 2008, Heilmeier et al. 2020). The quasi-steady minimum-time speed profile v_ref(s) is then recomputed on that faster line (corner-speed limit → power/traction-limited forward pass → braking-limited backward pass, budgeted at the grip the plant actually sustains), and the two-channel TrackDriver tracks it — the same unified driver described in the model chapter: the Kapania–Gerdes lookahead steering (one fixed gain carried by the speed-scaled lookahead's phase lead, now with the capped lookahead and high-speed gain knee the telemetry work added) and the pedal channel with its late-hard brake trigger and friction-circle shares. Setting the corridor to zero recovers exact centerline following, so the same driver does both (track_lap.py --line=center).

This is a genuine racing line — wide entry, apex, track-out — but the minimum-curvature line for a fixed corridor, not a provably minimum-time trajectory (see "How optimal is it?" below).

Lap times for the telemetry-calibrated Elise (862 kg / 88 kW / µ = 1.80, --car=elise), racing line vs. centerline following:

Track (--track=) Length Racing line Centerline v_max
nordschleife — Nürburgring Nordschleife 20.72 km 8:48.4 9:20.7 202 km/h
anderstorp — Anderstorp Raceway 4.01 km 1:54.9 1:59.9 189 km/h
gelleras — Gelleråsen Arena (Karlskoga) 2.33 km 1:17.6 1:23.5 173 km/h
knutstorp — Ring Knutstorp 2.06 km 1:11.5 1:17.1 169 km/h
kinnekulle — Kinnekulle Ring 2.06 km 1:02.1 1:05.6 170 km/h

The racing line is 4–7 % quicker, and the car tracks it to within ~1.2 m rms. (These are the geometric min-curvature lines; the live simulator runs the OCP min-time lines below, which is why its Knutstorp lap is a second faster still.)

Nordschleife racing line colored by speed — Elise, OCP line

python3 tracks/fetch_track.py --track=all         # (re)build centerlines from OSM - needs network
python3 track_lap.py    --track=knutstorp         # racing line + speed profile + lap sim + figures
python3 track_lap.py    --track=knutstorp --line=center   # centerline following, for comparison
python3 track_render.py --track=knutstorp         # chase-camera HTML viewer (outputs/<key>_chase.html):
                                                  # GTA-style follow cam, minimap, speed/yaw/accel HUD
python3 build_webgui.py                           # build the live browser simulator (outputs/index.html):
                                                  # JS port of plant+driver, all five tracks, live tuning
python3 port_validation.py                        # faithfulness check: Modelica vs JS, same car+line
                                                  # (outputs/svg/port_validation.svg)

Adding a track is one entry in the TRACKS registry in tracks/fetch_track.py (an OSM route relation, or a bounding box whose raceway ways are auto-assembled into the closed circuit loop). Vehicle setup is sweepable: track_lap.py mirrors the Tricycle.Track.TrackTricycle defaults, and per-run overrides pass straight through to simulate(..., simflags="-override Pmax=...").

How optimal is it? (--line=ocp)

The default racing line is the minimum-curvature line — a good, standard geometric approximation, but not a minimum-time one. For a genuine minimum-time line, track_lap.py --track=<key> --line=ocp solves a minimum-time optimal-control problem for a 3-DOF planar vehicle (tracks/opt_lap.py; the min-curvature line warm-starts it):

  • states = offset n, heading error Δψ, and the full chassis (longitudinal u, lateral v, yaw rate r); controls = road-wheel steer and longitudinal acceleration; minimize ∫dt in the arc-length domain around the loop;
  • subject to the curvilinear vehicle dynamics with the same brush tyre as the plant, the friction ellipse as an inequality F_x²+F_y² ≤ (μF_z)², the engine-power limit F_x·u ≤ P, quasi-static load transfer, and the track corridor;
  • transcribed by direct collocation into one nonlinear program and solved with CasADi + IPOPT (L-BFGS Hessian, ~30 s per short track, minutes for the Nordschleife). The KKT conditions certify local optimality (nonconvex — no global guarantee).

This follows the standard minimum-lap-time formulation (Perantoni & Limebeer 2014; the TUM opt_mintime work, Christ et al. 2021): a low-DOF chassis with realistic tyres and the friction limit as a constraint. Unlike a point-mass model, it has genuine yaw inertia, so the optimum is a proper wide-entry/apex/track-out line the real car can actually hold — not a weaving trajectory that only a point mass could follow.

The full Modelica TrackTricycle then drives this line (the OCP chooses the line; OpenModelica simulates the real car tracking it), realized by the lookahead-error driver above. Two things make that tracking work: the OCP's own optimal steer is passed through as a dynamic feedforward, and the corridor and driver margins are lateral-demand scaled — pulled in only where the car is both fast and loaded (overshoot needs speed and lateral force; a flat-out straight tracks exactly), so the aggressive line stays on the asphalt while entries still use the full width.

Honest caveats:

  1. Optimal for a reduced model. The 3-DOF single-track OCP omits the left/right load transfer and the steering actuator lag the full plant has — exactly what the plant adds back when it drives the line. T_opt is the 3-DOF optimum, a close lower estimate, not a bound on the full car.
  2. Local, not global. IPOPT certifies a KKT point, not the absence of a better basin.
  3. It helps most where the corners are slow and tight. On flowing high-speed tracks the minimum-curvature line is already close to time-optimal, so the OCP roughly ties it there; the gains come on technical circuits.

Result — driven laps (full model tracking each line), T_opt = the 3-DOF optimum:

Track Min-curvature OCP-tracked Δ T_opt (3-DOF)
Knutstorp 1:11.5 1:11.0 −0.5 s 1:04.9
Gelleråsen 1:17.6 1:17.0 −0.6 s 1:10.6
Anderstorp 1:54.9 1:52.8 −2.1 s 1:46.4
Kinnekulle 1:02.1 1:00.9 −1.2 s 0:56.8
Nordschleife 8:48.4 9:00.7 +12.3 s 8:01.3

(Elise, same setup as the table above.) The OCP line wins on every circuit except the Nordschleife, where the aggressive min-time line through the 200 km/h sections outruns what the driver can track through track_lap's plain corridor — the live simulator solves exactly this with per-track, lateral-demand-scaled driver margins, which is why its Nordschleife laps use the OCP line and still stay on the kerbs. Tracked to ~1.0–1.3 m rms.

The OCP needs CasADi (pip install casadi, bundles IPOPT); --line=optimal (min-curvature) remains the dependency-free default.

Modelica origin: the double-lane-change study

modelica/Tricycle.mo (single-file Modelica package, simulated with OpenModelica):

  • PlanarTricycle — planar three-wheel ("tricycle") vehicle: individual front wheels (own slip angles, quasi-static lateral load transfer with a roll-mode lag), lumped rear wheel — the architecture used for front-axle force estimation in WO 2025/113783 (Marzbanrad & Jonasson, Volvo). Chassis: 2 DOF (lateral velocity, yaw rate) at constant speed + path kinematics. Kingpin moment per side = Fy·(mechanical trail) − Mz; tie-rod force = M_kp/L_arm. toeL/toeR inputs are hooks for an active-toe actuator (wired to 0 here).
  • TireData / brushForces — classic brush tire (analytic form after Pacejka, Tire and Vehicle Dynamics Ch. 3): analytic Fy(α, Fz) and Mz(α, Fz) with a pneumatic trail that starts at a_p/3 ≈ 20 mm and collapses to zero at the grip limit; degressive load sensitivity; first-order relaxation lag. Smooth and event-free.
  • ManualSteering — handwheel/column inertia + ideal pinion (r_p = L_arm/i_S, i_S = 20 typical for unassisted steering) + rack mass. No assist: the full kingpin reaction reflects to the handwheel, τ_HW = F_rack·r_p.
  • Iso3888Path — ISO 3888-1 reference centerline (sections 15/30/25/25/15/15 m, 3.5 m offset, 125 m total) + MacAdam-class single-point preview driver with yaw-rate damping. The driver turns the handwheel through a 2 Hz arm filter.
  • Examples — StepSteer (understeer validation), DoubleLaneChange (headline, closed-loop at the ISO-recommended 80 km/h), OpenLoopDLC (prescribed one-period sine, repeatable sweeps).

Double lane change animation

Closed-loop ISO 3888-1 double lane change at 80 km/h (real-time playback): vehicle outline at true yaw with the running time, heading and lateral-acceleration readout. The distance axis is compressed 2:1; gate compliance is checked on the true footprint.

DLC headline results (defaults: D-segment sedan, 80 km/h)

Quantity Value
ISO 3888-1 gates (full-footprint check) PASS, margins +91/+68/+151 mm
Peak lateral acceleration 0.76 g
Peak tie-rod force ≈ 1.4 kN per side (left/right split by load transfer)
Peak kingpin moment ≈ 150 N·m per wheel
Peak handwheel angle / torque ≈ 110° / 10 N·m (unassisted!)
Understeer gradient (tires-only) 0.96 deg/g — low edge of the production band, as expected with compliance/roll steer omitted

Note the driver tuning (Tp = 0.55 s, Kdrv = 0.22, Kr = 0.25) is deliberately slow and well damped: the 2 Hz arm filter adds phase lag that the preview must compensate, and tighter tunings destabilize the driver–steering loop — a genuine interaction, not a numerical artifact.

Run the Modelica pipeline

python3 dlc_maneuver.py      # simulations, figures (outputs/svg), animation (outputs/gif), summary CSV
python3 render_diagram.py    # component diagram from the OpenModelica instance
python3 tests/run_tests.py   # test suite (also runs in GitHub Actions on every push)

Requires OpenModelica (omc on PATH), Python 3 with numpy + matplotlib. Everything sweepable (speed, driver, steering ratio, tire and chassis parameters) is a top-level parameter of the examples, overridable per run via -override=....

DLC validation (the study's original checks — still asserted in CI)

These were the project's first correctness checks, and they still run on every push; the port-and-telemetry validation in the model chapter now sits above them, but they remain the anchor to closed-form theory:

  • Against analytic theory: steady-state yaw-rate gain matches the linear bicycle including aligning moments to < 1 % across 40–120 km/h — the classic understeer-gradient check, model vs closed form:

    Yaw-rate gain vs speed

  • Across implementations: a NumPy twin of the brush tire is asserted against the Modelica steady state to < 10⁻³ on every run of dlc_maneuver.py (the same equations now also live in the JS port, verified identical to ~10⁻¹³ N — the tyre-section curves describe all three).

  • Against the standard's intent: ISO 3888-1 gate compliance is checked on the full yawed vehicle footprint (all four corners) — "no cones displaced", not just the CG between the lines.

The full validation ladder, bottom to top: tyre equations identical across all three implementations → the two plants lap as one (0.05 m rms; 3 mm through this very maneuver) → the driver calibrated against logged laps (Δ0.5 s at Knutstorp) → the five-track regression suite in CI.

Sources

Parameter provenance and reference anchors in sources/SOURCES.md (Pacejka, Heydinger SAE 1999-01-1336, ISO 3888-1:1999 incl. the recommended (80 ± 3) km/h entry speed, Milliken, MacAdam, Reimpell, a measured DLC field test [Bîndac et al. 2022], WO 2025/113783). Downforce aerodynamics: Katz 2006, "Aerodynamics of Race Cars", Annu. Rev. Fluid Mech. 38:27–63 (width-limited low-AR race wings; Table 1 desirable front-downforce share) and Zhang, Toet & Zerihan 2006, "Ground Effect Aerodynamics of Race Cars", Appl. Mech. Rev. 59:33–49 (ground-effect parts as the most drag-efficient downforce).

Documented omissions

For the DLC study's PlanarTricycle: constant speed; parallel steer (no Ackermann); no KPI/caster jacking; no scrub×Fx; no steering column compliance or rack friction; its tire parameters are textbook-typical. Shared by both plants: lumped rear tire (slightly understeer-optimistic; front link loads unaffected); no roll DOF (roll-mode lag on load transfer instead); planar — no elevation, banking or kerbs. The track plant adds the longitudinal DOF and drops the constant-speed assumption, and the sim cars' tyre parameters are not textbook-typical: the Elise and MX-5 are fitted to logged laps (see the calibration section).

Appendix: car parameters

Every number the plants use, per car (generated from tracks/cars.py by car_table.py — regenerate after preset changes). The four cars differ in far more than mass and power: yaw inertia spans 4× (Clubman 780 to M140i 2600 kg·m²), the Elise is the only rear-heavy one (38 % front), and the tyre stiffness/load scales are sized to each car's actual wheel loads:

parameter unit meaning Lotus Elise Mazda MX-5 "Oskar" BMW M140i Clubman Racer
m kg mass (incl. driver) 862 980 1530 580
I_zz kg·m² yaw inertia 1070 1300 2600 780
a / b m CG to front / rear axle 1.43 / 0.87 1.15 / 1.15 1.29 / 1.40 1.20 / 1.20
L m wheelbase 2.30 2.30 2.69 2.40
front weight % static front axle share = b/L 38 50 52 50
t_f m front track width 1.46 1.41 1.57 1.55
h_cg m CG height 0.46 0.48 0.52 0.30
ξ_F – front share of the roll couple 0.50 0.50 0.55 0.48
k_Bf – front share of brake force 0.62 0.62 0.64 0.60
P_max kW peak drive power (rear wheel) 88 104 250 116
C_dA m² drag area (base body) 0.68 0.66 0.66 0.85
C_lA m² lift (downforce) area 0.0 0.0 0.0 0.5
aeroBal – front share of downforce – – – 0.40
C_dA,i m² lift-induced drag area (from C_lA) – – – 0.015
C_rr – rolling resistance 0.012 0.012 0.011 0.012
µ – tyre friction coefficient 1.80 1.82 1.30 1.75
ayFrac – driver share of the lateral budget 0.96 0.97 0.95 0.92
c1F / c1R N/rad tyre stiffness scale, front wheel / lumped rear 28000 / 92000 42000 / 84000 80000 / 150000 26000 / 52000
c2F / c2R N stiffness load scale (C_α = c1·sin(2·atan(F_z/c2))) 1600 / 5250 2400 / 4800 3700 / 7300 1420 / 2840
F_z,nom F / R N nominal wheel loads (trail + contact length scale) 1600 / 5250 2400 / 4800 3700 / 7300 1420 / 2840
a_p0 F / R m contact half-length at F_z,nom 0.055 / 0.075 0.055 / 0.075 0.060 / 0.080 0.050 / 0.070
K_us rad·s²/m understeer gradient (steer FF) 1.00e-3 1.20e-3 1.50e-3 0.80e-3
K_LA rad/m lookahead feedback gain (× KMUL 1.7) 0.30 0.30 0.26 0.32
K_r rad·s/rad yaw damping gain 0.60 0.60 0.60 0.60

Global driver constants shared by all cars (see the driver section): KMUL = 1.7, lookahead x_LA = 5 m + 0.25·u capped at 38 m/s, gain knee 43 m/s (width 15), brake trigger 0.40 µg with ×1.25 commit, speed margin 0.08·(1−g_hi), steering actuator τ = 0.10 s, GRIP_DERATE = 0.90.

License

Released under the MIT License.

Releases

Packages

Contributors

Languages