diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b3c89f19..df56ad10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Added electricity and water consumption profiles as outputs to the `ECOElectrolyzerPerformanceModel` [PR 690](https://github.com/NatLabRockies/H2Integrate/pull/690) - Add `PeakLoadManagementHeuristicOpenLoopStorageController` as a storage control strategy. [PR 641](https://github.com/NatLabRockies/H2Integrate/pull/641) - Minor cleanup to `pose_optimization` [PR 695](https://github.com/NatLabRockies/H2Integrate/pull/695) +- Add charge and discharge efficiency support to `StoragePerformanceBaseConfig` and apply efficiency scaling internal to `PySAMBatteryPerformanceModel` so dispatch efficiencies are reflected in battery performance output [PR 699](https://github.com/NatLabRockies/H2Integrate/pull/699) - Added ability to have a custom/user-specified resource model [PR 698](https://github.com/NatLabRockies/H2Integrate/pull/698) ## 0.8 [April 15, 2026] diff --git a/examples/30_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py b/examples/30_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py index accec7347..43493fba6 100644 --- a/examples/30_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py +++ b/examples/30_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py @@ -43,25 +43,23 @@ ) ax[1].plot( range(start_hour, end_hour), - model.prob.get_val("battery.unused_electricity_out", units="MW")[start_hour:end_hour], + model.prob.get_val("electrical_load_demand.unused_electricity_out", units="MW")[ + start_hour:end_hour + ], linestyle=":", label="Unused Electricity (MW)", ) ax[1].plot( range(start_hour, end_hour), - model.prob.get_val("battery.unmet_electricity_demand_out", units="MW")[start_hour:end_hour], + model.prob.get_val("electrical_load_demand.unmet_electricity_demand_out", units="MW")[ + start_hour:end_hour + ], linestyle=":", label="Unmet Electrical Demand (MW)", ) ax[1].plot( range(start_hour, end_hour), model.prob.get_val("battery.electricity_out", units="MW")[start_hour:end_hour], - linestyle="-", - label="Electricity Out (MW)", -) -ax[1].plot( - range(start_hour, end_hour), - model.prob.get_val("battery.battery_electricity", units="MW")[start_hour:end_hour], linestyle="-.", label="Battery Electricity Out (MW)", ) diff --git a/examples/30_pyomo_optimized_dispatch/tech_config.yaml b/examples/30_pyomo_optimized_dispatch/tech_config.yaml index abe1b397a..6ea45afbb 100644 --- a/examples/30_pyomo_optimized_dispatch/tech_config.yaml +++ b/examples/30_pyomo_optimized_dispatch/tech_config.yaml @@ -49,6 +49,8 @@ technologies: max_soc_fraction: 0.9 # Maximum SOC allowable for the storage technology min_soc_fraction: 0.1 # Minimum SOC allowable for the storage technology commodity_rate_units: kW + charge_efficiency: 0.95 # Charge efficiency of the storage technology + discharge_efficiency: 0.95 # Discharge efficiency of the storage technology performance_parameters: chemistry: LFPGraphite demand_profile: 100000 # 100 MW @@ -59,8 +61,6 @@ technologies: opex_fraction: 0.25 # 0.25% of capex per year from 2024 ATB control_parameters: commodity: electricity - charge_efficiency: 0.95 # Charge efficiency of the storage technology - discharge_efficiency: 0.95 # Discharge efficiency of the storage technology cost_per_charge: 0.03 # in $/kW, cost to charge the storage (note that charging is incentivized) cost_per_discharge: 0.05 # in $/kW, cost to discharge the storage commodity_met_value: 0.1 # in $/kW, penalty for not meeting the desired load demand diff --git a/examples/test/test_all_examples.py b/examples/test/test_all_examples.py index a417d2670..f12802449 100644 --- a/examples/test/test_all_examples.py +++ b/examples/test/test_all_examples.py @@ -2622,13 +2622,13 @@ def test_pyomo_optimized_dispatch_example(subtests, temp_copy_of_example): battery_total = model.prob.get_val( "electrical_load_demand.total_electricity_produced", units="kW*h" )[0] - assert battery_total == pytest.approx(645_787_407.02, rel=1e-3) + assert battery_total == pytest.approx(639_232_586.47, rel=1e-3) with subtests.test("Check battery capacity factor"): battery_cf = model.prob.get_val("electrical_load_demand.capacity_factor", units="unitless")[ 0 ] - assert battery_cf == pytest.approx(0.7372, rel=1e-3) + assert battery_cf == pytest.approx(0.7297, rel=1e-3) with subtests.test("Check battery CapEx"): battery_capex = model.prob.get_val("battery.CapEx", units="USD")[0] diff --git a/h2integrate/control/control_strategies/storage/test/test_optimal_controllers.py b/h2integrate/control/control_strategies/storage/test/test_optimal_controllers.py index 0c0ec2633..6c8f3dcea 100644 --- a/h2integrate/control/control_strategies/storage/test/test_optimal_controllers.py +++ b/h2integrate/control/control_strategies/storage/test/test_optimal_controllers.py @@ -313,7 +313,10 @@ def test_min_operating_cost_load_following_battery_dispatch( assert np.cumsum(charge_plus_discharge).min() >= -1 * capacity with subtests.test("Expected discharge from hour 10-30"): - expected_discharge = np.concat([np.zeros(8), np.ones(8) * 5000, np.zeros(4)]) + discharge_eff = 0.95 + expected_discharge = np.concat( + [np.zeros(8), np.ones(8) * 5000 * discharge_eff, np.zeros(4)] + ) np.testing.assert_allclose( prob.get_val("battery.storage_electricity_discharge", units="kW")[0:20], expected_discharge, @@ -321,7 +324,8 @@ def test_min_operating_cost_load_following_battery_dispatch( ) with subtests.test("Expected charge hour 0-24"): - expected_charge = -1 * np.concat([np.zeros(16), np.ones(8) * 4000]) + charge_eff = 0.95 + expected_charge = -1 * np.concat([np.zeros(16), np.ones(8) * 4000 / charge_eff]) np.testing.assert_allclose( prob.get_val("battery.storage_electricity_charge", units="kW")[0:24], expected_charge, diff --git a/h2integrate/storage/battery/pysam_battery.py b/h2integrate/storage/battery/pysam_battery.py index 8e64b4b2d..ac9fcf24f 100644 --- a/h2integrate/storage/battery/pysam_battery.py +++ b/h2integrate/storage/battery/pysam_battery.py @@ -249,7 +249,7 @@ def simulate( # slightly exceeds soc_max. actual_charge = max(0.0, min(headroom, max_charge_input, -cmd)) - # Update the charge command for the PySAM batttery + # Update the charge command for the PySAM battery cmd = -actual_charge else: @@ -266,7 +266,7 @@ def simulate( # Clip and apply discharge efficiency. actual_discharge = max(0.0, min(headroom, max_discharge_input, cmd)) - # Update the discharge command for the PySAM batttery + # Update the discharge command for the PySAM battery cmd = actual_discharge # Set the input variable to the desired value @@ -276,7 +276,19 @@ def simulate( self.system_model.execute(0) # Save outputs at time t based on the simulation - storage_power_out_timesteps[t] = self.system_model.value("P") + # Apply external charge/discharge efficiency on top of PySAM's + # internal chemistry-based losses so the performance model output + # is consistent with the Pyomo dispatch optimizer's SOC constraint. + P = self.system_model.value("P") + if P > 0: + # Discharging: less commodity reaches the grid + storage_power_out_timesteps[t] = P * self.config.discharge_efficiency + else: + # Charging (P <= 0): more commodity drawn from the input stream + if self.config.charge_efficiency > 0: + storage_power_out_timesteps[t] = P / self.config.charge_efficiency + else: + storage_power_out_timesteps[t] = P soc_timesteps[t] = self.system_model.value("SOC") return storage_power_out_timesteps, soc_timesteps diff --git a/h2integrate/storage/battery/test/test_pysam_battery.py b/h2integrate/storage/battery/test/test_pysam_battery.py index 815fcd260..18b05ae6a 100644 --- a/h2integrate/storage/battery/test/test_pysam_battery.py +++ b/h2integrate/storage/battery/test/test_pysam_battery.py @@ -38,32 +38,6 @@ def test_pysam_battery_performance_model_without_controller(plant_config, subtes electricity_demand = np.ones(int(n_control_window)) * 1000.0 - prob.model.add_subsystem( - name="IVC1", - subsys=om.IndepVarComp(name="electricity_in", val=electricity_in, units="kW"), - promotes=["*"], - ) - - prob.model.add_subsystem( - name="IVC2", - subsys=om.IndepVarComp(name="time_step_duration", val=np.ones(n_control_window), units="h"), - promotes=["*"], - ) - - prob.model.add_subsystem( - name="IVC3", - subsys=om.IndepVarComp(name="electricity_demand", val=electricity_demand, units="kW"), - promotes=["*"], - ) - - prob.model.add_subsystem( - name="IVC4", - subsys=om.IndepVarComp( - name="electricity_set_point", val=electricity_demand - electricity_in, units="kW" - ), - promotes=["*"], - ) - prob.model.add_subsystem( "pysam_battery", PySAMBatteryPerformanceModel( @@ -75,6 +49,9 @@ def test_pysam_battery_performance_model_without_controller(plant_config, subtes prob.setup() + prob.set_val("electricity_in", electricity_in, units="kW") + prob.set_val("electricity_set_point", electricity_demand - electricity_in, units="kW") + prob.run_model() expected_battery_power = np.array( @@ -259,6 +236,126 @@ def test_battery_config(subtests): PySAMBatteryPerformanceModelConfig.from_dict(data) +@pytest.mark.unit +@pytest.mark.parametrize("n_timesteps", [24]) +def test_pysam_battery_charge_discharge_efficiency(plant_config, subtests): + """Test that charge and discharge efficiencies are applied to the PySAM battery output. + + Runs the battery twice with the same set-point commands: + 1. With default efficiencies (1.0) — baseline + 2. With charge_efficiency=0.9 and discharge_efficiency=0.9 + + Verifies that: + - During discharge timesteps, output power is scaled by discharge_efficiency + - During charge timesteps, commodity consumed is scaled by 1/charge_efficiency + """ + charge_eff = 0.9 + discharge_eff = 0.9 + + n_control_window = 24 + init_charge_rate = 50000.0 + init_capacity = 200000.0 + + # First 12 hours: excess input → charges battery (negative set_point) + # Last 12 hours: no input → discharges battery (positive set_point) + electricity_in = np.concatenate( + (np.ones(int(n_control_window / 2)) * 2000.0, np.zeros(int(n_control_window / 2))) + ) + electricity_demand = np.ones(n_control_window) * 1000.0 + set_point = electricity_demand - electricity_in + + results = {} + for label, ce, de in [("baseline", 1.0, 1.0), ("with_eff", charge_eff, discharge_eff)]: + tech_config = { + "model_inputs": { + "shared_parameters": { + "max_charge_rate": init_charge_rate, + "max_capacity": init_capacity, + "n_control_window": n_control_window, + "init_soc_fraction": 0.5, + "max_soc_fraction": 0.9, + "min_soc_fraction": 0.1, + }, + "performance_parameters": { + "chemistry": "LFPGraphite", + "demand_profile": 0.0, + "charge_efficiency": ce, + "discharge_efficiency": de, + }, + } + } + + prob = om.Problem() + prob.model.add_subsystem( + "pysam_battery", + PySAMBatteryPerformanceModel( + plant_config=plant_config, + tech_config=tech_config, + ), + promotes=["*"], + ) + prob.setup() + + prob.set_val("electricity_in", electricity_in, units="kW") + prob.set_val("electricity_set_point", set_point, units="kW") + + prob.run_model() + + results[label] = prob.get_val("electricity_out", units="kW").copy() + + baseline = results["baseline"] + with_eff = results["with_eff"] + + with subtests.test("discharge timesteps scaled by discharge_efficiency"): + # Discharge timesteps are where baseline > 0 + discharge_mask = baseline > 0 + assert discharge_mask.any(), "Expected some discharge timesteps" + np.testing.assert_allclose( + with_eff[discharge_mask], + baseline[discharge_mask] * discharge_eff, + rtol=1e-6, + ) + + with subtests.test("charge timesteps scaled by 1/charge_efficiency"): + # Charge timesteps are where baseline < 0 + charge_mask = baseline < 0 + assert charge_mask.any(), "Expected some charge timesteps" + np.testing.assert_allclose( + with_eff[charge_mask], + baseline[charge_mask] / charge_eff, + rtol=1e-6, + ) + + with subtests.test("config round_trip_efficiency"): + config_data = { + "max_capacity": 20000, + "max_charge_rate": 5000, + "chemistry": "LFPGraphite", + "init_soc_fraction": 0.5, + "max_soc_fraction": 0.9, + "min_soc_fraction": 0.1, + "demand_profile": 0.0, + "round_trip_efficiency": 0.81, + } + config = PySAMBatteryPerformanceModelConfig.from_dict(config_data) + assert config.charge_efficiency == pytest.approx(0.9, rel=1e-6) + assert config.discharge_efficiency == pytest.approx(0.9, rel=1e-6) + + with subtests.test("config defaults to 1.0 when no efficiency set"): + config_data = { + "max_capacity": 20000, + "max_charge_rate": 5000, + "chemistry": "LFPGraphite", + "init_soc_fraction": 0.5, + "max_soc_fraction": 0.9, + "min_soc_fraction": 0.1, + "demand_profile": 0.0, + } + config = PySAMBatteryPerformanceModelConfig.from_dict(config_data) + assert config.charge_efficiency == 1.0 + assert config.discharge_efficiency == 1.0 + + @pytest.mark.unit @pytest.mark.parametrize("n_timesteps", [24]) def test_battery_initialization(plant_config, subtests): @@ -327,26 +424,6 @@ def test_pysam_battery_no_controller_change_capacity(plant_config, subtests): } # Set up the OpenMDAO problem prob_init = om.Problem() - prob_init.model.add_subsystem( - name="IVC1", - subsys=om.IndepVarComp(name="electricity_demand", val=electricity_demand, units="kW"), - promotes=["*"], - ) - - prob_init.model.add_subsystem( - name="IVC2", - subsys=om.IndepVarComp(name="electricity_in", val=electricity_in, units="MW"), - promotes=["*"], - ) - - prob_init.model.add_subsystem( - name="IVC3", - subsys=om.IndepVarComp( - name="electricity_set_point", val=electricity_demand - electricity_in, units="kW" - ), - promotes=["*"], - ) - prob_init.model.add_subsystem( "pysam_battery", PySAMBatteryPerformanceModel( @@ -358,6 +435,9 @@ def test_pysam_battery_no_controller_change_capacity(plant_config, subtests): prob_init.setup() + prob_init.set_val("electricity_in", electricity_in, units="MW") + prob_init.set_val("electricity_set_point", electricity_demand - electricity_in, units="kW") + prob_init.run_model() with subtests.test("5 MW battery discharge profile within charge rate bounds"): @@ -390,26 +470,6 @@ def test_pysam_battery_no_controller_change_capacity(plant_config, subtests): # Re-run and set the charge rate as half of what it was before prob = om.Problem() - prob.model.add_subsystem( - name="IVC1", - subsys=om.IndepVarComp(name="electricity_demand", val=electricity_demand, units="kW"), - promotes=["*"], - ) - - prob.model.add_subsystem( - name="IVC2", - subsys=om.IndepVarComp(name="electricity_in", val=electricity_in, units="MW"), - promotes=["*"], - ) - - prob.model.add_subsystem( - name="IVC3", - subsys=om.IndepVarComp( - name="electricity_set_point", val=electricity_demand - electricity_in, units="kW" - ), - promotes=["*"], - ) - prob.model.add_subsystem( "pysam_battery", PySAMBatteryPerformanceModel( @@ -421,6 +481,8 @@ def test_pysam_battery_no_controller_change_capacity(plant_config, subtests): prob.setup() + prob.set_val("electricity_in", electricity_in, units="MW") + prob.set_val("electricity_set_point", electricity_demand - electricity_in, units="kW") prob.set_val("pysam_battery.max_charge_rate", init_charge_rate / 2, units="kW") prob.run_model() diff --git a/h2integrate/storage/storage_baseclass.py b/h2integrate/storage/storage_baseclass.py index 05a614da6..31c273124 100644 --- a/h2integrate/storage/storage_baseclass.py +++ b/h2integrate/storage/storage_baseclass.py @@ -2,7 +2,7 @@ from attrs import field, define from h2integrate.core.utilities import BaseConfig -from h2integrate.core.validators import range_val +from h2integrate.core.validators import range_val, range_val_or_none from h2integrate.core.model_baseclasses import PerformanceModelBaseClass @@ -17,6 +17,16 @@ class StoragePerformanceBaseConfig(BaseConfig): demand_profile (int | float | list): Demand values for each timestep, in the same units as `commodity_rate_units`. May be a scalar for constant demand or a list/array for time-varying demand. + charge_efficiency (float | None, optional): Efficiency of charging the storage, + represented as a decimal between 0 and 1 (e.g., 0.9 for 90% efficiency). + Optional if ``round_trip_efficiency`` is provided. Defaults to None. + discharge_efficiency (float | None, optional): Efficiency of discharging the storage, + represented as a decimal between 0 and 1 (e.g., 0.9 for 90% efficiency). + Optional if ``round_trip_efficiency`` is provided. Defaults to None. + round_trip_efficiency (float | None, optional): Combined efficiency of charging and + discharging the storage, represented as a decimal between 0 and 1 (e.g., 0.81 for + 81% efficiency). If provided, ``charge_efficiency`` and ``discharge_efficiency`` + are each set to the square root of this value. Defaults to None. """ # Below are used in all storage models @@ -24,6 +34,29 @@ class StoragePerformanceBaseConfig(BaseConfig): max_soc_fraction: float = field(validator=range_val(0, 1)) demand_profile: int | float | list = field() + charge_efficiency: float | None = field(default=None, validator=range_val_or_none(0, 1)) + discharge_efficiency: float | None = field(default=None, validator=range_val_or_none(0, 1)) + round_trip_efficiency: float | None = field(default=None, validator=range_val_or_none(0, 1)) + + def __attrs_post_init__(self): + """Post-initialization to resolve efficiencies. + + If ``round_trip_efficiency`` is provided and individual efficiencies are not, + calculates ``charge_efficiency`` and ``discharge_efficiency`` as the square + root of ``round_trip_efficiency``. If no efficiency is specified, defaults + both to 1.0 (no additional efficiency loss). + """ + if self.round_trip_efficiency is not None and ( + self.charge_efficiency is None and self.discharge_efficiency is None + ): + self.charge_efficiency = np.sqrt(self.round_trip_efficiency) + self.discharge_efficiency = np.sqrt(self.round_trip_efficiency) + + if self.charge_efficiency is None: + self.charge_efficiency = 1.0 + if self.discharge_efficiency is None: + self.discharge_efficiency = 1.0 + class StoragePerformanceBase(PerformanceModelBaseClass): """ diff --git a/h2integrate/storage/storage_performance_model.py b/h2integrate/storage/storage_performance_model.py index 2486f1d66..b28b57c23 100644 --- a/h2integrate/storage/storage_performance_model.py +++ b/h2integrate/storage/storage_performance_model.py @@ -1,8 +1,7 @@ -import numpy as np from attrs import field, define from h2integrate.core.utilities import merge_shared_inputs -from h2integrate.core.validators import gt_zero, range_val, range_val_or_none +from h2integrate.core.validators import gt_zero, range_val from h2integrate.storage.storage_baseclass import ( StoragePerformanceBase, StoragePerformanceBaseConfig, @@ -39,16 +38,6 @@ class StoragePerformanceModelConfig(StoragePerformanceBaseConfig): charge_equals_discharge (bool, optional): If True, set the max_discharge_rate equal to the max_charge_rate. If False, specify the max_discharge_rate as a value different than the max_charge_rate. Defaults to True. - charge_efficiency (float | None, optional): Efficiency of charging the storage, represented - as a decimal between 0 and 1 (e.g., 0.9 for 90% efficiency). Optional if - `round_trip_efficiency` is provided. - discharge_efficiency (float | None, optional): Efficiency of discharging the storage, - represented as a decimal between 0 and 1 (e.g., 0.9 for 90% efficiency). Optional if - `round_trip_efficiency` is provided. - round_trip_efficiency (float | None, optional): Combined efficiency of charging and - discharging the storage, represented as a decimal between 0 and 1 (e.g., 0.81 for - 81% efficiency). Optional if `charge_efficiency` and `discharge_efficiency` are - provided. """ @@ -64,32 +53,12 @@ class StoragePerformanceModelConfig(StoragePerformanceBaseConfig): max_discharge_rate: float | None = field(default=None) charge_equals_discharge: bool = field(default=True) - charge_efficiency: float | None = field(default=None, validator=range_val_or_none(0, 1)) - discharge_efficiency: float | None = field(default=None, validator=range_val_or_none(0, 1)) - round_trip_efficiency: float | None = field(default=None, validator=range_val_or_none(0, 1)) - def __attrs_post_init__(self): """ - Post-initialization logic to validate and calculate efficiencies. - - Ensures that either `charge_efficiency` and `discharge_efficiency` are provided, - or `round_trip_efficiency` is provided. If `round_trip_efficiency` is provided, - it calculates `charge_efficiency` and `discharge_efficiency` as the square root - of `round_trip_efficiency`. + Post-initialization logic to validate and calculate efficiencies + and discharge rate defaults. """ - if (self.round_trip_efficiency is not None) and ( - self.charge_efficiency is None and self.discharge_efficiency is None - ): - # Calculate charge and discharge efficiencies from round-trip efficiency - self.charge_efficiency = np.sqrt(self.round_trip_efficiency) - self.discharge_efficiency = np.sqrt(self.round_trip_efficiency) - - if self.charge_efficiency is None or self.discharge_efficiency is None: - raise ValueError( - "Exactly one of the following sets of parameters must be set: (a) " - "`round_trip_efficiency`, or (b) both `charge_efficiency` " - "and `discharge_efficiency`." - ) + super().__attrs_post_init__() if self.charge_equals_discharge: if (