f1verse 0.3.0__tar.gz

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-0.3.0/LICENSE ADDED
@@ -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.
f1verse-0.3.0/PKG-INFO ADDED
@@ -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,106 @@
1
+ # f1verse
2
+
3
+ **The story layer for Formula 1 data.** Data libraries fetch and tidy —
4
+ f1verse tells you *what happened*: lead changes, laps led, event timelines,
5
+ stint strategy, true race pace, and the live championship projection that
6
+ broadcasts never show.
7
+
8
+ **Zero dependencies.** Standard library only, seasons 2023+.
9
+
10
+ ```bash
11
+ pip install f1verse
12
+ ```
13
+
14
+ ```python
15
+ import f1verse
16
+
17
+ race = f1verse.load(2026, 12) # year, round — no other library needed
18
+
19
+ race.laps_led() # {'ANT': 32, 'NOR': 31, 'HAM': 9}
20
+ race.leader_runs() # [{'abbr': 'NOR', 'from': 1, 'to': 4}, ...]
21
+ race.results()[7] # {'abbr': 'HUL', 'gap': '+1 LAP', ...}
22
+ race.race_pace() # median pace — pit/SC/VSC laps excluded by default
23
+ race.story() # one call, whole story, plain JSON
24
+
25
+ race.championship_prediction() # per-lap "if it ended now" title projection
26
+ race.team_radio() # timestamped clip URLs (nothing downloaded)
27
+ ```
28
+
29
+ Using FastF1 already? Keep your workflow — the adapter takes a loaded
30
+ session (`pip install f1verse[fastf1]`, adds telemetry & pre-2023 seasons):
31
+
32
+ ```python
33
+ story = f1verse.analyze(fastf1_session)
34
+ ```
35
+
36
+ ## Why this exists
37
+
38
+ Everyone who builds on FastF1 rediscovers the same traps, and each fixes
39
+ them slightly differently:
40
+
41
+ - **`results['Time']` is not a gap for lapped cars.** The raw value can be
42
+ *smaller* than a car that finished ahead (`P7 +1:19.915` vs `P8 +36.049`).
43
+ No error, no warning — naive tables are silently wrong.
44
+ → `f1verse.format_gap` applies the broadcast convention (`+1 LAP`).
45
+ - **There is no API for "who led the race".** Lead changes, laps led,
46
+ overtake-for-the-lead moments — every notebook reinvents them.
47
+ → `leader_runs`, `laps_led`, `timeline`.
48
+ - **Race pace needs domain rules**, not just a quicklap threshold: in/out
49
+ laps, SC/VSC laps, and laps failing FastF1's own 4-way accuracy check
50
+ must go. → `race_pace` applies all of it by default.
51
+ - **numpy scalars break `json.dumps`.** Every f1verse output is plain
52
+ JSON-safe Python. → pipe results straight into web or video pipelines.
53
+ - **Data-quality warnings are only logged as text.**
54
+ → `integrity_report` returns them as structured data your pipeline can
55
+ act on (hold publication, exclude laps, annotate).
56
+
57
+ ## The feeds FastF1 throws away
58
+
59
+ The official live-timing archive contains more than FastF1 parses.
60
+ f1verse harvests three of the dropped feeds (through FastF1's own cache,
61
+ same rate-limit etiquette):
62
+
63
+ ```python
64
+ f1verse.championship_prediction(session)
65
+ # per-lap "if the race ended now" projection of both championships,
66
+ # including the moments the projected champion changed
67
+
68
+ f1verse.team_radio(session)
69
+ # timestamped team-radio clips: [{'t', 'utc', 'driver_number', 'url'}]
70
+ # URLs only — nothing is downloaded or redistributed
71
+
72
+ f1verse.timing_stats(session)
73
+ # personal bests, best sectors, speed-trap figures
74
+ ```
75
+
76
+ ## Design rules
77
+
78
+ 1. **Zero required dependencies.** The native loader speaks to public REST
79
+ endpoints (OpenF1) and the official live-timing archive directly, with
80
+ its own on-disk cache and polite pacing.
81
+ 2. **FastF1 is respected, not replaced** — optional adapter for telemetry,
82
+ qualifying segments and pre-2023 history.
83
+ 3. **Everything returned is plain JSON-safe Python.**
84
+ 4. **F1 domain rules are defaults, not options.**
85
+ 5. **Cross-checked where possible** — e.g. lapped-car gaps are computed by
86
+ convention *and* confirmed against a second source.
87
+ 6. **Code only.** No timing data, media, or images are bundled or
88
+ redistributed; data is fetched by the end user.
89
+
90
+ ## Roadmap
91
+
92
+ - Full cross-validation layer (publish only when two sources agree)
93
+ - Overtake timeline ([OpenF1](https://openf1.org) `/overtakes`) & undercut/overcut detection
94
+ - Circuit & driver metadata joins ([Jolpica](https://github.com/jolpica/jolpica-f1), [f1db](https://github.com/f1db/f1db))
95
+ - Korean localization package (`f1verse-ko`)
96
+ - Chart & vertical-video templates consuming f1verse JSON
97
+
98
+ ---
99
+
100
+ *Unofficial fan project. Not affiliated with, endorsed by, or associated
101
+ with Formula 1, FIA, FOM, or any F1 team. F1, FORMULA 1 and related marks
102
+ are trademarks of Formula One Licensing BV. This library contains code
103
+ only — no timing data, media, or images are included or redistributed;
104
+ data is fetched by the end user from publicly accessible endpoints,
105
+ subject to the respective providers' terms. Built on
106
+ [FastF1](https://github.com/theOehrly/Fast-F1) (MIT).*
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "f1verse"
7
+ version = "0.3.0"
8
+ description = "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."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "f1verse contributors" }]
13
+ keywords = [
14
+ "f1", "formula1", "formula-1", "fastf1", "openf1", "motorsport", "racing",
15
+ "telemetry", "live-timing", "race-analysis", "data-analysis", "grand-prix",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Scientific/Engineering :: Information Analysis",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.optional-dependencies]
27
+ fastf1 = ["fastf1>=3.4"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/f1verse/f1verse"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)]
@@ -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)
@@ -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}
@@ -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", {}))
@@ -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)
@@ -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))