from typing import Dict, Optional, Tuple
import ipywidgets as widgets
import numpy as np
import numpy.typing as npt
import plotly.graph_objects as go
from plotly.colors import qualitative
from plotly.subplots import make_subplots
from scipy.spatial import cKDTree
from ..log_analysis.gps_laps import LineSegment
from .plotting_constants import *
[docs]
def plot_gps_trajectory(
x: npt.NDArray[np.float64],
y: npt.NDArray[np.float64],
time_s: npt.NDArray[np.float64],
*,
layout_config: LayoutConfig = DEFAULT_LAYOUT_CONFIG,
font_config: FontConfig = DEFAULT_FONT_CONFIG,
) -> "widgets.VBox":
"""Interactive 2D GPS trajectory plot with a two-handled time trim slider.
Parameters
----------
x, y : NDArray[float64]
X and Y coordinates.
time_s : NDArray[float64]
Elapsed time in seconds (same length as x/y).
layout_config : LayoutConfig
font_config : FontConfig
Returns
-------
widgets.VBox
A VBox containing the FigureWidget and the range slider. Display
it directly in a notebook cell.
"""
t_min, t_max = float(time_s[0]), float(time_s[-1])
step = (t_max - t_min) / max(len(time_s) - 1, 1)
# Trace plotting
fig = go.FigureWidget()
fig.add_trace(
go.Scattergl(
x=x,
y=y,
mode="markers",
marker=dict(
size=4,
color=time_s,
colorscale="Viridis",
colorbar=dict(title="Time (s)"),
showscale=True,
),
name="Trajectory",
hovertemplate=(
f"X: %{{x:{FLOAT_PRECISION}}}<br>"
f"Y: %{{y:{FLOAT_PRECISION}}}<br>"
"t: %{marker.color:.2f} s<extra></extra>"
),
)
)
fig.update_layout(
title={
"text": "GPS Trajectory",
"font": dict(size=font_config.large, color=TEXT_COLOR_DARK),
"x": layout_config.title_x,
"xanchor": layout_config.title_xanchor,
},
xaxis_title={
"text": "X",
"font": dict(size=font_config.medium, color=TEXT_COLOR_DARK),
},
yaxis_title={
"text": "Y",
"font": dict(size=font_config.medium, color=TEXT_COLOR_DARK),
},
width=layout_config.width,
height=layout_config.height,
margin=layout_config.margin,
hovermode=HOVER_MODE,
plot_bgcolor=layout_config.plot_bgcolor,
)
fig.update_yaxes(scaleanchor="x", scaleratio=1)
# Time range slider
range_slider = widgets.FloatRangeSlider(
value=[t_min, t_max],
min=t_min,
max=t_max,
step=step,
description="Time (s):",
continuous_update=True,
readout=False,
layout=widgets.Layout(width="100%"),
)
label = widgets.Label(
value=f"[{t_min:.2f}, {t_max:.2f}] s ({len(time_s)} samples)"
)
# Re-render the trace on slider change by masking out points outside the selected time range
def _update(change):
s_val, e_val = range_slider.value
mask = (time_s >= s_val) & (time_s <= e_val)
with fig.batch_update():
fig.data[0].x = x[mask]
fig.data[0].y = y[mask]
fig.data[0].marker.color = time_s[mask]
count = int(mask.sum())
label.value = (
f"[{s_val:.2f}, {e_val:.2f}] s "
f"({count} samples, {e_val - s_val:.1f} s)"
)
range_slider.observe(_update, names="value")
return widgets.VBox(
[fig, range_slider, label],
layout=widgets.Layout(width=f"{layout_config.width}px"),
)
[docs]
def plot_track_build_verification(
distance: npt.NDArray[np.float64],
curv_raw: npt.NDArray[np.float64],
curv_filt: npt.NDArray[np.float64],
radius_filt: npt.NDArray[np.float64],
x_m: npt.NDArray[np.float64],
y_m: npt.NDArray[np.float64],
*,
font_config: FontConfig = DEFAULT_FONT_CONFIG,
layout_config: LayoutConfig = DEFAULT_LAYOUT_CONFIG,
) -> go.Figure:
"""XY track shape and curvature/radius before and after spatial filter.
Parameters
----------
distance : NDArray[float64]
Uniform distance grid (m).
curv_raw : NDArray[float64]
Raw curvature (1/m).
curv_filt : NDArray[float64]
Filtered curvature (1/m).
radius_filt : NDArray[float64]
Filtered radius of curvature (m).
x_m : NDArray[float64]
East coordinates (m) same axis as longitude/x.
y_m : NDArray[float64]
North coordinates (m) same axis as latitude/y.
Returns
-------
go.Figure
"""
fig = make_subplots(
rows=1,
cols=2,
subplot_titles=("Track XY", "Curvature (before/after filter)"),
horizontal_spacing=layout_config.grid_horizontal_spacing,
specs=[[{}, {"secondary_y": True}]],
)
# Plot track XY shape
fig.add_trace(
go.Scattergl(
x=x_m,
y=y_m,
mode="lines",
name="Track",
hovertemplate=f"X: %{{x:{FLOAT_PRECISION}}} m<br>Y: %{{y:{FLOAT_PRECISION}}} m<extra></extra>",
),
row=1,
col=1,
)
fig.update_xaxes(
title_text="X/East (m)",
row=1,
col=1,
title_font=dict(size=font_config.medium, color=TEXT_COLOR_DARK),
)
fig.update_yaxes(
title_text="Y/North (m)",
scaleanchor="x",
scaleratio=1,
row=1,
col=1,
title_font=dict(size=font_config.medium, color=TEXT_COLOR_DARK),
)
# Plot raw curvature
fig.add_trace(
go.Scattergl(
x=distance,
y=curv_raw,
mode="lines",
name="Raw Curvature",
opacity=0.4,
hovertemplate=f"d: %{{x:{FLOAT_PRECISION}}} m<br>κ: %{{y:{FLOAT_PRECISION}}} 1/m<extra></extra>",
),
row=1,
col=2,
secondary_y=False,
)
# Plot processed curvature
fig.add_trace(
go.Scattergl(
x=distance,
y=curv_filt,
mode="lines",
name="Filtered Curvature",
hovertemplate=f"d: %{{x:{FLOAT_PRECISION}}} m<br>κ: %{{y:{FLOAT_PRECISION}}} 1/m<extra></extra>",
),
row=1,
col=2,
secondary_y=False,
)
fig.update_xaxes(
title_text="Distance (m)",
row=1,
col=2,
title_font=dict(size=font_config.medium, color=TEXT_COLOR_DARK),
)
fig.update_yaxes(
title_text="Curvature (1/m)",
row=1,
col=2,
secondary_y=False,
title_font=dict(size=font_config.medium, color=TEXT_COLOR_DARK),
)
# Plot processed radius on second y-axis
fig.add_trace(
go.Scattergl(
x=distance,
y=radius_filt,
mode="lines",
name="Filtered Radius",
hovertemplate=f"d: %{{x:{FLOAT_PRECISION}}} m<br>R: %{{y:{FLOAT_PRECISION}}} m<extra></extra>",
),
row=1,
col=2,
secondary_y=True,
)
fig.update_yaxes(
title_text="Radius (m)",
row=1,
col=2,
secondary_y=True,
title_font=dict(size=font_config.medium, color=TEXT_COLOR_DARK),
)
fig.update_layout(
title={
"text": "Track Build Verification",
"font": dict(size=font_config.large, color=TEXT_COLOR_DARK),
"x": layout_config.title_x,
"xanchor": layout_config.title_xanchor,
},
height=layout_config.height,
width=layout_config.width,
margin=layout_config.margin,
showlegend=True,
legend_font=dict(size=font_config.small),
hovermode=HOVER_MODE,
plot_bgcolor=layout_config.plot_bgcolor,
)
return fig
[docs]
def plot_track_heatmap(
baseline_pos_x: npt.NDArray[np.float64],
baseline_pos_y: npt.NDArray[np.float64],
baseline_var: npt.NDArray[np.float64],
comparison_pos_x: npt.NDArray[np.float64] | None = None,
comparison_pos_y: npt.NDArray[np.float64] | None = None,
comparison_var: npt.NDArray[np.float64] | None = None,
*,
variable_name: str = "Variable",
baseline_label: str = "Baseline",
comparison_label: str = "Comparison",
data_label: str = "",
colorscale: str = "Viridis",
title: str | None = None,
layout_config: LayoutConfig | None = None,
font_config: FontConfig | None = None,
colorbar_config: ColorbarConfig | None = None,
) -> go.Figure:
"""2D track heatmap, either single-series or 3-panel comparison.
When called with only the baseline series (no comparison arguments), a
single 1:1-aspect scatter plot is returned. When comparison data is
supplied, a 3-panel figure is returned: baseline, comparison projected
onto the baseline spatial grid, and a signed delta panel.
The comparison series is projected via nearest-neighbour lookup
(scipy cKDTree) then linearly interpolated for sparse grid points.
Parameters
----------
baseline_pos_x, baseline_pos_y : NDArray[float64]
X and Y positions of the baseline series (m).
baseline_var : NDArray[float64]
Variable values for the baseline series.
comparison_pos_x, comparison_pos_y : NDArray[float64] or None
X and Y positions of the comparison series (m). All three
comparison arguments must be provided together or not at all.
comparison_var : NDArray[float64] or None
Variable values for the comparison series.
variable_name : str
Name of the variable (used in titles and colorbars).
baseline_label, comparison_label : str
Panel labels (comparison mode only).
data_label : str
Label appended to hover and colorbar values.
colorscale : str
Any Plotly named colorscale string (e.g. ``"Viridis"``, ``"Plasma"``).
title : str or None
Plot title override. ``None`` uses a generated default.
layout_config : LayoutConfig | None
font_config : FontConfig | None
colorbar_config : ColorbarConfig | None
Returns
-------
go.Figure
"""
font_config = font_config or DEFAULT_FONT_CONFIG
layout_config = layout_config or DEFAULT_LAYOUT_CONFIG
colorbar_config = colorbar_config or DEFAULT_COLORBAR_CONFIG
has_comparison = comparison_pos_x is not None
# Build panel descriptors: (color_data, colorscale, label, hovertemplate, name)
# The baseline panel is always first; comparison and delta panels are appended when present.
panels = [
(
baseline_var,
colorscale,
data_label,
f"{baseline_label}: %{{customdata:{FLOAT_PRECISION}}}{data_label}<extra></extra>",
baseline_label,
)
]
if has_comparison:
bl_xy = np.column_stack([baseline_pos_x, baseline_pos_y]) # (X, Y)
tree = cKDTree(bl_xy)
n_grid = len(bl_xy)
# Lookup baseline nearest neighbors for comparison points
cmp_xy = np.column_stack([comparison_pos_x, comparison_pos_y]) # (X, Y)
_, nearest_idx = tree.query(cmp_xy)
# Retrieve comparison variable values on the baseline grid
vel_sums = np.zeros(n_grid)
vel_counts = np.zeros(n_grid, dtype=int)
for i, tidx in enumerate(nearest_idx):
vel_sums[tidx] += comparison_var[i]
vel_counts[tidx] += 1
# Average values for grid points with multiple neighbors, leave empty points as NaN
has_data = vel_counts > 0
cmp_projected = np.full(n_grid, np.nan)
cmp_projected[has_data] = vel_sums[has_data] / vel_counts[has_data]
# Interpolate linearly over any grid points that have no comparison data
if int(np.sum(~has_data)) > 0:
# indices of grid points with data
valid = np.where(~np.isnan(cmp_projected))[0]
if 0 < len(valid) < n_grid:
cmp_projected = np.interp(
np.arange(n_grid), valid, cmp_projected[valid]
)
delta = baseline_var - cmp_projected
d_abs = float(max(abs(np.nanmin(delta)), abs(np.nanmax(delta))))
panels.append(
(
cmp_projected,
colorscale,
data_label,
f"{comparison_label}: %{{customdata:{FLOAT_PRECISION}}}{data_label}<extra></extra>",
comparison_label,
)
)
panels.append(
(
delta,
colorscale,
f"Delta {data_label}",
f"Delta: %{{customdata:+{FLOAT_PRECISION}}}{data_label}<extra></extra>",
"Delta",
)
)
n_cols = len(panels)
subplot_titles = [baseline_label] + (
[comparison_label, f"Delta ({baseline_label} - {comparison_label})"]
if has_comparison
else []
)
fig = make_subplots(
rows=1,
cols=n_cols,
subplot_titles=subplot_titles,
horizontal_spacing=layout_config.grid_horizontal_spacing,
)
# Helps position colorbars
col_width = (1.0 - layout_config.grid_horizontal_spacing * (n_cols - 1)) / n_cols
for col_idx, (color_data, cs, label, hover, name) in enumerate(panels, start=1):
subplot_right = (
col_idx * col_width + (col_idx - 1) * layout_config.grid_horizontal_spacing
)
colorbar_x = subplot_right + 0.01
fig.add_trace(
go.Scattergl(
x=baseline_pos_x,
y=baseline_pos_y,
mode="markers",
marker=dict(
size=MARKER_SIZE_LARGE,
color=color_data,
colorscale=cs,
colorbar=dict(
title=dict(
text=label,
font=dict(size=font_config.medium),
),
thickness=colorbar_config.thickness,
len=colorbar_config.length,
tickfont=dict(size=font_config.small),
x=colorbar_x,
xanchor="left",
),
showscale=True,
),
name=name,
customdata=color_data,
showlegend=False,
hovertemplate=hover,
),
row=1,
col=col_idx,
)
# Batch update axes
for col_idx in range(1, n_cols + 1):
ref = f"x{col_idx}" if col_idx > 1 else "x"
fig.update_xaxes(
title_text="X/East (m)",
title_font=dict(size=font_config.medium, color=TEXT_COLOR_DARK),
row=1,
col=col_idx,
)
fig.update_yaxes(
title_text="Y/North (m)",
title_font=dict(size=font_config.medium, color=TEXT_COLOR_DARK),
scaleanchor=ref,
scaleratio=1,
row=1,
col=col_idx,
)
fig.update_layout(
title={
"text": title,
"font": dict(size=font_config.large, color=TEXT_COLOR_DARK),
"x": layout_config.title_x,
"xanchor": layout_config.title_xanchor,
},
height=layout_config.height,
width=(
n_cols * layout_config.grid_width_per_col
if has_comparison
else layout_config.width
),
margin=layout_config.margin,
showlegend=False,
hovermode=HOVER_MODE,
plot_bgcolor=layout_config.plot_bgcolor,
)
return fig
[docs]
def plot_lap_overlay(
laps: list[tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]],
baseline_idx: int,
*,
sf_line: LineSegment | None = None,
layout_config: LayoutConfig = DEFAULT_LAYOUT_CONFIG,
font_config: FontConfig = DEFAULT_FONT_CONFIG,
) -> go.Figure:
"""Overlay GPS trajectories for multiple laps on a 2-D scatter plot.
Parameters
----------
laps : list[tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]],
List of tuples containing Y (m) and X (m) local cartesian coordinates for each lap
baseline_idx : int
Index of the baseline lap to be highlighted.
sf_line : LineSegment or None
Start/finish line endpoints as ((x1, y1), (x2, y2)) in local X/Y metres.
layout_config : LayoutConfig
font_config : FontConfig
Returns
-------
go.Figure
"""
fig = go.Figure()
for i, (pos_x, pos_y) in enumerate(laps):
fig.add_trace(
go.Scattergl(
x=pos_x,
y=pos_y,
mode="markers",
marker=dict(
size=MARKER_SIZE_LARGE if i == baseline_idx else MARKER_SIZE
),
name=f"Lap {i + 1}",
hovertemplate=(
f"X: %{{x:{FLOAT_PRECISION}}} m<br>"
f"Y: %{{y:{FLOAT_PRECISION}}} m<extra></extra>"
),
)
)
if sf_line is not None:
fig.add_trace(
go.Scattergl(
x=[sf_line[0][0], sf_line[1][0]], # X (East/lon), index 0
y=[sf_line[0][1], sf_line[1][1]], # Y (North/lat), index 1
mode="lines+markers",
line=dict(width=LINE_WIDTH),
marker=dict(size=MARKER_SIZE_LARGE),
name="S/F Line",
)
)
fig.update_yaxes(scaleanchor="x", scaleratio=1)
fig.update_layout(
title={
"text": "Lap Overlay",
"font": dict(size=font_config.large, color=TEXT_COLOR_DARK),
"x": layout_config.title_x,
"xanchor": layout_config.title_xanchor,
},
xaxis_title={
"text": "X (m)",
"font": dict(size=font_config.medium, color=TEXT_COLOR_DARK),
},
yaxis_title={
"text": "Y (m)",
"font": dict(size=font_config.medium, color=TEXT_COLOR_DARK),
},
width=layout_config.width,
height=layout_config.height,
margin=layout_config.margin,
hovermode=HOVER_MODE,
plot_bgcolor=layout_config.plot_bgcolor,
)
return fig
[docs]
def plot_time_delta_comparison_traces(
laps: Dict[
str,
Tuple[
npt.NDArray[np.float64], # distance (m)
npt.NDArray[np.float64], # variable values
npt.NDArray[np.float64], # velocity (m/s) for time-delta
],
],
*,
variable_name: str = "Variable",
reference_lap: Optional[str] = None,
data_label: str = "",
dx: float = 0.1,
layout_config: LayoutConfig = DEFAULT_LAYOUT_CONFIG,
font_config: FontConfig = DEFAULT_FONT_CONFIG,
) -> go.Figure:
"""2-subplot comparison traces. Left subplot shows the change of
a variable over distance along the track, and right subplots shows the cumulative time delta
relative to a reference lap.
Parameters
----------
laps : dict[label, (distance, variable, velocity)]
Each entry maps a string label to a tuple of three equal-length
float64 arrays: cumulative distance (m), the variable to plot,
and velocity (m/s) used for time-delta computation.
variable_name : str
Name of the plotted variable (axis label and title).
reference_lap : str or None
Key from *laps* to use as the time-delta reference. ``None``
selects the lap with the lowest estimated total time. Ignored
when *baseline_dist/var/vel* are provided.
data_label : str
Label used in hover templates.
dx : float
Resampling step for the common distance grid (m).
font_config : FontConfig
layout_config : LayoutConfig
Returns
-------
go.Figure
"""
# Build common distance grid
all_max_dists = [v[0][-1] for v in laps.values()]
grid_max = min(all_max_dists)
grid = np.arange(0, grid_max, dx)
# Interpolate every series onto the grid
grid_var: Dict[str, npt.NDArray[np.float64]] = {}
grid_vel: Dict[str, npt.NDArray[np.float64]] = {}
for k, v in laps.items():
d, var, vel = v
grid_var[k] = np.interp(grid, d, var, left=np.nan, right=np.nan)
grid_vel[k] = np.interp(grid, d, vel, left=np.nan, right=np.nan)
# Use specified reference lap or select the one with the lowest estimated total drive time
if reference_lap is not None:
if reference_lap not in laps:
raise ValueError(f"reference_lap={reference_lap} not in laps")
ref_label = reference_lap
else:
ref_label = None
ref_time = float("inf")
for k, val in laps.items():
# Estimate by summing dt = dx / vel, ignoring zero/near-zero velocities
dt = np.zeros_like(ref_vel, dtype=float)
mask = ref_vel > 0.5
dt[mask] = dx / ref_vel[mask]
total_drive_time = float(np.nansum(dt))
if total_drive_time < ref_time:
ref_time = total_drive_time
ref_label = k
# Color map for consistency across subplots and hover templates
palette = qualitative.Plotly * (len(laps) // len(qualitative.Plotly) + 1)
color_map: Dict[str, str] = {lb: palette[i] for i, lb in enumerate(laps.keys())}
subplot_titles = (
f"{variable_name} vs Distance",
f"Cumulative Time Delta v.s. {ref_label} Lap",
)
fig = make_subplots(
rows=2,
cols=1,
subplot_titles=subplot_titles,
vertical_spacing=layout_config.grid_vertical_spacing,
shared_xaxes=True,
)
# Variable traces
for label in laps.keys():
fig.add_trace(
go.Scattergl(
x=grid,
y=grid_var[label],
mode="lines",
name=f"{label} (ref)" if label == ref_label else label,
legendgroup=label,
line=dict(
color=color_map[label],
width=LINE_WIDTH * 2 if label == ref_label else LINE_WIDTH,
),
hovertemplate=f"{label}: %{{y:{FLOAT_PRECISION}}}{data_label}<extra></extra>",
),
row=1,
col=1,
)
fig.update_yaxes(
title_text=variable_name,
title_font=dict(size=font_config.medium, color=TEXT_COLOR_DARK),
row=1,
col=1,
)
# Cumulative time delta vs reference
ref_vel = grid_vel[ref_label]
dt_ref = np.zeros_like(ref_vel, dtype=float)
mask = ref_vel > 0.5
dt_ref[mask] = dx / ref_vel[mask]
for label in laps.keys():
if label == ref_label:
continue
vel = grid_vel[label]
dt_cmp = np.zeros_like(vel, dtype=float)
mask = vel > 0.5
dt_cmp[mask] = dx / vel[mask]
cum_delta = np.cumsum(dt_cmp - dt_ref)
fig.add_trace(
go.Scattergl(
x=grid,
y=cum_delta,
mode="lines",
name=f"Delta t of {label}",
legendgroup=label,
showlegend=False,
line=dict(color=color_map[label], width=LINE_WIDTH),
hovertemplate=(
f"{label} %{{y:{FLOAT_PRECISION}}}s vs {ref_label}<br>"
f"x=%{{x:{FLOAT_PRECISION}}}<extra></extra>"
),
),
row=2,
col=1,
)
fig.update_xaxes(
title_text="Distance (m)",
title_font=dict(size=font_config.medium, color=TEXT_COLOR_DARK),
row=2,
col=1,
)
fig.update_yaxes(
title_text="Delta t (s) (positive = slower)",
title_font=dict(size=font_config.medium, color=TEXT_COLOR_DARK),
row=2,
col=1,
)
fig.update_layout(
title={
"text": f"{variable_name} Comparison (ref: {ref_label})",
"font": dict(size=font_config.large, color=TEXT_COLOR_DARK),
"x": layout_config.title_x,
"xanchor": layout_config.title_xanchor,
},
height=2 * layout_config.grid_height_per_row,
width=layout_config.width,
margin=layout_config.margin,
showlegend=True,
hovermode=HOVER_MODE,
plot_bgcolor=layout_config.plot_bgcolor,
)
return fig