Buckets:
| """ | |
| Season Session Telemetry Extraction Script | |
| ========================================== | |
| Extracts telemetry data from non-testing F1 season sessions. | |
| Output directory: | |
| {year}/{event_name}/{session_name}/ | |
| Standalone cache: | |
| cache_session | |
| """ | |
| import gc | |
| import logging | |
| import os | |
| import time | |
| from typing import Dict, List, Optional, Tuple | |
| import fastf1 | |
| import numpy as np | |
| import orjson | |
| import pandas as pd | |
| import psutil | |
| import requests | |
| # --------------------------------------------------------------------------- | |
| # Constants & Configuration | |
| # --------------------------------------------------------------------------- | |
| DEFAULT_YEAR = 2019 | |
| # Keep exactly one uncommented event in this list. | |
| TARGET_EVENT_NAMES_LIST = [ | |
| # 'Abu Dhabi Grand Prix', | |
| # 'Australian Grand Prix', | |
| # 'Austrian Grand Prix', | |
| # 'Azerbaijan Grand Prix', | |
| # 'Bahrain Grand Prix', | |
| # 'Belgian Grand Prix', | |
| # 'Brazilian Grand Prix', | |
| # 'British Grand Prix', | |
| # 'Canadian Grand Prix', | |
| # 'Chinese Grand Prix', | |
| # 'French Grand Prix', | |
| # 'German Grand Prix', | |
| # 'Hungarian Grand Prix', | |
| # 'Italian Grand Prix', | |
| # 'Japanese Grand Prix', | |
| # 'Mexican Grand Prix', | |
| # 'Monaco Grand Prix', | |
| # 'Russian Grand Prix', | |
| # 'Singapore Grand Prix', | |
| # 'Spanish Grand Prix', | |
| 'United States Grand Prix', | |
| ] | |
| if len(TARGET_EVENT_NAMES_LIST) != 1: | |
| raise ValueError( | |
| "Set exactly one active event in TARGET_EVENT_NAME " | |
| "(comment all others)." | |
| ) | |
| TARGET_EVENT_NAME = TARGET_EVENT_NAMES_LIST[0] | |
| AVAILABLE_SESSIONS = [ | |
| "Practice 1", | |
| "Practice 2", | |
| "Practice 3", | |
| "Qualifying", | |
| "Sprint Qualifying", | |
| "Sprint", | |
| "Race", | |
| ] | |
| # Select one or more sessions from AVAILABLE_SESSIONS. | |
| TARGET_SESSIONS = [ | |
| "Practice 1", | |
| "Practice 2", | |
| "Practice 3", | |
| "Qualifying", | |
| # "Sprint Qualifying", | |
| # "Sprint", | |
| "Race", | |
| ] | |
| invalid_target_sessions = sorted(set(TARGET_SESSIONS) - set(AVAILABLE_SESSIONS)) | |
| if invalid_target_sessions: | |
| raise ValueError( | |
| "Invalid TARGET_SESSIONS value(s): " | |
| + ", ".join(invalid_target_sessions) | |
| ) | |
| PROTO = "https" | |
| HOST = "api.multiviewer.app" | |
| HEADERS = {"User-Agent": "FastF1/"} | |
| ORJSON_OPTS = orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS | |
| EPS = np.finfo(float).eps | |
| # Pre-allocated smoothing kernels | |
| _KERNEL_3 = np.ones(3, dtype=np.float64) / 3.0 | |
| _KERNEL_9 = np.ones(9, dtype=np.float64) / 9.0 | |
| # --------------------------------------------------------------------------- | |
| # Logging | |
| # --------------------------------------------------------------------------- | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", | |
| handlers=[ | |
| logging.FileHandler("session_extraction.log"), | |
| logging.StreamHandler(), | |
| ], | |
| ) | |
| logger = logging.getLogger("session_extractor") | |
| logging.getLogger("fastf1").setLevel(logging.WARNING) | |
| logging.getLogger("fastf1").propagate = False | |
| _MISSING_TEXT_VALUES = frozenset({ | |
| "", | |
| "null", | |
| "nan", | |
| "nat", | |
| "none", | |
| "inf", | |
| "-inf", | |
| "infinity", | |
| "-infinity", | |
| }) | |
| _MISSING_TEXT_LIST = list(_MISSING_TEXT_VALUES) | |
| # --------------------------------------------------------------------------- | |
| # Helper Functions (Copied from main_optimized.py for standalone execution) | |
| # --------------------------------------------------------------------------- | |
| def _write_json(path: str, obj, normalize_missing: bool = False) -> None: | |
| if normalize_missing: | |
| obj = _normalize_missing_for_json(obj) | |
| with open(path, "wb") as f: | |
| f.write(orjson.dumps(obj, option=ORJSON_OPTS)) | |
| def _td_col_to_seconds(series: pd.Series) -> list: | |
| if series.empty: | |
| return [] | |
| seconds = series.dt.total_seconds().to_numpy() | |
| mask = series.isna().to_numpy() | |
| out = np.round(seconds, 3).astype(object) | |
| out[mask] = "None" | |
| return out.tolist() | |
| def _col_to_list_str_or_none(col) -> list: | |
| if isinstance(col, np.ndarray): | |
| vals = col | |
| else: | |
| if col.empty: | |
| return [] | |
| vals = col.to_numpy() | |
| if len(vals) == 0: | |
| return [] | |
| mask = pd.isna(vals) | |
| valid = ~mask | |
| out = np.empty(vals.shape, dtype=object) | |
| out[mask] = "None" | |
| valid_vals = vals[valid] | |
| s_vals = np.array([str(v).strip().lower() for v in valid_vals]) | |
| missing_mask = np.isin(s_vals, _MISSING_TEXT_LIST) | |
| str_vals = np.array([str(v) for v in valid_vals]) | |
| out[valid] = np.where(missing_mask, "None", str_vals) | |
| return out.tolist() | |
| def _col_to_list_int_or_none(series: pd.Series) -> list: | |
| if series.empty: | |
| return [] | |
| vals = series.to_numpy() | |
| mask = pd.isna(vals) | |
| out = np.empty(vals.shape, dtype=object) | |
| out[mask] = "None" | |
| out[~mask] = vals[~mask].astype(int) | |
| return out.tolist() | |
| def _col_to_list_bool_or_none(series: pd.Series) -> list: | |
| if series.empty: | |
| return [] | |
| vals = series.to_numpy() | |
| mask = pd.isna(vals) | |
| out = np.empty(vals.shape, dtype=object) | |
| out[mask] = "None" | |
| out[~mask] = vals[~mask].astype(bool) | |
| return out.tolist() | |
| def _series_to_json_list(series: pd.Series) -> list: | |
| if series.empty: | |
| return [] | |
| if pd.api.types.is_timedelta64_dtype(series.dtype): | |
| return _td_col_to_seconds(series) | |
| vals = series.to_numpy() | |
| if pd.api.types.is_float_dtype(series.dtype): | |
| vals_f = vals.astype(np.float64, copy=False) | |
| mask = ~np.isfinite(vals_f) | |
| else: | |
| mask = pd.isna(vals) | |
| out = np.empty(vals.shape, dtype=object) | |
| out[mask] = "None" | |
| valid = ~mask | |
| if not valid.any(): | |
| return out.tolist() | |
| if pd.api.types.is_bool_dtype(series.dtype): | |
| out[valid] = vals[valid].astype(bool) | |
| elif pd.api.types.is_integer_dtype(series.dtype): | |
| out[valid] = vals[valid].astype(int) | |
| elif pd.api.types.is_float_dtype(series.dtype): | |
| out[valid] = vals[valid].astype(float) | |
| else: | |
| valid_vals = vals[valid] | |
| s_vals = np.array([str(v).strip().lower() for v in valid_vals]) | |
| missing_mask = np.isin(s_vals, _MISSING_TEXT_LIST) | |
| str_vals = np.array([str(v) for v in valid_vals]) | |
| out[valid] = np.where(missing_mask, "None", str_vals) | |
| return out.tolist() | |
| def _scalar_to_json_primitive_or_none(value): | |
| if isinstance(value, (float, np.floating)): | |
| return "None" if not np.isfinite(value) else float(value) | |
| if isinstance(value, np.integer): | |
| return int(value) | |
| if isinstance(value, np.bool_): | |
| return bool(value) | |
| if isinstance(value, str): | |
| return "None" if value.strip().lower() in _MISSING_TEXT_VALUES else value | |
| if pd.isna(value): | |
| return "None" | |
| return value | |
| def _normalize_missing_for_json(value): | |
| if isinstance(value, dict): | |
| return {k: _normalize_missing_for_json(v) for k, v in value.items()} | |
| if isinstance(value, list): | |
| return [_normalize_missing_for_json(v) for v in value] | |
| if isinstance(value, tuple): | |
| return [_normalize_missing_for_json(v) for v in value] | |
| if isinstance(value, np.ndarray): | |
| return [_normalize_missing_for_json(v) for v in value.tolist()] | |
| return _scalar_to_json_primitive_or_none(value) | |
| def _dataframe_to_column_lists(df: pd.DataFrame) -> Dict[str, list]: | |
| if df is None or df.empty: | |
| return {} | |
| return {col: _series_to_json_list(df[col]) for col in df.columns} | |
| _LAP_WEATHER_COL_MAP = ( | |
| ("wT", "Time"), | |
| ("wAT", "AirTemp"), | |
| ("wH", "Humidity"), | |
| ("wP", "Pressure"), | |
| ("wR", "Rainfall"), | |
| ("wTT", "TrackTemp"), | |
| ("wWD", "WindDirection"), | |
| ("wWS", "WindSpeed"), | |
| ) | |
| LAP_WEATHER_KEYS = tuple(k for k, _ in _LAP_WEATHER_COL_MAP) | |
| _RCM_COL_MAP = ( | |
| ("time", "Time"), | |
| ("cat", "Category"), | |
| ("msg", "Message"), | |
| ("status", "Status"), | |
| ("flag", "Flag"), | |
| ("scope", "Scope"), | |
| ("sector", "Sector"), | |
| ("dNum", "RacingNumber"), | |
| ("lap", "Lap"), | |
| ) | |
| def _session_weather_to_column_lists(weather_df: pd.DataFrame) -> Dict[str, list]: | |
| if weather_df is None or weather_df.empty: | |
| return {} | |
| out: Dict[str, list] = {} | |
| for short_key, weather_col in _LAP_WEATHER_COL_MAP: | |
| if weather_col in weather_df.columns: | |
| out[short_key] = _series_to_json_list(weather_df[weather_col]) | |
| return out | |
| def _session_rcm_to_column_lists(rcm_df: pd.DataFrame) -> Dict[str, list]: | |
| if rcm_df is None or rcm_df.empty: | |
| return {} | |
| out: Dict[str, list] = {} | |
| for short_key, rcm_col in _RCM_COL_MAP: | |
| if rcm_col in rcm_df.columns: | |
| out[short_key] = _series_to_json_list(rcm_df[rcm_col]) | |
| return out | |
| def _lap_weather_to_column_lists(laps: pd.DataFrame, weather_df: pd.DataFrame = None) -> Dict[str, list]: | |
| n_laps = len(laps) | |
| if n_laps == 0: | |
| return {k: [] for k in LAP_WEATHER_KEYS} | |
| none_row = ["None"] * n_laps | |
| out = {k: none_row.copy() for k in LAP_WEATHER_KEYS} | |
| if weather_df is None: | |
| if not hasattr(laps, "get_weather_data"): | |
| return out | |
| try: | |
| weather_df = laps.get_weather_data() | |
| except Exception: | |
| return out | |
| if weather_df is None: | |
| return out | |
| for short_key, weather_col in _LAP_WEATHER_COL_MAP: | |
| if weather_col not in weather_df.columns: | |
| continue | |
| values = _series_to_json_list(weather_df[weather_col]) | |
| if len(values) < n_laps: | |
| values.extend(["None"] * (n_laps - len(values))) | |
| elif len(values) > n_laps: | |
| values = values[:n_laps] | |
| out[short_key] = values | |
| return out | |
| def _array_to_list_float_or_none(arr: np.ndarray) -> list: | |
| if arr.size == 0: | |
| return [] | |
| valid = np.isfinite(arr) | |
| if valid.all(): | |
| return arr.tolist() | |
| out = np.empty(arr.shape, dtype=object) | |
| out[~valid] = "None" | |
| out[valid] = arr[valid] | |
| return out.tolist() | |
| def _array_to_list_int_or_none(arr: np.ndarray) -> list: | |
| if arr.size == 0: | |
| return [] | |
| mask = ~np.isfinite(arr) | |
| if not mask.any(): | |
| return arr.astype(int).tolist() | |
| out = np.empty(arr.shape, dtype=object) | |
| out[mask] = "None" | |
| out[~mask] = arr[~mask].astype(int) | |
| return out.tolist() | |
| def _smooth_outliers(arr: np.ndarray, threshold: float, use_abs: bool) -> None: | |
| if use_abs: | |
| mask = np.abs(arr) > threshold | |
| else: | |
| mask = arr > threshold | |
| if mask.any(): | |
| indices = np.where(mask)[0] | |
| indices = indices[(indices >= 1) & (indices < len(arr) - 1)] | |
| if len(indices) > 0: | |
| arr[indices] = arr[indices - 1] | |
| def _compute_accelerations( | |
| speed: np.ndarray, | |
| time_arr: np.ndarray, | |
| x: np.ndarray, | |
| y: np.ndarray, | |
| z: np.ndarray, | |
| dist: np.ndarray, | |
| ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: | |
| # Convert speed km/h -> m/s as float64 | |
| vx = speed * (1.0 / 3.6) | |
| if vx.dtype != np.float64: | |
| vx = vx.astype(np.float64) | |
| time_f = (time_arr / np.timedelta64(1, "s")).astype(np.float64) | |
| # Ensure float64 only when needed | |
| x_f = x if x.dtype == np.float64 else x.astype(np.float64) | |
| y_f = y if y.dtype == np.float64 else y.astype(np.float64) | |
| z_f = z if z.dtype == np.float64 else z.astype(np.float64) | |
| dist_f = dist if dist.dtype == np.float64 else dist.astype(np.float64) | |
| # --- X acceleration --- | |
| dtime = np.gradient(time_f) | |
| ax = np.gradient(vx) / dtime | |
| _smooth_outliers(ax, 25.0, use_abs=False) | |
| ax = np.convolve(ax, _KERNEL_3, mode="same") | |
| # --- Shared gradient for Y and Z --- | |
| dx = np.gradient(x_f) | |
| ds = np.gradient(dist_f) | |
| # --- Y acceleration --- | |
| dy = np.gradient(y_f) | |
| theta = np.arctan2(dy, dx + EPS) | |
| theta[0] = theta[1] | |
| dtheta = np.gradient(np.unwrap(theta)) | |
| _smooth_outliers(dtheta, 0.5, use_abs=True) | |
| C = dtheta / (ds + 0.0001) | |
| ay = np.square(vx) * C | |
| ay[np.abs(ay) > 150] = 0 | |
| ay = np.convolve(ay, _KERNEL_9, mode="same") | |
| # --- Z acceleration --- | |
| dz = np.gradient(z_f) | |
| z_theta = np.arctan2(dz, dx + EPS) | |
| z_theta[0] = z_theta[1] | |
| z_dtheta = np.gradient(np.unwrap(z_theta)) | |
| _smooth_outliers(z_dtheta, 0.5, use_abs=True) | |
| z_C = z_dtheta / (ds + 0.0001) | |
| az = np.square(vx) * z_C | |
| az[np.abs(az) > 150] = 0 | |
| az = np.convolve(az, _KERNEL_9, mode="same") | |
| return ax, ay, az, time_f | |
| def _process_telemetry_to_dict(telemetry: pd.DataFrame, data_key: str) -> dict: | |
| time_arr = telemetry["Time"].to_numpy() | |
| speed = telemetry["Speed"].to_numpy() | |
| x = telemetry["X"].to_numpy() | |
| y = telemetry["Y"].to_numpy() | |
| z = telemetry["Z"].to_numpy() | |
| dist = telemetry["Distance"].to_numpy() | |
| ax, ay, az, time_s = _compute_accelerations(speed, time_arr, x, y, z, dist) | |
| drs_raw = telemetry["DRS"].to_numpy() | |
| drs = np.isin(drs_raw, [10, 12, 14]).astype(np.int8) | |
| brake = telemetry["Brake"].to_numpy().astype(bool).astype(np.int8) | |
| driver_ahead = ( | |
| telemetry["DriverAhead"] | |
| if "DriverAhead" in telemetry.columns | |
| else np.full(len(telemetry), np.nan) | |
| ) | |
| distance_to_driver_ahead = ( | |
| telemetry["DistanceToDriverAhead"].to_numpy() | |
| if "DistanceToDriverAhead" in telemetry.columns | |
| else np.full(len(telemetry), np.nan, dtype=np.float64) | |
| ) | |
| return { | |
| "tel": { | |
| "time": _array_to_list_float_or_none(time_s), | |
| "rpm": _array_to_list_float_or_none(telemetry["RPM"].to_numpy()), | |
| "speed": _array_to_list_float_or_none(speed), | |
| "gear": _array_to_list_int_or_none(telemetry["nGear"].to_numpy()), | |
| "throttle": _array_to_list_float_or_none(telemetry["Throttle"].to_numpy()), | |
| "brake": _array_to_list_int_or_none(brake), | |
| "drs": _array_to_list_int_or_none(drs), | |
| "distance": _array_to_list_float_or_none(dist), | |
| "rel_distance": _array_to_list_float_or_none( | |
| telemetry["RelativeDistance"].to_numpy() | |
| if "RelativeDistance" in telemetry.columns | |
| else np.full(len(telemetry), np.nan, dtype=np.float64) | |
| ), | |
| "DriverAhead": _col_to_list_str_or_none(driver_ahead), | |
| "DistanceToDriverAhead": _array_to_list_float_or_none( | |
| distance_to_driver_ahead | |
| ), | |
| "acc_x": _array_to_list_float_or_none(ax), | |
| "acc_y": _array_to_list_float_or_none(ay), | |
| "acc_z": _array_to_list_float_or_none(az), | |
| "x": _array_to_list_float_or_none(x), | |
| "y": _array_to_list_float_or_none(y), | |
| "z": _array_to_list_float_or_none(z), | |
| "dataKey": data_key, | |
| } | |
| } | |
| def check_memory_usage(threshold_percent=80, session_cache=None, circuit_cache=None): | |
| process = psutil.Process(os.getpid()) | |
| memory_info = process.memory_info() | |
| memory_percent = process.memory_percent() | |
| logger.info( | |
| f"Current memory usage: {memory_percent:.2f}% " | |
| f"({memory_info.rss / 1024 / 1024:.2f} MB)" | |
| ) | |
| if memory_percent > threshold_percent: | |
| logger.warning( | |
| f"Memory usage exceeds {threshold_percent}% threshold, clearing caches" | |
| ) | |
| if session_cache is not None: | |
| session_cache.clear() | |
| if circuit_cache is not None: | |
| circuit_cache.clear() | |
| gc.collect() | |
| new_pct = psutil.Process(os.getpid()).memory_percent() | |
| logger.info(f"New memory usage after clearing caches: {new_pct:.2f}%") | |
| return True | |
| return False | |
| # --------------------------------------------------------------------------- | |
| # Season Session Extractor | |
| # --------------------------------------------------------------------------- | |
| class SeasonSessionExtractor: | |
| """Extract telemetry from non-testing season sessions.""" | |
| def __init__(self, year: int = DEFAULT_YEAR): | |
| self.year = year | |
| self._session_cache: Dict[str, fastf1.core.Session] = {} | |
| self._circuit_cache: Dict[str, dict] = {} | |
| def get_session( | |
| self, event_name: str, session_name: str, load_telemetry: bool = True | |
| ) -> fastf1.core.Session: | |
| cache_key = f"{self.year}-{event_name}-{session_name}" | |
| cached = self._session_cache.get(cache_key) | |
| if cached is not None: | |
| if load_telemetry and not getattr(cached, "_telemetry_loaded", False): | |
| cached.load(telemetry=True, weather=True, messages=True) | |
| cached._telemetry_loaded = True | |
| self._session_cache[cache_key] = cached | |
| return cached | |
| f1session = fastf1.get_session(self.year, event_name, session_name) | |
| f1session.load(telemetry=load_telemetry, weather=True, messages=True) | |
| f1session._telemetry_loaded = load_telemetry | |
| self._session_cache[cache_key] = f1session | |
| return f1session | |
| def session_drivers( | |
| self, event_name: str, session_name: str, f1session: fastf1.core.Session = None | |
| ) -> Dict[str, List[Dict[str, str]]]: | |
| try: | |
| if f1session is None: | |
| f1session = self.get_session(event_name, session_name) | |
| laps = f1session.laps | |
| driver_cols = ["Driver", "Team"] | |
| has_driver_number = "DriverNumber" in laps.columns | |
| if has_driver_number: | |
| driver_cols.append("DriverNumber") | |
| driver_team = laps.drop_duplicates(subset="Driver")[driver_cols] | |
| results = f1session.results | |
| result_by_abbr = {} | |
| result_by_number = {} | |
| if results is not None and not results.empty: | |
| for row in results.itertuples(): | |
| abbr = getattr(row, "Abbreviation", None) | |
| if pd.notna(abbr): | |
| result_by_abbr[str(abbr)] = row | |
| driver_number = getattr(row, "DriverNumber", None) | |
| if pd.notna(driver_number): | |
| result_by_number[str(driver_number)] = row | |
| drivers = [ | |
| self._build_driver_info( | |
| row=row, | |
| has_driver_number=has_driver_number, | |
| result_by_abbr=result_by_abbr, | |
| result_by_number=result_by_number, | |
| ) | |
| for row in driver_team.itertuples(index=False) | |
| ] | |
| return {"drivers": drivers} | |
| except Exception as e: | |
| logger.error( | |
| f"Error getting drivers for {event_name} {session_name}: {e}" | |
| ) | |
| return {"drivers": []} | |
| def _build_driver_info( | |
| self, | |
| row, | |
| has_driver_number: bool, | |
| result_by_abbr: Dict[str, object], | |
| result_by_number: Dict[str, object], | |
| ) -> Dict[str, str]: | |
| driver = _scalar_to_json_primitive_or_none(row.Driver) | |
| team = _scalar_to_json_primitive_or_none(row.Team) | |
| driver_number = _scalar_to_json_primitive_or_none( | |
| row.DriverNumber if has_driver_number else "None" | |
| ) | |
| result_row = result_by_abbr.get(str(driver)) | |
| if result_row is None and driver_number != "None": | |
| result_row = result_by_number.get(str(driver_number)) | |
| first_name = "None" | |
| last_name = "None" | |
| team_color = "None" | |
| headshot_url = "None" | |
| if result_row is not None: | |
| first_name = _scalar_to_json_primitive_or_none( | |
| getattr(result_row, "FirstName", "None") | |
| ) | |
| last_name = _scalar_to_json_primitive_or_none( | |
| getattr(result_row, "LastName", "None") | |
| ) | |
| team_color = _scalar_to_json_primitive_or_none( | |
| getattr(result_row, "TeamColor", "None") | |
| ) | |
| headshot_url = _scalar_to_json_primitive_or_none( | |
| getattr( | |
| result_row, | |
| "HeadshotUrl", | |
| getattr(result_row, "HeadShotUrl", "None"), | |
| ) | |
| ) | |
| return { | |
| "driver": driver, | |
| "team": team, | |
| "dn": driver_number, | |
| "fn": first_name, | |
| "ln": last_name, | |
| "tc": team_color, | |
| "url": headshot_url, | |
| } | |
| def laps_data( | |
| self, | |
| driver: str, | |
| f1session: fastf1.core.Session, | |
| driver_laps: pd.DataFrame = None, | |
| session_weather_df: pd.DataFrame = None, | |
| ) -> Dict[str, list]: | |
| try: | |
| if driver_laps is None: | |
| driver_laps = f1session.laps.pick_drivers(driver) | |
| lap_weather = _lap_weather_to_column_lists(driver_laps, session_weather_df) | |
| return { | |
| "time": _td_col_to_seconds(driver_laps["LapTime"]), | |
| "lap": _col_to_list_int_or_none(driver_laps["LapNumber"]), | |
| "compound": _col_to_list_str_or_none(driver_laps["Compound"]), | |
| "stint": _col_to_list_int_or_none(driver_laps["Stint"]), | |
| "s1": _td_col_to_seconds(driver_laps["Sector1Time"]), | |
| "s2": _td_col_to_seconds(driver_laps["Sector2Time"]), | |
| "s3": _td_col_to_seconds(driver_laps["Sector3Time"]), | |
| "life": _col_to_list_int_or_none(driver_laps["TyreLife"]), | |
| "pos": _col_to_list_int_or_none(driver_laps["Position"]), | |
| "status": _col_to_list_str_or_none(driver_laps["TrackStatus"]), | |
| "pb": _col_to_list_bool_or_none(driver_laps["IsPersonalBest"]), | |
| "sesT": _td_col_to_seconds(driver_laps["Time"]), | |
| "drv": _col_to_list_str_or_none(driver_laps["Driver"]), | |
| "dNum": _col_to_list_str_or_none(driver_laps["DriverNumber"]), | |
| "pout": _td_col_to_seconds(driver_laps["PitOutTime"]), | |
| "pin": _td_col_to_seconds(driver_laps["PitInTime"]), | |
| "s1T": _td_col_to_seconds(driver_laps["Sector1SessionTime"]), | |
| "s2T": _td_col_to_seconds(driver_laps["Sector2SessionTime"]), | |
| "s3T": _td_col_to_seconds(driver_laps["Sector3SessionTime"]), | |
| "vi1": _array_to_list_float_or_none(driver_laps["SpeedI1"].to_numpy()), | |
| "vi2": _array_to_list_float_or_none(driver_laps["SpeedI2"].to_numpy()), | |
| "vfl": _array_to_list_float_or_none(driver_laps["SpeedFL"].to_numpy()), | |
| "vst": _array_to_list_float_or_none(driver_laps["SpeedST"].to_numpy()), | |
| "fresh": _col_to_list_bool_or_none(driver_laps["FreshTyre"]), | |
| "team": _col_to_list_str_or_none(driver_laps["Team"]), | |
| "lST": _td_col_to_seconds(driver_laps["LapStartTime"]), | |
| "lSD": _col_to_list_str_or_none(driver_laps["LapStartDate"]), | |
| "del": _col_to_list_bool_or_none(driver_laps["Deleted"]), | |
| "delR": _col_to_list_str_or_none(driver_laps["DeletedReason"]), | |
| "ff1G": _col_to_list_bool_or_none(driver_laps["FastF1Generated"]), | |
| "iacc": _col_to_list_bool_or_none(driver_laps["IsAccurate"]), | |
| **lap_weather, | |
| } | |
| except Exception as e: | |
| logger.error(f"Error getting lap data for {driver}: {e}") | |
| return { | |
| k: [] | |
| for k in ( | |
| "time", "lap", "compound", "stint", | |
| "s1", "s2", "s3", "life", "pos", "status", "pb", | |
| "sesT", "drv", "dNum", "pout", "pin", | |
| "s1T", "s2T", "s3T", "vi1", "vi2", | |
| "vfl", "vst", "fresh", "team", "lST", | |
| "lSD", "del", "delR", "ff1G", "iacc", | |
| *LAP_WEATHER_KEYS, | |
| ) | |
| } | |
| def get_circuit_info( | |
| self, event_name: str, session_name: str | |
| ) -> Optional[Dict]: | |
| cache_key = f"{self.year}-{event_name}-{session_name}" | |
| if cache_key in self._circuit_cache: | |
| return self._circuit_cache[cache_key] | |
| try: | |
| f1session = self.get_session(event_name, session_name) | |
| circuit_key = f1session.session_info["Meeting"]["Circuit"]["Key"] | |
| try: | |
| circuit_info = f1session.get_circuit_info() | |
| corners = circuit_info.corners | |
| result = { | |
| "CornerNumber": _series_to_json_list(corners["Number"]), | |
| "X": _series_to_json_list(corners["X"]), | |
| "Y": _series_to_json_list(corners["Y"]), | |
| "Angle": _series_to_json_list(corners["Angle"]), | |
| "Distance": _series_to_json_list(corners["Distance"]), | |
| "Rotation": _scalar_to_json_primitive_or_none(circuit_info.rotation), | |
| } | |
| self._circuit_cache[cache_key] = result | |
| return result | |
| except (AttributeError, KeyError): | |
| circuit_df, rotation = self._get_circuit_info_from_api(circuit_key) | |
| if circuit_df is not None: | |
| result = { | |
| "CornerNumber": _series_to_json_list(circuit_df["Number"]), | |
| "X": _series_to_json_list(circuit_df["X"]), | |
| "Y": _series_to_json_list(circuit_df["Y"]), | |
| "Angle": _series_to_json_list(circuit_df["Angle"]), | |
| "Distance": _series_to_json_list(circuit_df["Distance"] / 10), | |
| "Rotation": _scalar_to_json_primitive_or_none(rotation), | |
| } | |
| self._circuit_cache[cache_key] = result | |
| return result | |
| logger.warning( | |
| f"Could not get corner data for {event_name} {session_name}" | |
| ) | |
| return None | |
| except Exception as e: | |
| logger.error( | |
| f"Error getting circuit info for {event_name} {session_name}: {e}" | |
| ) | |
| return None | |
| def _get_circuit_info_from_api( | |
| self, circuit_key: int | |
| ) -> Tuple[Optional[pd.DataFrame], float]: | |
| url = f"{PROTO}://{HOST}/api/v1/circuits/{circuit_key}/{self.year}" | |
| try: | |
| response = requests.get(url, headers=HEADERS) | |
| if response.status_code != 200: | |
| logger.debug(f"[{response.status_code}] {response.content.decode()}") | |
| return None, 0.0 | |
| data = response.json() | |
| rotation = float(data.get("rotation", 0.0)) | |
| rows = [ | |
| ( | |
| float(e.get("trackPosition", {}).get("x", 0.0)), | |
| float(e.get("trackPosition", {}).get("y", 0.0)), | |
| int(e.get("number", 0)), | |
| str(e.get("letter", "")), | |
| float(e.get("angle", 0.0)), | |
| float(e.get("length", 0.0)), | |
| ) | |
| for e in data["corners"] | |
| ] | |
| return ( | |
| pd.DataFrame( | |
| rows, columns=["X", "Y", "Number", "Letter", "Angle", "Distance"] | |
| ), | |
| rotation, | |
| ) | |
| except Exception as e: | |
| logger.error(f"Error fetching circuit data from API: {e}") | |
| return None, 0.0 | |
| def _process_single_lap( | |
| self, | |
| driver: str, | |
| lap_number: int, | |
| driver_dir: str, | |
| driver_laps: pd.DataFrame, | |
| event_name: str, | |
| session_name: str, | |
| ) -> bool: | |
| file_path = f"{driver_dir}/{lap_number}_tel.json" | |
| try: | |
| selected = driver_laps[driver_laps.LapNumber == lap_number] | |
| if selected.empty: | |
| logger.warning( | |
| f"No data for {driver} lap {lap_number} in {event_name} {session_name}" | |
| ) | |
| return False | |
| telemetry = selected.get_telemetry() | |
| data_key = f"{self.year}-{event_name}-{session_name}-{driver}-{lap_number}" | |
| tel_data = _process_telemetry_to_dict(telemetry, data_key) | |
| _write_json(file_path, tel_data) | |
| return True | |
| except Exception as e: | |
| logger.error(f"Error processing lap {lap_number} for {driver}: {e}") | |
| return False | |
| def process_driver( | |
| self, | |
| event_name: str, | |
| session_name: str, | |
| driver: str, | |
| base_dir: str, | |
| f1session: fastf1.core.Session = None, | |
| session_weather_df: pd.DataFrame = None, | |
| ) -> None: | |
| driver_dir = f"{base_dir}/{driver}" | |
| os.makedirs(driver_dir, exist_ok=True) | |
| try: | |
| if f1session is None: | |
| f1session = self.get_session( | |
| event_name, session_name, load_telemetry=True | |
| ) | |
| driver_laps = f1session.laps.pick_drivers(driver) | |
| driver_laps = driver_laps.assign( | |
| LapNumber=driver_laps["LapNumber"].astype(int) | |
| ) | |
| laptimes = self.laps_data(driver, f1session, driver_laps, session_weather_df) | |
| _write_json(f"{driver_dir}/laptimes.json", laptimes) | |
| lap_numbers = driver_laps["LapNumber"].tolist() | |
| existing = ( | |
| set(os.listdir(driver_dir)) | |
| if os.path.isdir(driver_dir) | |
| else set() | |
| ) | |
| for lap_number in lap_numbers: | |
| fname = f"{lap_number}_tel.json" | |
| if fname in existing: | |
| continue | |
| self._process_single_lap( | |
| driver, lap_number, driver_dir, driver_laps, event_name, session_name | |
| ) | |
| except Exception as e: | |
| logger.error(f"Error processing driver {driver}: {e}") | |
| def process_event_session(self, event_name: str, session_name: str) -> None: | |
| label = f"{event_name} - {session_name}" | |
| logger.info(f"Processing {label}") | |
| base_dir = f"{event_name}/{session_name}" | |
| os.makedirs(base_dir, exist_ok=True) | |
| try: | |
| f1session = self.get_session(event_name, session_name, load_telemetry=True) | |
| weather_data = _session_weather_to_column_lists(f1session.weather_data) | |
| _write_json(f"{base_dir}/weather.json", weather_data) | |
| session_control_messages = _session_rcm_to_column_lists( | |
| f1session.race_control_messages | |
| ) | |
| _write_json(f"{base_dir}/rcm.json", session_control_messages) | |
| drivers_info = self.session_drivers(event_name, session_name, f1session) | |
| _write_json(f"{base_dir}/drivers.json", drivers_info) | |
| corner_info = self.get_circuit_info(event_name, session_name) | |
| if corner_info: | |
| _write_json(f"{base_dir}/corners.json", corner_info) | |
| drivers = [d["driver"] for d in drivers_info.get("drivers", [])] | |
| if not drivers: | |
| logger.warning(f"No drivers found for {label}") | |
| return | |
| session_weather_df = None | |
| if hasattr(f1session.laps, "get_weather_data"): | |
| try: | |
| session_weather_df = f1session.laps.get_weather_data() | |
| except Exception: | |
| pass | |
| total_drivers = len(drivers) | |
| for i, driver in enumerate(drivers, 1): | |
| logger.info(f"Processing driver {driver} ({i}/{total_drivers})") | |
| self.process_driver( | |
| event_name, | |
| session_name, | |
| driver, | |
| base_dir, | |
| f1session, | |
| session_weather_df, | |
| ) | |
| except Exception as e: | |
| logger.error(f"Error processing {label}: {e}") | |
| def process_all(self) -> None: | |
| logger.info(f"Starting season session extraction for {self.year}") | |
| start_time = time.time() | |
| event_name = TARGET_EVENT_NAME.strip() if TARGET_EVENT_NAME else "" | |
| if not event_name: | |
| logger.warning("No TARGET_EVENT_NAME configured — nothing to extract.") | |
| return | |
| sessions = [s for s in TARGET_SESSIONS if isinstance(s, str) and s.strip()] | |
| if not sessions: | |
| logger.warning("No TARGET_SESSIONS configured — nothing to extract.") | |
| return | |
| logger.info(f"Processing {event_name} ({', '.join(sessions)})") | |
| for session_name in sessions: | |
| try: | |
| self.process_event_session(event_name, session_name) | |
| except Exception as e: | |
| logger.error(f"Failed {event_name} {session_name}: {e}") | |
| check_memory_usage( | |
| session_cache=self._session_cache, | |
| circuit_cache=self._circuit_cache, | |
| ) | |
| elapsed = time.time() - start_time | |
| logger.info(f"Season session extraction completed in {elapsed:.2f} seconds") | |
| # ====================================================================== | |
| # Data Availability | |
| # ====================================================================== | |
| def is_session_data_available( | |
| year: int, | |
| events: Optional[List[str]] = None, | |
| sessions: Optional[List[str]] = None, | |
| ) -> bool: | |
| """Check if data is available for the first specified event/session pair.""" | |
| try: | |
| if events is None: | |
| events = [TARGET_EVENT_NAME] if TARGET_EVENT_NAME else [] | |
| if sessions is None: | |
| sessions = list(TARGET_SESSIONS) | |
| if not events or not sessions: | |
| logger.warning("No events or sessions specified to check") | |
| return False | |
| event = events[0] | |
| session = sessions[0] | |
| logger.info(f"Checking data availability for {year} {event} {session}...") | |
| f1session = fastf1.get_session(year, event, session) | |
| f1session.load(telemetry=False, weather=False, messages=False) | |
| if f1session.laps.empty: | |
| logger.info(f"No lap data available yet for {year} {event} {session}") | |
| return False | |
| if "Driver" not in f1session.laps.columns: | |
| logger.info(f"No driver data available yet for {year} {event} {session}") | |
| return False | |
| if len(f1session.laps["Driver"].dropna().unique()) == 0: | |
| logger.info(f"No driver data available yet for {year} {event} {session}") | |
| return False | |
| logger.info(f"Data is available for {year} {event} {session}") | |
| return True | |
| except Exception as e: | |
| logger.info(f"Data not yet available: {str(e)}") | |
| return False | |
| def main(): | |
| try: | |
| year = DEFAULT_YEAR | |
| os.makedirs("cache", exist_ok=True) | |
| fastf1.Cache.enable_cache("cache") | |
| extractor = SeasonSessionExtractor(year=year) | |
| max_attempts = 720 | |
| wait_time = 30 | |
| attempt = 0 | |
| logger.info(f"Starting to wait for {year} season session data...") | |
| while attempt < max_attempts: | |
| if is_session_data_available(year): | |
| logger.info( | |
| f"Data is available for {year} season sessions. " | |
| "Starting extraction..." | |
| ) | |
| extractor.process_all() | |
| break | |
| else: | |
| attempt += 1 | |
| logger.info( | |
| f"Data not yet available. Waiting {wait_time}s " | |
| f"before retry ({attempt}/{max_attempts})..." | |
| ) | |
| time.sleep(wait_time) | |
| gc.collect() | |
| if attempt >= max_attempts: | |
| logger.error("Exceeded maximum wait time. Exiting.") | |
| except Exception as e: | |
| logger.error(f"Error in main function: {e}") | |
| raise | |
| if __name__ == "__main__": | |
| main() |
Xet Storage Details
- Size:
- 36.6 kB
- Xet hash:
- b4c93d0cf4f61243e1b1d7b08cc7e6bfc68d97849f14b0ae7c0cb54670116c0b
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.