f1verse 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- f1verse/__init__.py +45 -0
- f1verse/_json.py +52 -0
- f1verse/crosscheck.py +110 -0
- f1verse/feeds.py +72 -0
- f1verse/gaps.py +43 -0
- f1verse/http.py +49 -0
- f1verse/integrity.py +32 -0
- f1verse/race.py +231 -0
- f1verse/sources/__init__.py +1 -0
- f1verse/sources/livetiming.py +48 -0
- f1verse/sources/openf1.py +25 -0
- f1verse/story.py +163 -0
- f1verse-0.3.0.dist-info/METADATA +126 -0
- f1verse-0.3.0.dist-info/RECORD +17 -0
- f1verse-0.3.0.dist-info/WHEEL +5 -0
- f1verse-0.3.0.dist-info/licenses/LICENSE +21 -0
- f1verse-0.3.0.dist-info/top_level.txt +1 -0
f1verse/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""f1verse — the story layer for Formula 1 data.
|
|
2
|
+
|
|
3
|
+
Native (no FastF1 required, seasons 2023+):
|
|
4
|
+
|
|
5
|
+
>>> import f1verse
|
|
6
|
+
>>> race = f1verse.load(2026, 12)
|
|
7
|
+
>>> race.laps_led() # {'NOR': 31, 'ANT': 32, ...}
|
|
8
|
+
>>> race.story() # whole race as JSON-safe dict
|
|
9
|
+
>>> race.championship_prediction() # the feed everyone throws away
|
|
10
|
+
|
|
11
|
+
FastF1 adapter (``pip install f1verse[fastf1]``) for telemetry, qualifying
|
|
12
|
+
segments and pre-2023 seasons:
|
|
13
|
+
|
|
14
|
+
>>> story = f1verse.analyze(fastf1_session)
|
|
15
|
+
"""
|
|
16
|
+
from . import http
|
|
17
|
+
from ._json import jsonsafe
|
|
18
|
+
from .feeds import championship_prediction, team_radio, timing_stats
|
|
19
|
+
from .gaps import format_gap
|
|
20
|
+
from .crosscheck import crosscheck
|
|
21
|
+
from .race import Race, load
|
|
22
|
+
|
|
23
|
+
__version__ = "0.3.0"
|
|
24
|
+
_FASTF1_API = {"analyze", "leader_runs", "laps_led", "timeline", "stints",
|
|
25
|
+
"race_pace", "results", "interruption_bands",
|
|
26
|
+
"integrity_report"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def __getattr__(name):
|
|
30
|
+
if name in _FASTF1_API:
|
|
31
|
+
try:
|
|
32
|
+
from . import integrity, story
|
|
33
|
+
except ImportError as e:
|
|
34
|
+
raise ImportError(
|
|
35
|
+
f"f1verse.{name} needs the FastF1 adapter: "
|
|
36
|
+
"pip install f1verse[fastf1]") from e
|
|
37
|
+
mod = integrity if name == "integrity_report" else story
|
|
38
|
+
return getattr(mod, name)
|
|
39
|
+
raise AttributeError(name)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
enable_cache = http.enable_cache
|
|
43
|
+
__all__ = ["load", "Race", "format_gap", "jsonsafe", "enable_cache",
|
|
44
|
+
"championship_prediction", "team_radio", "timing_stats",
|
|
45
|
+
"crosscheck", *sorted(_FASTF1_API)]
|
f1verse/_json.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""JSON-safe conversion — every public f1verse output passes through here.
|
|
2
|
+
|
|
3
|
+
Works with or without numpy/pandas installed: the native loader returns
|
|
4
|
+
plain Python already; the FastF1 adapter returns numpy scalars and pandas
|
|
5
|
+
timestamps that ``json.dumps`` rejects.
|
|
6
|
+
"""
|
|
7
|
+
import datetime
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import numpy as _np
|
|
12
|
+
except ImportError: # zero-dep native install
|
|
13
|
+
_np = None
|
|
14
|
+
try:
|
|
15
|
+
import pandas as _pd
|
|
16
|
+
except ImportError:
|
|
17
|
+
_pd = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def jsonsafe(obj):
|
|
21
|
+
"""Recursively convert *obj* to plain JSON-serializable Python types."""
|
|
22
|
+
if obj is None or isinstance(obj, (bool, str)):
|
|
23
|
+
return obj
|
|
24
|
+
if _np is not None and isinstance(obj, _np.integer):
|
|
25
|
+
return int(obj)
|
|
26
|
+
if isinstance(obj, int):
|
|
27
|
+
return obj
|
|
28
|
+
if (_np is not None and isinstance(obj, _np.floating)) or isinstance(obj, float):
|
|
29
|
+
f = float(obj)
|
|
30
|
+
return None if math.isnan(f) else f
|
|
31
|
+
if isinstance(obj, datetime.timedelta):
|
|
32
|
+
return obj.total_seconds()
|
|
33
|
+
if isinstance(obj, (datetime.datetime, datetime.date)):
|
|
34
|
+
return obj.isoformat()
|
|
35
|
+
if _pd is not None:
|
|
36
|
+
if isinstance(obj, _pd.Timedelta):
|
|
37
|
+
return None if obj is _pd.NaT else obj.total_seconds()
|
|
38
|
+
if isinstance(obj, _pd.Timestamp):
|
|
39
|
+
return obj.isoformat()
|
|
40
|
+
if obj is _pd.NaT:
|
|
41
|
+
return None
|
|
42
|
+
if isinstance(obj, dict):
|
|
43
|
+
return {str(k): jsonsafe(v) for k, v in obj.items()}
|
|
44
|
+
if isinstance(obj, (list, tuple, set)):
|
|
45
|
+
return [jsonsafe(v) for v in obj]
|
|
46
|
+
if _np is not None and isinstance(obj, _np.ndarray):
|
|
47
|
+
return [jsonsafe(v) for v in obj.tolist()]
|
|
48
|
+
if _pd is not None and isinstance(obj, _pd.Series):
|
|
49
|
+
return [jsonsafe(v) for v in obj.tolist()]
|
|
50
|
+
if _pd is not None and _pd.isna(obj):
|
|
51
|
+
return None
|
|
52
|
+
return str(obj)
|
f1verse/crosscheck.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Cross-validation layer — publish only when independent sources agree.
|
|
2
|
+
|
|
3
|
+
One wrong number costs a data project its credibility. ``crosscheck`` runs
|
|
4
|
+
a race through independent checks and returns a machine-readable verdict a
|
|
5
|
+
publishing pipeline can gate on:
|
|
6
|
+
|
|
7
|
+
- **sector_sum** — s1+s2+s3 must equal the lap time within 3 ms
|
|
8
|
+
(the same tolerance FastF1 uses internally, recomputed natively).
|
|
9
|
+
- **lap_count** — winner's classified lap count vs the lap table.
|
|
10
|
+
- **gap_monotonic** — classified numeric gaps must increase with position;
|
|
11
|
+
this is the check that catches the classic lapped-car corruption
|
|
12
|
+
(``P8 +36.049`` printed above ``P7 +1:19.915``).
|
|
13
|
+
- **lapped_convention** — every ``+N LAP`` row completed fewer laps.
|
|
14
|
+
- **leader_vs_overtakes** — every on-track pass for P1 (independent
|
|
15
|
+
``/overtakes`` endpoint) must appear in the position-stream lead changes.
|
|
16
|
+
The two are *not* equal by design: leads gained through pit cycles are
|
|
17
|
+
lead changes but not overtakes.
|
|
18
|
+
- **stints_vs_pits** — stint splits explained by pit stops (red-flag tyre
|
|
19
|
+
changes legitimately add stints without a pit stop; reported, not failed).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .sources import openf1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _check(name, ok, detail):
|
|
26
|
+
return {"name": name, "status": "ok" if ok else "mismatch",
|
|
27
|
+
"detail": detail}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def crosscheck(race) -> dict:
|
|
31
|
+
checks = []
|
|
32
|
+
|
|
33
|
+
# -- lap_count ----------------------------------------------------------
|
|
34
|
+
winner = next((r for r in race.result if r.get("position") == 1), {})
|
|
35
|
+
table_max = race.total_laps
|
|
36
|
+
ok = winner.get("number_of_laps") == table_max
|
|
37
|
+
checks.append(_check("lap_count", ok,
|
|
38
|
+
f"winner classified {winner.get('number_of_laps')} laps,"
|
|
39
|
+
f" lap table max {table_max}"))
|
|
40
|
+
|
|
41
|
+
# -- sector_sum (FastF1's 3 ms tolerance, recomputed natively) ----------
|
|
42
|
+
bad = total = 0
|
|
43
|
+
for l in race.laps:
|
|
44
|
+
s = (l.get("duration_sector_1"), l.get("duration_sector_2"),
|
|
45
|
+
l.get("duration_sector_3"), l.get("lap_duration"))
|
|
46
|
+
if all(x is not None for x in s):
|
|
47
|
+
total += 1
|
|
48
|
+
if abs(s[0] + s[1] + s[2] - s[3]) > 0.003:
|
|
49
|
+
bad += 1
|
|
50
|
+
checks.append(_check("sector_sum", bad / max(total, 1) < 0.02,
|
|
51
|
+
f"{bad}/{total} laps off by >3ms"))
|
|
52
|
+
|
|
53
|
+
# -- gap_monotonic (the lapped-car trap, as an invariant) ---------------
|
|
54
|
+
prev, breaks = None, []
|
|
55
|
+
for r in race.results():
|
|
56
|
+
g = r["gap"]
|
|
57
|
+
if g.startswith("+") and "LAP" not in g:
|
|
58
|
+
sec = (lambda s: sum(float(x) * m for x, m in
|
|
59
|
+
zip(reversed(s.rstrip("s").lstrip("+").split(":")),
|
|
60
|
+
(1, 60))))(g)
|
|
61
|
+
if prev is not None and sec < prev:
|
|
62
|
+
breaks.append(r["abbr"])
|
|
63
|
+
prev = sec
|
|
64
|
+
checks.append(_check("gap_monotonic", not breaks,
|
|
65
|
+
f"out-of-order gaps: {breaks or 'none'}"))
|
|
66
|
+
|
|
67
|
+
# -- lapped_convention --------------------------------------------------
|
|
68
|
+
wrong = [r["abbr"] for r in race.results()
|
|
69
|
+
if "LAP" in r["gap"] and (r.get("laps") or 0) >= table_max]
|
|
70
|
+
checks.append(_check("lapped_convention", not wrong,
|
|
71
|
+
f"'+N LAP' rows with full distance: {wrong or 'none'}"))
|
|
72
|
+
|
|
73
|
+
# -- leader_vs_overtakes (independent endpoint) -------------------------
|
|
74
|
+
runs = [r["abbr"] for r in race.leader_runs()]
|
|
75
|
+
ot = sorted(openf1.get("overtakes", session_key=race.session_key,
|
|
76
|
+
position=1), key=lambda o: o["date"])
|
|
77
|
+
seq, prev_n = [], None
|
|
78
|
+
for o in ot:
|
|
79
|
+
n = o["overtaking_driver_number"]
|
|
80
|
+
if n != prev_n:
|
|
81
|
+
seq.append(race.abbr(n))
|
|
82
|
+
prev_n = n
|
|
83
|
+
# on-track P1 passes must be a subsequence of all lead changes —
|
|
84
|
+
# pit-cycle lead changes legitimately have no matching overtake
|
|
85
|
+
changes = [b for a, b in zip([None] + runs, runs) if a != b][1:]
|
|
86
|
+
passes = [b for a, b in zip([None] + seq, seq) if a != b]
|
|
87
|
+
it = iter(changes)
|
|
88
|
+
ok = all(any(p == c for c in it) for p in passes)
|
|
89
|
+
checks.append(_check(
|
|
90
|
+
"leader_vs_overtakes", ok,
|
|
91
|
+
f"on-track P1 passes {passes} ⊆ lead changes {changes}"
|
|
92
|
+
+ ("" if ok else " — FAILED")))
|
|
93
|
+
|
|
94
|
+
# -- stints_vs_pits (informational) -------------------------------------
|
|
95
|
+
pit_per = {}
|
|
96
|
+
for p in race.pits:
|
|
97
|
+
pit_per[p["driver_number"]] = pit_per.get(p["driver_number"], 0) + 1
|
|
98
|
+
unexplained = []
|
|
99
|
+
for num, sts in ((n, [s for s in race.stints_raw
|
|
100
|
+
if s["driver_number"] == n])
|
|
101
|
+
for n in race.drivers):
|
|
102
|
+
extra = (len(sts) - 1) - pit_per.get(num, 0)
|
|
103
|
+
if extra > 1: # >1 non-pit stint split is suspicious even with a red flag
|
|
104
|
+
unexplained.append(race.abbr(num))
|
|
105
|
+
checks.append(_check("stints_vs_pits", not unexplained,
|
|
106
|
+
f"suspicious stint splits: {unexplained or 'none'}"))
|
|
107
|
+
|
|
108
|
+
mismatches = [c["name"] for c in checks if c["status"] != "ok"]
|
|
109
|
+
return {"checks": checks, "mismatches": mismatches,
|
|
110
|
+
"publishable": not mismatches}
|
f1verse/feeds.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Harvest live-timing feeds that FastF1 knows about but never parses.
|
|
2
|
+
|
|
3
|
+
The official archive names 21 topics; FastF1 parses ~13. Three of the
|
|
4
|
+
dropped feeds are genuinely valuable:
|
|
5
|
+
|
|
6
|
+
- ``ChampionshipPrediction`` — per-lap "if the race ended now" projection.
|
|
7
|
+
- ``TeamRadio`` — timestamped team-radio clip paths (URLs only, no media).
|
|
8
|
+
- ``TimingStats`` — personal bests and speed-trap figures.
|
|
9
|
+
|
|
10
|
+
Every function accepts either a loaded FastF1 ``Session`` or an
|
|
11
|
+
``f1verse.Race`` — anything exposing ``.api_path``.
|
|
12
|
+
"""
|
|
13
|
+
import copy
|
|
14
|
+
|
|
15
|
+
from ._json import jsonsafe
|
|
16
|
+
from .sources.livetiming import BASE, deepmerge, fetch_stream
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _path(session) -> str:
|
|
20
|
+
p = getattr(session, "api_path", None)
|
|
21
|
+
if not p:
|
|
22
|
+
raise TypeError("expected a FastF1 Session or f1verse.Race")
|
|
23
|
+
return p
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def championship_prediction(session) -> dict:
|
|
27
|
+
"""Live championship projection through the race.
|
|
28
|
+
|
|
29
|
+
Returns ``{"series", "final", "leader_changes"}`` where
|
|
30
|
+
``leader_changes`` are the moments the *projected champion* changed —
|
|
31
|
+
the moments no broadcast graphic shows.
|
|
32
|
+
"""
|
|
33
|
+
series, state = [], {}
|
|
34
|
+
for t, patch in fetch_stream(_path(session), "ChampionshipPrediction.jsonStream"):
|
|
35
|
+
state = deepmerge(state, patch)
|
|
36
|
+
if state.get("Drivers"):
|
|
37
|
+
series.append({"t": t, "state": copy.deepcopy(state)})
|
|
38
|
+
changes, prev = [], None
|
|
39
|
+
for snap in series:
|
|
40
|
+
drivers = snap["state"].get("Drivers", {})
|
|
41
|
+
leader = min((d for d in drivers.values() if d.get("PredictedPosition")),
|
|
42
|
+
key=lambda d: d["PredictedPosition"], default=None)
|
|
43
|
+
num = leader and leader.get("RacingNumber")
|
|
44
|
+
if num and num != prev:
|
|
45
|
+
if prev is not None:
|
|
46
|
+
changes.append({"t": snap["t"], "to": num, "from": prev})
|
|
47
|
+
prev = num
|
|
48
|
+
return jsonsafe({"series": series,
|
|
49
|
+
"final": series[-1]["state"] if series else {},
|
|
50
|
+
"leader_changes": changes})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def team_radio(session) -> list:
|
|
54
|
+
"""Timestamped team-radio clips: URLs only, nothing downloaded."""
|
|
55
|
+
clips, path = [], _path(session)
|
|
56
|
+
for t, patch in fetch_stream(path, "TeamRadio.jsonStream"):
|
|
57
|
+
caps = patch.get("Captures")
|
|
58
|
+
items = caps.values() if isinstance(caps, dict) else (caps or [])
|
|
59
|
+
for c in items:
|
|
60
|
+
if isinstance(c, dict) and c.get("Path"):
|
|
61
|
+
clips.append({"t": t, "utc": c.get("Utc"),
|
|
62
|
+
"driver_number": c.get("RacingNumber"),
|
|
63
|
+
"url": BASE + path + c["Path"]})
|
|
64
|
+
return jsonsafe(clips)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def timing_stats(session) -> dict:
|
|
68
|
+
"""Final personal bests / best sectors / speed-trap figures per driver."""
|
|
69
|
+
state = {}
|
|
70
|
+
for _, patch in fetch_stream(_path(session), "TimingStats.jsonStream"):
|
|
71
|
+
state = deepmerge(state, patch)
|
|
72
|
+
return jsonsafe(state.get("Lines", {}))
|
f1verse/gaps.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Broadcast-convention gap formatting.
|
|
2
|
+
|
|
3
|
+
FastF1's ``results['Time']`` is **not** a gap for lapped cars — the raw value
|
|
4
|
+
can be *smaller* than a car that finished ahead on the lead lap::
|
|
5
|
+
|
|
6
|
+
P7 LAW +1:19.915 Status=Finished
|
|
7
|
+
P8 HUL +36.049 Status=Lapped <- looks ahead of P7!
|
|
8
|
+
|
|
9
|
+
No error, no warning — naive tables are silently wrong. ``format_gap``
|
|
10
|
+
applies the convention every broadcast uses instead.
|
|
11
|
+
"""
|
|
12
|
+
import math
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _seconds(td):
|
|
16
|
+
if td is None:
|
|
17
|
+
return None
|
|
18
|
+
try:
|
|
19
|
+
s = td.total_seconds()
|
|
20
|
+
except AttributeError:
|
|
21
|
+
s = float(td)
|
|
22
|
+
return None if math.isnan(s) else s
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_seconds(total: float) -> str:
|
|
26
|
+
if total >= 60:
|
|
27
|
+
return f"+{int(total // 60)}:{total % 60:06.3f}"
|
|
28
|
+
return f"+{total:.3f}s"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_gap(status, time_delta, position=None, laps_down=None) -> str:
|
|
32
|
+
"""Human gap string for one classified result row (broadcast convention)."""
|
|
33
|
+
if position == 1:
|
|
34
|
+
return "WINNER"
|
|
35
|
+
if status == "Lapped":
|
|
36
|
+
n = int(laps_down) if laps_down else 1
|
|
37
|
+
return f"+{n} LAP" if n == 1 else f"+{n} LAPS"
|
|
38
|
+
if status == "Disqualified":
|
|
39
|
+
return "DSQ"
|
|
40
|
+
if status != "Finished":
|
|
41
|
+
return "DNF"
|
|
42
|
+
s = _seconds(time_delta)
|
|
43
|
+
return "" if s is None else format_seconds(s)
|
f1verse/http.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Tiny cached HTTP layer — standard library only, zero dependencies.
|
|
2
|
+
|
|
3
|
+
One on-disk cache, BOM-safe JSON, polite pacing, gzip support.
|
|
4
|
+
Completed-session responses are immutable, so caching is aggressive.
|
|
5
|
+
"""
|
|
6
|
+
import gzip
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_UA = "f1verse (+https://github.com/jinsim/f1verse)"
|
|
15
|
+
_cache_dir = Path.home() / ".cache" / "f1verse"
|
|
16
|
+
_last_request = 0.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def enable_cache(path) -> None:
|
|
20
|
+
"""Override the cache directory (default ``~/.cache/f1verse``)."""
|
|
21
|
+
global _cache_dir
|
|
22
|
+
_cache_dir = Path(path)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_text(url: str, params: dict | None = None) -> str:
|
|
26
|
+
global _last_request
|
|
27
|
+
if params:
|
|
28
|
+
url = url + "?" + urllib.parse.urlencode(params)
|
|
29
|
+
f = _cache_dir / hashlib.sha256(url.encode()).hexdigest()[:24]
|
|
30
|
+
if f.exists():
|
|
31
|
+
return f.read_text()
|
|
32
|
+
wait = 0.5 - (time.monotonic() - _last_request)
|
|
33
|
+
if wait > 0:
|
|
34
|
+
time.sleep(wait)
|
|
35
|
+
req = urllib.request.Request(
|
|
36
|
+
url, headers={"User-Agent": _UA, "Accept-Encoding": "gzip"})
|
|
37
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
38
|
+
raw = r.read()
|
|
39
|
+
if r.headers.get("Content-Encoding") == "gzip":
|
|
40
|
+
raw = gzip.decompress(raw)
|
|
41
|
+
_last_request = time.monotonic()
|
|
42
|
+
text = raw.decode("utf-8-sig") # livetiming serves BOM-prefixed JSON
|
|
43
|
+
_cache_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
f.write_text(text)
|
|
45
|
+
return text
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_json(url: str, params: dict | None = None):
|
|
49
|
+
return json.loads(get_text(url, params))
|
f1verse/integrity.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Machine-readable data-quality report.
|
|
2
|
+
|
|
3
|
+
FastF1 logs warnings like ``Driver 41: Lap timing integrity check failed``
|
|
4
|
+
as *text* — an automated publishing pipeline cannot act on text. This module
|
|
5
|
+
re-surfaces the same information as structured data so a pipeline can hold
|
|
6
|
+
publication, exclude laps, or annotate output.
|
|
7
|
+
"""
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from ._json import jsonsafe
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def integrity_report(session) -> dict:
|
|
14
|
+
laps = session.laps
|
|
15
|
+
rep = {"drivers": {}, "total_laps": int(len(laps)),
|
|
16
|
+
"inaccurate_laps": 0, "deleted_laps": 0, "generated_laps": 0}
|
|
17
|
+
for drv in session.results["Abbreviation"]:
|
|
18
|
+
d = laps.pick_drivers(drv)
|
|
19
|
+
bad = d[~d["IsAccurate"].astype(bool)]
|
|
20
|
+
deleted = d[d["Deleted"] == True] if "Deleted" in d else d.iloc[0:0] # noqa: E712
|
|
21
|
+
gen = d[d["FastF1Generated"] == True] if "FastF1Generated" in d else d.iloc[0:0] # noqa: E712
|
|
22
|
+
rep["drivers"][drv] = {
|
|
23
|
+
"laps": int(len(d)),
|
|
24
|
+
"inaccurate": [int(x) for x in bad["LapNumber"].dropna()],
|
|
25
|
+
"deleted": [int(x) for x in deleted["LapNumber"].dropna()],
|
|
26
|
+
"generated": [int(x) for x in gen["LapNumber"].dropna()],
|
|
27
|
+
}
|
|
28
|
+
rep["inaccurate_laps"] += len(bad)
|
|
29
|
+
rep["deleted_laps"] += len(deleted)
|
|
30
|
+
rep["generated_laps"] += len(gen)
|
|
31
|
+
rep["publishable"] = rep["inaccurate_laps"] / max(rep["total_laps"], 1) < 0.35
|
|
32
|
+
return jsonsafe(rep)
|
f1verse/race.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Native multi-source race loader — no FastF1 required.
|
|
2
|
+
|
|
3
|
+
``f1verse.load(year, round)`` builds a :class:`Race` from public REST data
|
|
4
|
+
(OpenF1) plus the official live-timing archive for the feeds nobody else
|
|
5
|
+
parses. FastF1 remains available as an optional adapter (``f1verse.analyze``)
|
|
6
|
+
for telemetry, qualifying segments, and pre-2023 seasons.
|
|
7
|
+
"""
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from statistics import median
|
|
10
|
+
|
|
11
|
+
from . import feeds
|
|
12
|
+
from ._json import jsonsafe
|
|
13
|
+
from .gaps import format_seconds
|
|
14
|
+
from .sources import livetiming, openf1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _iso(s: str) -> datetime:
|
|
18
|
+
return datetime.fromisoformat(s)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Race:
|
|
22
|
+
def __init__(self, year: int, rnd: int):
|
|
23
|
+
self.year, self.round = year, rnd
|
|
24
|
+
s = openf1.resolve_race(year, rnd)
|
|
25
|
+
self.session_key = s["session_key"]
|
|
26
|
+
self.meeting = s["meeting"]
|
|
27
|
+
self.info = s
|
|
28
|
+
self._api_path = None
|
|
29
|
+
|
|
30
|
+
key = {"session_key": self.session_key}
|
|
31
|
+
self.drivers = {d["driver_number"]: d for d in openf1.get("drivers", **key)}
|
|
32
|
+
self.laps = sorted(openf1.get("laps", **key),
|
|
33
|
+
key=lambda l: (l["driver_number"], l["lap_number"]))
|
|
34
|
+
self.result = openf1.get("session_result", **key)
|
|
35
|
+
self.race_control = openf1.get("race_control", **key)
|
|
36
|
+
self.stints_raw = openf1.get("stints", **key)
|
|
37
|
+
self.pits = openf1.get("pit", **key)
|
|
38
|
+
self._p1 = sorted(openf1.get("position", position=1, **key),
|
|
39
|
+
key=lambda p: p["date"])
|
|
40
|
+
self.total_laps = max((l["lap_number"] for l in self.laps), default=0)
|
|
41
|
+
|
|
42
|
+
# -- identity helpers ---------------------------------------------------
|
|
43
|
+
def abbr(self, num) -> str:
|
|
44
|
+
d = self.drivers.get(num, {})
|
|
45
|
+
return d.get("name_acronym") or str(num)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def api_path(self) -> str:
|
|
49
|
+
"""Live-timing archive path (resolved lazily, cached)."""
|
|
50
|
+
if self._api_path is None:
|
|
51
|
+
self._api_path = livetiming.api_path(
|
|
52
|
+
self.year, self.meeting["meeting_name"])
|
|
53
|
+
return self._api_path
|
|
54
|
+
|
|
55
|
+
# -- story --------------------------------------------------------------
|
|
56
|
+
def leader_runs(self) -> list:
|
|
57
|
+
"""Lead spells on the lap axis, from the P1 position stream."""
|
|
58
|
+
events, prev = [], None
|
|
59
|
+
for p in self._p1:
|
|
60
|
+
if p["driver_number"] != prev:
|
|
61
|
+
events.append((_iso(p["date"]), p["driver_number"]))
|
|
62
|
+
prev = p["driver_number"]
|
|
63
|
+
by_drv = {}
|
|
64
|
+
for l in self.laps:
|
|
65
|
+
if l.get("date_start"):
|
|
66
|
+
by_drv.setdefault(l["driver_number"], []).append(
|
|
67
|
+
(_iso(l["date_start"]), l["lap_number"]))
|
|
68
|
+
runs = []
|
|
69
|
+
for t, num in events:
|
|
70
|
+
lap = 1
|
|
71
|
+
for ts, ln in by_drv.get(num, []):
|
|
72
|
+
if ts <= t:
|
|
73
|
+
lap = ln
|
|
74
|
+
else:
|
|
75
|
+
break
|
|
76
|
+
if runs and runs[-1]["abbr"] == self.abbr(num):
|
|
77
|
+
continue
|
|
78
|
+
if runs:
|
|
79
|
+
runs[-1]["to"] = max(lap - 1, runs[-1]["from"])
|
|
80
|
+
runs.append({"abbr": self.abbr(num), "from": lap, "to": lap})
|
|
81
|
+
if runs:
|
|
82
|
+
runs[-1]["to"] = self.total_laps
|
|
83
|
+
return runs
|
|
84
|
+
|
|
85
|
+
def laps_led(self) -> dict:
|
|
86
|
+
led = {}
|
|
87
|
+
for r in self.leader_runs():
|
|
88
|
+
led[r["abbr"]] = led.get(r["abbr"], 0) + r["to"] - r["from"] + 1
|
|
89
|
+
return dict(sorted(led.items(), key=lambda kv: -kv[1]))
|
|
90
|
+
|
|
91
|
+
def interruptions(self) -> dict:
|
|
92
|
+
"""SC/VSC lap bands and red-flag laps from race control."""
|
|
93
|
+
bands, red, start = [], [], None
|
|
94
|
+
for m in sorted(self.race_control, key=lambda m: m["date"]):
|
|
95
|
+
msg, lap = (m.get("message") or "").upper(), m.get("lap_number")
|
|
96
|
+
if "RED FLAG" in msg and lap:
|
|
97
|
+
red.append(int(lap))
|
|
98
|
+
if ("SAFETY CAR DEPLOYED" in msg or "VSC DEPLOYED" in msg
|
|
99
|
+
or "VIRTUAL SAFETY CAR DEPLOYED" in msg):
|
|
100
|
+
start = int(lap or 1)
|
|
101
|
+
if start is not None and ("ENDING" in msg or "IN THIS LAP" in msg
|
|
102
|
+
or "CLEAR" in msg and "TRACK" in msg):
|
|
103
|
+
bands.append([start, int(lap or start)])
|
|
104
|
+
start = None
|
|
105
|
+
if start is not None:
|
|
106
|
+
bands.append([start, self.total_laps])
|
|
107
|
+
return {"sc_vsc_bands": bands, "red_flag_laps": sorted(set(red))}
|
|
108
|
+
|
|
109
|
+
def stints(self) -> dict:
|
|
110
|
+
out = {}
|
|
111
|
+
for s in sorted(self.stints_raw,
|
|
112
|
+
key=lambda s: (s["driver_number"], s["stint_number"])):
|
|
113
|
+
out.setdefault(self.abbr(s["driver_number"]), []).append({
|
|
114
|
+
"compound": s.get("compound"),
|
|
115
|
+
"from": s.get("lap_start"), "to": s.get("lap_end"),
|
|
116
|
+
"laps": (s.get("lap_end") or 0) - (s.get("lap_start") or 0) + 1})
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
def race_pace(self, threshold: float = 1.07) -> dict:
|
|
120
|
+
"""Median representative pace. Domain rules by default:
|
|
121
|
+
pit-out laps, pit-in laps, SC/VSC laps and quicklap threshold."""
|
|
122
|
+
bad = {l for a, b in self.interruptions()["sc_vsc_bands"]
|
|
123
|
+
for l in range(a, b + 1)}
|
|
124
|
+
pit_in = {(p["driver_number"], p["lap_number"]) for p in self.pits}
|
|
125
|
+
per = {}
|
|
126
|
+
for l in self.laps:
|
|
127
|
+
d = l.get("lap_duration")
|
|
128
|
+
if (not d or l.get("is_pit_out_lap")
|
|
129
|
+
or l["lap_number"] in bad
|
|
130
|
+
or (l["driver_number"], l["lap_number"]) in pit_in):
|
|
131
|
+
continue
|
|
132
|
+
per.setdefault(l["driver_number"], []).append(d)
|
|
133
|
+
out = {}
|
|
134
|
+
for num, ds in per.items():
|
|
135
|
+
m = median(ds)
|
|
136
|
+
quick = [x for x in ds if x <= m * threshold]
|
|
137
|
+
if len(quick) >= 3:
|
|
138
|
+
out[self.abbr(num)] = round(median(quick), 3)
|
|
139
|
+
return dict(sorted(out.items(), key=lambda kv: kv[1]))
|
|
140
|
+
|
|
141
|
+
def results(self) -> list:
|
|
142
|
+
"""Classified results; OpenF1 already applies '+1 LAP' convention.
|
|
143
|
+
FIA gives DNFs no position — ordered by laps completed after that."""
|
|
144
|
+
rows = sorted(self.result,
|
|
145
|
+
key=lambda r: (r.get("position") is None,
|
|
146
|
+
r.get("position") or 0,
|
|
147
|
+
-(r.get("number_of_laps") or 0)))
|
|
148
|
+
out = []
|
|
149
|
+
for r in rows:
|
|
150
|
+
g = r.get("gap_to_leader")
|
|
151
|
+
if r.get("dsq"):
|
|
152
|
+
gap = "DSQ"
|
|
153
|
+
elif r.get("dnf") or r.get("dns"):
|
|
154
|
+
gap = "DNS" if r.get("dns") else "DNF"
|
|
155
|
+
elif r.get("position") == 1:
|
|
156
|
+
gap = "WINNER"
|
|
157
|
+
elif isinstance(g, str):
|
|
158
|
+
gap = g if g.startswith("+") else f"+{g}"
|
|
159
|
+
elif g is None:
|
|
160
|
+
gap = ""
|
|
161
|
+
else:
|
|
162
|
+
gap = format_seconds(float(g))
|
|
163
|
+
d = self.drivers.get(r["driver_number"], {})
|
|
164
|
+
out.append({"position": r.get("position"),
|
|
165
|
+
"abbr": self.abbr(r["driver_number"]),
|
|
166
|
+
"name": d.get("full_name"),
|
|
167
|
+
"team": d.get("team_name"),
|
|
168
|
+
"gap": gap, "points": r.get("points") or 0.0,
|
|
169
|
+
"laps": r.get("number_of_laps")})
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
def timeline(self) -> list:
|
|
173
|
+
ev = []
|
|
174
|
+
for r in self.result:
|
|
175
|
+
if r.get("dnf"):
|
|
176
|
+
ab = self.abbr(r["driver_number"])
|
|
177
|
+
last = max((l["lap_number"] for l in self.laps
|
|
178
|
+
if l["driver_number"] == r["driver_number"]),
|
|
179
|
+
default=0)
|
|
180
|
+
ev.append({"lap": last, "kind": "out", "abbr": ab,
|
|
181
|
+
"title": f"{ab} out"})
|
|
182
|
+
inter = self.interruptions()
|
|
183
|
+
ev += [{"lap": l, "kind": "red", "title": "Red flag"}
|
|
184
|
+
for l in inter["red_flag_laps"]]
|
|
185
|
+
ev += [{"lap": a, "kind": "sc", "title": f"SC/VSC (laps {a}-{b})"}
|
|
186
|
+
for a, b in inter["sc_vsc_bands"]]
|
|
187
|
+
runs = self.leader_runs()
|
|
188
|
+
ev += [{"lap": cur["from"], "kind": "lead", "abbr": cur["abbr"],
|
|
189
|
+
"over": prev["abbr"],
|
|
190
|
+
"title": f"{cur['abbr']} leads (from {prev['abbr']})"}
|
|
191
|
+
for prev, cur in zip(runs, runs[1:])]
|
|
192
|
+
return sorted(ev, key=lambda e: (e["lap"], e["kind"]))
|
|
193
|
+
|
|
194
|
+
def crosscheck(self) -> dict:
|
|
195
|
+
"""Independent-source validation — gate publication on this."""
|
|
196
|
+
from .crosscheck import crosscheck
|
|
197
|
+
return crosscheck(self)
|
|
198
|
+
|
|
199
|
+
# -- dropped-feed harvest ------------------------------------------------
|
|
200
|
+
def championship_prediction(self) -> dict:
|
|
201
|
+
return feeds.championship_prediction(self)
|
|
202
|
+
|
|
203
|
+
def team_radio(self) -> list:
|
|
204
|
+
return feeds.team_radio(self)
|
|
205
|
+
|
|
206
|
+
def timing_stats(self) -> dict:
|
|
207
|
+
return feeds.timing_stats(self)
|
|
208
|
+
|
|
209
|
+
def story(self) -> dict:
|
|
210
|
+
"""One call, whole story — JSON-safe, no FastF1 involved."""
|
|
211
|
+
m = self.meeting
|
|
212
|
+
return jsonsafe({
|
|
213
|
+
"event": {"name": m["meeting_name"],
|
|
214
|
+
"location": self.info.get("location"),
|
|
215
|
+
"round": self.round, "year": self.year,
|
|
216
|
+
"date": (self.info.get("date_start") or "")[:10],
|
|
217
|
+
"total_laps": self.total_laps},
|
|
218
|
+
"results": self.results(),
|
|
219
|
+
"leader_runs": self.leader_runs(),
|
|
220
|
+
"laps_led": self.laps_led(),
|
|
221
|
+
"timeline": self.timeline(),
|
|
222
|
+
"stints": self.stints(),
|
|
223
|
+
"race_pace": self.race_pace(),
|
|
224
|
+
"interruptions": self.interruptions(),
|
|
225
|
+
"sources": ["openf1", "livetiming-index"],
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def load(year: int, rnd: int) -> Race:
|
|
230
|
+
"""``f1verse.load(2026, 12)`` → :class:`Race` (2023+ seasons)."""
|
|
231
|
+
return Race(year, rnd)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import livetiming, openf1 # noqa: F401
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Direct client for the official live-timing static archive.
|
|
2
|
+
|
|
3
|
+
This is the same public host FastF1 reads. f1verse only needs three files
|
|
4
|
+
FastF1 never parses; session paths are discovered via the season Index.json,
|
|
5
|
+
so no third-party library is involved. Nothing is redistributed — clips stay
|
|
6
|
+
as URLs.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from .. import http
|
|
12
|
+
|
|
13
|
+
BASE = "https://livetiming.formula1.com"
|
|
14
|
+
_LINE = re.compile(r"^(\d{2}):(\d{2}):(\d{2}\.\d{3})(.*)$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def api_path(year: int, meeting_name: str, session_name: str = "Race") -> str:
|
|
18
|
+
idx = http.get_json(f"{BASE}/static/{year}/Index.json")
|
|
19
|
+
for m in idx.get("Meetings", []):
|
|
20
|
+
if meeting_name.lower() in m.get("Name", "").lower():
|
|
21
|
+
for s in m.get("Sessions", []):
|
|
22
|
+
if s.get("Name") == session_name and s.get("Path"):
|
|
23
|
+
return "/static/" + s["Path"]
|
|
24
|
+
raise LookupError(f"{year} {meeting_name!r} {session_name!r} not in Index")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def fetch_stream(path: str, filename: str) -> list:
|
|
28
|
+
"""A ``.jsonStream`` file → ``[(t_seconds, payload_dict), ...]``."""
|
|
29
|
+
out = []
|
|
30
|
+
for line in http.get_text(BASE + path + filename).splitlines():
|
|
31
|
+
m = _LINE.match(line.strip())
|
|
32
|
+
if not m:
|
|
33
|
+
continue
|
|
34
|
+
t = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + float(m.group(3))
|
|
35
|
+
try:
|
|
36
|
+
out.append((t, json.loads(m.group(4))))
|
|
37
|
+
except json.JSONDecodeError:
|
|
38
|
+
continue
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def deepmerge(base, patch):
|
|
43
|
+
"""Streams send one snapshot, then partial patches."""
|
|
44
|
+
if not isinstance(base, dict) or not isinstance(patch, dict):
|
|
45
|
+
return patch
|
|
46
|
+
for k, v in patch.items():
|
|
47
|
+
base[k] = deepmerge(base.get(k), v)
|
|
48
|
+
return base
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Thin OpenF1 REST client (https://openf1.org) — coverage from 2023 season."""
|
|
2
|
+
from .. import http
|
|
3
|
+
|
|
4
|
+
BASE = "https://api.openf1.org/v1/"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get(endpoint: str, **params) -> list:
|
|
8
|
+
return http.get_json(BASE + endpoint, params)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_race(year: int, rnd: int) -> dict:
|
|
12
|
+
"""(year, round) → race session dict. Rounds count real GP meetings only."""
|
|
13
|
+
meetings = sorted(get("meetings", year=year), key=lambda m: m["date_start"])
|
|
14
|
+
gps = [m for m in meetings
|
|
15
|
+
if "test" not in m["meeting_name"].lower()
|
|
16
|
+
and not m.get("is_cancelled")]
|
|
17
|
+
if not 1 <= rnd <= len(gps):
|
|
18
|
+
raise ValueError(f"round {rnd} out of range (1..{len(gps)})")
|
|
19
|
+
m = gps[rnd - 1]
|
|
20
|
+
race = get("sessions", meeting_key=m["meeting_key"], session_name="Race")
|
|
21
|
+
if not race:
|
|
22
|
+
raise LookupError(f"no Race session for {m['meeting_name']}")
|
|
23
|
+
s = race[0]
|
|
24
|
+
s["meeting"] = m
|
|
25
|
+
return s
|
f1verse/story.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Turn a loaded FastF1 session into a race story.
|
|
2
|
+
|
|
3
|
+
FastF1 is excellent at *fetching and tidying* data. It deliberately stops
|
|
4
|
+
short of *telling you what happened*: there is no API for lead changes,
|
|
5
|
+
laps led, or an event timeline — every analyst reinvents them in a notebook,
|
|
6
|
+
and each reinvents them slightly differently. ``analyze`` computes them once,
|
|
7
|
+
with F1 domain rules applied by default, and returns plain JSON-safe data.
|
|
8
|
+
"""
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from ._json import jsonsafe
|
|
13
|
+
from .gaps import format_gap
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def leader_runs(session) -> list:
|
|
17
|
+
"""Contiguous stretches of race leadership: [{abbr, from, to}]."""
|
|
18
|
+
laps = session.laps
|
|
19
|
+
lead = (laps[laps["Position"] == 1][["LapNumber", "Driver"]]
|
|
20
|
+
.dropna().sort_values("LapNumber"))
|
|
21
|
+
runs, cur, start, prev = [], None, None, None
|
|
22
|
+
for _, r in lead.iterrows():
|
|
23
|
+
if r["Driver"] != cur:
|
|
24
|
+
if cur is not None:
|
|
25
|
+
runs.append({"abbr": cur, "from": int(start), "to": int(prev)})
|
|
26
|
+
cur, start = r["Driver"], r["LapNumber"]
|
|
27
|
+
prev = r["LapNumber"]
|
|
28
|
+
if cur is not None:
|
|
29
|
+
runs.append({"abbr": cur, "from": int(start), "to": int(prev)})
|
|
30
|
+
return runs
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def laps_led(session) -> dict:
|
|
34
|
+
"""Laps led per driver, most first. The stat that catches false headlines:
|
|
35
|
+
the driver who led the most laps is not always the winner."""
|
|
36
|
+
led = {}
|
|
37
|
+
for r in leader_runs(session):
|
|
38
|
+
led[r["abbr"]] = led.get(r["abbr"], 0) + r["to"] - r["from"] + 1
|
|
39
|
+
return dict(sorted(led.items(), key=lambda kv: -kv[1]))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def interruption_bands(session) -> dict:
|
|
43
|
+
"""SC/VSC bands and red-flag laps, on the leader's lap axis."""
|
|
44
|
+
laps = session.laps
|
|
45
|
+
lead = laps[laps["Position"] == 1].dropna(subset=["LapNumber"])
|
|
46
|
+
bad = sorted(lead[lead["TrackStatus"].astype(str)
|
|
47
|
+
.str.contains(r"[467]", na=False)]["LapNumber"].astype(int))
|
|
48
|
+
bands, s = [], None
|
|
49
|
+
for i, n in enumerate(bad):
|
|
50
|
+
if s is None:
|
|
51
|
+
s = p = n
|
|
52
|
+
elif n == p + 1:
|
|
53
|
+
p = n
|
|
54
|
+
else:
|
|
55
|
+
bands.append([s, p]); s = p = n
|
|
56
|
+
if i == len(bad) - 1:
|
|
57
|
+
bands.append([s, p])
|
|
58
|
+
red = []
|
|
59
|
+
rcm = getattr(session, "race_control_messages", None)
|
|
60
|
+
if rcm is not None and len(rcm):
|
|
61
|
+
red = [int(x) for x in
|
|
62
|
+
rcm[rcm["Message"].str.contains("RED FLAG", na=False)]["Lap"]
|
|
63
|
+
.dropna().unique()]
|
|
64
|
+
return {"sc_vsc_bands": bands, "red_flag_laps": red}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def stints(session) -> dict:
|
|
68
|
+
"""Per-driver stint list with lap ranges — red-flag-truncated stints kept
|
|
69
|
+
as-is so the caller can see the restart split."""
|
|
70
|
+
st = (session.laps[["Driver", "Stint", "Compound", "LapNumber"]]
|
|
71
|
+
.dropna(subset=["Stint"])
|
|
72
|
+
.groupby(["Driver", "Stint", "Compound"])["LapNumber"]
|
|
73
|
+
.agg(["min", "max", "count"]).reset_index())
|
|
74
|
+
out = {}
|
|
75
|
+
for _, r in st.sort_values(["Driver", "min"]).iterrows():
|
|
76
|
+
out.setdefault(r["Driver"], []).append({
|
|
77
|
+
"compound": r["Compound"], "from": int(r["min"]),
|
|
78
|
+
"to": int(r["max"]), "laps": int(r["count"])})
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def race_pace(session, threshold: float = 1.07) -> dict:
|
|
83
|
+
"""Median representative lap per driver.
|
|
84
|
+
|
|
85
|
+
Domain rules applied by default (this is the point):
|
|
86
|
+
in/out laps excluded, SC/VSC laps excluded, quicklap threshold applied,
|
|
87
|
+
AND FastF1's own 4-way accuracy check (``pick_accurate``) applied.
|
|
88
|
+
"""
|
|
89
|
+
bands = interruption_bands(session)["sc_vsc_bands"]
|
|
90
|
+
bad = {l for a, b in bands for l in range(a, b + 1)}
|
|
91
|
+
q = session.laps.pick_accurate().pick_quicklaps(threshold)
|
|
92
|
+
q = q[~q["LapNumber"].isin(bad)]
|
|
93
|
+
med = {}
|
|
94
|
+
for drv in session.results["Abbreviation"]:
|
|
95
|
+
d = q.pick_drivers(drv)["LapTime"].dt.total_seconds().dropna()
|
|
96
|
+
if len(d) >= 3:
|
|
97
|
+
med[drv] = round(float(np.median(d)), 3)
|
|
98
|
+
return dict(sorted(med.items(), key=lambda kv: kv[1]))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def results(session) -> list:
|
|
102
|
+
"""Classified results with broadcast-convention gap strings baked in."""
|
|
103
|
+
total = int(session.laps["LapNumber"].max())
|
|
104
|
+
out = []
|
|
105
|
+
for _, r in session.results.sort_values("Position").iterrows():
|
|
106
|
+
drv_laps = session.laps.pick_drivers(r["Abbreviation"])
|
|
107
|
+
last = int(drv_laps["LapNumber"].max()) if len(drv_laps) else 0
|
|
108
|
+
down = total - last if r["Status"] == "Lapped" else None
|
|
109
|
+
out.append({
|
|
110
|
+
"position": int(r["Position"]) if pd.notna(r["Position"]) else None,
|
|
111
|
+
"abbr": r["Abbreviation"], "name": r["FullName"],
|
|
112
|
+
"team": r["TeamName"],
|
|
113
|
+
"grid": int(r["GridPosition"]) if pd.notna(r["GridPosition"]) else None,
|
|
114
|
+
"status": r["Status"],
|
|
115
|
+
"gap": format_gap(r["Status"], r["Time"],
|
|
116
|
+
int(r["Position"]) if pd.notna(r["Position"]) else None,
|
|
117
|
+
laps_down=down),
|
|
118
|
+
"points": float(r["Points"]) if pd.notna(r["Points"]) else 0.0,
|
|
119
|
+
"last_lap": last,
|
|
120
|
+
})
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def timeline(session) -> list:
|
|
125
|
+
"""Chronological event list: retirements, red flags, SC/VSC, lead changes."""
|
|
126
|
+
ev = []
|
|
127
|
+
res = session.results
|
|
128
|
+
for _, r in res[~res["Status"].isin(["Finished", "Lapped"])].iterrows():
|
|
129
|
+
d = session.laps.pick_drivers(r["Abbreviation"])
|
|
130
|
+
ev.append({"lap": int(d["LapNumber"].max()) if len(d) else 0,
|
|
131
|
+
"kind": "out", "abbr": r["Abbreviation"],
|
|
132
|
+
"title": f"{r['Abbreviation']} out"})
|
|
133
|
+
bands = interruption_bands(session)
|
|
134
|
+
for lap in bands["red_flag_laps"]:
|
|
135
|
+
ev.append({"lap": lap, "kind": "red", "title": "Red flag"})
|
|
136
|
+
for a, b in bands["sc_vsc_bands"]:
|
|
137
|
+
ev.append({"lap": a, "kind": "sc",
|
|
138
|
+
"title": f"SC/VSC (laps {a}-{b})"})
|
|
139
|
+
runs = leader_runs(session)
|
|
140
|
+
for prev, cur in zip(runs, runs[1:]):
|
|
141
|
+
ev.append({"lap": cur["from"], "kind": "lead",
|
|
142
|
+
"abbr": cur["abbr"], "over": prev["abbr"],
|
|
143
|
+
"title": f"{cur['abbr']} leads (from {prev['abbr']})"})
|
|
144
|
+
return sorted(ev, key=lambda e: (e["lap"], e["kind"]))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def analyze(session) -> dict:
|
|
148
|
+
"""One call, whole story. Returns a plain JSON-safe dict."""
|
|
149
|
+
e = session.event
|
|
150
|
+
story = {
|
|
151
|
+
"event": {"name": str(e["EventName"]), "location": str(e["Location"]),
|
|
152
|
+
"round": int(e["RoundNumber"]),
|
|
153
|
+
"date": str(e["EventDate"].date()),
|
|
154
|
+
"total_laps": int(session.laps["LapNumber"].max())},
|
|
155
|
+
"results": results(session),
|
|
156
|
+
"leader_runs": leader_runs(session),
|
|
157
|
+
"laps_led": laps_led(session),
|
|
158
|
+
"timeline": timeline(session),
|
|
159
|
+
"stints": stints(session),
|
|
160
|
+
"race_pace": race_pace(session),
|
|
161
|
+
"interruptions": interruption_bands(session),
|
|
162
|
+
}
|
|
163
|
+
return jsonsafe(story)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: f1verse
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: The story layer for Formula 1 data — zero-dependency race narratives: lead changes, stints, race pace, live championship projection, team radio index. Optional FastF1 adapter.
|
|
5
|
+
Author: f1verse contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/f1verse/f1verse
|
|
8
|
+
Keywords: f1,formula1,formula-1,fastf1,openf1,motorsport,racing,telemetry,live-timing,race-analysis,data-analysis,grand-prix
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: fastf1
|
|
18
|
+
Requires-Dist: fastf1>=3.4; extra == "fastf1"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# f1verse
|
|
22
|
+
|
|
23
|
+
**The story layer for Formula 1 data.** Data libraries fetch and tidy —
|
|
24
|
+
f1verse tells you *what happened*: lead changes, laps led, event timelines,
|
|
25
|
+
stint strategy, true race pace, and the live championship projection that
|
|
26
|
+
broadcasts never show.
|
|
27
|
+
|
|
28
|
+
**Zero dependencies.** Standard library only, seasons 2023+.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install f1verse
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import f1verse
|
|
36
|
+
|
|
37
|
+
race = f1verse.load(2026, 12) # year, round — no other library needed
|
|
38
|
+
|
|
39
|
+
race.laps_led() # {'ANT': 32, 'NOR': 31, 'HAM': 9}
|
|
40
|
+
race.leader_runs() # [{'abbr': 'NOR', 'from': 1, 'to': 4}, ...]
|
|
41
|
+
race.results()[7] # {'abbr': 'HUL', 'gap': '+1 LAP', ...}
|
|
42
|
+
race.race_pace() # median pace — pit/SC/VSC laps excluded by default
|
|
43
|
+
race.story() # one call, whole story, plain JSON
|
|
44
|
+
|
|
45
|
+
race.championship_prediction() # per-lap "if it ended now" title projection
|
|
46
|
+
race.team_radio() # timestamped clip URLs (nothing downloaded)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Using FastF1 already? Keep your workflow — the adapter takes a loaded
|
|
50
|
+
session (`pip install f1verse[fastf1]`, adds telemetry & pre-2023 seasons):
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
story = f1verse.analyze(fastf1_session)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Why this exists
|
|
57
|
+
|
|
58
|
+
Everyone who builds on FastF1 rediscovers the same traps, and each fixes
|
|
59
|
+
them slightly differently:
|
|
60
|
+
|
|
61
|
+
- **`results['Time']` is not a gap for lapped cars.** The raw value can be
|
|
62
|
+
*smaller* than a car that finished ahead (`P7 +1:19.915` vs `P8 +36.049`).
|
|
63
|
+
No error, no warning — naive tables are silently wrong.
|
|
64
|
+
→ `f1verse.format_gap` applies the broadcast convention (`+1 LAP`).
|
|
65
|
+
- **There is no API for "who led the race".** Lead changes, laps led,
|
|
66
|
+
overtake-for-the-lead moments — every notebook reinvents them.
|
|
67
|
+
→ `leader_runs`, `laps_led`, `timeline`.
|
|
68
|
+
- **Race pace needs domain rules**, not just a quicklap threshold: in/out
|
|
69
|
+
laps, SC/VSC laps, and laps failing FastF1's own 4-way accuracy check
|
|
70
|
+
must go. → `race_pace` applies all of it by default.
|
|
71
|
+
- **numpy scalars break `json.dumps`.** Every f1verse output is plain
|
|
72
|
+
JSON-safe Python. → pipe results straight into web or video pipelines.
|
|
73
|
+
- **Data-quality warnings are only logged as text.**
|
|
74
|
+
→ `integrity_report` returns them as structured data your pipeline can
|
|
75
|
+
act on (hold publication, exclude laps, annotate).
|
|
76
|
+
|
|
77
|
+
## The feeds FastF1 throws away
|
|
78
|
+
|
|
79
|
+
The official live-timing archive contains more than FastF1 parses.
|
|
80
|
+
f1verse harvests three of the dropped feeds (through FastF1's own cache,
|
|
81
|
+
same rate-limit etiquette):
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
f1verse.championship_prediction(session)
|
|
85
|
+
# per-lap "if the race ended now" projection of both championships,
|
|
86
|
+
# including the moments the projected champion changed
|
|
87
|
+
|
|
88
|
+
f1verse.team_radio(session)
|
|
89
|
+
# timestamped team-radio clips: [{'t', 'utc', 'driver_number', 'url'}]
|
|
90
|
+
# URLs only — nothing is downloaded or redistributed
|
|
91
|
+
|
|
92
|
+
f1verse.timing_stats(session)
|
|
93
|
+
# personal bests, best sectors, speed-trap figures
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Design rules
|
|
97
|
+
|
|
98
|
+
1. **Zero required dependencies.** The native loader speaks to public REST
|
|
99
|
+
endpoints (OpenF1) and the official live-timing archive directly, with
|
|
100
|
+
its own on-disk cache and polite pacing.
|
|
101
|
+
2. **FastF1 is respected, not replaced** — optional adapter for telemetry,
|
|
102
|
+
qualifying segments and pre-2023 history.
|
|
103
|
+
3. **Everything returned is plain JSON-safe Python.**
|
|
104
|
+
4. **F1 domain rules are defaults, not options.**
|
|
105
|
+
5. **Cross-checked where possible** — e.g. lapped-car gaps are computed by
|
|
106
|
+
convention *and* confirmed against a second source.
|
|
107
|
+
6. **Code only.** No timing data, media, or images are bundled or
|
|
108
|
+
redistributed; data is fetched by the end user.
|
|
109
|
+
|
|
110
|
+
## Roadmap
|
|
111
|
+
|
|
112
|
+
- Full cross-validation layer (publish only when two sources agree)
|
|
113
|
+
- Overtake timeline ([OpenF1](https://openf1.org) `/overtakes`) & undercut/overcut detection
|
|
114
|
+
- Circuit & driver metadata joins ([Jolpica](https://github.com/jolpica/jolpica-f1), [f1db](https://github.com/f1db/f1db))
|
|
115
|
+
- Korean localization package (`f1verse-ko`)
|
|
116
|
+
- Chart & vertical-video templates consuming f1verse JSON
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
*Unofficial fan project. Not affiliated with, endorsed by, or associated
|
|
121
|
+
with Formula 1, FIA, FOM, or any F1 team. F1, FORMULA 1 and related marks
|
|
122
|
+
are trademarks of Formula One Licensing BV. This library contains code
|
|
123
|
+
only — no timing data, media, or images are included or redistributed;
|
|
124
|
+
data is fetched by the end user from publicly accessible endpoints,
|
|
125
|
+
subject to the respective providers' terms. Built on
|
|
126
|
+
[FastF1](https://github.com/theOehrly/Fast-F1) (MIT).*
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
f1verse/__init__.py,sha256=kd0VGw4Db9a967PtM3PJ0QN2XJ0Z3tqrxHAE38qtrzI,1542
|
|
2
|
+
f1verse/_json.py,sha256=G4heUcehAWfyDwPNyT1fqwClSaQ5rI04ZmyBaFHd45k,1819
|
|
3
|
+
f1verse/crosscheck.py,sha256=0PkvS3z5JeyfJ4OzDcAOpljQ0CEL40cUzzYMewg15G4,5072
|
|
4
|
+
f1verse/feeds.py,sha256=EJur3u25VmCzp938XRYrgWHkWzzIsj5PeIQGKelyxqo,2871
|
|
5
|
+
f1verse/gaps.py,sha256=cqzyYUwLf7N6AQNMJROQrn9aBEdJQ34kiRVczKV1xl8,1287
|
|
6
|
+
f1verse/http.py,sha256=4Mk7_LAvh3bXnKKIo4PFmcp5BIBq6QtIA807HwR_c94,1546
|
|
7
|
+
f1verse/integrity.py,sha256=GQWybJKYij8-AJ4UqbVtH73h6i1MTQhtFFjGpR8wUks,1414
|
|
8
|
+
f1verse/race.py,sha256=7wPDeAkYcyjZykZZ0ApTpRNGtML9XKkau8LHUR_3RGE,9723
|
|
9
|
+
f1verse/story.py,sha256=HbS8BOwbB8xAtG5BFLu1VgJQeXy3wIDSDIaVEyCHhHs,6783
|
|
10
|
+
f1verse/sources/__init__.py,sha256=jw6DuvvOwGCaX_OtjJOp02A5CY6GD7bAKR_tBwqPSNE,47
|
|
11
|
+
f1verse/sources/livetiming.py,sha256=NP1OLvgrxfBd8om9vMBiX7oV_3hBV2fqBeoSV4S_F1o,1704
|
|
12
|
+
f1verse/sources/openf1.py,sha256=Jo6_cD4Asct79A886vxWJJfWAfP6XP496ddDK8WUvJw,912
|
|
13
|
+
f1verse-0.3.0.dist-info/licenses/LICENSE,sha256=fCJ3HbN7I0wp80aPf0QxILiIyO5hJAFiXbGlM6oDw4U,1077
|
|
14
|
+
f1verse-0.3.0.dist-info/METADATA,sha256=vmTVRbkTrMizH2_J0r3k9r_5RAaJecQPtp8RUPDnEUs,5329
|
|
15
|
+
f1verse-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
16
|
+
f1verse-0.3.0.dist-info/top_level.txt,sha256=DnPkoxeuj8pPDUVd0iQ1hJyIHvfhfIous6yNslrIBRs,8
|
|
17
|
+
f1verse-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 f1verse contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
f1verse
|