Skip to main content

Overview

One page of commands for every action an operator runs against the local Watchparty stack. Each entry is a pointer, not the full story: the repository’s docs/OPERATOR_CHEAT_SHEET.md is the source copy of this page, and docs/LIVE_DAY_RUNBOOK.md and ops/local/README.md hold the detail. All commands run from the repository root in PowerShell.
Everything targets the DEV stack. PROD holds approved datasets only and is populated from a DEV export, never written directly. Every ops/local script prints a TARGET: line first: read it before continuing.

Before Anything

Stacks and Ports

Load the Environment Into a Shell

Scripts under scripts/ read DATABASE_URL (some read DATABASE_DSN) from the shell. Do this once per PowerShell window:
Every script also accepts --database-url <dsn>. The DEV DSN is postgresql://vpw:vpw@localhost:5433/vpw_dev.

Stack Up, Status, Down

The data volume is preserved on down.

psql Into DEV

Migrate DEV by Hand

The launchers migrate for you. By hand, only when you need it without launching:

Backfill a Weekend From FastF1

Downloads one weekend’s sessions from the FastF1 archive and writes them through the normal normaliser. Rows land under synthetic session keys of the form year, round, code index (FP1 00, FP2 01, FP3 02, SQ 03, S 04, Q 05, R 06) with source = fastf1_backfill. A live OpenF1 copy of the same session keeps its own key and is never touched; the two converge in the picker through the canonical identity map.
  • --session-codes names exactly which sessions to fetch. Omit it and the script derives the full weekend from the FastF1 schedule, which fails loudly for sessions that have not happened yet. The rows that exist still land; the exit code is non-zero.
  • Round numbers are FastF1 rounds. 2026: Zandvoort 12, Monza 13, Madrid 14.
  • By default the script also precomputes each session’s insight outputs, and for a race or sprint its strategy outputs. --skip-insights and --skip-strategy turn those off and leave the session data-only, with a loud analytics-incomplete warning. Compute later from the Analytics section if you skipped.
  • Repeat --year N --round M pairs to do several weekends in one run.
  • Cache: data/fastf1_cache, override with --cache-dir. A warm cache makes a re-download fast.
  • Re-running an already backfilled session is a no-op: the backfill manifest records finished endpoints and the script skips them. There is no force flag; see Refresh below.
  • Typical time: a few minutes per practice session on a warm network, ten or more for a race with full telemetry.
FastF1 has no HTTP timeout. A run whose output has stopped growing for ten minutes is hung, not slow. Kill it and re-run; the manifest resumes from the last completed endpoint.

Refresh a Weekend

Use when the source data changed or a partial run went wrong. Clears the weekend’s synthetic keys from every backfilled table plus its manifest rows, then backfill again. Dry run by default; live OpenF1 keys are out of scope and survive.
--include-analytics also drops the weekend’s metric outputs and event parameters. Prod refuses without --allow-prod.

Verify

In psql:

Launch the Demo

The replay viewer for archived weekends.
1

One-time setup

On a cold machine, or after a data wipe. Brings up infra, installs Python dependencies into .venv, migrates, seeds, and backfills the two reference weekends (2025 Abu Dhabi round 24, 2026 Miami round 4) data-only. Needs Docker Desktop running, a repo-root .venv, and npm install already done in apps/pitwall-web.
2

Every demo

Ensures infra is up and the database has sessions, then opens three windows: replay, gateway and the web server.
Open http://localhost:5174. launch-demo.bat -Env dev is the same thing for a double-click launch or a snapshot recipient.
3

Stop

Close the three windows.
Replay only publishes server-computed insight channels for a session that has been precomputed (see Analytics). Sessions that have not been precomputed still replay; the webapp falls back to client-side derivations.

Launch Live Ingest

Full detail, including what LIVE looks like in the app and recovery steps, lives in the repository’s docs/LIVE_DAY_RUNBOOK.md.

The Week Before

None of this needs a session row yet.
Optional priors from a comparable precomputed race: python scripts\build_strategy_priors.py --source-session <session_key> --year 2026 --round 14 --dry-run, then drop --dry-run to write.

Pre-Weekend Docker and WSL Guard

Do this before every live weekend. Full detail is in the repository’s docs/LIVE_DAY_RUNBOOK.md. The preflight inside launch-live.ps1 also warns (it never blocks) on the two machine settings below, read-only. If the Store auto-update policy is absent it reads as a note rather than a warning; the winget upgrade check below is the interim mitigation.
Docker Desktop Resource Saver: Settings (gear icon) -> General -> turn off “Enable Resource Saver” (older versions: Settings -> Resources).

Pre-Weekend Sleep and Power

The ingestion window and the insights and strategy supervisors each hold a Windows keep-awake lock while they run, which stops idle sleep and Modern Standby. The screen may still turn off.
The keep-awake lock cannot stop a lid close or a manual Sleep. Before every live weekend: keep the lid open, stay on mains power for the whole session, and set sleep to Never on both mains and battery.
The preflight inside launch-live.ps1 warns (it never blocks) when sleep is not set to Never on mains or on battery, and when the machine is running on battery. Full detail is in the repository’s docs/LIVE_DAY_RUNBOOK.md, “Pre-weekend sleep and power”.
If no python.exe appears under SYSTEM while live, the lock is not held (look for a keep-awake warning in that window’s log) and the sleep settings are the only protection.

Start

Brings compose up, migrates, then opens five windows: gateway, web, insights supervisor, strategy supervisor, ingestion. OpenF1 credentials are read by key name from $HOME\Desktop\openf1.env (OPENF1_USERNAME=... and OPENF1_PASSWORD=..., order independent) and handed only to the ingestion window. The launcher refuses to start while an ingestion is already running or the gateway or web port is busy, and names the PID. Close the earlier windows or run -Down first.
-Year 2026 restricts the session watcher. -VenvPath <dir> borrows another checkout’s venv when running from a worktree. The raw archive lands in C:\Projects\vpw-captures\live-raw\<env>\<stamp> and strategy solve packages in C:\Projects\vpw-captures\strategy-archive\<env>\<stamp>.

Logs and Heartbeat

Every one of the five windows records its own output to <raw archive>\logs\<service>.log (gateway, web, insights-supervisor, strategy-supervisor, ingestion), so closing a window no longer loses what it printed. Ingestion also writes <raw archive>\heartbeat.log every 60 s. Each log opens with [start] at <UTC> and ends with [exit] code=<n> at <UTC> when the process stops. The web window alone restarts Vite if it exits: 3 s pause, logs [restart] attempt <k>, gives up after 5 restarts in 10 minutes, and Ctrl+C stops it for good. When reporting a live-day problem, attach the run folder first (C:\Projects\vpw-captures\live-raw\<env>\<stamp>). It carries the logs and the heartbeat alongside the raw payloads, so it is a complete record of what that launch actually did.

MQTT Client Id

Defaults to vpw-live-ingest-<computername>, printed at launch. To use a specific id, export it before launching. It must stay stable across restarts and never be shared with another install.

T-20 Minutes

Read the solve_vs_lap line of the strategy preflight. It compares the archived lap-0 solve wall time (timings.total_s in output_manifest.json) with the fastest driver’s base-pace lap in the uploaded race_data.json, and warns above 60% of a lap. A WARN means live solves will lag laps and be abandoned (Singapore 2026: 72 s against laps of about 90 s at ctl.number_repeats 100). Lower ctl.number_repeats in the file (50 fixed Singapore) and re-upload with load_race_data.py (see Race Data Template and the Lap-0 Solve). It never fails the preflight, and load_race_data.py prints the same line straight after the lap-0 solve. “skipped: no lap-0 solve found” means the archive holds none for this key (pass --archive-dir if it lives elsewhere); “cannot check: no base pace in race_data” means the file has no usable sector base paces. If the identity preflight reports an unresolved meeting, add the printed entry to services/normalisation/src/vpw_normalise/reference/openf1_meeting_rounds.json, then run --restamp. Without it the strategy child resolves no round parameters. The session row appears in the picker about 60 seconds after OpenF1 publishes the session. If a race_data.json was uploaded for this round, promote its lap-0 rows onto the live session key now, straight after the --restamp. See Race Data Template and the Lap-0 Solve below.
Do not start the strategy supervisor window for a sprint. The parameter rows describe the grand prix, so a sprint solve would run on the wrong distance and fuel load.

Stop and Restart

Close each service window (Ctrl+C), then stop compose:
To restart one child mid-session, close its window and re-run just that child’s block from launch-live.ps1, or re-run the whole script; the compose and migrate steps are idempotent and ingestion resumes from its persisted checkpoints.

Restart the Live Strategy Child

Only for a wedged child. A late or newer strategy: or race_data: row is adopted from the next lap by itself, so a restart is not needed to reload parameters. Never kill process trees by hand.
It drops a flag file; the strategy supervisor notices on its next scan (up to 30 s), stops the child (its engine trees die with it), and respawns it on the scan after, not counted as a crash. The new child catches up silently and offers one lap, so there is no flood of past laps. With --wait-s the script follows strategy-supervisor.log (newest run, or --log <path>) and prints the restart, respawn, warm-up and “race_data template pinned” or “newer parameters adopted” lines. The flag folder is %TEMP%\vpw-strategy-restart, or the folder named by VPW_STRATEGY_RESTART_DIR if set (the supervisor reads the same variable).

Post-Session Verification


Backfill or Repair From OpenF1

There is no OpenF1 historical backfill. MQTT live is the only processed OpenF1 path; REST polling runs alongside as an archive-only witness. What exists is repair from the raw archive that live ingestion wrote. Every repair is a dry run until --apply, routes through the real normaliser mapping, and never deletes.
<raw dir> is the raw tree under the live-raw stamp directory for that day. --raw-dir and --session-key can repeat. The gmt_offset repair is the one entry here that needs no raw archive: it looks each session’s circuit-local UTC offset up by that session’s own key, from OpenF1 for live rows or from the FastF1 event schedule for backfilled rows, and writes that single column. A session the lookup cannot resolve is reported and left NULL, never guessed. Every clock in the webapp reads track-local time from this column, falling back to browser-local time only when it is absent.

Raw-Only Capture

Records OpenF1 REST to disk for later analysis and writes nothing to the database:
Capture rehearsal through launch-live.ps1 -Capture is retired: the capture server is HTTP-only and ingestion now requires MQTT.

Parameters

Parameters are versioned rows, never constants. Loads are append-only: identical values are a no-op, changed values write a new active version.
Reload the fuel row after Saturday qualifying once the real Q time is in the CSV. Stored insights computed on the old row are stale until recomputed. load_default_parameters.py changes the value for every track; load_circuit_parameters.py changes it for one, and wins over the global value at that circuit. Precedence runs defaults, then circuit, then round, then session. Find a circuit key with SELECT DISTINCT circuit_key, country_name FROM sessions WHERE year = <y>. Pass every pin you want that circuit to keep in the same command: a key you do not pass keeps the circuit’s existing pin if it has one, otherwise it is filled from the global row. --deactivate drops the circuit’s pin so the global value applies again.
Neither command rewrites rows that are already stored. A parameter change takes effect at the next precompute or live run, so re-precompute the sessions you care about (see Analytics After Data Lands).
race_data:{year}:{round:02d} is a parameter family too, but it is loaded from the owner’s uploaded file rather than a CSV, and it carries a lap-0 solve with it. It has its own section below.

Race Data Template and the Lap-0 Solve

The owner’s race_data.json for one round. Uploading it does four things in a single command: validates the file as a template, writes race_data:{year}:{round:02d} to event_parameters (append-only, like every other parameter family), creates a provisional race session YYYYRR90 if the real one does not exist yet, and solves lap 0 into the strategy archive so a pre-race prediction is stored before a wheel turns. Keep uploaded files at C:\Projects\vpw-captures\race-data-uploads\<year>\<round>-<circuit>\race_data.json, with a sha256.txt beside each. The parameter row carries the whole uploaded file plus race_data_source = {filename, sha256}, so a file on disk can always be matched to the version that ran.
The race_data.json written into the solve archive is a derived file: gaps are re-based to the field minimum and a platform block is added. It is not the uploaded file, so the archive is not a backup of the original. The complete copy of the upload lives in the event_parameters row and in whatever you keep on disk.
ctl.num_strategies is optional (whole number, at least 1, default 5) and sets how many ranked strategy categories the engine carries per driver.

Validate First

No database connection and no writes.
It refuses on any problem and lists all of them at once. Warnings print and do not block.

Upload and Solve Lap 0

Windows only, because the lap-0 solve runs the engine bundle.
Straight after the lap-0 solve the script prints one OK: or WARN: line comparing the solve time with the base-pace lap (60% limit), naming ctl.number_repeats as the lever when it warns. It never changes the exit code. A running live strategy child picks up a late strategy: or race_data: load, or a newer version of one, from the next lap it processes (one INFO log line names the old and new versions); no restart is needed. If a driver in the file will not start (late driver change), re-upload the file without them before the race: a template driver who never posts a lap holds the opening solves until lap 3. --race-date <iso> sets the provisional session’s timestamp, defaulting to qualifying plus one day. --archive-dir, or the VPW_STRATEGY_ARCHIVE_DIR environment variable, moves the solve archive off the default .\strategy-archive. Re-running clears this service’s own strategy_* rows at trigger lap 0 first, so a corrected file can be re-uploaded safely, and the parameter row gains a new version rather than overwriting the old one. Confirm the row landed with python scripts\strategy_preflight.py --year 2026 --round 14. The race_data_row check is a hard failure in round mode. These lap-0 rows survive a later precompute_strategy.py run, so the upload and the precompute can be done in either order.

Associate the Lap-0 Rows With Every Race Key

The lap-0 solve runs once per round, never once per session key. At upload time load_race_data.py copies its own lap-0 rows onto every other non-provisional race session key of the round, so opening either the OpenF1 live key or a FastF1 backfill twin in the picker shows lap 0. A backfill twin that only appears after the upload, which is the usual case because FastF1 keys land after the race weekend, has nothing to copy from at upload time; precompute_strategy.py picks this up for its own --session-key before it replays, so running the precompute against the twin associates it. If neither has run for a given twin yet, repair it directly.
Copies only, never a second solve and never a delete, so it is always safe to re-run.

Promote at T-20

Once OpenF1 has published the real race session and identity_preflight.py --restamp has stamped it, move the lap-0 rows off the provisional key onto the live one and remove the provisional session. Both happen in one transaction.
It refuses unless the live key resolves to (year, round, R) through a non-provisional identity map row. It also renames the archive directory from the provisional key to the live one, but only within the root it is given, so pass the same --archive-dir the upload used. Running python scripts\strategy_preflight.py --session-key <sk> warns if a stale provisional session is still present for the round.

Analytics After Data Lands

A pass with no --session-key covers every stale session, which on a fresh environment means every session anyone has ever excluded a lap on, because the reconcile bookkeeping table starts empty. Name the session when only one is meant.
--session-key narrows a pass, it never forces one. A named session that is not stale is reported and left alone, and a named session whose newest lap is under an hour old is deferred exactly as it would be in a full pass, because clearing its rows under a live engine would leave a permanent hole in the timeline. To force a recompute regardless of staleness, use precompute_insights.py --session-key <sk>.
Re-run a precompute after any model version bump for a metric it covers, otherwise replay skips that metric rather than publish two versions. --keep-existing skips the clear step and is only safe when no version changed. Precompute only the sessions being looked at; no historical sweeps during active development. To time the real precompute path or compare its output against what is already stored, with zero database writes (metric_outputs untouched, no rows cleared):
Rows land in out.jsonl as one canonical JSON line each, in the metric_outputs column shape, and the run ends with a per-metric row-count table. --output-file and --keep-existing cannot be combined. A full race precompute takes about five minutes rather than 100.

What-If Studies on the Strategy Side

To try a different race-data file on a stored race, or just a timing and comparison run, with zero database writes (no clear, no lap-0 association, no metric_outputs write at all):
The session’s real stored laps still drive the replay (the database is read for those, and for any manual stint-compound corrections); only the engine’s own per-lap input changes to the given file instead of the database’s resolved parameters. Outputs land under the studies\ folder inside --archive-dir (default .\strategy-archive, or VPW_STRATEGY_ARCHIVE_DIR): the collected rows as JSON Lines at studies\<stem>.jsonl and the per-lap packages at studies\<stem>\<session_key>\lap_NNN\. The stem encodes the session key, the file’s own name, the first 12 hex characters of its sha256 and the run’s UTC start time, so two runs more than a second apart never collide. Both paths print at the end. Nothing prunes these study folders; delete them once a study is finished. --output-file PATH overrides only the JSONL path, and an existing non-empty file at that path is refused rather than overwritten. --output-file alone (no --race-data) is also a no-write study: the engine’s per-lap input stays the database’s own resolved parameters, only the output moves to a file. Neither study flag can be combined with --keep-existing or --clear-all (exit 2): those two only mean something for a database write, which a study never makes. A study replays from lap 1 and never produces the lap-0 pre-race row, so its row count is two short of a stored session’s when compared row for row.
The lap-0 pre-race rows from an uploaded race_data.json survive a strategy re-precompute: the clear spares trigger='pre_race'. They are removed anyway, with a warning, if they carry a different model version from the run, because a metric holding two versions is skipped by replay altogether; rebuild them with load_race_data.py when that happens. --clear-all deletes them deliberately.

Check a Race Against the FIA Timing PDFs

The FIA publish two timing documents per race: the Race Lap Analysis and the Race History Chart. Between them they give every car’s lap time and the clock time at which it completed lap 1, so comparing them against stored rows is the quickest proof that a weekend ingested cleanly. Read-only against the database. Keep the PDFs with the round’s other uploads in C:\Projects\vpw-captures\race-data-uploads\<year>\<round>-<circuit>\, alongside race_data.json and sha256.txt. PDFs dropped at the repository root are gitignored so they cannot be committed by accident.
Laps 2 onwards should be exact, to the millisecond. Madrid 2026 matched 1083 of 1083. Anything else is a real ingestion gap worth chasing. Lap 1 always differs, by roughly +0.3 s to +0.5 s, a different amount per car. This is upstream and expected: OpenF1 times lap 1 from the shared lights-out instant, the FIA from each car’s own start-line transponder trigger. The start check confirms which of the two is at fault and prints a verdict line. At Madrid it pinned the stored race start to within 61 ms, so there was nothing to fix on our side.
The race history chart is indexed by the leader’s lap, not by each car’s own lap. A lapped car simply has no row for the lap it lost, so its later rows sit one index higher than its own lap count. The scripts handle this; a hand comparison that ignores it produces a few hundred false differences.

New Circuit

Needs real car location rows from a session at the venue. Read-only against the database; it prints a paste-ready registry entry. Two cases: Circuit already raced in a backfilled season (the normal case): fit it before the weekend from the previous year’s Race session, which has 300k to 460k location rows. Race sessions do not need --laps-only. Paste the entry into apps/pitwall-web/src/circuits/registry.ts keyed by the verbatim backfill circuit name only. Then, once the first live OpenF1 row of the weekend has landed (FP1), add the one-line circuit key entry pointing at the same constant, quoting the observed session key. Never invent the key beforehand. In the same pass, run the identity preflight from the T-20 steps (identity_preflight.py, then --restamp): a new weekend’s OpenF1 meeting key is not in the round map until you add it, and until the live row is stamped the picker shows the live session and its FastF1 backfill as two separate rows instead of one slot with two badges (Baku 2026 FP1).
Brand-new venue (Madrid 2026 was the first): nothing to fit until FP1 has run, so this happens mid-weekend. Practice data needs --laps-only or garage and pit-lane points wreck the fit.
A sane fit has a median residual under about 5 m and an extent ratio near 1.0; anything else is left out of the registry (the widget degrades explicitly) rather than committed. Geojson ids are the filenames under apps/pitwall-web/public/circuits/circuits/.

Measure the Start Offset for a Track Definition

startOffset is the fraction [0, 1) of the vendored geojson outline’s own arc length at which the real start/finish line sits, so the Track Dominance widget can rotate the painted elements to line up with it (the outline’s first point is not necessarily the line). Measure it from the previous year’s Race session on DEV: take the race winner (the driver with the most completed laps), read laps 10 to 14, and take the median of the five per-lap fractions. Read-only against DEV. --calibration takes the circuit’s committed registry constants as scale,rotationRad,tx,ty[,mirrored] and --geojson its outline file. The printed “median fraction” is what goes into the definition’s startOffset.
The worked example is Baku from the 2025 race. Five of five laps should measure; a spread of more than about 0.005 between the per-lap fractions means the calibration or the lap starts need a second look before the median is committed.

Measure Sector Lines for a Track Definition

The Telemetry and Track Dominance widgets pin every lap’s own S1 and S2 positions (its integrated distance at the official cumulative sector times) to a circuit’s canonical sectorLinesM in apps/pitwall-web/src/circuits/definitions/*.json. The lines are not published anywhere, so they are measured from stored laps: the median length-scaled S1 and S2 distance across every timed lap with all three sector times. Read-only against DEV.
Pass several --session-key values to pool laps across sessions of the same circuit; --track-length-m comes from the definition’s own trackLengthM, never assumed. The printed s1_m and s2_m are what goes into sectorLinesM; the printed p10 and p90 spread for each line is worth checking before committing a wide band. A band of tens of metres or more means degraded telemetry, not a genuinely wide sector line (2026 Budapest is the known example: re-run against a healthy season’s sessions rather than commit it). The session keys used for each committed circuit are recorded in that circuit’s sectorLinesM commit message.

Backups and Promotion

Promotion of an approved dataset is an export from DEV restored into PROD through the same two scripts. The restore prints Every table matches the manifest (...) when every count matches.

Gates Before Committing or Merging

The full gate replays one random real golden fixture; pin it with $env:VPW_GOLDEN_SESSION = 11353 while other fixtures are stale.

Default Webapp Layout

The webapp’s default workspace (tabs, open widgets, window geometry) is the owner’s own captured layout, held verbatim in apps/pitwall-web/src/store/defaultWorkspace.json. Re-capturing it is a paste, not a code change.
1

Copy the layout out of the browser

On the Watchparty tab you actually use, press F12, open the Console, and run the line below. Make the tab you want the workbook to open on the active one first, because the capture records it.
2

Paste it over the JSON file

Replace the whole contents of defaultWorkspace.json with the clipboard. Pretty-printing is fine; the values must stay exactly as captured, so never tidy the file by hand.
3

Run the webapp tests and commit

The tests prove the default equals the pasted blob as loaded, then the usual gate applies.
A browser that already holds a stored workspace keeps it. The new default only shows on a first-ever load and after Reset layout in the transport footer. Each origin (port) has its own storage, so capture from the build you actually use.


Next Steps

Data Journey

How live and backfilled data travel the same pipeline.

Insights and Versioning

Why a precompute must be re-run after a model version bump.
Last modified on October 16, 2026