Back to notes
$ cat ~/notes/f1-strategy-prototype-race-simulator-part-2.mdx

Building an F1 Strategy Prototype: The Race Simulator, Part 2

A first Python race simulator, why the lap loop is useful but limited, and how SimPy gives the model better event structure.

June 27, 20268 min read

If you have not read it yet, Part 1 frames F1 race strategy as a combinatorial decision space. This post starts turning that framing into a small prototype.

The first useful piece of an F1 strategy prototype is not the optimiser. It is a small simulator that can answer a more basic question:

If we follow this strategy, what race state do we get?

That sounds modest, which is the point. Before searching a combinatorial decision space, the model needs a cheap way to turn decisions into consequences.

I would still start with a plain Python lap-based loop. It is easy to inspect, easy to change, and good enough to expose the first modelling mistakes. Then I would outgrow it deliberately.

Start with a lap loop

F1 strategy has event-like behaviour, but many first-order decisions can be represented per lap: tyre age increases, lap time changes, gaps update, cars pit, and interruptions may occur. A first simulator can be boring:

codepython
def simulate_race(initial_state, strategy, params):
    state = initial_state.copy()

    for lap in range(state.lap + 1, params.total_laps + 1):
        state.lap = lap
        apply_strategy_decisions(state, strategy, params)
        update_lap_times(state, params)
        update_positions_and_gaps(state, params)
        age_tyres(state)
        sample_optional_events(state, params)

    return state

This structure is useful because it gives the prototype a clear clock. Every lap has a predictable order of operations: apply the strategy, calculate lap time, update the race order, age the tyres, and sample whatever uncertainty the current version includes.

For an early model, that is more useful than elegance. If a result looks wrong, I want to know which lap broke the assumption.

The minimum state

A workable first state can stay small:

codepython
from dataclasses import dataclass

@dataclass
class CarState:
    name: str
    position: int
    gap_to_leader: float
    compound: str
    tyre_age: int
    base_pace: float
    pit_stops: int = 0

@dataclass
class RaceState:
    lap: int
    cars: list[CarState]
    safety_car: bool = False
    virtual_safety_car: bool = False

This is not enough for a serious model. It does not know about pit-lane queueing, double-stacking, tyre warmup, DRS, dirty air, track evolution, weather, or the exact geometry of a rejoin.

That is acceptable at this stage. The first simulator should make missing pieces visible. If traffic dominates the result, add traffic. If pit-loss variation changes the ranking, add pit-loss variation. If tyre degradation is too linear to create interesting tradeoffs, improve the tyre model.

The simulator should earn complexity by failing in useful ways.

Pace is a model, not a fact

The simplest lap-time model might be:

codetext
lap_time =
  base_pace
  + compound_offset
  + tyre_degradation(compound, tyre_age)
  + traffic_loss
  + random_noise

That is crude, but it creates the right shape. A soft tyre can be fast early and poor later. A hard tyre can be slower but durable. Traffic can make a theoretically faster strategy useless. Random noise prevents the prototype from pretending the race is a spreadsheet with wheels. In a serious model, that noise should be calibrated rather than invented.

The important part is separating model pieces:

  • baseline car and driver pace
  • tyre compound offset
  • tyre-age degradation
  • traffic loss
  • race-control modifiers
  • random noise

That separation makes later calibration possible. SciPy can fit degradation curves. Historical lap data can tune compound offsets. Monte Carlo runs can vary uncertain parameters.

Pit stops are state transitions

In the lap-loop version, a pit stop can just mutate the car state:

codepython
def pit(car, new_compound, pit_loss):
    car.gap_to_leader += pit_loss
    car.compound = new_compound
    car.tyre_age = 0
    car.pit_stops += 1

That is deliberately incomplete. It ignores pit-lane congestion, double-stacking, undercut warmup, and exact rejoin order. The first requirement is only that pitting has a visible time cost and a visible tyre benefit.

Once that works, the pit model can grow:

  • reduced pit loss under VSC or Safety Car
  • random stop-time variation
  • double-stack delay for two team cars
  • rejoin traffic penalty
  • pit-lane queue effects

This is where the lap loop starts to creak.

Where the loop becomes inadequate

A lap loop is a good sketch, but F1 strategy is not really one synchronised update per lap.

Several things happen at different times inside the same lap:

  • one car reaches the pit entry before another
  • the pit crew can service only one team car at a time
  • a double-stack delay depends on when both cars arrive, because both team cars are trying to use the same pit crew in quick succession
  • a Safety Car or VSC changes the effective pit loss while cars are at different parts of the circuit
  • a car can be released into traffic that has not "finished" the current lap in the model yet
  • a rival's stop is an event that changes the decision context immediately, not at the next loop boundary

The lap loop can approximate those effects by adding flags and ordering rules. That works for a while. Then the update function becomes a pile of small exceptions.

The useful question is not "can I force this into a loop?" Usually, yes. The question is whether the loop is still making the race easier to reason about.

For pit stops and shared resources, I would move to SimPy.

Why SimPy fits this part

SimPy is a discrete-event simulation library. In SimPy's basic model, active things are processes, processes live inside an environment, and processes wait for events such as env.timeout(...). Conveniently, the official introduction even uses a car process as its first example.

That maps well onto the parts of a race simulator that are awkward in a plain lap loop:

  • a car can be a process
  • driving a lap can be a timeout
  • a pit stop can be a sequence of events
  • a pit box can be a limited-capacity resource
  • a double-stack becomes a queue, not a special-case if
  • race-control or weather changes can be separate processes

The point is not that SimPy makes the model more realistic by itself. It gives the simulator a cleaner way to represent time and contention.

The smallest useful shape looks like this:

codepython
# /// script
# requires-python = ">=3.11"
# dependencies = ["simpy>=4.1.1"]
# ///

import simpy

def car_process(env, car, strategy, pit_box, params):
    for lap in range(1, params.total_laps + 1):
        lap_time = estimate_lap_time(car, params)
        yield env.timeout(lap_time)
        car.lap = lap
        car.tyre_age += 1

        if strategy.should_pit(car.name, lap):
            compound = strategy.next_compound(car.name, lap)
            yield env.process(pit_stop(env, car, pit_box, compound, params))

def pit_stop(env, car, pit_box, compound, params):
    yield env.timeout(params.pit_entry_loss)

    with pit_box.request() as request:
        yield request
        yield env.timeout(params.service_time)

    yield env.timeout(params.pit_exit_loss)
    car.compound = compound
    car.tyre_age = 0
    car.pit_stops += 1

The important line is with pit_box.request() as request. SimPy's Resource object models limited-capacity shared resources, so if two team cars reach the same one-car pit box together, the second process waits. The double-stack delay falls out of the resource model.

Then the simulation setup is ordinary:

codepython
env = simpy.Environment()
pit_box = simpy.Resource(env, capacity=1)

for car in cars:
    env.process(car_process(env, car, strategy, pit_box, params))

env.run()

That is a better fit for events that are not aligned to lap boundaries.

What improves

The SimPy version gives the prototype a few useful properties.

First, time becomes continuous enough for the problem. It is still simulated time, not real time, but cars no longer have to move in lockstep.

Second, limited resources become explicit. A pit box, pit crew, release lane, or even a simplified DRS train can be represented as a resource or queue instead of being hidden in a lap update function.

Third, separate processes can change shared race state. A race-control process can turn VSC on for a sampled interval. A weather process can change the track condition. A timing process can record order and gaps. The car processes react to the state they encounter.

That matters because strategy is full of interactions. The hard parts are often not the independent lap times. They are the moments where two plans collide.

What does not improve

SimPy does not solve the modelling problem.

The simulator still needs assumptions for pace, degradation, pit loss, traffic, tyre warmup, rival behaviour, and event probabilities. A bad model in SimPy is still a bad model. It just has a nicer event queue.

There is also a cost. The lap loop is easier to read and easier to debug at first. For a single-car strategy comparison, I would keep it. For pit contention, double-stack behaviour, race-control timing, or multi-car interactions, I would switch to SimPy.

That is the practical boundary:

Use the lap loop while each lap can be treated as one clean update.

Use SimPy when timing, waiting, and shared resources become part of the result.

Output should be strategic

The simulator should not only print final race time. Race strategy often cares about position, risk, and specific rival comparisons.

Useful outputs include:

  • final position
  • total race time or gap to leader
  • points estimate
  • probability of beating a target rival
  • time spent in traffic
  • pit-lane queue delay
  • tyre-life risk at the end of each stint
  • number of branches rejected as infeasible

Those outputs are what the later search layer will rank.

For the scripts attached to this post, I keep the output intentionally plain: final order, gaps, stop counts, compounds, and queue delay. That is enough to compare the two implementation styles without pretending this is a calibrated F1 model.

Running the scripts

Both versions are written as self-contained Python scripts using uv's PEP 723 inline script metadata format. That means the dependencies live in the TOML header at the top of the script, and the scripts are run with uv, not pip.

The plain loop has no third-party dependency:

codebash
uv run scripts/f1_strategy/lap_loop_simulator.py
codetext
Pure lap-loop simulator
pos  car          time      gap stops    tyre  age
  1  ALT-1    2151.796    0.000     1    hard   15
  2  RIVAL    2157.233    5.436     1    hard   13
  3  ALT-2    2160.489    8.693     1    hard   15

The SimPy version declares simpy in its script header:

codebash
uv run scripts/f1_strategy/simpy_race_simulator.py
codetext
SimPy discrete-event simulator
pos  car          time      gap stops    tyre  age   queue
  1  ALT-1    2151.303    0.000     1    hard   14   0.000
  2  ALT-2    2154.168    2.865     1    hard   14   2.756
  3  RIVAL    2154.647    3.344     1    hard   12   0.000

The source code for both approaches is available as Python scripts in the GitHub repo: f1_strategy scripts.

The caveat

A simple simulator can become dangerously persuasive. It will produce numbers even when the assumptions are weak.

The safe habit is to label the model's claims narrowly:

This prototype compares simplified strategy shapes under an assumed pace and tyre model.

It does not claim real F1 accuracy.

That may sound defensive. It is really just engineering hygiene. A model that knows its own boundary is easier to improve than one pretending to be complete.

The next piece is the decision model: the layer that decides which actions are legal and sensible from each race state.