Add project previews and race replay
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user