endurancepy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- endurancepy/__init__.py +59 -0
- endurancepy/_types.py +110 -0
- endurancepy/alkamel/__init__.py +12 -0
- endurancepy/alkamel/analysis.py +209 -0
- endurancepy/alkamel/classification.py +143 -0
- endurancepy/alkamel/client.py +69 -0
- endurancepy/alkamel/discovery.py +251 -0
- endurancepy/alkamel/headers.py +72 -0
- endurancepy/alkamel/timecards.py +14 -0
- endurancepy/alkamel/timeparse.py +47 -0
- endurancepy/alkamel/weather.py +55 -0
- endurancepy/cache.py +195 -0
- endurancepy/core.py +425 -0
- endurancepy/events.py +250 -0
- endurancepy/exceptions.py +23 -0
- endurancepy/logger.py +20 -0
- endurancepy/plotting.py +101 -0
- endurancepy/py.typed +0 -0
- endurancepy/results.py +90 -0
- endurancepy/standings.py +127 -0
- endurancepy/track_status.py +63 -0
- endurancepy-0.1.0.dist-info/METADATA +240 -0
- endurancepy-0.1.0.dist-info/RECORD +25 -0
- endurancepy-0.1.0.dist-info/WHEEL +4 -0
- endurancepy-0.1.0.dist-info/licenses/LICENSE +21 -0
endurancepy/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""EndurancePy — endurance racing timing & results data in Python.
|
|
2
|
+
|
|
3
|
+
Inspired by `FastF1 <https://github.com/theOehrly/Fast-F1>`_, EndurancePy
|
|
4
|
+
provides convenient, pandas-based access to timing and results data for endurance
|
|
5
|
+
racing series (WEC, ELMS, Asian Le Mans Series, Le Mans Cup, IMSA), built on top
|
|
6
|
+
of the publicly available Al Kamel Systems archives.
|
|
7
|
+
|
|
8
|
+
Quick start::
|
|
9
|
+
|
|
10
|
+
import endurancepy as ep
|
|
11
|
+
|
|
12
|
+
ep.Cache.enable_cache("./cache")
|
|
13
|
+
session = ep.get_session(2019, "WEC", "Spa", "Race")
|
|
14
|
+
session.load(season="08_2018-2019") # discover + download automatically
|
|
15
|
+
session.laps, session.results, session.weather_data
|
|
16
|
+
|
|
17
|
+
Or parse a local Analysis CSV directly with :func:`read_analysis`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
23
|
+
|
|
24
|
+
from endurancepy.alkamel.analysis import read_analysis
|
|
25
|
+
from endurancepy.alkamel.classification import read_classification
|
|
26
|
+
from endurancepy.alkamel.weather import read_weather
|
|
27
|
+
from endurancepy.cache import Cache
|
|
28
|
+
from endurancepy.events import (
|
|
29
|
+
Event,
|
|
30
|
+
EventSchedule,
|
|
31
|
+
Series,
|
|
32
|
+
get_event,
|
|
33
|
+
get_event_schedule,
|
|
34
|
+
get_session,
|
|
35
|
+
)
|
|
36
|
+
from endurancepy.logger import set_log_level
|
|
37
|
+
from endurancepy.standings import Standings, compute_standings
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
__version__ = version("endurancepy")
|
|
41
|
+
except PackageNotFoundError: # pragma: no cover - not installed (e.g. source tree)
|
|
42
|
+
__version__ = "0.0.0"
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"Cache",
|
|
46
|
+
"Event",
|
|
47
|
+
"EventSchedule",
|
|
48
|
+
"Series",
|
|
49
|
+
"Standings",
|
|
50
|
+
"__version__",
|
|
51
|
+
"compute_standings",
|
|
52
|
+
"get_event",
|
|
53
|
+
"get_event_schedule",
|
|
54
|
+
"get_session",
|
|
55
|
+
"read_analysis",
|
|
56
|
+
"read_classification",
|
|
57
|
+
"read_weather",
|
|
58
|
+
"set_log_level",
|
|
59
|
+
]
|
endurancepy/_types.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Reference column schemas (names and dtypes) for the EndurancePy data objects.
|
|
2
|
+
|
|
3
|
+
These mirror FastF1's data model where it makes sense and add endurance-specific
|
|
4
|
+
columns (car number, class, manufacturer, per-class position, gaps...). They are
|
|
5
|
+
the single source of truth for the parsers (milestones 2.2+) and guarantee that
|
|
6
|
+
the columns always exist with a stable dtype, even when a given session does not
|
|
7
|
+
populate them.
|
|
8
|
+
|
|
9
|
+
See ``docs/analyse_fastf1.md`` (§5, §7) and ``docs/plan_implementation.md`` (§4).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
# --- Laps -------------------------------------------------------------------
|
|
15
|
+
# Columns reused from FastF1 (same names/dtypes) plus endurance additions.
|
|
16
|
+
LAPS_COLUMNS: dict[str, str] = {
|
|
17
|
+
# timing
|
|
18
|
+
"Time": "timedelta64[ns]",
|
|
19
|
+
"LapTime": "timedelta64[ns]",
|
|
20
|
+
"LapNumber": "float64",
|
|
21
|
+
"Stint": "float64",
|
|
22
|
+
"Sector1Time": "timedelta64[ns]",
|
|
23
|
+
"Sector2Time": "timedelta64[ns]",
|
|
24
|
+
"Sector3Time": "timedelta64[ns]",
|
|
25
|
+
"Sector1SessionTime": "timedelta64[ns]",
|
|
26
|
+
"Sector2SessionTime": "timedelta64[ns]",
|
|
27
|
+
"Sector3SessionTime": "timedelta64[ns]",
|
|
28
|
+
"PitInTime": "timedelta64[ns]",
|
|
29
|
+
"PitOutTime": "timedelta64[ns]",
|
|
30
|
+
"LapStartTime": "timedelta64[ns]",
|
|
31
|
+
"LapStartDate": "datetime64[ns]",
|
|
32
|
+
# speeds
|
|
33
|
+
"SpeedST": "float64", # top speed at the speed-trap (from TOP_SPEED)
|
|
34
|
+
"LapAvgSpeed": "float64", # average lap speed in km/h (from KPH) -- NOT top speed
|
|
35
|
+
# identity (endurance unit is the car/crew)
|
|
36
|
+
"CarNumber": "string", # keep leading zeros -> string, never int
|
|
37
|
+
"Driver": "string",
|
|
38
|
+
"DriverNumber": "string",
|
|
39
|
+
"Team": "string",
|
|
40
|
+
"Class": "string",
|
|
41
|
+
"Manufacturer": "string",
|
|
42
|
+
# position
|
|
43
|
+
"Position": "float64",
|
|
44
|
+
"PositionInClass": "float64",
|
|
45
|
+
"GapToLeader": "timedelta64[ns]",
|
|
46
|
+
"GapToLeaderInClass": "timedelta64[ns]",
|
|
47
|
+
# status / flags
|
|
48
|
+
"TrackStatus": "string",
|
|
49
|
+
"IsPersonalBest": "boolean",
|
|
50
|
+
"IsAccurate": "boolean",
|
|
51
|
+
"Generated": "boolean",
|
|
52
|
+
"DriverChange": "boolean",
|
|
53
|
+
"Hour": "float64",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Columns kept for FastF1 compatibility but not populated for endurance data.
|
|
57
|
+
LAPS_COMPAT_COLUMNS: dict[str, str] = {
|
|
58
|
+
"Compound": "string",
|
|
59
|
+
"TyreLife": "float64",
|
|
60
|
+
"FreshTyre": "boolean",
|
|
61
|
+
"SpeedI1": "float64",
|
|
62
|
+
"SpeedI2": "float64",
|
|
63
|
+
"SpeedFL": "float64",
|
|
64
|
+
"Deleted": "boolean",
|
|
65
|
+
"DeletedReason": "string",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# --- Session results --------------------------------------------------------
|
|
69
|
+
RESULTS_COLUMNS: dict[str, str] = {
|
|
70
|
+
"CarNumber": "string",
|
|
71
|
+
"Class": "string",
|
|
72
|
+
"Manufacturer": "string",
|
|
73
|
+
"TeamName": "string",
|
|
74
|
+
"Crew": "string", # the car's drivers, "; "-joined
|
|
75
|
+
"Position": "float64",
|
|
76
|
+
"PositionInClass": "float64",
|
|
77
|
+
"ClassifiedPosition": "string",
|
|
78
|
+
"ClassifiedPositionInClass": "string",
|
|
79
|
+
"GridPosition": "float64",
|
|
80
|
+
"Time": "timedelta64[ns]",
|
|
81
|
+
"BestLapTime": "timedelta64[ns]",
|
|
82
|
+
"Status": "string",
|
|
83
|
+
"Points": "float64",
|
|
84
|
+
"Laps": "float64",
|
|
85
|
+
# driver-level fields (the crew is a list of these)
|
|
86
|
+
"DriverNumber": "string",
|
|
87
|
+
"Abbreviation": "string",
|
|
88
|
+
"FirstName": "string",
|
|
89
|
+
"LastName": "string",
|
|
90
|
+
"FullName": "string",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# --- Weather ----------------------------------------------------------------
|
|
94
|
+
WEATHER_COLUMNS: dict[str, str] = {
|
|
95
|
+
"Time": "timedelta64[ns]",
|
|
96
|
+
"AirTemp": "float64",
|
|
97
|
+
"TrackTemp": "float64",
|
|
98
|
+
"Humidity": "float64",
|
|
99
|
+
"Pressure": "float64",
|
|
100
|
+
"Rainfall": "boolean",
|
|
101
|
+
"WindSpeed": "float64",
|
|
102
|
+
"WindDirection": "float64",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# --- Track status -----------------------------------------------------------
|
|
106
|
+
TRACK_STATUS_COLUMNS: dict[str, str] = {
|
|
107
|
+
"Time": "timedelta64[ns]",
|
|
108
|
+
"Status": "string",
|
|
109
|
+
"Message": "string",
|
|
110
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Al Kamel Systems data layer.
|
|
2
|
+
|
|
3
|
+
This sub-package contains everything specific to the Al Kamel timing archives:
|
|
4
|
+
the HTTP client and URL building, results discovery, and the parsers that turn
|
|
5
|
+
the published CSV files into EndurancePy data objects.
|
|
6
|
+
|
|
7
|
+
The same parser covers WEC, ELMS, Asian Le Mans Series, Le Mans Cup and IMSA —
|
|
8
|
+
only the base host and minor URL details differ. See
|
|
9
|
+
``docs/analyse_fastf1.md`` §14 for the verified file formats and URL structure.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Parser for the Al Kamel "Analysis" CSV (``23_Analysis*.CSV``) -> ``Laps``.
|
|
2
|
+
|
|
3
|
+
This is the primary data product: one row per car-lap, with lap/sector times,
|
|
4
|
+
average and top speed, a pit indicator, class/team/manufacturer, and the flag at
|
|
5
|
+
the finish line. From these raw fields a number of FastF1-style columns are
|
|
6
|
+
derived here:
|
|
7
|
+
|
|
8
|
+
* ``Stint`` (segment between pit stops) and ``PitInTime`` / ``PitOutTime``
|
|
9
|
+
* ``LapStartTime`` and the cumulative ``SectorNSessionTime`` columns
|
|
10
|
+
* ``Position`` and ``PositionInClass`` (reconstructed from line crossings)
|
|
11
|
+
* ``IsPersonalBest``, ``IsAccurate`` and ``DriverChange``
|
|
12
|
+
|
|
13
|
+
Gaps (``GapToLeader*``), ``Hour`` and ``LapStartDate`` need session-level
|
|
14
|
+
context and are left empty here; they are filled in a later step.
|
|
15
|
+
|
|
16
|
+
See ``docs/analyse_fastf1.md`` §14.4 for the verified CSV format.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import pandas as pd
|
|
26
|
+
|
|
27
|
+
from endurancepy._types import LAPS_COLUMNS, LAPS_COMPAT_COLUMNS
|
|
28
|
+
from endurancepy.alkamel.headers import read_alkamel_csv
|
|
29
|
+
from endurancepy.alkamel.timeparse import parse_duration
|
|
30
|
+
from endurancepy.core import Laps
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from endurancepy.core import Session
|
|
34
|
+
|
|
35
|
+
#: Tolerance for the "sum of sectors ≈ lap time" accuracy check.
|
|
36
|
+
_SECTOR_SUM_TOLERANCE = pd.Timedelta(seconds=0.5)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_analysis(
|
|
40
|
+
source: bytes | str | os.PathLike[str], *, session: Session | None = None
|
|
41
|
+
) -> Laps:
|
|
42
|
+
"""Read and parse an Analysis CSV file (bytes or path) into ``Laps``."""
|
|
43
|
+
return to_laps(read_alkamel_csv(source), session=session)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def to_laps(raw: pd.DataFrame, *, session: Session | None = None) -> Laps:
|
|
47
|
+
"""Convert a normalised Analysis DataFrame into a :class:`Laps`."""
|
|
48
|
+
n = len(raw)
|
|
49
|
+
index = pd.RangeIndex(n)
|
|
50
|
+
|
|
51
|
+
def text(name: str) -> pd.Series:
|
|
52
|
+
if name in raw.columns:
|
|
53
|
+
return raw[name].reset_index(drop=True).replace("", pd.NA).astype("string")
|
|
54
|
+
return pd.Series(pd.NA, index=index, dtype="string")
|
|
55
|
+
|
|
56
|
+
def number(name: str) -> pd.Series:
|
|
57
|
+
if name in raw.columns:
|
|
58
|
+
return pd.to_numeric(raw[name].reset_index(drop=True), errors="coerce")
|
|
59
|
+
return pd.Series(np.nan, index=index)
|
|
60
|
+
|
|
61
|
+
def duration(name: str) -> pd.Series:
|
|
62
|
+
if name in raw.columns:
|
|
63
|
+
return pd.to_timedelta(raw[name].reset_index(drop=True).map(parse_duration))
|
|
64
|
+
return pd.Series(pd.NaT, index=index, dtype="timedelta64[ns]")
|
|
65
|
+
|
|
66
|
+
def sector(idx: int) -> pd.Series:
|
|
67
|
+
seconds_col = f"S{idx}_SECONDS"
|
|
68
|
+
if seconds_col in raw.columns:
|
|
69
|
+
return pd.to_timedelta(number(seconds_col), unit="s")
|
|
70
|
+
return duration(f"S{idx}")
|
|
71
|
+
|
|
72
|
+
df = pd.DataFrame(index=index)
|
|
73
|
+
df["CarNumber"] = text("NUMBER")
|
|
74
|
+
df["DriverNumber"] = text("DRIVER_NUMBER")
|
|
75
|
+
df["Driver"] = text("DRIVER_NAME")
|
|
76
|
+
df["Team"] = text("TEAM")
|
|
77
|
+
df["Class"] = text("CLASS")
|
|
78
|
+
df["Manufacturer"] = text("MANUFACTURER")
|
|
79
|
+
df["LapNumber"] = number("LAP_NUMBER").astype("float64")
|
|
80
|
+
df["LapTime"] = duration("LAP_TIME")
|
|
81
|
+
df["Sector1Time"] = sector(1)
|
|
82
|
+
df["Sector2Time"] = sector(2)
|
|
83
|
+
df["Sector3Time"] = sector(3)
|
|
84
|
+
df["Time"] = duration("ELAPSED")
|
|
85
|
+
df["SpeedST"] = number("TOP_SPEED").astype("float64")
|
|
86
|
+
df["LapAvgSpeed"] = number("KPH").astype("float64")
|
|
87
|
+
df["TrackStatus"] = text("FLAG_AT_FL")
|
|
88
|
+
df["IsPersonalBest"] = number("LAP_IMPROVEMENT").isin([1, 2])
|
|
89
|
+
|
|
90
|
+
# Helper columns (dropped before returning).
|
|
91
|
+
crossing = (
|
|
92
|
+
raw["CROSSING_FINISH_LINE_IN_PIT"].reset_index(drop=True).str.upper()
|
|
93
|
+
if "CROSSING_FINISH_LINE_IN_PIT" in raw.columns
|
|
94
|
+
else pd.Series("", index=index)
|
|
95
|
+
)
|
|
96
|
+
df["_PitTime"] = duration("PIT_TIME")
|
|
97
|
+
df["_Pitted"] = (crossing == "B") | df["_PitTime"].notna()
|
|
98
|
+
|
|
99
|
+
# Order per car/lap so the shift-based derivations are correct.
|
|
100
|
+
df = df.sort_values(["CarNumber", "LapNumber"]).reset_index(drop=True)
|
|
101
|
+
|
|
102
|
+
_derive_stints_and_pits(df)
|
|
103
|
+
_derive_sector_session_times(df)
|
|
104
|
+
_derive_driver_change(df)
|
|
105
|
+
_derive_positions(df)
|
|
106
|
+
_derive_accuracy(df)
|
|
107
|
+
|
|
108
|
+
df = df.drop(columns=["_PitTime", "_Pitted"])
|
|
109
|
+
return _finalize(df, session=session)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _derive_stints_and_pits(df: pd.DataFrame) -> None:
|
|
113
|
+
pitted = df["_Pitted"]
|
|
114
|
+
car = df["CarNumber"]
|
|
115
|
+
# A new stint starts on the lap *after* a pit-in lap.
|
|
116
|
+
df["Stint"] = (
|
|
117
|
+
pitted.groupby(car).transform(lambda s: s.shift(fill_value=False).cumsum()) + 1
|
|
118
|
+
).astype("float64")
|
|
119
|
+
df["PitInTime"] = df["Time"].where(pitted)
|
|
120
|
+
df["LapStartTime"] = df["Time"] - df["LapTime"]
|
|
121
|
+
stint_start = df.groupby("CarNumber")["Stint"].transform(lambda s: s != s.shift())
|
|
122
|
+
df["PitOutTime"] = df["LapStartTime"].where(stint_start & (df["Stint"] > 1))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _derive_sector_session_times(df: pd.DataFrame) -> None:
|
|
126
|
+
df["Sector3SessionTime"] = df["Time"]
|
|
127
|
+
df["Sector2SessionTime"] = df["Time"] - df["Sector3Time"]
|
|
128
|
+
df["Sector1SessionTime"] = df["Time"] - df["Sector3Time"] - df["Sector2Time"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _derive_driver_change(df: pd.DataFrame) -> None:
|
|
132
|
+
def changed(s: pd.Series) -> pd.Series:
|
|
133
|
+
return (s != s.shift()) & s.shift().notna()
|
|
134
|
+
|
|
135
|
+
df["DriverChange"] = df.groupby("CarNumber")["DriverNumber"].transform(changed)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _derive_positions(df: pd.DataFrame) -> None:
|
|
139
|
+
"""Reconstruct overall and in-class position at each line crossing."""
|
|
140
|
+
car_class = dict(zip(df["CarNumber"], df["Class"], strict=False))
|
|
141
|
+
laps_done: dict[object, float] = {}
|
|
142
|
+
last_time: dict[object, pd.Timedelta] = {}
|
|
143
|
+
position = pd.Series(np.nan, index=df.index, dtype="float64")
|
|
144
|
+
position_in_class = pd.Series(np.nan, index=df.index, dtype="float64")
|
|
145
|
+
|
|
146
|
+
order = df.sort_values("Time", kind="stable")
|
|
147
|
+
for idx in order.index:
|
|
148
|
+
car = df.at[idx, "CarNumber"]
|
|
149
|
+
lap = df.at[idx, "LapNumber"]
|
|
150
|
+
time = df.at[idx, "Time"]
|
|
151
|
+
if pd.isna(time) or pd.isna(lap):
|
|
152
|
+
continue
|
|
153
|
+
laps_done[car] = lap
|
|
154
|
+
last_time[car] = time
|
|
155
|
+
cls = car_class.get(car)
|
|
156
|
+
rank = 1
|
|
157
|
+
rank_in_class = 1
|
|
158
|
+
for other, other_lap in laps_done.items():
|
|
159
|
+
if other == car:
|
|
160
|
+
continue
|
|
161
|
+
other_time = last_time[other]
|
|
162
|
+
ahead = (other_lap > lap) or (other_lap == lap and other_time < time)
|
|
163
|
+
if ahead:
|
|
164
|
+
rank += 1
|
|
165
|
+
if car_class.get(other) == cls:
|
|
166
|
+
rank_in_class += 1
|
|
167
|
+
position.at[idx] = rank
|
|
168
|
+
position_in_class.at[idx] = rank_in_class
|
|
169
|
+
|
|
170
|
+
df["Position"] = position
|
|
171
|
+
df["PositionInClass"] = position_in_class
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _derive_accuracy(df: pd.DataFrame) -> None:
|
|
175
|
+
sector_sum = df[["Sector1Time", "Sector2Time", "Sector3Time"]].sum(
|
|
176
|
+
axis=1, min_count=3
|
|
177
|
+
)
|
|
178
|
+
consistent = (df["LapTime"] - sector_sum).abs() <= _SECTOR_SUM_TOLERANCE
|
|
179
|
+
flag = df["TrackStatus"].fillna("").str.upper()
|
|
180
|
+
accurate = (
|
|
181
|
+
df["LapTime"].notna()
|
|
182
|
+
& df["PitInTime"].isna()
|
|
183
|
+
& df["PitOutTime"].isna()
|
|
184
|
+
& (flag == "GF")
|
|
185
|
+
& (consistent | sector_sum.isna())
|
|
186
|
+
)
|
|
187
|
+
df["IsAccurate"] = accurate
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _empty_column(dtype: str, index: pd.Index) -> pd.Series:
|
|
191
|
+
if dtype.startswith("timedelta"):
|
|
192
|
+
return pd.Series(pd.NaT, index=index, dtype="timedelta64[ns]")
|
|
193
|
+
if dtype.startswith("datetime"):
|
|
194
|
+
return pd.Series(pd.NaT, index=index, dtype="datetime64[ns]")
|
|
195
|
+
if dtype == "float64":
|
|
196
|
+
return pd.Series(np.nan, index=index, dtype="float64")
|
|
197
|
+
if dtype == "boolean":
|
|
198
|
+
return pd.Series(pd.NA, index=index, dtype="boolean")
|
|
199
|
+
return pd.Series(pd.NA, index=index, dtype="string")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _finalize(df: pd.DataFrame, *, session: Session | None) -> Laps:
|
|
203
|
+
schema = {**LAPS_COLUMNS, **LAPS_COMPAT_COLUMNS}
|
|
204
|
+
df["Generated"] = False
|
|
205
|
+
columns: dict[str, pd.Series] = {}
|
|
206
|
+
for name, dtype in schema.items():
|
|
207
|
+
series = df[name] if name in df.columns else _empty_column(dtype, df.index)
|
|
208
|
+
columns[name] = series.astype(dtype)
|
|
209
|
+
return Laps(columns, session=session)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Parser for the Al Kamel Classification CSV -> ``SessionResults``.
|
|
2
|
+
|
|
3
|
+
Targets the race classification layout (verified real header)::
|
|
4
|
+
|
|
5
|
+
POSITION;NUMBER;TEAM;DRIVER_1;DRIVER_2;DRIVER_3;DRIVER_4;VEHICLE;TYRES;
|
|
6
|
+
CLASS;GROUP;DIVISION;STATUS;LAPS;TOTAL_TIME;GAP_FIRST;GAP_PREVIOUS;
|
|
7
|
+
FL_LAPNUM;FL_TIME;FL_KPH;DRIVER_5;
|
|
8
|
+
|
|
9
|
+
Times use an apostrophe for minutes (e.g. ``5:44'41.101``, ``1'58.056``). The
|
|
10
|
+
parser is tolerant: practice/qualifying classifications use a different, wider
|
|
11
|
+
schema, so missing columns are simply left empty. When a Classification CSV is
|
|
12
|
+
not available, results are instead derived from the laps (see
|
|
13
|
+
:mod:`endurancepy.results`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
from endurancepy._types import RESULTS_COLUMNS
|
|
25
|
+
from endurancepy.alkamel.headers import read_alkamel_csv
|
|
26
|
+
from endurancepy.alkamel.timeparse import parse_duration
|
|
27
|
+
from endurancepy.core import SessionResults
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from endurancepy.core import Session
|
|
31
|
+
|
|
32
|
+
_DRIVER_COLUMNS = ["DRIVER_1", "DRIVER_2", "DRIVER_3", "DRIVER_4", "DRIVER_5"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _empty_column(dtype: str, index: pd.Index) -> pd.Series:
|
|
36
|
+
if dtype.startswith("timedelta"):
|
|
37
|
+
return pd.Series(pd.NaT, index=index, dtype="timedelta64[ns]")
|
|
38
|
+
if dtype.startswith("datetime"):
|
|
39
|
+
return pd.Series(pd.NaT, index=index, dtype="datetime64[ns]")
|
|
40
|
+
if dtype == "float64":
|
|
41
|
+
return pd.Series(np.nan, index=index, dtype="float64")
|
|
42
|
+
if dtype == "boolean":
|
|
43
|
+
return pd.Series(pd.NA, index=index, dtype="boolean")
|
|
44
|
+
return pd.Series(pd.NA, index=index, dtype="string")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_clock(value: object) -> pd.Timedelta:
|
|
48
|
+
"""Parse an Al Kamel classification time (minutes use an apostrophe)."""
|
|
49
|
+
return parse_duration(str(value).replace("'", ":"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_classification(
|
|
53
|
+
source: bytes | str | os.PathLike[str], *, session: Session | None = None
|
|
54
|
+
) -> SessionResults:
|
|
55
|
+
"""Read and parse a Classification CSV (bytes or path) into ``SessionResults``."""
|
|
56
|
+
return to_results(read_alkamel_csv(source), session=session)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def to_results(raw: pd.DataFrame, *, session: Session | None = None) -> SessionResults:
|
|
60
|
+
"""Convert a normalised Classification DataFrame into ``SessionResults``."""
|
|
61
|
+
cols = set(raw.columns)
|
|
62
|
+
|
|
63
|
+
def get(*names: str) -> pd.Series | None:
|
|
64
|
+
for name in names:
|
|
65
|
+
if name in cols:
|
|
66
|
+
return raw[name].reset_index(drop=True)
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
if len(raw) == 0:
|
|
70
|
+
empty = {
|
|
71
|
+
name: _empty_column(dtype, pd.RangeIndex(0))
|
|
72
|
+
for name, dtype in RESULTS_COLUMNS.items()
|
|
73
|
+
}
|
|
74
|
+
return SessionResults(empty, session=session)
|
|
75
|
+
|
|
76
|
+
index = pd.RangeIndex(len(raw))
|
|
77
|
+
|
|
78
|
+
def text(series: pd.Series | None) -> pd.Series:
|
|
79
|
+
if series is None:
|
|
80
|
+
return pd.Series(pd.NA, index=index, dtype="string")
|
|
81
|
+
return series.replace("", pd.NA).astype("string")
|
|
82
|
+
|
|
83
|
+
def clock(series: pd.Series | None) -> pd.Series:
|
|
84
|
+
if series is None:
|
|
85
|
+
return pd.Series(pd.NaT, index=index, dtype="timedelta64[ns]")
|
|
86
|
+
return pd.to_timedelta(series.map(_parse_clock))
|
|
87
|
+
|
|
88
|
+
position = pd.to_numeric(get("POSITION", "POS"), errors="coerce").astype("float64")
|
|
89
|
+
|
|
90
|
+
driver_cols = [c for c in _DRIVER_COLUMNS if c in cols]
|
|
91
|
+
if driver_cols:
|
|
92
|
+
crew = (
|
|
93
|
+
raw[driver_cols]
|
|
94
|
+
.apply(
|
|
95
|
+
lambda row: "; ".join(
|
|
96
|
+
v for v in row if isinstance(v, str) and v.strip()
|
|
97
|
+
),
|
|
98
|
+
axis=1,
|
|
99
|
+
)
|
|
100
|
+
.reset_index(drop=True)
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
crew = pd.Series("", index=index)
|
|
104
|
+
|
|
105
|
+
vehicle = get("VEHICLE")
|
|
106
|
+
manufacturer = get("MANUFACTURER")
|
|
107
|
+
if manufacturer is None and vehicle is not None:
|
|
108
|
+
manufacturer = vehicle.map(
|
|
109
|
+
lambda v: v.split()[0] if isinstance(v, str) and v.strip() else pd.NA
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
summary = pd.DataFrame(
|
|
113
|
+
{
|
|
114
|
+
"CarNumber": text(get("NUMBER")),
|
|
115
|
+
"Class": text(get("CLASS")),
|
|
116
|
+
"Manufacturer": text(manufacturer),
|
|
117
|
+
"TeamName": text(get("TEAM")),
|
|
118
|
+
"Crew": crew.astype("string"),
|
|
119
|
+
"Position": position,
|
|
120
|
+
"Time": clock(get("TOTAL_TIME")),
|
|
121
|
+
"BestLapTime": clock(get("FL_TIME", "TIME")),
|
|
122
|
+
"Status": text(get("STATUS")),
|
|
123
|
+
"Laps": pd.to_numeric(get("LAPS"), errors="coerce").astype("float64"),
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
summary = summary.sort_values("Position").reset_index(drop=True)
|
|
127
|
+
summary["PositionInClass"] = (
|
|
128
|
+
summary.groupby("Class", sort=False).cumcount() + 1
|
|
129
|
+
).astype("float64")
|
|
130
|
+
summary["ClassifiedPosition"] = summary["Position"].map(
|
|
131
|
+
lambda p: str(int(p)) if pd.notna(p) else pd.NA
|
|
132
|
+
)
|
|
133
|
+
summary["ClassifiedPositionInClass"] = summary["PositionInClass"].map(
|
|
134
|
+
lambda p: str(int(p)) if pd.notna(p) else pd.NA
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
columns: dict[str, pd.Series] = {}
|
|
138
|
+
for name, dtype in RESULTS_COLUMNS.items():
|
|
139
|
+
if name in summary.columns:
|
|
140
|
+
columns[name] = summary[name].reset_index(drop=True).astype(dtype)
|
|
141
|
+
else:
|
|
142
|
+
columns[name] = _empty_column(dtype, pd.RangeIndex(len(summary)))
|
|
143
|
+
return SessionResults(columns, session=session)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""HTTP client and URL construction for the Al Kamel results portals.
|
|
2
|
+
|
|
3
|
+
Verified file-URL template (see ``docs/analyse_fastf1.md`` §14.2)::
|
|
4
|
+
|
|
5
|
+
https://<host>/Results/<NN_YYYY>/<NN_EVENT>/
|
|
6
|
+
<NNN_SERIES>/<YYYYMMDDHHMM_SESSION>/[Hour N/]<FILE>
|
|
7
|
+
|
|
8
|
+
The same structure serves every Al Kamel championship; only the host differs.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
from endurancepy.cache import Cache
|
|
16
|
+
from endurancepy.logger import LOGGER
|
|
17
|
+
|
|
18
|
+
#: Default request headers (identify the client honestly; be a good citizen).
|
|
19
|
+
DEFAULT_HEADERS = {
|
|
20
|
+
"User-Agent": "EndurancePy (+https://github.com/RomainFl50/EndurancePy)"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#: Default per-request timeout, in seconds.
|
|
24
|
+
REQUEST_TIMEOUT = 30
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_results_url(
|
|
28
|
+
host: str,
|
|
29
|
+
season: str,
|
|
30
|
+
event: str,
|
|
31
|
+
series_folder: str,
|
|
32
|
+
session: str,
|
|
33
|
+
filename: str,
|
|
34
|
+
*,
|
|
35
|
+
hour: int | str | None = None,
|
|
36
|
+
) -> str:
|
|
37
|
+
"""Build a download URL into a portal's ``Results`` tree.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
host:
|
|
42
|
+
Portal host, e.g. ``"fiawec.alkamelsystems.com"``.
|
|
43
|
+
season, event, series_folder, session:
|
|
44
|
+
Path components, e.g. ``"13_2024"``, ``"04_LE MANS"``, ``"267_FIA WEC"``,
|
|
45
|
+
``"201905041330_Race"``. Spaces are URL-encoded.
|
|
46
|
+
filename:
|
|
47
|
+
The file to fetch, e.g. ``"23_Analysis_Race.CSV"``.
|
|
48
|
+
hour:
|
|
49
|
+
For races, the hour sub-folder (e.g. ``6`` -> ``"Hour 6"``).
|
|
50
|
+
"""
|
|
51
|
+
parts = ["Results", season, event, series_folder, session]
|
|
52
|
+
if hour is not None:
|
|
53
|
+
parts.append(f"Hour {hour}")
|
|
54
|
+
parts.append(filename)
|
|
55
|
+
encoded = "/".join(quote(part, safe="") for part in parts)
|
|
56
|
+
return f"https://{host}/{encoded}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def download(url: str) -> bytes:
|
|
60
|
+
"""Download a file's bytes, honouring the cache.
|
|
61
|
+
|
|
62
|
+
Uses the session returned by :meth:`endurancepy.cache.Cache.requests_session`
|
|
63
|
+
(cached unless caching is disabled).
|
|
64
|
+
"""
|
|
65
|
+
LOGGER.debug("Downloading %s", url)
|
|
66
|
+
session = Cache.requests_session()
|
|
67
|
+
response = session.get(url, headers=DEFAULT_HEADERS, timeout=REQUEST_TIMEOUT)
|
|
68
|
+
response.raise_for_status()
|
|
69
|
+
return response.content
|