Arcade Dashboard Architecture (legacy)

Retired surface. The PySide6 dashboard was replaced by PITWALL and src/arcade/dashboard/ was removed from the repo, along with the PySide6 and pyqtgraph dependencies. It survives in git history, and the window's rendered output survives as committed screenshots under documents/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 on dev.

Two modules did NOT die with it. agent_formatters.py and reasoning_lines.py moved to src/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

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:

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.

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

  1. Extend the wire protocol: add the new agent's output dict under strategy.latest.per_agent.<name>.
  2. Add a formatter in agent_formatters.py (format_<name>(payload) -> (headline, body_lines)).
  3. Instantiate an AgentCard(title=...) inside MainWindow._build_right_panel.
  4. In MainWindow.on_data_received, dispatch the new payload into the formatter and call card.set_content(headline, body).
  5. If the card needs a chart, create a pyqtgraph subclass following pace_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.

The StrategyState._lock is the only contended mutex. Neither thread holds it across I/O.