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 +21 -0
- f1verse-0.3.0/PKG-INFO +126 -0
- f1verse-0.3.0/README.md +106 -0
- f1verse-0.3.0/pyproject.toml +33 -0
- f1verse-0.3.0/setup.cfg +4 -0
- f1verse-0.3.0/src/f1verse/__init__.py +45 -0
- f1verse-0.3.0/src/f1verse/_json.py +52 -0
- f1verse-0.3.0/src/f1verse/crosscheck.py +110 -0
- f1verse-0.3.0/src/f1verse/feeds.py +72 -0
- f1verse-0.3.0/src/f1verse/gaps.py +43 -0
- f1verse-0.3.0/src/f1verse/http.py +49 -0
- f1verse-0.3.0/src/f1verse/integrity.py +32 -0
- f1verse-0.3.0/src/f1verse/race.py +231 -0
- f1verse-0.3.0/src/f1verse/sources/__init__.py +1 -0
- f1verse-0.3.0/src/f1verse/sources/livetiming.py +48 -0
- f1verse-0.3.0/src/f1verse/sources/openf1.py +25 -0
- f1verse-0.3.0/src/f1verse/story.py +163 -0
- f1verse-0.3.0/src/f1verse.egg-info/PKG-INFO +126 -0
- f1verse-0.3.0/src/f1verse.egg-info/SOURCES.txt +20 -0
- f1verse-0.3.0/src/f1verse.egg-info/dependency_links.txt +1 -0
- f1verse-0.3.0/src/f1verse.egg-info/requires.txt +3 -0
- f1verse-0.3.0/src/f1verse.egg-info/top_level.txt +1 -0
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).*
|
f1verse-0.3.0/README.md
ADDED
|
@@ -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"]
|
f1verse-0.3.0/setup.cfg
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)]
|
|
@@ -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))
|