A lap time simulator for a Formula Student Electric car, written from scratch in Python (~2,900 lines + ~800 lines of tests). It combines a fast quasi-steady-state point-mass solver with a time-domain transient solver with 4 dynamic degrees of freedom, a simplified Magic Formula tyre model with vertical load sensitivity, a full electric powertrain (80 kW limit, regenerative braking, an equivalent-circuit battery whose state of charge feeds back into the available power), and a decoupled 7-DOF vertical model that runs as a virtual 7-post rig for damper work.
The code is verified against closed-form analytical solutions (48 automated
tests, e.g. skidpad against v = √(μgR) within 1 %) and includes spatial-mesh
and time-step convergence studies. It is not validated against test data —
see Limitations and next steps. The example car
(examples/car_configs/fse_example.json) is a fictional FSAE electric car
with plausible numbers, not a real team's data.
Example autocross lap (573 m, fictional car). Left: cornering-speed profile for both solvers — the transient lap is 6.0 % slower, which is the cost of dynamic load transfer plus load sensitivity plus the corner-transition phases. Right: the realised g-g scatter from the transient solver (grip usage, not a theoretical envelope).
| # | Model | File | Dynamic DOF | Coupled to lap time? |
|---|---|---|---|---|
| 1 | QSS point mass | ev_lap_sim/physics/lap_simulation.py |
0 (algebraic) | Yes — the default solver |
| 2 | Transient planar + roll | ev_lap_sim/physics/solver.py |
4 (longitudinal, lateral, yaw, roll) | Yes — --model transient |
| 3 | Vertical 7-DOF / 7-post rig | ev_lap_sim/physics/vertical.py |
7 (heave, pitch, roll + 4 unsprung) | No — separate offline tool |
Zero dynamic degrees of freedom: the car is a point mass and the problem is algebraic — at every point of the track, what is the highest speed compatible with the available grip? Classic three-pass algorithm in the distance domain:
- Corner limit — for each point, the speed at which
m·v²·|κ|equals the available lateral grip. No closed form (downforce grows withv², and tyreμdrops with load), so it is solved by fixed-point iteration with 0.5 relaxation (50 iterations, tolerance 1e-4 m/s). - Acceleration pass — integrates forward from the apexes:
v²(i+1) = v²(i) + 2·(F_net/m)·ds. This form is exact for a constant force overds(work–energy theorem, not a Taylor expansion). Tractive force is the minimum of remaining grip (friction ellipse) and the powertrain envelope. - Braking pass — the same integration backwards, limited by the friction ellipse plus aero drag plus rolling resistance, capped by brake capacity.
The final speed profile is the pointwise minimum of the three passes. On a closed track the passes run two laps so the start point has a periodic initial condition. There is no explicit apex detection — the passes start at the global minimum of the corner-limit curve (guaranteed to be an apex) and the pointwise minimum resolves the rest.
Assumptions: no lateral or longitudinal load transfer (this is the model's central approximation and its main source of optimism); no yaw inertia (the car "turns" instantly); insensitive to suspension setup by construction.
Time integration of the equations of motion, fixed-step RK4 at dt = 1 ms, in
Frenet coordinates (s, n, εψ) relative to the centreline. Nine states total:
| State | Meaning | Dynamic DOF |
|---|---|---|
s, n, epsi |
curvilinear position, lateral offset, heading error | kinematic |
vx |
longitudinal velocity (chassis frame) | 1 — longitudinal |
vy |
lateral velocity → body slip angle, axle slip angles | 2 — lateral |
r |
yaw rate, with inertia Izz: Izz·ṙ = a·Fy_f − b·Fy_r |
3 — yaw |
phi, phi_dot |
roll angle, 2nd-order oscillator: Ixx·φ̈ = m_s·a_y·h_arm − K_φ·φ − C_φ·φ̇ |
4 — roll |
dfz_long |
longitudinal load transfer, 1st-order lag (τ_pitch = 0.08 s) |
not a DOF |
- Pitch is not a DOF: it is replaced by the first-order lag on the
steady-state value
m·a_x·h_cg/L. A race car's pitch response is damping-dominated, so a first-order lag captures the time constant without introducing a spurious pitch resonance. - No wheel rotation DOF: no slip ratio, no traction control on longitudinal slip, no wheel lock in braking, no differential.
- Driver model: steering = Ackermann feedforward + an understeer/oversteer slip term + feedback on lateral deviation, heading error and yaw-rate damping, with a 4 rad/s steering-rate limit; pedals chase a load-transfer-aware QSS speed target (per-axle capacity — the first axle to saturate sets the limit) with a 0.96 margin and a throttle lift when the rear axle slides past the slip peak.
Why fixed-step RK4 and not an adaptive integrator: the driver inputs are discontinuous (throttle/brake handover). An adaptive integrator collapses its step at every discontinuity for no physical gain, because the discontinuity is real, not numerical. Fixed step gives deterministic cost and robustness.
The transient lap time is typically 3–8 % slower than the QSS (6.0 % on the example autocross, 7.4 % on the skidpad). That gap is the measurable cost of load transfer + load sensitivity + corner-transition phases.
14 states = 7 DOF × (position + velocity): sprung-mass heave z_s, pitch θ
(inertia Iyy), roll φ (inertia Ixx), and the vertical displacement of the
4 unsprung masses. Corner model: spring (wheel rate = k_spring·MR²) + 4-region
non-linear damper between chassis and wheel; tyre as a vertical spring (k_t,
default 100 kN/m) between wheel and the "post". Deviation coordinates around
static equilibrium, so gravity cancels and static loads are added afterwards.
No module in the lap-time solver imports vertical.py. It is driven only by
ev_lap_sim/tools/seven_post.py (a virtual frequency sweep). Outputs: heave
transmissibility, contact patch load variation (CPL — the relative standard
deviation of contact force; less variation = more usable grip), and a
damper-speed histogram. For the example car the heave natural frequency works out
to ≈ 2.88 Hz, consistent with the typical 2.5–3.5 Hz FSAE range.
Layer 1 — friction coefficient with (linear) load sensitivity, Fz per tyre:
μ(Fz) = μ_nom · (1 − sens · (Fz − Fz_ref)/Fz_ref), clamped at μ ≥ 0.2·μ_nom
Separate μ_lat_nom and μ_long_nom; same load_sensitivity; Fz_ref per tyre.
Layer 2 — pure lateral force, simplified Magic Formula (Pacejka):
Fy = −D · sin( C · atan( B·α − E·(B·α − atan(B·α)) ) ), D = μ_lat(Fz)·Fz
Defaults B = 16, C = 1.5, E = 0.6 → peak lateral force at α ≈ 6.2°,
cornering stiffness = B·C·D (the exact MF slope at the origin, so it inherits
the load sensitivity through D).
Layer 3 — combined slip: a geometric friction ellipse, not a combined Magic
Formula. QSS: frac = √(1 − (Fy_req/Fy_max)²) scales longitudinal capacity.
Transient: Fy = Fy_pure · √(1 − (Fx/cap_x)²) per axle.
Not modelled (be explicit about this): slip ratio / Fx(κ) curve (Fx is
commanded and only clamped at μ_long·Fz); combined Magic Formula (G-factors);
relaxation length (lateral force appears instantaneously with slip angle — the
model is optimistic in high-frequency manoeuvres such as an FSAE slalom);
aligning moment Mz; camber and toe; pressure and temperature (so no degradation
over a stint); horizontal/vertical offsets. The B, C, E values are
hand-written "typical FSAE slick" numbers — they have not been fitted to Tire
Test Consortium data. The B/C/E structure is the direct entry point for TTC
data: replace three numbers and the curve becomes the real tyre's, with no other
code change.
Lateral, per axle, decomposed into the three standard components (Milliken / Gillespie):
dFz = (K_φ·φ + C_φ·φ̇)/t ← ELASTIC (springs + ARB, follows the roll dynamics)
+ m_s,axle·a_y·z_rc/t ← GEOMETRIC (through the roll centre, instantaneous)
+ m_u,axle·a_y·r_tyre/t ← UNSPRUNG (unsprung CG at hub height)
Axle roll stiffness K_φ = wheel_rate·t²/2 + ARB. Roll damping uses the real LS
damper coefficients when a damper_* block is present, otherwise a parametric
C = 2·ζ·√(K_φ·Ixx) with ζ = 0.7. For the example car this gives a front
roll-stiffness distribution of 53.7 % against a 47 % front weight distribution —
a deliberately understeering setup — and a 0.82 °/g roll gradient.
Longitudinal: d(dfz_long)/dt = (ΣFx·h_cg/L − dfz_long)/τ_pitch. In QSS there is
no longitudinal load transfer at all.
- Envelope: total torque×rpm curve, single-ratio transmission, plus an
80 kW electrical power ceiling (Formula Student rules) applied as
P_mech = P_elec·η.drive_typeisrwd(default),fwdorawd; traction is grip-limited to the driven axle(s). - Regenerative braking: the rear share of braking is supplied by the motor up
to the minimum of the regen torque envelope and the battery recharge-power
limit (max charge current +
V_max); the rest is friction braking. Regen does not change lap time, only the energy balance. - Battery: OCV(SOC) + internal-resistance equivalent circuit. The cell
OCV-SOC curve is interpolated with PCHIP (
scipy.interpolate.PchipInterpolator, same algorithm as MATLAB'spchip— monotone, no overshoot, which matters because overshoot on an OCV curve would be a physically impossible voltage). Current limits and the cell voltage window are enforced. - Dynamic SOC: in the transient solver the SOC is integrated inside the loop and feeds back into the available power limit — a weak battery at the end of an endurance run makes the car slower. In QSS the battery is post-processed.
The example OCV-SOC curve (20 points, 2.50–4.15 V/cell, ~3.33 V plateau) is read
off the publicly available Molicel P45B cell datasheet. The pack arrangement and
every other battery number in fse_example.json are illustrative, like the rest
of the example car.
QSS — spatial mesh (example autocross):
ds [m] |
points | Lap time [s] |
|---|---|---|
| 1.00 | 574 | 30.7310 |
| 0.50 | 1147 | 30.6670 |
| 0.25 | 2293 | 30.6302 |
| 0.10 | 5732 | 30.6088 |
Total variation ds = 1.0 → 0.1 m: 0.40 %, monotone and clearly converging.
test_integration_step_convergence fails if it exceeds 1 % between ds = 1.0 and
ds = 0.25.
Transient — time step:
dt |
Lap time [s] |
|---|---|
| 2.0 ms | 32.4922 |
| 1.0 ms | 32.4920 |
| 0.5 ms | 32.4911 |
Variation on quadrupling the resolution: 1.1 ms (0.0034 %). The 1 ms step is comfortably converged.
python -m pytest validation -q → 48 passed, 1 skipped (~27 s). The skipped
test is test_external_references (no active external reference — by design; see
below).
Closed-form analytical benchmarks (validation/test_physics.py, on a
synthetic car with no aero and no load sensitivity so a closed form exists):
| Test | Analytical reference | Tolerance |
|---|---|---|
test_skidpad_analytical |
v = √(μ·g·R), R = 9.125 m |
1 % |
test_straight_top_speed |
top speed = rpm limit | 2 % |
test_powertrain_envelope |
Fx ≤ torque×rpm / power envelope |
2 % |
test_friction_ellipse |
(Fx/Fx_max)² + (Fy/Fy_max)² ≤ 1 |
5 % |
test_energy_consistency |
E_elec = tractive work / η |
1 % |
test_integration_step_convergence |
ds = 1.0 vs ds = 0.25 |
1 % |
test_awd_launches_harder_than_rwd |
a_x,max RWD = μ·0.55 |
5 % |
Physical invariants (validation/test_transient.py): 4 corner loads sum to
weight + downforce (< 2 %); lateral-transfer moment balance closes to < 0.5 %
across the whole a_y range (regression lock for the audit fix — see below);
correct transfer direction; roll < 3° and roll gradient in 0.3–2.0 °/g; transient
within 0.95×–1.30× of QSS; centreline deviation < 1 m (actual 0.71 m); net energy
= gross − regenerated.
Subsystems: battery (OCV monotonicity, current/voltage limits, regen), track
import (synthetic circle → κ ≈ 1/R, GPX/CSV round-trips), vertical model (static
equilibrium, transmissibility ≈ 1 at low frequency and < 0.5 at high frequency),
dashboard (all figures build). Order of magnitude
(test_against_known_laptimes.py): skidpad 4.0–6.5 s, autocross 30–65 s, energy
0.02–0.5 kWh.
Python 3.11+ with NumPy, SciPy, pandas, pyarrow, Plotly, Dash (and openpyxl for the Excel track/parameter tools).
pip install -r requirements.txt
Run from the project root (the folder containing ev_lap_sim/).
# QSS point-mass model (fast, ~1 s)
python -m ev_lap_sim.run examples/car_configs/fse_example.json examples/track_configs/autocross_fsae.json
# transient 4-DOF model (~8 s)
python -m ev_lap_sim.run examples/car_configs/fse_example.json examples/track_configs/autocross_fsae.json --model transient
# a GPS-imported track (.gpx / .kml / .csv) instead of the JSON
python -m ev_lap_sim.run examples/car_configs/fse_example.json examples/track_configs/autocross_example.gpx --model transient
Options: --out <folder> (telemetry destination, default output/),
--no-export (print the summary only).
# interactive F1-style dashboard (A/B lap comparison, delta-time, g-g, track map)
python -m ev_lap_sim.dashboard.app # then open http://127.0.0.1:8050
# setup sweep (1 parameter → line plot, 2 → heatmap)
python -m ev_lap_sim.tools.sweep examples/car_configs/fse_example.json \
examples/track_configs/autocross_fsae.json \
--param arb_front --values 0,8000,16000 --model transient
# virtual 7-post rig
python -m ev_lap_sim.tools.seven_post examples/car_configs/fse_example.json --n 25
Every simulation exports output/*.csv (opens in Excel/MATLAB) and *.parquet.
The full telemetry channel list is in USER_GUIDE.md, which also
covers building cars and tracks and reading the g-g diagram.
$ python -m ev_lap_sim.run examples/car_configs/fse_example.json examples/track_configs/autocross_fsae.json
Lap time : 30.667 s
Top speed : 121.6 km/h
Max lat accel : 2.17 g
Max long accel: +0.89 / -2.50 g
Energy used : 0.219 kWh (net, after regen)
$ python -m ev_lap_sim.run examples/car_configs/fse_example.json examples/track_configs/autocross_fsae.json --model transient
Lap time : 32.492 s (+6.0 % vs QSS)
Top speed : 112.1 km/h
Max lat accel : 1.87 g
Max long accel: +0.81 / -1.76 g
Energy used : 0.183 kWh (net, after regen)
(Fictional example car on the 573 m example autocross. Numbers measured from the current code; a real setup will differ.)
ev_lap_sim/
models/ frozen dataclasses loaded from JSON (vehicle, tyre, aero,
powertrain, battery, suspension, damper, track)
physics/ lap_simulation.py (QSS) · solver.py (transient + driver) ·
load_transfer.py · vertical.py (7-post) · battery_sim.py
io/ track_import.py (GPX/KML/CSV → κ(s)) · telemetry_export.py
tools/ sweep.py · seven_post.py · convert_track_xlsx.py
dashboard/ app.py (Dash / Plotly)
run.py CLI
examples/ fictional example car + example tracks
validation/ 8 pytest files (49 tests)
docs/ DOSSIE_TECNICO (deep technical analysis, PT-BR) · img/
Architecture: clean separation between parameters (models/, immutable
@dataclass(frozen=True)), physics (physics/, pure functions taking a
Vehicle + state), I/O and applications. Immutability lets
dataclasses.replace() generate car variants for setup sweeps with no side
effects — and a test checks exactly that.
This project is verified, not validated. Being explicit about what it does not do:
validation/references.json has the comparison framework (parametrised by JSON,
run as a regression test) but its single entry is "active": false and labelled
"EXAMPLE - replace with your data". No comparison against OptimumLap, another
reference lap-time tool, or real track data has been run. The highest-value
next step is to run one real comparison: model the same car and track in
OptimumLap, fill references.json, set "active": true, and run
pytest validation/test_references.py.
fse_example.json declares it: "FICTIONAL EXAMPLE … NOT real data from any
team." Every lap time here is for a made-up car. Creating a config with the real
team parameters is about half a day of work.
A line-by-line audit of the physics (written up in
docs/DOSSIE_TECNICO_LapTimeSimu.md)
surfaced three defects, all since fixed:
- Lateral load-transfer moment balance biased by −2.57 %. The moment
dFz_f·t_f + dFz_r·t_rwas closing 2.57 % belowm·a_y·h_cg, and the error was constant across the wholea_yrange — the signature of a wrong lever arm, not a dynamics error. Traced to the roll moment using the combined CG height as the sprung-mass moment arm instead of the sprung-mass CG height. Fixed by derivingVehicle.sprung_cg_heightfrom the mass split (mass·h_cg = m_sprung·h_sprung + m_unsprung·h_unsprung, unsprung CG at wheel centre) — 0.2884 m vs 0.28 m for the example car. The decomposition now closes to floating-point (test_lateral_transfer_moment_closure, tolerance 0.5 %, checked across foura_yvalues as a regression lock). Effect on the example autocross: transient lap 32.466 → 32.492 s; QSS unchanged. - Traction telemetry channel wrong for FWD/AWD. The transient solver logged
the rear-axle force as
f_tractionregardless ofdrive_type, so a front- or all-wheel-drive car showed zero traction (and zero motor torque) while pulling full power. Lap time was never affected — telemetry only — but the dashboard powertrain plots were meaningless for those layouts. Now logs the total driven-tyre force. - QSS energy reported gross, transient reported net.
LapResult.energy_kwhfrom the QSS solver did not subtract regenerated energy, so it was not comparable with the transient figure or with an endurance energy budget. Both solvers now report net energy (gross traction − regen); the QSS example drops from 0.229 to 0.219 kWh.
One item was reviewed and kept as a deliberate choice, with a comment in the
code: the geometric/unsprung load-transfer terms use a_y ≈ v_x·r while the roll
moment uses the full a_y = v̇y + v_x·r. Using the full a_y everywhere would
create an algebraic loop (tyre forces → v̇y → loads → tyre forces) needing an
implicit solve at each RK4 stage; v_x·r breaks it, the two agree in steady
state, and the dominant elastic transfer still tracks the accurate a_y through
the roll state.
- No theoretical g-g envelope generator. The friction ellipse is applied point-by-point; the dashboard "g-g diagram" is a scatter of realised acceleration (grip usage), not a pre-computed g-g-v surface like OptimumLap / ChassisSim use for optimisation.
- No tyre relaxation length — lateral force builds instantly, so the model is
optimistic in slaloms and chicanes. A one-state-per-axle first-order lag on
Fyis a cheap, high-value addition. - Tyre B/C/E not fitted to TTC data — absolute grip is a plausible guess.
- No racing line — the virtual driver follows the centreline (max deviation 0.71 m); a real driver cuts the apex, worth seconds. This is the largest source of absolute error.
- The vertical 7-DOF model is decoupled from the lap solver — no heave/pitch DOF in the lap, no track elevation/banking/roughness input. The real blocker is data (a track roughness profile), not code. Coupling it is the path to a ride-height aeromap, which does not make sense before then.
- No standing start in the transient solver (
vxis floored at 3 m/s) — use QSS for the acceleration and skidpad events, transient for autocross/endurance. - No slip ratio, no differential, no per-wheel torque vectoring; no thermal model of tyre, motor, inverter or battery; no minimum-lap-time optimal control (the transient time is the virtual driver's time, not the theoretical minimum).
- Performance: ~8 s per transient lap in pure Python — fine for sweeps of dozens of setups, would need vectorisation / Numba for thousands.
A fuller analysis of every model and assumption is in
docs/DOSSIE_TECNICO_LapTimeSimu.md
(Portuguese). Note that document predates the fixes listed under Audit history
above — it is the analysis that found them, so it still describes the pre-fix
numbers.
MIT — see LICENSE.


