How the platform generates, costs and ranks tyre strategies — and why the optimum it reports is exact rather than the best of a sample.
Implementation: backend/app/strategy/engine.py.
In the deterministic lap-time model, total race time decomposes as:
T = Σ_laps [ base_pace + fuel_effect·fuel(lap) − evolution(lap) ] (A)
+ Σ_stints Σ_age tyre_delta(compound, age) (B)
+ n_stops · pit_loss (C)
Two observations make the whole problem tractable:
-
Term (A) depends only on lap number. Every strategy runs the same number of laps, so (A) is identical for every strategy and cancels out of any comparison.
-
Term (B) depends only on compound and age within the stint — never on which lap of the race it happens to be.
Therefore the strategy problem separates exactly:
minimise J = Σ_i stint_cost(compound_i, length_i) + n_stops · pit_loss
subject to Σ_i length_i = race_laps
min_stint ≤ length_i ≤ max_stint
stint_cost is precomputable as a cumulative lookup table, and the constrained
partition has an exact solution.
This is why the engine reports relative_cost_s (terms B + C) as the comparison
figure and expected_race_time_s (A + B + C) as the headline: the first is what
actually distinguishes strategies, the second is what a user wants to read.
For a fixed compound sequence, the optimal split is solved by dynamic programming — a min-plus convolution over stint lengths.
f[i][n] = minimum tyre cost of covering the first n laps with the first i stints
f[0][0] = 0
f[i][n] = min over L ∈ [min_stint, max_stint] of
f[i−1][n−L] + stint_cost(compound_i, L)
Complexity O(k · N · L). With k ≤ 3 stints, N ≤ 78 laps and L ≤ 45 that is a few thousand operations — exact and instant. The split is recovered by backtracking through the choice table.
The alternative — sampling candidate stop laps and picking the best — gives an answer that looks the same but carries no guarantee. The DP returns the true optimum of the deterministic model.
test_dynamic_program_finds_the_true_optimum verifies this by brute-forcing
every legal two-stint split and asserting the DP matches to 1e-9.
Compound sequences are enumerated over the allowed set for each permitted stop count. Three filters:
- Two-compound rule — mirrors the regulation that a dry race must use two specifications.
- No consecutive identical compounds — legal but never optimal: the same split on one set is always cheaper by a pit stop. Skipped to keep the ranked list readable.
- Starting compound — optionally pinned.
Each surviving sequence gets its exact split solved, then the list is sorted by
relative_cost_s.
A typical Silverstone request with 1–3 stops produces 42 feasible strategies in about 50 ms.
pit_loss = circuit_pit_lane_transit + team_pit_crew_mean
The circuit contributes the time lost travelling the pit lane at the limit versus staying on track; the team contributes its mean stationary time. Behind the safety car the transit component falls to 52%, because the field is running slowly — which is the whole reason a safety car is a strategic event.
A 0–1 index with three contributions, each with a stated reason so the UI can explain the number rather than just display it.
max over stints of (length − cliff_onset) / cliff_onset
How far each stint runs past the compound's cliff onset — where the model is least trustworthy and a real tyre is most likely to fall away. Weighted 0.55.
min(1, n_stops / 3) · 0.35
Every stop is an opportunity for a slow stop and an exposure to losing track position.
sc_probability · (0.45 if n_stops ≤ 1 else 0.20)
A strategy with few stops loses more when a safety car hands cheap stops to everyone else.
Each contribution that fires also appends a human-readable note, e.g.:
One-stop with a 62% safety car rate: a mid-race deployment would give rivals a cheap stop this plan cannot answer.
Pit stop variance and lap-time consistency are independent, so they combine in quadrature:
σ_total = √( n_stops·σ_pit² + race_laps·σ_lap² )
best_case = expected − 1.96·σ_total
worst_case = expected + 1.96·σ_total + risk·12
This is the important caveat, and the reason the Monte Carlo engine exists.
The decomposition in §1 holds only for the deterministic model. Traffic, safety cars, pit stop variance and reliability all break it, because they make lap time depend on race state rather than only on lap number and tyre age.
Specifically, the deterministic optimum ignores:
- Track position. A strategy that is theoretically faster but emerges into traffic may lose in practice. The optimiser cannot see this at all.
- The undercut and overcut. These are interactions between two cars' strategies; they emerge only in the stochastic simulation.
- Safety car timing. The risk index accounts for exposure, but not for what actually happens.
At Monaco, the deterministic optimiser prefers a two- or three-stop plan, because degradation is low and pit loss is only 17.2 s. In reality Monaco is a one-stop race, because overtaking difficulty is ~96% and track position dominates everything.
That divergence is not a bug — it is the deterministic model correctly answering the question it was asked, and it is exactly why the workflow is:
rank exactly, then re-rank under uncertainty.
The Monte Carlo engine sees traffic, dirty air and overtaking difficulty, and its ranking reflects them.
Each scenario rebuilds the model with its perturbations applied, re-optimises from scratch, and re-simulates an ensemble. Comparing against the baseline shows why the optimal plan moves, not just that it did.
Available perturbations:
| Parameter | Effect |
|---|---|
tyre_degradation_scale |
Multiplies wear and cliff rates |
pit_loss_delta_s |
Shifts pit loss |
safety_car_probability_delta |
Shifts the deployment rate |
track_temp_delta_c |
Moves the tyre away from its optimal window |
driver_pace_delta_s |
Shifts base lap time |
Degradation scaling mutates the model's tyre cache, never the global compound
table. The compound parameters are module-level singletons, so a what-if
scenario that modified them would leak into every subsequent request. The
scaling wraps RaceModel.tyre_model for that model instance only.
Every driver runs their own optimal plan under each scenario, not a frozen default. Comparing the focus driver against opponents who are also racing sensibly is the only comparison that means anything.
The UI flags when a scenario changes the optimal plan:
Safety car +15% → plan changes from
S → MtoS → M → S, podium probability +7.2%
A separate endpoint (POST /api/strategies/tyre-projection) that projects a
tyre forward and returns every term of the degradation law separately so the
projection can be audited:
{
"current_grip_fraction": 0.9641,
"projected_grip_fraction": 0.9518,
"degradation_rate_s_per_lap": 0.0662,
"additional_loss_s": 0.42,
"usable_life_laps": 53.2,
"condition": "WORKING",
"confidence": 0.92,
"breakdown": {
"warmup_deficit": 0.00000,
"linear_wear": 0.02718,
"cliff": 0.00000,
"temperature": 0.00089,
"compound_offset": 0.01350,
"severity_multiplier": 1.1876,
"effective_wear_rate_per_lap": 0.001510,
"grip_sensitivity_s_per_grip": 33.798
}
}The race view displays this breakdown inline. Nothing about the projection is hidden.
backend/tests/test_simulation.py covers:
- Stint cost tables are cumulative and strictly increasing
- Generated strategies cover the full race distance exactly
- Ranking is by cost
- The two-compound rule and stint bounds are enforced
- The DP matches an exhaustive search (the exactness claim)
- More stops reduce tyre cost but add pit cost — the core trade
- Higher degradation does not reduce the optimal stop count
- Infeasible constraints return an empty list rather than crashing
- Pinned stop laps are honoured
- Risk index is bounded, and best/worst case bracket the expected time