From a0a8c1ae443d71d79b6827047a8f1572fc0406ab Mon Sep 17 00:00:00 2001 From: Chad Baker Date: Fri, 15 Nov 2024 14:45:27 -0700 Subject: [PATCH 1/4] BEL can fail trace without triggering error added PyYAML --- pyproject.toml | 1 + python/altrios/__init__.py | 32 +++++++++---- python/altrios/altrios_pyo3.pyi | 1 + python/altrios/demos/bel_demo.py | 13 ++++- python/altrios/demos/conv_demo.py | 2 +- python/altrios/tests/mock_resources.py | 4 +- .../src/consist/locomotive/loco_sim.rs | 48 ++++++++++++------- 7 files changed, 69 insertions(+), 32 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6237d609..0034ef7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "polars==0.20.25", "pyarrow", "requests", + "PyYAML==6.0.2", ] [project.urls] diff --git a/python/altrios/__init__.py b/python/altrios/__init__.py index b221f2cc..8d065912 100644 --- a/python/altrios/__init__.py +++ b/python/altrios/__init__.py @@ -1,6 +1,5 @@ -from pkg_resources import get_distribution -__version__ = get_distribution("altrios").version - +from importlib.metadata import version +__version__ = version("altrios") from pathlib import Path import re import numpy as np @@ -48,7 +47,7 @@ def variable_path_list(self, element_as_list:bool=False) -> List[str]: # Arguments: - `element_as_list`: if True, each element is itself a list of the path elements """ - return variable_path_list_from_py_objs(self.to_pydict(), element_as_list=element_as_list) + return variable_path_list_from_py_objs(self.to_pydict(flatten=False), element_as_list=element_as_list) def variable_path_list_from_py_objs( obj: Union[Dict, List], @@ -121,20 +120,33 @@ def history_path_list(self, element_as_list:bool=False) -> List[str]: ] return history_path_list -def to_pydict(self) -> Dict: +def to_pydict(self, flatten: bool=True) -> Dict: """ Returns self converted to pure python dictionary with no nested Rust objects + # Arguments + - `flatten`: if True, returns dict without any hierarchy """ - import json - return json.loads(self.to_json()) + from yaml import load + try: + from yaml import CLoader as Loader + except ImportError: + from yaml import Loader + + pydict = load(self.to_yaml(), Loader=Loader) + if not flatten: + return pydict + else: + return next(iter(pd.json_normalize(pydict, sep=".").to_dict(orient='records'))) + @classmethod def from_pydict(cls, pydict: Dict) -> Self: """ Instantiates Self from pure python dictionary """ - import json - return cls.from_json(json.dumps(pydict)) + import yaml + return cls.from_yaml(yaml.dump(pydict)) + def to_dataframe(self, pandas:bool=False) -> [pd.DataFrame, pl.DataFrame, pl.LazyFrame]: """ @@ -143,7 +155,7 @@ def to_dataframe(self, pandas:bool=False) -> [pd.DataFrame, pl.DataFrame, pl.Laz # Arguments - `pandas`: returns pandas dataframe if True; otherwise, returns polars dataframe by default """ - obj_dict = self.to_pydict() + obj_dict = self.to_pydict(flatten=False) history_paths = self.history_path_list(element_as_list=True) cols = [".".join(hp) for hp in history_paths] vals = [] diff --git a/python/altrios/altrios_pyo3.pyi b/python/altrios/altrios_pyo3.pyi index d16a7940..a1225b10 100644 --- a/python/altrios/altrios_pyo3.pyi +++ b/python/altrios/altrios_pyo3.pyi @@ -404,6 +404,7 @@ class LocomotiveSimulation(SerdeAPI): i: int loco_unit: Locomotive power_trace: PowerTrace + allow_trace_miss: bool @classmethod def __init__(cls) -> None: ... def clone(self) -> Self: ... diff --git a/python/altrios/demos/bel_demo.py b/python/altrios/demos/bel_demo.py index 8ecd3e47..ca66ae0a 100644 --- a/python/altrios/demos/bel_demo.py +++ b/python/altrios/demos/bel_demo.py @@ -1,3 +1,4 @@ +# %% Copied from ALTRIOS version 'v0.2.3'. Guaranteed compatibility with this version only. # %% # Script for running the Wabtech BEL consist for sample data from Barstow to Stockton # Consist comprises [2X Tier 4](https://www.wabteccorp.com/media/3641/download?inline) @@ -10,6 +11,7 @@ import time import os import seaborn as sns +import pandas as pd sns.set_theme() @@ -20,6 +22,11 @@ pt = alt.PowerTrace.default() +pt_df = pd.read_json(pt.to_json()) +pt_df.time_seconds = pt_df.time_seconds * 100 +pt_df.engine_on = pt_df.engine_on.astype(str).str.lower() +pt_df.loc[:, ['time_seconds', 'engine_on', 'pwr_watts']].to_csv('pwr_trace.csv', index=False) +pt = pt.from_csv_file('pwr_trace.csv') res = alt.ReversibleEnergyStorage.from_file( alt.resources_root() / @@ -44,7 +51,7 @@ # instantiate battery model t0 = time.perf_counter() -sim = alt.LocomotiveSimulation(bel, pt, SAVE_INTERVAL) +sim = alt.LocomotiveSimulation(bel, pt, True, SAVE_INTERVAL) t1 = time.perf_counter() print(f"Time to load: {t1-t0:.3g}") @@ -54,7 +61,7 @@ t1 = time.perf_counter() print(f"Time to simulate: {t1-t0:.5g}") - +#%% bel_rslt = sim.loco_unit t_s = np.array(sim.power_trace.time_seconds) @@ -102,3 +109,5 @@ if SHOW_PLOTS: plt.tight_layout() plt.show() + +# %% diff --git a/python/altrios/demos/conv_demo.py b/python/altrios/demos/conv_demo.py index cb8f767f..6b5f3abf 100644 --- a/python/altrios/demos/conv_demo.py +++ b/python/altrios/demos/conv_demo.py @@ -46,7 +46,7 @@ pt = alt.PowerTrace.default() -sim = alt.LocomotiveSimulation(conv, pt, SAVE_INTERVAL) +sim = alt.LocomotiveSimulation(conv, pt, save_interval=SAVE_INTERVAL) t1 = time.perf_counter() print(f"Time to load: {t1-t0:.3g}") diff --git a/python/altrios/tests/mock_resources.py b/python/altrios/tests/mock_resources.py index 0e6ace32..3f485e6e 100644 --- a/python/altrios/tests/mock_resources.py +++ b/python/altrios/tests/mock_resources.py @@ -143,9 +143,9 @@ def mock_locomotive_simulation( pt: alt.PowerTrace = mock_power_trace(), save_interval: Optional[int] = 1, ) -> alt.LocomotiveSimulation: - if not loco: + if loco is None: loco = mock_conventional_loco(save_interval=save_interval) - sim = alt.LocomotiveSimulation(loco, pt, save_interval) + sim = alt.LocomotiveSimulation(loco, pt, False, save_interval) return sim diff --git a/rust/altrios-core/src/consist/locomotive/loco_sim.rs b/rust/altrios-core/src/consist/locomotive/loco_sim.rs index ee1331fd..f250aad5 100644 --- a/rust/altrios-core/src/consist/locomotive/loco_sim.rs +++ b/rust/altrios-core/src/consist/locomotive/loco_sim.rs @@ -139,9 +139,10 @@ pub struct PowerTraceElement { fn __new__( loco_unit: Locomotive, power_trace: PowerTrace, + allow_trace_miss: Option, save_interval: Option, ) -> Self { - Self::new(loco_unit, power_trace, save_interval) + Self::new(loco_unit, power_trace, allow_trace_miss.unwrap_or_default(), save_interval) } #[pyo3(name = "walk")] @@ -178,6 +179,9 @@ pub struct PowerTraceElement { pub struct LocomotiveSimulation { pub loco_unit: Locomotive, pub power_trace: PowerTrace, + /// Whether to allow the Locomotive to miss the power trace. If true, the + /// locomotive will produce whatever power it can. + pub allow_trace_miss: bool, pub i: usize, } @@ -185,11 +189,13 @@ impl LocomotiveSimulation { pub fn new( loco_unit: Locomotive, power_trace: PowerTrace, + allow_trace_miss: bool, save_interval: Option, ) -> Self { let mut loco_sim = Self { loco_unit, power_trace, + allow_trace_miss, i: 1, }; loco_sim.loco_unit.set_save_interval(save_interval); @@ -216,7 +222,7 @@ impl LocomotiveSimulation { pub fn step(&mut self) -> anyhow::Result<()> { self.solve_step() - .map_err(|err| err.context(format!("time step: {}", self.i)))?; + .with_context(|| format!("time step: {}", self.i))?; self.save_state(); self.i += 1; self.loco_unit.step(); @@ -232,24 +238,32 @@ impl LocomotiveSimulation { self.loco_unit .set_cur_pwr_max_out(None, self.power_trace.dt(self.i))?; self.solve_energy_consumption( - self.power_trace.pwr[self.i], + if self.allow_trace_miss { + self.power_trace.pwr[self.i] + .min(self.loco_unit.state.pwr_out_max) + .max(-self.loco_unit.state.pwr_regen_max) + } else { + self.power_trace.pwr[self.i] + }, self.power_trace.dt(self.i), engine_on, )?; - ensure!( - utils::almost_eq_uom( - &self.power_trace.pwr[self.i], - &self.loco_unit.state.pwr_out, - None - ), - format_dbg!( - (utils::almost_eq_uom( + if !self.allow_trace_miss { + ensure!( + utils::almost_eq_uom( &self.power_trace.pwr[self.i], &self.loco_unit.state.pwr_out, None - )) - ) - ); + ), + format_dbg!( + (utils::almost_eq_uom( + &self.power_trace.pwr[self.i], + &self.loco_unit.state.pwr_out, + None + )) + ) + ); + } Ok(()) } @@ -297,7 +311,7 @@ impl Default for LocomotiveSimulation { fn default() -> Self { let power_trace = PowerTrace::default(); let loco_unit = Locomotive::default(); - Self::new(loco_unit, power_trace, None) + Self::new(loco_unit, power_trace, false, None) } } @@ -390,7 +404,7 @@ mod tests { fn test_conventional_locomotive_sim() { let cl = Locomotive::default(); let pt = PowerTrace::default(); - let mut loco_sim = LocomotiveSimulation::new(cl, pt, None); + let mut loco_sim = LocomotiveSimulation::new(cl, pt, false, None); loco_sim.walk().unwrap(); } @@ -410,7 +424,7 @@ mod tests { fn test_battery_locomotive_sim() { let bel = Locomotive::default_battery_electric_loco(); let pt = PowerTrace::default(); - let mut loco_sim = LocomotiveSimulation::new(bel, pt, None); + let mut loco_sim = LocomotiveSimulation::new(bel, pt, false, None); loco_sim.walk().unwrap(); } From 58e7cc1175a13fd75c90a06ae9845b9989fa0849 Mon Sep 17 00:00:00 2001 From: Chad Baker Date: Fri, 15 Nov 2024 14:47:49 -0700 Subject: [PATCH 2/4] reverted to previous version of bel_demo.py --- python/altrios/demos/bel_demo.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/python/altrios/demos/bel_demo.py b/python/altrios/demos/bel_demo.py index ca66ae0a..7f224290 100644 --- a/python/altrios/demos/bel_demo.py +++ b/python/altrios/demos/bel_demo.py @@ -22,11 +22,6 @@ pt = alt.PowerTrace.default() -pt_df = pd.read_json(pt.to_json()) -pt_df.time_seconds = pt_df.time_seconds * 100 -pt_df.engine_on = pt_df.engine_on.astype(str).str.lower() -pt_df.loc[:, ['time_seconds', 'engine_on', 'pwr_watts']].to_csv('pwr_trace.csv', index=False) -pt = pt.from_csv_file('pwr_trace.csv') res = alt.ReversibleEnergyStorage.from_file( alt.resources_root() / @@ -51,7 +46,7 @@ # instantiate battery model t0 = time.perf_counter() -sim = alt.LocomotiveSimulation(bel, pt, True, SAVE_INTERVAL) +sim = alt.LocomotiveSimulation(bel, pt, False, SAVE_INTERVAL) t1 = time.perf_counter() print(f"Time to load: {t1-t0:.3g}") From 367fd5d9d25728c0ddbde5b9c3e2ccbce46179c2 Mon Sep 17 00:00:00 2001 From: "D03\\ganderson" Date: Tue, 26 Nov 2024 08:43:53 -0600 Subject: [PATCH 3/4] made a demo that creates a powertrace by time in notch. It compares to powertraces to demonstrate a potential pathway to identify optimal bel route deployment from event recorder data. --- .../demos/bel_demo_powertrace_comparison.py | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 python/altrios/demos/bel_demo_powertrace_comparison.py diff --git a/python/altrios/demos/bel_demo_powertrace_comparison.py b/python/altrios/demos/bel_demo_powertrace_comparison.py new file mode 100644 index 00000000..0f4cd141 --- /dev/null +++ b/python/altrios/demos/bel_demo_powertrace_comparison.py @@ -0,0 +1,231 @@ +# %% Copied from ALTRIOS version 'v0.2.3'. Guaranteed compatibility with this version only. +# %% +# Script for running the Wabtech BEL consist for sample data from Barstow to Stockton +# Consist comprises [2X Tier 4](https://www.wabteccorp.com/media/3641/download?inline) +# + [1x BEL](https://www.wabteccorp.com/media/466/download?inline) + + +import altrios as alt +import numpy as np +import matplotlib.pyplot as plt +import time +import os +import seaborn as sns +import pandas as pd +import json +import plotly.graph_objects as go +from plotly.subplots import make_subplots +import plotly.express as px + +sns.set_theme() + +SHOW_PLOTS = alt.utils.show_plots() + + +SAVE_INTERVAL = 1 + +#power level in horsepower by notch. negative is dynamic braking +notch_schedule = {-8 : -4400, + -7 : -3740, + -6 : -3070, + -5 : -2350, + -4 : -1765, + -3 : -1265, + -2 : -615, + -1 : -310, + 0 : 0, + 1 : 310, + 2 : 615, + 3 : 1265, + 4 : 1765, + 5 : 2350, + 6 : 3070, + 7 : 3740, + 8 :4400 +} + + +Trace1 = [[1, 120], + [2, 120], + [3, 120], + [4, 3600], + [1, 600], + [0, 120], + [-1, 600], + [-4, 600], + [-1, 120], + ] + +Trace2 = [[1, 120], + [2, 120], + [3, 120], + [4, 3600], + [1, 600], + [0, 120], + [-4, 600], + [-6, 600], + [-4, 120], + ] +traces = [Trace1, Trace2] + + +def PowerTraceFromNotch(NotchTrace, NotchSchedule, Repeats=1): + TotalSeconds = np.array(NotchTrace)[:,1].sum() + PowerTrace = np.zeros(TotalSeconds) + CurrentSeconds = 0 + for NotchDuration in NotchTrace: + print("{}, {}".format(CurrentSeconds, NotchSchedule[NotchDuration[0]])) + PowerTrace[CurrentSeconds:CurrentSeconds+NotchDuration[1]] = NotchSchedule[NotchDuration[0]] + CurrentSeconds = CurrentSeconds + NotchDuration[1] + + #TODO: put slew rate limiter on power to make ramped notch transitions + + PowerTrace = np.tile(PowerTrace, Repeats) + PowerTrace = PowerTrace * 745.699872 #convert from hp to watts + + pt_dict = { + 'time_seconds' : np.arange(0,PowerTrace.shape[0]).tolist(), + 'pwr_watts' : PowerTrace.tolist(), + 'engine_on' : [True] * PowerTrace.shape[0] + } + pt = alt.PowerTrace.from_json(json.dumps(pt_dict)) + return pt + +PowerTraces = [] +for trace in traces: + print('--------------------') + PowerTraces.append(PowerTraceFromNotch(trace, notch_schedule, Repeats = 3)) + +# pt = alt.PowerTrace.default() +# pt_dict = json.loads(pt.to_json()) +# pt_dict['time_seconds'] = np.arange(0,30000,100).tolist() +# pt = pt.from_json(json.dumps(pt_dict)) + + + +res = alt.ReversibleEnergyStorage.from_file( + alt.resources_root() / + "powertrains/reversible_energy_storages/Kokam_NMC_75Ah_flx_drive.yaml" +) +# instantiate electric drivetrain (motors and any gearboxes) +edrv = alt.ElectricDrivetrain( + pwr_out_frac_interp=[0., 1.], + eta_interp=[0.98, 0.98], + pwr_out_max_watts=5e9, + save_interval=SAVE_INTERVAL, +) + + +bel = alt.Locomotive.build_battery_electric_loco( + reversible_energy_storage=res, + drivetrain=edrv, + loco_params=alt.LocoParams.from_dict(dict( + pwr_aux_offset_watts=8.55e3, + pwr_aux_traction_coeff_ratio=540.e-6, + force_max_newtons=667.2e3, +))) + +LocoRegenEnergy = [] +LocoPositiveEnergy = [] +PowerTracePositiveEnergy = [] +PowerTraceRegenEnergy = [] +FractionOfDemand = [] +RegenFraction = [] + +fig = make_subplots(rows=len(PowerTraces), + cols=1, + specs=[[{"secondary_y": True}]]*len(PowerTraces)) + +i = 0 +for trace in PowerTraces: + print(i) + # instantiate battery model + t0 = time.perf_counter() + sim = alt.LocomotiveSimulation(bel, trace, True, SAVE_INTERVAL) + t1 = time.perf_counter() + print(f"Time to load: {t1-t0:.3g}") + + # simulate + t0 = time.perf_counter() + sim.walk() + t1 = time.perf_counter() + print(f"Time to simulate: {t1-t0:.5g}") + + bel_rslt = sim.loco_unit + + LocoPositiveEnergy.append(np.sum(np.clip(np.array(bel_rslt.history.pwr_out_watts) * 1e-6, a_max=np.inf, a_min=0))/3600) + LocoRegenEnergy.append(np.sum(np.clip(np.array(bel_rslt.history.pwr_out_watts) * 1e-6, a_max=0, a_min=-np.inf))/3600) + PowerTracePositiveEnergy.append(np.sum(np.clip(np.array(sim.power_trace.pwr_watts) * 1e-6, a_max=np.inf,a_min=0))/3600) + PowerTraceRegenEnergy.append(np.sum(np.clip(np.array(sim.power_trace.pwr_watts) * 1e-6, a_max=0, a_min=-np.inf))/3600) + FractionOfDemand.append(LocoPositiveEnergy[-1]/PowerTracePositiveEnergy[-1]) + RegenFraction.append(LocoRegenEnergy[-1]/PowerTraceRegenEnergy[-1]) + t_s = np.array(sim.power_trace.time_seconds) + + fig.add_trace( + go.Scatter(x=t_s, + y=np.array(bel_rslt.history.pwr_out_watts) * 1e-6, + name='Locomotive Power', + marker={'color': px.colors.qualitative.Plotly[0]}), + + row=i+1, col=1) + + fig.add_trace( + go.Scatter(x=t_s, + y=np.array(sim.power_trace.pwr_watts) * 1e-6, + name='Power Demand', + marker={'color': px.colors.qualitative.Plotly[1]}), + + row=i+1, col=1) + + fig.add_trace( + go.Scatter(x=t_s, + y=np.array(bel_rslt.res.history.soc) * 100, + name='SOC', + marker={'color': px.colors.qualitative.Plotly[2]}), + secondary_y = True, + row=i+1, col=1) + + fig.update_xaxes(tickfont=dict(family='Arial', size=20), row=i+1, col=1) + fig.update_yaxes(tickfont=dict(family='Arial', size=20), range=[-3.1, 3.1], row=i+1, col=1) + fig.update_yaxes(title_text="Power [MW]", title_font=dict(size=24, family='Arial')) + fig.update_yaxes(title_text="SOC [%]", title_font=dict(size=24, family='Arial'), secondary_y=True, range=[0, 100], showgrid=False) + fig.update_xaxes(title_text="Time [s]", title_font=dict(size=20, family='Arial'), row=i+1, col=1) + fig.update_layout( + legend_title=" ", + font=dict( + size=20)) + + i=i+1 + +fig.write_html('test.html', auto_open=True) + +#%% +Data = pd.DataFrame() +Data['Actual Regen Energy'] = LocoRegenEnergy +Data['Actual Positive Tractive Effort'] =LocoPositiveEnergy +Data['Potential Positive Tractive Effort'] =PowerTracePositiveEnergy +Data['Potential Regen Energy'] = PowerTraceRegenEnergy +Data['Demand Fraction Achieved'] =FractionOfDemand +Data['Regen Fraction'] =RegenFraction +Data['Power Trace Name'] = ['Trace 1','Trace 2'] +fig = px.bar(Data, x='Power Trace Name', + y=['Actual Positive Tractive Effort', + 'Potential Positive Tractive Effort', + 'Actual Regen Energy', + 'Potential Regen Energy'], + barmode='group') + +fig.update_xaxes(tickfont=dict(family='Arial', size=20), row=i+1, col=1) +fig.update_yaxes(tickfont=dict(family='Arial', size=20), range=[-3.1, 3.1], row=i+1, col=1) +fig.update_yaxes(title_text="Energy [MWh]", title_font=dict(size=24, family='Arial')) +fig.update_xaxes(title_font=dict(size=20, family='Arial'), row=i+1, col=1) +fig.update_layout( + legend_title=" ", + font=dict( + size=20)) +fig.write_html('bar.html', auto_open=True) + + + + +# %% From e1bbd9a76311f1127cbd7debccfec4db1bb7b6d6 Mon Sep 17 00:00:00 2001 From: Chad Baker Date: Wed, 27 Nov 2024 12:26:28 -0700 Subject: [PATCH 4/4] added `Optional` --- python/altrios/altrios_pyo3.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/altrios/altrios_pyo3.pyi b/python/altrios/altrios_pyo3.pyi index a1225b10..c0dab14e 100644 --- a/python/altrios/altrios_pyo3.pyi +++ b/python/altrios/altrios_pyo3.pyi @@ -404,7 +404,7 @@ class LocomotiveSimulation(SerdeAPI): i: int loco_unit: Locomotive power_trace: PowerTrace - allow_trace_miss: bool + allow_trace_miss: Optional[bool] @classmethod def __init__(cls) -> None: ... def clone(self) -> Self: ...