Lap-level analysis of race pace and tyre degradation using FastF1. Single race, laps only, no telemetry.
Ranking drivers by median clean-lap time gives a different order than ranking
by fastest lap: GAS is 6th on his single best lap (82.871 s) but 11th on median
pace (86.599 s), a 3.7 s spread against 2.0 s for the race-pace leader NOR.
Fitting LapTimeSec ~ TyreLife per stint yields median slopes of +0.042 s/lap
(HARD), +0.028 (MEDIUM) and +0.009 (SOFT) — the reverse of the expected order.
This is not a calculation error: the slope measures tyre wear minus fuel burn,
and since compounds are not run at comparable fuel loads, stint lengths or race
phases, a single race cannot separate the two effects.
- Source: FastF1 3.8.3,
2026 Hungarian Grand Prix, Race - 22 drivers, 1431 raw laps, 1231 after cleaning
Each filter is a separate step with a row count printed after it, so a filter
removing an unexpected number of rows is visible immediately. This caught a real
bug during development: a positional slice (clean[1:]) that was meant to drop
the standing-start lap removed exactly one row instead of twenty-one.
| Step | Rule | Laps left |
|---|---|---|
| raw | — | 1431 |
| has lap time | LapTime not null |
1429 |
| known compound | Compound != 'None' |
1404 |
| no pit laps | PitInTime and PitOutTime both null |
1314 |
| no first lap | LapNumber != 1 |
1293 |
| green flag | TrackStatus == '1' |
1231 |
| within 107% | lap time <= 1.07 × driver median | 1231 |
Notes:
Compound == 'None'is a literal string, not a null, and it coincides exactly with the 25 rows whereTyreLifeis missing. One filter handles both.TrackStatusconcatenates codes when the status changes mid-lap, producing values like'12'or'2671'. Matching the exact string'1'keeps only laps run entirely under green. A lap where a VSC starts halfway is dropped whole — a deliberate trade of sample size for consistency.- No safety car in this race: code
4never appears. Codes6and7(VSC) account for most of the 84 non-green laps. - The standing start is not flagged as a pit out, so
LapNumber != 1is needed as its own step. - The 107% cut removed zero laps. After the earlier filters the widest deviation from a driver's own median was +4.4%, so the threshold was already satisfied. The step is kept because it would bind on a race with more disruption.
- FastF1's own
IsAccurateflag marks 1295 laps. This set is stricter (1231), as expected:IsAccuratedoes not exclude VSC laps.
groupby('Driver')['LapTimeSec'].agg(['min', 'median', 'count']), sorted by
median. The count column matters: BOT (12 laps) and PER (19 laps) retired
early and their medians are not comparable to drivers with ~60 laps. They are
excluded from any interpretation.
A per-compound pivot (pivot_table over Driver × Compound) shows
inconsistent signs — SOFT is faster than HARD for GAS (−2.0 s) but slower than
MEDIUM for ALO (+1.9 s). This is fuel load, not rubber: the same car is roughly
a second slower on a full tank, so whoever ran a compound late in the race gets
a flattering median. The pivot is reported to motivate the per-stint approach
below, not as a measurement of compound pace.
Two linear fits:
- Pooled — one line per compound across all laps: +0.004 / +0.022 / +0.048 s/lap for SOFT / MEDIUM / HARD.
- Per stint — one line per
(Driver, Stint)with at least 6 laps, then the median across the 65 stints: +0.009 / +0.028 / +0.042.
The per-stint version is the one to trust. Pooling mixes drivers whose baseline pace differs by up to 5 s, so the pooled line partly fits the gap between cars rather than the wear within a stint. The scatter plot shows this directly: at any fixed tyre age the vertical spread is 6–10 s, while the effect being measured is under 1 s across a whole stint.
Refitting on DeltaToMedian (lap time minus that driver's median) returns
identical slopes, confirming that removing a per-group constant shifts the
intercept only.
- Stint context is not random. HARD stints average 21 laps (max 43), SOFT 17. The 18 laps run past 35 laps of tyre life all belong to two midfield drivers (BOR, LIN) on a one-stop, whose own medians are 87.1 and 86.7 s. Their pace, not their rubber, lifts the HARD slope.
- SOFT is noisy. Slope std is 0.117 s/lap against a median of 0.009 — an order of magnitude larger than the effect, ranging from −0.31 to +0.25 across stints. HARD is far more consistent (std 0.044, median 0.042). Raising the minimum stint length from 6 to 10 laps drops two SOFT stints and brings the std to 0.087, still nine times the median, so the spread is a property of the compound rather than an artefact of short samples.
- Fuel burn cannot be the explanation. It is close to constant per lap, so it is subtracted from all three compounds alike: it lowers every slope by the same amount and leaves their order intact. It explains why nine of the 65 stints come out negative, not why HARD sits above SOFT.
The cleaned laps are exported to PostgreSQL (table laps) and the aggregation
steps are reimplemented as queries in queries.sql.
The pandas and SQL idioms map onto each other directly:
| pandas | PostgreSQL |
|---|---|
groupby('Driver')['LapTimeSec'].agg(...) |
GROUP BY driver with aggregates |
groupby('Driver')[...].transform('median') |
CTE with percentile_cont + JOIN (Q3) |
groupby('Driver')[...].transform('mean') |
avg(...) OVER (PARTITION BY driver) (Q7) |
pivot_table(index=, columns=, aggfunc='median') |
GROUP BY + percentile_cont(...) FILTER (WHERE ...) |
The middle rows are the ones worth internalising. transform and a window
function do the same thing: aggregate within a group, then broadcast the result
back onto every row instead of collapsing the group. PARTITION BY is the
groupby, OVER is the transform — but only for plain aggregates.
PostgreSQL refuses OVER on ordered-set aggregates (percentile_cont,
percentile_disc, mode), so a median broadcast has to be assembled by hand
from a CTE and a join, while a mean broadcast is a one-liner.
- No fuel correction. Every slope is wear minus fuel burn, i.e. a lower bound on true degradation.
- No track temperature. Merging
session.weather_databy nearest timestamp (pd.merge_asof) would control for this; out of scope here. - Compounds are relative labels, not fixed rubber. Pirelli allocates three of the C0–C6 range per event, so "SOFT" here is not comparable to "SOFT" at another track or in another season.
- Linear fit only. A stint's wear curve is typically flat then steep, so a straight line understates late-stint loss by construction.
- One race, and one where overtaking is rare. Nothing here generalises to other circuits.
Requires Python 3.10 or newer (FastF1 3.8).
# macOS / Linux
python3 -m venv .venv
source .venv/bin/activate
# Windows
py -m venv .venv
.venv\Scripts\activatepip install -r requirements.txt
export F1_DB_URL=postgresql://user@localhost:5432/f1
python analysis.py
psql "$F1_DB_URL" -f queries.sqlThe URL carries no +psycopg2 dialect prefix: SQLAlchemy defaults to psycopg2
for postgresql://, while libpq — which psql uses — does not understand the
prefixed form, so one variable serves both. If the server requires password
authentication, put the credentials in ~/.pgpass (mode 600) rather than in
the URL; psycopg2 goes through libpq and picks them up too.
The first run downloads session data (~1 min); later runs read from cache/,
which the script creates on startup — Cache.enable_cache raises
NotADirectoryError if the directory is missing.
If F1_DB_URL is unset the script skips the database and writes
clean_laps.csv instead, so the analysis still runs end to end without
PostgreSQL.
| File | Purpose |
|---|---|
analysis.py |
Full pipeline: load, clean, aggregate, fit, export |
queries.sql |
The same aggregations in PostgreSQL, plus window functions |
degradation.png |
Lap time vs tyre life by compound |
requirements.txt |
Pinned dependencies |
