System design, data flow, and the boundaries that let each layer change independently.
┌─────────────────────────────────────────────────────────────┐
│ Next.js frontend │
│ ├── pages composed from primitives │
│ ├── components circuit map, charts, timing, engineer │
│ ├── lib/api.ts one typed client, one error contract │
│ └── store cross-view selections only │
└──────────────────────────┬──────────────────────────────────┘
│ /api/* (proxied, same-origin)
┌──────────────────────────┴──────────────────────────────────┐
│ FastAPI backend │
│ │
│ api/routes ──► schemas ──► engines ──► models │
│ │ │
│ jobs (threads) │
│ │
│ data/provider ──► synthetic | historical | public │
│ db ──► SQLAlchemy (SQLite or Postgres) │
└──────────────────────────────────────────────────────────────┘
The dependency arrow points one way: routes know about engines, engines know about models, models know about nothing. A data provider never imports the API layer.
backend/app/models/ is the engineering heart. Everything else consumes it.
parameters.py every physical constant, in one place
│
track.py curvature spine ──► integrated geometry
│
vehicle.py point-mass forces, friction ellipse, transient window
│
laptime.py quasi-steady-state solver ──► speed profile, lap time
│ │
│ ├──► fuel_effect_s_per_kg (central difference)
│ └──► grip_sensitivity_s_per_grip (central difference)
│
tyre.py degradation law ──► grip fraction ──► × sensitivity ──► seconds
fuel_effect_s_per_kg and grip_sensitivity_s_per_grip are computed from the
lap-time model, not quoted as constants. This is the single most important
structural decision in the codebase: it means the tyre model's output arrives in
the units the race model consumes without an intermediate fudge factor, and it
means the same grip loss automatically costs more at a corner-dominated circuit
than at a power-dominated one.
RaceModel
(shared pace model)
│ │
┌──────────┘ └──────────┐
│ │
race_engine.py monte_carlo.py
single race vectorised ensemble
event-recording (S × D) state matrix
~60 ms ~5,800 races/second
│ │
replay, race view probabilities, distributions
Both engines import RaceModel for base pace, fuel effect, tyre deltas, traffic
and overtaking. That shared dependency is what makes a single simulation one
sample from the distribution the ensemble explores, rather than a different
model that happens to look similar.
test_monte_carlo_agrees_with_the_single_race_engine enforces this: the
ensemble mean must match a single race within 2% over 25 laps.
| Single race | Monte Carlo | |
|---|---|---|
| Overtaking | Sequential sweep | Odd-even transposition |
| Pit stops | Reactive to race state | Drawn up front with jitter |
| Output | Full event log, lap records | Aggregated distributions |
| Purpose | Replay, inspection, explanation | Probability, ranking |
The pit-stop difference is the ensemble's largest known bias: it cannot model a driver reacting to a safety car by pitting opportunistically.
A 10,000-race Monte Carlo takes ~2 s. Holding an HTTP request open for that blocks a worker and shows the user nothing but a spinner.
POST /api/simulations/monte-carlo ──► job_id (immediate)
GET /api/simulations/jobs/{id} ──► progress, polled at 250 ms
GET /api/simulations/jobs/{id}/result
The workload is NumPy, which releases the GIL for the array operations that dominate runtime — so a daemon thread genuinely runs in parallel with the event loop. That keeps the whole platform to a single process with no broker to deploy, which matters a great deal for a project that must run immediately after checkout.
JobRegistry is bounded in both count and age (TTL eviction, then oldest-first
over capacity), and its interface is narrow enough that swapping in Celery or RQ
later means implementing four methods.
DataProvider (ABC)
├── SyntheticDataProvider generated from the models [default]
├── HistoricalDataProvider replays stored races from the DB
└── PublicDataProvider adapter seam — NOT IMPLEMENTED
Providers return plain dictionaries, never ORM objects or Pydantic models, so a provider never imports the API layer.
A provider that cannot serve a request raises DataNotAvailable rather than
returning empty or falling back. PublicDataProvider raises on every method
by design:
A silent fallback to synthetic data is precisely how fabricated data ends up presented as real.
Its docstring carries the integration checklist for whoever implements it: map
identifiers, resample telemetry onto the uniform distance grid, set
provenance = "PUBLIC", register in get_provider().
Designed for PostgreSQL, runs unchanged on SQLite — which is what lets the application start with no database to provision.
circuits ──┬── circuit_segments the authored spine (geometry source)
└── corners
teams ────── drivers
races ──┬── laps one row per driver per lap
├── pit_stops
├── race_results
└── weather
simulations ── strategies
telemetry packed float32 arrays
Structure lives in columns, not JSON. "Every stint longer than 25 laps on
the hard tyre", "average pit loss by team" — these are the questions you want to
ask, and they are unanswerable if the race is one opaque document. JSON columns
appear in exactly two places: SimulationRecord.request (stored verbatim so a
run is reproducible even if the schema changes) and result_summary (genuinely
schemaless aggregate distributions, never queried field-by-field).
Telemetry is stored as binary arrays, not rows. A single lap is ~1500
samples across ten channels. At one row per sample a race weekend would be tens
of millions of rows to serve a chart that always reads the whole lap anyway. The
array is the natural unit of access, so it is the unit of storage; channels
maps name → (offset, length) so one channel can be sliced without deserialising
the rest.
SQLite gets PRAGMA foreign_keys=ON (off by default, which would silently
disable every ON DELETE CASCADE) and WAL mode (so reads do not block while a
simulation writes).
Every page is a client component. The data is user-driven and interactive — there is nothing to prerender, and server components would add a round trip before the first chart appears.
lib/api.ts has one request function doing fetching, error normalisation and
typing; everything else is a thin named wrapper. Errors normalise into
RaceIntelligenceError carrying {code, message, resource, hint}, so the UI
has exactly one error renderer and no view interprets a raw HTTP status.
A network-level failure is translated to a useful message rather than "Failed to fetch":
Cannot reach the Race Intelligence API. Start the backend with: uvicorn app.main:app --port 8000
cachedFetch is a module-level promise cache for immutable resources — circuit
geometry and the driver list are computed from fixed definitions on the server
and cannot change while the page is open. Promises are cached, not just results,
so two components mounting simultaneously share one request. A failed request
deletes its entry, so a transient backend restart cannot leave the page
permanently broken.
store/session.ts holds selections only — circuit, the two comparison
drivers, conditions, seed. Fetched data lives in the hooks that fetch it, so
there is exactly one source of truth for anything the server owns. Selections
persist to localStorage; anything derived is recomputed on load, so a stale
cache can never desynchronise from the models.
Three reasons: the telemetry view needs one cursor synchronised across six stacked traces and a track map, which fights most libraries' event models; the visual language is deliberately unlike any library's defaults; and a distance-domain trace with 700+ points per channel renders faster as a single memoised path than as a component tree.
Result: ~120 kB first-load JS across all thirteen routes.
The coloured track is one <path> per sample pair (~740 at stride 2). That is
well within what SVG handles smoothly and keeps every segment individually
addressable for colouring and hit-testing. The segment list is memoised on
(circuit, mode); pan and zoom apply a single transform to the parent <g>,
so they never rebuild it and stay at 60fps regardless of track complexity.
| Operation | Time |
|---|---|
| Integrate + solve one circuit | ~70 ms |
| Solve one lap (warm) | 18 ms |
| Single race simulation | ~60 ms |
| 10,000-race Monte Carlo | ~1.7 s |
| Strategy analysis (42 plans) | ~50 ms |
| Startup: 5 circuits + 50 driver solves | ~1.3 s |
The QSS integration loops evaluate the force model ~12,000 times per lap.
Routing each call through the vectorised VehicleModel methods built two
one-element NumPy arrays and dispatched a full ufunc to multiply a handful of
floats — ~50 µs per call, 600 ms per lap solve.
_ScalarPhysics hoists the constants and evaluates the same equations in plain
Python floats: 18 ms, a 33× speedup, bit-identical results. Startup warming
went from 41 s to 0.9 s.
Because it duplicates model code, test_scalar_physics_matches_vector_model
pins the two implementations together across the full operating range. Writing
that test immediately found a real bug: tractive_force ignored DRS when
computing its grip limit while the lateral term accounted for it.
@lru_cacheonload_circuit— geometry and reference lap@lru_cacheon_solve_driver_base_pace— one lap solve per (circuit, driver)- Background warm thread at startup fills both
- Per-model tyre model cache keyed by (compound, driver)
- Monte Carlo precomputes a
(D, C, 91)tyre delta table before the lap loop
The web application does not depend on MATLAB and never will. But the architecture keeps a specific door open:
MATLAB / Simulink
│ detailed vehicle dynamics, tyre thermal model,
│ system identification against measured data
▼
fitted parameters (exported as coefficients, not code)
▼
app/models/parameters.py ◄── the hand-off point
▼
app/models/{vehicle,tyre,laptime}.py
▼
app/simulation/ ──► app/api/ ──► frontend
parameters.py is deliberately the single place every physical constant
lives, and every model reads its coefficients from a frozen dataclass rather
than hardcoding them. A model identified offline in Simulink exports its fitted
VehicleParameters or CompoundParameters values straight into that module
without touching a line of simulation code.
Nothing here is MATLAB-flavoured for its own sake. The boundary exists because parameter identification is genuinely the thing Simulink is good at and this codebase is not.
164 tests, structured around what would actually be dangerous if it broke:
| File | Focus |
|---|---|
test_models.py (73) |
Physics invariants, monotonicity, self-consistency |
test_simulation.py (43) |
Reproducibility, engine agreement, strategy exactness |
test_api.py (48) |
Response shapes, validation, the error contract |
The assertions are mostly about invariants and directions, not exact values. A model whose constants are estimates should not be pinned to four decimal places — but it absolutely must be monotonic in the right direction, internally consistent, and reproducible.
Three tests earn their place more than the rest:
test_race_is_reproducible_for_a_fixed_seed— without this nothing downstream can be debugged.test_dynamic_program_finds_the_true_optimum— justifies calling the optimiser exact rather than heuristic.test_scalar_physics_matches_vector_model— pins duplicated model code, and found a real bug the first time it ran.