From d06e54c67c982ac417e298601a6cf34db861f216 Mon Sep 17 00:00:00 2001 From: Pratik Borole Date: Mon, 4 Mar 2024 19:56:06 +0530 Subject: [PATCH] feat: BACK-40 added /telemetry path --- app/constants.py | 5 ++ app/main.py | 192 ++++++++++++++++++++++++++++++++++++---- app/models.py | 91 +++++++++++++------ tests/test_telemetry.py | 83 +++++++++++++++++ 4 files changed, 328 insertions(+), 43 deletions(-) create mode 100644 tests/test_telemetry.py diff --git a/app/constants.py b/app/constants.py index 99b5d59..dc0dbc0 100644 --- a/app/constants.py +++ b/app/constants.py @@ -30,3 +30,8 @@ MAX_SUPPORTED_SESSION = 5 DEFAULT_SESSION = 5 # race + +MIN_SUPPORTED_DRIVER_NUMBER = 1 +MAX_SUPPORTED_DRIVER_NUMBER = 99 + +MIN_SUPPORTED_LAP_COUNT = 1 diff --git a/app/main.py b/app/main.py index 91a7e19..774f81c 100644 --- a/app/main.py +++ b/app/main.py @@ -15,24 +15,38 @@ from .constants import ( DEFAULT_SESSION, EVENT_SCHEDULE_DATETIME_DTYPE_LIST, + MAX_SUPPORTED_DRIVER_NUMBER, MAX_SUPPORTED_ROUND, MAX_SUPPORTED_SESSION, MAX_SUPPORTED_YEAR, METADATA_DESCRIPTION, + MIN_SUPPORTED_DRIVER_NUMBER, + MIN_SUPPORTED_LAP_COUNT, MIN_SUPPORTED_ROUND, MIN_SUPPORTED_SESSION, MIN_SUPPORTED_YEAR, ) -from .models import EventSchedule, HealthCheck, Laps, Results, Root, Schedule, Standings +from .models import ( + EventSchedule, + ExtendedTelemetry, + HealthCheck, + Laps, + Results, + Root, + Schedule, + Standings, + Telemetry, + Weather, +) from .utils import get_default_year +# FastF1 configuration fastf1.set_log_level("WARNING") - -# Cors Middleware -origins = ["http://localhost:3000"] # Ergast configuration ergast = Ergast(result_type="raw", auto_cast=True) +# Cors Middleware +origins = ["http://localhost:3000"] # Others favicon_path = "favicon.ico" @@ -284,7 +298,9 @@ def get_standings( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Driver standings not found.") else: # something went wrong, investigate - raise HTTPException(status_code=500, detail="Something went wrong. Investigate!") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Something went wrong. Investigate!" + ) @app.get( @@ -416,18 +432,17 @@ def get_laps( List[Laps]: Returns a JSON response with the list of session results """ - session_obj = fastf1.get_session(year=year, gp=round, identifier=session) - session_obj.load( - laps=True, - telemetry=False, - weather=False, - messages=True, # required for `Deleted` and `DeletedReason` - ) - session_laps = session_obj.laps - try: + session_obj = fastf1.get_session(year=year, gp=round, identifier=session) + session_obj.load( + laps=True, + telemetry=False, + weather=False, + messages=True, # required for `Deleted` and `DeletedReason` + ) + session_laps = session_obj.laps + if len(driver_number) > 0: - print(driver_number) session_laps = session_laps.pick_drivers(driver_number) # Convert the dataframe to a JSON string @@ -444,3 +459,150 @@ def get_laps( status_code=status.HTTP_404_NOT_FOUND, detail=f"Likely an error when fetching laps data for a session that has yet to happen. {str(ke)}", ) + + +@app.get( + "/telemetry/{year}/{round}/{driver_number}/{lap}", + tags=["telemetry"], + summary="Get telemetry of a driver for a given year, round and session for one or multiple laps optionally with weather data", + response_description="telemetry of a driver for a given year, round and session for one or multiple laps optionally with weather data.", + status_code=status.HTTP_200_OK, + response_model=ExtendedTelemetry, +) +def get_telemetry( + year: Annotated[ + int, + Path( + title="The year for which to get the telemetry", + description="The year for which to get the telemetry", + ge=MIN_SUPPORTED_YEAR, + le=MAX_SUPPORTED_YEAR, + ), + ], + round: Annotated[ + int, + Path( + title="The round in a year for which to get the telemetry", + description="The round in a year for which to get the telemetry", + ge=MIN_SUPPORTED_ROUND, + le=MAX_SUPPORTED_ROUND, + ), + ], + driver_number: Annotated[ + int, + Path( + title="Driver number for whom to get the telemetry", + description="Driver number for whom to get the telemetry", + ge=MIN_SUPPORTED_DRIVER_NUMBER, + le=MAX_SUPPORTED_DRIVER_NUMBER, + ), + ], + lap: Annotated[ + int, + Path( + title="List of laps of the driver for which to get the telemetry", + description="List of laps of the driver for which to get the telemetry", + ge=MIN_SUPPORTED_LAP_COUNT, + ), + ], + session: Annotated[ + int, + Query( + title="The session in a round for which to get the telemetry", + description="The session in a round for which to get the telemetry. (Default = 5; ie race)", + ge=MIN_SUPPORTED_SESSION, + le=MAX_SUPPORTED_SESSION, + ), + ] = DEFAULT_SESSION, + weather: Annotated[ + bool, + Query( + title="Flag to fetch weather data along with telemetry", + description="Flag to fetch weather data along with telemetry", + ), + ] = False, +) -> ExtendedTelemetry: + """ + ## Get telemetry of a driver for a given year, round and session for one or multiple laps optionally with weather data + Endpoint to get telemetry of a driver for a given year, round and session for one or multiple laps optionally with weather data. + + **NOTE**: + - If `session` is not provided; we use the default session. Default = 5; ie race. + + **Returns**: + ExtendedTelemetry: Returns a JSON response with the list of telemetry optionally with weather data + """ + + try: + session_obj = fastf1.get_session(year=year, gp=round, identifier=session) + session_obj.load( + laps=True, + telemetry=True, + weather=weather, + messages=True, # required for `Deleted` and `DeletedReason` + ) + session_laps_for_driver = session_obj.laps.pick_drivers(driver_number) + except ValueError as ve: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Bad Request. {str(ve)}") + except KeyError as ke: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Likely an error when fetching laps data for a session that has yet to happen. {str(ke)}", + ) + + # Error out if no laps are found for the driver + if len(session_laps_for_driver) == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Laps for driver {driver_number} not found.", + ) + + # filter laps based on the `lap` path parameter + filtered_lap_for_driver = session_laps_for_driver.pick_laps(lap) + + # Error out if the requested lap is not found for the driver + if len(filtered_lap_for_driver) == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Requested lap for driver {driver_number} not found.", + ) + + try: + session_telemetry = filtered_lap_for_driver.get_telemetry() + except ValueError as ve: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Telemetry not found for driver {driver_number} for the requested laps. {str(ve)}", + ) + + # keep only the required data in session_telemetry + session_telemetry = session_telemetry[ + ["Time", "RPM", "Speed", "nGear", "Throttle", "Brake", "DRS", "Distance", "X", "Y"] + ] + + # Convert the dataframe to a JSON string + session_telemetry_as_json = session_telemetry.to_json(orient="records") + + # Parse the JSON string to a JSON object + session_telemetry_as_json_obj: List[Telemetry] = json.loads(session_telemetry_as_json) + + session_weather_as_json_obj: Weather | None = None + if weather: + session_weather = filtered_lap_for_driver.get_weather_data() + + # Convert the dataframe to a JSON string + session_weather_as_json = session_weather.to_json(orient="records") + + # Parse the JSON string to a JSON object + session_weather_as_json_list_obj: List[Weather] = json.loads(session_weather_as_json) + + # Grab the first row of the weather data + # https://docs.fastf1.dev/core.html#fastf1.core.Laps.get_weather_data + session_weather_as_json_obj = session_weather_as_json_list_obj[0] + + return ExtendedTelemetry.model_validate( + { + "Telemetry": session_telemetry_as_json_obj, + "Weather": session_weather_as_json_obj, + } + ) diff --git a/app/models.py b/app/models.py index 7aaa581..e42ce95 100644 --- a/app/models.py +++ b/app/models.py @@ -107,38 +107,38 @@ class Standings(BaseModel): class Results(BaseModel): """Response model for session results for a given year, round and session""" - DriverNumber: str - BroadcastName: str - Abbreviation: str - DriverId: str - TeamName: str - TeamColor: str - TeamId: str - FirstName: str - LastName: str - FullName: str - HeadshotUrl: str - CountryCode: str + DriverNumber: str | None + BroadcastName: str | None + Abbreviation: str | None + DriverId: str | None + TeamName: str | None + TeamColor: str | None + TeamId: str | None + FirstName: str | None + LastName: str | None + FullName: str | None + HeadshotUrl: str | None + CountryCode: str | None Position: float | None - ClassifiedPosition: str + ClassifiedPosition: str | None GridPosition: float | None Q1: int | None Q2: int | None Q3: int | None Time: int | None - Status: str + Status: str | None Points: float | None class Laps(BaseModel): """Response model for session laps for a given year, round, session and drivers""" - Time: int - Driver: str - DriverNumber: str + Time: int | None + Driver: str | None + DriverNumber: str | None LapTime: int | None - LapNumber: float - Stint: float + LapNumber: float | None + Stint: float | None PitOutTime: int | None PitInTime: int | None Sector1Time: int | None @@ -151,16 +151,51 @@ class Laps(BaseModel): SpeedI2: float | None SpeedFL: float | None SpeedST: float | None - IsPersonalBest: bool - Compound: str - TyreLife: float - FreshTyre: bool - Team: str + IsPersonalBest: bool | None + Compound: str | None + TyreLife: float | None + FreshTyre: bool | None + Team: str | None LapStartTime: int | None LapStartDate: str | None - TrackStatus: str + TrackStatus: str | None Position: float | None Deleted: bool | None - DeletedReason: str - FastF1Generated: bool - IsAccurate: bool + DeletedReason: str | None + FastF1Generated: bool | None + IsAccurate: bool | None + + +class Telemetry(BaseModel): + """Response model for session telemetry for a given year, round, session, driver and laps""" + + Time: int + RPM: int + Speed: float + nGear: int + Throttle: float + Brake: bool + DRS: int + Distance: float + X: float + Y: float + + +class Weather(BaseModel): + """Response model for session weather for a given year, round, session and laps""" + + Time: int + AirTemp: float + Humidity: float + Pressure: float + Rainfall: bool + TrackTemp: float + WindDirection: int + WindSpeed: float + + +class ExtendedTelemetry(BaseModel): + """Response model for telemetry with weather""" + + Telemetry: List[Telemetry] + Weather: Weather | None diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 0000000..6784ff2 --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,83 @@ +# External +from fastapi import status +from fastapi.testclient import TestClient + +# Project +from app.main import app + + +client = TestClient(app) + + +# region good inputs + + +def test_get_telemetry(): + response = client.get("/telemetry/2023/4/1/1?session=5&weather=false") + assert response.status_code == status.HTTP_200_OK + assert response.json()["Telemetry"][0] == { + "Time": 0, + "RPM": 10115, + "Speed": 0.0, + "nGear": 1, + "Throttle": 16.0, + "Brake": False, + "DRS": 1, + "Distance": 0.0019578773, + "X": 1811.0527567522, + "Y": -279.9800289003, + } + assert response.json()["Weather"] == None + + +def test_get_telemetry_with_weather(): + response = client.get("/telemetry/2023/4/1/1?session=5&weather=true") + assert response.status_code == status.HTTP_200_OK + assert response.json()["Telemetry"][0] == { + "Time": 0, + "RPM": 10115, + "Speed": 0.0, + "nGear": 1, + "Throttle": 16.0, + "Brake": False, + "DRS": 1, + "Distance": 0.0019578773, + "X": 1811.0527567522, + "Y": -279.9800289003, + } + assert response.json()["Weather"] == { + "Time": 3790336, + "AirTemp": 24.9, + "Humidity": 49.0, + "Pressure": 1008.7, + "Rainfall": False, + "TrackTemp": 43.4, + "WindDirection": 50, + "WindSpeed": 0.8, + } + + +# endregion good inputs + +# region bad inputs + + +def test_get_telemetry_bad_round(): + response = client.get("/telemetry/2023/25/1/1?session=5&weather=false") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Bad Request. Invalid round: 25"} + + +def test_get_telemetry_bad_driver_number(): + response = client.get("/telemetry/2023/4/3/1?session=5&weather=false") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json() == {"detail": "Laps for driver 3 not found."} + + +def test_get_telemetry_bad_lap(): + response = client.get("/telemetry/2023/4/1/99?session=5&weather=false") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json() == {"detail": "Requested lap for driver 1 not found."} + + +# endregion bad inputs