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.
@@ -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