Skip to content

Repository files navigation

EV Lap Time Simulator — Formula Student Electric

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.

Speed trace — QSS vs transient

g-g diagram

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).


The three models

# 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

1. QSS point-mass solver

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:

  1. Corner limit — for each point, the speed at which m·v²·|κ| equals the available lateral grip. No closed form (downforce grows with , and tyre μ drops with load), so it is solved by fixed-point iteration with 0.5 relaxation (50 iterations, tolerance 1e-4 m/s).
  2. 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 over ds (work–energy theorem, not a Taylor expansion). Tractive force is the minimum of remaining grip (friction ellipse) and the powertrain envelope.
  3. 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.

2. Transient solver — 4 dynamic DOF

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.

3. Vertical 7-DOF model — decoupled, 7-post rig

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.


Tyre model

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.


Load transfer

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.


Electric powertrain

  • 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_type is rwd (default), fwd or awd; 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's pchip — 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.

Molicel P45B OCV-SOC curve, PCHIP interpolation


Numerical method and convergence

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.


Verification — what the test suite covers

python -m pytest validation -q48 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.


Installation

Python 3.11+ with NumPy, SciPy, pandas, pyarrow, Plotly, Dash (and openpyxl for the Excel track/parameter tools).

pip install -r requirements.txt

Usage

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.

Example output

$ 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.)

Repository layout

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.


Limitations and next steps

This project is verified, not validated. Being explicit about what it does not do:

Not validated against test data

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.

The example car is fictional

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.

Audit history — issues found by self-review and fixed

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_r was closing 2.57 % below m·a_y·h_cg, and the error was constant across the whole a_y range — 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 deriving Vehicle.sprung_cg_height from 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 four a_y values 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_traction regardless of drive_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_kwh from 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.

Known model gaps

  • 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 Fy is 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 (vx is 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.


License

MIT — see LICENSE.

About

Lap time simulator for a Formula Student Electric car — QSS + transient 4-DOF solvers, Magic Formula tyre model, electric powertrain with regen and battery SOC feedback, virtual 7-post rig. Verified against closed-form analytical solutions. Python / NumPy / SciPy.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages