tracinginsights's picture
download
raw
18 kB
from __future__ import annotations
import json
import math
import os
from dataclasses import dataclass, field, replace
from typing import Any
import requests
try:
import fastf1
except ModuleNotFoundError: # pragma: no cover - allows pure stdlib tests
fastf1 = None
# Configuration
YEAR = 2026
EVENTS = ["Australian Grand Prix"]
SESSIONS = ["Race", "Qualifying", "Practice 3", "Practice 2", "Practice 1"]
SECTIONS_DIR = "sections"
PROTO = "https"
HOST = "api.multiviewer.app"
HEADERS = {"User-Agent": "FastF1/"}
REQUEST_TIMEOUT = 30
VALID_SECTION_TYPES = {"Straight", "Low", "Medium", "High"}
# Circuit section breakdowns live in sections/<circuit>.json. Each event is
# mapped explicitly to its file (no fuzzy matching on event/circuit names);
# events without an entry get no sections.
# Event name -> sections/ file name (without the .json extension).
EVENT_SECTIONS_FILE: dict[str, str] = {
"Australian Grand Prix": "albert-park-circuit",
"Austrian Grand Prix": "red-bull-ring",
"Azerbaijan Grand Prix": "baku-city-circuit",
# No "Bahrain Grand Prix": held at Sepang (Kuala Lumpur) in 2026, which
# has no sections file.
"Barcelona Grand Prix": "circuit-de-barcelona-catalunya",
"Belgian Grand Prix": "circuit-de-spa-francorchamps",
"British Grand Prix": "silverstone",
"Canadian Grand Prix": "canada",
"Dutch Grand Prix": "circuit-zandvoort",
"Emilia Romagna Grand Prix": "emilia",
"Hungarian Grand Prix": "hungary",
"Italian Grand Prix": "monza",
"Miami Grand Prix": "miami-international-autodrome",
"Monaco Grand Prix": "circuit-de-monaco",
"Saudi Arabian Grand Prix": "jeddah-corniche-circuit",
"Singapore Grand Prix": "marina-bay-street-circuit",
"Qatar Grand Prix": "lusail-international-circuit",
"Abu Dhabi Grand Prix": "yas-marina-circuit",
"United States Grand Prix": "circuit-of-the-americas",
"Mexico City Grand Prix": "autodrome-hermanos-rodriguez",
"São Paulo Grand Prix": "interlagos-circuit",
"Pre-Season Testing": "bahrain-international-circuit",
"Pre-Season Testing 1": "bahrain-international-circuit",
"Pre-Season Testing 2": "bahrain-international-circuit",
}
_CIRCUITS_INDEX_CACHE: dict[str, dict[str, Any]] | None = None
_CIRCUIT_PAYLOAD_CACHE: dict[tuple[int, int], dict[str, Any]] = {}
@dataclass(frozen=True)
class LocalCircuitInfo:
corners: list[dict[str, Any]]
marshal_lights: list[dict[str, Any]]
marshal_sectors: list[dict[str, Any]]
rotation: float
requested_year: int
source_year: int
sections: list[dict[str, Any]] = field(default_factory=list)
def to_payload(self) -> dict[str, Any]:
return {
"corners": self.corners,
"sections": self.sections,
"marshal_lights": self.marshal_lights,
"marshal_sectors": self.marshal_sectors,
"rotation": self.rotation,
}
def _api_get(path: str) -> dict[str, Any]:
url = f"{PROTO}://{HOST}{path}"
response = requests.get(url, headers=HEADERS, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
return response.json()
def _get_circuits_index() -> dict[str, dict[str, Any]]:
global _CIRCUITS_INDEX_CACHE
if _CIRCUITS_INDEX_CACHE is None:
_CIRCUITS_INDEX_CACHE = _api_get("/api/v1/circuits/")
return _CIRCUITS_INDEX_CACHE
def get_supported_circuit_years(circuit_key: int) -> list[int]:
circuit_index = _get_circuits_index()
circuit = circuit_index.get(str(circuit_key))
if circuit is None:
raise KeyError(f"No circuit metadata found for circuit key {circuit_key}")
years = sorted({int(year) for year in circuit.get("years", [])})
if not years:
raise ValueError(f"No supported circuit years returned for {circuit_key}")
return years
def pick_supported_circuit_year(requested_year: int, supported_years: list[int]) -> int:
years = sorted({int(year) for year in supported_years})
if not years:
raise ValueError("supported_years cannot be empty")
if requested_year in years:
return requested_year
past_or_current_years = [year for year in years if year <= requested_year]
if past_or_current_years:
return past_or_current_years[-1]
return years[0]
def _get_circuit_payload(circuit_key: int, year: int) -> dict[str, Any]:
cache_key = (circuit_key, year)
if cache_key not in _CIRCUIT_PAYLOAD_CACHE:
_CIRCUIT_PAYLOAD_CACHE[cache_key] = _api_get(
f"/api/v1/circuits/{circuit_key}/{year}"
)
return _CIRCUIT_PAYLOAD_CACHE[cache_key]
def _as_int_or_none(value: Any) -> int | None:
if value in (None, ""):
return None
return int(value)
def _as_float_or_none(value: Any) -> float | None:
if value in (None, ""):
return None
result = float(value)
if not math.isfinite(result):
return None
return result
def _marker_rows(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
rows = []
for entry in entries:
track_position = entry.get("trackPosition") or {}
rows.append(
{
"X": _as_float_or_none(track_position.get("x")),
"Y": _as_float_or_none(track_position.get("y")),
"Number": _as_int_or_none(entry.get("number")),
"Letter": str(entry.get("letter") or ""),
"Angle": _as_float_or_none(entry.get("angle")),
"Distance": None,
}
)
return rows
def get_reference_telemetry_samples(reference_lap: Any) -> list[dict[str, float]]:
telemetry = None
telemetry_exc = None
try:
telemetry = reference_lap.get_telemetry(frequency="original")
except TypeError:
try:
telemetry = reference_lap.get_telemetry()
except Exception as exc:
telemetry = None
telemetry_exc = exc
except Exception as exc:
telemetry = None
telemetry_exc = exc
if telemetry is not None:
if telemetry.empty:
raise ValueError("Reference lap telemetry is empty")
samples = []
for row in telemetry.to_dict(orient="records"):
if row.get("Source") != "pos":
continue
x_pos = _as_float_or_none(row.get("X"))
y_pos = _as_float_or_none(row.get("Y"))
distance = _as_float_or_none(row.get("Distance"))
if None in (x_pos, y_pos, distance):
continue
samples.append({"X": x_pos, "Y": y_pos, "Distance": distance})
if samples:
return samples
try:
pos_data = reference_lap.get_pos_data(pad=1, pad_side="both")
except Exception as pos_exc:
if telemetry_exc is not None:
raise RuntimeError(
"Failed to load telemetry for the reference lap "
f"({type(telemetry_exc).__name__}: {telemetry_exc}); "
"position-data fallback also failed "
f"({type(pos_exc).__name__}: {pos_exc})"
) from pos_exc
raise RuntimeError(
"Failed to load position data for the reference lap "
f"({type(pos_exc).__name__}: {pos_exc})"
) from pos_exc
if pos_data.empty:
raise ValueError("Reference lap position data is empty")
samples = []
cumulative_distance = 0.0
prev_x = None
prev_y = None
for row in pos_data.to_dict(orient="records"):
source = row.get("Source")
if source not in (None, "pos"):
continue
x_pos = _as_float_or_none(row.get("X"))
y_pos = _as_float_or_none(row.get("Y"))
if None in (x_pos, y_pos):
continue
if prev_x is not None and prev_y is not None:
cumulative_distance += math.hypot(x_pos - prev_x, y_pos - prev_y) / 10.0
samples.append({"X": x_pos, "Y": y_pos, "Distance": cumulative_distance})
prev_x = x_pos
prev_y = y_pos
if not samples:
raise ValueError("No usable positional samples found for the reference lap")
return samples
def assign_marker_distances(
markers: list[dict[str, Any]], telemetry_samples: list[dict[str, float]]
) -> list[dict[str, Any]]:
if not telemetry_samples:
raise ValueError("telemetry_samples cannot be empty")
resolved_markers = []
for marker in markers:
marker_x = _as_float_or_none(marker.get("X"))
marker_y = _as_float_or_none(marker.get("Y"))
if None in (marker_x, marker_y):
resolved_markers.append({**marker, "Distance": None})
continue
best_distance = None
best_error = None
for sample in telemetry_samples:
error = (sample["X"] - marker_x) ** 2 + (sample["Y"] - marker_y) ** 2
if best_error is None or error < best_error:
best_error = error
best_distance = sample["Distance"]
resolved_markers.append({**marker, "Distance": best_distance})
return resolved_markers
def parse_circuit_sections(raw: Any) -> list[tuple[str, float, float]]:
"""Parse and validate raw section rows: [type, start_percent, end_percent]."""
if not isinstance(raw, list) or not raw:
raise ValueError("Circuit sections must be a non-empty list")
rows = []
for entry in raw:
if isinstance(entry, dict):
section_type = entry.get("Type") or entry.get("type")
start = entry.get("StartPercent", entry.get("start"))
end = entry.get("EndPercent", entry.get("end"))
else:
section_type, start, end = entry[0], entry[1], entry[2]
section_type = str(section_type).strip().capitalize()
if section_type not in VALID_SECTION_TYPES:
raise ValueError(
f"Invalid circuit section type {section_type!r}; "
f"expected one of {sorted(VALID_SECTION_TYPES)}"
)
start = float(start)
end = float(end)
if not (math.isfinite(start) and math.isfinite(end)):
raise ValueError("Circuit section bounds must be finite numbers")
if not (0.0 <= start < end <= 100.0):
raise ValueError(
f"Invalid circuit section bounds {start}-{end}; "
"expected 0 <= start < end <= 100"
)
rows.append((section_type, start, end))
return rows
def load_circuit_sections(event_name: str) -> list[tuple[str, float, float]] | None:
"""Load the sections file mapped to this event in EVENT_SECTIONS_FILE.
Returns None when the event has no mapped sections file (only some
circuits have a breakdown).
"""
stem = EVENT_SECTIONS_FILE.get(event_name)
if stem is None:
return None
path = os.path.join(SECTIONS_DIR, f"{stem}.json")
with open(path, encoding="utf-8") as f:
return parse_circuit_sections(json.load(f))
def build_section_entries(
section_rows: list[tuple[str, float, float]], total_lap_distance: float
) -> list[dict[str, Any]]:
"""Convert percentage-based sections to absolute distances using the
reference (fastest) lap's total lap distance."""
if not math.isfinite(total_lap_distance) or total_lap_distance <= 0:
raise ValueError("total_lap_distance must be a positive finite number")
return [
{
"Type": section_type,
"StartPercent": start,
"EndPercent": end,
"StartDistance": start / 100.0 * total_lap_distance,
"EndDistance": end / 100.0 * total_lap_distance,
}
for section_type, start, end in section_rows
]
def get_section_for_distance(
distance: float | None,
section_entries: list[dict[str, Any]],
total_lap_distance: float,
) -> str | None:
"""Return the section (Straight/Low/Medium/High) containing this distance."""
if distance is None or not section_entries:
return None
percent = distance / total_lap_distance * 100.0
for index, entry in enumerate(section_entries):
is_last = index == len(section_entries) - 1
if entry["StartPercent"] <= percent < entry["EndPercent"]:
return entry["Type"]
if is_last and percent >= entry["EndPercent"]:
return entry["Type"]
return None
def get_total_lap_distance(telemetry_samples: list[dict[str, float]]) -> float:
if not telemetry_samples:
raise ValueError("telemetry_samples cannot be empty")
return max(sample["Distance"] for sample in telemetry_samples)
def add_sections_local(
circuit_info: LocalCircuitInfo,
section_rows: list[tuple[str, float, float]],
total_lap_distance: float,
) -> LocalCircuitInfo:
"""Attach section breakdown and classify each corner into its section."""
section_entries = build_section_entries(section_rows, total_lap_distance)
corners = [
{
**corner,
"Section": get_section_for_distance(
corner.get("Distance"), section_entries, total_lap_distance
),
}
for corner in circuit_info.corners
]
return replace(circuit_info, corners=corners, sections=section_entries)
def add_marker_distance_from_samples(
circuit_info: LocalCircuitInfo, telemetry_samples: list[dict[str, float]]
) -> LocalCircuitInfo:
return LocalCircuitInfo(
corners=assign_marker_distances(circuit_info.corners, telemetry_samples),
marshal_lights=assign_marker_distances(
circuit_info.marshal_lights, telemetry_samples
),
marshal_sectors=assign_marker_distances(
circuit_info.marshal_sectors, telemetry_samples
),
rotation=circuit_info.rotation,
requested_year=circuit_info.requested_year,
source_year=circuit_info.source_year,
sections=circuit_info.sections,
)
def add_marker_distance_local(
circuit_info: LocalCircuitInfo, reference_lap: Any
) -> LocalCircuitInfo:
telemetry_samples = get_reference_telemetry_samples(reference_lap)
return add_marker_distance_from_samples(circuit_info, telemetry_samples)
def get_local_circuit_info(circuit_key: int, requested_year: int) -> LocalCircuitInfo:
supported_years = get_supported_circuit_years(circuit_key)
source_year = pick_supported_circuit_year(requested_year, supported_years)
payload = _get_circuit_payload(circuit_key, source_year)
return LocalCircuitInfo(
corners=_marker_rows(payload.get("corners", [])),
marshal_lights=_marker_rows(payload.get("marshalLights", [])),
marshal_sectors=_marker_rows(payload.get("marshalSectors", [])),
rotation=float(payload.get("rotation", 0.0) or 0.0),
requested_year=requested_year,
source_year=source_year,
)
def get_session_circuit_info(
year: int, event_name: str, session_name: str
) -> LocalCircuitInfo:
if fastf1 is None:
raise RuntimeError("fastf1 is required to load session telemetry")
session = fastf1.get_session(year, event_name, session_name)
session.load(laps=True, telemetry=True, weather=False, messages=False)
circuit_key = int(session.session_info["Meeting"]["Circuit"]["Key"])
fastest_lap = session.laps.pick_fastest()
if fastest_lap is None:
raise ValueError(f"No fastest lap available for {event_name}/{session_name}")
circuit_info = get_local_circuit_info(circuit_key, year)
# Same process as FastF1's circuit_info.add_marker_distance(): the fastest
# lap is the reference lap and all marker/section distances are resolved
# against its telemetry.
telemetry_samples = get_reference_telemetry_samples(fastest_lap)
circuit_info = add_marker_distance_from_samples(circuit_info, telemetry_samples)
section_rows = load_circuit_sections(event_name)
if section_rows is not None:
total_lap_distance = get_total_lap_distance(telemetry_samples)
circuit_info = add_sections_local(
circuit_info, section_rows, total_lap_distance
)
return circuit_info
def main() -> None:
for event_name in EVENTS:
for session_name in SESSIONS:
try:
circuit_info = get_session_circuit_info(YEAR, event_name, session_name)
except Exception as exc:
print(f"✗ Failed {event_name}/{session_name}: {exc}")
continue
if circuit_info.source_year != circuit_info.requested_year:
print(
"!"
f" Using circuit layout year {circuit_info.source_year} for "
f"{event_name} {circuit_info.requested_year}"
)
payload = circuit_info.to_payload()
output_dir = f"{event_name}/{session_name}"
os.makedirs(output_dir, exist_ok=True)
output_path = f"{output_dir}/corners.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, allow_nan=False)
sections_note = (
f", sections: {len(circuit_info.sections)}"
if circuit_info.sections
else ", no section breakdown available"
)
print(
"✓ Corners data saved to "
f"{output_path} (reference lap: fastest lap, "
f"layout year: {circuit_info.source_year}{sections_note})"
)
if __name__ == "__main__":
main()

Xet Storage Details

Size:
18 kB
·
Xet hash:
f04f5cbc9117f25438a79395621f07e164a0d12961996d104f1d93d88ef2763b

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.