diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..26d33521af10bcc7fd8cea344038eaaeb78d0ef5 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/F1-OvertakeAnalyser-Python.iml b/.idea/F1-OvertakeAnalyser-Python.iml new file mode 100644 index 0000000000000000000000000000000000000000..591c422bea5969b4f4e6edaa9412ebff3ae03ad2 --- /dev/null +++ b/.idea/F1-OvertakeAnalyser-Python.iml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<module type="PYTHON_MODULE" version="4"> + <component name="NewModuleRootManager"> + <content url="file://$MODULE_DIR$"> + <excludeFolder url="file://$MODULE_DIR$/.venv" /> + </content> + <orderEntry type="jdk" jdkName="Python 3.12 (F1-OvertakeAnalyser-Python)" jdkType="Python SDK" /> + <orderEntry type="sourceFolder" forTests="false" /> + </component> +</module> \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000000000000000000000000000000000000..105ce2da2d6447d11dfe32bfb846c3d5b199fc99 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ +<component name="InspectionProjectProfileManager"> + <settings> + <option name="USE_PROJECT_PROFILE" value="false" /> + <version value="1.0" /> + </settings> +</component> \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000000000000000000000000000000000000..9e377b7026972e4f9db0500ff67f4363d416e02f --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="Black"> + <option name="sdkName" value="Python 3.12 (F1-OvertakeAnalyser-Python)" /> + </component> +</project> \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000000000000000000000000000000000..2fbde88f2ebaad8ac855add891be3fabc613e6d5 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="ProjectModuleManager"> + <modules> + <module fileurl="file://$PROJECT_DIR$/.idea/F1-OvertakeAnalyser-Python.iml" filepath="$PROJECT_DIR$/.idea/F1-OvertakeAnalyser-Python.iml" /> + </modules> + </component> +</project> \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000000000000000000000000000000000000..94a25f7f4cb416c083d265558da75d457237d671 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="VcsDirectoryMappings"> + <mapping directory="$PROJECT_DIR$" vcs="Git" /> + </component> +</project> \ No newline at end of file diff --git a/DataAnalyser.py b/DataAnalyser.py new file mode 100644 index 0000000000000000000000000000000000000000..2a2af99b02c46ee2f8931a2bfb99fe304370fdaf --- /dev/null +++ b/DataAnalyser.py @@ -0,0 +1,242 @@ +import math +from typing import List + +from fastf1.core import Session, Lap + +from DataHandler import DataHandler + + +class DataAnalyser(DataHandler): + + + + # ===== Overtakes ===== + + def analyseRacesForOvertakes(self, races: List[Session]): + overtakesInRaces: List[List[int]] = [[]] + for race in races: + overtakesInRaces.append(self.analyseRaceForOvertakes(race)) + return overtakesInRaces + + def analyseRaceForOvertakes(self, race: Session): + + + # Collect grid positions + allLapPositions: List[dict[str, int]] = [] + + gridPositions: dict[str, int] = self.getGridPositions(race) + allLapPositions.append(gridPositions) + + runningLapsPositions: List[dict[str, int]] = self.getRunningLapsPositions(race) + for runningLapPositions in runningLapsPositions: + allLapPositions.append(runningLapPositions) + + # Count position changes between all lap pairs + overtakesInLaps: List[int] = self.countOvertakesInLaps(allLapPositions) + + return overtakesInLaps + + def getGridPositions(self, race: Session): + sessionResults = race.results + gridPositions: dict[str, int] = {} # driverId & lapNumber + for i in range(self.numberOfDrivers): + driverPosition = sessionResults['GridPosition'].iloc[i] + driverAbbreviation = sessionResults['Abbreviation'].iloc[i] + gridPositions[driverAbbreviation] = driverPosition + + if self.activateDebugOvertakeAnalysis: + print(f"\nLap: 0") + for position in range(len(gridPositions)): + gridPositions.values() + driverAtPosition = list(gridPositions.keys())[list(gridPositions.values()).index(position + 1)] # get dictionary keys (driverIds) by values (current race position) + print(f"P{position + 1}: {driverAtPosition}") + + return gridPositions + + def getRunningLapsPositions(self, race: Session): + runningLapsPositions: List[dict[str, int]] = [] + allRaceLaps = race.laps + + for raceLapIndex in range(race.total_laps): + runningLapPositions: dict[str, int] = {} # driverId & lapNumber + for driver in race.drivers: + raceLap = allRaceLaps.pick_laps(raceLapIndex + 1) # Lap 0 doesn't exist + raceLap = raceLap.pick_drivers(driver) + try : + driverAbbreviation = raceLap['Driver'].iloc[0] + driverPosition = raceLap['Position'].iloc[0] + runningLapPositions[driverAbbreviation] = driverPosition + except: # triggered when not all drivers that took part reached lap, probably by crashing or being behind + x = 0 # do nothing + runningLapsPositions.append(runningLapPositions) + + # debug + if self.activateDebugOvertakeAnalysis: + for raceLapIndex in range(race.total_laps): + print(f"\nLap: {raceLapIndex + 1}") + runningLapPositions = runningLapsPositions[raceLapIndex] + for position in range(len(runningLapPositions)): + runningLapPositions.values() + driverAtPosition = list(runningLapPositions.keys())[list(runningLapPositions.values()).index(position + 1)] + print(f"P{position + 1}: {driverAtPosition}") + + return runningLapsPositions + + def countOvertakesInLaps(self, allLapPositions: List[dict[str, int]]): + overtakesInLaps: List[int] = [] + for i in range(len(allLapPositions) - 1): + overtakesInLap: int = self.countOvertakesInLap(allLapPositions[i], allLapPositions[i + 1]) + overtakesInLaps.append(overtakesInLap) + if self.activateDebugOvertakeAnalysis: print(f"Overtakes in lap {i+1}: {overtakesInLap}") + return overtakesInLaps + + def countOvertakesInLap(self, startOrder: dict[str, int], endOrder: dict[str, int]): + out: int = self.numberOfDrivers + orderToSort: List[int] = [self.numberOfDrivers] * self.numberOfDrivers + for driver in startOrder: + if driver not in endOrder: # add missing drivers for sorting/counting purposes + endOrder[driver] = out # heaviest weighting to drop driver to bottom, as they do irl when crashing or similar + if math.isnan(startOrder[driver]): startOrder[driver] = out + if math.isnan(endOrder[driver]): endOrder[driver] = out + driverStartPosition: int = int(startOrder[driver]) + driverEndPosition: int = int(endOrder[driver]) + + orderToSort[driverStartPosition - 1] = driverEndPosition + + if self.activateDebugOvertakeAnalysis: + #print("Weighting:") + for i in range(len(orderToSort)): + x=0 + #print(orderToSort[i]) + + + + overtakes: int = 0 + index: int = 0 + while index < len(orderToSort) - 1: + if orderToSort[index] > orderToSort[index + 1]: + temp: int = orderToSort[index] + orderToSort[index] = orderToSort[index + 1] + orderToSort[index + 1] = temp + overtakes += 1 + index = -1 + index += 1 + + return overtakes + + # ===== Weather ===== + + def analyseRacesForWeather(self, races: List[Session]): + weatherInRaces: List[List[bool]] = [[]] # isRaining per lap per race + for race in races: + weatherInRace = self.analyseRaceForWeather(race) + weatherInRaces.append(weatherInRace) + return weatherInRaces + + def analyseRaceForWeather(self, race: Session): + x = 0 + + # ===== Tire Changes ===== + + def getEarliestTireChanges(self, races: List[Session]): + earliestTireChanges: List[int] = [[]] # isRaining per lap per race + for race in races: + earliestTireChange = self.getEarliestTireChange(race) + earliestTireChanges.append(earliestTireChange) + return earliestTireChanges + + # Returns -1 if no tire change occured + def getEarliestTireChange(self, race: Session): + earliestTireChangeLap: int = -1 + compoundsPerLap: List[List[str]] = self.getCompoundsForRace(race) + compoundsPerLap[0] = compoundsPerLap[1] # presume grid tires same as 1st lap; races are only picked if weather change after first 10 laps anyways, so its ok + startingCompound: str = self.getPredominantCompound(compoundsPerLap[0]) + earliestTireChangeLap = self.getFirstLapWithOppositeCompound(compoundsPerLap, startingCompound) + + return earliestTireChangeLap + + def getLatestTireChanges(self, races: List[Session]): + latestTireChanges: List[int] = [[]] # isRaining per lap per race + for race in races: + latestTireChange = self.getLatestTireChange(race) + latestTireChanges.append(latestTireChange) + return latestTireChanges + + # Returns -1 if no tire change occured + def getLatestTireChange(self, race: Session): + latestTireChangeLap: int = -1 + compoundsPerLap: List[List[str]] = self.getCompoundsForRace(race) + compoundsPerLap[0] = compoundsPerLap[1] # presume grid tires same as 1st lap; races are only picked if weather change after first 10 laps anyways, so its ok + startingCompound: str = self.getPredominantCompound(compoundsPerLap[0]) + latestTireChangeLap = self.getFirstLapWithoutCompound(compoundsPerLap, startingCompound) + + return latestTireChangeLap + + def getFirstLapWithoutCompound(self, compoundsPerLap: List[List[str]], startingCompound: str): + currentLap = 0 + filter = self.setFilter(startingCompound) + for compoundsThisLap in compoundsPerLap: + noStartingCompoundsLeft = True + for compound in compoundsThisLap: + if compound in filter: + noStartingCompoundsLeft = False + if noStartingCompoundsLeft: return currentLap + currentLap += 1 + + + def getCompoundsForRace(self, race: Session): + compoundsPerLap: List[List[str]] = [[]] + allRaceLaps = race.laps + + for raceLapIndex in range(race.total_laps): + compoundsThisLap: List[str] = [] + for driver in race.drivers: + raceLap = allRaceLaps.pick_laps(raceLapIndex + 1) # Lap 0 doesn't exist + raceLap = raceLap.pick_drivers(driver) + try: + driverAbbreviation = raceLap['Driver'].iloc[0] + compound = raceLap['Compound'].iloc[0] + compoundsThisLap.append(compound) + except: # triggered when not all drivers that took part reached lap, probably by crashing or being behind + x = 0 # do nothing + compoundsPerLap.append(compoundsThisLap) + + return compoundsPerLap + + def getPredominantCompound(self, compoundsThisLap: List[str]): + slickCounter = 0 + interCounter = 0 + wetCounter = 0 + for compound in compoundsThisLap: + if compound in self.slickCompounds: slickCounter += 1 + if compound == 'INTERMEDIATE': interCounter += 1 + if compound == 'WET': wetCounter += 1 + mostUsed = max(slickCounter, interCounter, wetCounter) + if slickCounter == mostUsed: return 'SLICK' + if interCounter == mostUsed: return 'INTERMEDIATE' + if wetCounter == mostUsed: return 'WET' + return 'error' + + def getFirstLapWithOppositeCompound(self, compoundsPerLap: List[List[str]], startingCompound: str): + filter = self.setFilter(startingCompound) + currentLap = 0 + for compoundsThisLap in compoundsPerLap: + for compound in compoundsThisLap: + if compound not in filter: + return currentLap + currentLap += 1 + + def setFilter(self, startingCompound: str): + if startingCompound == 'SLICK': return self.slickCompounds + return startingCompound + + # ===== Crashes ===== + + def analyseRacesForCrashes(self, races): + x = 0 + + # ===== Events ===== + + def analyseRacesForEvents(self, races): + x = 0 + diff --git a/DataChecker.py b/DataChecker.py new file mode 100644 index 0000000000000000000000000000000000000000..6ddbfa08f76dab291b67b1c8eca0b9e3e4684c81 --- /dev/null +++ b/DataChecker.py @@ -0,0 +1,19 @@ +from DataHandler import DataHandler + + +class DataChecker(DataHandler): + + def check_imported_race(self, race_data): + for current_lap in range(len(race_data)): + print(f"Lap {current_lap}") + for position in range(1, len(race_data[0]) + 1): + driver_at_position = race_data[current_lap][position - 1] + if driver_at_position == self.invalidDriverId: + break # skip entries without drivers + print(f"P{position}: {race_data[current_lap][position - 1]}") + print() + print(f"Number of laps run: {len(race_data) - 1}") + + def check_imported_races(self, races): + for race in races: + self.check_imported_race(race) \ No newline at end of file diff --git a/DataHandler.py b/DataHandler.py new file mode 100644 index 0000000000000000000000000000000000000000..c718a439337e95e47d859e77b70da8cc75129b50 --- /dev/null +++ b/DataHandler.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod + +class DataHandler(ABC): + + def __init__(self): + self.numberOfDrivers = 20 + self.invalidDriverId = "NO_DRIVER" + self.enableTestingMode = False # only partially import data to speed up testing, because HTTP 429 limits import speed + self.activateDebugOvertakeAnalysis = False + self.slickCompounds = ('SOFT', 'MEDIUM', 'HARD') \ No newline at end of file diff --git a/DataImporter.py b/DataImporter.py new file mode 100644 index 0000000000000000000000000000000000000000..40e7e2b44008f79233ffeabed9e1a7fef865bafe --- /dev/null +++ b/DataImporter.py @@ -0,0 +1,30 @@ +import json +import time + +import fastf1 +import requests +from abc import ABC +from typing import List + +from fastf1.core import Session + +from DataHandler import DataHandler + + + + + +class DataImporter(DataHandler, ABC): + + def importRaceSessions(self, races): + sessions: List[Session] = [] + for race in races: + sessions.append(self.importRaceSession(race)) + return sessions + + + def importRaceSession(self, race): + season, raceIndex = race + session = fastf1.get_session(season, raceIndex, 'R') + session.load(laps=True, weather=True, messages=False, telemetry=False) + return session \ No newline at end of file diff --git a/DataPlotter.py b/DataPlotter.py new file mode 100644 index 0000000000000000000000000000000000000000..31bc35cf563d0c66dc8f912e9b1d840614860c33 --- /dev/null +++ b/DataPlotter.py @@ -0,0 +1,48 @@ +import math +from abc import ABC +from typing import List + +import matplotlib.pyplot as plt +import numpy as np + + +from fastf1.core import Session, Lap + +from DataHandler import DataHandler + + +class DataPlotter(DataHandler): + def plotOvertakesWithTireChangeWindow(self, overtakesPerLap: List[int], earliestTireChange: int, latestTireChange: int): + + overtakesPerLap.insert(0, 0) # Insert 0th lap, which cannot have overtakes + laps: int = len(overtakesPerLap) + + figure, axis = plt.subplots(figsize=(8.0, 4.9)) + + axis.plot(overtakesPerLap) + + + axis.set_xlabel('Lap') + axis.set_ylabel('Position changes in lap') + + major_ticks_laps = np.arange(0, laps, 5) + major_ticks_overtakes = np.arange(0, max(overtakesPerLap) + 1, 5) + minor_ticks_laps = np.arange(0, laps, 1) + minor_ticks_overtakes = np.arange(0, max(overtakesPerLap) + 1, 1) + + axis.set_xticks(major_ticks_laps) + axis.set_xticks(minor_ticks_laps, minor=True) + axis.set_yticks(major_ticks_overtakes) + axis.set_yticks(minor_ticks_overtakes, minor=True) + + # And a corresponding grid + axis.grid(which='both') + + # Or if you want different settings for the grids: + axis.grid(which='minor', alpha=0.2) + axis.grid(which='major', alpha=0.5) + + + axis.legend(bbox_to_anchor=(1.0, 1.02)) + plt.tight_layout() + plt.show() \ No newline at end of file diff --git a/__pycache__/DataAnalyser.cpython-312.pyc b/__pycache__/DataAnalyser.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ecccefbfe08c806dc0f19ec65dbea28f80374b1f Binary files /dev/null and b/__pycache__/DataAnalyser.cpython-312.pyc differ diff --git a/__pycache__/DataChecker.cpython-312.pyc b/__pycache__/DataChecker.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3cc2c91a482e442f42e50c9323e7620b7bb2410a Binary files /dev/null and b/__pycache__/DataChecker.cpython-312.pyc differ diff --git a/__pycache__/DataHandler.cpython-312.pyc b/__pycache__/DataHandler.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b158e8a2cb16c80f6620ac6055ec6e4497358d2d Binary files /dev/null and b/__pycache__/DataHandler.cpython-312.pyc differ diff --git a/__pycache__/DataImporter.cpython-312.pyc b/__pycache__/DataImporter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0423a9fea9c28a4df0751aa5ec208ce2b742b005 Binary files /dev/null and b/__pycache__/DataImporter.cpython-312.pyc differ diff --git a/__pycache__/DataPlotter.cpython-312.pyc b/__pycache__/DataPlotter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e6aab7555863e182c257ac9dab1a0cc088b39228 Binary files /dev/null and b/__pycache__/DataPlotter.cpython-312.pyc differ diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..cb9736e123d740785f6f2b6b62beae1d78482283 --- /dev/null +++ b/main.py @@ -0,0 +1,131 @@ +from typing import List + +import matplotlib.pyplot as plt + +import fastf1.plotting + + +from DataAnalyser import DataAnalyser +from DataChecker import DataChecker +from DataImporter import DataImporter +from DataPlotter import DataPlotter + + + + + +class Main: + + def main(self): + # contains pairs of season & race number to identify specific races + race_indexes = [ + [2022, 4] # Imola 2022 (DWR) + # [2024, 7] # Imola 2024 (SWR) + # [2024, 9] # Canada 2024 (DWR) + # + # TODO: add more races + ] + + importer = DataImporter() + analyser = DataAnalyser() + plotter = DataPlotter() + + for race_index in race_indexes: + # Import + raceSession = importer.importRaceSession(race_index) + print("Import done") + + # Analyse + overtakesInRaces: List[int] = analyser.analyseRaceForOvertakes(raceSession) + print("Overtake analysis done") + # weatherInRaces = analyser.analyseRaceForWeather(raceSession) + earliestTireChange: int = analyser.getEarliestTireChange(raceSession) # first lap where someone switched from slicks to non slicks or vice versa, denoted by lap number + print("First tire change done") + latestTireChange: int = analyser.getLatestTireChange(raceSession) + print("Last tire change done") + + plotter.plotOvertakesWithTireChangeWindow(overtakesInRaces, earliestTireChange, latestTireChange) + print("Plot done") + + + + print(f"\n\n===== Data for race {race_index[0]}/{race_index[1]} =====") + print("Lap\tOvertakes") + currentLap = 0 + for overtakesInLap in overtakesInRaces: + print(f"{currentLap}\t{overtakesInLap}") + currentLap += 1 + print(f"First tire compound: lap {earliestTireChange}") + print(f"Last tire compound: lap {latestTireChange}") + print(f"Weather change window is therefore: laps {earliestTireChange-1} - {latestTireChange+1}") + + + + + def fastF1Example(self): + # Load FastF1's dark color scheme + fastf1.plotting.setup_mpl(mpl_timedelta_support=False, misc_mpl_mods=False, + color_scheme='fastf1') + + + session = fastf1.get_session(2024, 7, 'R') + session.load(laps=True, weather=True) + + figure, axis = plt.subplots(figsize=(8.0, 4.9)) + + + for driver in session.drivers: + driverLaps = session.laps.pick_drivers(driver) + + driverAbbreviation = driverLaps['Driver'].iloc[0] + style = fastf1.plotting.get_driver_style(identifier=driverAbbreviation, + style=['color', 'linestyle'], + session=session) + + axis.plot(driverLaps['LapNumber'], driverLaps['Position'], + label=driverAbbreviation, **style) + + + axis.set_ylim([20.5, 0.5]) + axis.set_yticks([1, 5, 10, 15, 20]) + axis.set_xlabel('Lap') + axis.set_ylabel('Position') + + + axis.legend(bbox_to_anchor=(1.0, 1.02)) + plt.tight_layout() + + plt.show() + + + def oldJavaTransfer(self): + # contains pairs of season & race number to identify specific races + race_indexes = [ + [2024, 7], # Imola 2024 + [2022, 4] # Imola 2022 + # TODO: add more races + ] + + importer = DataImporter() + checker = DataChecker() + analyser = DataAnalyser() + # plotter = DataPlotter() # Commented as it's not used + + races = importer.import_laps_for_races(race_indexes) + checker.check_imported_races(races) + overtakes_in_races = analyser.count_overtakes_in_races(races) + + print(f"Data for race {race_indexes[0][1]}/{race_indexes[0][0]}") + for current_lap in range(len(overtakes_in_races[0])): + print(f"Overtakes in lap {current_lap + 1}: {overtakes_in_races[0][current_lap]}") + + return overtakes_in_races + + + + +if __name__ == '__main__': + app = Main() + + # Call the get_overtakes_in_races method + app.main() \ No newline at end of file