Motorsport simulation, strategy and telemetry platform.
A quasi-steady-state lap-time solver drives a tyre degradation model, an exact strategy optimiser and a vectorised Monte Carlo race engine. Every number the interface displays is traceable to the model that produced it.
DATA → MODEL → SIMULATION → STRATEGY → VISUALISATION
Race dashboard — circuit map coloured by solved speed, live timing tower, tyre strategy timeline and race pace, all from one seeded simulation.
An open engineering demonstration of the modelling chain behind race strategy: how circuit geometry becomes corner speeds, how corner speeds become lap times, how lap times plus a tyre model become a strategy, and how a strategy plus uncertainty becomes a probability distribution.
- It does not reproduce any Formula 1 team's internal simulator.
- It has no access to proprietary telemetry or tyre data.
- The drivers and constructors are fictional.
- Circuit geometry is an approximation authored from public layout information, not a survey.
Every API response carries a provenance label — PUBLIC, MODEL_OUTPUT,
SIMULATED or ESTIMATED — and the interface displays it. See
Data honesty.
Two processes. No database to provision, no API keys, no external services.
cd backend
python -m venv .venv
.venv/Scripts/activate # Windows
# source .venv/bin/activate # macOS / Linux
pip install -r requirements-dev.txt
uvicorn app.main:app --port 8000The API is then on http://127.0.0.1:8000, with interactive docs at
/docs. On startup it integrates all five circuits, solves their reference
laps, and warms the per-driver pace cache on a background thread (about one
second total).
cd frontend
npm install
npm run devOpen http://localhost:3000. Next.js proxies /api/* to the backend, so the
browser makes same-origin requests and there is no CORS to configure.
cd backend
.venv/Scripts/python -m pytest # 164 testscd backend
python -m scripts.calibrate # residuals against published lap records
python -m scripts.calibrate --fit # refit the global grip calibrationThe platform is built around one continuous analysis path:
- Select a circuit — geometry is integrated from a curvature spine.
- Explore the map — eight visualisation modes over one solved lap.
- Compare two drivers — synchronised telemetry with a shared cursor.
- Inspect tyre degradation — with every term of the model broken out.
- Optimise a strategy — exact dynamic programming over every legal plan.
- Run 10,000 races — vectorised Monte Carlo, about two seconds.
- Stress-test it — re-optimise and re-simulate under perturbations.
- Replay a race — lap by lap, with the field animating around the circuit.
- Ask the race engineer — answers cite the computed values they rest on.
Speed, throttle, brake, gear, RPM and lateral load all derive from one solved speed profile, so the channels are mutually consistent by construction rather than drawn independently. Move the cursor anywhere — on a trace or on the map — and every panel follows: distance, corner name, sector, and the delta at that exact point.
Race time decomposes so strategies compare exactly on tyre cost plus pit loss. Optimal stint splits are solved by dynamic programming — the true optimum of the deterministic model, not the best of a sampled set. The what-if panel then re-optimises and re-simulates under perturbed assumptions, because a plan that is 0.4 s faster but collapses under a safety car is not the better plan.
Ask it whether to pit and it answers from an exhaustive evaluation of every
remaining stop lap, quoting the degradation rate, the pit cost and both branch
totals. Every figure it reports appears in the citations list and in the
inspectable context below the answer, because it is never asked to
calculate — only to phrase what the models already computed.
The position tape under the map is every driver's race position across every lap. A strategy that worked shows up as a line climbing through the field.
race-intelligence/
├── backend/
│ ├── app/
│ │ ├── models/ physical models (the engineering core)
│ │ │ ├── parameters.py every constant, in one place
│ │ │ ├── track.py curvature spine → integrated geometry
│ │ │ ├── vehicle.py point-mass forces, friction ellipse
│ │ │ ├── laptime.py quasi-steady-state solver
│ │ │ └── tyre.py degradation model
│ │ ├── simulation/ race engines
│ │ │ ├── race_model.py shared pace model
│ │ │ ├── race_engine.py single race, event-recording
│ │ │ ├── monte_carlo.py vectorised ensemble
│ │ │ └── jobs.py background job registry
│ │ ├── strategy/ exact optimiser + what-if
│ │ ├── telemetry/ synthetic channel generation
│ │ ├── engineer/ AI race engineer
│ │ ├── data/ circuits, entrants, provider abstraction
│ │ ├── schemas/ Pydantic contracts
│ │ ├── db/ SQLAlchemy schema
│ │ └── api/routes/ FastAPI endpoints
│ ├── scripts/calibrate.py
│ └── tests/
├── frontend/
│ ├── app/ Next.js App Router pages
│ ├── components/
│ │ ├── circuit/ interactive track map
│ │ ├── charts/ hand-built SVG chart primitives
│ │ ├── race/ timing tower, strategy timeline
│ │ ├── engineer/ race engineer panel
│ │ └── ui/ interface primitives
│ ├── lib/ typed API client, formatting, hooks
│ └── store/ cross-view session state
└── docs/
├── architecture.md
├── simulation-model.md
├── tyre-model.md
├── strategy-engine.md
└── data-sources.md
Full detail in docs/architecture.md.
Backend — Python 3.12+, FastAPI, Pydantic v2, NumPy, SQLAlchemy.
Frontend — Next.js 14 (App Router), React 18, TypeScript, Tailwind CSS, Zustand.
Two deliberate omissions:
- No charting library. The telemetry view needs one cursor synchronised across six stacked traces and a track map, which fights most libraries' event models. The charts are hand-built SVG, which also keeps first-load JS at ~120 kB.
- No task queue. Simulation is NumPy, which releases the GIL, so a daemon
thread genuinely runs in parallel with the event loop. The whole platform is
one process with nothing to deploy alongside it.
JobRegistryis narrow enough that swapping in Celery later means implementing four methods.
The classic three-pass quasi-steady-state algorithm. Circuits are authored as curvature spines — sequences of straights and constant-radius arcs — and integrated up to position, because the lap-time model needs curvature and differentiating a hand-drawn polyline twice produces noise that swamps the signal.
kappa(s) → corner speed limit → backward pass (braking)
→ forward pass (traction)
→ v(s) = min of the three
→ t = ∫ ds/v
Two corrections take the model from textbook to credible: a superelliptic friction envelope (exponent 1.7 rather than a friction circle) and a transient load-build window that stops the car snapping between lateral limits instantaneously. The second is what makes a direction-change circuit lose more than a power circuit — an effect that emerges rather than being asserted.
Modelled qualifying laps against published lap records, with one global grip calibration for the whole library — never a per-circuit correction, which would hide a bad geometry behind a good-looking table.
| Circuit | Modelled | Published record | Error |
|---|---|---|---|
| Silverstone | 1:25.266 | 1:27.097 | −2.10% |
| Monza | 1:23.310 | 1:20.329 | +3.71% |
| Spa | 1:43.033 | 1:46.286 | −3.06% |
| Suzuka | 1:28.879 | 1:30.983 | −2.31% |
| Monaco | 1:13.576 | 1:12.909 | +0.91% |
RMS error 2.60%, mean bias −0.57%, grip calibration 1.000.
A qualifying lap should sit a few percent inside a race lap record, so the
negative residuals are the expected direction. Monza's positive residual is the
one genuine outlier: that record dates from 2004, under a different technical
regulation. The report is served live at GET /api/circuits/validation and
shown on the landing page.
Total race time decomposes exactly:
T = Σ_laps [ base_pace + fuel_effect·fuel(lap) − evolution(lap) ]
+ Σ_stints Σ_age tyre_delta(compound, age)
+ n_stops · pit_loss
The first sum is identical for every strategy and cancels out of any comparison. The tyre term depends only on compound and age within the stint, never on which lap it falls. So the strategy problem separates, and the optimal stint split is solved exactly by dynamic programming — a min-plus convolution over stint lengths — rather than by sampling candidate stop laps.
Stochastic effects break the decomposition, which is exactly why the Monte Carlo engine exists. The workflow is: rank exactly, then re-rank under uncertainty.
Ten thousand races as array operations over a (n_simulations, n_drivers)
state matrix, looping only over laps. About 50 iterations of 200k-element NumPy
work instead of 10⁷ Python iterations.
~5,800 races/second. Deterministic for a fixed seed.
| Label | Meaning |
|---|---|
PUBLIC |
Published reference material — lap distances, corner counts, lap records, safety car base rates |
MODEL_OUTPUT |
Computed by the deterministic physical models — corner speeds, lap times, strategy costs |
SIMULATED |
Produced by the stochastic simulation — race results, telemetry, probabilities |
ESTIMATED |
Hand-authored parameters that were not measured — tyre coefficients, driver coefficients, geometry |
A data provider that cannot serve a request raises DataNotAvailable rather
than inventing an answer. PublicDataProvider is deliberately unimplemented
and raises on every method: a silent fallback to synthetic data is precisely
how fabricated data ends up presented as real.
The AI Race Engineer is constrained the same way. Every quantity it reports
is computed first and assembled into a structured context; the language model —
when one is configured — is given those facts and asked to phrase them, never
to calculate. Without an API key it runs deterministically from templates. Both
modes return citations listing the model outputs the answer rests on, and the
full computed context is inspectable in the UI.
Stated plainly, because a model whose limits are hidden is not an engineering artefact.
Vehicle model — point mass. No weight transfer, suspension kinematics, tyre slip curves, differential behaviour, transient aerodynamics, yaw dynamics, brake temperature, engine maps or ERS state of charge.
Lap-time model — no driver model and no racing line. It drives the geometric centreline, which is longer through corners than a real line.
Geometry — approximate. Lap distance and corner count match published figures; radii and arc angles do not. Elevation is authored per segment and only weakly coupled to the vehicle model.
Tyre model — coefficients are hand-authored estimates. The temperature penalty is symmetric, which is the model's weakest simplification: a real tyre degrades far faster when overheating than when cold.
Race model — a car can gain at most one position per lap. Damage, penalties, driver error and weather transitions are not modelled.
Monte Carlo — pit stops are drawn up front, so the ensemble cannot model a driver reacting to a safety car by pitting opportunistically. Safety car timing is modelled and its effect on gaps applied; only the reactive stop is missing. This is the largest known bias in the ensemble.
Known fidelity gap — Monza should be the highest full-throttle circuit in the library (~78% in reality) but the model puts it at ~68%, behind Spa. The authored chicane radii are tighter than the real ones relative to their entry speeds. Recorded rather than tuned away.
Replace the synthetic data. Implement DataProvider (see
app/data/provider.py), map the source's identifiers onto the platform's,
resample telemetry onto the uniform distance grid, set provenance = "PUBLIC",
and register it in get_provider(). Nothing else changes.
Use PostgreSQL. The schema in app/db/models.py is written for Postgres
and runs on SQLite unchanged. Point RI_DATABASE_URL at a Postgres instance
and install psycopg[binary].
Enable the assisted race engineer. Set RI_ANTHROPIC_API_KEY. The engineer
still computes every number itself; the model only phrases them.
Bring a MATLAB/Simulink model. app/models/parameters.py is the hand-off
point. A model identified offline exports its fitted coefficients straight into
VehicleParameters or TyreParameters without touching simulation code. See
docs/architecture.md.
GET /api/health
GET /api/model-info every constant the models use
GET /api/providers
GET /api/circuits
GET /api/circuits/validation model error vs lap records
GET /api/circuits/{id}
GET /api/circuits/{id}/elevation
GET /api/circuits/{id}/lap solve under custom conditions
GET /api/drivers
GET /api/drivers/teams
GET /api/drivers/{id}
GET /api/drivers/{id}/analytics
GET /api/telemetry/{circuit}/{driver}/{lap}
GET /api/telemetry/{circuit}/compare
POST /api/simulations/race synchronous, ~60 ms
POST /api/simulations/monte-carlo async job
GET /api/simulations/jobs/{id}
GET /api/simulations/jobs/{id}/result
DELETE /api/simulations/jobs/{id}
POST /api/strategies/analyze
POST /api/strategies/evaluate
POST /api/strategies/what-if
POST /api/strategies/tyre-projection
GET /api/engineer/status
POST /api/engineer/ask
Every error returns the same shape, so the UI has exactly one error renderer:
{ "detail": { "code": "...", "message": "...", "resource": "...", "hint": "..." } }Three containers behind Caddy, which obtains and renews a Let's Encrypt certificate on its own — no certbot, no cron job.
# 1. Point an A record for your domain at the VPS
# 2. On the VPS:
git clone <your-repo> race-intelligence && cd race-intelligence
cp .env.example .env && nano .env # set DOMAIN and ACME_EMAIL
docker compose up -d --buildThat is the whole deployment. Caddy serves the app on /, proxies /api/* to
the backend on the same origin (so there is no CORS anywhere in
production), and exposes the OpenAPI docs at /docs.
Neither application container publishes a port to the host; only Caddy is reachable from outside. Both run unprivileged, and the backend is CPU-capped so a 50,000-race request cannot starve the web tier on a small box.
Resources — comfortable on 2 vCPU / 2 GB. The backend holds all five circuits' solved geometry in memory (a few MB) and a 10,000-race ensemble peaks around 200 MB.
One worker, deliberately. Simulation jobs live in an in-process registry,
so a second uvicorn worker would serve job ids it cannot see. Scaling out means
moving JobRegistry to Redis first.
Backups — the only state is backend-data (SQLite) and caddy-data
(certificates). Losing the latter just means re-issuing.
- architecture.md — system design and data flow
- simulation-model.md — equations and assumptions
- tyre-model.md — the degradation law
- strategy-engine.md — the optimiser
- data-sources.md — provenance of every input





