Arcade Dashboard Architecture (legacy)
Retired surface. The PySide6 dashboard was replaced by PITWALL and
src/arcade/dashboard/was removed from the repo, along with thePySide6andpyqtgraphdependencies. It survives in git history, and the window's rendered output survives as committed screenshots underdocuments/dev_docs/migration/pitwall/, which is the baseline the port was checked against. This page is kept as historical reference; the paths below no longer exist ondev.Two modules did NOT die with it.
agent_formatters.pyandreasoning_lines.pymoved tosrc/pitwall/, because PITWALL's AGENTS window renders by calling them - which is what makes the port 1:1 by construction rather than by inspection.
Developer-level reference for the PySide6 dashboard that shipped alongside the arcade replay, written for someone extending either window.
Phase 3.5 Proceso B shipped the src/arcade/dashboard/ package (fifteen modules, reasoning_lines.py among them) plus a new src/arcade/strategy_pipeline.py and the rewritten src/arcade/strategy.py::SimConnector. The arcade autolaunched the dashboard subprocess when the user enabled strategy mode; the user never ran a second command.
The window split
- Arcade replay:
pyglet-backed, owned byF1ArcadeView. Drives the simulation loop, owns theStrategyState, runsTelemetryStreamServeron127.0.0.1:9998, and renders the track. - Strategy dashboard:
PySide6MainWindow. Orchestrator card, six sub-agent cards with embeddedpyqtgraphcharts, scenario bars, six-tab reasoning panel. - Telemetry window:
PySide6TelemetryWindow. StandaloneQMainWindowwith a 2x2 grid ofpyqtgraphplots (Delta, Speed, Brake, Throttle) in F1-broadcast style. - PITWALL: a third process,
python -m src.pitwall, opening PITWALL · DATA and PITWALL · AGENTS, two pywebview windows rendering React over the same broadcast, through one shared TCP client. It also serves the same two pages on loopback and logs the URL.
The pyglet window runs in the arcade process. The two Qt windows live together inside one subprocess; PITWALL is another.
Both follower stacks run at once, deliberately. PITWALL is the replacement for the two Qt windows, and keeping the originals alive beside it during the migration is what lets every ported panel be compared against the window it replaces while that window still exists (
_spawn_pitwall,src/arcade/app.py). The Qt pair retires at the end of the migration; this page describes it until then.
Process topology
┌────────────────────────────────┐ ┌──────────────────────────────┐
│ Arcade process (pyglet) │ │ Dashboard subprocess (Qt) │
│ │ TCP │ │
│ F1ArcadeView │◄──────►│ QApplication │
│ ├─ RaceReplayEngine thread │ 127. │ ├─ MainWindow │
│ ├─ StrategyState (_lock) │ 0.0.1: │ │ + TelemetryStreamClt │
│ └─ TelemetryStreamServer │ 9998 │ └─ TelemetryWindow │
│ accept thread │ │ + TelemetryStreamClt │
└────────────────────────────────┘ └──────────────────────────────┘
Two processes, not three. Both Qt windows share the same QApplication event loop and Python interpreter, spawning a third process just to host the telemetry window would cost an extra Python startup (roughly 300 ms), another TCP socket, and another set of imported heavy modules (torch, transformers) with no gain.
The arcade process stays free of PySide6. Importing Qt into the pyglet process would double the memory footprint and couple the two event loops. Keeping Qt out of the arcade is the reason stream.py is stdlib-only; the subprocess launch preserves that separation.
Package layout
theme.py
Colour palette, compound pill colour map, flag chip styles, monospace font stack, and the apply_dark_palette(app) helper. The palette mirrors the arcade window's styles.py. Pirelli compound colours use the canonical hexes (C1 white, C2 yellow, C3 red, INTERMEDIATE green, WET blue).
stream_client.py
TelemetryStreamClient is a QThread that opens a TCP socket to 127.0.0.1:9998, reads newline-delimited JSON payloads, and emits a data_received(dict) Signal on the main Qt thread. Reconnects automatically with exponential backoff; exposes connection_changed(str) so the header bar can light a chip green/red.
window.py
MainWindow composes the strategy surface. A header bar (40 px) shows session label, driver, connection chip, playback chip, and lap counter. A central QSplitter(Qt.Horizontal) holds two panels at 540 / 740. The status bar shows the last error from the stream client.
MainWindow.on_data_received is the fan-out router: pulls latest.per_agent, dispatches each sub-agent dict to the matching card, drives the orchestrator card from latest.recommendation, feeds the scenario bars from latest.scenario_scores, and appends to the reasoning tabs from latest.reasoning_per_agent.
orchestrator_card.py
The flagship card. Four visual elements:
- Action badge, large pill coloured by
classify_action: green STAY_OUT, amber PIT_NOW, cyan UNDERCUT, magenta OVERCUT, red ALERT. - Confidence bar:
QProgressBarwith aqlineargradientstylesheet painting a traffic-light gradient. - Pace and Risk chips, two smaller pills, recoloured per regime.
- Plan strip, one line: "Plan: PIT lap 28, fit C3, target UNDERCUT HAM". The compound is rendered as an inline pill.
- Guardrail line, shown only when the no-LLM hard guard overrode the LLM pick.
agent_card.py
Reusable widget: headline label, body QLabel (rich text with small monospace), and a reserved chart slot. The Pace and Tire cards slot in their pyqtgraph plots via card.set_chart(widget). The Pit and RAG cards dim to 60% opacity when the conditional agent did not fire on the current lap.
agent_formatters.py
Six pure functions: format_pace, format_tire, format_situation, format_pit, format_radio, format_rag. Each takes a sub-agent output dict and returns (headline: str, body_lines: list[str]). Mirrors the CLI inference panel section rules; keeping the mapping pure means the formatters are importable anywhere and unit-testable without Qt.
pace_chart.py and tire_chart.py
pyqtgraph.PlotWidget subclasses embedded in their cards.
PaceChartplots actual lap time, predicted lap time, and a shaded P10/P90 confidence band per lap.TireChartplots tyre life by compound with vertical lines at stint boundaries and horizontal dashed lines at the estimated cliff laps.
Both charts scroll the x-axis to show the last 20 laps.
scenario_bars.py
Four horizontal bars for STAY_OUT / PIT_NOW / UNDERCUT / OVERCUT. The raw Monte Carlo scores are sometimes negative; the widget shifts the minimum to zero, then normalises to [0, 1], so the visual remains readable while the tooltip exposes the raw score.
reasoning_tabs.py
QTabWidget with six tabs: Pace, Tire, Situation, Radio, Pit, RAG. Each tab is a QTextEdit with a QSyntaxHighlighter subclass that paints regex patterns in accent colours, compound codes, flag keywords, numbers with units, and action verbs.
telemetry_panel.py and telemetry_window.py
The 2x2 grid of telemetry plots. Lives in its own module so TelemetryWindow can host it as the central widget without pulling in the strategy-side imports.
Wire protocol
The arcade broadcasts one JSON dict per frame, roughly 10 Hz, as a newline-terminated payload.
The JSON below is a historical example and no longer describes the wire. It is the shape the Qt dashboard consumed before that dashboard was retired in 7ea6a7a6: a single telemetry sample per car under the role keys main and rival, plus session, driver, driver2 and delta_s keys the producer does not emit. The current contract is the table under it, and the frozen shape lives in tests/surfaces/test_arcade_wire_contract.py.
{
"arcade": {
"session": { "year": 2025, "round": 3, "location": "Suzuka", "lap": 18, "total_laps": 53 },
"driver": { "code": "VER", "team": "Red Bull Racing", "position": 2 },
"driver2": { "code": "LEC", "team": "Ferrari", "position": 4 },
"telemetry": {
"main": { "speed": 312.4, "throttle": 0.98, "brake": 0.0, "gear": 7, "drs": true },
"rival": { "speed": 308.1, "throttle": 0.95, "brake": 0.0, "gear": 7, "drs": false }
},
"delta_s": -0.412
},
"strategy": {
"latest": {
"lap": 18,
"recommendation": { "action": "STAY_OUT", "confidence": 0.73, "pace_mode": "NEUTRAL", "risk": "BALANCED" },
"scenario_scores": { "STAY_OUT": 0.21, "PIT_NOW": -0.14, "UNDERCUT": 0.08, "OVERCUT": -0.02 },
"per_agent": {
"pace": { "lap_time_pred": 93.42, "ci_p10": 92.81, "ci_p90": 94.03 },
"tire": { "laps_to_cliff_p50": 12, "warning_level": "MONITOR" },
"situation": { "overtake_prob": 0.42, "sc_prob_3lap": 0.11 },
// overtake_prob is null when the car ahead is farther than the 2.5s gap the
// model was trained on; the dashboards render that as "—", never as 0%.
"pit": { "action": "HOLD", "compound_recommendation": "C3" }
},
"reasoning_per_agent": { "pace": "...", "tire": "..." },
"active": ["pit"],
"guardrail_override": false
}
},
"playback": { "state": "PLAY", "speed": 1.0 }
}
Top-level keys: arcade, strategy, playback. The dashboard's fan-out router reads from these three roots and nothing else, extending the protocol is additive.
What the wire gained for PITWALL
Every one of these is additive (the Qt dashboard ignores them and is unaffected), and each exists because a consumer would otherwise have to re-derive it and drift from the producer:
| Field | What it carries |
|---|---|
arcade.race_order |
every published driver, best first, ranked by the arcade leaderboard's OWN code, so the wire and the panel cannot disagree |
arcade.drivers.<code>.laps_completed |
laps completed off the crossing map. The reveal carrier: show lap L for a driver iff L <= laps_completed |
arcade.drivers.<code>.progress |
laps plus fraction, the ordering coordinate. null when the telemetry never placed the car |
arcade.drivers.<code>.has_finished |
chequered flag versus retirement, from FastF1's official classification. active alone reads the winner as retired |
arcade.drivers.<code>.active / .rel_dist / .has_position |
on track, fraction of the current lap, and whether the car was ever placed at all |
arcade.driver_colors |
per-driver RGB from the arcade's own palette, published so no consumer hardcodes a sixth copy of it |
arcade.track_status |
FastF1 TrackStatus digits for the lap on screen |
arcade.telemetry.drivers |
a span of samples since the last tick, oldest first, per driver code, not one point, and not just the pinned pair. At 8x, one point per tick discarded 95% of the trace. Schema v2 replaced the role keys main/rival with the whole grid so a consumer can chart any car without asking the producer to publish it; read the old pair as drivers[driver_main] and drivers[driver_rival] |
arcade.telemetry.rewound / .dropped |
the eviction signals: a backwards seek, and frames a forward jump could not carry |
arcade.global_t_min / .location |
the session-time origin, and FastF1's authoritative Location for resolving the race directory |
schema_version / seq |
the payload version, and a strictly increasing sequence per message SENT, which is what lets two consumers on independent timers agree on a frame |
Bumping CACHE_VERSION to v12 came with these; the first launch of a given GP after upgrading rebuilds its session pickle.
Extension points
Adding a new sub-agent card
- Extend the wire protocol: add the new agent's output dict under
strategy.latest.per_agent.<name>. - Add a formatter in
agent_formatters.py(format_<name>(payload) -> (headline, body_lines)). - Instantiate an
AgentCard(title=...)insideMainWindow._build_right_panel. - In
MainWindow.on_data_received, dispatch the new payload into the formatter and callcard.set_content(headline, body). - If the card needs a chart, create a
pyqtgraphsubclass followingpace_chart.py.
Changing the palette
theme.py is the only place colour literals live. Every widget imports ACCENT, DANGER, SUCCESS, WARNING, TEXT_SECONDARY, and the compound colour map from there. A full retheme means editing theme.py plus the matching styles.py on the arcade side.
Threading model
Five threads cooperate.
- Arcade main thread (pyglet), runs the
F1ArcadeView.on_updatetick, mutates theStrategyStateunder_lock, callsTelemetryStreamServer.broadcast(snapshot). - SimConnector background thread, iterates
RaceReplayEngine.replay()and callsrun_strategy_pipeline(race_state, laps_df, lap_state, memory=self._memory)per lap. Passinglap_stateis not optional: with itNonethe Monte Carlo falls back to the legacy seconds path and the projection layer is silently amputated, which is the one call shape these pages used to show. One thread, one lap at a time, so the main pyglet thread never blocks on LLM inference. - TelemetryStreamServer accept thread, daemon thread inside the arcade process. Blocks on
server_socket.accept(). - Qt main thread (dashboard subprocess), runs
QApplication.exec(). Drives all UI updates. Never touches the network. - QThread stream clients (one per top-level Qt window), each owns its own TCP socket.
The StrategyState._lock is the only contended mutex. Neither thread holds it across I/O.