When a car is off the pace, why?
A browser-native, full-stack F1 analytics platform. It ingests 8 seasons of telemetry, models it through a 94-table dbt warehouse, trains 5 ML models, and serves 31 interactive analytics features with no server, no login, and no cost to serve.
Under the hood, every lap is decomposed into five additive, physically-grounded components, so lost time is attributed to an exact, named cause rather than a vibe.
▶ Launch the app · Read the docs
161,040 laps decomposed · 173 races · 43 drivers · 44 circuits · 94 dbt models · 1320 tests · 5/5 ML models beat baseline · 31 browser features · 0 servers
Most F1 analytics tells you who is slow. This project asks why. Each lap is decomposed into:
lap_time = base_track_pace + fuel + compound + constructor + dirty_air + driver_skill
The base is the field's own pace on that lap with fuel and tyre cost taken out, so track rubber and temperature are already inside it (they are reported, not subtracted again). The driver skill residual is what remains after every measurable physical factor is removed: the part that actually belongs to the human.
The five terms close the lap exactly. This isn't a stated property: it's tested on every lap in CI:
-- transform/macros/assert_additive_identity.sql
select count(*) from {{ model }}
where abs(pace_delta_s-(
fuel_component_s + compound_component_s + constructor_component_s +
dirty_air_tax_s + driver_skill_residual_s
)) > 0.0001If it fails, the build fails. An enforced invariant is worth more than a claimed one.
Trained on 2018–2025. The 2026 season is the designated holdout: evaluation switches to a true out-of-sample reveal as its races are ingested, with no code change. Until then the headline is the final time-series CV fold (2025), which was also used to admit features, so it is reported as a validation fold rather than a clean holdout. Paired with an explicit limitations section.
flowchart LR
A["FastF1 + OpenF1"] --> B["Bronze<br/>Hive-partitioned Parquet<br/>8 seasons · 173 races"]
B --> C["Transform<br/>dbt + DuckDB<br/>94 models · 1320 tests"]
C --> D["ML<br/>5 XGBoost models<br/>→ ONNX, 39 features"]
C --> E["GCS CDN<br/>parquet + models"]
D --> E
E --> F["Browser App<br/>React · DuckDB-Wasm · ONNX Runtime<br/>31 features · zero server"]
F --> G["Firebase Hosting"]
Bronze Parquet → a dbt/DuckDB warehouse → XGBoost models exported to ONNX → published to a GCS CDN → a React app that runs DuckDB-Wasm and ONNX Runtime Web in the browser. No backend computes anything at request time: the client downloads the data and models and does the work locally, which is why serving is free.
I want to understand the idea → offthepace.mintlify.app
I want to explore the app → off-the-pace.web.app
I want to run it → make setup && make dbt-dev (see Quickstart below)
Requires Python 3.11+.
git clone https://github.com/justinclarke/off-the-pace
cd off-the-pace
make setup # build venv + install Python/dbt deps
make dbt-dev # build the transform layer (94 models)
make dbt-test # run 1320 tests including assert_additive_identityNo cloud credentials required. DuckDB runs locally at data/dev.duckdb.
If you want to run the React app or the documentation site locally, you'll also need Node.js (v18+) and pnpm installed:
-
Install dependencies:
make docs-install # install docs dependencies make app-install # install React app dependencies
-
Generate data and models for the app: The React app runs entirely in the browser using DuckDB-Wasm and ONNX. Before running the app, export the warehouse data and ML models:
make app-data # export DuckDB warehouse data to app/public/data/ make app-models # copy trained ONNX models to app/public/models/
(Note:
make app-modelsrequires models to be trained first viamake ml-setup && make ml-all) -
Start the development servers:
- For the Docs site:
make docs-site(runs Mintlify at http://localhost:3000) - For the React app (offline/local data):
VITE_DATA_BASE="" make app-dev - For the React app (live CDN data):
make app-dev
Running offline: By default the app fetches parquet and models from the live CDN (
gs://off-the-pace-cdn). No credentials required to read, but you will see whatever data is currently published there. SetVITE_DATA_BASE=""to point the app at your localapp/public/data/export instead, so you can develop and test entirely offline with no dependency on the bucket. - For the Docs site:
-
Publish data to the CDN (when data changes, maintainers only):
make app-dataonly writes toapp/public/data/on disk; nothing reaches the live site until synced to the bucket (requiresgcloudwith write access):make app-publish-dry # preview the sync (uploads nothing) make app-publish # sync app/public/data/ + models → CDN (no delete; manifest last) make app-deploy # convenience: app-data then app-publish
Parquet URLs are cache-busted by the manifest
versionso a data change is never masked by stale CDN/browser caching (see ADR-010).
| Subsystem | State | Evidence |
|---|---|---|
| Ingestion (Bronze) | ✅ Built | ingestion/src/: FastF1 + OpenF1 → Hive-partitioned Parquet, 8 seasons / 173 races |
| Transform (94 models, 1320 tests) | ✅ Built | transform/models/: schema.yml and singular tests; additive identity enforced in CI |
| Coefficients (KM tyre cliff) | ✅ Fitted | transform/tasks/coefficients/: seeds |
| ML (5 XGBoost models, 270 tests) | ✅ Built | ml/: degradation quantile trio + cliff classifier + stint-life survival (AFT); ONNX parity; v14 models on a 39-column contract (the cliff classifier reads all 39 features; the other four mask the 7 qualifying columns) |
| Frontend (React + DuckDB-Wasm) | ✅ Built | app/: 31 interactive features, zero server, sub-10ms queries; deployed to Firebase Hosting |
| Docs | ✅ Built | docs/: Mintlify site with 5 tabs (Start Here, Data, Transform, Machine Learning, App) |
| Platform | ✅ Built | CI/CD, security scanning, observability (Sentry), E2E tests (Playwright), IaC (Terraform) |
| Streaming (Microsoft Fabric) | ❌ Planned | Streaming Integration |
The engine is built and deployed. The current focus is the live watch-along experience: in-race strategy predictions served from the same offline-trained models.
Five XGBoost models score every lap from the feature mart fct_cliff_prediction_features:
- Degradation quantile trio (
p10/p50/p90) next-lap pace loss with a calibrated interval (empirical coverage 0.80 at nominal 0.80). - Cliff classifier laps-until-cliff bucket (
0_to_2/3_to_5/6_plus/none_in_stint). - Stint-life regressor remaining laps of usable life.
Every model beats a strong per-cohort baseline on the headline metric (season-grouped TimeSeriesSplit; the 2025 fold stands in as a holdout until 2026 ingests) and every one of those claims carries an interval the effective sample is ~7,200 stints, not ~95,500 laps, so a lap-grain point estimate overstates certainty. Headlines are also reported as a fraction of what is attainable rather than of 1.0, which is not a score anything could reach on this data. Each booster round-trips to ONNX within atol=1e-5 for in-browser scoring. The leakage spine (no forward-looking features, driver_id/race_year excluded) is enforced by tests and CI.
Reproduce end-to-end (one venv, warehouse read-only, nothing written to app/):
make ml-setup # install ml/requirements.txt
make ml-all # features → tune → train → evaluate → predict → onnx → card → docs
make ml-test # 270 tests: leakage spine, ONNX parity, survival/AFT contract, version contract, output schema, beats-baseline, attainable ceilings + intervals, power-law tyre curveFull auto-generated model card (metrics, baselines, calibration, dual feature importance, limitations) is built from ml/model_card.yml.
Label spine version bump (WI-01 + WI-12, 2026-09-27): the label is now built from five
additive terms, not seven. The field pace base is neutral to fuel and compound (not fuel
alone), and rubber/ambient live only inside the base instead of being subtracted a second time.
theta_air (dirty-air tax) is 0.331 s/lap: the within-stint estimate (stint fixed effects plus
tyre-age bins), fitted with tyre cost subtracted on a declared season window and frozen as a
declared value. Event-driven laps (SC/VSC/red-flag/restart) and the lap after an SC or red-flag
restart are excluded from training. WI-01 and WI-12 together are one version bump: WI-01
first carried a pooled theta_air of 0.503 s/lap, WI-12's re-measure showed that the pooled
slope bills some car pace as dirty air, and the ruling (W40) set 0.331. Both values were
interim states of the same bump. The final label, the one a v14 configuration is rebuilt on
for comparison, carries 0.331, and nothing measured on the 0.503 state is kept. The published
v14 artefacts were fit on the pre-bump label, so no pre-bump number is comparable to a
post-bump one at fixed target. A new headline is compared only against a v14 configuration
rebuilt on the new label (see ml/README.md, _roadmap/_fixes/wi/WI-01-label-spine.md and
_roadmap/_fixes/wi/WI-12-06b-remeasure.md).
The app runs entirely in the browser: DuckDB-Wasm for sub-10ms SQL and ONNX Runtime Web for inference. No server, no login, no cost to serve. Thirty-one features across nine analytical families, plus the Query Lab SQL console:
| Family | Features |
|---|---|
| Ghost Car | Ghost Race Standings, Counterfactual Championship, Hidden Performance |
| Lap Decomposition | Lap Waterfall, Race Lost, Sector Decomposition |
| Tyre & Strategy | Tyre Cliff Survival, Stint Degradation Timeline, Pit Strategy, Tyre Recovery Forecast, Party Mode |
| Aero & Conditions | Dirty Air Cost, Dirty Air Lap Map, Track Evolution, Field Pace Curve |
| Race Craft | Race Control |
| Drivers | Era Ratings Timeline, Driver Consistency, Quali vs Race Skill, Driver Circuit Affinity, Era Translator, Wet Race Specialist, Driver Workload, Synthetic Teammate, Corner Phase Skill |
| Constructors | Constructor Structural Pace, Constructor Circuit Interaction |
| The Machine | Degradation Simulator, Model Metrics, Blind Test Scoreboard |
| Data & Validation | Query Lab, Data Quality Audit |
The in-app assistant reads short AI-facing summaries of the docs, while readers see the full Mintlify pages. A drift check (app/scripts/docs-drift/docsDrift.mjs) compares the two for the docs/app pages: it fails when a tagged <Warning>, <Note> or <Tip> goes missing from a summary, when a summary goes over its token budget, or when a number in a summary is not on its source page (untagged prose, such as a "Reading the signal correctly" section, is not compared). It also checks stated facts against the code, but only on the pages listed in its claims file (docs-claims.json: 42 of the 247 pages under docs/, plus this README), not on every page. Value claims bind a number to its label on the same line and compare it with the code (dbt model and test counts, feature counts, input width, config values, seed windows, nav coverage); prose claims are judged by a reviewer agent. A page is recorded as reviewed only when every claim on it has passed at its current content, and a changed page is reported as needing review. CI runs the value claims and the summary freshness check; the reviewer is run by hand. Its tests run without touching the real pages.
| Layer | Tech |
|---|---|
| Ingestion | FastF1 + OpenF1 → Hive-partitioned Parquet |
| Transform | dbt-core (DuckDB local, 94 models, 1320 tests) |
| ML | XGBoost (degradation quantile trio, cliff classifier, remaining life) → ONNX v14 (39 features) |
| Frontend | React + DuckDB-Wasm (sub-10ms queries, zero compute cost) |
| Hosting | Firebase Hosting (frontend) + GCS CDN gs://off-the-pace-cdn (data + models) |
| Docs | Mintlify (offthepace.mintlify.app) |
| Platform | GitHub Actions CI/CD · Sentry observability · Playwright E2E · Terraform IaC |
| Folder | Contents |
|---|---|
ingestion/ |
FastF1 + OpenF1 pulls → Bronze Parquet |
transform/ |
dbt project, from staging through feature marts |
ml/ |
Machine learning layer: XGBoost models, ONNX export, model card |
app/ |
React + DuckDB-Wasm frontend; 31 interactive features |
docs/ |
Mintlify docs site |
infra/ |
Terraform modules (GCS, WIF, billing budget) |
scripts/ |
Reference doc generators, CI tooling |
| Document | Purpose |
|---|---|
| README.md | Start here: thesis, status, quickstart, stack |
| offthepace.mintlify.app | Full docs: Start Here · Data · Transform · Machine Learning · App tabs |
| .github/CONTRIBUTING.md | How to contribute; pipeline overview for new contributors |
Built by Justin Clarke · Licensed under AGPL-3.0
