diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index 76f9b953f..8ebeba314 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -16,6 +16,7 @@ ) from h2integrate.converters.hydrogen.wombat_model import WOMBATElectrolyzerModel from h2integrate.converters.wind.wind_plant_pysam import PYSAMWindPlantPerformanceModel +from h2integrate.storage.battery.atb_battery_cost import ATBBatteryCostModel from h2integrate.transporters.electricity_combiner import CombinerPerformanceModel from h2integrate.transporters.electricity_splitter import SplitterPerformanceModel from h2integrate.converters.ammonia.ammonia_synloop import ( @@ -34,6 +35,7 @@ from h2integrate.converters.solar.atb_res_com_pv_cost import ATBResComPVCostModel from h2integrate.converters.solar.atb_utility_pv_cost import ATBUtilityPVCostModel from h2integrate.control.control_rules.converters.wind import PyomoDispatchWind +from h2integrate.control.control_rules.storage.battery import PyomoDispatchBattery from h2integrate.converters.methanol.smr_methanol_plant import ( SMRMethanolPlantCostModel, SMRMethanolPlantFinanceModel, @@ -49,7 +51,6 @@ CO2HMethanolPlantPerformanceModel, ) from h2integrate.control.control_rules.storage.h2_storage import PyomoDispatchH2Storage -from h2integrate.control.control_rules.storage.battery import PyomoDispatchBattery from h2integrate.converters.natural_gas.natural_gas_cc_ct import ( NaturalGasCostModel, NaturalGasPerformanceModel, @@ -151,6 +152,7 @@ "h2_storage": H2Storage, "hydrogen_tank_performance": HydrogenTankPerformanceModel, "hydrogen_tank_cost": HydrogenTankCostModel, + "atb_battery_cost": ATBBatteryCostModel, # Control "pass_through_controller": PassThroughOpenLoopController, "demand_open_loop_controller": DemandOpenLoopController, diff --git a/h2integrate/storage/battery/atb_battery_cost.py b/h2integrate/storage/battery/atb_battery_cost.py new file mode 100644 index 000000000..12cb9a8da --- /dev/null +++ b/h2integrate/storage/battery/atb_battery_cost.py @@ -0,0 +1,68 @@ +from attrs import field, define + +from h2integrate.core.utilities import CostModelBaseConfig, merge_shared_inputs +from h2integrate.core.validators import gt_zero, range_val +from h2integrate.core.model_baseclasses import CostModelBaseClass + + +@define +class ATBBatteryCostConfig(CostModelBaseConfig): + """Configuration class for the ATBBatteryCostModel with costs based on storage + capacity and charge rate. More information on ATB methodology and representative + battery technologies can be found + `here `_ + Reference cost values can be found on the `Utility-Scale Battery Storage`, + `Commercial Battery Storage`, and `Residential Battery Storage` sheets of the + `NREL ATB workbook `_. + + Attributes: + energy_capex (float|int): battery energy capital cost in $/kWh + power_capex (float|int): battery power capital cost in $/kW + opex_fraction (float): annual operating cost as a fraction of the total system cost. + cost_year (int): dollar year corresponding to input costs + """ + + energy_capex: float | int = field(validator=gt_zero) + power_capex: float | int = field(validator=gt_zero) + opex_fraction: float = field(validator=range_val(0, 1)) + + +class ATBBatteryCostModel(CostModelBaseClass): + """This cost model is based on the equations in the "Utility-Scale Battery Storage" + sheet in the ATB 2024 workbook. + + - Cell E29 has the equation for CapEx. Also found in the cells for the CapEx section. + - Cell G121 (all the cells in the Fixed Operation and Maintenance Expenses + section) include the equation to calculate fixed o&m costs. + + Total_CapEx = Energy_CapEx * Storage_Hours + Power_CapEx + + - Total_CapEx: Total System Cost (USD/kW) + - Storage_Hours: Storage Duration (hr) + - Energy_CapEx: Battery Energy Cost (USD/kWh) + - Power_CapEx: Battery Power Cost (USD/kW) + + """ + + def setup(self): + self.config = ATBBatteryCostConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "cost") + ) + + super().setup() + + self.add_input("charge_rate", val=0.0, units="kW", desc="Battery charge/discharge rate") + self.add_input("storage_capacity", val=0.0, units="kW*h", desc="Battery storage capacity") + + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): + storage_duration_hrs = inputs["storage_capacity"][0] / inputs["charge_rate"][0] + + # CapEx equation from Cell E29 + total_system_cost = ( + storage_duration_hrs * self.config.energy_capex + ) + self.config.power_capex + capex = total_system_cost * inputs["charge_rate"][0] + # OpEx equation from cells in the Fixed Operation and Maintenance Expenses section + opex = self.config.opex_fraction * capex + outputs["CapEx"] = capex + outputs["OpEx"] = opex diff --git a/h2integrate/storage/battery/pysam_battery.py b/h2integrate/storage/battery/pysam_battery.py index 18bf5be59..b5ea13fa4 100644 --- a/h2integrate/storage/battery/pysam_battery.py +++ b/h2integrate/storage/battery/pysam_battery.py @@ -1,19 +1,18 @@ -from attrs import field, define -from dataclasses import dataclass, asdict -from typing import Optional, Sequence, List +from dataclasses import asdict, dataclass +from collections.abc import Sequence import PySAM.BatteryStateful as BatteryStateful +from attrs import field, define +from hopp.utilities.validators import gt_zero, contains, range_val -from h2integrate.core.model_baseclasses import CostModelBaseClass from h2integrate.core.utilities import BaseConfig, merge_shared_inputs +from h2integrate.core.model_baseclasses import CostModelBaseClass from h2integrate.storage.battery.battery_baseclass import BatteryPerformanceBaseClass -from hopp.utilities.validators import contains, gt_zero, range_val - @dataclass class BatteryOutputs: - I: Sequence + # I: Sequence #TODO rename, remove, or otherwise figure out how to get this pass the liner P: Sequence Q: Sequence SOC: Sequence @@ -22,12 +21,13 @@ class BatteryOutputs: n_cycles: Sequence P_chargeable: Sequence P_dischargeable: Sequence - dispatch_I: List[float] - dispatch_P: List[float] - dispatch_SOC: List[float] - dispatch_lifecycles_per_day: List[Optional[int]] - unmet_demand: List[float] - excess_resource: List[float] + dispatch_I: list[float] + dispatch_P: list[float] + dispatch_SOC: list[float] + dispatch_lifecycles_per_day: list[int | None] + unmet_demand: list[float] + excess_resource: list[float] + """ The following outputs are simulated from the BatteryStateful model, an entry per timestep: I: current [A] @@ -43,7 +43,7 @@ class BatteryOutputs: dispatch_I: current [A], only applicable to battery dispatch models with current modeled dispatch_P: power [mW] dispatch_SOC: state-of-charge [%] - + This output has a different length, one entry per control window: dispatch_lifecycles_per_control_window: number of cycles per control window """ @@ -51,14 +51,21 @@ class BatteryOutputs: 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" + "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) - dispatch_attributes = ['I', 'P', 'SOC'] + dispatch_attributes = ["I", "P", "SOC"] for attr in dispatch_attributes: - setattr(self, 'dispatch_'+attr, [0.0] * n_timesteps) + setattr(self, "dispatch_" + attr, [0.0] * n_timesteps) self.dispatch_lifecycles_per_control_window = [None] * int(n_timesteps / n_control_window) @@ -85,21 +92,24 @@ class PySAMBatteryPerformanceModelConfig(BaseConfig): PySAM options: - "LFPGraphite" (default) - "LMOLTO" - - "LeadAcid" + - "LeadAcid" - "NMCGraphite" HOPP options: - "LDES" generic long-duration energy storage minimum_SOC: Minimum state of charge [%] maximum_SOC: Maximum state of charge [%] initial_SOC: Initial state of charge [%] + ref_module_capacity: reference module capacity in kWh + ref_module_surface_area: reference module surface area in m^2 """ + max_capacity: float = field(validator=gt_zero) system_capacity_kw: float = field(validator=gt_zero) cost_year: int = field() system_model_source: str = field(default="pysam", validator=contains(["pysam", "hopp"])) chemistry: str = field( default="LFPGraphite", - validator=contains(["LFPGraphite", "LMOLTO", "LeadAcid", "NMCGraphite", "LDES"]) + validator=contains(["LFPGraphite", "LMOLTO", "LeadAcid", "NMCGraphite", "LDES"]), ) tracking: bool = field(default=True) min_charge_percent: float = field(default=0.1, validator=range_val(0, 1)) @@ -110,6 +120,8 @@ class PySAMBatteryPerformanceModelConfig(BaseConfig): n_control_window: int = field(default=24) n_horizon_window: int = field(default=48) name: str = field(default="Battery") + ref_module_capacity: int | float = field(default=400) + ref_module_surface_area: int | float = field(default=30) class PySAMBatteryPerformanceModel(BatteryPerformanceBaseClass, CostModelBaseClass): @@ -122,6 +134,21 @@ def setup(self): self.config = PySAMBatteryPerformanceModelConfig.from_dict( merge_shared_inputs(self.options["tech_config"]["model_inputs"], "performance") ) + + self.add_input( + "charge_rate", + val=self.config.system_capacity_kw, + units="kW", + desc="Battery charge rate", + ) + + self.add_input( + "storage_capacity", + val=self.config.max_capacity, + units="kW*h", + desc="Battery storage capacity", + ) + BatteryPerformanceBaseClass.setup(self) CostModelBaseClass.setup(self) @@ -180,15 +207,30 @@ def setup(self): # 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"]: + # get technology group name + # TODO: The split below seems brittle + self.tech_group_name = self.pathname.split(".") + for _source_tech, intended_dispatch_tech in self.options["plant_config"][ + "tech_to_dispatch_connections" + ]: + if any(intended_dispatch_tech in name for name in self.tech_group_name): + self.add_discrete_input("pyomo_dispatch_solver", val=dummy_function) + break + self.unmet_demand = 0.0 self.excess_resource = 0.0 - # TODO: should this be made configurable by users? - module_specs = {'capacity': 400, 'surface_area': 30} # 400 [kWh] -> 30 [m^2] - + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): # Size the battery based on inputs -> method brought from HOPP + module_specs = { + "capacity": self.config.ref_module_capacity, + "surface_area": self.config.ref_module_surface_area, + } + self.size_batterystateful( - self.config.max_capacity, + inputs["storage_capacity"][0], self.system_model.ParamsPack.nominal_voltage, module_specs=module_specs, ) @@ -196,7 +238,7 @@ def setup(self): self.system_model.ParamsPack.Cp = 900 self.system_model.ParamsCell.resistance = 0.001 self.system_model.ParamsCell.C_rate = ( - self.config.system_capacity_kw / self.config.max_capacity + inputs["charge_rate"][0] / inputs["storage_capacity"][0] ) # Minimum set of parameters to set to get statefulBattery to work @@ -211,24 +253,11 @@ def setup(self): # Setup PySAM battery model using PySAM method self.system_model.setup() - # create inputs for pyomo control model - if "tech_to_dispatch_connections" in self.options["plant_config"]: - # get technology group name - # TODO: The split below seems brittle - self.tech_group_name = self.pathname.split(".") - for _source_tech, intended_dispatch_tech in self.options["plant_config"][ - "tech_to_dispatch_connections" - ]: - if any(intended_dispatch_tech in name for name in self.tech_group_name): - self.add_discrete_input("pyomo_dispatch_solver", val=dummy_function) - break - # Run PySAM battery model 1 timestep to initialize values - self.system_model.value('dt_hr', 1.0) + self.system_model.value("dt_hr", 1.0) self.system_model.value("input_power", 0.0) self.system_model.execute(0) - def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): if "pyomo_dispatch_solver" in discrete_inputs: # Simulate the battery with provided dispatch inputs dispatch = discrete_inputs["pyomo_dispatch_solver"] @@ -255,13 +284,13 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): outputs["excess_resource_out"] = self.outputs.excess_resource def simulate( - self, - electricity_in: list, - demand_in: list, - time_step_duration: list, - control_variable: str, - sim_start_index: int = 0, - ): + self, + electricity_in: list, + demand_in: list, + time_step_duration: list, + control_variable: str, + sim_start_index: int = 0, + ): """Simulates the battery with the provided dispatch inputs. Args: @@ -272,7 +301,7 @@ def simulate( either current values or power values. """ # Loop through the provided input power/current (decided by control_variable) - self.system_model.value('dt_hr', time_step_duration) + self.system_model.value("dt_hr", time_step_duration) for t in range(len(electricity_in)): # Set to 0.0 for each loop start @@ -281,7 +310,7 @@ def simulate( # Grab the available charge/discharge capacity of the battery P_chargeable = self.system_model.value("P_chargeable") - P_dischargeable = self.system_model.value("P_dischargeable") + self.system_model.value("P_dischargeable") # If discharging... if electricity_in[t] > 0.0: @@ -289,6 +318,7 @@ def simulate( if (self.system_model.value("SOC") - self.system_model.value("minimum_SOC")) < 0.05: # Avoid trickle power by setting to 0.0 electricity_in[t] = 0.0 + # If charging... elif electricity_in[t] < 0.0: # If the input electricity magnitude is greater than the battery chargeable capacity @@ -322,21 +352,15 @@ def simulate( # 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) + 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) - def size_batterystateful( - self, - desired_capacity, - desired_voltage, - module_specs=None - ): + def size_batterystateful(self, desired_capacity, desired_voltage, module_specs=None): """Helper function for ``battery_model_sizing()``. Modifies BatteryStateful model with new sizing. For Battery model, use ``size_battery()`` instead. Only battery side DC sizing. @@ -344,7 +368,7 @@ def size_batterystateful( :param float desired_voltage: Volts. :param dict module_specs: {capacity (float), surface_area (float)} Optional, module specs for scaling surface area. - + capacity: float Capacity of a single battery module in kWhAC if AC-connected, kWhDC otherwise. surface_area: float @@ -354,7 +378,7 @@ def size_batterystateful( :rtype: dict """ # calculate size - if type(self.system_model) != BatteryStateful.BatteryStateful: + if not isinstance(self.system_model, BatteryStateful.BatteryStateful): raise TypeError original_capacity = self.system_model.ParamsPack.nominal_energy @@ -364,26 +388,26 @@ def size_batterystateful( # calculate thermal thermal_inputs = { - 'mass': self.system_model.ParamsPack.mass, - 'surface_area': self.system_model.ParamsPack.surface_area, - 'original_capacity': original_capacity, - 'desired_capacity': desired_capacity + "mass": self.system_model.ParamsPack.mass, + "surface_area": self.system_model.ParamsPack.surface_area, + "original_capacity": original_capacity, + "desired_capacity": desired_capacity, } if module_specs is not None: - module_specs = {'module_'+k: v for k, v in module_specs.items()} + module_specs = {"module_" + k: v for k, v in module_specs.items()} thermal_inputs.update(module_specs) thermal_outputs = self.calculate_thermal_params(thermal_inputs) - self.system_model.ParamsPack.mass = thermal_outputs['mass'] - self.system_model.ParamsPack.surface_area = thermal_outputs['surface_area'] + self.system_model.ParamsPack.mass = thermal_outputs["mass"] + self.system_model.ParamsPack.surface_area = thermal_outputs["surface_area"] def calculate_thermal_params(self, input_dict): """Calculates the mass and surface area of a battery by calculating from its current parameters the mass / specific energy and volume / specific energy ratios. If module_capacity and module_surface_area are provided, battery surface area is calculated by scaling module_surface_area by the number of modules required to fulfill desired capacity. - + :param dict input_dict: A dictionary of battery thermal parameters at original size. {mass (float), surface_area (float), original_capacity (float), desired_capacity (float), module_capacity (float, optional), surface_area (float, optional)}. @@ -402,7 +426,7 @@ def calculate_thermal_params(self, input_dict): m^2 of module battery :returns: Dictionary of battery mass and surface area at desired size. - :rtype: dict {mass (float), surface_area (float)} + :rtype: dict {mass (float), surface_area (float)} mass: float kg of battery at desired size @@ -410,36 +434,36 @@ def calculate_thermal_params(self, input_dict): m^2 of battery at desired size """ - mass = input_dict['mass'] - surface_area = input_dict['surface_area'] - original_capacity = input_dict['original_capacity'] - desired_capacity = input_dict['desired_capacity'] + mass = input_dict["mass"] + surface_area = input_dict["surface_area"] + original_capacity = input_dict["original_capacity"] + desired_capacity = input_dict["desired_capacity"] mass_per_specific_energy = mass / original_capacity - volume = (surface_area / 6) ** (3/2) + volume = (surface_area / 6) ** (3 / 2) volume_per_specific_energy = volume / original_capacity output_dict = { - 'mass': mass_per_specific_energy * desired_capacity, - 'surface_area': (volume_per_specific_energy * desired_capacity) ** (2/3) * 6, + "mass": mass_per_specific_energy * desired_capacity, + "surface_area": (volume_per_specific_energy * desired_capacity) ** (2 / 3) * 6, } - if input_dict.keys() >= {'module_capacity', 'module_surface_area'}: - module_capacity = input_dict['module_capacity'] - module_surface_area = input_dict['module_surface_area'] - output_dict['surface_area'] = module_surface_area*desired_capacity/module_capacity + if input_dict.keys() >= {"module_capacity", "module_surface_area"}: + module_capacity = input_dict["module_capacity"] + module_surface_area = input_dict["module_surface_area"] + output_dict["surface_area"] = module_surface_area * desired_capacity / module_capacity return output_dict def _set_control_mode( - self, - control_mode: float=1.0, - input_power: float=0.0, - input_current: float=0.0, - control_variable: str="input_power" - ): + self, + control_mode: float = 1.0, + input_power: float = 0.0, + input_current: float = 0.0, + control_variable: str = "input_power", + ): """Sets control mode.""" if isinstance(self.system_model, BatteryStateful.BatteryStateful): # Power control = 1.0, current control = 0.0 @@ -453,4 +477,4 @@ def _set_control_mode( def dummy_function(): # this function is required for initialzing the pyomo control input and nothing else - pass \ No newline at end of file + pass diff --git a/tests/h2integrate/test_controllers/inputs/pyomo_controller/tech_config.yaml b/tests/h2integrate/test_controllers/inputs/pyomo_controller/tech_config.yaml index ef55e2374..6404051c2 100644 --- a/tests/h2integrate/test_controllers/inputs/pyomo_controller/tech_config.yaml +++ b/tests/h2integrate/test_controllers/inputs/pyomo_controller/tech_config.yaml @@ -37,7 +37,7 @@ technologies: adjust_air_density_for_elevation: True cost_parameters: cost_per_kw: 1500.0 - cost_year: 2024 # 2019? + cost_year: 2019 dispatch_rule_parameters: resource_name: "electricity" resource_storage_units: "kW"