219 lines
9.2 KiB
Python
219 lines
9.2 KiB
Python
"""Build the compact 2024 São Paulo replay snapshot from public race data.
|
|
|
|
Run from the repository root: python3 scripts/generate-race-replay.py
|
|
The website reads the checked-in snapshot and makes no live API requests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from urllib.parse import urlencode
|
|
from urllib.request import Request, urlopen
|
|
|
|
JOLPICA = "https://api.jolpi.ca/ergast/f1/2024/21"
|
|
OPENF1 = "https://api.openf1.org/v1"
|
|
OUTPUT = Path(__file__).resolve().parents[1] / "src/lib/race-replay-data.json"
|
|
|
|
|
|
def fetch(url: str):
|
|
for attempt in range(5):
|
|
try:
|
|
with urlopen(Request(url, headers={"User-Agent": "SeanOConnorRaceReplay/1.0"}), timeout=30) as response:
|
|
return json.load(response)
|
|
except Exception:
|
|
if attempt == 4:
|
|
raise
|
|
time.sleep(2 ** attempt)
|
|
|
|
|
|
def seconds(value: str) -> float:
|
|
total = 0.0
|
|
for part in value.split(":"):
|
|
total = total * 60 + float(part)
|
|
return round(total, 3)
|
|
|
|
|
|
def jolpica(endpoint: str):
|
|
payload = fetch(f"{JOLPICA}/{endpoint}/?limit=100")
|
|
return payload["MRData"]
|
|
|
|
|
|
first_page = jolpica("laps")
|
|
total = int(first_page["total"])
|
|
offsets = range(100, total, 100)
|
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
more_pages = list(pool.map(lambda offset: fetch(f"{JOLPICA}/laps/?limit=100&offset={offset}")["MRData"], offsets))
|
|
|
|
all_laps = {}
|
|
for page in [first_page, *more_pages]:
|
|
for lap in page["RaceTable"]["Races"][0]["Laps"]:
|
|
all_laps.setdefault(int(lap["number"]), []).extend(lap["Timings"])
|
|
|
|
results = jolpica("results")["RaceTable"]["Races"][0]
|
|
pits = jolpica("pitstops")["RaceTable"]["Races"][0]["PitStops"]
|
|
openf1_drivers = fetch(f"{OPENF1}/drivers?session_key=9636")
|
|
driver_colors = {int(driver["driver_number"]): f"#{driver['team_colour']}" for driver in openf1_drivers}
|
|
|
|
drivers = []
|
|
for result in results["Results"]:
|
|
driver = result["Driver"]
|
|
number = int(result["number"])
|
|
drivers.append({
|
|
"id": driver["driverId"],
|
|
"number": number,
|
|
"code": driver.get("code", driver["familyName"][:3].upper()),
|
|
"name": f"{driver['givenName']} {driver['familyName']}",
|
|
"team": result["Constructor"]["name"],
|
|
"color": driver_colors.get(number, "#ffffff"),
|
|
"grid": int(result["grid"]),
|
|
"finish": int(result["position"]),
|
|
"status": result["status"],
|
|
})
|
|
|
|
laps = []
|
|
for number, timings in sorted(all_laps.items()):
|
|
laps.append({
|
|
"number": number,
|
|
"timings": [
|
|
{"id": timing["driverId"], "position": int(timing["position"]), "seconds": seconds(timing["time"])}
|
|
for timing in timings
|
|
],
|
|
})
|
|
|
|
pit_stops = [
|
|
{"id": pit["driverId"], "lap": int(pit["lap"]), "seconds": seconds(pit["duration"])}
|
|
for pit in pits
|
|
]
|
|
|
|
# A complete lap from Verstappen's on-car coordinates, rather than an
|
|
# unrelated circuit image or generated illustration.
|
|
lap_eight = next(item for item in fetch(f"{OPENF1}/laps?session_key=9636&driver_number=1") if item["lap_number"] == 8)
|
|
from datetime import datetime, timedelta
|
|
|
|
start = datetime.fromisoformat(lap_eight["date_start"])
|
|
end = start + timedelta(seconds=lap_eight["lap_duration"])
|
|
params = urlencode({
|
|
"session_key": 9636,
|
|
"driver_number": 1,
|
|
"date>": start.isoformat(timespec="milliseconds"),
|
|
"date<": end.isoformat(timespec="milliseconds"),
|
|
})
|
|
location = fetch(f"{OPENF1}/location?{params}")
|
|
points = [(row["x"], row["y"]) for row in location if row["x"] is not None and row["y"] is not None]
|
|
if len(points) < 100:
|
|
raise RuntimeError("Not enough location samples for the circuit trace")
|
|
if math.dist(points[0], points[-1]) > 400:
|
|
raise RuntimeError("The sampled track lap does not close")
|
|
points.append(points[0])
|
|
# Mirror the telemetry Y axis to match the FIA's 2024 circuit map: the
|
|
# start/finish straight is on the left and the back straight is on the right.
|
|
min_x, max_x = min(x for x, _ in points), max(x for x, _ in points)
|
|
min_y, max_y = min(y for _, y in points), max(y for _, y in points)
|
|
scale = min(650 / (max_x - min_x), 630 / (max_y - min_y))
|
|
offset_x = (1000 - (max_x - min_x) * scale) / 2
|
|
offset_y = (740 - (max_y - min_y) * scale) / 2
|
|
|
|
|
|
def map_point(x: float, y: float) -> list[float]:
|
|
return [round(offset_x + (x - min_x) * scale, 1), round(offset_y + (max_y - y) * scale, 1)]
|
|
|
|
|
|
track = [map_point(x, y) for x, y in points]
|
|
circuit = fetch("https://api.multiviewer.app/api/v1/circuits/14/2024")
|
|
corners = []
|
|
for corner in circuit["corners"]:
|
|
x, y = map_point(corner["trackPosition"]["x"], corner["trackPosition"]["y"])
|
|
corners.append({"number": corner["number"], "x": x, "y": y})
|
|
|
|
|
|
def nearest_track_distance(x: float, y: float) -> float:
|
|
"""Project a mapped circuit marker onto the same polyline as the cars."""
|
|
best_distance = float("inf")
|
|
best_progress = 0.0
|
|
progress = 0.0
|
|
for (ax, ay), (bx, by) in zip(track, track[1:]):
|
|
length_sq = (bx - ax) ** 2 + (by - ay) ** 2
|
|
fraction = max(0.0, min(1.0, ((x - ax) * (bx - ax) + (y - ay) * (by - ay)) / length_sq)) if length_sq else 0.0
|
|
px, py = ax + fraction * (bx - ax), ay + fraction * (by - ay)
|
|
distance = math.dist((x, y), (px, py))
|
|
if distance < best_distance:
|
|
best_distance = distance
|
|
best_progress = progress + math.sqrt(length_sq) * fraction
|
|
progress += math.sqrt(length_sq)
|
|
return best_progress
|
|
|
|
|
|
track_length = sum(math.dist(a, b) for a, b in zip(track, track[1:]))
|
|
corner_distance = {corner["number"]: nearest_track_distance(corner["x"], corner["y"]) for corner in corners}
|
|
|
|
|
|
def point_at(distance: float) -> list[float]:
|
|
remaining = distance % track_length
|
|
for (ax, ay), (bx, by) in zip(track, track[1:]):
|
|
segment = math.dist((ax, ay), (bx, by))
|
|
if remaining <= segment:
|
|
fraction = remaining / segment if segment else 0
|
|
return [round(ax + (bx - ax) * fraction, 1), round(ay + (by - ay) * fraction, 1)]
|
|
remaining -= segment
|
|
return track[0]
|
|
|
|
|
|
def zone_path(start: float, end: float) -> list[list[float]]:
|
|
if end < start:
|
|
end += track_length
|
|
# Follow the same polyline as the animated dots, including the lap seam.
|
|
samples = max(2, math.ceil((end - start) / 3))
|
|
return [point_at(start + (end - start) * index / samples) for index in range(samples + 1)]
|
|
|
|
|
|
# FIA 2024 São Paulo Circuit Map: activation 1 is 30m after T3;
|
|
# activation 2 is 160m before T15. Zones end at the next braking turn.
|
|
metres_to_track = track_length / 4309
|
|
drs_zones = [
|
|
{"number": 1, "track": zone_path(corner_distance[3] + 30 * metres_to_track, corner_distance[4] - 30 * metres_to_track)},
|
|
{"number": 2, "track": zone_path(corner_distance[15] - 160 * metres_to_track, corner_distance[1] - 30 * metres_to_track)},
|
|
]
|
|
|
|
control = fetch(f"{OPENF1}/race_control?session_key=9636")
|
|
event_specs = [
|
|
(1, "start", "A race before the race", "An aborted start delays lights out on a wet Interlagos grid.", "ABORTED START"),
|
|
(28, "vsc", "Virtual safety car", "A VSC changes the pit-stop calculation as the rain intensifies.", "VIRTUAL SAFETY CAR DEPLOYED"),
|
|
(30, "safety", "Safety car deployed", "The field bunches up in difficult conditions.", "SAFETY CAR DEPLOYED"),
|
|
(32, "red", "Red flag", "The race stops, and the order is reset for a rolling restart.", "RED FLAG"),
|
|
(33, "restart", "Rolling restart", "The field gets going again behind Ocon.", "ROLLING START"),
|
|
(39, "safety", "Safety car returns", "Another interruption bunches the field.", "SAFETY CAR DEPLOYED"),
|
|
(42, "restart", "Safety car in", "The race returns to green-flag running.", "SAFETY CAR IN THIS LAP"),
|
|
(69, "finish", "Chequered flag", "Verstappen wins from P17; Alpine finishes second and third.", "CHEQUERED FLAG"),
|
|
]
|
|
events = []
|
|
for lap, kind, title, description, message in event_specs:
|
|
if not any(item.get("lap_number") == lap and message in item["message"] for item in control):
|
|
raise RuntimeError(f"Race-control event not found: lap {lap}, {message}")
|
|
events.append({"lap": lap, "kind": kind, "title": title, "description": description})
|
|
|
|
data = {
|
|
"race": {"title": "São Paulo Grand Prix", "year": 2024, "date": "3 November 2024", "circuit": "Autódromo José Carlos Pace", "location": "Interlagos · Brazil", "laps": 69},
|
|
"drivers": drivers,
|
|
"laps": laps,
|
|
"pitStops": pit_stops,
|
|
"events": events,
|
|
"track": track,
|
|
"corners": corners,
|
|
"drsZones": drs_zones,
|
|
"sources": {
|
|
"laps": f"{JOLPICA}/laps/",
|
|
"results": f"{JOLPICA}/results/",
|
|
"pitStops": f"{JOLPICA}/pitstops/",
|
|
"raceControl": f"{OPENF1}/race_control?session_key=9636",
|
|
"track": f"{OPENF1}/location?session_key=9636&driver_number=1",
|
|
"corners": "https://api.multiviewer.app/api/v1/circuits/14/2024",
|
|
"circuitMap": "https://www.fia.com/sites/default/files/decision-document/2024%20S%C3%A3o%20Paulo%20Grand%20Prix%20-%20Event%20Notes%20-%20Circuit%20Map,%20Pit%20Lane%20Drawing%20and%20Red%20Zones.pdf",
|
|
},
|
|
}
|
|
OUTPUT.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
print(f"Wrote {OUTPUT}: {len(drivers)} drivers, {len(laps)} laps, {len(track)} track points")
|