timingtower 0.1.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.
- timingtower-0.1.0/.github/workflows/ci.yml +39 -0
- timingtower-0.1.0/.github/workflows/publish.yml +41 -0
- timingtower-0.1.0/.gitignore +18 -0
- timingtower-0.1.0/.pre-commit-config.yaml +6 -0
- timingtower-0.1.0/.python-version +1 -0
- timingtower-0.1.0/CONTRIBUTING.md +66 -0
- timingtower-0.1.0/PKG-INFO +181 -0
- timingtower-0.1.0/README.md +165 -0
- timingtower-0.1.0/examples/average_rcm.py +112 -0
- timingtower-0.1.0/examples/car_and_position.py +70 -0
- timingtower-0.1.0/justfile +20 -0
- timingtower-0.1.0/pyproject.toml +103 -0
- timingtower-0.1.0/src/timingtower/__init__.py +3 -0
- timingtower-0.1.0/src/timingtower/api_handler/__init__.py +13 -0
- timingtower-0.1.0/src/timingtower/api_handler/client.py +565 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/__init__.py +73 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/archive_status.py +25 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/base.py +181 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/car_data.py +113 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/championship_prediction.py +102 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/circuit.py +7 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/content_streams.py +79 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/country.py +8 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/current_tyres.py +90 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/driver_list.py +90 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/driver_race_info.py +253 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/driver_tracker.py +109 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/extrapolated_clock.py +66 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/heartbeat.py +52 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/lap_count.py +82 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/lap_series.py +68 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/meeting.py +138 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/meeting_data.py +14 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/overtake_series.py +83 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/pit_lane_time_collection.py +99 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/pit_stop.py +55 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/pit_stop_series.py +80 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/position.py +66 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/race_control_messages.py +216 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/season.py +84 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/session.py +323 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/session_data.py +84 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/session_info.py +115 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/session_status.py +28 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/timing_app_data.py +134 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/timing_data.py +215 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/timing_stats.py +208 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/tla_rcm.py +51 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/top_three.py +81 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/track_status.py +29 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/tyre_stint_series.py +78 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/weather_data.py +39 -0
- timingtower-0.1.0/src/timingtower/api_handler/models/weather_data_series.py +91 -0
- timingtower-0.1.0/src/timingtower/api_handler/registry.py +20 -0
- timingtower-0.1.0/src/timingtower/api_handler/settings.py +11 -0
- timingtower-0.1.0/tests/__init__.py +0 -0
- timingtower-0.1.0/tests/conftest.py +91 -0
- timingtower-0.1.0/tests/data/base_index.json +1 -0
- timingtower-0.1.0/tests/data/season_index.json +1 -0
- timingtower-0.1.0/tests/data/timing_data.json +1 -0
- timingtower-0.1.0/tests/data/timing_data.jsonStream +61660 -0
- timingtower-0.1.0/tests/helpers.py +248 -0
- timingtower-0.1.0/tests/test_async_client.py +264 -0
- timingtower-0.1.0/tests/test_sync_client.py +255 -0
- timingtower-0.1.0/todo.md +5 -0
- timingtower-0.1.0/uv.lock +856 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
check:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.14"
|
|
18
|
+
allow-prereleases: true
|
|
19
|
+
|
|
20
|
+
- uses: astral-sh/setup-uv@v4
|
|
21
|
+
with:
|
|
22
|
+
enable-cache: true
|
|
23
|
+
|
|
24
|
+
- run: uv sync
|
|
25
|
+
|
|
26
|
+
- run: uv tool install ruff
|
|
27
|
+
- run: uv tool install basedpyright
|
|
28
|
+
|
|
29
|
+
- name: Format check
|
|
30
|
+
run: ruff format --check src tests
|
|
31
|
+
|
|
32
|
+
- name: Lint
|
|
33
|
+
run: ruff check src tests
|
|
34
|
+
|
|
35
|
+
- name: Type check
|
|
36
|
+
run: basedpyright
|
|
37
|
+
|
|
38
|
+
- name: Test
|
|
39
|
+
run: uv run pytest
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
|
|
14
|
+
- name: Install uv
|
|
15
|
+
uses: astral-sh/setup-uv@v4
|
|
16
|
+
|
|
17
|
+
- name: Build
|
|
18
|
+
run: uv build
|
|
19
|
+
|
|
20
|
+
- name: Upload dist
|
|
21
|
+
uses: actions/upload-artifact@v4
|
|
22
|
+
with:
|
|
23
|
+
name: dist
|
|
24
|
+
path: dist/
|
|
25
|
+
|
|
26
|
+
publish:
|
|
27
|
+
needs: build
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
environment: pypi
|
|
30
|
+
permissions:
|
|
31
|
+
id-token: write
|
|
32
|
+
|
|
33
|
+
steps:
|
|
34
|
+
- name: Download dist
|
|
35
|
+
uses: actions/download-artifact@v4
|
|
36
|
+
with:
|
|
37
|
+
name: dist
|
|
38
|
+
path: dist/
|
|
39
|
+
|
|
40
|
+
- name: Publish to PyPI
|
|
41
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Contributing to timingtower
|
|
2
|
+
|
|
3
|
+
Thanks for your interest in contributing! timingtower is in early development (`0.x`), so the API is still evolving - but that also means there's plenty of room to shape things.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
Ensure that [uv](https://docs.astral.sh/uv/) and [just](https://just.systems/man/en/installation.html) are installed.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
git clone https://github.com/bfoley12/timingtower.git
|
|
10
|
+
cd timingtower
|
|
11
|
+
just setup
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Development
|
|
15
|
+
Run tests, lint, and type check before submitting anything:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
just check
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or individually:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
ruff format
|
|
25
|
+
ruff check
|
|
26
|
+
basedpyright
|
|
27
|
+
uv run pytest
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Both must pass clean.
|
|
31
|
+
|
|
32
|
+
## What to work on
|
|
33
|
+
|
|
34
|
+
Check [open issues](https://github.com/bfoley12/timingtower/issues) for anything tagged `good first issue` or `help wanted`. Some areas where contributions are especially useful:
|
|
35
|
+
|
|
36
|
+
- **Test coverage** for existing feeds
|
|
37
|
+
- **Documentation** of existing classes and functions
|
|
38
|
+
- **Examples** of loading and basic transformations/uses
|
|
39
|
+
|
|
40
|
+
If you want to tackle something not listed, open an issue first so we can discuss the approach.
|
|
41
|
+
|
|
42
|
+
## Pull requests
|
|
43
|
+
|
|
44
|
+
- Keep PRs focused - one feed, one fix, one feature.
|
|
45
|
+
- Include tests for new feeds or changed behavior.
|
|
46
|
+
- All tests and basedpyright must pass.
|
|
47
|
+
- Use clear commit messages. No strict format required, just be descriptive.
|
|
48
|
+
|
|
49
|
+
## Code conventions
|
|
50
|
+
|
|
51
|
+
- **Pydantic v2** models for all feed .json.
|
|
52
|
+
- **Polars** for all DataFrames - no Pandas - especially in feed .jsonStream.
|
|
53
|
+
- **httpx** for HTTP.
|
|
54
|
+
- This will change to niquest when I have time to read up on it. Stay with httpx for now.
|
|
55
|
+
- Store raw strings at ingest (e.g., timestamps as `pl.String`). Parsing and casting belong in the transformation layer.
|
|
56
|
+
- Fail loudly on malformed data rather than silently skipping.
|
|
57
|
+
|
|
58
|
+
## Architecture quick reference
|
|
59
|
+
|
|
60
|
+
- `fetch()` is a thin HTTP/URL-builder layer. It does not resolve sessions or meetings.
|
|
61
|
+
- `get()` handles all domain resolution (meeting, session, folder lookup) and calls `fetch()` with fully resolved paths.
|
|
62
|
+
- Feed models live in their own modules and are registered for class-based `client.get(F1DataContainer)` calls.
|
|
63
|
+
|
|
64
|
+
## Questions?
|
|
65
|
+
|
|
66
|
+
Open a GitHub issue or discussion. There's no Discord/Slack yet.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: timingtower
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A thin, unopinionated wrapper of the F1 livetiming API. Provides typed values on a modern data stack.
|
|
5
|
+
Project-URL: Homepage, https://github.com/bfoley12/timingtower
|
|
6
|
+
Project-URL: Repository, https://github.com/bfoley12/timingtower
|
|
7
|
+
Author-email: Brendan Foley <brendanfoley1214@proton.me>
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.14
|
|
10
|
+
Requires-Dist: httpx>=0.28.1
|
|
11
|
+
Requires-Dist: polars>=1.39.0
|
|
12
|
+
Requires-Dist: pydantic-settings>=2.13.1
|
|
13
|
+
Requires-Dist: pydantic>=2.12.5
|
|
14
|
+
Requires-Dist: tenacity>=9.1.4
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# timingtower
|
|
18
|
+
## Motivation
|
|
19
|
+
The F1 livetiming API at https://livetiming.formula1.com exposes a rich set of data feeds - car telemetry, position data, timing, tyre stints, race control messages, weather, pit lane times, and more - going back to 2018. Existing tools access parts of this API but make tradeoffs that aren't right for every use case.
|
|
20
|
+
|
|
21
|
+
[FastF1](https://github.com/theOehrly/Fast-F1) is an excellent analysis-first library, but it consumes raw feeds internally and surfaces its own higher-level abstractions built on Pandas. While they deliver high quality, processed views of the data, some power-analysts may want to handle the raw data directly.
|
|
22
|
+
|
|
23
|
+
[OpenF1](https://github.com/br-g/openf1) is a hosted REST API that proxies a subset of the feed data into simplified JSON endpoints. It's convenient for quick queries but only covers 2023 onward and restructures the data into its own schema.
|
|
24
|
+
|
|
25
|
+
timingtower takes a different approach. It maps the livetiming feeds directly, preserving their original structure while wrapping them in Pydantic V2 models and Polars DataFrames. The raw data stays intact - timingtower doesn't decide what's relevant or how fields should be combined. It gives you typed, validated access to the feeds as they exist, on a modern stack that's faster and more ergonomic than Pandas.
|
|
26
|
+
|
|
27
|
+
Like its namesake, timingtower sits close to the action - just one layer above the raw data.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
## Data Model
|
|
31
|
+
The livetiming API is a hierarchical API that progressively provides more information. The base of the API is https://livetiming.formula1.com/static (from here on, we will refer to that as root - '/' and reference the API layers as starting from '/'). Most layers provide an Index.json that we can learn about the available endpoints to dive deeper into. For example, /Index.json shows the current year (why it doesn't show every available year is not known to me). /{year}/Index.json provides a list of meetings (race or testing weekends) with their relevant sessions (FP1, Qualifying, etc.). Oddly, /{year}/{meeting} does not provide an Index.json file. /{year}/{meeting}/{session}/Index.json displays all available 'feeds' for a session. A 'feed' refers to a single collection of data submitted either as a .json file (a 'keyframe') or a .jsonStream (aka .jsonl) file (a 'stream').
|
|
32
|
+
|
|
33
|
+
A keyframe represents a static json, while streams are dynamic, partial updates throughout the course of a session. This makes Pydantic a natural package to model the keyframe. Streams are timestamped by the session time as a duration. Polars is used to represent the streamed data.
|
|
34
|
+
|
|
35
|
+
Consistency is prioritized across returned models. Each model contains a 'keyframe' and 'stream' (with the exceptions of Season, Meeting, and Session, which offer only a keyframe since they only have an Index.json file). It is up to the user to determine whether they need the data from the stream or the keyframe, as some feeds are represented better in the stream (ie. CarData and Position keyframes are not useful for many applications). In anticipation of many use cases relying on the stream's dataframe, a property is provided directly on the F1DataContainer object: obj.df.
|
|
36
|
+
|
|
37
|
+
## Getting Started
|
|
38
|
+
|
|
39
|
+
### Install
|
|
40
|
+
```bash
|
|
41
|
+
# uv
|
|
42
|
+
uv add timingtower
|
|
43
|
+
|
|
44
|
+
# pip
|
|
45
|
+
pip install timingtower
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Data Exploration
|
|
49
|
+
```python
|
|
50
|
+
from timingtower import DirectClient
|
|
51
|
+
|
|
52
|
+
with DirectClient() as client:
|
|
53
|
+
# Available seasons
|
|
54
|
+
client.get_available_seasons()
|
|
55
|
+
|
|
56
|
+
# Get meetings from specific year
|
|
57
|
+
season = client.get_season(year=2026) # Using convenience method
|
|
58
|
+
season.meetings # Aliases season.keyframe.meetings for convenience
|
|
59
|
+
|
|
60
|
+
# Get sessions from specific meeting
|
|
61
|
+
meeting = season.get_meeting(meeting="Australia")
|
|
62
|
+
meeting.sessions
|
|
63
|
+
|
|
64
|
+
# Get a specific session
|
|
65
|
+
meeting.get_session(name="Qualifying")
|
|
66
|
+
# Using convenience properties
|
|
67
|
+
meeting.q # Qualifying
|
|
68
|
+
|
|
69
|
+
# Get session directly from client and look at available data
|
|
70
|
+
session_index = client.get(year=2026, meeting="Australia", session="Qualifying")
|
|
71
|
+
session_index.available_feeds
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Get data
|
|
75
|
+
#### Synchronously
|
|
76
|
+
```python
|
|
77
|
+
from timingtower import DirectClient
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# Can also use DirectClient as a long-lived instance:
|
|
81
|
+
# client = DirectClient()
|
|
82
|
+
# val = client.get(...)
|
|
83
|
+
|
|
84
|
+
with DirectClient() as client:
|
|
85
|
+
# Request CarData and Position from livetiming API
|
|
86
|
+
car_data = client.get(model="CarData", year=2026, meeting="Shanghai", session="Race") # Returns a Keyframe+Stream of CarData
|
|
87
|
+
position = client.get(model="Position", year=2026, meeting="Shanghai", session="Race") # Returns a Keyframe+Stream of Position
|
|
88
|
+
|
|
89
|
+
car_df = car_data.df
|
|
90
|
+
position_df = position.df
|
|
91
|
+
|
|
92
|
+
joined_df = car_df.join_asof(
|
|
93
|
+
position_df, on="timestamp", by="racing_number", strategy="nearest"
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
#### Asynchronously
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import asyncio
|
|
100
|
+
from timingtower import AsyncDirectClient
|
|
101
|
+
|
|
102
|
+
# Use the async client as a context manager
|
|
103
|
+
async with AsyncDirectClient() as client:
|
|
104
|
+
# Launch multiple jobs at the same time (this sends 4 requests - 1 for keyframe and 1 for stream in each client.get)
|
|
105
|
+
car_data, position = await asyncio.gather(
|
|
106
|
+
client.get(model="CarData", year=2024, meeting="Monza", session="Race"),
|
|
107
|
+
client.get(model="Position", year=2024, meeting="Monza", session="Race"),
|
|
108
|
+
)
|
|
109
|
+
car_df = car_data.df # Alias to car_data.stream.data
|
|
110
|
+
position_df = position.df
|
|
111
|
+
|
|
112
|
+
joined_df = car_df.join_asof(
|
|
113
|
+
position_df, on="timestamp", by="racing_number", strategy="nearest"
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Modify (Async)DirectClient settings
|
|
118
|
+
Since your connection to livetiming may be different from the testing environment, we allow for customization of ClientSettings. Check settings::ClientSettings for what can be changed.
|
|
119
|
+
```python
|
|
120
|
+
from timingtower import AsyncDirectClient, ClientSettings
|
|
121
|
+
|
|
122
|
+
settings = ClientSettings(
|
|
123
|
+
total_timeout = 10, # Set max allowed time to wait for client to 10s
|
|
124
|
+
request_timeout = 2 # Set max allowed time to wait per request to 2s
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async_client = AsyncDirectClient(settings=settings)
|
|
128
|
+
# Note: For DirectClient, total_timeout does not have any effect at the moment.
|
|
129
|
+
# Each request is still limited to request timeout, so the max waited for is 3x request_timeout
|
|
130
|
+
sync_client = DirectClient(settings=settings)
|
|
131
|
+
|
|
132
|
+
sync_client.get(year=2026)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Supported Feeds
|
|
136
|
+
<details>
|
|
137
|
+
<summary>Supported Feeds (32/32)</summary>
|
|
138
|
+
|
|
139
|
+
| Feed | Keyframe (`.json`) | Stream (`.jsonStream`) |
|
|
140
|
+
|------|:-----:|:-----:|
|
|
141
|
+
| [TimingDataF1](src/timingtower/api_handler/models/timing_data.py) | ✅ | ✅ |
|
|
142
|
+
| [TimingStats](src/timingtower/api_handler/models/timing_stats.py) | ✅ | ✅ |
|
|
143
|
+
| [TimingAppData](src/timingtower/api_handler/models/timing_app_data.py) | ✅ | ✅ |
|
|
144
|
+
| [LapSeries](src/timingtower/api_handler/models/lap_series.py) | ✅ | ✅ |
|
|
145
|
+
| [TyreStintSeries](src/timingtower/api_handler/models/tyre_stint_series.py) | ✅ | ✅ |
|
|
146
|
+
| [DriverTracker](src/timingtower/api_handler/models/driver_tracker.py) | ✅ | ✅ |
|
|
147
|
+
| [OvertakeSeries](src/timingtower/api_handler/models/overtake_series.py) | ✅ | ✅ |
|
|
148
|
+
| [PitStop](src/timingtower/api_handler/models/pit_stop.py) | ✅ | ✅ |
|
|
149
|
+
| [PitStopSeries](src/timingtower/api_handler/models/pit_stop_series.py) | ✅ | ✅ |
|
|
150
|
+
| [CurrentTyres](src/timingtower/api_handler/models/current_tyres.py) | ✅ | ✅ |
|
|
151
|
+
| [TimingData](src/timingtower/api_handler/models/timing_data.py) | ✅ | ✅ |
|
|
152
|
+
| [LapCount](src/timingtower/api_handler/models/lap_count.py) | ✅ | ✅ |
|
|
153
|
+
| [TopThree](src/timingtower/api_handler/models/top_three.py) | ✅ | ✅ |
|
|
154
|
+
| [CarData.z](src/timingtower/api_handler/models/car_data.py) | ✅ | ✅ |
|
|
155
|
+
| [Position.z](src/timingtower/api_handler/models/position.py) | ✅ | ✅ |
|
|
156
|
+
| [RaceControlMessages](src/timingtower/api_handler/models/race_control_messages.py) | ✅ | ✅ |
|
|
157
|
+
| [TrackStatus](src/timingtower/api_handler/models/track_status.py) | ✅ | ✅ |
|
|
158
|
+
| [TlaRcm](src/timingtower/api_handler/models/tla_rcm.py) | ✅ | ✅ |
|
|
159
|
+
| [TeamRadio](src/timingtower/api_handler/models/team_radio.py) | ✅ | ✅ |
|
|
160
|
+
| [WeatherData](src/timingtower/api_handler/models/weather_data.py) | ✅ | ✅ |
|
|
161
|
+
| [WeatherDataSeries](src/timingtower/api_handler/models/weather_data_series.py) | ✅ | ✅ |
|
|
162
|
+
| [DriverList](src/timingtower/api_handler/models/driver_list.py) | ✅ | ✅ |
|
|
163
|
+
| [PitLaneTimeCollection](src/timingtower/api_handler/models/pit_lane_time_collection.py) | ✅ | ✅ |
|
|
164
|
+
| [ChampionshipPrediction](src/timingtower/api_handler/models/championship_prediction.py) | ✅ | ✅ |
|
|
165
|
+
| [DriverRaceInfo](src/timingtower/api_handler/models/driver_race_info.py) | ✅ | ✅ |
|
|
166
|
+
| [SessionInfo](src/timingtower/api_handler/models/session_info.py) | ✅ | ✅ |
|
|
167
|
+
| [SessionData](src/timingtower/api_handler/models/session_data.py) | ✅ | ✅ |
|
|
168
|
+
| [SessionStatus](src/timingtower/api_handler/models/session_status.py) | ✅ | ✅ |
|
|
169
|
+
| [ArchiveStatus](src/timingtower/api_handler/models/archive_status.py) | ✅ | ✅ |
|
|
170
|
+
| [Heartbeat](src/timingtower/api_handler/models/heartbeat.py) | ✅ | ✅ |
|
|
171
|
+
| [ExtrapolatedClock](src/timingtower/api_handler/models/extrapolated_clock.py) | ✅ | ✅ |
|
|
172
|
+
| [ContentStreams](src/timingtower/api_handler/models/content_streams.py) | ✅ | ✅ |
|
|
173
|
+
| [AudioStreams](src/timingtower/api_handler/models/audio_streams.py) | ✅ | ✅ |
|
|
174
|
+
|
|
175
|
+
</details>
|
|
176
|
+
|
|
177
|
+
## Contributing
|
|
178
|
+
timingtower is in early development and contributions are welcome - whether that's new feed implementations, tests, docs, or bug reports. See [CONTRIBUTING.md] for setup instructions and guidelines.
|
|
179
|
+
|
|
180
|
+
## Disclaimer
|
|
181
|
+
timingtower is an unofficial project and is not affiliated with Formula 1 companies. All F1-related trademarks are owned by Formula One Licensing B.V.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# timingtower
|
|
2
|
+
## Motivation
|
|
3
|
+
The F1 livetiming API at https://livetiming.formula1.com exposes a rich set of data feeds - car telemetry, position data, timing, tyre stints, race control messages, weather, pit lane times, and more - going back to 2018. Existing tools access parts of this API but make tradeoffs that aren't right for every use case.
|
|
4
|
+
|
|
5
|
+
[FastF1](https://github.com/theOehrly/Fast-F1) is an excellent analysis-first library, but it consumes raw feeds internally and surfaces its own higher-level abstractions built on Pandas. While they deliver high quality, processed views of the data, some power-analysts may want to handle the raw data directly.
|
|
6
|
+
|
|
7
|
+
[OpenF1](https://github.com/br-g/openf1) is a hosted REST API that proxies a subset of the feed data into simplified JSON endpoints. It's convenient for quick queries but only covers 2023 onward and restructures the data into its own schema.
|
|
8
|
+
|
|
9
|
+
timingtower takes a different approach. It maps the livetiming feeds directly, preserving their original structure while wrapping them in Pydantic V2 models and Polars DataFrames. The raw data stays intact - timingtower doesn't decide what's relevant or how fields should be combined. It gives you typed, validated access to the feeds as they exist, on a modern stack that's faster and more ergonomic than Pandas.
|
|
10
|
+
|
|
11
|
+
Like its namesake, timingtower sits close to the action - just one layer above the raw data.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## Data Model
|
|
15
|
+
The livetiming API is a hierarchical API that progressively provides more information. The base of the API is https://livetiming.formula1.com/static (from here on, we will refer to that as root - '/' and reference the API layers as starting from '/'). Most layers provide an Index.json that we can learn about the available endpoints to dive deeper into. For example, /Index.json shows the current year (why it doesn't show every available year is not known to me). /{year}/Index.json provides a list of meetings (race or testing weekends) with their relevant sessions (FP1, Qualifying, etc.). Oddly, /{year}/{meeting} does not provide an Index.json file. /{year}/{meeting}/{session}/Index.json displays all available 'feeds' for a session. A 'feed' refers to a single collection of data submitted either as a .json file (a 'keyframe') or a .jsonStream (aka .jsonl) file (a 'stream').
|
|
16
|
+
|
|
17
|
+
A keyframe represents a static json, while streams are dynamic, partial updates throughout the course of a session. This makes Pydantic a natural package to model the keyframe. Streams are timestamped by the session time as a duration. Polars is used to represent the streamed data.
|
|
18
|
+
|
|
19
|
+
Consistency is prioritized across returned models. Each model contains a 'keyframe' and 'stream' (with the exceptions of Season, Meeting, and Session, which offer only a keyframe since they only have an Index.json file). It is up to the user to determine whether they need the data from the stream or the keyframe, as some feeds are represented better in the stream (ie. CarData and Position keyframes are not useful for many applications). In anticipation of many use cases relying on the stream's dataframe, a property is provided directly on the F1DataContainer object: obj.df.
|
|
20
|
+
|
|
21
|
+
## Getting Started
|
|
22
|
+
|
|
23
|
+
### Install
|
|
24
|
+
```bash
|
|
25
|
+
# uv
|
|
26
|
+
uv add timingtower
|
|
27
|
+
|
|
28
|
+
# pip
|
|
29
|
+
pip install timingtower
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Data Exploration
|
|
33
|
+
```python
|
|
34
|
+
from timingtower import DirectClient
|
|
35
|
+
|
|
36
|
+
with DirectClient() as client:
|
|
37
|
+
# Available seasons
|
|
38
|
+
client.get_available_seasons()
|
|
39
|
+
|
|
40
|
+
# Get meetings from specific year
|
|
41
|
+
season = client.get_season(year=2026) # Using convenience method
|
|
42
|
+
season.meetings # Aliases season.keyframe.meetings for convenience
|
|
43
|
+
|
|
44
|
+
# Get sessions from specific meeting
|
|
45
|
+
meeting = season.get_meeting(meeting="Australia")
|
|
46
|
+
meeting.sessions
|
|
47
|
+
|
|
48
|
+
# Get a specific session
|
|
49
|
+
meeting.get_session(name="Qualifying")
|
|
50
|
+
# Using convenience properties
|
|
51
|
+
meeting.q # Qualifying
|
|
52
|
+
|
|
53
|
+
# Get session directly from client and look at available data
|
|
54
|
+
session_index = client.get(year=2026, meeting="Australia", session="Qualifying")
|
|
55
|
+
session_index.available_feeds
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Get data
|
|
59
|
+
#### Synchronously
|
|
60
|
+
```python
|
|
61
|
+
from timingtower import DirectClient
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Can also use DirectClient as a long-lived instance:
|
|
65
|
+
# client = DirectClient()
|
|
66
|
+
# val = client.get(...)
|
|
67
|
+
|
|
68
|
+
with DirectClient() as client:
|
|
69
|
+
# Request CarData and Position from livetiming API
|
|
70
|
+
car_data = client.get(model="CarData", year=2026, meeting="Shanghai", session="Race") # Returns a Keyframe+Stream of CarData
|
|
71
|
+
position = client.get(model="Position", year=2026, meeting="Shanghai", session="Race") # Returns a Keyframe+Stream of Position
|
|
72
|
+
|
|
73
|
+
car_df = car_data.df
|
|
74
|
+
position_df = position.df
|
|
75
|
+
|
|
76
|
+
joined_df = car_df.join_asof(
|
|
77
|
+
position_df, on="timestamp", by="racing_number", strategy="nearest"
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
#### Asynchronously
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import asyncio
|
|
84
|
+
from timingtower import AsyncDirectClient
|
|
85
|
+
|
|
86
|
+
# Use the async client as a context manager
|
|
87
|
+
async with AsyncDirectClient() as client:
|
|
88
|
+
# Launch multiple jobs at the same time (this sends 4 requests - 1 for keyframe and 1 for stream in each client.get)
|
|
89
|
+
car_data, position = await asyncio.gather(
|
|
90
|
+
client.get(model="CarData", year=2024, meeting="Monza", session="Race"),
|
|
91
|
+
client.get(model="Position", year=2024, meeting="Monza", session="Race"),
|
|
92
|
+
)
|
|
93
|
+
car_df = car_data.df # Alias to car_data.stream.data
|
|
94
|
+
position_df = position.df
|
|
95
|
+
|
|
96
|
+
joined_df = car_df.join_asof(
|
|
97
|
+
position_df, on="timestamp", by="racing_number", strategy="nearest"
|
|
98
|
+
)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Modify (Async)DirectClient settings
|
|
102
|
+
Since your connection to livetiming may be different from the testing environment, we allow for customization of ClientSettings. Check settings::ClientSettings for what can be changed.
|
|
103
|
+
```python
|
|
104
|
+
from timingtower import AsyncDirectClient, ClientSettings
|
|
105
|
+
|
|
106
|
+
settings = ClientSettings(
|
|
107
|
+
total_timeout = 10, # Set max allowed time to wait for client to 10s
|
|
108
|
+
request_timeout = 2 # Set max allowed time to wait per request to 2s
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async_client = AsyncDirectClient(settings=settings)
|
|
112
|
+
# Note: For DirectClient, total_timeout does not have any effect at the moment.
|
|
113
|
+
# Each request is still limited to request timeout, so the max waited for is 3x request_timeout
|
|
114
|
+
sync_client = DirectClient(settings=settings)
|
|
115
|
+
|
|
116
|
+
sync_client.get(year=2026)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Supported Feeds
|
|
120
|
+
<details>
|
|
121
|
+
<summary>Supported Feeds (32/32)</summary>
|
|
122
|
+
|
|
123
|
+
| Feed | Keyframe (`.json`) | Stream (`.jsonStream`) |
|
|
124
|
+
|------|:-----:|:-----:|
|
|
125
|
+
| [TimingDataF1](src/timingtower/api_handler/models/timing_data.py) | ✅ | ✅ |
|
|
126
|
+
| [TimingStats](src/timingtower/api_handler/models/timing_stats.py) | ✅ | ✅ |
|
|
127
|
+
| [TimingAppData](src/timingtower/api_handler/models/timing_app_data.py) | ✅ | ✅ |
|
|
128
|
+
| [LapSeries](src/timingtower/api_handler/models/lap_series.py) | ✅ | ✅ |
|
|
129
|
+
| [TyreStintSeries](src/timingtower/api_handler/models/tyre_stint_series.py) | ✅ | ✅ |
|
|
130
|
+
| [DriverTracker](src/timingtower/api_handler/models/driver_tracker.py) | ✅ | ✅ |
|
|
131
|
+
| [OvertakeSeries](src/timingtower/api_handler/models/overtake_series.py) | ✅ | ✅ |
|
|
132
|
+
| [PitStop](src/timingtower/api_handler/models/pit_stop.py) | ✅ | ✅ |
|
|
133
|
+
| [PitStopSeries](src/timingtower/api_handler/models/pit_stop_series.py) | ✅ | ✅ |
|
|
134
|
+
| [CurrentTyres](src/timingtower/api_handler/models/current_tyres.py) | ✅ | ✅ |
|
|
135
|
+
| [TimingData](src/timingtower/api_handler/models/timing_data.py) | ✅ | ✅ |
|
|
136
|
+
| [LapCount](src/timingtower/api_handler/models/lap_count.py) | ✅ | ✅ |
|
|
137
|
+
| [TopThree](src/timingtower/api_handler/models/top_three.py) | ✅ | ✅ |
|
|
138
|
+
| [CarData.z](src/timingtower/api_handler/models/car_data.py) | ✅ | ✅ |
|
|
139
|
+
| [Position.z](src/timingtower/api_handler/models/position.py) | ✅ | ✅ |
|
|
140
|
+
| [RaceControlMessages](src/timingtower/api_handler/models/race_control_messages.py) | ✅ | ✅ |
|
|
141
|
+
| [TrackStatus](src/timingtower/api_handler/models/track_status.py) | ✅ | ✅ |
|
|
142
|
+
| [TlaRcm](src/timingtower/api_handler/models/tla_rcm.py) | ✅ | ✅ |
|
|
143
|
+
| [TeamRadio](src/timingtower/api_handler/models/team_radio.py) | ✅ | ✅ |
|
|
144
|
+
| [WeatherData](src/timingtower/api_handler/models/weather_data.py) | ✅ | ✅ |
|
|
145
|
+
| [WeatherDataSeries](src/timingtower/api_handler/models/weather_data_series.py) | ✅ | ✅ |
|
|
146
|
+
| [DriverList](src/timingtower/api_handler/models/driver_list.py) | ✅ | ✅ |
|
|
147
|
+
| [PitLaneTimeCollection](src/timingtower/api_handler/models/pit_lane_time_collection.py) | ✅ | ✅ |
|
|
148
|
+
| [ChampionshipPrediction](src/timingtower/api_handler/models/championship_prediction.py) | ✅ | ✅ |
|
|
149
|
+
| [DriverRaceInfo](src/timingtower/api_handler/models/driver_race_info.py) | ✅ | ✅ |
|
|
150
|
+
| [SessionInfo](src/timingtower/api_handler/models/session_info.py) | ✅ | ✅ |
|
|
151
|
+
| [SessionData](src/timingtower/api_handler/models/session_data.py) | ✅ | ✅ |
|
|
152
|
+
| [SessionStatus](src/timingtower/api_handler/models/session_status.py) | ✅ | ✅ |
|
|
153
|
+
| [ArchiveStatus](src/timingtower/api_handler/models/archive_status.py) | ✅ | ✅ |
|
|
154
|
+
| [Heartbeat](src/timingtower/api_handler/models/heartbeat.py) | ✅ | ✅ |
|
|
155
|
+
| [ExtrapolatedClock](src/timingtower/api_handler/models/extrapolated_clock.py) | ✅ | ✅ |
|
|
156
|
+
| [ContentStreams](src/timingtower/api_handler/models/content_streams.py) | ✅ | ✅ |
|
|
157
|
+
| [AudioStreams](src/timingtower/api_handler/models/audio_streams.py) | ✅ | ✅ |
|
|
158
|
+
|
|
159
|
+
</details>
|
|
160
|
+
|
|
161
|
+
## Contributing
|
|
162
|
+
timingtower is in early development and contributions are welcome - whether that's new feed implementations, tests, docs, or bug reports. See [CONTRIBUTING.md] for setup instructions and guidelines.
|
|
163
|
+
|
|
164
|
+
## Disclaimer
|
|
165
|
+
timingtower is an unofficial project and is not affiliated with Formula 1 companies. All F1-related trademarks are owned by Formula One Licensing B.V.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import marimo
|
|
2
|
+
|
|
3
|
+
__generated_with = "0.22.0"
|
|
4
|
+
app = marimo.App(width="full")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@app.cell
|
|
8
|
+
def _():
|
|
9
|
+
import plotly.express as px
|
|
10
|
+
import polars as pl
|
|
11
|
+
|
|
12
|
+
from timingtower import DirectClient
|
|
13
|
+
|
|
14
|
+
return (DirectClient,)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.cell
|
|
18
|
+
def _(DirectClient):
|
|
19
|
+
client = DirectClient()
|
|
20
|
+
return (client,)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.cell
|
|
24
|
+
def _(client):
|
|
25
|
+
season = client.get_season(year=2026)
|
|
26
|
+
return (season,)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.cell
|
|
30
|
+
def _(season) -> None:
|
|
31
|
+
season.keyframe.meetings[0].sessions[0]
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.cell
|
|
36
|
+
def _(client) -> None:
|
|
37
|
+
client.get(year=2026, model="Season").keyframe.meetings
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.cell
|
|
42
|
+
def _(client, season) -> None:
|
|
43
|
+
data_list = []
|
|
44
|
+
for meeting in season.keyframe.meetings:
|
|
45
|
+
for session in meeting.sessions:
|
|
46
|
+
print(session)
|
|
47
|
+
data_list.append(client.get(session=session, model="RaceControlMessages"))
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.cell
|
|
52
|
+
def _(client) -> None:
|
|
53
|
+
client.get(year=2026, meeting="Shanghai", session="Race", model="TlaRcm")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.cell
|
|
58
|
+
async def _():
|
|
59
|
+
import asyncio
|
|
60
|
+
|
|
61
|
+
from timingtower import AsyncDirectClient
|
|
62
|
+
|
|
63
|
+
# Use the async client as a context manager
|
|
64
|
+
async with AsyncDirectClient() as async_client:
|
|
65
|
+
# Launch multiple jobs at the same time (this sends 4 requests - 1 for keyframe and 1 for stream in each client.get)
|
|
66
|
+
car_data, _position = await asyncio.gather(
|
|
67
|
+
async_client.get("CarData", year=2024, meeting="Monza", session="Race"),
|
|
68
|
+
async_client.get("Position", year=2024, meeting="Monza", session="Race"),
|
|
69
|
+
)
|
|
70
|
+
car_df = car_data.df
|
|
71
|
+
position_df = car_data.df
|
|
72
|
+
|
|
73
|
+
car_df.join_asof(
|
|
74
|
+
position_df, on="timestamp", by="racing_number", strategy="nearest"
|
|
75
|
+
)
|
|
76
|
+
return (AsyncDirectClient,)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.cell
|
|
80
|
+
async def _(AsyncDirectClient) -> None:
|
|
81
|
+
from pprint import pprint
|
|
82
|
+
|
|
83
|
+
async with AsyncDirectClient() as _client:
|
|
84
|
+
pprint(await _client.get_available_seasons())
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.cell
|
|
89
|
+
def _(DirectClient) -> None:
|
|
90
|
+
with DirectClient() as _client:
|
|
91
|
+
# Available years
|
|
92
|
+
_client.get_available_seasons()
|
|
93
|
+
|
|
94
|
+
# Get meetings from specific year
|
|
95
|
+
_season = _client.get_season(year=2026) # Using convenience method
|
|
96
|
+
_season.keyframe.meetings
|
|
97
|
+
_season.meetings # Aliases season.keyframe.meetings for convenience
|
|
98
|
+
|
|
99
|
+
# Get sessions from specific meeting
|
|
100
|
+
_meeting = _season.get_meeting(name="Australia")
|
|
101
|
+
_meeting.sessions
|
|
102
|
+
|
|
103
|
+
# Get a specific session
|
|
104
|
+
_meeting.get_session(name="Qualifying")
|
|
105
|
+
# Using convenience properties
|
|
106
|
+
_meeting.fp1 # Free Practice 1
|
|
107
|
+
_meeting.q # Qualifying
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
app.run()
|