Source code for suboptimumg.plotting.gps_visualization

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