Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ frontend/out/
.fastf1-cache/
/data/
backend/data/sessions/
backend/data/live_test/

# Environment
.env
Expand Down
129 changes: 129 additions & 0 deletions backend/scripts/download_test_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Download .jsonStream files for a past session into backend/data/live_test/.

The live router's test replayer reads these files to simulate a live SignalR
stream without a real session running. See services/live_test_replayer.py.

Usage:
python backend/scripts/download_test_session.py --year 2024 --round 1 --session R
"""

from __future__ import annotations

import argparse
import sys
import time
import urllib.request
import urllib.error
from pathlib import Path

import fastf1

BASE = "https://livetiming.formula1.com"

# Mirror of live_signalr._TOPICS — files we expect to exist on the static API.
STREAM_TOPICS = [
"TimingData",
"TimingAppData",
"TimingStats",
"DriverList",
"RaceControlMessages",
"TrackStatus",
"WeatherData",
"LapCount",
"ExtrapolatedClock",
"SessionInfo",
"SessionStatus",
"SessionData",
"Position.z",
]

# Initial state .json files the replayer loads at t=-1 (see _SAFE_INIT_TOPICS).
INIT_TOPICS = [
"DriverList",
"TimingAppData",
"WeatherData",
"TrackStatus",
"SessionInfo",
]


def _fetch(url: str, dest: Path) -> int:
req = urllib.request.Request(url, headers={"User-Agent": "f1timing-test-downloader/1.0"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = resp.read()
except urllib.error.HTTPError as e:
if e.code == 404:
return 0
raise
dest.write_bytes(data)
return len(data)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--year", type=int, required=True)
parser.add_argument("--round", type=int, required=True)
parser.add_argument("--session", default="R", help="Session code: R, Q, S, SQ, FP1, FP2, FP3")
args = parser.parse_args()

session = fastf1.get_session(args.year, args.round, args.session)
api_path = session.api_path # e.g. /static/2024/2024-03-02_Bahrain_Grand_Prix/2024-03-02_Race/
base_url = BASE + api_path

out_dir = (
Path(__file__).resolve().parent.parent
/ "data"
/ "live_test"
/ f"{args.year}_{args.round}_{args.session}"
)
out_dir.mkdir(parents=True, exist_ok=True)

print(f"Downloading from {base_url}")
print(f"Saving to {out_dir}")
print()

total_bytes = 0
skipped = []

for topic in STREAM_TOPICS:
url = f"{base_url}{topic}.jsonStream"
dest = out_dir / f"{topic}.jsonStream"
try:
n = _fetch(url, dest)
except Exception as e:
print(f" {topic:<22} ERROR {e}")
continue
if n == 0:
skipped.append(topic + ".jsonStream")
print(f" {topic:<22} 404")
else:
total_bytes += n
print(f" {topic:<22} {n / 1024:>10.1f} KB stream")
time.sleep(0.1)

for topic in INIT_TOPICS:
url = f"{base_url}{topic}.json"
dest = out_dir / f"{topic}.json"
try:
n = _fetch(url, dest)
except Exception as e:
print(f" {topic:<22} ERROR {e}")
continue
if n == 0:
skipped.append(topic + ".json")
print(f" {topic:<22} 404")
else:
total_bytes += n
print(f" {topic:<22} {n / 1024:>10.1f} KB init")
time.sleep(0.1)

print()
print(f"Total: {total_bytes / 1024 / 1024:.2f} MB across {len(STREAM_TOPICS) + len(INIT_TOPICS) - len(skipped)} files")
if skipped:
print(f"Skipped (404): {', '.join(skipped)}")
return 0


if __name__ == "__main__":
sys.exit(main())
71 changes: 46 additions & 25 deletions frontend/src/app/live/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,37 @@ interface SessionData {
}>;
}

const DELAY_MIN = -300;
const DELAY_MAX = 10;
const DELAY_TICK_PERCENT = (-DELAY_MIN / (DELAY_MAX - DELAY_MIN)) * 100;

function clampDelay(value: number): number {
return Math.max(DELAY_MIN, Math.min(DELAY_MAX, Math.round(value * 2) / 2));
}

function formatDelayValue(s: number): string {
if (Math.abs(s) < 60) return s.toFixed(1);
const sign = s < 0 ? "−" : "+";
const abs = Math.abs(s);
const m = Math.floor(abs / 60);
const sec = Math.round(abs % 60);
return `${sign}${m}:${String(sec).padStart(2, "0")}`;
}

function formatDelayUnit(s: number): string {
return Math.abs(s) < 60 ? "seconds" : "min:sec";
}

function formatDelayShort(s: number): string {
if (s === 0) return "0s";
if (Math.abs(s) < 60) return `${s > 0 ? "+" : ""}${s}s`;
const sign = s < 0 ? "−" : "+";
const abs = Math.abs(s);
const m = Math.floor(abs / 60);
const sec = Math.round(abs % 60);
return sec === 0 ? `${sign}${m}m` : `${sign}${m}:${String(sec).padStart(2, "0")}`;
}

export default function LivePage() {
const searchParams = useSearchParams();
const year = Number(searchParams.get("year"));
Expand Down Expand Up @@ -617,7 +648,7 @@ export default function LivePage() {
: "bg-f1-dark border-f1-border text-f1-muted hover:text-white"
}`}
>
Delay: {delayOffset > 0 ? "+" : ""}{delayOffset}s
Delay: {formatDelayShort(delayOffset)}
</button>
{showDelaySlider && (<>
{/* Modal backdrop */}
Expand All @@ -637,17 +668,17 @@ export default function LivePage() {
<div className="px-3 sm:px-5 py-3 sm:py-4 space-y-3 sm:space-y-4">
{/* Current value display */}
<div className="text-center">
<span className="text-3xl font-extrabold text-white tabular-nums">{delayOffset.toFixed(1)}</span>
<span className="text-lg text-f1-muted ml-1">seconds</span>
<span className="text-3xl font-extrabold text-white tabular-nums">{formatDelayValue(delayOffset)}</span>
<span className="text-lg text-f1-muted ml-1">{formatDelayUnit(delayOffset)}</span>
</div>

{/* Slider */}
{/* Slider with zero tick mark */}
<div className="relative">
<input
type="range"
min={-60}
max={10}
min={DELAY_MIN}
max={DELAY_MAX}
step={0.5}
value={delayOffset}
onChange={(e) => setDelayOffset(Number(e.target.value))}
Expand All @@ -656,42 +687,32 @@ export default function LivePage() {
{/* Zero tick — positioned absolutely over the slider */}
<div
className="absolute pointer-events-none z-20"
style={{ left: `calc(${(60 / 70) * 100}% - 6px)`, top: "calc(50% + 3px)", transform: "translate(-50%, -50%)" }}
style={{ left: `calc(${DELAY_TICK_PERCENT}% - 7px)`, top: "calc(50% + 3px)", transform: "translate(-50%, -50%)" }}
>
<div className="w-px h-4 bg-white/40" />
</div>
</div>
<div className="relative flex justify-between text-[10px] text-f1-muted mt-1">
<span>-60s</span>
<span className="absolute text-[10px]" style={{ left: `calc(${(60 / 70) * 100}% - 5px)`, transform: "translateX(-50%)" }}>0s</span>
<span>+10s</span>
<span>-5m</span>
<span>0s</span>
</div>

{/* Quick adjust buttons */}
<div className="flex items-center justify-center gap-1">
{[
{ label: "-30s", delta: -30 },
{ label: "-5s", delta: -5 },
{ label: "-1s", delta: -1 },
{ label: "-0.5s", delta: -0.5 },
].map(({ label, delta }) => (
<button
key={label}
onClick={() => setDelayOffset(Math.max(-60, Math.min(10, Math.round((delayOffset + delta) * 2) / 2)))}
className="px-2 py-1.5 bg-f1-dark border border-f1-border rounded text-[11px] font-bold text-f1-muted hover:text-white hover:border-blue-500/50 transition-colors"
>
{label}
</button>
))}
<span className="w-8" />
{[
{ label: "+0.5s", delta: 0.5 },
{ label: "+1s", delta: 1 },
{ label: "+5s", delta: 5 },
].map(({ label, delta }) => (
{ label: "+30s", delta: 30 },
].map(({ label, delta }, i) => (
<button
key={label}
onClick={() => setDelayOffset(Math.max(-60, Math.min(10, Math.round((delayOffset + delta) * 2) / 2)))}
className="px-2 py-1.5 bg-f1-dark border border-f1-border rounded text-[11px] font-bold text-f1-muted hover:text-white hover:border-blue-500/50 transition-colors"
onClick={() => setDelayOffset(clampDelay(delayOffset + delta))}
className={`px-1.5 py-1.5 bg-f1-dark border border-f1-border rounded text-[10px] font-bold text-f1-muted hover:text-white hover:border-blue-500/50 transition-colors ${i === 4 ? "ml-1.5" : ""}`}
>
{label}
</button>
Expand Down Expand Up @@ -725,7 +746,7 @@ export default function LivePage() {
const v = Math.abs(Number(raw));
if (isNaN(v)) return;
const sign = (document.getElementById("delay-sign-btn") as HTMLButtonElement)?.textContent === "−" ? -1 : 1;
setDelayOffset(Math.max(-60, Math.min(10, Math.round(v * sign * 2) / 2)));
setDelayOffset(clampDelay(v * sign));
(e.target as HTMLInputElement).value = "";
}
}}
Expand All @@ -739,7 +760,7 @@ export default function LivePage() {
const v = Math.abs(Number(input.value));
if (isNaN(v)) return;
const sign = (document.getElementById("delay-sign-btn") as HTMLButtonElement)?.textContent === "−" ? -1 : 1;
setDelayOffset(Math.max(-60, Math.min(10, Math.round(v * sign * 2) / 2)));
setDelayOffset(clampDelay(v * sign));
input.value = "";
}}
className="px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white text-xs font-bold rounded transition-colors"
Expand Down
Loading