Skip to content

Latest commit

 

History

History
347 lines (263 loc) · 13.8 KB

architecture.md

File metadata and controls

347 lines (263 loc) · 13.8 KB

Architecture

System design, data flow, and the boundaries that let each layer change independently.


1. Shape of the system

┌─────────────────────────────────────────────────────────────┐
│  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.


2. The modelling core

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

Why the derived coefficients matter

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.


3. Two engines, one model

                    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.

Where they differ

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.


4. Background jobs

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

Why threads, not a task queue

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.


5. Data provider abstraction

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.

The honesty rule

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


6. Database schema

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

Two design decisions

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


7. Frontend

Rendering strategy

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.

The API client

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

Caching

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.

State

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.

No charting library

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.

Circuit map rendering

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.


8. Performance

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 one optimisation that mattered

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.

Caching layers

  • @lru_cache on load_circuit — geometry and reference lap
  • @lru_cache on _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

9. MATLAB/Simulink boundary

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.


10. Testing

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.