Skip to content

About

F1 analytics that decomposes lap times to explain why a car is off the pace, powered by telemetry, dbt, DuckDB, and machine learning.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Off The Pace

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.

Python dbt DuckDB XGBoost → ONNX React + TypeScript GCP Terraform CI/CD License

▶ Launch the app · Read the docs

Off The Pace: browser-native F1 analytics dashboard decomposing every lap into named causes

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


Three things to notice

1. The question, as the thesis

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.

2. A CI-enforced mathematical invariant

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.0001

If it fails, the build fails. An enforced invariant is worth more than a claimed one.

3. Out-of-sample validation + limitations

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.


Architecture

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"]
Loading

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.


Choose your path

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)


Quickstart

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_identity

No cloud credentials required. DuckDB runs locally at data/dev.duckdb.

Running the Frontend React App & Docs

If you want to run the React app or the documentation site locally, you'll also need Node.js (v18+) and pnpm installed:

  1. Install dependencies:

    make docs-install    # install docs dependencies
    make app-install     # install React app dependencies
  2. 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-models requires models to be trained first via make ml-setup && make ml-all)

  3. 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. Set VITE_DATA_BASE="" to point the app at your local app/public/data/ export instead, so you can develop and test entirely offline with no dependency on the bucket.

  4. Publish data to the CDN (when data changes, maintainers only): make app-data only writes to app/public/data/ on disk; nothing reaches the live site until synced to the bucket (requires gcloud with 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 version so a data change is never masked by stale CDN/browser caching (see ADR-010).


Project status

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.


Machine Learning

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 curve

Full 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).


App features

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

Docs checked against the code

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.


Stack

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

Repo layout

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

Documentation map

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

About

F1 analytics that decomposes lap times to explain why a car is off the pace, powered by telemetry, dbt, DuckDB, and machine learning.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages