🏁 F1 Podigami
A scorigami-style predictor for Formula 1 podiums — which trio of drivers will share a podium for the very first time next?
Turns 76 years of F1 race data (1950–2026) into a fast, framework-free static site.
No server. No database. No JavaScript framework. Just Python, one requests dependency, and vanilla JS.
🔮 Live demo →
✨ Features
- 🔮 Podigami predictor — ranks the never-before podium trios most likely to debut next, with a scorigami-style "% chance the next race is brand-new".
- 👋 First-visit welcome — a small card points new visitors to the FAQ, once, on whichever page they land on.
- 🧮 Backtested model — the algorithm was chosen by data, not guesswork (see the model).
- 🤝 Shared drives — until 1960 two drivers could share one car and both be classified, so those 18 races put more than one trio on the podium at once; every trio they produced counts, and the combinations table marks them.
- 🏁 Next race + last race — when and where the upcoming GP runs, in your local time, plus a compact recap of the last race's podium trio with its podigami status and linked history.
- 🗓️ Season timeline — drag a year slider to see every trio that debuted on a podium in each season since 1950.
- ⏳ Overdue trios — driver pairs whose individual form says they should share a podium, but haven't yet.
- 🎲 Unlikeliest podiums — the mirror of Overdue: trios that did happen despite the odds, ranked by how big a fluke they were.
- 🤝 Soulmates — the shared-podium matrix showing which drivers have stood on the box together most.
- 📋 Prediction scorecard — every prediction as it was published, frozen at lights out and scored against the real podium.
- 🔗 Cited sources — every race links to its official Formula 1 result page, with a Wikipedia fallback.
- 📊 Combinations table — every unique 3-driver podium combination since 1950, sortable and filterable by driver or by season range (behind a slide-out filter panel on phones).
- 📱 Mobile-first — fully responsive, dark theme, locked in by CSS regression tests.
- ⚙️ Zero-ops deploy — rebuilds from committed JSON in CI; a guarded 15-minute poll auto-opens and merges a data-refresh PR after each race, which ships straight to GitHub Pages.
🏎️ Pages
| Page | What it shows |
|---|
| 🔮 | index.html | The Podigami predictor — next/last race (with the upcoming qualifying time), most likely brand-new trio and a trio board ranking every trio in play for the next race — already-happened ones muted with their history on hover/tap, never-happened ones highlighted — all of which refresh after qualifying using the starting grid (with a collapsible current-form tower), season debut timeline (with quick-pick season chips), FAQ, plus live-stat discovery hooks and a "Keep exploring" grid routing to every other page |
| 🧩 | combos.html | Every unique three-driver combination that has shared a podium since 1950 — sortable, filterable by driver and season range (on mobile the filters fold into a slide-out panel), expandable (on phones each trio collapses to one row — its three drivers stacked one per line beside the podium count — with its detail on tap), with shared-drive podiums marked |
| ⏳ | overdue.html | Trios "overdue" to appear — driver pairs whose current form suggests a shared podium is imminent |
| 🎲 | unlikeliest.html | The mirror of Overdue — podium trios that did happen ranked by how statistically improbable they were, led by the single biggest fluke in F1 history |
| 🤝 | soulmates.html | Shared-podium matrix — which drivers have stood on the box together most often |
| 📋 | scorecard.html | How the predictions held up — each race's published prediction against its real podium, with the totals |
🔮 How the predictor works
A podigami = a 3-driver podium set that has never finished a podium together before.
The predictor is a dynamic Bayesian rating engine (src/compute/model_v2.py):
- Pace — every driver and every car carries a Gaussian rating (mean + uncertainty) over its log-worth. Each qualifying session and race classification since 1950 updates both with a closed-form truncated Plackett–Luce step; team lineage carries ratings across rebrands (Toro Rosso → AlphaTauri → RB, Jordan → … → Aston Martin).
- Time — ratings diffuse a little every race, more over a winter, a lot for cars when the technical regulations reset (2009, 2014, 2022, 2026…).
- Survival — exponentially-decayed DNF hazards, era-relative: mechanical failures charge the car, incidents charge the driver.
- Chaos — each circuit's grid→finish shuffle and DNF propensity adjust the prediction temperature and finish odds.
- Grid — once a race's qualifying is classified, the grid order feeds back through the ratings and a circuit-modulated track-position term shifts each driver's finishing odds — amplified at processional circuits where the grid rarely reshuffles, damped where it does — so the headline refreshes after qualifying. The starting slots for this term are F1's official starting grid — penalties applied, drivers without a lap time included — picked up automatically a few hours after qualifying and re-checked by the race-day watch in the hours before the start, so race-morning changes such as pit-lane starts count too; until it is published, hand-curated penalties in
data/grid_penalties.json rebuild the grid from the qualifying order. The qualifying order itself still counts at face value — a penalised driver demonstrated that pace regardless of where they start. Mid-race retirements can be recorded by hand (data/retirements.json), taking a crashed or broken-down car out of the running field so the headline can be refreshed while the race is live.
- Prediction — the engine simulates the next race (deterministic seed): skill noise + who survives, with exact conditional Plackett–Luce trio probabilities per draw.
P(next race is new) is the exact complement of every already-seen trio.
Why this model? Walk-forward evaluation — tuned on 2010–2018 only, then scored once on a frozen 2019–2026 test window (165 races) it never saw:
| model | top-1 | top-3 | top-5 | log-loss |
|---|
| recency Plackett–Luce (previous) | 12.1% | 29.1% | 40.6% | 4.101 |
| rating engine (live, pre-qualifying) | 17.0% | 35.8% | 44.2% | 3.878 |
| + qualifying order (post-qualifying) | 19.4% | 36.4% | 48.5% | 3.706 |
| + grid position (post-qualifying) | 24.2% | 39.4% | 50.3% | 3.434 |
One knob is not blind to the hold-out. The driver-rating prior sigma0_drv was widened
from 0.5 to 1.0 in Sept 2026 because at 0.5 the driver term carried only ~5% of a car+driver
strength, so a driver could not overhaul a team-mate within a season however decisively he beat
him. It wins both headline scores on the test window and on each of its halves separately, but
the 2010–2018 tuner still prefers 0.5 — that window is peak car hegemony. It was therefore chosen
with knowledge of post-2018 results, so 2019+ is no longer a clean out-of-sample estimate for that
one knob; the next genuinely frozen check needs seasons after 2026. Every other knob is
validation-tuned as described.
Ablations: pace + reliability alone already beat v1; adding the qualifying order gave the biggest jump; a failure-order (attrition) channel earned zero weight and is off, as does an adaptive car-diffusion channel that lets a constructor's rating loosen when its form keeps shifting the same way — it won the tuning window and lost the hold-out, so it ships dormant. Once a race's qualifying is in, replaying the grid order through the ratings plus a circuit-aware grid-position term (gate-accepted on the same hold-out) lifts top-3 to 39.4% and log-loss to 3.434. Full ladder in data/model_eval.json.
🧱 Architecture
A three-stage pipeline: fetch → compute → build. Each stage reads committed JSON and writes the next.
flowchart TD
classDef fetch fill:#15151E,stroke:#e10600,color:#fff
classDef compute fill:#1f2630,stroke:#8b949e,color:#fff
classDef build fill:#262d38,stroke:#f4c430,color:#fff
classDef page fill:#0b0d12,stroke:#e10600,color:#fff
classDef json fill:#161b22,stroke:#58a6ff,color:#8b949e
API(("Jolpica F1 API"))
OF(("OpenF1 API"))
subgraph FETCH ["① Fetch → data/*.json"]
direction LR
FP["fetch_podiums"]:::fetch
FR["fetch_race_results"]:::fetch
FQ["fetch_qualifying"]:::fetch
FG["fetch_current_drivers"]:::fetch
FS["fetch_schedule"]:::fetch
FO["fetch_openf1"]:::fetch
FSG["fetch_starting_grid"]:::fetch
end
API -.-> FETCH
OF -.-> FO
OF -.-> FSG
subgraph COMPUTE ["② Compute → data/*.json"]
direction LR
CC["count_combos"]:::compute
CP["compute_podigami"]:::compute
CO["compute_overdue"]:::compute
CU["compute_unlikeliest"]:::compute
CM["compute_soulmates"]:::compute
BT["backtest"]:::compute
CS["compute_scorecard"]:::compute
end
FETCH ==> COMPUTE
subgraph BUILD ["③ Build → dist/"]
direction LR
BP["index.html"]:::build
BC["combos.html"]:::build
BO["overdue.html"]:::build
BU["unlikeliest.html"]:::build
BS["soulmates.html"]:::build
BSC["scorecard.html"]:::build
end
COMPUTE ==> BUILD
BUILD ==> GH["GitHub Pages"]:::page
All access to data/*.json goes through src/datalib/ — Pydantic v2 schemas that validate every read and write. load_* returns typed model objects; save_* validates the payload and writes its canonical schema form — byte-identical to a load/dump round-trip, so regenerating never reformats committed data.
src/
fetch/ API fetchers → data/*.json
compute/ aggregation / modelling → data/*.json
build/ page renderers → dist/*.html
datalib/ Pydantic schemas + typed load/save (the data contract)
build_site.py render all pages + copy assets → dist/
update.py refresh data, then build
assets/ source CSS + JS (copied into dist/ at build time)
.claude/ Claude Code rules, commit hook and helper scripts
data/ committed JSON datasets the site builds from
dist/ generated, deployable site (git-ignored)
tests/ pytest suite (1341 tests, run in CI)
🚀 Quick start
# 1 · create + activate a virtual environment
python -m venv .venv
.venv\Scripts\activate # Windows
source .venv/bin/activate # macOS / Linux
# 2 · install dependencies
pip install -r requirements.txt
# 3 · build the site from the committed data (no network needed)
python src/build_site.py
# → open dist/index.html
🛠️ Usage
| Command | When | What it does |
|---|
python src/build_site.py | Anytime (offline) | Re-render all pages from committed data → dist/ |
python src/update.py | After each race | Incrementally fetch new podiums, classifications, qualifying + grid, recompute, rebuild |
python src/update.py --full | First run / full rebuild | Re-fetch the entire race history from 1950 (slow), then rebuild |
PYTHONPATH=src python -m datalib.validate | After editing data | Validate every dataset against its Pydantic schema |
🧪 Tests & CI
pip install -r requirements-dev.txt # tooling: ruff, pytest-cov, pip-audit
ruff check . && ruff format --check . # lint + format
pytest --cov # 1341 tests + coverage gate (≥70%)
The suite covers pure helpers, cross-dataset integrity (combos derive from podiums, podigami
from combos + grid…), build determinism & link resolution, mobile-CSS regressions, schema
fidelity (byte-identical load/save round-trips), and the prediction model (including edge cases
and backtesting).
Every push and PR runs a hardened pipeline:
| Workflow | What it enforces |
|---|
ci.yml | Lint & format (Ruff) · tests on Python 3.11 / 3.12 / 3.13 with a coverage gate · build + offline link-check of the generated HTML |
codeql.yml | CodeQL static analysis of Python and the workflow files (weekly + on PRs) |
security.yml | pip-audit for vulnerable dependencies · gitleaks secret scanning |
deploy.yml | Test-gated publish to GitHub Pages |
update.yml | Guarded data refresh — arms 3 h before every race and qualifying session (the post-qualifying prediction update included), then waits for F1's official starting grid and re-checks it on race morning, so a run is already waiting when results appear. It polls upstream in-run for up to 5 h and hands over to a fresh run if the results still aren't out, then opens an auto-merging PR; a weekly run forces a full reconciliation. A watchdog job checks that PR on every tick and raises an alert issue if it has sat unmerged for 45 min (failing check, conflict, auto-merge off); the issue stays quiet while the failure is unchanged and closes itself once no data PR is left open, so each new incident notifies afresh. A data update that changes an already-published podium is flagged — the PR is retitled and labelled podium-revised and an issue opens — without blocking the merge. |
| + |
All workflows run with least-privilege permissions, concurrency cancellation, and pip
caching. Quality settings live in pyproject.toml.
☁️ Deployment
.github/workflows/deploy.yml builds dist/ and publishes it to
GitHub Pages on every push to main — but only if the test suite passes first. A failing
test fails the build job, so the deploy step is skipped and the live site stays on the last good build.
flowchart LR
classDef ok fill:#1f2630,stroke:#2ea043,color:#fff
classDef bad fill:#1f2630,stroke:#e10600,color:#fff
classDef gate fill:#262d38,stroke:#f4c430,color:#fff
classDef ci fill:#161b22,stroke:#58a6ff,color:#8b949e
P[push to main] --> L[lint + format]:::ci --> T{tests<br/>py3.11–3.13}:::gate
T -- pass --> V[validate schemas]:::ci --> B[build dist/]:::ok --> D[(GitHub Pages)]:::ok
T -- fail --> X[deploy skipped<br/>site unchanged]:::bad
One-time setup: Settings → Pages → Build and deployment → Source: GitHub Actions.
🗂️ File map
| Script | Role |
|---|
| Fetch | |
src/fetch/fetch_podiums.py | Fetch P1/P2/P3 for every race → data/podiums.json |
src/fetch/fetch_race_results.py | Fetch full race classifications (grid, laps, status) → data/race_results.json |
src/fetch/fetch_qualifying.py | Fetch qualifying session order (1994→) → data/qualifying.json |
src/fetch/fetch_current_drivers.py | Fetch the current racing grid → data/current_drivers.json |
src/fetch/fetch_schedule.py | Fetch race calendar + circuit track outlines → data/schedule.json |
src/fetch/fetch_constructor_standings.py | Fetch constructor championship standings → data/constructor_standings.json |
src/fetch/fetch_driver_races.py | Fetch per-driver race participation → data/driver_races.json |
src/fetch/fetch_openf1.py | OpenF1 fast lane: fill the newest race/qualifying before Jolpica publishes |
src/fetch/fetch_starting_grid.py | F1's official starting grid (penalties applied) for the next race, via OpenF1 → data/starting_grids.json |
src/fetch/api_cache.py | Cache-buster for freshness-critical API reads (see below) |
src/fetch/track_geo.py | Resolve circuit SVG track paths from geo data |
| Compute | |
src/compute/count_combos.py | Aggregate podiums into unique trios → data/combos.json |
src/compute/shared_drives.py | Expand a shared-drive podium (one car, two classified drivers) into every real trio |
src/compute/compute_podigami.py | 🔮 Predict the next brand-new trio → data/podigami.json |
src/compute/compute_overdue.py | Find trios overdue based on driver form → data/overdue.json |
|
📡 Data source
All race data comes from the Jolpica F1 API — an Ergast-compatible
endpoint, no API key required. Race reports link to the official Formula 1 result pages (with a Wikipedia fallback for any race not yet mapped).
The newest race and qualifying session come first from OpenF1: at the one race measured so far (the 2026 Spanish GP) it had the classification about three hours after the start, nearly four hours before Jolpica. OpenF1's rows are written in Jolpica's exact format, held back while the stewards could still change the podium, and replaced by Jolpica's as soon as it publishes (a changed podium raises an alert). All history comes from Jolpica. The post-qualifying prediction's starting grid is F1's official one, from OpenF1's mirror of formula1.com's starting-grid page.