The bridge (src/pitwall/__main__.py) is the central HTTP surface for the Vue PWA, telemetry adapters, and any external client (dashboards, replay tools). It runs locally on 127.0.0.1:8765, wraps the full Python coaching stack (sonic_model, coach_engine, session_analyzer, ADK agents), and persists session state to DuckDB. Currently serving 56 endpoints across three tiers:
- Hot path (<50 ms):
/analyzeburst —sonic_model+RuleCoachreflexive cues - Warm path (<100 ms):
/coach/brief,/coach/debrief—LitertCoach(Gemma 4 E2B) - Paddock path (2–15 s):
/coach/ask— 18 ADK agents (Gemma 4 E4B vialit serve)
client (Vue PWA / curl / Termux) ──► HTTP :8765 ──► sonic_model.compute_cues ─┐
└────► coach_engine.propose ├─► /analyze response
└────► adk_agents.run_adk() │
└────► duckdb (sessions/laps/notes/traces)
See ADR-010 for the design rationale.
pip3 install flask duckdb requests python-can cantools # one-time
python3 -m src.pitwall # uses default track + auto coachCommon variants:
# Force the rule coach (no LLM dependency)
python3 -m src.pitwall --coach rule
# Force on-device LiteRT-LM inference (Gemma 4 E2B via MediaPipe Genai)
python3 -m src.pitwall --coach litert \
--litert-model ~/storage/shared/Pitwall/models/gemma-4-E2B-it.task
# Tune phrasing per pod
python3 -m src.pitwall --driver-level beginnerEmulator tunnel:
~/Library/Android/sdk/platform-tools/adb reverse tcp:8765 tcp:8765The bridge can spawn an in-process python-can reader on startup. When --can-channel is provided, frames decoded via cantools using data/dbc/pitwall.dbc (default) flow into the same DuckDB the HTTP API serves: wide-table canonicals into telemetry, everything else into telemetry_signals (ADR-015 sink).
| Flag | Default | Purpose |
|---|---|---|
--can-channel |
unset (no reader) | python-can channel (e.g. pitwall_dev, /dev/ttyACM0, vcan0) |
--can-interface |
virtual |
python-can interface (virtual | socketcan | slcan | pcan | kvaser) |
--can-bitrate |
500000 |
bitrate for slcan/socketcan/pcan/kvaser |
--can-dbc |
data/dbc/pitwall.dbc |
DBC file(s) to load. Repeat to layer multiple. |
--can-session-id |
auto-generated <track-slug>-<UTC> |
session id to tag rows with |
--can-flush-ms |
100 |
wide-table flush cadence (default 100 = 10 Hz) |
# Cross-platform dev / CI: virtual bus, no kernel modules required
python3 -m src.pitwall --can-channel pitwall_dev
# In another terminal — replay a real VBO as CAN frames at 2× realtime
python3 src/simulator/can_simulator.py \
--vbo "/path/Sonoma Intermediate - 1_47.5.vbo" \
--channel pitwall_dev --speed 2.0
# Linux dev with virtual CAN kernel module
sudo modprobe vcan && sudo ip link add dev vcan0 type vcan && sudo ip link set up vcan0
python3 -m src.pitwall --can-interface socketcan --can-channel vcan0
# Production: USB-CAN adapter (CANable Pro / Macchina M2) on the Pixel via Termux
python3 -m src.pitwall \
--can-interface slcan --can-channel /dev/ttyACM0 \
--can-dbc data/dbc/pitwall.dbc --can-dbc data/dbc/bmw_e46_m3.dbcWhen the reader is running, posting frames over HTTP via POST /session/<sid>/frames continues to work — both ingest paths land in the same wide table. CAN is the primary path for live sessions; HTTP frames are kept for VBO post-import (POST /session/import) and for unit tests.
Liveness probe and engine status.
The main coaching endpoint. Receives a telemetry burst from the Vue PWA, CAN reader, or any HTTP client and returns coaching text + audio cues + a rally-style pace note.
Request body (subset; extra fields ignored):
{
"session_id": "sonoma-2026-04-28-team2",
"burst_id": 7,
"avg_speed_kmh": 104.0,
"max_combo_g": 1.82,
"max_lateral_g": 1.35, // optional, derived from combo if absent
"max_long_g": -0.92,
"max_brake_bar": 45.0,
"avg_throttle_pct": 38.0,
"avg_steering_deg": 12.0,
"coast_frames": 12,
"trail_brake_frames": 4,
"frame_count": 75,
"corners_visited": ["Turn 3"],
"distance_m": 1450.0,
"in_corner": false,
"past_apex": false
}Response:
{
"coaching": "Trail brake — hold pressure. Trail braking: 24.3 bar (33%)", // sonic_model summary
"pace_note": "128, the Carousel, brake at just after the slight crest, downhill",
"coach_source": "rule", // "rule" | "litert" | "" (disabled)
"cues": [ /* serialized AudioCue list */ ],
"burst_id": 7,
"source": "sonic_model" // "sonic_model" | "bridge_rules"
}If the request includes session_id and the pace note is non-empty, the bridge writes a row to the coaching_notes DuckDB table so GET /session/<id> can replay the coaching timeline.
Parse a .vbo file from disk, create a sessions row, persist every frame into the telemetry table — one call to ingest a full historical session into the backend. Use this when loading an existing recording (e.g. forza dataset, prior session reviewed offline).
// Request
{
"vbo_path": "/path/to/lap.vbo",
"driver": "Taha", // optional, defaults to ""
"driver_level": "intermediate", // optional, default
"session_id": "my-custom-id", // optional, auto-generated otherwise
"note": "Sunday testing" // optional
}
// Response — 200 OK
{
"session_id": "sonoma-raceway-20260428-201503",
"n_frames": 8273,
"duration_s": 1387.2,
"distance_m": 95234.1,
"vbo_source": "Sonoma Intermediate - 1_47.5.vbo"
}Status codes:
200— ingested successfully.400—vbo_pathmissing/invalid or no frames parsed.409— the requestedsession_idalready has frames (idempotent guard). Use a differentsession_idor delete first.503— DuckDB unavailable.
The bulk equivalent for a directory of VBOs is scripts/bulk_import_sonoma_vbos.py, which calls this same path internally per file.
Open a new session row in DuckDB. The caller may supply their own session_id; otherwise the bridge generates <track-slug>-<UTC-YYYYMMDD-HHMMSS>.
// Request
{
"driver": "Taha",
"driver_level": "intermediate",
"track": "Sonoma Raceway",
"car": "BMW M3 (E46)",
"note": "Pre-sprint test"
}
// Response — 200 OK
{ "started": true, "session_id": "sonoma-raceway-20260428-170015" }Close a session by stamping ended_at = now(). Idempotent — re-ending an already-closed session is a no-op.
{ "ended": true, "session_id": "sonoma-raceway-20260428-170015" }List recent sessions, newest first. active_only=true filters to sessions without ended_at.
Each row carries derived lap_count and best_lap_s — useful for a leaderboard or session picker.
Full session detail: the session row, all laps belonging to it (ordered by lap_number), and the most recent 50 coaching_notes. Returns 404 when sid is unknown.
{
"session": { "session_id": "...", "driver": "...", "started_at": "...", ... },
"laps": [ { "lap_number": 1, "lap_time_s": 107.5, ... } ],
"notes": [ { "burst_id": 7, "distance_m": 350, "text": "185, left 6, brake at the bridge", "source": "rule", ... } ],
"lap_count": 8,
"best_lap_s": 107.5
}Save a completed lap.
{
"session_id": "sonoma-raceway-20260428-170015",
"lap_number": 3,
"lap_time_s": 107.5,
"best_sector": 34.2,
"avg_speed_kmh": 115.3,
"max_combo_g": 1.84,
"coast_pct": 5.4
}Read lap history. Without session_id, returns the most recent limit laps across all sessions.
The bridge initialises three tables on first use (pitwall_sessions.duckdb):
CREATE TABLE sessions (
session_id VARCHAR PRIMARY KEY,
driver VARCHAR,
driver_level VARCHAR,
track VARCHAR,
car VARCHAR,
started_at TIMESTAMP DEFAULT now(),
ended_at TIMESTAMP,
note VARCHAR
);
CREATE TABLE laps (
id INTEGER PRIMARY KEY DEFAULT nextval('laps_id_seq'),
session_id VARCHAR,
lap_number INTEGER,
lap_time_s DOUBLE,
best_sector DOUBLE,
avg_speed_kmh DOUBLE,
max_combo_g DOUBLE,
coast_pct DOUBLE,
recorded_at TIMESTAMP DEFAULT now()
);
CREATE TABLE coaching_notes (
id INTEGER PRIMARY KEY DEFAULT nextval('notes_id_seq'),
session_id VARCHAR,
burst_id INTEGER,
distance_m DOUBLE,
text VARCHAR,
source VARCHAR,
recorded_at TIMESTAMP DEFAULT now()
);session_id is the join key. coaching_notes.distance_m lets the off-track review screen scrub coaching back-to-back with telemetry on the lap timeline.
Future: a telemetry table for per-frame persistence (foundation for the per-corner replay feature) — not yet built.
The Flutter app consumes coaching from one source — the bridge. Per ADR-013, the backend owns all LLM logic and system prompts; the frontend visualizes only.
| Tier | Transport | Latency | Requires |
|---|---|---|---|
| 1 | Bridge 127.0.0.1:8765/analyze |
< 50 ms | Bridge running (src/pitwall/__main__.py) |
| 2 | Mock | 0 ms | Always works (used in tests / when bridge is unreachable) |
The bridge runs the full sonic_model + coach_engine pipeline locally. With --coach litert, it executes Gemma 4 E2B inference in-process via MediaPipe Genai — no cloud round-trip, no API quota.
- The
/analyzeresponse is additive —pace_noteandcoach_sourceare new fields; existing Flutter deserializers that ignore unknown keys keep working. - The new session endpoints are opt-in — existing flows that POST
/lapwithout first calling/session/startcontinue to work; sessions just won't be linked. versionin/healthis bumped from2.0→2.1to signal these additions.
- Flutter Pixel 10 app (
flutter/lib/platform/pitwall_channel.dart→ KotlinMessageArbiter). python3 -m src.pitwallsmoke test: 5 synthetic bursts produce one valid pace note before the 3-second arbiter cooldown silences the rest (works as designed).python3 tests/test_endpoints_smoke.pyend-to-end: ingests the Sonoma Intermediate VBO (8273 frames, 6.83 cumulative laps), streams 4 tall-store CAN-style signals into the ADR-015 sink, and asserts shape on every documented endpoint. 51/51 assertions green.pitwall_app.py --simple --replay <vbo>runs the samecoach_enginein-process for development without the bridge.
| Endpoint | Purpose | ADR |
|---|---|---|
POST /session/<sid>/frame |
Append a single telemetry frame (per-corner replay foundation); returns assigned frame_idx |
— |
GET /session/<sid>/corners |
Per-corner aggregates: best pass + averages over all passes, optional A–F grade + gold-standard delta when data/reference/sonoma_gold.json is loadable |
— |
POST /score |
Gemini-graded session score (0–100 + one-sentence why); 503 when GEMINI_API_KEY unset |
— |
GET /markers?corner=&kind= |
Filterable view over the track JSON's marker list | ADR-011 |
GET /coach/concepts |
The 9 Bentley pedagogical concepts the coach can fire, with description + when-fires hint | ADR-012 |
A second batch of analytical endpoints, inspired by WarmBed/PITWALL's F1 race-weekend tool, adapted for single-driver multi-lap track-day workflows. Most are thin SQL aggregations against the telemetry and laps tables — they exist to give the frontend ready-to-render data instead of a raw frame stream.
All eleven of these endpoints follow a common contract:
- Authentication: none (loopback only).
- 404 when the session has no frames in
telemetry(caller should ingest withPOST /session/importorPOST /session/<sid>/framesfirst). - 400 when the session exists but has < 1 complete lap (lap-detection requires at least one S/F crossing).
- 200 with the JSON body documented per endpoint.
Several endpoints (all the lap-time and sector ones) need to slice the per-frame stream into laps. The backend (src/pitwall/__main__.py:_detect_laps) tries three strategies in order, picking the first that yields any laps. This handles the three real shapes of session data: cumulative-distance Racelogic VBOs, per-lap-resetting synthetic frames, and any data without reliable distance integration.
Strategy 1 — cumulative distance (Racelogic VBO, default). Used when the final frame's distance_m > 1.5 × track_length. Lap boundary = floor(d / track_length) increments. Each multiple of track_length is one full lap; the pre-first-boundary segment (out-lap from pit lane) is discarded.
Strategy 2 — distance wraparound (synthetic / per-lap data). Used when distance never exceeds ~1.5 × track_length (i.e. distance_m resets toward 0 each lap). Lap boundary = a drop in distance_m greater than half the track length. The first segment is treated as lap 1 (synthetic data starts at distance 0, no out-lap).
Strategy 3 — GPS perpendicular S/F crossing (fallback). Used when neither distance pattern is present:
-
Take the S/F coordinate from the loaded track JSON's
start_finishfield (anonymized to match the dataset's GPS frame), falling back tosonoma.pyconstants (SF_LAT = 38.16152,SF_LON = -122.45472,SF_HEADING_DEG = 354.2) if the JSON has none. -
For each frame
f, project its (lat, lon) onto a local XY plane centred at S/F (small-angle approximation): $$x = (\text{lon}f - \text{lon}{\text{SF}}) \cdot \cos(\text{lat}_{\text{SF}}) \cdot R,\quad y = (\text{lat}f - \text{lat}{\text{SF}}) \cdot R,\quad R = 111{,}320\text{ m}$$ -
Compute signed perpendicular distance to the S/F line (line orientation =
SF_HEADING_DEG):$$d_f = -x \sin\theta + y \cos\theta$$ where θ is
SF_HEADING_DEG(degrees from North, clockwise; 354.2° for Sonoma). -
A lap boundary is a frame where
dchanges from negative to non-negative, and the radial distance to the S/F point$\sqrt{x^2 + y^2} < 50$ m. The 50 m gate excludes crossings of the infinite perpendicular line that occur far from the physical S/F marker (e.g. parallel sections of the back straight). -
Only complete crossing-to-crossing intervals are counted as laps. The pre-first-crossing segment (out-lap) and the post-last-crossing segment (in-lap) are discarded.
Final filter (all strategies): lap time = t_end - t_start. Laps shorter than 60 s or longer than 300 s are rejected as parser noise. Accepted laps are renumbered 1..N in time order.
Sector boundaries come from sonoma.SECTORS:
| Sector | Name | Start (m) | End (m) |
|---|---|---|---|
| 1 | Front Loop | 0 | 1294 |
| 2 | Carousel & Back | 1294 | 2752 |
| 3 | T10 to Calamity | 2752 | 4258 |
A frame "enters sector i" when its distance_m first crosses the sector's start_m within a lap. Sector time = t_exit - t_enter.
Per-lap times with sector splits, with the best lap and best sector flagged.
Math. For each detected lap j:
Best-lap and best-sector flags:
delta_to_best_s for lap j is
Response — 200:
{
"session_id": "sonoma-raceway-20260428-201503",
"lap_count": 22,
"best_lap_s": 105.32,
"best_lap_number": 7,
"laps": [
{
"lap_number": 1,
"lap_time_s": 110.48,
"delta_to_best_s": 5.16,
"is_best": false,
"sectors": [
{ "name": "Front Loop", "time_s": 33.21, "is_best": false },
{ "name": "Carousel & Back", "time_s": 38.45, "is_best": false },
{ "name": "T10 to Calamity", "time_s": 38.82, "is_best": false }
]
},
{
"lap_number": 7,
"lap_time_s": 105.32,
"delta_to_best_s": 0.0,
"is_best": true,
"sectors": [
{ "name": "Front Loop", "time_s": 32.45, "is_best": true },
{ "name": "Carousel & Back", "time_s": 38.95, "is_best": false },
{ "name": "T10 to Calamity", "time_s": 33.92, "is_best": true }
]
}
/* … */
]
}Frontend consumption. Render as a leaderboard-style table with the best lap highlighted (purple in motorsport convention). Per cell, colour-code on is_best (purple) vs. session-best for that sector (green) vs. neither (default).
final laps = (await api.get('/session/$sid/lap_time_table'))['laps'] as List;
return ListView(children: laps.map((l) => LapRow(
number: l['lap_number'],
totalSec: l['lap_time_s'],
deltaSec: l['delta_to_best_s'],
isBest: l['is_best'],
sectors: (l['sectors'] as List).map((s) => SectorCell(
timeSec: s['time_s'],
purple: s['is_best'],
)).toList(),
)).toList());curl -s 127.0.0.1:8765/session/sonoma-raceway-20260428-201503/lap_time_table | jq '.best_lap_s'Box-plot statistics over the session's lap times. Useful for "how consistent was I" panels.
Math (Tukey's box-plot). Sort lap times
Then:
Population mean and stddev:
Response — 200:
{
"session_id": "sonoma-raceway-20260428-201503",
"lap_count": 22,
"min_s": 105.32,
"max_s": 115.78,
"q1_s": 107.45,
"median_s": 108.92,
"q3_s": 110.18,
"iqr_s": 2.73,
"whisker_low_s": 105.32,
"whisker_high_s": 113.20,
"outliers": [{ "lap_number": 14, "lap_time_s": 115.78 }],
"mean_s": 109.03,
"stddev_s": 2.41
}Frontend consumption. Render as a vertical box-plot widget. The numeric stddev_s doubles as a one-glance "consistency score".
Theoretical fastest lap = sum of best sector times across the session. Highlights how much the driver leaves on the table by never stringing all three best sectors into one lap.
Math. Let
Response — 200:
{
"session_id": "sonoma-raceway-20260428-201503",
"ideal_lap_s": 104.18,
"best_actual_lap_s": 105.32,
"gain_potential_s": 1.14,
"best_sectors": [
{ "name": "Front Loop", "time_s": 32.45, "from_lap": 7 },
{ "name": "Carousel & Back", "time_s": 37.81, "from_lap": 12 },
{ "name": "T10 to Calamity", "time_s": 33.92, "from_lap": 7 }
]
}Frontend consumption. Display as a banner over the lap table — "Your ideal lap is 1:44.18 — 1.14s under your best of 1:45.32". Tapping a sector card jumps the lap-table view to the lap that produced that sector best.
Thinner per-lap-per-sector view (no totals, no best flags). Useful when the frontend already has lap times from another endpoint and only wants splits.
Math. Same sector boundaries and timing as the lap-time table.
Response — 200:
{
"session_id": "sonoma-raceway-20260428-201503",
"sector_definitions": [
{ "name": "Front Loop", "start_m": 0, "end_m": 1294 },
{ "name": "Carousel & Back", "start_m": 1294, "end_m": 2752 },
{ "name": "T10 to Calamity", "start_m": 2752, "end_m": 4258 }
],
"laps": [
{ "lap_number": 1, "s1": 33.21, "s2": 38.45, "s3": 38.82 },
{ "lap_number": 2, "s1": 33.05, "s2": 38.12, "s3": 38.61 }
/* … */
]
}Frontend consumption. Drives the "sector trend" chart — three lines (S1/S2/S3) on a shared x-axis (lap number).
Distribution of frames across the four canonical pedal states. Reveals whether a driver coasts too much, never trail-brakes, etc.
Math (per frame). Two thresholds, both configurable via query params (?throttle_th=5&brake_th=1.0):
| State | Throttle (%) | Brake (bar) |
|---|---|---|
throttle_only |
> 5 | ≤ 1.0 |
brake_only |
≤ 5 | > 1.0 |
trail_brake |
> 5 | > 1.0 |
coast |
≤ 5 | ≤ 1.0 |
(Trail-braking is the overlap region — both pedals modulating simultaneously. This deviates from WarmBed's F1 model, which uses 95% as the throttle threshold; we use 5% because road-car drivers rarely sit at full WOT and instead modulate.)
For each state
where
Response — 200:
{
"session_id": "sonoma-raceway-20260428-201503",
"frame_count": 8273,
"thresholds": { "throttle_pct": 5.0, "brake_bar": 1.0 },
"frame_dt_s": 0.1,
"states": {
"throttle_only": { "frames": 4523, "pct": 54.7, "time_s": 452.3 },
"brake_only": { "frames": 1240, "pct": 15.0, "time_s": 124.0 },
"trail_brake": { "frames": 847, "pct": 10.2, "time_s": 84.7 },
"coast": { "frames": 1663, "pct": 20.1, "time_s": 166.3 }
}
}Frontend consumption. Render as a stacked horizontal bar (one bar = the lap, four colored segments). High coast % is the alarm signal — a coaching cue can pop up "You're coasting 20% of the time. Trail-brake to corner entry instead of lifting early."
Per-corner throttle-application statistics over all passes through that corner. Box-plot data per corner.
Math (per corner c). Collect throttle_pct from every frame where entry_dist_c ≤ distance_m ≤ exit_dist_c. Compute min, q1, median, q3, max, mean using the same quantile formula as the lap-time distribution.
Response — 200:
{
"session_id": "sonoma-raceway-20260428-201503",
"corners": [
{
"name": "Turn 1",
"n_passes": 22,
"n_samples": 1430,
"min_pct": 0.0,
"q1_pct": 12.4,
"median_pct": 35.1,
"q3_pct": 67.8,
"max_pct": 100.0,
"mean_pct": 38.7
}
/* … one entry per corner in sonoma.json … */
]
}Frontend consumption. Render as 11 vertical box-plots in a horizontal row. Low median + high IQR = "indecisive throttle" → a coaching focus.
Group the track's corners into low/medium/high-speed bands by apex speed (the slowest point inside the corner). Lets the coach say "you're losing time in the slow stuff, not the fast stuff".
Math. Apex speed for corner c:
Bands (configurable via ?low_max=80&med_max=130):
Per band: list of corners + mean apex speed across all passes in band.
Response — 200:
{
"session_id": "sonoma-raceway-20260428-201503",
"thresholds": { "low_max_kmh": 80, "med_max_kmh": 130 },
"bands": [
{
"band": "low_speed",
"corners": ["Turn 7", "Turn 11"],
"mean_apex_kmh": 64.3,
"median_apex_kmh": 65.0
},
{
"band": "med_speed",
"corners": ["Turn 1", "Turn 2", "Turn 3a", "Turn 4", "Turn 8a", "Turn 9"],
"mean_apex_kmh": 101.2,
"median_apex_kmh": 99.5
},
{
"band": "high_speed",
"corners": ["Turn 6", "Turn 10"],
"mean_apex_kmh": 158.7,
"median_apex_kmh": 160.1
}
]
}Frontend consumption. Drives a track-map overlay where each corner is shaded by its band. A pre-brief view summarises "You're a low-speed corner driver — focus on T7/T11 to go faster".
Top speed per named straight. Reveals draft / corner-exit-momentum issues without wading through all 8000+ frames.
Named straights for Sonoma (defined in sonoma.STRAIGHTS):
| Name | Start (m) | End (m) | Description |
|---|---|---|---|
| Front Straight | 4080 | 4258 (wraps to 60) | T11 exit through S/F into T1 brake zone |
| T4 Run | 600 | 880 | Short squirt out of T4 toward T5 |
| T7 → T8a | 1620 | 1820 | Downhill section — fastest sustained speed |
Math (per straight s).
Returned alongside the lap that produced the top speed.
Response — 200:
{
"session_id": "sonoma-raceway-20260428-201503",
"straights": [
{ "name": "Front Straight", "start_m": 4080, "end_m": 4258, "top_speed_kmh": 198.4, "from_lap": 7 },
{ "name": "T4 Run", "start_m": 600, "end_m": 880, "top_speed_kmh": 138.7, "from_lap": 12 },
{ "name": "T7 → T8a", "start_m": 1620, "end_m": 1820, "top_speed_kmh": 187.2, "from_lap": 11 }
]
}Frontend consumption. Three speed badges on the track map, one per straight, coloured by improvement vs. last session.
Heavy-braking deceleration scatter + corner-exit longitudinal acceleration. Two halves of a "how hard is the driver attacking the limit" view.
Math — heavy-brake zones. A frame is in a heavy-brake zone iff brake_bar > 25. For each contiguous run, compute peak deceleration:
Group runs by their nearest corner (smallest |distance_m - entry_dist_c|). Per corner: max_decel_g = mean over zones, n_passes.
Math — corner exits. A frame is "in a corner exit" iff past_apex_c == True AND throttle_pct > 50%. For each pass through corner c:
exit_speed_kmh = speed at the moment we cross the corner's exit_distance_m.
Response — 200:
{
"session_id": "sonoma-raceway-20260428-201503",
"brake_zones": [
{ "corner": "Turn 7", "max_decel_g": -1.45, "duration_s": 1.8, "n_passes": 22 },
{ "corner": "Turn 10", "max_decel_g": -1.32, "duration_s": 2.2, "n_passes": 22 },
{ "corner": "Turn 11", "max_decel_g": -1.51, "duration_s": 1.6, "n_passes": 22 }
],
"corner_exits": [
{ "corner": "Turn 11", "max_long_accel_g": 0.85, "exit_speed_kmh": 145.2, "n_passes": 22 },
{ "corner": "Turn 4", "max_long_accel_g": 0.71, "exit_speed_kmh": 118.4, "n_passes": 22 }
]
}Frontend consumption. Render as paired horizontal bar charts: top half decel-per-corner (red, negative), bottom half exit-accel-per-corner (green). Tappable bars filter the lap-time table to passes in that corner.
Elevation profile along the centerline of any track in data/tracks/<id>.json.
Track ID. The <id> path parameter is the JSON filename stem in data/tracks/. At time of writing, only sonoma ships, but the parameterization lets us drop additional tracks in without code changes.
ls data/tracks/
# sonoma.json → GET /track/sonoma/elevation
# laguna_seca.json (future) → GET /track/laguna_seca/elevationMath. The track JSON's reference_line is sampled at every step_m (default 10 m, configurable via ?step_m=20):
If the JSON has no per-sample elevation (legacy data), the bridge falls back to [null] for elevation_m and the response carries "elevation_source": "missing".
Response — 200:
{
"track_id": "sonoma",
"name": "Sonoma Raceway",
"track_length_m": 4258,
"step_m": 10,
"elevation_source": "osm_srtm",
"min_elevation_m": 173.2,
"max_elevation_m": 214.8,
"samples": [
{ "distance_m": 0, "elevation_m": 198.4 },
{ "distance_m": 10, "elevation_m": 199.1 },
/* … 426 samples for a 4.26km track at 10m step … */
{ "distance_m": 4250, "elevation_m": 197.9 }
]
}Status codes:
200— profile returned.404—data/tracks/<id>.jsonnot found.422— track JSON exists but has noreference_line(cannot derive a profile).
Frontend consumption. Render as an area chart under the lap-trace plot. Drives the "downhill into T9, watch the entry speed" coaching cue.
Multi-session driver evolution on a track. Built on top of driver_profile.compute_profile (which already tracks events) but exposed as a flat time-series for chart rendering.
Math. Find all sessions driver_id = <id> AND (track = <track> OR <track> unspecified), ordered by started_at ascending.
For each session
-
$T_{\text{best},k} = \min_{j} T_{j,k}$ (best lap time) - $T_{\text{med},k} = \text{median}j T{j,k}$ (median lap time)
- For each sector i:
$T_{\text{pb},i,k} = \min_j S^{j,k}_i$ - Lap count, smoothness score (from
analytics.smoothness_per_corner)
Summary deltas:
For the biggest-corner-gain heuristic: per corner c, regress apex_speed_c against session index. Pick the corner with steepest positive slope:
Response — 200:
{
"driver_id": "taha",
"track": "Sonoma Raceway",
"session_count": 22,
"evolution": [
{
"session_id": "sonoma-raceway-20240301-150000",
"started_at": "2024-03-01T15:00:00Z",
"session_index": 1,
"best_lap_s": 110.45,
"median_lap_s": 113.20,
"lap_count": 12,
"sector_pbs": { "s1": 34.20, "s2": 39.10, "s3": 37.40 }
}
/* … 21 more sessions … */
],
"summary": {
"first_best_s": 110.45,
"latest_best_s": 105.32,
"improvement_s": 5.13,
"biggest_corner_gain": {
"corner": "Turn 11",
"delta_kmh": 8.2,
"from_session_index": 1,
"to_session_index": 22,
"regression_slope_kmh_per_session": 0.43
}
}
}Status codes:
200— evolution data computed.404— driver has zero sessions for the requested track.204— driver has < 5 sessions for the requested track (evolution requires a baseline; the frontend should hide the panel).
Frontend consumption. Render as a multi-line time-series (best lap + median lap + S1/S2/S3 PBs) with the session index on the x-axis. The biggest_corner_gain summary bubbles up to a hero card: "You've gained 8.2 km/h apex speed at Turn 11 since session #1".
final r = await api.get('/driver/$id/evolution', params: {'track': 'sonoma'});
if (r.statusCode == 204) return EvolutionEmptyState(needsMoreSessions: 5 - r['session_count']);
final hero = r['summary']['biggest_corner_gain'];
final chart = LineChart.fromSeries(r['evolution'], xKey: 'session_index', ySeries: [
('Best lap', (s) => s['best_lap_s'], purple),
('Median lap', (s) => s['median_lap_s'], grey),
('S1 PB', (s) => s['sector_pbs']['s1'], red),
('S2 PB', (s) => s['sector_pbs']['s2'], green),
('S3 PB', (s) => s['sector_pbs']['s3'], blue),
]);| Group | Endpoint | Method | Returns |
|---|---|---|---|
| meta | /health |
GET | engine + coach status |
| meta | /insights |
GET | aggregate ML/coaching insights |
| meta | /track/markers |
GET | 16 named markers |
| meta | /track/danger_zones |
GET | 3 known danger zones |
| meta | /track/weather |
GET | weather phase by ?hour_local= |
| meta | /track/<id>/elevation |
GET | NEW elevation profile |
| coach | /analyze |
POST | per-burst coaching |
| coach | /coach/brief |
GET | pre-session narrative |
| coach | /coach/debrief |
POST | post-session bundle |
| lifecycle | /session/start |
POST | open a session row (custom session_id optional) |
| lifecycle | /session/<sid>/end |
POST | stamp ended_at = now() (idempotent) |
| lifecycle | /sessions |
GET | list sessions; ?active_only=true hides ended |
| lifecycle | /session/<sid> |
GET | full detail: session + laps + recent notes |
| ingest | /session/<sid>/frames |
POST | append telemetry batch |
| ingest | /session/<sid>/frame |
POST | NEW append a single frame; returns frame_idx |
| ingest | /session/<sid>/video_frames |
POST | append video frame metadata |
| ingest | /session/<sid>/signals |
POST | ADR-015 append (name, t, value) tuples to tall sink |
| ingest | /session/import |
POST | parse VBO + persist |
| ingest | /session/reset |
POST | clear in-memory bundles |
| sink | /signals/registry |
GET | ADR-015 full signal catalog (54 seeded + discovered) |
| sink | /session/<sid>/capabilities |
GET | ADR-015 per-session signals + coaches_available/disabled (Phase 4) |
| sink | /session/<sid>/capabilities/recompute |
POST | ADR-015 trigger capability recomputation |
| sink | /session/<sid>/signals |
GET | ADR-015 synchroniser: ?names=&axis=&rate_hz=&interp=&t_from=&t_to= |
| analysis | /session/<sid>/sync |
GET | telemetry × video at ±50ms |
| analysis | /session/<sid>/scorecard |
GET | A–F per corner |
| analysis | /session/<sid>/highlights |
GET | top 8 moments |
| analysis | /session/<sid>/stats |
GET | session aggregates |
| analysis | /session/<sid>/friction_circle |
GET | gLat × gLong scatter |
| analysis | /session/<sid>/hustle_map |
GET | per-segment 100% throttle frac |
| analysis | /session/<sid>/eob |
GET | end-of-braking summary |
| analysis | /session/<sid>/incidents |
GET | flagged events |
| analysis | /session/<sid>/map |
GET | track + lap GPS overlay |
| analysis | /session/<sid>/clips |
GET | video clip cuts |
| analysis | /session/<sid>/lap_time_table |
GET | NEW per-lap + sector splits |
| analysis | /session/<sid>/lap_time_distribution |
GET | NEW Tukey box-plot stats |
| analysis | /session/<sid>/ideal_lap |
GET | NEW sum of best sectors |
| analysis | /session/<sid>/sector_times |
GET | NEW thin S1/S2/S3 view |
| analysis | /session/<sid>/pedal_behavior |
GET | NEW 4-state distribution |
| analysis | /session/<sid>/throttle_corner_box |
GET | NEW per-corner throttle box-plot |
| analysis | /session/<sid>/corner_classification |
GET | NEW low/med/high speed bands |
| analysis | /session/<sid>/straight_line_speed |
GET | NEW top speed per straight |
| analysis | /session/<sid>/brake_acceleration |
GET | NEW decel + exit-accel scatter |
| analysis | /session/<sid>/corners |
GET | NEW per-corner aggregates + best pass + optional gold delta |
| profile | /driver/<id>/profile |
GET | event-sourced profile |
| profile | /driver/<id>/evolution |
GET | NEW multi-session time-series (204 if <5 sessions) |
| laps | /lap |
POST | save a completed lap |
| laps | /laps |
GET | list laps |
| roadmap | /score |
POST | NEW Gemini-graded session score (503 without GEMINI_API_KEY) |
| roadmap | /markers |
GET | NEW filterable markers ?corner=&kind= |
| roadmap | /coach/concepts |
GET | NEW 9 Bentley pedagogical concepts with description + fires_when |