from enum import Enum
import numpy.typing as npt
import plotly.graph_objects as go
from scipy.interpolate import interp1d
from scipy.spatial import cKDTree
from ..plotting.plotting_constants import (
ColorbarConfig,
FontConfig,
LayoutConfig,
)
from ..track import *
from ..vehicle import Car
from .lap import *
from .models import *
from .utils import energy_data
[docs]
class OverlayOptions(str, Enum):
VELOCITY = "lap_vels"
ACCELERATION = "lap_accs"
MOTOR_TORQUE = "lap_eff_motor_torques"
POWER = "lap_powers"
[docs]
class CustomRun:
def __init__(
self,
mycar: Car,
track: Track,
):
self.mycar = mycar
self.track = track
self.sim_results_no_coast: LapsimResults | None = None
self.sim_results_coast: LapsimResults | None = None
[docs]
def run(self, extract_internal_data: bool = False) -> LapsimResults:
"""
Simulates a run along the custom track. Sets the sim_results attribute for future use.
Parameters
----------
extract_internal_data : bool, optional
Whether to extract internal data during the simulation, by default False.
Returns
-------
LapsimResults
Results from running the custom track.
"""
print("Running simulation on custom track...")
self.sim_results_no_coast = lapsim(
mycar=self.mycar,
track=self.track,
use_coast=False,
extract_internal_data=extract_internal_data,
)
self.sim_results_coast = lapsim(
mycar=self.mycar,
track=self.track,
use_coast=True,
extract_internal_data=extract_internal_data,
)
return self.sim_results_no_coast, self.sim_results_coast
[docs]
def track_overlay(
self,
var: OverlayOptions = OverlayOptions.VELOCITY,
use_coast: bool = True,
rotate_by_deg: float = 0.0,
font_config: FontConfig | None = None,
layout_config: LayoutConfig | None = None,
colorbar_config: ColorbarConfig | None = None,
) -> go.Figure:
"""
Overlay plot of the custom run results on the track layout.
Parameters
------------
var : OverlayOptions, optional
The variable to overlay on the track plot. Default is velocity
use_coast : bool, optional
Whether to use coasting results from the simulation. Default is True
rotate_by_deg : float, optional
Degrees to rotate the track plot for better visualization. Default is 0.0
font_config : FontConfig | None, optional
layout_config : LayoutConfig | None, optional
colorbar_config : ColorbarConfig | None, optional
"""
if self.sim_results_no_coast is None or self.sim_results_coast is None:
self.run()
vals = getattr(
self.sim_results_coast if use_coast else self.sim_results_no_coast,
var.value,
)
return self.track.plot_with_overlay(
variable=vals,
label=var.name.replace("_", " ").title(),
rotate_by_deg=rotate_by_deg,
theme="dri",
font_config=font_config,
layout_config=layout_config,
colorbar_config=colorbar_config,
)
[docs]
def comparison_track_overlays(
self,
sim_var: OverlayOptions,
data_var: npt.NDArray,
lat: npt.NDArray,
long: npt.NDArray,
use_coast: bool = True,
rotate_by_deg: float = 0.0,
show_matching_info: bool = False,
display_raw_data: bool = False,
interpolate_gaps: bool = False,
font_config: FontConfig | None = None,
layout_config: LayoutConfig | None = None,
colorbar_config: ColorbarConfig | None = None,
) -> None:
"""
Overlay plot comparing simulation results to real-world data on the track layout.
Parameters
------------
sim_var : OverlayOptions
The simulation variable to compare
data_var : npt.NDArray
The real-world data variable to compare
lat : npt.NDArray
Array of GPS latitudes corresponding to the data variable
long : npt.NDArray
Array of GPS longitudes corresponding to the data variable
use_coast : bool, optional
Whether to use coasting results from the simulation. Default is True
rotate_by_deg : float, optional
Degrees to rotate the track plot for better visualization. Default is 0.0
show_matching_info : bool, optional
If True, print matching information during GPS to track matching. Default is False
display_raw_data : bool, optional
If True, plot the projected GPS data rather than the difference. Default is False
font_config : FontConfig | None, optional
layout_config : LayoutConfig | None, optional
colorbar_config : ColorbarConfig | None, optional
"""
sim_vals = getattr(
self.sim_results_no_coast if not use_coast else self.sim_results_coast,
sim_var.value,
)
track_matched_data_vals = self._match_gps_to_track(
lat,
long,
data_var,
show_matching_info=show_matching_info,
interpolate_gaps=interpolate_gaps,
)
if display_raw_data:
diff = track_matched_data_vals
title = f"GPS Matched {sim_var.name.replace('_', ' ').title()}"
label = "real data"
else:
diff = sim_vals - track_matched_data_vals
title = (f"Difference in {sim_var.name.replace('_', ' ').title()}",)
label = ("sim_data - real_data",)
return self.track.plot_with_overlay(
variable=diff,
title=title,
label=label,
rotate_by_deg=rotate_by_deg,
theme="rdbu",
font_config=font_config,
layout_config=layout_config,
colorbar_config=colorbar_config,
)
[docs]
def print_results_summary(self) -> None:
"""
Print a summary of the custom run results.
"""
if self.sim_results_no_coast is None or self.sim_results_coast is None:
self.run()
distance = round(self.track.cumulative_dist[-1], 2)
print(f"{'Distance:':<10} {str(distance) + ' m':<10} ")
time_no_coast = round(self.sim_results_no_coast.lap_t[-1], 2)
time_coast = round(self.sim_results_coast.lap_t[-1], 2)
_, regened_energy_no_coast, consumed_energy_no_coast = energy_data(
self.sim_results_no_coast.lap_t,
self.sim_results_no_coast.lap_powers,
)
_, regened_energy_coast, consumed_energy_coast = energy_data(
self.sim_results_coast.lap_t, self.sim_results_coast.lap_powers
)
print("\nNO COAST")
print(f"{'Laptime:':<10} {str(time_no_coast) + ' s':<10}")
print(
f"{'Net Energy:':<10} {str(round(consumed_energy_no_coast, 3)) + ' kWh':<10} {'Regen:':<8} {str(round(regened_energy_no_coast, 3)) + ' kWh':<10}"
)
print("\nCOAST")
print(f"{'Laptime:':<10} {str(time_coast) + ' s':<10}")
print(
f"{'Net Energy:':<10} {str(round(consumed_energy_coast, 3)) + ' kWh':<10} {'Regen:':<8} {str(round(regened_energy_coast, 3)) + ' kWh':<10}"
)
def _match_gps_to_track(
self,
lat: npt.NDArray,
long: npt.NDArray,
val: npt.NDArray,
interpolate_gaps: bool = False,
show_matching_info: bool = False, # unused
):
"""
Project scattered log data onto the smoothed track path.
Parameters
----------
lat, long : array-like
Log GPS coordinates (unordered, potentially discontinuous).
val : array-like
Data values corresponding to each log point.
interpolate_gaps : bool
If True, linearly interpolate gaps instead of setting them to 0.
Returns
-------
projected : np.ndarray
Data values projected onto each track point.
"""
if self.sim_results_no_coast is None or self.sim_results_coast is None:
self.run()
if len(lat) != len(long) or len(lat) != len(val):
raise ValueError(
"Latitude, longitude, and value arrays must have the same length"
)
if len(lat) < 2:
raise ValueError("At least two GPS points are required for matching")
val = np.asarray(val, dtype=float)
# Convert GPS log coordinates to local cartesian
x_log, y_log, _ = gps_to_cartesian(
CoordinateListInput(
latitudes=lat,
longitudes=long,
),
origin_offset=True,
origin=self.track.absolute_origin,
)
# Pull track coordinates
track_x = np.asarray(self.track.x_m)
track_y = np.asarray(self.track.y_m)
n_track = len(track_x)
# Build KD-tree from track points for fast nearest-neighbor lookup
tree = cKDTree(np.column_stack((track_x, track_y)))
# For each log point, find the nearest track point index
_, nearest_indices = tree.query(np.column_stack((x_log, y_log)))
# Accumulate sums and counts per track index
sums = np.zeros(n_track)
counts = np.zeros(n_track, dtype=int)
np.add.at(sums, nearest_indices, val)
np.add.at(counts, nearest_indices, 1)
# Average where we have matches
matched = counts > 0
projected = np.zeros(n_track)
projected[matched] = sums[matched] / counts[matched]
if interpolate_gaps:
matched_indices = np.where(matched)[0]
if len(matched_indices) >= 2:
all_indices = np.arange(n_track)
projected = np.interp(
all_indices,
matched_indices,
projected[matched_indices],
left=projected[matched_indices[0]],
right=projected[matched_indices[-1]],
)
return projected
def _match_gps_to_track_old(
self,
lat: npt.NDArray,
long: npt.NDArray,
val: npt.NDArray,
show_matching_info: bool = False,
) -> npt.NDArray:
"""
Match GPS data to the custom track for comparison.
Parameters
----------
lat : npt.NDArray
Array of GPS latitudes
long : npt.NDArray
Array of GPS longitudes
val : npt.NDArray
Array of values corresponding to each GPS coordinate
show_matching_info : bool, optional
If True, print matching information. Default is False
Returns
-------
npt.NDArray
Array of values with same length as track points, interpolated from GPS data
"""
if self.sim_results_no_coast is None or self.sim_results_coast is None:
self.run()
if len(lat) != len(long) or len(lat) != len(val):
raise ValueError(
"Latitude, longitude, and value arrays must have the same length"
)
if len(lat) < 2:
raise ValueError("At least two GPS points are required for matching")
# Convert GPS coordinates to Cartesian coordinates
x_m_irl, y_m_irl, _ = gps_to_cartesian(
CoordinateListInput(
latitudes=lat,
longitudes=long,
), # No other parameters used by this function
origin_offset=True,
origin=self.track.absolute_origin,
)
# Initialize array to store matched values
n_track = len(self.track.x_m)
n_gps = len(x_m_irl)
matched_vals = np.full(n_track, np.nan)
matched_distances = np.full(n_track, np.inf)
# For each GPS point, find the nearest track point
# This is O(n_gps * n_track) but robust to noise
for gps_idx in range(n_gps):
# Calculate distances to all track points using vectorized operations
distances = np.sqrt(
(self.track.x_m - x_m_irl[gps_idx]) ** 2
+ (self.track.y_m - y_m_irl[gps_idx]) ** 2
)
# Find the closest track point
best_track_idx = np.argmin(distances)
best_distance = distances[best_track_idx]
# Update matched values if this is a better match for this track point
if best_distance < matched_distances[best_track_idx]:
matched_vals[best_track_idx] = val[gps_idx]
matched_distances[best_track_idx] = best_distance
# Get indices of matched and unmatched points
matched_indices = np.where(~np.isnan(matched_vals))[0]
# Get matched cumulative distances and values
matched_dists = self.track.cumulative_dist[matched_indices]
matched_values = matched_vals[matched_indices]
# Interpolate to fill in the gaps
# Use cumulative distance as the x-axis for interpolation
interp_func = interp1d(
matched_dists,
matched_values,
kind="linear",
fill_value="extrapolate",
)
interpolated_vals = interp_func(self.track.cumulative_dist)
if show_matching_info:
valid_distances = matched_distances[matched_indices]
print("==== GPS/Track Matching Results ====")
print(f"GPS points processed: {n_gps}")
print(f"Track points matched: {len(matched_indices)} / {n_track}")
print(f"Min match distance: {valid_distances.min():.2f}m")
print(f"Max match distance: {valid_distances.max():.2f}m")
print(f"Mean match distance: {valid_distances.mean():.2f}m")
return interpolated_vals
[docs]
def f1_trace_1(self) -> None:
"""
Plot F1 Trace 1 for the custom run results.
"""
if self.sim_results_no_coast is None or self.sim_results_coast is None:
self.run()
pass
[docs]
def f1_trace_2(self) -> None:
"""
Plot F1 Trace 2 for the custom run results.
"""
if self.sim_results_no_coast is None or self.sim_results_coast is None:
self.run()
pass