diff --git a/h2integrate/storage/battery/battery_baseclass.py b/h2integrate/storage/battery/battery_baseclass.py index fffdcfdff..a73811179 100644 --- a/h2integrate/storage/battery/battery_baseclass.py +++ b/h2integrate/storage/battery/battery_baseclass.py @@ -14,7 +14,7 @@ def setup(self): self.add_input( "electricity_in", val=0.0, - shape_by_conn=True, + shape=self.n_timesteps, units="kW", desc="Power input to Battery", ) @@ -22,7 +22,7 @@ def setup(self): self.add_output( "SOC", val=0.0, - copy_shape="electricity_in", + shape=self.n_timesteps, units="percent", desc="State of charge of Battery", ) @@ -30,7 +30,7 @@ def setup(self): self.add_output( "battery_electricity_discharge", val=0.0, - copy_shape="electricity_in", + shape=self.n_timesteps, units="kW", desc="Electricity output from Battery only", ) diff --git a/h2integrate/storage/battery/pysam_battery.py b/h2integrate/storage/battery/pysam_battery.py index 9254857e3..62035d0c1 100644 --- a/h2integrate/storage/battery/pysam_battery.py +++ b/h2integrate/storage/battery/pysam_battery.py @@ -1,6 +1,3 @@ -from dataclasses import asdict, dataclass -from collections.abc import Sequence - import numpy as np import PySAM.BatteryTools as BatteryTools import PySAM.BatteryStateful as BatteryStateful @@ -11,63 +8,6 @@ from h2integrate.storage.battery.battery_baseclass import BatteryPerformanceBaseClass -@dataclass -class BatteryOutputs: - I: Sequence # noqa: E741 - P: Sequence - Q: Sequence - SOC: Sequence - T_batt: Sequence - gen: Sequence - n_cycles: Sequence - P_chargeable: Sequence - P_dischargeable: Sequence - unmet_demand: list[float] - unused_commodity: list[float] - - """ - Container for simulated outputs from the `BatteryStateful` and H2I dispatch models. - - Attributes: - I (Sequence): Battery current [A] per timestep. - P (Sequence): Battery power [kW] per timestep. - Q (Sequence): Battery capacity [Ah] per timestep. - SOC (Sequence): State of charge [%] per timestep. - T_batt (Sequence): Battery temperature [°C] per timestep. - gen (Sequence): Generated power [kW] per timestep. - n_cycles (Sequence): Cumulative rainflow cycles since start of simulation [1]. - P_chargeable (Sequence): Maximum estimated chargeable power [kW] per timestep. - P_dischargeable (Sequence): Maximum estimated dischargeable power [kW] per timestep. - - unmet_demand (list[float]): Unmet demand [kW] per timestep. - unused_commodity (list[float]): Unused available commodity [kW] per timestep. - """ - - def __init__(self, n_timesteps, n_control_window): - """Class for storing stateful battery and dispatch outputs.""" - self.stateful_attributes = [ - "I", - "P", - "Q", - "SOC", - "T_batt", - "n_cycles", - "P_chargeable", - "P_dischargeable", - ] - for attr in self.stateful_attributes: - setattr(self, attr, [0.0] * n_timesteps) - - self.dispatch_lifecycles_per_control_window = [None] * int(n_timesteps / n_control_window) - - self.component_attributes = ["unmet_demand", "unused_commodity"] - for attr in self.component_attributes: - setattr(self, attr, [0.0] * n_timesteps) - - def export(self): - return asdict(self) - - @define(kw_only=True) class PySAMBatteryPerformanceModelConfig(BaseConfig): """Configuration class for battery performance models. @@ -115,6 +55,12 @@ class PySAMBatteryPerformanceModelConfig(BaseConfig): ref_module_surface_area (int | float, optional): Reference module surface area in square meters (m²). Defaults to 30. + Cp (int | float, optional): Battery specific heat capacity [J/kg*K]. + Defaults to 900. + battery_h (int | float, optional): Heat transfer between battery and + environment [W/m2*K]. Defaults to 20. + resistance (int | float, optional): Battery internal resistance [Ohm]. + Defaults to 0.001. """ max_capacity: float = field(validator=gt_zero) @@ -134,6 +80,9 @@ class PySAMBatteryPerformanceModelConfig(BaseConfig): ) ref_module_capacity: int | float = field(default=400) ref_module_surface_area: int | float = field(default=30) + Cp: int | float = field(default=900) + battery_h: int | float = field(default=20) + resistance: int | float = field(default=0.001) class PySAMBatteryPerformanceModel(BatteryPerformanceBaseClass): @@ -249,56 +198,49 @@ def setup(self): self.add_input( "electricity_demand", val=0.0, - copy_shape="electricity_in", + shape=self.n_timesteps, units="kW", desc="Power demand", ) self.add_output( - "P_chargeable", + "unmet_electricity_demand_out", val=0.0, - copy_shape="electricity_in", + shape=self.n_timesteps, units="kW", - desc="Estimated max chargeable power", + desc="Unmet power demand", ) self.add_output( - "P_dischargeable", + "unused_electricity_out", val=0.0, - copy_shape="electricity_in", + shape=self.n_timesteps, units="kW", - desc="Estimated max dischargeable power", + desc="Unused generated commodity", ) self.add_output( - "unmet_electricity_demand_out", + "battery_discharge", val=0.0, - copy_shape="electricity_in", + shape=self.n_timesteps, units="kW", - desc="Unmet power demand", + desc="Electricity discharged from battery", ) self.add_output( - "unused_electricity_out", + "battery_charge", val=0.0, - copy_shape="electricity_in", + shape=self.n_timesteps, units="kW", - desc="Unused generated commodity", + desc="Electricity to charge battery", ) # Initialize the PySAM BatteryStateful model with defaults self.system_model = BatteryStateful.default(self.config.chemistry) - n_timesteps = int( - self.options["plant_config"]["plant"]["simulation"]["n_timesteps"] - ) # self.config.n_timesteps self.dt_hr = int(self.options["plant_config"]["plant"]["simulation"]["dt"]) / ( 60**2 ) # convert from seconds to hours - n_control_window = self.config.n_control_window - - # Setup outputs for the battery model to be stored during the compute method - self.outputs = BatteryOutputs(n_timesteps=n_timesteps, n_control_window=n_control_window) # create inputs for pyomo control model if "tech_to_dispatch_connections" in self.options["plant_config"]: @@ -312,9 +254,6 @@ def setup(self): self.add_discrete_input("pyomo_dispatch_solver", val=dummy_function) break - self.unmet_demand = 0.0 - self.unused_commodity = 0.0 - def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): """Run the PySAM Battery model for one simulation step. @@ -333,6 +272,7 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): discrete_outputs (dict): Discrete outputs (unused in this component). """ + # Size the battery based on inputs -> method brought from HOPP module_specs = { "capacity": self.config.ref_module_capacity, @@ -341,14 +281,14 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): BatteryTools.battery_model_sizing( self.system_model, - self.config.max_charge_rate, - self.config.max_capacity, + inputs["max_charge_rate"][0], + inputs["storage_capacity"][0], self.system_model.ParamsPack.nominal_voltage, module_specs=module_specs, ) - self.system_model.ParamsPack.h = 20 - self.system_model.ParamsPack.Cp = 900 - self.system_model.ParamsCell.resistance = 0.001 + self.system_model.ParamsPack.h = self.config.battery_h + self.system_model.ParamsPack.Cp = self.config.Cp + self.system_model.ParamsCell.resistance = self.config.resistance self.system_model.ParamsCell.C_rate = ( inputs["max_charge_rate"][0] / inputs["storage_capacity"][0] ) @@ -375,6 +315,9 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): dispatch = discrete_inputs["pyomo_dispatch_solver"] kwargs = { "time_step_duration": self.dt_hr, + "charge_rate": inputs["max_charge_rate"][0], + "discharge_rate": inputs["max_charge_rate"][0], + "storage_capacity": inputs["storage_capacity"][0], "control_variable": self.config.control_variable, } ( @@ -385,6 +328,8 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): soc, ) = dispatch(self.simulate, kwargs, inputs) + battery_power_out = np.array(battery_power_out) + else: # Simulate the battery with provided inputs and no controller. # This essentially asks for discharge when demand exceeds input @@ -396,42 +341,47 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): battery_power, soc = self.simulate( storage_dispatch_commands=pseudo_commands, time_step_duration=self.dt_hr, + charge_rate=inputs["max_charge_rate"][0], + discharge_rate=inputs["max_charge_rate"][0], + storage_capacity=inputs["storage_capacity"][0], control_variable=self.config.control_variable, ) - n_time_steps = len(inputs["electricity_demand"]) # determine battery discharge - self.outputs.P = battery_power - battery_power_out = [np.max([0, battery_power[i]]) for i in range(n_time_steps)] + battery_power_out = np.max( + [np.array(battery_power), np.zeros(self.n_timesteps)], axis=0 + ) + # [np.max([0, battery_power[i]]) for i in range(self.n_timesteps)] # calculate combined power out from inflow source and battery (note: battery_power is # negative when charging) - combined_power_out = inputs["electricity_in"] + battery_power + combined_power_out = inputs["electricity_in"] + np.array(battery_power) # find the total power out to meet demand total_power_out = np.minimum(inputs["electricity_demand"], combined_power_out) # determine how much of the inflow electricity was unused - self.outputs.unused_commodity = [ + unused_commodity = [ np.max([0, combined_power_out[i] - inputs["electricity_demand"][i]]) - for i in range(n_time_steps) + for i in range(self.n_timesteps) ] - unused_commodity = self.outputs.unused_commodity # determine how much demand was not met - self.outputs.unmet_demand = [ + unmet_demand = [ np.max([0, inputs["electricity_demand"][i] - combined_power_out[i]]) - for i in range(n_time_steps) + for i in range(self.n_timesteps) ] - unmet_demand = self.outputs.unmet_demand outputs["unmet_electricity_demand_out"] = unmet_demand outputs["unused_electricity_out"] = unused_commodity outputs["battery_electricity_discharge"] = battery_power_out + + outputs["battery_charge"] = np.where(battery_power_out < 0, battery_power_out, 0) + outputs["battery_discharge"] = np.where(battery_power_out > 0, battery_power_out, 0) + outputs["electricity_out"] = total_power_out outputs["SOC"] = soc - outputs["P_chargeable"] = self.outputs.P_chargeable - outputs["P_dischargeable"] = self.outputs.P_dischargeable + outputs["rated_electricity_production"] = inputs["max_charge_rate"] outputs["total_electricity_produced"] = np.sum(total_power_out) @@ -446,6 +396,9 @@ def simulate( self, storage_dispatch_commands: list, time_step_duration: list, + charge_rate: float, + discharge_rate: float, + storage_capacity: float, control_variable: str, sim_start_index: int = 0, ): @@ -506,29 +459,19 @@ def simulate( # manually adjust the dispatch command based on SOC ## for when battery is withing set bounds - # according to specs - max_chargeable_0 = self.config.max_charge_rate # according to simulation max_chargeable_1 = np.maximum(0, -self.system_model.value("P_chargeable")) # according to soc - max_chargeable_2 = np.maximum( - 0, (soc_max - soc) * self.config.max_capacity / self.dt_hr - ) + max_chargeable_2 = np.maximum(0, (soc_max - soc) * storage_capacity / self.dt_hr) # compare all versions of max_chargeable - max_chargeable = np.min([max_chargeable_0, max_chargeable_1, max_chargeable_2]) + max_chargeable = np.min([charge_rate, max_chargeable_1, max_chargeable_2]) - # according to specs - max_dischargeable_0 = self.config.max_charge_rate # according to simulation max_dischargeable_1 = np.maximum(0, self.system_model.value("P_dischargeable")) # according to soc - max_dischargeable_2 = np.maximum( - 0, (soc - soc_min) * self.config.max_capacity / self.dt_hr - ) + max_dischargeable_2 = np.maximum(0, (soc - soc_min) * storage_capacity / self.dt_hr) # compare all versions of max_dischargeable - max_dischargeable = np.min( - [max_dischargeable_0, max_dischargeable_1, max_dischargeable_2] - ) + max_dischargeable = np.min([discharge_rate, max_dischargeable_1, max_dischargeable_2]) if dispatch_command_t < -max_chargeable: dispatch_command_t = -max_chargeable @@ -549,17 +492,6 @@ def simulate( storage_power_out_timesteps[t] = self.system_model.value("P") soc_timesteps[t] = self.system_model.value("SOC") - # Store outputs based on the outputs defined in `BatteryOutputs` above. The values are - # scraped from the PySAM model modules `StatePack` and `StateCell`. - for attr in self.outputs.stateful_attributes: - if hasattr(self.system_model.StatePack, attr) or hasattr( - self.system_model.StateCell, attr - ): - getattr(self.outputs, attr)[sim_start_index + t] = self.system_model.value(attr) - - for attr in self.outputs.component_attributes: - getattr(self.outputs, attr)[sim_start_index + t] = getattr(self, attr) - return storage_power_out_timesteps, soc_timesteps def _set_control_mode( diff --git a/h2integrate/storage/battery/test/test_pysam_battery.py b/h2integrate/storage/battery/test/test_pysam_battery.py index a8441f4e8..3c7ca6bb2 100644 --- a/h2integrate/storage/battery/test/test_pysam_battery.py +++ b/h2integrate/storage/battery/test/test_pysam_battery.py @@ -273,8 +273,6 @@ def test_battery_initialization(plant_config, subtests): with subtests.test("battery attribute not None system_model"): assert battery.system_model is not None - with subtests.test("battery attribute not None outputs"): - assert battery.outputs is not None with subtests.test("battery mass"): # this test value does not match the value in test_battery.py in HOPP