From f8f08440c6649ad1905c6cc59511b3cc3a0bc720 Mon Sep 17 00:00:00 2001 From: Casper Guo Date: Fri, 2 Aug 2024 18:53:39 +0800 Subject: [PATCH] Compound lineplot logic basically working --- app.py | 75 +++++++++++++++++- f1_visualization/plotly_dash/graphs.py | 77 ++++++++++++++++--- .../plotly_dash/visualization_config.toml | 10 +++ 3 files changed, 151 insertions(+), 11 deletions(-) diff --git a/app.py b/app.py index 23e515b..1311ea2 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,13 @@ """Dash app layout and callbacks.""" -from typing import TypeAlias +from pathlib import Path +from typing import Iterable, TypeAlias import dash_bootstrap_components as dbc import fastf1 as f import pandas as pd -from dash import Dash, Input, Output, State, callback +import tomli +from dash import Dash, Input, Output, State, callback, html from plotly import graph_objects as go import f1_visualization.plotly_dash.graphs as pg @@ -18,6 +20,16 @@ # must not be modified DF_DICT = load_laps() +with open( + Path(__file__).absolute().parent + / "f1_visualization" + / "plotly_dash" + / "visualization_config.toml", + "rb", +) as toml: + # TODO: revisit the palette + COMPOUND_PALETTE = tomli.load(toml)["relative"]["high_contrast_palette"] + def df_convert_timedelta(df: pd.DataFrame) -> pd.DataFrame: """ @@ -144,6 +156,23 @@ def enable_load_session(season: int | None, event: str | None, session: str | No return not (season is not None and event is not None and session is not None) +def create_compound_dropdown_options(compounds: Iterable[str]) -> list[dict]: + """Create compound dropdown options with styling.""" + # sort the compounds + compound_order = ["SOFT", "MEDIUM", "HARD", "INTERMEDIATE", "WET"] + compound_index = [compound_order.index(compound) for compound in compounds] + sorted_compounds = sorted(zip(compounds, compound_index), key=lambda x: x[1]) + compounds = [compound for compound, _ in sorted_compounds] + + return [ + { + "label": html.Span(compound, style={"color": COMPOUND_PALETTE[compound]}), + "value": compound, + } + for compound in compounds + ] + + @callback( Output("session-info", "data"), Input("load-session", "n_clicks"), @@ -415,5 +444,47 @@ def render_distplot( return fig +@callback( + Output("compound-plot", "figure"), + Input("compounds", "value"), + Input("compound-type", "value"), + Input("compound-unit", "value"), + State("laps", "data"), + State("session-info", "data"), +) +def render_compound_plot( + compounds: list[str], + plot_type: str, + show_seconds: bool, + included_laps: dict, + session_info: Session_info, +) -> go.Figure: + """Filter laps and render compound performance plot.""" + if not included_laps or not compounds: + return go.Figure() + + included_laps = pd.DataFrame.from_dict(included_laps) + included_laps = included_laps[included_laps["Compound"].isin(compounds)] + + y = "DeltaToLapRep" if show_seconds else "PctFromLapRep" + fig = go.Figure() + + match plot_type: + case "lineplot": + fig = pg.compounds_lineplot(included_laps, y, compounds) + case "boxplot": + fig = pg.compounds_distplot(included_laps, y, compounds, False) + case "violinplot": + fig = pg.compounds_distplot(included_laps, y, compounds, True) + case _: + # this should never be triggered + # but just in case, return empty plot + return fig + + event_name = session_info[1] + fig.update_layout(title=event_name) + return fig + + if __name__ == "__main__": app.run(host="0.0.0.0", port=8000) diff --git a/f1_visualization/plotly_dash/graphs.py b/f1_visualization/plotly_dash/graphs.py index b94214c..89ca657 100644 --- a/f1_visualization/plotly_dash/graphs.py +++ b/f1_visualization/plotly_dash/graphs.py @@ -174,7 +174,7 @@ def stats_scatterplot( "color": driver_laps[args[0]].map(args[1]), "symbol": driver_laps["FreshTyre"].map(VISUAL_CONFIG["fresh"]["markers"]), }, - name=f"{driver}", + name=driver, ), row=row, col=col, @@ -209,21 +209,16 @@ def stats_lineplot( if y in {"PctFromLapRep", "DeltaToLapRep"}: included_laps = included_laps[included_laps["PctFromLapRep"] > -5] - for index, driver in enumerate(reversed(drivers)): + for _, driver in enumerate(reversed(drivers)): driver_laps = included_laps[(included_laps["Driver"] == driver)] - # the top left subplot is indexed (1, 1) - row, col = divmod(index, 4) - row += 1 - col += 1 - fig.add_trace( go.Scatter( x=driver_laps["LapNumber"], y=driver_laps[y], mode="lines", line={"color": pick_driver_color(driver)}, - name=f"{driver}", + name=driver, ) ) @@ -253,7 +248,7 @@ def stats_distplot( drivers: list[str], boxplot: bool, ) -> go.Figure: - """Make distribution plot of lap times, with optional swarm and boxplots.""" + """Make distribution plot of lap times, either as boxplot or as violin plot.""" fig = go.Figure() for driver in drivers: @@ -292,3 +287,67 @@ def stats_distplot( height=500, ) return fig + + +def compounds_lineplot(included_laps: pd.DataFrame, y: str, compounds: list[str]) -> go.Figure: + """Plot compound degradation curve as a lineplot.""" + fig = go.Figure() + yaxis_title = "Seconds to LRT" if y == "DeltaToLapRep" else "Percent from LRT" + + # TODO: remove this hard-coded value + _, palette, marker, _ = _plot_args(2024) + + for compound in compounds: + compound_laps = included_laps[included_laps["Compound"] == compound] + # clip tyre life range to where there are at least three records + tyre_life_range = compound_laps.groupby("TyreLife").size() + tyre_life_range = tyre_life_range[tyre_life_range >= 3].index + median_LRT = compound_laps.groupby("TyreLife")[y].median(numeric_only=True) # noqa: N806 + median_LRT = median_LRT.loc[tyre_life_range] # noqa: N806 + + fig.add_trace( + go.Scatter( + x=tyre_life_range, + y=median_LRT, + line={"color": palette[compound]}, + marker={ + # TODO: tune these parameters + "line": {"width": 1, "color": "white"}, + "color": palette[compound], + "symbol": marker[compound], + "size": 8, + }, + mode="lines+markers", + name=compound, + ) + ) + + fig.update_layout( + template="plotly_dark", + xaxis_title="Tyre Age", + yaxis_title=yaxis_title, + showlegend=False, + autosize=False, + width=1250, + height=500, + ) + return fig + + +def compounds_distplot( + included_laps: pd.DataFrame, y: str, compounds: list[str], violin_plot: bool +) -> go.Figure: + """PLot compound performance vs tyre age as either a boxplot or violin plot.""" + fig = go.Figure() + yaxis_title = "Seconds to LRT" if y == "DeltaToLapRep" else "Percent from LRT" + + fig.update_layout( + template="plotly_dark", + xaxis_title="Tyre Age", + yaxis_title=yaxis_title, + showlegend=False, + autosize=False, + width=1250, + height=500, + ) + return fig diff --git a/f1_visualization/plotly_dash/visualization_config.toml b/f1_visualization/plotly_dash/visualization_config.toml index a754444..9af0be8 100644 --- a/f1_visualization/plotly_dash/visualization_config.toml +++ b/f1_visualization/plotly_dash/visualization_config.toml @@ -33,6 +33,16 @@ INTERMEDIATE = "#43b02a" WET = "#0067ad" UNKNOWN = "#00ffff" +# For display on a white background +# the customary palette is kept unchanged where the contrast is sufficiently high +[relative.high_contrast_palette] +SOFT = "#da291c" +MEDIUM = "8a6d00" +HARD = "#000000" +INTERMEDIATE = "#276719" +WET = "#0067ad" +UNKNOWN = "#004aaa" + [relative.markers] SOFT = "circle" MEDIUM = "triangle-up"