diff --git a/app.py b/app.py index 23e515b..6fc7f3e 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: """ @@ -67,6 +79,26 @@ def configure_lap_numbers_slider(data: dict) -> tuple[int, list[int], dict[int, return num_laps, [1, num_laps], marks +def style_compound_options(compounds: Iterable[str]) -> list[dict]: + """Create compound dropdown options with styling.""" + compound_order = ["SOFT", "MEDIUM", "HARD", "INTERMEDIATE", "WET"] + # discard unknown compounds + compounds = [compound for compound in compounds if compound in compound_order] + + # sort the compounds + 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 + ] + + app = Dash( __name__, external_stylesheets=[dbc.themes.SANDSTONE], @@ -237,6 +269,19 @@ def readable_gap_col_name(col: str) -> str: ) +@callback( + Output("compounds", "options"), + Output("compounds", "disabled"), + Input("laps", "data"), + prevent_initial_call=True, +) +def set_compounds_dropdown(data: dict) -> tuple[list[dict], bool]: + """Update compound plot dropdown options based on the laps dataframe.""" + # exploit how Pandas dataframes are converted to dictionaries + # avoid having to construct a new dataframe + return style_compound_options(set(data["Compound"].values())), False + + @callback( Output("laps", "data", allow_duplicate=True), Input("add-gap", "n_clicks"), @@ -415,5 +460,32 @@ def render_distplot( return fig +@callback( + Output("compound-plot", "figure"), + Input("compounds", "value"), + Input("compound-unit", "value"), + State("laps", "data"), + State("session-info", "data"), +) +def render_compound_plot( + compounds: list[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 = pg.compounds_lineplot(included_laps, y, compounds) + 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..b72cb0a 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,57 @@ 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" + + _, palette, marker, _ = _plot_args() + max_stint_length = 0 + + for compound in compounds: + compound_laps = included_laps[included_laps["Compound"] == compound] + + # clip tyre life range to where there are at least three records + # if a driver does a very long stint, not all of it will be plotted + tyre_life_range = compound_laps.groupby("TyreLife").size() + tyre_life_range = tyre_life_range[tyre_life_range >= 3].index + + # use the max instead of the length because tyre life range is + # not guarenteed to start at 0 + max_stint_length = max(max_stint_length, tyre_life_range.max()) + 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={ + "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={ + "tickmode": "array", + "tickvals": list(range(5, max_stint_length, 5)), + "title": "Tyre Age", + }, + yaxis_title=yaxis_title, + showlegend=False, + autosize=False, + width=1250, + height=500, + ) + return fig diff --git a/f1_visualization/plotly_dash/layout.py b/f1_visualization/plotly_dash/layout.py index 0d0b211..00be00f 100644 --- a/f1_visualization/plotly_dash/layout.py +++ b/f1_visualization/plotly_dash/layout.py @@ -225,6 +225,94 @@ def lap_numbers_slider(slider_id: str, **kwargs) -> dcc.RangeSlider: label="Distribution Plot", ) +compound_plot_explanation = dbc.Alert( + [ + html.H4("Methodology", className="alert-heading"), + html.P( + "The metric behind this graph is delta to lap representative time (DLRT). " + "It is a measure of how good a lap time is compared to other cars on track " + "at the same time, thus accounting for fuel load and track evolution." + ), + html.Hr(), + html.P( + "Since this metric is relative, this plot is best used for understanding " + "how different compounds degrade at different rates." + ), + ], + color="info", + dismissable=True, +) + +compound_plot_caveats = dbc.Alert( + [ + html.H4("Caveats", className="alert-heading"), + html.P( + "The driver selections does not apply to this plot. " + "This plot always considers laps driven by all drivers." + ), + html.Hr(), + html.P( + "Tyre life does not always correspond to stint length. " + "As the same tyre may have been used in qualifying sessions." + ), + html.Hr(), + html.P( + "Only compounds that completed at least one sixth of all laps are shown. " + "Outlier laps are filtered out." + ), + html.Hr(), + html.P( + "For each compound, the range of shown tyre life is limited by " + "the number of drivers who completed a stint of that length. This is to avoid " + "the plot being stretched by one driver doing a very long stint." + ), + ], + color="info", + dismissable=True, +) + +compound_plot_tab = dbc.Tab( + dbc.Card( + dbc.CardBody( + [ + compound_plot_explanation, + compound_plot_caveats, + html.Br(), + dbc.Row( + dbc.Col( + dcc.Dropdown( + options=[ + {"label": "Show delta as seconds", "value": True}, + {"label": "Show delta as percentages", "value": False}, + ], + value=True, + clearable=False, + placeholder="Select a unit", + id="compound-unit", + ) + ) + ), + html.Br(), + dbc.Row( + dcc.Loading( + dcc.Dropdown( + options=[], + value=[], + placeholder="Select compounds", + disabled=True, + multi=True, + id="compounds", + ) + ) + ), + html.Br(), + dbc.Row(dcc.Loading(dcc.Graph(id="compound-plot"))), + ] + ) + ), + label="Compound Performance Plot", +) + external_links = dbc.Alert( [ "All data provided by ", @@ -267,7 +355,9 @@ def lap_numbers_slider(slider_id: str, **kwargs) -> dcc.RangeSlider: html.Br(), add_gap_row, html.Br(), - dbc.Tabs([strategy_tab, scatterplot_tab, lineplot_tab, distplot_tab]), + dbc.Tabs( + [strategy_tab, scatterplot_tab, lineplot_tab, distplot_tab, compound_plot_tab] + ), html.Br(), dbc.Row(external_links), ] diff --git a/f1_visualization/plotly_dash/visualization_config.toml b/f1_visualization/plotly_dash/visualization_config.toml index a754444..ecac150 100644 --- a/f1_visualization/plotly_dash/visualization_config.toml +++ b/f1_visualization/plotly_dash/visualization_config.toml @@ -33,6 +33,17 @@ INTERMEDIATE = "#43b02a" WET = "#0067ad" UNKNOWN = "#00ffff" +# For display on a white background +# the customary palette is kept unchanged where the contrast is sufficiently high +# TODO: improve this palette +[relative.high_contrast_palette] +SOFT = "#da291c" +MEDIUM = "8a6d00" +HARD = "#000000" +INTERMEDIATE = "#276719" +WET = "#0067ad" +UNKNOWN = "#004aaa" + [relative.markers] SOFT = "circle" MEDIUM = "triangle-up"