From 0abe06fa083a9ebc6fd96ea0ec57aa2f07a7baa6 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:50:50 -0600 Subject: [PATCH 001/105] added test to highlight error --- .../test/test_storage_performance_model.py | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/h2integrate/storage/test/test_storage_performance_model.py b/h2integrate/storage/test/test_storage_performance_model.py index 159ee36cf..6edcbd235 100644 --- a/h2integrate/storage/test/test_storage_performance_model.py +++ b/h2integrate/storage/test/test_storage_performance_model.py @@ -957,3 +957,136 @@ def test_generic_storage_with_simple_control_with_losses_round_trip(plant_config expected_charge, rtol=1e-6, ) + + +@pytest.mark.regression +@pytest.mark.parametrize("n_timesteps", [24]) +def test_generic_storage_charge_more_than_available(plant_config, subtests): + # this tests a case where the demand < charge rate and charge_rate=discharge_rate + model_inputs = { + "shared_parameters": { + "commodity": "hydrogen", + "commodity_rate_units": "kg/h", + }, + "performance_parameters": { + "max_capacity": 40, + "max_charge_rate": 10, + "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, + }, + "control_parameters": {"set_demand_as_avg_commodity_in": False}, + } + + prob = om.Problem() + + commodity_demand = np.full(24, 5.0) + commodity_in = np.concat([np.zeros(3), np.cumsum(np.ones(15)), np.full(6, 4.0)]) + nominal_set_point = commodity_demand - commodity_in + nominal_charge_profile = np.where(nominal_set_point < 0, nominal_set_point, 0) + indx_charge = np.argwhere(nominal_charge_profile < 0).flatten() + excess_commodity_avail = np.abs( + np.abs(nominal_charge_profile)[indx_charge] - commodity_in[indx_charge] + ) + more_than_avail_charge_cmd = nominal_charge_profile[indx_charge] - excess_commodity_avail * 2 + # nominal_charge_profile[indx_charge] = more_than_avail_charge_cmd + nominal_set_point[indx_charge] = more_than_avail_charge_cmd + + prob.model.add_subsystem( + name="IVC1", + subsys=om.IndepVarComp(name="hydrogen_in", val=commodity_in, units="kg/h"), + promotes=["*"], + ) + + prob.model.add_subsystem( + name="IVC2", + subsys=om.IndepVarComp(name="hydrogen_demand", val=commodity_demand, units="kg/h"), + promotes=["*"], + ) + + prob.model.add_subsystem( + name="IVC3", + subsys=om.IndepVarComp(name="hydrogen_set_point", val=nominal_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() + + performance_model_config = model_inputs["performance_parameters"] + + charge_rate = prob.get_val("storage.max_charge_rate", units="kg/h")[0] + discharge_rate = prob.get_val("storage.max_charge_rate", units="kg/h")[0] + prob.get_val("storage.storage_capacity", units="kg")[0] + + # Test that discharge is always positive + with subtests.test("Discharge is always positive"): + assert np.all(prob.get_val("storage.storage_hydrogen_discharge", units="kg/h") >= 0) + + with subtests.test("Charge is always negative"): + assert np.all(prob.get_val("storage.storage_hydrogen_charge", units="kg/h") <= 0) + + with subtests.test("Charge + Discharge == storage_hydrogen_out"): + charge_plus_discharge = prob.get_val( + "storage.storage_hydrogen_charge", units="kg/h" + ) + prob.get_val("storage.storage_hydrogen_discharge", units="kg/h") + np.testing.assert_allclose( + charge_plus_discharge, prob.get_val("storage_hydrogen_out", units="kg/h"), rtol=1e-6 + ) + with subtests.test("Initial SOC is correct"): + assert ( + pytest.approx(prob.model.get_val("storage.SOC", units="unitless")[0], rel=1e-6) + == performance_model_config["init_soc_fraction"] + ) + + with subtests.test("Charge never exceeds charge rate"): + assert ( + prob.get_val("storage.storage_hydrogen_charge", units="kg/h").min() >= -1 * charge_rate + ) + + 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]) + + with subtests.test("Discharge never exceeds discharge rate"): + assert ( + prob.get_val("storage.storage_hydrogen_discharge", units="kg/h").max() <= discharge_rate + ) + + with subtests.test("Discharge never exceeds demand"): + assert np.all( + prob.get_val("storage.storage_hydrogen_discharge", units="kg/h").max() + <= commodity_demand + ) + + with subtests.test("Expected discharge"): + expected_discharge = np.concat([np.zeros(18), np.ones(6)]) + np.testing.assert_allclose( + prob.get_val("storage.storage_hydrogen_discharge", units="kg/h"), + expected_discharge, + rtol=1e-6, + ) + + with subtests.test("Expected charge"): + expected_charge = np.concat([np.zeros(8), np.arange(-1, -9, -1), np.zeros(8)]) + np.testing.assert_allclose( + prob.get_val("storage.storage_hydrogen_charge", units="kg/h"), + expected_charge, + rtol=1e-6, + ) From f65d00f4a90c3bb49ae689652d60f5fdb5305cc1 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:33:19 -0600 Subject: [PATCH 002/105] fixed bug of charging with unavailable commodity --- h2integrate/storage/battery/pysam_battery.py | 6 +++++- h2integrate/storage/storage_baseclass.py | 14 +++++++++++++- .../storage/test/test_storage_performance_model.py | 4 +++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/h2integrate/storage/battery/pysam_battery.py b/h2integrate/storage/battery/pysam_battery.py index c20bc7b32..7848a1e54 100644 --- a/h2integrate/storage/battery/pysam_battery.py +++ b/h2integrate/storage/battery/pysam_battery.py @@ -180,6 +180,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. @@ -236,13 +237,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 batttery cmd = -actual_charge diff --git a/h2integrate/storage/storage_baseclass.py b/h2integrate/storage/storage_baseclass.py index 65fa00f3e..7299b7bc4 100644 --- a/h2integrate/storage/storage_baseclass.py +++ b/h2integrate/storage/storage_baseclass.py @@ -269,6 +269,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) @@ -278,6 +279,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 @@ -342,6 +344,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`` timesteps. @@ -380,6 +383,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. @@ -422,11 +427,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 diff --git a/h2integrate/storage/test/test_storage_performance_model.py b/h2integrate/storage/test/test_storage_performance_model.py index 6edcbd235..386e1d515 100644 --- a/h2integrate/storage/test/test_storage_performance_model.py +++ b/h2integrate/storage/test/test_storage_performance_model.py @@ -1084,7 +1084,9 @@ def test_generic_storage_charge_more_than_available(plant_config, subtests): ) with subtests.test("Expected charge"): - expected_charge = np.concat([np.zeros(8), np.arange(-1, -9, -1), np.zeros(8)]) + expected_charge = np.concat( + [np.zeros(8), np.arange(-6, -10, -1), np.array([-6]), np.zeros(11)] + ) np.testing.assert_allclose( prob.get_val("storage.storage_hydrogen_charge", units="kg/h"), expected_charge, From 255b7c319ba97276bb41b23c72d35accc6672b70 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:43:52 -0600 Subject: [PATCH 003/105] added subtests for charging less than available --- .../control/test/test_optimal_controllers.py | 20 +++++++++++++++ .../battery/test/test_pysam_battery.py | 14 +++++++++++ .../storage/test/test_storage_auto_sizing.py | 17 +++++++++++++ .../test/test_storage_performance_model.py | 25 +++++++++++++++++++ 4 files changed, 76 insertions(+) diff --git a/h2integrate/control/test/test_optimal_controllers.py b/h2integrate/control/test/test_optimal_controllers.py index f1f1d5496..84528b11a 100644 --- a/h2integrate/control/test/test_optimal_controllers.py +++ b/h2integrate/control/test/test_optimal_controllers.py @@ -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( @@ -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( @@ -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( @@ -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]) diff --git a/h2integrate/storage/battery/test/test_pysam_battery.py b/h2integrate/storage/battery/test/test_pysam_battery.py index 1f47e22c5..9089e7c9b 100644 --- a/h2integrate/storage/battery/test/test_pysam_battery.py +++ b/h2integrate/storage/battery/test/test_pysam_battery.py @@ -190,6 +190,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 @@ -382,6 +386,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( @@ -458,3 +467,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]) diff --git a/h2integrate/storage/test/test_storage_auto_sizing.py b/h2integrate/storage/test/test_storage_auto_sizing.py index e7918ff5f..3aa247a78 100644 --- a/h2integrate/storage/test/test_storage_auto_sizing.py +++ b/h2integrate/storage/test/test_storage_auto_sizing.py @@ -188,6 +188,10 @@ def test_storage_autosizing_basic_performance_no_losses(plant_config, subtests): assert ( pytest.approx(prob.get_val("unused_hydrogen_out", units="kg/h").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 @@ -285,6 +289,10 @@ def test_storage_autosizing_soc_bounds(plant_config, subtests): np.testing.assert_allclose( prob.get_val("hydrogen_out", units="kg/h"), commodity_demand, 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 @@ -416,6 +424,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]) @@ -507,3 +520,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]) diff --git a/h2integrate/storage/test/test_storage_performance_model.py b/h2integrate/storage/test/test_storage_performance_model.py index 386e1d515..f07c40e08 100644 --- a/h2integrate/storage/test/test_storage_performance_model.py +++ b/h2integrate/storage/test/test_storage_performance_model.py @@ -184,6 +184,11 @@ def test_generic_storage_with_simple_control_dmd_lessthan_charge_rate(plant_conf rtol=1e-6, ) + 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]) @@ -376,6 +381,11 @@ def test_generic_storage_with_simple_control_charge_rate_lessthan_demand(plant_c rtol=1e-6, ) + 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]) @@ -537,6 +547,11 @@ def test_generic_storage_with_simple_control_zero_size(plant_config, subtests): == performance_model_config["init_soc_fraction"] ) + 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]) @@ -764,6 +779,11 @@ def test_generic_storage_with_simple_control_with_losses(plant_config, subtests) rtol=1e-6, ) + 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]) @@ -958,6 +978,11 @@ def test_generic_storage_with_simple_control_with_losses_round_trip(plant_config rtol=1e-6, ) + 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]) From ade5a20ebf2a99c248c2d40b2ae46533c16600d1 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:56:49 -0600 Subject: [PATCH 004/105] added test for technology naming and updated logic for using_feedback_control in storage baseclass setup() --- ...est_multistorage_pyomo_openloop_control.py | 98 +++++++++++++++++++ h2integrate/storage/storage_baseclass.py | 2 +- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/h2integrate/control/test/test_multistorage_pyomo_openloop_control.py b/h2integrate/control/test/test_multistorage_pyomo_openloop_control.py index dc41da0fe..33841e0ae 100644 --- a/h2integrate/control/test/test_multistorage_pyomo_openloop_control.py +++ b/h2integrate/control/test/test_multistorage_pyomo_openloop_control.py @@ -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, + ) diff --git a/h2integrate/storage/storage_baseclass.py b/h2integrate/storage/storage_baseclass.py index 7299b7bc4..28d96f822 100644 --- a/h2integrate/storage/storage_baseclass.py +++ b/h2integrate/storage/storage_baseclass.py @@ -181,7 +181,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_discrete_input("pyomo_dispatch_solver", val=lambda: None) # set the using feedback control variable to True using_feedback_control = True From a2fe7822ffb391af16b1bd194e3b2686acdf6af8 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:22:09 -0600 Subject: [PATCH 005/105] other fixes from merge --- h2integrate/storage/test/test_storage_auto_sizing.py | 10 ++++------ .../storage/test/test_storage_performance_model.py | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/h2integrate/storage/test/test_storage_auto_sizing.py b/h2integrate/storage/test/test_storage_auto_sizing.py index 3912ff214..3fa782f2c 100644 --- a/h2integrate/storage/test/test_storage_auto_sizing.py +++ b/h2integrate/storage/test/test_storage_auto_sizing.py @@ -188,16 +188,14 @@ def test_storage_autosizing_basic_performance_no_losses(plant_config, subtests): ) with subtests.test("Total unused commodity"): - assert ( - pytest.approx(prob.get_val("unused_hydrogen_out", units="kg/h").sum(), rel=1e-6) == 5.0 - ) + combined_out = prob.get_val("hydrogen_out", units="kg/h") + commodity_in + 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]) - combined_out = prob.get_val("hydrogen_out", units="kg/h") + commodity_in - unused_commodity_out = combined_out - commodity_demand - assert pytest.approx(unused_commodity_out.sum(), rel=1e-6) == 5.0 @pytest.mark.regression diff --git a/h2integrate/storage/test/test_storage_performance_model.py b/h2integrate/storage/test/test_storage_performance_model.py index 39c44b54a..542c03463 100644 --- a/h2integrate/storage/test/test_storage_performance_model.py +++ b/h2integrate/storage/test/test_storage_performance_model.py @@ -1121,7 +1121,7 @@ def test_generic_storage_charge_more_than_available(plant_config, subtests): "storage.storage_hydrogen_charge", units="kg/h" ) + prob.get_val("storage.storage_hydrogen_discharge", units="kg/h") np.testing.assert_allclose( - charge_plus_discharge, prob.get_val("storage_hydrogen_out", units="kg/h"), rtol=1e-6 + charge_plus_discharge, prob.get_val("hydrogen_out", units="kg/h"), rtol=1e-6 ) with subtests.test("Initial SOC is correct"): assert ( @@ -1169,7 +1169,7 @@ def test_generic_storage_charge_more_than_available(plant_config, subtests): ) with subtests.test("Expected capacity factor"): assert ( - pytest.approx(-17.5, rel=1e-6) + pytest.approx(-12.5, rel=1e-6) == prob.get_val("storage.capacity_factor", units="percent")[0] ) From 3d0e272fcf75ba619019a0cc6cc9c86ef7adb6c8 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Tue, 28 Apr 2026 10:57:11 -0600 Subject: [PATCH 006/105] Add system_level_control to the CI --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b362c792..f9f154f59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: branches: - main - develop + - system_level_control pull_request: branches: - main From d13d25a4425808a6a853d2b56cbb2723cb251d81 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:22:14 -0600 Subject: [PATCH 007/105] added control classifiers to key technology models --- h2integrate/converters/ammonia/ammonia_synloop.py | 1 + h2integrate/converters/grid/grid.py | 1 + h2integrate/converters/hydrogen/pem_electrolyzer.py | 1 + h2integrate/converters/natural_gas/natural_gas_cc_ct.py | 1 + h2integrate/converters/solar/solar_baseclass.py | 1 + h2integrate/converters/wind/wind_plant_baseclass.py | 1 + h2integrate/storage/storage_baseclass.py | 1 + 7 files changed, 7 insertions(+) diff --git a/h2integrate/converters/ammonia/ammonia_synloop.py b/h2integrate/converters/ammonia/ammonia_synloop.py index ac1308c29..972de7207 100644 --- a/h2integrate/converters/ammonia/ammonia_synloop.py +++ b/h2integrate/converters/ammonia/ammonia_synloop.py @@ -143,6 +143,7 @@ class AmmoniaSynLoopPerformanceModel(ResizeablePerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/grid/grid.py b/h2integrate/converters/grid/grid.py index cb0760c47..dfe61b572 100644 --- a/h2integrate/converters/grid/grid.py +++ b/h2integrate/converters/grid/grid.py @@ -51,6 +51,7 @@ class GridPerformanceModel(PerformanceModelBaseClass): 300, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/hydrogen/pem_electrolyzer.py b/h2integrate/converters/hydrogen/pem_electrolyzer.py index 57bd10908..0e18eb985 100644 --- a/h2integrate/converters/hydrogen/pem_electrolyzer.py +++ b/h2integrate/converters/hydrogen/pem_electrolyzer.py @@ -63,6 +63,7 @@ class ECOElectrolyzerPerformanceModel(ElectrolyzerPerformanceBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "curtailable" def setup(self): self.config = ECOElectrolyzerPerformanceModelConfig.from_dict( diff --git a/h2integrate/converters/natural_gas/natural_gas_cc_ct.py b/h2integrate/converters/natural_gas/natural_gas_cc_ct.py index 41771ebae..21bb091ff 100644 --- a/h2integrate/converters/natural_gas/natural_gas_cc_ct.py +++ b/h2integrate/converters/natural_gas/natural_gas_cc_ct.py @@ -60,6 +60,7 @@ class NaturalGasPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/solar/solar_baseclass.py b/h2integrate/converters/solar/solar_baseclass.py index 0bfa4019c..cdb125a8d 100644 --- a/h2integrate/converters/solar/solar_baseclass.py +++ b/h2integrate/converters/solar/solar_baseclass.py @@ -6,6 +6,7 @@ class SolarPerformanceBaseClass(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "curtailable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/wind/wind_plant_baseclass.py b/h2integrate/converters/wind/wind_plant_baseclass.py index 6e4de0bd2..b8a90ed6e 100644 --- a/h2integrate/converters/wind/wind_plant_baseclass.py +++ b/h2integrate/converters/wind/wind_plant_baseclass.py @@ -6,6 +6,7 @@ class WindPerformanceBaseClass(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "curtailable" def initialize(self): super().initialize() diff --git a/h2integrate/storage/storage_baseclass.py b/h2integrate/storage/storage_baseclass.py index 803966d70..9de0fc0ad 100644 --- a/h2integrate/storage/storage_baseclass.py +++ b/h2integrate/storage/storage_baseclass.py @@ -49,6 +49,7 @@ class StoragePerformanceBase(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "storage" def setup(self): """Set up the storage performance model in OpenMDAO. From c51d33d0cc6315622a6315769f8deaeb346c221c Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:38:18 -0600 Subject: [PATCH 008/105] added curtailable component --- .../converters/curtailable_component.py | 48 ++++++++++++++++ .../test/test_curtailable_component.py | 56 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 h2integrate/control/control_strategies/converters/curtailable_component.py create mode 100644 h2integrate/control/control_strategies/converters/test/test_curtailable_component.py diff --git a/h2integrate/control/control_strategies/converters/curtailable_component.py b/h2integrate/control/control_strategies/converters/curtailable_component.py new file mode 100644 index 000000000..54578a5cb --- /dev/null +++ b/h2integrate/control/control_strategies/converters/curtailable_component.py @@ -0,0 +1,48 @@ +import numpy as np +import openmdao.api as om + + +class CurtailableComponentModel(om.ExplicitComponent): + _time_step_bounds = ( + 1, + 1e9, + ) # (min, max) time step lengths (in seconds) compatible with this model + + def initialize(self): + self.options.declare("commodity", types=str) + self.options.declare("plant_config", types=dict) + + def setup(self): + self.commodity = self.options["commodity"] + n_timesteps = int(self.options["plant_config"]["plant"]["simulation"]["n_timesteps"]) + self.add_input(f"{self.commodity}_out", shape=n_timesteps, units=None, units_by_conn=True) + self.add_input( + f"{self.commodity}_set_point", + shape=n_timesteps, + units=None, + copy_units=f"{self.commodity}_out", + ) + + self.add_output( + f"modulated_{self.commodity}_out", + shape=n_timesteps, + units=None, + copy_units=f"{self.commodity}_out", + ) + self.add_output( + f"curtailed_{self.commodity}_out", + shape=n_timesteps, + units=None, + copy_units=f"{self.commodity}_out", + ) + + def compute(self, inputs, outputs): + set_point_difference = ( + inputs[f"{self.commodity}_out"] - inputs[f"{self.commodity}_set_point"] + ) + # commodity_out exceeds setpoint + excess_commodity = np.where(set_point_difference > 0, set_point_difference, 0) + commodity_to_setpoint = inputs[f"{self.commodity}_out"] - excess_commodity + + outputs[f"modulated_{self.commodity}_out"] = commodity_to_setpoint + outputs[f"curtailed_{self.commodity}_out"] = excess_commodity diff --git a/h2integrate/control/control_strategies/converters/test/test_curtailable_component.py b/h2integrate/control/control_strategies/converters/test/test_curtailable_component.py new file mode 100644 index 000000000..a08b37774 --- /dev/null +++ b/h2integrate/control/control_strategies/converters/test/test_curtailable_component.py @@ -0,0 +1,56 @@ +import numpy as np +import openmdao.api as om +from pytest import fixture + +from h2integrate.control.control_strategies.converters.curtailable_component import ( + CurtailableComponentModel, +) + + +@fixture +def plant_config_base(): + plant_config = { + "plant": { + "plant_life": 30, + "simulation": { + "n_timesteps": 8760, + "dt": 3600, + "timezone": 0, + "start_time": "01/01/2000 00:00:00", + }, + } + } + + return plant_config + + +def test_curtailable_component(plant_config_base, subtests): + prob = om.Problem() + + prob.model.add_subsystem( + name="IVC1", + subsys=om.IndepVarComp(name="hydrogen_out", val=20, shape=8760, units="kg/h"), + promotes=["*"], + ) + + prob.model.add_subsystem( + name="IVC2", + subsys=om.IndepVarComp(name="hydrogen_set_point", val=10, shape=8760, units="kg/h"), + promotes=["*"], + ) + + prob.model.add_subsystem( + "comp", + CurtailableComponentModel( + plant_config=plant_config_base, + commodity="hydrogen", + ), + promotes=["*"], + ) + + prob.setup() + + prob.run_model() + + with subtests.test("modulated output"): + assert np.all(prob.get_val("comp.modulated_hydrogen_out", units="kg/h") == 10) From 0e9dd0b66be169222a7c4816255de27806001ae7 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:56:00 -0600 Subject: [PATCH 009/105] added logic in h2imodel to add curtailment component to curtailment control_classifiers models --- .../control_strategies/converters/__init__.py | 0 h2integrate/core/h2integrate_model.py | 63 +++++++++++++++++++ h2integrate/core/supported_models.py | 5 ++ h2integrate/transporters/generic_combiner.py | 2 + h2integrate/transporters/generic_splitter.py | 2 + 5 files changed, 72 insertions(+) create mode 100644 h2integrate/control/control_strategies/converters/__init__.py diff --git a/h2integrate/control/control_strategies/converters/__init__.py b/h2integrate/control/control_strategies/converters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 652640a07..466fa80b4 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -46,6 +46,11 @@ def __init__(self, config_input): # read in config file; it's a yaml dict that looks like this: self.load_config(config_input) + # add bool for whether using system-level control + self.slc = False + if "system_level_control" in self.plant_config: + self.slc = True + # load in supported models self.supported_models = supported_models.copy() @@ -463,6 +468,7 @@ def create_technology_models(self): self.dispatch_rule_sets = [] self.cost_models = [] self.finance_models = [] + self.tech_control_classifiers = {} # for system-level control combined_performance_and_cost_models = [ "HOPPComponent", @@ -520,6 +526,7 @@ def create_technology_models(self): tech_config=individual_tech_config, ) self._check_time_step(perf_model, comp) + self.tech_control_classifiers.update({tech_name: "feedback"}) self.plant.add_subsystem(f"{tech_name}_source", comp) else: tech_group = self.plant.add_subsystem(tech_name, om.Group()) @@ -555,6 +562,9 @@ def create_technology_models(self): plant_config=self.plant_config, tech_config=individual_tech_config, ) + + self._check_control_classifier(perf_model, comp) + self.tech_control_classifiers.update({tech_name: comp._control_classifier}) self._check_time_step(perf_model, comp) om_model_object = tech_group.add_subsystem(perf_model, comp, promotes=["*"]) self.performance_models.append(om_model_object) @@ -583,6 +593,52 @@ def create_technology_models(self): plural_model_type_name = model_type + "s" getattr(self, plural_model_type_name).append(om_model_object) + # below logic is only used if using system-level control + if self.slc and model_type == "performance_model": + self._check_control_classifier(perf_model, om_model_object) + control_classifier = comp._control_classifier + self.tech_control_classifiers.update( + {tech_name: getattr(comp, control_classifier)} + ) + + # add curtail component to curtailable technology performance models + if control_classifier == "curtailable": + # get the commodity output from this component with length + # 4 connections + # TODO: update to handle length 3 connections + tech_is_source_connections = [ + k + for k in self.plant_config["technology_interconnections"] + if k[0] == tech_name and len(k) == 4 + ] + if len(tech_is_source_connections) == 0: + msg = ( + f"{tech_name} is not a source technology " + f"for another component" + ) + + raise ValueError(msg) + # Get unique commodity outputs + tech_commodity_output = list( + {k[3] for k in tech_is_source_connections} + ) + if len(tech_commodity_output) > 1: + msg = ( + f"{tech_name} has multiple commodity outputs " + f"({tech_commodity_output}) which is not yet supported" + ) + raise ValueError(msg) + # get the commodity of the component + model_object = self.supported_models["CurtailableComponentModel"] + om_model_object = tech_group.add_subsystem( + "CurtailableComponentModel", + model_object( + commodity=tech_commodity_output[0], + plant_config=self.plant_config, + ), + promotes=["*"], + ) + # Process the finance models if "finance_model" in individual_tech_config: if "model" in individual_tech_config["finance_model"]: @@ -648,6 +704,13 @@ def _check_time_step(self, model_name, model_object): ) raise ValueError(msg) + def _check_control_classifier(self, model_name, model_object): + if not self.slc: + return + if not hasattr(model_object, "_control_classifier"): + msg = f"Model {model_name} is missing a control classifier" + raise ValueError(msg) + def create_finance_model(self): """ Create and configure the finance model(s) for the plant. diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index 62e197de9..98249fb09 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -158,6 +158,9 @@ from h2integrate.control.control_rules.storage.pyomo_storage_rule_baseclass import ( PyomoRuleStorageBaseclass, ) +from h2integrate.control.control_strategies.converters.curtailable_component import ( + CurtailableComponentModel, +) from h2integrate.resource.solar.nlr_developer_meteosat_prime_meridian_models import ( MeteosatPrimeMeridianSolarAPI, MeteosatPrimeMeridianTMYSolarAPI, @@ -281,6 +284,8 @@ "IronTransportCostComponent": IronTransportCostComponent, # Simple Summers "GenericSummerPerformanceModel": GenericSummerPerformanceModel, + # Curtailable component + "CurtailableComponentModel": CurtailableComponentModel, # Storage "PySAMBatteryPerformanceModel": PySAMBatteryPerformanceModel, "StoragePerformanceModel": StoragePerformanceModel, diff --git a/h2integrate/transporters/generic_combiner.py b/h2integrate/transporters/generic_combiner.py index d9d11a2ef..c02ef56f4 100644 --- a/h2integrate/transporters/generic_combiner.py +++ b/h2integrate/transporters/generic_combiner.py @@ -43,6 +43,8 @@ class GenericCombinerPerformanceModel(om.ExplicitComponent): 1e9, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "connector" + def initialize(self): self.options.declare("driver_config", types=dict) self.options.declare("plant_config", types=dict) diff --git a/h2integrate/transporters/generic_splitter.py b/h2integrate/transporters/generic_splitter.py index 3f07a3df8..1573a83f5 100644 --- a/h2integrate/transporters/generic_splitter.py +++ b/h2integrate/transporters/generic_splitter.py @@ -68,6 +68,8 @@ class GenericSplitterPerformanceModel(om.ExplicitComponent): 1e9, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "connector" + def initialize(self): self.options.declare("driver_config", types=dict, default={}) self.options.declare("plant_config", types=dict, default={}) From 0cedd828f5ce30577e23d2ed27cebb15e00632c2 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Tue, 28 Apr 2026 15:48:51 -0600 Subject: [PATCH 010/105] Working through start of system level controller --- .../23_solar_wind_ng_demand/plant_config.yaml | 10 +- .../system_level/system_level_control.py | 156 ++++++++++++++++++ 2 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 h2integrate/control/control_strategies/system_level/system_level_control.py diff --git a/examples/23_solar_wind_ng_demand/plant_config.yaml b/examples/23_solar_wind_ng_demand/plant_config.yaml index cdb2b956b..5bf31591d 100644 --- a/examples/23_solar_wind_ng_demand/plant_config.yaml +++ b/examples/23_solar_wind_ng_demand/plant_config.yaml @@ -21,14 +21,14 @@ sites: # this will naturally grow as we mature the interconnected tech technology_interconnections: - [wind, combiner, electricity, cable] - # source_tech, dest_tech, transport_item, transport_type = connection + # source_tech, dest_tech, transport_item, transport_type = connection - [solar, combiner, electricity, cable] - [ng_feedstock, natural_gas_plant, natural_gas, pipe] - # connect NG feedstock to NG plant - - [combiner, electrical_load_demand, [electricity_out, electricity_in]] - # subtract wind and solar from demand + # connect NG feedstock to NG plant + - [combiner, electrical_load_demand, electricity, cable] + # subtract wind and solar from demand - [electrical_load_demand, natural_gas_plant, [unmet_electricity_demand_out, electricity_set_point]] - # give remaining load demand to natural gas plant + # give remaining load demand to natural gas plant - [combiner, fin_combiner, electricity, cable] - [natural_gas_plant, fin_combiner, electricity, cable] resource_to_tech_connections: diff --git a/h2integrate/control/control_strategies/system_level/system_level_control.py b/h2integrate/control/control_strategies/system_level/system_level_control.py new file mode 100644 index 000000000..f67222736 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/system_level_control.py @@ -0,0 +1,156 @@ +import numpy as np +import openmdao.api as om + + +class SystemLevelControl(om.ExplicitComponent): + """System-level control that satisfies demand evenly across all technologies. + + Parses ``technology_interconnections`` and ``tech_config`` to identify: + + - **Demand technology**: the single component with a ``demand_profile`` + - **Dispatchable technologies**: all producing technologies found in + 4-element connections (excluding demand techs, combiners, splitters, + and feedstocks) + + Only one commodity demand stream is supported. At each timestep the + demand is distributed equally as set-points to the dispatchable + technologies. + """ + + def initialize(self): + self.options.declare("driver_config", types=dict) + self.options.declare("plant_config", types=dict) + self.options.declare("tech_config", types=dict) + + def setup(self): + plant_config = self.options["plant_config"] + tech_config = self.options["tech_config"] + + self.n_timesteps = plant_config["plant"]["simulation"]["n_timesteps"] + interconnections = plant_config.get("technology_interconnections", []) + technologies = tech_config.get("technologies", {}) + + # ---- 1. Identify the (single) demand technology from tech_config ---- + # A demand tech has ``demand_profile`` in its performance or shared params. + self.commodity = None + self.demand_profile = None + self.commodity_units = None + for tech_name, tech_def in technologies.items(): + model_inputs = tech_def.get("model_inputs", {}) + perf_params = model_inputs.get("performance_parameters", {}) + shared_params = model_inputs.get("shared_parameters", {}) + all_params = {**shared_params, **perf_params} + + if "demand_profile" in all_params: + if self.commodity is not None: + raise ValueError( + "SystemLevelControl currently supports only one demand " + f"stream, but found demands for both '{self.commodity}' " + f"and '{all_params['commodity']}'." + ) + self.commodity = all_params["commodity"] + self.commodity_units = all_params.get("commodity_rate_units", None) + self.demand_profile = all_params["demand_profile"] + self.demand_tech = tech_name + + # ---- 2. Identify all dispatchable (producing) technologies ---- + # Every source tech in a 4-element connection is dispatchable, + # excluding demand techs, infrastructure (combiners, splitters), + # and feedstocks. + demand_tech_names = {self.demand_tech} if self.commodity else set() + + infrastructure_techs = set() + feedstock_techs = set() + for tech_name, tech_def in technologies.items(): + model_name = tech_def.get("performance_model", {}).get("model", "") + if "Combiner" in model_name or "Splitter" in model_name: + infrastructure_techs.add(tech_name) + if "Feedstock" in model_name: + feedstock_techs.add(tech_name) + + excluded = demand_tech_names | infrastructure_techs | feedstock_techs + + self.dispatchable_techs = [] # [tech_name, ...] + seen = set() + for connection in interconnections: + if len(connection) == 4: + source_tech, _dest, commodity, _transport = connection + if ( + commodity == self.commodity + and source_tech not in excluded + and source_tech not in seen + ): + self.dispatchable_techs.append(source_tech) + seen.add(source_tech) + + # Also pick up destination techs from 3-element set-point connections + # that weren't already found via 4-element connections. + for connection in interconnections: + if len(connection) == 3: + _source, dest_tech, var_mapping = connection + if isinstance(var_mapping, list | tuple) and len(var_mapping) == 2: + _src_var, dst_var = var_mapping + if "_set_point" in dst_var: + commodity = dst_var.replace("_set_point", "") + if ( + commodity == self.commodity + and dest_tech not in excluded + and dest_tech not in seen + ): + self.dispatchable_techs.append(dest_tech) + seen.add(dest_tech) + + # ---- 3. Add OpenMDAO inputs / outputs ---- + # Input: demand profile + self.demand_input_name = f"{self.commodity}_demand" + self.add_input( + self.demand_input_name, + val=self.demand_profile, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"Demand profile of {self.commodity}", + ) + + # Inputs: commodity output from each dispatchable tech + self.commodity_input_names = [] + for tech_name in self.dispatchable_techs: + var_name = f"{tech_name}_{self.commodity}_out" + self.add_input( + var_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"{self.commodity} output from {tech_name}", + ) + self.commodity_input_names.append(var_name) + + # Outputs: set-points for all dispatchable techs + self.set_point_output_names = [] + for tech_name in self.dispatchable_techs: + var_name = f"{tech_name}_{self.commodity}_set_point" + self.add_output( + var_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"Set point for {tech_name} {self.commodity} production", + ) + self.set_point_output_names.append(var_name) + + def compute(self, inputs, outputs): + demand = np.maximum(inputs[self.demand_input_name], 0.0) + + # Sum actual commodity output from all dispatchable techs + total_supply = np.zeros(self.n_timesteps) + for var_name in self.commodity_input_names: + total_supply += inputs[var_name] + + # Gap between demand and current total supply + gap = demand - total_supply + + # Adjust each tech's set_point: current output + its share of the gap + n_dispatchable = len(self.set_point_output_names) + if n_dispatchable > 0: + correction = gap / n_dispatchable + for var_name, in_name in zip(self.set_point_output_names, self.commodity_input_names): + outputs[var_name] = inputs[in_name] + correction From 0aff1348a9ab3d92ac72fd16005b3afee0e39c33 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Tue, 28 Apr 2026 22:42:51 -0600 Subject: [PATCH 011/105] Added initial system level control example --- .../driver_config.yaml | 4 + .../35_system_level_control/plant_config.yaml | 101 +++++++++ .../run_wind_ng_demand.py | 102 +++++++++ .../35_system_level_control/tech_config.yaml | 114 ++++++++++ .../wind_ng_demand.yaml | 5 + .../system_level/system_level_control.py | 208 ++++++++++-------- 6 files changed, 440 insertions(+), 94 deletions(-) create mode 100644 examples/35_system_level_control/driver_config.yaml create mode 100644 examples/35_system_level_control/plant_config.yaml create mode 100644 examples/35_system_level_control/run_wind_ng_demand.py create mode 100644 examples/35_system_level_control/tech_config.yaml create mode 100644 examples/35_system_level_control/wind_ng_demand.yaml diff --git a/examples/35_system_level_control/driver_config.yaml b/examples/35_system_level_control/driver_config.yaml new file mode 100644 index 000000000..5b6b7e05a --- /dev/null +++ b/examples/35_system_level_control/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: This analysis runs a natural gas power plant +general: + folder_output: outputs diff --git a/examples/35_system_level_control/plant_config.yaml b/examples/35_system_level_control/plant_config.yaml new file mode 100644 index 000000000..80dff18d7 --- /dev/null +++ b/examples/35_system_level_control/plant_config.yaml @@ -0,0 +1,101 @@ +name: plant_config +description: This plant is located in Texas, USA. +sites: + site: + latitude: 30.6617 + longitude: -101.7096 + resources: + wind_resource: + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2013 +# array of arrays containing left-to-right technology +# interconnections; can support bidirectional connections +# with the reverse definition. +# this will naturally grow as we mature the interconnected tech +technology_interconnections: + - [wind, combiner, electricity, cable] + # source_tech, dest_tech, transport_item, transport_type = connection + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] + # connect NG feedstock to NG plant + - [combiner, electrical_load_demand, electricity, cable] + # subtract wind and solar from demand + - [electrical_load_demand, natural_gas_plant, [unmet_electricity_demand_out, electricity_set_point]] + # give remaining load demand to natural gas plant + - [combiner, fin_combiner, electricity, cable] + - [natural_gas_plant, fin_combiner, electricity, cable] +resource_to_tech_connections: + # connect the wind resource to the wind technology + - [site.wind_resource, wind, wind_resource_data] + - [site.solar_resource, solar, solar_resource_data] +plant: + plant_life: 30 + simulation: + n_timesteps: 8760 + dt: 3600 +finance_parameters: + finance_groups: + profast_lco: + finance_model: ProFastLCO + model_inputs: + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + depr_period: 5 # 5 years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + refurb: [0.] + profast_npv: + finance_model: ProFastNPV + model_inputs: + commodity_sell_price: 0.05167052 + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + depr_period: 5 # 5 years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + refurb: [0.] + finance_subgroups: + renewables: + commodity: electricity + commodity_stream: combiner + finance_groups: [profast_lco, profast_npv] + technologies: [solar, wind] + natural_gas: + commodity: electricity + commodity_stream: natural_gas_plant + finance_groups: [profast_lco] + technologies: [natural_gas_plant, ng_feedstock] + electricity: + commodity: electricity + commodity_stream: fin_combiner + finance_groups: [profast_lco] + technologies: [solar, wind, natural_gas_plant, ng_feedstock] + cost_adjustment_parameters: + cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year + target_dollar_year: 2022 diff --git a/examples/35_system_level_control/run_wind_ng_demand.py b/examples/35_system_level_control/run_wind_ng_demand.py new file mode 100644 index 000000000..4abe424ff --- /dev/null +++ b/examples/35_system_level_control/run_wind_ng_demand.py @@ -0,0 +1,102 @@ +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +################################## +# Create an H2I model with a fixed electricity load demand +h2i = H2IntegrateModel("solar_wind_ng_demand.yaml") + +# Run the model +h2i.run() + +# Post-process the results +h2i.post_process() + + +################################## +# Create H2I model but replace electrical load demand to be flexible +h2i_flexible = H2IntegrateModel("solar_wind_ng_flexible_demand.yaml") + +# Run the model +h2i_flexible.run() + +# Post-process the results +h2i_flexible.post_process() + +import matplotlib.pyplot as plt + + +# Battery dispatch plotting +model = h2i_flexible +fig, ax = plt.subplots(2, 1, sharex=True, figsize=(11, 9)) + +start_hour = 0 +end_hour = 200 +total_time_steps = model.prob.get_val("wind.electricity_out", units="MW").size +demand_profile = [ + model.technology_config["technologies"]["electrical_load_demand"]["model_inputs"][ + "control_parameters" + ]["demand_profile"] + * 1e-3 +] * total_time_steps + +# First subplot for wind and solar production and baseline demand profile +ax[0].plot( + range(start_hour, end_hour), + model.prob.get_val("wind.electricity_out", units="MW")[start_hour:end_hour], + linestyle="-", + label="Wind Electricity (MW)", + linewidth=2, + color="blue", +) +ax[0].plot( + range(start_hour, end_hour), + model.prob.get_val("solar.electricity_out", units="MW")[start_hour:end_hour], + linestyle="-", + label="Solar Electricity (MW)", + linewidth=2, + color="gold", +) + +ax[0].plot( + range(start_hour, end_hour), + demand_profile[start_hour:end_hour], + linestyle="--", + label="Baseline Electricity Demand (MW)", + linewidth=2, +) +ax[0].set_ylabel("Generation (MW)") +ax[0].legend(loc="upper right") + +# Second subplot for renewables electricity, NG electricity, and flexible demand profile +ax[1].plot( + range(start_hour, end_hour), + model.prob.get_val("combiner.electricity_out", units="MW")[start_hour:end_hour], + linestyle="-", + label="Combined Wind+Solar Electricity (MW)", + linewidth=2, + color="green", +) +ax[1].plot( + range(start_hour, end_hour), + model.prob.get_val("natural_gas_plant.electricity_out", units="MW")[start_hour:end_hour], + linestyle="-", + label="NG Plant Electricity (MW)", + linewidth=2, + color="orange", +) +ax[1].plot( + range(start_hour, end_hour), + model.prob.get_val("electrical_load_demand.electricity_flexible_demand_profile", units="MW")[ + start_hour:end_hour + ], + linestyle="--", + label="Flexible Demand Profile (MW)", + linewidth=2, + color="purple", +) +ax[1].set_ylabel("Generation & Demand (MW)") +ax[1].set_xlabel("Timestep (hr)") +ax[1].legend(loc="upper right") + +plt.tight_layout() +plt.show() diff --git a/examples/35_system_level_control/tech_config.yaml b/examples/35_system_level_control/tech_config.yaml new file mode 100644 index 000000000..e044c1788 --- /dev/null +++ b/examples/35_system_level_control/tech_config.yaml @@ -0,0 +1,114 @@ +name: technology_config +description: This plant produces electricity with wind, solar, and a natural gas power plant to meet a fixed electrical load + demand. +technologies: + wind: + performance_model: + model: PYSAMWindPlantPerformanceModel + cost_model: + model: ATBWindPlantCostModel + model_inputs: + performance_parameters: + num_turbines: 20 + turbine_rating_kw: 6000 + hub_height: 115 + rotor_diameter: 170 + create_model_from: default + config_name: WindPowerSingleOwner + pysam_options: + Farm: + wind_farm_wake_model: 0 + Losses: + ops_strategies_loss: 10.0 + layout: + layout_mode: basicgrid + layout_options: + row_D_spacing: 5.0 + turbine_D_spacing: 5.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: square + cost_parameters: + capex_per_kW: 1300 + opex_per_kW_per_year: 39 + cost_year: 2022 + solar: + performance_model: + model: PYSAMSolarPlantPerformanceModel + cost_model: + model: ATBUtilityPVCostModel + model_inputs: + performance_parameters: + pv_capacity_kWdc: 100000 # 100 MWdc + dc_ac_ratio: 1.34 + create_model_from: new + tilt_angle_func: none + tilt: 0 + pysam_options: + SystemDesign: + gcr: 0.3 + array_type: 2 + bifaciality: 0.65 + inv_eff: 96.0 + losses: 14.6 + module_type: 1 + azimuth: 180 + rotlim: 45 + SolarResource: + albedo_default: 0.2 + cost_parameters: + capex_per_kWac: 1193 + opex_per_kWac_per_year: 18 + cost_year: 2024 + ng_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: natural_gas + commodity_rate_units: MMBtu/h + performance_parameters: + rated_capacity: 750. # MMBtu + cost_parameters: + cost_year: 2023 + price: 4.2 # USD/MMBtu + annual_cost: 0. + start_up_cost: 0. + natural_gas_plant: + performance_model: + model: NaturalGasPerformanceModel + cost_model: + model: NaturalGasCostModel + model_inputs: + shared_parameters: + heat_rate_mmbtu_per_mwh: 7.5 # MMBtu/MWh - typical for NGCC + system_capacity_mw: 100. # MW + cost_parameters: + capex_per_kw: 1000 # $/kW - typical for NGCC + fixed_opex_per_kw_per_year: 10.0 # $/kW/year + variable_opex_per_mwh: 0.0 # $/MWh + cost_year: 2023 + electrical_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 100000 # 100 MW + combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + fin_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW diff --git a/examples/35_system_level_control/wind_ng_demand.yaml b/examples/35_system_level_control/wind_ng_demand.yaml new file mode 100644 index 000000000..f2b5599a0 --- /dev/null +++ b/examples/35_system_level_control/wind_ng_demand.yaml @@ -0,0 +1,5 @@ +name: H2Integrate_config +system_summary: This example uses wind, solar and a natural gas power plant to meet a fixed electrical load demand. +driver_config: driver_config.yaml +technology_config: tech_config.yaml +plant_config: plant_config.yaml diff --git a/h2integrate/control/control_strategies/system_level/system_level_control.py b/h2integrate/control/control_strategies/system_level/system_level_control.py index f67222736..537ca503c 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control.py @@ -1,20 +1,18 @@ import numpy as np import openmdao.api as om +from h2integrate.core.supported_models import supported_models -class SystemLevelControl(om.ExplicitComponent): - """System-level control that satisfies demand evenly across all technologies. - Parses ``technology_interconnections`` and ``tech_config`` to identify: +class SystemLevelControl(om.ExplicitComponent): + """System-level control that satisfies demand across all technologies. - - **Demand technology**: the single component with a ``demand_profile`` - - **Dispatchable technologies**: all producing technologies found in - 4-element connections (excluding demand techs, combiners, splitters, - and feedstocks) + Parses ``tech_config`` to classify each technology by its + ``_control_classifier`` attribute (curtailable, dispatchable, or storage). - Only one commodity demand stream is supported. At each timestep the - demand is distributed equally as set-points to the dispatchable - technologies. + Only one commodity demand stream is supported. At each timestep, + curtailable production is applied first, then the remaining demand + is distributed equally across dispatchable technologies. """ def initialize(self): @@ -27,80 +25,35 @@ def setup(self): tech_config = self.options["tech_config"] self.n_timesteps = plant_config["plant"]["simulation"]["n_timesteps"] - interconnections = plant_config.get("technology_interconnections", []) + plant_config.get("technology_interconnections", []) technologies = tech_config.get("technologies", {}) # ---- 1. Identify the (single) demand technology from tech_config ---- - # A demand tech has ``demand_profile`` in its performance or shared params. + # A demand tech has "Demand" in its performance model name. self.commodity = None self.demand_profile = None self.commodity_units = None for tech_name, tech_def in technologies.items(): + model_name = tech_def.get("performance_model", {}).get("model", "") + if "Demand" not in model_name: + continue + model_inputs = tech_def.get("model_inputs", {}) perf_params = model_inputs.get("performance_parameters", {}) shared_params = model_inputs.get("shared_parameters", {}) all_params = {**shared_params, **perf_params} - if "demand_profile" in all_params: - if self.commodity is not None: - raise ValueError( - "SystemLevelControl currently supports only one demand " - f"stream, but found demands for both '{self.commodity}' " - f"and '{all_params['commodity']}'." - ) - self.commodity = all_params["commodity"] - self.commodity_units = all_params.get("commodity_rate_units", None) - self.demand_profile = all_params["demand_profile"] - self.demand_tech = tech_name - - # ---- 2. Identify all dispatchable (producing) technologies ---- - # Every source tech in a 4-element connection is dispatchable, - # excluding demand techs, infrastructure (combiners, splitters), - # and feedstocks. - demand_tech_names = {self.demand_tech} if self.commodity else set() - - infrastructure_techs = set() - feedstock_techs = set() - for tech_name, tech_def in technologies.items(): - model_name = tech_def.get("performance_model", {}).get("model", "") - if "Combiner" in model_name or "Splitter" in model_name: - infrastructure_techs.add(tech_name) - if "Feedstock" in model_name: - feedstock_techs.add(tech_name) - - excluded = demand_tech_names | infrastructure_techs | feedstock_techs - - self.dispatchable_techs = [] # [tech_name, ...] - seen = set() - for connection in interconnections: - if len(connection) == 4: - source_tech, _dest, commodity, _transport = connection - if ( - commodity == self.commodity - and source_tech not in excluded - and source_tech not in seen - ): - self.dispatchable_techs.append(source_tech) - seen.add(source_tech) - - # Also pick up destination techs from 3-element set-point connections - # that weren't already found via 4-element connections. - for connection in interconnections: - if len(connection) == 3: - _source, dest_tech, var_mapping = connection - if isinstance(var_mapping, list | tuple) and len(var_mapping) == 2: - _src_var, dst_var = var_mapping - if "_set_point" in dst_var: - commodity = dst_var.replace("_set_point", "") - if ( - commodity == self.commodity - and dest_tech not in excluded - and dest_tech not in seen - ): - self.dispatchable_techs.append(dest_tech) - seen.add(dest_tech) + if self.commodity is not None: + raise ValueError( + "SystemLevelControl currently supports only one demand " + f"stream, but found demands for both '{self.commodity}' " + f"and '{all_params.get('commodity', tech_name)}'." + ) + self.commodity = all_params["commodity"] + self.commodity_units = all_params.get("commodity_rate_units", None) + self.demand_profile = all_params.get("demand_profile", 0.0) + self.demand_tech = tech_name - # ---- 3. Add OpenMDAO inputs / outputs ---- # Input: demand profile self.demand_input_name = f"{self.commodity}_demand" self.add_input( @@ -111,46 +64,113 @@ def setup(self): desc=f"Demand profile of {self.commodity}", ) - # Inputs: commodity output from each dispatchable tech - self.commodity_input_names = [] - for tech_name in self.dispatchable_techs: - var_name = f"{tech_name}_{self.commodity}_out" + # ---- 2. Classify technologies by _control_classifier ---- + self.curtailable_techs = [] + self.dispatchable_techs = [] + self.storage_techs = [] + + for tech_name, tech_def in technologies.items(): + perf_model_name = tech_def.get("performance_model", {}).get("model", "") + if perf_model_name not in supported_models: + continue + model_cls = supported_models[perf_model_name] + classifier = getattr(model_cls, "_control_classifier", None) + if classifier == "curtailable": + self.curtailable_techs.append(tech_name) + elif classifier == "dispatchable": + self.dispatchable_techs.append(tech_name) + elif classifier == "storage": + self.storage_techs.append(tech_name) + + # ---- 3. Add OpenMDAO inputs / outputs ---- + # Inputs & outputs for curtailable techs + self.curtailable_input_names = [] + self.curtailable_output_names = [] + for tech_name in self.curtailable_techs: + in_name = f"{tech_name}_{self.commodity}_out" + out_name = f"{tech_name}_{self.commodity}_set_point" self.add_input( - var_name, + in_name, val=0.0, shape=self.n_timesteps, units=self.commodity_units, desc=f"{self.commodity} output from {tech_name}", ) - self.commodity_input_names.append(var_name) + self.add_output( + out_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"Set point for {tech_name} {self.commodity} production", + ) + self.curtailable_input_names.append(in_name) + self.curtailable_output_names.append(out_name) - # Outputs: set-points for all dispatchable techs - self.set_point_output_names = [] + # Inputs & outputs for dispatchable techs + self.dispatchable_input_names = [] + self.dispatchable_output_names = [] for tech_name in self.dispatchable_techs: - var_name = f"{tech_name}_{self.commodity}_set_point" + in_name = f"{tech_name}_{self.commodity}_out" + out_name = f"{tech_name}_{self.commodity}_set_point" + self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"{self.commodity} output from {tech_name}", + ) + self.add_output( + out_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"Set point for {tech_name} {self.commodity} production", + ) + self.dispatchable_input_names.append(in_name) + self.dispatchable_output_names.append(out_name) + + # Inputs & outputs for storage techs + self.storage_input_names = [] + self.storage_output_names = [] + for tech_name in self.storage_techs: + in_name = f"{tech_name}_{self.commodity}_out" + out_name = f"{tech_name}_{self.commodity}_set_point" + self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"{self.commodity} output from {tech_name}", + ) self.add_output( - var_name, + out_name, val=0.0, shape=self.n_timesteps, units=self.commodity_units, desc=f"Set point for {tech_name} {self.commodity} production", ) - self.set_point_output_names.append(var_name) + self.storage_input_names.append(in_name) + self.storage_output_names.append(out_name) def compute(self, inputs, outputs): - demand = np.maximum(inputs[self.demand_input_name], 0.0) + demand = inputs[self.demand_input_name].copy() - # Sum actual commodity output from all dispatchable techs - total_supply = np.zeros(self.n_timesteps) - for var_name in self.commodity_input_names: - total_supply += inputs[var_name] + # 1. Apply curtailable production first (pass through actual output) + for in_name, out_name in zip(self.curtailable_input_names, self.curtailable_output_names): + curtailable_output = inputs[in_name] + outputs[out_name] = curtailable_output + demand -= curtailable_output - # Gap between demand and current total supply - gap = demand - total_supply + # Remaining demand after curtailable production + remaining = np.maximum(demand, 0.0) - # Adjust each tech's set_point: current output + its share of the gap - n_dispatchable = len(self.set_point_output_names) + # 2. Distribute remaining demand equally across dispatchable techs + n_dispatchable = len(self.dispatchable_output_names) if n_dispatchable > 0: - correction = gap / n_dispatchable - for var_name, in_name in zip(self.set_point_output_names, self.commodity_input_names): - outputs[var_name] = inputs[in_name] + correction + share = remaining / n_dispatchable + for out_name in self.dispatchable_output_names: + outputs[out_name] = share + + # 3. Storage techs get zero set_point for now + for out_name in self.storage_output_names: + outputs[out_name] = np.zeros(self.n_timesteps) From 9ff6faed9bbca51c062d846eb4371e642d39775c Mon Sep 17 00:00:00 2001 From: John Jasa Date: Tue, 28 Apr 2026 22:58:50 -0600 Subject: [PATCH 012/105] Fixed SLC example --- .../35_system_level_control/plant_config.yaml | 7 +- .../run_wind_ng_demand.py | 92 +------------------ .../35_system_level_control/tech_config.yaml | 28 ------ 3 files changed, 4 insertions(+), 123 deletions(-) diff --git a/examples/35_system_level_control/plant_config.yaml b/examples/35_system_level_control/plant_config.yaml index 80dff18d7..095f33c27 100644 --- a/examples/35_system_level_control/plant_config.yaml +++ b/examples/35_system_level_control/plant_config.yaml @@ -19,7 +19,7 @@ technology_interconnections: - [ng_feedstock, natural_gas_plant, natural_gas, pipe] # connect NG feedstock to NG plant - [combiner, electrical_load_demand, electricity, cable] - # subtract wind and solar from demand + # subtract wind from demand - [electrical_load_demand, natural_gas_plant, [unmet_electricity_demand_out, electricity_set_point]] # give remaining load demand to natural gas plant - [combiner, fin_combiner, electricity, cable] @@ -27,7 +27,6 @@ technology_interconnections: resource_to_tech_connections: # connect the wind resource to the wind technology - [site.wind_resource, wind, wind_resource_data] - - [site.solar_resource, solar, solar_resource_data] plant: plant_life: 30 simulation: @@ -85,7 +84,7 @@ finance_parameters: commodity: electricity commodity_stream: combiner finance_groups: [profast_lco, profast_npv] - technologies: [solar, wind] + technologies: [wind] natural_gas: commodity: electricity commodity_stream: natural_gas_plant @@ -95,7 +94,7 @@ finance_parameters: commodity: electricity commodity_stream: fin_combiner finance_groups: [profast_lco] - technologies: [solar, wind, natural_gas_plant, ng_feedstock] + technologies: [wind, natural_gas_plant, ng_feedstock] cost_adjustment_parameters: cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year target_dollar_year: 2022 diff --git a/examples/35_system_level_control/run_wind_ng_demand.py b/examples/35_system_level_control/run_wind_ng_demand.py index 4abe424ff..9e85c88ca 100644 --- a/examples/35_system_level_control/run_wind_ng_demand.py +++ b/examples/35_system_level_control/run_wind_ng_demand.py @@ -3,100 +3,10 @@ ################################## # Create an H2I model with a fixed electricity load demand -h2i = H2IntegrateModel("solar_wind_ng_demand.yaml") +h2i = H2IntegrateModel("wind_ng_demand.yaml") # Run the model h2i.run() # Post-process the results h2i.post_process() - - -################################## -# Create H2I model but replace electrical load demand to be flexible -h2i_flexible = H2IntegrateModel("solar_wind_ng_flexible_demand.yaml") - -# Run the model -h2i_flexible.run() - -# Post-process the results -h2i_flexible.post_process() - -import matplotlib.pyplot as plt - - -# Battery dispatch plotting -model = h2i_flexible -fig, ax = plt.subplots(2, 1, sharex=True, figsize=(11, 9)) - -start_hour = 0 -end_hour = 200 -total_time_steps = model.prob.get_val("wind.electricity_out", units="MW").size -demand_profile = [ - model.technology_config["technologies"]["electrical_load_demand"]["model_inputs"][ - "control_parameters" - ]["demand_profile"] - * 1e-3 -] * total_time_steps - -# First subplot for wind and solar production and baseline demand profile -ax[0].plot( - range(start_hour, end_hour), - model.prob.get_val("wind.electricity_out", units="MW")[start_hour:end_hour], - linestyle="-", - label="Wind Electricity (MW)", - linewidth=2, - color="blue", -) -ax[0].plot( - range(start_hour, end_hour), - model.prob.get_val("solar.electricity_out", units="MW")[start_hour:end_hour], - linestyle="-", - label="Solar Electricity (MW)", - linewidth=2, - color="gold", -) - -ax[0].plot( - range(start_hour, end_hour), - demand_profile[start_hour:end_hour], - linestyle="--", - label="Baseline Electricity Demand (MW)", - linewidth=2, -) -ax[0].set_ylabel("Generation (MW)") -ax[0].legend(loc="upper right") - -# Second subplot for renewables electricity, NG electricity, and flexible demand profile -ax[1].plot( - range(start_hour, end_hour), - model.prob.get_val("combiner.electricity_out", units="MW")[start_hour:end_hour], - linestyle="-", - label="Combined Wind+Solar Electricity (MW)", - linewidth=2, - color="green", -) -ax[1].plot( - range(start_hour, end_hour), - model.prob.get_val("natural_gas_plant.electricity_out", units="MW")[start_hour:end_hour], - linestyle="-", - label="NG Plant Electricity (MW)", - linewidth=2, - color="orange", -) -ax[1].plot( - range(start_hour, end_hour), - model.prob.get_val("electrical_load_demand.electricity_flexible_demand_profile", units="MW")[ - start_hour:end_hour - ], - linestyle="--", - label="Flexible Demand Profile (MW)", - linewidth=2, - color="purple", -) -ax[1].set_ylabel("Generation & Demand (MW)") -ax[1].set_xlabel("Timestep (hr)") -ax[1].legend(loc="upper right") - -plt.tight_layout() -plt.show() diff --git a/examples/35_system_level_control/tech_config.yaml b/examples/35_system_level_control/tech_config.yaml index e044c1788..e1a6c2cf3 100644 --- a/examples/35_system_level_control/tech_config.yaml +++ b/examples/35_system_level_control/tech_config.yaml @@ -32,34 +32,6 @@ technologies: capex_per_kW: 1300 opex_per_kW_per_year: 39 cost_year: 2022 - solar: - performance_model: - model: PYSAMSolarPlantPerformanceModel - cost_model: - model: ATBUtilityPVCostModel - model_inputs: - performance_parameters: - pv_capacity_kWdc: 100000 # 100 MWdc - dc_ac_ratio: 1.34 - create_model_from: new - tilt_angle_func: none - tilt: 0 - pysam_options: - SystemDesign: - gcr: 0.3 - array_type: 2 - bifaciality: 0.65 - inv_eff: 96.0 - losses: 14.6 - module_type: 1 - azimuth: 180 - rotlim: 45 - SolarResource: - albedo_default: 0.2 - cost_parameters: - capex_per_kWac: 1193 - opex_per_kWac_per_year: 18 - cost_year: 2024 ng_feedstock: performance_model: model: FeedstockPerformanceModel From 08f87900230f6032093c99d33f57e4b6ec44b915 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 00:36:13 -0600 Subject: [PATCH 013/105] Reordering SLC settings --- .../35_system_level_control/plant_config.yaml | 7 +- .../system_level/system_level_control.py | 113 ++++------- h2integrate/core/h2integrate_model.py | 184 +++++++++++++----- 3 files changed, 183 insertions(+), 121 deletions(-) diff --git a/examples/35_system_level_control/plant_config.yaml b/examples/35_system_level_control/plant_config.yaml index 095f33c27..217d57a9b 100644 --- a/examples/35_system_level_control/plant_config.yaml +++ b/examples/35_system_level_control/plant_config.yaml @@ -20,8 +20,6 @@ technology_interconnections: # connect NG feedstock to NG plant - [combiner, electrical_load_demand, electricity, cable] # subtract wind from demand - - [electrical_load_demand, natural_gas_plant, [unmet_electricity_demand_out, electricity_set_point]] - # give remaining load demand to natural gas plant - [combiner, fin_combiner, electricity, cable] - [natural_gas_plant, fin_combiner, electricity, cable] resource_to_tech_connections: @@ -32,6 +30,11 @@ plant: simulation: n_timesteps: 8760 dt: 3600 +system_level_control: + control_strategy: load_meeting + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 finance_parameters: finance_groups: profast_lco: diff --git a/h2integrate/control/control_strategies/system_level/system_level_control.py b/h2integrate/control/control_strategies/system_level/system_level_control.py index 537ca503c..3c566b731 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control.py @@ -1,14 +1,19 @@ import numpy as np import openmdao.api as om -from h2integrate.core.supported_models import supported_models - class SystemLevelControl(om.ExplicitComponent): """System-level control that satisfies demand across all technologies. - Parses ``tech_config`` to classify each technology by its - ``_control_classifier`` attribute (curtailable, dispatchable, or storage). + Reads pre-computed technology classification from + ``plant_config["system_level_control"]``, which must contain: + + - ``commodity``: the commodity being controlled (e.g. "electricity") + - ``commodity_units``: units string (or None) + - ``demand_tech``: name of the demand technology + - ``curtailable_techs``: list of curtailable technology names + - ``dispatchable_techs``: list of dispatchable technology names + - ``storage_techs``: list of storage technology names Only one commodity demand stream is supported. At each timestep, curtailable production is applied first, then the remaining demand @@ -22,73 +27,35 @@ def initialize(self): def setup(self): plant_config = self.options["plant_config"] - tech_config = self.options["tech_config"] + slc_config = plant_config["system_level_control"] self.n_timesteps = plant_config["plant"]["simulation"]["n_timesteps"] - plant_config.get("technology_interconnections", []) - technologies = tech_config.get("technologies", {}) - - # ---- 1. Identify the (single) demand technology from tech_config ---- - # A demand tech has "Demand" in its performance model name. - self.commodity = None - self.demand_profile = None - self.commodity_units = None - for tech_name, tech_def in technologies.items(): - model_name = tech_def.get("performance_model", {}).get("model", "") - if "Demand" not in model_name: - continue - - model_inputs = tech_def.get("model_inputs", {}) - perf_params = model_inputs.get("performance_parameters", {}) - shared_params = model_inputs.get("shared_parameters", {}) - all_params = {**shared_params, **perf_params} - - if self.commodity is not None: - raise ValueError( - "SystemLevelControl currently supports only one demand " - f"stream, but found demands for both '{self.commodity}' " - f"and '{all_params.get('commodity', tech_name)}'." - ) - self.commodity = all_params["commodity"] - self.commodity_units = all_params.get("commodity_rate_units", None) - self.demand_profile = all_params.get("demand_profile", 0.0) - self.demand_tech = tech_name - - # Input: demand profile + + # Read pre-computed classification from plant_config + self.commodity = slc_config["commodity"] + self.commodity_units = slc_config.get("commodity_units", None) + self.demand_tech = slc_config["demand_tech"] + self.curtailable_techs = list(slc_config.get("curtailable_techs", [])) + self.dispatchable_techs = list(slc_config.get("dispatchable_techs", [])) + self.storage_techs = list(slc_config.get("storage_techs", [])) + + # Input: demand profile (default value from config) + demand_profile = slc_config.get("demand_profile", 0.0) self.demand_input_name = f"{self.commodity}_demand" self.add_input( self.demand_input_name, - val=self.demand_profile, + val=demand_profile, shape=self.n_timesteps, units=self.commodity_units, desc=f"Demand profile of {self.commodity}", ) - # ---- 2. Classify technologies by _control_classifier ---- - self.curtailable_techs = [] - self.dispatchable_techs = [] - self.storage_techs = [] - - for tech_name, tech_def in technologies.items(): - perf_model_name = tech_def.get("performance_model", {}).get("model", "") - if perf_model_name not in supported_models: - continue - model_cls = supported_models[perf_model_name] - classifier = getattr(model_cls, "_control_classifier", None) - if classifier == "curtailable": - self.curtailable_techs.append(tech_name) - elif classifier == "dispatchable": - self.dispatchable_techs.append(tech_name) - elif classifier == "storage": - self.storage_techs.append(tech_name) - - # ---- 3. Add OpenMDAO inputs / outputs ---- - # Inputs & outputs for curtailable techs + # ---- Add OpenMDAO inputs / outputs per tech category ---- + # Curtailable techs: read-only (no set_point output, since these + # produce based on resource availability, not a set_point) self.curtailable_input_names = [] - self.curtailable_output_names = [] for tech_name in self.curtailable_techs: in_name = f"{tech_name}_{self.commodity}_out" - out_name = f"{tech_name}_{self.commodity}_set_point" self.add_input( in_name, val=0.0, @@ -96,17 +63,18 @@ def setup(self): units=self.commodity_units, desc=f"{self.commodity} output from {tech_name}", ) - self.add_output( - out_name, - val=0.0, - shape=self.n_timesteps, - units=self.commodity_units, - desc=f"Set point for {tech_name} {self.commodity} production", - ) self.curtailable_input_names.append(in_name) - self.curtailable_output_names.append(out_name) - # Inputs & outputs for dispatchable techs + # Compute a reasonable initial set_point for dispatchable techs + n_dispatchable = len(self.dispatchable_techs) + if n_dispatchable > 0: + if np.isscalar(demand_profile): + initial_sp = demand_profile / n_dispatchable + else: + initial_sp = np.array(demand_profile) / n_dispatchable + else: + initial_sp = 0.0 + self.dispatchable_input_names = [] self.dispatchable_output_names = [] for tech_name in self.dispatchable_techs: @@ -121,7 +89,7 @@ def setup(self): ) self.add_output( out_name, - val=0.0, + val=initial_sp, shape=self.n_timesteps, units=self.commodity_units, desc=f"Set point for {tech_name} {self.commodity} production", @@ -129,7 +97,6 @@ def setup(self): self.dispatchable_input_names.append(in_name) self.dispatchable_output_names.append(out_name) - # Inputs & outputs for storage techs self.storage_input_names = [] self.storage_output_names = [] for tech_name in self.storage_techs: @@ -155,11 +122,9 @@ def setup(self): def compute(self, inputs, outputs): demand = inputs[self.demand_input_name].copy() - # 1. Apply curtailable production first (pass through actual output) - for in_name, out_name in zip(self.curtailable_input_names, self.curtailable_output_names): - curtailable_output = inputs[in_name] - outputs[out_name] = curtailable_output - demand -= curtailable_output + # 1. Subtract curtailable production from demand + for in_name in self.curtailable_input_names: + demand -= inputs[in_name] # Remaining demand after curtailable production remaining = np.maximum(demand, 0.0) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 466fa80b4..6ad4fb22f 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -23,6 +23,9 @@ multivariable_streams, is_electricity_producer, ) +from h2integrate.control.control_strategies.system_level.system_level_control import ( + SystemLevelControl, +) from h2integrate.control.control_strategies.pyomo_storage_controller_baseclass import ( PyomoStorageControllerBaseClass, ) @@ -81,6 +84,11 @@ def __init__(self, config_input): self.create_finance_model() + # add system-level controller if configured + if self.slc: + self._classify_slc_technologies() + self.add_system_level_controller() + # connect technologies # technologies are connected within the `technology_interconnections` section of the # plant config @@ -458,6 +466,130 @@ def create_plant_model(self): # Create the plant model group and add components self.plant = self.model.add_subsystem("plant", plant_group, promotes=["*"]) + def _classify_slc_technologies(self): + """Classify technologies for system-level control and store in plant_config. + + Uses ``self.tech_control_classifiers`` (populated by ``create_technology_models()``) + to partition technologies into curtailable, dispatchable, and storage lists. + Also identifies the single demand technology and its commodity. + + Results are written into ``self.plant_config["system_level_control"]`` so + they are available to the ``SystemLevelControl`` component at setup time. + """ + slc_config = self.plant_config["system_level_control"] + technologies = self.technology_config.get("technologies", {}) + + # Identify the (single) demand technology + commodity = None + demand_tech = None + commodity_units = None + for tech_name, tech_def in technologies.items(): + model_name = tech_def.get("performance_model", {}).get("model", "") + if "Demand" not in model_name: + continue + + model_inputs = tech_def.get("model_inputs", {}) + perf_params = model_inputs.get("performance_parameters", {}) + shared_params = model_inputs.get("shared_parameters", {}) + all_params = {**shared_params, **perf_params} + + if commodity is not None: + raise ValueError( + "SystemLevelControl currently supports only one demand " + f"stream, but found demands for both '{commodity}' " + f"and '{all_params.get('commodity', tech_name)}'." + ) + commodity = all_params["commodity"] + commodity_units = all_params.get("commodity_rate_units", None) + demand_profile = all_params.get("demand_profile", 0.0) + demand_tech = tech_name + + # Classify technologies using pre-computed classifiers + curtailable_techs = [] + dispatchable_techs = [] + storage_techs = [] + for tech_name, classifier in self.tech_control_classifiers.items(): + if classifier == "curtailable": + curtailable_techs.append(tech_name) + elif classifier == "dispatchable": + dispatchable_techs.append(tech_name) + elif classifier == "storage": + storage_techs.append(tech_name) + + # Store classification results in plant_config for SLC component + slc_config["commodity"] = commodity + slc_config["commodity_units"] = commodity_units + slc_config["demand_tech"] = demand_tech + slc_config["demand_profile"] = demand_profile + slc_config["curtailable_techs"] = curtailable_techs + slc_config["dispatchable_techs"] = dispatchable_techs + slc_config["storage_techs"] = storage_techs + + def add_system_level_controller(self): + """Add the SystemLevelControl component and configure the plant solver. + + This method: + 1. Adds a ``SystemLevelControl`` subsystem to the plant group + 2. Configures the nonlinear solver on the plant group based on + ``plant_config["system_level_control"]`` parameters + 3. Creates connections between the controller and each technology + """ + slc_config = self.plant_config["system_level_control"] + + # Map user-facing solver names to OpenMDAO solver classes + solver_map = { + "gauss_seidel": om.NonlinearBlockGS, + "newton": om.NewtonSolver, + "block_jacobi": om.NonlinearBlockJac, + } + + # 1. Add the controller as the first subsystem in the plant group + slc_comp = SystemLevelControl( + driver_config=self.driver_config, + plant_config=self.plant_config, + tech_config=self.technology_config, + ) + self.plant.add_subsystem("system_level_controller", slc_comp) + + # 2. Configure the nonlinear solver + solver_name = slc_config.get("solver_name", "gauss_seidel") + solver_cls = solver_map.get(solver_name) + if solver_cls is None: + raise ValueError( + f"Unknown solver_name '{solver_name}' in system_level_control. " + f"Supported: {list(solver_map.keys())}" + ) + solver = solver_cls() + solver.options["maxiter"] = slc_config.get("max_iter", 20) + solver.options["atol"] = slc_config.get("convergence_tolerance", 1e-6) + solver.options["rtol"] = slc_config.get("convergence_tolerance", 1e-6) + solver.options["iprint"] = 2 # print convergence at each iteration + self.plant.nonlinear_solver = solver + self.plant.linear_solver = om.DirectSolver() + + # 3. Connect the controller's inputs/outputs to technology models + commodity = slc_config["commodity"] + + # Curtailable techs: read their output but don't write a set_point + # (curtailable sources like wind produce based on resource, not a set_point) + for tech_name in slc_config["curtailable_techs"]: + self.plant.connect( + f"{tech_name}.{commodity}_out", + f"system_level_controller.{tech_name}_{commodity}_out", + ) + + # Dispatchable and storage techs: read output and write set_point + for tech_list in ["dispatchable_techs", "storage_techs"]: + for tech_name in slc_config[tech_list]: + self.plant.connect( + f"{tech_name}.{commodity}_out", + f"system_level_controller.{tech_name}_{commodity}_out", + ) + self.plant.connect( + f"system_level_controller.{tech_name}_{commodity}_set_point", + f"{tech_name}.{commodity}_set_point", + ) + def create_technology_models(self): # Loop through each technology and instantiate an OpenMDAO object (assume it exists) # for each technology @@ -593,51 +725,13 @@ def create_technology_models(self): plural_model_type_name = model_type + "s" getattr(self, plural_model_type_name).append(om_model_object) - # below logic is only used if using system-level control - if self.slc and model_type == "performance_model": - self._check_control_classifier(perf_model, om_model_object) - control_classifier = comp._control_classifier - self.tech_control_classifiers.update( - {tech_name: getattr(comp, control_classifier)} - ) - - # add curtail component to curtailable technology performance models - if control_classifier == "curtailable": - # get the commodity output from this component with length - # 4 connections - # TODO: update to handle length 3 connections - tech_is_source_connections = [ - k - for k in self.plant_config["technology_interconnections"] - if k[0] == tech_name and len(k) == 4 - ] - if len(tech_is_source_connections) == 0: - msg = ( - f"{tech_name} is not a source technology " - f"for another component" - ) - - raise ValueError(msg) - # Get unique commodity outputs - tech_commodity_output = list( - {k[3] for k in tech_is_source_connections} - ) - if len(tech_commodity_output) > 1: - msg = ( - f"{tech_name} has multiple commodity outputs " - f"({tech_commodity_output}) which is not yet supported" - ) - raise ValueError(msg) - # get the commodity of the component - model_object = self.supported_models["CurtailableComponentModel"] - om_model_object = tech_group.add_subsystem( - "CurtailableComponentModel", - model_object( - commodity=tech_commodity_output[0], - plant_config=self.plant_config, - ), - promotes=["*"], - ) + # Collect control classifier for system-level control + if model_type == "performance_model" and self.slc: + perf_cls = self.supported_models.get(perf_model) + if perf_cls is not None: + classifier = getattr(perf_cls, "_control_classifier", None) + if classifier is not None: + self.tech_control_classifiers[tech_name] = classifier # Process the finance models if "finance_model" in individual_tech_config: From 733c636ceaa3f3c0e888c049330cddaa6f2ac074 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 00:56:42 -0600 Subject: [PATCH 014/105] Updating the SLC example --- .../run_wind_ng_demand.py | 35 +++++++++++++++++++ .../35_system_level_control/tech_config.yaml | 2 +- h2integrate/core/h2integrate_model.py | 9 +++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/examples/35_system_level_control/run_wind_ng_demand.py b/examples/35_system_level_control/run_wind_ng_demand.py index 9e85c88ca..f4be3e869 100644 --- a/examples/35_system_level_control/run_wind_ng_demand.py +++ b/examples/35_system_level_control/run_wind_ng_demand.py @@ -1,3 +1,6 @@ +import numpy as np +import matplotlib.pyplot as plt + from h2integrate.core.h2integrate_model import H2IntegrateModel @@ -10,3 +13,35 @@ # Post-process the results h2i.post_process() + +# Plot the first 100 hours +n_hours = 100 +hours = np.arange(n_hours) + +demand = h2i.prob.get_val("plant.electrical_load_demand.electricity_demand")[:n_hours] +wind_out = h2i.prob.get_val("plant.wind.electricity_out")[:n_hours] +ng_out = h2i.prob.get_val("plant.natural_gas_plant.electricity_out", units="kW")[:n_hours] +curtailed = h2i.prob.get_val("plant.electrical_load_demand.unused_electricity_out")[:n_hours] + +fig, axes = plt.subplots(4, 1, figsize=(12, 10), sharex=True) + +axes[0].plot(hours, demand, color="black") +axes[0].set_ylabel("Demand (kW)") +axes[0].set_title("System-Level Control: First 100 Hours") + +axes[1].plot(hours, wind_out, color="tab:blue") +axes[1].set_ylabel("Wind (kW)") + +axes[2].plot(hours, ng_out, color="tab:orange") +axes[2].set_ylabel("Natural Gas (kW)") + +axes[3].plot(hours, curtailed, color="tab:red") +axes[3].set_ylabel("Curtailed (kW)") +axes[3].set_xlabel("Hour") + +for ax in axes: + ax.grid(True, alpha=0.3) + +plt.tight_layout() +plt.savefig("slc_results.png", dpi=150) +plt.show() diff --git a/examples/35_system_level_control/tech_config.yaml b/examples/35_system_level_control/tech_config.yaml index e1a6c2cf3..10c0959c6 100644 --- a/examples/35_system_level_control/tech_config.yaml +++ b/examples/35_system_level_control/tech_config.yaml @@ -69,7 +69,7 @@ technologies: performance_parameters: commodity: electricity commodity_rate_units: kW - demand_profile: 100000 # 100 MW + demand_profile: 50000 combiner: performance_model: model: GenericCombinerPerformanceModel diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 6ad4fb22f..ef7c47742 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -590,6 +590,15 @@ def add_system_level_controller(self): f"{tech_name}.{commodity}_set_point", ) + ### Commented out for now; we'll need to determine how to treat demand + ### components in the new SLC paradigm. + # # Connect demand profile to the controller + # demand_tech = slc_config["demand_tech"] + # self.plant.connect( + # f"{demand_tech}.{commodity}_demand", + # f"system_level_controller.{commodity}_demand", + # ) + def create_technology_models(self): # Loop through each technology and instantiate an OpenMDAO object (assume it exists) # for each technology From d91e8aec3fab3379e180399a577745d21b37a14b Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 01:23:41 -0600 Subject: [PATCH 015/105] Added curtailment --- .../system_level/system_level_control.py | 51 +++++++++++++++---- .../converters/hydrogen/pem_electrolyzer.py | 3 ++ h2integrate/converters/solar/solar_pysam.py | 3 ++ h2integrate/converters/wind/floris.py | 3 ++ h2integrate/converters/wind/wind_pysam.py | 3 ++ h2integrate/core/h2integrate_model.py | 18 ++++++- h2integrate/core/model_baseclasses.py | 41 +++++++++++++++ 7 files changed, 111 insertions(+), 11 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control.py b/h2integrate/control/control_strategies/system_level/system_level_control.py index 3c566b731..0184ae35a 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control.py @@ -51,11 +51,14 @@ def setup(self): ) # ---- Add OpenMDAO inputs / outputs per tech category ---- - # Curtailable techs: read-only (no set_point output, since these - # produce based on resource availability, not a set_point) + # Curtailable techs: read output + rated production, write set_point self.curtailable_input_names = [] + self.curtailable_output_names = [] + self.curtailable_rated_names = [] for tech_name in self.curtailable_techs: in_name = f"{tech_name}_{self.commodity}_out" + out_name = f"{tech_name}_{self.commodity}_set_point" + rated_name = f"{tech_name}_rated_{self.commodity}_production" self.add_input( in_name, val=0.0, @@ -63,23 +66,40 @@ def setup(self): units=self.commodity_units, desc=f"{self.commodity} output from {tech_name}", ) + self.add_input( + rated_name, + val=0.0, + units=self.commodity_units, + desc=f"Rated {self.commodity} production for {tech_name}", + ) + self.add_output( + out_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"Set point for {tech_name} {self.commodity} curtailment", + ) self.curtailable_input_names.append(in_name) + self.curtailable_output_names.append(out_name) + self.curtailable_rated_names.append(rated_name) # Compute a reasonable initial set_point for dispatchable techs n_dispatchable = len(self.dispatchable_techs) if n_dispatchable > 0: if np.isscalar(demand_profile): - initial_sp = demand_profile / n_dispatchable + initial_set_point = demand_profile / n_dispatchable else: - initial_sp = np.array(demand_profile) / n_dispatchable + initial_set_point = np.array(demand_profile) / n_dispatchable else: - initial_sp = 0.0 + initial_set_point = 0.0 self.dispatchable_input_names = [] self.dispatchable_output_names = [] + self.dispatchable_rated_names = [] for tech_name in self.dispatchable_techs: in_name = f"{tech_name}_{self.commodity}_out" out_name = f"{tech_name}_{self.commodity}_set_point" + rated_name = f"{tech_name}_rated_{self.commodity}_production" self.add_input( in_name, val=0.0, @@ -87,15 +107,22 @@ def setup(self): units=self.commodity_units, desc=f"{self.commodity} output from {tech_name}", ) + self.add_input( + rated_name, + val=0.0, + units=self.commodity_units, + desc=f"Rated {self.commodity} production for {tech_name}", + ) self.add_output( out_name, - val=initial_sp, + val=initial_set_point, shape=self.n_timesteps, units=self.commodity_units, desc=f"Set point for {tech_name} {self.commodity} production", ) self.dispatchable_input_names.append(in_name) self.dispatchable_output_names.append(out_name) + self.dispatchable_rated_names.append(rated_name) self.storage_input_names = [] self.storage_output_names = [] @@ -122,9 +149,15 @@ def setup(self): def compute(self, inputs, outputs): demand = inputs[self.demand_input_name].copy() - # 1. Subtract curtailable production from demand - for in_name in self.curtailable_input_names: - demand -= inputs[in_name] + # 1. Curtailable techs: set_point = rated production (no curtailment) + for in_name, out_name, rated_name in zip( + self.curtailable_input_names, + self.curtailable_output_names, + self.curtailable_rated_names, + ): + curtailable_output = inputs[in_name] + outputs[out_name] = inputs[rated_name] * np.ones(self.n_timesteps) + demand -= curtailable_output # Remaining demand after curtailable production remaining = np.maximum(demand, 0.0) diff --git a/h2integrate/converters/hydrogen/pem_electrolyzer.py b/h2integrate/converters/hydrogen/pem_electrolyzer.py index 0e18eb985..d09d6d65c 100644 --- a/h2integrate/converters/hydrogen/pem_electrolyzer.py +++ b/h2integrate/converters/hydrogen/pem_electrolyzer.py @@ -215,3 +215,6 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): outputs["annual_oxygen_produced"] = H2_Results["Performance Schedules"][ "Annual O2 Production [kg/year]" ] + + # Apply curtailment based on set_point + self.apply_curtailment(outputs) diff --git a/h2integrate/converters/solar/solar_pysam.py b/h2integrate/converters/solar/solar_pysam.py index 2fefd5986..1a400485d 100644 --- a/h2integrate/converters/solar/solar_pysam.py +++ b/h2integrate/converters/solar/solar_pysam.py @@ -311,3 +311,6 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): outputs["capacity_factor"] = outputs["total_electricity_produced"] / max_production outputs["annual_electricity_produced"] = self.system_model.value("ac_annual") + + # Apply curtailment based on set_point + self.apply_curtailment(outputs) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index 02c26ef1e..90d66b2d0 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -287,6 +287,9 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): 1 / self.fraction_of_year_simulated ) + # Apply curtailment based on set_point + self.apply_curtailment(outputs) + # 3. Cache the results for future use if enabled self.cache_outputs( inputs, outputs, discrete_inputs, discrete_outputs={}, config_dict=config_dict diff --git a/h2integrate/converters/wind/wind_pysam.py b/h2integrate/converters/wind/wind_pysam.py index 46dd84a2e..310657a1a 100644 --- a/h2integrate/converters/wind/wind_pysam.py +++ b/h2integrate/converters/wind/wind_pysam.py @@ -478,6 +478,9 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): ) outputs["capacity_factor"] = outputs["total_electricity_produced"] / max_production + # Apply curtailment based on set_point + self.apply_curtailment(outputs) + def post_process(self, show_plots=False): def plot_turbine_points( ax: plt.Axes = None, diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index ef7c47742..0ed6d0577 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -570,13 +570,20 @@ def add_system_level_controller(self): # 3. Connect the controller's inputs/outputs to technology models commodity = slc_config["commodity"] - # Curtailable techs: read their output but don't write a set_point - # (curtailable sources like wind produce based on resource, not a set_point) + # Curtailable techs: read their output and write set_point for tech_name in slc_config["curtailable_techs"]: self.plant.connect( f"{tech_name}.{commodity}_out", f"system_level_controller.{tech_name}_{commodity}_out", ) + self.plant.connect( + f"{tech_name}.rated_{commodity}_production", + f"system_level_controller.{tech_name}_rated_{commodity}_production", + ) + self.plant.connect( + f"system_level_controller.{tech_name}_{commodity}_set_point", + f"{tech_name}.{commodity}_set_point", + ) # Dispatchable and storage techs: read output and write set_point for tech_list in ["dispatchable_techs", "storage_techs"]: @@ -590,6 +597,13 @@ def add_system_level_controller(self): f"{tech_name}.{commodity}_set_point", ) + # Dispatchable techs: also connect rated production + for tech_name in slc_config["dispatchable_techs"]: + self.plant.connect( + f"{tech_name}.rated_{commodity}_production", + f"system_level_controller.{tech_name}_rated_{commodity}_production", + ) + ### Commented out for now; we'll need to determine how to treat demand ### components in the new SLC paradigm. # # Connect demand profile to the controller diff --git a/h2integrate/core/model_baseclasses.py b/h2integrate/core/model_baseclasses.py index c6adf5cd6..45d8b98da 100644 --- a/h2integrate/core/model_baseclasses.py +++ b/h2integrate/core/model_baseclasses.py @@ -3,6 +3,7 @@ from pathlib import Path import dill +import numpy as np import openmdao.api as om from attrs import field, define @@ -98,6 +99,46 @@ def setup(self): # operational life of the technology if the technology cannot be replaced self.add_output("operational_life", val=self.plant_life, units="yr") + # Curtailable models get additional I/O for set_point-based curtailment + if getattr(self, "_control_classifier", None) == "curtailable": + self.add_input( + f"{self.commodity}_set_point", + val=0.0, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc=f"Set point for {self.commodity} production (curtailment limit)", + ) + self.add_output( + f"uncurtailed_{self.commodity}_out", + val=0.0, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc=f"Full (uncurtailed) {self.commodity} output", + ) + + def apply_curtailment(self, outputs): + """Apply curtailment to ``{commodity}_out`` based on ``{commodity}_set_point``. + + Copies the current ``{commodity}_out`` into ``uncurtailed_{commodity}_out``, + then clips ``{commodity}_out`` to ``min(uncurtailed, set_point)`` element-wise. + + Only operates when the model has ``_control_classifier == "curtailable"``. + Should be called at the end of each curtailable model's ``compute()`` method + after the raw production has been written to ``outputs[f"{commodity}_out"]``. + """ + if getattr(self, "_control_classifier", None) != "curtailable": + return + + commodity_out_key = f"{self.commodity}_out" + uncurtailed_key = f"uncurtailed_{self.commodity}_out" + set_point_key = f"{self.commodity}_set_point" + + uncurtailed = np.array(outputs[commodity_out_key]) + outputs[uncurtailed_key] = uncurtailed + + set_point = self._inputs[set_point_key] + outputs[commodity_out_key] = np.minimum(uncurtailed, set_point) + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): """ Computation for the OM component. From fc6bd5fe6599558078a714153d89267ba21f9edd Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 12:04:59 -0600 Subject: [PATCH 016/105] WIP: adding battery example for SLC --- .../{ => no_battery}/driver_config.yaml | 0 .../{ => no_battery}/plant_config.yaml | 0 .../{ => no_battery}/run_wind_ng_demand.py | 0 .../{ => no_battery}/tech_config.yaml | 0 .../{ => no_battery}/wind_ng_demand.yaml | 0 .../yes_battery/driver_config.yaml | 4 + .../yes_battery/plant_config.yaml | 106 +++++++++++++++++ .../yes_battery/run_wind_ng_demand.py | 55 +++++++++ .../yes_battery/tech_config.yaml | 109 ++++++++++++++++++ .../yes_battery/wind_ng_demand.yaml | 5 + .../system_level/system_level_control.py | 32 +++-- h2integrate/core/h2integrate_model.py | 13 ++- 12 files changed, 311 insertions(+), 13 deletions(-) rename examples/35_system_level_control/{ => no_battery}/driver_config.yaml (100%) rename examples/35_system_level_control/{ => no_battery}/plant_config.yaml (100%) rename examples/35_system_level_control/{ => no_battery}/run_wind_ng_demand.py (100%) rename examples/35_system_level_control/{ => no_battery}/tech_config.yaml (100%) rename examples/35_system_level_control/{ => no_battery}/wind_ng_demand.yaml (100%) create mode 100644 examples/35_system_level_control/yes_battery/driver_config.yaml create mode 100644 examples/35_system_level_control/yes_battery/plant_config.yaml create mode 100644 examples/35_system_level_control/yes_battery/run_wind_ng_demand.py create mode 100644 examples/35_system_level_control/yes_battery/tech_config.yaml create mode 100644 examples/35_system_level_control/yes_battery/wind_ng_demand.yaml diff --git a/examples/35_system_level_control/driver_config.yaml b/examples/35_system_level_control/no_battery/driver_config.yaml similarity index 100% rename from examples/35_system_level_control/driver_config.yaml rename to examples/35_system_level_control/no_battery/driver_config.yaml diff --git a/examples/35_system_level_control/plant_config.yaml b/examples/35_system_level_control/no_battery/plant_config.yaml similarity index 100% rename from examples/35_system_level_control/plant_config.yaml rename to examples/35_system_level_control/no_battery/plant_config.yaml diff --git a/examples/35_system_level_control/run_wind_ng_demand.py b/examples/35_system_level_control/no_battery/run_wind_ng_demand.py similarity index 100% rename from examples/35_system_level_control/run_wind_ng_demand.py rename to examples/35_system_level_control/no_battery/run_wind_ng_demand.py diff --git a/examples/35_system_level_control/tech_config.yaml b/examples/35_system_level_control/no_battery/tech_config.yaml similarity index 100% rename from examples/35_system_level_control/tech_config.yaml rename to examples/35_system_level_control/no_battery/tech_config.yaml diff --git a/examples/35_system_level_control/wind_ng_demand.yaml b/examples/35_system_level_control/no_battery/wind_ng_demand.yaml similarity index 100% rename from examples/35_system_level_control/wind_ng_demand.yaml rename to examples/35_system_level_control/no_battery/wind_ng_demand.yaml diff --git a/examples/35_system_level_control/yes_battery/driver_config.yaml b/examples/35_system_level_control/yes_battery/driver_config.yaml new file mode 100644 index 000000000..5b6b7e05a --- /dev/null +++ b/examples/35_system_level_control/yes_battery/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: This analysis runs a natural gas power plant +general: + folder_output: outputs diff --git a/examples/35_system_level_control/yes_battery/plant_config.yaml b/examples/35_system_level_control/yes_battery/plant_config.yaml new file mode 100644 index 000000000..821d07237 --- /dev/null +++ b/examples/35_system_level_control/yes_battery/plant_config.yaml @@ -0,0 +1,106 @@ +name: plant_config +description: This plant is located in Texas, USA. +sites: + site: + latitude: 30.6617 + longitude: -101.7096 + resources: + wind_resource: + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2013 +# array of arrays containing left-to-right technology +# interconnections; can support bidirectional connections +# with the reverse definition. +# this will naturally grow as we mature the interconnected tech +technology_interconnections: + - [wind, combiner, electricity, cable] + # source_tech, dest_tech, transport_item, transport_type = connection + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] + # connect NG feedstock to NG plant + - [combiner, battery, electricity, cable] + # wind available for battery charging + - [combiner, electrical_load_demand, electricity, cable] + # subtract wind from demand + - [combiner, fin_combiner, electricity, cable] + - [battery, fin_combiner, electricity, cable] + - [natural_gas_plant, fin_combiner, electricity, cable] +resource_to_tech_connections: + # connect the wind resource to the wind technology + - [site.wind_resource, wind, wind_resource_data] +plant: + plant_life: 30 + simulation: + n_timesteps: 8760 + dt: 3600 +system_level_control: + control_strategy: load_meeting + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +finance_parameters: + finance_groups: + profast_lco: + finance_model: ProFastLCO + model_inputs: + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + depr_period: 5 # 5 years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + refurb: [0.] + profast_npv: + finance_model: ProFastNPV + model_inputs: + commodity_sell_price: 0.05167052 + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + depr_period: 5 # 5 years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + refurb: [0.] + finance_subgroups: + renewables: + commodity: electricity + commodity_stream: combiner + finance_groups: [profast_lco, profast_npv] + technologies: [wind] + natural_gas: + commodity: electricity + commodity_stream: natural_gas_plant + finance_groups: [profast_lco] + technologies: [natural_gas_plant, ng_feedstock] + electricity: + commodity: electricity + commodity_stream: fin_combiner + finance_groups: [profast_lco] + technologies: [wind, battery, natural_gas_plant, ng_feedstock] + cost_adjustment_parameters: + cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year + target_dollar_year: 2022 diff --git a/examples/35_system_level_control/yes_battery/run_wind_ng_demand.py b/examples/35_system_level_control/yes_battery/run_wind_ng_demand.py new file mode 100644 index 000000000..6b184eb4d --- /dev/null +++ b/examples/35_system_level_control/yes_battery/run_wind_ng_demand.py @@ -0,0 +1,55 @@ +import numpy as np +import matplotlib.pyplot as plt + +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +################################## +# Create an H2I model with a fixed electricity load demand +h2i = H2IntegrateModel("wind_ng_demand.yaml") + +# Run the model +h2i.run() + +# Post-process the results +h2i.post_process() + +# Plot the first 168 hours (1 week) +n_hours = 168 +hours = np.arange(n_hours) + +wind_out = h2i.prob.get_val("plant.wind.electricity_out")[:n_hours] +ng_out = h2i.prob.get_val("plant.natural_gas_plant.electricity_out", units="kW")[:n_hours] +batt_charge = h2i.prob.get_val("plant.battery.storage_electricity_charge")[:n_hours] +batt_discharge = h2i.prob.get_val("plant.battery.storage_electricity_discharge")[:n_hours] +batt_soc = h2i.prob.get_val("plant.battery.SOC")[:n_hours] +curtailed = h2i.prob.get_val("plant.electrical_load_demand.unused_electricity_out")[:n_hours] + +fig, axes = plt.subplots(6, 1, figsize=(12, 14), sharex=True) + +axes[0].plot(hours, wind_out, color="tab:blue") +axes[0].set_ylabel("Wind (kW)") +axes[0].set_title("System-Level Control: First 168 Hours") + +axes[1].plot(hours, ng_out, color="tab:orange") +axes[1].set_ylabel("Natural Gas (kW)") + +axes[2].plot(hours, batt_charge, color="tab:green") +axes[2].set_ylabel("Battery Charge (kW)") + +axes[3].plot(hours, batt_discharge, color="tab:purple") +axes[3].set_ylabel("Battery Discharge (kW)") + +axes[4].plot(hours, batt_soc, color="tab:cyan") +axes[4].set_ylabel("Battery SOC (%)") + +axes[5].plot(hours, curtailed, color="tab:red") +axes[5].set_ylabel("Curtailed (kW)") +axes[5].set_xlabel("Hour") + +for ax in axes: + ax.grid(True, alpha=0.3) + +plt.tight_layout() +plt.savefig("slc_results.png", dpi=150) +plt.show() diff --git a/examples/35_system_level_control/yes_battery/tech_config.yaml b/examples/35_system_level_control/yes_battery/tech_config.yaml new file mode 100644 index 000000000..870889a0d --- /dev/null +++ b/examples/35_system_level_control/yes_battery/tech_config.yaml @@ -0,0 +1,109 @@ +name: technology_config +description: This plant produces electricity with wind, solar, and a natural gas power plant to meet a fixed electrical load + demand. +technologies: + wind: + performance_model: + model: PYSAMWindPlantPerformanceModel + cost_model: + model: ATBWindPlantCostModel + model_inputs: + performance_parameters: + num_turbines: 20 + turbine_rating_kw: 6000 + hub_height: 115 + rotor_diameter: 170 + create_model_from: default + config_name: WindPowerSingleOwner + pysam_options: + Farm: + wind_farm_wake_model: 0 + Losses: + ops_strategies_loss: 10.0 + layout: + layout_mode: basicgrid + layout_options: + row_D_spacing: 5.0 + turbine_D_spacing: 5.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: square + cost_parameters: + capex_per_kW: 1300 + opex_per_kW_per_year: 39 + cost_year: 2022 + ng_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: natural_gas + commodity_rate_units: MMBtu/h + performance_parameters: + rated_capacity: 750. # MMBtu + cost_parameters: + cost_year: 2023 + price: 4.2 # USD/MMBtu + annual_cost: 0. + start_up_cost: 0. + natural_gas_plant: + performance_model: + model: NaturalGasPerformanceModel + cost_model: + model: NaturalGasCostModel + model_inputs: + shared_parameters: + heat_rate_mmbtu_per_mwh: 7.5 # MMBtu/MWh - typical for NGCC + system_capacity_mw: 100. # MW + cost_parameters: + capex_per_kw: 1000 # $/kW - typical for NGCC + fixed_opex_per_kw_per_year: 10.0 # $/kW/year + variable_opex_per_mwh: 0.0 # $/MWh + cost_year: 2023 + electrical_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 50000 + combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + battery: + performance_model: + model: PySAMBatteryPerformanceModel + cost_model: + model: ATBBatteryCostModel + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + max_charge_rate: 50000 # kW (50 MW) + max_capacity: 200000 # kWh (200 MWh, 4-hour duration) + init_soc_fraction: 0.5 + max_soc_fraction: 1.0 + min_soc_fraction: 0.1 + performance_parameters: + chemistry: LFPGraphite + demand_profile: 50000 # kW, required by storage base config + cost_parameters: + cost_year: 2022 + energy_capex: 310 # $/kWh + power_capex: 311 # $/kW + opex_fraction: 0.025 + fin_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 3 diff --git a/examples/35_system_level_control/yes_battery/wind_ng_demand.yaml b/examples/35_system_level_control/yes_battery/wind_ng_demand.yaml new file mode 100644 index 000000000..f2b5599a0 --- /dev/null +++ b/examples/35_system_level_control/yes_battery/wind_ng_demand.yaml @@ -0,0 +1,5 @@ +name: H2Integrate_config +system_summary: This example uses wind, solar and a natural gas power plant to meet a fixed electrical load demand. +driver_config: driver_config.yaml +technology_config: tech_config.yaml +plant_config: plant_config.yaml diff --git a/h2integrate/control/control_strategies/system_level/system_level_control.py b/h2integrate/control/control_strategies/system_level/system_level_control.py index 0184ae35a..555b1f9dc 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control.py @@ -126,9 +126,11 @@ def setup(self): self.storage_input_names = [] self.storage_output_names = [] + self.storage_rated_names = [] for tech_name in self.storage_techs: in_name = f"{tech_name}_{self.commodity}_out" out_name = f"{tech_name}_{self.commodity}_set_point" + rated_name = f"{tech_name}_rated_{self.commodity}_production" self.add_input( in_name, val=0.0, @@ -136,6 +138,12 @@ def setup(self): units=self.commodity_units, desc=f"{self.commodity} output from {tech_name}", ) + self.add_input( + rated_name, + val=0.0, + units=self.commodity_units, + desc=f"Rated {self.commodity} production for {tech_name}", + ) self.add_output( out_name, val=0.0, @@ -145,6 +153,7 @@ def setup(self): ) self.storage_input_names.append(in_name) self.storage_output_names.append(out_name) + self.storage_rated_names.append(rated_name) def compute(self, inputs, outputs): demand = inputs[self.demand_input_name].copy() @@ -159,16 +168,25 @@ def compute(self, inputs, outputs): outputs[out_name] = inputs[rated_name] * np.ones(self.n_timesteps) demand -= curtailable_output - # Remaining demand after curtailable production + # 2. Storage dispatch: set_point = net demand per storage tech + # positive set_point → discharge, negative → charge + # The storage model's simulate() handles rate/SOC/availability clipping + # internally, so we pass the raw demand signal here. + n_storage = len(self.storage_output_names) + if n_storage > 0: + storage_share = demand / n_storage + for out_name in self.storage_output_names: + outputs[out_name] = storage_share + + # Subtract actual storage output from demand + # (electricity_out > 0 when discharging, < 0 when charging) + for in_name in self.storage_input_names: + demand -= inputs[in_name] + + # 3. Remaining demand after curtailable + storage → dispatchable techs remaining = np.maximum(demand, 0.0) - - # 2. Distribute remaining demand equally across dispatchable techs n_dispatchable = len(self.dispatchable_output_names) if n_dispatchable > 0: share = remaining / n_dispatchable for out_name in self.dispatchable_output_names: outputs[out_name] = share - - # 3. Storage techs get zero set_point for now - for out_name in self.storage_output_names: - outputs[out_name] = np.zeros(self.n_timesteps) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 0ed6d0577..2445d256c 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -597,12 +597,13 @@ def add_system_level_controller(self): f"{tech_name}.{commodity}_set_point", ) - # Dispatchable techs: also connect rated production - for tech_name in slc_config["dispatchable_techs"]: - self.plant.connect( - f"{tech_name}.rated_{commodity}_production", - f"system_level_controller.{tech_name}_rated_{commodity}_production", - ) + # Dispatchable and storage techs: also connect rated production + for tech_list in ["dispatchable_techs", "storage_techs"]: + for tech_name in slc_config[tech_list]: + self.plant.connect( + f"{tech_name}.rated_{commodity}_production", + f"system_level_controller.{tech_name}_rated_{commodity}_production", + ) ### Commented out for now; we'll need to determine how to treat demand ### components in the new SLC paradigm. From efd309393ddfc0d61be81996f5bb5acbd4485f26 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 12:19:53 -0600 Subject: [PATCH 017/105] Improving plotting for battery SLC example --- .../yes_battery/run_wind_ng_demand.py | 48 +++++++++++-------- .../yes_battery/tech_config.yaml | 2 +- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/examples/35_system_level_control/yes_battery/run_wind_ng_demand.py b/examples/35_system_level_control/yes_battery/run_wind_ng_demand.py index 6b184eb4d..a70f69372 100644 --- a/examples/35_system_level_control/yes_battery/run_wind_ng_demand.py +++ b/examples/35_system_level_control/yes_battery/run_wind_ng_demand.py @@ -20,32 +20,42 @@ wind_out = h2i.prob.get_val("plant.wind.electricity_out")[:n_hours] ng_out = h2i.prob.get_val("plant.natural_gas_plant.electricity_out", units="kW")[:n_hours] -batt_charge = h2i.prob.get_val("plant.battery.storage_electricity_charge")[:n_hours] batt_discharge = h2i.prob.get_val("plant.battery.storage_electricity_discharge")[:n_hours] batt_soc = h2i.prob.get_val("plant.battery.SOC")[:n_hours] +demand = h2i.prob.get_val("plant.electrical_load_demand.electricity_demand")[:n_hours] curtailed = h2i.prob.get_val("plant.electrical_load_demand.unused_electricity_out")[:n_hours] -fig, axes = plt.subplots(6, 1, figsize=(12, 14), sharex=True) - -axes[0].plot(hours, wind_out, color="tab:blue") -axes[0].set_ylabel("Wind (kW)") +fig, axes = plt.subplots(3, 1, figsize=(12, 10), sharex=True) + +# Stacked area: wind + battery discharge + NG = total supply +axes[0].fill_between(hours, 0, wind_out, alpha=0.7, color="tab:blue", label="Wind") +axes[0].fill_between( + hours, + wind_out, + wind_out + batt_discharge, + alpha=0.7, + color="tab:purple", + label="Battery Discharge", +) +axes[0].fill_between( + hours, + wind_out + batt_discharge, + wind_out + batt_discharge + ng_out, + alpha=0.7, + color="tab:orange", + label="Natural Gas", +) +axes[0].plot(hours, demand, color="black", linewidth=1.5, linestyle="--", label="Demand") +axes[0].set_ylabel("Power (kW)") axes[0].set_title("System-Level Control: First 168 Hours") +axes[0].legend(loc="upper right") -axes[1].plot(hours, ng_out, color="tab:orange") -axes[1].set_ylabel("Natural Gas (kW)") - -axes[2].plot(hours, batt_charge, color="tab:green") -axes[2].set_ylabel("Battery Charge (kW)") - -axes[3].plot(hours, batt_discharge, color="tab:purple") -axes[3].set_ylabel("Battery Discharge (kW)") - -axes[4].plot(hours, batt_soc, color="tab:cyan") -axes[4].set_ylabel("Battery SOC (%)") +axes[1].plot(hours, batt_soc, color="tab:cyan") +axes[1].set_ylabel("Battery SOC (%)") -axes[5].plot(hours, curtailed, color="tab:red") -axes[5].set_ylabel("Curtailed (kW)") -axes[5].set_xlabel("Hour") +axes[2].plot(hours, curtailed, color="tab:red") +axes[2].set_ylabel("Curtailed (kW)") +axes[2].set_xlabel("Hour") for ax in axes: ax.grid(True, alpha=0.3) diff --git a/examples/35_system_level_control/yes_battery/tech_config.yaml b/examples/35_system_level_control/yes_battery/tech_config.yaml index 870889a0d..137668e15 100644 --- a/examples/35_system_level_control/yes_battery/tech_config.yaml +++ b/examples/35_system_level_control/yes_battery/tech_config.yaml @@ -69,7 +69,7 @@ technologies: performance_parameters: commodity: electricity commodity_rate_units: kW - demand_profile: 50000 + demand_profile: 30000 combiner: performance_model: model: GenericCombinerPerformanceModel From 59f9b1642b542fb2bfeee3262c11850229d23c98 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 12:48:30 -0600 Subject: [PATCH 018/105] Moved combiner and changed battery model --- .../yes_battery/plant_config.yaml | 17 +++++++++-------- .../yes_battery/tech_config.yaml | 17 +++++------------ 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/examples/35_system_level_control/yes_battery/plant_config.yaml b/examples/35_system_level_control/yes_battery/plant_config.yaml index 821d07237..72331d352 100644 --- a/examples/35_system_level_control/yes_battery/plant_config.yaml +++ b/examples/35_system_level_control/yes_battery/plant_config.yaml @@ -14,17 +14,18 @@ sites: # with the reverse definition. # this will naturally grow as we mature the interconnected tech technology_interconnections: - - [wind, combiner, electricity, cable] - # source_tech, dest_tech, transport_item, transport_type = connection - [ng_feedstock, natural_gas_plant, natural_gas, pipe] # connect NG feedstock to NG plant - - [combiner, battery, electricity, cable] - # wind available for battery charging - - [combiner, electrical_load_demand, electricity, cable] - # subtract wind from demand - - [combiner, fin_combiner, electricity, cable] + - [wind, battery, electricity, cable] + # wind output available for battery charging (electricity_in) + - [wind, fin_combiner, electricity, cable] + # wind to combined output - [battery, fin_combiner, electricity, cable] + # battery net output to combined output - [natural_gas_plant, fin_combiner, electricity, cable] + # NG to combined output + - [fin_combiner, electrical_load_demand, electricity, cable] + # combined supply to demand resource_to_tech_connections: # connect the wind resource to the wind technology - [site.wind_resource, wind, wind_resource_data] @@ -88,7 +89,7 @@ finance_parameters: finance_subgroups: renewables: commodity: electricity - commodity_stream: combiner + commodity_stream: wind finance_groups: [profast_lco, profast_npv] technologies: [wind] natural_gas: diff --git a/examples/35_system_level_control/yes_battery/tech_config.yaml b/examples/35_system_level_control/yes_battery/tech_config.yaml index 137668e15..0805b5e52 100644 --- a/examples/35_system_level_control/yes_battery/tech_config.yaml +++ b/examples/35_system_level_control/yes_battery/tech_config.yaml @@ -70,18 +70,11 @@ technologies: commodity: electricity commodity_rate_units: kW demand_profile: 30000 - combiner: - performance_model: - model: GenericCombinerPerformanceModel - model_inputs: - performance_parameters: - commodity: electricity - commodity_rate_units: kW battery: performance_model: - model: PySAMBatteryPerformanceModel + model: StoragePerformanceModel cost_model: - model: ATBBatteryCostModel + model: GenericStorageCostModel model_inputs: shared_parameters: commodity: electricity @@ -92,12 +85,12 @@ technologies: max_soc_fraction: 1.0 min_soc_fraction: 0.1 performance_parameters: - chemistry: LFPGraphite + round_trip_efficiency: 0.90 demand_profile: 50000 # kW, required by storage base config cost_parameters: cost_year: 2022 - energy_capex: 310 # $/kWh - power_capex: 311 # $/kW + capacity_capex: 310 # $/kWh + charge_capex: 311 # $/kW opex_fraction: 0.025 fin_combiner: performance_model: From 0fdfde2b534caf6d3f3d757750b18b4cd7ea6e64 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 12:51:00 -0600 Subject: [PATCH 019/105] Changed example so wind power is sometimes curtailed after charging battery --- .../35_system_level_control/yes_battery/tech_config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/35_system_level_control/yes_battery/tech_config.yaml b/examples/35_system_level_control/yes_battery/tech_config.yaml index 0805b5e52..acda181e3 100644 --- a/examples/35_system_level_control/yes_battery/tech_config.yaml +++ b/examples/35_system_level_control/yes_battery/tech_config.yaml @@ -79,14 +79,14 @@ technologies: shared_parameters: commodity: electricity commodity_rate_units: kW - max_charge_rate: 50000 # kW (50 MW) - max_capacity: 200000 # kWh (200 MWh, 4-hour duration) + max_charge_rate: 20000 # kW (20 MW) + max_capacity: 80000 # kWh (80 MWh, 4-hour duration) init_soc_fraction: 0.5 max_soc_fraction: 1.0 min_soc_fraction: 0.1 performance_parameters: round_trip_efficiency: 0.90 - demand_profile: 50000 # kW, required by storage base config + demand_profile: 20000 # kW, required by storage base config cost_parameters: cost_year: 2022 capacity_capex: 310 # $/kWh From 2e428b337f481afb53aa75f912dbc95d6027d94c Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 13:35:34 -0600 Subject: [PATCH 020/105] updated plotting script --- .../35_system_level_control/yes_battery/run_wind_ng_demand.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/35_system_level_control/yes_battery/run_wind_ng_demand.py b/examples/35_system_level_control/yes_battery/run_wind_ng_demand.py index a70f69372..b536e660b 100644 --- a/examples/35_system_level_control/yes_battery/run_wind_ng_demand.py +++ b/examples/35_system_level_control/yes_battery/run_wind_ng_demand.py @@ -48,7 +48,7 @@ axes[0].plot(hours, demand, color="black", linewidth=1.5, linestyle="--", label="Demand") axes[0].set_ylabel("Power (kW)") axes[0].set_title("System-Level Control: First 168 Hours") -axes[0].legend(loc="upper right") +axes[0].legend() axes[1].plot(hours, batt_soc, color="tab:cyan") axes[1].set_ylabel("Battery SOC (%)") From 237df21cde7d14a70e410795605235b05dfd4c3a Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:01:31 -0600 Subject: [PATCH 021/105] added marker to curtailment component test --- .../converters/test/test_curtailable_component.py | 2 ++ h2integrate/core/h2integrate_model.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/h2integrate/control/control_strategies/converters/test/test_curtailable_component.py b/h2integrate/control/control_strategies/converters/test/test_curtailable_component.py index a08b37774..a0042effd 100644 --- a/h2integrate/control/control_strategies/converters/test/test_curtailable_component.py +++ b/h2integrate/control/control_strategies/converters/test/test_curtailable_component.py @@ -1,4 +1,5 @@ import numpy as np +import pytest import openmdao.api as om from pytest import fixture @@ -24,6 +25,7 @@ def plant_config_base(): return plant_config +@pytest.mark.unit def test_curtailable_component(plant_config_base, subtests): prob = om.Problem() diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 2445d256c..2bdbd7a21 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -485,7 +485,7 @@ def _classify_slc_technologies(self): commodity_units = None for tech_name, tech_def in technologies.items(): model_name = tech_def.get("performance_model", {}).get("model", "") - if "Demand" not in model_name: + if "DemandComponent" not in model_name: continue model_inputs = tech_def.get("model_inputs", {}) From cb9d0535a0f8a24a8f3cf274a604a2fdf14464f8 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:02:03 -0600 Subject: [PATCH 022/105] added super basic SLC tests to make sure examples dont break --- .../system_level/test/test_slc_examples.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 h2integrate/control/control_strategies/system_level/test/test_slc_examples.py diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py new file mode 100644 index 000000000..259ad9a5a --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -0,0 +1,37 @@ +import pytest + +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +@pytest.mark.unit +@pytest.mark.parametrize( + "example_folder,resource_example_folder", [("35_system_level_control/no_battery", None)] +) +def test_slc_no_battery(subtests, temp_copy_of_example): + example_folder = temp_copy_of_example + + model = H2IntegrateModel(example_folder / "wind_ng_demand.yaml") + + model.run() + + wind_out = model.prob.get_val("plant.wind.electricity_out") + + with subtests.test("wind farm generates power"): + assert wind_out.sum() > 0 + + +@pytest.mark.unit +@pytest.mark.parametrize( + "example_folder,resource_example_folder", [("35_system_level_control/yes_battery", None)] +) +def test_slc_yes_battery(subtests, temp_copy_of_example): + example_folder = temp_copy_of_example + + model = H2IntegrateModel(example_folder / "wind_ng_demand.yaml") + + model.run() + + wind_out = model.prob.get_val("plant.wind.electricity_out") + + with subtests.test("wind farm generates power"): + assert wind_out.sum() > 0 From 1ef9743d77115c23688f9e75ebf480bccfaeb7da Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:06:10 -0600 Subject: [PATCH 023/105] fixed example tests --- .../control_strategies/system_level/test/conftest.py | 7 +++++++ .../system_level/test/test_slc_examples.py | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 h2integrate/control/control_strategies/system_level/test/conftest.py diff --git a/h2integrate/control/control_strategies/system_level/test/conftest.py b/h2integrate/control/control_strategies/system_level/test/conftest.py new file mode 100644 index 000000000..3380e4e17 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/test/conftest.py @@ -0,0 +1,7 @@ +from test.conftest import ( # noqa: F401 + temp_dir, + temp_dir_module, + temp_copy_of_example, + pytest_collection_modifyitems, + temp_copy_of_example_module_scope, +) diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index 259ad9a5a..502be050b 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -14,7 +14,7 @@ def test_slc_no_battery(subtests, temp_copy_of_example): model.run() - wind_out = model.prob.get_val("plant.wind.electricity_out") + wind_out = model.prob.get_val("wind.electricity_out") with subtests.test("wind farm generates power"): assert wind_out.sum() > 0 @@ -31,7 +31,7 @@ def test_slc_yes_battery(subtests, temp_copy_of_example): model.run() - wind_out = model.prob.get_val("plant.wind.electricity_out") + wind_out = model.prob.get_val("wind.electricity_out") with subtests.test("wind farm generates power"): assert wind_out.sum() > 0 From 748be3206ce46d57f07985d8ca332ae97210184d Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 16:37:47 -0600 Subject: [PATCH 024/105] Minor name clarifications within SLC --- .../system_level/system_level_control.py | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control.py b/h2integrate/control/control_strategies/system_level/system_level_control.py index 555b1f9dc..dc758a208 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control.py @@ -53,11 +53,11 @@ def setup(self): # ---- Add OpenMDAO inputs / outputs per tech category ---- # Curtailable techs: read output + rated production, write set_point self.curtailable_input_names = [] - self.curtailable_output_names = [] + self.curtailable_set_point_names = [] self.curtailable_rated_names = [] for tech_name in self.curtailable_techs: in_name = f"{tech_name}_{self.commodity}_out" - out_name = f"{tech_name}_{self.commodity}_set_point" + set_point_name = f"{tech_name}_{self.commodity}_set_point" rated_name = f"{tech_name}_rated_{self.commodity}_production" self.add_input( in_name, @@ -73,14 +73,14 @@ def setup(self): desc=f"Rated {self.commodity} production for {tech_name}", ) self.add_output( - out_name, + set_point_name, val=0.0, shape=self.n_timesteps, units=self.commodity_units, desc=f"Set point for {tech_name} {self.commodity} curtailment", ) self.curtailable_input_names.append(in_name) - self.curtailable_output_names.append(out_name) + self.curtailable_set_point_names.append(set_point_name) self.curtailable_rated_names.append(rated_name) # Compute a reasonable initial set_point for dispatchable techs @@ -94,11 +94,11 @@ def setup(self): initial_set_point = 0.0 self.dispatchable_input_names = [] - self.dispatchable_output_names = [] + self.dispatchable_set_point_names = [] self.dispatchable_rated_names = [] for tech_name in self.dispatchable_techs: in_name = f"{tech_name}_{self.commodity}_out" - out_name = f"{tech_name}_{self.commodity}_set_point" + set_point_name = f"{tech_name}_{self.commodity}_set_point" rated_name = f"{tech_name}_rated_{self.commodity}_production" self.add_input( in_name, @@ -114,22 +114,22 @@ def setup(self): desc=f"Rated {self.commodity} production for {tech_name}", ) self.add_output( - out_name, + set_point_name, val=initial_set_point, shape=self.n_timesteps, units=self.commodity_units, desc=f"Set point for {tech_name} {self.commodity} production", ) self.dispatchable_input_names.append(in_name) - self.dispatchable_output_names.append(out_name) + self.dispatchable_set_point_names.append(set_point_name) self.dispatchable_rated_names.append(rated_name) self.storage_input_names = [] - self.storage_output_names = [] + self.storage_set_point_names = [] self.storage_rated_names = [] for tech_name in self.storage_techs: in_name = f"{tech_name}_{self.commodity}_out" - out_name = f"{tech_name}_{self.commodity}_set_point" + set_point_name = f"{tech_name}_{self.commodity}_set_point" rated_name = f"{tech_name}_rated_{self.commodity}_production" self.add_input( in_name, @@ -145,38 +145,38 @@ def setup(self): desc=f"Rated {self.commodity} production for {tech_name}", ) self.add_output( - out_name, + set_point_name, val=0.0, shape=self.n_timesteps, units=self.commodity_units, desc=f"Set point for {tech_name} {self.commodity} production", ) self.storage_input_names.append(in_name) - self.storage_output_names.append(out_name) + self.storage_set_point_names.append(set_point_name) self.storage_rated_names.append(rated_name) def compute(self, inputs, outputs): demand = inputs[self.demand_input_name].copy() # 1. Curtailable techs: set_point = rated production (no curtailment) - for in_name, out_name, rated_name in zip( + for in_name, set_point_name, rated_name in zip( self.curtailable_input_names, - self.curtailable_output_names, + self.curtailable_set_point_names, self.curtailable_rated_names, ): curtailable_output = inputs[in_name] - outputs[out_name] = inputs[rated_name] * np.ones(self.n_timesteps) + outputs[set_point_name] = inputs[rated_name] * np.ones(self.n_timesteps) demand -= curtailable_output # 2. Storage dispatch: set_point = net demand per storage tech # positive set_point → discharge, negative → charge # The storage model's simulate() handles rate/SOC/availability clipping # internally, so we pass the raw demand signal here. - n_storage = len(self.storage_output_names) + n_storage = len(self.storage_set_point_names) if n_storage > 0: storage_share = demand / n_storage - for out_name in self.storage_output_names: - outputs[out_name] = storage_share + for set_point_name in self.storage_set_point_names: + outputs[set_point_name] = storage_share # Subtract actual storage output from demand # (electricity_out > 0 when discharging, < 0 when charging) @@ -185,8 +185,8 @@ def compute(self, inputs, outputs): # 3. Remaining demand after curtailable + storage → dispatchable techs remaining = np.maximum(demand, 0.0) - n_dispatchable = len(self.dispatchable_output_names) + n_dispatchable = len(self.dispatchable_set_point_names) if n_dispatchable > 0: share = remaining / n_dispatchable - for out_name in self.dispatchable_output_names: - outputs[out_name] = share + for set_point_name in self.dispatchable_set_point_names: + outputs[set_point_name] = share From f8294c6667dbc516d43943493a1d66e156e01fa0 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 17:18:00 -0600 Subject: [PATCH 025/105] Adding a notion of price-considering SLCs --- .../system_level/cost_minimization_control.py | 67 ++++++ .../system_level/demand_following_control.py | 35 +++ .../profit_maximization_control.py | 86 ++++++++ .../system_level/system_level_control.py | 199 +----------------- .../system_level/system_level_control_base.py | 195 +++++++++++++++++ h2integrate/core/h2integrate_model.py | 58 ++++- 6 files changed, 441 insertions(+), 199 deletions(-) create mode 100644 h2integrate/control/control_strategies/system_level/cost_minimization_control.py create mode 100644 h2integrate/control/control_strategies/system_level/demand_following_control.py create mode 100644 h2integrate/control/control_strategies/system_level/profit_maximization_control.py create mode 100644 h2integrate/control/control_strategies/system_level/system_level_control_base.py diff --git a/h2integrate/control/control_strategies/system_level/cost_minimization_control.py b/h2integrate/control/control_strategies/system_level/cost_minimization_control.py new file mode 100644 index 000000000..4855c1d77 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/cost_minimization_control.py @@ -0,0 +1,67 @@ +import numpy as np + +from h2integrate.control.control_strategies.system_level.system_level_control_base import ( + SystemLevelControlBase, +) + + +class CostMinimizationControl(SystemLevelControlBase): + """Cost-minimizing system-level controller. + + Meets demand at minimum variable cost using merit-order dispatch: + + 1. Curtailable techs run at rated capacity (zero marginal cost). + 2. Storage absorbs surplus / provides deficit. + 3. Dispatchable techs are dispatched in ascending marginal-cost order, + each up to its rated capacity, until remaining demand is met. + + Each dispatchable technology must have a ``marginal_cost`` input + ($/commodity_rate_unit·h, e.g. $/kWh) representing its variable cost + per unit of production. These are connected from cost model outputs + or set as defaults in the plant config. + """ + + def setup(self): + super().setup() + + # Add marginal cost inputs for dispatchable techs + self.dispatchable_marginal_cost_names = [] + for tech_name in self.dispatchable_techs: + mc_name = f"{tech_name}_marginal_cost" + self.add_input( + mc_name, + val=0.0, + units=f"USD/({self.commodity_units}*h)", + desc=f"Marginal cost of {self.commodity} from {tech_name}", + ) + self.dispatchable_marginal_cost_names.append(mc_name) + + def compute(self, inputs, outputs): + demand = inputs[self.demand_input_name].copy() + + # 1. Curtailable techs: full production + demand = self._subtract_curtailable(inputs, outputs, demand) + + # 2. Storage dispatch + demand = self._dispatch_storage(inputs, outputs, demand) + + # 3. Merit-order dispatch: cheapest dispatchable first + remaining = np.maximum(demand, 0.0) + + # Collect marginal costs and sort by ascending cost + marginal_costs = np.array([inputs[mc][0] for mc in self.dispatchable_marginal_cost_names]) + dispatch_order = np.argsort(marginal_costs) + + # Initialize all dispatchable set_points to zero + for set_point_name in self.dispatchable_set_point_names: + outputs[set_point_name] = np.zeros(self.n_timesteps) + + # Dispatch in merit order + for idx in dispatch_order: + set_point_name = self.dispatchable_set_point_names[idx] + rated_name = self.dispatchable_rated_names[idx] + rated = inputs[rated_name] + + dispatch = np.minimum(remaining, rated) + outputs[set_point_name] = dispatch + remaining -= dispatch diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py new file mode 100644 index 000000000..0864d879a --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -0,0 +1,35 @@ +import numpy as np + +from h2integrate.control.control_strategies.system_level.system_level_control_base import ( + SystemLevelControlBase, +) + + +class DemandFollowingControl(SystemLevelControlBase): + """Demand-following system-level controller. + + Dispatch priority: + 1. Curtailable techs run at rated capacity (zero marginal cost). + 2. Storage absorbs surplus / provides deficit (set_point = net demand). + 3. Remaining demand is split equally across dispatchable techs. + + This strategy always attempts to meet demand exactly; it does not + consider costs. + """ + + def compute(self, inputs, outputs): + demand = inputs[self.demand_input_name].copy() + + # 1. Curtailable techs: full production + demand = self._subtract_curtailable(inputs, outputs, demand) + + # 2. Storage dispatch + demand = self._dispatch_storage(inputs, outputs, demand) + + # 3. Dispatchable techs: equal share of remaining demand + remaining = np.maximum(demand, 0.0) + n_dispatchable = len(self.dispatchable_set_point_names) + if n_dispatchable > 0: + share = remaining / n_dispatchable + for set_point_name in self.dispatchable_set_point_names: + outputs[set_point_name] = share diff --git a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py new file mode 100644 index 000000000..a585c3db3 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py @@ -0,0 +1,86 @@ +import numpy as np + +from h2integrate.control.control_strategies.system_level.system_level_control_base import ( + SystemLevelControlBase, +) + + +class ProfitMaximizationControl(SystemLevelControlBase): + """Profit-maximizing system-level controller. + + Dispatches technologies only when the commodity sell price exceeds + the marginal cost of production: + + 1. Curtailable techs run at rated capacity (zero marginal cost, + always profitable to produce). + 2. Storage absorbs surplus / provides deficit. + 3. Dispatchable techs are dispatched in merit order (cheapest first), + but **only if** their marginal cost is below the sell price. + Demand may go unmet if dispatch is unprofitable. + + Configuration: + ``plant_config["system_level_control"]["commodity_sell_price"]`` + must be set ($/commodity_rate_unit·h, e.g. $/kWh). + + Each dispatchable technology must have a ``marginal_cost`` input + representing its variable cost per unit of production. + """ + + def setup(self): + super().setup() + + slc_config = self.options["plant_config"]["system_level_control"] + + # Commodity sell price — user-set in config + default_sell_price = slc_config.get("commodity_sell_price", 0.0) + self.add_input( + "commodity_sell_price", + val=default_sell_price, + units=f"USD/({self.commodity_units}*h)", + desc=f"Sell price per unit of {self.commodity}", + ) + + # Add marginal cost inputs for dispatchable techs + self.dispatchable_marginal_cost_names = [] + for tech_name in self.dispatchable_techs: + mc_name = f"{tech_name}_marginal_cost" + self.add_input( + mc_name, + val=0.0, + units=f"USD/({self.commodity_units}*h)", + desc=f"Marginal cost of {self.commodity} from {tech_name}", + ) + self.dispatchable_marginal_cost_names.append(mc_name) + + def compute(self, inputs, outputs): + demand = inputs[self.demand_input_name].copy() + sell_price = inputs["commodity_sell_price"][0] + + # 1. Curtailable techs: full production (always profitable) + demand = self._subtract_curtailable(inputs, outputs, demand) + + # 2. Storage dispatch + demand = self._dispatch_storage(inputs, outputs, demand) + + # 3. Profit-driven merit-order dispatch + remaining = np.maximum(demand, 0.0) + + marginal_costs = np.array([inputs[mc][0] for mc in self.dispatchable_marginal_cost_names]) + dispatch_order = np.argsort(marginal_costs) + + # Initialize all dispatchable set_points to zero + for set_point_name in self.dispatchable_set_point_names: + outputs[set_point_name] = np.zeros(self.n_timesteps) + + # Dispatch only if profitable + for idx in dispatch_order: + if marginal_costs[idx] >= sell_price: + break # remaining techs are even more expensive + + set_point_name = self.dispatchable_set_point_names[idx] + rated_name = self.dispatchable_rated_names[idx] + rated = inputs[rated_name] + + dispatch = np.minimum(remaining, rated) + outputs[set_point_name] = dispatch + remaining -= dispatch diff --git a/h2integrate/control/control_strategies/system_level/system_level_control.py b/h2integrate/control/control_strategies/system_level/system_level_control.py index dc758a208..eeb51065d 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control.py @@ -1,192 +1,11 @@ -import numpy as np -import openmdao.api as om +"""Backward-compatible alias for :class:`DemandFollowingControl`. +The ``SystemLevelControl`` name is kept so that existing imports +(e.g. ``from ...system_level_control import SystemLevelControl``) +continue to work. New code should import the specific controller +class directly. +""" -class SystemLevelControl(om.ExplicitComponent): - """System-level control that satisfies demand across all technologies. - - Reads pre-computed technology classification from - ``plant_config["system_level_control"]``, which must contain: - - - ``commodity``: the commodity being controlled (e.g. "electricity") - - ``commodity_units``: units string (or None) - - ``demand_tech``: name of the demand technology - - ``curtailable_techs``: list of curtailable technology names - - ``dispatchable_techs``: list of dispatchable technology names - - ``storage_techs``: list of storage technology names - - Only one commodity demand stream is supported. At each timestep, - curtailable production is applied first, then the remaining demand - is distributed equally across dispatchable technologies. - """ - - def initialize(self): - self.options.declare("driver_config", types=dict) - self.options.declare("plant_config", types=dict) - self.options.declare("tech_config", types=dict) - - def setup(self): - plant_config = self.options["plant_config"] - slc_config = plant_config["system_level_control"] - - self.n_timesteps = plant_config["plant"]["simulation"]["n_timesteps"] - - # Read pre-computed classification from plant_config - self.commodity = slc_config["commodity"] - self.commodity_units = slc_config.get("commodity_units", None) - self.demand_tech = slc_config["demand_tech"] - self.curtailable_techs = list(slc_config.get("curtailable_techs", [])) - self.dispatchable_techs = list(slc_config.get("dispatchable_techs", [])) - self.storage_techs = list(slc_config.get("storage_techs", [])) - - # Input: demand profile (default value from config) - demand_profile = slc_config.get("demand_profile", 0.0) - self.demand_input_name = f"{self.commodity}_demand" - self.add_input( - self.demand_input_name, - val=demand_profile, - shape=self.n_timesteps, - units=self.commodity_units, - desc=f"Demand profile of {self.commodity}", - ) - - # ---- Add OpenMDAO inputs / outputs per tech category ---- - # Curtailable techs: read output + rated production, write set_point - self.curtailable_input_names = [] - self.curtailable_set_point_names = [] - self.curtailable_rated_names = [] - for tech_name in self.curtailable_techs: - in_name = f"{tech_name}_{self.commodity}_out" - set_point_name = f"{tech_name}_{self.commodity}_set_point" - rated_name = f"{tech_name}_rated_{self.commodity}_production" - self.add_input( - in_name, - val=0.0, - shape=self.n_timesteps, - units=self.commodity_units, - desc=f"{self.commodity} output from {tech_name}", - ) - self.add_input( - rated_name, - val=0.0, - units=self.commodity_units, - desc=f"Rated {self.commodity} production for {tech_name}", - ) - self.add_output( - set_point_name, - val=0.0, - shape=self.n_timesteps, - units=self.commodity_units, - desc=f"Set point for {tech_name} {self.commodity} curtailment", - ) - self.curtailable_input_names.append(in_name) - self.curtailable_set_point_names.append(set_point_name) - self.curtailable_rated_names.append(rated_name) - - # Compute a reasonable initial set_point for dispatchable techs - n_dispatchable = len(self.dispatchable_techs) - if n_dispatchable > 0: - if np.isscalar(demand_profile): - initial_set_point = demand_profile / n_dispatchable - else: - initial_set_point = np.array(demand_profile) / n_dispatchable - else: - initial_set_point = 0.0 - - self.dispatchable_input_names = [] - self.dispatchable_set_point_names = [] - self.dispatchable_rated_names = [] - for tech_name in self.dispatchable_techs: - in_name = f"{tech_name}_{self.commodity}_out" - set_point_name = f"{tech_name}_{self.commodity}_set_point" - rated_name = f"{tech_name}_rated_{self.commodity}_production" - self.add_input( - in_name, - val=0.0, - shape=self.n_timesteps, - units=self.commodity_units, - desc=f"{self.commodity} output from {tech_name}", - ) - self.add_input( - rated_name, - val=0.0, - units=self.commodity_units, - desc=f"Rated {self.commodity} production for {tech_name}", - ) - self.add_output( - set_point_name, - val=initial_set_point, - shape=self.n_timesteps, - units=self.commodity_units, - desc=f"Set point for {tech_name} {self.commodity} production", - ) - self.dispatchable_input_names.append(in_name) - self.dispatchable_set_point_names.append(set_point_name) - self.dispatchable_rated_names.append(rated_name) - - self.storage_input_names = [] - self.storage_set_point_names = [] - self.storage_rated_names = [] - for tech_name in self.storage_techs: - in_name = f"{tech_name}_{self.commodity}_out" - set_point_name = f"{tech_name}_{self.commodity}_set_point" - rated_name = f"{tech_name}_rated_{self.commodity}_production" - self.add_input( - in_name, - val=0.0, - shape=self.n_timesteps, - units=self.commodity_units, - desc=f"{self.commodity} output from {tech_name}", - ) - self.add_input( - rated_name, - val=0.0, - units=self.commodity_units, - desc=f"Rated {self.commodity} production for {tech_name}", - ) - self.add_output( - set_point_name, - val=0.0, - shape=self.n_timesteps, - units=self.commodity_units, - desc=f"Set point for {tech_name} {self.commodity} production", - ) - self.storage_input_names.append(in_name) - self.storage_set_point_names.append(set_point_name) - self.storage_rated_names.append(rated_name) - - def compute(self, inputs, outputs): - demand = inputs[self.demand_input_name].copy() - - # 1. Curtailable techs: set_point = rated production (no curtailment) - for in_name, set_point_name, rated_name in zip( - self.curtailable_input_names, - self.curtailable_set_point_names, - self.curtailable_rated_names, - ): - curtailable_output = inputs[in_name] - outputs[set_point_name] = inputs[rated_name] * np.ones(self.n_timesteps) - demand -= curtailable_output - - # 2. Storage dispatch: set_point = net demand per storage tech - # positive set_point → discharge, negative → charge - # The storage model's simulate() handles rate/SOC/availability clipping - # internally, so we pass the raw demand signal here. - n_storage = len(self.storage_set_point_names) - if n_storage > 0: - storage_share = demand / n_storage - for set_point_name in self.storage_set_point_names: - outputs[set_point_name] = storage_share - - # Subtract actual storage output from demand - # (electricity_out > 0 when discharging, < 0 when charging) - for in_name in self.storage_input_names: - demand -= inputs[in_name] - - # 3. Remaining demand after curtailable + storage → dispatchable techs - remaining = np.maximum(demand, 0.0) - n_dispatchable = len(self.dispatchable_set_point_names) - if n_dispatchable > 0: - share = remaining / n_dispatchable - for set_point_name in self.dispatchable_set_point_names: - outputs[set_point_name] = share +from h2integrate.control.control_strategies.system_level.demand_following_control import ( # noqa: F401 + DemandFollowingControl as SystemLevelControl, +) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py new file mode 100644 index 000000000..61afd1f97 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -0,0 +1,195 @@ +import numpy as np +import openmdao.api as om + + +class SystemLevelControlBase(om.ExplicitComponent): + """Base class for system-level controllers. + + Provides common setup logic shared by all system-level control strategies: + demand input, curtailable/dispatchable/storage technology I/O creation, + and technology classification reading from ``plant_config``. + + Subclasses must implement ``compute()`` with their dispatch strategy. + + Configuration is read from ``plant_config["system_level_control"]``, + which must contain: + + - ``commodity``: the commodity being controlled (e.g. "electricity") + - ``commodity_units``: units string (or None) + - ``demand_tech``: name of the demand technology + - ``curtailable_techs``: list of curtailable technology names + - ``dispatchable_techs``: list of dispatchable technology names + - ``storage_techs``: list of storage technology names + """ + + def initialize(self): + self.options.declare("driver_config", types=dict) + self.options.declare("plant_config", types=dict) + self.options.declare("tech_config", types=dict) + + def setup(self): + plant_config = self.options["plant_config"] + slc_config = plant_config["system_level_control"] + + self.n_timesteps = plant_config["plant"]["simulation"]["n_timesteps"] + + # Read pre-computed classification from plant_config + self.commodity = slc_config["commodity"] + self.commodity_units = slc_config.get("commodity_units", None) + self.demand_tech = slc_config["demand_tech"] + self.curtailable_techs = list(slc_config.get("curtailable_techs", [])) + self.dispatchable_techs = list(slc_config.get("dispatchable_techs", [])) + self.storage_techs = list(slc_config.get("storage_techs", [])) + + # Input: demand profile (default value from config) + demand_profile = slc_config.get("demand_profile", 0.0) + self.demand_input_name = f"{self.commodity}_demand" + self.add_input( + self.demand_input_name, + val=demand_profile, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"Demand profile of {self.commodity}", + ) + + self._setup_curtailable_techs() + self._setup_dispatchable_techs(demand_profile) + self._setup_storage_techs() + + def _setup_curtailable_techs(self): + """Create I/O for curtailable technologies.""" + self.curtailable_input_names = [] + self.curtailable_set_point_names = [] + self.curtailable_rated_names = [] + for tech_name in self.curtailable_techs: + in_name = f"{tech_name}_{self.commodity}_out" + set_point_name = f"{tech_name}_{self.commodity}_set_point" + rated_name = f"{tech_name}_rated_{self.commodity}_production" + self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"{self.commodity} output from {tech_name}", + ) + self.add_input( + rated_name, + val=0.0, + units=self.commodity_units, + desc=f"Rated {self.commodity} production for {tech_name}", + ) + self.add_output( + set_point_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"Set point for {tech_name} {self.commodity} curtailment", + ) + self.curtailable_input_names.append(in_name) + self.curtailable_set_point_names.append(set_point_name) + self.curtailable_rated_names.append(rated_name) + + def _setup_dispatchable_techs(self, demand_profile): + """Create I/O for dispatchable technologies.""" + n_dispatchable = len(self.dispatchable_techs) + if n_dispatchable > 0: + if np.isscalar(demand_profile): + initial_set_point = demand_profile / n_dispatchable + else: + initial_set_point = np.array(demand_profile) / n_dispatchable + else: + initial_set_point = 0.0 + + self.dispatchable_input_names = [] + self.dispatchable_set_point_names = [] + self.dispatchable_rated_names = [] + for tech_name in self.dispatchable_techs: + in_name = f"{tech_name}_{self.commodity}_out" + set_point_name = f"{tech_name}_{self.commodity}_set_point" + rated_name = f"{tech_name}_rated_{self.commodity}_production" + self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"{self.commodity} output from {tech_name}", + ) + self.add_input( + rated_name, + val=0.0, + units=self.commodity_units, + desc=f"Rated {self.commodity} production for {tech_name}", + ) + self.add_output( + set_point_name, + val=initial_set_point, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"Set point for {tech_name} {self.commodity} production", + ) + self.dispatchable_input_names.append(in_name) + self.dispatchable_set_point_names.append(set_point_name) + self.dispatchable_rated_names.append(rated_name) + + def _setup_storage_techs(self): + """Create I/O for storage technologies.""" + self.storage_input_names = [] + self.storage_set_point_names = [] + self.storage_rated_names = [] + for tech_name in self.storage_techs: + in_name = f"{tech_name}_{self.commodity}_out" + set_point_name = f"{tech_name}_{self.commodity}_set_point" + rated_name = f"{tech_name}_rated_{self.commodity}_production" + self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"{self.commodity} output from {tech_name}", + ) + self.add_input( + rated_name, + val=0.0, + units=self.commodity_units, + desc=f"Rated {self.commodity} production for {tech_name}", + ) + self.add_output( + set_point_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodity_units, + desc=f"Set point for {tech_name} {self.commodity} production", + ) + self.storage_input_names.append(in_name) + self.storage_set_point_names.append(set_point_name) + self.storage_rated_names.append(rated_name) + + def _subtract_curtailable(self, inputs, outputs, demand): + """Apply curtailable techs: set_point = rated, subtract output from demand. + + Returns the updated demand array. + """ + for in_name, set_point_name, rated_name in zip( + self.curtailable_input_names, + self.curtailable_set_point_names, + self.curtailable_rated_names, + ): + outputs[set_point_name] = inputs[rated_name] * np.ones(self.n_timesteps) + demand -= inputs[in_name] + return demand + + def _dispatch_storage(self, inputs, outputs, demand): + """Dispatch storage techs proportionally and subtract actual output from demand. + + Positive set_point = discharge, negative = charge. + Returns the updated demand array. + """ + n_storage = len(self.storage_set_point_names) + if n_storage > 0: + storage_share = demand / n_storage + for set_point_name in self.storage_set_point_names: + outputs[set_point_name] = storage_share + + for in_name in self.storage_input_names: + demand -= inputs[in_name] + return demand diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 2bdbd7a21..028209ad5 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -23,12 +23,18 @@ multivariable_streams, is_electricity_producer, ) -from h2integrate.control.control_strategies.system_level.system_level_control import ( - SystemLevelControl, -) from h2integrate.control.control_strategies.pyomo_storage_controller_baseclass import ( PyomoStorageControllerBaseClass, ) +from h2integrate.control.control_strategies.system_level.demand_following_control import ( + DemandFollowingControl, +) +from h2integrate.control.control_strategies.system_level.cost_minimization_control import ( + CostMinimizationControl, +) +from h2integrate.control.control_strategies.system_level.profit_maximization_control import ( + ProfitMaximizationControl, +) try: @@ -529,13 +535,22 @@ def add_system_level_controller(self): """Add the SystemLevelControl component and configure the plant solver. This method: - 1. Adds a ``SystemLevelControl`` subsystem to the plant group - 2. Configures the nonlinear solver on the plant group based on - ``plant_config["system_level_control"]`` parameters - 3. Creates connections between the controller and each technology + 1. Selects the appropriate controller class based on ``control_strategy`` + 2. Adds it as a subsystem to the plant group + 3. Configures the nonlinear solver on the plant group + 4. Creates connections between the controller and each technology + 5. For cost/profit strategies, connects marginal cost inputs """ slc_config = self.plant_config["system_level_control"] + # Map control_strategy config values to controller classes + strategy_map = { + "demand_following": DemandFollowingControl, + "load_meeting": DemandFollowingControl, # alias + "cost_minimization": CostMinimizationControl, + "profit_maximization": ProfitMaximizationControl, + } + # Map user-facing solver names to OpenMDAO solver classes solver_map = { "gauss_seidel": om.NonlinearBlockGS, @@ -543,8 +558,16 @@ def add_system_level_controller(self): "block_jacobi": om.NonlinearBlockJac, } - # 1. Add the controller as the first subsystem in the plant group - slc_comp = SystemLevelControl( + # 1. Select controller class based on strategy + strategy_name = slc_config.get("control_strategy", "demand_following") + slc_cls = strategy_map.get(strategy_name) + if slc_cls is None: + raise ValueError( + f"Unknown control_strategy '{strategy_name}' in system_level_control. " + f"Supported: {list(strategy_map.keys())}" + ) + + slc_comp = slc_cls( driver_config=self.driver_config, plant_config=self.plant_config, tech_config=self.technology_config, @@ -605,6 +628,23 @@ def add_system_level_controller(self): f"system_level_controller.{tech_name}_rated_{commodity}_production", ) + # 4. For cost-aware strategies, connect marginal costs + # Marginal cost defaults are set from tech_config; they can also be + # connected from cost model outputs if available. + if strategy_name in ("cost_minimization", "profit_maximization"): + technologies = self.technology_config.get("technologies", {}) + for tech_name in slc_config["dispatchable_techs"]: + tech_def = technologies.get(tech_name, {}) + model_inputs = tech_def.get("model_inputs", {}) + shared = model_inputs.get("shared_parameters", {}) + cost_params = model_inputs.get("cost_parameters", {}) + all_params = {**shared, **cost_params} + + mc = all_params.get("marginal_cost", None) + if mc is not None: + mc_input = f"system_level_controller.{tech_name}_marginal_cost" + self.prob.set_val(mc_input, mc) + ### Commented out for now; we'll need to determine how to treat demand ### components in the new SLC paradigm. # # Connect demand profile to the controller From 3bcbc2f598366ca3697d63931b7eca21e646d444 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 17:52:23 -0600 Subject: [PATCH 026/105] WIP: working on marginal costs for SLC --- .../profit_maximization_control.py | 15 ++++++------- h2integrate/core/h2integrate_model.py | 21 ++++++------------- h2integrate/core/model_baseclasses.py | 13 ++++++++++++ 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py index a585c3db3..a5561c7d0 100644 --- a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py +++ b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py @@ -20,7 +20,7 @@ class ProfitMaximizationControl(SystemLevelControlBase): Configuration: ``plant_config["system_level_control"]["commodity_sell_price"]`` - must be set ($/commodity_rate_unit·h, e.g. $/kWh). + must be set ($/(commodity_rate_unit*h), e.g. $/kWh). Each dispatchable technology must have a ``marginal_cost`` input representing its variable cost per unit of production. @@ -31,11 +31,12 @@ def setup(self): slc_config = self.options["plant_config"]["system_level_control"] - # Commodity sell price — user-set in config + # Commodity sell price — user-set in config, can be scalar or time-varying default_sell_price = slc_config.get("commodity_sell_price", 0.0) self.add_input( "commodity_sell_price", val=default_sell_price, + shape=self.n_timesteps, units=f"USD/({self.commodity_units}*h)", desc=f"Sell price per unit of {self.commodity}", ) @@ -54,7 +55,7 @@ def setup(self): def compute(self, inputs, outputs): demand = inputs[self.demand_input_name].copy() - sell_price = inputs["commodity_sell_price"][0] + sell_price = inputs["commodity_sell_price"] # shape (n_timesteps,) # 1. Curtailable techs: full production (always profitable) demand = self._subtract_curtailable(inputs, outputs, demand) @@ -72,15 +73,15 @@ def compute(self, inputs, outputs): for set_point_name in self.dispatchable_set_point_names: outputs[set_point_name] = np.zeros(self.n_timesteps) - # Dispatch only if profitable + # Dispatch only where profitable (element-wise comparison) for idx in dispatch_order: - if marginal_costs[idx] >= sell_price: - break # remaining techs are even more expensive + mc = marginal_costs[idx] + profitable = mc < sell_price # boolean mask per timestep set_point_name = self.dispatchable_set_point_names[idx] rated_name = self.dispatchable_rated_names[idx] rated = inputs[rated_name] - dispatch = np.minimum(remaining, rated) + dispatch = np.where(profitable, np.minimum(remaining, rated), 0.0) outputs[set_point_name] = dispatch remaining -= dispatch diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 028209ad5..2885dff63 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -628,22 +628,13 @@ def add_system_level_controller(self): f"system_level_controller.{tech_name}_rated_{commodity}_production", ) - # 4. For cost-aware strategies, connect marginal costs - # Marginal cost defaults are set from tech_config; they can also be - # connected from cost model outputs if available. + # 4. For cost-aware strategies, connect marginal costs from cost models if strategy_name in ("cost_minimization", "profit_maximization"): - technologies = self.technology_config.get("technologies", {}) for tech_name in slc_config["dispatchable_techs"]: - tech_def = technologies.get(tech_name, {}) - model_inputs = tech_def.get("model_inputs", {}) - shared = model_inputs.get("shared_parameters", {}) - cost_params = model_inputs.get("cost_parameters", {}) - all_params = {**shared, **cost_params} - - mc = all_params.get("marginal_cost", None) - if mc is not None: - mc_input = f"system_level_controller.{tech_name}_marginal_cost" - self.prob.set_val(mc_input, mc) + self.plant.connect( + f"{tech_name}.marginal_cost", + f"system_level_controller.{tech_name}_marginal_cost", + ) ### Commented out for now; we'll need to determine how to treat demand ### components in the new SLC paradigm. @@ -1625,7 +1616,7 @@ def run(self): # do model setup based on the driver config # might add a recorder, driver, set solver tolerances, etc if self.state < State.SETUP: - self.prob.setup() + self.setup() if self.state < State.RUN: # OpenMDAO will skip this step if it encounters an issue leading to silent failures diff --git a/h2integrate/core/model_baseclasses.py b/h2integrate/core/model_baseclasses.py index 45d8b98da..f4ad59d4c 100644 --- a/h2integrate/core/model_baseclasses.py +++ b/h2integrate/core/model_baseclasses.py @@ -152,6 +152,7 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): @define(kw_only=True) class CostModelBaseConfig(BaseConfig): cost_year: int = field(converter=int) + marginal_cost: float = field(default=0.0) class CostModelBaseClass(om.ExplicitComponent): @@ -164,6 +165,7 @@ class CostModelBaseClass(om.ExplicitComponent): - CapEx (float): capital expenditure costs in $ - OpEx (float): annual fixed operating expenditure costs in $/year - VarOpEx (float): annual variable operating expenditure costs in $/year + - marginal_cost (float): marginal cost of production for dispatch decisions Discrete Outputs: - cost_year (int): dollar-year corresponding to CapEx and OpEx values. @@ -192,6 +194,17 @@ def setup(self): "cost_year", val=self.config.cost_year, desc="Dollar year for costs" ) + # Marginal cost output for dispatch decisions + model_inputs = self.options["tech_config"].get("model_inputs", {}) + shared = model_inputs.get("shared_parameters", {}) + commodity_rate_units = shared.get("commodity_rate_units", "kW") + self.add_output( + "marginal_cost", + val=self.config.marginal_cost, + units=f"USD/({commodity_rate_units}*h)", + desc="Marginal cost of production for dispatch decisions", + ) + # dt is seconds per timestep self.dt = int(self.options["plant_config"]["plant"]["simulation"]["dt"]) From 6dff23693757866e32feacbb3c56e4f380fe21f9 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 17:57:41 -0600 Subject: [PATCH 027/105] Updating SLC example to be simpler --- .../profit_maximization/driver_config.yaml | 4 + .../profit_maximization/plant_config.yaml | 30 +++++ .../profit_maximization/run_profit_max.py | 105 ++++++++++++++++++ .../profit_maximization/tech_config.yaml | 104 +++++++++++++++++ .../profit_maximization/wind_ng_demand.yaml | 4 + 5 files changed, 247 insertions(+) create mode 100644 examples/35_system_level_control/profit_maximization/driver_config.yaml create mode 100644 examples/35_system_level_control/profit_maximization/plant_config.yaml create mode 100644 examples/35_system_level_control/profit_maximization/run_profit_max.py create mode 100644 examples/35_system_level_control/profit_maximization/tech_config.yaml create mode 100644 examples/35_system_level_control/profit_maximization/wind_ng_demand.yaml diff --git a/examples/35_system_level_control/profit_maximization/driver_config.yaml b/examples/35_system_level_control/profit_maximization/driver_config.yaml new file mode 100644 index 000000000..d7190b031 --- /dev/null +++ b/examples/35_system_level_control/profit_maximization/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: Profit-maximization dispatch with diurnal pricing +general: + folder_output: outputs diff --git a/examples/35_system_level_control/profit_maximization/plant_config.yaml b/examples/35_system_level_control/profit_maximization/plant_config.yaml new file mode 100644 index 000000000..6ab2093d7 --- /dev/null +++ b/examples/35_system_level_control/profit_maximization/plant_config.yaml @@ -0,0 +1,30 @@ +name: plant_config +description: Wind + NG plant with profit-maximization control and diurnal pricing. +sites: + site: + latitude: 30.6617 + longitude: -101.7096 + resources: + wind_resource: + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2013 +technology_interconnections: + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] + - [wind, battery, electricity, cable] + - [wind, fin_combiner, electricity, cable] + - [battery, fin_combiner, electricity, cable] + - [natural_gas_plant, fin_combiner, electricity, cable] + - [fin_combiner, electrical_load_demand, electricity, cable] +resource_to_tech_connections: + - [site.wind_resource, wind, wind_resource_data] +plant: + plant_life: 30 + simulation: + n_timesteps: 8760 + dt: 3600 +system_level_control: + control_strategy: profit_maximization + commodity_sell_price: 0.06 # $/kWh default; overridden in run script + solver_name: gauss_seidel + max_iter: 20 diff --git a/examples/35_system_level_control/profit_maximization/run_profit_max.py b/examples/35_system_level_control/profit_maximization/run_profit_max.py new file mode 100644 index 000000000..77e404933 --- /dev/null +++ b/examples/35_system_level_control/profit_maximization/run_profit_max.py @@ -0,0 +1,105 @@ +""" +Profit-maximization example with diurnal electricity sell prices. + +The NG plant has a fixed marginal cost of $0.05/kWh. The electricity sell +price follows a diurnal pattern that swings above and below this cost: + - Night (22:00-16:00): $0.03/kWh, NG is unprofitable, not dispatched + - Peak (16:00-22:00): $0.08/kWh, NG is profitable, dispatched + +The controller dispatches the NG plant only during hours when the sell price +exceeds the marginal cost, demonstrating profit-driven curtailment of +dispatchable generation. +""" + +import numpy as np +import matplotlib.pyplot as plt + +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +# -- Build diurnal sell-price profile ($/kWh) -- +n_timesteps = 8760 +sell_price = np.zeros(n_timesteps) +for h in range(n_timesteps): + hour_of_day = h % 24 + if 16 <= hour_of_day < 22: + sell_price[h] = 0.08 # peak + else: + sell_price[h] = 0.03 # night (cheap) + +# -- Create and run model -- +h2i = H2IntegrateModel("wind_ng_demand.yaml") + +# Setup first so we can set values +h2i.setup() + +# Override the sell price with our diurnal profile +h2i.prob.set_val( + "plant.system_level_controller.commodity_sell_price", + sell_price, + units="USD/(kW*h)", +) + +h2i.run() +h2i.post_process() + +# -- Extract results -- +n_hours = 168 # first week +hours = np.arange(n_hours) + +wind_out = h2i.prob.get_val("plant.wind.electricity_out")[:n_hours] +ng_out = h2i.prob.get_val("plant.natural_gas_plant.electricity_out", units="kW")[:n_hours] +batt_discharge = h2i.prob.get_val("plant.battery.storage_electricity_discharge")[:n_hours] +batt_soc = h2i.prob.get_val("plant.battery.SOC")[:n_hours] +demand = h2i.prob.get_val("plant.electrical_load_demand.electricity_demand")[:n_hours] +curtailed = h2i.prob.get_val("plant.electrical_load_demand.unused_electricity_out")[:n_hours] +price = sell_price[:n_hours] + +# -- Plot -- +fig, axes = plt.subplots(4, 1, figsize=(14, 12), sharex=True) + +# Panel 1: stacked supply vs demand +axes[0].fill_between(hours, 0, wind_out, alpha=0.7, color="tab:blue", label="Wind") +axes[0].fill_between( + hours, + wind_out, + wind_out + batt_discharge, + alpha=0.7, + color="tab:purple", + label="Battery Discharge", +) +axes[0].fill_between( + hours, + wind_out + batt_discharge, + wind_out + batt_discharge + ng_out, + alpha=0.7, + color="tab:orange", + label="Natural Gas", +) +axes[0].plot(hours, demand, "k--", linewidth=1.5, label="Demand") +axes[0].set_ylabel("Power (kW)") +axes[0].set_title("Profit Maximization: First 168 Hours") +axes[0].legend(loc="upper right") + +# Panel 2: battery SOC +axes[1].plot(hours, batt_soc, color="tab:green") +axes[1].set_ylabel("SOC (kWh)") +axes[1].set_title("Battery State of Charge") + +# Panel 3: sell price vs NG marginal cost +axes[2].plot(hours, price * 100, color="tab:red", label="Sell Price") +axes[2].axhline(y=5.0, color="tab:orange", linestyle="--", label="NG Marginal Cost (5 ¢/kWh)") +axes[2].set_ylabel("Price (¢/kWh)") +axes[2].set_title("Electricity Sell Price vs NG Marginal Cost") +axes[2].legend(loc="upper right") + +# Panel 4: curtailed energy +axes[3].plot(hours, curtailed, color="tab:gray") +axes[3].set_ylabel("Curtailed (kW)") +axes[3].set_xlabel("Hour") +axes[3].set_title("Curtailed Electricity") + +plt.tight_layout() +plt.savefig("profit_max_results.png", dpi=150) +print("Plot saved to profit_max_results.png") +# plt.show() diff --git a/examples/35_system_level_control/profit_maximization/tech_config.yaml b/examples/35_system_level_control/profit_maximization/tech_config.yaml new file mode 100644 index 000000000..c9097a505 --- /dev/null +++ b/examples/35_system_level_control/profit_maximization/tech_config.yaml @@ -0,0 +1,104 @@ +name: technology_config +description: > + Wind farm, battery, NG plant with marginal cost for profit-maximization dispatch, + and a fixed electrical demand. +technologies: + wind: + performance_model: + model: PYSAMWindPlantPerformanceModel + cost_model: + model: ATBWindPlantCostModel + model_inputs: + performance_parameters: + num_turbines: 20 + turbine_rating_kw: 6000 + hub_height: 115 + rotor_diameter: 170 + create_model_from: default + config_name: WindPowerSingleOwner + pysam_options: + Farm: + wind_farm_wake_model: 0 + Losses: + ops_strategies_loss: 10.0 + layout: + layout_mode: basicgrid + layout_options: + row_D_spacing: 5.0 + turbine_D_spacing: 5.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: square + cost_parameters: + capex_per_kW: 1300 + opex_per_kW_per_year: 39 + cost_year: 2022 + ng_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: natural_gas + commodity_rate_units: MMBtu/h + performance_parameters: + rated_capacity: 750. + cost_parameters: + cost_year: 2023 + price: 4.2 + annual_cost: 0. + start_up_cost: 0. + natural_gas_plant: + performance_model: + model: NaturalGasPerformanceModel + cost_model: + model: NaturalGasCostModel + model_inputs: + shared_parameters: + heat_rate_mmbtu_per_mwh: 7.5 + system_capacity_mw: 100. + cost_parameters: + capex_per_kw: 1000 + fixed_opex_per_kw_per_year: 10.0 + variable_opex_per_mwh: 0.0 + cost_year: 2023 + marginal_cost: 0.05 # $/kWh — NG dispatch cost + electrical_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 30000 + battery: + performance_model: + model: StoragePerformanceModel + cost_model: + model: GenericStorageCostModel + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + max_charge_rate: 20000 + max_capacity: 80000 + init_soc_fraction: 0.5 + max_soc_fraction: 1.0 + min_soc_fraction: 0.1 + performance_parameters: + round_trip_efficiency: 0.90 + demand_profile: 20000 + cost_parameters: + cost_year: 2022 + capacity_capex: 310 + charge_capex: 311 + opex_fraction: 0.025 + fin_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 3 diff --git a/examples/35_system_level_control/profit_maximization/wind_ng_demand.yaml b/examples/35_system_level_control/profit_maximization/wind_ng_demand.yaml new file mode 100644 index 000000000..e09f3dcda --- /dev/null +++ b/examples/35_system_level_control/profit_maximization/wind_ng_demand.yaml @@ -0,0 +1,4 @@ +name: H2Integrate_config +driver_config: driver_config.yaml +plant_config: plant_config.yaml +technology_config: tech_config.yaml From c2123f86968f91ee3eec1e2003e00a428a397f14 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 18:01:25 -0600 Subject: [PATCH 028/105] Removing prior SLC file --- .../no_battery/plant_config.yaml | 2 +- .../yes_battery/plant_config.yaml | 2 +- .../system_level/system_level_control.py | 11 ----------- h2integrate/core/h2integrate_model.py | 7 +++---- 4 files changed, 5 insertions(+), 17 deletions(-) delete mode 100644 h2integrate/control/control_strategies/system_level/system_level_control.py diff --git a/examples/35_system_level_control/no_battery/plant_config.yaml b/examples/35_system_level_control/no_battery/plant_config.yaml index 217d57a9b..92b0daa5c 100644 --- a/examples/35_system_level_control/no_battery/plant_config.yaml +++ b/examples/35_system_level_control/no_battery/plant_config.yaml @@ -31,7 +31,7 @@ plant: n_timesteps: 8760 dt: 3600 system_level_control: - control_strategy: load_meeting + control_strategy: demand_following solver_name: gauss_seidel max_iter: 20 convergence_tolerance: 1.0e-6 diff --git a/examples/35_system_level_control/yes_battery/plant_config.yaml b/examples/35_system_level_control/yes_battery/plant_config.yaml index 72331d352..a01166342 100644 --- a/examples/35_system_level_control/yes_battery/plant_config.yaml +++ b/examples/35_system_level_control/yes_battery/plant_config.yaml @@ -35,7 +35,7 @@ plant: n_timesteps: 8760 dt: 3600 system_level_control: - control_strategy: load_meeting + control_strategy: demand_following solver_name: gauss_seidel max_iter: 20 convergence_tolerance: 1.0e-6 diff --git a/h2integrate/control/control_strategies/system_level/system_level_control.py b/h2integrate/control/control_strategies/system_level/system_level_control.py deleted file mode 100644 index eeb51065d..000000000 --- a/h2integrate/control/control_strategies/system_level/system_level_control.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Backward-compatible alias for :class:`DemandFollowingControl`. - -The ``SystemLevelControl`` name is kept so that existing imports -(e.g. ``from ...system_level_control import SystemLevelControl``) -continue to work. New code should import the specific controller -class directly. -""" - -from h2integrate.control.control_strategies.system_level.demand_following_control import ( # noqa: F401 - DemandFollowingControl as SystemLevelControl, -) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 2885dff63..ffbe72710 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -480,7 +480,7 @@ def _classify_slc_technologies(self): Also identifies the single demand technology and its commodity. Results are written into ``self.plant_config["system_level_control"]`` so - they are available to the ``SystemLevelControl`` component at setup time. + they are available to the ``DemandFollowingControl`` component at setup time. """ slc_config = self.plant_config["system_level_control"] technologies = self.technology_config.get("technologies", {}) @@ -501,7 +501,7 @@ def _classify_slc_technologies(self): if commodity is not None: raise ValueError( - "SystemLevelControl currently supports only one demand " + "DemandFollowingControl currently supports only one demand " f"stream, but found demands for both '{commodity}' " f"and '{all_params.get('commodity', tech_name)}'." ) @@ -532,7 +532,7 @@ def _classify_slc_technologies(self): slc_config["storage_techs"] = storage_techs def add_system_level_controller(self): - """Add the SystemLevelControl component and configure the plant solver. + """Add the DemandFollowingControl component and configure the plant solver. This method: 1. Selects the appropriate controller class based on ``control_strategy`` @@ -546,7 +546,6 @@ def add_system_level_controller(self): # Map control_strategy config values to controller classes strategy_map = { "demand_following": DemandFollowingControl, - "load_meeting": DemandFollowingControl, # alias "cost_minimization": CostMinimizationControl, "profit_maximization": ProfitMaximizationControl, } From 9df4fd73fda3b6dae0c055ef3a48aaa9c2600d23 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 21:42:42 -0600 Subject: [PATCH 029/105] Consolidating methods for SLC --- .../system_level/cost_minimization_control.py | 4 +- .../system_level/system_level_control_base.py | 137 +++++++----------- 2 files changed, 55 insertions(+), 86 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/cost_minimization_control.py b/h2integrate/control/control_strategies/system_level/cost_minimization_control.py index 4855c1d77..3ba2d5366 100644 --- a/h2integrate/control/control_strategies/system_level/cost_minimization_control.py +++ b/h2integrate/control/control_strategies/system_level/cost_minimization_control.py @@ -10,13 +10,13 @@ class CostMinimizationControl(SystemLevelControlBase): Meets demand at minimum variable cost using merit-order dispatch: - 1. Curtailable techs run at rated capacity (zero marginal cost). + 1. Curtailable techs run at rated capacity (assuming zero marginal cost). 2. Storage absorbs surplus / provides deficit. 3. Dispatchable techs are dispatched in ascending marginal-cost order, each up to its rated capacity, until remaining demand is met. Each dispatchable technology must have a ``marginal_cost`` input - ($/commodity_rate_unit·h, e.g. $/kWh) representing its variable cost + ($/commodity_rate_unit*h, e.g. $/kWh) representing its variable cost per unit of production. These are connected from cost model outputs or set as defaults in the plant config. """ diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 61afd1f97..dffab3dfd 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -52,61 +52,58 @@ def setup(self): desc=f"Demand profile of {self.commodity}", ) - self._setup_curtailable_techs() - self._setup_dispatchable_techs(demand_profile) - self._setup_storage_techs() - - def _setup_curtailable_techs(self): - """Create I/O for curtailable technologies.""" - self.curtailable_input_names = [] - self.curtailable_set_point_names = [] - self.curtailable_rated_names = [] - for tech_name in self.curtailable_techs: - in_name = f"{tech_name}_{self.commodity}_out" - set_point_name = f"{tech_name}_{self.commodity}_set_point" - rated_name = f"{tech_name}_rated_{self.commodity}_production" - self.add_input( - in_name, - val=0.0, - shape=self.n_timesteps, - units=self.commodity_units, - desc=f"{self.commodity} output from {tech_name}", - ) - self.add_input( - rated_name, - val=0.0, - units=self.commodity_units, - desc=f"Rated {self.commodity} production for {tech_name}", - ) - self.add_output( - set_point_name, - val=0.0, - shape=self.n_timesteps, - units=self.commodity_units, - desc=f"Set point for {tech_name} {self.commodity} curtailment", - ) - self.curtailable_input_names.append(in_name) - self.curtailable_set_point_names.append(set_point_name) - self.curtailable_rated_names.append(rated_name) - - def _setup_dispatchable_techs(self, demand_profile): - """Create I/O for dispatchable technologies.""" - n_dispatchable = len(self.dispatchable_techs) - if n_dispatchable > 0: + self._setup_tech_category("curtailable", self.curtailable_techs) + self._setup_tech_category("dispatchable", self.dispatchable_techs, demand_profile) + self._setup_tech_category("storage", self.storage_techs) + + def _setup_tech_category(self, category, tech_names, demand_profile=None): + """Create OpenMDAO I/O variables for a category of technologies. + + For each technology in the category, this method creates three variables: + + - ``{tech}_{commodity}_out``: an input for the technology's actual commodity + output at each timestep (shape ``n_timesteps``). + - ``{tech}_rated_{commodity}_production``: an input for the technology's + rated (maximum) production capacity (scalar). + - ``{tech}_{commodity}_set_point``: an output for the dispatch set-point + sent back to the technology at each timestep (shape ``n_timesteps``). + + The variable names are collected into three lists stored as instance + attributes named ``{category}_input_names``, + ``{category}_set_point_names``, and ``{category}_rated_names``. + + Args: + category: Technology classification label (e.g. ``"curtailable"``, + ``"dispatchable"``, ``"storage"``). Used to name the instance + attribute lists and the set-point description. + tech_names: Iterable of technology name strings belonging to this + category. + demand_profile: Optional demand profile used to initialize the + set-point output for dispatchable technologies. When provided, + the initial set-point is ``demand_profile / n_techs`` so that + the solver starts from a reasonable guess. Ignored (or + ``None``) for curtailable and storage categories, whose + set-points default to zero. + """ + # Compute initial set-point value + n_techs = len(tech_names) + if demand_profile is not None and n_techs > 0: if np.isscalar(demand_profile): - initial_set_point = demand_profile / n_dispatchable + initial_set_point = demand_profile / n_techs else: - initial_set_point = np.array(demand_profile) / n_dispatchable + initial_set_point = np.array(demand_profile) / n_techs else: initial_set_point = 0.0 - self.dispatchable_input_names = [] - self.dispatchable_set_point_names = [] - self.dispatchable_rated_names = [] - for tech_name in self.dispatchable_techs: + input_names = [] + set_point_names = [] + rated_names = [] + + for tech_name in tech_names: in_name = f"{tech_name}_{self.commodity}_out" set_point_name = f"{tech_name}_{self.commodity}_set_point" rated_name = f"{tech_name}_rated_{self.commodity}_production" + self.add_input( in_name, val=0.0, @@ -125,44 +122,16 @@ def _setup_dispatchable_techs(self, demand_profile): val=initial_set_point, shape=self.n_timesteps, units=self.commodity_units, - desc=f"Set point for {tech_name} {self.commodity} production", + desc=f"{category} set point for {tech_name} {self.commodity}", ) - self.dispatchable_input_names.append(in_name) - self.dispatchable_set_point_names.append(set_point_name) - self.dispatchable_rated_names.append(rated_name) - - def _setup_storage_techs(self): - """Create I/O for storage technologies.""" - self.storage_input_names = [] - self.storage_set_point_names = [] - self.storage_rated_names = [] - for tech_name in self.storage_techs: - in_name = f"{tech_name}_{self.commodity}_out" - set_point_name = f"{tech_name}_{self.commodity}_set_point" - rated_name = f"{tech_name}_rated_{self.commodity}_production" - self.add_input( - in_name, - val=0.0, - shape=self.n_timesteps, - units=self.commodity_units, - desc=f"{self.commodity} output from {tech_name}", - ) - self.add_input( - rated_name, - val=0.0, - units=self.commodity_units, - desc=f"Rated {self.commodity} production for {tech_name}", - ) - self.add_output( - set_point_name, - val=0.0, - shape=self.n_timesteps, - units=self.commodity_units, - desc=f"Set point for {tech_name} {self.commodity} production", - ) - self.storage_input_names.append(in_name) - self.storage_set_point_names.append(set_point_name) - self.storage_rated_names.append(rated_name) + + input_names.append(in_name) + set_point_names.append(set_point_name) + rated_names.append(rated_name) + + setattr(self, f"{category}_input_names", input_names) + setattr(self, f"{category}_set_point_names", set_point_names) + setattr(self, f"{category}_rated_names", rated_names) def _subtract_curtailable(self, inputs, outputs, demand): """Apply curtailable techs: set_point = rated, subtract output from demand. From 95673189675bfc6d10aa42c18d92d764712b13dc Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 29 Apr 2026 21:49:39 -0600 Subject: [PATCH 030/105] Moving SLC strategy definitions --- .../no_battery/plant_config.yaml | 2 +- .../profit_maximization/plant_config.yaml | 2 +- .../yes_battery/plant_config.yaml | 2 +- h2integrate/core/h2integrate_model.py | 24 ++++--------------- h2integrate/core/supported_models.py | 13 ++++++++++ 5 files changed, 20 insertions(+), 23 deletions(-) diff --git a/examples/35_system_level_control/no_battery/plant_config.yaml b/examples/35_system_level_control/no_battery/plant_config.yaml index 92b0daa5c..e6d3e8c7c 100644 --- a/examples/35_system_level_control/no_battery/plant_config.yaml +++ b/examples/35_system_level_control/no_battery/plant_config.yaml @@ -31,7 +31,7 @@ plant: n_timesteps: 8760 dt: 3600 system_level_control: - control_strategy: demand_following + control_strategy: DemandFollowingControl solver_name: gauss_seidel max_iter: 20 convergence_tolerance: 1.0e-6 diff --git a/examples/35_system_level_control/profit_maximization/plant_config.yaml b/examples/35_system_level_control/profit_maximization/plant_config.yaml index 6ab2093d7..d3b56d109 100644 --- a/examples/35_system_level_control/profit_maximization/plant_config.yaml +++ b/examples/35_system_level_control/profit_maximization/plant_config.yaml @@ -24,7 +24,7 @@ plant: n_timesteps: 8760 dt: 3600 system_level_control: - control_strategy: profit_maximization + control_strategy: ProfitMaximizationControl commodity_sell_price: 0.06 # $/kWh default; overridden in run script solver_name: gauss_seidel max_iter: 20 diff --git a/examples/35_system_level_control/yes_battery/plant_config.yaml b/examples/35_system_level_control/yes_battery/plant_config.yaml index a01166342..d3c242b7b 100644 --- a/examples/35_system_level_control/yes_battery/plant_config.yaml +++ b/examples/35_system_level_control/yes_battery/plant_config.yaml @@ -35,7 +35,7 @@ plant: n_timesteps: 8760 dt: 3600 system_level_control: - control_strategy: demand_following + control_strategy: DemandFollowingControl solver_name: gauss_seidel max_iter: 20 convergence_tolerance: 1.0e-6 diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index ffbe72710..d68f890cc 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -26,15 +26,6 @@ from h2integrate.control.control_strategies.pyomo_storage_controller_baseclass import ( PyomoStorageControllerBaseClass, ) -from h2integrate.control.control_strategies.system_level.demand_following_control import ( - DemandFollowingControl, -) -from h2integrate.control.control_strategies.system_level.cost_minimization_control import ( - CostMinimizationControl, -) -from h2integrate.control.control_strategies.system_level.profit_maximization_control import ( - ProfitMaximizationControl, -) try: @@ -543,13 +534,6 @@ def add_system_level_controller(self): """ slc_config = self.plant_config["system_level_control"] - # Map control_strategy config values to controller classes - strategy_map = { - "demand_following": DemandFollowingControl, - "cost_minimization": CostMinimizationControl, - "profit_maximization": ProfitMaximizationControl, - } - # Map user-facing solver names to OpenMDAO solver classes solver_map = { "gauss_seidel": om.NonlinearBlockGS, @@ -558,12 +542,12 @@ def add_system_level_controller(self): } # 1. Select controller class based on strategy - strategy_name = slc_config.get("control_strategy", "demand_following") - slc_cls = strategy_map.get(strategy_name) + strategy_name = slc_config.get("control_strategy", "DemandFollowingControl") + slc_cls = self.supported_models.get(strategy_name) if slc_cls is None: raise ValueError( f"Unknown control_strategy '{strategy_name}' in system_level_control. " - f"Supported: {list(strategy_map.keys())}" + f"Must be a valid model name in supported_models." ) slc_comp = slc_cls( @@ -628,7 +612,7 @@ def add_system_level_controller(self): ) # 4. For cost-aware strategies, connect marginal costs from cost models - if strategy_name in ("cost_minimization", "profit_maximization"): + if strategy_name in ("CostMinimizationControl", "ProfitMaximizationControl"): for tech_name in slc_config["dispatchable_techs"]: self.plant.connect( f"{tech_name}.marginal_cost", diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index 98249fb09..0f357f5eb 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -174,12 +174,21 @@ from h2integrate.control.control_strategies.storage.simple_openloop_controller import ( SimpleStorageOpenLoopController, ) +from h2integrate.control.control_strategies.system_level.demand_following_control import ( + DemandFollowingControl, +) +from h2integrate.control.control_strategies.system_level.cost_minimization_control import ( + CostMinimizationControl, +) from h2integrate.control.control_strategies.storage.plm_openloop_storage_controller import ( PeakLoadManagementHeuristicOpenLoopStorageController, ) from h2integrate.control.control_rules.storage.pyomo_storage_rule_min_operating_cost import ( PyomoRuleStorageMinOperatingCosts, ) +from h2integrate.control.control_strategies.system_level.profit_maximization_control import ( + ProfitMaximizationControl, +) from h2integrate.control.control_rules.converters.generic_converter_min_operating_cost import ( PyomoDispatchGenericConverterMinOperatingCosts, ) @@ -307,6 +316,10 @@ "OptimizedDispatchStorageController": OptimizedDispatchStorageController, "GenericDemandComponent": GenericDemandComponent, "FlexibleDemandComponent": FlexibleDemandComponent, + # System-level control strategies + "DemandFollowingControl": DemandFollowingControl, + "CostMinimizationControl": CostMinimizationControl, + "ProfitMaximizationControl": ProfitMaximizationControl, # Dispatch "PyomoDispatchGenericConverter": PyomoDispatchGenericConverter, "PyomoRuleStorageBaseclass": PyomoRuleStorageBaseclass, From 8910d363b6213e0c77d2d8e6b99ed6f4071347f3 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 1 May 2026 15:36:02 -0600 Subject: [PATCH 031/105] moved solver options to a config class --- .../no_battery/plant_config.yaml | 9 +-- .../profit_maximization/plant_config.yaml | 7 +- .../yes_battery/plant_config.yaml | 9 +-- .../system_level/solver_options.py | 47 ++++++++++++++ .../system_level/system_level_control_base.py | 8 +++ h2integrate/core/h2integrate_model.py | 64 ++++++++++--------- h2integrate/core/supported_models.py | 16 +++++ 7 files changed, 118 insertions(+), 42 deletions(-) create mode 100644 h2integrate/control/control_strategies/system_level/solver_options.py diff --git a/examples/35_system_level_control/no_battery/plant_config.yaml b/examples/35_system_level_control/no_battery/plant_config.yaml index 92b0daa5c..c5eca242c 100644 --- a/examples/35_system_level_control/no_battery/plant_config.yaml +++ b/examples/35_system_level_control/no_battery/plant_config.yaml @@ -31,10 +31,11 @@ plant: n_timesteps: 8760 dt: 3600 system_level_control: - control_strategy: demand_following - solver_name: gauss_seidel - max_iter: 20 - convergence_tolerance: 1.0e-6 + control_strategy: DemandFollowingControl + solver_options: + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 finance_parameters: finance_groups: profast_lco: diff --git a/examples/35_system_level_control/profit_maximization/plant_config.yaml b/examples/35_system_level_control/profit_maximization/plant_config.yaml index 6ab2093d7..0a63d1765 100644 --- a/examples/35_system_level_control/profit_maximization/plant_config.yaml +++ b/examples/35_system_level_control/profit_maximization/plant_config.yaml @@ -24,7 +24,8 @@ plant: n_timesteps: 8760 dt: 3600 system_level_control: - control_strategy: profit_maximization + control_strategy: ProfitMaximizationControl commodity_sell_price: 0.06 # $/kWh default; overridden in run script - solver_name: gauss_seidel - max_iter: 20 + solver_options: + solver_name: gauss_seidel + max_iter: 20 diff --git a/examples/35_system_level_control/yes_battery/plant_config.yaml b/examples/35_system_level_control/yes_battery/plant_config.yaml index a01166342..a2967e397 100644 --- a/examples/35_system_level_control/yes_battery/plant_config.yaml +++ b/examples/35_system_level_control/yes_battery/plant_config.yaml @@ -35,10 +35,11 @@ plant: n_timesteps: 8760 dt: 3600 system_level_control: - control_strategy: demand_following - solver_name: gauss_seidel - max_iter: 20 - convergence_tolerance: 1.0e-6 + control_strategy: DemandFollowingControl + solver_options: + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 finance_parameters: finance_groups: profast_lco: diff --git a/h2integrate/control/control_strategies/system_level/solver_options.py b/h2integrate/control/control_strategies/system_level/solver_options.py new file mode 100644 index 000000000..ae1d928e5 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/solver_options.py @@ -0,0 +1,47 @@ +from typing import ClassVar + +import openmdao.api as om +from attrs import field, define + +from h2integrate.core.utilities import BaseConfig +from h2integrate.core.validators import gt_zero, contains, gte_zero + + +@define(kw_only=True) +class SLCSolverOptionsConfig(BaseConfig): + solver_name: str = field( + default="gauss_seidel", validator=contains["gauss_seidel", "newton", "block_jacobi"] + ) + maxiter: int = field(default=20, converter=int, validator=gte_zero()) + atol: float | None = field(default=None) + rtol: float | None = field(default=None) + convergence_tolerance: float = field(default=1e-6, validator=gt_zero()) + iprint: int = field(default=2) + solver_option_kwargs: dict = field(default={}) + + solver_map: ClassVar = { + "gauss_seidel": om.NonlinearBlockGS, + "newton": om.NewtonSolver, + "block_jacobi": om.NonlinearBlockJac, + } + + def __attrs_post_init__(self): + if self.atol is None: + self.atol = self.convergence_tolerance + if self.rtol is None: + self.rtol = self.convergence_tolerance + + def get_solver_options(self): + d = self.as_dict() + non_solver_option_attrs = [ + "solver_name", + "solver_map", + "solver_option_kwargs", + "convergence_tolerance", + ] + solver_options = {k: v for k, v in d.items() if k not in non_solver_option_attrs} + solver_options_full = solver_options | self.solver_option_kwargs + return solver_options_full + + def return_nonlinear_solver(self): + return self.solver_map[self.solver_name] diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 61afd1f97..ea3f41669 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1,5 +1,13 @@ import numpy as np import openmdao.api as om +from attrs import field, define + +from h2integrate.core.utilities import BaseConfig + + +@define(kw_only=True) +class SystemLevelControlBaseConfig(BaseConfig): + demand_tech: str | None = field(default=None) class SystemLevelControlBase(om.ExplicitComponent): diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index ffbe72710..e1006f506 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -12,6 +12,7 @@ from h2integrate.core.file_utils import get_path, find_file, load_yaml from h2integrate.finances.finances import AdjustedCapexOpexComp from h2integrate.core.supported_models import ( + strategy_map, no_cost_models, supported_models, no_replacement_schedule_models, @@ -23,18 +24,12 @@ multivariable_streams, is_electricity_producer, ) +from h2integrate.control.control_strategies.system_level.solver_options import ( + SLCSolverOptionsConfig, +) from h2integrate.control.control_strategies.pyomo_storage_controller_baseclass import ( PyomoStorageControllerBaseClass, ) -from h2integrate.control.control_strategies.system_level.demand_following_control import ( - DemandFollowingControl, -) -from h2integrate.control.control_strategies.system_level.cost_minimization_control import ( - CostMinimizationControl, -) -from h2integrate.control.control_strategies.system_level.profit_maximization_control import ( - ProfitMaximizationControl, -) try: @@ -544,21 +539,21 @@ def add_system_level_controller(self): slc_config = self.plant_config["system_level_control"] # Map control_strategy config values to controller classes - strategy_map = { - "demand_following": DemandFollowingControl, - "cost_minimization": CostMinimizationControl, - "profit_maximization": ProfitMaximizationControl, - } + # strategy_map = { + # "demand_following": DemandFollowingControl, + # "cost_minimization": CostMinimizationControl, + # "profit_maximization": ProfitMaximizationControl, + # } # Map user-facing solver names to OpenMDAO solver classes - solver_map = { - "gauss_seidel": om.NonlinearBlockGS, - "newton": om.NewtonSolver, - "block_jacobi": om.NonlinearBlockJac, - } + # solver_map = { + # "gauss_seidel": om.NonlinearBlockGS, + # "newton": om.NewtonSolver, + # "block_jacobi": om.NonlinearBlockJac, + # } # 1. Select controller class based on strategy - strategy_name = slc_config.get("control_strategy", "demand_following") + strategy_name = slc_config.get("control_strategy", "DemandFollowingControl") slc_cls = strategy_map.get(strategy_name) if slc_cls is None: raise ValueError( @@ -574,20 +569,27 @@ def add_system_level_controller(self): self.plant.add_subsystem("system_level_controller", slc_comp) # 2. Configure the nonlinear solver - solver_name = slc_config.get("solver_name", "gauss_seidel") - solver_cls = solver_map.get(solver_name) - if solver_cls is None: - raise ValueError( - f"Unknown solver_name '{solver_name}' in system_level_control. " - f"Supported: {list(solver_map.keys())}" - ) + solver_config = SLCSolverOptionsConfig.from_dict(slc_config.get("solver_options", {})) + solver_cls = solver_config.get_solver_options() solver = solver_cls() - solver.options["maxiter"] = slc_config.get("max_iter", 20) - solver.options["atol"] = slc_config.get("convergence_tolerance", 1e-6) - solver.options["rtol"] = slc_config.get("convergence_tolerance", 1e-6) - solver.options["iprint"] = 2 # print convergence at each iteration + solver_options = solver_config.get_solver_options() + for k, v in solver_options.items(): + solver.options[k] = v self.plant.nonlinear_solver = solver self.plant.linear_solver = om.DirectSolver() + # solver_name = slc_config.get("solver_name", "gauss_seidel") + # solver_cls = solver_map.get(solver_name) + + # if solver_cls is None: + # raise ValueError( + # f"Unknown solver_name '{solver_name}' in system_level_control. " + # f"Supported: {list(solver_map.keys())}" + # ) + + # solver.options["maxiter"] = slc_config.get("max_iter", 20) + # solver.options["atol"] = slc_config.get("convergence_tolerance", 1e-6) + # solver.options["rtol"] = slc_config.get("convergence_tolerance", 1e-6) + # solver.options["iprint"] = 2 # print convergence at each iteration # 3. Connect the controller's inputs/outputs to technology models commodity = slc_config["commodity"] diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index 98249fb09..072004900 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -174,12 +174,21 @@ from h2integrate.control.control_strategies.storage.simple_openloop_controller import ( SimpleStorageOpenLoopController, ) +from h2integrate.control.control_strategies.system_level.demand_following_control import ( + DemandFollowingControl, +) +from h2integrate.control.control_strategies.system_level.cost_minimization_control import ( + CostMinimizationControl, +) from h2integrate.control.control_strategies.storage.plm_openloop_storage_controller import ( PeakLoadManagementHeuristicOpenLoopStorageController, ) from h2integrate.control.control_rules.storage.pyomo_storage_rule_min_operating_cost import ( PyomoRuleStorageMinOperatingCosts, ) +from h2integrate.control.control_strategies.system_level.profit_maximization_control import ( + ProfitMaximizationControl, +) from h2integrate.control.control_rules.converters.generic_converter_min_operating_cost import ( PyomoDispatchGenericConverterMinOperatingCosts, ) @@ -351,3 +360,10 @@ no_replacement_schedule_models = { "IronTransportPerformanceComponent", } + +# System-level controllers +strategy_map = { + "DemandFollowingControl": DemandFollowingControl, + "CostMinimizationControl": CostMinimizationControl, + "ProfitMaximizationControl": ProfitMaximizationControl, +} From 553ed206b2be09971ae71e97967fc1403eaf1ab9 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 1 May 2026 15:42:53 -0600 Subject: [PATCH 032/105] minor fixes --- .../control_strategies/system_level/solver_options.py | 11 +++++++---- h2integrate/core/h2integrate_model.py | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/solver_options.py b/h2integrate/control/control_strategies/system_level/solver_options.py index ae1d928e5..58817b36b 100644 --- a/h2integrate/control/control_strategies/system_level/solver_options.py +++ b/h2integrate/control/control_strategies/system_level/solver_options.py @@ -10,12 +10,12 @@ @define(kw_only=True) class SLCSolverOptionsConfig(BaseConfig): solver_name: str = field( - default="gauss_seidel", validator=contains["gauss_seidel", "newton", "block_jacobi"] + default="gauss_seidel", validator=contains(["gauss_seidel", "newton", "block_jacobi"]) ) - maxiter: int = field(default=20, converter=int, validator=gte_zero()) + max_iter: int = field(default=20, converter=int, validator=gte_zero) atol: float | None = field(default=None) rtol: float | None = field(default=None) - convergence_tolerance: float = field(default=1e-6, validator=gt_zero()) + convergence_tolerance: float = field(default=1e-6, validator=gt_zero) iprint: int = field(default=2) solver_option_kwargs: dict = field(default={}) @@ -38,9 +38,12 @@ def get_solver_options(self): "solver_map", "solver_option_kwargs", "convergence_tolerance", + "max_iter", ] solver_options = {k: v for k, v in d.items() if k not in non_solver_option_attrs} - solver_options_full = solver_options | self.solver_option_kwargs + solver_options_full = ( + solver_options | self.solver_option_kwargs | {"maxiter": self.max_iter} + ) return solver_options_full def return_nonlinear_solver(self): diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index e1006f506..464ae372f 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -570,7 +570,7 @@ def add_system_level_controller(self): # 2. Configure the nonlinear solver solver_config = SLCSolverOptionsConfig.from_dict(slc_config.get("solver_options", {})) - solver_cls = solver_config.get_solver_options() + solver_cls = solver_config.return_nonlinear_solver() solver = solver_cls() solver_options = solver_config.get_solver_options() for k, v in solver_options.items(): From 2a741ba9d725665d4386ddd85e34c1c87ca638bc Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 4 May 2026 16:47:04 -0600 Subject: [PATCH 033/105] Multi-commodity systems for SLC (#717) * minor notes added to h2i model * added test for profit maximization example * updated slc base and h2i to handle multiple commodity streams * added test for multiple commodity streams * added test for multicommodity example, unsure if works * fixed example and updated non-demand commodity units handling in slc baseclass * some fixes in baseclass and tried to add logic for controlling multi-commodities * tried to update control logic to handle multiple commods * working on debugging whatever issue I caused * minor fixes for debugging * fixed bug fully * made electrolyzer capacity smaller * updated electrolyzer to not outputs nans * commented out finances for example 35 with hydrogen * aded output check for nans or infs * cleanups to slc baseclass * added docstrings and inline comments * added tiny comment * minor update to use self.technology_graph attribute * added use of _get_commodity_for_tech * Minor changes for clarity * Combining the three setup methods in SLC base * removed old keys in slc_config --------- Co-authored-by: John Jasa --- .../yes_hydrogen/driver_config.yaml | 4 + .../yes_hydrogen/plant_config.yaml | 96 +++++ .../yes_hydrogen/run_multi_commodity.py | 19 + .../yes_hydrogen/tech_config.yaml | 156 +++++++++ .../yes_hydrogen/wind_ng_demand.yaml | 4 + .../system_level/demand_following_control.py | 26 +- .../system_level/system_level_control_base.py | 331 ++++++++++++++---- .../system_level/test/test_slc_examples.py | 62 ++++ .../converters/hydrogen/pem_electrolyzer.py | 10 +- h2integrate/core/h2integrate_model.py | 114 +++--- 10 files changed, 707 insertions(+), 115 deletions(-) create mode 100644 examples/35_system_level_control/yes_hydrogen/driver_config.yaml create mode 100644 examples/35_system_level_control/yes_hydrogen/plant_config.yaml create mode 100644 examples/35_system_level_control/yes_hydrogen/run_multi_commodity.py create mode 100644 examples/35_system_level_control/yes_hydrogen/tech_config.yaml create mode 100644 examples/35_system_level_control/yes_hydrogen/wind_ng_demand.yaml diff --git a/examples/35_system_level_control/yes_hydrogen/driver_config.yaml b/examples/35_system_level_control/yes_hydrogen/driver_config.yaml new file mode 100644 index 000000000..5b6b7e05a --- /dev/null +++ b/examples/35_system_level_control/yes_hydrogen/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: This analysis runs a natural gas power plant +general: + folder_output: outputs diff --git a/examples/35_system_level_control/yes_hydrogen/plant_config.yaml b/examples/35_system_level_control/yes_hydrogen/plant_config.yaml new file mode 100644 index 000000000..14e1878ec --- /dev/null +++ b/examples/35_system_level_control/yes_hydrogen/plant_config.yaml @@ -0,0 +1,96 @@ +name: plant_config +description: This plant is located in Texas, USA. +sites: + site: + latitude: 30.6617 + longitude: -101.7096 + resources: + wind_resource: + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2013 +# array of arrays containing left-to-right technology +# interconnections; can support bidirectional connections +# with the reverse definition. +# this will naturally grow as we mature the interconnected tech +technology_interconnections: + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] + # connect NG feedstock to NG plant + - [wind, battery, electricity, cable] + # wind output available for battery charging (electricity_in) + - [wind, elec_combiner, electricity, cable] + # wind to combined output + - [battery, elec_combiner, electricity, cable] + # battery net output to combined output + - [natural_gas_plant, elec_combiner, electricity, cable] + # electricity to electrolyzer + - [elec_combiner, electrolyzer, electricity, cable] + # electrolyzer to storage + - [electrolyzer, h2_storage, hydrogen, pipe] + # combine hydrogen streams + - [electrolyzer, h2_combiner, hydrogen, pipe] + - [h2_storage, h2_combiner, hydrogen, pipe] + # send hydrogen to load + - [h2_combiner, h2_load_demand, hydrogen, pipe] + # combined supply to demand +resource_to_tech_connections: + # connect the wind resource to the wind technology + - [site.wind_resource, wind, wind_resource_data] +plant: + plant_life: 30 + simulation: + n_timesteps: 8760 + dt: 3600 +system_level_control: + control_strategy: DemandFollowingControl + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +# finance_parameters: +# finance_groups: +# profast_lco: +# finance_model: ProFastLCO +# model_inputs: +# params: +# analysis_start_year: 2032 +# installation_time: 36 # months +# inflation_rate: 0.0 # 0 for nominal analysis +# discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind +# debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind +# property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx +# total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) +# capital_gains_tax_rate: 0.15 # H2FAST default +# sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ +# debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind +# debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH +# loan_period_if_used: 0 # H2FAST default, not used for revolving debt +# cash_onhand_months: 1 # H2FAST default +# admin_expense: 0.00 # percent of sales H2FAST default +# capital_items: +# depr_type: MACRS # can be "MACRS" or "Straight line" +# depr_period: 5 # 5 years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 +# refurb: [0.] +# finance_subgroups: +# # renewables: +# # commodity: electricity +# # commodity_stream: wind +# # finance_groups: [profast_lco] +# # technologies: [wind] +# # natural_gas: +# # commodity: electricity +# # commodity_stream: natural_gas_plant +# # finance_groups: [profast_lco] +# # technologies: [natural_gas_plant, ng_feedstock] +# electricity: +# commodity: electricity +# commodity_stream: elec_combiner +# finance_groups: [profast_lco] +# technologies: [wind, battery, natural_gas_plant, ng_feedstock] +# # hydrogen: +# # commodity: hydrogen +# # commodity_stream: h2_combiner +# # finance_groups: [profast_lco] +# # technologies: [wind, battery, natural_gas_plant, ng_feedstock, electrolyzer, h2_storage] +# cost_adjustment_parameters: +# cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year +# target_dollar_year: 2022 diff --git a/examples/35_system_level_control/yes_hydrogen/run_multi_commodity.py b/examples/35_system_level_control/yes_hydrogen/run_multi_commodity.py new file mode 100644 index 000000000..d6662e37c --- /dev/null +++ b/examples/35_system_level_control/yes_hydrogen/run_multi_commodity.py @@ -0,0 +1,19 @@ +import os + +from h2integrate import EXAMPLE_DIR +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +os.chdir(EXAMPLE_DIR / "35_system_level_control" / "yes_hydrogen") + +################################## +# Create an H2I model with a fixed electricity load demand +h2i = H2IntegrateModel("wind_ng_demand.yaml") + +h2i.setup() + +# Run the model +h2i.run() + +# Post-process the results +h2i.post_process() diff --git a/examples/35_system_level_control/yes_hydrogen/tech_config.yaml b/examples/35_system_level_control/yes_hydrogen/tech_config.yaml new file mode 100644 index 000000000..739b34083 --- /dev/null +++ b/examples/35_system_level_control/yes_hydrogen/tech_config.yaml @@ -0,0 +1,156 @@ +name: technology_config +description: This plant produces electricity with wind, solar, and a natural gas power plant to meet a fixed electrical load + demand. +technologies: + wind: + performance_model: + model: PYSAMWindPlantPerformanceModel + cost_model: + model: ATBWindPlantCostModel + model_inputs: + performance_parameters: + num_turbines: 20 + turbine_rating_kw: 6000 + hub_height: 115 + rotor_diameter: 170 + create_model_from: default + config_name: WindPowerSingleOwner + pysam_options: + Farm: + wind_farm_wake_model: 0 + Losses: + ops_strategies_loss: 10.0 + layout: + layout_mode: basicgrid + layout_options: + row_D_spacing: 5.0 + turbine_D_spacing: 5.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: square + cost_parameters: + capex_per_kW: 1300 + opex_per_kW_per_year: 39 + cost_year: 2022 + ng_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: natural_gas + commodity_rate_units: MMBtu/h + performance_parameters: + rated_capacity: 750. # MMBtu + cost_parameters: + cost_year: 2023 + price: 4.2 # USD/MMBtu + annual_cost: 0. + start_up_cost: 0. + natural_gas_plant: + performance_model: + model: NaturalGasPerformanceModel + cost_model: + model: NaturalGasCostModel + model_inputs: + shared_parameters: + heat_rate_mmbtu_per_mwh: 7.5 # MMBtu/MWh - typical for NGCC + system_capacity_mw: 100. # MW + cost_parameters: + capex_per_kw: 1000 # $/kW - typical for NGCC + fixed_opex_per_kw_per_year: 10.0 # $/kW/year + variable_opex_per_mwh: 0.0 # $/MWh + cost_year: 2023 + battery: + performance_model: + model: StoragePerformanceModel + cost_model: + model: GenericStorageCostModel + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + max_charge_rate: 20000 # kW (20 MW) + max_capacity: 80000 # kWh (80 MWh, 4-hour duration) + init_soc_fraction: 0.5 + max_soc_fraction: 1.0 + min_soc_fraction: 0.1 + performance_parameters: + round_trip_efficiency: 0.90 + demand_profile: 20000 # kW, required by storage base config + cost_parameters: + cost_year: 2022 + capacity_capex: 310 # $/kWh + charge_capex: 311 # $/kW + opex_fraction: 0.025 + elec_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 3 + electrolyzer: + performance_model: + model: ECOElectrolyzerPerformanceModel + cost_model: + model: SingliticoCostModel + model_inputs: + shared_parameters: + location: onshore + electrolyzer_capex: 1295 # $/kW overnight installed capital costs for a 1 MW system in 2022 USD/kW (DOE hydrogen program record 24005 Clean Hydrogen Production Cost Scenarios with PEM Electrolyzer Technology 05/20/24) (https://www.hydrogen.energy.gov/docs/hydrogenprogramlibraries/pdfs/24005-clean-hydrogen-production-cost-pem-electrolyzer.pdf?sfvrsn=8cb10889_1) + performance_parameters: + size_mode: normal + n_clusters: 10 + cluster_rating_MW: 3 + eol_eff_percent_loss: 10 # eol defined as x% change in efficiency from bol + uptime_hours_until_eol: 80000. # number of 'on' hours until electrolyzer reaches eol + include_degradation_penalty: true # include degradation + turndown_ratio: 0.1 # turndown_ratio = minimum_cluster_power/cluster_rating_MW + financial_parameters: + capital_items: + depr_period: 7 # based on PEM Electrolysis H2A Production Case Study Documentation estimate of 7 years. also see https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + replacement_cost_percent: 0.15 # percent of capex - H2A default case + h2_storage: + performance_model: + model: StoragePerformanceModel + cost_model: + model: GenericStorageCostModel + # control_strategy: + # model: DemandOpenLoopStorageController + model_inputs: + shared_parameters: + commodity: hydrogen + commodity_rate_units: kg/h + max_charge_rate: 100.0 # kg/time step + max_capacity: 300.0 # kg + performance_parameters: + max_soc_fraction: 1.0 # fraction (0-1) + min_soc_fraction: 0.1 # fraction (0-1) + init_soc_fraction: 0.1 # fraction (0-1) + max_discharge_rate: 100.0 # kg/time step + charge_efficiency: 1.0 # fraction (0-1) + discharge_efficiency: 1.0 # fraction (0-1) + demand_profile: 500.0 # constant demand of 5000 kg per hour (see commodity_rate_units) + cost_parameters: + cost_year: 2022 + capacity_capex: 100 # $/kg + charge_capex: 100 # $/kg/h + opex_fraction: 0.025 + h2_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: hydrogen + commodity_rate_units: kg/h + h2_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: hydrogen + commodity_rate_units: kg/h + demand_profile: 500.0 diff --git a/examples/35_system_level_control/yes_hydrogen/wind_ng_demand.yaml b/examples/35_system_level_control/yes_hydrogen/wind_ng_demand.yaml new file mode 100644 index 000000000..e09f3dcda --- /dev/null +++ b/examples/35_system_level_control/yes_hydrogen/wind_ng_demand.yaml @@ -0,0 +1,4 @@ +name: H2Integrate_config +driver_config: driver_config.yaml +plant_config: plant_config.yaml +technology_config: tech_config.yaml diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 0864d879a..209a05312 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -28,8 +28,28 @@ def compute(self, inputs, outputs): # 3. Dispatchable techs: equal share of remaining demand remaining = np.maximum(demand, 0.0) - n_dispatchable = len(self.dispatchable_set_point_names) + n_dispatchable = len( + [ + s + for s in self.dispatchable_techs + if self.commodity in self._get_commodity_for_tech(s) + ] + ) + + # calculate the number of dispatchable technologies that + # produce the demanded commodity if n_dispatchable > 0: share = remaining / n_dispatchable - for set_point_name in self.dispatchable_set_point_names: - outputs[set_point_name] = share + for set_point_name, commodity in zip( + self.dispatchable_set_point_names, self.dispatchable_commodity_names + ): + if commodity == self.commodity: + outputs[set_point_name] = share + + # Check for nans or inf + if not all(np.isfinite(c).all() for k, c in outputs.items()): + bad_outputs = [k for k, c in outputs.items() if not np.isfinite(c).all()] + raise ValueError(f"These outputs contain non-finite values: {bad_outputs}") + if not all(np.isfinite(c).all() for k, c in inputs.items()): + bad_inputs = [k for k, c in inputs.items() if not np.isfinite(c).all()] + raise ValueError(f"These inputs contain non-finite values: {bad_inputs}") diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index dffab3dfd..99bba05e9 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -34,8 +34,8 @@ def setup(self): self.n_timesteps = plant_config["plant"]["simulation"]["n_timesteps"] # Read pre-computed classification from plant_config - self.commodity = slc_config["commodity"] - self.commodity_units = slc_config.get("commodity_units", None) + self.commodity = slc_config["demand_commodity"] + self.commodity_units = slc_config.get("demand_commodity_rate_units", None) self.demand_tech = slc_config["demand_tech"] self.curtailable_techs = list(slc_config.get("curtailable_techs", [])) self.dispatchable_techs = list(slc_config.get("dispatchable_techs", [])) @@ -52,99 +52,274 @@ def setup(self): desc=f"Demand profile of {self.commodity}", ) - self._setup_tech_category("curtailable", self.curtailable_techs) - self._setup_tech_category("dispatchable", self.dispatchable_techs, demand_profile) - self._setup_tech_category("storage", self.storage_techs) + self.techs_to_commodities = slc_config["tech_to_commodity"] - def _setup_tech_category(self, category, tech_names, demand_profile=None): - """Create OpenMDAO I/O variables for a category of technologies. + # There are multiple commodities being produced by technologies in the system + self.multi_commodity_system = ( + True if len({e[-1] for e in self.techs_to_commodities}) > 1 else False + ) - For each technology in the category, this method creates three variables: + self.commodities_to_units = {self.commodity: self.commodity_units} + self.commodities_to_ref_var = {} + self._setup_tech_category("curtailable", self.curtailable_techs) + self._setup_tech_category( + "dispatchable", self.dispatchable_techs, demand_profile=demand_profile + ) + self._setup_tech_category("storage", self.storage_techs) - - ``{tech}_{commodity}_out``: an input for the technology's actual commodity - output at each timestep (shape ``n_timesteps``). - - ``{tech}_rated_{commodity}_production``: an input for the technology's - rated (maximum) production capacity (scalar). - - ``{tech}_{commodity}_set_point``: an output for the dispatch set-point - sent back to the technology at each timestep (shape ``n_timesteps``). + def _setup_commodity_for_given_units( + self, tech_name, commodity, commodity_units, add_in_name=True, initial_set_point=0.0 + ): + """Adds inputs and outputs for a commodity when the units are known. + The inputs and outputs that are added have the below naming convention: - The variable names are collected into three lists stored as instance - attributes named ``{category}_input_names``, - ``{category}_set_point_names``, and ``{category}_rated_names``. + - ``f"{tech_name}_{commodity}_out"``: input commodity produced by tech_name + - ``f"{tech_name}_rated_{commodity}_production"``: input rated commodity production + capacity of tech_name + - ``f"{tech_name}_{commodity}_set_point"``: output control setpoint for tech_name Args: - category: Technology classification label (e.g. ``"curtailable"``, - ``"dispatchable"``, ``"storage"``). Used to name the instance - attribute lists and the set-point description. - tech_names: Iterable of technology name strings belonging to this - category. - demand_profile: Optional demand profile used to initialize the - set-point output for dispatchable technologies. When provided, - the initial set-point is ``demand_profile / n_techs`` so that - the solver starts from a reasonable guess. Ignored (or - ``None``) for curtailable and storage categories, whose - set-points default to zero. + tech_name (str): name of technology + commodity (str): commodity of the technology described by `tech_name` + commodity_units (str): units of commodity + add_in_name (bool, optional): If True, add the input for the in_name variable. + Defaults to True. + initial_set_point (float, optional): Add as the initial value for the + set_point variable. Defaults to 0.0. + Returns: + tuple(str, str, str): tuple of in_name, set_point_name, and rated_name """ - # Compute initial set-point value - n_techs = len(tech_names) - if demand_profile is not None and n_techs > 0: - if np.isscalar(demand_profile): - initial_set_point = demand_profile / n_techs - else: - initial_set_point = np.array(demand_profile) / n_techs - else: - initial_set_point = 0.0 - - input_names = [] - set_point_names = [] - rated_names = [] - - for tech_name in tech_names: - in_name = f"{tech_name}_{self.commodity}_out" - set_point_name = f"{tech_name}_{self.commodity}_set_point" - rated_name = f"{tech_name}_rated_{self.commodity}_production" + in_name = f"{tech_name}_{commodity}_out" + set_point_name = f"{tech_name}_{commodity}_set_point" + rated_name = f"{tech_name}_rated_{commodity}_production" + if add_in_name: self.add_input( in_name, val=0.0, shape=self.n_timesteps, - units=self.commodity_units, - desc=f"{self.commodity} output from {tech_name}", + units=commodity_units, + desc=f"{commodity} output from {tech_name}", ) + self.add_input( + rated_name, + val=0.0, + units=commodity_units, + desc=f"Rated {commodity} production for {tech_name}", + ) + self.add_output( + set_point_name, + val=initial_set_point, + shape=self.n_timesteps, + units=commodity_units, + desc=f"Set point for {tech_name} {commodity} curtailment", + ) + + return in_name, set_point_name, rated_name + + def _setup_commodity_for_copy_units( + self, tech_name, commodity, commodity_reference_var, add_in_name=True, initial_set_point=0.0 + ): + """Adds inputs and outputs for a commodity where the units are based on a reference + input variable. The inputs and outputs that are added have the below + naming convention: + + - ``f"{tech_name}_{commodity}_out"``: input commodity produced by tech_name + - ``f"{tech_name}_rated_{commodity}_production"``: input rated commodity production + capacity of tech_name + - ``f"{tech_name}_{commodity}_set_point"``: output control setpoint for tech_name + + Args: + tech_name (str): name of technology + commodity (str): commodity of the technology described by `tech_name` + commodity_reference_var (str): name of input to copy units from + add_in_name (bool, optional): If True, add the input for the in_name variable. + Defaults to True. + initial_set_point (float, optional): Add as the initial value for the + set_point variable. Defaults to 0.0. + + Returns: + tuple(str, str, str): tuple of in_name, set_point_name, and rated_name + """ + in_name = f"{tech_name}_{commodity}_out" + set_point_name = f"{tech_name}_{commodity}_set_point" + rated_name = f"{tech_name}_rated_{commodity}_production" + + if add_in_name: self.add_input( - rated_name, + in_name, val=0.0, - units=self.commodity_units, - desc=f"Rated {self.commodity} production for {tech_name}", - ) - self.add_output( - set_point_name, - val=initial_set_point, shape=self.n_timesteps, - units=self.commodity_units, - desc=f"{category} set point for {tech_name} {self.commodity}", + units=None, + copy_units=commodity_reference_var, + desc=f"{commodity} output from {tech_name}", ) + self.add_input( + rated_name, + val=0.0, + units=None, + copy_units=commodity_reference_var, + desc=f"Rated {commodity} production for {tech_name}", + ) + self.add_output( + set_point_name, + val=initial_set_point, + shape=self.n_timesteps, + units=None, + copy_units=commodity_reference_var, + desc=f"Set point for {tech_name} {commodity} curtailment", + ) + + return in_name, set_point_name, rated_name + + def _setup_tech_category(self, category, tech_list, demand_profile=None): + """Create OpenMDAO I/O variables for all technologies in a given category. + + This single method handles curtailable, dispatchable, and storage + technologies. The logic is identical for all three categories — + iterate over each technology's commodities and register the + appropriate inputs (production output, rated capacity) and output + (control set-point) — with one difference: + + * **Curtailable / Storage** (``demand_profile is None``): + ``initial_set_point`` is ``0.0``. Curtailable techs are later + assigned set-points equal to their rated production; storage techs + get set-points computed at run-time in ``_dispatch_storage``. + + * **Dispatchable** (``demand_profile`` is provided): + ``initial_set_point`` is the demand evenly divided among the + dispatchable techs that produce the demanded commodity, giving + the solver a reasonable starting guess. + + After this method returns, four lists are stored on ``self`` under + names produced by the *category* prefix: + + ``self.{category}_input_names`` + ``self.{category}_set_point_names`` + ``self.{category}_rated_names`` + ``self.{category}_commodity_names`` + + These lists are consumed by ``compute()`` and the helper methods + ``_subtract_curtailable`` and ``_dispatch_storage``. + + Args: + category (str): One of ``"curtailable"``, ``"dispatchable"``, + or ``"storage"``. Used to name the attribute lists. + tech_list (list[str]): Technology names belonging to this category + (e.g. ``self.curtailable_techs``). + demand_profile (float | np.ndarray | None, optional): + Only relevant for **dispatchable** techs. When provided, the + demand is split equally among dispatchable techs that produce + the demanded commodity to set a non-zero ``initial_set_point``. + For curtailable and storage techs, leave as ``None`` (default). + """ + # --- Compute initial_set_point -------------------------------- + # Dispatchable techs: split demand equally among those that produce + # the demanded commodity so the solver starts from a feasible guess. + # Curtailable and storage techs always start at 0. + if demand_profile is not None: + n_producing = len( + [t for t in tech_list if self.commodity in self._get_commodity_for_tech(t)] + ) + if n_producing > 0: + if np.isscalar(demand_profile): + initial_set_point = demand_profile / n_producing + else: + initial_set_point = np.array(demand_profile) / n_producing + else: + initial_set_point = 0.0 + else: + initial_set_point = 0.0 + + # --- Initialize the four per-category bookkeeping lists ------- + input_names = [] + set_point_names = [] + rated_names = [] + commodity_names = [] + + # --- Register I/O for every (tech, commodity) pair ------------ + for tech_name in tech_list: + tech_commodities = [e[1] for e in self.techs_to_commodities if e[0] == tech_name] + for commodity in tech_commodities: + if commodity in self.commodities_to_units: + # Units are already known explicitly + in_name, set_point_name, rated_name = self._setup_commodity_for_given_units( + tech_name, + commodity, + self.commodities_to_units[commodity], + add_in_name=True, + initial_set_point=initial_set_point, + ) + elif commodity in self.commodities_to_ref_var: + # Units are inferred from a previously-registered reference variable + in_name, set_point_name, rated_name = self._setup_commodity_for_copy_units( + tech_name, + commodity, + self.commodities_to_ref_var[commodity], + add_in_name=True, + initial_set_point=initial_set_point, + ) + else: + # Units are unknown; try to discover them from the connection + in_name = f"{tech_name}_{commodity}_out" + meta_data = self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=None, + units_by_conn=True, + desc=f"{commodity} output from {tech_name}", + ) + if meta_data["units"] is None: + # Still unknown: register in_name as the reference + # variable so later techs with this commodity can + # copy its units. + self.commodities_to_ref_var[commodity] = in_name + in_name, set_point_name, rated_name = self._setup_commodity_for_copy_units( + tech_name, + commodity, + self.commodities_to_ref_var[commodity], + add_in_name=False, + initial_set_point=initial_set_point, + ) + else: + # Connection provided units — record them for future use + self.commodities_to_units[commodity] = meta_data["units"] + in_name, set_point_name, rated_name = self._setup_commodity_for_given_units( + tech_name, + commodity, + self.commodities_to_units[commodity], + add_in_name=False, + initial_set_point=initial_set_point, + ) - input_names.append(in_name) - set_point_names.append(set_point_name) - rated_names.append(rated_name) + commodity_names.append(commodity) + input_names.append(in_name) + set_point_names.append(set_point_name) + rated_names.append(rated_name) + # --- Store lists as self._ attributes ------- setattr(self, f"{category}_input_names", input_names) setattr(self, f"{category}_set_point_names", set_point_names) setattr(self, f"{category}_rated_names", rated_names) + setattr(self, f"{category}_commodity_names", commodity_names) def _subtract_curtailable(self, inputs, outputs, demand): """Apply curtailable techs: set_point = rated, subtract output from demand. Returns the updated demand array. """ - for in_name, set_point_name, rated_name in zip( + for in_name, set_point_name, rated_name, commodity in zip( self.curtailable_input_names, self.curtailable_set_point_names, self.curtailable_rated_names, + self.curtailable_commodity_names, ): + # Output the set-point as the rated production of that technology outputs[set_point_name] = inputs[rated_name] * np.ones(self.n_timesteps) - demand -= inputs[in_name] + if commodity == self.commodity: + demand -= inputs[in_name] + return demand def _dispatch_storage(self, inputs, outputs, demand): @@ -153,12 +328,34 @@ def _dispatch_storage(self, inputs, outputs, demand): Positive set_point = discharge, negative = charge. Returns the updated demand array. """ - n_storage = len(self.storage_set_point_names) + # calculate the number of storage technologies that + # produce the demanded commodity + n_storage = len( + [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] + ) if n_storage > 0: storage_share = demand / n_storage - for set_point_name in self.storage_set_point_names: - outputs[set_point_name] = storage_share + for set_point_name, commodity in zip( + self.storage_set_point_names, self.storage_commodity_names + ): + if commodity == self.commodity: + outputs[set_point_name] = storage_share + + for tech_name, in_name in zip(self.storage_techs, self.storage_input_names): + if self.commodity in self._get_commodity_for_tech(tech_name): + demand -= inputs[in_name] - for in_name in self.storage_input_names: - demand -= inputs[in_name] return demand + + def _get_commodity_for_tech(self, tech_name): + """Get a list of the commodities produced for a technology. + + Args: + tech_name (str): name of technology + + Returns: + list[str]: list of commodities produced by the tech_name + """ + tech_commodities = [e[1] for e in self.techs_to_commodities if e[0] == tech_name] + + return tech_commodities diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index 502be050b..22e2340b3 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -1,3 +1,4 @@ +import numpy as np import pytest from h2integrate.core.h2integrate_model import H2IntegrateModel @@ -35,3 +36,64 @@ def test_slc_yes_battery(subtests, temp_copy_of_example): with subtests.test("wind farm generates power"): assert wind_out.sum() > 0 + + with subtests.test("lcoe"): + assert ( + pytest.approx( + model.prob.get_val("finance_subgroup_electricity.LCOE", units="USD/(kW*h)"), + rel=1e-6, + ) + == 0.10902004 + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "example_folder,resource_example_folder", + [("35_system_level_control/profit_maximization", None)], +) +def test_slc_profit_max(subtests, temp_copy_of_example): + example_folder = temp_copy_of_example + + model = H2IntegrateModel(example_folder / "wind_ng_demand.yaml") + + n_timesteps = 8760 + sell_price = np.zeros(n_timesteps) + for h in range(n_timesteps): + hour_of_day = h % 24 + if 16 <= hour_of_day < 22: + sell_price[h] = 0.08 # peak + else: + sell_price[h] = 0.03 # night (cheap) + + model.setup() + + model.prob.set_val( + "plant.system_level_controller.commodity_sell_price", + sell_price, + units="USD/(kW*h)", + ) + + model.run() + + wind_out = model.prob.get_val("wind.electricity_out") + + with subtests.test("wind farm generates power"): + assert wind_out.sum() > 0 + + +@pytest.mark.unit +@pytest.mark.parametrize( + "example_folder,resource_example_folder", [("35_system_level_control/yes_hydrogen", None)] +) +def test_slc_yes_hydrogen(subtests, temp_copy_of_example): + example_folder = temp_copy_of_example + + model = H2IntegrateModel(example_folder / "wind_ng_demand.yaml") + + model.run() + + wind_out = model.prob.get_val("wind.electricity_out") + + with subtests.test("wind farm generates power"): + assert wind_out.sum() > 0 diff --git a/h2integrate/converters/hydrogen/pem_electrolyzer.py b/h2integrate/converters/hydrogen/pem_electrolyzer.py index d09d6d65c..8621613be 100644 --- a/h2integrate/converters/hydrogen/pem_electrolyzer.py +++ b/h2integrate/converters/hydrogen/pem_electrolyzer.py @@ -176,12 +176,17 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): # 1 gal H2O = 3.79 kg H2O outputs["water_consumed"] = 3.79 * H2_Results["Water Hourly Consumption [kg/hr]"] outputs["total_hydrogen_produced"] = outputs["hydrogen_out"].sum() - outputs["efficiency"] = H2_Results["Sim: Average Efficiency [%-HHV]"] + if not np.isfinite(H2_Results["Sim: Average Efficiency [%-HHV]"]).all(): + outputs["efficiency"] = 0.0 + else: + outputs["efficiency"] = H2_Results["Sim: Average Efficiency [%-HHV]"] refurb_schedule = np.zeros(self.plant_life) if np.isnan(H2_Results["Time Until Replacement [hrs]"]): - refurb_period = round(80000 / (24 * 365)) + refurb_period = round(self.config.uptime_hours_until_eol / (24 * 365)) + outputs["time_until_replacement"] = self.config.uptime_hours_until_eol else: refurb_period = round(float(H2_Results["Time Until Replacement [hrs]"]) / (24 * 365)) + outputs["time_until_replacement"] = H2_Results["Time Until Replacement [hrs]"] refurb_schedule[refurb_period : self.plant_life : refurb_period] = 1 @@ -197,7 +202,6 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): # ) # TODO: remove time_until_replacement as output after finance model(s) have been updated to not use it - outputs["time_until_replacement"] = H2_Results["Time Until Replacement [hrs]"] outputs["rated_hydrogen_production"] = H2_Results["Rated BOL: H2 Production [kg/hr]"] outputs["electrolyzer_size_mw"] = electrolyzer_actual_capacity_MW diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index b11c4455a..4327bcb47 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -482,9 +482,9 @@ def _classify_slc_technologies(self): technologies = self.technology_config.get("technologies", {}) # Identify the (single) demand technology - commodity = None demand_tech = None - commodity_units = None + demand_commodity = None + demand_commodity_rate_units = None for tech_name, tech_def in technologies.items(): model_name = tech_def.get("performance_model", {}).get("model", "") if "DemandComponent" not in model_name: @@ -495,16 +495,45 @@ def _classify_slc_technologies(self): shared_params = model_inputs.get("shared_parameters", {}) all_params = {**shared_params, **perf_params} - if commodity is not None: + if demand_commodity is not None: + # NOTE: this error should only be raised if two demand components + # are in the tech connections raise ValueError( "DemandFollowingControl currently supports only one demand " - f"stream, but found demands for both '{commodity}' " + f"stream, but found demands for both '{demand_commodity}' " f"and '{all_params.get('commodity', tech_name)}'." ) - commodity = all_params["commodity"] - commodity_units = all_params.get("commodity_rate_units", None) + + demand_commodity = all_params["commodity"] + demand_commodity_rate_units = all_params.get("commodity_rate_units", None) demand_profile = all_params.get("demand_profile", 0.0) demand_tech = tech_name + # Check that the demand tech is in the technology_interconnections + tech_interconnections = self.plant_config["technology_interconnections"] + demand_is_source_connection = [ + tech_connection + for tech_connection in tech_interconnections + if tech_connection[0] == demand_tech + ] + demand_is_destination_connection = [ + tech_connection + for tech_connection in tech_interconnections + if tech_connection[1] == demand_tech + ] + if len(demand_is_source_connection) == 0 and len(demand_is_destination_connection) == 0: + # demand is not in tech interconnections + demand_tech = None + demand_commodity = None + + demand_commodity_rate_units = None + + # Raise error if no demand commodity was defined + if demand_tech is None: + msg = ( + "No demand commodity was found in the technology interconnections. " + "Please define a demand component." + ) + raise ValueError(msg) # Classify technologies using pre-computed classifiers curtailable_techs = [] @@ -518,14 +547,29 @@ def _classify_slc_technologies(self): elif classifier == "storage": storage_techs.append(tech_name) + # Classify technologies based on their output commodity (or commodities) + # Use a set to remove duplicates (in case one tech produces multiple commodities) + sources_to_commodities = { + (e[0], e[-1]) + for e in self.technology_graph.edges(data="commodity") + if e[-1] is not None + } + + # Remove feedstocks and connectors + techs_to_connect = set(curtailable_techs + dispatchable_techs + storage_techs) + tech_to_commodities = { + (e[0], e[-1]) for e in sources_to_commodities if e[0] in techs_to_connect + } + # Store classification results in plant_config for SLC component - slc_config["commodity"] = commodity - slc_config["commodity_units"] = commodity_units slc_config["demand_tech"] = demand_tech slc_config["demand_profile"] = demand_profile + slc_config["demand_commodity"] = demand_commodity + slc_config["demand_commodity_rate_units"] = demand_commodity_rate_units slc_config["curtailable_techs"] = curtailable_techs slc_config["dispatchable_techs"] = dispatchable_techs slc_config["storage_techs"] = storage_techs + slc_config["tech_to_commodity"] = tech_to_commodities def add_system_level_controller(self): """Add the DemandFollowingControl component and configure the plant solver. @@ -571,6 +615,7 @@ def add_system_level_controller(self): f"Supported: {list(solver_map.keys())}" ) solver = solver_cls() + # TODO: make a config for the below defaults solver.options["maxiter"] = slc_config.get("max_iter", 20) solver.options["atol"] = slc_config.get("convergence_tolerance", 1e-6) solver.options["rtol"] = slc_config.get("convergence_tolerance", 1e-6) @@ -579,42 +624,26 @@ def add_system_level_controller(self): self.plant.linear_solver = om.DirectSolver() # 3. Connect the controller's inputs/outputs to technology models - commodity = slc_config["commodity"] - - # Curtailable techs: read their output and write set_point - for tech_name in slc_config["curtailable_techs"]: - self.plant.connect( - f"{tech_name}.{commodity}_out", - f"system_level_controller.{tech_name}_{commodity}_out", - ) - self.plant.connect( - f"{tech_name}.rated_{commodity}_production", - f"system_level_controller.{tech_name}_rated_{commodity}_production", - ) - self.plant.connect( - f"system_level_controller.{tech_name}_{commodity}_set_point", - f"{tech_name}.{commodity}_set_point", - ) - # Dispatchable and storage techs: read output and write set_point - for tech_list in ["dispatchable_techs", "storage_techs"]: + # Curtailable, dispatchable, and storage techs: read output and write set_point + for tech_list in ["curtailable_techs", "dispatchable_techs", "storage_techs"]: for tech_name in slc_config[tech_list]: - self.plant.connect( - f"{tech_name}.{commodity}_out", - f"system_level_controller.{tech_name}_{commodity}_out", - ) - self.plant.connect( - f"system_level_controller.{tech_name}_{commodity}_set_point", - f"{tech_name}.{commodity}_set_point", - ) + tech_commodities = self._get_commodity_for_tech(tech_name) + for commodity in tech_commodities: + self.plant.connect( + f"{tech_name}.{commodity}_out", + f"system_level_controller.{tech_name}_{commodity}_out", + ) - # Dispatchable and storage techs: also connect rated production - for tech_list in ["dispatchable_techs", "storage_techs"]: - for tech_name in slc_config[tech_list]: - self.plant.connect( - f"{tech_name}.rated_{commodity}_production", - f"system_level_controller.{tech_name}_rated_{commodity}_production", - ) + self.plant.connect( + f"{tech_name}.rated_{commodity}_production", + f"system_level_controller.{tech_name}_rated_{commodity}_production", + ) + + self.plant.connect( + f"system_level_controller.{tech_name}_{commodity}_set_point", + f"{tech_name}.{commodity}_set_point", + ) # 4. For cost-aware strategies, connect marginal costs from cost models if strategy_name in ("CostMinimizationControl", "ProfitMaximizationControl"): @@ -628,6 +657,7 @@ def add_system_level_controller(self): ### components in the new SLC paradigm. # # Connect demand profile to the controller # demand_tech = slc_config["demand_tech"] + # demand_commodity = slc_config["demand_commodity"] # self.plant.connect( # f"{demand_tech}.{commodity}_demand", # f"system_level_controller.{commodity}_demand", @@ -701,7 +731,7 @@ def create_technology_models(self): tech_config=individual_tech_config, ) self._check_time_step(perf_model, comp) - self.tech_control_classifiers.update({tech_name: "feedback"}) + self.tech_control_classifiers.update({tech_name: "feedstock"}) self.plant.add_subsystem(f"{tech_name}_source", comp) else: tech_group = self.plant.add_subsystem(tech_name, om.Group()) From 8b6f2c103775d3fc143a0494b604dc1b9ecc7e42 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 5 May 2026 14:45:11 -0600 Subject: [PATCH 034/105] added framework to interface with storage controllers and example --- .../driver_config.yaml | 4 + .../battery_with_controller/plant_config.yaml | 107 ++++++++++++++++++ .../run_wind_ng_demand.py | 65 +++++++++++ .../battery_with_controller/tech_config.yaml | 104 +++++++++++++++++ .../wind_ng_demand.yaml | 5 + .../system_level/system_level_control_base.py | 35 +++++- .../system_level/test/test_slc_examples.py | 19 ++++ h2integrate/core/h2integrate_model.py | 41 ++++++- 8 files changed, 373 insertions(+), 7 deletions(-) create mode 100644 examples/35_system_level_control/battery_with_controller/driver_config.yaml create mode 100644 examples/35_system_level_control/battery_with_controller/plant_config.yaml create mode 100644 examples/35_system_level_control/battery_with_controller/run_wind_ng_demand.py create mode 100644 examples/35_system_level_control/battery_with_controller/tech_config.yaml create mode 100644 examples/35_system_level_control/battery_with_controller/wind_ng_demand.yaml diff --git a/examples/35_system_level_control/battery_with_controller/driver_config.yaml b/examples/35_system_level_control/battery_with_controller/driver_config.yaml new file mode 100644 index 000000000..5b6b7e05a --- /dev/null +++ b/examples/35_system_level_control/battery_with_controller/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: This analysis runs a natural gas power plant +general: + folder_output: outputs diff --git a/examples/35_system_level_control/battery_with_controller/plant_config.yaml b/examples/35_system_level_control/battery_with_controller/plant_config.yaml new file mode 100644 index 000000000..d3c242b7b --- /dev/null +++ b/examples/35_system_level_control/battery_with_controller/plant_config.yaml @@ -0,0 +1,107 @@ +name: plant_config +description: This plant is located in Texas, USA. +sites: + site: + latitude: 30.6617 + longitude: -101.7096 + resources: + wind_resource: + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2013 +# array of arrays containing left-to-right technology +# interconnections; can support bidirectional connections +# with the reverse definition. +# this will naturally grow as we mature the interconnected tech +technology_interconnections: + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] + # connect NG feedstock to NG plant + - [wind, battery, electricity, cable] + # wind output available for battery charging (electricity_in) + - [wind, fin_combiner, electricity, cable] + # wind to combined output + - [battery, fin_combiner, electricity, cable] + # battery net output to combined output + - [natural_gas_plant, fin_combiner, electricity, cable] + # NG to combined output + - [fin_combiner, electrical_load_demand, electricity, cable] + # combined supply to demand +resource_to_tech_connections: + # connect the wind resource to the wind technology + - [site.wind_resource, wind, wind_resource_data] +plant: + plant_life: 30 + simulation: + n_timesteps: 8760 + dt: 3600 +system_level_control: + control_strategy: DemandFollowingControl + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +finance_parameters: + finance_groups: + profast_lco: + finance_model: ProFastLCO + model_inputs: + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + depr_period: 5 # 5 years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + refurb: [0.] + profast_npv: + finance_model: ProFastNPV + model_inputs: + commodity_sell_price: 0.05167052 + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + depr_period: 5 # 5 years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + refurb: [0.] + finance_subgroups: + renewables: + commodity: electricity + commodity_stream: wind + finance_groups: [profast_lco, profast_npv] + technologies: [wind] + natural_gas: + commodity: electricity + commodity_stream: natural_gas_plant + finance_groups: [profast_lco] + technologies: [natural_gas_plant, ng_feedstock] + electricity: + commodity: electricity + commodity_stream: fin_combiner + finance_groups: [profast_lco] + technologies: [wind, battery, natural_gas_plant, ng_feedstock] + cost_adjustment_parameters: + cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year + target_dollar_year: 2022 diff --git a/examples/35_system_level_control/battery_with_controller/run_wind_ng_demand.py b/examples/35_system_level_control/battery_with_controller/run_wind_ng_demand.py new file mode 100644 index 000000000..b536e660b --- /dev/null +++ b/examples/35_system_level_control/battery_with_controller/run_wind_ng_demand.py @@ -0,0 +1,65 @@ +import numpy as np +import matplotlib.pyplot as plt + +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +################################## +# Create an H2I model with a fixed electricity load demand +h2i = H2IntegrateModel("wind_ng_demand.yaml") + +# Run the model +h2i.run() + +# Post-process the results +h2i.post_process() + +# Plot the first 168 hours (1 week) +n_hours = 168 +hours = np.arange(n_hours) + +wind_out = h2i.prob.get_val("plant.wind.electricity_out")[:n_hours] +ng_out = h2i.prob.get_val("plant.natural_gas_plant.electricity_out", units="kW")[:n_hours] +batt_discharge = h2i.prob.get_val("plant.battery.storage_electricity_discharge")[:n_hours] +batt_soc = h2i.prob.get_val("plant.battery.SOC")[:n_hours] +demand = h2i.prob.get_val("plant.electrical_load_demand.electricity_demand")[:n_hours] +curtailed = h2i.prob.get_val("plant.electrical_load_demand.unused_electricity_out")[:n_hours] + +fig, axes = plt.subplots(3, 1, figsize=(12, 10), sharex=True) + +# Stacked area: wind + battery discharge + NG = total supply +axes[0].fill_between(hours, 0, wind_out, alpha=0.7, color="tab:blue", label="Wind") +axes[0].fill_between( + hours, + wind_out, + wind_out + batt_discharge, + alpha=0.7, + color="tab:purple", + label="Battery Discharge", +) +axes[0].fill_between( + hours, + wind_out + batt_discharge, + wind_out + batt_discharge + ng_out, + alpha=0.7, + color="tab:orange", + label="Natural Gas", +) +axes[0].plot(hours, demand, color="black", linewidth=1.5, linestyle="--", label="Demand") +axes[0].set_ylabel("Power (kW)") +axes[0].set_title("System-Level Control: First 168 Hours") +axes[0].legend() + +axes[1].plot(hours, batt_soc, color="tab:cyan") +axes[1].set_ylabel("Battery SOC (%)") + +axes[2].plot(hours, curtailed, color="tab:red") +axes[2].set_ylabel("Curtailed (kW)") +axes[2].set_xlabel("Hour") + +for ax in axes: + ax.grid(True, alpha=0.3) + +plt.tight_layout() +plt.savefig("slc_results.png", dpi=150) +plt.show() diff --git a/examples/35_system_level_control/battery_with_controller/tech_config.yaml b/examples/35_system_level_control/battery_with_controller/tech_config.yaml new file mode 100644 index 000000000..c5ca7a20f --- /dev/null +++ b/examples/35_system_level_control/battery_with_controller/tech_config.yaml @@ -0,0 +1,104 @@ +name: technology_config +description: This plant produces electricity with wind, solar, and a natural gas power plant to meet a fixed electrical load + demand. +technologies: + wind: + performance_model: + model: PYSAMWindPlantPerformanceModel + cost_model: + model: ATBWindPlantCostModel + model_inputs: + performance_parameters: + num_turbines: 20 + turbine_rating_kw: 6000 + hub_height: 115 + rotor_diameter: 170 + create_model_from: default + config_name: WindPowerSingleOwner + pysam_options: + Farm: + wind_farm_wake_model: 0 + Losses: + ops_strategies_loss: 10.0 + layout: + layout_mode: basicgrid + layout_options: + row_D_spacing: 5.0 + turbine_D_spacing: 5.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: square + cost_parameters: + capex_per_kW: 1300 + opex_per_kW_per_year: 39 + cost_year: 2022 + ng_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: natural_gas + commodity_rate_units: MMBtu/h + performance_parameters: + rated_capacity: 750. # MMBtu + cost_parameters: + cost_year: 2023 + price: 4.2 # USD/MMBtu + annual_cost: 0. + start_up_cost: 0. + natural_gas_plant: + performance_model: + model: NaturalGasPerformanceModel + cost_model: + model: NaturalGasCostModel + model_inputs: + shared_parameters: + heat_rate_mmbtu_per_mwh: 7.5 # MMBtu/MWh - typical for NGCC + system_capacity_mw: 100. # MW + cost_parameters: + capex_per_kw: 1000 # $/kW - typical for NGCC + fixed_opex_per_kw_per_year: 10.0 # $/kW/year + variable_opex_per_mwh: 0.0 # $/MWh + cost_year: 2023 + electrical_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 30000 + battery: + performance_model: + model: StoragePerformanceModel + cost_model: + model: GenericStorageCostModel + control_strategy: + model: DemandOpenLoopStorageController + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + max_charge_rate: 20000 # kW (20 MW) + max_capacity: 80000 # kWh (80 MWh, 4-hour duration) + init_soc_fraction: 0.5 + max_soc_fraction: 1.0 + min_soc_fraction: 0.1 + # performance_parameters: + round_trip_efficiency: 0.90 + demand_profile: 20000 # kW, required by storage base config + cost_parameters: + cost_year: 2022 + capacity_capex: 310 # $/kWh + charge_capex: 311 # $/kW + opex_fraction: 0.025 + fin_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 3 diff --git a/examples/35_system_level_control/battery_with_controller/wind_ng_demand.yaml b/examples/35_system_level_control/battery_with_controller/wind_ng_demand.yaml new file mode 100644 index 000000000..f2b5599a0 --- /dev/null +++ b/examples/35_system_level_control/battery_with_controller/wind_ng_demand.yaml @@ -0,0 +1,5 @@ +name: H2Integrate_config +system_summary: This example uses wind, solar and a natural gas power plant to meet a fixed electrical load demand. +driver_config: driver_config.yaml +technology_config: tech_config.yaml +plant_config: plant_config.yaml diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 99bba05e9..47157f9b7 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -40,6 +40,8 @@ def setup(self): self.curtailable_techs = list(slc_config.get("curtailable_techs", [])) self.dispatchable_techs = list(slc_config.get("dispatchable_techs", [])) self.storage_techs = list(slc_config.get("storage_techs", [])) + self.storage_techs_to_control = slc_config.get("storage_techs_to_control", {}) + self.technology_graph = slc_config["technology_graph"] # Input: demand profile (default value from config) demand_profile = slc_config.get("demand_profile", 0.0) @@ -67,6 +69,9 @@ def setup(self): ) self._setup_tech_category("storage", self.storage_techs) + # def _get_upstream_techs(self, inputs, tech_name): + # tech_commodities = self._get_commodity_for_tech(tech_name) + def _setup_commodity_for_given_units( self, tech_name, commodity, commodity_units, add_in_name=True, initial_set_point=0.0 ): @@ -90,9 +95,16 @@ def _setup_commodity_for_given_units( tuple(str, str, str): tuple of in_name, set_point_name, and rated_name """ in_name = f"{tech_name}_{commodity}_out" - set_point_name = f"{tech_name}_{commodity}_set_point" rated_name = f"{tech_name}_rated_{commodity}_production" + if self.storage_techs_to_control.get(tech_name, False): + # tech_name is storage and does have an attached controller + set_point_name = f"{tech_name}_{commodity}_demand" + else: + # if tech_name is not in storage_techs_to_control + # or storage tech does not have an attached controller + set_point_name = f"{tech_name}_{commodity}_set_point" + if add_in_name: self.add_input( in_name, @@ -142,9 +154,16 @@ def _setup_commodity_for_copy_units( tuple(str, str, str): tuple of in_name, set_point_name, and rated_name """ in_name = f"{tech_name}_{commodity}_out" - set_point_name = f"{tech_name}_{commodity}_set_point" rated_name = f"{tech_name}_rated_{commodity}_production" + if self.storage_techs_to_control.get(tech_name, False): + # tech_name is storage and does have an attached controller + set_point_name = f"{tech_name}_{commodity}_demand" + else: + # if tech_name is not in storage_techs_to_control + # or storage tech does not have an attached controller + set_point_name = f"{tech_name}_{commodity}_set_point" + if add_in_name: self.add_input( in_name, @@ -334,12 +353,22 @@ def _dispatch_storage(self, inputs, outputs, demand): [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] ) if n_storage > 0: + # split the demand across the storage technologies storage_share = demand / n_storage for set_point_name, commodity in zip( self.storage_set_point_names, self.storage_commodity_names ): if commodity == self.commodity: - outputs[set_point_name] = storage_share + if f"_{commodity}_demand" in set_point_name: + # storage tech has a controller, output combined demand (always positive) + # TODO: update to output whatever is input to storage + storage_share + outputs[set_point_name] = np.clip(storage_share, a_min=0.0, a_max=None) + else: + # storage tech does not have a controller, + # output set point (charge/discharge) command + # charge when remaining demand is negative + # discharge when remaining demand is positive + outputs[set_point_name] = storage_share for tech_name, in_name in zip(self.storage_techs, self.storage_input_names): if self.commodity in self._get_commodity_for_tech(tech_name): diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index 22e2340b3..79c1e56ef 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -97,3 +97,22 @@ def test_slc_yes_hydrogen(subtests, temp_copy_of_example): with subtests.test("wind farm generates power"): assert wind_out.sum() > 0 + + +@pytest.mark.unit +@pytest.mark.parametrize( + "example_folder,resource_example_folder", + [("35_system_level_control/battery_with_controller", None)], +) +def test_slc_battery_with_controller(subtests, temp_copy_of_example): + # TODO: this test still needs to be completed! + example_folder = temp_copy_of_example + + model = H2IntegrateModel(example_folder / "wind_ng_demand.yaml") + + model.run() + + wind_out = model.prob.get_val("wind.electricity_out") + + with subtests.test("wind farm generates power"): + assert wind_out.sum() > 0 diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 4327bcb47..eb487fe57 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -555,6 +555,20 @@ def _classify_slc_technologies(self): if e[-1] is not None } + # Check if storage models have a controller + storage_tech_to_control = {} + for tech in storage_techs: + control_model = ( + self.technology_config["technologies"][tech] + .get("control_strategy", {}) + .get("model", None) + ) + if control_model is None: + storage_tech_to_control[tech] = False + else: + # storage model does use a controller + storage_tech_to_control[tech] = True + # Remove feedstocks and connectors techs_to_connect = set(curtailable_techs + dispatchable_techs + storage_techs) tech_to_commodities = { @@ -570,6 +584,8 @@ def _classify_slc_technologies(self): slc_config["dispatchable_techs"] = dispatchable_techs slc_config["storage_techs"] = storage_techs slc_config["tech_to_commodity"] = tech_to_commodities + slc_config["storage_techs_to_control"] = storage_tech_to_control + slc_config["technology_graph"] = self.technology_graph def add_system_level_controller(self): """Add the DemandFollowingControl component and configure the plant solver. @@ -640,10 +656,27 @@ def add_system_level_controller(self): f"system_level_controller.{tech_name}_rated_{commodity}_production", ) - self.plant.connect( - f"system_level_controller.{tech_name}_{commodity}_set_point", - f"{tech_name}.{commodity}_set_point", - ) + if tech_list == "storage_techs": + if slc_config["storage_techs_to_control"][tech_name]: + # storage has its own controller + # provide demand to storage controller, + # storage controller will provide set-point to performance model + self.plant.connect( + f"system_level_controller.{tech_name}_{commodity}_demand", + f"{tech_name}.{commodity}_demand", + ) + + else: + # storage doesnt have its own controller, it takes in set-point + self.plant.connect( + f"system_level_controller.{tech_name}_{commodity}_set_point", + f"{tech_name}.{commodity}_set_point", + ) + else: + self.plant.connect( + f"system_level_controller.{tech_name}_{commodity}_set_point", + f"{tech_name}.{commodity}_set_point", + ) # 4. For cost-aware strategies, connect marginal costs from cost models if strategy_name in ("CostMinimizationControl", "ProfitMaximizationControl"): From 23a251a37c16d7df9dfff676d7e192b3e4f0c149 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 5 May 2026 15:48:12 -0600 Subject: [PATCH 035/105] fix so curtailment only applied if using SLC --- h2integrate/core/model_baseclasses.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/h2integrate/core/model_baseclasses.py b/h2integrate/core/model_baseclasses.py index f4ad59d4c..77a1f42b6 100644 --- a/h2integrate/core/model_baseclasses.py +++ b/h2integrate/core/model_baseclasses.py @@ -126,18 +126,19 @@ def apply_curtailment(self, outputs): Should be called at the end of each curtailable model's ``compute()`` method after the raw production has been written to ``outputs[f"{commodity}_out"]``. """ - if getattr(self, "_control_classifier", None) != "curtailable": - return + if "system_level_controller" in self.options["plant_config"]: + if getattr(self, "_control_classifier", None) != "curtailable": + return - commodity_out_key = f"{self.commodity}_out" - uncurtailed_key = f"uncurtailed_{self.commodity}_out" - set_point_key = f"{self.commodity}_set_point" + commodity_out_key = f"{self.commodity}_out" + uncurtailed_key = f"uncurtailed_{self.commodity}_out" + set_point_key = f"{self.commodity}_set_point" - uncurtailed = np.array(outputs[commodity_out_key]) - outputs[uncurtailed_key] = uncurtailed + uncurtailed = np.array(outputs[commodity_out_key]) + outputs[uncurtailed_key] = uncurtailed - set_point = self._inputs[set_point_key] - outputs[commodity_out_key] = np.minimum(uncurtailed, set_point) + set_point = self._inputs[set_point_key] + outputs[commodity_out_key] = np.minimum(uncurtailed, set_point) def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): """ From b271026e6df8475d8f045a421d0666fb16c73132 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 5 May 2026 16:24:00 -0600 Subject: [PATCH 036/105] made slc_config an input option --- .../system_level/system_level_control_base.py | 3 ++- h2integrate/core/h2integrate_model.py | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 47157f9b7..67145ae75 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -26,10 +26,11 @@ def initialize(self): self.options.declare("driver_config", types=dict) self.options.declare("plant_config", types=dict) self.options.declare("tech_config", types=dict) + self.options.declare("slc_config", types=dict) def setup(self): plant_config = self.options["plant_config"] - slc_config = plant_config["system_level_control"] + slc_config = self.options["slc_config"] self.n_timesteps = plant_config["plant"]["simulation"]["n_timesteps"] diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index eb487fe57..e10d05fb1 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -88,8 +88,8 @@ def __init__(self, config_input): # add system-level controller if configured if self.slc: - self._classify_slc_technologies() - self.add_system_level_controller() + slc_config = self._classify_slc_technologies() + self.add_system_level_controller(slc_config) # connect technologies # technologies are connected within the `technology_interconnections` section of the @@ -478,7 +478,7 @@ def _classify_slc_technologies(self): Results are written into ``self.plant_config["system_level_control"]`` so they are available to the ``DemandFollowingControl`` component at setup time. """ - slc_config = self.plant_config["system_level_control"] + slc_config = {} # self.plant_config["system_level_control"] technologies = self.technology_config.get("technologies", {}) # Identify the (single) demand technology @@ -586,8 +586,9 @@ def _classify_slc_technologies(self): slc_config["tech_to_commodity"] = tech_to_commodities slc_config["storage_techs_to_control"] = storage_tech_to_control slc_config["technology_graph"] = self.technology_graph + return slc_config - def add_system_level_controller(self): + def add_system_level_controller(self, slc_config): """Add the DemandFollowingControl component and configure the plant solver. This method: @@ -597,7 +598,6 @@ def add_system_level_controller(self): 4. Creates connections between the controller and each technology 5. For cost/profit strategies, connects marginal cost inputs """ - slc_config = self.plant_config["system_level_control"] # Map user-facing solver names to OpenMDAO solver classes solver_map = { From c4d95bbeac19ed7c86f1ebcddf8e0b1d407c264d Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 5 May 2026 16:40:40 -0600 Subject: [PATCH 037/105] actually make it so slc get option of slc conig and fixed bug in apply_curtailment --- .../profit_maximization/plant_config.yaml | 3 ++- .../system_level/profit_maximization_control.py | 15 +++++++++++---- .../system_level/test/test_slc_examples.py | 2 +- h2integrate/core/h2integrate_model.py | 5 ++++- h2integrate/core/model_baseclasses.py | 2 +- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/examples/35_system_level_control/profit_maximization/plant_config.yaml b/examples/35_system_level_control/profit_maximization/plant_config.yaml index d3b56d109..5aa2635d9 100644 --- a/examples/35_system_level_control/profit_maximization/plant_config.yaml +++ b/examples/35_system_level_control/profit_maximization/plant_config.yaml @@ -25,6 +25,7 @@ plant: dt: 3600 system_level_control: control_strategy: ProfitMaximizationControl - commodity_sell_price: 0.06 # $/kWh default; overridden in run script + control_parameters: + commodity_sell_price: 0.06 # $/kWh default; overridden in run script solver_name: gauss_seidel max_iter: 20 diff --git a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py index a5561c7d0..0d3c12733 100644 --- a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py +++ b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py @@ -1,10 +1,17 @@ import numpy as np +from attrs import field, define +from h2integrate.core.utilities import BaseConfig from h2integrate.control.control_strategies.system_level.system_level_control_base import ( SystemLevelControlBase, ) +@define(kw_only=True) +class ProfitMaximizationControlConfig(BaseConfig): + commodity_sell_price: float = field(default=0.0) + + class ProfitMaximizationControl(SystemLevelControlBase): """Profit-maximizing system-level controller. @@ -29,13 +36,13 @@ class ProfitMaximizationControl(SystemLevelControlBase): def setup(self): super().setup() - slc_config = self.options["plant_config"]["system_level_control"] - + config = ProfitMaximizationControlConfig.from_dict( + self.options["plant_config"]["system_level_control"]["control_parameters"] + ) # Commodity sell price — user-set in config, can be scalar or time-varying - default_sell_price = slc_config.get("commodity_sell_price", 0.0) self.add_input( "commodity_sell_price", - val=default_sell_price, + val=config.commodity_sell_price, shape=self.n_timesteps, units=f"USD/({self.commodity_units}*h)", desc=f"Sell price per unit of {self.commodity}", diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index 79c1e56ef..e973163d8 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -69,7 +69,7 @@ def test_slc_profit_max(subtests, temp_copy_of_example): model.setup() model.prob.set_val( - "plant.system_level_controller.commodity_sell_price", + "system_level_controller.commodity_sell_price", sell_price, units="USD/(kW*h)", ) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index e10d05fb1..b1b434c99 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -607,7 +607,9 @@ def add_system_level_controller(self, slc_config): } # 1. Select controller class based on strategy - strategy_name = slc_config.get("control_strategy", "DemandFollowingControl") + strategy_name = self.plant_config["system_level_control"].get( + "control_strategy", "DemandFollowingControl" + ) slc_cls = self.supported_models.get(strategy_name) if slc_cls is None: raise ValueError( @@ -619,6 +621,7 @@ def add_system_level_controller(self, slc_config): driver_config=self.driver_config, plant_config=self.plant_config, tech_config=self.technology_config, + slc_config=slc_config, ) self.plant.add_subsystem("system_level_controller", slc_comp) diff --git a/h2integrate/core/model_baseclasses.py b/h2integrate/core/model_baseclasses.py index 77a1f42b6..3cbe96e18 100644 --- a/h2integrate/core/model_baseclasses.py +++ b/h2integrate/core/model_baseclasses.py @@ -126,7 +126,7 @@ def apply_curtailment(self, outputs): Should be called at the end of each curtailable model's ``compute()`` method after the raw production has been written to ``outputs[f"{commodity}_out"]``. """ - if "system_level_controller" in self.options["plant_config"]: + if "system_level_control" in self.options["plant_config"]: if getattr(self, "_control_classifier", None) != "curtailable": return From 8de0ff193e21e48edbcf8ee2a87a69598a775dc1 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 5 May 2026 16:42:24 -0600 Subject: [PATCH 038/105] added check for slc in apply_curtailment() method --- h2integrate/core/model_baseclasses.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/h2integrate/core/model_baseclasses.py b/h2integrate/core/model_baseclasses.py index f4ad59d4c..3cbe96e18 100644 --- a/h2integrate/core/model_baseclasses.py +++ b/h2integrate/core/model_baseclasses.py @@ -126,18 +126,19 @@ def apply_curtailment(self, outputs): Should be called at the end of each curtailable model's ``compute()`` method after the raw production has been written to ``outputs[f"{commodity}_out"]``. """ - if getattr(self, "_control_classifier", None) != "curtailable": - return + if "system_level_control" in self.options["plant_config"]: + if getattr(self, "_control_classifier", None) != "curtailable": + return - commodity_out_key = f"{self.commodity}_out" - uncurtailed_key = f"uncurtailed_{self.commodity}_out" - set_point_key = f"{self.commodity}_set_point" + commodity_out_key = f"{self.commodity}_out" + uncurtailed_key = f"uncurtailed_{self.commodity}_out" + set_point_key = f"{self.commodity}_set_point" - uncurtailed = np.array(outputs[commodity_out_key]) - outputs[uncurtailed_key] = uncurtailed + uncurtailed = np.array(outputs[commodity_out_key]) + outputs[uncurtailed_key] = uncurtailed - set_point = self._inputs[set_point_key] - outputs[commodity_out_key] = np.minimum(uncurtailed, set_point) + set_point = self._inputs[set_point_key] + outputs[commodity_out_key] = np.minimum(uncurtailed, set_point) def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): """ From 7a4752a838f092a12139012a86c61de627a44aa5 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 5 May 2026 17:09:43 -0600 Subject: [PATCH 039/105] updated so slc_config is an input option and added control config class for profit max --- .../profit_maximization/plant_config.yaml | 3 ++- .../profit_maximization_control.py | 14 ++++++++--- .../system_level/system_level_control_base.py | 3 ++- h2integrate/core/h2integrate_model.py | 25 ++++++++++++------- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/examples/35_system_level_control/profit_maximization/plant_config.yaml b/examples/35_system_level_control/profit_maximization/plant_config.yaml index d3b56d109..5aa2635d9 100644 --- a/examples/35_system_level_control/profit_maximization/plant_config.yaml +++ b/examples/35_system_level_control/profit_maximization/plant_config.yaml @@ -25,6 +25,7 @@ plant: dt: 3600 system_level_control: control_strategy: ProfitMaximizationControl - commodity_sell_price: 0.06 # $/kWh default; overridden in run script + control_parameters: + commodity_sell_price: 0.06 # $/kWh default; overridden in run script solver_name: gauss_seidel max_iter: 20 diff --git a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py index a5561c7d0..5e289ee84 100644 --- a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py +++ b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py @@ -1,10 +1,17 @@ import numpy as np +from attrs import field, define +from h2integrate.core.utilities import BaseConfig from h2integrate.control.control_strategies.system_level.system_level_control_base import ( SystemLevelControlBase, ) +@define(kw_only=True) +class ProfitMaximizationControlConfig(BaseConfig): + commodity_sell_price: float = field(default=0.0) + + class ProfitMaximizationControl(SystemLevelControlBase): """Profit-maximizing system-level controller. @@ -29,13 +36,14 @@ class ProfitMaximizationControl(SystemLevelControlBase): def setup(self): super().setup() - slc_config = self.options["plant_config"]["system_level_control"] + config = ProfitMaximizationControlConfig.from_dict( + self.options["plant_config"]["system_level_control"]["control_parameters"] + ) # Commodity sell price — user-set in config, can be scalar or time-varying - default_sell_price = slc_config.get("commodity_sell_price", 0.0) self.add_input( "commodity_sell_price", - val=default_sell_price, + val=config.commodity_sell_price, shape=self.n_timesteps, units=f"USD/({self.commodity_units}*h)", desc=f"Sell price per unit of {self.commodity}", diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 99bba05e9..dfc9dc1ef 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -26,10 +26,11 @@ def initialize(self): self.options.declare("driver_config", types=dict) self.options.declare("plant_config", types=dict) self.options.declare("tech_config", types=dict) + self.options.declare("slc_config", types=dict) def setup(self): plant_config = self.options["plant_config"] - slc_config = plant_config["system_level_control"] + slc_config = self.options["slc_config"] self.n_timesteps = plant_config["plant"]["simulation"]["n_timesteps"] diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 4327bcb47..e0c71c9de 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -88,8 +88,8 @@ def __init__(self, config_input): # add system-level controller if configured if self.slc: - self._classify_slc_technologies() - self.add_system_level_controller() + slc_config = self._classify_slc_technologies() + self.add_system_level_controller(slc_config) # connect technologies # technologies are connected within the `technology_interconnections` section of the @@ -478,7 +478,7 @@ def _classify_slc_technologies(self): Results are written into ``self.plant_config["system_level_control"]`` so they are available to the ``DemandFollowingControl`` component at setup time. """ - slc_config = self.plant_config["system_level_control"] + slc_config = {} technologies = self.technology_config.get("technologies", {}) # Identify the (single) demand technology @@ -570,8 +570,9 @@ def _classify_slc_technologies(self): slc_config["dispatchable_techs"] = dispatchable_techs slc_config["storage_techs"] = storage_techs slc_config["tech_to_commodity"] = tech_to_commodities + return slc_config - def add_system_level_controller(self): + def add_system_level_controller(self, slc_config): """Add the DemandFollowingControl component and configure the plant solver. This method: @@ -581,7 +582,6 @@ def add_system_level_controller(self): 4. Creates connections between the controller and each technology 5. For cost/profit strategies, connects marginal cost inputs """ - slc_config = self.plant_config["system_level_control"] # Map user-facing solver names to OpenMDAO solver classes solver_map = { @@ -591,7 +591,9 @@ def add_system_level_controller(self): } # 1. Select controller class based on strategy - strategy_name = slc_config.get("control_strategy", "DemandFollowingControl") + strategy_name = self.plant_config["system_level_control"].get( + "control_strategy", "DemandFollowingControl" + ) slc_cls = self.supported_models.get(strategy_name) if slc_cls is None: raise ValueError( @@ -603,6 +605,7 @@ def add_system_level_controller(self): driver_config=self.driver_config, plant_config=self.plant_config, tech_config=self.technology_config, + slc_config=slc_config, ) self.plant.add_subsystem("system_level_controller", slc_comp) @@ -616,9 +619,13 @@ def add_system_level_controller(self): ) solver = solver_cls() # TODO: make a config for the below defaults - solver.options["maxiter"] = slc_config.get("max_iter", 20) - solver.options["atol"] = slc_config.get("convergence_tolerance", 1e-6) - solver.options["rtol"] = slc_config.get("convergence_tolerance", 1e-6) + solver.options["maxiter"] = self.plant_config["system_level_control"].get("max_iter", 20) + solver.options["atol"] = self.plant_config["system_level_control"].get( + "convergence_tolerance", 1e-6 + ) + solver.options["rtol"] = self.plant_config["system_level_control"].get( + "convergence_tolerance", 1e-6 + ) solver.options["iprint"] = 2 # print convergence at each iteration self.plant.nonlinear_solver = solver self.plant.linear_solver = om.DirectSolver() From 5881e07c56f358e1ff97f8f4623100a6bc6f2786 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Tue, 5 May 2026 17:15:11 -0600 Subject: [PATCH 040/105] Adding marginal_cost calcs --- .../profit_maximization/plant_config.yaml | 2 + .../profit_maximization/run_profit_max.py | 14 +- .../profit_maximization/tech_config.yaml | 1 - .../system_level/cost_minimization_control.py | 32 +- .../profit_maximization_control.py | 31 +- .../system_level/system_level_control_base.py | 239 +++++++- .../system_level/test/test_slc_controllers.py | 539 ++++++++++++++++++ h2integrate/core/h2integrate_model.py | 31 +- h2integrate/core/model_baseclasses.py | 13 - 9 files changed, 838 insertions(+), 64 deletions(-) create mode 100644 h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py diff --git a/examples/35_system_level_control/profit_maximization/plant_config.yaml b/examples/35_system_level_control/profit_maximization/plant_config.yaml index d3b56d109..532ba7391 100644 --- a/examples/35_system_level_control/profit_maximization/plant_config.yaml +++ b/examples/35_system_level_control/profit_maximization/plant_config.yaml @@ -26,5 +26,7 @@ plant: system_level_control: control_strategy: ProfitMaximizationControl commodity_sell_price: 0.06 # $/kWh default; overridden in run script + cost_per_tech: + natural_gas_plant: feedstock # use upstream feedstock (ng_feedstock) VarOpEx solver_name: gauss_seidel max_iter: 20 diff --git a/examples/35_system_level_control/profit_maximization/run_profit_max.py b/examples/35_system_level_control/profit_maximization/run_profit_max.py index 77e404933..1aa35b721 100644 --- a/examples/35_system_level_control/profit_maximization/run_profit_max.py +++ b/examples/35_system_level_control/profit_maximization/run_profit_max.py @@ -59,22 +59,22 @@ fig, axes = plt.subplots(4, 1, figsize=(14, 12), sharex=True) # Panel 1: stacked supply vs demand -axes[0].fill_between(hours, 0, wind_out, alpha=0.7, color="tab:blue", label="Wind") +axes[0].fill_between(hours, 0, ng_out, alpha=0.7, color="tab:orange", label="Natural Gas") axes[0].fill_between( hours, - wind_out, - wind_out + batt_discharge, + ng_out, + ng_out + batt_discharge, alpha=0.7, color="tab:purple", label="Battery Discharge", ) axes[0].fill_between( hours, - wind_out + batt_discharge, - wind_out + batt_discharge + ng_out, + ng_out + batt_discharge, + ng_out + batt_discharge + wind_out, alpha=0.7, - color="tab:orange", - label="Natural Gas", + color="tab:blue", + label="Wind", ) axes[0].plot(hours, demand, "k--", linewidth=1.5, label="Demand") axes[0].set_ylabel("Power (kW)") diff --git a/examples/35_system_level_control/profit_maximization/tech_config.yaml b/examples/35_system_level_control/profit_maximization/tech_config.yaml index c9097a505..b498acf4a 100644 --- a/examples/35_system_level_control/profit_maximization/tech_config.yaml +++ b/examples/35_system_level_control/profit_maximization/tech_config.yaml @@ -63,7 +63,6 @@ technologies: fixed_opex_per_kw_per_year: 10.0 variable_opex_per_mwh: 0.0 cost_year: 2023 - marginal_cost: 0.05 # $/kWh — NG dispatch cost electrical_load_demand: performance_model: model: GenericDemandComponent diff --git a/h2integrate/control/control_strategies/system_level/cost_minimization_control.py b/h2integrate/control/control_strategies/system_level/cost_minimization_control.py index 3ba2d5366..3c125d305 100644 --- a/h2integrate/control/control_strategies/system_level/cost_minimization_control.py +++ b/h2integrate/control/control_strategies/system_level/cost_minimization_control.py @@ -15,26 +15,20 @@ class CostMinimizationControl(SystemLevelControlBase): 3. Dispatchable techs are dispatched in ascending marginal-cost order, each up to its rated capacity, until remaining demand is met. - Each dispatchable technology must have a ``marginal_cost`` input - ($/commodity_rate_unit*h, e.g. $/kWh) representing its variable cost - per unit of production. These are connected from cost model outputs - or set as defaults in the plant config. + Marginal costs are configured via ``cost_per_tech`` in the + ``system_level_control`` section of ``plant_config``. Each + dispatchable technology's entry can be: + + - A numeric value ($/commodity_unit, e.g. 0.05 for $0.05/kWh) + - ``"buy_price"`` - use the technology's purchase price + - ``"VarOpEx"`` - derive from VarOpEx / total production """ def setup(self): super().setup() - # Add marginal cost inputs for dispatchable techs - self.dispatchable_marginal_cost_names = [] - for tech_name in self.dispatchable_techs: - mc_name = f"{tech_name}_marginal_cost" - self.add_input( - mc_name, - val=0.0, - units=f"USD/({self.commodity_units}*h)", - desc=f"Marginal cost of {self.commodity} from {tech_name}", - ) - self.dispatchable_marginal_cost_names.append(mc_name) + # Set up marginal cost inputs based on cost_per_tech config + self._setup_marginal_costs() def compute(self, inputs, outputs): demand = inputs[self.demand_input_name].copy() @@ -48,9 +42,11 @@ def compute(self, inputs, outputs): # 3. Merit-order dispatch: cheapest dispatchable first remaining = np.maximum(demand, 0.0) - # Collect marginal costs and sort by ascending cost - marginal_costs = np.array([inputs[mc][0] for mc in self.dispatchable_marginal_cost_names]) - dispatch_order = np.argsort(marginal_costs) + marginal_costs = self._compute_marginal_costs(inputs) + + # Merit order: sort by mean marginal cost (cheapest first) + mean_costs = np.array([mc.mean() for mc in marginal_costs]) + dispatch_order = np.argsort(mean_costs) # Initialize all dispatchable set_points to zero for set_point_name in self.dispatchable_set_point_names: diff --git a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py index a5561c7d0..9ad50feed 100644 --- a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py +++ b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py @@ -22,8 +22,13 @@ class ProfitMaximizationControl(SystemLevelControlBase): ``plant_config["system_level_control"]["commodity_sell_price"]`` must be set ($/(commodity_rate_unit*h), e.g. $/kWh). - Each dispatchable technology must have a ``marginal_cost`` input - representing its variable cost per unit of production. + Marginal costs are configured via ``cost_per_tech`` in the + ``system_level_control`` section of ``plant_config``. Each + dispatchable technology's entry can be: + + - A numeric value ($/commodity_unit, e.g. 0.05 for $0.05/kWh) + - ``"buy_price"`` — use the technology's purchase price + - ``"VarOpEx"`` — derive from VarOpEx / total production """ def setup(self): @@ -41,17 +46,8 @@ def setup(self): desc=f"Sell price per unit of {self.commodity}", ) - # Add marginal cost inputs for dispatchable techs - self.dispatchable_marginal_cost_names = [] - for tech_name in self.dispatchable_techs: - mc_name = f"{tech_name}_marginal_cost" - self.add_input( - mc_name, - val=0.0, - units=f"USD/({self.commodity_units}*h)", - desc=f"Marginal cost of {self.commodity} from {tech_name}", - ) - self.dispatchable_marginal_cost_names.append(mc_name) + # Set up marginal cost inputs based on cost_per_tech config + self._setup_marginal_costs() def compute(self, inputs, outputs): demand = inputs[self.demand_input_name].copy() @@ -66,8 +62,11 @@ def compute(self, inputs, outputs): # 3. Profit-driven merit-order dispatch remaining = np.maximum(demand, 0.0) - marginal_costs = np.array([inputs[mc][0] for mc in self.dispatchable_marginal_cost_names]) - dispatch_order = np.argsort(marginal_costs) + marginal_costs = self._compute_marginal_costs(inputs) + + # Merit order: sort by mean marginal cost (cheapest first) + mean_costs = np.array([mc.mean() for mc in marginal_costs]) + dispatch_order = np.argsort(mean_costs) # Initialize all dispatchable set_points to zero for set_point_name in self.dispatchable_set_point_names: @@ -75,7 +74,7 @@ def compute(self, inputs, outputs): # Dispatch only where profitable (element-wise comparison) for idx in dispatch_order: - mc = marginal_costs[idx] + mc = marginal_costs[idx] # per-timestep array profitable = mc < sell_price # boolean mask per timestep set_point_name = self.dispatchable_set_point_names[idx] diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 99bba05e9..7947a23db 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -176,13 +176,13 @@ def _setup_tech_category(self, category, tech_list, demand_profile=None): """Create OpenMDAO I/O variables for all technologies in a given category. This single method handles curtailable, dispatchable, and storage - technologies. The logic is identical for all three categories — + technologies. The logic is identical for all three categories — iterate over each technology's commodities and register the appropriate inputs (production output, rated capacity) and output (control set-point) — with one difference: * **Curtailable / Storage** (``demand_profile is None``): - ``initial_set_point`` is ``0.0``. Curtailable techs are later + ``initial_set_point`` is ``0.0``. Curtailable techs are later assigned set-points equal to their rated production; storage techs get set-points computed at run-time in ``_dispatch_storage``. @@ -204,11 +204,11 @@ def _setup_tech_category(self, category, tech_list, demand_profile=None): Args: category (str): One of ``"curtailable"``, ``"dispatchable"``, - or ``"storage"``. Used to name the attribute lists. + or ``"storage"``. Used to name the attribute lists. tech_list (list[str]): Technology names belonging to this category (e.g. ``self.curtailable_techs``). demand_profile (float | np.ndarray | None, optional): - Only relevant for **dispatchable** techs. When provided, the + Only relevant for **dispatchable** techs. When provided, the demand is split equally among dispatchable techs that produce the demanded commodity to set a non-zero ``initial_set_point``. For curtailable and storage techs, leave as ``None`` (default). @@ -359,3 +359,234 @@ def _get_commodity_for_tech(self, tech_name): tech_commodities = [e[1] for e in self.techs_to_commodities if e[0] == tech_name] return tech_commodities + + # ------------------------------------------------------------------ + # Marginal-cost helpers for cost-aware controllers + # ------------------------------------------------------------------ + + def _setup_marginal_costs(self): + """Set up marginal cost inputs for dispatchable techs based on ``cost_per_tech``. + + Should be called from ``setup()`` of cost-aware controllers + (e.g., ``CostMinimizationControl``, ``ProfitMaximizationControl``). + + Reads ``cost_per_tech`` from + ``plant_config["system_level_control"]`` and creates appropriate + OpenMDAO inputs for each dispatchable technology: + + - Numeric value (e.g. ``0.05``): used directly as a constant + marginal cost in ``USD/(commodity_rate_unit*h)``. No additional + inputs or connections are required. + - ``"buy_price"``: creates a ``{tech_name}_buy_price`` input + whose default value is read from the technology's cost config + (``electricity_buy_price`` for Grid, ``price`` for Feedstock). + Can be scalar or time-varying and may be overridden at runtime + via ``prob.set_val()``. + - ``"VarOpEx"``: creates a ``{tech_name}_VarOpEx`` input + connected to the cost model's ``VarOpEx`` output. The + per-unit marginal cost is computed at run time by dividing + ``VarOpEx`` by the total production. + - ``"feedstock"``: looks up ``technology_interconnections`` to + find all feedstock technologies connected upstream of the + dispatchable tech, sums their ``VarOpEx`` outputs, and + divides by the tech's total production. Handles the common + single-feedstock case as well as multiple feedstock streams. + """ + slc_config = self.options["plant_config"]["system_level_control"] + self.cost_per_tech = slc_config.get("cost_per_tech", {}) + self.dt_hours = self.options["plant_config"]["plant"]["simulation"]["dt"] / 3600 + hours_simulated = self.dt_hours * self.n_timesteps + self.fraction_of_year_simulated = hours_simulated / 8760 + plant_life = int(self.options["plant_config"]["plant"]["plant_life"]) + + self.dispatchable_marginal_cost_types = [] + + for tech_name in self.dispatchable_techs: + cost_spec = self.cost_per_tech.get(tech_name, 0.0) + + if isinstance(cost_spec, int | float): + self.dispatchable_marginal_cost_types.append(("scalar", cost_spec)) + + elif cost_spec == "buy_price": + # Read default buy price from tech config + tech_config = self.options["tech_config"] + tech_def = tech_config.get("technologies", {}).get(tech_name, {}) + model_inputs = tech_def.get("model_inputs", {}) + cost_params = model_inputs.get("cost_parameters", {}) + shared_params = model_inputs.get("shared_parameters", {}) + all_params = {**shared_params, **cost_params} + + default_price = all_params.get( + "electricity_buy_price", + all_params.get("price", 0.0), + ) + + self.add_input( + f"{tech_name}_buy_price", + val=default_price, + shape=self.n_timesteps, + units=f"USD/({self.commodity_units}*h)", + desc=f"Buy price for {tech_name}", + ) + self.dispatchable_marginal_cost_types.append(("buy_price", tech_name)) + + elif cost_spec == "VarOpEx": + self.add_input( + f"{tech_name}_VarOpEx", + val=0.0, + shape=plant_life, + units="USD/year", + desc=f"Variable operating expenditure from {tech_name}", + ) + self.dispatchable_marginal_cost_types.append(("VarOpEx", tech_name)) + + elif cost_spec == "feedstock": + # Find feedstock techs connected upstream of this tech + feedstock_names = self._find_feedstock_techs(tech_name) + if not feedstock_names: + raise ValueError( + f"cost_per_tech '{cost_spec}' for '{tech_name}' requires " + f"at least one feedstock connected upstream in " + f"technology_interconnections, but none were found." + ) + for feedstock_name in feedstock_names: + self.add_input( + f"{feedstock_name}_VarOpEx", + val=0.0, + shape=plant_life, + units="USD/year", + desc=f"Variable operating expenditure from feedstock {feedstock_name}", + ) + self.dispatchable_marginal_cost_types.append( + ("feedstock", (tech_name, feedstock_names)) + ) + + else: + raise ValueError( + f"Unknown cost_per_tech value '{cost_spec}' for '{tech_name}'. " + f"Must be a numeric value, 'buy_price', 'VarOpEx', or 'feedstock'." + ) + + def _compute_marginal_costs(self, inputs): + """Compute per-timestep marginal costs for each dispatchable tech. + + Returns: + list[np.ndarray]: marginal cost arrays, one per dispatchable + tech, each of shape ``(n_timesteps,)``. + """ + marginal_costs = [] + + for marginal_cost_type, marginal_cost_data in self.dispatchable_marginal_cost_types: + if marginal_cost_type == "scalar": + marginal_cost = np.full(self.n_timesteps, marginal_cost_data) + elif marginal_cost_type == "buy_price": + marginal_cost = self._buy_price_marginal_cost(inputs, marginal_cost_data) + elif marginal_cost_type == "VarOpEx": + marginal_cost = self._varopex_marginal_cost(inputs, marginal_cost_data) + elif marginal_cost_type == "feedstock": + marginal_cost = self._feedstock_marginal_cost(inputs, marginal_cost_data) + else: + marginal_cost = np.zeros(self.n_timesteps) + + marginal_costs.append(marginal_cost) + + return marginal_costs + + def _buy_price_marginal_cost(self, inputs, tech_name): + """Compute marginal cost from buy price. + + Returns a per-timestep marginal cost array equal to the + technology's buy price (scalar or time-varying). + """ + return np.broadcast_to(inputs[f"{tech_name}_buy_price"], self.n_timesteps).copy() + + def _varopex_marginal_cost(self, inputs, tech_name): + """Compute marginal cost from VarOpEx and commodity output. + + Divides the first-year ``VarOpEx`` (``$/year``) by the + annualized total production to obtain an average marginal cost + in ``$/(commodity_amount_unit)``. + + Returns a constant per-timestep array. + """ + varopex = inputs[f"{tech_name}_VarOpEx"] # $/year, shape=plant_life + + # Use commodity_out already connected for this dispatchable tech + tech_commodities = self._get_commodity_for_tech(tech_name) + commodity = tech_commodities[0] if tech_commodities else self.commodity + + production = inputs[f"{tech_name}_{commodity}_out"] # rate units, shape=n_timesteps + total_production = production.sum() * self.dt_hours + + if total_production > 0: + annual_production = total_production / self.fraction_of_year_simulated + marginal_cost_scalar = varopex[0] / annual_production + else: + marginal_cost_scalar = 0.0 + + return np.full(self.n_timesteps, marginal_cost_scalar) + + def _find_feedstock_techs(self, tech_name): + """Find feedstock technologies connected upstream of tech_name. + + Scans ``technology_interconnections`` for connections whose + destination is tech_name and whose source uses + ``FeedstockPerformanceModel`` or ``FeedstockCostModel``. + + Args: + tech_name (str): the dispatchable technology name. + + Returns: + list[str]: names of upstream feedstock technologies. + """ + tech_config = self.options["tech_config"] + technologies = tech_config.get("technologies", {}) + interconnections = self.options["plant_config"].get("technology_interconnections", []) + + # Upstream tech names for this dispatchable tech + upstream_techs = [conn[0] for conn in interconnections if conn[1] == tech_name] + + feedstock_names = [] + for upstream in upstream_techs: + tech_def = technologies.get(upstream, {}) + perf_model = tech_def.get("performance_model", {}).get("model", "") + cost_model = tech_def.get("cost_model", {}).get("model", "") + if "Feedstock" in perf_model or "Feedstock" in cost_model: + feedstock_names.append(upstream) + + return feedstock_names + + def _feedstock_marginal_cost(self, inputs, marginal_cost_data): + """Compute marginal cost from upstream feedstock VarOpEx values. + + Sums the first-year ``VarOpEx`` from all feedstock technologies + connected to the dispatchable tech, then divides by the tech's + annualized total production. + + Args: + marginal_cost_data (tuple): ``(tech_name, feedstock_names)`` where + tech_name is the dispatchable tech and feedstock_names + is a list of upstream feedstock technology names. + + Returns: + np.ndarray: constant per-timestep marginal cost array. + """ + tech_name, feedstock_names = marginal_cost_data + + # Sum VarOpEx from all connected feedstocks (first year) + total_varopex = sum(inputs[f"{fs}_VarOpEx"][0] for fs in feedstock_names) + + # Get the tech's production + tech_commodities = self._get_commodity_for_tech(tech_name) + commodity = tech_commodities[0] if tech_commodities else self.commodity + + production = inputs[f"{tech_name}_{commodity}_out"] + total_production = production.sum() * self.dt_hours + + if total_production > 0: + annual_production = total_production / self.fraction_of_year_simulated + marginal_cost_scalar = total_varopex / annual_production + else: + marginal_cost_scalar = 0.0 + + return np.full(self.n_timesteps, marginal_cost_scalar) diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py b/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py new file mode 100644 index 000000000..6d7b5a83b --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py @@ -0,0 +1,539 @@ +"""Unit tests for system-level control base class and all controller strategies.""" + +import numpy as np +import pytest +import openmdao.api as om + +from h2integrate.control.control_strategies.system_level.demand_following_control import ( + DemandFollowingControl, +) +from h2integrate.control.control_strategies.system_level.cost_minimization_control import ( + CostMinimizationControl, +) +from h2integrate.control.control_strategies.system_level.profit_maximization_control import ( + ProfitMaximizationControl, +) + + +def _make_plant_config( + n_timesteps=4, + demand=50000, + curtailable=None, + dispatchable=None, + storage=None, + sell_price=0.06, + cost_per_tech=None, + technology_interconnections=None, +): + """Build a minimal plant_config dict for controller tests.""" + all_techs = (curtailable or []) + (dispatchable or []) + (storage or []) + tech_to_commodity = {(t, "electricity") for t in all_techs} + config = { + "plant": {"simulation": {"n_timesteps": n_timesteps, "dt": 3600}, "plant_life": 30}, + "system_level_control": { + "demand_commodity": "electricity", + "demand_commodity_rate_units": "kW", + "demand_tech": "demand", + "demand_profile": demand, + "curtailable_techs": curtailable or [], + "dispatchable_techs": dispatchable or [], + "storage_techs": storage or [], + "tech_to_commodity": tech_to_commodity, + "commodity_sell_price": sell_price, + "cost_per_tech": cost_per_tech or {}, + }, + } + if technology_interconnections is not None: + config["technology_interconnections"] = technology_interconnections + return config + + +def _build_problem(slc_cls, plant_config, tech_config=None): + """Create and setup an OpenMDAO Problem with the given controller.""" + prob = om.Problem() + prob.model.add_subsystem( + "slc", + slc_cls( + driver_config={}, + plant_config=plant_config, + tech_config=tech_config or {}, + ), + ) + prob.setup() + return prob + + +# --------------------------------------------------------------------------- +# SystemLevelControlBase +# --------------------------------------------------------------------------- +@pytest.mark.unit +class TestSystemLevelControlBase: + """Tests for the abstract base class setup logic.""" + + def test_base_creates_curtailable_io(self): + pc = _make_plant_config(curtailable=["wind"]) + # Use DemandFollowingControl since base is abstract + prob = _build_problem(DemandFollowingControl, pc) + # _var_rel2meta uses relative names (no "slc." prefix) + assert "wind_electricity_out" in prob.model.slc._var_rel2meta + assert "wind_rated_electricity_production" in prob.model.slc._var_rel2meta + assert "wind_electricity_set_point" in prob.model.slc._var_rel2meta + + def test_base_creates_dispatchable_io(self): + pc = _make_plant_config(dispatchable=["ng"]) + prob = _build_problem(DemandFollowingControl, pc) + assert "ng_electricity_out" in prob.model.slc._var_rel2meta + assert "ng_rated_electricity_production" in prob.model.slc._var_rel2meta + assert "ng_electricity_set_point" in prob.model.slc._var_rel2meta + + def test_base_creates_storage_io(self): + pc = _make_plant_config(storage=["battery"]) + prob = _build_problem(DemandFollowingControl, pc) + assert "battery_electricity_out" in prob.model.slc._var_rel2meta + assert "battery_rated_electricity_production" in prob.model.slc._var_rel2meta + assert "battery_electricity_set_point" in prob.model.slc._var_rel2meta + + def test_base_creates_demand_input(self): + pc = _make_plant_config() + prob = _build_problem(DemandFollowingControl, pc) + assert "electricity_demand" in prob.model.slc._var_rel2meta + + def test_backward_compat_alias(self): + """DemandFollowingControl should be an alias for DemandFollowingControl.""" + assert DemandFollowingControl is DemandFollowingControl + + +# --------------------------------------------------------------------------- +# DemandFollowingControl +# --------------------------------------------------------------------------- +@pytest.mark.unit +class TestDemandFollowingControl: + """Tests for the demand-following (equal-share) controller.""" + + def test_equal_share_two_dispatchable(self): + pc = _make_plant_config(dispatchable=["ng1", "ng2"]) + prob = _build_problem(DemandFollowingControl, pc) + prob.set_val("slc.ng1_rated_electricity_production", 80000) + prob.set_val("slc.ng2_rated_electricity_production", 40000) + prob.run_model() + + sp1 = prob.get_val("slc.ng1_electricity_set_point") + sp2 = prob.get_val("slc.ng2_electricity_set_point") + np.testing.assert_allclose(sp1, 25000) + np.testing.assert_allclose(sp2, 25000) + + def test_curtailable_reduces_demand(self): + pc = _make_plant_config(curtailable=["wind"], dispatchable=["ng"]) + prob = _build_problem(DemandFollowingControl, pc) + prob.set_val("slc.wind_electricity_out", [30000, 60000, 50000, 10000]) + prob.set_val("slc.wind_rated_electricity_production", 120000) + prob.set_val("slc.ng_rated_electricity_production", 100000) + prob.run_model() + + ng_sp = prob.get_val("slc.ng_electricity_set_point") + # demand=50k, wind outputs [30k,60k,50k,10k] → remaining = max(0, demand-wind) + expected = np.maximum(50000 - np.array([30000, 60000, 50000, 10000]), 0) + np.testing.assert_allclose(ng_sp, expected) + + def test_storage_absorbs_surplus(self): + pc = _make_plant_config(curtailable=["wind"], storage=["battery"], dispatchable=["ng"]) + prob = _build_problem(DemandFollowingControl, pc) + prob.set_val("slc.wind_electricity_out", [70000, 30000, 50000, 50000]) + prob.set_val("slc.wind_rated_electricity_production", 120000) + prob.set_val("slc.battery_electricity_out", [0, 0, 0, 0]) + prob.set_val("slc.battery_rated_electricity_production", 50000) + prob.set_val("slc.ng_rated_electricity_production", 100000) + prob.run_model() + + batt_sp = prob.get_val("slc.battery_electricity_set_point") + # demand - wind = [50k-70k, 50k-30k, 0, 0] = [-20k, 20k, 0, 0] + expected = np.array([-20000, 20000, 0, 0]) + np.testing.assert_allclose(batt_sp, expected) + + def test_no_techs_runs(self): + """Controller with no techs should still run without error.""" + pc = _make_plant_config() + prob = _build_problem(DemandFollowingControl, pc) + prob.run_model() # should not raise + + +# --------------------------------------------------------------------------- +# CostMinimizationControl +# --------------------------------------------------------------------------- +@pytest.mark.unit +class TestCostMinimizationControl: + """Tests for the merit-order cost-minimization controller.""" + + def test_cheapest_dispatched_first(self): + pc = _make_plant_config( + dispatchable=["cheap", "expensive"], + demand=50000, + cost_per_tech={"cheap": 0.03, "expensive": 0.08}, + ) + prob = _build_problem(CostMinimizationControl, pc) + prob.set_val("slc.cheap_rated_electricity_production", 80000) + prob.set_val("slc.expensive_rated_electricity_production", 40000) + prob.run_model() + + cheap_sp = prob.get_val("slc.cheap_electricity_set_point") + expensive_sp = prob.get_val("slc.expensive_electricity_set_point") + # Cheap can handle all 50k (rated 80k), so expensive gets 0 + np.testing.assert_allclose(cheap_sp, 50000) + np.testing.assert_allclose(expensive_sp, 0) + + def test_overflow_to_expensive(self): + pc = _make_plant_config( + dispatchable=["cheap", "expensive"], + demand=50000, + cost_per_tech={"cheap": 0.03, "expensive": 0.08}, + ) + prob = _build_problem(CostMinimizationControl, pc) + prob.set_val("slc.cheap_rated_electricity_production", 30000) + prob.set_val("slc.expensive_rated_electricity_production", 40000) + prob.run_model() + + cheap_sp = prob.get_val("slc.cheap_electricity_set_point") + expensive_sp = prob.get_val("slc.expensive_electricity_set_point") + # Cheap maxes at 30k, expensive picks up remaining 20k + np.testing.assert_allclose(cheap_sp, 30000) + np.testing.assert_allclose(expensive_sp, 20000) + + def test_with_curtailable_reduces_dispatch(self): + pc = _make_plant_config( + curtailable=["wind"], + dispatchable=["ng"], + demand=50000, + cost_per_tech={"ng": 0.05}, + ) + prob = _build_problem(CostMinimizationControl, pc) + prob.set_val("slc.wind_electricity_out", [40000, 40000, 40000, 40000]) + prob.set_val("slc.wind_rated_electricity_production", 120000) + prob.set_val("slc.ng_rated_electricity_production", 100000) + prob.run_model() + + ng_sp = prob.get_val("slc.ng_electricity_set_point") + # demand 50k - wind 40k = 10k remaining + np.testing.assert_allclose(ng_sp, 10000) + + +# --------------------------------------------------------------------------- +# ProfitMaximizationControl +# --------------------------------------------------------------------------- +@pytest.mark.unit +class TestProfitMaximizationControl: + """Tests for the profit-maximization controller.""" + + def test_unprofitable_tech_not_dispatched(self): + pc = _make_plant_config( + dispatchable=["cheap", "expensive"], + demand=50000, + sell_price=0.06, + cost_per_tech={"cheap": 0.03, "expensive": 0.08}, + ) + prob = _build_problem(ProfitMaximizationControl, pc) + prob.set_val("slc.cheap_rated_electricity_production", 30000) + prob.set_val("slc.expensive_rated_electricity_production", 40000) + prob.set_val("slc.commodity_sell_price", 0.06) + prob.run_model() + + cheap_sp = prob.get_val("slc.cheap_electricity_set_point") + expensive_sp = prob.get_val("slc.expensive_electricity_set_point") + # Cheap (0.03 < 0.06) dispatched up to rated 30k + # Expensive (0.08 >= 0.06) NOT dispatched, demand unmet + np.testing.assert_allclose(cheap_sp, 30000) + np.testing.assert_allclose(expensive_sp, 0) + + def test_all_profitable(self): + pc = _make_plant_config( + dispatchable=["a", "b"], + demand=50000, + sell_price=0.10, + cost_per_tech={"a": 0.03, "b": 0.05}, + ) + prob = _build_problem(ProfitMaximizationControl, pc) + prob.set_val("slc.a_rated_electricity_production", 80000) + prob.set_val("slc.b_rated_electricity_production", 40000) + prob.set_val("slc.commodity_sell_price", 0.10) + prob.run_model() + + a_sp = prob.get_val("slc.a_electricity_set_point") + b_sp = prob.get_val("slc.b_electricity_set_point") + # Both profitable, cheapest first: a gets 50k (rated 80k), b gets 0 + np.testing.assert_allclose(a_sp, 50000) + np.testing.assert_allclose(b_sp, 0) + + def test_none_profitable(self): + pc = _make_plant_config( + dispatchable=["ng"], + demand=50000, + sell_price=0.01, + cost_per_tech={"ng": 0.05}, + ) + prob = _build_problem(ProfitMaximizationControl, pc) + prob.set_val("slc.ng_rated_electricity_production", 100000) + prob.set_val("slc.commodity_sell_price", 0.01) + prob.run_model() + + ng_sp = prob.get_val("slc.ng_electricity_set_point") + # NG cost (0.05) >= sell price (0.01), not dispatched + np.testing.assert_allclose(ng_sp, 0) + + def test_sell_price_from_config(self): + pc = _make_plant_config( + dispatchable=["ng"], + demand=50000, + sell_price=0.10, + cost_per_tech={"ng": 0.03}, + ) + prob = _build_problem(ProfitMaximizationControl, pc) + prob.set_val("slc.ng_rated_electricity_production", 100000) + # Don't set sell_price explicitly — should use config default 0.10 + prob.run_model() + + ng_sp = prob.get_val("slc.ng_electricity_set_point") + # Config sell_price=0.10 > marginal 0.03 → dispatched + np.testing.assert_allclose(ng_sp, 50000) + + def test_time_varying_sell_price(self): + pc = _make_plant_config( + dispatchable=["ng"], + demand=50000, + sell_price=0.06, + cost_per_tech={"ng": 0.05}, + ) + prob = _build_problem(ProfitMaximizationControl, pc) + prob.set_val("slc.ng_rated_electricity_production", 100000) + # Sell price varies: 2 profitable hours, 2 unprofitable + prob.set_val("slc.commodity_sell_price", [0.08, 0.03, 0.10, 0.02]) + prob.run_model() + + ng_sp = prob.get_val("slc.ng_electricity_set_point") + # mc=0.05: profitable when sell>0.05 (hours 0,2), not when sell<0.05 (hours 1,3) + np.testing.assert_allclose(ng_sp, [50000, 0, 50000, 0]) + + def test_buy_price_scalar(self): + """buy_price mode with a scalar buy price from tech config.""" + pc = _make_plant_config( + dispatchable=["grid"], + demand=50000, + sell_price=0.10, + cost_per_tech={"grid": "buy_price"}, + ) + # Add tech config with buy price + tech_config = { + "technologies": { + "grid": { + "model_inputs": { + "cost_parameters": {"electricity_buy_price": 0.04}, + } + } + } + } + prob = om.Problem() + prob.model.add_subsystem( + "slc", + ProfitMaximizationControl(driver_config={}, plant_config=pc, tech_config=tech_config), + ) + prob.setup() + prob.set_val("slc.grid_rated_electricity_production", 100000) + prob.set_val("slc.commodity_sell_price", 0.10) + prob.run_model() + + grid_sp = prob.get_val("slc.grid_electricity_set_point") + # buy_price=0.04 < sell_price=0.10 → dispatched + np.testing.assert_allclose(grid_sp, 50000) + + def test_buy_price_time_varying(self): + """buy_price mode with time-varying prices (override via set_val).""" + pc = _make_plant_config( + dispatchable=["grid"], + demand=50000, + sell_price=0.06, + cost_per_tech={"grid": "buy_price"}, + ) + tech_config = { + "technologies": { + "grid": { + "model_inputs": { + "cost_parameters": {"electricity_buy_price": 0.04}, + } + } + } + } + prob = om.Problem() + prob.model.add_subsystem( + "slc", + ProfitMaximizationControl(driver_config={}, plant_config=pc, tech_config=tech_config), + ) + prob.setup() + prob.set_val("slc.grid_rated_electricity_production", 100000) + prob.set_val("slc.commodity_sell_price", 0.06) + # Time-varying buy price: profitable at hours 0,2; unprofitable at hours 1,3 + prob.set_val("slc.grid_buy_price", [0.03, 0.08, 0.04, 0.09]) + prob.run_model() + + grid_sp = prob.get_val("slc.grid_electricity_set_point") + np.testing.assert_allclose(grid_sp, [50000, 0, 50000, 0]) + + def test_varopex_mode(self): + """VarOpEx mode computes marginal cost from VarOpEx / production.""" + pc = _make_plant_config( + dispatchable=["gen"], + demand=50000, + sell_price=0.10, + cost_per_tech={"gen": "VarOpEx"}, + ) + prob = _build_problem(CostMinimizationControl, pc) + prob.set_val("slc.gen_rated_electricity_production", 100000) + # Set VarOpEx ($/year, shape=plant_life=30) and production + prob.set_val("slc.gen_VarOpEx", np.full(30, 500000.0)) + # Simulate 4 hours of 100 MW production → 400 MWh + prob.set_val("slc.gen_electricity_out", np.full(4, 100000.0)) + prob.run_model() + + gen_sp = prob.get_val("slc.gen_electricity_set_point") + # VarOpEx=500k $/yr, production=100MW*4h=400MWh over 4h + # Annual production = 400 MWh / (4/8760) = 876,000 MWh + # mc = 500k / 876k ≈ 0.571 $/MWh ≈ 0.000571 $/kWh + # This is very cheap, so it should be dispatched fully + np.testing.assert_allclose(gen_sp, 50000) + + def test_cost_per_tech_default_zero(self): + """Techs not listed in cost_per_tech default to zero marginal cost.""" + pc = _make_plant_config( + dispatchable=["ng"], + demand=50000, + sell_price=0.10, + cost_per_tech={}, # Empty: ng defaults to 0.0 + ) + prob = _build_problem(ProfitMaximizationControl, pc) + prob.set_val("slc.ng_rated_electricity_production", 100000) + prob.set_val("slc.commodity_sell_price", 0.10) + prob.run_model() + + ng_sp = prob.get_val("slc.ng_electricity_set_point") + # mc=0.0 < sell_price=0.10 → dispatched + np.testing.assert_allclose(ng_sp, 50000) + + def test_feedstock_single(self): + """feedstock mode: single upstream feedstock drives marginal cost.""" + pc = _make_plant_config( + dispatchable=["ng_plant"], + demand=50000, + sell_price=0.10, + cost_per_tech={"ng_plant": "feedstock"}, + technology_interconnections=[ + ["ng_feed", "ng_plant", "natural_gas", "pipe"], + ], + ) + tech_config = { + "technologies": { + "ng_feed": { + "performance_model": {"model": "FeedstockPerformanceModel"}, + "cost_model": {"model": "FeedstockCostModel"}, + }, + } + } + prob = _build_problem(CostMinimizationControl, pc, tech_config=tech_config) + prob.set_val("slc.ng_plant_rated_electricity_production", 100000) + # Feedstock VarOpEx: $1M/yr; production: 100 MW * 4 h = 400 MWh + prob.set_val("slc.ng_feed_VarOpEx", np.full(30, 1_000_000.0)) + prob.set_val("slc.ng_plant_electricity_out", np.full(4, 100000.0)) + prob.run_model() + + sp = prob.get_val("slc.ng_plant_electricity_set_point") + # Annual production = 400 MWh / (4/8760) = 876,000 MWh + # mc = 1M / 876k ≈ 1.14 $/MWh ≈ 0.00114 $/kWh → very cheap + np.testing.assert_allclose(sp, 50000) + + def test_feedstock_multiple(self): + """feedstock mode: multiple upstream feedstocks are summed.""" + pc = _make_plant_config( + dispatchable=["plant"], + demand=50000, + sell_price=0.10, + cost_per_tech={"plant": "feedstock"}, + technology_interconnections=[ + ["feed_a", "plant", "gas_a", "pipe"], + ["feed_b", "plant", "gas_b", "pipe"], + ["other_tech", "plant", "something", "cable"], + ], + ) + tech_config = { + "technologies": { + "feed_a": { + "performance_model": {"model": "FeedstockPerformanceModel"}, + "cost_model": {"model": "FeedstockCostModel"}, + }, + "feed_b": { + "performance_model": {"model": "FeedstockPerformanceModel"}, + "cost_model": {"model": "FeedstockCostModel"}, + }, + "other_tech": { + "performance_model": {"model": "SomePerformanceModel"}, + }, + } + } + prob = _build_problem(CostMinimizationControl, pc, tech_config=tech_config) + prob.set_val("slc.plant_rated_electricity_production", 100000) + # Two feedstocks: $500k and $300k → total $800k/yr + prob.set_val("slc.feed_a_VarOpEx", np.full(30, 500_000.0)) + prob.set_val("slc.feed_b_VarOpEx", np.full(30, 300_000.0)) + prob.set_val("slc.plant_electricity_out", np.full(4, 100000.0)) + prob.run_model() + + sp = prob.get_val("slc.plant_electricity_set_point") + # Total VarOpEx = 800k, annual production = 876,000 MWh + # mc ≈ 0.913 $/MWh ≈ 0.000913 $/kWh → very cheap + np.testing.assert_allclose(sp, 50000) + + def test_feedstock_profit_max_unprofitable(self): + """feedstock mode in profit max: unprofitable when feedstock costs exceed sell price.""" + pc = _make_plant_config( + dispatchable=["ng_plant"], + demand=50000, + sell_price=0.01, # very low sell price + cost_per_tech={"ng_plant": "feedstock"}, + technology_interconnections=[ + ["ng_feed", "ng_plant", "natural_gas", "pipe"], + ], + ) + tech_config = { + "technologies": { + "ng_feed": { + "performance_model": {"model": "FeedstockPerformanceModel"}, + "cost_model": {"model": "FeedstockCostModel"}, + }, + } + } + prob = _build_problem(ProfitMaximizationControl, pc, tech_config=tech_config) + prob.set_val("slc.ng_plant_rated_electricity_production", 100000) + prob.set_val("slc.commodity_sell_price", 0.01) + # Very expensive feedstock: $100M/yr → high marginal cost + prob.set_val("slc.ng_feed_VarOpEx", np.full(30, 100_000_000.0)) + prob.set_val("slc.ng_plant_electricity_out", np.full(4, 100000.0)) + prob.run_model() + + sp = prob.get_val("slc.ng_plant_electricity_set_point") + # mc = 100M / 876k ≈ 114 $/MWh ≈ 0.114 $/kWh > sell 0.01 → NOT dispatched + np.testing.assert_allclose(sp, 0) + + def test_feedstock_no_feedstock_raises(self): + """feedstock mode raises ValueError when no feedstock is found upstream.""" + pc = _make_plant_config( + dispatchable=["ng_plant"], + demand=50000, + cost_per_tech={"ng_plant": "feedstock"}, + technology_interconnections=[ + ["some_tech", "ng_plant", "electricity", "cable"], + ], + ) + tech_config = { + "technologies": { + "some_tech": { + "performance_model": {"model": "SomePerformanceModel"}, + }, + } + } + with pytest.raises(ValueError, match="at least one feedstock"): + _build_problem(CostMinimizationControl, pc, tech_config=tech_config) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 4327bcb47..c60288e13 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -645,13 +645,34 @@ def add_system_level_controller(self): f"{tech_name}.{commodity}_set_point", ) - # 4. For cost-aware strategies, connect marginal costs from cost models + # 4. For cost-aware strategies, connect cost inputs based on cost_per_tech if strategy_name in ("CostMinimizationControl", "ProfitMaximizationControl"): + cost_per_tech = slc_config.get("cost_per_tech", {}) for tech_name in slc_config["dispatchable_techs"]: - self.plant.connect( - f"{tech_name}.marginal_cost", - f"system_level_controller.{tech_name}_marginal_cost", - ) + cost_spec = cost_per_tech.get(tech_name, 0.0) + if cost_spec == "VarOpEx": + self.plant.connect( + f"{tech_name}.VarOpEx", + f"system_level_controller.{tech_name}_VarOpEx", + ) + elif cost_spec == "feedstock": + # Connect VarOpEx from each upstream feedstock + interconnections = self.plant_config.get("technology_interconnections", []) + technologies = self.technology_config.get("technologies", {}) + for conn in interconnections: + if conn[1] != tech_name: + continue + upstream = conn[0] + tech_def = technologies.get(upstream, {}) + perf_model = tech_def.get("performance_model", {}).get("model", "") + cost_model = tech_def.get("cost_model", {}).get("model", "") + if "Feedstock" in perf_model or "Feedstock" in cost_model: + self.plant.connect( + f"{upstream}.VarOpEx", + f"system_level_controller.{upstream}_VarOpEx", + ) + # buy_price: defaults from tech config, overridable via set_val + # scalar: no connection needed ### Commented out for now; we'll need to determine how to treat demand ### components in the new SLC paradigm. diff --git a/h2integrate/core/model_baseclasses.py b/h2integrate/core/model_baseclasses.py index f4ad59d4c..45d8b98da 100644 --- a/h2integrate/core/model_baseclasses.py +++ b/h2integrate/core/model_baseclasses.py @@ -152,7 +152,6 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): @define(kw_only=True) class CostModelBaseConfig(BaseConfig): cost_year: int = field(converter=int) - marginal_cost: float = field(default=0.0) class CostModelBaseClass(om.ExplicitComponent): @@ -165,7 +164,6 @@ class CostModelBaseClass(om.ExplicitComponent): - CapEx (float): capital expenditure costs in $ - OpEx (float): annual fixed operating expenditure costs in $/year - VarOpEx (float): annual variable operating expenditure costs in $/year - - marginal_cost (float): marginal cost of production for dispatch decisions Discrete Outputs: - cost_year (int): dollar-year corresponding to CapEx and OpEx values. @@ -194,17 +192,6 @@ def setup(self): "cost_year", val=self.config.cost_year, desc="Dollar year for costs" ) - # Marginal cost output for dispatch decisions - model_inputs = self.options["tech_config"].get("model_inputs", {}) - shared = model_inputs.get("shared_parameters", {}) - commodity_rate_units = shared.get("commodity_rate_units", "kW") - self.add_output( - "marginal_cost", - val=self.config.marginal_cost, - units=f"USD/({commodity_rate_units}*h)", - desc="Marginal cost of production for dispatch decisions", - ) - # dt is seconds per timestep self.dt = int(self.options["plant_config"]["plant"]["simulation"]["dt"]) From d084f698b423c3bd0997eefebd0863f1a4ff0181 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 5 May 2026 17:24:10 -0600 Subject: [PATCH 041/105] replaced lists of tech control classifiers with the dictionary --- .../system_level/system_level_control_base.py | 12 +++- h2integrate/core/h2integrate_model.py | 65 ++++++++----------- 2 files changed, 36 insertions(+), 41 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index dfc9dc1ef..341719af5 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -38,9 +38,15 @@ def setup(self): self.commodity = slc_config["demand_commodity"] self.commodity_units = slc_config.get("demand_commodity_rate_units", None) self.demand_tech = slc_config["demand_tech"] - self.curtailable_techs = list(slc_config.get("curtailable_techs", [])) - self.dispatchable_techs = list(slc_config.get("dispatchable_techs", [])) - self.storage_techs = list(slc_config.get("storage_techs", [])) + self.curtailable_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "curtailable" + ] + self.dispatchable_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "dispatchable" + ] + self.storage_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "storage" + ] # Input: demand profile (default value from config) demand_profile = slc_config.get("demand_profile", 0.0) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index e0c71c9de..0a2b08b74 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -535,18 +535,6 @@ def _classify_slc_technologies(self): ) raise ValueError(msg) - # Classify technologies using pre-computed classifiers - curtailable_techs = [] - dispatchable_techs = [] - storage_techs = [] - for tech_name, classifier in self.tech_control_classifiers.items(): - if classifier == "curtailable": - curtailable_techs.append(tech_name) - elif classifier == "dispatchable": - dispatchable_techs.append(tech_name) - elif classifier == "storage": - storage_techs.append(tech_name) - # Classify technologies based on their output commodity (or commodities) # Use a set to remove duplicates (in case one tech produces multiple commodities) sources_to_commodities = { @@ -556,9 +544,11 @@ def _classify_slc_technologies(self): } # Remove feedstocks and connectors - techs_to_connect = set(curtailable_techs + dispatchable_techs + storage_techs) + control_classifiers_to_connect = ["curtailable", "dispatchable", "storage"] tech_to_commodities = { - (e[0], e[-1]) for e in sources_to_commodities if e[0] in techs_to_connect + (e[0], e[-1]) + for e in sources_to_commodities + if self.tech_control_classifiers[e[0]] in control_classifiers_to_connect } # Store classification results in plant_config for SLC component @@ -566,10 +556,9 @@ def _classify_slc_technologies(self): slc_config["demand_profile"] = demand_profile slc_config["demand_commodity"] = demand_commodity slc_config["demand_commodity_rate_units"] = demand_commodity_rate_units - slc_config["curtailable_techs"] = curtailable_techs - slc_config["dispatchable_techs"] = dispatchable_techs - slc_config["storage_techs"] = storage_techs slc_config["tech_to_commodity"] = tech_to_commodities + slc_config["tech_control_classifiers"] = self.tech_control_classifiers + return slc_config def add_system_level_controller(self, slc_config): @@ -633,32 +622,32 @@ def add_system_level_controller(self, slc_config): # 3. Connect the controller's inputs/outputs to technology models # Curtailable, dispatchable, and storage techs: read output and write set_point - for tech_list in ["curtailable_techs", "dispatchable_techs", "storage_techs"]: - for tech_name in slc_config[tech_list]: - tech_commodities = self._get_commodity_for_tech(tech_name) - for commodity in tech_commodities: - self.plant.connect( - f"{tech_name}.{commodity}_out", - f"system_level_controller.{tech_name}_{commodity}_out", - ) + for tech_to_commodity in slc_config["tech_to_commodity"]: + tech_name, commodity = tech_to_commodity + self.plant.connect( + f"{tech_name}.{commodity}_out", + f"system_level_controller.{tech_name}_{commodity}_out", + ) - self.plant.connect( - f"{tech_name}.rated_{commodity}_production", - f"system_level_controller.{tech_name}_rated_{commodity}_production", - ) + self.plant.connect( + f"{tech_name}.rated_{commodity}_production", + f"system_level_controller.{tech_name}_rated_{commodity}_production", + ) - self.plant.connect( - f"system_level_controller.{tech_name}_{commodity}_set_point", - f"{tech_name}.{commodity}_set_point", - ) + self.plant.connect( + f"system_level_controller.{tech_name}_{commodity}_set_point", + f"{tech_name}.{commodity}_set_point", + ) # 4. For cost-aware strategies, connect marginal costs from cost models if strategy_name in ("CostMinimizationControl", "ProfitMaximizationControl"): - for tech_name in slc_config["dispatchable_techs"]: - self.plant.connect( - f"{tech_name}.marginal_cost", - f"system_level_controller.{tech_name}_marginal_cost", - ) + for tech_to_commodity in slc_config["tech_to_commodity"]: + tech_name, commodity = tech_to_commodity + if self.tech_control_classifiers[tech_name] == "dispatchable": + self.plant.connect( + f"{tech_name}.marginal_cost", + f"system_level_controller.{tech_name}_marginal_cost", + ) ### Commented out for now; we'll need to determine how to treat demand ### components in the new SLC paradigm. From a11e37c0751dbc1c6aaa8bd80cd50a4081b2e67d Mon Sep 17 00:00:00 2001 From: John Jasa Date: Tue, 5 May 2026 20:24:40 -0600 Subject: [PATCH 042/105] Directly connected demand profile --- h2integrate/core/h2integrate_model.py | 18 +++++++----------- h2integrate/demand/demand_base.py | 10 ++++++++++ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 4327bcb47..a743f5a94 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -506,7 +506,6 @@ def _classify_slc_technologies(self): demand_commodity = all_params["commodity"] demand_commodity_rate_units = all_params.get("commodity_rate_units", None) - demand_profile = all_params.get("demand_profile", 0.0) demand_tech = tech_name # Check that the demand tech is in the technology_interconnections tech_interconnections = self.plant_config["technology_interconnections"] @@ -563,7 +562,6 @@ def _classify_slc_technologies(self): # Store classification results in plant_config for SLC component slc_config["demand_tech"] = demand_tech - slc_config["demand_profile"] = demand_profile slc_config["demand_commodity"] = demand_commodity slc_config["demand_commodity_rate_units"] = demand_commodity_rate_units slc_config["curtailable_techs"] = curtailable_techs @@ -653,15 +651,13 @@ def add_system_level_controller(self): f"system_level_controller.{tech_name}_marginal_cost", ) - ### Commented out for now; we'll need to determine how to treat demand - ### components in the new SLC paradigm. - # # Connect demand profile to the controller - # demand_tech = slc_config["demand_tech"] - # demand_commodity = slc_config["demand_commodity"] - # self.plant.connect( - # f"{demand_tech}.{commodity}_demand", - # f"system_level_controller.{commodity}_demand", - # ) + # Connect demand profile from the demand component to the controller + demand_tech = slc_config["demand_tech"] + demand_commodity = slc_config["demand_commodity"] + self.plant.connect( + f"{demand_tech}.{demand_commodity}_demand_out", + f"system_level_controller.{demand_commodity}_demand", + ) def create_technology_models(self): # Loop through each technology and instantiate an OpenMDAO object (assume it exists) diff --git a/h2integrate/demand/demand_base.py b/h2integrate/demand/demand_base.py index 09072594f..9417bdf83 100644 --- a/h2integrate/demand/demand_base.py +++ b/h2integrate/demand/demand_base.py @@ -94,6 +94,14 @@ def setup(self): desc=f"Excess production of {self.commodity}", ) + self.add_output( + f"{self.commodity}_demand_out", + val=self.config.demand_profile, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc=f"Pass-through of {self.commodity} demand profile", + ) + def compute(): """This method must be implemented by subclasses to define the demand component. @@ -125,6 +133,8 @@ def calculate_outputs(self, commodity_in, commodity_demand, outputs): array shape ``(n_timesteps,)``. """ + outputs[f"{self.commodity}_demand_out"] = commodity_demand + remaining_demand = commodity_demand - commodity_in # Calculate missed load and curtailed production From db9c9c0243986c15e69365212f2758d35edbd445 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Tue, 5 May 2026 21:42:10 -0600 Subject: [PATCH 043/105] Shared commodity sell price as input from finance subgroups --- .../profit_maximization/plant_config.yaml | 36 ++++++++++++++++++- .../profit_maximization/run_profit_max.py | 26 ++------------ .../profit_maximization_control.py | 36 +++++++++++++++++-- 3 files changed, 70 insertions(+), 28 deletions(-) diff --git a/examples/35_system_level_control/profit_maximization/plant_config.yaml b/examples/35_system_level_control/profit_maximization/plant_config.yaml index d3b56d109..37c1bcb78 100644 --- a/examples/35_system_level_control/profit_maximization/plant_config.yaml +++ b/examples/35_system_level_control/profit_maximization/plant_config.yaml @@ -25,6 +25,40 @@ plant: dt: 3600 system_level_control: control_strategy: ProfitMaximizationControl - commodity_sell_price: 0.06 # $/kWh default; overridden in run script + commodity_sell_price: profast_npv # name of finance group whose commodity_sell_price to use solver_name: gauss_seidel max_iter: 20 +finance_parameters: + finance_groups: + profast_npv: + finance_model: ProFastNPV + model_inputs: + commodity_sell_price: 0.06 + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + depr_period: 5 # 5 years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + refurb: [0.] + finance_subgroups: + electricity: + commodity: electricity + commodity_stream: fin_combiner + finance_groups: [profast_npv] + technologies: [wind, natural_gas_plant, ng_feedstock, battery] + cost_adjustment_parameters: + cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year + target_dollar_year: 2022 diff --git a/examples/35_system_level_control/profit_maximization/run_profit_max.py b/examples/35_system_level_control/profit_maximization/run_profit_max.py index 77e404933..2ac05f67a 100644 --- a/examples/35_system_level_control/profit_maximization/run_profit_max.py +++ b/examples/35_system_level_control/profit_maximization/run_profit_max.py @@ -1,10 +1,5 @@ """ -Profit-maximization example with diurnal electricity sell prices. - -The NG plant has a fixed marginal cost of $0.05/kWh. The electricity sell -price follows a diurnal pattern that swings above and below this cost: - - Night (22:00-16:00): $0.03/kWh, NG is unprofitable, not dispatched - - Peak (16:00-22:00): $0.08/kWh, NG is profitable, dispatched +Profit-maximization example with simple electricity price profiles. The controller dispatches the NG plant only during hours when the sell price exceeds the marginal cost, demonstrating profit-driven curtailment of @@ -17,29 +12,12 @@ from h2integrate.core.h2integrate_model import H2IntegrateModel -# -- Build diurnal sell-price profile ($/kWh) -- -n_timesteps = 8760 -sell_price = np.zeros(n_timesteps) -for h in range(n_timesteps): - hour_of_day = h % 24 - if 16 <= hour_of_day < 22: - sell_price[h] = 0.08 # peak - else: - sell_price[h] = 0.03 # night (cheap) - # -- Create and run model -- h2i = H2IntegrateModel("wind_ng_demand.yaml") # Setup first so we can set values h2i.setup() -# Override the sell price with our diurnal profile -h2i.prob.set_val( - "plant.system_level_controller.commodity_sell_price", - sell_price, - units="USD/(kW*h)", -) - h2i.run() h2i.post_process() @@ -53,7 +31,7 @@ batt_soc = h2i.prob.get_val("plant.battery.SOC")[:n_hours] demand = h2i.prob.get_val("plant.electrical_load_demand.electricity_demand")[:n_hours] curtailed = h2i.prob.get_val("plant.electrical_load_demand.unused_electricity_out")[:n_hours] -price = sell_price[:n_hours] +price = h2i.prob.get_val("system_level_controller.commodity_sell_price")[:n_hours] # -- Plot -- fig, axes = plt.subplots(4, 1, figsize=(14, 12), sharex=True) diff --git a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py index a5561c7d0..164653d37 100644 --- a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py +++ b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py @@ -20,19 +20,49 @@ class ProfitMaximizationControl(SystemLevelControlBase): Configuration: ``plant_config["system_level_control"]["commodity_sell_price"]`` - must be set ($/(commodity_rate_unit*h), e.g. $/kWh). + must be set as either a numeric value ($/(commodity_rate_unit*h), + e.g. $/kWh) or the name of a finance group (e.g. ``"profast_npv"``) + whose ``model_inputs.commodity_sell_price`` will be used. Each dispatchable technology must have a ``marginal_cost`` input representing its variable cost per unit of production. """ + def _resolve_sell_price(self, slc_config): + """Resolve commodity_sell_price from config. + + If the value is a string, look it up from + ``finance_parameters.finance_groups..model_inputs.commodity_sell_price``. + Otherwise return it as-is (numeric). + """ + raw = slc_config.get("commodity_sell_price", 0.0) + if isinstance(raw, str): + finance_groups = ( + self.options["plant_config"].get("finance_parameters", {}).get("finance_groups", {}) + ) + group = finance_groups.get(raw) + if group is None: + raise ValueError( + f"commodity_sell_price references finance group '{raw}', " + f"but it was not found in finance_parameters.finance_groups. " + f"Available groups: {list(finance_groups.keys())}" + ) + price = group.get("model_inputs", {}).get("commodity_sell_price", None) + if price is None: + raise ValueError( + f"Finance group '{raw}' does not contain " f"model_inputs.commodity_sell_price." + ) + return price + return raw + def setup(self): super().setup() slc_config = self.options["plant_config"]["system_level_control"] - # Commodity sell price — user-set in config, can be scalar or time-varying - default_sell_price = slc_config.get("commodity_sell_price", 0.0) + # Commodity sell price - user-set in config, can be scalar or time-varying + # Accepts a numeric value or the name of a finance group to look up + default_sell_price = self._resolve_sell_price(slc_config) self.add_input( "commodity_sell_price", val=default_sell_price, From 887873fe0a8ec52954b22375ed77457c892cbadc Mon Sep 17 00:00:00 2001 From: John Jasa Date: Tue, 5 May 2026 22:22:27 -0600 Subject: [PATCH 044/105] Fixed bad set-point behavior --- .../system_level/system_level_control_base.py | 53 ++++--------------- h2integrate/core/model_baseclasses.py | 4 +- 2 files changed, 13 insertions(+), 44 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 341719af5..499c1909e 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -49,11 +49,10 @@ def setup(self): ] # Input: demand profile (default value from config) - demand_profile = slc_config.get("demand_profile", 0.0) self.demand_input_name = f"{self.commodity}_demand" self.add_input( self.demand_input_name, - val=demand_profile, + val=10.0, shape=self.n_timesteps, units=self.commodity_units, desc=f"Demand profile of {self.commodity}", @@ -69,13 +68,11 @@ def setup(self): self.commodities_to_units = {self.commodity: self.commodity_units} self.commodities_to_ref_var = {} self._setup_tech_category("curtailable", self.curtailable_techs) - self._setup_tech_category( - "dispatchable", self.dispatchable_techs, demand_profile=demand_profile - ) + self._setup_tech_category("dispatchable", self.dispatchable_techs) self._setup_tech_category("storage", self.storage_techs) def _setup_commodity_for_given_units( - self, tech_name, commodity, commodity_units, add_in_name=True, initial_set_point=0.0 + self, tech_name, commodity, commodity_units, add_in_name=True, initial_set_point=1.0 ): """Adds inputs and outputs for a commodity when the units are known. The inputs and outputs that are added have the below naming convention: @@ -92,7 +89,7 @@ def _setup_commodity_for_given_units( add_in_name (bool, optional): If True, add the input for the in_name variable. Defaults to True. initial_set_point (float, optional): Add as the initial value for the - set_point variable. Defaults to 0.0. + set_point variable. Defaults to 1.0. Returns: tuple(str, str, str): tuple of in_name, set_point_name, and rated_name """ @@ -125,7 +122,7 @@ def _setup_commodity_for_given_units( return in_name, set_point_name, rated_name def _setup_commodity_for_copy_units( - self, tech_name, commodity, commodity_reference_var, add_in_name=True, initial_set_point=0.0 + self, tech_name, commodity, commodity_reference_var, add_in_name=True, initial_set_point=1.0 ): """Adds inputs and outputs for a commodity where the units are based on a reference input variable. The inputs and outputs that are added have the below @@ -143,7 +140,7 @@ def _setup_commodity_for_copy_units( add_in_name (bool, optional): If True, add the input for the in_name variable. Defaults to True. initial_set_point (float, optional): Add as the initial value for the - set_point variable. Defaults to 0.0. + set_point variable. Defaults to 1.0. Returns: tuple(str, str, str): tuple of in_name, set_point_name, and rated_name @@ -179,24 +176,17 @@ def _setup_commodity_for_copy_units( return in_name, set_point_name, rated_name - def _setup_tech_category(self, category, tech_list, demand_profile=None): + def _setup_tech_category(self, category, tech_list): """Create OpenMDAO I/O variables for all technologies in a given category. This single method handles curtailable, dispatchable, and storage technologies. The logic is identical for all three categories — iterate over each technology's commodities and register the appropriate inputs (production output, rated capacity) and output - (control set-point) — with one difference: - - * **Curtailable / Storage** (``demand_profile is None``): - ``initial_set_point`` is ``0.0``. Curtailable techs are later - assigned set-points equal to their rated production; storage techs - get set-points computed at run-time in ``_dispatch_storage``. + (control set-point). - * **Dispatchable** (``demand_profile`` is provided): - ``initial_set_point`` is the demand evenly divided among the - dispatchable techs that produce the demanded commodity, giving - the solver a reasonable starting guess. + All initial set-points are ``0.0``; the solver converges from there + using the connected rated-production inputs at run time. After this method returns, four lists are stored on ``self`` under names produced by the *category* prefix: @@ -214,29 +204,8 @@ def _setup_tech_category(self, category, tech_list, demand_profile=None): or ``"storage"``. Used to name the attribute lists. tech_list (list[str]): Technology names belonging to this category (e.g. ``self.curtailable_techs``). - demand_profile (float | np.ndarray | None, optional): - Only relevant for **dispatchable** techs. When provided, the - demand is split equally among dispatchable techs that produce - the demanded commodity to set a non-zero ``initial_set_point``. - For curtailable and storage techs, leave as ``None`` (default). """ - # --- Compute initial_set_point -------------------------------- - # Dispatchable techs: split demand equally among those that produce - # the demanded commodity so the solver starts from a feasible guess. - # Curtailable and storage techs always start at 0. - if demand_profile is not None: - n_producing = len( - [t for t in tech_list if self.commodity in self._get_commodity_for_tech(t)] - ) - if n_producing > 0: - if np.isscalar(demand_profile): - initial_set_point = demand_profile / n_producing - else: - initial_set_point = np.array(demand_profile) / n_producing - else: - initial_set_point = 0.0 - else: - initial_set_point = 0.0 + initial_set_point = 1.0 # --- Initialize the four per-category bookkeeping lists ------- input_names = [] diff --git a/h2integrate/core/model_baseclasses.py b/h2integrate/core/model_baseclasses.py index 3cbe96e18..0869dd4b5 100644 --- a/h2integrate/core/model_baseclasses.py +++ b/h2integrate/core/model_baseclasses.py @@ -103,14 +103,14 @@ def setup(self): if getattr(self, "_control_classifier", None) == "curtailable": self.add_input( f"{self.commodity}_set_point", - val=0.0, + val=1.0, shape=self.n_timesteps, units=self.commodity_rate_units, desc=f"Set point for {self.commodity} production (curtailment limit)", ) self.add_output( f"uncurtailed_{self.commodity}_out", - val=0.0, + val=1.0, shape=self.n_timesteps, units=self.commodity_rate_units, desc=f"Full (uncurtailed) {self.commodity} output", From 93a69c0fe70dc4836fff3baefbc129e937f9ab1c Mon Sep 17 00:00:00 2001 From: John Jasa Date: Tue, 5 May 2026 22:29:10 -0600 Subject: [PATCH 045/105] Uploading complex profit maximization --- .../complex_profit_max.yaml | 8 + .../complex_profit_max/driver_config.yaml | 4 + .../complex_profit_max/plant_config.yaml | 50 +++++ .../run_complex_profit_max.py | 197 ++++++++++++++++++ .../complex_profit_max/tech_config.yaml | 149 +++++++++++++ .../system_level/system_level_control_base.py | 2 +- 6 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 examples/35_system_level_control/complex_profit_max/complex_profit_max.yaml create mode 100644 examples/35_system_level_control/complex_profit_max/driver_config.yaml create mode 100644 examples/35_system_level_control/complex_profit_max/plant_config.yaml create mode 100644 examples/35_system_level_control/complex_profit_max/run_complex_profit_max.py create mode 100644 examples/35_system_level_control/complex_profit_max/tech_config.yaml diff --git a/examples/35_system_level_control/complex_profit_max/complex_profit_max.yaml b/examples/35_system_level_control/complex_profit_max/complex_profit_max.yaml new file mode 100644 index 000000000..cd142e5eb --- /dev/null +++ b/examples/35_system_level_control/complex_profit_max/complex_profit_max.yaml @@ -0,0 +1,8 @@ +name: H2Integrate_config +system_summary: > + Complex profit-maximization example using wind, solar, battery storage, + natural gas, and grid electricity to meet a time-varying demand with + realistic wholesale electricity pricing. +driver_config: driver_config.yaml +plant_config: plant_config.yaml +technology_config: tech_config.yaml diff --git a/examples/35_system_level_control/complex_profit_max/driver_config.yaml b/examples/35_system_level_control/complex_profit_max/driver_config.yaml new file mode 100644 index 000000000..4cfa2fe2d --- /dev/null +++ b/examples/35_system_level_control/complex_profit_max/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: Complex profit-maximization dispatch with variable pricing and demand +general: + folder_output: outputs diff --git a/examples/35_system_level_control/complex_profit_max/plant_config.yaml b/examples/35_system_level_control/complex_profit_max/plant_config.yaml new file mode 100644 index 000000000..b9379f090 --- /dev/null +++ b/examples/35_system_level_control/complex_profit_max/plant_config.yaml @@ -0,0 +1,50 @@ +name: plant_config +description: > + Wind + solar + battery + NG + grid plant in west Texas with + profit-maximization system-level control. Demonstrates complex + multi-source dispatch with realistic pricing. +sites: + site: + latitude: 30.6617 + longitude: -101.7096 + resources: + wind_resource: + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2013 + solar_resource: + resource_model: OpenMeteoHistoricalSolarResource + resource_parameters: + resource_year: 2013 +technology_interconnections: + # Combine wind and solar into a single renewable stream + - [wind, renewable_combiner, electricity, cable] + - [solar, renewable_combiner, electricity, cable] + # Renewables charge the battery + - [renewable_combiner, battery, electricity, cable] + # Everything feeds into the final combiner + - [renewable_combiner, fin_combiner, electricity, cable] + - [battery, fin_combiner, electricity, cable] + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] + - [natural_gas_plant, fin_combiner, electricity, cable] + - [grid_buy, fin_combiner, electricity, cable] + # Final combiner delivers to demand + - [fin_combiner, electrical_load_demand, electricity, cable] +resource_to_tech_connections: + - [site.wind_resource, wind, wind_resource_data] + - [site.solar_resource, solar, solar_resource_data] +plant: + plant_life: 30 + simulation: + n_timesteps: 8760 + dt: 3600 +system_level_control: + control_strategy: ProfitMaximizationControl + control_parameters: + commodity_sell_price: 0.06 # $/kWh default; overridden in run script + cost_per_tech: + natural_gas_plant: feedstock # use upstream feedstock (ng_feedstock) VarOpEx + grid_buy: buy_price # use electricity_buy_price from cost config + solver_name: gauss_seidel + max_iter: 30 + convergence_tolerance: 1.0e-6 diff --git a/examples/35_system_level_control/complex_profit_max/run_complex_profit_max.py b/examples/35_system_level_control/complex_profit_max/run_complex_profit_max.py new file mode 100644 index 000000000..1ca78c9d8 --- /dev/null +++ b/examples/35_system_level_control/complex_profit_max/run_complex_profit_max.py @@ -0,0 +1,197 @@ +""" +Complex profit-maximization example with wind, solar, battery, NG, and grid. + +This example demonstrates profit-driven dispatch with: + - Wind + solar (curtailable) combined into a single renewable stream + - Battery storage (200 MWh) for renewable energy shifting + - Natural gas turbine with marginal cost of $0.05/kWh (dispatchable) + - Grid buying with time-varying marginal cost (dispatchable) + - Non-constant demand (commercial load profile with seasonal variation) + - Realistic wholesale electricity sell prices (ERCOT-like diurnal + seasonal) + +The controller dispatches NG and grid only during hours when the sell price +exceeds each source's marginal cost, preferring the cheaper source first +(merit-order dispatch). Renewables run at full capacity (zero marginal cost) +and the battery shifts energy toward high-price hours. +""" + +import numpy as np +import matplotlib.pyplot as plt + +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +# --------------------------------------------------------------------------- +# Build realistic time-varying profiles +# --------------------------------------------------------------------------- +n_timesteps = 8760 +hours_of_day = np.tile(np.arange(24), 365) +day_of_year = np.repeat(np.arange(365), 24) + +# --- Non-constant demand (commercial/industrial load) --- +# Base: 50 MW, business-hours bump to ~80 MW, summer cooling adds ~20 MW +base_demand = 50_000 # kW +daytime_bump = np.where((hours_of_day >= 7) & (hours_of_day < 21), 30_000, 0) +# Seasonal factor: 1.0 in winter, peaks at 1.4 in summer (day ~172 = June 21) +seasonal_demand = 1.0 + 0.4 * np.sin(2 * np.pi * (day_of_year - 172) / 365) +demand_profile = (base_demand + daytime_bump) * seasonal_demand + +# --- Realistic ERCOT-like wholesale sell price ($/kWh) --- +sell_price = np.zeros(n_timesteps) +for h in range(n_timesteps): + hour = hours_of_day[h] + day = day_of_year[h // 24] if h // 24 < 365 else day_of_year[-1] + # Seasonal base: higher in summer + season = 1.0 + 0.35 * np.sin(2 * np.pi * (day - 172) / 365) + + # Diurnal wholesale price shape (duck curve) + if hour < 6: + price = 0.025 # overnight trough + elif hour < 10: + price = 0.025 + (hour - 6) * 0.008 # morning ramp + elif hour < 15: + price = 0.035 # midday dip (solar flood) + elif hour < 20: + price = 0.035 + (hour - 15) * 0.018 # evening ramp to peak + else: + price = 0.125 - (hour - 20) * 0.025 # evening decline + + sell_price[h] = price * season + +# Add summer evening price spikes (simulate heat-wave scarcity) +for h in range(n_timesteps): + day = day_of_year[h // 24] if h // 24 < 365 else day_of_year[-1] + hour = hours_of_day[h] + if 150 <= day <= 250 and 17 <= hour <= 20 and day % 5 == 0: + sell_price[h] = max(sell_price[h], 0.20) + +# --- Grid buy marginal cost: tracks wholesale price + retail markup --- +grid_buy_price = sell_price + 0.02 # grid is always more expensive than selling + +# --------------------------------------------------------------------------- +# Create and run model +# --------------------------------------------------------------------------- +h2i = H2IntegrateModel("complex_profit_max.yaml") +h2i.setup() + +# Override demand profile +h2i.prob.set_val( + "plant.electrical_load_demand.electricity_demand", + demand_profile, +) + +# Override sell price with time-varying profile +h2i.prob.set_val( + "plant.system_level_controller.commodity_sell_price", + sell_price, + units="USD/(kW*h)", +) + +# Override grid buy price with time-varying profile +h2i.prob.set_val( + "plant.grid_buy.electricity_buy_price", + grid_buy_price, + units="USD/(kW*h)", +) + +h2i.run() +h2i.post_process() + +# --------------------------------------------------------------------------- +# Extract results +# --------------------------------------------------------------------------- +n_hours = 336 # two weeks for clearer patterns +hours = np.arange(n_hours) + +wind_out = h2i.prob.get_val("plant.wind.electricity_out")[:n_hours] +solar_out = h2i.prob.get_val("plant.solar.electricity_out")[:n_hours] +ng_out = h2i.prob.get_val("plant.natural_gas_plant.electricity_out", units="kW")[:n_hours] +grid_out = h2i.prob.get_val("plant.grid_buy.electricity_out")[:n_hours] +batt_discharge = h2i.prob.get_val("plant.battery.storage_electricity_discharge")[:n_hours] +batt_soc = h2i.prob.get_val("plant.battery.SOC")[:n_hours] +demand = demand_profile[:n_hours] +price = sell_price[:n_hours] + +# --------------------------------------------------------------------------- +# Plot +# --------------------------------------------------------------------------- +fig, axes = plt.subplots(5, 1, figsize=(16, 16), sharex=True) + +# Panel 1: stacked supply vs demand +axes[0].fill_between(hours, 0, wind_out, alpha=0.7, color="tab:blue", label="Wind") +axes[0].fill_between( + hours, + wind_out, + wind_out + solar_out, + alpha=0.7, + color="gold", + label="Solar", +) +axes[0].fill_between( + hours, + wind_out + solar_out, + wind_out + solar_out + batt_discharge, + alpha=0.7, + color="tab:purple", + label="Battery", +) +axes[0].fill_between( + hours, + wind_out + solar_out + batt_discharge, + wind_out + solar_out + batt_discharge + ng_out, + alpha=0.7, + color="tab:orange", + label="Natural Gas", +) +axes[0].fill_between( + hours, + wind_out + solar_out + batt_discharge + ng_out, + wind_out + solar_out + batt_discharge + ng_out + grid_out, + alpha=0.7, + color="tab:gray", + label="Grid Buy", +) +axes[0].plot(hours, demand, "k--", linewidth=1.5, label="Demand") +axes[0].set_ylabel("Power (kW)") +axes[0].set_title("Complex Profit Maximization: First Two Weeks") +axes[0].legend(loc="upper right", ncol=3) + +# Panel 2: battery state of charge +axes[1].plot(hours, batt_soc, color="tab:green") +axes[1].set_ylabel("SOC (kWh)") +axes[1].set_title("Battery State of Charge") + +# Panel 3: sell price vs marginal costs +axes[2].plot(hours, price * 100, color="tab:red", linewidth=0.8, label="Sell Price") +axes[2].axhline( + y=5.0, color="tab:orange", linestyle="--", alpha=0.8, label="NG Marginal Cost (5 ¢/kWh)" +) +axes[2].plot( + hours, (price + 0.02) * 100, color="tab:gray", linewidth=0.6, alpha=0.7, label="Grid Buy Cost" +) +axes[2].set_ylabel("Price (¢/kWh)") +axes[2].set_title("Electricity Prices vs Dispatch Costs") +axes[2].legend(loc="upper right") + +# Panel 4: individual dispatch decisions +axes[3].plot(hours, ng_out / 1000, color="tab:orange", label="NG (MW)") +axes[3].plot(hours, grid_out / 1000, color="tab:gray", label="Grid Buy (MW)") +axes[3].set_ylabel("Power (MW)") +axes[3].set_title("Dispatchable Generation Decisions") +axes[3].legend(loc="upper right") + +# Panel 5: renewable generation +axes[4].plot(hours, wind_out / 1000, color="tab:blue", label="Wind (MW)") +axes[4].plot(hours, solar_out / 1000, color="gold", label="Solar (MW)") +axes[4].set_ylabel("Power (MW)") +axes[4].set_xlabel("Hour") +axes[4].set_title("Curtailable Renewable Generation") +axes[4].legend(loc="upper right") + +for ax in axes: + ax.grid(True, alpha=0.3) + +plt.tight_layout() +plt.savefig("complex_profit_max_results.png", dpi=150) +print("Plot saved to complex_profit_max_results.png") +# plt.show() diff --git a/examples/35_system_level_control/complex_profit_max/tech_config.yaml b/examples/35_system_level_control/complex_profit_max/tech_config.yaml new file mode 100644 index 000000000..56879ab71 --- /dev/null +++ b/examples/35_system_level_control/complex_profit_max/tech_config.yaml @@ -0,0 +1,149 @@ +name: technology_config +description: > + Wind + solar + battery + NG + grid with marginal costs for + profit-maximization dispatch. Non-constant demand and realistic + grid pricing create a challenging dispatch optimization problem. +technologies: + wind: + performance_model: + model: PYSAMWindPlantPerformanceModel + cost_model: + model: ATBWindPlantCostModel + model_inputs: + performance_parameters: + num_turbines: 20 + turbine_rating_kw: 6000 + hub_height: 115 + rotor_diameter: 170 + create_model_from: default + config_name: WindPowerSingleOwner + pysam_options: + Farm: + wind_farm_wake_model: 0 + Losses: + ops_strategies_loss: 10.0 + layout: + layout_mode: basicgrid + layout_options: + row_D_spacing: 5.0 + turbine_D_spacing: 5.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: square + cost_parameters: + capex_per_kW: 1300 + opex_per_kW_per_year: 39 + cost_year: 2022 + solar: + performance_model: + model: PYSAMSolarPlantPerformanceModel + cost_model: + model: ATBUtilityPVCostModel + model_inputs: + performance_parameters: + pv_capacity_kWdc: 100000 # 100 MWdc + dc_ac_ratio: 1.3 + create_model_from: default + config_name: PVWattsSingleOwner + tilt_angle_func: lat-func + pysam_options: + SystemDesign: + inv_eff: 96.0 + module_type: 0 + losses: 14.08 + Lifetime: + dc_degradation: [0] + cost_parameters: + capex_per_kWac: 900 + opex_per_kWac_per_year: 15 + cost_year: 2024 + ng_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: natural_gas + commodity_rate_units: MMBtu/h + performance_parameters: + rated_capacity: 750. + cost_parameters: + cost_year: 2023 + price: 4.2 + annual_cost: 0. + start_up_cost: 0. + natural_gas_plant: + performance_model: + model: NaturalGasPerformanceModel + cost_model: + model: NaturalGasCostModel + model_inputs: + shared_parameters: + heat_rate_mmbtu_per_mwh: 7.5 + system_capacity_mw: 100. + cost_parameters: + capex_per_kw: 1000 + fixed_opex_per_kw_per_year: 10.0 + variable_opex_per_mwh: 0.0 + cost_year: 2023 + grid_buy: + performance_model: + model: GridPerformanceModel + cost_model: + model: GridCostModel + model_inputs: + shared_parameters: + interconnection_size: 200000. # 200 MW interconnection limit + cost_parameters: + cost_year: 2024 + electricity_buy_price: 0.06 # $/kWh default; overridden in run script + interconnection_capex_per_kw: 0.0 + interconnection_opex_per_kw: 0.0 + fixed_interconnection_cost: 0.0 + electrical_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 80000 # 80 MW default; overridden in run script + battery: + performance_model: + model: StoragePerformanceModel + cost_model: + model: GenericStorageCostModel + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + max_charge_rate: 50000 # 50 MW charge rate + max_capacity: 200000 # 200 MWh capacity + init_soc_fraction: 0.5 + max_soc_fraction: 1.0 + min_soc_fraction: 0.1 + performance_parameters: + round_trip_efficiency: 0.90 + demand_profile: 50000 + cost_parameters: + cost_year: 2024 + capacity_capex: 280 + charge_capex: 300 + opex_fraction: 0.025 + renewable_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 2 + fin_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 4 diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 499c1909e..59ff62a3c 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -185,7 +185,7 @@ def _setup_tech_category(self, category, tech_list): appropriate inputs (production output, rated capacity) and output (control set-point). - All initial set-points are ``0.0``; the solver converges from there + All initial set-points are ``1.0``; the solver converges from there using the connected rated-production inputs at run time. After this method returns, four lists are stored on ``self`` under From e4a94bd8f11045394ef0bae030b617c982e346e9 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 6 May 2026 09:42:20 -0600 Subject: [PATCH 046/105] tried to add helper methods to slc baseclass --- .../system_level/system_level_control_base.py | 97 +++++++++++++++++++ h2integrate/core/h2integrate_model.py | 2 +- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 17d2f6aff..508a4f4e1 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1,4 +1,5 @@ import numpy as np +import networkx as nx import openmdao.api as om @@ -51,6 +52,10 @@ def setup(self): k for k, v in slc_config["tech_control_classifiers"].items() if v == "storage" ] + self.input_techs = set( + self.curtailable_techs + self.dispatchable_techs + self.storage_techs + ) + # Input: demand profile (default value from config) demand_profile = slc_config.get("demand_profile", 0.0) self.demand_input_name = f"{self.commodity}_demand" @@ -396,3 +401,95 @@ def _get_commodity_for_tech(self, tech_name): tech_commodities = [e[1] for e in self.techs_to_commodities if e[0] == tech_name] return tech_commodities + + def get_upstream_techs_for_commodity(self, tech_name: str, commodity: str): + """Get the name of technologies that are upstream + of `tech_name` and that output `commodity` + + Args: + tech_name (str): name of technology + commodity (str): commodity name + + Returns: + list[str]: list of technologies upstream of the tech_name that produce a given commodity + """ + # figure out where the upstream commodity is coming from + upstream_components = nx.ancestors(self.technology_graph, tech_name) + # iterates through a list of 3 length tuples (source, dest, commodity) + upstream_components_shared_commodity = [ + s[0] + for s in list(self.technology_graph.edges(data="commodity")) + if s[0] in upstream_components and s[2] == commodity + ] + # get the technologies that are available to the controller + upstream_techs = set(upstream_components_shared_commodity).intersection( + set(self.input_techs) + ) + return list(upstream_techs) + + def find_converter_techs(self): + """Get the name of the technology that transforms a commodity + + Returns: + set(tuple): set of converter technologies formatted as + (input_commodity, converter tech name, output_commodity) + """ + if not self.multi_commodity_system: + return + + converter_techs = set() + + edges = list(self.technology_graph.edges(data="commodity")) + upstream_converter = None + # for tech in self.input_techs: + for edge in edges: + tech, dest_tech, cmod = edge + if tech in self.input_techs: + tech_output_commodity = self._get_commodity_for_tech(tech) + + # NOTE: unsure how this would work for systems with tiered converters + # aka - maybe have to eliminate a converter once we've discovered it + if upstream_converter is None: + upstream_techs = nx.ancestors(self.technology_graph, tech).intersection( + set(self.input_techs) + ) + else: + idx_upstream_converter = [ + i + for i, n in enumerate(self.technology_graph.__iter__()) + if n == upstream_converter + ] + downstream_of_previous_converter = [ + n + for i, n in enumerate(self.technology_graph.__iter__()) + if i > idx_upstream_converter + ] + all_upstream_techs = nx.ancestors(self.technology_graph, tech).intersection( + set(self.input_techs) + ) + upstream_techs = all_upstream_techs.intersection( + set(downstream_of_previous_converter) + ) + + connected_upstream_techs = [ + t for t in upstream_techs if nx.has_path(self.technology_graph, t, tech) + ] + upstream_commodities = [ + self._get_commodity_for_tech(t) for t in connected_upstream_techs + ] + # symmetric difference + # commodities that are not in both + input_output_commodity = set(upstream_commodities) ^ set(tech_output_commodity) + if len(input_output_commodity) > 1: + input_commodity = list( + input_output_commodity.intersection(set(upstream_commodities)) + ) + output_commodity = list( + input_output_commodity.intersection(set(tech_output_commodity)) + ) + + # formatted as (input commodity, tech_name, output comodity) + converter_techs.add((input_commodity, tech, output_commodity)) + upstream_converter = tech + + return converter_techs diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index c1e1503a6..9c83a16b0 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -652,7 +652,7 @@ def add_system_level_controller(self, slc_config): f"system_level_controller.{tech_name}_rated_{commodity}_production", ) - if slc_config["storage_techs_to_control"][tech_name]: + if slc_config["storage_techs_to_control"].get(tech_name, False): # storage has its own controller # provide demand to storage controller, # storage controller will provide set-point to performance model From 9a5cfdc1dfa7084a2ed23f1fc6b6379c745738b7 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 6 May 2026 09:49:04 -0600 Subject: [PATCH 047/105] added marginal cost to cost models that dont inherit costmodelbaseconfig --- h2integrate/converters/hopp/hopp_wrapper.py | 1 + h2integrate/converters/iron/iron_transport.py | 1 + h2integrate/core/model_baseclasses.py | 1 + h2integrate/storage/hydrogen/h2_storage_cost.py | 1 + h2integrate/storage/hydrogen/mch_storage.py | 1 + 5 files changed, 5 insertions(+) diff --git a/h2integrate/converters/hopp/hopp_wrapper.py b/h2integrate/converters/hopp/hopp_wrapper.py index 06579c601..b4fd4c33e 100644 --- a/h2integrate/converters/hopp/hopp_wrapper.py +++ b/h2integrate/converters/hopp/hopp_wrapper.py @@ -16,6 +16,7 @@ class HOPPComponentModelConfig(CacheBaseConfig): hopp_config: dict = field() cost_year: int = field(converter=int) electrolyzer_rating: int | float | None = field(default=None) + marginal_cost: float = field(default=0.0) class HOPPComponent(PerformanceModelBaseClass, CacheBaseClass): diff --git a/h2integrate/converters/iron/iron_transport.py b/h2integrate/converters/iron/iron_transport.py index 93223b453..ff823ecfa 100644 --- a/h2integrate/converters/iron/iron_transport.py +++ b/h2integrate/converters/iron/iron_transport.py @@ -165,6 +165,7 @@ def compute(self, inputs, outputs): class IronTransportCostConfig(BaseConfig): transport_year: int = field(converter=int, validator=range_val(2022, 2065)) cost_year: int = field(converter=int, validator=range_val(2010, 2024)) + marginal_cost: float = field(default=0.0) class IronTransportCostComponent(CostModelBaseClass): diff --git a/h2integrate/core/model_baseclasses.py b/h2integrate/core/model_baseclasses.py index 0869dd4b5..00a374166 100644 --- a/h2integrate/core/model_baseclasses.py +++ b/h2integrate/core/model_baseclasses.py @@ -199,6 +199,7 @@ def setup(self): model_inputs = self.options["tech_config"].get("model_inputs", {}) shared = model_inputs.get("shared_parameters", {}) commodity_rate_units = shared.get("commodity_rate_units", "kW") + self.add_output( "marginal_cost", val=self.config.marginal_cost, diff --git a/h2integrate/storage/hydrogen/h2_storage_cost.py b/h2integrate/storage/hydrogen/h2_storage_cost.py index bbc29c796..9868797b3 100644 --- a/h2integrate/storage/hydrogen/h2_storage_cost.py +++ b/h2integrate/storage/hydrogen/h2_storage_cost.py @@ -51,6 +51,7 @@ class HydrogenStorageBaseCostModelConfig(BaseConfig): storage_pressure_bar: float = field(default=200, validator=range_val(0, 700)) cg_capex_per_kg_350_bar: float = field(default=1333.11625, validator=gte_zero) cg_capex_per_kg_700_bar: float = field(default=1999.67437, validator=gte_zero) + marginal_cost: float = field(default=0.0) def __attrs_post_init__(self): undefined_capacities = self.max_capacity is None or self.max_charge_rate is None diff --git a/h2integrate/storage/hydrogen/mch_storage.py b/h2integrate/storage/hydrogen/mch_storage.py index 4888519c4..7d6ae90af 100644 --- a/h2integrate/storage/hydrogen/mch_storage.py +++ b/h2integrate/storage/hydrogen/mch_storage.py @@ -24,6 +24,7 @@ class MCHTOLStorageCostModelConfig(BaseConfig): commodity_units: str = field(default="kg/h", validator=contains(["kg/h", "g/h", "t/h"])) cost_year: int = field(default=2024, converter=int, validator=contains([2024])) + marginal_cost: float = field(default=0.0) def __attrs_post_init__(self): if self.charge_equals_discharge: From afb0af383fcdd2348160aa8ed81553de9913e53d Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 6 May 2026 14:33:07 -0600 Subject: [PATCH 048/105] updated cost model baseclass handling of marginal cost --- h2integrate/core/model_baseclasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/h2integrate/core/model_baseclasses.py b/h2integrate/core/model_baseclasses.py index 00a374166..eafbd254a 100644 --- a/h2integrate/core/model_baseclasses.py +++ b/h2integrate/core/model_baseclasses.py @@ -202,7 +202,7 @@ def setup(self): self.add_output( "marginal_cost", - val=self.config.marginal_cost, + val=getattr(self.config, "marginal_cost", 0.0), units=f"USD/({commodity_rate_units}*h)", desc="Marginal cost of production for dispatch decisions", ) From a6a841d431962abdb6719b7be27de9f8bf88303e Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 6 May 2026 14:45:45 -0600 Subject: [PATCH 049/105] updated so that demand is properly output from slc for storage controllers --- .../battery_with_controller/tech_config.yaml | 7 +++++-- .../system_level/system_level_control_base.py | 11 +++++++++-- .../system_level/test/test_slc_examples.py | 8 ++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/examples/35_system_level_control/battery_with_controller/tech_config.yaml b/examples/35_system_level_control/battery_with_controller/tech_config.yaml index c5ca7a20f..21075c502 100644 --- a/examples/35_system_level_control/battery_with_controller/tech_config.yaml +++ b/examples/35_system_level_control/battery_with_controller/tech_config.yaml @@ -76,19 +76,22 @@ technologies: cost_model: model: GenericStorageCostModel control_strategy: - model: DemandOpenLoopStorageController + model: SimpleStorageOpenLoopController model_inputs: shared_parameters: commodity: electricity commodity_rate_units: kW + demand_profile: 20000 # kW, required by storage base config max_charge_rate: 20000 # kW (20 MW) max_capacity: 80000 # kWh (80 MWh, 4-hour duration) + performance_parameters: init_soc_fraction: 0.5 max_soc_fraction: 1.0 min_soc_fraction: 0.1 # performance_parameters: round_trip_efficiency: 0.90 - demand_profile: 20000 # kW, required by storage base config + control_parameters: + set_demand_as_avg_commodity_in: false cost_parameters: cost_year: 2022 capacity_capex: 310 # $/kWh diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 508a4f4e1..96461683e 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -374,8 +374,15 @@ def _dispatch_storage(self, inputs, outputs, demand): if commodity == self.commodity: if f"_{commodity}_demand" in set_point_name: # storage tech has a controller, output combined demand (always positive) - # TODO: update to output whatever is input to storage + storage_share - outputs[set_point_name] = np.clip(storage_share, a_min=0.0, a_max=None) + # demand should be what is input to storage + storage_share + storage_tech_name = set_point_name.split(f"_{commodity}_demand")[0] + upstream_techs = self.get_upstream_techs_for_commodity( + storage_tech_name, commodity + ) + commodity_into_storage = np.zeros(self.n_timesteps) + for tech_name in upstream_techs: + commodity_into_storage += inputs[f"{tech_name}_{commodity}_out"] + outputs[set_point_name] = commodity_into_storage + storage_share else: # storage tech does not have a controller, # output set point (charge/discharge) command diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index e973163d8..8e0d5222b 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -116,3 +116,11 @@ def test_slc_battery_with_controller(subtests, temp_copy_of_example): with subtests.test("wind farm generates power"): assert wind_out.sum() > 0 + with subtests.test("lcoe"): + assert ( + pytest.approx( + model.prob.get_val("finance_subgroup_electricity.LCOE", units="USD/(kW*h)"), + rel=1e-6, + ) + == 0.10902004 + ) From 9984c704e2f3ad6af8330a2fba9c92489821df21 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 6 May 2026 15:50:05 -0600 Subject: [PATCH 050/105] fixed example 33 --- examples/33_peak_load_management/plant_config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/33_peak_load_management/plant_config.yaml b/examples/33_peak_load_management/plant_config.yaml index afcf590de..93be1cecf 100644 --- a/examples/33_peak_load_management/plant_config.yaml +++ b/examples/33_peak_load_management/plant_config.yaml @@ -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 From fb219d515f6f01c09b5f58b0fcd5b7d0a0ee28d6 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 6 May 2026 16:29:20 -0600 Subject: [PATCH 051/105] Minor typographical changes --- .../battery_with_controller/driver_config.yaml | 2 +- .../system_level/system_level_control_base.py | 3 --- .../control_strategies/system_level/test/test_slc_examples.py | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/examples/35_system_level_control/battery_with_controller/driver_config.yaml b/examples/35_system_level_control/battery_with_controller/driver_config.yaml index 5b6b7e05a..74ae9ca19 100644 --- a/examples/35_system_level_control/battery_with_controller/driver_config.yaml +++ b/examples/35_system_level_control/battery_with_controller/driver_config.yaml @@ -1,4 +1,4 @@ name: driver_config -description: This analysis runs a natural gas power plant +description: This analysis runs a battery with a simple controller general: folder_output: outputs diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 9df37cdbc..83d34bcb7 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -79,9 +79,6 @@ def setup(self): self._setup_tech_category("dispatchable", self.dispatchable_techs) self._setup_tech_category("storage", self.storage_techs) - # def _get_upstream_techs(self, inputs, tech_name): - # tech_commodities = self._get_commodity_for_tech(tech_name) - def _setup_commodity_for_given_units( self, tech_name, commodity, commodity_units, add_in_name=True, initial_set_point=1.0 ): diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index 8e0d5222b..4e23a67d6 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -105,7 +105,6 @@ def test_slc_yes_hydrogen(subtests, temp_copy_of_example): [("35_system_level_control/battery_with_controller", None)], ) def test_slc_battery_with_controller(subtests, temp_copy_of_example): - # TODO: this test still needs to be completed! example_folder = temp_copy_of_example model = H2IntegrateModel(example_folder / "wind_ng_demand.yaml") From f2e023b1b45d405936a9dadd217601cd213fb075 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 6 May 2026 22:38:37 -0600 Subject: [PATCH 052/105] Added control classifiers to all technologies --- h2integrate/converters/ammonia/simple_ammonia_model.py | 1 + h2integrate/converters/co2/marine/direct_ocean_capture.py | 1 + .../converters/co2/marine/ocean_alkalinity_enhancement.py | 1 + h2integrate/converters/hopp/hopp_wrapper.py | 1 + h2integrate/converters/hydrogen/electrolyzer_baseclass.py | 1 + .../hydrogen/geologic/h2_well_subsurface_baseclass.py | 2 ++ .../converters/hydrogen/geologic/h2_well_surface_baseclass.py | 2 ++ h2integrate/converters/hydrogen/h2_fuel_cell.py | 1 + h2integrate/converters/hydrogen/steam_methane_reformer.py | 1 + h2integrate/converters/iron/humbert_ewin_perf.py | 1 + h2integrate/converters/iron/iron_dri_base.py | 1 + h2integrate/converters/iron/martin_mine_perf_model.py | 1 + h2integrate/converters/methanol/methanol_baseclass.py | 1 + h2integrate/converters/natural_gas/dummy_gas_components.py | 2 ++ h2integrate/converters/nitrogen/simple_ASU.py | 1 + h2integrate/converters/nuclear/nuclear_plant.py | 1 + h2integrate/converters/steel/cmu_electric_arc_furnace_dri.py | 1 + h2integrate/converters/steel/cmu_electric_arc_furnace_scrap.py | 1 + h2integrate/converters/steel/steel_baseclass.py | 1 + h2integrate/converters/steel/steel_eaf_base.py | 1 + h2integrate/converters/water/desal/desalination_baseclass.py | 1 + h2integrate/converters/water_power/hydro_plant_run_of_river.py | 1 + h2integrate/converters/water_power/tidal_pysam.py | 1 + h2integrate/converters/wind/wind_plant_ard.py | 1 + h2integrate/demand/demand_base.py | 1 + 25 files changed, 28 insertions(+) diff --git a/h2integrate/converters/ammonia/simple_ammonia_model.py b/h2integrate/converters/ammonia/simple_ammonia_model.py index 527e2333b..79e8e2adc 100644 --- a/h2integrate/converters/ammonia/simple_ammonia_model.py +++ b/h2integrate/converters/ammonia/simple_ammonia_model.py @@ -34,6 +34,7 @@ class SimpleAmmoniaPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/co2/marine/direct_ocean_capture.py b/h2integrate/converters/co2/marine/direct_ocean_capture.py index 0702aab32..7f72eae12 100644 --- a/h2integrate/converters/co2/marine/direct_ocean_capture.py +++ b/h2integrate/converters/co2/marine/direct_ocean_capture.py @@ -80,6 +80,7 @@ class DOCPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/co2/marine/ocean_alkalinity_enhancement.py b/h2integrate/converters/co2/marine/ocean_alkalinity_enhancement.py index 96995c8f5..6089c0658 100644 --- a/h2integrate/converters/co2/marine/ocean_alkalinity_enhancement.py +++ b/h2integrate/converters/co2/marine/ocean_alkalinity_enhancement.py @@ -73,6 +73,7 @@ class OAEPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/hopp/hopp_wrapper.py b/h2integrate/converters/hopp/hopp_wrapper.py index b4fd4c33e..d801acc7e 100644 --- a/h2integrate/converters/hopp/hopp_wrapper.py +++ b/h2integrate/converters/hopp/hopp_wrapper.py @@ -33,6 +33,7 @@ class HOPPComponent(PerformanceModelBaseClass, CacheBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "curtailable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/hydrogen/electrolyzer_baseclass.py b/h2integrate/converters/hydrogen/electrolyzer_baseclass.py index c0934a013..3c3221947 100644 --- a/h2integrate/converters/hydrogen/electrolyzer_baseclass.py +++ b/h2integrate/converters/hydrogen/electrolyzer_baseclass.py @@ -9,6 +9,7 @@ class ElectrolyzerPerformanceBaseClass(ResizeablePerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/hydrogen/geologic/h2_well_subsurface_baseclass.py b/h2integrate/converters/hydrogen/geologic/h2_well_subsurface_baseclass.py index fc3c24fea..11b44189a 100644 --- a/h2integrate/converters/hydrogen/geologic/h2_well_subsurface_baseclass.py +++ b/h2integrate/converters/hydrogen/geologic/h2_well_subsurface_baseclass.py @@ -46,6 +46,8 @@ class GeoH2SubsurfacePerformanceConfig(BaseConfig): class GeoH2SubsurfacePerformanceBaseClass(PerformanceModelBaseClass): + _control_classifier = "curtailable" + """OpenMDAO component for modeling the performance of the well subsurface for geologic hydrogen. diff --git a/h2integrate/converters/hydrogen/geologic/h2_well_surface_baseclass.py b/h2integrate/converters/hydrogen/geologic/h2_well_surface_baseclass.py index e9107249c..84edafcc8 100644 --- a/h2integrate/converters/hydrogen/geologic/h2_well_surface_baseclass.py +++ b/h2integrate/converters/hydrogen/geologic/h2_well_surface_baseclass.py @@ -28,6 +28,8 @@ class GeoH2SurfacePerformanceConfig(BaseConfig): class GeoH2SurfacePerformanceBaseClass(PerformanceModelBaseClass): + _control_classifier = "dispatchable" + """OpenMDAO component for modeling the performance of the wellhead surface processing for geologic hydrogen. diff --git a/h2integrate/converters/hydrogen/h2_fuel_cell.py b/h2integrate/converters/hydrogen/h2_fuel_cell.py index c0fe905f1..36d2c1a76 100644 --- a/h2integrate/converters/hydrogen/h2_fuel_cell.py +++ b/h2integrate/converters/hydrogen/h2_fuel_cell.py @@ -42,6 +42,7 @@ class LinearH2FuelCellPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/hydrogen/steam_methane_reformer.py b/h2integrate/converters/hydrogen/steam_methane_reformer.py index d82d585c8..b469cbd8a 100644 --- a/h2integrate/converters/hydrogen/steam_methane_reformer.py +++ b/h2integrate/converters/hydrogen/steam_methane_reformer.py @@ -48,6 +48,7 @@ class SteamMethaneReformerPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/iron/humbert_ewin_perf.py b/h2integrate/converters/iron/humbert_ewin_perf.py index 1fc65f46d..0d87433ee 100644 --- a/h2integrate/converters/iron/humbert_ewin_perf.py +++ b/h2integrate/converters/iron/humbert_ewin_perf.py @@ -79,6 +79,7 @@ class HumbertEwinPerformanceComponent(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): self.commodity = "sponge_iron" diff --git a/h2integrate/converters/iron/iron_dri_base.py b/h2integrate/converters/iron/iron_dri_base.py index 3b4e9efba..b9ac1348b 100644 --- a/h2integrate/converters/iron/iron_dri_base.py +++ b/h2integrate/converters/iron/iron_dri_base.py @@ -34,6 +34,7 @@ class IronReductionPlantBasePerformanceComponent(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/iron/martin_mine_perf_model.py b/h2integrate/converters/iron/martin_mine_perf_model.py index 8c77d0777..b5084de4a 100644 --- a/h2integrate/converters/iron/martin_mine_perf_model.py +++ b/h2integrate/converters/iron/martin_mine_perf_model.py @@ -35,6 +35,7 @@ class MartinIronMinePerformanceComponent(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/methanol/methanol_baseclass.py b/h2integrate/converters/methanol/methanol_baseclass.py index 1d918303d..b860c99de 100644 --- a/h2integrate/converters/methanol/methanol_baseclass.py +++ b/h2integrate/converters/methanol/methanol_baseclass.py @@ -38,6 +38,7 @@ class MethanolPerformanceBaseClass(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/natural_gas/dummy_gas_components.py b/h2integrate/converters/natural_gas/dummy_gas_components.py index 96525ff5f..f35537b74 100644 --- a/h2integrate/converters/natural_gas/dummy_gas_components.py +++ b/h2integrate/converters/natural_gas/dummy_gas_components.py @@ -65,6 +65,7 @@ class SimpleGasProducerPerformance(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() @@ -148,6 +149,7 @@ class SimpleGasConsumerPerformance(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/nitrogen/simple_ASU.py b/h2integrate/converters/nitrogen/simple_ASU.py index 12ef88b48..da4762517 100644 --- a/h2integrate/converters/nitrogen/simple_ASU.py +++ b/h2integrate/converters/nitrogen/simple_ASU.py @@ -69,6 +69,7 @@ class SimpleASUPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/nuclear/nuclear_plant.py b/h2integrate/converters/nuclear/nuclear_plant.py index fc9a7e3f1..d8a86ec29 100644 --- a/h2integrate/converters/nuclear/nuclear_plant.py +++ b/h2integrate/converters/nuclear/nuclear_plant.py @@ -37,6 +37,7 @@ class QuinnNuclearPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/steel/cmu_electric_arc_furnace_dri.py b/h2integrate/converters/steel/cmu_electric_arc_furnace_dri.py index ff9eb0461..073171d52 100644 --- a/h2integrate/converters/steel/cmu_electric_arc_furnace_dri.py +++ b/h2integrate/converters/steel/cmu_electric_arc_furnace_dri.py @@ -143,6 +143,7 @@ class CMUElectricArcFurnaceDRIPerformanceComponent(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/steel/cmu_electric_arc_furnace_scrap.py b/h2integrate/converters/steel/cmu_electric_arc_furnace_scrap.py index 00dfa74d6..492babb34 100644 --- a/h2integrate/converters/steel/cmu_electric_arc_furnace_scrap.py +++ b/h2integrate/converters/steel/cmu_electric_arc_furnace_scrap.py @@ -105,6 +105,7 @@ class CMUElectricArcFurnaceScrapOnlyPerformanceComponent(PerformanceModelBaseCla 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/steel/steel_baseclass.py b/h2integrate/converters/steel/steel_baseclass.py index 24eb57f5c..121ac3087 100644 --- a/h2integrate/converters/steel/steel_baseclass.py +++ b/h2integrate/converters/steel/steel_baseclass.py @@ -6,6 +6,7 @@ class SteelPerformanceBaseClass(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/steel/steel_eaf_base.py b/h2integrate/converters/steel/steel_eaf_base.py index a73c3863e..8fdd9e131 100644 --- a/h2integrate/converters/steel/steel_eaf_base.py +++ b/h2integrate/converters/steel/steel_eaf_base.py @@ -34,6 +34,7 @@ class ElectricArcFurnacePlantBasePerformanceComponent(PerformanceModelBaseClass) 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/water/desal/desalination_baseclass.py b/h2integrate/converters/water/desal/desalination_baseclass.py index fcabf728e..6b87e7c70 100644 --- a/h2integrate/converters/water/desal/desalination_baseclass.py +++ b/h2integrate/converters/water/desal/desalination_baseclass.py @@ -6,6 +6,7 @@ class DesalinationPerformanceBaseClass(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/water_power/hydro_plant_run_of_river.py b/h2integrate/converters/water_power/hydro_plant_run_of_river.py index c00575901..108269067 100644 --- a/h2integrate/converters/water_power/hydro_plant_run_of_river.py +++ b/h2integrate/converters/water_power/hydro_plant_run_of_river.py @@ -40,6 +40,7 @@ class RunOfRiverHydroPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "curtailable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/water_power/tidal_pysam.py b/h2integrate/converters/water_power/tidal_pysam.py index f0e961175..6d65d8fc2 100644 --- a/h2integrate/converters/water_power/tidal_pysam.py +++ b/h2integrate/converters/water_power/tidal_pysam.py @@ -124,6 +124,7 @@ class PySAMTidalPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "curtailable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/wind/wind_plant_ard.py b/h2integrate/converters/wind/wind_plant_ard.py index fedb9ab33..408fc4890 100644 --- a/h2integrate/converters/wind/wind_plant_ard.py +++ b/h2integrate/converters/wind/wind_plant_ard.py @@ -38,6 +38,7 @@ class WindArdPerformanceCompatibilityComponent(PerformanceModelBaseClass): """ _time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model + _control_classifier = "curtailable" def initialize(self): super().initialize() diff --git a/h2integrate/demand/demand_base.py b/h2integrate/demand/demand_base.py index 9417bdf83..1052471e4 100644 --- a/h2integrate/demand/demand_base.py +++ b/h2integrate/demand/demand_base.py @@ -44,6 +44,7 @@ class DemandComponentBase(PerformanceModelBaseClass): """ _time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model + _control_classifier = "dispatchable" def setup(self): """Define inputs and outputs for demand component. From a56a57fc7874b09d5f5679bbfbe35b1cc351fcd9 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 7 May 2026 11:13:50 -0600 Subject: [PATCH 053/105] updated baseclass methods and demand following controller --- .../system_level/demand_following_control.py | 40 +++-- .../system_level/system_level_control_base.py | 148 +++++++++++------- 2 files changed, 123 insertions(+), 65 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 209a05312..09b7cb65d 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -21,13 +21,33 @@ def compute(self, inputs, outputs): demand = inputs[self.demand_input_name].copy() # 1. Curtailable techs: full production - demand = self._subtract_curtailable(inputs, outputs, demand) + for curtailable_tech in self.curtailable_techs: + commodity_from_tech = self._get_commodity_for_tech(curtailable_tech) + # check that this tech produces the commodity demanded + if self.commodity in commodity_from_tech: + # if the commodity produced from a tech is the demanded commodity + # then subtract the curtailable production from the demand + demand = self._subtract_curtailable( + curtailable_tech, demand, self.commodity, inputs, outputs + ) # 2. Storage dispatch - demand = self._dispatch_storage(inputs, outputs, demand) + # number of storage components that produce the demanded commodity + n_storage = len( + [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] + ) + for storage_tech in self.storage_techs: + commodity_from_tech = self._get_commodity_for_tech(storage_tech) + if self.commodity in commodity_from_tech: + demand = self._dispatch_storage( + storage_tech, demand / n_storage, self.commodity, inputs, outputs + ) # 3. Dispatchable techs: equal share of remaining demand remaining = np.maximum(demand, 0.0) + + # calculate the number of dispatchable technologies that + # produce the demanded commodity n_dispatchable = len( [ s @@ -35,16 +55,12 @@ def compute(self, inputs, outputs): if self.commodity in self._get_commodity_for_tech(s) ] ) - - # calculate the number of dispatchable technologies that - # produce the demanded commodity - if n_dispatchable > 0: - share = remaining / n_dispatchable - for set_point_name, commodity in zip( - self.dispatchable_set_point_names, self.dispatchable_commodity_names - ): - if commodity == self.commodity: - outputs[set_point_name] = share + for dispatchable_tech in self.dispatchable_techs: + commodity_from_tech = self._get_commodity_for_tech(dispatchable_tech) + if self.commodity in commodity_from_tech: + outputs[f"{dispatchable_tech}_{self.commodity}_set_point"] = ( + remaining / n_dispatchable + ) # Check for nans or inf if not all(np.isfinite(c).all() for k, c in outputs.items()): diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 83d34bcb7..185b661b0 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -302,65 +302,107 @@ def _setup_tech_category(self, category, tech_list): setattr(self, f"{category}_rated_names", rated_names) setattr(self, f"{category}_commodity_names", commodity_names) - def _subtract_curtailable(self, inputs, outputs, demand): + def _subtract_curtailable(self, curtailable_tech, remaining_demand, commodity, inputs, outputs): """Apply curtailable techs: set_point = rated, subtract output from demand. Returns the updated demand array. """ - for in_name, set_point_name, rated_name, commodity in zip( - self.curtailable_input_names, - self.curtailable_set_point_names, - self.curtailable_rated_names, - self.curtailable_commodity_names, - ): - # Output the set-point as the rated production of that technology - outputs[set_point_name] = inputs[rated_name] * np.ones(self.n_timesteps) - if commodity == self.commodity: - demand -= inputs[in_name] - - return demand - - def _dispatch_storage(self, inputs, outputs, demand): - """Dispatch storage techs proportionally and subtract actual output from demand. - - Positive set_point = discharge, negative = charge. - Returns the updated demand array. - """ - # calculate the number of storage technologies that - # produce the demanded commodity - n_storage = len( - [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] - ) - if n_storage > 0: - # split the demand across the storage technologies - storage_share = demand / n_storage - for set_point_name, commodity in zip( - self.storage_set_point_names, self.storage_commodity_names - ): - if commodity == self.commodity: - if f"_{commodity}_demand" in set_point_name: - # storage tech has a controller, output combined demand (always positive) - # demand should be what is input to storage + storage_share - storage_tech_name = set_point_name.split(f"_{commodity}_demand")[0] - upstream_techs = self.get_upstream_techs_for_commodity( - storage_tech_name, commodity - ) - commodity_into_storage = np.zeros(self.n_timesteps) - for tech_name in upstream_techs: - commodity_into_storage += inputs[f"{tech_name}_{commodity}_out"] - outputs[set_point_name] = commodity_into_storage + storage_share - else: - # storage tech does not have a controller, - # output set point (charge/discharge) command - # charge when remaining demand is negative - # discharge when remaining demand is positive - outputs[set_point_name] = storage_share + if curtailable_tech not in self.curtailable_techs: + return - for tech_name, in_name in zip(self.storage_techs, self.storage_input_names): - if self.commodity in self._get_commodity_for_tech(tech_name): - demand -= inputs[in_name] + if f"{curtailable_tech}_rated_{commodity}_production" not in inputs: + return + + # Output the set-point as the rated production of that technology + outputs[f"{curtailable_tech}_{commodity}_set_point"] = inputs[ + f"{curtailable_tech}_rated_{commodity}_production" + ] * np.ones(self.n_timesteps) + remaining_demand -= inputs[f"{curtailable_tech}_{commodity}_out"] + + return remaining_demand + # for in_name, set_point_name, rated_name, commodity in zip( + # self.curtailable_input_names, + # self.curtailable_set_point_names, + # self.curtailable_rated_names, + # self.curtailable_commodity_names, + # ): + # # Output the set-point as the rated production of that technology + # outputs[set_point_name] = inputs[rated_name] * np.ones(self.n_timesteps) + # if commodity == self.commodity: + # demand -= inputs[in_name] + + # return demand + + def _dispatch_storage(self, storage_tech, remaining_demand, commodity, inputs, outputs): + if storage_tech not in self.storage_techs: + return - return demand + if f"{storage_tech}_{commodity}_out" not in inputs: + return + + if self.storage_techs_to_control.get(storage_tech, False): + # storage tech has a controller, output combined demand (always positive) + # demand should be what is input to storage + remaining_demand + # get the technologies upstream of the storage that output that commodity + upstream_techs = self.get_upstream_techs_for_commodity(storage_tech, commodity) + commodity_into_storage = np.zeros(self.n_timesteps) + for tech_name in upstream_techs: + commodity_into_storage += inputs[f"{tech_name}_{commodity}_out"] + + outputs[f"{storage_tech}_{commodity}_demand"] = ( + commodity_into_storage + remaining_demand + ) + remaining_demand -= inputs[f"{storage_tech}_{commodity}_out"] + return remaining_demand + + if f"{storage_tech}_{commodity}_set_point" in outputs: + # storage tech does not have a controller, output set point (charge/discharge) command + # charge when remaining demand is negative, discharge when remaining demand is positive + outputs[f"{storage_tech}_{commodity}_set_point"] = remaining_demand + remaining_demand -= inputs[f"{storage_tech}_{commodity}_out"] + return remaining_demand + + # def _dispatch_storage(self, inputs, outputs, demand): + # """Dispatch storage techs proportionally and subtract actual output from demand. + + # Positive set_point = discharge, negative = charge. + # Returns the updated demand array. + # """ + # # calculate the number of storage technologies that + # # produce the demanded commodity + # n_storage = len( + # [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] + # ) + # if n_storage > 0: + # # split the demand across the storage technologies + # storage_share = demand / n_storage + # for set_point_name, commodity in zip( + # self.storage_set_point_names, self.storage_commodity_names + # ): + # if commodity == self.commodity: + # if f"_{commodity}_demand" in set_point_name: + # # storage tech has a controller, output combined demand (always positive) + # # demand should be what is input to storage + storage_share + # storage_tech_name = set_point_name.split(f"_{commodity}_demand")[0] + # upstream_techs = self.get_upstream_techs_for_commodity( + # storage_tech_name, commodity + # ) + # commodity_into_storage = np.zeros(self.n_timesteps) + # for tech_name in upstream_techs: + # commodity_into_storage += inputs[f"{tech_name}_{commodity}_out"] + # outputs[set_point_name] = commodity_into_storage + storage_share + # else: + # # storage tech does not have a controller, + # # output set point (charge/discharge) command + # # charge when remaining demand is negative + # # discharge when remaining demand is positive + # outputs[set_point_name] = storage_share + + # for tech_name, in_name in zip(self.storage_techs, self.storage_input_names): + # if self.commodity in self._get_commodity_for_tech(tech_name): + # demand -= inputs[in_name] + + # return demand def _get_commodity_for_tech(self, tech_name): """Get a list of the commodities produced for a technology. From 0f5e5e722f7ab401d8bb45c4b7c6eece4defe234 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 7 May 2026 11:18:14 -0600 Subject: [PATCH 054/105] updated cost control strategies and cleaned up baseclass --- .../system_level/cost_minimization_control.py | 21 +++++++- .../profit_maximization_control.py | 21 +++++++- .../system_level/system_level_control_base.py | 54 ------------------- 3 files changed, 38 insertions(+), 58 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/cost_minimization_control.py b/h2integrate/control/control_strategies/system_level/cost_minimization_control.py index 3ba2d5366..96bc458b4 100644 --- a/h2integrate/control/control_strategies/system_level/cost_minimization_control.py +++ b/h2integrate/control/control_strategies/system_level/cost_minimization_control.py @@ -40,10 +40,27 @@ def compute(self, inputs, outputs): demand = inputs[self.demand_input_name].copy() # 1. Curtailable techs: full production - demand = self._subtract_curtailable(inputs, outputs, demand) + for curtailable_tech in self.curtailable_techs: + commodity_from_tech = self._get_commodity_for_tech(curtailable_tech) + # check that this tech produces the commodity demanded + if self.commodity in commodity_from_tech: + # if the commodity produced from a tech is the demanded commodity + # then subtract the curtailable production from the demand + demand = self._subtract_curtailable( + curtailable_tech, demand, self.commodity, inputs, outputs + ) # 2. Storage dispatch - demand = self._dispatch_storage(inputs, outputs, demand) + # number of storage components that produce the demanded commodity + n_storage = len( + [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] + ) + for storage_tech in self.storage_techs: + commodity_from_tech = self._get_commodity_for_tech(storage_tech) + if self.commodity in commodity_from_tech: + demand = self._dispatch_storage( + storage_tech, demand / n_storage, self.commodity, inputs, outputs + ) # 3. Merit-order dispatch: cheapest dispatchable first remaining = np.maximum(demand, 0.0) diff --git a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py index eb9077856..ef178ad0d 100644 --- a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py +++ b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py @@ -97,10 +97,27 @@ def compute(self, inputs, outputs): sell_price = inputs["commodity_sell_price"] # shape (n_timesteps,) # 1. Curtailable techs: full production (always profitable) - demand = self._subtract_curtailable(inputs, outputs, demand) + for curtailable_tech in self.curtailable_techs: + commodity_from_tech = self._get_commodity_for_tech(curtailable_tech) + # check that this tech produces the commodity demanded + if self.commodity in commodity_from_tech: + # if the commodity produced from a tech is the demanded commodity + # then subtract the curtailable production from the demand + demand = self._subtract_curtailable( + curtailable_tech, demand, self.commodity, inputs, outputs + ) # 2. Storage dispatch - demand = self._dispatch_storage(inputs, outputs, demand) + # number of storage components that produce the demanded commodity + n_storage = len( + [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] + ) + for storage_tech in self.storage_techs: + commodity_from_tech = self._get_commodity_for_tech(storage_tech) + if self.commodity in commodity_from_tech: + demand = self._dispatch_storage( + storage_tech, demand / n_storage, self.commodity, inputs, outputs + ) # 3. Profit-driven merit-order dispatch remaining = np.maximum(demand, 0.0) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 185b661b0..663f136a4 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -320,18 +320,6 @@ def _subtract_curtailable(self, curtailable_tech, remaining_demand, commodity, i remaining_demand -= inputs[f"{curtailable_tech}_{commodity}_out"] return remaining_demand - # for in_name, set_point_name, rated_name, commodity in zip( - # self.curtailable_input_names, - # self.curtailable_set_point_names, - # self.curtailable_rated_names, - # self.curtailable_commodity_names, - # ): - # # Output the set-point as the rated production of that technology - # outputs[set_point_name] = inputs[rated_name] * np.ones(self.n_timesteps) - # if commodity == self.commodity: - # demand -= inputs[in_name] - - # return demand def _dispatch_storage(self, storage_tech, remaining_demand, commodity, inputs, outputs): if storage_tech not in self.storage_techs: @@ -362,48 +350,6 @@ def _dispatch_storage(self, storage_tech, remaining_demand, commodity, inputs, o remaining_demand -= inputs[f"{storage_tech}_{commodity}_out"] return remaining_demand - # def _dispatch_storage(self, inputs, outputs, demand): - # """Dispatch storage techs proportionally and subtract actual output from demand. - - # Positive set_point = discharge, negative = charge. - # Returns the updated demand array. - # """ - # # calculate the number of storage technologies that - # # produce the demanded commodity - # n_storage = len( - # [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] - # ) - # if n_storage > 0: - # # split the demand across the storage technologies - # storage_share = demand / n_storage - # for set_point_name, commodity in zip( - # self.storage_set_point_names, self.storage_commodity_names - # ): - # if commodity == self.commodity: - # if f"_{commodity}_demand" in set_point_name: - # # storage tech has a controller, output combined demand (always positive) - # # demand should be what is input to storage + storage_share - # storage_tech_name = set_point_name.split(f"_{commodity}_demand")[0] - # upstream_techs = self.get_upstream_techs_for_commodity( - # storage_tech_name, commodity - # ) - # commodity_into_storage = np.zeros(self.n_timesteps) - # for tech_name in upstream_techs: - # commodity_into_storage += inputs[f"{tech_name}_{commodity}_out"] - # outputs[set_point_name] = commodity_into_storage + storage_share - # else: - # # storage tech does not have a controller, - # # output set point (charge/discharge) command - # # charge when remaining demand is negative - # # discharge when remaining demand is positive - # outputs[set_point_name] = storage_share - - # for tech_name, in_name in zip(self.storage_techs, self.storage_input_names): - # if self.commodity in self._get_commodity_for_tech(tech_name): - # demand -= inputs[in_name] - - # return demand - def _get_commodity_for_tech(self, tech_name): """Get a list of the commodities produced for a technology. From e8b0319ce9319f5af8c5fec29fad7666ba557288 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 7 May 2026 11:46:36 -0600 Subject: [PATCH 055/105] updated find_converter_tech method --- .../system_level/system_level_control_base.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 663f136a4..8e25454f1 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1,3 +1,6 @@ +import operator +import functools + import numpy as np import networkx as nx import openmdao.api as om @@ -423,7 +426,7 @@ def find_converter_techs(self): downstream_of_previous_converter = [ n for i, n in enumerate(self.technology_graph.__iter__()) - if i > idx_upstream_converter + if i > min(idx_upstream_converter) ] all_upstream_techs = nx.ancestors(self.technology_graph, tech).intersection( set(self.input_techs) @@ -438,19 +441,22 @@ def find_converter_techs(self): upstream_commodities = [ self._get_commodity_for_tech(t) for t in connected_upstream_techs ] + upstream_commodities = functools.reduce(operator.iadd, upstream_commodities, []) # symmetric difference # commodities that are not in both input_output_commodity = set(upstream_commodities) ^ set(tech_output_commodity) if len(input_output_commodity) > 1: - input_commodity = list( + input_commodities = list( input_output_commodity.intersection(set(upstream_commodities)) ) - output_commodity = list( + output_commodities = list( input_output_commodity.intersection(set(tech_output_commodity)) ) - # formatted as (input commodity, tech_name, output comodity) - converter_techs.add((input_commodity, tech, output_commodity)) + for input_commodity in input_commodities: + for output_commodity in output_commodities: + # formatted as (input commodity, tech_name, output comodity) + converter_techs.add((input_commodity, tech, output_commodity)) upstream_converter = tech return converter_techs From 8fd6a7662925e41c36706a456e62af8f3952abb8 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 7 May 2026 11:53:16 -0600 Subject: [PATCH 056/105] started adding logic for multi-commods --- .../system_level/demand_following_control.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 09b7cb65d..0672194da 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -20,6 +20,9 @@ class DemandFollowingControl(SystemLevelControlBase): def compute(self, inputs, outputs): demand = inputs[self.demand_input_name].copy() + if self.multi_commodity_system: + self.find_converter_techs() + # 1. Curtailable techs: full production for curtailable_tech in self.curtailable_techs: commodity_from_tech = self._get_commodity_for_tech(curtailable_tech) From 2432793e1aeefc9326f77dc7cac41b562849e9d6 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 7 May 2026 12:21:33 -0600 Subject: [PATCH 057/105] connected feedstocks --- .../system_level/system_level_control_base.py | 53 +++++++++++++++++++ h2integrate/core/h2integrate_model.py | 13 ++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 83d34bcb7..149a3fe2a 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -51,6 +51,9 @@ def setup(self): self.storage_techs = [ k for k, v in slc_config["tech_control_classifiers"].items() if v == "storage" ] + self.feedstock_comps = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "feedstock" + ] self.input_techs = set( self.curtailable_techs + self.dispatchable_techs + self.storage_techs @@ -78,6 +81,7 @@ def setup(self): self._setup_tech_category("curtailable", self.curtailable_techs) self._setup_tech_category("dispatchable", self.dispatchable_techs) self._setup_tech_category("storage", self.storage_techs) + self._setup_feedstock_category(self.feedstock_comps) def _setup_commodity_for_given_units( self, tech_name, commodity, commodity_units, add_in_name=True, initial_set_point=1.0 @@ -302,6 +306,55 @@ def _setup_tech_category(self, category, tech_list): setattr(self, f"{category}_rated_names", rated_names) setattr(self, f"{category}_commodity_names", commodity_names) + def _setup_feedstock_category(self, feedstock_list): + """Iterate over the feedstocks and add inputs for the available feedstock + + Args: + feedstock_list (list[str]): name of feedstock techs + """ + for tech_name in feedstock_list: + tech_commodities = [e[1] for e in self.techs_to_commodities if e[0] == tech_name] + for commodity in tech_commodities: + in_name = f"{tech_name}_{commodity}_out" + + if commodity in self.commodities_to_units: + # Units are already known explicitly + self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodities_to_units[commodity], + desc=f"{commodity} output from {tech_name}", + ) + elif commodity in self.commodities_to_ref_var: + # Units are inferred from a previously-registered reference variable + self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=None, + copy_units=self.commodities_to_ref_var[commodity], + desc=f"{commodity} output from {tech_name}", + ) + else: + # Units are unknown; try to discover them from the connection + meta_data = self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=None, + units_by_conn=True, + desc=f"{commodity} output from {tech_name}", + ) + if meta_data["units"] is None: + # Still unknown: register in_name as the reference + # variable so later techs with this commodity can + # copy its units. + self.commodities_to_ref_var[commodity] = in_name + else: + # Connection provided units — record them for future use + self.commodities_to_units[commodity] = meta_data["units"] + def _subtract_curtailable(self, inputs, outputs, demand): """Apply curtailable techs: set_point = rated, subtract output from demand. diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 8bf0082c1..a43f6e40b 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -558,7 +558,7 @@ def _classify_slc_technologies(self): storage_tech_to_control[tech] = True # Remove feedstocks and connectors - control_classifiers_to_connect = ["curtailable", "dispatchable", "storage"] + control_classifiers_to_connect = ["curtailable", "dispatchable", "storage", "feedstock"] tech_to_commodities = { (e[0], e[-1]) for e in sources_to_commodities @@ -640,6 +640,16 @@ def add_system_level_controller(self, slc_config): # Curtailable, dispatchable, and storage techs: read output and write set_point for tech_to_commodity in slc_config["tech_to_commodity"]: tech_name, commodity = tech_to_commodity + if slc_config["tech_control_classifiers"][tech_name] == "feedstock": + # Only connect the feedstock output to the SLC + self.plant.connect( + f"{tech_name}_source.{commodity}_out", + f"system_level_controller.{tech_name}_{commodity}_out", + ) + continue + + # For all other techs, connect the tech output and rated production + # to the SLC self.plant.connect( f"{tech_name}.{commodity}_out", f"system_level_controller.{tech_name}_{commodity}_out", @@ -650,6 +660,7 @@ def add_system_level_controller(self, slc_config): f"system_level_controller.{tech_name}_rated_{commodity}_production", ) + # Connect the SLC to the controllable tech input if slc_config["storage_techs_to_control"].get(tech_name, False): # storage has its own controller # provide demand to storage controller, From ae3b8d127af4a54c8b46af56724d400fc05e6554 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 7 May 2026 12:39:30 -0600 Subject: [PATCH 058/105] moved logic from compute to method in demand following --- .../system_level/demand_following_control.py | 84 ++++++++++++++----- .../system_level/system_level_control_base.py | 2 +- 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 0672194da..b8afe9270 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -17,33 +17,30 @@ class DemandFollowingControl(SystemLevelControlBase): consider costs. """ - def compute(self, inputs, outputs): - demand = inputs[self.demand_input_name].copy() - - if self.multi_commodity_system: - self.find_converter_techs() + def run_control_for_commodity_subset(self, inputs, outputs, commodity, commodity_demand): + demand = commodity_demand.copy() # 1. Curtailable techs: full production for curtailable_tech in self.curtailable_techs: commodity_from_tech = self._get_commodity_for_tech(curtailable_tech) # check that this tech produces the commodity demanded - if self.commodity in commodity_from_tech: + if commodity in commodity_from_tech: # if the commodity produced from a tech is the demanded commodity # then subtract the curtailable production from the demand demand = self._subtract_curtailable( - curtailable_tech, demand, self.commodity, inputs, outputs + curtailable_tech, demand, commodity, inputs, outputs ) # 2. Storage dispatch # number of storage components that produce the demanded commodity n_storage = len( - [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] + [s for s in self.storage_techs if commodity in self._get_commodity_for_tech(s)] ) for storage_tech in self.storage_techs: commodity_from_tech = self._get_commodity_for_tech(storage_tech) - if self.commodity in commodity_from_tech: + if commodity in commodity_from_tech: demand = self._dispatch_storage( - storage_tech, demand / n_storage, self.commodity, inputs, outputs + storage_tech, demand / n_storage, commodity, inputs, outputs ) # 3. Dispatchable techs: equal share of remaining demand @@ -52,18 +49,67 @@ def compute(self, inputs, outputs): # calculate the number of dispatchable technologies that # produce the demanded commodity n_dispatchable = len( - [ - s - for s in self.dispatchable_techs - if self.commodity in self._get_commodity_for_tech(s) - ] + [s for s in self.dispatchable_techs if commodity in self._get_commodity_for_tech(s)] ) for dispatchable_tech in self.dispatchable_techs: commodity_from_tech = self._get_commodity_for_tech(dispatchable_tech) - if self.commodity in commodity_from_tech: - outputs[f"{dispatchable_tech}_{self.commodity}_set_point"] = ( - remaining / n_dispatchable - ) + if commodity in commodity_from_tech: + outputs[f"{dispatchable_tech}_{commodity}_set_point"] = remaining / n_dispatchable + + return outputs + + def compute(self, inputs, outputs): + if self.multi_commodity_system: + self.find_converter_techs() + outputs = self.run_control_for_commodity_subset( + inputs, outputs, self.commodity, inputs[self.demand_input_name].copy() + ) + + else: + demand = inputs[self.demand_input_name].copy() + outputs = self.run_control_for_commodity_subset(inputs, outputs, self.commodity, demand) + + # # 1. Curtailable techs: full production + # for curtailable_tech in self.curtailable_techs: + # commodity_from_tech = self._get_commodity_for_tech(curtailable_tech) + # # check that this tech produces the commodity demanded + # if self.commodity in commodity_from_tech: + # # if the commodity produced from a tech is the demanded commodity + # # then subtract the curtailable production from the demand + # demand = self._subtract_curtailable( + # curtailable_tech, demand, self.commodity, inputs, outputs + # ) + + # # 2. Storage dispatch + # # number of storage components that produce the demanded commodity + # n_storage = len( + # [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] + # ) + # for storage_tech in self.storage_techs: + # commodity_from_tech = self._get_commodity_for_tech(storage_tech) + # if self.commodity in commodity_from_tech: + # demand = self._dispatch_storage( + # storage_tech, demand / n_storage, self.commodity, inputs, outputs + # ) + + # # 3. Dispatchable techs: equal share of remaining demand + # remaining = np.maximum(demand, 0.0) + + # # calculate the number of dispatchable technologies that + # # produce the demanded commodity + # n_dispatchable = len( + # [ + # s + # for s in self.dispatchable_techs + # if self.commodity in self._get_commodity_for_tech(s) + # ] + # ) + # for dispatchable_tech in self.dispatchable_techs: + # commodity_from_tech = self._get_commodity_for_tech(dispatchable_tech) + # if self.commodity in commodity_from_tech: + # outputs[f"{dispatchable_tech}_{self.commodity}_set_point"] = ( + # remaining / n_dispatchable + # ) # Check for nans or inf if not all(np.isfinite(c).all() for k, c in outputs.items()): diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 8e25454f1..37fd54d3b 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -392,7 +392,7 @@ def get_upstream_techs_for_commodity(self, tech_name: str, commodity: str): return list(upstream_techs) def find_converter_techs(self): - """Get the name of the technology that transforms a commodity + """Get the name of the technology that transforms a commodity. Returns: set(tuple): set of converter technologies formatted as From 0f7d6b9ea2a6c9b5ea8d4662ce06e552bd6b6a5e Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 7 May 2026 12:50:00 -0600 Subject: [PATCH 059/105] updated find_converter_techs and get_upstream_techs_for_commodity --- .../system_level/system_level_control_base.py | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 1f39ddc7c..988f8a021 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -658,9 +658,11 @@ def _feedstock_marginal_cost(self, inputs, marginal_cost_data): return np.full(self.n_timesteps, marginal_cost_scalar) - def get_upstream_techs_for_commodity(self, tech_name: str, commodity: str): + def get_upstream_techs_for_commodity( + self, tech_name: str, commodity: str, include_feedstock_sources=True + ): """Get the name of technologies that are upstream - of `tech_name` and that output `commodity` + of `tech_name` and that output `commodity`. Args: tech_name (str): name of technology @@ -669,6 +671,11 @@ def get_upstream_techs_for_commodity(self, tech_name: str, commodity: str): Returns: list[str]: list of technologies upstream of the tech_name that produce a given commodity """ + if include_feedstock_sources: + input_techs = self.input_techs | set(self.feedstock_comps) + else: + input_techs = self.input_techs.copy() + # figure out where the upstream commodity is coming from upstream_components = nx.ancestors(self.technology_graph, tech_name) # iterates through a list of 3 length tuples (source, dest, commodity) @@ -678,18 +685,21 @@ def get_upstream_techs_for_commodity(self, tech_name: str, commodity: str): if s[0] in upstream_components and s[2] == commodity ] # get the technologies that are available to the controller - upstream_techs = set(upstream_components_shared_commodity).intersection( - set(self.input_techs) - ) + upstream_techs = set(upstream_components_shared_commodity).intersection(set(input_techs)) return list(upstream_techs) - def find_converter_techs(self): + def find_converter_techs(self, include_feedstock_sources=True): """Get the name of the technology that transforms a commodity. + Does not include feedstocks. Returns: set(tuple): set of converter technologies formatted as (input_commodity, converter tech name, output_commodity) """ + if include_feedstock_sources: + input_techs = self.input_techs | set(self.feedstock_comps) + else: + input_techs = self.input_techs.copy() if not self.multi_commodity_system: return @@ -697,17 +707,16 @@ def find_converter_techs(self): edges = list(self.technology_graph.edges(data="commodity")) upstream_converter = None - # for tech in self.input_techs: for edge in edges: tech, dest_tech, cmod = edge - if tech in self.input_techs: + if tech in input_techs: tech_output_commodity = self._get_commodity_for_tech(tech) # NOTE: unsure how this would work for systems with tiered converters # aka - maybe have to eliminate a converter once we've discovered it if upstream_converter is None: upstream_techs = nx.ancestors(self.technology_graph, tech).intersection( - set(self.input_techs) + set(input_techs) ) else: idx_upstream_converter = [ @@ -721,7 +730,7 @@ def find_converter_techs(self): if i > min(idx_upstream_converter) ] all_upstream_techs = nx.ancestors(self.technology_graph, tech).intersection( - set(self.input_techs) + set(input_techs) ) upstream_techs = all_upstream_techs.intersection( set(downstream_of_previous_converter) From be846a0e5612362a655d221a3df73546fbfeb1ba Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 7 May 2026 13:28:48 -0600 Subject: [PATCH 060/105] bugfix --- h2integrate/core/h2integrate_model.py | 1 - 1 file changed, 1 deletion(-) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 002ec15f4..f2e3d1c44 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -590,7 +590,6 @@ def add_system_level_controller(self, slc_config): 4. Creates connections between the controller and each technology 5. For cost/profit strategies, connects marginal cost inputs """ - slc_config = self.plant_config["system_level_control"] # 1. Select controller class based on strategy strategy_name = self.plant_config["system_level_control"].get("control_strategy") From 1f2e5cc419bec3713f5417b4a0b8c10d6221be69 Mon Sep 17 00:00:00 2001 From: kbrunik Date: Thu, 7 May 2026 15:20:36 -0500 Subject: [PATCH 061/105] Ard control classifier --- h2integrate/converters/wind/wind_plant_ard.py | 1 + 1 file changed, 1 insertion(+) diff --git a/h2integrate/converters/wind/wind_plant_ard.py b/h2integrate/converters/wind/wind_plant_ard.py index 408fc4890..779f72988 100644 --- a/h2integrate/converters/wind/wind_plant_ard.py +++ b/h2integrate/converters/wind/wind_plant_ard.py @@ -149,6 +149,7 @@ class ArdWindPlantModel(om.Group): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "curtailable" def initialize(self): self.options.declare("driver_config", types=dict) From 8f77755fed8cc7918cb3310e8209801e1834e56a Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 7 May 2026 14:28:15 -0600 Subject: [PATCH 062/105] Updating for changes to slc_config --- h2integrate/core/h2integrate_model.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 002ec15f4..69aad11fe 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -590,10 +590,10 @@ def add_system_level_controller(self, slc_config): 4. Creates connections between the controller and each technology 5. For cost/profit strategies, connects marginal cost inputs """ - slc_config = self.plant_config["system_level_control"] + plant_slc_config = self.plant_config["system_level_control"] # 1. Select controller class based on strategy - strategy_name = self.plant_config["system_level_control"].get("control_strategy") + strategy_name = plant_slc_config.get("control_strategy") slc_cls = self.supported_models.get(strategy_name) if slc_cls is None: raise ValueError( @@ -610,7 +610,7 @@ def add_system_level_controller(self, slc_config): self.plant.add_subsystem("system_level_controller", slc_comp) # 2. Configure the nonlinear solver - solver_config = SLCSolverOptionsConfig.from_dict(slc_config.get("solver_options", {})) + solver_config = SLCSolverOptionsConfig.from_dict(plant_slc_config.get("solver_options", {})) solver_cls = solver_config.return_nonlinear_solver() solver = solver_cls() solver_options = solver_config.get_solver_options() @@ -660,7 +660,7 @@ def add_system_level_controller(self, slc_config): # 4. For cost-aware strategies, connect cost inputs based on cost_per_tech if strategy_name in ("CostMinimizationControl", "ProfitMaximizationControl"): - cost_per_tech = slc_config.get("cost_per_tech", {}) + cost_per_tech = plant_slc_config.get("cost_per_tech", {}) for tech_name, _ in slc_config["tech_to_commodity"]: if self.tech_control_classifiers[tech_name] == "dispatchable": cost_spec = cost_per_tech.get(tech_name, 0.0) From 883c0b9fd6321756e46e12284b42d65105ab36d2 Mon Sep 17 00:00:00 2001 From: kbrunik Date: Thu, 7 May 2026 15:44:32 -0500 Subject: [PATCH 063/105] ard. --- h2integrate/converters/wind/wind_plant_ard.py | 4 ++++ h2integrate/core/h2integrate_model.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/h2integrate/converters/wind/wind_plant_ard.py b/h2integrate/converters/wind/wind_plant_ard.py index 779f72988..811e083f2 100644 --- a/h2integrate/converters/wind/wind_plant_ard.py +++ b/h2integrate/converters/wind/wind_plant_ard.py @@ -156,6 +156,10 @@ def initialize(self): self.options.declare("plant_config", types=dict) self.options.declare("tech_config", types=dict) + self.commodity = "electricity" + self.commodity_rate_units = "kW" + self.commodity_amount_units = "kW*h" + if set_up_ard_model is None: msg = ( "Please install `ard-nrel` or `h2integrate[ard]` to use the" diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index f2e3d1c44..2b5a0f89c 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -1952,7 +1952,7 @@ def _check_tech_connections(self): if group is None: continue - io_params.update(group.get_io_metadata().keys()) + io_params.update([key.split(".")[-1] for key in group.get_io_metadata().keys()]) tech_io[tech_name] = io_params From b6b14151f82d1010825c8f79ec129021fe9ae889 Mon Sep 17 00:00:00 2001 From: kbrunik Date: Thu, 7 May 2026 16:43:36 -0500 Subject: [PATCH 064/105] test --- h2integrate/postprocess/test/test_sql_timeseries_to_csv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py b/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py index a5b04bb6e..d345474c8 100644 --- a/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py +++ b/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py @@ -60,7 +60,7 @@ def test_save_csv_all_results(subtests, configuration, run_example_02_sql_fpath) res = save_case_timeseries_as_csv(run_example_02_sql_fpath, save_to_file=True) with subtests.test("Check number of columns"): - assert len(res.columns.to_list()) == 51 + assert len(res.columns.to_list()) == 57 with subtests.test("Check number of rows"): assert len(res) == 8760 From f46e7c84d5d169901295e6128450c61b0e91e6b7 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 7 May 2026 16:46:38 -0600 Subject: [PATCH 065/105] updated _find_feedstock_techs to use self.feedstock_comps --- .../system_level/system_level_control_base.py | 17 +++-- .../system_level/test/test_slc_controllers.py | 68 ++++++------------- 2 files changed, 30 insertions(+), 55 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 988f8a021..6c9fdb287 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -606,20 +606,19 @@ def _find_feedstock_techs(self, tech_name): Returns: list[str]: names of upstream feedstock technologies. """ - tech_config = self.options["tech_config"] - technologies = tech_config.get("technologies", {}) interconnections = self.options["plant_config"].get("technology_interconnections", []) # Upstream tech names for this dispatchable tech + # NOTE: could getting upstream_techs be replaced with the two lines below + # comds = self._get_commodity_for_tech(tech_name) + # upstream_techs = list( + # set([self.get_upstream_techs_for_commodity(tech_name, c) for c in comds]) + # ) upstream_techs = [conn[0] for conn in interconnections if conn[1] == tech_name] - feedstock_names = [] - for upstream in upstream_techs: - tech_def = technologies.get(upstream, {}) - perf_model = tech_def.get("performance_model", {}).get("model", "") - cost_model = tech_def.get("cost_model", {}).get("model", "") - if "Feedstock" in perf_model or "Feedstock" in cost_model: - feedstock_names.append(upstream) + feedstock_names = [ + upstream for upstream in upstream_techs if upstream in self.feedstock_comps + ] return feedstock_names diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py b/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py index fc41d3727..a908e4c19 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py @@ -22,13 +22,16 @@ def _make_plant_config( curtailable=None, dispatchable=None, storage=None, + feedstock=None, sell_price=0.06, cost_per_tech=None, technology_interconnections=None, ): """Build a minimal plant_config dict for controller tests.""" - all_techs = (curtailable or []) + (dispatchable or []) + (storage or []) + all_techs = (curtailable or []) + (dispatchable or []) + (storage or []) + (feedstock or []) + tech_to_commodity = {(t, "electricity") for t in all_techs} + config = { "plant": {"simulation": {"n_timesteps": n_timesteps, "dt": 3600}, "plant_life": 30}, "system_level_control": { @@ -39,6 +42,7 @@ def _make_plant_config( "curtailable_techs": curtailable or [], "dispatchable_techs": dispatchable or [], "storage_techs": storage or [], + "feedstock_techs": feedstock or [], "tech_to_commodity": tech_to_commodity, "commodity_sell_price": sell_price, "cost_per_tech": cost_per_tech or {}, @@ -56,7 +60,8 @@ def _make_slc_config(plant_config): curtailable = slc.get("curtailable_techs", []) dispatchable = slc.get("dispatchable_techs", []) storage = slc.get("storage_techs", []) - all_techs = curtailable + dispatchable + storage + feedstock = slc.get("feedstock_techs", []) + all_techs = curtailable + dispatchable + storage + feedstock # Build technology graph tech_graph = nx.DiGraph() @@ -79,6 +84,8 @@ def _make_slc_config(plant_config): classifiers[t] = "dispatchable" for t in storage: classifiers[t] = "storage" + for t in feedstock: + classifiers[t] = "feedstock" return { "demand_commodity": slc["demand_commodity"], @@ -91,7 +98,7 @@ def _make_slc_config(plant_config): } -def _build_problem(slc_cls, plant_config, tech_config=None): +def _build_problem(slc_cls, plant_config): """Create and setup an OpenMDAO Problem with the given controller.""" slc_config = _make_slc_config(plant_config) prob = om.Problem() @@ -100,7 +107,7 @@ def _build_problem(slc_cls, plant_config, tech_config=None): slc_cls( driver_config={}, plant_config=plant_config, - tech_config=tech_config or {}, + tech_config={}, slc_config=slc_config, ), ) @@ -482,6 +489,7 @@ def test_feedstock_single(self): """feedstock mode: single upstream feedstock drives marginal cost.""" pc = _make_plant_config( dispatchable=["ng_plant"], + feedstock=["ng_feed"], demand=50000, sell_price=0.10, cost_per_tech={"ng_plant": "feedstock"}, @@ -489,15 +497,8 @@ def test_feedstock_single(self): ["ng_feed", "ng_plant", "natural_gas", "pipe"], ], ) - tech_config = { - "technologies": { - "ng_feed": { - "performance_model": {"model": "FeedstockPerformanceModel"}, - "cost_model": {"model": "FeedstockCostModel"}, - }, - } - } - prob = _build_problem(CostMinimizationControl, pc, tech_config=tech_config) + + prob = _build_problem(CostMinimizationControl, pc) prob.set_val("slc.ng_plant_rated_electricity_production", 100000) # Feedstock VarOpEx: $1M/yr; production: 100 MW * 4 h = 400 MWh prob.set_val("slc.ng_feed_VarOpEx", np.full(30, 1_000_000.0)) @@ -513,6 +514,7 @@ def test_feedstock_multiple(self): """feedstock mode: multiple upstream feedstocks are summed.""" pc = _make_plant_config( dispatchable=["plant"], + feedstock=["feed_a", "feed_b"], demand=50000, sell_price=0.10, cost_per_tech={"plant": "feedstock"}, @@ -522,22 +524,8 @@ def test_feedstock_multiple(self): ["other_tech", "plant", "something", "cable"], ], ) - tech_config = { - "technologies": { - "feed_a": { - "performance_model": {"model": "FeedstockPerformanceModel"}, - "cost_model": {"model": "FeedstockCostModel"}, - }, - "feed_b": { - "performance_model": {"model": "FeedstockPerformanceModel"}, - "cost_model": {"model": "FeedstockCostModel"}, - }, - "other_tech": { - "performance_model": {"model": "SomePerformanceModel"}, - }, - } - } - prob = _build_problem(CostMinimizationControl, pc, tech_config=tech_config) + + prob = _build_problem(CostMinimizationControl, pc) prob.set_val("slc.plant_rated_electricity_production", 100000) # Two feedstocks: $500k and $300k → total $800k/yr prob.set_val("slc.feed_a_VarOpEx", np.full(30, 500_000.0)) @@ -554,6 +542,7 @@ def test_feedstock_profit_max_unprofitable(self): """feedstock mode in profit max: unprofitable when feedstock costs exceed sell price.""" pc = _make_plant_config( dispatchable=["ng_plant"], + feedstock=["ng_feed"], demand=50000, sell_price=0.01, # very low sell price cost_per_tech={"ng_plant": "feedstock"}, @@ -561,15 +550,8 @@ def test_feedstock_profit_max_unprofitable(self): ["ng_feed", "ng_plant", "natural_gas", "pipe"], ], ) - tech_config = { - "technologies": { - "ng_feed": { - "performance_model": {"model": "FeedstockPerformanceModel"}, - "cost_model": {"model": "FeedstockCostModel"}, - }, - } - } - prob = _build_problem(ProfitMaximizationControl, pc, tech_config=tech_config) + + prob = _build_problem(ProfitMaximizationControl, pc) prob.set_val("slc.ng_plant_rated_electricity_production", 100000) prob.set_val("slc.commodity_sell_price", 0.01) # Very expensive feedstock: $100M/yr → high marginal cost @@ -591,12 +573,6 @@ def test_feedstock_no_feedstock_raises(self): ["some_tech", "ng_plant", "electricity", "cable"], ], ) - tech_config = { - "technologies": { - "some_tech": { - "performance_model": {"model": "SomePerformanceModel"}, - }, - } - } + with pytest.raises(ValueError, match="at least one feedstock"): - _build_problem(CostMinimizationControl, pc, tech_config=tech_config) + _build_problem(CostMinimizationControl, pc) From b34bb916cac96b72fd04e48afdcb55624bcaec9d Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 7 May 2026 16:49:43 -0600 Subject: [PATCH 066/105] updated some doc strings in slc baseclass --- .../system_level/system_level_control_base.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 6c9fdb287..b953a965a 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -597,8 +597,7 @@ def _find_feedstock_techs(self, tech_name): """Find feedstock technologies connected upstream of tech_name. Scans ``technology_interconnections`` for connections whose - destination is tech_name and whose source uses - ``FeedstockPerformanceModel`` or ``FeedstockCostModel``. + destination is tech_name and whose source is a feedstock. Args: tech_name (str): the dispatchable technology name. @@ -666,6 +665,8 @@ def get_upstream_techs_for_commodity( Args: tech_name (str): name of technology commodity (str): commodity name + include_feedstock_sources (bool, optional): If True, include techs + that have an input commodity from a feedstock. Defaults to True. Returns: list[str]: list of technologies upstream of the tech_name that produce a given commodity @@ -689,7 +690,10 @@ def get_upstream_techs_for_commodity( def find_converter_techs(self, include_feedstock_sources=True): """Get the name of the technology that transforms a commodity. - Does not include feedstocks. + + Args: + include_feedstock_sources (bool, optional): If True, include techs + that have an input commodity from a feedstock. Defaults to True. Returns: set(tuple): set of converter technologies formatted as From 4fb89906e2c305e806e9c1c100d6d30ce7485504 Mon Sep 17 00:00:00 2001 From: kbrunik Date: Fri, 8 May 2026 11:20:30 -0500 Subject: [PATCH 067/105] docs wip --- .../control_classifier.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/control/system_level_control/control_classifier.md diff --git a/docs/control/system_level_control/control_classifier.md b/docs/control/system_level_control/control_classifier.md new file mode 100644 index 000000000..8797ab81b --- /dev/null +++ b/docs/control/system_level_control/control_classifier.md @@ -0,0 +1,50 @@ +# System Level Control Technology Performance Classifiers + +To enable a generic system level control framework we need to classify each technology based on how the model, that is included in H2I, can operate within the system. + +```{note} +While in real life there are a lot of controllable parameters allowing for ramping production up or down for a particular technology (e.g., turbine yaw). The particular model in H2I might not be capable of simulating a modulated response based on an input signal. +``` + +We have identified three key classifiers that are able to represent the different behaviors that we can expect from the model. Each performance model includes a parameter setting the classifier `_control_classifier`. + +Classifier | Meaning | Example Techs +-- | -- | -- +curtailable | Produces based on resource or input commodity; can only be reduced | wind, solar, nuclear +dispatchable | Can modulate consumption/production within bounds | grid, NG turbine +storage | Can modulate consumption/production within bounds while tracking SOC; does not produce/consume energy | battery, h2 storage, any storage + +To add a classifier for a particular model it would look something like this in the class: +```{python} +_control_classifier = "curtailable" +``` + +## Curtailable +A curtailable performance model represents anything that can have the output reduced based on a give set point from the system level controller. This classifier and the inputs and outputs are included in the figure below. A good example of this is the PVWatts PySAM solar plant in H2I, the performance of the system is based on the input solar resource. The solar performance does not change based on, for example, an updated set point to the tracking software, but we could limit the power output from the solar performance model based on a given demand set point. To simplify the implementation of applying this curtailment or reduction based on a set point we added a method, `apply_curtailment()` to the `PerformanceBaseClass`. + +```{figure} figures/curtailable.png +:width: 70% +:align: center +``` + +### Apply curtailment based on set_point +Within the `compute()` method in the performance model you can apply the curtailment using the `apply_curtailment()` method. +``` +self.apply_curtailment(outputs) +``` +which, applies curtailment to `{commodity}_out` based on `{commodity}_set_point`. There is then `uncurtailed_{commodity}_out` and `{commodity}_out` as outputs from the performance model. + +## Dispatchable +A dispatchable performance model represents anything that can receive a set point. Any model that has the "dispatchable" control classifier tag is able to receive a set point and change it's behavior based on that set point. There aren't additional special methods to handle this because it's internal to each performance model. + +```{figure} figures/dispatchable.png +:width: 70% +:align: center +``` + +## Storage +Storage is a unique control classifier because it assumes that within the model that energy isn't created or destroyed (minus some efficiency losses). While it's technically "dispatchable" in that it can receive and change its performance based on a set point it's handling within H2I is unique because it's attached to storage performance models, which is handled differently than converter performance models. + +The storage performance models that have the "storage" control classifier additionally require a storage-level controller in addition to the overarching system level controller. See the image below for reference. The storage-level controller takes in the system level-controller set points and outputs charge (negative) and discharge (positive) commands (storage-level set points) to the storage performance model. + +ADD IMAGE - wait until [PR # 731](https://github.com/NatLabRockies/H2Integrate/pull/731) comes in. From 31711d41504833d28a5a6e467e986c91331b3c80 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 8 May 2026 10:57:48 -0600 Subject: [PATCH 068/105] Minor refactor to demand following control --- .../system_level/demand_following_control.py | 67 +++---------------- .../system_level/system_level_control_base.py | 2 +- 2 files changed, 9 insertions(+), 60 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index b8afe9270..0269bde9e 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -43,8 +43,8 @@ def run_control_for_commodity_subset(self, inputs, outputs, commodity, commodity storage_tech, demand / n_storage, commodity, inputs, outputs ) - # 3. Dispatchable techs: equal share of remaining demand - remaining = np.maximum(demand, 0.0) + # 3. Dispatchable techs + remaining_demand = np.maximum(demand, 0.0) # calculate the number of dispatchable technologies that # produce the demanded commodity @@ -54,67 +54,16 @@ def run_control_for_commodity_subset(self, inputs, outputs, commodity, commodity for dispatchable_tech in self.dispatchable_techs: commodity_from_tech = self._get_commodity_for_tech(dispatchable_tech) if commodity in commodity_from_tech: - outputs[f"{dispatchable_tech}_{commodity}_set_point"] = remaining / n_dispatchable + outputs[f"{dispatchable_tech}_{commodity}_set_point"] = ( + remaining_demand / n_dispatchable + ) return outputs def compute(self, inputs, outputs): if self.multi_commodity_system: self.find_converter_techs() - outputs = self.run_control_for_commodity_subset( - inputs, outputs, self.commodity, inputs[self.demand_input_name].copy() - ) - - else: - demand = inputs[self.demand_input_name].copy() - outputs = self.run_control_for_commodity_subset(inputs, outputs, self.commodity, demand) - - # # 1. Curtailable techs: full production - # for curtailable_tech in self.curtailable_techs: - # commodity_from_tech = self._get_commodity_for_tech(curtailable_tech) - # # check that this tech produces the commodity demanded - # if self.commodity in commodity_from_tech: - # # if the commodity produced from a tech is the demanded commodity - # # then subtract the curtailable production from the demand - # demand = self._subtract_curtailable( - # curtailable_tech, demand, self.commodity, inputs, outputs - # ) - - # # 2. Storage dispatch - # # number of storage components that produce the demanded commodity - # n_storage = len( - # [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] - # ) - # for storage_tech in self.storage_techs: - # commodity_from_tech = self._get_commodity_for_tech(storage_tech) - # if self.commodity in commodity_from_tech: - # demand = self._dispatch_storage( - # storage_tech, demand / n_storage, self.commodity, inputs, outputs - # ) - # # 3. Dispatchable techs: equal share of remaining demand - # remaining = np.maximum(demand, 0.0) - - # # calculate the number of dispatchable technologies that - # # produce the demanded commodity - # n_dispatchable = len( - # [ - # s - # for s in self.dispatchable_techs - # if self.commodity in self._get_commodity_for_tech(s) - # ] - # ) - # for dispatchable_tech in self.dispatchable_techs: - # commodity_from_tech = self._get_commodity_for_tech(dispatchable_tech) - # if self.commodity in commodity_from_tech: - # outputs[f"{dispatchable_tech}_{self.commodity}_set_point"] = ( - # remaining / n_dispatchable - # ) - - # Check for nans or inf - if not all(np.isfinite(c).all() for k, c in outputs.items()): - bad_outputs = [k for k, c in outputs.items() if not np.isfinite(c).all()] - raise ValueError(f"These outputs contain non-finite values: {bad_outputs}") - if not all(np.isfinite(c).all() for k, c in inputs.items()): - bad_inputs = [k for k, c in inputs.items() if not np.isfinite(c).all()] - raise ValueError(f"These inputs contain non-finite values: {bad_inputs}") + outputs = self.run_control_for_commodity_subset( + inputs, outputs, self.commodity, inputs[self.demand_input_name].copy() + ) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index b953a965a..d162e0778 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -759,7 +759,7 @@ def find_converter_techs(self, include_feedstock_sources=True): for input_commodity in input_commodities: for output_commodity in output_commodities: - # formatted as (input commodity, tech_name, output comodity) + # formatted as (input commodity, tech_name, output commodity) converter_techs.add((input_commodity, tech, output_commodity)) upstream_converter = tech From 9bae204ad76c4621fe1aac773e8316fd311f4f7d Mon Sep 17 00:00:00 2001 From: kbrunik Date: Fri, 8 May 2026 12:10:36 -0500 Subject: [PATCH 069/105] docs --- docs/_static/class_hierarchy.html | 4 ++-- docs/_toc.yml | 11 ++++++++--- docs/control/storage_level_control.md | 3 +++ .../system_level_control/control_classifier.md | 15 ++++++++++++--- .../system_level_control/system_level_control.md | 10 ++++++++++ 5 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 docs/control/storage_level_control.md create mode 100644 docs/control/system_level_control/system_level_control.md diff --git a/docs/_static/class_hierarchy.html b/docs/_static/class_hierarchy.html index fea629882..7149a5c30 100644 --- a/docs/_static/class_hierarchy.html +++ b/docs/_static/class_hierarchy.html @@ -380,8 +380,8 @@

// parsing and collecting nodes and edges from the python - nodes = new vis.DataSet([{"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "FeedstockCostModel", "label": "FeedstockCostModel", "shape": "ellipse", "size": 18.0, "title": "FeedstockCostModel\ncore/feedstocks.py\n[Core / General]", "x": -79.5125603737886, "y": 584.9567473090942}, {"borderWidth": 5.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SiteBaseComponent", "label": "SiteBaseComponent", "shape": "ellipse", "size": 18.72972972972973, "title": "SiteBaseComponent\ncore/sites.py\n[Core / General]", "x": -51.99787538017026, "y": 668.8874099443384}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SiteLocationComponent", "label": "SiteLocationComponent", "shape": "ellipse", "size": 18.0, "title": "SiteLocationComponent\ncore/sites.py\n[Core / General]", "x": -137.54209445136647, "y": 722.897904528822}, {"borderWidth": 5.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PerformanceModelBaseClass", "label": "PerformanceModelBaseClass", "shape": "ellipse", "size": 40.62162162162162, "title": "PerformanceModelBaseClass\ncore/model_baseclasses.py\n[Core / General]", "x": -243.0155271316636, "y": 685.2630296209401}, {"borderWidth": 5.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CostModelBaseClass", "label": "CostModelBaseClass", "shape": "ellipse", "size": 45.0, "title": "CostModelBaseClass\ncore/model_baseclasses.py\n[Core / General]", "x": -288.0485916668164, "y": 575.9204299065049}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ResizeablePerformanceModelBaseClass", "label": "ResizeablePerformanceModelBaseClass", "shape": "ellipse", "size": 19.45945945945946, "title": "ResizeablePerformanceModelBaseClass\ncore/model_baseclasses.py\n[Core / General]", "x": -237.4418960911029, "y": 464.62515055513364}, {"borderWidth": 5.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CacheBaseClass", "label": "CacheBaseClass", "shape": "ellipse", "size": 19.45945945945946, "title": "CacheBaseClass\ncore/model_baseclasses.py\n[Core / General]", "x": -119.33772505659815, "y": 423.5780806776881}, {"borderWidth": 4.0, "color": {"background": "#F5C542", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GenericConverterCostModel", "label": "GenericConverterCostModel", "shape": "dot", "size": 18.0, "title": "GenericConverterCostModel\nconverters/generic_converter_cost.py\n[Converter / Other]", "x": 428.09388111524015, "y": 469.0988894808179}, {"borderWidth": 3.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PYSAMSolarPlantPerformanceModel", "label": "PYSAMSolarPlantPerformanceModel", "shape": "dot", "size": 18.0, "title": "PYSAMSolarPlantPerformanceModel\nconverters/solar/solar_pysam.py\n[Converter / Solar]", "x": 455.6085661088585, "y": 553.0295521160621}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ATBUtilityPVCostModel", "label": "ATBUtilityPVCostModel", "shape": "dot", "size": 18.0, "title": "ATBUtilityPVCostModel\nconverters/solar/atb_utility_pv_cost.py\n[Converter / Solar]", "x": 370.0643470376623, "y": 607.0400467005456}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ATBResComPVCostModel", "label": "ATBResComPVCostModel", "shape": "dot", "size": 18.0, "title": "ATBResComPVCostModel\nconverters/solar/atb_res_com_pv_cost.py\n[Converter / Solar]", "x": 264.59091435736514, "y": 569.4051717926637}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SolarPerformanceBaseClass", "label": "SolarPerformanceBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "SolarPerformanceBaseClass\nconverters/solar/solar_baseclass.py\n[Converter / Solar]", "x": 219.55784982221238, "y": 460.06257207822847}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ElectrolyzerPerformanceBaseClass", "label": "ElectrolyzerPerformanceBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "ElectrolyzerPerformanceBaseClass\nconverters/hydrogen/electrolyzer_baseclass.py\n[Converter / Hydrogen]", "x": 270.16454539792585, "y": 348.7672927268573}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ElectrolyzerCostBaseClass", "label": "ElectrolyzerCostBaseClass", "shape": "dot", "size": 20.18918918918919, "title": "ElectrolyzerCostBaseClass\nconverters/hydrogen/electrolyzer_baseclass.py\n[Converter / Hydrogen]", "x": 388.2687164324306, "y": 307.72022284941175}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SingliticoCostModel", "label": "SingliticoCostModel", "shape": "dot", "size": 18.0, "title": "SingliticoCostModel\nconverters/hydrogen/singlitico_cost_model.py\n[Converter / Hydrogen]", "x": 501.4805766605487, "y": 365.4133442102892}, {"borderWidth": 1, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "WOMBATElectrolyzerModel", "label": "WOMBATElectrolyzerModel", "shape": "dot", "size": 18.0, "title": "WOMBATElectrolyzerModel\nconverters/hydrogen/wombat_model.py\n[Converter / Hydrogen]", "x": 538.9625776291001, "y": 488.44605748599986}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "LinearH2FuelCellPerformanceModel", "label": "LinearH2FuelCellPerformanceModel", "shape": "dot", "size": 18.0, "title": "LinearH2FuelCellPerformanceModel\nconverters/hydrogen/h2_fuel_cell.py\n[Converter / Hydrogen]", "x": 475.93189116793957, "y": 601.9588898891661}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "H2FuelCellCostModel", "label": "H2FuelCellCostModel", "shape": "dot", "size": 18.0, "title": "H2FuelCellCostModel\nconverters/hydrogen/h2_fuel_cell.py\n[Converter / Hydrogen]", "x": 349.5705117805884, "y": 635.8507248662497}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SteamMethaneReformerPerformanceModel", "label": "SteamMethaneReformerPerformanceModel", "shape": "dot", "size": 18.0, "title": "SteamMethaneReformerPerformanceModel\nconverters/hydrogen/steam_methane_reformer.py\n[Converter / Hydrogen]", "x": 236.61361523178945, "y": 568.2423536759715}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SteamMethaneReformerCostModel", "label": "SteamMethaneReformerCostModel", "shape": "dot", "size": 18.0, "title": "SteamMethaneReformerCostModel\nconverters/hydrogen/steam_methane_reformer.py\n[Converter / Hydrogen]", "x": 206.39396475153276, "y": 439.409697663304}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "BasicElectrolyzerCostModel", "label": "BasicElectrolyzerCostModel", "shape": "dot", "size": 18.0, "title": "BasicElectrolyzerCostModel\nconverters/hydrogen/basic_cost_model.py\n[Converter / Hydrogen]", "x": 278.165573095184, "y": 327.54056326717114}, {"borderWidth": 2.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ECOElectrolyzerPerformanceModel", "label": "ECOElectrolyzerPerformanceModel", "shape": "dot", "size": 18.72972972972973, "title": "ECOElectrolyzerPerformanceModel\nconverters/hydrogen/pem_electrolyzer.py\n[Converter / Hydrogen]", "x": 408.929515746538, "y": 301.0719911584519}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CustomElectrolyzerCostModel", "label": "CustomElectrolyzerCostModel", "shape": "dot", "size": 18.0, "title": "CustomElectrolyzerCostModel\nconverters/hydrogen/custom_electrolyzer_cost_model.py\n[Converter / Hydrogen]", "x": 519.3422437620703, "y": 376.741275969993}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GeoH2SubsurfaceCostModel", "label": "GeoH2SubsurfaceCostModel", "shape": "dot", "size": 18.0, "title": "GeoH2SubsurfaceCostModel\nconverters/hydrogen/geologic/mathur_modified.py\n[Converter / Hydrogen]", "x": 541.9933368656093, "y": 509.0547616483423}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GeoH2SubsurfacePerformanceBaseClass", "label": "GeoH2SubsurfacePerformanceBaseClass", "shape": "dot", "size": 19.45945945945946, "title": "GeoH2SubsurfacePerformanceBaseClass\nconverters/hydrogen/geologic/h2_well_subsurface_baseclass.py\n[Converter / Hydrogen]", "x": 462.61867180963463, "y": 617.7338834509918}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GeoH2SubsurfaceCostBaseClass", "label": "GeoH2SubsurfaceCostBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "GeoH2SubsurfaceCostBaseClass\nconverters/hydrogen/geologic/h2_well_subsurface_baseclass.py\n[Converter / Hydrogen]", "x": 329.04975630639217, "y": 636.513453478361}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "AspenGeoH2SurfacePerformanceModel", "label": "AspenGeoH2SurfacePerformanceModel", "shape": "dot", "size": 18.0, "title": "AspenGeoH2SurfacePerformanceModel\nconverters/hydrogen/geologic/aspen_surface_processing.py\n[Converter / Hydrogen]", "x": 222.32576716621537, "y": 553.5863417347226}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "AspenGeoH2SurfaceCostModel", "label": "AspenGeoH2SurfaceCostModel", "shape": "dot", "size": 18.0, "title": "AspenGeoH2SurfaceCostModel\nconverters/hydrogen/geologic/aspen_surface_processing.py\n[Converter / Hydrogen]", "x": 207.46113153897124, "y": 419.00371238109653}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NaturalGeoH2PerformanceModel", "label": "NaturalGeoH2PerformanceModel", "shape": "dot", "size": 18.0, "title": "NaturalGeoH2PerformanceModel\nconverters/hydrogen/geologic/simple_natural_geoh2.py\n[Converter / Hydrogen]", "x": 293.81016729091453, "y": 314.4201619012048}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "StimulatedGeoH2PerformanceModel", "label": "StimulatedGeoH2PerformanceModel", "shape": "dot", "size": 18.0, "title": "StimulatedGeoH2PerformanceModel\nconverters/hydrogen/geologic/templeton_serpentinization.py\n[Converter / Hydrogen]", "x": 429.1980303433203, "y": 303.5048975398027}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GeoH2SurfacePerformanceBaseClass", "label": "GeoH2SurfacePerformanceBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "GeoH2SurfacePerformanceBaseClass\nconverters/hydrogen/geologic/h2_well_surface_baseclass.py\n[Converter / Hydrogen]", "x": 531.4807512394786, "y": 393.1585470237545}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GeoH2SurfaceCostBaseClass", "label": "GeoH2SurfaceCostBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "GeoH2SurfaceCostBaseClass\nconverters/hydrogen/geologic/h2_well_surface_baseclass.py\n[Converter / Hydrogen]", "x": 538.4198729733245, "y": 529.1652694271473}, {"borderWidth": 3.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ReverseOsmosisPerformanceModel", "label": "ReverseOsmosisPerformanceModel", "shape": "dot", "size": 18.0, "title": "ReverseOsmosisPerformanceModel\nconverters/water/desal/desalination.py\n[Converter / Water]", "x": 445.5710237882473, "y": 629.0047614736493}, {"borderWidth": 3.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ReverseOsmosisCostModel", "label": "ReverseOsmosisCostModel", "shape": "dot", "size": 18.0, "title": "ReverseOsmosisCostModel\nconverters/water/desal/desalination.py\n[Converter / Water]", "x": 309.11651798504033, "y": 631.947653855694}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "DesalinationPerformanceBaseClass", "label": "DesalinationPerformanceBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "DesalinationPerformanceBaseClass\nconverters/water/desal/desalination_baseclass.py\n[Converter / Water]", "x": 211.84908461781183, "y": 536.0083507662598}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "DesalinationCostBaseClass", "label": "DesalinationCostBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "DesalinationCostBaseClass\nconverters/water/desal/desalination_baseclass.py\n[Converter / Water]", "x": 212.91655044595416, "y": 399.26617741803875}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "QuinnNuclearPerformanceModel", "label": "QuinnNuclearPerformanceModel", "shape": "dot", "size": 18.0, "title": "QuinnNuclearPerformanceModel\nconverters/nuclear/nuclear_plant.py\n[Converter / Nuclear]", "x": 311.84419223616084, "y": 304.68898638961775}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "QuinnNuclearCostModel", "label": "QuinnNuclearCostModel", "shape": "dot", "size": 18.0, "title": "QuinnNuclearCostModel\nconverters/nuclear/nuclear_plant.py\n[Converter / Nuclear]", "x": 448.7220200189815, "y": 309.77552217810415}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CMUElectricArcFurnaceCostModel", "label": "CMUElectricArcFurnaceCostModel", "shape": "dot", "size": 18.0, "title": "CMUElectricArcFurnaceCostModel\nconverters/steel/cmu_eaf_cost.py\n[Converter / Steel]", "x": 540.4994435124524, "y": 411.5906925910052}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HydrogenEAFPlantCostComponent", "label": "HydrogenEAFPlantCostComponent", "shape": "dot", "size": 18.0, "title": "HydrogenEAFPlantCostComponent\nconverters/steel/steel_eaf_plant.py\n[Converter / Steel]", "x": 531.3901362621964, "y": 548.4583323631547}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NaturalGasEAFPlantCostComponent", "label": "NaturalGasEAFPlantCostComponent", "shape": "dot", "size": 18.0, "title": "NaturalGasEAFPlantCostComponent\nconverters/steel/steel_eaf_plant.py\n[Converter / Steel]", "x": 426.78782378431725, "y": 637.3337381981482}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HydrogenEAFPlantPerformanceComponent", "label": "HydrogenEAFPlantPerformanceComponent", "shape": "dot", "size": 18.0, "title": "HydrogenEAFPlantPerformanceComponent\nconverters/steel/steel_eaf_plant.py\n[Converter / Steel]", "x": 290.0713774101109, "y": 624.2026575560053}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NaturalGasEAFPlantPerformanceComponent", "label": "NaturalGasEAFPlantPerformanceComponent", "shape": "dot", "size": 18.0, "title": "NaturalGasEAFPlantPerformanceComponent\nconverters/steel/steel_eaf_plant.py\n[Converter / Steel]", "x": 204.19394571089566, "y": 516.9137611171568}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CMUElectricArcFurnaceScrapOnlyPerformanceComponent", "label": "CMUElectricArcFurnaceScrapOnlyPerformanceComponent", "shape": "dot", "size": 18.0, "title": "CMUElectricArcFurnaceScrapOnlyPerformanceComponent\nconverters/steel/cmu_electric_arc_furnace_scrap.py\n[Converter / Steel]", "x": 221.34134343018306, "y": 380.48561129518464}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ElectricArcFurnacePlantBasePerformanceComponent", "label": "ElectricArcFurnacePlantBasePerformanceComponent", "shape": "dot", "size": 19.45945945945946, "title": "ElectricArcFurnacePlantBasePerformanceComponent\nconverters/steel/steel_eaf_base.py\n[Converter / Steel]", "x": 331.21568985008844, "y": 297.6965237937115}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ElectricArcFurnacePlantBaseCostComponent", "label": "ElectricArcFurnacePlantBaseCostComponent", "shape": "dot", "size": 19.45945945945946, "title": "ElectricArcFurnacePlantBaseCostComponent\nconverters/steel/steel_eaf_base.py\n[Converter / Steel]", "x": 467.2216800346311, "y": 318.8505150630724}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CMUElectricArcFurnaceDRIPerformanceComponent", "label": "CMUElectricArcFurnaceDRIPerformanceComponent", "shape": "dot", "size": 18.0, "title": "CMUElectricArcFurnaceDRIPerformanceComponent\nconverters/steel/cmu_electric_arc_furnace_dri.py\n[Converter / Steel]", "x": 546.8371236174256, "y": 431.20832772949075}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SteelPerformanceBaseClass", "label": "SteelPerformanceBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "SteelPerformanceBaseClass\nconverters/steel/steel_baseclass.py\n[Converter / Steel]", "x": 521.6903751911968, "y": 566.6610637668477}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SteelCostBaseClass", "label": "SteelCostBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "SteelCostBaseClass\nconverters/steel/steel_baseclass.py\n[Converter / Steel]", "x": 406.952112243024, "y": 643.0222537742708}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SteelPerformanceModel", "label": "SteelPerformanceModel", "shape": "dot", "size": 18.0, "title": "SteelPerformanceModel\nconverters/steel/steel.py\n[Converter / Steel]", "x": 272.18129178907674, "y": 613.9005699916162}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SteelCostAndFinancialModel", "label": "SteelCostAndFinancialModel", "shape": "dot", "size": 18.0, "title": "SteelCostAndFinancialModel\nconverters/steel/steel.py\n[Converter / Steel]", "x": 199.15055717864382, "y": 496.88602197643854}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleASUPerformanceModel", "label": "SimpleASUPerformanceModel", "shape": "dot", "size": 18.0, "title": "SimpleASUPerformanceModel\nconverters/nitrogen/simple_ASU.py\n[Converter / Nitrogen]", "x": 232.2254768075065, "y": 362.9235822800501}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleASUCostModel", "label": "SimpleASUCostModel", "shape": "dot", "size": 18.0, "title": "SimpleASUCostModel\nconverters/nitrogen/simple_ASU.py\n[Converter / Nitrogen]", "x": 351.4109262191128, "y": 293.29530750309686}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ATBWindPlantCostModel", "label": "ATBWindPlantCostModel", "shape": "dot", "size": 18.0, "title": "ATBWindPlantCostModel\nconverters/wind/atb_wind_cost.py\n[Converter / Wind]", "x": 484.44055215455836, "y": 330.297978929092}, {"borderWidth": 3.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PYSAMWindPlantPerformanceModel", "label": "PYSAMWindPlantPerformanceModel", "shape": "dot", "size": 18.0, "title": "PYSAMWindPlantPerformanceModel\nconverters/wind/wind_pysam.py\n[Converter / Wind]", "x": 550.5984027605526, "y": 451.5476939728353}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "WindPerformanceBaseClass", "label": "WindPerformanceBaseClass", "shape": "dot", "size": 19.45945945945946, "title": "WindPerformanceBaseClass\nconverters/wind/wind_plant_baseclass.py\n[Converter / Wind]", "x": 509.69716559653716, "y": 583.5219955261796}, {"borderWidth": 3.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "FlorisWindPlantPerformanceModel", "label": "FlorisWindPlantPerformanceModel", "shape": "dot", "size": 18.0, "title": "FlorisWindPlantPerformanceModel\nconverters/wind/floris.py\n[Converter / Wind]", "x": 386.4910795111691, "y": 646.1453801885751}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "WindArdPerformanceCompatibilityComponent", "label": "WindArdPerformanceCompatibilityComponent", "shape": "dot", "size": 18.0, "title": "WindArdPerformanceCompatibilityComponent\nconverters/wind/wind_plant_ard.py\n[Converter / Wind]", "x": 255.69275934662096, "y": 601.3783905588575}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "WindArdCostCompatibilityComponent", "label": "WindArdCostCompatibilityComponent", "shape": "dot", "size": 18.0, "title": "WindArdCostCompatibilityComponent\nconverters/wind/wind_plant_ard.py\n[Converter / Wind]", "x": 196.66404742738186, "y": 476.325072910983}, {"borderWidth": 3.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SMRMethanolPlantPerformanceModel", "label": "SMRMethanolPlantPerformanceModel", "shape": "dot", "size": 18.0, "title": "SMRMethanolPlantPerformanceModel\nconverters/methanol/smr_methanol_plant.py\n[Converter / Methanol]", "x": 245.26041830184303, "y": 346.82157500140227}, {"borderWidth": 3.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SMRMethanolPlantCostModel", "label": "SMRMethanolPlantCostModel", "shape": "dot", "size": 18.0, "title": "SMRMethanolPlantCostModel\nconverters/methanol/smr_methanol_plant.py\n[Converter / Methanol]", "x": 372.0506131909644, "y": 291.4439725755037}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SMRMethanolPlantFinanceModel", "label": "SMRMethanolPlantFinanceModel", "shape": "dot", "size": 18.0, "title": "SMRMethanolPlantFinanceModel\nconverters/methanol/smr_methanol_plant.py\n[Converter / Methanol]", "x": 500.1422507144458, "y": 343.82986093378406}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MethanolPerformanceBaseClass", "label": "MethanolPerformanceBaseClass", "shape": "dot", "size": 19.45945945945946, "title": "MethanolPerformanceBaseClass\nconverters/methanol/methanol_baseclass.py\n[Converter / Methanol]", "x": 551.8160292273673, "y": 472.2454066683052}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MethanolCostBaseClass", "label": "MethanolCostBaseClass", "shape": "dot", "size": 19.45945945945946, "title": "MethanolCostBaseClass\nconverters/methanol/methanol_baseclass.py\n[Converter / Methanol]", "x": 495.6839168464708, "y": 598.8099536341376}, {"borderWidth": 5.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MethanolFinanceBaseClass", "label": "MethanolFinanceBaseClass", "shape": "dot", "size": 19.45945945945946, "title": "MethanolFinanceBaseClass\nconverters/methanol/methanol_baseclass.py\n[Converter / Methanol]", "x": 365.7556658010815, "y": 646.7308814798549}, {"borderWidth": 3.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CO2HMethanolPlantPerformanceModel", "label": "CO2HMethanolPlantPerformanceModel", "shape": "dot", "size": 18.0, "title": "CO2HMethanolPlantPerformanceModel\nconverters/methanol/co2h_methanol_plant.py\n[Converter / Methanol]", "x": 240.8316118946296, "y": 586.8992069729802}, {"borderWidth": 3.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CO2HMethanolPlantCostModel", "label": "CO2HMethanolPlantCostModel", "shape": "dot", "size": 18.0, "title": "CO2HMethanolPlantCostModel\nconverters/methanol/co2h_methanol_plant.py\n[Converter / Methanol]", "x": 196.70889860494393, "y": 455.57195413603677}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CO2HMethanolPlantFinanceModel", "label": "CO2HMethanolPlantFinanceModel", "shape": "dot", "size": 18.0, "title": "CO2HMethanolPlantFinanceModel\nconverters/methanol/co2h_methanol_plant.py\n[Converter / Methanol]", "x": 260.1901653677206, "y": 332.3999371200461}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HydrogenIronReductionPlantCostComponent", "label": "HydrogenIronReductionPlantCostComponent", "shape": "dot", "size": 18.0, "title": "HydrogenIronReductionPlantCostComponent\nconverters/iron/iron_dri_plant.py\n[Converter / Iron]", "x": 392.80172635379284, "y": 292.1171568185306}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NaturalGasIronReductionPlantCostComponent", "label": "NaturalGasIronReductionPlantCostComponent", "shape": "dot", "size": 18.0, "title": "NaturalGasIronReductionPlantCostComponent\nconverters/iron/iron_dri_plant.py\n[Converter / Iron]", "x": 514.1120611197007, "y": 359.1947982547017}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HydrogenIronReductionPlantPerformanceComponent", "label": "HydrogenIronReductionPlantPerformanceComponent", "shape": "dot", "size": 18.0, "title": "HydrogenIronReductionPlantPerformanceComponent\nconverters/iron/iron_dri_plant.py\n[Converter / Iron]", "x": 550.5168235029448, "y": 492.97505700886006}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NaturalGasIronReductionPlantPerformanceComponent", "label": "NaturalGasIronReductionPlantPerformanceComponent", "shape": "dot", "size": 18.0, "title": "NaturalGasIronReductionPlantPerformanceComponent\nconverters/iron/iron_dri_plant.py\n[Converter / Iron]", "x": 479.8992121579394, "y": 612.3160092753269}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HumbertEwinPerformanceComponent", "label": "HumbertEwinPerformanceComponent", "shape": "dot", "size": 18.0, "title": "HumbertEwinPerformanceComponent\nconverters/iron/humbert_ewin_perf.py\n[Converter / Iron]", "x": 345.0667047898959, "y": 644.8082942814825}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MartinIronMineCostComponent", "label": "MartinIronMineCostComponent", "shape": "dot", "size": 18.0, "title": "MartinIronMineCostComponent\nconverters/iron/martin_mine_cost_model.py\n[Converter / Iron]", "x": 227.80083824635747, "y": 570.7102438066149}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "IronTransportCostComponent", "label": "IronTransportCostComponent", "shape": "dot", "size": 18.0, "title": "IronTransportCostComponent\nconverters/iron/iron_transport.py\n[Converter / Iron]", "x": 199.25186993001995, "y": 434.94269396402495}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "IronReductionPlantBasePerformanceComponent", "label": "IronReductionPlantBasePerformanceComponent", "shape": "dot", "size": 19.45945945945946, "title": "IronReductionPlantBasePerformanceComponent\nconverters/iron/iron_dri_base.py\n[Converter / Iron]", "x": 276.76776483357787, "y": 319.8555634030555}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "IronReductionPlantBaseCostComponent", "label": "IronReductionPlantBaseCostComponent", "shape": "dot", "size": 19.45945945945946, "title": "IronReductionPlantBaseCostComponent\nconverters/iron/iron_dri_base.py\n[Converter / Iron]", "x": 413.3524790181906, "y": 295.27713454003253}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MartinIronMinePerformanceComponent", "label": "MartinIronMinePerformanceComponent", "shape": "dot", "size": 18.0, "title": "MartinIronMinePerformanceComponent\nconverters/iron/martin_mine_perf_model.py\n[Converter / Iron]", "x": 526.1593352437131, "y": 376.14527848915895}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HumbertStinnEwinCostComponent", "label": "HumbertStinnEwinCostComponent", "shape": "dot", "size": 18.0, "title": "HumbertStinnEwinCostComponent\nconverters/iron/humbert_stinn_ewin_cost.py\n[Converter / Iron]", "x": 546.743615781497, "y": 513.4286945748072}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HOPPComponent", "label": "HOPPComponent", "shape": "dot", "size": 18.0, "title": "HOPPComponent\nconverters/hopp/hopp_wrapper.py\n[Converter / HOPP]", "x": 462.5917542911312, "y": 623.8559111338152}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PySAMTidalPerformanceModel", "label": "PySAMTidalPerformanceModel", "shape": "dot", "size": 18.0, "title": "PySAMTidalPerformanceModel\nconverters/water_power/tidal_pysam.py\n[Converter / Water Power]", "x": 324.72859310432364, "y": 640.4260460063381}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PySAMMarineCostModel", "label": "PySAMMarineCostModel", "shape": "dot", "size": 18.0, "title": "PySAMMarineCostModel\nconverters/water_power/pysam_marine_cost.py\n[Converter / Water Power]", "x": 216.77814592164026, "y": 553.0618685876668}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "RunOfRiverHydroPerformanceModel", "label": "RunOfRiverHydroPerformanceModel", "shape": "dot", "size": 18.0, "title": "RunOfRiverHydroPerformanceModel\nconverters/water_power/hydro_plant_run_of_river.py\n[Converter / Water Power]", "x": 204.23854510804242, "y": 414.73832160687783}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "RunOfRiverHydroCostModel", "label": "RunOfRiverHydroCostModel", "shape": "dot", "size": 18.0, "title": "RunOfRiverHydroCostModel\nconverters/water_power/hydro_plant_run_of_river.py\n[Converter / Water Power]", "x": 294.740834296002, "y": 309.3594743780847}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleAmmoniaPerformanceModel", "label": "SimpleAmmoniaPerformanceModel", "shape": "dot", "size": 18.0, "title": "SimpleAmmoniaPerformanceModel\nconverters/ammonia/simple_ammonia_model.py\n[Converter / Ammonia]", "x": 433.40509869823285, "y": 300.86319031224053}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleAmmoniaCostModel", "label": "SimpleAmmoniaCostModel", "shape": "dot", "size": 18.0, "title": "SimpleAmmoniaCostModel\nconverters/ammonia/simple_ammonia_model.py\n[Converter / Ammonia]", "x": 536.1198784691253, "y": 394.42665374762043}, {"borderWidth": 3.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "AmmoniaSynLoopPerformanceModel", "label": "AmmoniaSynLoopPerformanceModel", "shape": "dot", "size": 18.0, "title": "AmmoniaSynLoopPerformanceModel\nconverters/ammonia/ammonia_synloop.py\n[Converter / Ammonia]", "x": 540.5636645511591, "y": 533.3117528946093}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "AmmoniaSynLoopCostModel", "label": "AmmoniaSynLoopCostModel", "shape": "dot", "size": 18.0, "title": "AmmoniaSynLoopCostModel\nconverters/ammonia/ammonia_synloop.py\n[Converter / Ammonia]", "x": 444.01862690077985, "y": 633.2724247412674}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "DOCPerformanceModel", "label": "DOCPerformanceModel", "shape": "dot", "size": 18.0, "title": "DOCPerformanceModel\nconverters/co2/marine/direct_ocean_capture.py\n[Converter / CO2]", "x": 305.03269414802736, "y": 633.6581275003193}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "DOCCostModel", "label": "DOCCostModel", "shape": "dot", "size": 18.0, "title": "DOCCostModel\nconverters/co2/marine/direct_ocean_capture.py\n[Converter / CO2]", "x": 207.91368001519578, "y": 534.2137056268189}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "OAEPerformanceModel", "label": "OAEPerformanceModel", "shape": "dot", "size": 18.0, "title": "OAEPerformanceModel\nconverters/co2/marine/ocean_alkalinity_enhancement.py\n[Converter / CO2]", "x": 211.58805712431138, "y": 395.2469621597975}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "OAECostModel", "label": "OAECostModel", "shape": "dot", "size": 18.0, "title": "OAECostModel\nconverters/co2/marine/ocean_alkalinity_enhancement.py\n[Converter / CO2]", "x": 313.84715753363025, "y": 301.0546018388401}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "OAECostAndFinancialModel", "label": "OAECostAndFinancialModel", "shape": "dot", "size": 18.0, "title": "OAECostAndFinancialModel\nconverters/co2/marine/ocean_alkalinity_enhancement.py\n[Converter / CO2]", "x": 452.67476445714954, "y": 308.7874754191507}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GridPerformanceModel", "label": "GridPerformanceModel", "shape": "dot", "size": 18.0, "title": "GridPerformanceModel\nconverters/grid/grid.py\n[Converter / Grid]", "x": 543.8580906910796, "y": 413.7741089596238}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GridCostModel", "label": "GridCostModel", "shape": "dot", "size": 18.0, "title": "GridCostModel\nconverters/grid/grid.py\n[Converter / Grid]", "x": 532.0718732102009, "y": 552.342805603834}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NaturalGasPerformanceModel", "label": "NaturalGasPerformanceModel", "shape": "dot", "size": 18.0, "title": "NaturalGasPerformanceModel\nconverters/natural_gas/natural_gas_cc_ct.py\n[Converter / Natural Gas]", "x": 424.4472139785939, "y": 640.4373943201894}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NaturalGasCostModel", "label": "NaturalGasCostModel", "shape": "dot", "size": 18.0, "title": "NaturalGasCostModel\nconverters/natural_gas/natural_gas_cc_ct.py\n[Converter / Natural Gas]", "x": 286.2569295976094, "y": 624.6065418587294}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleGasProducerPerformance", "label": "SimpleGasProducerPerformance", "shape": "dot", "size": 18.0, "title": "SimpleGasProducerPerformance\nconverters/natural_gas/dummy_gas_components.py\n[Converter / Natural Gas]", "x": 201.3280452712436, "y": 514.4356470449293}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleGasConsumerPerformance", "label": "SimpleGasConsumerPerformance", "shape": "dot", "size": 18.0, "title": "SimpleGasConsumerPerformance\nconverters/natural_gas/dummy_gas_components.py\n[Converter / Natural Gas]", "x": 221.19128243913522, "y": 376.74290673623443}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleGasProducerCost", "label": "SimpleGasProducerCost", "shape": "dot", "size": 18.0, "title": "SimpleGasProducerCost\nconverters/natural_gas/dummy_gas_components.py\n[Converter / Natural Gas]", "x": 333.8144210804685, "y": 295.05389888426953}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleGasConsumerCost", "label": "SimpleGasConsumerCost", "shape": "dot", "size": 18.0, "title": "SimpleGasConsumerCost\nconverters/natural_gas/dummy_gas_components.py\n[Converter / Natural Gas]", "x": 470.89095415961566, "y": 318.9337463910879}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "StoragePerformanceModel", "label": "StoragePerformanceModel", "shape": "diamond", "size": 18.0, "title": "StoragePerformanceModel\nstorage/storage_performance_model.py\n[Storage / General]", "x": 428.09388111524004, "y": -469.09888948081795}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "StoragePerformanceBase", "label": "StoragePerformanceBase", "shape": "diamond", "size": 20.18918918918919, "title": "StoragePerformanceBase\nstorage/storage_baseclass.py\n[Storage / General]", "x": 455.6085661088584, "y": -385.16822684557377}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "StorageAutoSizingModel", "label": "StorageAutoSizingModel", "shape": "diamond", "size": 18.0, "title": "StorageAutoSizingModel\nstorage/simple_storage_auto_sizing.py\n[Storage / General]", "x": 370.0643470376622, "y": -331.15773226109025}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GenericStorageCostModel", "label": "GenericStorageCostModel", "shape": "diamond", "size": 18.0, "title": "GenericStorageCostModel\nstorage/generic_storage_cost.py\n[Storage / General]", "x": 264.590914357365, "y": -368.7926071689721}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MCHTOLStorageCostModel", "label": "MCHTOLStorageCostModel", "shape": "diamond", "size": 18.0, "title": "MCHTOLStorageCostModel\nstorage/hydrogen/mch_storage.py\n[Storage / General]", "x": 219.55784982221226, "y": -478.13520688340736}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HydrogenStorageBaseCostModel", "label": "HydrogenStorageBaseCostModel", "shape": "diamond", "size": 20.91891891891892, "title": "HydrogenStorageBaseCostModel\nstorage/hydrogen/h2_storage_cost.py\n[Storage / General]", "x": 270.16454539792574, "y": -589.4304862347785}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "LinedRockCavernStorageCostModel", "label": "LinedRockCavernStorageCostModel", "shape": "diamond", "size": 18.0, "title": "LinedRockCavernStorageCostModel\nstorage/hydrogen/h2_storage_cost.py\n[Storage / General]", "x": 388.2687164324305, "y": -630.4775561122241}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SaltCavernStorageCostModel", "label": "SaltCavernStorageCostModel", "shape": "diamond", "size": 18.0, "title": "SaltCavernStorageCostModel\nstorage/hydrogen/h2_storage_cost.py\n[Storage / General]", "x": 501.4805766605486, "y": -572.7844347513467}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PipeStorageCostModel", "label": "PipeStorageCostModel", "shape": "diamond", "size": 18.0, "title": "PipeStorageCostModel\nstorage/hydrogen/h2_storage_cost.py\n[Storage / General]", "x": 538.9625776291, "y": -449.75172147563603}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CompressedGasStorageCostModel", "label": "CompressedGasStorageCostModel", "shape": "diamond", "size": 18.0, "title": "CompressedGasStorageCostModel\nstorage/hydrogen/h2_storage_cost.py\n[Storage / General]", "x": 475.93189116793945, "y": -336.23888907246976}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PySAMBatteryPerformanceModel", "label": "PySAMBatteryPerformanceModel", "shape": "diamond", "size": 18.0, "title": "PySAMBatteryPerformanceModel\nstorage/battery/pysam_battery.py\n[Storage / General]", "x": 349.5705117805883, "y": -302.34705409538617}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ATBBatteryCostModel", "label": "ATBBatteryCostModel", "shape": "diamond", "size": 18.0, "title": "ATBBatteryCostModel\nstorage/battery/atb_battery_cost.py\n[Storage / General]", "x": 236.61361523178934, "y": -369.9554252856643}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ProFastLCO", "label": "ProFastLCO", "shape": "star", "size": 18.0, "title": "ProFastLCO\nfinances/profast_lco.py\n[Finance / General]", "x": -486.58132074145146, "y": 260.3302434705349}, {"borderWidth": 5.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ProFastBase", "label": "ProFastBase", "shape": "star", "size": 19.45945945945946, "title": "ProFastBase\nfinances/profast_base.py\n[Finance / General]", "x": -459.0666357478331, "y": 344.2609061057791}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ProFastNPV", "label": "ProFastNPV", "shape": "star", "size": 18.0, "title": "ProFastNPV\nfinances/profast_npv.py\n[Finance / General]", "x": -544.6108548190293, "y": 398.2714006902626}, {"borderWidth": 5.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ResourceBaseAPIModel", "label": "ResourceBaseAPIModel", "shape": "triangle", "size": 19.45945945945946, "title": "ResourceBaseAPIModel\nresource/resource_base.py\n[Resource / General]", "x": -79.51256037378874, "y": -584.9567473090942}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MeteosatPrimeMeridianSolarAPI", "label": "MeteosatPrimeMeridianSolarAPI", "shape": "triangle", "size": 18.0, "title": "MeteosatPrimeMeridianSolarAPI\nresource/solar/nlr_developer_meteosat_prime_meridian_models.py\n[Resource / General]", "x": -51.9978753801704, "y": -501.02608467385005}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MeteosatPrimeMeridianTMYSolarAPI", "label": "MeteosatPrimeMeridianTMYSolarAPI", "shape": "triangle", "size": 18.0, "title": "MeteosatPrimeMeridianTMYSolarAPI\nresource/solar/nlr_developer_meteosat_prime_meridian_models.py\n[Resource / General]", "x": -137.5420944513666, "y": -447.0155900893665}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SolarResourceBaseAPIModel", "label": "SolarResourceBaseAPIModel", "shape": "triangle", "size": 19.45945945945946, "title": "SolarResourceBaseAPIModel\nresource/solar/solar_resource_base.py\n[Resource / General]", "x": -243.01552713166376, "y": -484.6504649972484}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "OpenMeteoHistoricalSolarResource", "label": "OpenMeteoHistoricalSolarResource", "shape": "triangle", "size": 18.0, "title": "OpenMeteoHistoricalSolarResource\nresource/solar/openmeteo_solar.py\n[Resource / General]", "x": -288.0485916668165, "y": -593.9930647116836}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "Himawari7SolarAPI", "label": "Himawari7SolarAPI", "shape": "triangle", "size": 18.0, "title": "Himawari7SolarAPI\nresource/solar/nlr_developer_himawari_api_models.py\n[Resource / General]", "x": -237.44189609110305, "y": -705.2883440630549}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "Himawari8SolarAPI", "label": "Himawari8SolarAPI", "shape": "triangle", "size": 18.0, "title": "Himawari8SolarAPI\nresource/solar/nlr_developer_himawari_api_models.py\n[Resource / General]", "x": -119.33772505659829, "y": -746.3354139405003}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HimawariTMYSolarAPI", "label": "HimawariTMYSolarAPI", "shape": "triangle", "size": 18.0, "title": "HimawariTMYSolarAPI\nresource/solar/nlr_developer_himawari_api_models.py\n[Resource / General]", "x": -6.125864828480175, "y": -688.6422925796229}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NLRDeveloperAPISolarResourceBase", "label": "NLRDeveloperAPISolarResourceBase", "shape": "triangle", "size": 24.56756756756757, "title": "NLRDeveloperAPISolarResourceBase\nresource/solar/nlr_developer_api_base.py\n[Resource / General]", "x": 31.356136140071186, "y": -565.6095793039123}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GOESAggregatedSolarAPI", "label": "GOESAggregatedSolarAPI", "shape": "triangle", "size": 18.0, "title": "GOESAggregatedSolarAPI\nresource/solar/nlr_developer_goes_api_models.py\n[Resource / General]", "x": -31.67455032108934, "y": -452.096746900746}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GOESConusSolarAPI", "label": "GOESConusSolarAPI", "shape": "triangle", "size": 18.0, "title": "GOESConusSolarAPI\nresource/solar/nlr_developer_goes_api_models.py\n[Resource / General]", "x": -158.0359297084405, "y": -418.2049119236624}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GOESFullDiscSolarAPI", "label": "GOESFullDiscSolarAPI", "shape": "triangle", "size": 18.0, "title": "GOESFullDiscSolarAPI\nresource/solar/nlr_developer_goes_api_models.py\n[Resource / General]", "x": -270.99282625723947, "y": -485.8132831139406}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GOESTMYSolarAPI", "label": "GOESTMYSolarAPI", "shape": "triangle", "size": 18.0, "title": "GOESTMYSolarAPI\nresource/solar/nlr_developer_goes_api_models.py\n[Resource / General]", "x": -301.2124767374961, "y": -614.6459391266081}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "OpenMeteoHistoricalWindResource", "label": "OpenMeteoHistoricalWindResource", "shape": "triangle", "size": 18.0, "title": "OpenMeteoHistoricalWindResource\nresource/wind/openmeteo_wind.py\n[Resource / General]", "x": -229.44086839384488, "y": -726.515073522741}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "WindResourceBaseAPIModel", "label": "WindResourceBaseAPIModel", "shape": "triangle", "size": 19.45945945945946, "title": "WindResourceBaseAPIModel\nresource/wind/wind_resource_base.py\n[Resource / General]", "x": -98.67692574249088, "y": -752.9836456314601}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "WTKNLRDeveloperAPIWindResource", "label": "WTKNLRDeveloperAPIWindResource", "shape": "triangle", "size": 18.0, "title": "WTKNLRDeveloperAPIWindResource\nresource/wind/nlr_developer_wtk_api.py\n[Resource / General]", "x": 11.735802273041458, "y": -677.3143608199191}, {"borderWidth": 3.0, "color": {"background": "#F5C542", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GenericDemandComponent", "label": "GenericDemandComponent", "shape": "dot", "size": 18.0, "title": "GenericDemandComponent\ndemand/generic_demand.py\n[Other / Other]", "x": -486.58132074145146, "y": -260.3302434705348}, {"borderWidth": 4.0, "color": {"background": "#F5C542", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "DemandComponentBase", "label": "DemandComponentBase", "shape": "dot", "size": 19.45945945945946, "title": "DemandComponentBase\ndemand/demand_base.py\n[Other / Other]", "x": -459.0666357478331, "y": -176.39958083529064}, {"borderWidth": 3.0, "color": {"background": "#F5C542", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "FlexibleDemandComponent", "label": "FlexibleDemandComponent", "shape": "dot", "size": 18.0, "title": "FlexibleDemandComponent\ndemand/flexible_demand.py\n[Other / Other]", "x": -544.6108548190293, "y": -122.38908625080711}, {"borderWidth": 5.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PyomoStorageControllerBaseClass", "label": "PyomoStorageControllerBaseClass", "shape": "diamond", "size": 19.45945945945946, "title": "PyomoStorageControllerBaseClass\ncontrol/control_strategies/pyomo_storage_controller_baseclass.py\n[Storage / General]", "x": 206.39396475153265, "y": -498.78808129833186}, {"borderWidth": 5.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "StorageOpenLoopControlBase", "label": "StorageOpenLoopControlBase", "shape": "diamond", "size": 20.18918918918919, "title": "StorageOpenLoopControlBase\ncontrol/control_strategies/storage/openloop_storage_control_base.py\n[Storage / General]", "x": 278.1655730951839, "y": -610.6572156944648}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "DemandOpenLoopStorageController", "label": "DemandOpenLoopStorageController", "shape": "diamond", "size": 18.0, "title": "DemandOpenLoopStorageController\ncontrol/control_strategies/storage/demand_openloop_storage_controller.py\n[Storage / General]", "x": 408.9295157465379, "y": -637.1257878031839}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HeuristicLoadFollowingStorageController", "label": "HeuristicLoadFollowingStorageController", "shape": "diamond", "size": 18.0, "title": "HeuristicLoadFollowingStorageController\ncontrol/control_strategies/storage/heuristic_pyomo_controller.py\n[Storage / General]", "x": 519.3422437620702, "y": -561.4565029916428}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "OptimizedDispatchStorageController", "label": "OptimizedDispatchStorageController", "shape": "diamond", "size": 18.0, "title": "OptimizedDispatchStorageController\ncontrol/control_strategies/storage/optimized_pyomo_controller.py\n[Storage / General]", "x": 541.9933368656092, "y": -429.1430173132935}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleStorageOpenLoopController", "label": "SimpleStorageOpenLoopController", "shape": "diamond", "size": 18.0, "title": "SimpleStorageOpenLoopController\ncontrol/control_strategies/storage/simple_openloop_controller.py\n[Storage / General]", "x": 462.6186718096345, "y": -320.4638955106441}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PeakLoadManagementHeuristicOpenLoopStorageController", "label": "PeakLoadManagementHeuristicOpenLoopStorageController", "shape": "diamond", "size": 18.0, "title": "PeakLoadManagementHeuristicOpenLoopStorageController\ncontrol/control_strategies/storage/plm_openloop_storage_controller.py\n[Storage / General]", "x": 329.04975630639206, "y": -301.68432548327485}, {"borderWidth": 5.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PyomoRuleBaseClass", "label": "PyomoRuleBaseClass", "shape": "hexagon", "size": 19.45945945945946, "title": "PyomoRuleBaseClass\ncontrol/control_rules/pyomo_rule_baseclass.py\n[Control / General]", "x": 654.0, "y": 0.0}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PyomoDispatchGenericConverter", "label": "PyomoDispatchGenericConverter", "shape": "dot", "size": 18.0, "title": "PyomoDispatchGenericConverter\ncontrol/control_rules/converters/generic_converter.py\n[Converter / Other]", "x": 549.2687648311249, "y": 433.9130181048081}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PyomoRuleStorageBaseclass", "label": "PyomoRuleStorageBaseclass", "shape": "diamond", "size": 18.0, "title": "PyomoRuleStorageBaseclass\ncontrol/control_rules/storage/pyomo_storage_rule_baseclass.py\n[Storage / General]", "x": 222.32576716621526, "y": -384.6114372269132}]); - edges = new vis.DataSet([{"arrows": "to", "from": "SiteBaseComponent", "to": "SiteLocationComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "ResizeablePerformanceModelBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "SolarPerformanceBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "LinearH2FuelCellPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "SteamMethaneReformerPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "GeoH2SubsurfacePerformanceBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "GeoH2SurfacePerformanceBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "DesalinationPerformanceBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "QuinnNuclearPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "CMUElectricArcFurnaceScrapOnlyPerformanceComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "ElectricArcFurnacePlantBasePerformanceComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "CMUElectricArcFurnaceDRIPerformanceComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "SteelPerformanceBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "SimpleASUPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "WindPerformanceBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "WindArdPerformanceCompatibilityComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "MethanolPerformanceBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "HumbertEwinPerformanceComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "IronReductionPlantBasePerformanceComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "MartinIronMinePerformanceComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "HOPPComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "PySAMTidalPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "RunOfRiverHydroPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "SimpleAmmoniaPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "DOCPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "OAEPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "GridPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "NaturalGasPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "SimpleGasProducerPerformance"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "SimpleGasConsumerPerformance"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "StoragePerformanceBase"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "DemandComponentBase"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "FeedstockCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "GenericConverterCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "ATBUtilityPVCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "ATBResComPVCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "ElectrolyzerCostBaseClass"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "H2FuelCellCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "SteamMethaneReformerCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "GeoH2SubsurfaceCostBaseClass"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "GeoH2SurfaceCostBaseClass"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "DesalinationCostBaseClass"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "QuinnNuclearCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "CMUElectricArcFurnaceCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "ElectricArcFurnacePlantBaseCostComponent"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "SteelCostBaseClass"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "SimpleASUCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "ATBWindPlantCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "WindArdCostCompatibilityComponent"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "MethanolCostBaseClass"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "MartinIronMineCostComponent"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "IronTransportCostComponent"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "IronReductionPlantBaseCostComponent"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "HumbertStinnEwinCostComponent"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "PySAMMarineCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "RunOfRiverHydroCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "SimpleAmmoniaCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "AmmoniaSynLoopCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "DOCCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "OAECostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "OAECostAndFinancialModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "GridCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "NaturalGasCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "SimpleGasProducerCost"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "SimpleGasConsumerCost"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "GenericStorageCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "MCHTOLStorageCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "HydrogenStorageBaseCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "ATBBatteryCostModel"}, {"arrows": "to", "from": "ResizeablePerformanceModelBaseClass", "to": "ElectrolyzerPerformanceBaseClass"}, {"arrows": "to", "from": "ResizeablePerformanceModelBaseClass", "to": "AmmoniaSynLoopPerformanceModel"}, {"arrows": "to", "from": "CacheBaseClass", "to": "FlorisWindPlantPerformanceModel"}, {"arrows": "to", "from": "CacheBaseClass", "to": "HOPPComponent"}, {"arrows": "to", "from": "SolarPerformanceBaseClass", "to": "PYSAMSolarPlantPerformanceModel"}, {"arrows": "to", "from": "ElectrolyzerPerformanceBaseClass", "to": "ECOElectrolyzerPerformanceModel"}, {"arrows": "to", "from": "ElectrolyzerCostBaseClass", "to": "SingliticoCostModel"}, {"arrows": "to", "from": "ElectrolyzerCostBaseClass", "to": "BasicElectrolyzerCostModel"}, {"arrows": "to", "from": "ElectrolyzerCostBaseClass", "to": "CustomElectrolyzerCostModel"}, {"arrows": "to", "from": "ECOElectrolyzerPerformanceModel", "to": "WOMBATElectrolyzerModel"}, {"arrows": "to", "from": "GeoH2SubsurfacePerformanceBaseClass", "to": "NaturalGeoH2PerformanceModel"}, {"arrows": "to", "from": "GeoH2SubsurfacePerformanceBaseClass", "to": "StimulatedGeoH2PerformanceModel"}, {"arrows": "to", "from": "GeoH2SubsurfaceCostBaseClass", "to": "GeoH2SubsurfaceCostModel"}, {"arrows": "to", "from": "GeoH2SurfacePerformanceBaseClass", "to": "AspenGeoH2SurfacePerformanceModel"}, {"arrows": "to", "from": "GeoH2SurfaceCostBaseClass", "to": "AspenGeoH2SurfaceCostModel"}, {"arrows": "to", "from": "DesalinationPerformanceBaseClass", "to": "ReverseOsmosisPerformanceModel"}, {"arrows": "to", "from": "DesalinationCostBaseClass", "to": "ReverseOsmosisCostModel"}, {"arrows": "to", "from": "ElectricArcFurnacePlantBasePerformanceComponent", "to": "HydrogenEAFPlantPerformanceComponent"}, {"arrows": "to", "from": "ElectricArcFurnacePlantBasePerformanceComponent", "to": "NaturalGasEAFPlantPerformanceComponent"}, {"arrows": "to", "from": "ElectricArcFurnacePlantBaseCostComponent", "to": "HydrogenEAFPlantCostComponent"}, {"arrows": "to", "from": "ElectricArcFurnacePlantBaseCostComponent", "to": "NaturalGasEAFPlantCostComponent"}, {"arrows": "to", "from": "SteelPerformanceBaseClass", "to": "SteelPerformanceModel"}, {"arrows": "to", "from": "SteelCostBaseClass", "to": "SteelCostAndFinancialModel"}, {"arrows": "to", "from": "WindPerformanceBaseClass", "to": "PYSAMWindPlantPerformanceModel"}, {"arrows": "to", "from": "WindPerformanceBaseClass", "to": "FlorisWindPlantPerformanceModel"}, {"arrows": "to", "from": "MethanolPerformanceBaseClass", "to": "SMRMethanolPlantPerformanceModel"}, {"arrows": "to", "from": "MethanolPerformanceBaseClass", "to": "CO2HMethanolPlantPerformanceModel"}, {"arrows": "to", "from": "MethanolCostBaseClass", "to": "SMRMethanolPlantCostModel"}, {"arrows": "to", "from": "MethanolCostBaseClass", "to": "CO2HMethanolPlantCostModel"}, {"arrows": "to", "from": "MethanolFinanceBaseClass", "to": "SMRMethanolPlantFinanceModel"}, {"arrows": "to", "from": "MethanolFinanceBaseClass", "to": "CO2HMethanolPlantFinanceModel"}, {"arrows": "to", "from": "IronReductionPlantBasePerformanceComponent", "to": "HydrogenIronReductionPlantPerformanceComponent"}, {"arrows": "to", "from": "IronReductionPlantBasePerformanceComponent", "to": "NaturalGasIronReductionPlantPerformanceComponent"}, {"arrows": "to", "from": "IronReductionPlantBaseCostComponent", "to": "HydrogenIronReductionPlantCostComponent"}, {"arrows": "to", "from": "IronReductionPlantBaseCostComponent", "to": "NaturalGasIronReductionPlantCostComponent"}, {"arrows": "to", "from": "StoragePerformanceBase", "to": "StoragePerformanceModel"}, {"arrows": "to", "from": "StoragePerformanceBase", "to": "StorageAutoSizingModel"}, {"arrows": "to", "from": "StoragePerformanceBase", "to": "PySAMBatteryPerformanceModel"}, {"arrows": "to", "from": "HydrogenStorageBaseCostModel", "to": "LinedRockCavernStorageCostModel"}, {"arrows": "to", "from": "HydrogenStorageBaseCostModel", "to": "SaltCavernStorageCostModel"}, {"arrows": "to", "from": "HydrogenStorageBaseCostModel", "to": "PipeStorageCostModel"}, {"arrows": "to", "from": "HydrogenStorageBaseCostModel", "to": "CompressedGasStorageCostModel"}, {"arrows": "to", "from": "ProFastBase", "to": "ProFastLCO"}, {"arrows": "to", "from": "ProFastBase", "to": "ProFastNPV"}, {"arrows": "to", "from": "ResourceBaseAPIModel", "to": "SolarResourceBaseAPIModel"}, {"arrows": "to", "from": "ResourceBaseAPIModel", "to": "WindResourceBaseAPIModel"}, {"arrows": "to", "from": "SolarResourceBaseAPIModel", "to": "OpenMeteoHistoricalSolarResource"}, {"arrows": "to", "from": "SolarResourceBaseAPIModel", "to": "NLRDeveloperAPISolarResourceBase"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "MeteosatPrimeMeridianSolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "MeteosatPrimeMeridianTMYSolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "Himawari7SolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "Himawari8SolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "HimawariTMYSolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "GOESAggregatedSolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "GOESConusSolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "GOESFullDiscSolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "GOESTMYSolarAPI"}, {"arrows": "to", "from": "WindResourceBaseAPIModel", "to": "OpenMeteoHistoricalWindResource"}, {"arrows": "to", "from": "WindResourceBaseAPIModel", "to": "WTKNLRDeveloperAPIWindResource"}, {"arrows": "to", "from": "DemandComponentBase", "to": "GenericDemandComponent"}, {"arrows": "to", "from": "DemandComponentBase", "to": "FlexibleDemandComponent"}, {"arrows": "to", "from": "PyomoStorageControllerBaseClass", "to": "HeuristicLoadFollowingStorageController"}, {"arrows": "to", "from": "PyomoStorageControllerBaseClass", "to": "OptimizedDispatchStorageController"}, {"arrows": "to", "from": "StorageOpenLoopControlBase", "to": "DemandOpenLoopStorageController"}, {"arrows": "to", "from": "StorageOpenLoopControlBase", "to": "SimpleStorageOpenLoopController"}, {"arrows": "to", "from": "StorageOpenLoopControlBase", "to": "PeakLoadManagementHeuristicOpenLoopStorageController"}, {"arrows": "to", "from": "PyomoRuleBaseClass", "to": "PyomoDispatchGenericConverter"}, {"arrows": "to", "from": "PyomoRuleBaseClass", "to": "PyomoRuleStorageBaseclass"}]); + nodes = new vis.DataSet([{"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "FeedstockCostModel", "label": "FeedstockCostModel", "shape": "ellipse", "size": 18.0, "title": "FeedstockCostModel\ncore/feedstocks.py\n[Core / General]", "x": -79.5125603737886, "y": 584.9567473090942}, {"borderWidth": 5.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SiteBaseComponent", "label": "SiteBaseComponent", "shape": "ellipse", "size": 18.72972972972973, "title": "SiteBaseComponent\ncore/sites.py\n[Core / General]", "x": -51.99787538017026, "y": 668.8874099443384}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SiteLocationComponent", "label": "SiteLocationComponent", "shape": "ellipse", "size": 18.0, "title": "SiteLocationComponent\ncore/sites.py\n[Core / General]", "x": -137.54209445136647, "y": 722.897904528822}, {"borderWidth": 5.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PerformanceModelBaseClass", "label": "PerformanceModelBaseClass", "shape": "ellipse", "size": 40.62162162162162, "title": "PerformanceModelBaseClass\ncore/model_baseclasses.py\n[Core / General]", "x": -243.0155271316636, "y": 685.2630296209401}, {"borderWidth": 5.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CostModelBaseClass", "label": "CostModelBaseClass", "shape": "ellipse", "size": 45.0, "title": "CostModelBaseClass\ncore/model_baseclasses.py\n[Core / General]", "x": -288.0485916668164, "y": 575.9204299065049}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ResizeablePerformanceModelBaseClass", "label": "ResizeablePerformanceModelBaseClass", "shape": "ellipse", "size": 19.45945945945946, "title": "ResizeablePerformanceModelBaseClass\ncore/model_baseclasses.py\n[Core / General]", "x": -237.4418960911029, "y": 464.62515055513364}, {"borderWidth": 5.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CacheBaseClass", "label": "CacheBaseClass", "shape": "ellipse", "size": 19.45945945945946, "title": "CacheBaseClass\ncore/model_baseclasses.py\n[Core / General]", "x": -119.33772505659815, "y": 423.5780806776881}, {"borderWidth": 4.0, "color": {"background": "#F5C542", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GenericConverterCostModel", "label": "GenericConverterCostModel", "shape": "dot", "size": 18.0, "title": "GenericConverterCostModel\nconverters/generic_converter_cost.py\n[Converter / Other]", "x": 428.09388111524015, "y": 469.0988894808179}, {"borderWidth": 3.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PYSAMSolarPlantPerformanceModel", "label": "PYSAMSolarPlantPerformanceModel", "shape": "dot", "size": 18.0, "title": "PYSAMSolarPlantPerformanceModel\nconverters/solar/solar_pysam.py\n[Converter / Solar]", "x": 455.6085661088585, "y": 553.0295521160621}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ATBUtilityPVCostModel", "label": "ATBUtilityPVCostModel", "shape": "dot", "size": 18.0, "title": "ATBUtilityPVCostModel\nconverters/solar/atb_utility_pv_cost.py\n[Converter / Solar]", "x": 370.0643470376623, "y": 607.0400467005456}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ATBResComPVCostModel", "label": "ATBResComPVCostModel", "shape": "dot", "size": 18.0, "title": "ATBResComPVCostModel\nconverters/solar/atb_res_com_pv_cost.py\n[Converter / Solar]", "x": 264.59091435736514, "y": 569.4051717926637}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SolarPerformanceBaseClass", "label": "SolarPerformanceBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "SolarPerformanceBaseClass\nconverters/solar/solar_baseclass.py\n[Converter / Solar]", "x": 219.55784982221238, "y": 460.06257207822847}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ElectrolyzerPerformanceBaseClass", "label": "ElectrolyzerPerformanceBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "ElectrolyzerPerformanceBaseClass\nconverters/hydrogen/electrolyzer_baseclass.py\n[Converter / Hydrogen]", "x": 270.16454539792585, "y": 348.7672927268573}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ElectrolyzerCostBaseClass", "label": "ElectrolyzerCostBaseClass", "shape": "dot", "size": 20.18918918918919, "title": "ElectrolyzerCostBaseClass\nconverters/hydrogen/electrolyzer_baseclass.py\n[Converter / Hydrogen]", "x": 388.2687164324306, "y": 307.72022284941175}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SingliticoCostModel", "label": "SingliticoCostModel", "shape": "dot", "size": 18.0, "title": "SingliticoCostModel\nconverters/hydrogen/singlitico_cost_model.py\n[Converter / Hydrogen]", "x": 501.4805766605487, "y": 365.4133442102892}, {"borderWidth": 1, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "WOMBATElectrolyzerModel", "label": "WOMBATElectrolyzerModel", "shape": "dot", "size": 18.0, "title": "WOMBATElectrolyzerModel\nconverters/hydrogen/wombat_model.py\n[Converter / Hydrogen]", "x": 538.9625776291001, "y": 488.44605748599986}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "LinearH2FuelCellPerformanceModel", "label": "LinearH2FuelCellPerformanceModel", "shape": "dot", "size": 18.0, "title": "LinearH2FuelCellPerformanceModel\nconverters/hydrogen/h2_fuel_cell.py\n[Converter / Hydrogen]", "x": 475.93189116793957, "y": 601.9588898891661}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "H2FuelCellCostModel", "label": "H2FuelCellCostModel", "shape": "dot", "size": 18.0, "title": "H2FuelCellCostModel\nconverters/hydrogen/h2_fuel_cell.py\n[Converter / Hydrogen]", "x": 349.5705117805884, "y": 635.8507248662497}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SteamMethaneReformerPerformanceModel", "label": "SteamMethaneReformerPerformanceModel", "shape": "dot", "size": 18.0, "title": "SteamMethaneReformerPerformanceModel\nconverters/hydrogen/steam_methane_reformer.py\n[Converter / Hydrogen]", "x": 236.61361523178945, "y": 568.2423536759715}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SteamMethaneReformerCostModel", "label": "SteamMethaneReformerCostModel", "shape": "dot", "size": 18.0, "title": "SteamMethaneReformerCostModel\nconverters/hydrogen/steam_methane_reformer.py\n[Converter / Hydrogen]", "x": 206.39396475153276, "y": 439.409697663304}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "BasicElectrolyzerCostModel", "label": "BasicElectrolyzerCostModel", "shape": "dot", "size": 18.0, "title": "BasicElectrolyzerCostModel\nconverters/hydrogen/basic_cost_model.py\n[Converter / Hydrogen]", "x": 278.165573095184, "y": 327.54056326717114}, {"borderWidth": 2.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ECOElectrolyzerPerformanceModel", "label": "ECOElectrolyzerPerformanceModel", "shape": "dot", "size": 18.72972972972973, "title": "ECOElectrolyzerPerformanceModel\nconverters/hydrogen/pem_electrolyzer.py\n[Converter / Hydrogen]", "x": 408.929515746538, "y": 301.0719911584519}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CustomElectrolyzerCostModel", "label": "CustomElectrolyzerCostModel", "shape": "dot", "size": 18.0, "title": "CustomElectrolyzerCostModel\nconverters/hydrogen/custom_electrolyzer_cost_model.py\n[Converter / Hydrogen]", "x": 519.3422437620703, "y": 376.741275969993}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GeoH2SubsurfaceCostModel", "label": "GeoH2SubsurfaceCostModel", "shape": "dot", "size": 18.0, "title": "GeoH2SubsurfaceCostModel\nconverters/hydrogen/geologic/mathur_modified.py\n[Converter / Hydrogen]", "x": 541.9933368656093, "y": 509.0547616483423}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GeoH2SubsurfacePerformanceBaseClass", "label": "GeoH2SubsurfacePerformanceBaseClass", "shape": "dot", "size": 19.45945945945946, "title": "GeoH2SubsurfacePerformanceBaseClass\nconverters/hydrogen/geologic/h2_well_subsurface_baseclass.py\n[Converter / Hydrogen]", "x": 462.61867180963463, "y": 617.7338834509918}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GeoH2SubsurfaceCostBaseClass", "label": "GeoH2SubsurfaceCostBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "GeoH2SubsurfaceCostBaseClass\nconverters/hydrogen/geologic/h2_well_subsurface_baseclass.py\n[Converter / Hydrogen]", "x": 329.04975630639217, "y": 636.513453478361}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "AspenGeoH2SurfacePerformanceModel", "label": "AspenGeoH2SurfacePerformanceModel", "shape": "dot", "size": 18.0, "title": "AspenGeoH2SurfacePerformanceModel\nconverters/hydrogen/geologic/aspen_surface_processing.py\n[Converter / Hydrogen]", "x": 222.32576716621537, "y": 553.5863417347226}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "AspenGeoH2SurfaceCostModel", "label": "AspenGeoH2SurfaceCostModel", "shape": "dot", "size": 18.0, "title": "AspenGeoH2SurfaceCostModel\nconverters/hydrogen/geologic/aspen_surface_processing.py\n[Converter / Hydrogen]", "x": 207.46113153897124, "y": 419.00371238109653}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NaturalGeoH2PerformanceModel", "label": "NaturalGeoH2PerformanceModel", "shape": "dot", "size": 18.0, "title": "NaturalGeoH2PerformanceModel\nconverters/hydrogen/geologic/simple_natural_geoh2.py\n[Converter / Hydrogen]", "x": 293.81016729091453, "y": 314.4201619012048}, {"borderWidth": 3.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "StimulatedGeoH2PerformanceModel", "label": "StimulatedGeoH2PerformanceModel", "shape": "dot", "size": 18.0, "title": "StimulatedGeoH2PerformanceModel\nconverters/hydrogen/geologic/templeton_serpentinization.py\n[Converter / Hydrogen]", "x": 429.1980303433203, "y": 303.5048975398027}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GeoH2SurfacePerformanceBaseClass", "label": "GeoH2SurfacePerformanceBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "GeoH2SurfacePerformanceBaseClass\nconverters/hydrogen/geologic/h2_well_surface_baseclass.py\n[Converter / Hydrogen]", "x": 531.4807512394786, "y": 393.1585470237545}, {"borderWidth": 4.0, "color": {"background": "#2E7D32", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GeoH2SurfaceCostBaseClass", "label": "GeoH2SurfaceCostBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "GeoH2SurfaceCostBaseClass\nconverters/hydrogen/geologic/h2_well_surface_baseclass.py\n[Converter / Hydrogen]", "x": 538.4198729733245, "y": 529.1652694271473}, {"borderWidth": 3.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ReverseOsmosisPerformanceModel", "label": "ReverseOsmosisPerformanceModel", "shape": "dot", "size": 18.0, "title": "ReverseOsmosisPerformanceModel\nconverters/water/desal/desalination.py\n[Converter / Water]", "x": 445.5710237882473, "y": 629.0047614736493}, {"borderWidth": 3.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ReverseOsmosisCostModel", "label": "ReverseOsmosisCostModel", "shape": "dot", "size": 18.0, "title": "ReverseOsmosisCostModel\nconverters/water/desal/desalination.py\n[Converter / Water]", "x": 309.11651798504033, "y": 631.947653855694}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "DesalinationPerformanceBaseClass", "label": "DesalinationPerformanceBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "DesalinationPerformanceBaseClass\nconverters/water/desal/desalination_baseclass.py\n[Converter / Water]", "x": 211.84908461781183, "y": 536.0083507662598}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "DesalinationCostBaseClass", "label": "DesalinationCostBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "DesalinationCostBaseClass\nconverters/water/desal/desalination_baseclass.py\n[Converter / Water]", "x": 212.91655044595416, "y": 399.26617741803875}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "QuinnNuclearPerformanceModel", "label": "QuinnNuclearPerformanceModel", "shape": "dot", "size": 18.0, "title": "QuinnNuclearPerformanceModel\nconverters/nuclear/nuclear_plant.py\n[Converter / Nuclear]", "x": 311.84419223616084, "y": 304.68898638961775}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "QuinnNuclearCostModel", "label": "QuinnNuclearCostModel", "shape": "dot", "size": 18.0, "title": "QuinnNuclearCostModel\nconverters/nuclear/nuclear_plant.py\n[Converter / Nuclear]", "x": 448.7220200189815, "y": 309.77552217810415}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CMUElectricArcFurnaceCostModel", "label": "CMUElectricArcFurnaceCostModel", "shape": "dot", "size": 18.0, "title": "CMUElectricArcFurnaceCostModel\nconverters/steel/cmu_eaf_cost.py\n[Converter / Steel]", "x": 540.4994435124524, "y": 411.5906925910052}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HydrogenEAFPlantCostComponent", "label": "HydrogenEAFPlantCostComponent", "shape": "dot", "size": 18.0, "title": "HydrogenEAFPlantCostComponent\nconverters/steel/steel_eaf_plant.py\n[Converter / Steel]", "x": 531.3901362621964, "y": 548.4583323631547}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NaturalGasEAFPlantCostComponent", "label": "NaturalGasEAFPlantCostComponent", "shape": "dot", "size": 18.0, "title": "NaturalGasEAFPlantCostComponent\nconverters/steel/steel_eaf_plant.py\n[Converter / Steel]", "x": 426.78782378431725, "y": 637.3337381981482}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HydrogenEAFPlantPerformanceComponent", "label": "HydrogenEAFPlantPerformanceComponent", "shape": "dot", "size": 18.0, "title": "HydrogenEAFPlantPerformanceComponent\nconverters/steel/steel_eaf_plant.py\n[Converter / Steel]", "x": 290.0713774101109, "y": 624.2026575560053}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NaturalGasEAFPlantPerformanceComponent", "label": "NaturalGasEAFPlantPerformanceComponent", "shape": "dot", "size": 18.0, "title": "NaturalGasEAFPlantPerformanceComponent\nconverters/steel/steel_eaf_plant.py\n[Converter / Steel]", "x": 204.19394571089566, "y": 516.9137611171568}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CMUElectricArcFurnaceScrapOnlyPerformanceComponent", "label": "CMUElectricArcFurnaceScrapOnlyPerformanceComponent", "shape": "dot", "size": 18.0, "title": "CMUElectricArcFurnaceScrapOnlyPerformanceComponent\nconverters/steel/cmu_electric_arc_furnace_scrap.py\n[Converter / Steel]", "x": 221.34134343018306, "y": 380.48561129518464}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ElectricArcFurnacePlantBasePerformanceComponent", "label": "ElectricArcFurnacePlantBasePerformanceComponent", "shape": "dot", "size": 19.45945945945946, "title": "ElectricArcFurnacePlantBasePerformanceComponent\nconverters/steel/steel_eaf_base.py\n[Converter / Steel]", "x": 331.21568985008844, "y": 297.6965237937115}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ElectricArcFurnacePlantBaseCostComponent", "label": "ElectricArcFurnacePlantBaseCostComponent", "shape": "dot", "size": 19.45945945945946, "title": "ElectricArcFurnacePlantBaseCostComponent\nconverters/steel/steel_eaf_base.py\n[Converter / Steel]", "x": 467.2216800346311, "y": 318.8505150630724}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CMUElectricArcFurnaceDRIPerformanceComponent", "label": "CMUElectricArcFurnaceDRIPerformanceComponent", "shape": "dot", "size": 18.0, "title": "CMUElectricArcFurnaceDRIPerformanceComponent\nconverters/steel/cmu_electric_arc_furnace_dri.py\n[Converter / Steel]", "x": 546.8371236174256, "y": 431.20832772949075}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SteelPerformanceBaseClass", "label": "SteelPerformanceBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "SteelPerformanceBaseClass\nconverters/steel/steel_baseclass.py\n[Converter / Steel]", "x": 521.6903751911968, "y": 566.6610637668477}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SteelCostBaseClass", "label": "SteelCostBaseClass", "shape": "dot", "size": 18.72972972972973, "title": "SteelCostBaseClass\nconverters/steel/steel_baseclass.py\n[Converter / Steel]", "x": 406.952112243024, "y": 643.0222537742708}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SteelPerformanceModel", "label": "SteelPerformanceModel", "shape": "dot", "size": 18.0, "title": "SteelPerformanceModel\nconverters/steel/steel.py\n[Converter / Steel]", "x": 272.18129178907674, "y": 613.9005699916162}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SteelCostAndFinancialModel", "label": "SteelCostAndFinancialModel", "shape": "dot", "size": 18.0, "title": "SteelCostAndFinancialModel\nconverters/steel/steel.py\n[Converter / Steel]", "x": 199.15055717864382, "y": 496.88602197643854}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleASUPerformanceModel", "label": "SimpleASUPerformanceModel", "shape": "dot", "size": 18.0, "title": "SimpleASUPerformanceModel\nconverters/nitrogen/simple_ASU.py\n[Converter / Nitrogen]", "x": 232.2254768075065, "y": 362.9235822800501}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleASUCostModel", "label": "SimpleASUCostModel", "shape": "dot", "size": 18.0, "title": "SimpleASUCostModel\nconverters/nitrogen/simple_ASU.py\n[Converter / Nitrogen]", "x": 351.4109262191128, "y": 293.29530750309686}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ATBWindPlantCostModel", "label": "ATBWindPlantCostModel", "shape": "dot", "size": 18.0, "title": "ATBWindPlantCostModel\nconverters/wind/atb_wind_cost.py\n[Converter / Wind]", "x": 484.44055215455836, "y": 330.297978929092}, {"borderWidth": 3.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PYSAMWindPlantPerformanceModel", "label": "PYSAMWindPlantPerformanceModel", "shape": "dot", "size": 18.0, "title": "PYSAMWindPlantPerformanceModel\nconverters/wind/wind_pysam.py\n[Converter / Wind]", "x": 550.5984027605526, "y": 451.5476939728353}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "WindPerformanceBaseClass", "label": "WindPerformanceBaseClass", "shape": "dot", "size": 19.45945945945946, "title": "WindPerformanceBaseClass\nconverters/wind/wind_plant_baseclass.py\n[Converter / Wind]", "x": 509.69716559653716, "y": 583.5219955261796}, {"borderWidth": 3.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "FlorisWindPlantPerformanceModel", "label": "FlorisWindPlantPerformanceModel", "shape": "dot", "size": 18.0, "title": "FlorisWindPlantPerformanceModel\nconverters/wind/floris.py\n[Converter / Wind]", "x": 386.4910795111691, "y": 646.1453801885751}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "WindArdPerformanceCompatibilityComponent", "label": "WindArdPerformanceCompatibilityComponent", "shape": "dot", "size": 18.0, "title": "WindArdPerformanceCompatibilityComponent\nconverters/wind/wind_plant_ard.py\n[Converter / Wind]", "x": 255.69275934662096, "y": 601.3783905588575}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "WindArdCostCompatibilityComponent", "label": "WindArdCostCompatibilityComponent", "shape": "dot", "size": 18.0, "title": "WindArdCostCompatibilityComponent\nconverters/wind/wind_plant_ard.py\n[Converter / Wind]", "x": 196.66404742738186, "y": 476.325072910983}, {"borderWidth": 3.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SMRMethanolPlantPerformanceModel", "label": "SMRMethanolPlantPerformanceModel", "shape": "dot", "size": 18.0, "title": "SMRMethanolPlantPerformanceModel\nconverters/methanol/smr_methanol_plant.py\n[Converter / Methanol]", "x": 245.26041830184303, "y": 346.82157500140227}, {"borderWidth": 3.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SMRMethanolPlantCostModel", "label": "SMRMethanolPlantCostModel", "shape": "dot", "size": 18.0, "title": "SMRMethanolPlantCostModel\nconverters/methanol/smr_methanol_plant.py\n[Converter / Methanol]", "x": 372.0506131909644, "y": 291.4439725755037}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SMRMethanolPlantFinanceModel", "label": "SMRMethanolPlantFinanceModel", "shape": "dot", "size": 18.0, "title": "SMRMethanolPlantFinanceModel\nconverters/methanol/smr_methanol_plant.py\n[Converter / Methanol]", "x": 500.1422507144458, "y": 343.82986093378406}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MethanolPerformanceBaseClass", "label": "MethanolPerformanceBaseClass", "shape": "dot", "size": 19.45945945945946, "title": "MethanolPerformanceBaseClass\nconverters/methanol/methanol_baseclass.py\n[Converter / Methanol]", "x": 551.8160292273673, "y": 472.2454066683052}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MethanolCostBaseClass", "label": "MethanolCostBaseClass", "shape": "dot", "size": 19.45945945945946, "title": "MethanolCostBaseClass\nconverters/methanol/methanol_baseclass.py\n[Converter / Methanol]", "x": 495.6839168464708, "y": 598.8099536341376}, {"borderWidth": 5.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MethanolFinanceBaseClass", "label": "MethanolFinanceBaseClass", "shape": "dot", "size": 19.45945945945946, "title": "MethanolFinanceBaseClass\nconverters/methanol/methanol_baseclass.py\n[Converter / Methanol]", "x": 365.7556658010815, "y": 646.7308814798549}, {"borderWidth": 3.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CO2HMethanolPlantPerformanceModel", "label": "CO2HMethanolPlantPerformanceModel", "shape": "dot", "size": 18.0, "title": "CO2HMethanolPlantPerformanceModel\nconverters/methanol/co2h_methanol_plant.py\n[Converter / Methanol]", "x": 240.8316118946296, "y": 586.8992069729802}, {"borderWidth": 3.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CO2HMethanolPlantCostModel", "label": "CO2HMethanolPlantCostModel", "shape": "dot", "size": 18.0, "title": "CO2HMethanolPlantCostModel\nconverters/methanol/co2h_methanol_plant.py\n[Converter / Methanol]", "x": 196.70889860494393, "y": 455.57195413603677}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CO2HMethanolPlantFinanceModel", "label": "CO2HMethanolPlantFinanceModel", "shape": "dot", "size": 18.0, "title": "CO2HMethanolPlantFinanceModel\nconverters/methanol/co2h_methanol_plant.py\n[Converter / Methanol]", "x": 260.1901653677206, "y": 332.3999371200461}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HydrogenIronReductionPlantCostComponent", "label": "HydrogenIronReductionPlantCostComponent", "shape": "dot", "size": 18.0, "title": "HydrogenIronReductionPlantCostComponent\nconverters/iron/iron_dri_plant.py\n[Converter / Iron]", "x": 392.80172635379284, "y": 292.1171568185306}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NaturalGasIronReductionPlantCostComponent", "label": "NaturalGasIronReductionPlantCostComponent", "shape": "dot", "size": 18.0, "title": "NaturalGasIronReductionPlantCostComponent\nconverters/iron/iron_dri_plant.py\n[Converter / Iron]", "x": 514.1120611197007, "y": 359.1947982547017}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HydrogenIronReductionPlantPerformanceComponent", "label": "HydrogenIronReductionPlantPerformanceComponent", "shape": "dot", "size": 18.0, "title": "HydrogenIronReductionPlantPerformanceComponent\nconverters/iron/iron_dri_plant.py\n[Converter / Iron]", "x": 550.5168235029448, "y": 492.97505700886006}, {"borderWidth": 3.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NaturalGasIronReductionPlantPerformanceComponent", "label": "NaturalGasIronReductionPlantPerformanceComponent", "shape": "dot", "size": 18.0, "title": "NaturalGasIronReductionPlantPerformanceComponent\nconverters/iron/iron_dri_plant.py\n[Converter / Iron]", "x": 479.8992121579394, "y": 612.3160092753269}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HumbertEwinPerformanceComponent", "label": "HumbertEwinPerformanceComponent", "shape": "dot", "size": 18.0, "title": "HumbertEwinPerformanceComponent\nconverters/iron/humbert_ewin_perf.py\n[Converter / Iron]", "x": 345.0667047898959, "y": 644.8082942814825}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MartinIronMineCostComponent", "label": "MartinIronMineCostComponent", "shape": "dot", "size": 18.0, "title": "MartinIronMineCostComponent\nconverters/iron/martin_mine_cost_model.py\n[Converter / Iron]", "x": 227.80083824635747, "y": 570.7102438066149}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "IronTransportCostComponent", "label": "IronTransportCostComponent", "shape": "dot", "size": 18.0, "title": "IronTransportCostComponent\nconverters/iron/iron_transport.py\n[Converter / Iron]", "x": 199.25186993001995, "y": 434.94269396402495}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "IronReductionPlantBasePerformanceComponent", "label": "IronReductionPlantBasePerformanceComponent", "shape": "dot", "size": 19.45945945945946, "title": "IronReductionPlantBasePerformanceComponent\nconverters/iron/iron_dri_base.py\n[Converter / Iron]", "x": 276.76776483357787, "y": 319.8555634030555}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "IronReductionPlantBaseCostComponent", "label": "IronReductionPlantBaseCostComponent", "shape": "dot", "size": 19.45945945945946, "title": "IronReductionPlantBaseCostComponent\nconverters/iron/iron_dri_base.py\n[Converter / Iron]", "x": 413.3524790181906, "y": 295.27713454003253}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MartinIronMinePerformanceComponent", "label": "MartinIronMinePerformanceComponent", "shape": "dot", "size": 18.0, "title": "MartinIronMinePerformanceComponent\nconverters/iron/martin_mine_perf_model.py\n[Converter / Iron]", "x": 526.1593352437131, "y": 376.14527848915895}, {"borderWidth": 4.0, "color": {"background": "#D84315", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HumbertStinnEwinCostComponent", "label": "HumbertStinnEwinCostComponent", "shape": "dot", "size": 18.0, "title": "HumbertStinnEwinCostComponent\nconverters/iron/humbert_stinn_ewin_cost.py\n[Converter / Iron]", "x": 546.743615781497, "y": 513.4286945748072}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HOPPComponent", "label": "HOPPComponent", "shape": "dot", "size": 18.0, "title": "HOPPComponent\nconverters/hopp/hopp_wrapper.py\n[Converter / HOPP]", "x": 462.5917542911312, "y": 623.8559111338152}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PySAMTidalPerformanceModel", "label": "PySAMTidalPerformanceModel", "shape": "dot", "size": 18.0, "title": "PySAMTidalPerformanceModel\nconverters/water_power/tidal_pysam.py\n[Converter / Water Power]", "x": 324.72859310432364, "y": 640.4260460063381}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PySAMMarineCostModel", "label": "PySAMMarineCostModel", "shape": "dot", "size": 18.0, "title": "PySAMMarineCostModel\nconverters/water_power/pysam_marine_cost.py\n[Converter / Water Power]", "x": 216.77814592164026, "y": 553.0618685876668}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "RunOfRiverHydroPerformanceModel", "label": "RunOfRiverHydroPerformanceModel", "shape": "dot", "size": 18.0, "title": "RunOfRiverHydroPerformanceModel\nconverters/water_power/hydro_plant_run_of_river.py\n[Converter / Water Power]", "x": 204.23854510804242, "y": 414.73832160687783}, {"borderWidth": 4.0, "color": {"background": "#4A90D9", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "RunOfRiverHydroCostModel", "label": "RunOfRiverHydroCostModel", "shape": "dot", "size": 18.0, "title": "RunOfRiverHydroCostModel\nconverters/water_power/hydro_plant_run_of_river.py\n[Converter / Water Power]", "x": 294.740834296002, "y": 309.3594743780847}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleAmmoniaPerformanceModel", "label": "SimpleAmmoniaPerformanceModel", "shape": "dot", "size": 18.0, "title": "SimpleAmmoniaPerformanceModel\nconverters/ammonia/simple_ammonia_model.py\n[Converter / Ammonia]", "x": 433.40509869823285, "y": 300.86319031224053}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleAmmoniaCostModel", "label": "SimpleAmmoniaCostModel", "shape": "dot", "size": 18.0, "title": "SimpleAmmoniaCostModel\nconverters/ammonia/simple_ammonia_model.py\n[Converter / Ammonia]", "x": 536.1198784691253, "y": 394.42665374762043}, {"borderWidth": 3.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "AmmoniaSynLoopPerformanceModel", "label": "AmmoniaSynLoopPerformanceModel", "shape": "dot", "size": 18.0, "title": "AmmoniaSynLoopPerformanceModel\nconverters/ammonia/ammonia_synloop.py\n[Converter / Ammonia]", "x": 540.5636645511591, "y": 533.3117528946093}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "AmmoniaSynLoopCostModel", "label": "AmmoniaSynLoopCostModel", "shape": "dot", "size": 18.0, "title": "AmmoniaSynLoopCostModel\nconverters/ammonia/ammonia_synloop.py\n[Converter / Ammonia]", "x": 444.01862690077985, "y": 633.2724247412674}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "DOCPerformanceModel", "label": "DOCPerformanceModel", "shape": "dot", "size": 18.0, "title": "DOCPerformanceModel\nconverters/co2/marine/direct_ocean_capture.py\n[Converter / CO2]", "x": 305.03269414802736, "y": 633.6581275003193}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "DOCCostModel", "label": "DOCCostModel", "shape": "dot", "size": 18.0, "title": "DOCCostModel\nconverters/co2/marine/direct_ocean_capture.py\n[Converter / CO2]", "x": 207.91368001519578, "y": 534.2137056268189}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "OAEPerformanceModel", "label": "OAEPerformanceModel", "shape": "dot", "size": 18.0, "title": "OAEPerformanceModel\nconverters/co2/marine/ocean_alkalinity_enhancement.py\n[Converter / CO2]", "x": 211.58805712431138, "y": 395.2469621597975}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "OAECostModel", "label": "OAECostModel", "shape": "dot", "size": 18.0, "title": "OAECostModel\nconverters/co2/marine/ocean_alkalinity_enhancement.py\n[Converter / CO2]", "x": 313.84715753363025, "y": 301.0546018388401}, {"borderWidth": 4.0, "color": {"background": "#66BB6A", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "OAECostAndFinancialModel", "label": "OAECostAndFinancialModel", "shape": "dot", "size": 18.0, "title": "OAECostAndFinancialModel\nconverters/co2/marine/ocean_alkalinity_enhancement.py\n[Converter / CO2]", "x": 452.67476445714954, "y": 308.7874754191507}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GridPerformanceModel", "label": "GridPerformanceModel", "shape": "dot", "size": 18.0, "title": "GridPerformanceModel\nconverters/grid/grid.py\n[Converter / Grid]", "x": 543.8580906910796, "y": 413.7741089596238}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GridCostModel", "label": "GridCostModel", "shape": "dot", "size": 18.0, "title": "GridCostModel\nconverters/grid/grid.py\n[Converter / Grid]", "x": 532.0718732102009, "y": 552.342805603834}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NaturalGasPerformanceModel", "label": "NaturalGasPerformanceModel", "shape": "dot", "size": 18.0, "title": "NaturalGasPerformanceModel\nconverters/natural_gas/natural_gas_cc_ct.py\n[Converter / Natural Gas]", "x": 424.4472139785939, "y": 640.4373943201894}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NaturalGasCostModel", "label": "NaturalGasCostModel", "shape": "dot", "size": 18.0, "title": "NaturalGasCostModel\nconverters/natural_gas/natural_gas_cc_ct.py\n[Converter / Natural Gas]", "x": 286.2569295976094, "y": 624.6065418587294}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleGasProducerPerformance", "label": "SimpleGasProducerPerformance", "shape": "dot", "size": 18.0, "title": "SimpleGasProducerPerformance\nconverters/natural_gas/dummy_gas_components.py\n[Converter / Natural Gas]", "x": 201.3280452712436, "y": 514.4356470449293}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleGasConsumerPerformance", "label": "SimpleGasConsumerPerformance", "shape": "dot", "size": 18.0, "title": "SimpleGasConsumerPerformance\nconverters/natural_gas/dummy_gas_components.py\n[Converter / Natural Gas]", "x": 221.19128243913522, "y": 376.74290673623443}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleGasProducerCost", "label": "SimpleGasProducerCost", "shape": "dot", "size": 18.0, "title": "SimpleGasProducerCost\nconverters/natural_gas/dummy_gas_components.py\n[Converter / Natural Gas]", "x": 333.8144210804685, "y": 295.05389888426953}, {"borderWidth": 4.0, "color": {"background": "#1B3A5C", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleGasConsumerCost", "label": "SimpleGasConsumerCost", "shape": "dot", "size": 18.0, "title": "SimpleGasConsumerCost\nconverters/natural_gas/dummy_gas_components.py\n[Converter / Natural Gas]", "x": 470.89095415961566, "y": 318.9337463910879}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "StoragePerformanceModel", "label": "StoragePerformanceModel", "shape": "diamond", "size": 18.0, "title": "StoragePerformanceModel\nstorage/storage_performance_model.py\n[Storage / General]", "x": 428.09388111524004, "y": -469.09888948081795}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "StoragePerformanceBase", "label": "StoragePerformanceBase", "shape": "diamond", "size": 20.18918918918919, "title": "StoragePerformanceBase\nstorage/storage_baseclass.py\n[Storage / General]", "x": 455.6085661088584, "y": -385.16822684557377}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "StorageAutoSizingModel", "label": "StorageAutoSizingModel", "shape": "diamond", "size": 18.0, "title": "StorageAutoSizingModel\nstorage/simple_storage_auto_sizing.py\n[Storage / General]", "x": 370.0643470376622, "y": -331.15773226109025}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GenericStorageCostModel", "label": "GenericStorageCostModel", "shape": "diamond", "size": 18.0, "title": "GenericStorageCostModel\nstorage/generic_storage_cost.py\n[Storage / General]", "x": 264.590914357365, "y": -368.7926071689721}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MCHTOLStorageCostModel", "label": "MCHTOLStorageCostModel", "shape": "diamond", "size": 18.0, "title": "MCHTOLStorageCostModel\nstorage/hydrogen/mch_storage.py\n[Storage / General]", "x": 219.55784982221226, "y": -478.13520688340736}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HydrogenStorageBaseCostModel", "label": "HydrogenStorageBaseCostModel", "shape": "diamond", "size": 20.91891891891892, "title": "HydrogenStorageBaseCostModel\nstorage/hydrogen/h2_storage_cost.py\n[Storage / General]", "x": 270.16454539792574, "y": -589.4304862347785}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "LinedRockCavernStorageCostModel", "label": "LinedRockCavernStorageCostModel", "shape": "diamond", "size": 18.0, "title": "LinedRockCavernStorageCostModel\nstorage/hydrogen/h2_storage_cost.py\n[Storage / General]", "x": 388.2687164324305, "y": -630.4775561122241}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SaltCavernStorageCostModel", "label": "SaltCavernStorageCostModel", "shape": "diamond", "size": 18.0, "title": "SaltCavernStorageCostModel\nstorage/hydrogen/h2_storage_cost.py\n[Storage / General]", "x": 501.4805766605486, "y": -572.7844347513467}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PipeStorageCostModel", "label": "PipeStorageCostModel", "shape": "diamond", "size": 18.0, "title": "PipeStorageCostModel\nstorage/hydrogen/h2_storage_cost.py\n[Storage / General]", "x": 538.9625776291, "y": -449.75172147563603}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CompressedGasStorageCostModel", "label": "CompressedGasStorageCostModel", "shape": "diamond", "size": 18.0, "title": "CompressedGasStorageCostModel\nstorage/hydrogen/h2_storage_cost.py\n[Storage / General]", "x": 475.93189116793945, "y": -336.23888907246976}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PySAMBatteryPerformanceModel", "label": "PySAMBatteryPerformanceModel", "shape": "diamond", "size": 18.0, "title": "PySAMBatteryPerformanceModel\nstorage/battery/pysam_battery.py\n[Storage / General]", "x": 349.5705117805883, "y": -302.34705409538617}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ATBBatteryCostModel", "label": "ATBBatteryCostModel", "shape": "diamond", "size": 18.0, "title": "ATBBatteryCostModel\nstorage/battery/atb_battery_cost.py\n[Storage / General]", "x": 236.61361523178934, "y": -369.9554252856643}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ProFastLCO", "label": "ProFastLCO", "shape": "star", "size": 18.0, "title": "ProFastLCO\nfinances/profast_lco.py\n[Finance / General]", "x": -486.58132074145146, "y": 260.3302434705349}, {"borderWidth": 5.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ProFastBase", "label": "ProFastBase", "shape": "star", "size": 19.45945945945946, "title": "ProFastBase\nfinances/profast_base.py\n[Finance / General]", "x": -459.0666357478331, "y": 344.2609061057791}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ProFastNPV", "label": "ProFastNPV", "shape": "star", "size": 18.0, "title": "ProFastNPV\nfinances/profast_npv.py\n[Finance / General]", "x": -544.6108548190293, "y": 398.2714006902626}, {"borderWidth": 5.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ResourceBaseAPIModel", "label": "ResourceBaseAPIModel", "shape": "triangle", "size": 19.45945945945946, "title": "ResourceBaseAPIModel\nresource/resource_base.py\n[Resource / General]", "x": -79.51256037378874, "y": -584.9567473090942}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MeteosatPrimeMeridianSolarAPI", "label": "MeteosatPrimeMeridianSolarAPI", "shape": "triangle", "size": 18.0, "title": "MeteosatPrimeMeridianSolarAPI\nresource/solar/nlr_developer_meteosat_prime_meridian_models.py\n[Resource / General]", "x": -51.9978753801704, "y": -501.02608467385005}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "MeteosatPrimeMeridianTMYSolarAPI", "label": "MeteosatPrimeMeridianTMYSolarAPI", "shape": "triangle", "size": 18.0, "title": "MeteosatPrimeMeridianTMYSolarAPI\nresource/solar/nlr_developer_meteosat_prime_meridian_models.py\n[Resource / General]", "x": -137.5420944513666, "y": -447.0155900893665}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SolarResourceBaseAPIModel", "label": "SolarResourceBaseAPIModel", "shape": "triangle", "size": 19.45945945945946, "title": "SolarResourceBaseAPIModel\nresource/solar/solar_resource_base.py\n[Resource / General]", "x": -243.01552713166376, "y": -484.6504649972484}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "OpenMeteoHistoricalSolarResource", "label": "OpenMeteoHistoricalSolarResource", "shape": "triangle", "size": 18.0, "title": "OpenMeteoHistoricalSolarResource\nresource/solar/openmeteo_solar.py\n[Resource / General]", "x": -288.0485916668165, "y": -593.9930647116836}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "Himawari7SolarAPI", "label": "Himawari7SolarAPI", "shape": "triangle", "size": 18.0, "title": "Himawari7SolarAPI\nresource/solar/nlr_developer_himawari_api_models.py\n[Resource / General]", "x": -237.44189609110305, "y": -705.2883440630549}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "Himawari8SolarAPI", "label": "Himawari8SolarAPI", "shape": "triangle", "size": 18.0, "title": "Himawari8SolarAPI\nresource/solar/nlr_developer_himawari_api_models.py\n[Resource / General]", "x": -119.33772505659829, "y": -746.3354139405003}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HimawariTMYSolarAPI", "label": "HimawariTMYSolarAPI", "shape": "triangle", "size": 18.0, "title": "HimawariTMYSolarAPI\nresource/solar/nlr_developer_himawari_api_models.py\n[Resource / General]", "x": -6.125864828480175, "y": -688.6422925796229}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "NLRDeveloperAPISolarResourceBase", "label": "NLRDeveloperAPISolarResourceBase", "shape": "triangle", "size": 24.56756756756757, "title": "NLRDeveloperAPISolarResourceBase\nresource/solar/nlr_developer_api_base.py\n[Resource / General]", "x": 31.356136140071186, "y": -565.6095793039123}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GOESAggregatedSolarAPI", "label": "GOESAggregatedSolarAPI", "shape": "triangle", "size": 18.0, "title": "GOESAggregatedSolarAPI\nresource/solar/nlr_developer_goes_api_models.py\n[Resource / General]", "x": -31.67455032108934, "y": -452.096746900746}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GOESConusSolarAPI", "label": "GOESConusSolarAPI", "shape": "triangle", "size": 18.0, "title": "GOESConusSolarAPI\nresource/solar/nlr_developer_goes_api_models.py\n[Resource / General]", "x": -158.0359297084405, "y": -418.2049119236624}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GOESFullDiscSolarAPI", "label": "GOESFullDiscSolarAPI", "shape": "triangle", "size": 18.0, "title": "GOESFullDiscSolarAPI\nresource/solar/nlr_developer_goes_api_models.py\n[Resource / General]", "x": -270.99282625723947, "y": -485.8132831139406}, {"borderWidth": 2.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GOESTMYSolarAPI", "label": "GOESTMYSolarAPI", "shape": "triangle", "size": 18.0, "title": "GOESTMYSolarAPI\nresource/solar/nlr_developer_goes_api_models.py\n[Resource / General]", "x": -301.2124767374961, "y": -614.6459391266081}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "OpenMeteoHistoricalWindResource", "label": "OpenMeteoHistoricalWindResource", "shape": "triangle", "size": 18.0, "title": "OpenMeteoHistoricalWindResource\nresource/wind/openmeteo_wind.py\n[Resource / General]", "x": -229.44086839384488, "y": -726.515073522741}, {"borderWidth": 4.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "WindResourceBaseAPIModel", "label": "WindResourceBaseAPIModel", "shape": "triangle", "size": 19.45945945945946, "title": "WindResourceBaseAPIModel\nresource/wind/wind_resource_base.py\n[Resource / General]", "x": -98.67692574249088, "y": -752.9836456314601}, {"borderWidth": 3.0, "color": {"background": "#555555", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "WTKNLRDeveloperAPIWindResource", "label": "WTKNLRDeveloperAPIWindResource", "shape": "triangle", "size": 18.0, "title": "WTKNLRDeveloperAPIWindResource\nresource/wind/nlr_developer_wtk_api.py\n[Resource / General]", "x": 11.735802273041458, "y": -677.3143608199191}, {"borderWidth": 3.0, "color": {"background": "#F5C542", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "GenericDemandComponent", "label": "GenericDemandComponent", "shape": "dot", "size": 18.0, "title": "GenericDemandComponent\ndemand/generic_demand.py\n[Other / Other]", "x": -486.58132074145146, "y": -260.3302434705348}, {"borderWidth": 4.0, "color": {"background": "#F5C542", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "DemandComponentBase", "label": "DemandComponentBase", "shape": "dot", "size": 19.45945945945946, "title": "DemandComponentBase\ndemand/demand_base.py\n[Other / Other]", "x": -459.0666357478331, "y": -176.39958083529064}, {"borderWidth": 3.0, "color": {"background": "#F5C542", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "FlexibleDemandComponent", "label": "FlexibleDemandComponent", "shape": "dot", "size": 18.0, "title": "FlexibleDemandComponent\ndemand/flexible_demand.py\n[Other / Other]", "x": -544.6108548190293, "y": -122.38908625080711}, {"borderWidth": 5.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PyomoStorageControllerBaseClass", "label": "PyomoStorageControllerBaseClass", "shape": "diamond", "size": 20.18918918918919, "title": "PyomoStorageControllerBaseClass\ncontrol/control_strategies/pyomo_storage_controller_baseclass.py\n[Storage / General]", "x": 206.39396475153265, "y": -498.78808129833186}, {"borderWidth": 5.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SystemLevelControlBase", "label": "SystemLevelControlBase", "shape": "hexagon", "size": 20.18918918918919, "title": "SystemLevelControlBase\ncontrol/control_strategies/system_level/system_level_control_base.py\n[Control / General]", "x": 654.0, "y": 0.0}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "ProfitMaximizationControl", "label": "ProfitMaximizationControl", "shape": "hexagon", "size": 18.0, "title": "ProfitMaximizationControl\ncontrol/control_strategies/system_level/profit_maximization_control.py\n[Control / General]", "x": 681.5146849936184, "y": 83.93066263524416}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "DemandFollowingControl", "label": "DemandFollowingControl", "shape": "hexagon", "size": 18.0, "title": "DemandFollowingControl\ncontrol/control_strategies/system_level/demand_following_control.py\n[Control / General]", "x": 595.9704659224221, "y": 137.9411572197277}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "CostMinimizationControl", "label": "CostMinimizationControl", "shape": "hexagon", "size": 18.0, "title": "CostMinimizationControl\ncontrol/control_strategies/system_level/cost_minimization_control.py\n[Control / General]", "x": 490.497033242125, "y": 100.30628231184586}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PeakLoadManagementOptimizedStorageController", "label": "PeakLoadManagementOptimizedStorageController", "shape": "diamond", "size": 18.0, "title": "PeakLoadManagementOptimizedStorageController\ncontrol/control_strategies/storage/plm_optimized_storage_controller.py\n[Storage / General]", "x": 278.1655730951839, "y": -610.6572156944648}, {"borderWidth": 5.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "StorageOpenLoopControlBase", "label": "StorageOpenLoopControlBase", "shape": "diamond", "size": 20.18918918918919, "title": "StorageOpenLoopControlBase\ncontrol/control_strategies/storage/openloop_storage_control_base.py\n[Storage / General]", "x": 408.9295157465379, "y": -637.1257878031839}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "DemandOpenLoopStorageController", "label": "DemandOpenLoopStorageController", "shape": "diamond", "size": 18.0, "title": "DemandOpenLoopStorageController\ncontrol/control_strategies/storage/demand_openloop_storage_controller.py\n[Storage / General]", "x": 519.3422437620702, "y": -561.4565029916428}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "HeuristicLoadFollowingStorageController", "label": "HeuristicLoadFollowingStorageController", "shape": "diamond", "size": 18.0, "title": "HeuristicLoadFollowingStorageController\ncontrol/control_strategies/storage/heuristic_pyomo_controller.py\n[Storage / General]", "x": 541.9933368656092, "y": -429.1430173132935}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "OptimizedDispatchStorageController", "label": "OptimizedDispatchStorageController", "shape": "diamond", "size": 18.0, "title": "OptimizedDispatchStorageController\ncontrol/control_strategies/storage/optimized_pyomo_controller.py\n[Storage / General]", "x": 462.6186718096345, "y": -320.4638955106441}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "SimpleStorageOpenLoopController", "label": "SimpleStorageOpenLoopController", "shape": "diamond", "size": 18.0, "title": "SimpleStorageOpenLoopController\ncontrol/control_strategies/storage/simple_openloop_controller.py\n[Storage / General]", "x": 329.04975630639206, "y": -301.68432548327485}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PeakLoadManagementHeuristicOpenLoopStorageController", "label": "PeakLoadManagementHeuristicOpenLoopStorageController", "shape": "diamond", "size": 18.0, "title": "PeakLoadManagementHeuristicOpenLoopStorageController\ncontrol/control_strategies/storage/plm_openloop_storage_controller.py\n[Storage / General]", "x": 222.32576716621526, "y": -384.6114372269132}, {"borderWidth": 5.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PyomoRuleBaseClass", "label": "PyomoRuleBaseClass", "shape": "hexagon", "size": 19.45945945945946, "title": "PyomoRuleBaseClass\ncontrol/control_rules/pyomo_rule_baseclass.py\n[Control / General]", "x": 445.4639687069722, "y": -9.036317402589397}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PyomoDispatchGenericConverter", "label": "PyomoDispatchGenericConverter", "shape": "dot", "size": 18.0, "title": "PyomoDispatchGenericConverter\ncontrol/control_rules/converters/generic_converter.py\n[Converter / Other]", "x": 549.2687648311249, "y": 433.9130181048081}, {"borderWidth": 4.0, "color": {"background": "#00ACC1", "border": "#555555", "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, "hover": {"background": "#FFD700", "border": "#FF8C00"}}, "font": {"color": "#333333"}, "id": "PyomoRuleStorageBaseclass", "label": "PyomoRuleStorageBaseclass", "shape": "diamond", "size": 18.0, "title": "PyomoRuleStorageBaseclass\ncontrol/control_rules/storage/pyomo_storage_rule_baseclass.py\n[Storage / General]", "x": 207.46113153897113, "y": -519.1940665805394}]); + edges = new vis.DataSet([{"arrows": "to", "from": "SiteBaseComponent", "to": "SiteLocationComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "ResizeablePerformanceModelBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "SolarPerformanceBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "LinearH2FuelCellPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "SteamMethaneReformerPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "GeoH2SubsurfacePerformanceBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "GeoH2SurfacePerformanceBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "DesalinationPerformanceBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "QuinnNuclearPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "CMUElectricArcFurnaceScrapOnlyPerformanceComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "ElectricArcFurnacePlantBasePerformanceComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "CMUElectricArcFurnaceDRIPerformanceComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "SteelPerformanceBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "SimpleASUPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "WindPerformanceBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "WindArdPerformanceCompatibilityComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "MethanolPerformanceBaseClass"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "HumbertEwinPerformanceComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "IronReductionPlantBasePerformanceComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "MartinIronMinePerformanceComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "HOPPComponent"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "PySAMTidalPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "RunOfRiverHydroPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "SimpleAmmoniaPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "DOCPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "OAEPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "GridPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "NaturalGasPerformanceModel"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "SimpleGasProducerPerformance"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "SimpleGasConsumerPerformance"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "StoragePerformanceBase"}, {"arrows": "to", "from": "PerformanceModelBaseClass", "to": "DemandComponentBase"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "FeedstockCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "GenericConverterCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "ATBUtilityPVCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "ATBResComPVCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "ElectrolyzerCostBaseClass"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "H2FuelCellCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "SteamMethaneReformerCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "GeoH2SubsurfaceCostBaseClass"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "GeoH2SurfaceCostBaseClass"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "DesalinationCostBaseClass"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "QuinnNuclearCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "CMUElectricArcFurnaceCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "ElectricArcFurnacePlantBaseCostComponent"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "SteelCostBaseClass"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "SimpleASUCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "ATBWindPlantCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "WindArdCostCompatibilityComponent"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "MethanolCostBaseClass"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "MartinIronMineCostComponent"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "IronTransportCostComponent"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "IronReductionPlantBaseCostComponent"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "HumbertStinnEwinCostComponent"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "PySAMMarineCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "RunOfRiverHydroCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "SimpleAmmoniaCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "AmmoniaSynLoopCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "DOCCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "OAECostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "OAECostAndFinancialModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "GridCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "NaturalGasCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "SimpleGasProducerCost"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "SimpleGasConsumerCost"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "GenericStorageCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "MCHTOLStorageCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "HydrogenStorageBaseCostModel"}, {"arrows": "to", "from": "CostModelBaseClass", "to": "ATBBatteryCostModel"}, {"arrows": "to", "from": "ResizeablePerformanceModelBaseClass", "to": "ElectrolyzerPerformanceBaseClass"}, {"arrows": "to", "from": "ResizeablePerformanceModelBaseClass", "to": "AmmoniaSynLoopPerformanceModel"}, {"arrows": "to", "from": "CacheBaseClass", "to": "FlorisWindPlantPerformanceModel"}, {"arrows": "to", "from": "CacheBaseClass", "to": "HOPPComponent"}, {"arrows": "to", "from": "SolarPerformanceBaseClass", "to": "PYSAMSolarPlantPerformanceModel"}, {"arrows": "to", "from": "ElectrolyzerPerformanceBaseClass", "to": "ECOElectrolyzerPerformanceModel"}, {"arrows": "to", "from": "ElectrolyzerCostBaseClass", "to": "SingliticoCostModel"}, {"arrows": "to", "from": "ElectrolyzerCostBaseClass", "to": "BasicElectrolyzerCostModel"}, {"arrows": "to", "from": "ElectrolyzerCostBaseClass", "to": "CustomElectrolyzerCostModel"}, {"arrows": "to", "from": "ECOElectrolyzerPerformanceModel", "to": "WOMBATElectrolyzerModel"}, {"arrows": "to", "from": "GeoH2SubsurfacePerformanceBaseClass", "to": "NaturalGeoH2PerformanceModel"}, {"arrows": "to", "from": "GeoH2SubsurfacePerformanceBaseClass", "to": "StimulatedGeoH2PerformanceModel"}, {"arrows": "to", "from": "GeoH2SubsurfaceCostBaseClass", "to": "GeoH2SubsurfaceCostModel"}, {"arrows": "to", "from": "GeoH2SurfacePerformanceBaseClass", "to": "AspenGeoH2SurfacePerformanceModel"}, {"arrows": "to", "from": "GeoH2SurfaceCostBaseClass", "to": "AspenGeoH2SurfaceCostModel"}, {"arrows": "to", "from": "DesalinationPerformanceBaseClass", "to": "ReverseOsmosisPerformanceModel"}, {"arrows": "to", "from": "DesalinationCostBaseClass", "to": "ReverseOsmosisCostModel"}, {"arrows": "to", "from": "ElectricArcFurnacePlantBasePerformanceComponent", "to": "HydrogenEAFPlantPerformanceComponent"}, {"arrows": "to", "from": "ElectricArcFurnacePlantBasePerformanceComponent", "to": "NaturalGasEAFPlantPerformanceComponent"}, {"arrows": "to", "from": "ElectricArcFurnacePlantBaseCostComponent", "to": "HydrogenEAFPlantCostComponent"}, {"arrows": "to", "from": "ElectricArcFurnacePlantBaseCostComponent", "to": "NaturalGasEAFPlantCostComponent"}, {"arrows": "to", "from": "SteelPerformanceBaseClass", "to": "SteelPerformanceModel"}, {"arrows": "to", "from": "SteelCostBaseClass", "to": "SteelCostAndFinancialModel"}, {"arrows": "to", "from": "WindPerformanceBaseClass", "to": "PYSAMWindPlantPerformanceModel"}, {"arrows": "to", "from": "WindPerformanceBaseClass", "to": "FlorisWindPlantPerformanceModel"}, {"arrows": "to", "from": "MethanolPerformanceBaseClass", "to": "SMRMethanolPlantPerformanceModel"}, {"arrows": "to", "from": "MethanolPerformanceBaseClass", "to": "CO2HMethanolPlantPerformanceModel"}, {"arrows": "to", "from": "MethanolCostBaseClass", "to": "SMRMethanolPlantCostModel"}, {"arrows": "to", "from": "MethanolCostBaseClass", "to": "CO2HMethanolPlantCostModel"}, {"arrows": "to", "from": "MethanolFinanceBaseClass", "to": "SMRMethanolPlantFinanceModel"}, {"arrows": "to", "from": "MethanolFinanceBaseClass", "to": "CO2HMethanolPlantFinanceModel"}, {"arrows": "to", "from": "IronReductionPlantBasePerformanceComponent", "to": "HydrogenIronReductionPlantPerformanceComponent"}, {"arrows": "to", "from": "IronReductionPlantBasePerformanceComponent", "to": "NaturalGasIronReductionPlantPerformanceComponent"}, {"arrows": "to", "from": "IronReductionPlantBaseCostComponent", "to": "HydrogenIronReductionPlantCostComponent"}, {"arrows": "to", "from": "IronReductionPlantBaseCostComponent", "to": "NaturalGasIronReductionPlantCostComponent"}, {"arrows": "to", "from": "StoragePerformanceBase", "to": "StoragePerformanceModel"}, {"arrows": "to", "from": "StoragePerformanceBase", "to": "StorageAutoSizingModel"}, {"arrows": "to", "from": "StoragePerformanceBase", "to": "PySAMBatteryPerformanceModel"}, {"arrows": "to", "from": "HydrogenStorageBaseCostModel", "to": "LinedRockCavernStorageCostModel"}, {"arrows": "to", "from": "HydrogenStorageBaseCostModel", "to": "SaltCavernStorageCostModel"}, {"arrows": "to", "from": "HydrogenStorageBaseCostModel", "to": "PipeStorageCostModel"}, {"arrows": "to", "from": "HydrogenStorageBaseCostModel", "to": "CompressedGasStorageCostModel"}, {"arrows": "to", "from": "ProFastBase", "to": "ProFastLCO"}, {"arrows": "to", "from": "ProFastBase", "to": "ProFastNPV"}, {"arrows": "to", "from": "ResourceBaseAPIModel", "to": "SolarResourceBaseAPIModel"}, {"arrows": "to", "from": "ResourceBaseAPIModel", "to": "WindResourceBaseAPIModel"}, {"arrows": "to", "from": "SolarResourceBaseAPIModel", "to": "OpenMeteoHistoricalSolarResource"}, {"arrows": "to", "from": "SolarResourceBaseAPIModel", "to": "NLRDeveloperAPISolarResourceBase"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "MeteosatPrimeMeridianSolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "MeteosatPrimeMeridianTMYSolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "Himawari7SolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "Himawari8SolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "HimawariTMYSolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "GOESAggregatedSolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "GOESConusSolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "GOESFullDiscSolarAPI"}, {"arrows": "to", "from": "NLRDeveloperAPISolarResourceBase", "to": "GOESTMYSolarAPI"}, {"arrows": "to", "from": "WindResourceBaseAPIModel", "to": "OpenMeteoHistoricalWindResource"}, {"arrows": "to", "from": "WindResourceBaseAPIModel", "to": "WTKNLRDeveloperAPIWindResource"}, {"arrows": "to", "from": "DemandComponentBase", "to": "GenericDemandComponent"}, {"arrows": "to", "from": "DemandComponentBase", "to": "FlexibleDemandComponent"}, {"arrows": "to", "from": "PyomoStorageControllerBaseClass", "to": "PeakLoadManagementOptimizedStorageController"}, {"arrows": "to", "from": "PyomoStorageControllerBaseClass", "to": "HeuristicLoadFollowingStorageController"}, {"arrows": "to", "from": "PyomoStorageControllerBaseClass", "to": "OptimizedDispatchStorageController"}, {"arrows": "to", "from": "SystemLevelControlBase", "to": "ProfitMaximizationControl"}, {"arrows": "to", "from": "SystemLevelControlBase", "to": "DemandFollowingControl"}, {"arrows": "to", "from": "SystemLevelControlBase", "to": "CostMinimizationControl"}, {"arrows": "to", "from": "StorageOpenLoopControlBase", "to": "DemandOpenLoopStorageController"}, {"arrows": "to", "from": "StorageOpenLoopControlBase", "to": "SimpleStorageOpenLoopController"}, {"arrows": "to", "from": "StorageOpenLoopControlBase", "to": "PeakLoadManagementHeuristicOpenLoopStorageController"}, {"arrows": "to", "from": "PyomoRuleBaseClass", "to": "PyomoDispatchGenericConverter"}, {"arrows": "to", "from": "PyomoRuleBaseClass", "to": "PyomoRuleStorageBaseclass"}]); nodeColors = {}; allNodes = nodes.get({ returnType: "Object" }); diff --git a/docs/_toc.yml b/docs/_toc.yml index f09d2dc31..1f4fa339a 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -71,9 +71,14 @@ parts: - caption: Control chapters: - file: control/control_overview - - file: control/open-loop_controllers - - file: control/pyomo_controllers - - file: control/controller_demonstrations + - file: control/system_level_control/system_level_control + sections: + - file: control/system_level_control/control_classifier + - file: control/storage_level_control + sections: + - file: control/open-loop_controllers + - file: control/pyomo_controllers + - file: control/controller_demonstrations - caption: Demand chapters: - file: demand/demand_components diff --git a/docs/control/storage_level_control.md b/docs/control/storage_level_control.md new file mode 100644 index 000000000..30544f0a3 --- /dev/null +++ b/docs/control/storage_level_control.md @@ -0,0 +1,3 @@ +# Storage-Level Control + +ADD diff --git a/docs/control/system_level_control/control_classifier.md b/docs/control/system_level_control/control_classifier.md index 8797ab81b..c5586b54c 100644 --- a/docs/control/system_level_control/control_classifier.md +++ b/docs/control/system_level_control/control_classifier.md @@ -43,8 +43,17 @@ A dispatchable performance model represents anything that can receive a set poin ``` ## Storage -Storage is a unique control classifier because it assumes that within the model that energy isn't created or destroyed (minus some efficiency losses). While it's technically "dispatchable" in that it can receive and change its performance based on a set point it's handling within H2I is unique because it's attached to storage performance models, which is handled differently than converter performance models. +Storage is a unique control classifier because it assumes that within the model that energy isn't created or destroyed (minus some efficiency losses). While it's technically "dispatchable" in that it can receive and change its performance based on a set point it's handling within H2I is unique because it's attached to storage performance models, which is handled differently than converter performance models. A converter model only has positive (or zero) `{commodity}_out`, whereas a storage model can have positive or negative `{commodity}_out`. -The storage performance models that have the "storage" control classifier additionally require a storage-level controller in addition to the overarching system level controller. See the image below for reference. The storage-level controller takes in the system level-controller set points and outputs charge (negative) and discharge (positive) commands (storage-level set points) to the storage performance model. +There are two types of cases for the storage control classifier: +1. **with a storage controller** +When the storage performance model is controlled with a storage-level controller (open-loop or feedback controlled), the system-level controller outputs combined demand, that is always positive to the storage-level controller. The demand is `{commodity}_in` from the technologies upstream of the storage that output the same commodity to the storage performance model and the `remaining_demand`. -ADD IMAGE - wait until [PR # 731](https://github.com/NatLabRockies/H2Integrate/pull/731) comes in. +2. **without a storage controller** +The system-level controller outputs set points to the storage performance model which can be considered charge (negative) and discharge (positive) commands (storage-level set points) to the storage performance model, directly. + + +```{figure} figures/storage.png +:width: 85% +:align: center +``` diff --git a/docs/control/system_level_control/system_level_control.md b/docs/control/system_level_control/system_level_control.md new file mode 100644 index 000000000..249e01f67 --- /dev/null +++ b/docs/control/system_level_control/system_level_control.md @@ -0,0 +1,10 @@ +# System-Level Control + +System-level control (SLC) within H2I is meant to operate to control the entire plant with performance and cost feedback driving the operation of the plant or system in a closed-loop. It acts as a supervisory controller, which can work with other technology level controllers. + +```{figure} figures/storage.png +:width: 70% +:align: center +``` + +At a very basic level the SLC receives a demand. Based on that demand From b04ece225d9bce40eefc32fc5875deb0cec3f300 Mon Sep 17 00:00:00 2001 From: kbrunik Date: Fri, 8 May 2026 12:12:09 -0500 Subject: [PATCH 070/105] docs --- docs/control/system_level_control/system_level_control.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control/system_level_control/system_level_control.md b/docs/control/system_level_control/system_level_control.md index 249e01f67..a3bca1143 100644 --- a/docs/control/system_level_control/system_level_control.md +++ b/docs/control/system_level_control/system_level_control.md @@ -2,7 +2,7 @@ System-level control (SLC) within H2I is meant to operate to control the entire plant with performance and cost feedback driving the operation of the plant or system in a closed-loop. It acts as a supervisory controller, which can work with other technology level controllers. -```{figure} figures/storage.png +```{figure} figures/slc_basic.png :width: 70% :align: center ``` From 66e11533084c48b4713610dec180b62afefdb851 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 8 May 2026 11:25:25 -0600 Subject: [PATCH 071/105] Additional refactoring of demand following control --- .../system_level/demand_following_control.py | 15 +++------------ .../system_level/system_level_control_base.py | 2 +- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 0269bde9e..00e081ee8 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -17,8 +17,9 @@ class DemandFollowingControl(SystemLevelControlBase): consider costs. """ - def run_control_for_commodity_subset(self, inputs, outputs, commodity, commodity_demand): - demand = commodity_demand.copy() + def compute(self, inputs, outputs): + commodity = self.commodity + demand = inputs[self.demand_input_name].copy() # 1. Curtailable techs: full production for curtailable_tech in self.curtailable_techs: @@ -57,13 +58,3 @@ def run_control_for_commodity_subset(self, inputs, outputs, commodity, commodity outputs[f"{dispatchable_tech}_{commodity}_set_point"] = ( remaining_demand / n_dispatchable ) - - return outputs - - def compute(self, inputs, outputs): - if self.multi_commodity_system: - self.find_converter_techs() - - outputs = self.run_control_for_commodity_subset( - inputs, outputs, self.commodity, inputs[self.demand_input_name].copy() - ) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index d162e0778..0e8e0abe1 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -70,7 +70,7 @@ def setup(self): self.curtailable_techs + self.dispatchable_techs + self.storage_techs ) - # Input: demand profile (default value from config) + # Input: demand profile self.demand_input_name = f"{self.commodity}_demand" self.add_input( self.demand_input_name, From ebb9abb7220f8ea7ab0674afb0100c95c930c18e Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 8 May 2026 11:34:14 -0600 Subject: [PATCH 072/105] Refactoring some SLC base methods --- .../system_level/demand_following_control.py | 2 +- .../system_level/system_level_control_base.py | 146 +++++++----------- h2integrate/core/h2integrate_model.py | 4 +- 3 files changed, 62 insertions(+), 90 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 00e081ee8..0e885e1a4 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -21,7 +21,7 @@ def compute(self, inputs, outputs): commodity = self.commodity demand = inputs[self.demand_input_name].copy() - # 1. Curtailable techs: full production + # 1. Curtailable techs: operate at full production for curtailable_tech in self.curtailable_techs: commodity_from_tech = self._get_commodity_for_tech(curtailable_tech) # check that this tech produces the commodity demanded diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 0e8e0abe1..7d649f668 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -94,121 +94,93 @@ def setup(self): self._setup_tech_category("storage", self.storage_techs) self._setup_feedstock_category(self.feedstock_comps) - def _setup_commodity_for_given_units( - self, tech_name, commodity, commodity_units, add_in_name=True, initial_set_point=1.0 + def _setup_commodity( + self, + tech_name, + commodity, + commodity_units=None, + commodity_reference_var=None, + add_in_name=True, + initial_set_point=1.0, ): - """Adds inputs and outputs for a commodity when the units are known. - The inputs and outputs that are added have the below naming convention: + """Register OpenMDAO inputs and outputs for a single (tech, commodity) pair. - - ``f"{tech_name}_{commodity}_out"``: input commodity produced by tech_name - - ``f"{tech_name}_rated_{commodity}_production"``: input rated commodity production - capacity of tech_name - - ``f"{tech_name}_{commodity}_set_point"``: output control setpoint for tech_name + This method handles unit specification in two mutually exclusive ways: - Args: - tech_name (str): name of technology - commodity (str): commodity of the technology described by `tech_name` - commodity_units (str): units of commodity - add_in_name (bool, optional): If True, add the input for the in_name variable. - Defaults to True. - initial_set_point (float, optional): Add as the initial value for the - set_point variable. Defaults to 1.0. - Returns: - tuple(str, str, str): tuple of in_name, set_point_name, and rated_name - """ - in_name = f"{tech_name}_{commodity}_out" - rated_name = f"{tech_name}_rated_{commodity}_production" + 1. **Explicit units** - pass ``commodity_units`` (e.g. ``"kW"``). + Each variable is created with ``units=commodity_units``. + 2. **Copied units** - pass ``commodity_reference_var`` (the name of an + already-registered input whose units should be reused). + Each variable is created with ``units=None, copy_units=commodity_reference_var``. - if self.storage_techs_to_control.get(tech_name, False): - # tech_name is storage and does have an attached controller - set_point_name = f"{tech_name}_{commodity}_demand" - else: - # if tech_name is not in storage_techs_to_control - # or storage tech does not have an attached controller - set_point_name = f"{tech_name}_{commodity}_set_point" + Exactly one of ``commodity_units`` or ``commodity_reference_var`` must be + provided. - if add_in_name: - self.add_input( - in_name, - val=0.0, - shape=self.n_timesteps, - units=commodity_units, - desc=f"{commodity} output from {tech_name}", - ) - self.add_input( - rated_name, - val=0.0, - units=commodity_units, - desc=f"Rated {commodity} production for {tech_name}", - ) - self.add_output( - set_point_name, - val=initial_set_point, - shape=self.n_timesteps, - units=commodity_units, - desc=f"Set point for {tech_name} {commodity} curtailment", - ) - - return in_name, set_point_name, rated_name - - def _setup_commodity_for_copy_units( - self, tech_name, commodity, commodity_reference_var, add_in_name=True, initial_set_point=1.0 - ): - """Adds inputs and outputs for a commodity where the units are based on a reference - input variable. The inputs and outputs that are added have the below - naming convention: + The following OpenMDAO variables are created: - - ``f"{tech_name}_{commodity}_out"``: input commodity produced by tech_name - - ``f"{tech_name}_rated_{commodity}_production"``: input rated commodity production - capacity of tech_name - - ``f"{tech_name}_{commodity}_set_point"``: output control setpoint for tech_name + - Input ``"{tech_name}_{commodity}_out"`` - commodity produced by the tech + (only if ``add_in_name=True``). + - Input ``"{tech_name}_rated_{commodity}_production"`` - rated production + capacity of the tech. + - Output ``"{tech_name}_{commodity}_set_point"`` (or + ``"{tech_name}_{commodity}_demand"`` for storage techs with an attached + controller) - control set-point sent to the tech. Args: - tech_name (str): name of technology - commodity (str): commodity of the technology described by `tech_name` - commodity_reference_var (str): name of input to copy units from - add_in_name (bool, optional): If True, add the input for the in_name variable. - Defaults to True. - initial_set_point (float, optional): Add as the initial value for the - set_point variable. Defaults to 1.0. + tech_name (str): Name of the technology. + commodity (str): Commodity produced by ``tech_name``. + commodity_units (str | None): Explicit unit string for the commodity. + Mutually exclusive with ``commodity_reference_var``. + commodity_reference_var (str | None): Name of an existing input + variable whose units should be copied. Mutually exclusive with + ``commodity_units``. + add_in_name (bool, optional): If True, register the + ``"{tech_name}_{commodity}_out"`` input. Defaults to True. + initial_set_point (float, optional): Initial value for the + set-point output. Defaults to 1.0. Returns: - tuple(str, str, str): tuple of in_name, set_point_name, and rated_name + tuple[str, str, str]: ``(in_name, set_point_name, rated_name)`` """ + # --- Determine unit kwargs for add_input / add_output --------- + # Either explicit units or copy_units from a reference variable. + if commodity_units is not None: + unit_kwargs = {"units": commodity_units} + else: + unit_kwargs = {"units": None, "copy_units": commodity_reference_var} + + # --- Build variable names ------------------------------------- in_name = f"{tech_name}_{commodity}_out" rated_name = f"{tech_name}_rated_{commodity}_production" + # Storage techs with an attached controller receive a "demand" + # output instead of a "set_point" output. if self.storage_techs_to_control.get(tech_name, False): - # tech_name is storage and does have an attached controller set_point_name = f"{tech_name}_{commodity}_demand" else: - # if tech_name is not in storage_techs_to_control - # or storage tech does not have an attached controller set_point_name = f"{tech_name}_{commodity}_set_point" + # --- Register inputs and output ------------------------------- if add_in_name: self.add_input( in_name, val=0.0, shape=self.n_timesteps, - units=None, - copy_units=commodity_reference_var, desc=f"{commodity} output from {tech_name}", + **unit_kwargs, ) self.add_input( rated_name, val=0.0, - units=None, - copy_units=commodity_reference_var, desc=f"Rated {commodity} production for {tech_name}", + **unit_kwargs, ) self.add_output( set_point_name, val=initial_set_point, shape=self.n_timesteps, - units=None, - copy_units=commodity_reference_var, desc=f"Set point for {tech_name} {commodity} curtailment", + **unit_kwargs, ) return in_name, set_point_name, rated_name @@ -256,19 +228,19 @@ def _setup_tech_category(self, category, tech_list): for commodity in tech_commodities: if commodity in self.commodities_to_units: # Units are already known explicitly - in_name, set_point_name, rated_name = self._setup_commodity_for_given_units( + in_name, set_point_name, rated_name = self._setup_commodity( tech_name, commodity, - self.commodities_to_units[commodity], + commodity_units=self.commodities_to_units[commodity], add_in_name=True, initial_set_point=initial_set_point, ) elif commodity in self.commodities_to_ref_var: # Units are inferred from a previously-registered reference variable - in_name, set_point_name, rated_name = self._setup_commodity_for_copy_units( + in_name, set_point_name, rated_name = self._setup_commodity( tech_name, commodity, - self.commodities_to_ref_var[commodity], + commodity_reference_var=self.commodities_to_ref_var[commodity], add_in_name=True, initial_set_point=initial_set_point, ) @@ -288,20 +260,20 @@ def _setup_tech_category(self, category, tech_list): # variable so later techs with this commodity can # copy its units. self.commodities_to_ref_var[commodity] = in_name - in_name, set_point_name, rated_name = self._setup_commodity_for_copy_units( + in_name, set_point_name, rated_name = self._setup_commodity( tech_name, commodity, - self.commodities_to_ref_var[commodity], + commodity_reference_var=self.commodities_to_ref_var[commodity], add_in_name=False, initial_set_point=initial_set_point, ) else: # Connection provided units — record them for future use self.commodities_to_units[commodity] = meta_data["units"] - in_name, set_point_name, rated_name = self._setup_commodity_for_given_units( + in_name, set_point_name, rated_name = self._setup_commodity( tech_name, commodity, - self.commodities_to_units[commodity], + commodity_units=self.commodities_to_units[commodity], add_in_name=False, initial_set_point=initial_set_point, ) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index d83df0674..69afe1177 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -562,7 +562,7 @@ def _classify_slc_technologies(self): # Remove feedstocks and connectors control_classifiers_to_connect = ["curtailable", "dispatchable", "storage", "feedstock"] - tech_to_commodities = { + tech_to_commodity = { (e[0], e[-1]) for e in sources_to_commodities if self.tech_control_classifiers[e[0]] in control_classifiers_to_connect @@ -572,7 +572,7 @@ def _classify_slc_technologies(self): slc_config["demand_tech"] = demand_tech slc_config["demand_commodity"] = demand_commodity slc_config["demand_commodity_rate_units"] = demand_commodity_rate_units - slc_config["tech_to_commodity"] = tech_to_commodities + slc_config["tech_to_commodity"] = tech_to_commodity slc_config["storage_techs_to_control"] = storage_tech_to_control slc_config["technology_graph"] = self.technology_graph From b427be854694c04b6d8534a0191ac426cfad94f0 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 8 May 2026 12:05:31 -0600 Subject: [PATCH 073/105] Added complex profit max example to test --- .../system_level/test/test_slc_examples.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index 4e23a67d6..ccdf5dc37 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -123,3 +123,90 @@ def test_slc_battery_with_controller(subtests, temp_copy_of_example): ) == 0.10902004 ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "example_folder,resource_example_folder", + [("35_system_level_control/complex_profit_max", None)], +) +def test_slc_complex_profit_max(subtests, temp_copy_of_example): + example_folder = temp_copy_of_example + + model = H2IntegrateModel(example_folder / "complex_profit_max.yaml") + + n_timesteps = 8760 + hours_of_day = np.tile(np.arange(24), 365) + day_of_year = np.repeat(np.arange(365), 24) + + # Non-constant demand: base 50 MW, daytime bump to ~80 MW, summer cooling peak + base_demand = 50_000 # kW + daytime_bump = np.where((hours_of_day >= 7) & (hours_of_day < 21), 30_000, 0) + seasonal_demand = 1.0 + 0.4 * np.sin(2 * np.pi * (day_of_year - 172) / 365) + demand_profile = (base_demand + daytime_bump) * seasonal_demand + + # ERCOT-like wholesale sell price ($/kWh) with diurnal shape + sell_price = np.zeros(n_timesteps) + for h in range(n_timesteps): + hour = hours_of_day[h] + day = day_of_year[h // 24] if h // 24 < 365 else day_of_year[-1] + season = 1.0 + 0.35 * np.sin(2 * np.pi * (day - 172) / 365) + + if hour < 6: + price = 0.025 + elif hour < 10: + price = 0.025 + (hour - 6) * 0.008 + elif hour < 15: + price = 0.035 + elif hour < 20: + price = 0.035 + (hour - 15) * 0.018 + else: + price = 0.125 - (hour - 20) * 0.025 + + sell_price[h] = price * season + + # Summer evening price spikes + for h in range(n_timesteps): + day = day_of_year[h // 24] if h // 24 < 365 else day_of_year[-1] + hour = hours_of_day[h] + if 150 <= day <= 250 and 17 <= hour <= 20 and day % 5 == 0: + sell_price[h] = max(sell_price[h], 0.20) + + # Grid buy price: wholesale + retail markup + grid_buy_price = sell_price + 0.02 + + model.setup() + + model.prob.set_val( + "plant.electrical_load_demand.electricity_demand", + demand_profile, + ) + model.prob.set_val( + "plant.system_level_controller.commodity_sell_price", + sell_price, + units="USD/(kW*h)", + ) + model.prob.set_val( + "plant.grid_buy.electricity_buy_price", + grid_buy_price, + units="USD/(kW*h)", + ) + + model.run() + + wind_out = model.prob.get_val("wind.electricity_out") + solar_out = model.prob.get_val("solar.electricity_out") + ng_out = model.prob.get_val("natural_gas_plant.electricity_out", units="kW") + grid_out = model.prob.get_val("grid_buy.electricity_out") + + with subtests.test("wind farm generates power"): + assert wind_out.sum() > 0 + + with subtests.test("solar farm generates power"): + assert solar_out.sum() > 0 + + with subtests.test("natural gas dispatched"): + assert ng_out.sum() > 0 + + with subtests.test("grid used when needed"): + assert grid_out.sum() > 0 From 59b16766307f89c8ceefa9893ae270e701356c12 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 8 May 2026 12:07:59 -0600 Subject: [PATCH 074/105] updated location of cost_per_tech --- .../battery_with_controller/plant_config.yaml | 7 ++++--- .../complex_profit_max/plant_config.yaml | 13 +++++++------ .../profit_maximization/plant_config.yaml | 10 ++++------ .../yes_hydrogen/plant_config.yaml | 7 ++++--- .../system_level/cost_minimization_control.py | 2 +- .../system_level/profit_maximization_control.py | 3 ++- .../system_level/system_level_control_base.py | 10 +++++++--- h2integrate/core/h2integrate_model.py | 2 +- 8 files changed, 30 insertions(+), 24 deletions(-) diff --git a/examples/35_system_level_control/battery_with_controller/plant_config.yaml b/examples/35_system_level_control/battery_with_controller/plant_config.yaml index d3c242b7b..a2967e397 100644 --- a/examples/35_system_level_control/battery_with_controller/plant_config.yaml +++ b/examples/35_system_level_control/battery_with_controller/plant_config.yaml @@ -36,9 +36,10 @@ plant: dt: 3600 system_level_control: control_strategy: DemandFollowingControl - solver_name: gauss_seidel - max_iter: 20 - convergence_tolerance: 1.0e-6 + solver_options: + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 finance_parameters: finance_groups: profast_lco: diff --git a/examples/35_system_level_control/complex_profit_max/plant_config.yaml b/examples/35_system_level_control/complex_profit_max/plant_config.yaml index b9379f090..9270b35a2 100644 --- a/examples/35_system_level_control/complex_profit_max/plant_config.yaml +++ b/examples/35_system_level_control/complex_profit_max/plant_config.yaml @@ -42,9 +42,10 @@ system_level_control: control_strategy: ProfitMaximizationControl control_parameters: commodity_sell_price: 0.06 # $/kWh default; overridden in run script - cost_per_tech: - natural_gas_plant: feedstock # use upstream feedstock (ng_feedstock) VarOpEx - grid_buy: buy_price # use electricity_buy_price from cost config - solver_name: gauss_seidel - max_iter: 30 - convergence_tolerance: 1.0e-6 + cost_per_tech: + natural_gas_plant: feedstock # use upstream feedstock (ng_feedstock) VarOpEx + grid_buy: buy_price # use electricity_buy_price from cost config + solver_options: + solver_name: gauss_seidel + max_iter: 30 + convergence_tolerance: 1.0e-6 diff --git a/examples/35_system_level_control/profit_maximization/plant_config.yaml b/examples/35_system_level_control/profit_maximization/plant_config.yaml index 46f0fb769..fff67f46d 100644 --- a/examples/35_system_level_control/profit_maximization/plant_config.yaml +++ b/examples/35_system_level_control/profit_maximization/plant_config.yaml @@ -25,15 +25,13 @@ plant: dt: 3600 system_level_control: control_strategy: ProfitMaximizationControl + control_parameters: + commodity_sell_price: profast_npv # name of finance group whose commodity_sell_price to use + cost_per_tech: + natural_gas_plant: feedstock # use upstream feedstock (ng_feedstock) VarOpEx solver_options: solver_name: gauss_seidel max_iter: 20 - cost_per_tech: - natural_gas_plant: feedstock # use upstream feedstock (ng_feedstock) VarOpEx - control_parameters: - commodity_sell_price: profast_npv # name of finance group whose commodity_sell_price to use - solver_name: gauss_seidel - max_iter: 20 finance_parameters: finance_groups: profast_npv: diff --git a/examples/35_system_level_control/yes_hydrogen/plant_config.yaml b/examples/35_system_level_control/yes_hydrogen/plant_config.yaml index 14e1878ec..ce6872101 100644 --- a/examples/35_system_level_control/yes_hydrogen/plant_config.yaml +++ b/examples/35_system_level_control/yes_hydrogen/plant_config.yaml @@ -43,9 +43,10 @@ plant: dt: 3600 system_level_control: control_strategy: DemandFollowingControl - solver_name: gauss_seidel - max_iter: 20 - convergence_tolerance: 1.0e-6 + solver_options: + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 # finance_parameters: # finance_groups: # profast_lco: diff --git a/h2integrate/control/control_strategies/system_level/cost_minimization_control.py b/h2integrate/control/control_strategies/system_level/cost_minimization_control.py index e2ea815c1..948589b25 100644 --- a/h2integrate/control/control_strategies/system_level/cost_minimization_control.py +++ b/h2integrate/control/control_strategies/system_level/cost_minimization_control.py @@ -16,7 +16,7 @@ class CostMinimizationControl(SystemLevelControlBase): each up to its rated capacity, until remaining demand is met. Marginal costs are configured via ``cost_per_tech`` in the - ``system_level_control`` section of ``plant_config``. Each + ``system_level_control["control_parameters"]`` section of ``plant_config``. Each dispatchable technology's entry can be: - A numeric value ($/commodity_unit, e.g. 0.05 for $0.05/kWh) diff --git a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py index 4c80f3b52..ed4e04315 100644 --- a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py +++ b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py @@ -10,6 +10,7 @@ @define(kw_only=True) class ProfitMaximizationControlConfig(BaseConfig): commodity_sell_price: float = field(default=0.0) + cost_per_tech: dict = field(default={}) class ProfitMaximizationControl(SystemLevelControlBase): @@ -32,7 +33,7 @@ class ProfitMaximizationControl(SystemLevelControlBase): whose ``model_inputs.commodity_sell_price`` will be used. Marginal costs are configured via ``cost_per_tech`` in the - ``system_level_control`` section of ``plant_config``. Each + ``system_level_control["control_parameters"]`` section of ``plant_config``. Each dispatchable technology's entry can be: - A numeric value ($/commodity_unit, e.g. 0.05 for $0.05/kWh) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index b953a965a..b80d3a489 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -438,7 +438,7 @@ def _setup_marginal_costs(self): (e.g., ``CostMinimizationControl``, ``ProfitMaximizationControl``). Reads ``cost_per_tech`` from - ``plant_config["system_level_control"]`` and creates appropriate + ``plant_config["system_level_control"]["control_parameters"]`` and creates appropriate OpenMDAO inputs for each dispatchable technology: - Numeric value (e.g. ``0.05``): used directly as a constant @@ -459,8 +459,12 @@ def _setup_marginal_costs(self): divides by the tech's total production. Handles the common single-feedstock case as well as multiple feedstock streams. """ - slc_config = self.options["plant_config"]["system_level_control"] - self.cost_per_tech = slc_config.get("cost_per_tech", {}) + + self.cost_per_tech = ( + self.options["plant_config"]["system_level_control"] + .get("control_parameters", {}) + .get("cost_per_tech", {}) + ) self.dt_hours = self.options["plant_config"]["plant"]["simulation"]["dt"] / 3600 hours_simulated = self.dt_hours * self.n_timesteps self.fraction_of_year_simulated = hours_simulated / 8760 diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 26cdeb595..7fdf556db 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -660,7 +660,7 @@ def add_system_level_controller(self, slc_config): # 4. For cost-aware strategies, connect cost inputs based on cost_per_tech if strategy_name in ("CostMinimizationControl", "ProfitMaximizationControl"): - cost_per_tech = plant_slc_config.get("cost_per_tech", {}) + cost_per_tech = plant_slc_config.get("control_parameters", {}).get("cost_per_tech", {}) for tech_name, _ in slc_config["tech_to_commodity"]: if self.tech_control_classifiers[tech_name] == "dispatchable": cost_spec = cost_per_tech.get(tech_name, 0.0) From 3a20fcf65a43687cf263ed35ed75e33e4c4081ee Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 8 May 2026 12:12:50 -0600 Subject: [PATCH 075/105] update SLC add method --- .../system_level/system_level_control_base.py | 23 ++-- h2integrate/core/h2integrate_model.py | 128 ++++++++++++++---- 2 files changed, 115 insertions(+), 36 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 7d649f668..373b6cb4f 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -53,18 +53,17 @@ def setup(self): self.storage_techs_to_control = slc_config.get("storage_techs_to_control", {}) self.technology_graph = slc_config["technology_graph"] - self.curtailable_techs = [ - k for k, v in slc_config["tech_control_classifiers"].items() if v == "curtailable" - ] - self.dispatchable_techs = [ - k for k, v in slc_config["tech_control_classifiers"].items() if v == "dispatchable" - ] - self.storage_techs = [ - k for k, v in slc_config["tech_control_classifiers"].items() if v == "storage" - ] - self.feedstock_comps = [ - k for k, v in slc_config["tech_control_classifiers"].items() if v == "feedstock" - ] + # Partition technologies by control classifier in a single pass + classifiers = slc_config["tech_control_classifiers"] + techs_by_category = {"curtailable": [], "dispatchable": [], "storage": [], "feedstock": []} + for tech, category in classifiers.items(): + if category in techs_by_category: + techs_by_category[category].append(tech) + + self.curtailable_techs = techs_by_category["curtailable"] + self.dispatchable_techs = techs_by_category["dispatchable"] + self.storage_techs = techs_by_category["storage"] + self.feedstock_comps = techs_by_category["feedstock"] self.input_techs = set( self.curtailable_techs + self.dispatchable_techs + self.storage_techs diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 69afe1177..818eb728e 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -581,18 +581,92 @@ def _classify_slc_technologies(self): return slc_config def add_system_level_controller(self, slc_config): - """Add the DemandFollowingControl component and configure the plant solver. - - This method: - 1. Selects the appropriate controller class based on ``control_strategy`` - 2. Adds it as a subsystem to the plant group - 3. Configures the nonlinear solver on the plant group - 4. Creates connections between the controller and each technology - 5. For cost/profit strategies, connects marginal cost inputs + """Add a system-level controller component and connect it within the plant. + + Instantiates the controller specified by ``control_strategy`` in the plant configuration, + adds it as an OpenMDAO subsystem named ``"system_level_controller"``, configures + solvers on the plant group to resolve the feedback loop, and creates all + necessary OpenMDAO connections between the controller and the technology models it + dispatches. + + The method executes in five sequential steps: + + 1. **Select and instantiate the controller** - Looks up the class from + ``supported_models`` using the ``control_strategy`` string (e.g. + ``"DemandFollowingControl"``, ``"ProfitMaximizationControl"``). Raises ``ValueError`` + if the strategy name is not found. The instantiated component is added to + ``self.plant`` as ``"system_level_controller"``. + + 2. **Configure the plant-level nonlinear solver** - Because the controller creates a + feedback loop (controller outputs become technology inputs, whose outputs feed back to + the controller), a nonlinear solver is required. Solver type and options are read from + ``plant_config["system_level_control"]["solver_options"]`` via + ``SLCSolverOptionsConfig``. A ``DirectSolver`` is set as the linear solver and + is largely inconsequential as we're not propagating derivatives at this time. + + 3. **Connect technology outputs to controller inputs** - For each ``(tech_name, + commodity)`` pair in ``slc_config["tech_to_commodity"]``: + + - **Feedstock techs**: Only the commodity output + (``{tech_name}_source.{commodity}_out``) is connected to the controller. Feedstocks + have no set-point or rated-production connection. + - **Curtailable / dispatchable / storage techs**: Both the commodity output + (``{tech_name}.{commodity}_out``) and rated production + (``{tech_name}.rated_{commodity}_production``) are connected as controller inputs. + The controller's set-point output is connected back to the tech: + + - If the storage tech has its own sub-controller + (``storage_techs_to_control[tech_name] == True``), the controller outputs a + *demand* signal (``{tech_name}_{commodity}_demand``) that the storage + sub-controller consumes. + - Otherwise, the controller outputs a *set_point* signal + (``{tech_name}_{commodity}_set_point``) directly to the performance model. + + 4. **Connect marginal-cost inputs for cost-aware strategies** - Only executed when + ``control_strategy`` is ``"CostMinimizationControl"`` or + ``"ProfitMaximizationControl"``. Additional cost-aware control strategies + would need to be added here. For each dispatchable tech, the ``cost_per_tech`` + specification determines which cost signal is connected: + + - ``"VarOpEx"``: connects the tech's own ``VarOpEx`` output. + - ``"feedstock"``: scans ``technology_interconnections`` for upstream feedstock + technologies and connects each feedstock's ``VarOpEx`` output. + - ``"buy_price"``: no connection needed; the controller reads a default value from the + tech config that can be overridden at runtime via ``prob.set_val()``. + - Numeric scalar: no connection needed; the value is used directly as a constant + marginal cost. + + 5. **Connect the demand profile** - Connects the demand technology's output + (``{demand_tech}.{demand_commodity}_demand_out``) to the controller's demand input + (``system_level_controller.{demand_commodity}_demand``). + + Args: + slc_config (dict): Pre-computed dictionary produced by + ``_classify_slc_technologies()``. Expected keys: + + - ``"demand_tech"`` (str): Name of the demand technology. + - ``"demand_commodity"`` (str): Commodity the demand consumes. + - ``"tech_to_commodity"`` (set[tuple[str, str]]): Set of ``(tech_name, + commodity)`` pairs for all controlled techs. + - ``"tech_control_classifiers"`` (dict[str, str]): Mapping of tech name to + classifier (``"curtailable"``, ``"dispatchable"``, ``"storage"``, + ``"feedstock"``). + - ``"storage_techs_to_control"`` (dict[str, bool]): Whether each storage tech + has its own sub-controller. + - ``"technology_graph"`` (nx.DiGraph): Directed graph of technology + interconnections. + + Raises: + ValueError: If ``control_strategy`` is not found in ``self.supported_models``. + + Side Effects: + - Adds ``"system_level_controller"`` subsystem to ``self.plant``. + - Sets ``self.plant.nonlinear_solver`` and ``self.plant.linear_solver``. + - Creates OpenMDAO connections within ``self.plant``. """ plant_slc_config = self.plant_config["system_level_control"] - # 1. Select controller class based on strategy + # --- Step 1: Select and instantiate the controller class ---------- strategy_name = plant_slc_config.get("control_strategy") slc_cls = self.supported_models.get(strategy_name) if slc_cls is None: @@ -609,7 +683,9 @@ def add_system_level_controller(self, slc_config): ) self.plant.add_subsystem("system_level_controller", slc_comp) - # 2. Configure the nonlinear solver + # --- Step 2: Configure the nonlinear solver on the plant group ---- + # The feedback loop (controller <-> technologies) requires an + # iterative nonlinear solver to converge. solver_config = SLCSolverOptionsConfig.from_dict(plant_slc_config.get("solver_options", {})) solver_cls = solver_config.return_nonlinear_solver() solver = solver_cls() @@ -618,21 +694,22 @@ def add_system_level_controller(self, slc_config): solver.options[k] = v self.plant.nonlinear_solver = solver self.plant.linear_solver = om.DirectSolver() - # 3. Connect the controller's inputs/outputs to technology models - # Curtailable, dispatchable, and storage techs: read output and write set_point + # --- Step 3: Connect technology outputs/inputs to the controller -- for tech_to_commodity in slc_config["tech_to_commodity"]: tech_name, commodity = tech_to_commodity + if slc_config["tech_control_classifiers"][tech_name] == "feedstock": - # Only connect the feedstock output to the SLC + # Feedstocks only provide their commodity output to the + # controller; they receive no set-point back. self.plant.connect( f"{tech_name}_source.{commodity}_out", f"system_level_controller.{tech_name}_{commodity}_out", ) continue - # For all other techs, connect the tech output and rated production - # to the SLC + # Curtailable, dispatchable, and storage techs: connect their + # commodity output and rated production as controller inputs. self.plant.connect( f"{tech_name}.{commodity}_out", f"system_level_controller.{tech_name}_{commodity}_out", @@ -643,34 +720,37 @@ def add_system_level_controller(self, slc_config): f"system_level_controller.{tech_name}_rated_{commodity}_production", ) - # Connect the SLC to the controllable tech input + # Connect the controller's output back to the technology. if slc_config["storage_techs_to_control"].get(tech_name, False): - # storage has its own controller - # provide demand to storage controller, - # storage controller will provide set-point to performance model + # Storage tech with its own sub-controller: provide a demand + # signal that the sub-controller translates into + # charge/discharge commands. self.plant.connect( f"system_level_controller.{tech_name}_{commodity}_demand", f"{tech_name}.{commodity}_demand", ) else: + # All other techs (or storage without a sub-controller): + # provide a set-point directly to the performance model. self.plant.connect( f"system_level_controller.{tech_name}_{commodity}_set_point", f"{tech_name}.{commodity}_set_point", ) - # 4. For cost-aware strategies, connect cost inputs based on cost_per_tech + # --- Step 4: Connect marginal-cost inputs (cost-aware strategies) - if strategy_name in ("CostMinimizationControl", "ProfitMaximizationControl"): cost_per_tech = plant_slc_config.get("cost_per_tech", {}) for tech_name, _ in slc_config["tech_to_commodity"]: if self.tech_control_classifiers[tech_name] == "dispatchable": cost_spec = cost_per_tech.get(tech_name, 0.0) if cost_spec == "VarOpEx": + # Tech's own variable operating expenditure self.plant.connect( f"{tech_name}.VarOpEx", f"system_level_controller.{tech_name}_VarOpEx", ) elif cost_spec == "feedstock": - # Connect VarOpEx from each upstream feedstock + # Sum VarOpEx from all upstream feedstock technologies interconnections = self.plant_config.get("technology_interconnections", []) technologies = self.technology_config.get("technologies", {}) for conn in interconnections: @@ -685,10 +765,10 @@ def add_system_level_controller(self, slc_config): f"{upstream}.VarOpEx", f"system_level_controller.{upstream}_VarOpEx", ) - # buy_price: defaults from tech config, overridable via set_val - # scalar: no connection needed + # "buy_price": default from tech config, overridable via set_val + # numeric scalar: used directly, no connection needed - # Connect demand profile from the demand component to the controller + # --- Step 5: Connect the demand profile to the controller --------- demand_tech = slc_config["demand_tech"] demand_commodity = slc_config["demand_commodity"] self.plant.connect( From 503c6066a8ce8656903b16f757ee9bfffd021b92 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 8 May 2026 12:13:00 -0600 Subject: [PATCH 076/105] update SLC add method --- .../system_level/system_level_control_base.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 373b6cb4f..7d649f668 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -53,17 +53,18 @@ def setup(self): self.storage_techs_to_control = slc_config.get("storage_techs_to_control", {}) self.technology_graph = slc_config["technology_graph"] - # Partition technologies by control classifier in a single pass - classifiers = slc_config["tech_control_classifiers"] - techs_by_category = {"curtailable": [], "dispatchable": [], "storage": [], "feedstock": []} - for tech, category in classifiers.items(): - if category in techs_by_category: - techs_by_category[category].append(tech) - - self.curtailable_techs = techs_by_category["curtailable"] - self.dispatchable_techs = techs_by_category["dispatchable"] - self.storage_techs = techs_by_category["storage"] - self.feedstock_comps = techs_by_category["feedstock"] + self.curtailable_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "curtailable" + ] + self.dispatchable_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "dispatchable" + ] + self.storage_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "storage" + ] + self.feedstock_comps = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "feedstock" + ] self.input_techs = set( self.curtailable_techs + self.dispatchable_techs + self.storage_techs From 4deb91dc4d5773449737c9d406d41c6ca8a1d69f Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 8 May 2026 12:24:37 -0600 Subject: [PATCH 077/105] Refactoring some SLC base methods --- .../system_level/system_level_control_base.py | 190 +++++++++--------- 1 file changed, 91 insertions(+), 99 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 7d649f668..fe404af0a 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1,6 +1,3 @@ -import operator -import functools - import numpy as np import networkx as nx import openmdao.api as om @@ -566,32 +563,20 @@ def _varopex_marginal_cost(self, inputs, tech_name): return np.full(self.n_timesteps, marginal_cost_scalar) def _find_feedstock_techs(self, tech_name): - """Find feedstock technologies connected upstream of tech_name. + """Find feedstock technologies upstream of ``tech_name`` at any depth. - Scans ``technology_interconnections`` for connections whose - destination is tech_name and whose source is a feedstock. + Uses graph ancestors rather than direct interconnections so that + feedstocks behind intermediate components (e.g. combiners) are found. Args: - tech_name (str): the dispatchable technology name. + tech_name (str): The dispatchable technology name. Returns: - list[str]: names of upstream feedstock technologies. + list[str]: Names of upstream feedstock technologies. """ - interconnections = self.options["plant_config"].get("technology_interconnections", []) - - # Upstream tech names for this dispatchable tech - # NOTE: could getting upstream_techs be replaced with the two lines below - # comds = self._get_commodity_for_tech(tech_name) - # upstream_techs = list( - # set([self.get_upstream_techs_for_commodity(tech_name, c) for c in comds]) - # ) - upstream_techs = [conn[0] for conn in interconnections if conn[1] == tech_name] - - feedstock_names = [ - upstream for upstream in upstream_techs if upstream in self.feedstock_comps - ] - - return feedstock_names + # All ancestors at any depth, filtered to feedstocks + ancestors = nx.ancestors(self.technology_graph, tech_name) + return [tech for tech in ancestors if tech in self.feedstock_comps] def _feedstock_marginal_cost(self, inputs, marginal_cost_data): """Compute marginal cost from upstream feedstock VarOpEx values. @@ -631,108 +616,115 @@ def _feedstock_marginal_cost(self, inputs, marginal_cost_data): def get_upstream_techs_for_commodity( self, tech_name: str, commodity: str, include_feedstock_sources=True ): - """Get the name of technologies that are upstream - of `tech_name` and that output `commodity`. + """Find controlled technologies upstream of ``tech_name`` that output ``commodity``. + + Walks the technology graph backwards from ``tech_name``, finds all ancestor + nodes that have an outgoing edge carrying ``commodity``, then filters to only + those managed by the controller. Args: - tech_name (str): name of technology - commodity (str): commodity name - include_feedstock_sources (bool, optional): If True, include techs - that have an input commodity from a feedstock. Defaults to True. + tech_name (str): Technology whose upstream suppliers are sought. + commodity (str): Commodity of interest (e.g. ``"electricity"``). + include_feedstock_sources (bool, optional): If True, feedstock techs are + included in the set of controller-managed technologies. Defaults to True. Returns: - list[str]: list of technologies upstream of the tech_name that produce a given commodity + list[str]: Controller-managed technologies upstream of ``tech_name`` + that produce ``commodity``. """ + # Build the set of techs the controller can see if include_feedstock_sources: input_techs = self.input_techs | set(self.feedstock_comps) else: input_techs = self.input_techs.copy() - # figure out where the upstream commodity is coming from - upstream_components = nx.ancestors(self.technology_graph, tech_name) - # iterates through a list of 3 length tuples (source, dest, commodity) - upstream_components_shared_commodity = [ - s[0] - for s in list(self.technology_graph.edges(data="commodity")) - if s[0] in upstream_components and s[2] == commodity - ] - # get the technologies that are available to the controller - upstream_techs = set(upstream_components_shared_commodity).intersection(set(input_techs)) - return list(upstream_techs) + # All graph ancestors of tech_name (any depth) + ancestors = nx.ancestors(self.technology_graph, tech_name) + + # Keep only ancestors that have an outgoing edge carrying the target commodity. + # Edges are (source, dest, commodity) tuples + ancestors_with_commodity = { + src + for src, _, comm in self.technology_graph.edges(data="commodity") + if src in ancestors and comm == commodity + } + + # Intersect with controller-managed techs + return list(ancestors_with_commodity & input_techs) def find_converter_techs(self, include_feedstock_sources=True): - """Get the name of the technology that transforms a commodity. + """Identify technologies that transform one commodity into another. + + A "converter" is a tech whose output commodities differ from the commodities + produced by its upstream ancestors (e.g. an electrolyzer: electricity → hydrogen). Args: - include_feedstock_sources (bool, optional): If True, include techs - that have an input commodity from a feedstock. Defaults to True. + include_feedstock_sources (bool, optional): If True, include feedstock techs + in the set of candidate technologies. Defaults to True. Returns: - set(tuple): set of converter technologies formatted as - (input_commodity, converter tech name, output_commodity) + set[tuple[str, str, str]]: Set of ``(input_commodity, tech_name, output_commodity)`` + tuples for each detected conversion. Returns ``None`` for single-commodity systems. """ if include_feedstock_sources: input_techs = self.input_techs | set(self.feedstock_comps) else: input_techs = self.input_techs.copy() + + # Single-commodity systems have no special handling by definition if not self.multi_commodity_system: return converter_techs = set() - + node_order = list(self.technology_graph.nodes()) edges = list(self.technology_graph.edges(data="commodity")) - upstream_converter = None - for edge in edges: - tech, dest_tech, cmod = edge - if tech in input_techs: - tech_output_commodity = self._get_commodity_for_tech(tech) - - # NOTE: unsure how this would work for systems with tiered converters - # aka - maybe have to eliminate a converter once we've discovered it - if upstream_converter is None: - upstream_techs = nx.ancestors(self.technology_graph, tech).intersection( - set(input_techs) - ) - else: - idx_upstream_converter = [ - i - for i, n in enumerate(self.technology_graph.__iter__()) - if n == upstream_converter - ] - downstream_of_previous_converter = [ - n - for i, n in enumerate(self.technology_graph.__iter__()) - if i > min(idx_upstream_converter) - ] - all_upstream_techs = nx.ancestors(self.technology_graph, tech).intersection( - set(input_techs) - ) - upstream_techs = all_upstream_techs.intersection( - set(downstream_of_previous_converter) - ) - connected_upstream_techs = [ - t for t in upstream_techs if nx.has_path(self.technology_graph, t, tech) - ] - upstream_commodities = [ - self._get_commodity_for_tech(t) for t in connected_upstream_techs - ] - upstream_commodities = functools.reduce(operator.iadd, upstream_commodities, []) - # symmetric difference - # commodities that are not in both - input_output_commodity = set(upstream_commodities) ^ set(tech_output_commodity) - if len(input_output_commodity) > 1: - input_commodities = list( - input_output_commodity.intersection(set(upstream_commodities)) - ) - output_commodities = list( - input_output_commodity.intersection(set(tech_output_commodity)) - ) - - for input_commodity in input_commodities: - for output_commodity in output_commodities: - # formatted as (input commodity, tech_name, output commodity) - converter_techs.add((input_commodity, tech, output_commodity)) - upstream_converter = tech + # Track the most recently discovered converter so we can scope + # upstream searches for chained converters (A→B→C where B and C + # both convert). Without this, C would see A's commodity as upstream + # input even though B already consumed it. + last_converter = None + + for source_tech, _, _ in edges: + if source_tech not in input_techs: + continue + + # Get the commodities produced by this tech (the "output" side of the conversion) + output_commodities = set(self._get_commodity_for_tech(source_tech)) + + # Find controlled ancestors of this tech + all_ancestors = nx.ancestors(self.technology_graph, source_tech) & input_techs + + if last_converter is not None: + # Only consider ancestors that appear after the last converter + # in topological order, preventing double-counting across + # chained converters. + converter_idx = node_order.index(last_converter) + nodes_after_converter = set(node_order[converter_idx + 1 :]) + ancestors = all_ancestors & nodes_after_converter + else: + ancestors = all_ancestors + + # Keep only ancestors actually connected (reachable) to this tech + connected_ancestors = [ + t for t in ancestors if nx.has_path(self.technology_graph, t, source_tech) + ] + + # Gather all commodities produced by connected ancestors + input_commodities = set() + for ancestor in connected_ancestors: + input_commodities.update(self._get_commodity_for_tech(ancestor)) + + # A converter has commodities that appear only on one side: + # upstream-only commodities are consumed, output-only are produced. + consumed = input_commodities - output_commodities + produced = output_commodities - input_commodities + + # If both sides have unique commodities, this tech is a converter + if consumed and produced: + for in_comm in consumed: + for out_comm in produced: + converter_techs.add((in_comm, source_tech, out_comm)) + last_converter = source_tech return converter_techs From 2b7fed56e51b41697fe94f8dc65943c2598efa28 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 8 May 2026 12:39:50 -0600 Subject: [PATCH 078/105] Expanding SLC docstrings --- .../system_level/demand_following_control.py | 23 +++++++++---- .../system_level/solver_options.py | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 0e885e1a4..815b95099 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -8,13 +8,24 @@ class DemandFollowingControl(SystemLevelControlBase): """Demand-following system-level controller. - Dispatch priority: - 1. Curtailable techs run at rated capacity (zero marginal cost). - 2. Storage absorbs surplus / provides deficit (set_point = net demand). - 3. Remaining demand is split equally across dispatchable techs. + Dispatches technologies to meet a time-varying demand profile without + considering costs. The demand is satisfied in a fixed three-step priority + order, and each step's shortfall or surplus is passed to the next: - This strategy always attempts to meet demand exactly; it does not - consider costs. + 1. **Curtailable techs** run at their full rated capacity. Their total + output is subtracted from the demand, which may drive the residual + demand negative (surplus). + + 2. **Storage techs** receive the residual demand (which may be positive + or negative). When demand is positive the storage is commanded to + discharge; when negative it is commanded to charge. If multiple + storage techs produce the demanded commodity, the residual demand is + split **evenly** across them (each receives ``demand / n_storage``). + + 3. **Dispatchable techs** cover any remaining positive demand after + storage. The remaining demand (floored at zero) is split **evenly** + across all dispatchable techs that produce the demanded commodity + (each receives ``remaining_demand / n_dispatchable``). """ def compute(self, inputs, outputs): diff --git a/h2integrate/control/control_strategies/system_level/solver_options.py b/h2integrate/control/control_strategies/system_level/solver_options.py index 58817b36b..e3b5186c8 100644 --- a/h2integrate/control/control_strategies/system_level/solver_options.py +++ b/h2integrate/control/control_strategies/system_level/solver_options.py @@ -9,6 +9,25 @@ @define(kw_only=True) class SLCSolverOptionsConfig(BaseConfig): + """Configuration for the nonlinear solver used by the system-level controller. + + Controls which OpenMDAO nonlinear solver is applied to the plant group and + how it converges. The ``convergence_tolerance`` sets both ``atol`` and ``rtol`` + by default; either can be overridden individually. + + Attributes: + solver_name: Solver type. One of ``"gauss_seidel"``, ``"newton"``, or + ``"block_jacobi"``. + max_iter: Maximum number of nonlinear iterations. + atol: Absolute convergence tolerance. Defaults to ``convergence_tolerance``. + rtol: Relative convergence tolerance. Defaults to ``convergence_tolerance``. + convergence_tolerance: Convenience value used to set both ``atol`` and ``rtol`` + when they are not specified individually. + iprint: Solver print level (0 = silent, 2 = verbose). + solver_option_kwargs: Additional keyword arguments passed directly to the + solver's ``options`` dict. + """ + solver_name: str = field( default="gauss_seidel", validator=contains(["gauss_seidel", "newton", "block_jacobi"]) ) @@ -19,6 +38,7 @@ class SLCSolverOptionsConfig(BaseConfig): iprint: int = field(default=2) solver_option_kwargs: dict = field(default={}) + # Maps user-facing solver names to OpenMDAO solver classes solver_map: ClassVar = { "gauss_seidel": om.NonlinearBlockGS, "newton": om.NewtonSolver, @@ -26,13 +46,23 @@ class SLCSolverOptionsConfig(BaseConfig): } def __attrs_post_init__(self): + # Default atol/rtol to the shared convergence_tolerance if not set if self.atol is None: self.atol = self.convergence_tolerance if self.rtol is None: self.rtol = self.convergence_tolerance def get_solver_options(self): + """Build the options dict to apply to the nonlinear solver. + + Merges config attributes with any extra ``solver_option_kwargs`` and + renames ``max_iter`` to ``maxiter`` (the OpenMDAO option name). + + Returns: + dict: Keyword arguments suitable for ``solver.options[k] = v``. + """ d = self.as_dict() + # These attrs configure *which* solver or are handled separately non_solver_option_attrs = [ "solver_name", "solver_map", @@ -41,10 +71,12 @@ def get_solver_options(self): "max_iter", ] solver_options = {k: v for k, v in d.items() if k not in non_solver_option_attrs} + # Merge extra kwargs and translate max_iter → maxiter for OpenMDAO solver_options_full = ( solver_options | self.solver_option_kwargs | {"maxiter": self.max_iter} ) return solver_options_full def return_nonlinear_solver(self): + """Return the OpenMDAO nonlinear solver class for ``solver_name``.""" return self.solver_map[self.solver_name] From da11a526bf2ddd6b65386b9e0c0ae989993af129 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 8 May 2026 12:57:34 -0600 Subject: [PATCH 079/105] refactored test_slc_controllers --- .../system_level/test/test_slc_controllers.py | 597 +++++++++++------- 1 file changed, 371 insertions(+), 226 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py b/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py index a908e4c19..895341142 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py @@ -16,107 +16,127 @@ ) -def _make_plant_config( - n_timesteps=4, - demand=50000, - curtailable=None, - dispatchable=None, - storage=None, - feedstock=None, - sell_price=0.06, - cost_per_tech=None, - technology_interconnections=None, +def _build_plant_config( + technology_interconnections, n_timesteps=4, sell_price=0.06, cost_per_tech=None ): - """Build a minimal plant_config dict for controller tests.""" - all_techs = (curtailable or []) + (dispatchable or []) + (storage or []) + (feedstock or []) - - tech_to_commodity = {(t, "electricity") for t in all_techs} - - config = { + if cost_per_tech is None: + return { + "plant": {"simulation": {"n_timesteps": n_timesteps, "dt": 3600}, "plant_life": 30}, + "system_level_control": {"control_parameters": {"commodity_sell_price": sell_price}}, + "technology_interconnections": technology_interconnections, + } + return { "plant": {"simulation": {"n_timesteps": n_timesteps, "dt": 3600}, "plant_life": 30}, "system_level_control": { - "demand_commodity": "electricity", - "demand_commodity_rate_units": "kW", - "demand_tech": "demand", - "demand_profile": demand, - "curtailable_techs": curtailable or [], - "dispatchable_techs": dispatchable or [], - "storage_techs": storage or [], - "feedstock_techs": feedstock or [], - "tech_to_commodity": tech_to_commodity, - "commodity_sell_price": sell_price, - "cost_per_tech": cost_per_tech or {}, - "control_parameters": {"commodity_sell_price": sell_price}, + "control_parameters": { + "commodity_sell_price": sell_price, + "cost_per_tech": cost_per_tech, + } }, + "technology_interconnections": technology_interconnections, } - if technology_interconnections is not None: - config["technology_interconnections"] = technology_interconnections - return config - - -def _make_slc_config(plant_config): - """Build a minimal slc_config dict from a plant_config for controller tests.""" - slc = plant_config["system_level_control"] - curtailable = slc.get("curtailable_techs", []) - dispatchable = slc.get("dispatchable_techs", []) - storage = slc.get("storage_techs", []) - feedstock = slc.get("feedstock_techs", []) - all_techs = curtailable + dispatchable + storage + feedstock - - # Build technology graph - tech_graph = nx.DiGraph() - for t in all_techs: - tech_graph.add_node(t) - tech_graph.add_node(slc["demand_tech"]) - for t in all_techs: - tech_graph.add_edge(t, slc["demand_tech"], commodity=slc["demand_commodity"]) - for conn in plant_config.get("technology_interconnections", []): - if len(conn) >= 4: - tech_graph.add_edge(conn[0], conn[1], commodity=conn[2]) + + +def _build_technology_graph(technology_interconnections): + technology_graph = nx.DiGraph() + for connection in technology_interconnections: + source = connection[0] + destination = connection[1] + if len(connection) == 4: + technology_graph.add_edge(source, destination, commodity=connection[2]) else: - tech_graph.add_edge(conn[0], conn[1]) - - # Build tech_control_classifiers - classifiers = {} - for t in curtailable: - classifiers[t] = "curtailable" - for t in dispatchable: - classifiers[t] = "dispatchable" - for t in storage: - classifiers[t] = "storage" - for t in feedstock: - classifiers[t] = "feedstock" + technology_graph.add_edge(source, destination) + return technology_graph - return { - "demand_commodity": slc["demand_commodity"], - "demand_commodity_rate_units": slc.get("demand_commodity_rate_units"), - "demand_tech": slc["demand_tech"], - "tech_to_commodity": slc["tech_to_commodity"], - "storage_techs_to_control": {}, - "technology_graph": tech_graph, - "tech_control_classifiers": classifiers, + +def _build_tech_control_classifiers( + curtailable=None, dispatchable=None, storage=None, feedstock=None +): + tech_control_classifiers = {k: "curtailable" for k in (curtailable or [])} + tech_control_classifiers |= {k: "dispatchable" for k in (dispatchable or [])} + tech_control_classifiers |= {k: "storage" for k in (storage or [])} + tech_control_classifiers |= {k: "feedstock" for k in (feedstock or [])} + return tech_control_classifiers + + +def _build_slc_config( + technology_graph, + tech_control_classifiers: dict, + demand_tech: str = "demand", + demand_commodity: str = "electricity", + demand_commodity_rate_units: str = "kW", + storage_techs_with_control: list = [], +): + sources_to_commodities = { + (e[0], e[-1]) for e in technology_graph.edges(data="commodity") if e[-1] is not None } + tech_to_commodities = { + (e[0], e[-1]) for e in sources_to_commodities if e[0] in tech_control_classifiers + } + + storage_techs = [k for k, v in tech_control_classifiers.items() if v == "storage"] + storage_techs_to_control = { + k: True if k in storage_techs_with_control else False for k in storage_techs + } + + slc_config = { + "demand_commodity": demand_commodity, + "demand_commodity_rate_units": demand_commodity_rate_units, + "demand_tech": demand_tech, + "tech_to_commodity": tech_to_commodities, + "storage_techs_to_control": storage_techs_to_control, + "technology_graph": technology_graph, + "tech_control_classifiers": tech_control_classifiers, + } + return slc_config -def _build_problem(slc_cls, plant_config): + +def _build_problem(slc_cls, plant_config, slc_config, demand=50000, tech_config={}): """Create and setup an OpenMDAO Problem with the given controller.""" - slc_config = _make_slc_config(plant_config) prob = om.Problem() + + feedstock_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "feedstock" + ] + feedstock_subsystem_names = [] + for fi, feedstock_tech in enumerate(feedstock_techs): + feedstock_commodity = [ + e[-1] for e in slc_config["tech_to_commodity"] if e[0] == feedstock_tech + ] + feedstock_comp = prob.model.add_subsystem(f"IVC{fi}", om.Group()) + feedstock_comp.add_subsystem( + "feedstock", + om.IndepVarComp( + name=f"{feedstock_tech}_{feedstock_commodity[0]}_out", + val=np.full(plant_config["plant"]["simulation"]["n_timesteps"], 1e9), + units="MMBtu/h", + ), + ) + + feedstock_subsystem_names.append( + f"IVC{fi}.feedstock.{feedstock_tech}_{feedstock_commodity[0]}_out" + ) + prob.model.add_subsystem( "slc", slc_cls( driver_config={}, plant_config=plant_config, - tech_config={}, + tech_config=tech_config, slc_config=slc_config, ), ) + + for feedstock_name in feedstock_subsystem_names: + connection_destination = feedstock_name.split(".")[-1] + prob.model.connect(feedstock_name, f"slc.{connection_destination}") + prob.setup() # Set demand profile from config - slc_cfg = plant_config["system_level_control"] - demand_name = f"slc.{slc_cfg['demand_commodity']}_demand" - prob.set_val(demand_name, slc_cfg["demand_profile"]) + demand_name = f"slc.{slc_config['demand_commodity']}_demand" + prob.set_val(demand_name, demand) return prob @@ -129,31 +149,48 @@ class TestSystemLevelControlBase: """Tests for the abstract base class setup logic.""" def test_base_creates_curtailable_io(self): - pc = _make_plant_config(curtailable=["wind"]) + tech_connections = [["wind", "demand", "electricity", "cable"]] + plant_config = _build_plant_config(tech_connections) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(curtailable=["wind"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) # Use DemandFollowingControl since base is abstract - prob = _build_problem(DemandFollowingControl, pc) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) # _var_rel2meta uses relative names (no "slc." prefix) assert "wind_electricity_out" in prob.model.slc._var_rel2meta assert "wind_rated_electricity_production" in prob.model.slc._var_rel2meta assert "wind_electricity_set_point" in prob.model.slc._var_rel2meta def test_base_creates_dispatchable_io(self): - pc = _make_plant_config(dispatchable=["ng"]) - prob = _build_problem(DemandFollowingControl, pc) + tech_connections = [["ng", "demand", "electricity", "cable"]] + plant_config = _build_plant_config(tech_connections) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["ng"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) assert "ng_electricity_out" in prob.model.slc._var_rel2meta assert "ng_rated_electricity_production" in prob.model.slc._var_rel2meta assert "ng_electricity_set_point" in prob.model.slc._var_rel2meta def test_base_creates_storage_io(self): - pc = _make_plant_config(storage=["battery"]) - prob = _build_problem(DemandFollowingControl, pc) + tech_connections = [["battery", "demand", "electricity", "cable"]] + plant_config = _build_plant_config(tech_connections) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(storage=["battery"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) + assert "battery_electricity_out" in prob.model.slc._var_rel2meta assert "battery_rated_electricity_production" in prob.model.slc._var_rel2meta assert "battery_electricity_set_point" in prob.model.slc._var_rel2meta def test_base_creates_demand_input(self): - pc = _make_plant_config() - prob = _build_problem(DemandFollowingControl, pc) + plant_config = _build_plant_config([]) + tech_graph = _build_technology_graph([]) + tech_control_classifiers = _build_tech_control_classifiers() + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) + assert "electricity_demand" in prob.model.slc._var_rel2meta def test_backward_compat_alias(self): @@ -169,8 +206,17 @@ class TestDemandFollowingControl: """Tests for the demand-following (equal-share) controller.""" def test_equal_share_two_dispatchable(self): - pc = _make_plant_config(dispatchable=["ng1", "ng2"]) - prob = _build_problem(DemandFollowingControl, pc) + tech_connections = [ + ["ng1", "combiner", "electricity", "cable"], + ["ng2", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config(tech_connections) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["ng1", "ng2"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) + prob.set_val("slc.ng1_rated_electricity_production", 80000) prob.set_val("slc.ng2_rated_electricity_production", 40000) prob.run_model() @@ -181,8 +227,19 @@ def test_equal_share_two_dispatchable(self): np.testing.assert_allclose(sp2, 25000) def test_curtailable_reduces_demand(self): - pc = _make_plant_config(curtailable=["wind"], dispatchable=["ng"]) - prob = _build_problem(DemandFollowingControl, pc) + tech_connections = [ + ["wind", "combiner", "electricity", "cable"], + ["ng", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config(tech_connections) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + curtailable=["wind"], dispatchable=["ng"] + ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) + prob.set_val("slc.wind_electricity_out", [30000, 60000, 50000, 10000]) prob.set_val("slc.wind_rated_electricity_production", 120000) prob.set_val("slc.ng_rated_electricity_production", 100000) @@ -194,8 +251,21 @@ def test_curtailable_reduces_demand(self): np.testing.assert_allclose(ng_sp, expected) def test_storage_absorbs_surplus(self): - pc = _make_plant_config(curtailable=["wind"], storage=["battery"], dispatchable=["ng"]) - prob = _build_problem(DemandFollowingControl, pc) + tech_connections = [ + ["wind", "battery", "electricity", "cable"], + ["wind", "combiner", "electricity", "cable"], + ["battery", "combiner", "electricity", "cable"], + ["ng", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config(tech_connections) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + curtailable=["wind"], storage=["battery"], dispatchable=["ng"] + ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) + prob.set_val("slc.wind_electricity_out", [70000, 30000, 50000, 50000]) prob.set_val("slc.wind_rated_electricity_production", 120000) prob.set_val("slc.battery_electricity_out", [0, 0, 0, 0]) @@ -210,8 +280,12 @@ def test_storage_absorbs_surplus(self): def test_no_techs_runs(self): """Controller with no techs should still run without error.""" - pc = _make_plant_config() - prob = _build_problem(DemandFollowingControl, pc) + plant_config = _build_plant_config([]) + tech_graph = _build_technology_graph([]) + tech_control_classifiers = _build_tech_control_classifiers() + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) + prob.run_model() # should not raise @@ -223,12 +297,21 @@ class TestCostMinimizationControl: """Tests for the merit-order cost-minimization controller.""" def test_cheapest_dispatched_first(self): - pc = _make_plant_config( - dispatchable=["cheap", "expensive"], - demand=50000, - cost_per_tech={"cheap": 0.03, "expensive": 0.08}, + tech_connections = [ + ["cheap", "combiner", "electricity", "cable"], + ["expensive", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, cost_per_tech={"cheap": 0.03, "expensive": 0.08} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + dispatchable=["cheap", "expensive"] ) - prob = _build_problem(CostMinimizationControl, pc) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) + prob.set_val("slc.cheap_rated_electricity_production", 80000) prob.set_val("slc.expensive_rated_electricity_production", 40000) prob.run_model() @@ -240,12 +323,21 @@ def test_cheapest_dispatched_first(self): np.testing.assert_allclose(expensive_sp, 0) def test_overflow_to_expensive(self): - pc = _make_plant_config( - dispatchable=["cheap", "expensive"], - demand=50000, - cost_per_tech={"cheap": 0.03, "expensive": 0.08}, + tech_connections = [ + ["cheap", "combiner", "electricity", "cable"], + ["expensive", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, cost_per_tech={"cheap": 0.03, "expensive": 0.08} ) - prob = _build_problem(CostMinimizationControl, pc) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + dispatchable=["cheap", "expensive"] + ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) + prob.set_val("slc.cheap_rated_electricity_production", 30000) prob.set_val("slc.expensive_rated_electricity_production", 40000) prob.run_model() @@ -257,13 +349,19 @@ def test_overflow_to_expensive(self): np.testing.assert_allclose(expensive_sp, 20000) def test_with_curtailable_reduces_dispatch(self): - pc = _make_plant_config( - curtailable=["wind"], - dispatchable=["ng"], - demand=50000, - cost_per_tech={"ng": 0.05}, + tech_connections = [ + ["wind", "combiner", "electricity", "cable"], + ["ng", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config(tech_connections, cost_per_tech={"ng": 0.05}) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + curtailable=["wind"], dispatchable=["ng"] ) - prob = _build_problem(CostMinimizationControl, pc) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) + prob.set_val("slc.wind_electricity_out", [40000, 40000, 40000, 40000]) prob.set_val("slc.wind_rated_electricity_production", 120000) prob.set_val("slc.ng_rated_electricity_production", 100000) @@ -282,13 +380,21 @@ class TestProfitMaximizationControl: """Tests for the profit-maximization controller.""" def test_unprofitable_tech_not_dispatched(self): - pc = _make_plant_config( - dispatchable=["cheap", "expensive"], - demand=50000, - sell_price=0.06, - cost_per_tech={"cheap": 0.03, "expensive": 0.08}, + tech_connections = [ + ["cheap", "combiner", "electricity", "cable"], + ["expensive", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.06, cost_per_tech={"cheap": 0.03, "expensive": 0.08} ) - prob = _build_problem(ProfitMaximizationControl, pc) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + dispatchable=["cheap", "expensive"] + ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(ProfitMaximizationControl, plant_config, slc_config, demand=50000) + prob.set_val("slc.cheap_rated_electricity_production", 30000) prob.set_val("slc.expensive_rated_electricity_production", 40000) prob.set_val("slc.commodity_sell_price", 0.06) @@ -302,13 +408,19 @@ def test_unprofitable_tech_not_dispatched(self): np.testing.assert_allclose(expensive_sp, 0) def test_all_profitable(self): - pc = _make_plant_config( - dispatchable=["a", "b"], - demand=50000, - sell_price=0.10, - cost_per_tech={"a": 0.03, "b": 0.05}, + tech_connections = [ + ["a", "combiner", "electricity", "cable"], + ["b", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.10, cost_per_tech={"a": 0.03, "b": 0.05} ) - prob = _build_problem(ProfitMaximizationControl, pc) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["a", "b"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(ProfitMaximizationControl, plant_config, slc_config, demand=50000) + prob.set_val("slc.a_rated_electricity_production", 80000) prob.set_val("slc.b_rated_electricity_production", 40000) prob.set_val("slc.commodity_sell_price", 0.10) @@ -321,13 +433,17 @@ def test_all_profitable(self): np.testing.assert_allclose(b_sp, 0) def test_none_profitable(self): - pc = _make_plant_config( - dispatchable=["ng"], - demand=50000, - sell_price=0.01, - cost_per_tech={"ng": 0.05}, + tech_connections = [ + ["ng", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.01, cost_per_tech={"ng": 0.05} ) - prob = _build_problem(ProfitMaximizationControl, pc) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["ng"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(ProfitMaximizationControl, plant_config, slc_config, demand=50000) + prob.set_val("slc.ng_rated_electricity_production", 100000) prob.set_val("slc.commodity_sell_price", 0.01) prob.run_model() @@ -337,13 +453,17 @@ def test_none_profitable(self): np.testing.assert_allclose(ng_sp, 0) def test_sell_price_from_config(self): - pc = _make_plant_config( - dispatchable=["ng"], - demand=50000, - sell_price=0.10, - cost_per_tech={"ng": 0.03}, + tech_connections = [ + ["ng", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.10, cost_per_tech={"ng": 0.03} ) - prob = _build_problem(ProfitMaximizationControl, pc) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["ng"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(ProfitMaximizationControl, plant_config, slc_config, demand=50000) + prob.set_val("slc.ng_rated_electricity_production", 100000) # Don't set sell_price explicitly — should use config default 0.10 prob.run_model() @@ -353,13 +473,17 @@ def test_sell_price_from_config(self): np.testing.assert_allclose(ng_sp, 50000) def test_time_varying_sell_price(self): - pc = _make_plant_config( - dispatchable=["ng"], - demand=50000, - sell_price=0.06, - cost_per_tech={"ng": 0.05}, + tech_connections = [ + ["ng", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.06, cost_per_tech={"ng": 0.05} ) - prob = _build_problem(ProfitMaximizationControl, pc) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["ng"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(ProfitMaximizationControl, plant_config, slc_config, demand=50000) + prob.set_val("slc.ng_rated_electricity_production", 100000) # Sell price varies: 2 profitable hours, 2 unprofitable prob.set_val("slc.commodity_sell_price", [0.08, 0.03, 0.10, 0.02]) @@ -371,13 +495,6 @@ def test_time_varying_sell_price(self): def test_buy_price_scalar(self): """buy_price mode with a scalar buy price from tech config.""" - pc = _make_plant_config( - dispatchable=["grid"], - demand=50000, - sell_price=0.10, - cost_per_tech={"grid": "buy_price"}, - ) - # Add tech config with buy price tech_config = { "technologies": { "grid": { @@ -387,17 +504,24 @@ def test_buy_price_scalar(self): } } } - prob = om.Problem() - prob.model.add_subsystem( - "slc", - ProfitMaximizationControl( - driver_config={}, - plant_config=pc, - tech_config=tech_config, - slc_config=_make_slc_config(pc), - ), + + tech_connections = [ + ["grid", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.10, cost_per_tech={"grid": "buy_price"} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["grid"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem( + ProfitMaximizationControl, + plant_config, + slc_config, + demand=50000, + tech_config=tech_config, ) - prob.setup() + prob.set_val("slc.electricity_demand", 50000) prob.set_val("slc.grid_rated_electricity_production", 100000) prob.set_val("slc.commodity_sell_price", 0.10) @@ -409,12 +533,7 @@ def test_buy_price_scalar(self): def test_buy_price_time_varying(self): """buy_price mode with time-varying prices (override via set_val).""" - pc = _make_plant_config( - dispatchable=["grid"], - demand=50000, - sell_price=0.06, - cost_per_tech={"grid": "buy_price"}, - ) + tech_config = { "technologies": { "grid": { @@ -424,17 +543,23 @@ def test_buy_price_time_varying(self): } } } - prob = om.Problem() - prob.model.add_subsystem( - "slc", - ProfitMaximizationControl( - driver_config={}, - plant_config=pc, - tech_config=tech_config, - slc_config=_make_slc_config(pc), - ), + tech_connections = [ + ["grid", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.06, cost_per_tech={"grid": "buy_price"} ) - prob.setup() + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["grid"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem( + ProfitMaximizationControl, + plant_config, + slc_config, + demand=50000, + tech_config=tech_config, + ) + prob.set_val("slc.electricity_demand", 50000) prob.set_val("slc.grid_rated_electricity_production", 100000) prob.set_val("slc.commodity_sell_price", 0.06) @@ -447,13 +572,17 @@ def test_buy_price_time_varying(self): def test_varopex_mode(self): """VarOpEx mode computes marginal cost from VarOpEx / production.""" - pc = _make_plant_config( - dispatchable=["gen"], - demand=50000, - sell_price=0.10, - cost_per_tech={"gen": "VarOpEx"}, + tech_connections = [ + ["gen", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.10, cost_per_tech={"gen": "VarOpEx"} ) - prob = _build_problem(CostMinimizationControl, pc) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["gen"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) + prob.set_val("slc.gen_rated_electricity_production", 100000) # Set VarOpEx ($/year, shape=plant_life=30) and production prob.set_val("slc.gen_VarOpEx", np.full(30, 500000.0)) @@ -470,13 +599,16 @@ def test_varopex_mode(self): def test_cost_per_tech_default_zero(self): """Techs not listed in cost_per_tech default to zero marginal cost.""" - pc = _make_plant_config( - dispatchable=["ng"], - demand=50000, - sell_price=0.10, - cost_per_tech={}, # Empty: ng defaults to 0.0 - ) - prob = _build_problem(ProfitMaximizationControl, pc) + + tech_connections = [ + ["ng", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config(tech_connections, sell_price=0.10, cost_per_tech={}) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["ng"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(ProfitMaximizationControl, plant_config, slc_config, demand=50000) + prob.set_val("slc.ng_rated_electricity_production", 100000) prob.set_val("slc.commodity_sell_price", 0.10) prob.run_model() @@ -487,18 +619,21 @@ def test_cost_per_tech_default_zero(self): def test_feedstock_single(self): """feedstock mode: single upstream feedstock drives marginal cost.""" - pc = _make_plant_config( - dispatchable=["ng_plant"], - feedstock=["ng_feed"], - demand=50000, - sell_price=0.10, - cost_per_tech={"ng_plant": "feedstock"}, - technology_interconnections=[ - ["ng_feed", "ng_plant", "natural_gas", "pipe"], - ], + + tech_connections = [ + ["ng_feed", "ng_plant", "natural_gas", "pipe"], + ["ng_plant", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.10, cost_per_tech={"ng_plant": "feedstock"} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + dispatchable=["ng_plant"], feedstock=["ng_feed"] ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) - prob = _build_problem(CostMinimizationControl, pc) prob.set_val("slc.ng_plant_rated_electricity_production", 100000) # Feedstock VarOpEx: $1M/yr; production: 100 MW * 4 h = 400 MWh prob.set_val("slc.ng_feed_VarOpEx", np.full(30, 1_000_000.0)) @@ -512,20 +647,23 @@ def test_feedstock_single(self): def test_feedstock_multiple(self): """feedstock mode: multiple upstream feedstocks are summed.""" - pc = _make_plant_config( - dispatchable=["plant"], - feedstock=["feed_a", "feed_b"], - demand=50000, - sell_price=0.10, - cost_per_tech={"plant": "feedstock"}, - technology_interconnections=[ - ["feed_a", "plant", "gas_a", "pipe"], - ["feed_b", "plant", "gas_b", "pipe"], - ["other_tech", "plant", "something", "cable"], - ], + tech_connections = [ + ["feed_a", "plant", "gas_a", "pipe"], + ["feed_b", "plant", "gas_b", "pipe"], + ["other_tech", "plant", "something", "cable"], + ["plant", "demand", "electricity", "cable"], + ] + + plant_config = _build_plant_config( + tech_connections, sell_price=0.10, cost_per_tech={"plant": "feedstock"} ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + dispatchable=["plant"], feedstock=["feed_a", "feed_b"] + ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) - prob = _build_problem(CostMinimizationControl, pc) prob.set_val("slc.plant_rated_electricity_production", 100000) # Two feedstocks: $500k and $300k → total $800k/yr prob.set_val("slc.feed_a_VarOpEx", np.full(30, 500_000.0)) @@ -540,18 +678,22 @@ def test_feedstock_multiple(self): def test_feedstock_profit_max_unprofitable(self): """feedstock mode in profit max: unprofitable when feedstock costs exceed sell price.""" - pc = _make_plant_config( - dispatchable=["ng_plant"], - feedstock=["ng_feed"], - demand=50000, - sell_price=0.01, # very low sell price - cost_per_tech={"ng_plant": "feedstock"}, - technology_interconnections=[ - ["ng_feed", "ng_plant", "natural_gas", "pipe"], - ], + + tech_connections = [ + ["ng_feed", "ng_plant", "natural_gas", "pipe"], + ["ng_plant", "demand", "electricity", "cable"], + ] + # use a very low sell price + plant_config = _build_plant_config( + tech_connections, sell_price=0.01, cost_per_tech={"ng_plant": "feedstock"} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + dispatchable=["ng_plant"], feedstock=["ng_feed"] ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(ProfitMaximizationControl, plant_config, slc_config, demand=50000) - prob = _build_problem(ProfitMaximizationControl, pc) prob.set_val("slc.ng_plant_rated_electricity_production", 100000) prob.set_val("slc.commodity_sell_price", 0.01) # Very expensive feedstock: $100M/yr → high marginal cost @@ -565,14 +707,17 @@ def test_feedstock_profit_max_unprofitable(self): def test_feedstock_no_feedstock_raises(self): """feedstock mode raises ValueError when no feedstock is found upstream.""" - pc = _make_plant_config( - dispatchable=["ng_plant"], - demand=50000, - cost_per_tech={"ng_plant": "feedstock"}, - technology_interconnections=[ - ["some_tech", "ng_plant", "electricity", "cable"], - ], + + tech_connections = [ + ["some_tech", "ng_plant", "electricity", "cable"], + ] + + plant_config = _build_plant_config( + tech_connections, sell_price=0.01, cost_per_tech={"ng_plant": "feedstock"} ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["ng_plant"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) with pytest.raises(ValueError, match="at least one feedstock"): - _build_problem(CostMinimizationControl, pc) + _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) From 6eeac7194b52341eb63ca7486c0a22b2a8ce454b Mon Sep 17 00:00:00 2001 From: kbrunik Date: Sun, 10 May 2026 08:09:53 -0500 Subject: [PATCH 080/105] docs update --- docs/_toc.yml | 7 ++- docs/control/storage_level_control.md | 3 -- .../system_level_control/slc_cost_min.md | 2 + .../slc_demand_following.md | 2 + .../system_level_control/slc_profit_max.md | 2 + .../system_level_control.md | 47 +++++++++++++++++- .../system_level_control_base.md | 2 + .../controller_demonstrations.md | 4 +- .../figures/Pyomo_dispatch_figure.png | Bin .../figures/example_peak_load_dispatch.png | Bin .../figures/plm_optimized_dispatch.png | Bin .../open-loop_controllers.md | 2 +- .../pyomo_controllers.md | 4 +- .../technology_control_overview.md} | 4 +- 14 files changed, 65 insertions(+), 14 deletions(-) delete mode 100644 docs/control/storage_level_control.md create mode 100644 docs/control/system_level_control/slc_cost_min.md create mode 100644 docs/control/system_level_control/slc_demand_following.md create mode 100644 docs/control/system_level_control/slc_profit_max.md create mode 100644 docs/control/system_level_control/system_level_control_base.md rename docs/control/{ => technology_level_control}/controller_demonstrations.md (94%) rename docs/control/{ => technology_level_control}/figures/Pyomo_dispatch_figure.png (100%) rename docs/control/{ => technology_level_control}/figures/example_peak_load_dispatch.png (100%) rename docs/control/{ => technology_level_control}/figures/plm_optimized_dispatch.png (100%) rename docs/control/{ => technology_level_control}/open-loop_controllers.md (99%) rename docs/control/{ => technology_level_control}/pyomo_controllers.md (94%) rename docs/control/{control_overview.md => technology_level_control/technology_control_overview.md} (71%) diff --git a/docs/_toc.yml b/docs/_toc.yml index 1f4fa339a..e1255eaca 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -70,11 +70,14 @@ parts: - file: resource/tidal_resource - caption: Control chapters: - - file: control/control_overview - file: control/system_level_control/system_level_control sections: + - file: control/system_level_control/system_level_control_base - file: control/system_level_control/control_classifier - - file: control/storage_level_control + - file: control/system_level_control/slc_demand_following + - file: control/system_level_control/slc_cost_min + - file: control/system_level_control/slc_profit_max + - file: control/technology_level_control/technology_control_overview sections: - file: control/open-loop_controllers - file: control/pyomo_controllers diff --git a/docs/control/storage_level_control.md b/docs/control/storage_level_control.md deleted file mode 100644 index 30544f0a3..000000000 --- a/docs/control/storage_level_control.md +++ /dev/null @@ -1,3 +0,0 @@ -# Storage-Level Control - -ADD diff --git a/docs/control/system_level_control/slc_cost_min.md b/docs/control/system_level_control/slc_cost_min.md new file mode 100644 index 000000000..f24570590 --- /dev/null +++ b/docs/control/system_level_control/slc_cost_min.md @@ -0,0 +1,2 @@ +(slc-cost-min)= +# Cost Minimization System Level Controller diff --git a/docs/control/system_level_control/slc_demand_following.md b/docs/control/system_level_control/slc_demand_following.md new file mode 100644 index 000000000..72a05dcf4 --- /dev/null +++ b/docs/control/system_level_control/slc_demand_following.md @@ -0,0 +1,2 @@ +(slc-demand-following)= +# Demand Following System Level Controller diff --git a/docs/control/system_level_control/slc_profit_max.md b/docs/control/system_level_control/slc_profit_max.md new file mode 100644 index 000000000..42ac80f14 --- /dev/null +++ b/docs/control/system_level_control/slc_profit_max.md @@ -0,0 +1,2 @@ +(slc-profit-max)= +# Profit Maximization System Level Controller diff --git a/docs/control/system_level_control/system_level_control.md b/docs/control/system_level_control/system_level_control.md index a3bca1143..5ebbd3838 100644 --- a/docs/control/system_level_control/system_level_control.md +++ b/docs/control/system_level_control/system_level_control.md @@ -1,10 +1,53 @@ # System-Level Control -System-level control (SLC) within H2I is meant to operate to control the entire plant with performance and cost feedback driving the operation of the plant or system in a closed-loop. It acts as a supervisory controller, which can work with other technology level controllers. +System-level control (SLC) within H2I is meant to operate to control the entire plant with performance and cost feedback driving the operation of the plant or system in a closed-loop. It acts as a supervisory controller meaning that it can work to coordinate the entire system and can work with other technology level controllers. + +The most basic SLC is shown in the figured below, where the SLC receives a demand. Based on that demand it will output set points for `{commodity}_out` to the individual technology blocks included within the system. Each technology based on it's controller classification will respond to the set point. From each technology block there is `{commodity}_out` (potentially changed by the set point signal) that is connected via feedback to the SLC. The SLC will then attempt to converge the system where it will loop through changing the set points in attempts to meet the demand until the overall system stops changing how much `{commodity}_out` each technology is outputting. ```{figure} figures/slc_basic.png :width: 70% :align: center ``` -At a very basic level the SLC receives a demand. Based on that demand +The SLC control strategy and solver options are set within `plant_config.yaml` under the `"system_level_control"` section. + +```{yaml} +system_level_control: + control_strategy: DemandFollowingControl + solver_options: + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +``` + +To set the demand for the SLC that is configured in the `tech_config.yaml` using a demand block/component. For example: + +```{yaml} +electrical_load_demand: +performance_model: + model: GenericDemandComponent +model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 30000 +``` + +## Control Strategies +There are several simple control strategies already implemented in the SLC paradigm. While fairly simplistic, they are meant to illustrate how information can be passed from different blocks/components (converters, storage, feedstocks, demand, etc.) and models (performance, cost, finance) to use within the SLC. + +The current control strategies are: +1. [Demand Following](#slc-demand-following) +2. [Cost Minimization](#slc-cost-min) +3. [Profit Maximization](#slc-profit-max) + +```{note} +The strategies currently implemented are experimental and will likely require further development for specific analyses. +``` + +All control strategies inherit `SystemLevelControlBase`, which is a base class that has common setup logic shared by all system-level control strategies. + +See additional information, which is more developer focused, about the [`SystemLevelControlBase`](#slc-base). + +## Solver Options +The system attempts to converge the system using a solver. The solver is defined in `solver_options`. diff --git a/docs/control/system_level_control/system_level_control_base.md b/docs/control/system_level_control/system_level_control_base.md new file mode 100644 index 000000000..003ee07ad --- /dev/null +++ b/docs/control/system_level_control/system_level_control_base.md @@ -0,0 +1,2 @@ +(slc-base)= +# System Level Control Base Class diff --git a/docs/control/controller_demonstrations.md b/docs/control/technology_level_control/controller_demonstrations.md similarity index 94% rename from docs/control/controller_demonstrations.md rename to docs/control/technology_level_control/controller_demonstrations.md index 825ca8675..e6e42af58 100644 --- a/docs/control/controller_demonstrations.md +++ b/docs/control/technology_level_control/controller_demonstrations.md @@ -28,7 +28,7 @@ The following example is an expanded form of `examples/14_wind_hydrogen_dispatch Here, we're highlighting the dispatch controller setup from `examples/14_wind_hydrogen_dispatch/inputs/tech_config.yaml`. Please note some sections are removed simply to highlight the controller sections -```{literalinclude} ../../examples/14_wind_hydrogen_dispatch/inputs/tech_config.yaml +```{literalinclude} ../../../examples/14_wind_hydrogen_dispatch/inputs/tech_config.yaml :language: yaml :lineno-start: 52 :linenos: true @@ -37,7 +37,7 @@ Here, we're highlighting the dispatch controller setup from We also include a demand technology to calculate how much demand is met, how much commodity is unused to meet the demand, and how much demand is remaining: -```{literalinclude} ../../examples/14_wind_hydrogen_dispatch/inputs/tech_config.yaml +```{literalinclude} ../../../examples/14_wind_hydrogen_dispatch/inputs/tech_config.yaml :language: yaml :lineno-start: 79 :linenos: true diff --git a/docs/control/figures/Pyomo_dispatch_figure.png b/docs/control/technology_level_control/figures/Pyomo_dispatch_figure.png similarity index 100% rename from docs/control/figures/Pyomo_dispatch_figure.png rename to docs/control/technology_level_control/figures/Pyomo_dispatch_figure.png diff --git a/docs/control/figures/example_peak_load_dispatch.png b/docs/control/technology_level_control/figures/example_peak_load_dispatch.png similarity index 100% rename from docs/control/figures/example_peak_load_dispatch.png rename to docs/control/technology_level_control/figures/example_peak_load_dispatch.png diff --git a/docs/control/figures/plm_optimized_dispatch.png b/docs/control/technology_level_control/figures/plm_optimized_dispatch.png similarity index 100% rename from docs/control/figures/plm_optimized_dispatch.png rename to docs/control/technology_level_control/figures/plm_optimized_dispatch.png diff --git a/docs/control/open-loop_controllers.md b/docs/control/technology_level_control/open-loop_controllers.md similarity index 99% rename from docs/control/open-loop_controllers.md rename to docs/control/technology_level_control/open-loop_controllers.md index cc601a137..60b7f378d 100644 --- a/docs/control/open-loop_controllers.md +++ b/docs/control/technology_level_control/open-loop_controllers.md @@ -49,7 +49,7 @@ from pathlib import Path from IPython.display import HTML, display # Change to an example directory -os.chdir("../../examples/14_wind_hydrogen_dispatch/") +os.chdir("../../../examples/14_wind_hydrogen_dispatch/") # Build and set up the model h2i_model = H2IntegrateModel("inputs/h2i_wind_to_h2_storage.yaml") diff --git a/docs/control/pyomo_controllers.md b/docs/control/technology_level_control/pyomo_controllers.md similarity index 94% rename from docs/control/pyomo_controllers.md rename to docs/control/technology_level_control/pyomo_controllers.md index 09f93e5af..19ebfc9f4 100644 --- a/docs/control/pyomo_controllers.md +++ b/docs/control/technology_level_control/pyomo_controllers.md @@ -33,7 +33,7 @@ from pathlib import Path from IPython.display import HTML, display # Change to an example directory -os.chdir("../../examples/18_pyomo_heuristic_dispatch/") +os.chdir("../../../examples/18_pyomo_heuristic_dispatch/") # Build and set up the model h2i_model = H2IntegrateModel("pyomo_heuristic_dispatch.yaml") @@ -75,7 +75,7 @@ For an example of how to use the heuristic Pyomo control framework with the `Heu ## Optimized Load Following Controller The optimized dispatch method is specified by setting the storage control to `OptimizedDispatchStorageController`. Unlike the heuristic method, the optimized dispatch method does not use `dispatch_rule_set` as an input in the `tech_config`. The `OptimizedDispatchStorageController` method maximizes the load met while minimizing the cost of the system (operating cost) over each specified time window. -The optimized dispatch using Pyomo is implemented differently than the heuristic dispatch in order to be able to properly aggregate the individual Pyomo technology models into a cohesive Pyomo plant model for the optimization solver. Practically, this means that the Pyomo elements of the dispatch (including the individual technology models and the plant model) are not exposed to the main H2I code flow, and do not appear in the N2 diagram. The figure below shows a flow diagram of how the dispatch is implemented. The green blocks below represent what is represented in the N2 diagram of the system. The dispatch routine is currently self-contained within the storage technology of the system, though it includes solving an aggregated plant model in the optimization +The optimized dispatch using Pyomo is implemented differently than the heuristic dispatch in order to be able to properly aggregate the individual Pyomo technology models into a cohesive Pyomo plant model for the optimization solver. The Pyomo plant model is from the perspective of the storage technology and is meant to track inflows of commodities and other parameters that might impact the dispatch of the storage from upstream technologies. Practically, this means that the Pyomo elements of the dispatch (including the individual technology models and the plant model) are not exposed to the main H2I code flow, and do not appear in the N2 diagram. The figure below shows a flow diagram of how the dispatch is implemented. The green blocks below represent what is represented in the N2 diagram of the system. The dispatch routine is currently self-contained within the storage technology of the system, though it includes solving an aggregated plant model in the optimization ```{note} Only the PySAM battery performance model can call Pyomo dispatch at this time. ``` diff --git a/docs/control/control_overview.md b/docs/control/technology_level_control/technology_control_overview.md similarity index 71% rename from docs/control/control_overview.md rename to docs/control/technology_level_control/technology_control_overview.md index 1b18fd85b..d0016acea 100644 --- a/docs/control/control_overview.md +++ b/docs/control/technology_level_control/technology_control_overview.md @@ -1,6 +1,6 @@ -# Control Overview +# Technology-Level Control -There are two different systematic approaches, or frameworks, in H2Integrate for control: [open-loop](#open-loop-control) and [pyomo](#pyomo-control). These two frameworks are useful in different situations and have different impacts on the system and control strategies that can be implemented. Both control frameworks are focused on technology-level dispatching. The open-loop framework has logic that is applicable to both storage technologies and converter technologies and the pyomo framework is currently applicable to storage technologies. However, we plan to extend them to work more generally as system controllers. Although the controllers are not operating at the system-level for now, they behave somewhat like system controllers in that they may curtail/discard commodity amounts exceeding the needs of the storage technology and the specified demand. However, any unused commodity may be connected to another down-stream component to avoid actual curtailment. +There are two different systematic approaches, or frameworks, in H2Integrate for technology-level control: [open-loop](#open-loop-control) and [pyomo](#pyomo-control). These two frameworks are useful in different situations and have different impacts on the system and control strategies that can be implemented. Both control frameworks are focused on technology-level dispatching. The open-loop framework has logic that is applicable to both storage technologies and converter technologies **IS THIS STILL TRUE?** and the pyomo framework is currently applicable to storage technologies. The technology-level storage controllers may curtail/discard commodity amounts exceeding the needs of the storage technology and the specified demand. However, any unused commodity may be connected to another down-stream component to avoid actual curtailment. (open-loop-control-framework)= ## Open-loop control framework From 9beec63e822965015ba7835e1a08c8f873ffd05f Mon Sep 17 00:00:00 2001 From: kbrunik Date: Mon, 11 May 2026 14:38:12 -0500 Subject: [PATCH 081/105] more docs --- docs/_toc.yml | 14 ++++++++------ .../system_level_control/control_classifier.md | 4 ++++ docs/control/system_level_control/controllers.md | 15 +++++++++++++++ .../system_level_control/system_level_control.md | 4 ++-- 4 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 docs/control/system_level_control/controllers.md diff --git a/docs/_toc.yml b/docs/_toc.yml index e1255eaca..f7ac1f722 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -74,14 +74,16 @@ parts: sections: - file: control/system_level_control/system_level_control_base - file: control/system_level_control/control_classifier - - file: control/system_level_control/slc_demand_following - - file: control/system_level_control/slc_cost_min - - file: control/system_level_control/slc_profit_max + - file: control/system_level_control/controllers + sections: + - file: control/system_level_control/slc_demand_following + - file: control/system_level_control/slc_cost_min + - file: control/system_level_control/slc_profit_max - file: control/technology_level_control/technology_control_overview sections: - - file: control/open-loop_controllers - - file: control/pyomo_controllers - - file: control/controller_demonstrations + - file: control/technology_level_control/open-loop_controllers + - file: control/technology_level_control/pyomo_controllers + - file: control/technology_level_control/controller_demonstrations - caption: Demand chapters: - file: demand/demand_components diff --git a/docs/control/system_level_control/control_classifier.md b/docs/control/system_level_control/control_classifier.md index c5586b54c..246961029 100644 --- a/docs/control/system_level_control/control_classifier.md +++ b/docs/control/system_level_control/control_classifier.md @@ -13,6 +13,7 @@ Classifier | Meaning | Example Techs curtailable | Produces based on resource or input commodity; can only be reduced | wind, solar, nuclear dispatchable | Can modulate consumption/production within bounds | grid, NG turbine storage | Can modulate consumption/production within bounds while tracking SOC; does not produce/consume energy | battery, h2 storage, any storage +feedstock | Can't be controlled but system knows how much is available | NG from a pipe To add a classifier for a particular model it would look something like this in the class: ```{python} @@ -57,3 +58,6 @@ The system-level controller outputs set points to the storage performance model :width: 85% :align: center ``` + +## Feedstock +Another category of control classifiers are feedstocks. The unique thing about feedstocks is that they are considered outside of the controllable system within H2I. While they can't be controlled it can be helpful for controllers to know how much feedstock is available within the system, hence their classification. diff --git a/docs/control/system_level_control/controllers.md b/docs/control/system_level_control/controllers.md new file mode 100644 index 000000000..c0b91b9e5 --- /dev/null +++ b/docs/control/system_level_control/controllers.md @@ -0,0 +1,15 @@ +# Control Strategies +There are several simple control strategies already implemented in the SLC paradigm. While fairly simplistic, they are meant to illustrate how information can be passed from different blocks/components (converters, storage, feedstocks, demand, etc.) and models (performance, cost, finance) to use within the SLC. + +The current control strategies are: +1. [Demand Following](#slc-demand-following) +2. [Cost Minimization](#slc-cost-min) +3. [Profit Maximization](#slc-profit-max) + +```{note} +The strategies currently implemented are experimental and will likely require further development for specific analyses. +``` + +All control strategies inherit `SystemLevelControlBase`, which is a base class that has common setup logic shared by all system-level control strategies. + +See additional information, which is more developer focused, about the [`SystemLevelControlBase`](#slc-base). diff --git a/docs/control/system_level_control/system_level_control.md b/docs/control/system_level_control/system_level_control.md index 5ebbd3838..9e8f89b4f 100644 --- a/docs/control/system_level_control/system_level_control.md +++ b/docs/control/system_level_control/system_level_control.md @@ -11,7 +11,7 @@ The most basic SLC is shown in the figured below, where the SLC receives a deman The SLC control strategy and solver options are set within `plant_config.yaml` under the `"system_level_control"` section. -```{yaml} +```yaml system_level_control: control_strategy: DemandFollowingControl solver_options: @@ -22,7 +22,7 @@ system_level_control: To set the demand for the SLC that is configured in the `tech_config.yaml` using a demand block/component. For example: -```{yaml} +```yaml electrical_load_demand: performance_model: model: GenericDemandComponent From f76330235083065d5075fd42009711a42d5f1498 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 11 May 2026 14:54:42 -0600 Subject: [PATCH 082/105] updated no_battery example --- .../no_battery/plant_config.yaml | 14 +++++++------- .../no_battery/tech_config.yaml | 7 ------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/examples/35_system_level_control/no_battery/plant_config.yaml b/examples/35_system_level_control/no_battery/plant_config.yaml index c5eca242c..010794a6e 100644 --- a/examples/35_system_level_control/no_battery/plant_config.yaml +++ b/examples/35_system_level_control/no_battery/plant_config.yaml @@ -14,14 +14,14 @@ sites: # with the reverse definition. # this will naturally grow as we mature the interconnected tech technology_interconnections: - - [wind, combiner, electricity, cable] # source_tech, dest_tech, transport_item, transport_type = connection - - [ng_feedstock, natural_gas_plant, natural_gas, pipe] # connect NG feedstock to NG plant + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] + # combine the electricity and natural gas production + - [wind, combiner, electricity, cable] + - [natural_gas_plant, combiner, electricity, cable] + # subtract the combined electricity production from the demand - [combiner, electrical_load_demand, electricity, cable] - # subtract wind from demand - - [combiner, fin_combiner, electricity, cable] - - [natural_gas_plant, fin_combiner, electricity, cable] resource_to_tech_connections: # connect the wind resource to the wind technology - [site.wind_resource, wind, wind_resource_data] @@ -86,7 +86,7 @@ finance_parameters: finance_subgroups: renewables: commodity: electricity - commodity_stream: combiner + commodity_stream: wind finance_groups: [profast_lco, profast_npv] technologies: [wind] natural_gas: @@ -96,7 +96,7 @@ finance_parameters: technologies: [natural_gas_plant, ng_feedstock] electricity: commodity: electricity - commodity_stream: fin_combiner + commodity_stream: combiner finance_groups: [profast_lco] technologies: [wind, natural_gas_plant, ng_feedstock] cost_adjustment_parameters: diff --git a/examples/35_system_level_control/no_battery/tech_config.yaml b/examples/35_system_level_control/no_battery/tech_config.yaml index 10c0959c6..fa4881fbd 100644 --- a/examples/35_system_level_control/no_battery/tech_config.yaml +++ b/examples/35_system_level_control/no_battery/tech_config.yaml @@ -77,10 +77,3 @@ technologies: performance_parameters: commodity: electricity commodity_rate_units: kW - fin_combiner: - performance_model: - model: GenericCombinerPerformanceModel - model_inputs: - performance_parameters: - commodity: electricity - commodity_rate_units: kW From 12f0b1115603af5efc98ba928883f1031148c10a Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 11 May 2026 14:59:46 -0600 Subject: [PATCH 083/105] cleaned up other slc examples --- .../battery_with_controller/plant_config.yaml | 16 ++++++++-------- .../battery_with_controller/tech_config.yaml | 2 +- .../yes_battery/plant_config.yaml | 14 +++++++------- .../yes_battery/tech_config.yaml | 2 +- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/examples/35_system_level_control/battery_with_controller/plant_config.yaml b/examples/35_system_level_control/battery_with_controller/plant_config.yaml index a2967e397..24e0aeb39 100644 --- a/examples/35_system_level_control/battery_with_controller/plant_config.yaml +++ b/examples/35_system_level_control/battery_with_controller/plant_config.yaml @@ -14,18 +14,18 @@ sites: # with the reverse definition. # this will naturally grow as we mature the interconnected tech technology_interconnections: - - [ng_feedstock, natural_gas_plant, natural_gas, pipe] # connect NG feedstock to NG plant - - [wind, battery, electricity, cable] + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] # wind output available for battery charging (electricity_in) - - [wind, fin_combiner, electricity, cable] + - [wind, battery, electricity, cable] # wind to combined output - - [battery, fin_combiner, electricity, cable] - # battery net output to combined output - - [natural_gas_plant, fin_combiner, electricity, cable] + - [wind, combiner, electricity, cable] + # battery output to combined output + - [battery, combiner, electricity, cable] # NG to combined output - - [fin_combiner, electrical_load_demand, electricity, cable] + - [natural_gas_plant, combiner, electricity, cable] # combined supply to demand + - [combiner, electrical_load_demand, electricity, cable] resource_to_tech_connections: # connect the wind resource to the wind technology - [site.wind_resource, wind, wind_resource_data] @@ -100,7 +100,7 @@ finance_parameters: technologies: [natural_gas_plant, ng_feedstock] electricity: commodity: electricity - commodity_stream: fin_combiner + commodity_stream: combiner finance_groups: [profast_lco] technologies: [wind, battery, natural_gas_plant, ng_feedstock] cost_adjustment_parameters: diff --git a/examples/35_system_level_control/battery_with_controller/tech_config.yaml b/examples/35_system_level_control/battery_with_controller/tech_config.yaml index 21075c502..20d6e1f8a 100644 --- a/examples/35_system_level_control/battery_with_controller/tech_config.yaml +++ b/examples/35_system_level_control/battery_with_controller/tech_config.yaml @@ -97,7 +97,7 @@ technologies: capacity_capex: 310 # $/kWh charge_capex: 311 # $/kW opex_fraction: 0.025 - fin_combiner: + combiner: performance_model: model: GenericCombinerPerformanceModel model_inputs: diff --git a/examples/35_system_level_control/yes_battery/plant_config.yaml b/examples/35_system_level_control/yes_battery/plant_config.yaml index a2967e397..d53a525ca 100644 --- a/examples/35_system_level_control/yes_battery/plant_config.yaml +++ b/examples/35_system_level_control/yes_battery/plant_config.yaml @@ -14,18 +14,18 @@ sites: # with the reverse definition. # this will naturally grow as we mature the interconnected tech technology_interconnections: - - [ng_feedstock, natural_gas_plant, natural_gas, pipe] # connect NG feedstock to NG plant - - [wind, battery, electricity, cable] + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] # wind output available for battery charging (electricity_in) - - [wind, fin_combiner, electricity, cable] + - [wind, battery, electricity, cable] # wind to combined output - - [battery, fin_combiner, electricity, cable] + - [wind, combiner, electricity, cable] # battery net output to combined output - - [natural_gas_plant, fin_combiner, electricity, cable] + - [battery, combiner, electricity, cable] # NG to combined output - - [fin_combiner, electrical_load_demand, electricity, cable] + - [natural_gas_plant, combiner, electricity, cable] # combined supply to demand + - [combiner, electrical_load_demand, electricity, cable] resource_to_tech_connections: # connect the wind resource to the wind technology - [site.wind_resource, wind, wind_resource_data] @@ -100,7 +100,7 @@ finance_parameters: technologies: [natural_gas_plant, ng_feedstock] electricity: commodity: electricity - commodity_stream: fin_combiner + commodity_stream: combiner finance_groups: [profast_lco] technologies: [wind, battery, natural_gas_plant, ng_feedstock] cost_adjustment_parameters: diff --git a/examples/35_system_level_control/yes_battery/tech_config.yaml b/examples/35_system_level_control/yes_battery/tech_config.yaml index acda181e3..258e95da2 100644 --- a/examples/35_system_level_control/yes_battery/tech_config.yaml +++ b/examples/35_system_level_control/yes_battery/tech_config.yaml @@ -92,7 +92,7 @@ technologies: capacity_capex: 310 # $/kWh charge_capex: 311 # $/kW opex_fraction: 0.025 - fin_combiner: + combiner: performance_model: model: GenericCombinerPerformanceModel model_inputs: From 6af23971a2f39122f57a99eaa8bfeb4ff7a1d91f Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 11 May 2026 15:02:18 -0600 Subject: [PATCH 084/105] removed SLC baseconfig since its unused --- .../system_level/system_level_control_base.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 20ffe56b7..1c7ba6dc5 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -1,14 +1,6 @@ import numpy as np import networkx as nx import openmdao.api as om -from attrs import field, define - -from h2integrate.core.utilities import BaseConfig - - -@define(kw_only=True) -class SystemLevelControlBaseConfig(BaseConfig): - demand_tech: str | None = field(default=None) class SystemLevelControlBase(om.ExplicitComponent): From dee192513703158421f8ced3d3a3759cff2ca7b4 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 11 May 2026 15:32:47 -0600 Subject: [PATCH 085/105] updated docstring in baseclass --- .../system_level/system_level_control_base.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 1c7ba6dc5..e7e5ad05f 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -8,19 +8,27 @@ class SystemLevelControlBase(om.ExplicitComponent): Provides common setup logic shared by all system-level control strategies: demand input, curtailable/dispatchable/storage technology I/O creation, - and technology classification reading from ``plant_config``. + and technology classification reading from ``plant_config`` and ``slc_config`` Subclasses must implement ``compute()`` with their dispatch strategy. - Configuration is read from ``plant_config["system_level_control"]``, + Information passed to the controller from H2IntegrateModel is input in the ``slc_config``, which must contain: - - ``commodity``: the commodity being controlled (e.g. "electricity") - - ``commodity_units``: units string (or None) + - ``demand_commodity``: the commodity being controlled (e.g. "electricity") + - ``demand_commodity_rate_units``: units string (or None) of the demand commodity - ``demand_tech``: name of the demand technology - - ``curtailable_techs``: list of curtailable technology names - - ``dispatchable_techs``: list of dispatchable technology names - - ``storage_techs``: list of storage technology names + - ``storage_techs_to_control``: dictionary with keys of the technology names. The value is True + if the technology is classified as "storage" and has an attached controller. + Otherwise the value is False. + - ``technology_graph``: directional graph object representation of the + technology_interconnections found in the ``plant_config`` + - ``tech_to_commodity``: set of tuples formatted as (tech_name, tech_output_commodity) + - ``tech_control_classifiers``: dictionary of technologies with keys as the technology names the + value as the corresponding control classifier + + Controller-specific configuration parameters may be read from + ``plant_config["system_level_control"]["control_parameters"]`` """ def initialize(self): From 37be44e12114ed578917c1538a105e95cf20d079 Mon Sep 17 00:00:00 2001 From: kbrunik Date: Mon, 11 May 2026 16:41:10 -0500 Subject: [PATCH 086/105] docs added --- .../control_classifier.md | 4 +- .../system_level_control/slc_cost_min.md | 2 + .../slc_demand_following.md | 6 +++ .../system_level_control/slc_profit_max.md | 2 + .../system_level_control.md | 2 +- .../system_level_control_base.md | 37 +++++++++++++++++++ 6 files changed, 50 insertions(+), 3 deletions(-) diff --git a/docs/control/system_level_control/control_classifier.md b/docs/control/system_level_control/control_classifier.md index 246961029..51047c007 100644 --- a/docs/control/system_level_control/control_classifier.md +++ b/docs/control/system_level_control/control_classifier.md @@ -6,14 +6,14 @@ To enable a generic system level control framework we need to classify each tech While in real life there are a lot of controllable parameters allowing for ramping production up or down for a particular technology (e.g., turbine yaw). The particular model in H2I might not be capable of simulating a modulated response based on an input signal. ``` -We have identified three key classifiers that are able to represent the different behaviors that we can expect from the model. Each performance model includes a parameter setting the classifier `_control_classifier`. +We have identified four key classifiers that are able to represent the different behaviors that we can expect from the models. Each performance model includes a parameter setting the classifier `_control_classifier`. Classifier | Meaning | Example Techs -- | -- | -- curtailable | Produces based on resource or input commodity; can only be reduced | wind, solar, nuclear dispatchable | Can modulate consumption/production within bounds | grid, NG turbine storage | Can modulate consumption/production within bounds while tracking SOC; does not produce/consume energy | battery, h2 storage, any storage -feedstock | Can't be controlled but system knows how much is available | NG from a pipe +feedstock | Can't be controlled but system knows how much is available | NG or water from feedstock To add a classifier for a particular model it would look something like this in the class: ```{python} diff --git a/docs/control/system_level_control/slc_cost_min.md b/docs/control/system_level_control/slc_cost_min.md index f24570590..1212a39ef 100644 --- a/docs/control/system_level_control/slc_cost_min.md +++ b/docs/control/system_level_control/slc_cost_min.md @@ -1,2 +1,4 @@ (slc-cost-min)= # Cost Minimization System Level Controller + +## Limitations diff --git a/docs/control/system_level_control/slc_demand_following.md b/docs/control/system_level_control/slc_demand_following.md index 72a05dcf4..969d170c1 100644 --- a/docs/control/system_level_control/slc_demand_following.md +++ b/docs/control/system_level_control/slc_demand_following.md @@ -1,2 +1,8 @@ (slc-demand-following)= # Demand Following System Level Controller + +## Inputs and Outputs + +## Heterogenous Systems + +## Limitations diff --git a/docs/control/system_level_control/slc_profit_max.md b/docs/control/system_level_control/slc_profit_max.md index 42ac80f14..e8659f8c5 100644 --- a/docs/control/system_level_control/slc_profit_max.md +++ b/docs/control/system_level_control/slc_profit_max.md @@ -1,2 +1,4 @@ (slc-profit-max)= # Profit Maximization System Level Controller + +## Limitations diff --git a/docs/control/system_level_control/system_level_control.md b/docs/control/system_level_control/system_level_control.md index 9e8f89b4f..9c2b61509 100644 --- a/docs/control/system_level_control/system_level_control.md +++ b/docs/control/system_level_control/system_level_control.md @@ -45,7 +45,7 @@ The current control strategies are: The strategies currently implemented are experimental and will likely require further development for specific analyses. ``` -All control strategies inherit `SystemLevelControlBase`, which is a base class that has common setup logic shared by all system-level control strategies. +All control strategies inherit [`SystemLevelControlBase`](#slc-base), which is a base class that has common setup logic shared by all system-level control strategies. See additional information, which is more developer focused, about the [`SystemLevelControlBase`](#slc-base). diff --git a/docs/control/system_level_control/system_level_control_base.md b/docs/control/system_level_control/system_level_control_base.md index 003ee07ad..ad2acf592 100644 --- a/docs/control/system_level_control/system_level_control_base.md +++ b/docs/control/system_level_control/system_level_control_base.md @@ -1,2 +1,39 @@ (slc-base)= # System Level Control Base Class + +The system-level control base class provides a common framework that all controllers (advanced control strategies) can use to configure required inputs and outputs for both the controllers and the components they control or track. This generalization is necessary to implement system-level control in H2I. If the technologies and controllers in a given system were fully specified, this base class would not be needed. + +The base class also abstracts logic that may be shared across different controller types. It includes methods that could be useful, but not all methods will be relevant to every controller you implement. + +There are several methods that are already used in the simple controllers that inherit these system. + +Setup I/O for SLC controllers. +- `initialize()` +- `setup()` +- `_setup_commodity()` +- `_setup_tech_category()` +- `_setup_feedstock_category()` +- `find_converter_techs()` + - Note: this currently is not implemented but will be used for heterogeneous commodity systems. + +Functions for controlling components based on assigned control classifier. +- `_subtract_curtailable()` +- `_dispatch_storage()` +- `get_upstream_techs_for_commodity()` + +Helper functions for cost-aware controllers. +- `_setup_marginal_costs()` +- `_compute_marginal_costs()` +- `_buy_price_marginal_cost()` +- `_varopex_marginal_cost()` +- `_find_feedstock_techs()` +- `_feedstock_marginal_cost()` + +## Base Class and Methods + +```{eval-rst} +.. autoclass:: h2integrate.control.control_strategies.system_level.system_level_control_base.SystemLevelControlBase + :members: + :undoc-members: + :show-inheritance: +``` From 416dc5dc9598d220f6b76b47ba2cb019ec805762 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 11 May 2026 16:42:22 -0600 Subject: [PATCH 087/105] minor update to demand following and updated profast to handle zero cf --- .../yes_hydrogen/plant_config.yaml | 96 +++++++++---------- .../system_level/demand_following_control.py | 19 ++-- .../system_level/test/test_slc_examples.py | 8 ++ h2integrate/finances/profast_lco.py | 13 +++ 4 files changed, 82 insertions(+), 54 deletions(-) diff --git a/examples/35_system_level_control/yes_hydrogen/plant_config.yaml b/examples/35_system_level_control/yes_hydrogen/plant_config.yaml index ce6872101..9244ce3fb 100644 --- a/examples/35_system_level_control/yes_hydrogen/plant_config.yaml +++ b/examples/35_system_level_control/yes_hydrogen/plant_config.yaml @@ -47,51 +47,51 @@ system_level_control: solver_name: gauss_seidel max_iter: 20 convergence_tolerance: 1.0e-6 -# finance_parameters: -# finance_groups: -# profast_lco: -# finance_model: ProFastLCO -# model_inputs: -# params: -# analysis_start_year: 2032 -# installation_time: 36 # months -# inflation_rate: 0.0 # 0 for nominal analysis -# discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind -# debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind -# property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx -# total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) -# capital_gains_tax_rate: 0.15 # H2FAST default -# sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ -# debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind -# debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH -# loan_period_if_used: 0 # H2FAST default, not used for revolving debt -# cash_onhand_months: 1 # H2FAST default -# admin_expense: 0.00 # percent of sales H2FAST default -# capital_items: -# depr_type: MACRS # can be "MACRS" or "Straight line" -# depr_period: 5 # 5 years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 -# refurb: [0.] -# finance_subgroups: -# # renewables: -# # commodity: electricity -# # commodity_stream: wind -# # finance_groups: [profast_lco] -# # technologies: [wind] -# # natural_gas: -# # commodity: electricity -# # commodity_stream: natural_gas_plant -# # finance_groups: [profast_lco] -# # technologies: [natural_gas_plant, ng_feedstock] -# electricity: -# commodity: electricity -# commodity_stream: elec_combiner -# finance_groups: [profast_lco] -# technologies: [wind, battery, natural_gas_plant, ng_feedstock] -# # hydrogen: -# # commodity: hydrogen -# # commodity_stream: h2_combiner -# # finance_groups: [profast_lco] -# # technologies: [wind, battery, natural_gas_plant, ng_feedstock, electrolyzer, h2_storage] -# cost_adjustment_parameters: -# cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year -# target_dollar_year: 2022 +finance_parameters: + finance_groups: + profast_lco: + finance_model: ProFastLCO + model_inputs: + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + depr_period: 5 # 5 years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + refurb: [0.] + finance_subgroups: + renewables: + commodity: electricity + commodity_stream: wind + finance_groups: [profast_lco] + technologies: [wind] + natural_gas: + commodity: electricity + commodity_stream: natural_gas_plant + finance_groups: [profast_lco] + technologies: [natural_gas_plant, ng_feedstock] + electricity: + commodity: electricity + commodity_stream: elec_combiner + finance_groups: [profast_lco] + technologies: [wind, battery, natural_gas_plant, ng_feedstock] + hydrogen: + commodity: hydrogen + commodity_stream: h2_combiner + finance_groups: [profast_lco] + technologies: [wind, battery, natural_gas_plant, ng_feedstock, electrolyzer, h2_storage] + cost_adjustment_parameters: + cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year + target_dollar_year: 2022 diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 815b95099..7821f8d84 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -36,12 +36,19 @@ def compute(self, inputs, outputs): for curtailable_tech in self.curtailable_techs: commodity_from_tech = self._get_commodity_for_tech(curtailable_tech) # check that this tech produces the commodity demanded - if commodity in commodity_from_tech: - # if the commodity produced from a tech is the demanded commodity - # then subtract the curtailable production from the demand - demand = self._subtract_curtailable( - curtailable_tech, demand, commodity, inputs, outputs - ) + for tech_commodity in commodity_from_tech: + if tech_commodity == commodity: + # if the commodity produced from a tech is the demanded commodity + # then subtract the curtailable production from the demand + demand = self._subtract_curtailable( + curtailable_tech, demand, commodity, inputs, outputs + ) + else: + if f"{curtailable_tech}_rated_{tech_commodity}_production" in inputs: + # set the set-point as the rated production + outputs[f"{curtailable_tech}_{tech_commodity}_set_point"] = inputs[ + f"{curtailable_tech}_rated_{tech_commodity}_production" + ] * np.ones(self.n_timesteps) # 2. Storage dispatch # number of storage components that produce the demanded commodity diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index ccdf5dc37..c85352ef5 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -98,6 +98,14 @@ def test_slc_yes_hydrogen(subtests, temp_copy_of_example): with subtests.test("wind farm generates power"): assert wind_out.sum() > 0 + with subtests.test("LCOH"): + assert ( + pytest.approx( + model.prob.get_val("finance_subgroup_hydrogen.LCOH", units="USD/kg"), rel=1e-6 + ) + == 14.878096642042243 + ) + @pytest.mark.unit @pytest.mark.parametrize( diff --git a/h2integrate/finances/profast_lco.py b/h2integrate/finances/profast_lco.py index 566b393d3..ab353ffe3 100644 --- a/h2integrate/finances/profast_lco.py +++ b/h2integrate/finances/profast_lco.py @@ -1,3 +1,4 @@ +import warnings from pathlib import Path import numpy as np @@ -106,6 +107,18 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): """ pf = self.populate_profast(inputs) + if "system_level_control" in self.options["plant_config"] and np.all( + inputs["capacity_factor"] == 0.0 + ): + outputs[self.LCO_str] = 1e12 + msg = ( + f"Commodity stream for finance group has a zero capacity factor. " + "If you recieve this warning multiple times, there may be a problem " + "with your setup. ProFAST is not being run on this iteration and the " + f"{self.LCO_str} is being set to default value of 1e12 ({self.price_units})" + ) + warnings.warn(msg, UserWarning) + return # simulate ProFAST sol, summary, price_breakdown = run_profast(pf) From 7deabecbad33a44225f8054ac96b02d4c1964611 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 11 May 2026 16:43:13 -0600 Subject: [PATCH 088/105] added start of demand following docs --- .../slc_demand_following.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/control/system_level_control/slc_demand_following.md b/docs/control/system_level_control/slc_demand_following.md index 72a05dcf4..01d0e5b3c 100644 --- a/docs/control/system_level_control/slc_demand_following.md +++ b/docs/control/system_level_control/slc_demand_following.md @@ -1,2 +1,33 @@ (slc-demand-following)= # Demand Following System Level Controller + +The demand following controller, `DemandFollowingControl`, aims to fully meet the demand and does not have any inputs related to cost. + +## Inputs + +The inputs for technologies classified as `curtailable`, `dispatchable`, and `storage` are: + +- `f"{tech_name}_{tech_output_commodity}_out"` +- `f"{tech_name}_rated_{tech_output_commodity}_production"` + +The inputs for technologies classified as `feedstock` are: +- `f"{tech_name}_{commodity}_out"` + +## Outputs +The outputs for technologies classified as `curtailable`, `dispatchable`, or `storage` and *without a storage controller* are: +- `f"{tech_name}_{tech_output_commodity}_set_point"` + +The outputs for technologies classified as `storage` that *have a storage controller* are: +- `f"{tech_name}_{tech_output_commodity}_demand"` + +## Heterogenous Systems + + +## Limitations + + +## General Logic + +First, control logic is as follows: +- For every technology classified as "curtailable", set the set-point as the rated commodity production of that technology. Subtract the commodity produced by the technology from the overall demand profile +- The remaining demand profile will be negative when the curtailable technologies produce more commodity than demanded and positive when the curtailable technologies produce less commodity than demanded. The remaining demand profile is divided by the number of storage technologies in the system to get the set point for each storage technology. This set point is negative to command the storage to charge, and positive to command the storage to discharge. From b6003fe8946c871b29f1b37a0428fc87ba6918c7 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Mon, 11 May 2026 22:48:44 -0600 Subject: [PATCH 089/105] Adding to SLC docs --- .../system_level_control/slc_cost_min.md | 68 ++++++++++ .../slc_demand_following.md | 61 ++++++++- .../system_level_control/slc_profit_max.md | 117 ++++++++++++++++++ 3 files changed, 245 insertions(+), 1 deletion(-) diff --git a/docs/control/system_level_control/slc_cost_min.md b/docs/control/system_level_control/slc_cost_min.md index 1212a39ef..40e304523 100644 --- a/docs/control/system_level_control/slc_cost_min.md +++ b/docs/control/system_level_control/slc_cost_min.md @@ -1,4 +1,72 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.18.1 +kernelspec: + display_name: Python 3.11.13 ('h2i_env') + language: python + name: python3 +--- + (slc-cost-min)= # Cost Minimization System Level Controller +The cost minimization controller, `CostMinimizationControl`, meets demand at minimum variable cost using merit-order dispatch. +Unlike the {ref}`demand following controller `, which splits demand evenly across dispatchable technologies, this controller dispatches the cheapest technologies first. + +## Dispatch Logic + +The controller follows a three-step dispatch process: + +1. **Curtailable technologies** run at their full rated capacity (assumed zero marginal cost). Their output is subtracted from the demand. +2. **Storage technologies** absorb any surplus (charging) or provide the deficit (discharging). Residual demand is split evenly across storage technologies producing the demanded commodity. +3. **Dispatchable technologies** are dispatched by cheapest marginal cost first, each up to its rated capacity, until the remaining demand is met. + +## Marginal Cost Configuration + +Marginal costs are specified per dispatchable technology in the `cost_per_tech` dictionary under `system_level_control.control_parameters` in the plant config. Each entry can be: + +| Value | Description | +| --- | --- | +| Numeric (e.g. `0.05`) | Constant marginal cost in `$/(commodity_unit*h)` | +| `"buy_price"` | Uses the technology's configured purchase price | +| `"VarOpEx"` | Derives marginal cost from the technology's variable operating expenditure divided by total production | +| `"feedstock"` | Sums upstream feedstock `VarOpEx` values and divides by the technology's total production | + +```{note} +The dispatch order is determined by sorting dispatchable technologies by their **mean** marginal cost across all timesteps (cheapest first). +``` + +### Example Configuration + +```yaml +system_level_control: + control_strategy: CostMinimizationControl + control_parameters: + cost_per_tech: + natural_gas_plant: feedstock +``` + +## Inputs and Outputs + +In addition to the standard inputs inherited from `SystemLevelControlBase`, the cost minimization controller adds marginal cost inputs based on the `cost_per_tech` configuration (see above). + +The base inputs for technologies classified as `curtailable`, `dispatchable`, and `storage` are: + +- `f"{tech_name}_{tech_output_commodity}_out"` +- `f"{tech_name}_rated_{tech_output_commodity}_production"` + +The outputs for `curtailable`, `dispatchable`, or `storage` technologies *without* a storage controller are: +- `f"{tech_name}_{tech_output_commodity}_set_point"` + +The outputs for `storage` technologies *with* a storage controller are: +- `f"{tech_name}_{tech_output_commodity}_demand"` + ## Limitations + +- Greedy dispatch: The merit-order approach is greedy - it does not look ahead across timesteps to optimize total cost over the simulation horizon. +- Even splitting across storage: Residual demand is split evenly across storage technologies regardless of capacity or state of charge. +- Demand is always met: Unlike the {ref}`profit maximization controller `, this controller always attempts to meet demand regardless of cost. diff --git a/docs/control/system_level_control/slc_demand_following.md b/docs/control/system_level_control/slc_demand_following.md index acca39b00..1e58a9e49 100644 --- a/docs/control/system_level_control/slc_demand_following.md +++ b/docs/control/system_level_control/slc_demand_following.md @@ -1,8 +1,59 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.18.1 +kernelspec: + display_name: Python 3.11.13 ('h2i_env') + language: python + name: python3 +--- + (slc-demand-following)= # Demand Following System Level Controller The demand following controller, `DemandFollowingControl`, aims to fully meet the demand and does not have any inputs related to cost. +The N2 diagram below shows an example system using the demand following controller with wind, natural gas, and battery storage technologies. + +```{code-cell} ipython3 +:tags: [remove-input] + +from h2integrate.core.h2integrate_model import H2IntegrateModel +import openmdao.api as om +import os + +import html +from pathlib import Path +from IPython.display import HTML, display + +os.chdir("../../../examples/35_system_level_control/battery_with_controller/") + +h2i_model = H2IntegrateModel("wind_ng_demand.yaml") +h2i_model.setup() + +om.n2( + h2i_model.prob, + outfile="h2i_n2.html", + display_in_notebook=False, + show_browser=False, +) + +n2_html = "h2i_n2.html" +n2_srcdoc = html.escape(Path(n2_html).read_text(encoding="utf-8")) +display( + HTML( + f'
' + f'' + '
' + ) +) +``` + ## Inputs and Outputs The inputs for technologies classified as `curtailable`, `dispatchable`, and `storage` are: @@ -20,11 +71,19 @@ The outputs for technologies classified as `curtailable`, `dispatchable`, or `st The outputs for technologies classified as `storage` that *have a storage controller* are: - `f"{tech_name}_{tech_output_commodity}_demand"` -## Heterogenous Systems +## Systems with Heterogeneous Commodities + +The `DemandFollowingControl` controller can be used in hybrid systems where technologies produce different commodities. +For example, in a system where an electrolyzer produces hydrogen and the demand commodity is hydrogen, the controller can set the electricity-generating technologies' set-points to meet the hydrogen demand. +This framework provides a starting point for hybrid energy system control but is intended to be extended with more sophisticated strategies for complex multi-commodity systems. ## Limitations +- No cost awareness: The controller dispatches technologies purely to meet demand without considering operational costs, commodity prices, or economic optimization. +- Even splitting across storage: When multiple storage technologies produce the demanded commodity, the residual demand is divided evenly among them (`demand / n_storage`), regardless of differences in capacity, state of charge, or efficiency. +- Even splitting across dispatchable technologies: Similarly, any remaining demand after storage dispatch is split evenly across all dispatchable technologies (`remaining_demand / n_dispatchable`), without accounting for marginal costs or capacity constraints. +- Fixed priority order: The dispatch order (curtailable → storage → dispatchable) is fixed in the current implementation. ## General Logic diff --git a/docs/control/system_level_control/slc_profit_max.md b/docs/control/system_level_control/slc_profit_max.md index e8659f8c5..c31b77aa4 100644 --- a/docs/control/system_level_control/slc_profit_max.md +++ b/docs/control/system_level_control/slc_profit_max.md @@ -1,4 +1,121 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.18.1 +kernelspec: + display_name: Python 3.11.13 ('h2i_env') + language: python + name: python3 +--- + (slc-profit-max)= # Profit Maximization System Level Controller +The profit maximization controller, `ProfitMaximizationControl`, dispatches technologies only when the revenue from selling the commodity exceeds the marginal cost of production. This means demand may go **unmet** if dispatch is unprofitable. + +The N2 diagram below shows an example system using the profit maximization controller with wind, natural gas, and battery storage technologies. + +```{code-cell} ipython3 +:tags: [remove-input] + +from h2integrate.core.h2integrate_model import H2IntegrateModel +import openmdao.api as om +import os + +import html +from pathlib import Path +from IPython.display import HTML, display + +os.chdir("../../../examples/35_system_level_control/profit_maximization/") + +h2i_model = H2IntegrateModel("wind_ng_demand.yaml") +h2i_model.setup() + +om.n2( + h2i_model.prob, + outfile="h2i_n2.html", + display_in_notebook=False, + show_browser=False, +) + +n2_html = "h2i_n2.html" +n2_srcdoc = html.escape(Path(n2_html).read_text(encoding="utf-8")) +display( + HTML( + f'
' + f'' + '
' + ) +) +``` + +## Dispatch Logic + +The controller follows a three-step dispatch process: + +1. **Curtailable technologies** run at full rated capacity - they are always profitable to produce (zero marginal cost). +2. **Storage technologies** absorb any surplus (charging) or provide the deficit (discharging), split evenly across storage technologies producing the demanded commodity. +3. **Dispatchable technologies** are dispatched in merit order (cheapest first), but **only at timesteps where their marginal cost is below the sell price**. At each timestep, the dispatch is the minimum of the remaining demand and the rated capacity, gated by the profitability check. + +```{note} +This is the key difference from the {ref}`cost minimization controller `: unprofitable dispatch is skipped entirely, so demand may go unmet. +``` + +## Commodity Sell Price + +The sell price can be configured in two ways in `system_level_control.control_parameters`: + +| Value | Description | +| --- | --- | +| Numeric (e.g. `0.06`) | Constant sell price in `$/(commodity_unit*h)` | +| String (e.g. `"profast_npv"`) | Name of a finance group in `finance_parameters.finance_groups` whose `model_inputs.commodity_sell_price` will be used | + +## Marginal Cost Configuration + +Marginal costs are configured identically to the {ref}`cost minimization controller ` via `cost_per_tech`. Each dispatchable technology's entry can be: + +| Value | Description | +| --- | --- | +| Numeric (e.g. `0.05`) | Constant marginal cost in `$/(commodity_unit*h)` | +| `"buy_price"` | Uses the technology's configured purchase price | +| `"VarOpEx"` | Derives cost from VarOpEx / total production | +| `"feedstock"` | Sums upstream feedstock VarOpEx / total production | + +### Example Configuration + +```yaml +system_level_control: + control_strategy: ProfitMaximizationControl + control_parameters: + commodity_sell_price: profast_npv # look up from finance group + cost_per_tech: + natural_gas_plant: feedstock # use upstream feedstock VarOpEx +``` + +## Inputs and Outputs + +In addition to the standard inputs inherited from `SystemLevelControlBase`, this controller adds: + +- `commodity_sell_price` - the sell price per unit of the demanded commodity, shape `(n_timesteps,)` +- Marginal cost inputs per dispatchable technology based on `cost_per_tech` configuration + +The base inputs for technologies classified as `curtailable`, `dispatchable`, and `storage` are: + +- `f"{tech_name}_{tech_output_commodity}_out"` +- `f"{tech_name}_rated_{tech_output_commodity}_production"` + +The outputs for `curtailable`, `dispatchable`, or `storage` technologies *without* a storage controller are: +- `f"{tech_name}_{tech_output_commodity}_set_point"` + +The outputs for `storage` technologies *with* a storage controller are: +- `f"{tech_name}_{tech_output_commodity}_demand"` + ## Limitations + +- Demand may go unmet: If no dispatchable technology is profitable at a given timestep, the remaining demand is not served. +- Even splitting across storage: Residual demand is split evenly across storage technologies regardless of capacity or state of charge. From 2b4f83503216c7ae1840dec168f48a0bbbe9d63a Mon Sep 17 00:00:00 2001 From: kbrunik Date: Tue, 12 May 2026 08:58:10 -0500 Subject: [PATCH 090/105] small doc mods --- docs/_toc.yml | 2 +- .../system_level_control/controllers.md | 4 +-- .../slc_demand_following.md | 27 ++++++++++++++----- .../system_level_control.md | 18 ++++++------- .../system_level_control_base.md | 2 +- 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/docs/_toc.yml b/docs/_toc.yml index f7ac1f722..aa8d2794a 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -77,8 +77,8 @@ parts: - file: control/system_level_control/controllers sections: - file: control/system_level_control/slc_demand_following - - file: control/system_level_control/slc_cost_min - file: control/system_level_control/slc_profit_max + - file: control/system_level_control/slc_cost_min - file: control/technology_level_control/technology_control_overview sections: - file: control/technology_level_control/open-loop_controllers diff --git a/docs/control/system_level_control/controllers.md b/docs/control/system_level_control/controllers.md index c0b91b9e5..fed919828 100644 --- a/docs/control/system_level_control/controllers.md +++ b/docs/control/system_level_control/controllers.md @@ -3,8 +3,8 @@ There are several simple control strategies already implemented in the SLC parad The current control strategies are: 1. [Demand Following](#slc-demand-following) -2. [Cost Minimization](#slc-cost-min) -3. [Profit Maximization](#slc-profit-max) +2. [Profit Maximization](#slc-profit-max) +3. [Cost Minimization](#slc-cost-min) ```{note} The strategies currently implemented are experimental and will likely require further development for specific analyses. diff --git a/docs/control/system_level_control/slc_demand_following.md b/docs/control/system_level_control/slc_demand_following.md index 1e58a9e49..391026a1b 100644 --- a/docs/control/system_level_control/slc_demand_following.md +++ b/docs/control/system_level_control/slc_demand_following.md @@ -53,6 +53,27 @@ display( ) ) ``` +## Dispatch Logic + +The he demand is satisfied in a fixed three-step priority order, and each step's shortfall or surplus is passed to the next: + +1. **Curtailable techs** run at their full rated capacity. Their total output is subtracted from the demand, which may drive the residual demand negative (surplus). + +2. **Storage techs** receive the residual demand (which may be positive or negative). When demand is positive the storage is commanded to discharge; when negative it is commanded to charge. If multiple storage techs produce the demanded commodity, the residual demand is +split **evenly** across them (each receives ``demand / n_storage``). + +3. **Dispatchable techs** cover any remaining positive demand after storage. The remaining demand (floored at zero) is split **evenly** across all dispatchable techs that produce the demanded commodity (each receives ``remaining_demand / n_dispatchable``). + +### Example Configuration + +```yaml +system_level_control: + control_strategy: DemandFollowingControl + solver_options: # solver options for resolving feedback + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +``` ## Inputs and Outputs @@ -84,9 +105,3 @@ This framework provides a starting point for hybrid energy system control but is - Even splitting across storage: When multiple storage technologies produce the demanded commodity, the residual demand is divided evenly among them (`demand / n_storage`), regardless of differences in capacity, state of charge, or efficiency. - Even splitting across dispatchable technologies: Similarly, any remaining demand after storage dispatch is split evenly across all dispatchable technologies (`remaining_demand / n_dispatchable`), without accounting for marginal costs or capacity constraints. - Fixed priority order: The dispatch order (curtailable → storage → dispatchable) is fixed in the current implementation. - -## General Logic - -First, control logic is as follows: -- For every technology classified as "curtailable", set the set-point as the rated commodity production of that technology. Subtract the commodity produced by the technology from the overall demand profile -- The remaining demand profile will be negative when the curtailable technologies produce more commodity than demanded and positive when the curtailable technologies produce less commodity than demanded. The remaining demand profile is divided by the number of storage technologies in the system to get the set point for each storage technology. This set point is negative to command the storage to charge, and positive to command the storage to discharge. diff --git a/docs/control/system_level_control/system_level_control.md b/docs/control/system_level_control/system_level_control.md index 9c2b61509..8f4b2e40f 100644 --- a/docs/control/system_level_control/system_level_control.md +++ b/docs/control/system_level_control/system_level_control.md @@ -24,13 +24,13 @@ To set the demand for the SLC that is configured in the `tech_config.yaml` using ```yaml electrical_load_demand: -performance_model: - model: GenericDemandComponent -model_inputs: - performance_parameters: - commodity: electricity - commodity_rate_units: kW - demand_profile: 30000 + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 30000 ``` ## Control Strategies @@ -38,8 +38,8 @@ There are several simple control strategies already implemented in the SLC parad The current control strategies are: 1. [Demand Following](#slc-demand-following) -2. [Cost Minimization](#slc-cost-min) -3. [Profit Maximization](#slc-profit-max) +2. [Profit Maximization](#slc-profit-max) +3. [Cost Minimization](#slc-cost-min) ```{note} The strategies currently implemented are experimental and will likely require further development for specific analyses. diff --git a/docs/control/system_level_control/system_level_control_base.md b/docs/control/system_level_control/system_level_control_base.md index ad2acf592..bee202706 100644 --- a/docs/control/system_level_control/system_level_control_base.md +++ b/docs/control/system_level_control/system_level_control_base.md @@ -14,7 +14,7 @@ Setup I/O for SLC controllers. - `_setup_tech_category()` - `_setup_feedstock_category()` - `find_converter_techs()` - - Note: this currently is not implemented but will be used for heterogeneous commodity systems. + - Note: this method is currently is not used but will be used for heterogeneous commodity systems. Functions for controlling components based on assigned control classifier. - `_subtract_curtailable()` From e8cf31f92359dc488b1f19e396fbffb2e4e50c8d Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 12 May 2026 09:31:42 -0600 Subject: [PATCH 091/105] minor update to doc page --- docs/control/system_level_control/slc_demand_following.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/control/system_level_control/slc_demand_following.md b/docs/control/system_level_control/slc_demand_following.md index 391026a1b..9f68aed6b 100644 --- a/docs/control/system_level_control/slc_demand_following.md +++ b/docs/control/system_level_control/slc_demand_following.md @@ -27,9 +27,10 @@ import os import html from pathlib import Path +from h2integrate import EXAMPLE_DIR from IPython.display import HTML, display -os.chdir("../../../examples/35_system_level_control/battery_with_controller/") +os.chdir("EXAMPLE_DIR/35_system_level_control/battery_with_controller/") h2i_model = H2IntegrateModel("wind_ng_demand.yaml") h2i_model.setup() @@ -55,7 +56,7 @@ display( ``` ## Dispatch Logic -The he demand is satisfied in a fixed three-step priority order, and each step's shortfall or surplus is passed to the next: +The demand is satisfied in a fixed three-step priority order, and each step's shortfall or surplus is passed to the next: 1. **Curtailable techs** run at their full rated capacity. Their total output is subtracted from the demand, which may drive the residual demand negative (surplus). From 0263a589843fdb48c719ee1dc67d0993be2bad83 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 12 May 2026 13:12:06 -0600 Subject: [PATCH 092/105] minor clarification in doc --- docs/control/system_level_control/slc_demand_following.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control/system_level_control/slc_demand_following.md b/docs/control/system_level_control/slc_demand_following.md index 9f68aed6b..8fea76611 100644 --- a/docs/control/system_level_control/slc_demand_following.md +++ b/docs/control/system_level_control/slc_demand_following.md @@ -96,7 +96,7 @@ The outputs for technologies classified as `storage` that *have a storage contro ## Systems with Heterogeneous Commodities The `DemandFollowingControl` controller can be used in hybrid systems where technologies produce different commodities. -For example, in a system where an electrolyzer produces hydrogen and the demand commodity is hydrogen, the controller can set the electricity-generating technologies' set-points to meet the hydrogen demand. +For example, in a system where an electrolyzer produces hydrogen and the demand commodity is hydrogen, the controller can set the electricity-generating *curtailable* technologies' set-points to meet the hydrogen demand. This framework provides a starting point for hybrid energy system control but is intended to be extended with more sophisticated strategies for complex multi-commodity systems. From 85c867954a92cdec199adb43d87cdab8b32a1091 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 13 May 2026 09:36:43 -0600 Subject: [PATCH 093/105] Reconfiguring the connection for varopex --- h2integrate/core/h2integrate_model.py | 51 ++++++++++++++++----------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index b48f4368e..3fc6e88ad 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -472,14 +472,22 @@ def create_plant_model(self): self.plant = self.model.add_subsystem("plant", plant_group, promotes=["*"]) def _classify_slc_technologies(self): - """Classify technologies for system-level control and store in plant_config. + """Classify technologies for system-level control. Uses ``self.tech_control_classifiers`` (populated by ``create_technology_models()``) to partition technologies into curtailable, dispatchable, and storage lists. Also identifies the single demand technology and its commodity. - Results are written into ``self.plant_config["system_level_control"]`` so - they are available to the ``DemandFollowingControl`` component at setup time. + Returns: + dict: Classification dictionary (``slc_config``) with keys: + + - ``"demand_tech"`` (str) + - ``"demand_commodity"`` (str) + - ``"demand_commodity_rate_units"`` (str | None) + - ``"tech_to_commodity"`` (set[tuple[str, str]]) + - ``"storage_techs_to_control"`` (dict[str, bool]) + - ``"technology_graph"`` (nx.DiGraph) + - ``"tech_control_classifiers"`` (dict[str, str]) """ slc_config = {} technologies = self.technology_config.get("technologies", {}) @@ -629,8 +637,11 @@ def add_system_level_controller(self, slc_config): specification determines which cost signal is connected: - ``"VarOpEx"``: connects the tech's own ``VarOpEx`` output. - - ``"feedstock"``: scans ``technology_interconnections`` for upstream feedstock - technologies and connects each feedstock's ``VarOpEx`` output. + - ``"feedstock"``: uses graph traversal (``nx.ancestors``) on the + ``technology_graph`` to find all upstream feedstock technologies + at any depth and connects each feedstock's ``VarOpEx`` output. + This is consistent with the ``_find_feedstock_techs`` method + used by the controller component internally. - ``"buy_price"``: no connection needed; the controller reads a default value from the tech config that can be overridden at runtime via ``prob.set_val()``. - Numeric scalar: no connection needed; the value is used directly as a constant @@ -740,6 +751,7 @@ def add_system_level_controller(self, slc_config): # --- Step 4: Connect marginal-cost inputs (cost-aware strategies) - if strategy_name in ("CostMinimizationControl", "ProfitMaximizationControl"): cost_per_tech = plant_slc_config.get("control_parameters", {}).get("cost_per_tech", {}) + technology_graph = slc_config["technology_graph"] for tech_name, _ in slc_config["tech_to_commodity"]: if self.tech_control_classifiers[tech_name] == "dispatchable": cost_spec = cost_per_tech.get(tech_name, 0.0) @@ -750,21 +762,20 @@ def add_system_level_controller(self, slc_config): f"system_level_controller.{tech_name}_VarOpEx", ) elif cost_spec == "feedstock": - # Sum VarOpEx from all upstream feedstock technologies - interconnections = self.plant_config.get("technology_interconnections", []) - technologies = self.technology_config.get("technologies", {}) - for conn in interconnections: - if conn[1] != tech_name: - continue - upstream = conn[0] - tech_def = technologies.get(upstream, {}) - perf_model = tech_def.get("performance_model", {}).get("model", "") - cost_model = tech_def.get("cost_model", {}).get("model", "") - if "Feedstock" in perf_model or "Feedstock" in cost_model: - self.plant.connect( - f"{upstream}.VarOpEx", - f"system_level_controller.{upstream}_VarOpEx", - ) + # Find all upstream feedstock technologies using + # graph traversal (matches _find_feedstock_techs + # in the SLC component). + ancestors = nx.ancestors(technology_graph, tech_name) + feedstock_names = [ + t + for t in ancestors + if self.tech_control_classifiers.get(t) == "feedstock" + ] + for feedstock_name in feedstock_names: + self.plant.connect( + f"{feedstock_name}.VarOpEx", + f"system_level_controller.{feedstock_name}_VarOpEx", + ) # "buy_price": default from tech config, overridable via set_val # numeric scalar: used directly, no connection needed From ecf4273c38dd5cf7f6cce26be47178783a5244e8 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 14 May 2026 13:49:38 -0600 Subject: [PATCH 094/105] Shifting to five control classifiers --- .../control_classifier.md | 29 +++-- .../system_level/cost_minimization_control.py | 30 +++-- .../system_level/demand_following_control.py | 41 ++++--- .../profit_maximization_control.py | 30 +++-- .../system_level/system_level_control_base.py | 114 +++++++++++++++--- .../system_level/test/test_slc_controllers.py | 19 +-- h2integrate/converters/hopp/hopp_wrapper.py | 2 +- .../hydrogen/electrolyzer_baseclass.py | 10 ++ .../geologic/h2_well_subsurface_baseclass.py | 2 +- .../converters/hydrogen/pem_electrolyzer.py | 9 +- .../converters/nuclear/nuclear_plant.py | 2 +- .../converters/solar/solar_baseclass.py | 2 +- .../water_power/hydro_plant_run_of_river.py | 2 +- .../converters/water_power/tidal_pysam.py | 2 +- h2integrate/converters/wind/wind_plant_ard.py | 4 +- .../converters/wind/wind_plant_baseclass.py | 2 +- h2integrate/core/h2integrate_model.py | 28 ++++- h2integrate/core/model_baseclasses.py | 10 +- 18 files changed, 240 insertions(+), 98 deletions(-) diff --git a/docs/control/system_level_control/control_classifier.md b/docs/control/system_level_control/control_classifier.md index 51047c007..9b8cc5038 100644 --- a/docs/control/system_level_control/control_classifier.md +++ b/docs/control/system_level_control/control_classifier.md @@ -4,24 +4,30 @@ To enable a generic system level control framework we need to classify each tech ```{note} While in real life there are a lot of controllable parameters allowing for ramping production up or down for a particular technology (e.g., turbine yaw). The particular model in H2I might not be capable of simulating a modulated response based on an input signal. +These classifications are for how the models in H2I are implemented, **not** how the actual physical subsystem might operate. +This is a useful and necessary distinction that delineates different model capabilities clearly. ``` -We have identified four key classifiers that are able to represent the different behaviors that we can expect from the models. Each performance model includes a parameter setting the classifier `_control_classifier`. +We have identified five key classifiers that are able to represent the different behaviors that we can expect from the models. Each performance model includes a parameter setting the classifier `_control_classifier`. Classifier | Meaning | Example Techs -- | -- | -- -curtailable | Produces based on resource or input commodity; can only be reduced | wind, solar, nuclear -dispatchable | Can modulate consumption/production within bounds | grid, NG turbine -storage | Can modulate consumption/production within bounds while tracking SOC; does not produce/consume energy | battery, h2 storage, any storage -feedstock | Can't be controlled but system knows how much is available | NG or water from feedstock +fixed | Always produces commodity and cannot be controlled or reduced; does not receive a set-point | classical nuclear +flexible | Produces based on resource; can only reduce (curtail) | wind, solar +dispatchable | Can modulate consumption/production within bounds; receives a commodity set-point | grid, electrolyzer, NG turbine +storage | Can modulate consumption/production within bounds while tracking SOC | battery, h2 storage, any storage +feedstock | Are not directly controlled, but useful for SLC to know about to make dispatch decisions | feedstocks To add a classifier for a particular model it would look something like this in the class: ```{python} -_control_classifier = "curtailable" +_control_classifier = "flexible" ``` -## Curtailable -A curtailable performance model represents anything that can have the output reduced based on a give set point from the system level controller. This classifier and the inputs and outputs are included in the figure below. A good example of this is the PVWatts PySAM solar plant in H2I, the performance of the system is based on the input solar resource. The solar performance does not change based on, for example, an updated set point to the tracking software, but we could limit the power output from the solar performance model based on a given demand set point. To simplify the implementation of applying this curtailment or reduction based on a set point we added a method, `apply_curtailment()` to the `PerformanceBaseClass`. +## Fixed +A fixed performance model represents anything that always produces at its rated capacity and cannot be controlled or reduced by the system level controller. The SLC reads the output from a fixed technology and subtracts it from the demand, but does not send a set-point back to the technology. A good example of this is a classical nuclear plant model — it produces a constant output that the rest of the system must accommodate. + +## Flexible +A flexible performance model represents anything that can have the output reduced based on a given set point from the system level controller. A good example of this is the PVWatts PySAM solar plant in H2I, the performance of the system is based on the input solar resource. The solar performance does not change based on, for example, an updated set point to the tracking software, but we could limit the power output from the solar performance model based on a given demand set point. To simplify the implementation of applying this curtailment or reduction based on a set point we added a method, `apply_curtailment()` to the `PerformanceBaseClass`. ```{figure} figures/curtailable.png :width: 70% @@ -61,3 +67,10 @@ The system-level controller outputs set points to the storage performance model ## Feedstock Another category of control classifiers are feedstocks. The unique thing about feedstocks is that they are considered outside of the controllable system within H2I. While they can't be controlled it can be helpful for controllers to know how much feedstock is available within the system, hence their classification. + +## SLC Dispatch Order +The system level controllers dispatch technologies in the following order: +1. **Fixed** — subtract their production from demand +2. **Flexible** — run at full capacity, subtract from demand +3. **Storage** — absorb surplus or provide deficit +4. **Dispatchable** — cover remaining demand diff --git a/h2integrate/control/control_strategies/system_level/cost_minimization_control.py b/h2integrate/control/control_strategies/system_level/cost_minimization_control.py index 948589b25..d9208a0c7 100644 --- a/h2integrate/control/control_strategies/system_level/cost_minimization_control.py +++ b/h2integrate/control/control_strategies/system_level/cost_minimization_control.py @@ -10,9 +10,10 @@ class CostMinimizationControl(SystemLevelControlBase): Meets demand at minimum variable cost using merit-order dispatch: - 1. Curtailable techs run at rated capacity (assuming zero marginal cost). - 2. Storage absorbs surplus / provides deficit. - 3. Dispatchable techs are dispatched in ascending marginal-cost order, + 1. Fixed techs always produce (cannot be controlled). + 2. Flexible techs run at rated capacity (assuming zero marginal cost). + 3. Storage absorbs surplus / provides deficit. + 4. Dispatchable techs are dispatched in ascending marginal-cost order, each up to its rated capacity, until remaining demand is met. Marginal costs are configured via ``cost_per_tech`` in the @@ -33,18 +34,21 @@ def setup(self): def compute(self, inputs, outputs): demand = inputs[self.demand_input_name].copy() - # 1. Curtailable techs: full production - for curtailable_tech in self.curtailable_techs: - commodity_from_tech = self._get_commodity_for_tech(curtailable_tech) - # check that this tech produces the commodity demanded + # 1. Fixed techs: always produce, subtract from demand + for fixed_tech in self.fixed_techs: + commodity_from_tech = self._get_commodity_for_tech(fixed_tech) if self.commodity in commodity_from_tech: - # if the commodity produced from a tech is the demanded commodity - # then subtract the curtailable production from the demand - demand = self._subtract_curtailable( - curtailable_tech, demand, self.commodity, inputs, outputs + demand = self._subtract_fixed(fixed_tech, demand, self.commodity, inputs) + + # 2. Flexible techs: full production + for flexible_tech in self.flexible_techs: + commodity_from_tech = self._get_commodity_for_tech(flexible_tech) + if self.commodity in commodity_from_tech: + demand = self._subtract_flexible( + flexible_tech, demand, self.commodity, inputs, outputs ) - # 2. Storage dispatch + # 3. Storage dispatch # number of storage components that produce the demanded commodity n_storage = len( [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] @@ -56,7 +60,7 @@ def compute(self, inputs, outputs): storage_tech, demand / n_storage, self.commodity, inputs, outputs ) - # 3. Merit-order dispatch: cheapest dispatchable first + # 4. Merit-order dispatch: cheapest dispatchable first remaining = np.maximum(demand, 0.0) marginal_costs = self._compute_marginal_costs(inputs) diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py index 7821f8d84..eb2427f00 100644 --- a/h2integrate/control/control_strategies/system_level/demand_following_control.py +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -9,20 +9,23 @@ class DemandFollowingControl(SystemLevelControlBase): """Demand-following system-level controller. Dispatches technologies to meet a time-varying demand profile without - considering costs. The demand is satisfied in a fixed three-step priority + considering costs. The demand is satisfied in a fixed four-step priority order, and each step's shortfall or surplus is passed to the next: - 1. **Curtailable techs** run at their full rated capacity. Their total + 1. **Fixed techs** always produce at their rated capacity and cannot be + controlled. Their total output is subtracted from the demand. + + 2. **Flexible techs** run at their full rated capacity. Their total output is subtracted from the demand, which may drive the residual demand negative (surplus). - 2. **Storage techs** receive the residual demand (which may be positive + 3. **Storage techs** receive the residual demand (which may be positive or negative). When demand is positive the storage is commanded to discharge; when negative it is commanded to charge. If multiple storage techs produce the demanded commodity, the residual demand is split **evenly** across them (each receives ``demand / n_storage``). - 3. **Dispatchable techs** cover any remaining positive demand after + 4. **Dispatchable techs** cover any remaining positive demand after storage. The remaining demand (floored at zero) is split **evenly** across all dispatchable techs that produce the demanded commodity (each receives ``remaining_demand / n_dispatchable``). @@ -32,25 +35,29 @@ def compute(self, inputs, outputs): commodity = self.commodity demand = inputs[self.demand_input_name].copy() - # 1. Curtailable techs: operate at full production - for curtailable_tech in self.curtailable_techs: - commodity_from_tech = self._get_commodity_for_tech(curtailable_tech) - # check that this tech produces the commodity demanded + # 1. Fixed techs: always produce, subtract from demand + for fixed_tech in self.fixed_techs: + commodity_from_tech = self._get_commodity_for_tech(fixed_tech) + for tech_commodity in commodity_from_tech: + if tech_commodity == commodity: + demand = self._subtract_fixed(fixed_tech, demand, commodity, inputs) + + # 2. Flexible techs: operate at full production + for flexible_tech in self.flexible_techs: + commodity_from_tech = self._get_commodity_for_tech(flexible_tech) for tech_commodity in commodity_from_tech: if tech_commodity == commodity: - # if the commodity produced from a tech is the demanded commodity - # then subtract the curtailable production from the demand - demand = self._subtract_curtailable( - curtailable_tech, demand, commodity, inputs, outputs + demand = self._subtract_flexible( + flexible_tech, demand, commodity, inputs, outputs ) else: - if f"{curtailable_tech}_rated_{tech_commodity}_production" in inputs: + if f"{flexible_tech}_rated_{tech_commodity}_production" in inputs: # set the set-point as the rated production - outputs[f"{curtailable_tech}_{tech_commodity}_set_point"] = inputs[ - f"{curtailable_tech}_rated_{tech_commodity}_production" + outputs[f"{flexible_tech}_{tech_commodity}_set_point"] = inputs[ + f"{flexible_tech}_rated_{tech_commodity}_production" ] * np.ones(self.n_timesteps) - # 2. Storage dispatch + # 3. Storage dispatch # number of storage components that produce the demanded commodity n_storage = len( [s for s in self.storage_techs if commodity in self._get_commodity_for_tech(s)] @@ -62,7 +69,7 @@ def compute(self, inputs, outputs): storage_tech, demand / n_storage, commodity, inputs, outputs ) - # 3. Dispatchable techs + # 4. Dispatchable techs remaining_demand = np.maximum(demand, 0.0) # calculate the number of dispatchable technologies that diff --git a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py index ed4e04315..1fd8868a1 100644 --- a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py +++ b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py @@ -19,10 +19,11 @@ class ProfitMaximizationControl(SystemLevelControlBase): Dispatches technologies only when the commodity sell price exceeds the marginal cost of production: - 1. Curtailable techs run at rated capacity (zero marginal cost, + 1. Fixed techs always produce (cannot be controlled). + 2. Flexible techs run at rated capacity (zero marginal cost, always profitable to produce). - 2. Storage absorbs surplus / provides deficit. - 3. Dispatchable techs are dispatched in merit order (cheapest first), + 3. Storage absorbs surplus / provides deficit. + 4. Dispatchable techs are dispatched in merit order (cheapest first), but **only if** their marginal cost is below the sell price. Demand may go unmet if dispatch is unprofitable. @@ -93,18 +94,21 @@ def compute(self, inputs, outputs): demand = inputs[self.demand_input_name].copy() sell_price = inputs["commodity_sell_price"] # shape (n_timesteps,) - # 1. Curtailable techs: full production (always profitable) - for curtailable_tech in self.curtailable_techs: - commodity_from_tech = self._get_commodity_for_tech(curtailable_tech) - # check that this tech produces the commodity demanded + # 1. Fixed techs: always produce, subtract from demand + for fixed_tech in self.fixed_techs: + commodity_from_tech = self._get_commodity_for_tech(fixed_tech) if self.commodity in commodity_from_tech: - # if the commodity produced from a tech is the demanded commodity - # then subtract the curtailable production from the demand - demand = self._subtract_curtailable( - curtailable_tech, demand, self.commodity, inputs, outputs + demand = self._subtract_fixed(fixed_tech, demand, self.commodity, inputs) + + # 2. Flexible techs: full production (always profitable) + for flexible_tech in self.flexible_techs: + commodity_from_tech = self._get_commodity_for_tech(flexible_tech) + if self.commodity in commodity_from_tech: + demand = self._subtract_flexible( + flexible_tech, demand, self.commodity, inputs, outputs ) - # 2. Storage dispatch + # 3. Storage dispatch # number of storage components that produce the demanded commodity n_storage = len( [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] @@ -116,7 +120,7 @@ def compute(self, inputs, outputs): storage_tech, demand / n_storage, self.commodity, inputs, outputs ) - # 3. Profit-driven merit-order dispatch + # 4. Profit-driven merit-order dispatch remaining = np.maximum(demand, 0.0) marginal_costs = self._compute_marginal_costs(inputs) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index e7e5ad05f..8cd0ee195 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -7,7 +7,7 @@ class SystemLevelControlBase(om.ExplicitComponent): """Base class for system-level controllers. Provides common setup logic shared by all system-level control strategies: - demand input, curtailable/dispatchable/storage technology I/O creation, + demand input, fixed/flexible/dispatchable/storage technology I/O creation, and technology classification reading from ``plant_config`` and ``slc_config`` Subclasses must implement ``compute()`` with their dispatch strategy. @@ -50,8 +50,11 @@ def setup(self): self.storage_techs_to_control = slc_config.get("storage_techs_to_control", {}) self.technology_graph = slc_config["technology_graph"] - self.curtailable_techs = [ - k for k, v in slc_config["tech_control_classifiers"].items() if v == "curtailable" + self.fixed_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "fixed" + ] + self.flexible_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "flexible" ] self.dispatchable_techs = [ k for k, v in slc_config["tech_control_classifiers"].items() if v == "dispatchable" @@ -64,7 +67,7 @@ def setup(self): ] self.input_techs = set( - self.curtailable_techs + self.dispatchable_techs + self.storage_techs + self.fixed_techs + self.flexible_techs + self.dispatchable_techs + self.storage_techs ) # Input: demand profile @@ -86,7 +89,8 @@ def setup(self): self.commodities_to_units = {self.commodity: self.commodity_units} self.commodities_to_ref_var = {} - self._setup_tech_category("curtailable", self.curtailable_techs) + self._setup_fixed_category(self.fixed_techs) + self._setup_tech_category("flexible", self.flexible_techs) self._setup_tech_category("dispatchable", self.dispatchable_techs) self._setup_tech_category("storage", self.storage_techs) self._setup_feedstock_category(self.feedstock_comps) @@ -185,7 +189,7 @@ def _setup_commodity( def _setup_tech_category(self, category, tech_list): """Create OpenMDAO I/O variables for all technologies in a given category. - This single method handles curtailable, dispatchable, and storage + This single method handles flexible, dispatchable, and storage technologies. The logic is identical for all three categories — iterate over each technology's commodities and register the appropriate inputs (production output, rated capacity) and output @@ -203,13 +207,13 @@ def _setup_tech_category(self, category, tech_list): ``self.{category}_commodity_names`` These lists are consumed by ``compute()`` and the helper methods - ``_subtract_curtailable`` and ``_dispatch_storage``. + ``_subtract_flexible`` and ``_dispatch_storage``. Args: - category (str): One of ``"curtailable"``, ``"dispatchable"``, + category (str): One of ``"flexible"``, ``"dispatchable"``, or ``"storage"``. Used to name the attribute lists. tech_list (list[str]): Technology names belonging to this category - (e.g. ``self.curtailable_techs``). + (e.g. ``self.flexible_techs``). """ initial_set_point = 1.0 @@ -286,6 +290,67 @@ def _setup_tech_category(self, category, tech_list): setattr(self, f"{category}_rated_names", rated_names) setattr(self, f"{category}_commodity_names", commodity_names) + def _setup_fixed_category(self, fixed_list): + """Create OpenMDAO input variables for fixed technologies. + + Fixed technologies always produce at their rated capacity and do not + receive a set-point from the controller. Only commodity output inputs + are registered so the controller can read their production and subtract + it from demand. + + After this method returns, two lists are stored on ``self``: + + ``self.fixed_input_names`` + ``self.fixed_commodity_names`` + + Args: + fixed_list (list[str]): Technology names classified as ``"fixed"``. + """ + input_names = [] + commodity_names = [] + + for tech_name in fixed_list: + tech_commodities = [e[1] for e in self.techs_to_commodities if e[0] == tech_name] + for commodity in tech_commodities: + in_name = f"{tech_name}_{commodity}_out" + + if commodity in self.commodities_to_units: + self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodities_to_units[commodity], + desc=f"{commodity} output from {tech_name}", + ) + elif commodity in self.commodities_to_ref_var: + self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=None, + copy_units=self.commodities_to_ref_var[commodity], + desc=f"{commodity} output from {tech_name}", + ) + else: + meta_data = self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=None, + units_by_conn=True, + desc=f"{commodity} output from {tech_name}", + ) + if meta_data["units"] is None: + self.commodities_to_ref_var[commodity] = in_name + else: + self.commodities_to_units[commodity] = meta_data["units"] + + input_names.append(in_name) + commodity_names.append(commodity) + + self.fixed_input_names = input_names + self.fixed_commodity_names = commodity_names + def _setup_feedstock_category(self, feedstock_list): """Iterate over the feedstocks and add inputs for the available feedstock @@ -335,22 +400,39 @@ def _setup_feedstock_category(self, feedstock_list): # Connection provided units — record them for future use self.commodities_to_units[commodity] = meta_data["units"] - def _subtract_curtailable(self, curtailable_tech, remaining_demand, commodity, inputs, outputs): - """Apply curtailable techs: set_point = rated, subtract output from demand. + def _subtract_fixed(self, fixed_tech, remaining_demand, commodity, inputs): + """Apply fixed techs: subtract their output from demand. + + Fixed techs always produce and do not receive a set-point. + + Returns the updated demand array. + """ + if fixed_tech not in self.fixed_techs: + return remaining_demand + + in_name = f"{fixed_tech}_{commodity}_out" + if in_name not in inputs: + return remaining_demand + + remaining_demand -= inputs[in_name] + return remaining_demand + + def _subtract_flexible(self, flexible_tech, remaining_demand, commodity, inputs, outputs): + """Apply flexible techs: set_point = rated, subtract output from demand. Returns the updated demand array. """ - if curtailable_tech not in self.curtailable_techs: + if flexible_tech not in self.flexible_techs: return - if f"{curtailable_tech}_rated_{commodity}_production" not in inputs: + if f"{flexible_tech}_rated_{commodity}_production" not in inputs: return # Output the set-point as the rated production of that technology - outputs[f"{curtailable_tech}_{commodity}_set_point"] = inputs[ - f"{curtailable_tech}_rated_{commodity}_production" + outputs[f"{flexible_tech}_{commodity}_set_point"] = inputs[ + f"{flexible_tech}_rated_{commodity}_production" ] * np.ones(self.n_timesteps) - remaining_demand -= inputs[f"{curtailable_tech}_{commodity}_out"] + remaining_demand -= inputs[f"{flexible_tech}_{commodity}_out"] return remaining_demand diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py b/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py index 895341142..91cd39e9d 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py @@ -50,9 +50,10 @@ def _build_technology_graph(technology_interconnections): def _build_tech_control_classifiers( - curtailable=None, dispatchable=None, storage=None, feedstock=None + fixed=None, flexible=None, dispatchable=None, storage=None, feedstock=None ): - tech_control_classifiers = {k: "curtailable" for k in (curtailable or [])} + tech_control_classifiers = {k: "fixed" for k in (fixed or [])} + tech_control_classifiers |= {k: "flexible" for k in (flexible or [])} tech_control_classifiers |= {k: "dispatchable" for k in (dispatchable or [])} tech_control_classifiers |= {k: "storage" for k in (storage or [])} tech_control_classifiers |= {k: "feedstock" for k in (feedstock or [])} @@ -148,11 +149,11 @@ def _build_problem(slc_cls, plant_config, slc_config, demand=50000, tech_config= class TestSystemLevelControlBase: """Tests for the abstract base class setup logic.""" - def test_base_creates_curtailable_io(self): + def test_base_creates_flexible_io(self): tech_connections = [["wind", "demand", "electricity", "cable"]] plant_config = _build_plant_config(tech_connections) tech_graph = _build_technology_graph(tech_connections) - tech_control_classifiers = _build_tech_control_classifiers(curtailable=["wind"]) + tech_control_classifiers = _build_tech_control_classifiers(flexible=["wind"]) slc_config = _build_slc_config(tech_graph, tech_control_classifiers) # Use DemandFollowingControl since base is abstract prob = _build_problem(DemandFollowingControl, plant_config, slc_config) @@ -226,7 +227,7 @@ def test_equal_share_two_dispatchable(self): np.testing.assert_allclose(sp1, 25000) np.testing.assert_allclose(sp2, 25000) - def test_curtailable_reduces_demand(self): + def test_flexible_reduces_demand(self): tech_connections = [ ["wind", "combiner", "electricity", "cable"], ["ng", "combiner", "electricity", "cable"], @@ -235,7 +236,7 @@ def test_curtailable_reduces_demand(self): plant_config = _build_plant_config(tech_connections) tech_graph = _build_technology_graph(tech_connections) tech_control_classifiers = _build_tech_control_classifiers( - curtailable=["wind"], dispatchable=["ng"] + flexible=["wind"], dispatchable=["ng"] ) slc_config = _build_slc_config(tech_graph, tech_control_classifiers) prob = _build_problem(DemandFollowingControl, plant_config, slc_config) @@ -261,7 +262,7 @@ def test_storage_absorbs_surplus(self): plant_config = _build_plant_config(tech_connections) tech_graph = _build_technology_graph(tech_connections) tech_control_classifiers = _build_tech_control_classifiers( - curtailable=["wind"], storage=["battery"], dispatchable=["ng"] + flexible=["wind"], storage=["battery"], dispatchable=["ng"] ) slc_config = _build_slc_config(tech_graph, tech_control_classifiers) prob = _build_problem(DemandFollowingControl, plant_config, slc_config) @@ -348,7 +349,7 @@ def test_overflow_to_expensive(self): np.testing.assert_allclose(cheap_sp, 30000) np.testing.assert_allclose(expensive_sp, 20000) - def test_with_curtailable_reduces_dispatch(self): + def test_with_flexible_reduces_dispatch(self): tech_connections = [ ["wind", "combiner", "electricity", "cable"], ["ng", "combiner", "electricity", "cable"], @@ -357,7 +358,7 @@ def test_with_curtailable_reduces_dispatch(self): plant_config = _build_plant_config(tech_connections, cost_per_tech={"ng": 0.05}) tech_graph = _build_technology_graph(tech_connections) tech_control_classifiers = _build_tech_control_classifiers( - curtailable=["wind"], dispatchable=["ng"] + flexible=["wind"], dispatchable=["ng"] ) slc_config = _build_slc_config(tech_graph, tech_control_classifiers) prob = _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) diff --git a/h2integrate/converters/hopp/hopp_wrapper.py b/h2integrate/converters/hopp/hopp_wrapper.py index d801acc7e..994ae6cfc 100644 --- a/h2integrate/converters/hopp/hopp_wrapper.py +++ b/h2integrate/converters/hopp/hopp_wrapper.py @@ -33,7 +33,7 @@ class HOPPComponent(PerformanceModelBaseClass, CacheBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model - _control_classifier = "curtailable" + _control_classifier = "flexible" def initialize(self): super().initialize() diff --git a/h2integrate/converters/hydrogen/electrolyzer_baseclass.py b/h2integrate/converters/hydrogen/electrolyzer_baseclass.py index 3c3221947..94a5a50a2 100644 --- a/h2integrate/converters/hydrogen/electrolyzer_baseclass.py +++ b/h2integrate/converters/hydrogen/electrolyzer_baseclass.py @@ -23,6 +23,16 @@ def setup(self): # Define inputs for electricity self.add_input("electricity_in", val=0.0, shape=self.n_timesteps, units="kW") + # Dispatchable models receive a set_point from the system-level controller + if "system_level_control" in self.options["plant_config"]: + self.add_input( + f"{self.commodity}_set_point", + val=0.0, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc=f"Set point for {self.commodity} production from SLC", + ) + def compute(self, inputs, outputs): """ Computation for the OM component. diff --git a/h2integrate/converters/hydrogen/geologic/h2_well_subsurface_baseclass.py b/h2integrate/converters/hydrogen/geologic/h2_well_subsurface_baseclass.py index 11b44189a..adaf61d9c 100644 --- a/h2integrate/converters/hydrogen/geologic/h2_well_subsurface_baseclass.py +++ b/h2integrate/converters/hydrogen/geologic/h2_well_subsurface_baseclass.py @@ -46,7 +46,7 @@ class GeoH2SubsurfacePerformanceConfig(BaseConfig): class GeoH2SubsurfacePerformanceBaseClass(PerformanceModelBaseClass): - _control_classifier = "curtailable" + _control_classifier = "dispatchable" """OpenMDAO component for modeling the performance of the well subsurface for geologic hydrogen. diff --git a/h2integrate/converters/hydrogen/pem_electrolyzer.py b/h2integrate/converters/hydrogen/pem_electrolyzer.py index 39e2069ef..0c5aada06 100644 --- a/h2integrate/converters/hydrogen/pem_electrolyzer.py +++ b/h2integrate/converters/hydrogen/pem_electrolyzer.py @@ -63,7 +63,7 @@ class ECOElectrolyzerPerformanceModel(ElectrolyzerPerformanceBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model - _control_classifier = "curtailable" + _control_classifier = "dispatchable" def setup(self): self.config = ECOElectrolyzerPerformanceModelConfig.from_dict( @@ -223,5 +223,8 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): "Annual O2 Production [kg/year]" ] - # Apply curtailment based on set_point - self.apply_curtailment(outputs) + # Apply set_point from system-level controller if present + if "system_level_control" in self.options["plant_config"]: + set_point = inputs[f"{self.commodity}_set_point"] + commodity_out_key = f"{self.commodity}_out" + outputs[commodity_out_key] = np.minimum(outputs[commodity_out_key], set_point) diff --git a/h2integrate/converters/nuclear/nuclear_plant.py b/h2integrate/converters/nuclear/nuclear_plant.py index d8a86ec29..836d9ce52 100644 --- a/h2integrate/converters/nuclear/nuclear_plant.py +++ b/h2integrate/converters/nuclear/nuclear_plant.py @@ -37,7 +37,7 @@ class QuinnNuclearPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model - _control_classifier = "dispatchable" + _control_classifier = "fixed" def initialize(self): super().initialize() diff --git a/h2integrate/converters/solar/solar_baseclass.py b/h2integrate/converters/solar/solar_baseclass.py index cdb125a8d..dcf01bbc5 100644 --- a/h2integrate/converters/solar/solar_baseclass.py +++ b/h2integrate/converters/solar/solar_baseclass.py @@ -6,7 +6,7 @@ class SolarPerformanceBaseClass(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model - _control_classifier = "curtailable" + _control_classifier = "flexible" def initialize(self): super().initialize() diff --git a/h2integrate/converters/water_power/hydro_plant_run_of_river.py b/h2integrate/converters/water_power/hydro_plant_run_of_river.py index 108269067..a7034046b 100644 --- a/h2integrate/converters/water_power/hydro_plant_run_of_river.py +++ b/h2integrate/converters/water_power/hydro_plant_run_of_river.py @@ -40,7 +40,7 @@ class RunOfRiverHydroPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model - _control_classifier = "curtailable" + _control_classifier = "flexible" def initialize(self): super().initialize() diff --git a/h2integrate/converters/water_power/tidal_pysam.py b/h2integrate/converters/water_power/tidal_pysam.py index 6d65d8fc2..6c245ec8b 100644 --- a/h2integrate/converters/water_power/tidal_pysam.py +++ b/h2integrate/converters/water_power/tidal_pysam.py @@ -124,7 +124,7 @@ class PySAMTidalPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model - _control_classifier = "curtailable" + _control_classifier = "flexible" def initialize(self): super().initialize() diff --git a/h2integrate/converters/wind/wind_plant_ard.py b/h2integrate/converters/wind/wind_plant_ard.py index 811e083f2..929f0b0a9 100644 --- a/h2integrate/converters/wind/wind_plant_ard.py +++ b/h2integrate/converters/wind/wind_plant_ard.py @@ -38,7 +38,7 @@ class WindArdPerformanceCompatibilityComponent(PerformanceModelBaseClass): """ _time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model - _control_classifier = "curtailable" + _control_classifier = "flexible" def initialize(self): super().initialize() @@ -149,7 +149,7 @@ class ArdWindPlantModel(om.Group): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model - _control_classifier = "curtailable" + _control_classifier = "flexible" def initialize(self): self.options.declare("driver_config", types=dict) diff --git a/h2integrate/converters/wind/wind_plant_baseclass.py b/h2integrate/converters/wind/wind_plant_baseclass.py index b8a90ed6e..08e8907b4 100644 --- a/h2integrate/converters/wind/wind_plant_baseclass.py +++ b/h2integrate/converters/wind/wind_plant_baseclass.py @@ -6,7 +6,7 @@ class WindPerformanceBaseClass(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model - _control_classifier = "curtailable" + _control_classifier = "flexible" def initialize(self): super().initialize() diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 3fc6e88ad..9f526ce6e 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -475,7 +475,7 @@ def _classify_slc_technologies(self): """Classify technologies for system-level control. Uses ``self.tech_control_classifiers`` (populated by ``create_technology_models()``) - to partition technologies into curtailable, dispatchable, and storage lists. + to partition technologies into fixed, flexible, dispatchable, and storage lists. Also identifies the single demand technology and its commodity. Returns: @@ -569,7 +569,13 @@ def _classify_slc_technologies(self): storage_tech_to_control[tech] = True # Remove feedstocks and connectors - control_classifiers_to_connect = ["curtailable", "dispatchable", "storage", "feedstock"] + control_classifiers_to_connect = [ + "fixed", + "flexible", + "dispatchable", + "storage", + "feedstock", + ] tech_to_commodity = { (e[0], e[-1]) for e in sources_to_commodities @@ -618,7 +624,10 @@ def add_system_level_controller(self, slc_config): - **Feedstock techs**: Only the commodity output (``{tech_name}_source.{commodity}_out``) is connected to the controller. Feedstocks have no set-point or rated-production connection. - - **Curtailable / dispatchable / storage techs**: Both the commodity output + - **Fixed techs**: Only the commodity output + (``{tech_name}.{commodity}_out``) is connected to the controller. Fixed techs + always produce and receive no set-point or rated-production connection. + - **Flexible / dispatchable / storage techs**: Both the commodity output (``{tech_name}.{commodity}_out``) and rated production (``{tech_name}.rated_{commodity}_production``) are connected as controller inputs. The controller's set-point output is connected back to the tech: @@ -660,7 +669,7 @@ def add_system_level_controller(self, slc_config): - ``"tech_to_commodity"`` (set[tuple[str, str]]): Set of ``(tech_name, commodity)`` pairs for all controlled techs. - ``"tech_control_classifiers"`` (dict[str, str]): Mapping of tech name to - classifier (``"curtailable"``, ``"dispatchable"``, ``"storage"``, + classifier (``"fixed"``, ``"flexible"``, ``"dispatchable"``, ``"storage"``, ``"feedstock"``). - ``"storage_techs_to_control"`` (dict[str, bool]): Whether each storage tech has its own sub-controller. @@ -719,7 +728,16 @@ def add_system_level_controller(self, slc_config): ) continue - # Curtailable, dispatchable, and storage techs: connect their + if slc_config["tech_control_classifiers"][tech_name] == "fixed": + # Fixed techs only provide their commodity output to the + # controller; they always produce and receive no set-point. + self.plant.connect( + f"{tech_name}.{commodity}_out", + f"system_level_controller.{tech_name}_{commodity}_out", + ) + continue + + # Flexible, dispatchable, and storage techs: connect their # commodity output and rated production as controller inputs. self.plant.connect( f"{tech_name}.{commodity}_out", diff --git a/h2integrate/core/model_baseclasses.py b/h2integrate/core/model_baseclasses.py index baa43e5f2..ac2f34dfc 100644 --- a/h2integrate/core/model_baseclasses.py +++ b/h2integrate/core/model_baseclasses.py @@ -99,8 +99,8 @@ def setup(self): # operational life of the technology if the technology cannot be replaced self.add_output("operational_life", val=self.plant_life, units="yr") - # Curtailable models get additional I/O for set_point-based curtailment - if getattr(self, "_control_classifier", None) == "curtailable": + # Flexible models get additional I/O for set_point-based curtailment + if getattr(self, "_control_classifier", None) == "flexible": self.add_input( f"{self.commodity}_set_point", val=1.0, @@ -122,12 +122,12 @@ def apply_curtailment(self, outputs): Copies the current ``{commodity}_out`` into ``uncurtailed_{commodity}_out``, then clips ``{commodity}_out`` to ``min(uncurtailed, set_point)`` element-wise. - Only operates when the model has ``_control_classifier == "curtailable"``. - Should be called at the end of each curtailable model's ``compute()`` method + Only operates when the model has ``_control_classifier == "flexible"``. + Should be called at the end of each flexible model's ``compute()`` method after the raw production has been written to ``outputs[f"{commodity}_out"]``. """ if "system_level_control" in self.options["plant_config"]: - if getattr(self, "_control_classifier", None) != "curtailable": + if getattr(self, "_control_classifier", None) != "flexible": return commodity_out_key = f"{self.commodity}_out" From 4fbd901e18b26527dd1bc3f1eb4de8a30a6ea89a Mon Sep 17 00:00:00 2001 From: kbrunik Date: Thu, 14 May 2026 14:04:19 -0600 Subject: [PATCH 095/105] small doc change --- docs/control/system_level_control/control_classifier.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/docs/control/system_level_control/control_classifier.md b/docs/control/system_level_control/control_classifier.md index 9b8cc5038..a08b77b8e 100644 --- a/docs/control/system_level_control/control_classifier.md +++ b/docs/control/system_level_control/control_classifier.md @@ -10,7 +10,7 @@ This is a useful and necessary distinction that delineates different model capab We have identified five key classifiers that are able to represent the different behaviors that we can expect from the models. Each performance model includes a parameter setting the classifier `_control_classifier`. -Classifier | Meaning | Example Techs +Classifier | Meaning | Example Technology Models -- | -- | -- fixed | Always produces commodity and cannot be controlled or reduced; does not receive a set-point | classical nuclear flexible | Produces based on resource; can only reduce (curtail) | wind, solar @@ -67,10 +67,3 @@ The system-level controller outputs set points to the storage performance model ## Feedstock Another category of control classifiers are feedstocks. The unique thing about feedstocks is that they are considered outside of the controllable system within H2I. While they can't be controlled it can be helpful for controllers to know how much feedstock is available within the system, hence their classification. - -## SLC Dispatch Order -The system level controllers dispatch technologies in the following order: -1. **Fixed** — subtract their production from demand -2. **Flexible** — run at full capacity, subtract from demand -3. **Storage** — absorb surplus or provide deficit -4. **Dispatchable** — cover remaining demand From 4deb083a03bd911e42a1a4463ef4edd534204ce2 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 14 May 2026 16:43:20 -0600 Subject: [PATCH 096/105] connected storage duration --- .../system_level/system_level_control_base.py | 5 +++++ h2integrate/core/h2integrate_model.py | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index 8cd0ee195..6622f8199 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -279,6 +279,11 @@ def _setup_tech_category(self, category, tech_list): initial_set_point=initial_set_point, ) + if category == "storage": + self.add_input( + f"{tech_name}_{commodity}_storage_duration", val=0.0, shape=1, units="h" + ) + commodity_names.append(commodity) input_names.append(in_name) set_point_names.append(set_point_name) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 9f526ce6e..38795f983 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -749,6 +749,13 @@ def add_system_level_controller(self, slc_config): f"system_level_controller.{tech_name}_rated_{commodity}_production", ) + # Storage tech: connect the storage duration as a controller input + if slc_config["tech_control_classifiers"][tech_name] == "storage": + self.plant.connect( + f"{tech_name}.storage_duration", + f"system_level_controller.{tech_name}_{commodity}_storage_duration", + ) + # Connect the controller's output back to the technology. if slc_config["storage_techs_to_control"].get(tech_name, False): # Storage tech with its own sub-controller: provide a demand From 8c968a465509245a882f42e7b9e1abcaa6910b8d Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 19 May 2026 16:21:32 -0600 Subject: [PATCH 097/105] added more subtests to test_slc_examples for the two single-commodity demand following examples --- .../system_level/test/test_slc_examples.py | 108 ++++++++++++++++-- 1 file changed, 96 insertions(+), 12 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index c85352ef5..0d81f137f 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -15,10 +15,52 @@ def test_slc_no_battery(subtests, temp_copy_of_example): model.run() - wind_out = model.prob.get_val("wind.electricity_out") + with subtests.test("Wind set point == rated"): + assert np.all( + model.prob.get_val("system_level_controller.wind_electricity_set_point", units="kW") + == model.prob.get_val("wind.rated_electricity_production", units="kW") + ) - with subtests.test("wind farm generates power"): - assert wind_out.sum() > 0 + with subtests.test("Natural gas plant set point"): + remaining_demand = model.prob.get_val( + "electrical_load_demand.electricity_demand_out", units="kW" + ) - model.prob.get_val("wind.electricity_out", units="kW") + ng_set_point = model.prob.get_val( + "system_level_controller.natural_gas_plant_electricity_set_point", units="kW" + ) + expected_ng_set_point = np.clip( + remaining_demand, + a_min=0.0, + a_max=model.prob.get_val("natural_gas_plant.rated_electricity_production", units="kW")[ + 0 + ], + ) + assert np.allclose(expected_ng_set_point, ng_set_point, rtol=1e-6, atol=1e-8) + + with subtests.test("Total unmet demand"): + assert ( + pytest.approx(0.0, rel=1e-6, abs=1e-8) + == model.prob.get_val( + "electrical_load_demand.unmet_electricity_demand_out", units="kW" + ).sum() + ) + + with subtests.test("Wind LCOE"): + assert pytest.approx(77.07060204, rel=1e-6) == model.prob.get_val( + "finance_subgroup_renewables.LCOE_profast_lco", units="USD/(MW*h)" + ) + with subtests.test("Natural gas LCOE"): + assert pytest.approx(85.5774049107076, rel=1e-6) == model.prob.get_val( + "finance_subgroup_natural_gas.LCOE", units="USD/(MW*h)" + ) + with subtests.test("Electricity LCOE"): + assert pytest.approx(80.79533451532551, rel=1e-6) == model.prob.get_val( + "finance_subgroup_electricity.LCOE", units="USD/(MW*h)" + ) + with subtests.test("Wind NPV"): + assert pytest.approx(-38.5777102298, rel=1e-6) == model.prob.get_val( + "finance_subgroup_renewables.NPV_electricity__profast_npv", units="MUSD" + ) @pytest.mark.unit @@ -32,18 +74,60 @@ def test_slc_yes_battery(subtests, temp_copy_of_example): model.run() - wind_out = model.prob.get_val("wind.electricity_out") + with subtests.test("Wind set point == rated"): + assert np.all( + model.prob.get_val("system_level_controller.wind_electricity_set_point", units="kW") + == model.prob.get_val("wind.rated_electricity_production", units="kW") + ) - with subtests.test("wind farm generates power"): - assert wind_out.sum() > 0 + with subtests.test("Battery set point"): + remaining_demand = model.prob.get_val( + "electrical_load_demand.electricity_demand_out", units="kW" + ) - model.prob.get_val("wind.electricity_out", units="kW") + battery_set_point = model.prob.get_val( + "system_level_controller.battery_electricity_set_point", units="kW" + ) + assert np.allclose(remaining_demand, battery_set_point, rtol=1e-6, atol=1e-8) - with subtests.test("lcoe"): + with subtests.test("Natural gas plant set point"): + remaining_demand = remaining_demand - model.prob.get_val( + "battery.electricity_out", units="kW" + ) + ng_set_point = model.prob.get_val( + "system_level_controller.natural_gas_plant_electricity_set_point", units="kW" + ) + expected_ng_set_point = np.clip( + remaining_demand, + a_min=0.0, + a_max=model.prob.get_val("natural_gas_plant.rated_electricity_production", units="kW")[ + 0 + ], + ) + assert np.allclose(expected_ng_set_point, ng_set_point, rtol=1e-6, atol=1e-8) + + with subtests.test("Total unmet demand"): assert ( - pytest.approx( - model.prob.get_val("finance_subgroup_electricity.LCOE", units="USD/(kW*h)"), - rel=1e-6, - ) - == 0.10902004 + pytest.approx(0.0, rel=1e-6, abs=1e-8) + == model.prob.get_val( + "electrical_load_demand.unmet_electricity_demand_out", units="kW" + ).sum() + ) + + with subtests.test("Wind LCOE"): + assert pytest.approx(77.07060204, rel=1e-6) == model.prob.get_val( + "finance_subgroup_renewables.LCOE_profast_lco", units="USD/(MW*h)" + ) + with subtests.test("Natural gas LCOE"): + assert pytest.approx(161.0833612618841, rel=1e-6) == model.prob.get_val( + "finance_subgroup_natural_gas.LCOE", units="USD/(MW*h)" + ) + with subtests.test("Electricity LCOE"): + assert pytest.approx(109.02003689718997, rel=1e-6) == model.prob.get_val( + "finance_subgroup_electricity.LCOE", units="USD/(MW*h)" + ) + with subtests.test("Wind NPV"): + assert pytest.approx(-38.5777102298, rel=1e-6) == model.prob.get_val( + "finance_subgroup_renewables.NPV_electricity__profast_npv", units="MUSD" ) From f08382cf24ae723f78401c933b8761b280e5aa9e Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 20 May 2026 09:46:05 -0600 Subject: [PATCH 098/105] Fixing example dir --- docs/control/system_level_control/slc_demand_following.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control/system_level_control/slc_demand_following.md b/docs/control/system_level_control/slc_demand_following.md index 8fea76611..95315f191 100644 --- a/docs/control/system_level_control/slc_demand_following.md +++ b/docs/control/system_level_control/slc_demand_following.md @@ -30,7 +30,7 @@ from pathlib import Path from h2integrate import EXAMPLE_DIR from IPython.display import HTML, display -os.chdir("EXAMPLE_DIR/35_system_level_control/battery_with_controller/") +os.chdir(EXAMPLE_DIR / "35_system_level_control/battery_with_controller/") h2i_model = H2IntegrateModel("wind_ng_demand.yaml") h2i_model.setup() From 1d519847a0e6521865e2af747eee70857387a468 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 20 May 2026 10:46:17 -0600 Subject: [PATCH 099/105] fixed failing test --- h2integrate/postprocess/test/test_sql_timeseries_to_csv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py b/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py index 35fd261d8..27903af9f 100644 --- a/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py +++ b/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py @@ -59,7 +59,7 @@ def test_save_csv_all_results(subtests, configuration, run_example_02_sql_fpath) res = save_case_timeseries_as_csv(run_example_02_sql_fpath, save_to_file=True) with subtests.test("Check number of columns"): - assert len(res.columns.to_list()) == 57 + assert len(res.columns.to_list()) == 55 with subtests.test("Check number of rows"): assert len(res) == 8760 From 784e9374b3c3c9022a77dd0cb061edb75139d53d Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 21 May 2026 10:30:15 -0600 Subject: [PATCH 100/105] Checkpointing progress on all controllers for techs --- h2integrate/converters/grid/test/test_grid.py | 2 +- h2integrate/core/h2integrate_model.py | 67 ++++++++++++++++++- h2integrate/core/supported_models.py | 2 + 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/h2integrate/converters/grid/test/test_grid.py b/h2integrate/converters/grid/test/test_grid.py index 7263c9659..4551144ca 100644 --- a/h2integrate/converters/grid/test/test_grid.py +++ b/h2integrate/converters/grid/test/test_grid.py @@ -390,7 +390,7 @@ def test_grid_integration_dt_1800(subtests, tmp_path): h2i.setup() demand = np.full(n_timesteps, demand_kw) - h2i.prob.set_val("grid.electricity_set_point", demand, units="kW") + h2i.prob.set_val("grid.electricity_demand", demand, units="kW") h2i.prob.run_model() expected_out = np.full(n_timesteps, demand_kw) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 67bbb0af2..0a2d42c8c 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -24,6 +24,7 @@ multivariable_streams, is_electricity_producer, ) +from h2integrate.control.control_strategies.passthrough_controller import PassthroughController from h2integrate.control.control_strategies.system_level.solver_options import ( SLCSolverOptionsConfig, ) @@ -757,6 +758,7 @@ def add_system_level_controller(self, slc_config): ) # Connect the controller's output back to the technology. + classifier = slc_config["tech_control_classifiers"][tech_name] if slc_config["storage_techs_to_control"].get(tech_name, False): # Storage tech with its own sub-controller: provide a demand # signal that the sub-controller translates into @@ -765,9 +767,18 @@ def add_system_level_controller(self, slc_config): f"system_level_controller.{tech_name}_{commodity}_demand", f"{tech_name}.{commodity}_demand", ) + elif classifier in ("flexible", "dispatchable"): + # Flexible / dispatchable techs always have a controller in + # their tech group (auto-injected PassthroughController if no + # user-defined control_strategy). Route the SLC output to the + # controller's demand input. + self.plant.connect( + f"system_level_controller.{tech_name}_{commodity}_set_point", + f"{tech_name}.{commodity}_demand", + ) else: - # All other techs (or storage without a sub-controller): - # provide a set-point directly to the performance model. + # Storage without a sub-controller: provide a set-point + # directly to the performance model. self.plant.connect( f"system_level_controller.{tech_name}_{commodity}_set_point", f"{tech_name}.{commodity}_set_point", @@ -936,6 +947,8 @@ def create_technology_models(self): self.cost_models.append(om_model_object) self.finance_models.append(om_model_object) + self._maybe_add_passthrough_controller(tech_group, comp, individual_tech_config) + continue # Process the models @@ -947,6 +960,7 @@ def create_technology_models(self): "cost_model", ] + perf_om_object = None for model_type in model_types: if model_type in individual_tech_config: om_model_object = self._process_model( @@ -958,6 +972,9 @@ def create_technology_models(self): plural_model_type_name = model_type + "s" getattr(self, plural_model_type_name).append(om_model_object) + if model_type == "performance_model": + perf_om_object = om_model_object + # Collect control classifier for system-level control if model_type == "performance_model" and self.slc: perf_cls = self.supported_models.get(perf_model) @@ -966,6 +983,11 @@ def create_technology_models(self): if classifier is not None: self.tech_control_classifiers[tech_name] = classifier + if perf_om_object is not None: + self._maybe_add_passthrough_controller( + tech_group, perf_om_object, individual_tech_config + ) + # Process the finance models if "finance_model" in individual_tech_config: if "model" in individual_tech_config["finance_model"]: @@ -1038,6 +1060,47 @@ def _check_control_classifier(self, model_name, model_object): msg = f"Model {model_name} is missing a control classifier" raise ValueError(msg) + def _maybe_add_passthrough_controller(self, tech_group, perf_comp, individual_tech_config): + """Automatically add a PassthroughController to a tech group if appropriate. + + A controller is auto-inserted only when: + - the technology has no user-defined ``control_strategy`` in its config, + - the performance model exposes a ``_control_classifier`` of + ``"flexible"`` or ``"dispatchable"``, + - the performance model has set ``commodity`` and ``commodity_rate_units`` + attributes (typically set in its ``initialize()``). + + The controller's ``{commodity}_demand`` input becomes the tech group's + external demand-input promoted at the tech group level, and its + ``{commodity}_set_point`` output is auto-connected (via promotion) to the + performance model's ``{commodity}_set_point`` input if one exists. + """ + if "control_strategy" in individual_tech_config: + return + classifier = getattr(perf_comp, "_control_classifier", None) + if classifier not in ("flexible", "dispatchable"): + return + commodity = getattr(perf_comp, "commodity", None) + commodity_rate_units = getattr(perf_comp, "commodity_rate_units", None) + if commodity is None or commodity_rate_units is None: + return + n_timesteps = int(self.plant_config["plant"]["simulation"]["n_timesteps"]) + controller = PassthroughController( + commodity=commodity, + n_timesteps=n_timesteps, + commodity_rate_units=commodity_rate_units, + ) + om_controller = tech_group.add_subsystem("controller", controller, promotes=["*"]) + self.control_strategies.append(om_controller) + + # Ensure the controller runs before the performance/cost models that + # consume its set_point output. Subsystem creation order otherwise + # places the controller last in the group's execution order. + existing_order = list(tech_group._static_subsystems_allprocs.keys()) + if "controller" in existing_order: + new_order = ["controller"] + [n for n in existing_order if n != "controller"] + tech_group.set_order(new_order) + def create_finance_model(self): """ Create and configure the finance model(s) for the plant. diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index 40d4737dd..563c899be 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -134,6 +134,7 @@ Himawari8SolarAPI, HimawariTMYSolarAPI, ) +from h2integrate.control.control_strategies.passthrough_controller import PassthroughController from h2integrate.converters.hydrogen.geologic.simple_natural_geoh2 import ( NaturalGeoH2PerformanceModel, ) @@ -318,6 +319,7 @@ "PeakLoadManagementOptimizedStorageController": (PeakLoadManagementOptimizedStorageController), "HeuristicLoadFollowingStorageController": HeuristicLoadFollowingStorageController, "OptimizedDispatchStorageController": OptimizedDispatchStorageController, + "PassthroughController": PassthroughController, "GenericDemandComponent": GenericDemandComponent, "FlexibleDemandComponent": FlexibleDemandComponent, # System-level control strategies From 83577e1c32849ff0c9b0b9ee0df042189582dc95 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 22 May 2026 11:21:55 -0600 Subject: [PATCH 101/105] Adding passthrough controller file --- .../passthrough_controller.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 h2integrate/control/control_strategies/passthrough_controller.py diff --git a/h2integrate/control/control_strategies/passthrough_controller.py b/h2integrate/control/control_strategies/passthrough_controller.py new file mode 100644 index 000000000..347ccaae7 --- /dev/null +++ b/h2integrate/control/control_strategies/passthrough_controller.py @@ -0,0 +1,88 @@ +import openmdao.api as om + + +class PassthroughController(om.ExplicitComponent): + """Simple controller that passes a demand signal directly through as a set-point. + + Every technology group is expected to have a controller subsystem. When a + technology does not define its own ``control_strategy``, this passthrough + controller is inserted automatically so that the group exposes a uniform + ``{commodity}_demand`` input and ``{commodity}_set_point`` output interface. + + In a system-level-control (SLC) configuration the SLC output is connected to + ``{commodity}_demand``; this component copies that signal to + ``{commodity}_set_point`` which the performance model consumes. + + When no SLC is present the input defaults to a very large value so that + production is unconstrained, making the component a harmless no-op. + """ + + _time_step_bounds = (0, float("inf")) + + def initialize(self): + self.options.declare("commodity", types=str) + self.options.declare("n_timesteps", types=int) + self.options.declare( + "commodity_rate_units", + types=str, + default=None, + desc="Units for the commodity rate (e.g. 'kW', 'kg/h'). " + "When provided, explicit units are used on the demand input " + "so the variable works even when unconnected (no SLC). " + "The set-point output always uses units_by_conn to inherit " + "units from the connected performance model.", + ) + + def setup(self): + commodity = self.options["commodity"] + n_timesteps = self.options["n_timesteps"] + commodity_rate_units = self.options["commodity_rate_units"] + + # Use explicit units on the input when available so that the + # variable remains valid even when no SLC is connected + # (units_by_conn fails on unconnected variables). + # Default to a large value so that when no SLC is connected the + # downstream performance model behaves as if unconstrained (the perf + # model typically saturates the set-point at its rated capacity). + # We avoid extreme values (e.g. 1e30) here because they pollute the + # NLBGS relative-residual check and cause premature false convergence + # in cyclic system-level control configurations. + default_val = 1.0e9 + + if commodity_rate_units is not None: + self.add_input( + f"{commodity}_demand", + val=default_val, + shape=n_timesteps, + desc=f"Demand signal for {commodity}", + units=commodity_rate_units, + ) + else: + self.add_input( + f"{commodity}_demand", + val=default_val, + shape=n_timesteps, + desc=f"Demand signal for {commodity}", + units_by_conn=True, + ) + + if commodity_rate_units is not None: + self.add_output( + f"{commodity}_set_point", + val=default_val, + shape=n_timesteps, + desc=f"Set point for {commodity} (passthrough of demand)", + units=commodity_rate_units, + ) + else: + self.add_output( + f"{commodity}_set_point", + val=default_val, + shape=n_timesteps, + desc=f"Set point for {commodity} (passthrough of demand)", + units_by_conn=True, + ) + + def compute(self, inputs, outputs): + commodity = self.options["commodity"] + outputs[f"{commodity}_set_point"] = inputs[f"{commodity}_demand"] From 8dbd7bd06707259f74931f8852f63637a03b08b2 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 22 May 2026 11:39:17 -0600 Subject: [PATCH 102/105] Updating tests --- .../system_level/test/test_slc_examples.py | 6 +++--- h2integrate/postprocess/test/test_sql_timeseries_to_csv.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index 0d81f137f..230f186a2 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -270,16 +270,16 @@ def test_slc_complex_profit_max(subtests, temp_copy_of_example): model.setup() model.prob.set_val( - "plant.electrical_load_demand.electricity_demand", + "electrical_load_demand.electricity_demand", demand_profile, ) model.prob.set_val( - "plant.system_level_controller.commodity_sell_price", + "system_level_controller.commodity_sell_price", sell_price, units="USD/(kW*h)", ) model.prob.set_val( - "plant.grid_buy.electricity_buy_price", + "grid_buy.electricity_buy_price", grid_buy_price, units="USD/(kW*h)", ) diff --git a/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py b/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py index 27903af9f..14a4dbaf3 100644 --- a/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py +++ b/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py @@ -59,7 +59,7 @@ def test_save_csv_all_results(subtests, configuration, run_example_02_sql_fpath) res = save_case_timeseries_as_csv(run_example_02_sql_fpath, save_to_file=True) with subtests.test("Check number of columns"): - assert len(res.columns.to_list()) == 55 + assert len(res.columns.to_list()) == 61 with subtests.test("Check number of rows"): assert len(res) == 8760 From b2f13c0238fc1d1c97c7e369483a0dc0253953db Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 22 May 2026 11:58:42 -0600 Subject: [PATCH 103/105] Added comments to passthrough controller --- h2integrate/core/h2integrate_model.py | 29 +++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 0a2d42c8c..b7e58b6f3 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -947,7 +947,7 @@ def create_technology_models(self): self.cost_models.append(om_model_object) self.finance_models.append(om_model_object) - self._maybe_add_passthrough_controller(tech_group, comp, individual_tech_config) + self._add_passthrough_controller(tech_group, comp, individual_tech_config) continue @@ -984,7 +984,7 @@ def create_technology_models(self): self.tech_control_classifiers[tech_name] = classifier if perf_om_object is not None: - self._maybe_add_passthrough_controller( + self._add_passthrough_controller( tech_group, perf_om_object, individual_tech_config ) @@ -1060,7 +1060,7 @@ def _check_control_classifier(self, model_name, model_object): msg = f"Model {model_name} is missing a control classifier" raise ValueError(msg) - def _maybe_add_passthrough_controller(self, tech_group, perf_comp, individual_tech_config): + def _add_passthrough_controller(self, tech_group, perf_comp, individual_tech_config): """Automatically add a PassthroughController to a tech group if appropriate. A controller is auto-inserted only when: @@ -1075,27 +1075,48 @@ def _maybe_add_passthrough_controller(self, tech_group, perf_comp, individual_te ``{commodity}_set_point`` output is auto-connected (via promotion) to the performance model's ``{commodity}_set_point`` input if one exists. """ + # Skip if the user has already specified a control strategy for this tech; + # their explicit choice takes precedence over the auto-injected passthrough. if "control_strategy" in individual_tech_config: return + + # Only flexible/dispatchable techs accept an externally provided demand + # signal. Fixed, feedstock, connector, and storage techs are handled + # elsewhere (storage uses its own sub-controller; fixed/feedstock have + # no set-point) and must not get a passthrough. classifier = getattr(perf_comp, "_control_classifier", None) if classifier not in ("flexible", "dispatchable"): return + + # The performance model must declare the commodity it produces and the + # units of its set-point so the PassthroughController can size its I/O + # consistently. If either is missing we have nothing to wire up. commodity = getattr(perf_comp, "commodity", None) commodity_rate_units = getattr(perf_comp, "commodity_rate_units", None) if commodity is None or commodity_rate_units is None: return + + # Build the controller sized to the plant's simulation horizon so its + # vector I/O matches the performance model's time-series I/O. n_timesteps = int(self.plant_config["plant"]["simulation"]["n_timesteps"]) controller = PassthroughController( commodity=commodity, n_timesteps=n_timesteps, commodity_rate_units=commodity_rate_units, ) + + # Promote all controller variables so: + # - `{commodity}_demand` becomes the tech group's external input + # (this is what the system-level controller connects to), and + # - `{commodity}_set_point` is auto-connected by name to the + # performance model's matching input via promotion. om_controller = tech_group.add_subsystem("controller", controller, promotes=["*"]) self.control_strategies.append(om_controller) # Ensure the controller runs before the performance/cost models that # consume its set_point output. Subsystem creation order otherwise - # places the controller last in the group's execution order. + # places the controller last in the group's execution order, which + # would delay the set_point by one solver iteration. existing_order = list(tech_group._static_subsystems_allprocs.keys()) if "controller" in existing_order: new_order = ["controller"] + [n for n in existing_order if n != "controller"] From dfe4cf4b4acdbb0c3e3b5183128635237635823a Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 22 May 2026 12:06:41 -0600 Subject: [PATCH 104/105] Fixing import statements --- h2integrate/core/supported_models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index 6bd2d8500..4d6040888 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -182,9 +182,9 @@ def copy(self): "SimpleGasConsumerCost": "converters.natural_gas.dummy_gas_components:SimpleGasConsumerCost", "GasStreamCombinerPerformanceModel": "transporters.gas_stream_combiner:GasStreamCombinerPerformanceModel", # System-level control strategies - "DemandFollowingControl": "control.control_strategies.system_level.demand_following:demand_following_control:DemandFollowingControl", - "CostMinimizationControl": "control.control_strategies.system_level.cost_minimization:cost_minimization_control:CostMinimizationControl", - "ProfitMaximizationControl": "control.control_strategies.system_level.profit_maximization:profit_maximization_control:ProfitMaximizationControl", + "DemandFollowingControl": "control.control_strategies.system_level.demand_following_control:DemandFollowingControl", + "CostMinimizationControl": "control.control_strategies.system_level.cost_minimization_control:CostMinimizationControl", + "ProfitMaximizationControl": "control.control_strategies.system_level.profit_maximization_control:ProfitMaximizationControl", } ) From 270b4d82e889b407644fa8cbb54035e0e6633c28 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 22 May 2026 15:16:57 -0600 Subject: [PATCH 105/105] Fixing tests and examples --- examples/16_natural_gas/plant_config.yaml | 2 +- examples/23_solar_wind_ng_demand/plant_config.yaml | 2 +- examples/24_solar_battery_grid/plant_config.yaml | 2 +- examples/33_peak_load_management/plant_config.yaml | 2 +- .../control_strategies/passthrough_controller.py | 4 ++-- .../converters/steel/cmu_electric_arc_furnace_dri.py | 12 ++++++------ .../steel/cmu_electric_arc_furnace_scrap.py | 12 ++++++------ 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/examples/16_natural_gas/plant_config.yaml b/examples/16_natural_gas/plant_config.yaml index 5024ff790..aa30629f7 100644 --- a/examples/16_natural_gas/plant_config.yaml +++ b/examples/16_natural_gas/plant_config.yaml @@ -26,7 +26,7 @@ technology_interconnections: # subtract the combined generation from the demand profile - [elec_combiner, electrical_load_demand, electricity, cable] # connect the remaining electricity demand to the NG plant - - [electrical_load_demand, natural_gas_plant, [unmet_electricity_demand_out, electricity_set_point]] + - [electrical_load_demand, natural_gas_plant, [unmet_electricity_demand_out, electricity_demand]] # connect NG feedstock to NG plant - [ng_feedstock, natural_gas_plant, natural_gas, pipe] # connect natural gas and solar to finance combiner diff --git a/examples/23_solar_wind_ng_demand/plant_config.yaml b/examples/23_solar_wind_ng_demand/plant_config.yaml index 5bf31591d..417448b50 100644 --- a/examples/23_solar_wind_ng_demand/plant_config.yaml +++ b/examples/23_solar_wind_ng_demand/plant_config.yaml @@ -27,7 +27,7 @@ technology_interconnections: # connect NG feedstock to NG plant - [combiner, electrical_load_demand, electricity, cable] # subtract wind and solar from demand - - [electrical_load_demand, natural_gas_plant, [unmet_electricity_demand_out, electricity_set_point]] + - [electrical_load_demand, natural_gas_plant, [unmet_electricity_demand_out, electricity_demand]] # give remaining load demand to natural gas plant - [combiner, fin_combiner, electricity, cable] - [natural_gas_plant, fin_combiner, electricity, cable] diff --git a/examples/24_solar_battery_grid/plant_config.yaml b/examples/24_solar_battery_grid/plant_config.yaml index 3fed5d46d..8c9298c03 100644 --- a/examples/24_solar_battery_grid/plant_config.yaml +++ b/examples/24_solar_battery_grid/plant_config.yaml @@ -27,7 +27,7 @@ technology_interconnections: # subtract variable generation from the demand - [bat_combiner, electrical_load_demand, electricity, cable] # connect remaining demand to grid buying - - [electrical_load_demand, grid_buy, [unmet_electricity_demand_out, electricity_set_point]] + - [electrical_load_demand, grid_buy, [unmet_electricity_demand_out, electricity_demand]] # connect surplus generation to grid selling - [electrical_load_demand, grid_sell, [unused_electricity_out, electricity_in]] # combine electricity generated from solar and electricity bought from grid diff --git a/examples/33_peak_load_management/plant_config.yaml b/examples/33_peak_load_management/plant_config.yaml index 93be1cecf..e7bc28ac4 100644 --- a/examples/33_peak_load_management/plant_config.yaml +++ b/examples/33_peak_load_management/plant_config.yaml @@ -12,4 +12,4 @@ technology_interconnections: # 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 - - [electrical_load_demand, grid_buy, [unmet_electricity_demand_out, electricity_set_point]] + - [electrical_load_demand, grid_buy, [unmet_electricity_demand_out, electricity_demand]] diff --git a/h2integrate/control/control_strategies/passthrough_controller.py b/h2integrate/control/control_strategies/passthrough_controller.py index 347ccaae7..a48aabd17 100644 --- a/h2integrate/control/control_strategies/passthrough_controller.py +++ b/h2integrate/control/control_strategies/passthrough_controller.py @@ -17,7 +17,7 @@ class PassthroughController(om.ExplicitComponent): production is unconstrained, making the component a harmless no-op. """ - _time_step_bounds = (0, float("inf")) + _time_step_bounds = (1, float("inf")) def initialize(self): self.options.declare("commodity", types=str) @@ -45,7 +45,7 @@ def setup(self): # downstream performance model behaves as if unconstrained (the perf # model typically saturates the set-point at its rated capacity). # We avoid extreme values (e.g. 1e30) here because they pollute the - # NLBGS relative-residual check and cause premature false convergence + # solver relative-residual check and cause premature false convergence # in cyclic system-level control configurations. default_val = 1.0e9 diff --git a/h2integrate/converters/steel/cmu_electric_arc_furnace_dri.py b/h2integrate/converters/steel/cmu_electric_arc_furnace_dri.py index 073171d52..bcdb78223 100644 --- a/h2integrate/converters/steel/cmu_electric_arc_furnace_dri.py +++ b/h2integrate/converters/steel/cmu_electric_arc_furnace_dri.py @@ -180,7 +180,7 @@ def setup(self): # Default the steel demand input as the production rate self.add_input( - "steel_demand", + "steel_set_point", val=units.convert_units( self.config.steel_production_rate_tonnes_per_year, "t/year", "t/h" ), @@ -328,19 +328,19 @@ def compute(self, inputs, outputs): } # steel demand, saturated at maximum rated system capacity - steel_demand = np.where( - inputs["steel_demand"] > system_production, + steel_set_point = np.where( + inputs["steel_set_point"] > system_production, system_production, - inputs["steel_demand"], + inputs["steel_set_point"], ) # initialize an array of how much steel could be produced # from the available feedstocks and the demand steel_from_feedstocks = np.zeros( - (len(feedstocks_usage_per_tonne_steel) + 1, len(inputs["steel_demand"])) + (len(feedstocks_usage_per_tonne_steel) + 1, len(inputs["steel_set_point"])) ) # first entry is the steel demand - steel_from_feedstocks[0] = steel_demand + steel_from_feedstocks[0] = steel_set_point ii = 1 for feedstock_type, consumption_rate in feedstocks_usage_per_tonne_steel.items(): diff --git a/h2integrate/converters/steel/cmu_electric_arc_furnace_scrap.py b/h2integrate/converters/steel/cmu_electric_arc_furnace_scrap.py index 492babb34..a6ed8debe 100644 --- a/h2integrate/converters/steel/cmu_electric_arc_furnace_scrap.py +++ b/h2integrate/converters/steel/cmu_electric_arc_furnace_scrap.py @@ -142,7 +142,7 @@ def setup(self): # Default the steel demand input as the production rate self.add_input( - "steel_demand", + "steel_set_point", val=units.convert_units( self.config.steel_production_rate_tonnes_per_year, "t/year", "t/h" ), @@ -260,19 +260,19 @@ def compute(self, inputs, outputs): } # steel demand, saturated at maximum rated system capacity - steel_demand = np.where( - inputs["steel_demand"] > system_production, + steel_set_point = np.where( + inputs["steel_set_point"] > system_production, system_production, - inputs["steel_demand"], + inputs["steel_set_point"], ) # initialize an array of how much steel could be produced # from the available feedstocks and the demand steel_from_feedstocks = np.zeros( - (len(feedstocks_usage_per_tonne_steel) + 1, len(inputs["steel_demand"])) + (len(feedstocks_usage_per_tonne_steel) + 1, len(inputs["steel_set_point"])) ) # first entry is the steel demand - steel_from_feedstocks[0] = steel_demand + steel_from_feedstocks[0] = steel_set_point ii = 1 for feedstock_type, consumption_rate in feedstocks_usage_per_tonne_steel.items():