Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add compound performance plots to the dashboard #87

Merged
merged 3 commits into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 74 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
"""
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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)
67 changes: 58 additions & 9 deletions f1_visualization/plotly_dash/graphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
92 changes: 91 additions & 1 deletion f1_visualization/plotly_dash/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ",
Expand Down Expand Up @@ -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),
]
Expand Down
11 changes: 11 additions & 0 deletions f1_visualization/plotly_dash/visualization_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down