From 244df1f34f4304c37cb32cd6f4ae310ad9636f34 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:28:21 -0600 Subject: [PATCH 1/5] added battery cost model and updated pysam battery so capacity is input --- h2integrate/core/supported_models.py | 2 + .../storage/battery/atb_battery_cost.py | 50 +++++++ h2integrate/storage/battery/pysam_battery.py | 137 ++++++++++-------- 3 files changed, 126 insertions(+), 63 deletions(-) create mode 100644 h2integrate/storage/battery/atb_battery_cost.py diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index 67c750dd2..694e9b515 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -14,6 +14,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.converters.ammonia.ammonia_synloop import ( AmmoniaSynLoopCostModel, AmmoniaSynLoopPerformanceModel, @@ -123,6 +124,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..c7203dc24 --- /dev/null +++ b/h2integrate/storage/battery/atb_battery_cost.py @@ -0,0 +1,50 @@ +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): + 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] + + total_system_cost = ( + storage_duration_hrs * self.config.energy_capex + ) + self.config.power_capex + capex = total_system_cost * inputs["charge_rate"][0] + 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 3f6172101..3b4b1fff6 100644 --- a/h2integrate/storage/battery/pysam_battery.py +++ b/h2integrate/storage/battery/pysam_battery.py @@ -1,14 +1,13 @@ -from attrs import field, define -from dataclasses import dataclass, asdict -from typing import Optional, Sequence, List +from collections.abc import Sequence +from dataclasses import asdict, dataclass import PySAM.BatteryStateful as BatteryStateful +from attrs import field, define +from hopp.utilities.validators import gt_zero, contains, range_val from h2integrate.core.utilities import BaseConfig, merge_shared_inputs from h2integrate.storage.battery.battery_baseclass import BatteryPerformanceBaseClass -from hopp.utilities.validators import contains, gt_zero, range_val - @dataclass class BatteryOutputs: @@ -20,10 +19,10 @@ class BatteryOutputs: gen: Sequence n_cycles: Sequence P_chargeable: Sequence - dispatch_I: List[float] - dispatch_P: List[float] - dispatch_SOC: List[float] - dispatch_lifecycles_per_day: List[Optional[int]] + dispatch_I: list[float] + dispatch_P: list[float] + dispatch_SOC: list[float] + dispatch_lifecycles_per_day: list[int | None] """ The following outputs are simulated from the BatteryStateful model, an entry per timestep: I: current [A] @@ -39,20 +38,20 @@ 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 """ 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"] + self.stateful_attributes = ["I", "P", "Q", "SOC", "T_batt", "n_cycles", "P_chargeable"] 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) @@ -75,20 +74,23 @@ 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 """ + system_capacity_kwh: float = field(validator=gt_zero) system_capacity_kw: float = field(validator=gt_zero) 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) minimum_SOC: float = field(default=10, validator=range_val(0, 100)) @@ -99,6 +101,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): @@ -112,6 +116,19 @@ 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.system_capacity_kwh, + units="kW*h", + desc="Battery storage capacity", + ) self.add_input( "time_step_duration", @@ -144,12 +161,15 @@ 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) - # 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.system_capacity_kwh, + inputs["storage_capacity"][0], self.system_model.ParamsPack.nominal_voltage, module_specs=module_specs, ) @@ -157,13 +177,13 @@ 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.system_capacity_kwh + inputs["charge_rate"][0] / inputs["storage_capacity"][0], ) # Minimum set of parameters to set to get statefulBattery to work - self.system_model.value("control_mode", 1.0) # 0.0 for current, 1.0 for power - self.system_model.value("input_power", 0.0) # just an initial value - self.system_model.value("input_current", 0.0) # just an initial value + self.system_model.value("control_mode", 1.0) # 0.0 for current, 1.0 for power + self.system_model.value("input_power", 0.0) # just an initial value + self.system_model.value("input_current", 0.0) # just an initial value self.system_model.value("dt_hr", self.config.dt) self.system_model.value("minimum_SOC", self.config.minimum_SOC) @@ -173,7 +193,6 @@ def setup(self): # Setup PySAM battery model using PySAM method self.system_model.setup() - def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): # Simulate the battery with provide dispatch inputs self.battery_simulate_with_dispatch( electricity_in=inputs["electricity_in"], @@ -187,11 +206,11 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): outputs["P_chargeable"] = self.outputs.P_chargeable def battery_simulate_with_dispatch( - self, - electricity_in: list, - time_step_duration: list, - control_variable: str, - ): + self, + electricity_in: list, + time_step_duration: list, + control_variable: str, + ): """Simulates the battery with the provided dispatch inputs. Args: @@ -203,7 +222,7 @@ def battery_simulate_with_dispatch( """ # Loop through the provided input power/current (decided by control_variable) for t in range(len(electricity_in)): - self.system_model.value('dt_hr', time_step_duration[t]) + self.system_model.value("dt_hr", time_step_duration[t]) self.system_model.value(control_variable, electricity_in[t]) self.system_model.execute(0) @@ -211,19 +230,12 @@ def battery_simulate_with_dispatch( # 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)[t] = self.system_model.value(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. @@ -231,7 +243,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 @@ -241,7 +253,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 @@ -251,27 +263,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)}. @@ -290,7 +301,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 @@ -298,25 +309,25 @@ 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 From 692726b59ef5096cf1c3fcada48479893eb74bf8 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:28:48 -0600 Subject: [PATCH 2/5] updated pysam battery --- h2integrate/storage/battery/pysam_battery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/h2integrate/storage/battery/pysam_battery.py b/h2integrate/storage/battery/pysam_battery.py index 3b4b1fff6..47ceed706 100644 --- a/h2integrate/storage/battery/pysam_battery.py +++ b/h2integrate/storage/battery/pysam_battery.py @@ -1,5 +1,5 @@ -from collections.abc import Sequence from dataclasses import asdict, dataclass +from collections.abc import Sequence import PySAM.BatteryStateful as BatteryStateful from attrs import field, define @@ -253,7 +253,7 @@ def size_batterystateful(self, desired_capacity, desired_voltage, module_specs=N :rtype: dict """ # calculate size - if not isinstance(self.system_model,BatteryStateful.BatteryStateful): + if not isinstance(self.system_model, BatteryStateful.BatteryStateful): raise TypeError original_capacity = self.system_model.ParamsPack.nominal_energy From 76952a762446980f2cf853fe1a2126a3fb530eab Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 8 Sep 2025 13:04:24 -0600 Subject: [PATCH 3/5] added docstring for battery cost model --- .../storage/battery/atb_battery_cost.py | 18 ++++++++++++++++++ .../inputs/pyomo_controller/tech_config.yaml | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/h2integrate/storage/battery/atb_battery_cost.py b/h2integrate/storage/battery/atb_battery_cost.py index c7203dc24..12cb9a8da 100644 --- a/h2integrate/storage/battery/atb_battery_cost.py +++ b/h2integrate/storage/battery/atb_battery_cost.py @@ -28,6 +28,22 @@ class ATBBatteryCostConfig(CostModelBaseConfig): 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") @@ -41,10 +57,12 @@ def setup(self): 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/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" From 37d64d4d60be60672e6e7eaf1096c55c788652a8 Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Tue, 9 Sep 2025 10:33:29 -0600 Subject: [PATCH 4/5] remove todo --- h2integrate/storage/battery/pysam_battery.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/h2integrate/storage/battery/pysam_battery.py b/h2integrate/storage/battery/pysam_battery.py index a4bbc79ec..360019ced 100644 --- a/h2integrate/storage/battery/pysam_battery.py +++ b/h2integrate/storage/battery/pysam_battery.py @@ -222,8 +222,6 @@ def setup(self): self.unmet_demand = 0.0 self.excess_resource = 0.0 - # TODO: should this be made configurable by users? - def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): # Size the battery based on inputs -> method brought from HOPP module_specs = { From 18a03fb01493649ef157e38eb9699c9309b1ef2c Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Tue, 9 Sep 2025 11:46:44 -0600 Subject: [PATCH 5/5] add blank line --- h2integrate/storage/battery/pysam_battery.py | 1 + 1 file changed, 1 insertion(+) diff --git a/h2integrate/storage/battery/pysam_battery.py b/h2integrate/storage/battery/pysam_battery.py index 360019ced..b5ea13fa4 100644 --- a/h2integrate/storage/battery/pysam_battery.py +++ b/h2integrate/storage/battery/pysam_battery.py @@ -318,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