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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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