From f5cc2ea8718edc3f6ae61081ac61dff9de21c01c Mon Sep 17 00:00:00 2001 From: svijaysh Date: Fri, 15 May 2026 17:33:18 -0600 Subject: [PATCH 1/8] Make sure storage_baseclass handles arbit dt --- h2integrate/storage/storage_baseclass.py | 18 +-- .../test/test_storage_performance_model.py | 113 ++++++++++++++++++ 2 files changed, 124 insertions(+), 7 deletions(-) diff --git a/h2integrate/storage/storage_baseclass.py b/h2integrate/storage/storage_baseclass.py index 96997fa85..b297a3c16 100644 --- a/h2integrate/storage/storage_baseclass.py +++ b/h2integrate/storage/storage_baseclass.py @@ -296,7 +296,11 @@ def run_storage( # Performance model outputs outputs[f"rated_{self.commodity}_production"] = discharge_rate - outputs[f"total_{self.commodity}_produced"] = np.sum(storage_commodity_out) + # Units of total_{commodity}_produced are in commodity_amount_units, + # To make sure the LHS and RHS have + # consistent units, we need to multiply by dt_hr + # to convert it to commodity_amount_units. + outputs[f"total_{self.commodity}_produced"] = np.sum(storage_commodity_out)*self.dt_hr outputs[f"annual_{self.commodity}_produced"] = outputs[ f"total_{self.commodity}_produced" ] * (1 / self.fraction_of_year_simulated) @@ -306,12 +310,12 @@ def run_storage( outputs["standard_capacity_factor"] = 0.0 else: outputs["capacity_factor"] = outputs[f"total_{self.commodity}_produced"] / ( - outputs[f"rated_{self.commodity}_production"] * self.n_timesteps + outputs[f"rated_{self.commodity}_production"] * self.n_timesteps * self.dt_hr ) # standard_capacity_factor is the ratio of commodity discharged to the discharge rate - total_commodity_discharged = outputs[f"storage_{self.commodity}_discharge"].sum() - outputs["standard_capacity_factor"] = total_commodity_discharged / ( - outputs[f"rated_{self.commodity}_production"] * self.n_timesteps + total_commodity_discharged = outputs[f"storage_{self.commodity}_discharge"].sum() * self.dt_hr + outputs["standard_capacity_factor"] = (total_commodity_discharged) / ( + outputs[f"rated_{self.commodity}_production"] * self.n_timesteps * self.dt_hr ) return outputs @@ -418,7 +422,7 @@ def simulate( ) # Update SOC (actual_charge is in post-efficiency units) - soc += actual_charge / storage_capacity + soc += actual_charge * self.dt_hr / storage_capacity # Update the amount of commodity used to charge from the input stream # If charge_eff<1, more commodity is pulled from the input stream than @@ -437,7 +441,7 @@ def simulate( ) # Update SOC (actual_discharge is before efficiency losses are applied.) - soc -= actual_discharge / storage_capacity + soc -= actual_discharge * self.dt_hr / storage_capacity # If discharge_eff<1, then less commodity is output from the storage # than the commodity discharged from storage diff --git a/h2integrate/storage/test/test_storage_performance_model.py b/h2integrate/storage/test/test_storage_performance_model.py index bc70c7de4..e9967376a 100644 --- a/h2integrate/storage/test/test_storage_performance_model.py +++ b/h2integrate/storage/test/test_storage_performance_model.py @@ -1210,3 +1210,116 @@ def test_round_trip_efficiency_preserved_in_config(subtests): assert config_dict["round_trip_efficiency"] == round_trip_eff assert config_dict["charge_efficiency"] == pytest.approx(np.sqrt(round_trip_eff)) assert config_dict["discharge_efficiency"] == pytest.approx(np.sqrt(round_trip_eff)) + +@pytest.fixture +def plant_config_non_hourly(n_timesteps): + plant = { + "plant": { + "plant_life": 30, + "simulation": { + "dt": 1800, + "n_timesteps": n_timesteps, + }, + }, + } + return plant + + + +@pytest.mark.regression +def test_storage_half_hourly_known_outputs(subtests): + """Verify SOC, charge/discharge profiles, and scalar outputs against calculated + values for a simple scenario at dt=1800s (30-min dt). + + Scenario (4 timesteps * 30 min = 2 hours total): + t0, t1: charge at 10 kg/h - stores 5 kg each step + t2, t3: discharge at 10 kg/h — removes 5 kg each step + + With capacity=40 kg, init_soc=0.1, eff=1.0, min_soc=0.1, max_soc=1.0: + SOC[0] = 0.1 + 5/40 = 0.225 + SOC[1] = 0.225 + 5/40 = 0.35 + SOC[2] = 0.35 - 5/40 = 0.225 + SOC[3] = 0.225 - 5/40 = 0.1 + total_hydrogen_produced = (-10 - 10 + 10 + 10) * 0.5 hr = 0 kg + standard_capacity_factor = (10+10)*0.5 / (10 * 4 * 0.5) = 10/20 = 0.5 -> 50 % + """ + plant_config = { + "plant": { + "plant_life": 30, + "simulation": {"dt": 1800, "n_timesteps": 4}, + } + } + model_inputs = { + "shared_parameters": { + "commodity": "hydrogen", + "commodity_rate_units": "kg/h", + }, + "performance_parameters": { + "max_capacity": 40.0, + "max_charge_rate": 10.0, + "min_soc_fraction": 0.1, + "max_soc_fraction": 1.0, + "init_soc_fraction": 0.1, + "commodity_amount_units": "kg", + "charge_equals_discharge": True, + "charge_efficiency": 1.0, + "discharge_efficiency": 1.0, + "demand_profile": 0.0, + }, + } + + commodity_in = np.array([10.0, 10.0, 0.0, 0.0]) + set_point = np.array([-10.0, -10.0, 10.0, 10.0]) + + prob = om.Problem() + prob.model.add_subsystem( + "IVC1", + om.IndepVarComp("hydrogen_in", val=commodity_in, units="kg/h"), + promotes=["*"], + ) + prob.model.add_subsystem( + "IVC2", + om.IndepVarComp("hydrogen_set_point", val=set_point, units="kg/h"), + promotes=["*"], + ) + prob.model.add_subsystem( + "storage", + StoragePerformanceModel( + plant_config=plant_config, + tech_config={"model_inputs": model_inputs}, + ), + promotes=["*"], + ) + prob.setup() + prob.run_model() + + with subtests.test("SOC profile matches hand-calculated values"): + expected_soc_pct = np.array([22.5, 35.0, 22.5, 10.0]) + np.testing.assert_allclose( + prob.get_val("storage.SOC", units="percent"), expected_soc_pct, rtol=1e-9 + ) + + with subtests.test("Charge profile"): + np.testing.assert_allclose( + prob.get_val("storage.storage_hydrogen_charge", units="kg/h"), + np.array([-10.0, -10.0, 0.0, 0.0]), + rtol=1e-9, + ) + + with subtests.test("Discharge profile"): + np.testing.assert_allclose( + prob.get_val("storage.storage_hydrogen_discharge", units="kg/h"), + np.array([0.0, 0.0, 10.0, 10.0]), + rtol=1e-9, + ) + + with subtests.test("total_hydrogen_produced = 0 kg (charge equals discharge)"): + assert pytest.approx( + prob.get_val("storage.total_hydrogen_produced", units="kg")[0], abs=1e-9 + ) == 0.0 + + with subtests.test("standard_capacity_factor = 50 %"): + assert pytest.approx( + prob.get_val("storage.standard_capacity_factor", units="percent")[0], rel=1e-9 + ) == 50.0 + From 521de04a2f4bb62bd4c21d1116ec15e884de5f6c Mon Sep 17 00:00:00 2001 From: svijaysh Date: Fri, 15 May 2026 17:54:48 -0600 Subject: [PATCH 2/8] Format storage_baseclass --- h2integrate/storage/storage_baseclass.py | 10 ++++---- .../test/test_storage_performance_model.py | 23 +++++++++++-------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/h2integrate/storage/storage_baseclass.py b/h2integrate/storage/storage_baseclass.py index b297a3c16..63a845fe7 100644 --- a/h2integrate/storage/storage_baseclass.py +++ b/h2integrate/storage/storage_baseclass.py @@ -296,11 +296,11 @@ def run_storage( # Performance model outputs outputs[f"rated_{self.commodity}_production"] = discharge_rate - # Units of total_{commodity}_produced are in commodity_amount_units, + # Units of total_{commodity}_produced are in commodity_amount_units, # To make sure the LHS and RHS have - # consistent units, we need to multiply by dt_hr + # consistent units, we need to multiply by dt_hr # to convert it to commodity_amount_units. - outputs[f"total_{self.commodity}_produced"] = np.sum(storage_commodity_out)*self.dt_hr + outputs[f"total_{self.commodity}_produced"] = np.sum(storage_commodity_out) * self.dt_hr outputs[f"annual_{self.commodity}_produced"] = outputs[ f"total_{self.commodity}_produced" ] * (1 / self.fraction_of_year_simulated) @@ -313,7 +313,9 @@ def run_storage( outputs[f"rated_{self.commodity}_production"] * self.n_timesteps * self.dt_hr ) # standard_capacity_factor is the ratio of commodity discharged to the discharge rate - total_commodity_discharged = outputs[f"storage_{self.commodity}_discharge"].sum() * self.dt_hr + total_commodity_discharged = ( + outputs[f"storage_{self.commodity}_discharge"].sum() * self.dt_hr + ) outputs["standard_capacity_factor"] = (total_commodity_discharged) / ( outputs[f"rated_{self.commodity}_production"] * self.n_timesteps * self.dt_hr ) diff --git a/h2integrate/storage/test/test_storage_performance_model.py b/h2integrate/storage/test/test_storage_performance_model.py index e9967376a..99d2980e5 100644 --- a/h2integrate/storage/test/test_storage_performance_model.py +++ b/h2integrate/storage/test/test_storage_performance_model.py @@ -1211,6 +1211,7 @@ def test_round_trip_efficiency_preserved_in_config(subtests): assert config_dict["charge_efficiency"] == pytest.approx(np.sqrt(round_trip_eff)) assert config_dict["discharge_efficiency"] == pytest.approx(np.sqrt(round_trip_eff)) + @pytest.fixture def plant_config_non_hourly(n_timesteps): plant = { @@ -1225,7 +1226,6 @@ def plant_config_non_hourly(n_timesteps): return plant - @pytest.mark.regression def test_storage_half_hourly_known_outputs(subtests): """Verify SOC, charge/discharge profiles, and scalar outputs against calculated @@ -1236,8 +1236,8 @@ def test_storage_half_hourly_known_outputs(subtests): t2, t3: discharge at 10 kg/h — removes 5 kg each step With capacity=40 kg, init_soc=0.1, eff=1.0, min_soc=0.1, max_soc=1.0: - SOC[0] = 0.1 + 5/40 = 0.225 - SOC[1] = 0.225 + 5/40 = 0.35 + SOC[0] = 0.1 + 5/40 = 0.225 + SOC[1] = 0.225 + 5/40 = 0.35 SOC[2] = 0.35 - 5/40 = 0.225 SOC[3] = 0.225 - 5/40 = 0.1 total_hydrogen_produced = (-10 - 10 + 10 + 10) * 0.5 hr = 0 kg @@ -1314,12 +1314,15 @@ def test_storage_half_hourly_known_outputs(subtests): ) with subtests.test("total_hydrogen_produced = 0 kg (charge equals discharge)"): - assert pytest.approx( - prob.get_val("storage.total_hydrogen_produced", units="kg")[0], abs=1e-9 - ) == 0.0 + assert ( + pytest.approx(prob.get_val("storage.total_hydrogen_produced", units="kg")[0], abs=1e-9) + == 0.0 + ) with subtests.test("standard_capacity_factor = 50 %"): - assert pytest.approx( - prob.get_val("storage.standard_capacity_factor", units="percent")[0], rel=1e-9 - ) == 50.0 - + assert ( + pytest.approx( + prob.get_val("storage.standard_capacity_factor", units="percent")[0], rel=1e-9 + ) + == 50.0 + ) From d74f80e80ae57ab9ee59f7760808b621f357d480 Mon Sep 17 00:00:00 2001 From: svijaysh Date: Tue, 19 May 2026 10:30:49 -0600 Subject: [PATCH 3/8] Add ability to handle different rate units --- h2integrate/storage/storage_baseclass.py | 41 ++++++++++++++---------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/h2integrate/storage/storage_baseclass.py b/h2integrate/storage/storage_baseclass.py index 63a845fe7..bf269ff75 100644 --- a/h2integrate/storage/storage_baseclass.py +++ b/h2integrate/storage/storage_baseclass.py @@ -1,5 +1,6 @@ import numpy as np from attrs import field, define +from openmdao.utils import units as om_units from h2integrate.core.utilities import BaseConfig from h2integrate.core.validators import range_val @@ -185,10 +186,17 @@ def setup(self): ) self.using_feedback_control = using_feedback_control - # convert from seconds to hours - self.dt_hr = int(self.options["plant_config"]["plant"]["simulation"]["dt"]) / ( - 3600 - ) # convert from seconds to hours + # convert from seconds to hours (kept for PySAM and legacy callers) + self.dt_hr = self.dt / 3600.0 + + # dt expressed in (commodity_amount_units / commodity_rate_units), i.e. the + # timestep width in whatever time unit makes rate × dt_amount = amount. + # Using self.dt (seconds) as the canonical source avoids any /3600 assumption. + self.dt_amount = om_units.convert_units( + self.dt, + "s", + f"({self.commodity_amount_units})/({self.commodity_rate_units})", + ) def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): """Run the storage model. @@ -296,11 +304,10 @@ def run_storage( # Performance model outputs outputs[f"rated_{self.commodity}_production"] = discharge_rate - # Units of total_{commodity}_produced are in commodity_amount_units, - # To make sure the LHS and RHS have - # consistent units, we need to multiply by dt_hr - # to convert it to commodity_amount_units. - outputs[f"total_{self.commodity}_produced"] = np.sum(storage_commodity_out) * self.dt_hr + # rate × dt_amount → commodity_amount_units (works for any commodity_rate_units) + outputs[f"total_{self.commodity}_produced"] = ( + np.sum(storage_commodity_out) * self.dt_amount + ) outputs[f"annual_{self.commodity}_produced"] = outputs[ f"total_{self.commodity}_produced" ] * (1 / self.fraction_of_year_simulated) @@ -310,14 +317,14 @@ def run_storage( outputs["standard_capacity_factor"] = 0.0 else: outputs["capacity_factor"] = outputs[f"total_{self.commodity}_produced"] / ( - outputs[f"rated_{self.commodity}_production"] * self.n_timesteps * self.dt_hr + outputs[f"rated_{self.commodity}_production"] * self.n_timesteps * self.dt_amount ) # standard_capacity_factor is the ratio of commodity discharged to the discharge rate total_commodity_discharged = ( - outputs[f"storage_{self.commodity}_discharge"].sum() * self.dt_hr + outputs[f"storage_{self.commodity}_discharge"].sum() * self.dt_amount ) - outputs["standard_capacity_factor"] = (total_commodity_discharged) / ( - outputs[f"rated_{self.commodity}_production"] * self.n_timesteps * self.dt_hr + outputs["standard_capacity_factor"] = total_commodity_discharged / ( + outputs[f"rated_{self.commodity}_production"] * self.n_timesteps * self.dt_amount ) return outputs @@ -408,7 +415,7 @@ def simulate( # --- Charging --- # headroom: how much more commodity the storage can accept, # expressed as a rate (commodity_rate_units). - headroom = (soc_max - soc) * storage_capacity / self.dt_hr + headroom = (soc_max - soc) * storage_capacity / self.dt_amount # charge available based on the available input commodity charge_available = commodity_available[sim_start_index + t] @@ -424,7 +431,7 @@ def simulate( ) # Update SOC (actual_charge is in post-efficiency units) - soc += actual_charge * self.dt_hr / storage_capacity + soc += actual_charge * self.dt_amount / storage_capacity # Update the amount of commodity used to charge from the input stream # If charge_eff<1, more commodity is pulled from the input stream than @@ -434,7 +441,7 @@ def simulate( # --- Discharging --- # headroom: how much commodity can still be drawn before # hitting the minimum SOC, expressed as a rate. - headroom = (soc - soc_min) * storage_capacity / self.dt_hr + headroom = (soc - soc_min) * storage_capacity / self.dt_amount # Clip to the most restrictive limit without applied efficiency. # Efficiency losses occur as energy leaves storage. @@ -443,7 +450,7 @@ def simulate( ) # Update SOC (actual_discharge is before efficiency losses are applied.) - soc -= actual_discharge * self.dt_hr / storage_capacity + soc -= actual_discharge * self.dt_amount / storage_capacity # If discharge_eff<1, then less commodity is output from the storage # than the commodity discharged from storage From 58a56da5261ec51d6b5d248803ca53fb6a80bf95 Mon Sep 17 00:00:00 2001 From: svijaysh Date: Tue, 19 May 2026 10:50:40 -0600 Subject: [PATCH 4/8] Add comments --- h2integrate/storage/storage_baseclass.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/h2integrate/storage/storage_baseclass.py b/h2integrate/storage/storage_baseclass.py index bf269ff75..cf3a4d370 100644 --- a/h2integrate/storage/storage_baseclass.py +++ b/h2integrate/storage/storage_baseclass.py @@ -304,7 +304,7 @@ def run_storage( # Performance model outputs outputs[f"rated_{self.commodity}_production"] = discharge_rate - # rate × dt_amount → commodity_amount_units (works for any commodity_rate_units) + # rate * dt_amount = commodity_amount_units (works for any commodity_rate_units) outputs[f"total_{self.commodity}_produced"] = ( np.sum(storage_commodity_out) * self.dt_amount ) From 4d479194d1752373d18fb70f4d128479ec6ff524 Mon Sep 17 00:00:00 2001 From: svijaysh Date: Tue, 19 May 2026 10:57:26 -0600 Subject: [PATCH 5/8] Run pre-commit --- h2integrate/storage/storage_baseclass.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/h2integrate/storage/storage_baseclass.py b/h2integrate/storage/storage_baseclass.py index cf3a4d370..b974c2188 100644 --- a/h2integrate/storage/storage_baseclass.py +++ b/h2integrate/storage/storage_baseclass.py @@ -190,7 +190,7 @@ def setup(self): self.dt_hr = self.dt / 3600.0 # dt expressed in (commodity_amount_units / commodity_rate_units), i.e. the - # timestep width in whatever time unit makes rate × dt_amount = amount. + # timestep width in whatever time unit makes rate * dt_amount = amount. # Using self.dt (seconds) as the canonical source avoids any /3600 assumption. self.dt_amount = om_units.convert_units( self.dt, @@ -305,9 +305,7 @@ def run_storage( # Performance model outputs outputs[f"rated_{self.commodity}_production"] = discharge_rate # rate * dt_amount = commodity_amount_units (works for any commodity_rate_units) - outputs[f"total_{self.commodity}_produced"] = ( - np.sum(storage_commodity_out) * self.dt_amount - ) + outputs[f"total_{self.commodity}_produced"] = np.sum(storage_commodity_out) * self.dt_amount outputs[f"annual_{self.commodity}_produced"] = outputs[ f"total_{self.commodity}_produced" ] * (1 / self.fraction_of_year_simulated) From e0e40a66452c4183f9c78b41e8a998eaadf2ecd2 Mon Sep 17 00:00:00 2001 From: svijaysh Date: Wed, 20 May 2026 08:30:52 -0600 Subject: [PATCH 6/8] Expand _time_step_bounds --- h2integrate/storage/storage_baseclass.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/h2integrate/storage/storage_baseclass.py b/h2integrate/storage/storage_baseclass.py index b974c2188..f03c3cb67 100644 --- a/h2integrate/storage/storage_baseclass.py +++ b/h2integrate/storage/storage_baseclass.py @@ -47,7 +47,7 @@ class StoragePerformanceBase(PerformanceModelBaseClass): """ _time_step_bounds = ( - 3600, + 1, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model @@ -191,7 +191,6 @@ def setup(self): # dt expressed in (commodity_amount_units / commodity_rate_units), i.e. the # timestep width in whatever time unit makes rate * dt_amount = amount. - # Using self.dt (seconds) as the canonical source avoids any /3600 assumption. self.dt_amount = om_units.convert_units( self.dt, "s", From 56052fed5c1180351aa0e68c52d9452dc612ec5d Mon Sep 17 00:00:00 2001 From: svijaysh Date: Thu, 21 May 2026 10:16:17 -0600 Subject: [PATCH 7/8] Add tests for kgs and kwh --- h2integrate/storage/storage_baseclass.py | 2 +- .../storage/storage_performance_model.py | 2 +- .../test/test_storage_performance_model.py | 207 +++++++++++++++++- 3 files changed, 201 insertions(+), 10 deletions(-) diff --git a/h2integrate/storage/storage_baseclass.py b/h2integrate/storage/storage_baseclass.py index f03c3cb67..4e4d36c54 100644 --- a/h2integrate/storage/storage_baseclass.py +++ b/h2integrate/storage/storage_baseclass.py @@ -48,7 +48,7 @@ class StoragePerformanceBase(PerformanceModelBaseClass): _time_step_bounds = ( 1, - 3600, + 36000, ) # (min, max) time step lengths (in seconds) compatible with this model def setup(self): diff --git a/h2integrate/storage/storage_performance_model.py b/h2integrate/storage/storage_performance_model.py index 2486f1d66..d597446b7 100644 --- a/h2integrate/storage/storage_performance_model.py +++ b/h2integrate/storage/storage_performance_model.py @@ -120,7 +120,7 @@ class StoragePerformanceModel(StoragePerformanceBase): """OpenMDAO component for a storage component.""" _time_step_bounds = ( - 3600, + 1, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model diff --git a/h2integrate/storage/test/test_storage_performance_model.py b/h2integrate/storage/test/test_storage_performance_model.py index 99d2980e5..245fc39e0 100644 --- a/h2integrate/storage/test/test_storage_performance_model.py +++ b/h2integrate/storage/test/test_storage_performance_model.py @@ -1227,7 +1227,8 @@ def plant_config_non_hourly(n_timesteps): @pytest.mark.regression -def test_storage_half_hourly_known_outputs(subtests): +@pytest.mark.parametrize("n_timesteps", [4]) +def test_storage_half_hourly_known_outputs(subtests, plant_config_non_hourly): """Verify SOC, charge/discharge profiles, and scalar outputs against calculated values for a simple scenario at dt=1800s (30-min dt). @@ -1243,12 +1244,7 @@ def test_storage_half_hourly_known_outputs(subtests): total_hydrogen_produced = (-10 - 10 + 10 + 10) * 0.5 hr = 0 kg standard_capacity_factor = (10+10)*0.5 / (10 * 4 * 0.5) = 10/20 = 0.5 -> 50 % """ - plant_config = { - "plant": { - "plant_life": 30, - "simulation": {"dt": 1800, "n_timesteps": 4}, - } - } + model_inputs = { "shared_parameters": { "commodity": "hydrogen", @@ -1285,7 +1281,7 @@ def test_storage_half_hourly_known_outputs(subtests): prob.model.add_subsystem( "storage", StoragePerformanceModel( - plant_config=plant_config, + plant_config=plant_config_non_hourly, tech_config={"model_inputs": model_inputs}, ), promotes=["*"], @@ -1326,3 +1322,198 @@ def test_storage_half_hourly_known_outputs(subtests): ) == 50.0 ) + +@pytest.mark.regression +@pytest.mark.parametrize("n_timesteps", [4]) +def test_storage_half_hourly_known_outputs_kg_s(subtests, plant_config_non_hourly): + """Verify SOC, charge/discharge profiles against hand-calculated values when + commodity_rate_units='kg/s' and dt=1800s. + + This test verifies that dt_amount is correctly computed via OpenMDAO + that SOC increments are correct for a non-hourly rate unit. + + Scenario + capacity=3600 kg, charge_rate=1 kg/s, init_soc=0.1, min/max_soc=0.1/1.0, eff=1.0 + commodity_in = [1, 1, 0, 0] kg/s + set_point = [-1, -1, 1, 1] kg/s (negative=charge, positive=discharge) + + With 1800s (0.5 hr) timesteps, the expected SOC profile is: + SOC[0] = 0.1 + (1 kg/s * 1800s) / 3600 kg = 0.1 + 0.5 = 0.6 -> 60% + SOC[1] = 0.6 + (1 kg/s * 1800s) / 3600 kg = 0.6 + 0 = 1.0 -> 100% + SOC[2] = 1.0 - (1 kg/s * 1800s) / 3600 kg = 1.0 - 0.5 = 0.5 -> 50% + SOC[3] = 0.5 - (1 kg/s * 1800s) / 3600 kg = 0.5 - 0.5 = 0.1 -> 10% + + total_produced = (-1-0.8+1+0.8)*1800 = 0 kg + standard_capacity_factor = (1+0.8)*1800 / (1*4*1800) = 3240/7200 = 45% + """ + + model_inputs = { + "shared_parameters": { + "commodity": "hydrogen", + "commodity_rate_units": "kg/s", + }, + "performance_parameters": { + "max_capacity": 3600.0, + "max_charge_rate": 1.0, + "min_soc_fraction": 0.1, + "max_soc_fraction": 1.0, + "init_soc_fraction": 0.1, + "commodity_amount_units": "kg", + "charge_equals_discharge": True, + "charge_efficiency": 1.0, + "discharge_efficiency": 1.0, + "demand_profile": 0.0, + }, + } + + commodity_in = np.array([1.0, 1.0, 0.0, 0.0]) + set_point = np.array([-1.0, -1.0, 1.0, 1.0]) + + prob = om.Problem() + prob.model.add_subsystem( + "IVC1", + om.IndepVarComp("hydrogen_in", val=commodity_in, units="kg/s"), + promotes=["*"], + ) + prob.model.add_subsystem( + "IVC2", + om.IndepVarComp("hydrogen_set_point", val=set_point, units="kg/s"), + promotes=["*"], + ) + prob.model.add_subsystem( + "storage", + StoragePerformanceModel( + plant_config=plant_config_non_hourly, + tech_config={"model_inputs": model_inputs}, + ), + promotes=["*"], + ) + prob.setup() + prob.run_model() + + with subtests.test("SOC profile matches hand-calculated values"): + expected_soc_pct = np.array([60.0, 100.0, 50.0, 10.0]) + np.testing.assert_allclose( + prob.get_val("storage.SOC", units="percent"), expected_soc_pct, rtol=1e-9 + ) + + with subtests.test("Charge profile"): + np.testing.assert_allclose( + prob.get_val("storage.storage_hydrogen_charge", units="kg/s"), + np.array([-1.0, -0.8, 0.0, 0.0]), + rtol=1e-9, + ) + + with subtests.test("Discharge profile"): + np.testing.assert_allclose( + prob.get_val("storage.storage_hydrogen_discharge", units="kg/s"), + np.array([0.0, 0.0, 1.0, 0.8]), + rtol=1e-9, + ) + + with subtests.test("total_hydrogen_produced = 0 kg (charge equals discharge)"): + assert ( + pytest.approx(prob.get_val("storage.total_hydrogen_produced", units="kg")[0], abs=1e-9) + == 0.0 + ) + + with subtests.test("standard_capacity_factor = 45 %"): + assert ( + pytest.approx( + prob.get_val("storage.standard_capacity_factor", units="percent")[0], rel=1e-9 + ) + == 45.0 + ) + + +@pytest.mark.regression +def test_storage_half_hourly_kw_kwh_2hr(subtests): + model_inputs = { + "shared_parameters": { + "commodity": "electricity", + "commodity_rate_units": "kW", + }, + "performance_parameters": { + "max_capacity": 10.0, + "max_charge_rate": 10.0, + "min_soc_fraction": 0.1, + "max_soc_fraction": 1.0, + "init_soc_fraction": 0.1, + "commodity_amount_units": "kW*h", + "charge_equals_discharge": True, + "charge_efficiency": 1.0, + "discharge_efficiency": 1.0, + "demand_profile": 0.0, + }, + } + plant_config_non_hourly = { + "plant": { + "plant_life": 30, + "simulation": { + "dt": 7200, + "n_timesteps": 4, + }, + }, + } + + commodity_in = np.array([10.0, 10.0, 10.0, 10.0]) + set_point = np.array([-10.0, -10.0, 10.0, 10.0]) + + prob = om.Problem() + prob.model.add_subsystem( + "IVC1", + om.IndepVarComp("electricity_in", val=commodity_in, units="kW"), + promotes=["*"], + ) + prob.model.add_subsystem( + "IVC2", + om.IndepVarComp("electricity_set_point", val=set_point, units="kW"), + promotes=["*"], + ) + prob.model.add_subsystem( + "storage", + StoragePerformanceModel( + plant_config=plant_config_non_hourly, + tech_config={"model_inputs": model_inputs}, + ), + promotes=["*"], + ) + prob.setup() + prob.run_model() + + with subtests.test("SOC profile"): + np.testing.assert_allclose( + prob.get_val("storage.SOC", units="percent"), + np.array([100.0, 100.0, 10.0, 10.0]), + rtol=1e-9, + ) + + with subtests.test("Charge profile"): + np.testing.assert_allclose( + prob.get_val("storage.storage_electricity_charge", units="kW"), + np.array([-4.5, -0.0, 0.0, 0.0]), + rtol=1e-9, + ) + + with subtests.test("Discharge profile"): + np.testing.assert_allclose( + prob.get_val("storage.storage_electricity_discharge", units="kW"), + np.array([0.0, 0.0, 4.5, 0.0]), + rtol=1e-9, + ) + + with subtests.test("total_electricity_produced = 0 kWh"): + assert ( + pytest.approx( + prob.get_val("storage.total_electricity_produced", units="kW*h")[0], abs=1e-9 + ) + == 0.0 + ) + + with subtests.test("standard_capacity_factor"): + assert ( + pytest.approx( + prob.get_val("storage.standard_capacity_factor", units="percent")[0], rel=1e-9 + ) + == 11.25 + ) From 42bc5e06a459be1a576e7716729a552075727fe2 Mon Sep 17 00:00:00 2001 From: svijaysh Date: Thu, 21 May 2026 10:18:57 -0600 Subject: [PATCH 8/8] Fix comments --- h2integrate/storage/test/test_storage_performance_model.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/h2integrate/storage/test/test_storage_performance_model.py b/h2integrate/storage/test/test_storage_performance_model.py index 245fc39e0..2ad903400 100644 --- a/h2integrate/storage/test/test_storage_performance_model.py +++ b/h2integrate/storage/test/test_storage_performance_model.py @@ -1323,16 +1323,17 @@ def test_storage_half_hourly_known_outputs(subtests, plant_config_non_hourly): == 50.0 ) + @pytest.mark.regression @pytest.mark.parametrize("n_timesteps", [4]) def test_storage_half_hourly_known_outputs_kg_s(subtests, plant_config_non_hourly): """Verify SOC, charge/discharge profiles against hand-calculated values when commodity_rate_units='kg/s' and dt=1800s. - This test verifies that dt_amount is correctly computed via OpenMDAO + This test verifies that dt_amount is correctly computed via OpenMDAO that SOC increments are correct for a non-hourly rate unit. - Scenario + Scenario capacity=3600 kg, charge_rate=1 kg/s, init_soc=0.1, min/max_soc=0.1/1.0, eff=1.0 commodity_in = [1, 1, 0, 0] kg/s set_point = [-1, -1, 1, 1] kg/s (negative=charge, positive=discharge)