From ec012a5298efb04000b36c40ed9d2c9ee940386c Mon Sep 17 00:00:00 2001 From: Mitko Tochev Date: Wed, 21 Jan 2026 21:36:03 -0500 Subject: [PATCH 1/4] initial implementation of weather conditions --- src/f1p/__init__.py | 11 ++ src/f1p/ui/components/weather.py | 229 +++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 src/f1p/ui/components/weather.py diff --git a/src/f1p/__init__.py b/src/f1p/__init__.py index 6e55e33..135c53d 100644 --- a/src/f1p/__init__.py +++ b/src/f1p/__init__.py @@ -9,6 +9,7 @@ from f1p.ui.components.menu import Menu from f1p.ui.components.origin import Origin from f1p.ui.components.playback import PlaybackControls +from f1p.ui.components.weather import WeatherBoard class F1PlayerApp(ShowBase): @@ -98,10 +99,20 @@ def register_ui_components(self) -> Self: self.data_extractor, ) + weather_board = WeatherBoard( + self.pixel2d, + self.taskMgr, + self.symbols_font, + self.text_font, + self.width, + self.data_extractor, + ) + self.ui_components = [ playback_controls, circuit_map, leaderboard, + weather_board ] return self diff --git a/src/f1p/ui/components/weather.py b/src/f1p/ui/components/weather.py new file mode 100644 index 0000000..a9d238a --- /dev/null +++ b/src/f1p/ui/components/weather.py @@ -0,0 +1,229 @@ +from typing import Any + +from direct.gui.DirectFrame import DirectFrame +from direct.gui.OnscreenText import OnscreenText +from direct.showbase.DirectObject import DirectObject +from direct.task.Task import TaskManager, Task +from panda3d.core import StaticTextFont, Point3, TextNode + +from f1p import DataExtractorService + + +class WeatherBoard(DirectObject): + def __init__( + self, + pixel2d, + task_manager: TaskManager, + symbols_font: StaticTextFont, + text_font: StaticTextFont, + window_width: int, + data_extractor: DataExtractorService, + ): + super().__init__() + + self.pixel2d = pixel2d + self.task_manager = task_manager + self.width = 150 + self.height = 275 + self.symbols_font = symbols_font + self.text_font = text_font + self.window_width = window_width + self.data_extractor = data_extractor + + self.accept("sessionSelected", self.render_weather_board) + + self.frame: DirectFrame | None = None + self.title_frame: DirectFrame | None = None + self.title: OnscreenText | None = None + self.title_2: OnscreenText | None = None + self.condition: OnscreenText | None = None + self.temperature_C: OnscreenText | None = None + self.temperature_F: OnscreenText | None = None + self.track_temp_title: OnscreenText | None = None + self.track_temp_C: OnscreenText | None = None + self.track_temp_F: OnscreenText | None = None + self.humidity_title: OnscreenText | None = None + self.humidity: OnscreenText | None = None + + def render_frame(self) -> None: + self.frame = DirectFrame( + parent=self.pixel2d, + frameColor=(0.20, 0.20, 0.20, 0.7), + frameSize=(0, self.width, 0, -self.height), + pos=Point3(self.window_width - self.width - 10, 0, -50), + ) + + def render_title(self) -> None: + self.title_frame = DirectFrame( + parent=self.frame, + frameColor=(0.15, 0.15, 0.15, 0.7), + frameSize=(0, self.width, 0, -45), + pos=Point3(0, 0, 0), + ) + + self.title = OnscreenText( + parent=self.title_frame, + pos=(self.width / 2, -18), + scale=self.width / 10, + fg=(1, 1, 1, 0.8), + font=self.text_font, + text="WEATHER", + ) + + self.title_2 = OnscreenText( + parent=self.title_frame, + pos=(self.width / 2, -35), + scale=self.width / 10, + fg=(1, 1, 1, 0.8), + font=self.text_font, + text="CONDITIONS", + ) + + def render_weather(self) -> None: + self.condition = OnscreenText( + parent=self.frame, + pos=(5, -65), + scale=self.width / 8, + fg=(0.8, 1, 0, 0.7), + font=self.text_font, + align=TextNode.A_left, + text="RAIN", + ) + + self.temperature_C = OnscreenText( + parent=self.frame, + pos=(5, -85), + scale=self.width / 10, + fg=(1, 1, 1, 0.8), + font=self.text_font, + align=TextNode.A_left, + text="15°C", + ) + + self.temperature_F = OnscreenText( + parent=self.frame, + pos=(50, -85), + scale=self.width / 10, + fg=(0.5, 0.5, 0.5, 1), + font=self.text_font, + align=TextNode.A_left, + text="59°F", + ) + + def render_track_temperature(self) -> None: + self.track_temp_title = OnscreenText( + parent=self.frame, + pos=(5, -105), + scale=self.width / 13, + fg=(0.8, 1, 0, 0.7), + font=self.text_font, + align=TextNode.A_left, + text="TRACK TEMP", + ) + + self.track_temp_C = OnscreenText( + parent=self.frame, + pos=(5, -125), + scale=self.width / 10, + fg=(1, 1, 1, 0.8), + font=self.text_font, + align=TextNode.A_left, + text="17°C", + ) + + self.track_temp_F = OnscreenText( + parent=self.frame, + pos=(50, -125), + scale=self.width / 10, + fg=(0.5, 0.5, 0.5, 1), + font=self.text_font, + align=TextNode.A_left, + text="62°F", + ) + + def render_humidity(self) -> None: + self.humidity_title = OnscreenText( + parent=self.frame, + pos=(5, -145), + scale=self.width / 13, + fg=(0.8, 1, 0, 0.7), + font=self.text_font, + align=TextNode.A_left, + text="HUMIDITY", + ) + + self.humidity = OnscreenText( + parent=self.frame, + pos=(5, -165), + scale=self.width / 10, + fg=(1, 1, 1, 0.8), + font=self.text_font, + align=TextNode.A_left, + text="54%", + ) + + def render_pressure(self) -> None: + self.pressure_title = OnscreenText( + parent=self.frame, + pos=(5, -185), + scale=self.width / 13, + fg=(0.8, 1, 0, 0.7), + font=self.text_font, + align=TextNode.A_left, + text="AIR PRESSURE", + ) + + self.pressure = OnscreenText( + parent=self.frame, + pos=(5, -205), + scale=self.width / 10, + fg=(1, 1, 1, 0.8), + font=self.text_font, + align=TextNode.A_left, + text="101.325 kPa", + ) + + def render_wind(self) -> None: + self.wind_title = OnscreenText( + parent=self.frame, + pos=(5, -225), + scale=self.width / 13, + fg=(0.8, 1, 0, 0.7), + font=self.text_font, + align=TextNode.A_left, + text="AIR PRESSURE", + ) + + self.wind_speed = OnscreenText( + parent=self.frame, + pos=(5, -245), + scale=self.width / 10, + fg=(1, 1, 1, 0.8), + font=self.text_font, + align=TextNode.A_left, + text="5.7 km/h", + ) + + self.wind_direction = OnscreenText( + parent=self.frame, + pos=(5, -265), + scale=self.width / 13, + fg=(1, 1, 1, 0.8), + font=self.text_font, + align=TextNode.A_left, + text="NORTH EAST", + ) + + def render_weather_board(self) -> None: + self.task_manager.add(self.render, "renderLeaderboard") + + def render(self, task: Task) -> Any: + self.render_frame() + self.render_title() + self.render_weather() # TODO is raining + self.render_track_temperature() + self.render_humidity() + self.render_pressure() + self.render_wind() + + return task.done \ No newline at end of file From 44d98f34ba172621a5a8afa5ec30aff0a6232aa9 Mon Sep 17 00:00:00 2001 From: Mitko Tochev Date: Fri, 23 Jan 2026 20:16:02 -0500 Subject: [PATCH 2/4] moved things around for proper code coverage --- src/f1p/__init__.py | 118 --- src/f1p/app.py | 118 +++ src/f1p/main.py | 2 +- src/f1p/services/data_extractor/__init__.py | 704 ------------------ src/f1p/services/data_extractor/service.py | 704 ++++++++++++++++++ src/f1p/ui/components/leaderboard/__init__.py | 327 -------- .../ui/components/leaderboard/component.py | 327 ++++++++ .../{processors/__init__.py => processors.py} | 2 +- src/f1p/ui/components/map.py | 2 +- src/f1p/ui/components/menu.py | 2 +- src/f1p/ui/components/playback.py | 2 +- src/f1p/ui/components/weather.py | 2 +- 12 files changed, 1155 insertions(+), 1155 deletions(-) create mode 100644 src/f1p/app.py create mode 100644 src/f1p/services/data_extractor/service.py create mode 100644 src/f1p/ui/components/leaderboard/component.py rename src/f1p/ui/components/leaderboard/{processors/__init__.py => processors.py} (99%) diff --git a/src/f1p/__init__.py b/src/f1p/__init__.py index 135c53d..e69de29 100644 --- a/src/f1p/__init__.py +++ b/src/f1p/__init__.py @@ -1,118 +0,0 @@ -from typing import Self - -from direct.showbase.ShowBase import ShowBase -from panda3d.core import PStatClient, WindowProperties - -from f1p.services.data_extractor import DataExtractorService -from f1p.ui.components.leaderboard import Leaderboard -from f1p.ui.components.map import Map -from f1p.ui.components.menu import Menu -from f1p.ui.components.origin import Origin -from f1p.ui.components.playback import PlaybackControls -from f1p.ui.components.weather import WeatherBoard - - -class F1PlayerApp(ShowBase): - def __init__( - self, - width: int = 800, - height: int = 800, - draw_origin: bool = False, - show_frame_rate: bool = False, - pstat_debug: bool = False, - ): - super().__init__(self) - - self.symbols_font = self.loader.loadFont("./src/f1p/ui/fonts/NotoSansSymbols2-Regular.ttf") - self.text_font = self.loader.loadFont("./src/f1p/ui/fonts/f1_font.ttf") - - self.width = width - self.height = height - - self._data_extractor: DataExtractorService | None = None - self.cam.setPos(0, -70, 40) - self.cam.lookAt(0, 0, 0) - - self.setBackgroundColor(0.3, 0.3, 0.3, 1) - - self.taskMgr.setupTaskChain("loadingData", numThreads=1) - - self.ui_components: list = [] - - if draw_origin: - origin = Origin(self.render) - origin.render() - - self.setFrameRateMeter(show_frame_rate) - - if pstat_debug: - PStatClient.connect() - - @property - def data_extractor(self) -> DataExtractorService: - if self._data_extractor is None: - self._data_extractor = DataExtractorService( - self.pixel2d, - self.taskMgr, - self.width, - self.height, - self.text_font, - ) - - return self._data_extractor - - def configure_window(self) -> Self: - props = WindowProperties() - props.setSize(self.width, self.height) - props.setFixedSize(True) - self.win.requestProperties(props) - - return self - - def draw_menu(self) -> Self: - menu = Menu(self.pixel2d, self.taskMgr, self.messenger, self.width, 40, self.text_font, self.data_extractor) - menu.render() - - return self - - def register_ui_components(self) -> Self: - playback_controls = PlaybackControls( - self.pixel2d, - self.cam, - self.taskMgr, - self.height, - self.width, - 30, - self.symbols_font, - self.text_font, - self.data_extractor, - ) - - circuit_map = Map(self.render, self.taskMgr, self.data_extractor) - - leaderboard = Leaderboard( - self.pixel2d, - self.taskMgr, - self.symbols_font, - self.text_font, - circuit_map, - self.data_extractor, - ) - - weather_board = WeatherBoard( - self.pixel2d, - self.taskMgr, - self.symbols_font, - self.text_font, - self.width, - self.data_extractor, - ) - - self.ui_components = [ - playback_controls, - circuit_map, - leaderboard, - weather_board - ] - - return self diff --git a/src/f1p/app.py b/src/f1p/app.py new file mode 100644 index 0000000..7f621d0 --- /dev/null +++ b/src/f1p/app.py @@ -0,0 +1,118 @@ +from typing import Self + +from direct.showbase.ShowBase import ShowBase +from panda3d.core import PStatClient, WindowProperties + +from f1p.services.data_extractor.service import DataExtractorService +from f1p.ui.components.leaderboard.component import Leaderboard +from f1p.ui.components.map import Map +from f1p.ui.components.menu import Menu +from f1p.ui.components.origin import Origin +from f1p.ui.components.playback import PlaybackControls +from f1p.ui.components.weather import WeatherBoard + + +class F1PlayerApp(ShowBase): + def __init__( + self, + width: int = 800, + height: int = 800, + draw_origin: bool = False, + show_frame_rate: bool = False, + pstat_debug: bool = False, + ): + super().__init__(self) + + self.symbols_font = self.loader.loadFont("./src/f1p/ui/fonts/NotoSansSymbols2-Regular.ttf") + self.text_font = self.loader.loadFont("./src/f1p/ui/fonts/f1_font.ttf") + + self.width = width + self.height = height + + self._data_extractor: DataExtractorService | None = None + self.cam.setPos(0, -70, 40) + self.cam.lookAt(0, 0, 0) + + self.setBackgroundColor(0.3, 0.3, 0.3, 1) + + self.taskMgr.setupTaskChain("loadingData", numThreads=1) + + self.ui_components: list = [] + + if draw_origin: + origin = Origin(self.render) + origin.render() + + self.setFrameRateMeter(show_frame_rate) + + if pstat_debug: + PStatClient.connect() + + @property + def data_extractor(self) -> DataExtractorService: + if self._data_extractor is None: + self._data_extractor = DataExtractorService( + self.pixel2d, + self.taskMgr, + self.width, + self.height, + self.text_font, + ) + + return self._data_extractor + + def configure_window(self) -> Self: + props = WindowProperties() + props.setSize(self.width, self.height) + props.setFixedSize(True) + self.win.requestProperties(props) + + return self + + def draw_menu(self) -> Self: + menu = Menu(self.pixel2d, self.taskMgr, self.messenger, self.width, 40, self.text_font, self.data_extractor) + menu.render() + + return self + + def register_ui_components(self) -> Self: + playback_controls = PlaybackControls( + self.pixel2d, + self.cam, + self.taskMgr, + self.height, + self.width, + 30, + self.symbols_font, + self.text_font, + self.data_extractor, + ) + + circuit_map = Map(self.render, self.taskMgr, self.data_extractor) + + leaderboard = Leaderboard( + self.pixel2d, + self.taskMgr, + self.symbols_font, + self.text_font, + circuit_map, + self.data_extractor, + ) + + weather_board = WeatherBoard( + self.pixel2d, + self.taskMgr, + self.symbols_font, + self.text_font, + self.width, + self.data_extractor, + ) + + self.ui_components = [ + playback_controls, + circuit_map, + leaderboard, + weather_board + ] + + return self diff --git a/src/f1p/main.py b/src/f1p/main.py index 221afd8..256808f 100644 --- a/src/f1p/main.py +++ b/src/f1p/main.py @@ -1,4 +1,4 @@ -from f1p import F1PlayerApp +from f1p.app import F1PlayerApp app = F1PlayerApp() app.disableMouse() # disable camera controls diff --git a/src/f1p/services/data_extractor/__init__.py b/src/f1p/services/data_extractor/__init__.py index e939a63..e69de29 100644 --- a/src/f1p/services/data_extractor/__init__.py +++ b/src/f1p/services/data_extractor/__init__.py @@ -1,704 +0,0 @@ -import math -from pathlib import Path -from typing import Any, Self - -import fastf1 -import numpy as np -import pandas as pd -from direct.gui.DirectFrame import DirectFrame -from direct.gui.DirectWaitBar import DirectWaitBar -from direct.gui.OnscreenText import OnscreenText -from direct.showbase.DirectObject import DirectObject -from direct.showbase.MessengerGlobal import messenger -from direct.task.Task import Task, TaskManager -from fastf1.core import Lap, Laps, Session, Telemetry -from fastf1.events import Event, EventSchedule -from fastf1.mvapi import CircuitInfo -from panda3d.core import LVecBase4f, NodePath, Point3, StaticTextFont, deg2Rad -from pandas import DataFrame, Series, Timedelta - -from f1p.utils.geometry import center_pos_data, find_center, resize_pos_data - - -class DataExtractorService(DirectObject): - year: int - event_name: str - session_id: str - cache_path: Path = Path(__file__).parent.parent.parent.parent.parent / ".fastf1-cache" - - def __init__( - self, - parent: NodePath, - task_manager: TaskManager, - window_width: int, - window_height: int, - text_font: StaticTextFont, - ): - self.parent = parent - self.task_manager = task_manager - self.window_width = window_width - self.window_height = window_height - self.text_font = text_font - - self._event_schedule: EventSchedule | None = None - self._event: Event | None = None - self._session: Session | None = None - self._session_status: DataFrame | None = None - self._session_start_time: Timedelta | None = None - self._session_end_time: Timedelta | None = None - self._pos_data: dict[str, Telemetry] | None = None - self._circuit_info: CircuitInfo | None = None - self._track_status: DataFrame | None = None - self._track_status_colors: DataFrame | None = None - self._green_flag_track_status: DataFrame | None = None - self._track_statuses: DataFrame | None = None - self._total_laps: int | None = None - self._laps: Laps | None = None - self._fastest_lap: Lap | None = None - self.fastest_lap_telemetry: DataFrame | None = None - self.map_center_coordinate: tuple[float, float, float] | None = None - - self.loading_frame: DirectFrame | None = None - self.loading_text: OnscreenText | None = None - self.wait_bar: DirectWaitBar | None = None - - self.processed_pos_data: DataFrame | None = None - - self.accept("loadData", self.load_data) - - if not self.cache_path.exists(): - self.cache_path.mkdir(parents=True) - - fastf1.Cache.enable_cache(str(self.cache_path)) - - @property - def event_schedule(self) -> EventSchedule: - if self._event_schedule is None: - self._event_schedule = fastf1.get_event_schedule(self.year) - - return self._event_schedule - - @property - def event(self) -> Event: - if self._event is None: - self._event = fastf1.get_event(self.year, self.event_name) - - return self._event - - @property - def session(self) -> Session: - if self._session is None: - self._session = fastf1.get_session(self.year, self.event_name, self.session_id) - - return self._session - - @property - def session_status(self) -> DataFrame: - if self._session_status is None: - self._session_status = self.session.session_status - - return self._session_status - - @property - def total_laps(self) -> int: - if self._total_laps is None: - self._total_laps = int(self.session.total_laps) - - return self._total_laps - - @property - def pos_data(self) -> dict[str, Telemetry]: - if self._pos_data is None: - self._pos_data = self.session.pos_data - - return self._pos_data - - @property - def laps(self) -> Laps: - if self._laps is None: - self._laps = self.session.laps - - return self._laps - - def get_current_lap_number(self, session_time_tick: int) -> int: - df = self.processed_pos_data - df = df[df["SessionTimeTick"] == session_time_tick] - - return int(math.ceil(df["LapsCompletion"].max())) - - @property - def fastest_lap(self) -> Lap: - if self._fastest_lap is None: - self._fastest_lap = self.laps.pick_fastest() - - return self._fastest_lap - - @property - def circuit_info(self) -> CircuitInfo: - if self._circuit_info is None: - self._circuit_info = self.session.get_circuit_info() - - return self._circuit_info - - @property - def track_status(self) -> DataFrame: - if self._track_status is None: - self._track_status = self.session.track_status - - return self._track_status - - @property - def track_status_colors(self) -> DataFrame: - if self._track_status_colors is None: - self._track_status_colors = DataFrame( - data={ - "Status": [1, 2, 4, 5, 6, 7], - "Label": [ - "Green Flag", - "Yellow Flag", - "Safety Car", - "Red Flag", - "VSC Deployed", - "VSC Ending", - ], - "Color": [ - LVecBase4f(0, 1, 0, 0.8), - LVecBase4f(1, 1, 0, 0.8), - LVecBase4f(1, 1, 0, 0.8), - LVecBase4f(1, 0, 0, 0.8), - LVecBase4f(1, 0.64, 0, 0.8), - LVecBase4f(1, 0.64, 0, 0.8), - ], - "TextColor": [ - LVecBase4f(0, 0, 0, 0.8), - LVecBase4f(0, 0, 0, 0.8), - LVecBase4f(0, 0, 0, 0.8), - LVecBase4f(1, 1, 1, 0.8), - LVecBase4f(0, 0, 0, 0.8), - LVecBase4f(0, 0, 0, 0.8), - ], - }, - ) - - return self._track_status_colors - - @property - def green_flag_track_status(self) -> DataFrame: - if self._green_flag_track_status is None: - ts_colors_df = self.track_status_colors - self._green_flag_track_status = ts_colors_df[ts_colors_df["Status"] == 1] - - return self._green_flag_track_status - - @property - def green_flag_track_status_label(self) -> str: - return self.green_flag_track_status["Label"].iloc[0] - - @property - def green_flag_track_status_color(self) -> LVecBase4f: - return self.green_flag_track_status["Color"].iloc[0] - - @property - def green_flag_track_status_text_color(self) -> LVecBase4f: - return self.green_flag_track_status["TextColor"].iloc[0] - - @property - def map_rotation(self) -> float: - return deg2Rad(self.circuit_info.rotation) - - @property - def session_start_time(self) -> Timedelta: - if self._session_start_time is None: - self._session_start_time = self.session_status[self.session_status["Status"] == "Started"].iloc[0]["Time"] - - return self._session_start_time - - @property - def session_start_time_milliseconds(self) -> int: - return int(self.session_start_time.total_seconds() * 1e3) - - @property - def session_end_time(self) -> Timedelta: - if self._session_end_time is None: - self._session_end_time = self.session_status[self.session_status["Status"] == "Finalised"].iloc[0]["Time"] - - return self._session_end_time - - @property - def session_end_time_milliseconds(self) -> int: - return int(self.session_end_time.total_seconds() * 1e3) - - @property - def session_ticks(self) -> int: - df = self.processed_pos_data.copy() - - df = df[["DriverNumber", "SessionTimeTick"]] - df = df.groupby("DriverNumber")["SessionTimeTick"].count() - - return df.min() - - def process_track_statuses(self, width: int) -> None: - df = self.processed_pos_data.copy() - df = df[["SessionTimeTick", "SessionTime"]].drop_duplicates(keep="first").copy() - - pixel_per_tick = width / self.session_ticks - - df.loc[:, "Pixel"] = df.loc[:, "SessionTimeTick"] * pixel_per_tick - - ts_df = self.track_status.copy() - ts_df = ts_df[ts_df["Time"] >= self.session_start_time] - ts_df = ts_df[ts_df["Time"] <= self.session_end_time] - - ts_df["EndTime"] = ts_df["Time"].shift(-1).fillna(self.session_end_time) - - for record in ts_df.itertuples(): - ts_df.loc[ts_df["Time"] == record.Time, "SessionTimeTick"] = df.loc[ - df["SessionTime"] <= record.Time, - "SessionTimeTick", - ].max() - ts_df.loc[ts_df["Time"] == record.Time, "SessionTimeTickEnd"] = df.loc[ - df["SessionTime"] <= record.EndTime, - "SessionTimeTick", - ].max() - - ts_df["SessionTimeTick"] = ts_df["SessionTimeTick"].astype("int64") - ts_df["SessionTimeTickEnd"] = ts_df["SessionTimeTickEnd"].astype("int64") - - ts_df = ts_df.merge(df, on="SessionTimeTick", how="left") - ts_df = ts_df.rename(columns={"Pixel": "PixelStart"}).drop(columns="SessionTime") - ts_df = ts_df.merge(df, left_on="SessionTimeTickEnd", right_on="SessionTimeTick", how="left").rename( - columns={"SessionTimeTick_x": "SessionTimeTick"}, - ) - ts_df = ts_df.rename(columns={"Pixel": "PixelEnd"}).drop(columns=["SessionTime", "SessionTimeTick_y"]) - ts_df = ts_df.drop(columns=["Time", "EndTime"]).reset_index() - - ts_df["Width"] = ts_df["PixelEnd"] - ts_df["PixelStart"] - ts_df["Status"] = ts_df["Status"].astype("int64") - - self._track_statuses = ts_df.merge(self.track_status_colors, on="Status", how="left") - - @property - def track_statuses(self) -> DataFrame: - if self._track_statuses is None: - raise ValueError("Track statuses not processed.") - - return self._track_statuses - - def get_current_track_status(self, session_time_tick: int) -> Series | None: - ts_df = self.track_statuses - - ts_df = ts_df[ts_df["SessionTimeTick"] <= session_time_tick] - ts_df = ts_df[ts_df["SessionTimeTickEnd"] >= session_time_tick] - - if ts_df.empty: - return None - - return ts_df.iloc[0] - - def process_fastest_lap(self) -> Self: - pos_data = self.fastest_lap.get_pos_data() - resized_pos_data_df = resize_pos_data(self.map_rotation, pos_data) - - self.map_center_coordinate = find_center(resized_pos_data_df) - - self.fastest_lap_telemetry = center_pos_data(self.map_center_coordinate, resized_pos_data_df) - - self.update_loading(5) - - return self - - def combine_position_data(self) -> Self: - drivers_pos_data = [] - for driver_number, pos_data in self.pos_data.items(): - pos_data["DriverNumber"] = driver_number - drivers_pos_data.append(pos_data) - - self.processed_pos_data = pd.concat(drivers_pos_data, ignore_index=True) - - self.update_loading(5) - - return self - - def remove_records_before_session_start_time(self) -> Self: - self.processed_pos_data = self.processed_pos_data[ - self.processed_pos_data["SessionTime"] >= self.session_start_time - ] - - self.update_loading(5) - - return self - - def normalize_position_data(self) -> Self: - df = self.processed_pos_data.copy() - - resized_pos_data_df = resize_pos_data(self.map_rotation, df) - self.processed_pos_data = center_pos_data(self.map_center_coordinate, resized_pos_data_df) - - self.update_loading(5) - - return self - - def add_session_time_in_milliseconds(self) -> Self: - session_time_in_milliseconds = self.processed_pos_data["SessionTime"].dt.total_seconds() * 1e3 - - self.processed_pos_data["SessionTimeMilliseconds"] = session_time_in_milliseconds.astype("int64") - - self.update_loading(5) - - return self - - def add_session_time_tick(self) -> Self: - df = self.processed_pos_data.copy() - - df["SessionTimeTick"] = df.groupby("DriverNumber").cumcount().add(1) - - self.processed_pos_data = df - - self.update_loading(5) - - return self - - def process_laps(self) -> Self: - laps = self.laps.copy() - - laps.loc[laps["Sector1SessionTime"].isna(), "Sector1SessionTime"] = ( - laps.loc[laps["Sector1SessionTime"].isna(), "LapStartTime"] - + laps.loc[laps["Sector1SessionTime"].isna(), "Sector1Time"] - ) - laps.loc[laps["Sector2SessionTime"].isna(), "Sector2SessionTime"] = ( - laps.loc[laps["Sector2SessionTime"].isna(), "Sector1SessionTime"] - + laps.loc[laps["Sector2SessionTime"].isna(), "Sector2Time"] - ) - laps.loc[laps["Sector3SessionTime"].isna(), "Sector3SessionTime"] = ( - laps.loc[laps["Sector3SessionTime"].isna(), "Sector2SessionTime"] - + laps.loc[laps["Sector3SessionTime"].isna(), "Sector3Time"] - ) - - lap_start_time_in_milliseconds = laps["LapStartTime"].fillna(Timedelta(milliseconds=0)).dt.total_seconds() * 1e3 - laps["LapStartTimeMilliseconds"] = lap_start_time_in_milliseconds.astype("int64") - sector1_session_time_in_milliseconds = ( - laps["Sector1SessionTime"].fillna(Timedelta(milliseconds=0)).dt.total_seconds() * 1e3 - ) - laps["Sector1SessionTimeMilliseconds"] = sector1_session_time_in_milliseconds.astype("int64") - sector2_session_time_in_milliseconds = ( - laps["Sector2SessionTime"].fillna(Timedelta(milliseconds=0)).dt.total_seconds() * 1e3 - ) - laps["Sector2SessionTimeMilliseconds"] = sector2_session_time_in_milliseconds.astype("int64") - sector3_session_time_in_milliseconds = ( - laps["Sector3SessionTime"].fillna(Timedelta(milliseconds=0)).dt.total_seconds() * 1e3 - ) - laps["Sector3SessionTimeMilliseconds"] = sector3_session_time_in_milliseconds.astype("int64") - - lap_time_in_milliseconds = laps["LapTime"].fillna(Timedelta(milliseconds=0)).dt.total_seconds() * 1e3 - laps["LapTimeMilliseconds"] = lap_time_in_milliseconds.astype("int64") - laps["LapEndTimeMilliseconds"] = laps["LapStartTimeMilliseconds"] + laps["LapTimeMilliseconds"] - - pit_in_time_in_milliseconds = laps.loc[laps["PitInTime"].notna(), "PitInTime"].dt.total_seconds() * 1e3 - laps.loc[laps["PitInTime"].notna(), "PitInTimeMilliseconds"] = pit_in_time_in_milliseconds.astype("int64") - - pit_out_time_in_milliseconds = laps.loc[laps["PitOutTime"].notna(), "PitOutTime"].dt.total_seconds() * 1e3 - laps.loc[laps["PitOutTime"].notna(), "PitOutTimeMilliseconds"] = pit_out_time_in_milliseconds.astype("int64") - - laps["S1DiffToCarAhead"] = ( - laps.sort_values(by=["Sector1SessionTimeMilliseconds"], ascending=[True]) - .groupby("LapNumber")["Sector1SessionTimeMilliseconds"] - .diff() - ) - laps["S2DiffToCarAhead"] = ( - laps.sort_values(by=["Sector2SessionTimeMilliseconds"], ascending=[True]) - .groupby("LapNumber")["Sector2SessionTimeMilliseconds"] - .diff() - ) - laps["S3DiffToCarAhead"] = ( - laps.sort_values(by=["Sector3SessionTimeMilliseconds"], ascending=[True]) - .groupby("LapNumber")["Sector3SessionTimeMilliseconds"] - .diff() - ) - - laps["LastLapTimeMilliseconds"] = laps.groupby("DriverNumber")["LapTimeMilliseconds"].shift(1) - laps["FastestLapTimeMillisecondsSoFar"] = laps.groupby("DriverNumber")["LastLapTimeMilliseconds"].cummin() - - self._laps = laps - - self.update_loading(5) - - return self - - def merge_pos_and_laps(self) -> Self: - df = self.processed_pos_data.copy() - ts_df = df[["SessionTimeTick", "SessionTimeMilliseconds"]].drop_duplicates(keep="first").copy() - laps_df = self.laps.copy() - - for record in laps_df.itertuples(): - laps_df.loc[ - (laps_df["LapNumber"] == record.LapNumber) & (laps_df["DriverNumber"] == record.DriverNumber), - "SessionTimeTick", - ] = ts_df.loc[ts_df["SessionTimeMilliseconds"] <= record.LapStartTimeMilliseconds, "SessionTimeTick"].max() - - laps_df.loc[laps_df["LapNumber"] == 1.0, "SessionTimeTick"] = 1 - laps_df = laps_df.dropna(subset=["SessionTimeTick"]) - laps_df["SessionTimeTick"] = laps_df["SessionTimeTick"].astype("int64") - - lap_n_tick_df = laps_df[["DriverNumber", "LapNumber", "SessionTimeTick"]] - - # Merge once to get he LapNumber and fill it for all SessionTimeTicks - combined_df = df.merge(lap_n_tick_df, on=["DriverNumber", "SessionTimeTick"], how="left") - combined_df["LapNumber"] = combined_df.groupby("DriverNumber")["LapNumber"].ffill() - - # Merge second time with full laps_df to get full data per SessionTimeTick - combined_df = combined_df.merge(laps_df, on=["DriverNumber", "LapNumber"], how="left") - combined_df = combined_df.rename( - columns={ - "Time_x": "Time", - "Time_y": "TimeLap", - "SessionTimeTick_x": "SessionTimeTick", - }, - ) - combined_df = combined_df.drop(columns=["SessionTimeTick_y"]) - - self.processed_pos_data = combined_df - - self.update_loading(5) - - return self - - def compute_lap_completion(self) -> Self: - df = self.processed_pos_data.copy() - - df["LapStartTimeMilliseconds"] = df.groupby("DriverNumber")["LapStartTimeMilliseconds"].ffill() - df["LapEndTimeMilliseconds"] = df.groupby("DriverNumber")["LapEndTimeMilliseconds"].ffill() - - df.loc[ - (df["LapNumber"] == self.total_laps) & (df["SessionTimeMilliseconds"] > df["LapEndTimeMilliseconds"]), - "LapNumber", - ] = self.total_laps + 1 - - df["ElapsedTimeSinceStartOfLapMilliseconds"] = df["SessionTimeMilliseconds"] - df["LapStartTimeMilliseconds"] - df["LapPercentageCompletion"] = df["ElapsedTimeSinceStartOfLapMilliseconds"] / df["LapTimeMilliseconds"] - df["LapPercentageCompletion"] = df["LapPercentageCompletion"].replace([np.inf, -np.inf], 0) - df.loc[df["LapNumber"] > self.total_laps, "LapPercentageCompletion"] = 0 - df["LapsCompletion"] = (df["LapNumber"] - 1) + df["LapPercentageCompletion"] - - self.processed_pos_data = df - - self.update_loading(5) - - return self - - def compute_is_dnf(self) -> Self: - df = self.processed_pos_data.copy() - - df.loc[df["Position"].notna(), "IsDNF"] = False - df.loc[df["Position"].isna(), "IsDNF"] = True - - self.processed_pos_data = df - - self.update_loading(5) - - return self - - def compute_is_finished(self) -> Self: - df = self.processed_pos_data.copy() - df.loc[df["LapsCompletion"] == self.total_laps, "IsFinished"] = True - df.loc[df["IsFinished"].isna(), "IsFinished"] = False - df.loc[df["IsFinished"], "IsDNF"] = False - - self.processed_pos_data = df - - self.update_loading(5) - - return self - - def compute_position_index(self) -> Self: - df = self.processed_pos_data.copy() - laps_df = self.laps.copy() - end_of_race = laps_df.loc[laps_df["LapNumber"] == self.total_laps, "LapEndTimeMilliseconds"].min() - - df["PositionIndex"] = ( - df.sort_values(by=["SessionTimeTick", "LapsCompletion"], ascending=[True, False]) - .groupby("SessionTimeTick") - .cumcount() - .add(1) - - 1 - ) - df.loc[df["SessionTimeMilliseconds"] >= end_of_race, "PositionIndex"] = pd.NA - df["PositionIndex"] = df.groupby("DriverNumber")["PositionIndex"].ffill().astype("int64") - - self.processed_pos_data = df - - self.update_loading(5) - - return self - - def compute_fastest_lap(self) -> Self: - df = self.processed_pos_data.copy() - - df["FastestLapTimeMilliseconds"] = df.sort_values( - by=["SessionTimeTick", "LapsCompletion"], - ascending=[True, False], - )["FastestLapTimeMillisecondsSoFar"].cummin() - df.loc[df["FastestLapTimeMillisecondsSoFar"] == df["FastestLapTimeMilliseconds"], "HasFastestLap"] = True - df.loc[df["HasFastestLap"].isna(), "HasFastestLap"] = False - df["HasFastestLap"] = df.groupby("DriverNumber")["HasFastestLap"].ffill() - - self.processed_pos_data = df - - self.update_loading(5) - - return self - - def compute_diff_to_car_in_front(self) -> Self: - df = self.processed_pos_data.copy() - df.loc[ - (df["SessionTimeMilliseconds"] >= df["Sector1SessionTimeMilliseconds"]), - "DiffToCarInFront", - ] = df["S1DiffToCarAhead"] - df.loc[ - (df["SessionTimeMilliseconds"] >= df["Sector2SessionTimeMilliseconds"]), - "DiffToCarInFront", - ] = df["S2DiffToCarAhead"] - df.loc[ - (df["SessionTimeMilliseconds"] >= df["Sector3SessionTimeMilliseconds"]), - "DiffToCarInFront", - ] = df["S3DiffToCarAhead"] - df.loc[df["PositionIndex"] == 0, "DiffToCarInFront"] = 0 - df["DiffToCarInFront"] = df.groupby("DriverNumber")["DiffToCarInFront"].ffill() - df["DiffToCarInFront"] = round(df["DiffToCarInFront"] / 1000, 3) - - self.processed_pos_data = df - - self.update_loading(5) - - return self - - def compute_diff_to_leader(self) -> Self: - df = self.processed_pos_data.copy() - df["DiffToLeader"] = ( - df.sort_values(by=["SessionTimeTick", "LapsCompletion"], ascending=[True, False]) - .groupby(["SessionTimeTick"])["DiffToCarInFront"] - .cumsum() - ) - df["DiffToLeader"] = round(df["DiffToLeader"], 3) - - self.processed_pos_data = df - - self.update_loading(5) - - return self - - def compute_in_pit(self) -> Self: - df = self.processed_pos_data.copy() - - df.loc[ - ( - (df["PitInTimeMilliseconds"].notna() & (df["PitInTimeMilliseconds"] <= df["SessionTimeMilliseconds"])) - | ( - df["PitOutTimeMilliseconds"].notna() - & (df["PitOutTimeMilliseconds"] >= df["SessionTimeMilliseconds"]) - ) - ), - "InPit", - ] = True - - df["InPit"] = df["InPit"].astype("boolean").fillna(False) - - self.processed_pos_data = df - - self.update_loading(5) - - return self - - def compute_tire_compound(self) -> Self: - df = self.processed_pos_data.copy() - - df["Compound"] = df["Compound"].str[0].astype("string") - df["Compound"] = df.groupby("DriverNumber")["Compound"].ffill() - df["SCompoundColor"] = LVecBase4f(1, 0, 0, 0.8) - df["MCompoundColor"] = LVecBase4f(1, 1, 0, 0.8) - df["HCompoundColor"] = LVecBase4f(1, 1, 1, 0.8) - df["ICompoundColor"] = LVecBase4f(0, 1, 0, 0.8) - df["WCompoundColor"] = LVecBase4f(0, 0, 1, 0.8) - - df.loc[df["Compound"] == "S", "CompoundColor"] = df.loc[df["Compound"] == "S", "SCompoundColor"] - df.loc[df["Compound"] == "M", "CompoundColor"] = df.loc[df["Compound"] == "M", "MCompoundColor"] - df.loc[df["Compound"] == "H", "CompoundColor"] = df.loc[df["Compound"] == "H", "HCompoundColor"] - df.loc[df["Compound"] == "I", "CompoundColor"] = df.loc[df["Compound"] == "I", "ICompoundColor"] - df.loc[df["Compound"] == "W", "CompoundColor"] = df.loc[df["Compound"] == "W", "WCompoundColor"] - - self.processed_pos_data = df.drop( - columns=["SCompoundColor", "MCompoundColor", "HCompoundColor", "ICompoundColor", "WCompoundColor"], - ) - - self.update_loading(5) - - return self - - def render_wait_bar(self) -> None: - width = 400 - height = 200 - self.loading_frame = DirectFrame( - parent=self.parent, - frameColor=(0.20, 0.20, 0.20, 0.7), - frameSize=(0, width, 0, -height), - pos=Point3((self.window_width / 2) - (width / 2), 0, -((self.window_height / 2) - (height / 2))), - ) - - self.loading_text = OnscreenText( - parent=self.loading_frame, - pos=(width / 2, -(height / 2)), - scale=width / 10, - fg=(1, 1, 1, 0.8), - font=self.text_font, - text="Loading ...", - ) - - self.wait_bar = DirectWaitBar( - parent=self.loading_frame, - text="WaitBar", - value=0, - range=100, - barColor=(0, 1, 0, 0.7), - frameSize=(0, width - 20, 0, -10), - pos=Point3(10, 0, -(height - 20)), - ) - - def update_loading(self, value: int) -> None: - self.wait_bar["value"] += value - - def delete_loading(self) -> None: - self.wait_bar.destroy() - self.loading_text.destroy() - self.loading_frame.destroy() - - def load_data(self) -> None: - self.render_wait_bar() - self.task_manager.add(self.extract, "extractData", taskChain="loadingData") - - def extract(self, task: Task) -> Any: - self.session.load() - self.update_loading(10) - - ( - self.process_fastest_lap() - .combine_position_data() - .remove_records_before_session_start_time() - .normalize_position_data() - .add_session_time_in_milliseconds() - .add_session_time_tick() - .process_laps() - .merge_pos_and_laps() - .compute_lap_completion() - .compute_is_dnf() - .compute_is_finished() - .compute_position_index() - .compute_fastest_lap() - .compute_diff_to_car_in_front() - .compute_diff_to_leader() - .compute_in_pit() - .compute_tire_compound() - ) - - self.delete_loading() - messenger.send("sessionSelected") - - return task.done diff --git a/src/f1p/services/data_extractor/service.py b/src/f1p/services/data_extractor/service.py new file mode 100644 index 0000000..0f3c757 --- /dev/null +++ b/src/f1p/services/data_extractor/service.py @@ -0,0 +1,704 @@ +import math +from pathlib import Path +from typing import Any, Self + +import fastf1 +import numpy as np +import pandas as pd +from direct.gui.DirectFrame import DirectFrame +from direct.gui.DirectWaitBar import DirectWaitBar +from direct.gui.OnscreenText import OnscreenText +from direct.showbase.DirectObject import DirectObject +from direct.showbase.MessengerGlobal import messenger +from direct.task.Task import Task, TaskManager +from fastf1.core import Lap, Laps, Session, Telemetry +from fastf1.events import Event, EventSchedule +from fastf1.mvapi import CircuitInfo +from panda3d.core import LVecBase4f, NodePath, Point3, StaticTextFont, deg2Rad +from pandas import DataFrame, Series, Timedelta + +from f1p.utils.geometry import center_pos_data, find_center, resize_pos_data + + +class DataExtractorService(DirectObject): + year: int + event_name: str + session_id: str + cache_path: Path = Path(__file__).parent.parent.parent.parent.parent / ".fastf1-cache" + + def __init__( + self, + parent: NodePath, + task_manager: TaskManager, + window_width: int, + window_height: int, + text_font: StaticTextFont, + ): + self.parent = parent + self.task_manager = task_manager + self.window_width = window_width + self.window_height = window_height + self.text_font = text_font + + self._event_schedule: EventSchedule | None = None + self._event: Event | None = None + self._session: Session | None = None + self._session_status: DataFrame | None = None + self._session_start_time: Timedelta | None = None + self._session_end_time: Timedelta | None = None + self._pos_data: dict[str, Telemetry] | None = None + self._circuit_info: CircuitInfo | None = None + self._track_status: DataFrame | None = None + self._track_status_colors: DataFrame | None = None + self._green_flag_track_status: DataFrame | None = None + self._track_statuses: DataFrame | None = None + self._total_laps: int | None = None + self._laps: Laps | None = None + self._fastest_lap: Lap | None = None + self.fastest_lap_telemetry: DataFrame | None = None + self.map_center_coordinate: tuple[float, float, float] | None = None + + self.loading_frame: DirectFrame | None = None + self.loading_text: OnscreenText | None = None + self.wait_bar: DirectWaitBar | None = None + + self.processed_pos_data: DataFrame | None = None + + self.accept("loadData", self.load_data) + + if not self.cache_path.exists(): + self.cache_path.mkdir(parents=True) + + fastf1.Cache.enable_cache(str(self.cache_path)) + + @property + def event_schedule(self) -> EventSchedule: + if self._event_schedule is None: + self._event_schedule = fastf1.get_event_schedule(self.year) + + return self._event_schedule + + @property + def event(self) -> Event: + if self._event is None: + self._event = fastf1.get_event(self.year, self.event_name) + + return self._event + + @property + def session(self) -> Session: + if self._session is None: + self._session = fastf1.get_session(self.year, self.event_name, self.session_id) + + return self._session + + @property + def session_status(self) -> DataFrame: + if self._session_status is None: + self._session_status = self.session.session_status + + return self._session_status + + @property + def total_laps(self) -> int: + if self._total_laps is None: + self._total_laps = int(self.session.total_laps) + + return self._total_laps + + @property + def pos_data(self) -> dict[str, Telemetry]: + if self._pos_data is None: + self._pos_data = self.session.pos_data + + return self._pos_data + + @property + def laps(self) -> Laps: + if self._laps is None: + self._laps = self.session.laps + + return self._laps + + def get_current_lap_number(self, session_time_tick: int) -> int: + df = self.processed_pos_data + df = df[df["SessionTimeTick"] == session_time_tick] + + return int(math.ceil(df["LapsCompletion"].max())) + + @property + def fastest_lap(self) -> Lap: + if self._fastest_lap is None: + self._fastest_lap = self.laps.pick_fastest() + + return self._fastest_lap + + @property + def circuit_info(self) -> CircuitInfo: + if self._circuit_info is None: + self._circuit_info = self.session.get_circuit_info() + + return self._circuit_info + + @property + def track_status(self) -> DataFrame: + if self._track_status is None: + self._track_status = self.session.track_status + + return self._track_status + + @property + def track_status_colors(self) -> DataFrame: + if self._track_status_colors is None: + self._track_status_colors = DataFrame( + data={ + "Status": [1, 2, 4, 5, 6, 7], + "Label": [ + "Green Flag", + "Yellow Flag", + "Safety Car", + "Red Flag", + "VSC Deployed", + "VSC Ending", + ], + "Color": [ + LVecBase4f(0, 1, 0, 0.8), + LVecBase4f(1, 1, 0, 0.8), + LVecBase4f(1, 1, 0, 0.8), + LVecBase4f(1, 0, 0, 0.8), + LVecBase4f(1, 0.64, 0, 0.8), + LVecBase4f(1, 0.64, 0, 0.8), + ], + "TextColor": [ + LVecBase4f(0, 0, 0, 0.8), + LVecBase4f(0, 0, 0, 0.8), + LVecBase4f(0, 0, 0, 0.8), + LVecBase4f(1, 1, 1, 0.8), + LVecBase4f(0, 0, 0, 0.8), + LVecBase4f(0, 0, 0, 0.8), + ], + }, + ) + + return self._track_status_colors + + @property + def green_flag_track_status(self) -> DataFrame: + if self._green_flag_track_status is None: + ts_colors_df = self.track_status_colors + self._green_flag_track_status = ts_colors_df[ts_colors_df["Status"] == 1] + + return self._green_flag_track_status + + @property + def green_flag_track_status_label(self) -> str: + return self.green_flag_track_status["Label"].iloc[0] + + @property + def green_flag_track_status_color(self) -> LVecBase4f: + return self.green_flag_track_status["Color"].iloc[0] + + @property + def green_flag_track_status_text_color(self) -> LVecBase4f: + return self.green_flag_track_status["TextColor"].iloc[0] + + @property + def map_rotation(self) -> float: + return deg2Rad(self.circuit_info.rotation) + + @property + def session_start_time(self) -> Timedelta: + if self._session_start_time is None: + self._session_start_time = self.session_status[self.session_status["Status"] == "Started"].iloc[0]["Time"] + + return self._session_start_time + + @property + def session_start_time_milliseconds(self) -> int: + return int(self.session_start_time.total_seconds() * 1e3) + + @property + def session_end_time(self) -> Timedelta: + if self._session_end_time is None: + self._session_end_time = self.session_status[self.session_status["Status"] == "Finalised"].iloc[0]["Time"] + + return self._session_end_time + + @property + def session_end_time_milliseconds(self) -> int: + return int(self.session_end_time.total_seconds() * 1e3) + + @property + def session_ticks(self) -> int: + df = self.processed_pos_data.copy() + + df = df[["DriverNumber", "SessionTimeTick"]] + df = df.groupby("DriverNumber")["SessionTimeTick"].count() + + return df.min() + + def process_track_statuses(self, width: int) -> None: + df = self.processed_pos_data.copy() + df = df[["SessionTimeTick", "SessionTime"]].drop_duplicates(keep="first").copy() + + pixel_per_tick = width / self.session_ticks + + df.loc[:, "Pixel"] = df.loc[:, "SessionTimeTick"] * pixel_per_tick + + ts_df = self.track_status.copy() + ts_df = ts_df[ts_df["Time"] >= self.session_start_time] + ts_df = ts_df[ts_df["Time"] <= self.session_end_time] + + ts_df["EndTime"] = ts_df["Time"].shift(-1).fillna(self.session_end_time) + + for record in ts_df.itertuples(): + ts_df.loc[ts_df["Time"] == record.Time, "SessionTimeTick"] = df.loc[ + df["SessionTime"] <= record.Time, + "SessionTimeTick", + ].max() + ts_df.loc[ts_df["Time"] == record.Time, "SessionTimeTickEnd"] = df.loc[ + df["SessionTime"] <= record.EndTime, + "SessionTimeTick", + ].max() + + ts_df["SessionTimeTick"] = ts_df["SessionTimeTick"].astype("int64") + ts_df["SessionTimeTickEnd"] = ts_df["SessionTimeTickEnd"].astype("int64") + + ts_df = ts_df.merge(df, on="SessionTimeTick", how="left") + ts_df = ts_df.rename(columns={"Pixel": "PixelStart"}).drop(columns="SessionTime") + ts_df = ts_df.merge(df, left_on="SessionTimeTickEnd", right_on="SessionTimeTick", how="left").rename( + columns={"SessionTimeTick_x": "SessionTimeTick"}, + ) + ts_df = ts_df.rename(columns={"Pixel": "PixelEnd"}).drop(columns=["SessionTime", "SessionTimeTick_y"]) + ts_df = ts_df.drop(columns=["Time", "EndTime"]).reset_index() + + ts_df["Width"] = ts_df["PixelEnd"] - ts_df["PixelStart"] + ts_df["Status"] = ts_df["Status"].astype("int64") + + self._track_statuses = ts_df.merge(self.track_status_colors, on="Status", how="left") + + @property + def track_statuses(self) -> DataFrame: + if self._track_statuses is None: + raise ValueError("Track statuses not processed.") + + return self._track_statuses + + def get_current_track_status(self, session_time_tick: int) -> Series | None: + ts_df = self.track_statuses + + ts_df = ts_df[ts_df["SessionTimeTick"] <= session_time_tick] + ts_df = ts_df[ts_df["SessionTimeTickEnd"] >= session_time_tick] + + if ts_df.empty: + return None + + return ts_df.iloc[0] + + def process_fastest_lap(self) -> Self: + pos_data = self.fastest_lap.get_pos_data() + resized_pos_data_df = resize_pos_data(self.map_rotation, pos_data) + + self.map_center_coordinate = find_center(resized_pos_data_df) + + self.fastest_lap_telemetry = center_pos_data(self.map_center_coordinate, resized_pos_data_df) + + self.update_loading(5) + + return self + + def combine_position_data(self) -> Self: + drivers_pos_data = [] + for driver_number, pos_data in self.pos_data.items(): + pos_data["DriverNumber"] = driver_number + drivers_pos_data.append(pos_data) + + self.processed_pos_data = pd.concat(drivers_pos_data, ignore_index=True) + + self.update_loading(5) + + return self + + def remove_records_before_session_start_time(self) -> Self: + self.processed_pos_data = self.processed_pos_data[ + self.processed_pos_data["SessionTime"] >= self.session_start_time + ] + + self.update_loading(5) + + return self + + def normalize_position_data(self) -> Self: + df = self.processed_pos_data.copy() + + resized_pos_data_df = resize_pos_data(self.map_rotation, df) + self.processed_pos_data = center_pos_data(self.map_center_coordinate, resized_pos_data_df) + + self.update_loading(5) + + return self + + def add_session_time_in_milliseconds(self) -> Self: + session_time_in_milliseconds = self.processed_pos_data["SessionTime"].dt.total_seconds() * 1e3 + + self.processed_pos_data["SessionTimeMilliseconds"] = session_time_in_milliseconds.astype("int64") + + self.update_loading(5) + + return self + + def add_session_time_tick(self) -> Self: + df = self.processed_pos_data.copy() + + df["SessionTimeTick"] = df.groupby("DriverNumber").cumcount().add(1) + + self.processed_pos_data = df + + self.update_loading(5) + + return self + + def process_laps(self) -> Self: + laps = self.laps.copy() + + laps.loc[laps["Sector1SessionTime"].isna(), "Sector1SessionTime"] = ( + laps.loc[laps["Sector1SessionTime"].isna(), "LapStartTime"] + + laps.loc[laps["Sector1SessionTime"].isna(), "Sector1Time"] + ) + laps.loc[laps["Sector2SessionTime"].isna(), "Sector2SessionTime"] = ( + laps.loc[laps["Sector2SessionTime"].isna(), "Sector1SessionTime"] + + laps.loc[laps["Sector2SessionTime"].isna(), "Sector2Time"] + ) + laps.loc[laps["Sector3SessionTime"].isna(), "Sector3SessionTime"] = ( + laps.loc[laps["Sector3SessionTime"].isna(), "Sector2SessionTime"] + + laps.loc[laps["Sector3SessionTime"].isna(), "Sector3Time"] + ) + + lap_start_time_in_milliseconds = laps["LapStartTime"].fillna(Timedelta(milliseconds=0)).dt.total_seconds() * 1e3 + laps["LapStartTimeMilliseconds"] = lap_start_time_in_milliseconds.astype("int64") + sector1_session_time_in_milliseconds = ( + laps["Sector1SessionTime"].fillna(Timedelta(milliseconds=0)).dt.total_seconds() * 1e3 + ) + laps["Sector1SessionTimeMilliseconds"] = sector1_session_time_in_milliseconds.astype("int64") + sector2_session_time_in_milliseconds = ( + laps["Sector2SessionTime"].fillna(Timedelta(milliseconds=0)).dt.total_seconds() * 1e3 + ) + laps["Sector2SessionTimeMilliseconds"] = sector2_session_time_in_milliseconds.astype("int64") + sector3_session_time_in_milliseconds = ( + laps["Sector3SessionTime"].fillna(Timedelta(milliseconds=0)).dt.total_seconds() * 1e3 + ) + laps["Sector3SessionTimeMilliseconds"] = sector3_session_time_in_milliseconds.astype("int64") + + lap_time_in_milliseconds = laps["LapTime"].fillna(Timedelta(milliseconds=0)).dt.total_seconds() * 1e3 + laps["LapTimeMilliseconds"] = lap_time_in_milliseconds.astype("int64") + laps["LapEndTimeMilliseconds"] = laps["LapStartTimeMilliseconds"] + laps["LapTimeMilliseconds"] + + pit_in_time_in_milliseconds = laps.loc[laps["PitInTime"].notna(), "PitInTime"].dt.total_seconds() * 1e3 + laps.loc[laps["PitInTime"].notna(), "PitInTimeMilliseconds"] = pit_in_time_in_milliseconds.astype("int64") + + pit_out_time_in_milliseconds = laps.loc[laps["PitOutTime"].notna(), "PitOutTime"].dt.total_seconds() * 1e3 + laps.loc[laps["PitOutTime"].notna(), "PitOutTimeMilliseconds"] = pit_out_time_in_milliseconds.astype("int64") + + laps["S1DiffToCarAhead"] = ( + laps.sort_values(by=["Sector1SessionTimeMilliseconds"], ascending=[True]) + .groupby("LapNumber")["Sector1SessionTimeMilliseconds"] + .diff() + ) + laps["S2DiffToCarAhead"] = ( + laps.sort_values(by=["Sector2SessionTimeMilliseconds"], ascending=[True]) + .groupby("LapNumber")["Sector2SessionTimeMilliseconds"] + .diff() + ) + laps["S3DiffToCarAhead"] = ( + laps.sort_values(by=["Sector3SessionTimeMilliseconds"], ascending=[True]) + .groupby("LapNumber")["Sector3SessionTimeMilliseconds"] + .diff() + ) + + laps["LastLapTimeMilliseconds"] = laps.groupby("DriverNumber")["LapTimeMilliseconds"].shift(1) + laps["FastestLapTimeMillisecondsSoFar"] = laps.groupby("DriverNumber")["LastLapTimeMilliseconds"].cummin() + + self._laps = laps + + self.update_loading(5) + + return self + + def merge_pos_and_laps(self) -> Self: + df = self.processed_pos_data.copy() + ts_df = df[["SessionTimeTick", "SessionTimeMilliseconds"]].drop_duplicates(keep="first").copy() + laps_df = self.laps.copy() + + for record in laps_df.itertuples(): + laps_df.loc[ + (laps_df["LapNumber"] == record.LapNumber) & (laps_df["DriverNumber"] == record.DriverNumber), + "SessionTimeTick", + ] = ts_df.loc[ts_df["SessionTimeMilliseconds"] <= record.LapStartTimeMilliseconds, "SessionTimeTick"].max() + + laps_df.loc[laps_df["LapNumber"] == 1.0, "SessionTimeTick"] = 1 + laps_df = laps_df.dropna(subset=["SessionTimeTick"]) + laps_df["SessionTimeTick"] = laps_df["SessionTimeTick"].astype("int64") + + lap_n_tick_df = laps_df[["DriverNumber", "LapNumber", "SessionTimeTick"]] + + # Merge once to get he LapNumber and fill it for all SessionTimeTicks + combined_df = df.merge(lap_n_tick_df, on=["DriverNumber", "SessionTimeTick"], how="left") + combined_df["LapNumber"] = combined_df.groupby("DriverNumber")["LapNumber"].ffill() + + # Merge second time with full laps_df to get full data per SessionTimeTick + combined_df = combined_df.merge(laps_df, on=["DriverNumber", "LapNumber"], how="left") + combined_df = combined_df.rename( + columns={ + "Time_x": "Time", + "Time_y": "TimeLap", + "SessionTimeTick_x": "SessionTimeTick", + }, + ) + combined_df = combined_df.drop(columns=["SessionTimeTick_y"]) + + self.processed_pos_data = combined_df + + self.update_loading(10) + + return self + + def compute_lap_completion(self) -> Self: + df = self.processed_pos_data.copy() + + df["LapStartTimeMilliseconds"] = df.groupby("DriverNumber")["LapStartTimeMilliseconds"].ffill() + df["LapEndTimeMilliseconds"] = df.groupby("DriverNumber")["LapEndTimeMilliseconds"].ffill() + + df.loc[ + (df["LapNumber"] == self.total_laps) & (df["SessionTimeMilliseconds"] > df["LapEndTimeMilliseconds"]), + "LapNumber", + ] = self.total_laps + 1 + + df["ElapsedTimeSinceStartOfLapMilliseconds"] = df["SessionTimeMilliseconds"] - df["LapStartTimeMilliseconds"] + df["LapPercentageCompletion"] = df["ElapsedTimeSinceStartOfLapMilliseconds"] / df["LapTimeMilliseconds"] + df["LapPercentageCompletion"] = df["LapPercentageCompletion"].replace([np.inf, -np.inf], 0) + df.loc[df["LapNumber"] > self.total_laps, "LapPercentageCompletion"] = 0 + df["LapsCompletion"] = (df["LapNumber"] - 1) + df["LapPercentageCompletion"] + + self.processed_pos_data = df + + self.update_loading(5) + + return self + + def compute_is_dnf(self) -> Self: + df = self.processed_pos_data.copy() + + df.loc[df["Position"].notna(), "IsDNF"] = False + df.loc[df["Position"].isna(), "IsDNF"] = True + + self.processed_pos_data = df + + self.update_loading(5) + + return self + + def compute_is_finished(self) -> Self: + df = self.processed_pos_data.copy() + df.loc[df["LapsCompletion"] == self.total_laps, "IsFinished"] = True + df.loc[df["IsFinished"].isna(), "IsFinished"] = False + df.loc[df["IsFinished"], "IsDNF"] = False + + self.processed_pos_data = df + + self.update_loading(5) + + return self + + def compute_position_index(self) -> Self: + df = self.processed_pos_data.copy() + laps_df = self.laps.copy() + end_of_race = laps_df.loc[laps_df["LapNumber"] == self.total_laps, "LapEndTimeMilliseconds"].min() + + df["PositionIndex"] = ( + df.sort_values(by=["SessionTimeTick", "LapsCompletion"], ascending=[True, False]) + .groupby("SessionTimeTick") + .cumcount() + .add(1) + - 1 + ) + df.loc[df["SessionTimeMilliseconds"] >= end_of_race, "PositionIndex"] = pd.NA + df["PositionIndex"] = df.groupby("DriverNumber")["PositionIndex"].ffill().astype("int64") + + self.processed_pos_data = df + + self.update_loading(5) + + return self + + def compute_fastest_lap(self) -> Self: + df = self.processed_pos_data.copy() + + df["FastestLapTimeMilliseconds"] = df.sort_values( + by=["SessionTimeTick", "LapsCompletion"], + ascending=[True, False], + )["FastestLapTimeMillisecondsSoFar"].cummin() + df.loc[df["FastestLapTimeMillisecondsSoFar"] == df["FastestLapTimeMilliseconds"], "HasFastestLap"] = True + df.loc[df["HasFastestLap"].isna(), "HasFastestLap"] = False + df["HasFastestLap"] = df.groupby("DriverNumber")["HasFastestLap"].ffill() + + self.processed_pos_data = df + + self.update_loading(5) + + return self + + def compute_diff_to_car_in_front(self) -> Self: + df = self.processed_pos_data.copy() + df.loc[ + (df["SessionTimeMilliseconds"] >= df["Sector1SessionTimeMilliseconds"]), + "DiffToCarInFront", + ] = df["S1DiffToCarAhead"] + df.loc[ + (df["SessionTimeMilliseconds"] >= df["Sector2SessionTimeMilliseconds"]), + "DiffToCarInFront", + ] = df["S2DiffToCarAhead"] + df.loc[ + (df["SessionTimeMilliseconds"] >= df["Sector3SessionTimeMilliseconds"]), + "DiffToCarInFront", + ] = df["S3DiffToCarAhead"] + df.loc[df["PositionIndex"] == 0, "DiffToCarInFront"] = 0 + df["DiffToCarInFront"] = df.groupby("DriverNumber")["DiffToCarInFront"].ffill() + df["DiffToCarInFront"] = round(df["DiffToCarInFront"] / 1000, 3) + + self.processed_pos_data = df + + self.update_loading(5) + + return self + + def compute_diff_to_leader(self) -> Self: + df = self.processed_pos_data.copy() + df["DiffToLeader"] = ( + df.sort_values(by=["SessionTimeTick", "LapsCompletion"], ascending=[True, False]) + .groupby(["SessionTimeTick"])["DiffToCarInFront"] + .cumsum() + ) + df["DiffToLeader"] = round(df["DiffToLeader"], 3) + + self.processed_pos_data = df + + self.update_loading(5) + + return self + + def compute_in_pit(self) -> Self: + df = self.processed_pos_data.copy() + + df.loc[ + ( + (df["PitInTimeMilliseconds"].notna() & (df["PitInTimeMilliseconds"] <= df["SessionTimeMilliseconds"])) + | ( + df["PitOutTimeMilliseconds"].notna() + & (df["PitOutTimeMilliseconds"] >= df["SessionTimeMilliseconds"]) + ) + ), + "InPit", + ] = True + + df["InPit"] = df["InPit"].astype("boolean").fillna(False) + + self.processed_pos_data = df + + self.update_loading(5) + + return self + + def compute_tire_compound(self) -> Self: + df = self.processed_pos_data.copy() + + df["Compound"] = df["Compound"].str[0].astype("string") + df["Compound"] = df.groupby("DriverNumber")["Compound"].ffill() + df["SCompoundColor"] = LVecBase4f(1, 0, 0, 0.8) + df["MCompoundColor"] = LVecBase4f(1, 1, 0, 0.8) + df["HCompoundColor"] = LVecBase4f(1, 1, 1, 0.8) + df["ICompoundColor"] = LVecBase4f(0, 1, 0, 0.8) + df["WCompoundColor"] = LVecBase4f(0, 0, 1, 0.8) + + df.loc[df["Compound"] == "S", "CompoundColor"] = df.loc[df["Compound"] == "S", "SCompoundColor"] + df.loc[df["Compound"] == "M", "CompoundColor"] = df.loc[df["Compound"] == "M", "MCompoundColor"] + df.loc[df["Compound"] == "H", "CompoundColor"] = df.loc[df["Compound"] == "H", "HCompoundColor"] + df.loc[df["Compound"] == "I", "CompoundColor"] = df.loc[df["Compound"] == "I", "ICompoundColor"] + df.loc[df["Compound"] == "W", "CompoundColor"] = df.loc[df["Compound"] == "W", "WCompoundColor"] + + self.processed_pos_data = df.drop( + columns=["SCompoundColor", "MCompoundColor", "HCompoundColor", "ICompoundColor", "WCompoundColor"], + ) + + self.update_loading(5) + + return self + + def render_wait_bar(self) -> None: + width = 400 + height = 200 + self.loading_frame = DirectFrame( + parent=self.parent, + frameColor=(0.20, 0.20, 0.20, 0.7), + frameSize=(0, width, 0, -height), + pos=Point3((self.window_width / 2) - (width / 2), 0, -((self.window_height / 2) - (height / 2))), + ) + + self.loading_text = OnscreenText( + parent=self.loading_frame, + pos=(width / 2, -(height / 2)), + scale=width / 10, + fg=(1, 1, 1, 0.8), + font=self.text_font, + text="Loading ...", + ) + + self.wait_bar = DirectWaitBar( + parent=self.loading_frame, + text="WaitBar", + value=0, + range=100, + barColor=(0, 1, 0, 0.7), + frameSize=(0, width - 20, 0, -10), + pos=Point3(10, 0, -(height - 20)), + ) + + def update_loading(self, value: int) -> None: + self.wait_bar["value"] += value + + def delete_loading(self) -> None: + self.wait_bar.destroy() + self.loading_text.destroy() + self.loading_frame.destroy() + + def load_data(self) -> None: + self.render_wait_bar() + self.task_manager.add(self.extract, "extractData", taskChain="loadingData") + + def extract(self, task: Task) -> Any: + self.session.load() + self.update_loading(10) + + ( + self.process_fastest_lap() + .combine_position_data() + .remove_records_before_session_start_time() + .normalize_position_data() + .add_session_time_in_milliseconds() + .add_session_time_tick() + .process_laps() + .merge_pos_and_laps() + .compute_lap_completion() + .compute_is_dnf() + .compute_is_finished() + .compute_position_index() + .compute_fastest_lap() + .compute_diff_to_car_in_front() + .compute_diff_to_leader() + .compute_in_pit() + .compute_tire_compound() + ) + + self.delete_loading() + messenger.send("sessionSelected") + + return task.done diff --git a/src/f1p/ui/components/leaderboard/__init__.py b/src/f1p/ui/components/leaderboard/__init__.py index e3de613..e69de29 100644 --- a/src/f1p/ui/components/leaderboard/__init__.py +++ b/src/f1p/ui/components/leaderboard/__init__.py @@ -1,327 +0,0 @@ -from typing import Any - -from direct.gui.DirectFrame import DirectFrame -from direct.gui.OnscreenImage import OnscreenImage -from direct.gui.OnscreenText import OnscreenText -from direct.showbase.DirectObject import DirectObject -from direct.task.Task import Task, TaskManager -from fastf1.core import Laps -from panda3d.core import Point3, StaticTextFont, TextNode, TransparencyAttrib - -from f1p.services.data_extractor import DataExtractorService -from f1p.ui.components.driver import Driver -from f1p.ui.components.gui.drop_down import BlackDropDown -from f1p.ui.components.leaderboard.processors import ( - IntervalLeaderboardProcessor, - LeaderboardProcessor, - LeaderLeaderboardProcessor, - TiresLeaderboardProcessor, -) -from f1p.ui.components.map import Map - - -class Leaderboard(DirectObject): - def __init__( - self, - pixel2d, - task_manager: TaskManager, - symbols_font: StaticTextFont, - text_font: StaticTextFont, - circuit_map: Map, - data_extractor: DataExtractorService, - ): - super().__init__() - - self.pixel2d = pixel2d - self.task_manager = task_manager - self.width = 215 - self._height: float | None = None - self.symbols_font = symbols_font - self.text_font = text_font - self.circuit_map = circuit_map - self.data_extractor = data_extractor - - self.accept("sessionSelected", self.render_task) - self.accept("updateLeaderboard", self.update) - - self.frame: DirectFrame | None = None - self.track_status_frame_top: DirectFrame | None = None - self.track_status_frame_left: DirectFrame | None = None - self.track_status_frame_bottom: DirectFrame | None = None - self.track_status_frame: DirectFrame | None = None - self.track_status: OnscreenText | None = None - self.f1_logo: OnscreenImage | None = None - self.lap_counter: OnscreenText | None = None - self.mode: str = "interval" - self.checkered_flags: list[OnscreenText] = [] - self.team_colors: list[DirectFrame] = [] - self.driver_abbreviations: list[OnscreenText] = [] - self.driver_times: list[OnscreenText] = [] - self.driver_tires: list[OnscreenText] = [] - self.has_fastest_lap: list[OnscreenText] = [] - - self.drivers: list[Driver] = self.circuit_map.drivers - - self._laps: Laps | None = None - self._total_laps: int | None = None - - @property - def height(self) -> float: - if self._height is None: - self._height = 130 + (len(self.circuit_map.drivers) * 23) - - return self._height - - @property - def total_laps(self) -> int: - if self._total_laps is None: - self._total_laps = self.data_extractor.session.total_laps - - return self._total_laps - - def render_frame(self) -> None: - self.frame = DirectFrame( - parent=self.pixel2d, - frameColor=(0.20, 0.20, 0.20, 0.7), - frameSize=(0, self.width, 0, -self.height), - pos=Point3(20, 0, -50), - ) - - def render_track_status_frame(self) -> None: - self.track_status_frame_top = DirectFrame( - parent=self.frame, - frameColor=self.data_extractor.green_flag_track_status_color, - frameSize=(0, self.width - 5, 0, -1), - pos=Point3(2, 0, -2), - ) - - self.track_status_frame_left = DirectFrame( - parent=self.frame, - frameColor=self.data_extractor.green_flag_track_status_color, - frameSize=(0, 1, 0, self.width - 5), - pos=Point3(2, 0, -self.width + 3), - ) - - def render_f1_logo(self) -> None: - self.f1_logo = OnscreenImage( - image="./src/f1p/ui/images/f1_logo.png", - pos=Point3(self.width / 2, 0, -27), - scale=self.width / 4, - parent=self.frame, - ) - self.f1_logo.setTransparency(TransparencyAttrib.MAlpha) - - def render_lap_counter(self) -> None: - lap_counter_background = DirectFrame( - parent=self.frame, - frameColor=(0.1, 0.1, 0.1, 0.7), - frameSize=(0, self.width, 0, 40), - pos=Point3(0, 0, -90), - ) - - self.lap_counter = OnscreenText( - parent=lap_counter_background, - pos=(self.width / 2, 14), - scale=self.width / 10, - fg=(1, 1, 1, 0.8), - font=self.text_font, - text=f"LAP 1/{self.total_laps}", - ) - - def render_track_status(self) -> None: - self.track_status_frame = DirectFrame( - parent=self.frame, - frameColor=self.data_extractor.green_flag_track_status_color, - frameSize=(0, self.width - 2, 0, 30), - pos=Point3(2, 0, -120), - ) - - self.track_status = OnscreenText( - parent=self.track_status_frame, - pos=(self.width / 2, 9), - scale=self.width / 11, - fg=self.data_extractor.green_flag_track_status_text_color, - font=self.text_font, - text=self.data_extractor.green_flag_track_status_label, - ) - - def switch_mode(self, mode: str) -> None: - match mode: - case "🕒": - self.mode = "interval" - case "🕘": - self.mode = "leader" - case "⛁": - self.mode = "tires" - - def render_mode_selector(self) -> None: - ( - BlackDropDown( - parent=self.frame, - width=40, - height=30, - font=self.symbols_font, - font_scale=20, - popup_menu_below=True, - command=self.switch_mode, - text="leaderboard", - text_pos=(20, -5), - text_align=TextNode.ACenter, - item_text_align=TextNode.ACenter, - items=["🕒", "🕘", "⛁"], - item_scale=1.0, - initialitem=0, - pos=Point3(self.width - 45, 0, -22), - ), - ) - - def render_drivers(self) -> None: - offset_from_top = 140 - - for index, driver in enumerate(self.drivers): - self.checkered_flags.append( - OnscreenText( - parent=self.frame, - pos=(-10, -offset_from_top - (index * 23)), - scale=self.width / 14, - fg=(1, 1, 1, 0.8), - font=self.symbols_font, - text="", - ), - ) - - OnscreenText( - parent=self.frame, - pos=(20, -offset_from_top - (index * 23)), - scale=self.width / 14, - fg=(1, 1, 1, 0.8), - font=self.text_font, - text=str(index + 1), - ) - - self.team_colors.append( - DirectFrame( - parent=self.frame, - frameColor=driver.team_color_obj, - frameSize=(0, 12, 0, 12), - pos=Point3(40, 0, -offset_from_top - 2 - (index * 23)), - ), - ) - - self.driver_abbreviations.append( - OnscreenText( - parent=self.frame, - pos=(80, -offset_from_top - (index * 23)), - scale=self.width / 14, - fg=(1, 1, 1, 0.8), - font=self.text_font, - text=driver.abbreviation, - ), - ) - - self.driver_times.append( - OnscreenText( - parent=self.frame, - pos=(145, -offset_from_top - (index * 23)), - scale=self.width / 14, - fg=(1, 1, 1, 0.8), - font=self.text_font, - text="NO TIME", - ), - ) - - self.driver_tires.append( - OnscreenText( - parent=self.frame, - pos=(200, -offset_from_top - (index * 23)), - scale=self.width / 14, - fg=(1, 0, 0, 0.8), - font=self.text_font, - text="S", - ), - ) - - has_fastest_lap = OnscreenText( - parent=self.frame, - pos=(self.width + 10, -offset_from_top - (index * 23)), - scale=self.width / 14, - bg=(1, 0, 1, 0.6), - fg=(1, 1, 1, 0.8), - font=self.symbols_font, - text="", - ) - has_fastest_lap.textNode.setCardAsMargin(0.1, 0.2, 0.1, 0.02) - - self.has_fastest_lap.append(has_fastest_lap) - - def update(self, session_time_tick: int) -> None: - processor: LeaderboardProcessor | None = None - - match self.mode: - case "interval": - processor = IntervalLeaderboardProcessor( - self.lap_counter, - self.track_status_frame_top, - self.track_status_frame_left, - self.track_status_frame, - self.track_status, - self.drivers, - self.checkered_flags, - self.team_colors, - self.driver_abbreviations, - self.driver_times, - self.driver_tires, - self.has_fastest_lap, - self.data_extractor, - ) - case "leader": - processor = LeaderLeaderboardProcessor( - self.lap_counter, - self.track_status_frame_top, - self.track_status_frame_left, - self.track_status_frame, - self.track_status, - self.drivers, - self.checkered_flags, - self.team_colors, - self.driver_abbreviations, - self.driver_times, - self.driver_tires, - self.has_fastest_lap, - self.data_extractor, - ) - case "tires": - processor = TiresLeaderboardProcessor( - self.lap_counter, - self.track_status_frame_top, - self.track_status_frame_left, - self.track_status_frame, - self.track_status, - self.drivers, - self.checkered_flags, - self.team_colors, - self.driver_abbreviations, - self.driver_times, - self.driver_tires, - self.has_fastest_lap, - self.data_extractor, - ) - - if processor is None: - return - - processor.update(session_time_tick) - - def render_task(self) -> None: - self.task_manager.add(self.render, "renderLeaderboard") - - def render(self, task: Task) -> Any: - self.render_frame() - self.render_f1_logo() - self.render_lap_counter() - self.render_track_status_frame() - self.render_track_status() - self.render_mode_selector() - self.render_drivers() - - return task.done diff --git a/src/f1p/ui/components/leaderboard/component.py b/src/f1p/ui/components/leaderboard/component.py new file mode 100644 index 0000000..a4895d5 --- /dev/null +++ b/src/f1p/ui/components/leaderboard/component.py @@ -0,0 +1,327 @@ +from typing import Any + +from direct.gui.DirectFrame import DirectFrame +from direct.gui.OnscreenImage import OnscreenImage +from direct.gui.OnscreenText import OnscreenText +from direct.showbase.DirectObject import DirectObject +from direct.task.Task import Task, TaskManager +from fastf1.core import Laps +from panda3d.core import Point3, StaticTextFont, TextNode, TransparencyAttrib + +from f1p.services.data_extractor.service import DataExtractorService +from f1p.ui.components.driver import Driver +from f1p.ui.components.gui.drop_down import BlackDropDown +from f1p.ui.components.leaderboard.processors import ( + IntervalLeaderboardProcessor, + LeaderboardProcessor, + LeaderLeaderboardProcessor, + TiresLeaderboardProcessor, +) +from f1p.ui.components.map import Map + + +class Leaderboard(DirectObject): + def __init__( + self, + pixel2d, + task_manager: TaskManager, + symbols_font: StaticTextFont, + text_font: StaticTextFont, + circuit_map: Map, + data_extractor: DataExtractorService, + ): + super().__init__() + + self.pixel2d = pixel2d + self.task_manager = task_manager + self.width = 215 + self._height: float | None = None + self.symbols_font = symbols_font + self.text_font = text_font + self.circuit_map = circuit_map + self.data_extractor = data_extractor + + self.accept("sessionSelected", self.render_task) + self.accept("updateLeaderboard", self.update) + + self.frame: DirectFrame | None = None + self.track_status_frame_top: DirectFrame | None = None + self.track_status_frame_left: DirectFrame | None = None + self.track_status_frame_bottom: DirectFrame | None = None + self.track_status_frame: DirectFrame | None = None + self.track_status: OnscreenText | None = None + self.f1_logo: OnscreenImage | None = None + self.lap_counter: OnscreenText | None = None + self.mode: str = "interval" + self.checkered_flags: list[OnscreenText] = [] + self.team_colors: list[DirectFrame] = [] + self.driver_abbreviations: list[OnscreenText] = [] + self.driver_times: list[OnscreenText] = [] + self.driver_tires: list[OnscreenText] = [] + self.has_fastest_lap: list[OnscreenText] = [] + + self.drivers: list[Driver] = self.circuit_map.drivers + + self._laps: Laps | None = None + self._total_laps: int | None = None + + @property + def height(self) -> float: + if self._height is None: + self._height = 130 + (len(self.circuit_map.drivers) * 23) + + return self._height + + @property + def total_laps(self) -> int: + if self._total_laps is None: + self._total_laps = self.data_extractor.session.total_laps + + return self._total_laps + + def render_frame(self) -> None: + self.frame = DirectFrame( + parent=self.pixel2d, + frameColor=(0.20, 0.20, 0.20, 0.7), + frameSize=(0, self.width, 0, -self.height), + pos=Point3(20, 0, -50), + ) + + def render_track_status_frame(self) -> None: + self.track_status_frame_top = DirectFrame( + parent=self.frame, + frameColor=self.data_extractor.green_flag_track_status_color, + frameSize=(0, self.width - 5, 0, -1), + pos=Point3(2, 0, -2), + ) + + self.track_status_frame_left = DirectFrame( + parent=self.frame, + frameColor=self.data_extractor.green_flag_track_status_color, + frameSize=(0, 1, 0, self.width - 5), + pos=Point3(2, 0, -self.width + 3), + ) + + def render_f1_logo(self) -> None: + self.f1_logo = OnscreenImage( + image="./src/f1p/ui/images/f1_logo.png", + pos=Point3(self.width / 2, 0, -27), + scale=self.width / 4, + parent=self.frame, + ) + self.f1_logo.setTransparency(TransparencyAttrib.MAlpha) + + def render_lap_counter(self) -> None: + lap_counter_background = DirectFrame( + parent=self.frame, + frameColor=(0.1, 0.1, 0.1, 0.7), + frameSize=(0, self.width, 0, 40), + pos=Point3(0, 0, -90), + ) + + self.lap_counter = OnscreenText( + parent=lap_counter_background, + pos=(self.width / 2, 14), + scale=self.width / 10, + fg=(1, 1, 1, 0.8), + font=self.text_font, + text=f"LAP 1/{self.total_laps}", + ) + + def render_track_status(self) -> None: + self.track_status_frame = DirectFrame( + parent=self.frame, + frameColor=self.data_extractor.green_flag_track_status_color, + frameSize=(0, self.width - 2, 0, 30), + pos=Point3(2, 0, -120), + ) + + self.track_status = OnscreenText( + parent=self.track_status_frame, + pos=(self.width / 2, 9), + scale=self.width / 11, + fg=self.data_extractor.green_flag_track_status_text_color, + font=self.text_font, + text=self.data_extractor.green_flag_track_status_label, + ) + + def switch_mode(self, mode: str) -> None: + match mode: + case "🕒": + self.mode = "interval" + case "🕘": + self.mode = "leader" + case "⛁": + self.mode = "tires" + + def render_mode_selector(self) -> None: + ( + BlackDropDown( + parent=self.frame, + width=40, + height=30, + font=self.symbols_font, + font_scale=20, + popup_menu_below=True, + command=self.switch_mode, + text="leaderboard", + text_pos=(20, -5), + text_align=TextNode.ACenter, + item_text_align=TextNode.ACenter, + items=["🕒", "🕘", "⛁"], + item_scale=1.0, + initialitem=0, + pos=Point3(self.width - 45, 0, -22), + ), + ) + + def render_drivers(self) -> None: + offset_from_top = 140 + + for index, driver in enumerate(self.drivers): + self.checkered_flags.append( + OnscreenText( + parent=self.frame, + pos=(-10, -offset_from_top - (index * 23)), + scale=self.width / 14, + fg=(1, 1, 1, 0.8), + font=self.symbols_font, + text="", + ), + ) + + OnscreenText( + parent=self.frame, + pos=(20, -offset_from_top - (index * 23)), + scale=self.width / 14, + fg=(1, 1, 1, 0.8), + font=self.text_font, + text=str(index + 1), + ) + + self.team_colors.append( + DirectFrame( + parent=self.frame, + frameColor=driver.team_color_obj, + frameSize=(0, 12, 0, 12), + pos=Point3(40, 0, -offset_from_top - 2 - (index * 23)), + ), + ) + + self.driver_abbreviations.append( + OnscreenText( + parent=self.frame, + pos=(80, -offset_from_top - (index * 23)), + scale=self.width / 14, + fg=(1, 1, 1, 0.8), + font=self.text_font, + text=driver.abbreviation, + ), + ) + + self.driver_times.append( + OnscreenText( + parent=self.frame, + pos=(145, -offset_from_top - (index * 23)), + scale=self.width / 14, + fg=(1, 1, 1, 0.8), + font=self.text_font, + text="NO TIME", + ), + ) + + self.driver_tires.append( + OnscreenText( + parent=self.frame, + pos=(200, -offset_from_top - (index * 23)), + scale=self.width / 14, + fg=(1, 0, 0, 0.8), + font=self.text_font, + text="S", + ), + ) + + has_fastest_lap = OnscreenText( + parent=self.frame, + pos=(self.width + 10, -offset_from_top - (index * 23)), + scale=self.width / 14, + bg=(1, 0, 1, 0.6), + fg=(1, 1, 1, 0.8), + font=self.symbols_font, + text="", + ) + has_fastest_lap.textNode.setCardAsMargin(0.1, 0.2, 0.1, 0.02) + + self.has_fastest_lap.append(has_fastest_lap) + + def update(self, session_time_tick: int) -> None: + processor: LeaderboardProcessor | None = None + + match self.mode: + case "interval": + processor = IntervalLeaderboardProcessor( + self.lap_counter, + self.track_status_frame_top, + self.track_status_frame_left, + self.track_status_frame, + self.track_status, + self.drivers, + self.checkered_flags, + self.team_colors, + self.driver_abbreviations, + self.driver_times, + self.driver_tires, + self.has_fastest_lap, + self.data_extractor, + ) + case "leader": + processor = LeaderLeaderboardProcessor( + self.lap_counter, + self.track_status_frame_top, + self.track_status_frame_left, + self.track_status_frame, + self.track_status, + self.drivers, + self.checkered_flags, + self.team_colors, + self.driver_abbreviations, + self.driver_times, + self.driver_tires, + self.has_fastest_lap, + self.data_extractor, + ) + case "tires": + processor = TiresLeaderboardProcessor( + self.lap_counter, + self.track_status_frame_top, + self.track_status_frame_left, + self.track_status_frame, + self.track_status, + self.drivers, + self.checkered_flags, + self.team_colors, + self.driver_abbreviations, + self.driver_times, + self.driver_tires, + self.has_fastest_lap, + self.data_extractor, + ) + + if processor is None: + return + + processor.update(session_time_tick) + + def render_task(self) -> None: + self.task_manager.add(self.render, "renderLeaderboard") + + def render(self, task: Task) -> Any: + self.render_frame() + self.render_f1_logo() + self.render_lap_counter() + self.render_track_status_frame() + self.render_track_status() + self.render_mode_selector() + self.render_drivers() + + return task.done diff --git a/src/f1p/ui/components/leaderboard/processors/__init__.py b/src/f1p/ui/components/leaderboard/processors.py similarity index 99% rename from src/f1p/ui/components/leaderboard/processors/__init__.py rename to src/f1p/ui/components/leaderboard/processors.py index 8ab2b7d..d052dca 100644 --- a/src/f1p/ui/components/leaderboard/processors/__init__.py +++ b/src/f1p/ui/components/leaderboard/processors.py @@ -3,7 +3,7 @@ from panda3d.core import LVecBase4f from pandas import Series -from f1p.services.data_extractor import DataExtractorService +from f1p.services.data_extractor.service import DataExtractorService from f1p.ui.components.driver import Driver diff --git a/src/f1p/ui/components/map.py b/src/f1p/ui/components/map.py index fdc3836..1ee64f1 100644 --- a/src/f1p/ui/components/map.py +++ b/src/f1p/ui/components/map.py @@ -6,7 +6,7 @@ from panda3d.core import LineSegs, NodePath from pandas import DataFrame -from f1p.services.data_extractor import DataExtractorService +from f1p.services.data_extractor.service import DataExtractorService from f1p.ui.components.driver import Driver diff --git a/src/f1p/ui/components/menu.py b/src/f1p/ui/components/menu.py index b41627c..79c1639 100644 --- a/src/f1p/ui/components/menu.py +++ b/src/f1p/ui/components/menu.py @@ -7,7 +7,7 @@ from direct.task.Task import TaskManager from panda3d.core import Point3, StaticTextFont -from f1p.services.data_extractor import DataExtractorService +from f1p.services.data_extractor.service import DataExtractorService from f1p.services.data_extractor.enums import ConventionalSessionIdentifiers, SprintQualifyingSessionIdentifiers from f1p.ui.components.gui.drop_down import BlackDropDown diff --git a/src/f1p/ui/components/playback.py b/src/f1p/ui/components/playback.py index 9ba34fe..2e60788 100644 --- a/src/f1p/ui/components/playback.py +++ b/src/f1p/ui/components/playback.py @@ -10,7 +10,7 @@ from direct.task.Task import Task, TaskManager from panda3d.core import Camera, Point3, StaticTextFont, TextNode, deg2Rad -from f1p.services.data_extractor import DataExtractorService +from f1p.services.data_extractor.service import DataExtractorService from f1p.ui.components.gui.button import BlackButton from f1p.ui.components.gui.drop_down import BlackDropDown diff --git a/src/f1p/ui/components/weather.py b/src/f1p/ui/components/weather.py index a9d238a..255fef1 100644 --- a/src/f1p/ui/components/weather.py +++ b/src/f1p/ui/components/weather.py @@ -6,7 +6,7 @@ from direct.task.Task import TaskManager, Task from panda3d.core import StaticTextFont, Point3, TextNode -from f1p import DataExtractorService +from f1p.services.data_extractor.service import DataExtractorService class WeatherBoard(DirectObject): From ffccade5fd1d72d1f966ffe4d7e4a5e8b08c7974 Mon Sep 17 00:00:00 2001 From: Mitko Tochev Date: Fri, 23 Jan 2026 21:43:28 -0500 Subject: [PATCH 3/4] weather data implemented --- src/f1p/app.py | 2 +- src/f1p/services/data_extractor/service.py | 121 +++++++++++++++++++- src/f1p/ui/components/menu.py | 2 +- src/f1p/ui/components/playback.py | 1 + src/f1p/ui/components/weather.py | 127 ++++++++++++++++++--- 5 files changed, 232 insertions(+), 21 deletions(-) diff --git a/src/f1p/app.py b/src/f1p/app.py index 7f621d0..cb38f8c 100644 --- a/src/f1p/app.py +++ b/src/f1p/app.py @@ -112,7 +112,7 @@ def register_ui_components(self) -> Self: playback_controls, circuit_map, leaderboard, - weather_board + weather_board, ] return self diff --git a/src/f1p/services/data_extractor/service.py b/src/f1p/services/data_extractor/service.py index 0f3c757..db8f011 100644 --- a/src/f1p/services/data_extractor/service.py +++ b/src/f1p/services/data_extractor/service.py @@ -49,6 +49,8 @@ def __init__( self._pos_data: dict[str, Telemetry] | None = None self._circuit_info: CircuitInfo | None = None self._track_status: DataFrame | None = None + self._weather_data: DataFrame | None = None + self.processed_weather_data: DataFrame | None = None self._track_status_colors: DataFrame | None = None self._green_flag_track_status: DataFrame | None = None self._track_statuses: DataFrame | None = None @@ -202,6 +204,13 @@ def green_flag_track_status_color(self) -> LVecBase4f: def green_flag_track_status_text_color(self) -> LVecBase4f: return self.green_flag_track_status["TextColor"].iloc[0] + @property + def weather_data(self) -> DataFrame: + if self._weather_data is None: + self._weather_data = self.session.weather_data + + return self._weather_data + @property def map_rotation(self) -> float: return deg2Rad(self.circuit_info.rotation) @@ -295,6 +304,17 @@ def get_current_track_status(self, session_time_tick: int) -> Series | None: return ts_df.iloc[0] + def get_current_weather_data(self, session_time_tick: int) -> Series | None: + weather_df = self.processed_weather_data + + weather_df = weather_df[weather_df["SessionTimeTick"] <= session_time_tick] + weather_df = weather_df.sort_values(by="SessionTimeTick", ascending=False) + + if weather_df.empty: + return None + + return weather_df.iloc[0] + def process_fastest_lap(self) -> Self: pos_data = self.fastest_lap.get_pos_data() resized_pos_data_df = resize_pos_data(self.map_rotation, pos_data) @@ -458,7 +478,7 @@ def merge_pos_and_laps(self) -> Self: self.processed_pos_data = combined_df - self.update_loading(10) + self.update_loading(5) return self @@ -633,6 +653,104 @@ def compute_tire_compound(self) -> Self: return self + def process_weather_data(self) -> Self: + df = self.processed_pos_data.copy() + df = df[["SessionTimeTick", "SessionTime"]].drop_duplicates(keep="first").copy() + + weather_df = self.weather_data.copy() + weather_df = weather_df[weather_df["Time"] >= self.session_start_time] + weather_df = weather_df[weather_df["Time"] <= self.session_end_time] + + for record in weather_df.itertuples(): + weather_df.loc[weather_df["Time"] == record.Time, "SessionTimeTick"] = df.loc[ + df["SessionTime"] <= record.Time, + "SessionTimeTick", + ].max() + + weather_df["SessionTimeTick"] = weather_df["SessionTimeTick"].astype("int64") + weather_df["AirTempF"] = (weather_df["AirTemp"] * 9 / 5) + 32 + weather_df["TrackTempF"] = (weather_df["TrackTemp"] * 9 / 5) + 32 + weather_df["Pressure"] = weather_df["Pressure"] / 10 + weather_df["WindSpeed"] = weather_df["WindSpeed"] * 18 / 5 + weather_df.loc[weather_df["Rainfall"], "WeatherSymbol"] = "🌧" + weather_df.loc[weather_df["Rainfall"], "WeatherText"] = "RAIN" + weather_df["WeatherSymbol"] = weather_df["WeatherSymbol"].fillna("🌣") + weather_df["WeatherText"] = weather_df["WeatherText"].fillna("SUNNY") + + weather_df.loc[ + (weather_df["WindDirection"] > 337.5) | (weather_df["WindDirection"] <= 22.5), + "WindDirectionSymbol", + ] = "🢃" + weather_df.loc[ + (weather_df["WindDirection"] > 337.5) | (weather_df["WindDirection"] <= 22.5), + "WindDirectionText", + ] = "NORTH" + weather_df.loc[ + (weather_df["WindDirection"] > 22.5) & (weather_df["WindDirection"] <= 67.5), + "WindDirectionSymbol", + ] = "🢇" + weather_df.loc[ + (weather_df["WindDirection"] > 22.5) & (weather_df["WindDirection"] <= 67.5), + "WindDirectionText", + ] = "NORTH EAST" + weather_df.loc[ + (weather_df["WindDirection"] > 67.5) & (weather_df["WindDirection"] <= 112.5), + "WindDirectionSymbol", + ] = "🢀" + weather_df.loc[ + (weather_df["WindDirection"] > 67.5) & (weather_df["WindDirection"] <= 112.5), + "WindDirectionText", + ] = "EAST" + weather_df.loc[ + (weather_df["WindDirection"] > 112.5) & (weather_df["WindDirection"] <= 157.5), + "WindDirectionSymbol", + ] = "🢄" + weather_df.loc[ + (weather_df["WindDirection"] > 112.5) & (weather_df["WindDirection"] <= 157.5), + "WindDirectionText", + ] = "SOUTH EAST" + weather_df.loc[ + (weather_df["WindDirection"] > 157.5) & (weather_df["WindDirection"] <= 202.5), + "WindDirectionSymbol", + ] = "🢁" + weather_df.loc[ + (weather_df["WindDirection"] > 157.5) & (weather_df["WindDirection"] <= 202.5), + "WindDirectionText", + ] = "SOUTH" + weather_df.loc[ + (weather_df["WindDirection"] > 202.5) & (weather_df["WindDirection"] <= 247.5), + "WindDirectionSymbol", + ] = "🢅" + weather_df.loc[ + (weather_df["WindDirection"] > 202.5) & (weather_df["WindDirection"] <= 247.5), + "WindDirectionText", + ] = "SOUTH WEST" + weather_df.loc[ + (weather_df["WindDirection"] > 247.5) & (weather_df["WindDirection"] <= 292.5), + "WindDirectionSymbol", + ] = "🢂" + weather_df.loc[ + (weather_df["WindDirection"] > 247.5) & (weather_df["WindDirection"] <= 292.5), + "WindDirectionText", + ] = "WEST" + weather_df.loc[ + (weather_df["WindDirection"] > 292.5) & (weather_df["WindDirection"] <= 337.5), + "WindDirectionSymbol", + ] = "🢆" + weather_df.loc[ + (weather_df["WindDirection"] > 292.5) & (weather_df["WindDirection"] <= 337.5), + "WindDirectionText", + ] = "NORTH WEST" + + weather_df["WindDirectionSymbol"] = weather_df["WindDirectionSymbol"].ffill() + weather_df["WindDirectionText"] = weather_df["WindDirectionText"].ffill() + + self.processed_weather_data = weather_df + + self.update_loading(5) + + return self + def render_wait_bar(self) -> None: width = 400 height = 200 @@ -696,6 +814,7 @@ def extract(self, task: Task) -> Any: .compute_diff_to_leader() .compute_in_pit() .compute_tire_compound() + .process_weather_data() ) self.delete_loading() diff --git a/src/f1p/ui/components/menu.py b/src/f1p/ui/components/menu.py index 79c1639..9e71517 100644 --- a/src/f1p/ui/components/menu.py +++ b/src/f1p/ui/components/menu.py @@ -7,8 +7,8 @@ from direct.task.Task import TaskManager from panda3d.core import Point3, StaticTextFont -from f1p.services.data_extractor.service import DataExtractorService from f1p.services.data_extractor.enums import ConventionalSessionIdentifiers, SprintQualifyingSessionIdentifiers +from f1p.services.data_extractor.service import DataExtractorService from f1p.ui.components.gui.drop_down import BlackDropDown diff --git a/src/f1p/ui/components/playback.py b/src/f1p/ui/components/playback.py index 2e60788..3203f4e 100644 --- a/src/f1p/ui/components/playback.py +++ b/src/f1p/ui/components/playback.py @@ -100,6 +100,7 @@ def update_components(self) -> None: session_time_tick = int(self.timeline["value"]) messenger.send("updateDrivers", sentArgs=[session_time_tick]) messenger.send("updateLeaderboard", sentArgs=[session_time_tick]) + messenger.send("updateWeather", sentArgs=[session_time_tick]) def render_timeline(self) -> None: self.timeline_all_clear = DirectFrame( diff --git a/src/f1p/ui/components/weather.py b/src/f1p/ui/components/weather.py index 255fef1..5dd5447 100644 --- a/src/f1p/ui/components/weather.py +++ b/src/f1p/ui/components/weather.py @@ -3,8 +3,8 @@ from direct.gui.DirectFrame import DirectFrame from direct.gui.OnscreenText import OnscreenText from direct.showbase.DirectObject import DirectObject -from direct.task.Task import TaskManager, Task -from panda3d.core import StaticTextFont, Point3, TextNode +from direct.task.Task import Task, TaskManager +from panda3d.core import Point3, StaticTextFont, TextNode from f1p.services.data_extractor.service import DataExtractorService @@ -31,19 +31,29 @@ def __init__( self.data_extractor = data_extractor self.accept("sessionSelected", self.render_weather_board) + self.accept("updateWeather", self.update) self.frame: DirectFrame | None = None self.title_frame: DirectFrame | None = None self.title: OnscreenText | None = None self.title_2: OnscreenText | None = None - self.condition: OnscreenText | None = None + self.weather_symbol: OnscreenText | None = None + self.weather_text: OnscreenText | None = None self.temperature_C: OnscreenText | None = None self.temperature_F: OnscreenText | None = None self.track_temp_title: OnscreenText | None = None + self.track_temp_symbol: OnscreenText | None = None self.track_temp_C: OnscreenText | None = None self.track_temp_F: OnscreenText | None = None self.humidity_title: OnscreenText | None = None + self.humidity_symbol: OnscreenText | None = None self.humidity: OnscreenText | None = None + self.pressure_title: OnscreenText | None = None + self.pressure: OnscreenText | None = None + self.wind_title: OnscreenText | None = None + self.wind_direction: OnscreenText | None = None + self.wind_speed: OnscreenText | None = None + self.wind_direction_text: OnscreenText | None = None def render_frame(self) -> None: self.frame = DirectFrame( @@ -80,9 +90,19 @@ def render_title(self) -> None: ) def render_weather(self) -> None: - self.condition = OnscreenText( + self.weather_symbol = OnscreenText( parent=self.frame, - pos=(5, -65), + pos=(5, -62), + scale=self.width / 8, + fg=(0.8, 1, 0, 0.7), + font=self.symbols_font, + align=TextNode.A_left, + text="🌦", + ) + + self.weather_text = OnscreenText( + parent=self.frame, + pos=(30, -65), scale=self.width / 8, fg=(0.8, 1, 0, 0.7), font=self.text_font, @@ -97,17 +117,17 @@ def render_weather(self) -> None: fg=(1, 1, 1, 0.8), font=self.text_font, align=TextNode.A_left, - text="15°C", + text="24.5°C", ) self.temperature_F = OnscreenText( parent=self.frame, - pos=(50, -85), + pos=(70, -85), scale=self.width / 10, fg=(0.5, 0.5, 0.5, 1), font=self.text_font, align=TextNode.A_left, - text="59°F", + text="159.5°F", ) def render_track_temperature(self) -> None: @@ -121,24 +141,34 @@ def render_track_temperature(self) -> None: text="TRACK TEMP", ) - self.track_temp_C = OnscreenText( + self.track_temp_symbol = OnscreenText( parent=self.frame, pos=(5, -125), scale=self.width / 10, fg=(1, 1, 1, 0.8), + font=self.symbols_font, + align=TextNode.A_left, + text="🌡", + ) + + self.track_temp_C = OnscreenText( + parent=self.frame, + pos=(20, -125), + scale=self.width / 10, + fg=(1, 1, 1, 0.8), font=self.text_font, align=TextNode.A_left, - text="17°C", + text="99.5°C", ) self.track_temp_F = OnscreenText( parent=self.frame, - pos=(50, -125), + pos=(80, -125), scale=self.width / 10, fg=(0.5, 0.5, 0.5, 1), font=self.text_font, align=TextNode.A_left, - text="62°F", + text="124.5°F", ) def render_humidity(self) -> None: @@ -152,11 +182,21 @@ def render_humidity(self) -> None: text="HUMIDITY", ) - self.humidity = OnscreenText( + self.humidity_symbol = OnscreenText( parent=self.frame, pos=(5, -165), scale=self.width / 10, fg=(1, 1, 1, 0.8), + font=self.symbols_font, + align=TextNode.A_left, + text="🌢", + ) + + self.humidity = OnscreenText( + parent=self.frame, + pos=(20, -165), + scale=self.width / 10, + fg=(1, 1, 1, 0.8), font=self.text_font, align=TextNode.A_left, text="54%", @@ -191,12 +231,22 @@ def render_wind(self) -> None: fg=(0.8, 1, 0, 0.7), font=self.text_font, align=TextNode.A_left, - text="AIR PRESSURE", + text="WIND", ) - self.wind_speed = OnscreenText( + self.wind_direction = OnscreenText( parent=self.frame, pos=(5, -245), + scale=self.width / 8, + fg=(1, 1, 1, 0.8), + font=self.symbols_font, + align=TextNode.A_left, + text="🡿", + ) + + self.wind_speed = OnscreenText( + parent=self.frame, + pos=(20, -245), scale=self.width / 10, fg=(1, 1, 1, 0.8), font=self.text_font, @@ -204,7 +254,7 @@ def render_wind(self) -> None: text="5.7 km/h", ) - self.wind_direction = OnscreenText( + self.wind_direction_text = OnscreenText( parent=self.frame, pos=(5, -265), scale=self.width / 13, @@ -220,10 +270,51 @@ def render_weather_board(self) -> None: def render(self, task: Task) -> Any: self.render_frame() self.render_title() - self.render_weather() # TODO is raining + self.render_weather() self.render_track_temperature() self.render_humidity() self.render_pressure() self.render_wind() - return task.done \ No newline at end of file + return task.done + + def update(self, session_time_tick: int) -> None: + weather_data = self.data_extractor.get_current_weather_data(session_time_tick) + + if weather_data is None: + return + + if self.weather_symbol["text"] != weather_data["WeatherSymbol"]: + self.weather_symbol["text"] = weather_data["WeatherSymbol"] + if self.weather_text["text"] != weather_data["WeatherText"]: + self.weather_text["text"] = weather_data["WeatherText"] + + temp_C = f"{weather_data['AirTemp']:.1f}°C" + temp_F = f"{weather_data['AirTempF']:.1f}°F" + if self.temperature_C["text"] != temp_C: + self.temperature_C["text"] = temp_C + if self.temperature_F["text"] != temp_F: + self.temperature_F["text"] = temp_F + + track_temp_C = f"{weather_data['TrackTemp']:.1f}°C" + track_temp_F = f"{weather_data['TrackTempF']:.1f}°F" + if self.track_temp_C["text"] != track_temp_C: + self.track_temp_C["text"] = track_temp_C + if self.track_temp_F["text"] != track_temp_F: + self.track_temp_F["text"] = track_temp_F + + humidity = f"{weather_data['Humidity']:.0f}%" + if self.humidity["text"] != humidity: + self.humidity["text"] = humidity + + pressure = f"{weather_data['Pressure']:.2f} kPa" + if self.pressure["text"] != pressure: + self.pressure["text"] = pressure + + if self.wind_direction["text"] != weather_data["WindDirectionSymbol"]: + self.wind_direction["text"] = weather_data["WindDirectionSymbol"] + if self.wind_direction_text["text"] != weather_data["WindDirectionText"]: + self.wind_direction_text["text"] = weather_data["WindDirectionText"] + wind_speed = f"{weather_data['WindSpeed']:.2f} km/h" + if self.wind_speed["text"] != wind_speed: + self.wind_speed["text"] = wind_speed From 66f09baa6ca8f365ff42e04447613e9e48fd3ff8 Mon Sep 17 00:00:00 2001 From: Mitko Tochev Date: Fri, 23 Jan 2026 21:52:36 -0500 Subject: [PATCH 4/4] fix default --- src/f1p/ui/components/weather.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/f1p/ui/components/weather.py b/src/f1p/ui/components/weather.py index 5dd5447..936026b 100644 --- a/src/f1p/ui/components/weather.py +++ b/src/f1p/ui/components/weather.py @@ -92,12 +92,12 @@ def render_title(self) -> None: def render_weather(self) -> None: self.weather_symbol = OnscreenText( parent=self.frame, - pos=(5, -62), - scale=self.width / 8, + pos=(5, -64), + scale=self.width / 7, fg=(0.8, 1, 0, 0.7), font=self.symbols_font, align=TextNode.A_left, - text="🌦", + text="🌣", ) self.weather_text = OnscreenText( @@ -107,7 +107,7 @@ def render_weather(self) -> None: fg=(0.8, 1, 0, 0.7), font=self.text_font, align=TextNode.A_left, - text="RAIN", + text="WEATHER", ) self.temperature_C = OnscreenText( @@ -117,7 +117,7 @@ def render_weather(self) -> None: fg=(1, 1, 1, 0.8), font=self.text_font, align=TextNode.A_left, - text="24.5°C", + text="00.0°C", ) self.temperature_F = OnscreenText( @@ -127,7 +127,7 @@ def render_weather(self) -> None: fg=(0.5, 0.5, 0.5, 1), font=self.text_font, align=TextNode.A_left, - text="159.5°F", + text="00.0°F", ) def render_track_temperature(self) -> None: @@ -158,7 +158,7 @@ def render_track_temperature(self) -> None: fg=(1, 1, 1, 0.8), font=self.text_font, align=TextNode.A_left, - text="99.5°C", + text="00.0°C", ) self.track_temp_F = OnscreenText( @@ -168,7 +168,7 @@ def render_track_temperature(self) -> None: fg=(0.5, 0.5, 0.5, 1), font=self.text_font, align=TextNode.A_left, - text="124.5°F", + text="00.0°F", ) def render_humidity(self) -> None: @@ -199,7 +199,7 @@ def render_humidity(self) -> None: fg=(1, 1, 1, 0.8), font=self.text_font, align=TextNode.A_left, - text="54%", + text="00.0%", ) def render_pressure(self) -> None: @@ -220,7 +220,7 @@ def render_pressure(self) -> None: fg=(1, 1, 1, 0.8), font=self.text_font, align=TextNode.A_left, - text="101.325 kPa", + text="000.00 kPa", ) def render_wind(self) -> None: @@ -251,7 +251,7 @@ def render_wind(self) -> None: fg=(1, 1, 1, 0.8), font=self.text_font, align=TextNode.A_left, - text="5.7 km/h", + text="00.0 km/h", ) self.wind_direction_text = OnscreenText( @@ -261,7 +261,7 @@ def render_wind(self) -> None: fg=(1, 1, 1, 0.8), font=self.text_font, align=TextNode.A_left, - text="NORTH EAST", + text="Pending...", ) def render_weather_board(self) -> None: