A live Formula 1 race strategy engine.
Pitwall consumes Formula 1's live timing stream, fits tyre degradation and safety-car models in-session, and simulates the remainder of the race to answer one question: should we pit now?
Every recommendation is committed to this repository with a timestamp before the lap it refers to. The accuracy log is public and includes the calls it got wrong.
Lap 34 of the 2026 Hungarian GP, replayed through the engine. Leclerc is P3; stopping now on hards
comes out at an expected P4.58, 0.33 clear of the next option, with Antonelli one second back at a
41% chance of jumping him. The histogram is the simulation's actual output — the spread from P1 to
P7 is the part a single expected value hides. Bottom right, the model reports the confounding in
its own degradation estimates rather than presenting them as fact. Regenerate with
python scripts/screenshot.py.
Status: all five phases built. Live ingest, race state, clean-lap filtering, fuel correction, degradation and a per-circuit safety-car hazard feed a Monte Carlo simulation that produces pit calls, and every call is logged, committed before the lap it refers to, and scored against what happened. What remains is races — see the plan.
Most public F1 projects predict outcomes from historical data after the fact. A pit wall does something harder: it makes decisions in real time, under uncertainty, against rivals who react. Pitwall is built around that difference.
| Typical F1 repo | Pitwall | |
|---|---|---|
| Timing | Offline, post-race | Real-time, under a latency budget |
| Output | "Who will win?" | "Pit now, or lap +3?" |
| Rivals | Assumed static | Modelled as reactive agents |
| Validation | Often none | Pre-registered calls, scored on calibration |
Requires Python 3.11+. uv handles the interpreter for you.
uv sync --extra dev
uv run pytestuv run pitwall dashboard # live
uv run pitwall dashboard --replay data/raw/2026-hungary-race.txt --speed 20 --skip 62 --driver LECPushed over a websocket to http://127.0.0.1:8000, and it paints from a REST snapshot on load so
it is correct from the first frame rather than after a round trip.
The timing tower carries team colours, tyre-compound badges with age, lap-time sparklines (green improving, red falling away), stint bars showing each car's strategy so far, session-fastest in purple, and positions gained or lost since the grid. Click any car to advise it instead.
The right column is the engine showing its working:
- The call, with its margin over the next option — and where that margin is thin, it says "marginal" rather than dressing a coin-flip as a decision.
- The finishing distribution from the Monte Carlo, clipped to the positions holding 95% of the probability. A tight spread around P4 and a coin-flip between P2 and P8 have the same mean and mean very different things; the histogram is where that shows.
- Undercut threats — who can jump us by stopping now, and how likely, against our own planned stop rather than against never stopping.
- Race risk — safety-car probability over the next 5, 10 and 20 laps, plus the chance a given car retires and how many DNFs to expect, both from per-circuit hazards fitted on 103 races.
- The pace model — race-lap trend, per-compound degradation, residual σ and r², plus any warning the fit carries. When the design is not identified it says so and no call is offered.
Because it runs off a RaceFeed it is identical live or on a recording — the replay form
reproduces the Hungarian GP from lap 30 in about a minute, so it can be demonstrated on any
Tuesday rather than once a fortnight. The simulation runs in a worker thread, so ingest and the
screen keep going through the second or two a decision takes.
On a race day this is the whole system in one process — one connection to F1's endpoint, which records the raw frames, folds them, fits the models, publishes a call every lap, and commits each call to the ledger as it is made:
uv run pitwall dashboard --record data/raw/2026-netherlands-race.txt \
--log-predictions --session "2026 Dutch GP"scripts/race_day.sh wraps that for an unattended run: it waits for the session, checks the git
identity before the two-hour wait rather than failing silently for the whole race, holds off
system sleep, and stops on its own.
Predictions are written only when LapCount says a race is running, so a dashboard left going
through practice records nothing; only once per lap, so the reconnection that a two-hour feed
guarantees cannot double-count a call; and never fatally, because the race is not re-runnable. The
CLI refuses to commit calls made from a --replay, since the recording already contains the
outcome and the commit timestamp is the only thing the ledger is worth anything for.
For just the timing screen, without the engine:
uv run pitwall live --record data/raw/2026-netherlands-race.txtConnects to F1's live timing stream, folds it in real time, and redraws a timing screen. It works between sessions too — the endpoint replies with the last session's snapshot, then keepalive pings.
This is the part FastF1 does not do. Its client connects to the same endpoint and writes frames to
disk; its documentation states that it "is not possible to do real-time processing of the data".
SignalRFeed parses them as they arrive, and is a drop-in RaceFeed alongside ReplayFeed, so
everything downstream is identical whether the race is happening now or happened in July.
Measured against the live endpoint: 17 events in 0.32 s, decode p50 0.05 ms, p99 4.6 ms.
Two protocol notes, established by probing since none of this is documented. The classic
/signalr/negotiate path that most public clients use now answers 401. And /signalrcore
accepts an unauthenticated connection despite FastF1 wiring in an F1 TV token — no login is
required.
Reconnection is tested, not hoped for. F1's feed drops after roughly two hours and a Grand Prix is two hours, so a mid-race drop is the expected case. The suite drives the retry loop through injected drops, escalating backoff, the cap, the reset after a good connection, and cancellation. Against the live endpoint, with the silence timeout cut to force real drops, it completed 11 reconnections in 75 seconds — each a full negotiate, handshake and re-subscribe — with race state intact after every one.
Run this ~5 minutes before a session starts. It supervises the connection and reconnects, because F1's feed reliably drops after about two hours — roughly the length of a Grand Prix.
uv run python scripts/record.py data/raw/2026-hungary-race.txtIt is safe to start early: a connection made outside a session returns one state snapshot and then nothing, and the supervisor detects that and backs off to five-minute retries rather than reconnecting in a loop.
Races happen at awkward times. This waits for the session, holds off system sleep, records, and stops on its own:
nohup ./scripts/scheduled_record.sh "2026-07-26 13:30" data/raw/2026-hungary-race.txt 210 &Arguments are start time (local), output file, and minutes to record. Progress goes to
data/raw/<name>-runner.log.
Leave the machine plugged in, on wi-fi, and with the lid open. macOS sleeps on lid close
regardless of caffeinate, and a sleeping Mac records nothing.
uv run pitwall topics data/raw/2026-hungary-race.txt # what's in the recording
uv run pitwall replay data/raw/2026-hungary-race.txt # fold it into race state
uv run pitwall replay data/raw/2026-hungary-race.txt --speed 60 --follow
uv run pitwall laps data/raw/2026-hungary-race.txt # extract and filter laps
uv run pitwall hazard # per-circuit safety-car risk
uv run pitwall pitloss # per-circuit pit loss
uv run pitwall degradation # pooled tyre-degradation prior
uv run pitwall strategy data/raw/2026-hungary-race.txt --lap 34 --driver LEC--speed 60 replays a two-hour race in about two minutes with a live timing screen.
Degradation is a tenths-per-lap effect buried under much larger ones — an in-lap is ~20 s slow, a
safety-car lap ~30 s. pitwall laps extracts completed laps and reports what it excluded and why:
881 clean of 1,405 laps (62.7% kept, 524 excluded)
following within the dirty-air threshold 429
entered the pits 92
left the pits 45
safety car, VSC or red flag 34
lap 1 (standing start) 22
lap time implausible for a racing lap 2
Rejections are reported as counted reasons rather than a boolean, because the breakdown is what shows the filter is behaving — on the 2026 Hungarian GP the 22 first-lap exclusions match the grid exactly, and the in/out-lap counts match the 47 recorded pit stops.
A wet phase is treated as a different race. Every reason above judges a lap on its own; this one judges it against the track. Laps on intermediate or wet tyres are dropped — the model has no rates for them — and so are slick laps run before the track dried, starting six laps after the field leaves wet tyres. The boundary is read off what the field is running rather than the rain sensor: a lap is wet when more than one car in ten ran it on wet-weather tyres.
It exists because of the 2026 Bahrain GP in Malaysia, which was recorded whole and produced no call at all. Eight laps on intermediates at the start bent the single race-lap trend for the whole afternoon — −4.3 s/lap at lap 7, still −0.24 at the flag — and the fit was refused on every lap it could be attempted. Fitted on the dry phase alone it is usable from lap 31, at 0.025–0.033 s/kg. Across eleven recordings the usable count goes down slightly, 107 to 101: the change removes fits made in the rain (24 of the 41 laps lost) and adds the dry ones that were being thrown away.
pitwall strategy replays a recording to any lap and asks what the engine would have said there.
2,000 full race simulations take 0.14 s, so a twelve-option decision lands in under two seconds:
Race @ Hungaroring lap 34/70
LEC P3, lap 34 (3,000 sims)
→ now on SOF marginally ahead; no clear call
expected P5.11, margin +0.01 to next option
option exp. pos top3 points gain
now on SOF 5.11 24.8% 97.3% 17.4%
now on MED 5.13 24.5% 97.3% 14.2%
now on HAR 5.29 20.1% 97.3% 10.2%
...
undercut threats:
ANT +0.9s behind P(jumps us) 47.4%
Three things carry the realism. Track position is enforced — cars cannot pass through each other, so a quick car stuck in a train stays stuck, which is what makes an undercut work. Safety cars compress the field, collapsing a twenty-second lead and discounting a stop taken under one. Rivals are not static — their stops are sampled from a policy distribution, so the answer accounts for them covering or attacking.
The suite asserts textbook dynamics rather than just unit behaviour: starting ahead is worth something, a faster car is held up behind a slower one, safety cars erase a large lead, and the undercut works. If those break the model is wrong whatever the unit tests say.
The timing of a stop rests on how much one costs, and that is now measured per circuit rather than assumed — see below.
Degradation is fitted as α_c·age + γ_c·age² — the quadratic being the cliff, the point where a
tyre stops wearing and starts falling away. γ is constrained non-negative, because a negative
quadratic describes a tyre that gets faster the longer it runs, and extrapolating one would
actively reward never stopping.
On synthetic data the estimator recovers a true cliff of 0.00300 as 0.00297. On real races it fits essentially zero — Zandvoort's hard tyre 0.00000, Hungary's 0.00008 — and that is not the estimator being too weak: regenerated at the real 0.88 s residual, a cliff of that size still comes back. Within the stint lengths teams actually run, degradation at these circuits really is close to linear.
The cliff is real; it just lives past the point anyone runs a tyre. Which is the more important limitation:
The model cannot see past its own data, and now says so. The longest hard stint at Zandvoort was 36 laps. Asked what lap 50 looks like, a straight line promises a tyre that lasts forever — and staying out is precisely the option that benefits from that optimism. Teams pit before the cliff, so the steep part of the curve is missing because it is steep.
A cliff needs stints long enough to contain one, which no single race provides. scripts/ fetch_degradation.py collects 95 races out of the local FastF1 cache — no API calls — by
stripping each race down to the part of a lap attributable to tyre age and pooling those deltas,
which mean the same thing at Monaco and Monza. 81 of the 95 are used; the rest are excluded,
and which and why is the subject of the next section.
Pooling did not reveal a cliff, and why is the more useful result:
| compound | age 0–4 | 10–14 | 20–24 | 30–34 | 50–54 |
|---|---|---|---|---|---|
| HAR | +0.10 | +0.48 | +0.81 | +0.84 | +1.55 |
| SOF | −0.29 | +0.21 | +0.21 | −0.61 | — |
A thirty-lap-old soft cannot be faster than a fifteen-lap-old one. That is survivorship: a car whose tyres are going away gets pitted, so the sample still circulating at high age is made of exactly the stints that were not degrading. The selection strengthens with age, and more races sharpen the artifact rather than removing it — which is why the observed curve is concave where physics says convex, and why an unconstrained quadratic through it comes back negative.
So the shape is fitted only up to where the binned curve stops rising — found per compound, soft at 19, hard at 29 — and continued linearly beyond rather than flattening. Flattening is the artifact; a straight line is the smallest claim that is not knowingly wrong.
uv run pitwall degradation
compound linear cliff trusted seen @20 @40 @55
HAR +0.0378 +0.00000 54 71 +0.76s +1.51s +2.08s
MED +0.0456 +0.00000 29 59 +0.91s +1.82s +2.51s
SOF +0.0647 +0.00000 24 38 +1.29s +2.59s +3.56s
circuit factor (shrunk toward 1.0):
Spa 2.03x Sakhir 1.90x Barcelona 1.38x ... Monza 0.78x Budapest 0.51x
Circuits whose fitted scale comes out negative are rejected rather than shrunk — Melbourne's raw scale was −0.64, and shrinking that toward 1.0 produced a plausible-looking 0.06x, which is how a sign error survives review. They fall back to the field average with a warning.
The table above used to read SOF −0.0148 against HAR +0.0369: a soft tyre that improves with
age and wears more slowly than a hard, which is backwards twice over. It was in the model every pit
call runs on for four days and no test failed.
Seventeen of the 95 races fit a negative degradation scale — a tyre that gets faster the longer it runs. They are not random. Against the 78 that fit a positive one they were six times as likely to be red-flagged (29% against 5%), had three times the safety-car starts, and ran twice the share of the race neutralised. The decomposition subtracts one straight line in race lap — fuel burn and track evolution together — before attributing anything to tyre age, and a red flag resets fuel load and track state mid-race while a drying track falls several seconds a lap. Whatever that line fails to fit is attributed to the tyre.
Two things follow, and the second matters more than it looks:
- The circuit scale is fitted per race and combined by median, not pooled into a single least-squares over every bucket row. Pooling gave one failed race a vote proportional to its lap count: Zandvoort's factor was 0.505x — second-lowest of twenty-two, on a banked high-load circuit — because the wet 2023 race outvoted four sound ones. Per compound that race read hard 0.92x, medium 1.30x, soft −1.48x. The guard that exists for exactly this tests the aggregate, which came to +0.207, so nothing looked wrong. It is now 1.05x.
- Disrupted races are excluded on a criterion measured independently of the answer — red-flagged, or over 5% of laps on wet tyres — never on the sign of the estimate. Excluding on the sign systematically overstates every circuit whose true degradation is near zero, since noise there lands either side and only one side survives, and those are the circuits the scale matters most for.
What that does to the only number a strategist would check — how many laps of running a second stop needs before it pays for itself, on a 26-lap-old hard:
| Zandvoort | Montréal | Monaco | Silverstone | Spa | Monza | |
|---|---|---|---|---|---|---|
| before | 47 | 57 | 36 | 22 | 10 | 30 |
| after | 22 | 30 | 21 | 22 | 10 | 31 |
The circuits that were already clean do not move, which is the shape a contamination fix should have. At Zandvoort the old number meant that past lap 25 of 72 no second stop could ever win, which is what made the 23 August "stay out on everything" result impossible to fix by tuning.
The in-session fit is blended toward this prior in proportion to how many laps back it, so a well-observed compound keeps its own rate and a thin one borrows the pooled shape.
Every fit records observed_max_age per compound, and any option needing a tyre older
than that is flagged. Of the 49 calls made live at the 2026 Dutch GP, 31 rest on tyre ages never
observed — and the flag separates them properly: staying out on lap 71 needs a 23-lap tyre where
33 were seen, while staying out on lap 26 needs 49. Same call, entirely different standing. With
the pooled prior supplying evidence out to 78 laps, none of the 49 still rest on unobserved tyre
ages; see the re-scores, which are post-hoc and kept well away from
the live ledger.
For roughly the first third of a race the engine offers no call. A car that has not stopped has a tyre age equal to its race lap, so fuel burn and tyre wear are one column of the design matrix and no estimator separates them; the fit is refused until the field's stints stagger. That cost three races their decision window — Baku silent to lap 41, Kuala Lumpur to 31.
The fix is to hold the race-lap trend with a prior and take tyre wear as what is left. It was built, measured from 13 races (0.040 s/kg, sd 0.013), and works as a fit: first usable lap moves from 41 to 10 at Baku, and the degradation it leaves is closer to the race's own end-of-race value than the pooled prior on 72% of laps.
It is not switched on, because of what the calls did. scripts/stop_calls.py scores the one
thing about a call a recording can check — when the engine said "stop within three laps", did the
team? — counting only calls it marked decisive:
| decisive "stop soon" calls | team then did | |
|---|---|---|
| laps the engine already speaks on | 63 | 44% |
| laps that speak only if the trend is held | 69 | 10% |
A control on identical laps, where the trend was measurable either way, shows the prior itself is harmless (same call in 84% of rows) and that an early call is not bad merely for being early. What fails is a call made on a one-stint field: early track evolution is steeper than any end-of-race trend, so the wear left over comes out negative, the fit falls back on the pooled rate, and the call rests on nothing the race measured.
So the silence is a measured choice rather than a missing feature, and --hold-trend reproduces
the comparison. reports/trend-prior.json has the tables.
Every call is written to an append-only log and committed before the lap it refers to. The
commit timestamp is the evidence: git log predictions/ shows when each was made. Predictions
stage only the log file, never source, so no commit can be argued to have tuned the model to fit.
uv run pitwall backtest data/raw/2026-hungary-race.txt --laps 16,24,32,40,48 --drivers NOR,VER,LEC
uv run pitwall report data/raw/2026-hungary-race.txt --log predictions/2026-hungarian-gp.jsonlbacktest re-folds the recording from scratch for every lap, so a decision at lap 24 sees only
laps 1–24 — no leak from the finish into a forecast of it. The report grades the log against the
actual classification, and is generated rather than written so the bad weekends cannot be quietly
skipped.
PLAN.md §6.4 asks for this specifically: F1 teams hire for real-time systems, and a latency
budget with evidence behind it is a stronger signal than any accuracy number. An accuracy number
can be got by running a notebook overnight; a p99 cannot.
The budget is p99 ≤ 2 s from a packet landing to a recommendation existing, and it is checked
against the samples rather than asserted. Measured over the 2026 Dutch GP, at a fixed 1,500
simulations, it was missed — so the simulation count now adapts, which PLAN.md §10 named as
the mitigation before any of this was built:
| fixed 1,500 sims | adaptive | |
|---|---|---|
| p50 | 1250 ms | 1158 ms |
| p95 | 2814 ms | 1306 ms |
| p99 | 3222 ms | 1505 ms |
| over budget | 13 / 49 | 0 / 49 |
packet arrival -> recommendation, distribution:
50-100 ms 1 2.0% #
100-250 ms 3 6.1% ####
250-500 ms 6 12.2% ########
500-1000 ms 7 14.3% #########
1000-2000 ms 32 65.3% ########################################
Cost is near-linear in the number of simulations, so the controller is a damped ratio — undamped, one slow lap halves the count and the next doubles it back. It starts below the configured ceiling and earns its way up, because the first decision is the one it cannot correct and with fifty decisions in a race a single uncorrected outlier is the p99. It never goes below 300 simulations: past that the margin between two options is sampling error, and the honest response is to report the miss rather than keep degrading quietly.
Two measurement notes, both of which flattered the number before they were fixed. Cycles where the
pace fit is refused run no simulation and cost microseconds; counting them as decisions put a fifth
of the samples in the sub-millisecond bucket and nearly halved the reported median. And lag —
the age of a message on arrival — is reported separately and never folded into the total, because
it measures F1's pipeline and the internet rather than this engine.
A recommendation log cannot be calibrated. The engine advises one car — a pit wall only has one
to advise — so a race yields ~50 calls, nearly all on a leader who was never going to move. Every
p_points sits at 0.98, the baseline scores a perfect 0.0000, and the reliability diagram has one
populated bucket. Nothing there is measurable.
So alongside each call the engine forecasts every car, which costs one simulation rather than one per car — 0.16 s against the ~37 s it would take to evaluate a full recommendation for all 22 — because the field is simulated jointly anyway. That turns 49 concentrated claims into 1,078 spread across cars whose outcomes genuinely differ:
| metric | model | baseline | skill |
|---|---|---|---|
| Brier (win) | 0.0210 | 0.0427 | +50.8% |
| Brier (top 3) | 0.0412 | 0.0390 | −5.6% |
| Brier (points) | 0.0554 | 0.0705 | +21.5% |
And a reliability diagram that says something:
| confidence band | n | said | happened |
|---|---|---|---|
| 0%–20% | 872 | 1.0% | 1.8% |
| 20%–40% | 51 | 29.0% | 33.3% |
| 40%–60% | 24 | 48.8% | 16.7% |
| 60%–80% | 47 | 73.0% | 59.6% |
| 80%–100% | 84 | 91.8% | 97.6% |
The engine is overconfident in the middle — when it says 73% it happens 60% of the time — and slightly underconfident at the top. That is a real, actionable flaw, and it is the first one this project has been able to state at all, because leader-only calls could not reveal it.
Forecasts are kept in their own file and committed a lap at a time rather than a car at a time: they are a different claim from a recommendation, settled at the flag with no judgement required.
28 leak-free predictions from the 2026 Hungarian GP:
| metric | model | baseline | skill |
|---|---|---|---|
| Brier (top 3) | 0.1961 | 0.2143 | +8.5% |
| Brier (points) | 0.1327 | 0.1429 | +7.1% |
| Mean position error | 3.69 | 3.50 | — |
The baseline assumes every car finishes where it currently runs. In F1 that is a strong benchmark, not a straw man — and on one race this engine barely beats it. That is the honest position.
An earlier version of this table reported +33.2% skill. It was wrong, and the correction is instructive: a bug placed the race leader at the back of the grid, so the baseline was also being scored against corrupted positions and looked far worse than it was. Fixing the bug cut the apparent skill by an order of magnitude. Both the bug and the inflated number are written up in the logbook.
RaceFeed ──► RaceStateReducer ──► Models ──► Simulation ──► Decision
(ingest) (fold events) (fit) (Monte Carlo) (pit or stay)
Two design rules carry the whole system:
- Everything is a
RaceFeed. Live websocket, recorded file, REST API — nothing downstream knows which. This is what lets the same code be developed offline and run live. - State is only ever produced by folding events. No model, heuristic, or UI callback writes to it. Given the same recording the reducer produces byte-identical state every time, which is what makes replay-based regression tests — and the backtest — trustworthy.
src/pitwall/
├── feed/
│ ├── base.py RaceFeed ABC, FeedEvent, .z inflation
│ └── replay.py ReplayFeed — the development workhorse
├── state/
│ ├── merge.py delta merging (the most safety-critical code here)
│ ├── models.py typed projections: RaceState, CarState, Stint
│ └── reducer.py folds events into state
├── laps/
│ ├── records.py completed laps with the context to judge them
│ └── clean.py clean-lap filtering, with counted reject reasons
└── models/
├── fuel.py physics fuel correction, usable from lap 1
├── pace.py joint fit: pace, fuel trend, per-compound degradation
├── degradation.py pooled tyre wear, selection-aware
├── pit_loss.py per-circuit green-flag pit loss, shrunk
└── safety_car.py per-circuit hazard with empirical-Bayes shrinkage
scripts/record.py supervised live recorder
scripts/fetch_history.py resumable safety-car history fetch
scripts/fetch_pit_loss.py resumable pit-loss measurement
scripts/fetch_degradation.py pooled degradation history
scripts/circuit_aliases.py derive circuit-name aliases from F1's session info
docs/PLAN.md the full five-phase plan
docs/logbook.md what broke and what the data taught me
The feed sends deltas, not snapshots. After the initial dump, {"Lines": {"4": {"Position": "3"}}} means "car 4 moved to P3" — not "the timing table now contains only car 4". Overwriting
instead of merging doesn't crash; it silently drops state and still renders a plausible-looking
screen. state/merge.py handles this, including the feed's habit of patching arrays with
index-keyed objects, and it's the most heavily tested module in the repo.
Recorded lines are Python reprs, not JSON. The common approach is to convert them by replacing
' with ", which corrupts any payload containing an apostrophe — race control messages routinely
contain them. Pitwall uses ast.literal_eval, which parses Python literals natively and safely.
There's a regression test for exactly this.
Everything used here is free.
| Purpose | Source |
|---|---|
| Live timing | F1 SignalR feed — no key, no account |
| Backtest telemetry, 2023+ | OpenF1 free tier |
| Laps and telemetry, 2018+ | FastF1 |
| Results and standings, 1950+ | Jolpica-F1 |
No public dataset publishes per-circuit safety-car rates, so scripts/fetch_history.py builds one
from 2022–2026 race control data and pitwall hazard fits a discrete-time hazard over it.
The strongest signal is that lap 1 is a different regime entirely — a 21% chance of a safety car or
VSC (pitwall hazard --kind any; the default reports safety cars alone), against about 2% for any
other lap:
baseline per-lap hazard by race phase:
lap1 0.2075
early 0.0204
mid 0.0207
late 0.0210
final 0.0140
circuit factor (shrunk, prior weight 3):
Melbourne 2.23x (5 races)
Zandvoort 1.29x (5 races)
...
Yas Island 0.49x (4 races)
Laps already running under a safety car are excluded from exposure — the question is "given none is out, does one get deployed" — and circuit factors are shrunk toward the field average, since four races per circuit cannot justify a raw ratio.
Note the F1 API allows 500 calls an hour, so a full fetch takes a couple of runs. The script is resumable: re-run the same command and it picks up where it stopped.
The time a stop costs is the most stable constant in the sport — it is fixed by pit lane geometry and a speed limit, not by car performance. Monza measures 25.2, 25.3, 25.8 and 25.3 seconds across four consecutive seasons. That stability is exactly why modelling it as one flat number everywhere was expensive: the constant is reliable, it is just different everywhere, and the calendar spans about nine seconds.
scripts/fetch_pit_loss.py measures it from 2022–2026 lap data and pitwall pitloss fits it:
Green-flag pit loss from 87 races, 1981 stops
field median 22.17s (spread 1.39s)
botched stops: 5.0% of stops, +2.0s then mean +3.81s more
so a stop costs 22.17s at the median, 22.45s on average
per circuit (shrunk, prior weight 2 races):
circuit shrunk raw sd races stops
Spa-Francorchamps 19.47s 18.40s 1.60s 5 101
Miami 20.46s 19.78s 1.55s 5 73
...
Monza 24.24s 25.29s 1.15s 4 92
Lusail 25.55s 27.81s 1.32s 3 69
What is measured is the quantity the simulation actually adds — total time lost against staying out, in-lap delta plus out-lap delta — against a local per-driver baseline, so fuel load, track evolution and driver pace cancel rather than needing correction. Spa comes out at 18.40 s raw, which is independently the number this sport quotes as the cheapest stop of the era.
Green-flag stops only: a stop under a safety car is cheaper because the field is crawling, and the simulation discounts that separately. Races contribute their median rather than their stops, so one wet afternoon with forty measurable stops cannot outvote three clean ones, and circuits are shrunk toward the field median because five races is not enough to justify a raw constant.
Stops are not symmetric, so they are not drawn symmetrically. A stop cannot go meaningfully better than a clean one — the pit lane has a speed limit and the stationary time has a floor — but it can go very much worse. The simulation draws ordinary scatter around the circuit median plus, 5% of the time, an exponential excess for a stop that went wrong. That is the difference between a median stop and an expected one: 22.17 s against 22.45 s across the calendar, and the expensive futures are exactly the ones a marginal call turns on.
Two things keep that tail from being too fat, which would bias the engine against pitting for a reason that is not real. Served time penalties are excluded — they are added to the stationary time and read as botched stops in lap data, and leaving them in roughly doubles the apparent rate of disasters. And the rate subtracts what ordinary scatter already explains: with a spread of 1.4 s about 7% of perfectly clean stops clear +2 s on their own, so counting those as botched would double-count them. Fitted on 1,981 stops, the mixture reproduces the empirical mean excess exactly and P(stop >2 s slow) to within 0.2 points.
Above +15 s the model deliberately stops: that is a front wing change, a repair, or an uncaught penalty — a different event, not a routine stop, and not one this model claims to describe.
These models are fitted on FastF1's Location but queried at runtime with the name the live feed
sends, Meeting.Circuit.ShortName. Nine of twenty-seven circuits spell those differently —
Hungaroring/Budapest, Catalunya/Barcelona, Singapore/Marina Bay, Interlagos/São Paulo — and the
mismatch was silent: the lookup returned the neutral default and nothing reported it. The 2026
Hungarian GP ran on a 1.0x safety-car factor when the fitted value was 0.58x.
The alias table is derived, never recalled — scripts/circuit_aliases.py reads both spellings
out of F1's own session info for every cached race and prints the pairs that disagree. A wrong
entry here maps one circuit's history onto another and nothing errors.
Unofficial and unaffiliated with Formula 1. It uses F1's public live timing endpoint the same way open-source clients like FastF1 and f1-dash do: one connection, no redistribution of the raw feed, non-commercial use only. Jolpica is volunteer-funded — its responses are cached rather than polled.
MIT licensed.
