Equations, assumptions and limitations for the physical and stochastic models.
Every constant referenced here lives in backend/app/models/parameters.py.
None of it derives from a Formula 1 team's data.
A circuit is authored as a spine: an ordered sequence of straights and constant-radius arcs.
Straight: kappa = 0, length = L
Arc: kappa = 1/R, length = R·|theta|
The alternative — storing an outline of x/y coordinates — fails for a specific reason: the lap-time model needs curvature, and differentiating a hand-drawn polyline twice produces noise that swamps the signal. Authoring in curvature space and integrating up to position is numerically stable and gives an exact, analytic κ(s).
Planar, arc-length parameterised:
dψ/ds = κ(s)
dx/ds = cos ψ
dy/ds = sin ψ
Heading is integrated with the midpoint rule, which keeps closure error an order of magnitude below forward Euler — this matters for long, gentle radii.
Real circuits do not close perfectly under this idealisation. The residual gap is distributed along the lap as a translation field:
delta(s) = -gap · (s / L)
This shifts positions only. Curvature is untouched, so no physical quantity changes. It is a cosmetic fix to a cosmetic problem.
A closed planar curve turns through a whole number of revolutions. Every
circuit in the library is a single clockwise loop (−360°) except Suzuka, whose
figure-of-eight crossover gives it a turning number of zero. This is
asserted in test_turning_number_is_topologically_valid.
Straight lengths are scaled by a single factor so the integrated lap matches the published distance:
k = (target − Σ arc lengths) / Σ straight lengths
Corner radii and angles are held fixed — they are the physically meaningful part. If the arcs alone already exceed the target, the spine is returned untouched; silently shrinking corners would corrupt the physics.
A point mass with aerodynamic downforce and drag, a power-limited drivetrain and a friction-limited tyre.
F_drag = ½ ρ (C_d A) v²
F_down = ½ ρ (C_l A) v²
Air density from the barometric formula with an ideal-gas temperature correction:
ρ = ρ₀ · (1 − 2.25577×10⁻⁵ h)^4.25588 · (288.15 / T)
This matters because every aerodynamic force scales linearly with ρ — Mexico City at 2200 m is a genuinely different aerodynamic problem to Monza at 160 m.
Downforce level runs 0 (minimum drag) to 1 (maximum downforce) along a drag polar where induced drag grows faster than linearly:
C_l A = df_min + (df_max − df_min)·level
C_d A = drag_min + drag_span·level^1.55
Fitted so level 0 gives ~355 km/h drag-limited top speed and level 1 ~290 km/h.
Balance lateral demand against grip, noting that grip itself depends on speed through downforce:
m v² κ = μ_y (m g + ½ ρ C_l A v²)
Solving for v²:
μ_y · m · g
v² = ─────────────────────────
m·κ − ½ ρ C_l A μ_y
When the denominator is zero or negative, the car generates downforce faster than the corner demands load. The corner is aerodynamically unlimited and the ceiling comes from drag instead. That is exactly the physics of a modern high-downforce car through a fast kink, and it falls out of the algebra rather than being special-cased. For this car it happens above roughly 130 m radius.
A superellipse, not a friction circle:
(a_long / a_long_max)^n + (a_lat / a_lat_max)^n = 1
a_long = a_long_max · (1 − (a_lat/a_lat_max)^n)^(1/n)
n = 2 is the textbook friction circle. Measured tyre data puts the exponent
nearer 1.6–1.8, describing an envelope smaller than the circle: a tyre asked
to do two things at once gives up more than the circular idealisation suggests.
The model uses n = 1.70. This is the main reason the solver does not produce
the implausibly deep trail-braking a pure friction-circle model does.
A steady-state solver assumes the car can go from zero lateral acceleration to its full limit instantaneously. It cannot: the platform has to roll onto its outside tyres, the aerodynamic load has to settle, and the driver has to commit.
Rather than integrating a transient model, the standard engineering approximation is applied: over a window corresponding to the load-build time constant, the car is limited by the tightest curvature within that window.
window = transient_time_constant · reference_speed
= 0.34 s × 62 m/s ≈ 21 m
The differential effect is the point. An isolated corner at the end of a long straight barely changes — there is nothing tight nearby. A rapid left-right-left sequence is penalised heavily, because every point in it sits within a window of something tighter. That is the real difference between a power circuit and a direction-change circuit, and here it emerges from the model rather than being asserted.
The classic three-pass quasi-steady-state algorithm.
- Limit pass — steady-state cornering speed at every sample, from transient-corrected curvature.
- Backward pass — walk backwards; the car can be no faster at i than it
can be while still slowing to the limit at i+1:
v_i² = v_{i+1}² + 2·a_brake(v, κ)·ds - Forward pass — walk forwards under the same integration with the acceleration available at full throttle after drag and lateral grip usage.
- Combine —
v(s) = min(limit, forward, backward), thent = ∫ ds/v.
Because acceleration depends on the speed being solved for, each step uses a two-iteration fixed point. At ds = 4 m the speed change over one sample is small enough that it converges immediately.
A flying lap is periodic, so the passes are repeated with the previous solution's boundary speed. This converges in one or two iterations because the start/finish line sits on a straight for every circuit in the library.
The integration loops evaluate the force model roughly twelve thousand times
per lap. Routing each call through the vectorised VehicleModel methods means
building two one-element NumPy arrays and dispatching a full ufunc to multiply
a handful of floats — about 50 µs per call, which dominated the entire solve
(600 ms per lap).
_ScalarPhysics hoists every constant out of the loop and evaluates the same
equations in plain Python floats: 18 ms per lap, a 33× speedup, with
bit-identical results. Because it is a hand transcription of
VehicleModel.available_longitudinal, it is exactly the kind of code that
silently drifts from the model it duplicates, so
test_scalar_physics_matches_vector_model pins the two together across the
full operating range. Writing that test immediately found a real inconsistency:
tractive_force was ignoring DRS when computing its grip limit while the
lateral term accounted for it.
Two coefficients that other models need are derived by central difference on the lap-time model, not quoted as rules of thumb:
fuel_effect_s_per_kg— solve at fuel ± δ/2, divide.grip_sensitivity_s_per_grip— solve at grip ± δ, divide.
The second is the bridge between the tyre model (which produces grip loss) and the strategy engine (which needs seconds per lap). It means a degradation figure in the UI is traceable:
0.031 grip lost × 33.8 s per unit grip at Silverstone = 1.05 s/lap
rather than an invented constant.
Throttle and brake are inferred from demanded versus available acceleration, not authored independently — which is why they line up with the speed trace instead of being decorative. Gear comes from the speed trace through the ratio set (lowest gear that stays under the limiter, with a short median filter for shift hysteresis); RPM follows from gear and speed; steering is the Ackermann angle for the track's curvature.
Each index is a documented functional of the solved speed profile, normalised against reference bounds. They are model output with stated definitions, not editorial ratings — two engineers reading the definitions would compute the same numbers.
| Index | Definition |
|---|---|
| Full throttle | Share of lap distance above 95% throttle |
| High speed | Share of lap distance above 250 km/h |
| Braking energy | Σ |a_decel|·v² · ds / L, normalised |
| Tyre energy | Σ |a_lat|·v · ds / L, normalised |
| Downforce demand | Share of lap above 2.5 g lateral |
| Overtaking | DRS length + heavy braking zones, discounted by circuit difficulty |
Corners are classified by apex speed, not radius. Radius alone is a poor classifier for a car generating multiples of its own weight in downforce: a 210 m radius is a slow corner for a road car and flat out for this one.
lap_time = base_pace(driver, car, circuit) [QSS solver]
+ fuel_effect · fuel_mass [QSS-derived coefficient]
+ tyre_delta(compound, age) [tyre model × grip sensitivity]
− track_evolution(lap) [saturating grip gain]
+ dirty_air(gap_ahead) [operational]
− drs_benefit(gap_ahead) [operational]
+ ε [driver consistency]
Only ε is random in a single lap. Everything else is deterministic given the race state, which is why a fixed seed reproduces a race exactly.
A driver who is not extracting the last of the tyre carries less speed through the corners and brakes earlier. Modelling that as a grip scale rather than a flat time offset means the penalty is larger at a corner-dominated circuit like Monaco than at Monza. A flat penalty would get that backwards.
Two competing effects inside the same gap window:
dirty_air = max_loss · (1 − gap/threshold)^1.6
drs_gain = benefit · √(1 − gap/drs_threshold) for gap ≤ 1.0 s
Below about 0.6 s the dirty-air penalty dominates, so a car can be quicker sitting 0.8 s back than 0.2 s back. That non-monotonicity is deliberate — it is the mechanism behind a real DRS train.
P = σ(k · racecraft · (Δpace − threshold)) · (1 − difficulty)
At Monaco the (1 − difficulty) factor drives this to near zero regardless of
pace, which is the intended behaviour.
Deployments are a Bernoulli trial on each eligible lap — neither the opening nor the final lap qualifies:
h = 1 − (1 − P_race)^(1 / N_eligible)
Using the full race distance instead would systematically undershoot the
circuit's published rate by roughly 2/N. The conversion is verified against a
4,000-race ensemble in test_safety_car_rate_matches_the_configured_probability.
A six-lap cooldown prevents periods stacking. Without it the model could deploy again on the lap the previous safety car came in: a 25-lap race could end up with three periods covering over half the distance. The cooldown bounds that without changing P(at least one), since it only applies after a deployment has already happened.
Behind the safety car, lap times inflate by ×1.42, gaps converge toward 1.1 s spacing at 55% per lap, racing is neutralised, and pit loss falls to 52%.
Fuel required for the distance is capped at tank capacity. If the requirement exceeds the tank the field must lift-and-coast, which the model represents by carrying the shortfall as a per-lap time penalty rather than silently violating the regulations.
Ten thousand races lap by lap in a Python loop is roughly 10⁷ inner iterations
and takes minutes. As array operations over a (n_simulations, n_drivers) state
matrix — looping only over laps — it is about 50 iterations of 200k-element
NumPy work.
state[s, d] = the state of driver d in simulation s
~5,800 races/second. The simulation index becomes an array axis instead of an outer loop; the physics is identical.
Tyre lap-time deltas are precomputed for every (driver, compound, age) into a
(D, C, 91) table, turning the most expensive per-lap operation into a memory
lookup. Fuel mass is held at the race mid-point for this table; the fuel–tyre
coupling is second-order compared with the direct fuel mass effect, which is
modelled exactly per lap.
Pairs (0,1), (2,3), … are evaluated together, then (1,2), (3,4), …. Because no car appears in two pairs of the same phase, every swap in a phase is independent and applies as one vectorised update. Two phases per lap allow a car to gain at most one position — matching the single-race engine.
The two engines share RaceModel, so a single simulation is one sample from the
same distribution the ensemble explores.
test_monte_carlo_agrees_with_the_single_race_engine checks the ensemble mean
against a single race within 2% over 25 laps, with safety cars disabled on both
sides — a single race either gets one or does not, and one deployment moves a
25-lap race time by minutes, which would swamp the physics agreement the test
exists to check.
Pit stops are drawn per simulation up front rather than decided reactively, so the ensemble cannot model a driver reacting to a safety car by pitting. Safety car timing is modelled and its effect on gaps applied; only the opportunistic stop is missing. This is the largest known bias in the ensemble.
Given a fixed seed, output is bit-for-bit reproducible. All randomness flows
through one numpy.random.Generator, and in the single-race engine it is drawn
in a fixed order every lap regardless of race state — including for drivers
who have already retired. Drawing unconditionally costs a negligible amount of
work and buys the property that adding or removing a conditional branch cannot
silently change historical results.
Stochastic inputs: per-lap driver noise, race-start performance, pit stop stationary time, botched stops, overtake resolution, safety car deployment, mechanical retirement.
See README for the residual table.
Method — modelled qualifying lap (low fuel, DRS, fresh soft) against the
published lap record, with one global grip calibration fitted across the whole
library by golden-section search (scripts/calibrate.py).
Why one scalar, not per-circuit — a per-circuit factor would guarantee a good-looking table while hiding the fact that one circuit's geometry is worse than another's. One scalar for five circuits means the residuals that remain are informative: they tell you which authored geometry is least faithful.
Result — RMS 2.60%, mean bias −0.57%, calibration 1.000. The model needed no fudge factor once the friction exponent and transient window were correct.
Weight transfer, suspension kinematics, tyre slip curves and relaxation, differential behaviour, transient aerodynamics, yaw dynamics, brake temperature, engine maps, ERS state of charge, damage, penalties, driver error, weather transitions, blue flags, track limits.
No racing line. The solver drives the geometric centreline, which is longer through corners than a real line. This is the largest single source of error in the lap-time model.
Elevation is decorative. Carried in the geometry and displayed, but only weakly coupled to the vehicle model, because the authored profiles are approximations.
Monza's full-throttle share. Should be the highest in the library (~78% in
reality); the model puts it at ~68%, behind Spa at ~72%. The authored chicane
radii are tighter than the real ones relative to their entry speeds, so the
model spends longer accelerating out of them. Recorded rather than tuned away —
test_monza_spends_the_least_distance_cornering asserts the claim the geometry
genuinely supports (lowest arc share) instead.
One position per lap. Both engines cap position change at one place per lap, so a genuinely dominant car climbs the field more slowly than it should.
Symmetric tyre temperature penalty. See tyre-model.md.