src/simulation: Race Replay Engine
Purpose
Offline replay of a race from a stored parquet snapshot. Emits lap_state dicts, the canonical data contract consumed by all seven strategy agents.
This is the demo path for the thesis defence. The live path will replace the iterator at the v3.0.0 milestone (roadmap) without touching any agent code, because agents only see lap_state dicts regardless of source.
Architecture
data/raw/2025/<GP>/
laps.parquet ← loaded by RaceReplayEngine
weather.parquet ← loaded if present
metadata.json ← gp_name, year
RaceReplayEngine
└── RaceStateManager ← data boundary enforcement
├── get_driver_state() ← full telemetry (our car)
├── get_rival_states() ← timing-screen only (rivals)
├── get_weather_state() ← track + weather snapshot
└── get_lap_state() ← merges all into lap_state dict
↓
lap_state dict → all 7 agents → strategy orchestrator
RaceReplayEngine.to_arcade_frame() still exists in replay_engine.py, but nothing calls it and its docstring's /ws/replay WebSocket route was never registered on the backend, the arcade's real live path is the direct in-process pipeline broadcasting over a local TCP socket, documented in Arcade strategy pipeline and Multi-agent system → Three-window arcade.
Data boundary (architectural constraint)
The single most important design decision in this module.
| Field | Our driver | Rivals |
|---|---|---|
| LapTime | yes | yes |
| Sector1/2/3 | yes | NO |
| SpeedI1, SpeedI2, SpeedFL | yes | NO |
| SpeedST | yes | yes |
| TyreLife | yes | yes (broadcast) |
| Compound | yes | yes |
| FuelLoad | yes (estimated) | NO |
| Position | yes | yes |
| gap_to_leader | yes | yes |
| interval_to_driver | yes | yes |
Rivals get only what appears on the FIA live timing screen. This mirrors the real information asymmetry a strategy engineer faces on the pit wall.
Who counts as a rival
The boundary above says what a rival's row contains. This says which cars get a row at all, and it is just as load-bearing:
A car appears in rivals for a lap only if it was classified on that lap. Retired, crashed, or not yet across the line means no row, so the car is absent from the list entirely. It is never present with an invented position, a placeholder gap or a default lap time.
This is the rule that a real timing screen follows, and skipping it produces decisions that look reasonable and are absurd. The pit wall once recommended undercutting HUL at Lusail lap 7. HUL had just crashed on lap 6, which is what brought out the safety car the same recommendation was reacting to. The car was gone; only a filled-in default kept it on the list.
Two habits follow from it, and both are worth copying into any new code that shapes race data:
- Absence beats a default. If a value is unknown, leave it
Noneand let the consumer filter. A number is a claim, and an invented one is a false claim that nothing downstream can distinguish from a reading. - Never default to a value the code also searches by.
Positiondefaulting to0is how the leader found "the car ahead at positionpos - 1 == 0" and got handed the car that had just crashed. Placeholders belong in sort keys, never in emitted data.
Gap computation
Gaps come from session_time_s, the session elapsed time at the end of each lap, read from FastF1's Time column. This is the same value FastF1 uses internally for gaps in session.laps.
gap_to_leader[driver, lap] = session_time[driver, lap] - session_time[leader, lap]
interval_to_driver[rival, lap] = session_time[rival, lap] - session_time[our_driver, lap]
Not cumulative LapTime sums, which is the tempting alternative. Summed lap times drift away from the real on-track gap wherever timing corrections or safety-car bunching apply, which is exactly where a strategy call matters most. _compute_session_times in src/simulation/race_state_manager.py takes elapsed time for that reason.
This is worth stating loudly because the feature-engineering step drops the Time column, so any consumer reading the featured parquet directly used to fall back to lap-time deltas without noticing. Measured on Lusail 2025 lap 20, that fallback fed the model a 0.10 s gap between PIA and NOR when the real one was 3.58 s: the difference between "inside the DRS window" and "not close". The loader now restores Time_s from the raw per-race parquet at load time, so both paths agree.
Known limitations:
- Lapped cars show a large positive gap rather than a "lapped" flag.
Files
| File | Responsibility |
|---|---|
race_state_manager.py |
Data boundary, per-lap state construction |
replay_engine.py |
Parquet loading, lap iterator, Arcade frame builder |
stint_history.py |
Per-driver stint tracking; emits the Art. 30.5(m) stint_flags and rival_stop_pending this page documents |
data_validation.py |
Parquet quality checks, including the IsAccurate / Deleted warning the CLI prints on load |
__main__.py |
Terminal CLI for quick testing |
README.md |
Package-level notes |
Running
# All laps, no delay
python -m src.simulation Melbourne NOR McLaren
# Specific lap range
python -m src.simulation Monaco HAM Mercedes --laps 30-50
# 2s between laps (simulates real-time ingestion)
python -m src.simulation Melbourne NOR McLaren --interval 2
# Different season
python -m src.simulation Silverstone VER "Red Bull Racing" --data-dir data/raw/2024
Example output
------------------------------------------------------------------------
F1 Strategy - Race Replay Melbourne | NOR / McLaren | 57 laps
------------------------------------------------------------------------
Lap Pos Compound LapTime Gap Leader Ahead Behind
------------------------------------------------------------------------
1 1 INT( 1L) 1:57.099 +0.000s P2:VER INT(1L) +2.276s
2 1 INT( 2L) ---.- +0.000s P2:VER INT(2L) +1.695s [IN]
20 1 INT(20L) 1:30.710 +0.000s P2:PIA INT(20L) +2.623s
34 1 INT(34L) 2:02.273 +0.000s P2:PIA INT(34L) +8.185s [IN]
35 1 HAR-C3( 2L) 2:03.448 +0.000s P2:PIA HAR-C3(2L) +8.518s [OUT]
44 6 HAR-C3(11L) 1:45.587 +15.061s P5:LEC HAR-C3(11L) -0.138s P7:LAW MED-C4(11L) +4.589s [IN]
46 3 INT( 2L) 1:31.567 +4.415s P2:LEC HAR-C3(13L) -0.414s P4:TSU MED-C4(13L) +0.381s
57 1 INT(13L) 1:27.126 +0.000s P2:VER INT(11L) +0.902s
Captured from a real run of the command above, on 2026-08-10. The block it replaced had the same lap times and pre-fix gaps: it showed P2 at +13.836 s on lap 20 where the code now emits +2.623 s, because it predated the
Time_scorrection the Gap computation section below describes. A stale example that demonstrates the exact failure its own page warns about is worse than no example. The compound labels also gained their Pirelli compound ID (HAR-C3) since the old block was captured.
Reading the output:
| Column | Meaning |
|---|---|
Lap |
Lap number |
Pos |
Our driver's position |
Compound |
Tyre compound abbreviation + laps on tyre: INT(20L) = Intermediate, 20 laps; HAR(2L) = Hard, 2 laps; MED = Medium; SOF = Soft |
LapTime |
Lap time in M:SS.mmm. ---.- = deleted lap (red flag, pit-in lap, etc.) |
Gap Leader |
Our gap to P1 in seconds. +0.000 = we are the leader |
Ahead / Behind |
Rival directly ahead/behind in position |
[IN] |
Driver pitted this lap (in-lap) |
[OUT] |
Driver exiting the pits (out-lap) |
lap_state schema
{
"lap_number": int,
"driver": {
# Identity, repeated inside the sub-dict so a consumer that has been
# handed only this slice still knows whose lap it is holding.
"driver": str,
"team": str,
"lap_number": int,
"lap_time_s": float | None,
# This lap's Prev_LapTime feature, never last lap's own lap_time_s
# reused as a stand-in — that self-reference used to feed the pace
# agent its own most recent prediction back in as "previous" (#435).
"prev_lap_time": float | None,
"sector1_s": float | None,
"sector2_s": float | None,
"sector3_s": float | None,
"position": int | None,
"gap_to_leader_s": float | None,
"compound": str,
"compound_id": int | None,
"tyre_life": int | None,
"stint": int | None,
# TyreLife at the first lap of the current stint. N06's FuelEffect is
# measured from this baseline, not from lap 1 of the race (#446).
"stint_baseline_tyre_life": int | None,
"fresh_tyre": bool,
"speed_i1": float | None,
"speed_i2": float | None,
"speed_fl": float | None,
"speed_st": float | None,
"fuel_load": float | None,
"track_status": str,
"is_in_lap": bool,
"is_out_lap": bool,
},
# Only cars classified on this lap. A car that has retired, crashed or has
# not yet completed the lap has no row for it, so it is simply ABSENT from
# `rivals` — never present with a filled-in default. See "Who counts as a
# rival" above.
"rivals": [
{
"driver": str,
"team": str,
# None when the car has no position that lap. It sorts to the back
# through a placeholder confined to the sort key, so no consumer can
# look up a car at that position and find one.
"position": int | None,
"lap_time_s": float | None,
"compound": str,
"tyre_life": int | None,
"stint": int | None,
"speed_st": float | None,
"gap_to_leader_s": float | None,
"interval_to_driver_s": float | None,
"is_pitting": bool,
}
],
"weather": {
"track_status": str,
"air_temp": float | None,
"track_temp": float | None,
# The session's FIRST track temperature, not this lap's — a session
# constant carried in the weather dict because that is the channel
# its consumer (N14's track_temp_delta feature) reads from (#486).
"track_temp_start": float | None,
"humidity": float | None,
"wind_speed": float | None,
"rainfall": bool,
},
"session_meta": {
"gp_name": str,
"year": int,
"driver": str,
"team": str,
"total_laps": int,
},
# Art. 30.5(m) (2024-25 numbering; it was 30.5(n) in 2023) two-compound obligation, for our driver, as of this lap.
# Emitted here (rather than fetched separately) so the CLI, the arcade and
# the backend cannot each derive their own, divergent view of who still
# owes a stop. A lap with no row falls back to the nearest earlier lap,
# since compound history only grows and the last known state is still the
# truth about what has been used.
"stint_flags": {
"stops_made": int | None, # highest visible stint number minus one
"compounds_used": list[str], # first-seen order, dry and wet compounds
"mandatory_stop_pending": bool | None, # None when an unseen stint could hide the second compound
},
# mandatory_stop_pending for every rival present in `rivals`, keyed by
# driver code. A rival who must still stop is no threat: they pay the same
# price later. A driver absent from this dict has no row for this lap and
# is not asserted either way, the same "absence beats a default" rule
# `rivals` itself follows.
"rival_stop_pending": {
"<driver_code>": bool | None,
},
}
Future: live ingestion (v3.0.0)
Replace RaceReplayEngine.replay() with a consumer that emits the same lap_state dict from a live feed. Zero changes to agents or orchestrator.
The sketch below is written against Kafka because that was the assumed transport when this page was drafted; the roadmap's v3.0.0 entry names the OpenF1 WebSocket. The shape of the change is what matters and it is the same either way: swap the iterator, leave the contract alone. The old
v0.14numbering here predated the 1.0 release and is long dead.
# Current (offline)
for lap_state in engine.replay():
...
# Future (live)
for lap_state in kafka_consumer.consume_lap():
...