diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 50312cd5..bbf67d87 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -55,7 +55,7 @@ jobs: - run: cd rust/ && cargo test - name: install python dependencies - run: pip install -U setuptools wheel twine cibuildwheel + run: pip install -U setuptools wheel twine cibuildwheel plotly - name: build sdist if: matrix.os == 'ubuntu' && matrix.python-version == '8' diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index bf058097..58760bf5 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -36,6 +36,6 @@ jobs: - name: Python unit tests run: | - pip install setuptools_rust pytest + pip install setuptools_rust pytest plotly pip install . pytest -v fastsim/tests/ diff --git a/.github/workflows/wheels.yaml b/.github/workflows/wheels.yaml index bd68f8f8..f50a1aa2 100644 --- a/.github/workflows/wheels.yaml +++ b/.github/workflows/wheels.yaml @@ -54,7 +54,7 @@ jobs: - run: cd rust/ && cargo test - name: install python dependencies - run: pip install -U setuptools wheel twine cibuildwheel + run: pip install -U setuptools wheel twine cibuildwheel plotly - name: build sdist if: matrix.os == 'ubuntu' && matrix.python-version == '8' diff --git a/fastsim/calibration.py b/fastsim/calibration.py index 45acc261..ed4ae38a 100644 --- a/fastsim/calibration.py +++ b/fastsim/calibration.py @@ -6,12 +6,13 @@ import argparse # pymoo -from pymoo.util.display.output import Output +from pymoo.util.display.output import Output from pymoo.util.display.column import Column from pymoo.operators.sampling.lhs import LatinHypercubeSampling as LHS from pymoo.termination.default import DefaultMultiObjectiveTermination as DMOT from pymoo.core.problem import Problem, ElementwiseProblem, LoopedElementwiseEvaluation, StarmapParallelization from pymoo.algorithms.moo.nsga3 import NSGA3 +from pymoo.algorithms.moo.nsga2 import NSGA2 from pymoo.util.ref_dirs import get_reference_directions from pymoo.algorithms.base.genetic import GeneticAlgorithm from pymoo.optimize import minimize @@ -22,6 +23,10 @@ import json import time import matplotlib.pyplot as plt +from matplotlib.figure import Figure +from matplotlib.axes import Axes +import plotly.graph_objects as go +from plotly.subplots import make_subplots import numpy as np # local -- expects rust-port version of fastsim so make sure this is in the env @@ -82,8 +87,10 @@ class ModelObjectives(object): verbose: bool = False # calculated in __post_init__ - n_obj: int = None + n_obj: Optional[int] = None + # whether to use simdrive hot + # TOOD: consider passing the type to be used rather than this boolean in the future use_simdrivehot: bool = False def __post_init__(self): @@ -97,59 +104,65 @@ def get_errors( return_mods: Optional[bool] = False, plot: Optional[bool] = False, plot_save_dir: Optional[str] = None, - plot_perc_err: Optional[bool] = True, + plot_perc_err: Optional[bool] = False, show: Optional[bool] = False, fontsize: Optional[float] = 12, + plotly: Optional[bool] = False, ) -> Union[ Dict[str, Dict[str, float]], # or if return_mods is True Dict[str, fsim.simdrive.SimDrive], ]: - # TODO: should return type instead be `Dict[str, Dict[str, float]] | Tuple[Dict[str, Dict[str, float]], Dict[str, fsim.simdrive.SimDrive]]` - # This would make `from typing import Union` unnecessary """ Calculate model errors w.r.t. test data for each element in dfs/models for each objective. Arguments: ---------- + - sim_drives: dictionary with user-defined keys and SimDrive or SimDriveHot instances - return_mods: if true, also returns dict of solved models - - plot: if true, plots objectives + - plot: if true, plots objectives using matplotlib.pyplot + - plot_save_dir: directory in which to save plots. If `None` (default), plots are not saved. + - plot_perc_err: whether to include % error axes in plots + - show: whether to show matplotlib.pyplot plots + - fontsize: plot font size + - plotly: whether to generate plotly plots, which can be opened manually in a browser window """ + # TODO: should return type instead be `Dict[str, Dict[str, float]] | Tuple[Dict[str, Dict[str, float]], Dict[str, fsim.simdrive.SimDrive]]` + # This would make `from typing import Union` unnecessary - objectives = {} - solved_mods = {} + objectives: Dict = {} + solved_mods: Dict = {} # loop through all the provided trips for ((key, df_exp), sim_drive) in zip(self.dfs.items(), sim_drives.values()): t0 = time.perf_counter() sim_drive = sim_drive.copy() # TODO: do we need this? - sim_drive.sim_drive() + sim_drive.sim_drive() # type: ignore + t1 = time.perf_counter() + if self.verbose: + print(f"Time to simulate {key}: {t1 - t0:.3g}") objectives[key] = {} if return_mods or plot: solved_mods[key] = sim_drive.copy() - if plot or plot_save_dir: - Path(plot_save_dir).mkdir(exist_ok=True, parents=True) - if not self.use_simdrivehot: - time_hr = np.array(sim_drive.cyc.time_s) / 3_600 - mph_ach = sim_drive.mph_ach - else: - time_hr = np.array(sim_drive.sd.cyc.time_s) / 3_600 - mph_ach = sim_drive.sd.mph_ach - ax_multiplier = 2 if plot_perc_err else 1 - fig, ax = plt.subplots( - len(self.obj_names) * ax_multiplier + 1, 1, sharex=True, figsize=(12, 8), - ) - plt.suptitle(f"trip: {key}", fontsize=fontsize) - ax[-1].plot( - time_hr, - mph_ach, - ) - ax[-1].set_xlabel('Time [hr]', fontsize=fontsize) - ax[-1].set_ylabel('Speed [mph]', fontsize=fontsize) - - t1 = time.perf_counter() - if self.verbose: - print(f"Time to simulate {key}: {t1 - t0:.3g}") + ax_multiplier = 2 if plot_perc_err else 1 + # extract speed trace for plotting + if not self.use_simdrivehot: + time_hr = np.array(sim_drive.cyc.time_s) / 3_600 # type: ignore + mph_ach = sim_drive.mph_ach # type: ignore + else: + time_hr = np.array(sim_drive.sd.cyc.time_s) / 3_600 # type: ignore + mph_ach = sim_drive.sd.mph_ach # type: ignore + fig, ax, pltly_fig = self.setup_plots( + plot or show, + plot_save_dir, + sim_drive, + fontsize, + key, + ax_multiplier, + time_hr, + mph_ach, + plotly, + ) # loop through the objectives for each trip for i_obj, obj in enumerate(self.obj_names): @@ -180,51 +193,34 @@ def get_errors( else: pass # TODO: write else block for objective minimization - - if plot or plot_save_dir: - # this code needs to be cleaned up - # raw signals - ax[i_obj * ax_multiplier].set_title( - f"error: {objectives[key][obj[0]]:.3g}", fontsize=fontsize) - ax[i_obj * ax_multiplier].plot(time_hr, - mod_sig, label='mod') - ax[i_obj * ax_multiplier].plot(time_hr, - ref_sig, - linestyle='--', - label="exp", - ) - ax[i_obj * - ax_multiplier].set_ylabel(obj[0], fontsize=fontsize) - ax[i_obj * ax_multiplier].legend(fontsize=fontsize) - - if plot_perc_err: - # error - if "deg_c" in mod_path: - perc_err = (mod_sig - ref_sig) / \ - (ref_sig + 273.15) * 100 - else: - perc_err = (mod_sig - ref_sig) / ref_sig * 100 - # clean up inf and nan - perc_err[np.where(perc_err == np.inf)[0][:]] = 0.0 - # trim off the first few bits of junk - perc_err[np.where(perc_err > 500)[0][:]] = 0.0 - ax[i_obj * ax_multiplier + 1].plot( - time_hr, - perc_err - ) - ax[i_obj * ax_multiplier + - 1].set_ylabel(obj[0] + "\n%Err", fontsize=fontsize) - ax[i_obj * ax_multiplier + 1].set_ylim([-20, 20]) - - if show: - plt.show() - - if plot_save_dir: - if not Path(plot_save_dir).exists(): - Path(plot_save_dir).mkdir() - plt.tight_layout() + + update_plots( + ax, + pltly_fig, + i_obj, + ax_multiplier, + objectives, + key, + obj, + fontsize, + time_hr, + mod_sig, + ref_sig, + plot_perc_err, + mod_path, + show, + ) + + if plot_save_dir is not None: + if not Path(plot_save_dir).exists(): + Path(plot_save_dir).mkdir(exist_ok=True, parents=True) + if ax is not None: plt.savefig(Path(plot_save_dir) / f"{key}.svg") plt.savefig(Path(plot_save_dir) / f"{key}.png") + plt.tight_layout() + if pltly_fig is not None: + pltly_fig.update_layout(showlegend=True) + pltly_fig.write_html(str(Path(plot_save_dir) / f"{key}.html")) t2 = time.perf_counter() if self.verbose: @@ -262,11 +258,164 @@ def update_params(self, xs: List[Any]): else: veh = sim_drives[key].sd.veh veh.set_derived() - sim_drives[key].sd.veh = veh + fsim.utils.set_attr_with_path(sim_drives[key], "sd.veh", veh) t1 = time.perf_counter() if self.verbose: print(f"Time to update params: {t1 - t0:.3g} s") return sim_drives + + def setup_plots( + self, + plot: bool, + plot_save_dir: Optional[Path], + sim_drive: Union[fsr.RustSimDrive, fsr.SimDriveHot], + fontsize: float, + key: str, + ax_multiplier: int, + time_hr: float, + mph_ach: float, + plotly: bool, + ) -> Tuple[Figure, Axes, go.Figure]: + rows = len(self.obj_names) * ax_multiplier + 1 + + if plotly and (plot_save_dir is not None): + pltly_fig = make_subplots( + rows=rows, + cols=1, + shared_xaxes=True, + vertical_spacing=0.05, + ) + pltly_fig.update_layout(title=f'trip: {key}') + pltly_fig.add_trace( + go.Scatter( + x=time_hr, + y=mph_ach, + name="mph", + ), + row=rows, + col=1, + ) + pltly_fig.update_xaxes(title_text="Time [hr]", row=rows, col=1) + pltly_fig.update_yaxes(title_text="Speed [mph]", row=rows, col=1) + elif plotly: + raise Exception("`plot_save_dir` must also be provided for `plotly` to have any effect.") + else: + pltly_fig = None + + if plot: + # make directory if it doesn't exist + Path(plot_save_dir).mkdir(exist_ok=True, parents=True) + fig, ax = plt.subplots( + len(self.obj_names) * ax_multiplier + 1, 1, sharex=True, figsize=(12, 8), + ) + plt.suptitle(f"trip: {key}", fontsize=fontsize) + ax[-1].plot( + time_hr, + mph_ach, + ) + ax[-1].set_xlabel('Time [hr]', fontsize=fontsize) + ax[-1].set_ylabel('Speed [mph]', fontsize=fontsize) + return fig, ax, pltly_fig + else: + return (None, None, None) + +def update_plots( + ax: Optional[Axes], + pltly_fig: go.Figure, + i_obj: int, + ax_multiplier: int, + objectives: Dict, + key: str, + obj: Any, # need to check type on this + fontsize: int, + time_hr: np.ndarray, + mod_sig: np.ndarray, + ref_sig: np.ndarray, + plot_perc_err: bool, + mod_path: str, + show: bool, +): + if ax is not None: + # this code needs to be cleaned up + # raw signals + ax[i_obj * ax_multiplier].set_title( + f"error: {objectives[key][obj[0]]:.3g}", fontsize=fontsize) + ax[i_obj * ax_multiplier].plot(time_hr, + mod_sig, label='mod') + ax[i_obj * ax_multiplier].plot(time_hr, + ref_sig, + linestyle='--', + label="exp", + ) + ax[i_obj * + ax_multiplier].set_ylabel(obj[0], fontsize=fontsize) + ax[i_obj * ax_multiplier].legend(fontsize=fontsize) + + if plot_perc_err: + # error + if "deg_c" in mod_path: + perc_err = (mod_sig - ref_sig) / \ + (ref_sig + 273.15) * 100 + else: + perc_err = (mod_sig - ref_sig) / ref_sig * 100 + # clean up inf and nan + perc_err[np.where(perc_err == np.inf)[0][:]] = 0.0 + # trim off the first few bits of junk + perc_err[np.where(perc_err > 500)[0][:]] = 0.0 + ax[i_obj * ax_multiplier + 1].plot( + time_hr, + perc_err + ) + ax[i_obj * ax_multiplier + + 1].set_ylabel(obj[0] + "\n%Err", fontsize=fontsize) + ax[i_obj * ax_multiplier + 1].set_ylim([-20, 20]) + + if show: + plt.show() + + if pltly_fig is not None: + pltly_fig.add_trace( + go.Scatter( + x=time_hr, + y=mod_sig, + # might want to prepend signal name for this + name=obj[0] + ' mod', + ), + # add 1 for 1-based indexing in plotly + row=i_obj * ax_multiplier + 1, + col=1, + ) + pltly_fig.add_trace( + go.Scatter( + x=time_hr, + y=ref_sig, + # might want to prepend signal name for this + name=obj[0] + ' exp', + ), + # add 1 for 1-based indexing in plotly + row=i_obj * ax_multiplier + 1, + col=1, + ) + pltly_fig.update_yaxes(title_text=obj[1], row=i_obj * ax_multiplier + 1, col=1) + + if plot_perc_err: + pltly_fig.add_trace( + go.Scatter( + x=time_hr, + y=perc_err, + # might want to prepend signal name for this + name=obj[0] + ' % err', + ), + # add 2 for 1-based indexing and offset for % err plot + row=i_obj * ax_multiplier + 2, + col=1, + ) + # pltly_fig.update_yaxes(title_text=obj[0] + "%Err", row=i_obj * ax_multiplier + 2, col=1) + pltly_fig.update_yaxes(title_text="%Err", row=i_obj * ax_multiplier + 2, col=1) + + + + @dataclass @@ -328,7 +477,7 @@ def run_minimize( copy_algorithm: bool = False, copy_termination: bool = False, save_history: bool = False, - save_path: Optional[str] = Path("pymoo_res/"), + save_path: Union[Path, str] = Path("pymoo_res/"), ): print("`run_minimize` starting at") fsim.utils.print_dt() diff --git a/fastsim/docs/demo.py b/fastsim/docs/demo.py index 0085708a..925b0d4f 100644 --- a/fastsim/docs/demo.py +++ b/fastsim/docs/demo.py @@ -1,7 +1,5 @@ # To add a new cell, type '# %%' # To add a new markdown cell, type '# %% [markdown]' -# %% -from IPython import get_ipython # %% [markdown] # # FASTSim Demonstration @@ -32,10 +30,6 @@ # import seaborn as sns # sns.set(font_scale=2, style='whitegrid') -if not __name__ == "__main__": - get_ipython().run_line_magic('matplotlib', 'inline') - - # local modules import fastsim as fsim # importlib.reload(simdrive) @@ -808,7 +802,6 @@ ax.set_xlabel('Cycle Time [s]', weight='bold') ax.set_ylabel('Engine Input Power [kW]', weight='bold', color='xkcd:bluish') ax.tick_params('y', colors='xkcd:bluish') - ax2.set_ylabel('Speed [MPH]', weight='bold', color='xkcd:pale red') ax2.grid(False) ax2.tick_params('y', colors='xkcd:pale red') @@ -824,6 +817,6 @@ (drag_coef, wheel_rr_coef) = abc_to_drag_coeffs(test_veh, 25.91, 0.1943, 0.01796, simdrive_optimize=True) # %% -print(f'Drag Coefficient: {drag_coef}') -print(f'Wheel Rolling Resistance Coefficient: {wheel_rr_coef}') +print(f'Drag Coefficient: {drag_coef:.3g}') +print(f'Wheel Rolling Resistance Coefficient: {wheel_rr_coef:.3g}') # %% diff --git a/fastsim/docs/fusion_thermal_cal.py b/fastsim/docs/fusion_thermal_cal.py index 1f1de7da..45f08e33 100644 --- a/fastsim/docs/fusion_thermal_cal.py +++ b/fastsim/docs/fusion_thermal_cal.py @@ -10,6 +10,8 @@ import fastsimrust as fsr +use_nsga2 = True + def load_data() -> Dict[str, pd.DataFrame]: # full data dfs_raw = dict() @@ -35,12 +37,13 @@ def load_data() -> Dict[str, pd.DataFrame]: dfs[file.stem] = fsim.resample( dfs_raw[file.stem], - rate_vars=('Eng_FuelFlow_Direct[cc/s]') + rate_vars=('Eng_FuelFlow_Direct[cc/s]',) ) + assert len(dfs) > 0 return dfs -def get_cal_and_val_objs(use_simdrivehot: bool): +def get_cal_and_val_objs(): dfs = load_data() # Separate calibration and validation cycles @@ -121,7 +124,8 @@ def get_cal_and_val_objs(use_simdrivehot: bool): dfs=dfs_cal, obj_names=obj_names, params=params, - verbose=False + use_simdrivehot=True, + verbose=False, ) # to ensure correct key order @@ -131,8 +135,8 @@ def get_cal_and_val_objs(use_simdrivehot: bool): dfs=dfs_val, obj_names=obj_names, params=params, - use_simdrivehot=use_simdrivehot, - verbose=False + use_simdrivehot=True, + verbose=False, ) return cal_objectives, val_objectives, params_bounds @@ -172,21 +176,27 @@ def get_cal_and_val_objs(use_simdrivehot: bool): # override default of False use_simdrivehot = True - cal_objectives, val_objectives, params_bounds = get_cal_and_val_objs(use_simdrivehot) + cal_objectives, val_objectives, params_bounds = get_cal_and_val_objs() if run_minimize: print("Starting calibration.") - - algorithm = fsim.calibration.NSGA3( - ref_dirs=fsim.calibration.get_reference_directions( - "energy", - n_dim=cal_objectives.n_obj, # must be at least cal_objectives.n_obj - n_points=pop_size, # must be at least pop_size - ), - # size of each population - pop_size=pop_size, - sampling=fsim.calibration.LHS(), - ) + if use_nsga2: + algorithm = fsim.calibration.NSGA2( + # size of each population + pop_size=pop_size, + sampling=fsim.calibration.LHS(), + ) + else: + algorithm = fsim.calibration.NSGA3( + ref_dirs=fsim.calibration.get_reference_directions( + "energy", + n_dim=cal_objectives.n_obj, # must be at least cal_objectives.n_obj + n_points=pop_size, # must be at least pop_size + ), + # size of each population + pop_size=pop_size, + sampling=fsim.calibration.LHS(), + ) termination = fsim.calibration.DMOT( # max number of generations, default of 10 is very small n_max_gen=n_max_gen, @@ -248,12 +258,15 @@ def get_cal_and_val_objs(use_simdrivehot: bool): plot_save_dir=save_path, show=show_plots and make_plots, plot=make_plots, + plotly=make_plots, return_mods=True, ) val_objectives.get_errors( val_objectives.update_params(param_vals), plot_save_dir=save_path, show=show_plots, + plot=make_plots, + plotly=make_plots, ) # save calibrated vehicle to file diff --git a/fastsim/docs/fusion_thermal_cal_post.py b/fastsim/docs/fusion_thermal_cal_post.py index 985dabad..b4b0f816 100644 --- a/fastsim/docs/fusion_thermal_cal_post.py +++ b/fastsim/docs/fusion_thermal_cal_post.py @@ -45,12 +45,14 @@ plot_save_dir=Path(save_path), show=show_plots, return_mods=True, + plotly=True, ) val_errs, val_mods = val_objectives.get_errors( val_objectives.update_params(param_vals), plot_save_dir=Path(save_path), show=show_plots, return_mods=True, + plotly=True ) # %% diff --git a/fastsim/docs/fusion_thermal_demo.py b/fastsim/docs/fusion_thermal_demo.py index 826b8386..47948275 100644 --- a/fastsim/docs/fusion_thermal_demo.py +++ b/fastsim/docs/fusion_thermal_demo.py @@ -11,9 +11,6 @@ from pathlib import Path import os import sys -from IPython import get_ipython -get_ipython().run_line_magic('matplotlib', 'inline') - # %% sns.set() @@ -102,8 +99,8 @@ ax[-1].set_xlabel("Time") ax[-1].set_ylabel("Speed [mph]") plt.tight_layout() -plt.savefig("plots/fusion udds cold start.png") -plt.savefig("plots/fusion udds cold start.svg") +# plt.savefig("plots/fusion udds cold start.png") +# plt.savefig("plots/fusion udds cold start.svg") # %% Case with cabin cooling @@ -150,8 +147,8 @@ ax[-1].set_xlabel("Time") ax[-1].set_ylabel("Speed [mph]") plt.tight_layout() -plt.savefig("plots/fusion udds hot start.png") -plt.savefig("plots/fusion udds hot start.svg") +# plt.savefig("plots/fusion udds hot start.png") +# plt.savefig("plots/fusion udds hot start.svg") # %% sweep ambient @@ -195,6 +192,6 @@ ax.set_xlabel('Ambient/Init. Temp [°C]') ax.set_ylabel('Fuel Economy [mpg]') plt.tight_layout() -plt.savefig("plots/fusion FE vs temp sweep.png") -plt.savefig("plots/fusion FE vs temp sweep.svg") +# plt.savefig("plots/fusion FE vs temp sweep.png") +# plt.savefig("plots/fusion FE vs temp sweep.svg") # %% diff --git a/pyproject.toml b/pyproject.toml index e3a13a8e..e14aa1a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "Homepage" = "https://www.nrel.gov/transportation/fastsim.html" [project.optional-dependencies] -dev = ["black", "pytest", "maturin"] +dev = ["black", "pytest", "maturin", "plotly"] [tool.setuptools] zip-safe = false