Source code for suboptimumg.compsim.custom_run

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