Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0abe06f
added test to highlight error
elenya-grant Apr 9, 2026
f65d00f
fixed bug of charging with unavailable commodity
elenya-grant Apr 9, 2026
255b7c3
added subtests for charging less than available
elenya-grant Apr 9, 2026
ade5a20
added test for technology naming and updated logic for using_feedback…
elenya-grant Apr 9, 2026
27ca0a9
Merge remote-tracking branch 'h2i_upstream/develop' into dispatch/sto…
elenya-grant Apr 9, 2026
85eda16
Merge remote-tracking branch 'h2i_upstream/develop' into dispatch/sto…
elenya-grant Apr 13, 2026
696ffe6
Merge branch 'develop' into dispatch/storage_fixes
johnjasa Apr 14, 2026
aacc334
merged in develop
elenya-grant Apr 28, 2026
a2fe782
other fixes from merge
elenya-grant Apr 28, 2026
2697431
fixed example 33
elenya-grant May 6, 2026
8509b1c
Merge remote-tracking branch 'h2i_upstream/develop' into dispatch/sto…
elenya-grant May 6, 2026
d57b3c3
Merge branch 'develop' into dispatch/storage_fixes
johnjasa May 7, 2026
89f679e
Merge remote-tracking branch 'h2i_upstream/develop' into dispatch/sto…
elenya-grant May 7, 2026
cfcf30a
Merge branch 'develop' into dispatch/storage_fixes
johnjasa May 8, 2026
2880673
updated example 34 but tests still failing, doesnt converge
elenya-grant May 11, 2026
d3f30e2
fixed example 34 w help from sanjana
elenya-grant May 11, 2026
ddb22ed
Merge remote-tracking branch 'h2i_upstream/develop' into dispatch/sto…
elenya-grant May 11, 2026
9bd0c4a
Merge remote-tracking branch 'h2i_upstream/develop' into dispatch/sto…
elenya-grant May 12, 2026
eb274bd
updated example 34 to use electricity feedstock instead of grid
elenya-grant May 13, 2026
d83f179
Merge branch 'develop' into dispatch/storage_fixes
johnjasa May 14, 2026
b6bab2a
Merge branch 'develop' into dispatch/storage_fixes
johnjasa May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/33_peak_load_management/plant_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ plant:
timezone: -6
start_time: 2025/07/01 00:00:00
technology_interconnections:
- [grid_buy, battery, electricity, cable]
# include battery charge/discharge in the load
- [battery, electrical_load_demand, [electricity_out, electricity_in]]
# buy power from the grid to fulfill demand including to accommodate battery operation
Expand Down
5 changes: 4 additions & 1 deletion examples/34_plm_optimized_dispatch/plant_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ plant:
dt: 3600 # 1-hour timesteps
timezone: -6 # MDT (UTC-6)
start_time: 2025/01/01 00:00:00
technology_interconnections:
# supply the battery with electricity feedstock
- [electricity_feedstock, battery, electricity, cable]
tech_to_dispatch_connections:
- [grid_buy, battery]
- [electricity_feedstock, battery]
- [battery, battery]
17 changes: 6 additions & 11 deletions examples/34_plm_optimized_dispatch/tech_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,12 @@ technologies:
energy_capex: 408 # $/kWh
power_capex: 379 # $/kW
opex_fraction: 0.025
grid_buy:
electricity_feedstock:
performance_model:
model: GridPerformanceModel
cost_model:
model: GridCostModel
model: FeedstockPerformanceModel
model_inputs:
shared_parameters:
interconnection_size: 100000 # kW
cost_parameters:
cost_year: 2024
fixed_interconnection_cost: 0.0
interconnection_capex_per_kw: 0.0
interconnection_opex_per_kw: 0.0
electricity_buy_price: 0.09 # $/kWh
commodity: electricity
commodity_rate_units: kW
performance_parameters:
rated_capacity: 1e6 # 100 MW
Original file line number Diff line number Diff line change
Expand Up @@ -788,3 +788,101 @@ def test_battery_pyomo_h2s_openloop(subtests, plant_config):
h2s_expected_discharge,
rtol=1e-6,
)


@pytest.mark.regression
@pytest.mark.parametrize("pyo_controllers", ["bat"])
def test_battery_pyomo_battery_openloop(subtests, plant_config):
bat2_expected_discharge = np.concat([np.zeros(18), np.ones(6)])
bat2_expected_charge = np.concat([np.zeros(8), np.arange(-1, -9, -1), np.zeros(8)])
bat_expected_charge = np.concat(
[
np.zeros(12),
np.array(
[
-3988.62235554,
-3989.2357847,
-3989.76832626,
-3990.26170521,
-3990.71676106,
-3991.13573086,
-3991.52143699,
-3991.87684905,
-3992.20485715,
-3992.50815603,
-3992.78920148,
-3993.05020268,
]
),
]
)
bat_expected_discharge = np.concat(
[
np.array(
[
5999.99995059,
5990.56676743,
5990.138959,
5989.64831176,
5989.08548217,
5988.44193888,
5987.70577962,
5986.86071125,
5985.88493352,
5984.7496388,
5983.41717191,
5981.839478,
]
),
np.zeros(12),
]
)

prob = om.Problem()

# make h2 storage group
h2s_group = prob.model.add_subsystem("battery_2", om.Group())
h2s_ivc_comp, h2s_perf_comp, h2s_control_comp = make_h2_storage_openloop_group(plant_config)
h2s_group.add_subsystem("IVC1", h2s_ivc_comp, promotes=["*"])
h2s_group.add_subsystem("control", h2s_control_comp, promotes=["*"])
h2s_group.add_subsystem("perf", h2s_perf_comp, promotes=["*"])

# make battery group
bat_rule_comp, bat_perf_comp, bat_control_comp, electricity_in = make_battery_pyo_group(
plant_config
)
bat_group = prob.model.add_subsystem("battery", om.Group())
bat_group.add_subsystem("IVC2", electricity_in, promotes=["*"])
bat_group.add_subsystem("rule", bat_rule_comp, promotes=["*"])
bat_group.add_subsystem("control", bat_control_comp, promotes=["*"])
bat_group.add_subsystem("perf", bat_perf_comp, promotes=["*"])

prob.setup()
prob.run_model()

with subtests.test("Battery #1: Expected charge"):
np.testing.assert_allclose(
prob.get_val("battery.storage_electricity_charge", units="kW")[:24],
bat_expected_charge,
rtol=1e-6,
)
with subtests.test("Battery #1: Expected discharge"):
np.testing.assert_allclose(
prob.get_val("battery.storage_electricity_discharge", units="kW")[:24],
bat_expected_discharge,
rtol=1e-6,
)

# battery_2 is a "hydrogen battery"
with subtests.test("Battery #2: Expected charge"):
np.testing.assert_allclose(
prob.get_val("battery_2.storage_hydrogen_charge", units="kg/h")[:24],
bat2_expected_charge,
rtol=1e-6,
)
with subtests.test("Battery #2: Expected discharge"):
np.testing.assert_allclose(
prob.get_val("battery_2.storage_hydrogen_discharge", units="kg/h")[:24],
bat2_expected_discharge,
rtol=1e-6,
)
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,11 @@ def test_min_operating_cost_load_following_battery_dispatch(
rtol=1e-2,
)

with subtests.test("Charge never exceeds available commodity"):
charge_profile = prob.get_val("battery.storage_electricity_charge", units="kW")
indx_charging = np.argwhere(charge_profile).flatten()
assert np.all(np.abs(charge_profile)[indx_charging] <= electricity_in[indx_charging])


@pytest.mark.regression
def test_optimal_control_with_generic_storage(
Expand Down Expand Up @@ -477,6 +482,11 @@ def test_optimal_control_with_generic_storage(
rtol=1e-6,
)

with subtests.test("Charge never exceeds available commodity"):
charge_profile = prob.get_val("h2_storage.storage_hydrogen_charge", units="kg/h")
indx_charging = np.argwhere(charge_profile).flatten()
assert np.all(np.abs(charge_profile)[indx_charging] <= commodity_in[indx_charging])


@pytest.mark.regression
def test_optimal_dispatch_with_autosizing_storage_demand_less_than_avg_in(
Expand Down Expand Up @@ -547,6 +557,11 @@ def test_optimal_dispatch_with_autosizing_storage_demand_less_than_avg_in(
rtol=1e-6,
)

with subtests.test("Charge never exceeds available commodity"):
charge_profile = prob.get_val("h2_storage.storage_hydrogen_charge", units="kg/h")
indx_charging = np.argwhere(charge_profile).flatten()
assert np.all(np.abs(charge_profile)[indx_charging] <= commodity_in[indx_charging])


@pytest.mark.regression
def test_optimal_dispatch_with_autosizing_storage_demand_is_avg_in(
Expand Down Expand Up @@ -624,3 +639,8 @@ def test_optimal_dispatch_with_autosizing_storage_demand_is_avg_in(
expected_charge,
rtol=1e-6,
)

with subtests.test("Charge never exceeds available commodity"):
charge_profile = prob.get_val("h2_storage.storage_hydrogen_charge", units="kg/h")
indx_charging = np.argwhere(charge_profile).flatten()
assert np.all(np.abs(charge_profile)[indx_charging] <= commodity_in[indx_charging])
6 changes: 5 additions & 1 deletion h2integrate/storage/battery/pysam_battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ def simulate(
charge_rate: float,
discharge_rate: float,
storage_capacity: float,
commodity_available=list | np.ndarray,
sim_start_index: int = 0,
):
"""Run the PySAM BatteryStateful model over a control window.
Expand Down Expand Up @@ -241,13 +242,16 @@ def simulate(
# expressed as a rate (commodity_rate_units).
headroom = (soc_max - soc) * storage_capacity / self.dt_hr

# charge available based on the available input commodity
charge_available = commodity_available[sim_start_index + t]

# Calculate the max charge according to the charge rate and the simulation
max_charge_input = min([charge_rate, -self.system_model.value("P_chargeable")])

# Clip to the most restrictive limit,
# max(0, ...) guards against negative headroom when SOC
# slightly exceeds soc_max.
actual_charge = max(0.0, min(headroom, max_charge_input, -cmd))
actual_charge = max(0.0, min(headroom, max_charge_input, -cmd, charge_available))

# Update the charge command for the PySAM battery
cmd = -actual_charge
Expand Down
14 changes: 14 additions & 0 deletions h2integrate/storage/battery/test/test_pysam_battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ def test_pysam_battery_performance_model_without_controller(plant_config, subtes
expected_unused_electricity,
rtol=1e-2,
)
with subtests.test("Charge never exceeds available commodity"):
charge_profile = prob.get_val("storage_electricity_charge", units="kW")
indx_charging = np.argwhere(charge_profile).flatten()
assert np.all(np.abs(charge_profile)[indx_charging] <= electricity_in[indx_charging])


@pytest.mark.regression
Expand Down Expand Up @@ -393,6 +397,11 @@ def test_pysam_battery_no_controller_change_capacity(plant_config, subtests):
== init_charge_rate
)

with subtests.test("Charge never exceeds available commodity"):
charge_profile = prob_init.get_val("pysam_battery.storage_electricity_charge", units="kW")
indx_charging = np.argwhere(charge_profile).flatten()
assert np.all(np.abs(charge_profile)[indx_charging] <= electricity_in[indx_charging])

# Re-run and set the charge rate as half of what it was before
prob = om.Problem()
prob.model.add_subsystem(
Expand Down Expand Up @@ -469,3 +478,8 @@ def test_pysam_battery_no_controller_change_capacity(plant_config, subtests):
)
== 2.5
)

with subtests.test("Charge never exceeds available commodity"):
charge_profile = prob.get_val("pysam_battery.storage_electricity_charge", units="kW")
indx_charging = np.argwhere(charge_profile).flatten()
assert np.all(np.abs(charge_profile)[indx_charging] <= electricity_in[indx_charging])
16 changes: 14 additions & 2 deletions h2integrate/storage/storage_baseclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ def setup(self):
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):
if any(intended_dispatch_tech == name for name in self.tech_group_name):
self.add_input(
f"{commodity}_demand",
val=self.config.demand_profile,
Expand Down Expand Up @@ -260,6 +260,7 @@ def run_storage(
"charge_rate": charge_rate,
"discharge_rate": discharge_rate,
"storage_capacity": storage_capacity,
"commodity_available": inputs[f"{self.commodity}_in"],
}
storage_commodity_out, soc = dispatch(self.simulate, kwargs, inputs)

Expand All @@ -269,6 +270,7 @@ def run_storage(
charge_rate=charge_rate,
discharge_rate=discharge_rate,
storage_capacity=storage_capacity,
commodity_available=inputs[f"{self.commodity}_in"],
)

# determine storage charge and discharge
Expand Down Expand Up @@ -319,6 +321,7 @@ def simulate(
charge_rate: float,
discharge_rate: float,
storage_capacity: float,
commodity_available: list | np.ndarray,
sim_start_index: int = 0,
):
"""Run the storage model over a control window of ``n_control_window_hours`` length of time.
Expand Down Expand Up @@ -357,6 +360,8 @@ def simulate(
``commodity_rate_units`` (before discharge efficiency is applied).
storage_capacity (float):
Rated storage capacity in ``commodity_amount_units``.
commodity_available (list | np.ndarray): the input commodity available
to charge storage.
sim_start_index (int, optional):
Starting index for writing into persistent output arrays.
Defaults to 0.
Expand Down Expand Up @@ -399,11 +404,18 @@ def simulate(
# expressed as a rate (commodity_rate_units).
headroom = (soc_max - soc) * storage_capacity / self.dt_hr

# charge available based on the available input commodity
charge_available = commodity_available[sim_start_index + t]

# Clip to the most restrictive limit, then apply efficiency.
# max(0, ...) guards against negative headroom when SOC
# slightly exceeds soc_max.
# correct headroom to not include charge_eff.
actual_charge = max(0.0, min(headroom / charge_eff, charge_rate, -cmd)) * charge_eff

actual_charge = (
max(0.0, min(headroom / charge_eff, charge_rate, -cmd, charge_available))
* charge_eff
)

# Update SOC (actual_charge is in post-efficiency units)
soc += actual_charge / storage_capacity
Expand Down
18 changes: 18 additions & 0 deletions h2integrate/storage/test/test_storage_auto_sizing.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ def test_storage_autosizing_basic_performance_no_losses(plant_config, subtests):
unused_commodity_out = combined_out - commodity_demand
assert pytest.approx(unused_commodity_out.sum(), rel=1e-6) == 5.0

with subtests.test("Charge never exceeds available commodity"):
charge_profile = prob.get_val("storage.storage_hydrogen_charge", units="kg/h")
indx_charging = np.argwhere(charge_profile).flatten()
assert np.all(np.abs(charge_profile)[indx_charging] <= commodity_in[indx_charging])


@pytest.mark.regression
@pytest.mark.parametrize("n_timesteps", [24])
Expand Down Expand Up @@ -292,6 +297,10 @@ def test_storage_autosizing_soc_bounds(plant_config, subtests):
rtol=1e-6,
atol=1e-10,
)
with subtests.test("Charge never exceeds available commodity"):
charge_profile = prob.get_val("storage.storage_hydrogen_charge", units="kg/h")
indx_charging = np.argwhere(charge_profile).flatten()
assert np.all(np.abs(charge_profile)[indx_charging] <= commodity_in[indx_charging])


@pytest.mark.regression
Expand Down Expand Up @@ -423,6 +432,11 @@ def test_storage_autosizing_losses(plant_config, subtests):
atol=1e-10,
)

with subtests.test("Charge never exceeds available commodity"):
charge_profile = prob.get_val("storage.storage_hydrogen_charge", units="kg/h")
indx_charging = np.argwhere(charge_profile).flatten()
assert np.all(np.abs(charge_profile)[indx_charging] <= commodity_in[indx_charging])


@pytest.mark.regression
@pytest.mark.parametrize("n_timesteps", [24])
Expand Down Expand Up @@ -514,3 +528,7 @@ def test_storage_autosizing_with_passthrough_controller(plant_config, subtests):
rtol=1e-6,
atol=1e-10,
)
with subtests.test("Charge never exceeds available commodity"):
charge_profile = prob.get_val("storage.storage_hydrogen_charge", units="kg/h")
indx_charging = np.argwhere(charge_profile).flatten()
assert np.all(np.abs(charge_profile)[indx_charging] <= commodity_in[indx_charging])
Loading
Loading