diff --git a/CHANGELOG.md b/CHANGELOG.md index 7705cb287..22a1cac58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Update N2 diagram for demand openloop control from static and outdated to dynamic and interactive [PR 714](https://github.com/NatLabRockies/H2Integrate/pull/714) - Added basic check of 4-length connections in `technology_interconnections` [PR 720](https://github.com/NatLabRockies/H2Integrate/pull/720) - Update N2 diagram for Pyomo heuristic control from static image to dynamic and interactive embedded diagram [PR 726](https://github.com/NatLabRockies/H2Integrate/pull/726) +- Added ability to use timeseries for finance calculations [PR 725](https://github.com/NatLabRockies/H2Integrate/pull/725) ## 0.8 [April 15, 2026] - Updated README and docs intro page with expanded H2I description, reorganized sections, and streamlined installation instructions [PR 677](https://github.com/NatLabRockies/H2Integrate/pull/677) diff --git a/docs/user_guide/specifying_finance_parameters.md b/docs/user_guide/specifying_finance_parameters.md index 3f12b4e4c..2a375386b 100644 --- a/docs/user_guide/specifying_finance_parameters.md +++ b/docs/user_guide/specifying_finance_parameters.md @@ -45,6 +45,9 @@ Within this framework, there are two distinct layers, **finance groups** and **f A text label to further distinguish outputs for a commodity. This is particularly useful when multiple finance models or subgroups reference the same commodity but need to produce separate outputs. - `commodity_stream` (optional): A text label of a technology that outputs the specified ``commodity`` to use as the commodity production stream in finance calculations. This is particularly useful when wanting to choose a specific commodity stream to use in finance calculations (such as the outputs of combiners or splitters) + - The below two parameters are only needed to [use a specified timeseries profile for finance calculations](fin:commodity_output_streams) + - `use_commodity_stream_timeseries` (optional): A boolean that defaults to False. If True, then flags to use a timeseries profile for the finance calculation rather than the capacity factor and rated commodity production of the `commodity_stream` technology. + - `commodity_stream_output`: The name of timeseries profile output variable from `commodity_stream` to use for the finance calculation(s). This parameter is required if `use_commodity_stream_timeseries` is True. ```{important} If no subgroups are defined, a **default subgroup** is created that contains *all technologies* and references the default finance model and commodity defined in `finance_groups`. @@ -159,3 +162,45 @@ Examples: - Finance groups must not include a key named "default", as this is reserved for internal use. - Each subgroup must reference valid technology keys from technology_config['technologies']. Invalid keys raise errors. - Finance models must be listed in `self.supported_models`. Unknown models raise errors. + +(fin:commodity_output_streams)= +## Using custom output streams for finance calculations +Typically, the finance models use the capacity factor and rated commodity production rate of the technology specified as the `commodity_stream` for the finance calculations. + +In some cases, the capacity factor or rated commodity production rate are not available or may not contain the information desired for the finance calculation. Some technologies, such as splitters, don't output the capacity factor or rated commodity production, they just output two timeseries profiles. Other technologies, such as the grid, output the capacity factor based on the electricity bought, but not the electricity sold. Below illustrates how to use a specified timeseries profile for the finance calculations rather than the capacity factor and rated commodity production rate output from the `commodity_stream` technology. + +Below shows three different finance subgroups that use the timeseries outputs. +- `subgroup_a` is using the `wind.electricity_out` profile for the finance calculation. +- `subgroup_b` is using the `splitter.electricity_out1` profile for the finance calculation. +- `subgroup_c` is using the `grid.electricity_sold` profile for the finance calculation. + +To use this functionality, `use_commodity_stream_timeseries` must be True and `commodity_stream_output` must be specified. + +General format: +```yaml +finance_parameters: + finance_groups: + finance_model: ProFastLCO + model_inputs: #dictionary of inputs for ProFastLCO + finance_subgroups: + subgroup_a: + commodity: electricity #required + commodity_stream: wind # technology that electricity is output from + use_commodity_stream_timeseries: true + commodity_stream_output: electricity_out + technologies: [wind] + subgroup_b: + commodity: electricity #required + commodity_stream: splitter + use_commodity_stream_timeseries: true + commodity_stream_output: electricity_out1 + technologies: [wind, electrolyzer] + subgroup_c: + commodity: electricity #required + commodity_stream: grid + use_commodity_stream_timeseries: true + commodity_stream_output: electricity_sold + technologies: [grid] +``` + +- [Example 17](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/17_splitter_wind_doc_h2/plant_config.yaml) diff --git a/examples/17_splitter_wind_doc_h2/plant_config.yaml b/examples/17_splitter_wind_doc_h2/plant_config.yaml index 187c6cd12..227a66922 100644 --- a/examples/17_splitter_wind_doc_h2/plant_config.yaml +++ b/examples/17_splitter_wind_doc_h2/plant_config.yaml @@ -52,8 +52,21 @@ finance_parameters: electricity: commodity: electricity technologies: [wind] + electricity_doc: + commodity: electricity + commodity_stream: electricity_splitter + use_commodity_stream_timeseries: true + commodity_stream_output: electricity_out1 + technologies: [wind, doc] + electricity_electrolyzer: + commodity: electricity + commodity_stream: electricity_splitter + use_commodity_stream_timeseries: true + commodity_stream_output: electricity_out2 + technologies: [wind, electrolyzer] hydrogen: commodity: hydrogen + commodity_stream: electrolyzer technologies: [wind, electrolyzer] co2: commodity: co2 diff --git a/examples/test/test_all_examples.py b/examples/test/test_all_examples.py index a417d2670..69b3e40a3 100644 --- a/examples/test/test_all_examples.py +++ b/examples/test/test_all_examples.py @@ -10,6 +10,7 @@ from h2integrate import ROOT_DIR from h2integrate.core.file_utils import load_yaml from h2integrate.core.h2integrate_model import H2IntegrateModel +from h2integrate.core.inputs.validation import load_plant_yaml ROOT = Path(__file__).parents[1] @@ -646,8 +647,38 @@ def test_wind_wave_doc_example(subtests, temp_copy_of_example): def test_splitter_wind_doc_h2_example(subtests, temp_copy_of_example): example_folder = temp_copy_of_example + new_finance_subgroup_h2 = { + "hydrogen_ts": { + "commodity": "hydrogen", + "commodity_stream": "electrolyzer", + "use_commodity_stream_timeseries": True, + "commodity_stream_output": "hydrogen_out", + "technologies": ["wind", "electrolyzer"], + } + } + + new_finance_subgroup_wind = { + "electricity_ts": { + "commodity": "electricity", + "commodity_stream": "wind", + "use_commodity_stream_timeseries": True, + "commodity_stream_output": "electricity_out", + "technologies": ["wind"], + } + } + + plant_config = load_plant_yaml(example_folder / "plant_config.yaml") + + plant_config["finance_parameters"]["finance_subgroups"].update(new_finance_subgroup_h2) + plant_config["finance_parameters"]["finance_subgroups"].update(new_finance_subgroup_wind) + + top_level_config = { + "plant_config": plant_config, + "technology_config": example_folder / "tech_config.yaml", + "driver_config": example_folder / "driver_config.yaml", + } # Create a H2Integrate model - model = H2IntegrateModel(example_folder / "offshore_plant_splitter_doc_h2.yaml") + model = H2IntegrateModel(top_level_config) # Run the model model.run() @@ -679,6 +710,23 @@ def test_splitter_wind_doc_h2_example(subtests, temp_copy_of_example): == 9.8059083 ) + with subtests.test( + "Check LCOH (using timeseries) is less than LCOH using lifetime performance" + ): + assert ( + model.prob.get_val("finance_subgroup_hydrogen_ts.LCOH", units="USD/kg")[0] + < model.prob.get_val("finance_subgroup_hydrogen.LCOH", units="USD/kg")[0] + ) + + with subtests.test("Check LCOH (using timeseries)"): + assert ( + pytest.approx( + model.prob.get_val("finance_subgroup_hydrogen_ts.LCOH", units="USD/kg")[0], + rel=1e-3, + ) + == 9.34595395123 + ) + with subtests.test("Check LCOC"): assert ( pytest.approx( @@ -696,6 +744,58 @@ def test_splitter_wind_doc_h2_example(subtests, temp_copy_of_example): == 132.395036462 ) + with subtests.test("Check LCOE (using timeseries)"): + assert ( + pytest.approx( + model.prob.get_val("finance_subgroup_electricity_ts.LCOE", units="USD/(MW*h)")[0], + rel=1e-3, + ) + == model.prob.get_val("finance_subgroup_electricity.LCOE", units="USD/(MW*h)")[0] + ) + + with subtests.test("Check LCOE (doc)"): + assert ( + pytest.approx( + model.prob.get_val("finance_subgroup_electricity_doc.LCOE", units="USD/(MW*h)")[0], + rel=1e-3, + ) + == 674.2414136935529 + ) + + with subtests.test("Check finance_subgroup_electricity_doc electricity inputs"): + assert ( + pytest.approx( + model.prob.get_val( + "finance_subgroup_electricity_doc.rated_electricity_production", units="kW" + ), + rel=1e-6, + ) + == model.prob.get_val("doc.electricity_in", units="kW").mean() + ) + + with subtests.test("Check LCOE (electrolyzer)"): + assert ( + pytest.approx( + model.prob.get_val( + "finance_subgroup_electricity_electrolyzer.LCOE", units="USD/(MW*h)" + )[0], + rel=1e-3, + ) + == 182.8942790183688 + ) + + with subtests.test("Check finance_subgroup_electricity_electrolyzer electricity inputs"): + assert ( + pytest.approx( + model.prob.get_val( + "finance_subgroup_electricity_electrolyzer.rated_electricity_production", + units="kW", + ), + rel=1e-6, + ) + == model.prob.get_val("electrolyzer.electricity_in", units="kW").mean() + ) + @pytest.mark.integration @pytest.mark.parametrize( diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 67bd83085..84654549c 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -11,7 +11,7 @@ from h2integrate.core.utilities import create_xdsm_from_config from h2integrate.core.dict_utils import check_inputs from h2integrate.core.file_utils import get_path, find_file, load_yaml -from h2integrate.finances.finances import AdjustedCapexOpexComp +from h2integrate.finances.finances import AdjustedCapexOpexComp, AdjustedCapacityFactorComp from h2integrate.core.supported_models import ( no_cost_models, supported_models, @@ -798,7 +798,6 @@ def create_finance_model(self): ) tech_names = subgroup_params.get("technologies") commodity_stream = subgroup_params.get("commodity_stream", None) - if isinstance(finance_group_names, str): finance_group_names = [finance_group_names] @@ -839,9 +838,16 @@ def create_finance_model(self): "commodity": commodity, "commodity_stream": commodity_stream, "is_system_finance_model": True, + "use_commodity_stream_timeseries": subgroup_params.get( + "use_commodity_stream_timeseries", False + ), + "commodity_stream_output": subgroup_params.get( + "commodity_stream_output", None + ), } } ) + finance_subgroup = om.Group() # Default logic for handling cases without specified commodity streams @@ -1002,6 +1008,24 @@ def create_finance_model(self): # uniquely named outputs commodity_output_desc = commodity_output_desc + f"_{finance_group_name}" + if finance_subgroups[subgroup_name]["use_commodity_stream_timeseries"]: + if ( + finance_subgroups[subgroup_name].get("commodity_stream_output", None) + is None + ): + msg = ( + "`commodity_stream_output` is a required input if " + f"`use_commodity_stream_timeseries` is True. Please add the " + f"`commodity_stream_output` for finance subgroup `{subgroup_name}`" + ) + raise ValueError(msg) + + adj_cf_comp = AdjustedCapacityFactorComp( + plant_config=filtered_plant_config, + commodity_type=commodity, + ) + finance_subgroup.add_subsystem("adjusted_cf_comp", adj_cf_comp, promotes=["*"]) + # create the finance component fin_comp = fin_model( driver_config=self.driver_config, @@ -1298,17 +1322,24 @@ def connect_technologies(self): is_system_finance_model = group_configs.get("is_system_finance_model") if is_system_finance_model: - # Connect the rated commodity production and capacity factor - # for system-level finance models - self.plant.connect( - f"{commodity_stream}.rated_{primary_commodity_type}_production", - f"finance_subgroup_{group_id}.rated_{primary_commodity_type}_production", - ) + if group_configs.get("use_commodity_stream_timeseries", False): + # TODO: finish this logic + self.plant.connect( + f"{commodity_stream}.{group_configs.get('commodity_stream_output')}", + f"finance_subgroup_{group_id}.{primary_commodity_type}_produced", + ) + else: + # Connect the rated commodity production and capacity factor + # for system-level finance models + self.plant.connect( + f"{commodity_stream}.rated_{primary_commodity_type}_production", + f"finance_subgroup_{group_id}.rated_{primary_commodity_type}_production", + ) - self.plant.connect( - f"{commodity_stream}.capacity_factor", - f"finance_subgroup_{group_id}.capacity_factor", - ) + self.plant.connect( + f"{commodity_stream}.capacity_factor", + f"finance_subgroup_{group_id}.capacity_factor", + ) # Only connect technologies that are included in the finance stackup for tech_name in tech_configs.keys(): diff --git a/h2integrate/core/test/test_framework.py b/h2integrate/core/test/test_framework.py index b080e935b..b02718f21 100644 --- a/h2integrate/core/test/test_framework.py +++ b/h2integrate/core/test/test_framework.py @@ -14,6 +14,39 @@ from h2integrate.core.inputs.validation import load_tech_yaml, load_plant_yaml, load_driver_yaml +@pytest.mark.integration +@pytest.mark.parametrize( + "example_folder,resource_example_folder", [("17_splitter_wind_doc_h2", None)] +) +def test_use_commodity_stream_timeseries_finances_error(subtests, temp_copy_of_example): + example_folder = temp_copy_of_example + plant_config = load_plant_yaml(example_folder / "plant_config.yaml") + driver_config = load_driver_yaml(example_folder / "driver_config.yaml") + tech_config = load_tech_yaml(example_folder / "tech_config.yaml") + + # Remove commodity_stream_output from finace subgroup + plant_config["finance_parameters"]["finance_subgroups"]["electricity_doc"].pop( + "commodity_stream_output" + ) + top_level_config = { + "plant_config": plant_config, + "technology_config": tech_config, + "driver_config": driver_config, + } + + with pytest.raises(ValueError) as excinfo: + H2IntegrateModel(top_level_config) + err = str(excinfo.value) + with subtests.test("Commodity stream name is missing (commodity_stream_output is required)"): + assert "`commodity_stream_output` is a required input" in err + with subtests.test( + "Commodity stream name is missing (use_commodity_stream_timeseries is True)" + ): + assert "`use_commodity_stream_timeseries` is True" in err + with subtests.test("Commodity stream name is missing (finance subgroup `electricity_doc`)"): + assert "finance subgroup `electricity_doc`" in err + + @pytest.mark.integration @pytest.mark.parametrize("example_folder,resource_example_folder", [("01_onshore_steel_mn", None)]) def test_check_tech_interconnections(subtests, temp_copy_of_example): diff --git a/h2integrate/finances/finances.py b/h2integrate/finances/finances.py index d5dfe8f2a..3db49dbd2 100644 --- a/h2integrate/finances/finances.py +++ b/h2integrate/finances/finances.py @@ -85,3 +85,61 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): outputs["total_capex_adjusted"] = total_capex_adjusted outputs["total_opex_adjusted"] = total_opex_adjusted outputs["total_varopex_adjusted"] = total_varopex_adjusted + + +class AdjustedCapacityFactorComp(om.ExplicitComponent): + """OpenMDAO component to compute an adjusted capacity factor for a given commodity. + + This component takes in a timeseries of commodity production values and computes + the rated (mean) production and a capacity factor for each year of the plant's + lifetime. Currently, the capacity factor is set to 1.0 by default. + + Inputs: + {commodity}_produced (array): Timeseries of commodity production values + with shape ``(n_timesteps,)``. Units are determined by connection. + + Outputs: + rated_{commodity}_production (float): Mean commodity production across all + timesteps. Units are copied from the input. + capacity_factor (array): Capacity factor for each year of plant life, + with shape ``(plant_life,)``. Unitless. + """ + + def initialize(self): + self.options.declare("plant_config", types=dict) + self.options.declare("commodity_type", types=str) + + def setup(self): + self.commodity = self.options["commodity_type"] + plant_life = int(self.options["plant_config"]["plant"]["plant_life"]) + n_timesteps = int(self.options["plant_config"]["plant"]["simulation"]["n_timesteps"]) + + self.add_input( + f"{self.commodity}_produced", + val=0.0, + units_by_conn=True, + shape=n_timesteps, + ) + + self.add_output( + f"rated_{self.commodity}_production", + val=0.0, + copy_units=f"{self.commodity}_produced", + shape=1, + ) + + self.add_output( + "capacity_factor", + val=1.0, + units="unitless", + shape=plant_life, + ) + + def compute(self, inputs, outputs): + # Take the average of the timeseries profile + outputs[f"rated_{self.commodity}_production"] = np.mean( + inputs[f"{self.commodity}_produced"] + ) + + # Set capacity factor equal to one + outputs["capacity_factor"] = 1.0 diff --git a/h2integrate/finances/test/test_finance_components.py b/h2integrate/finances/test/test_finance_components.py new file mode 100644 index 000000000..6c4f888db --- /dev/null +++ b/h2integrate/finances/test/test_finance_components.py @@ -0,0 +1,52 @@ +import numpy as np +import pytest +import openmdao.api as om + +from h2integrate.finances.finances import AdjustedCapacityFactorComp + + +@pytest.fixture +def plant_config(n_timesteps): + plant = { + "plant": { + "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": n_timesteps, + }, + }, + } + return plant + + +@pytest.mark.unit +@pytest.mark.parametrize("n_timesteps", [8760]) +def test_adjusted_cf_comp(plant_config, subtests): + rng = np.random.default_rng(seed=0) + electricity_input = rng.random(8760) * 1e3 + + prob = om.Problem() + comp = AdjustedCapacityFactorComp(plant_config=plant_config, commodity_type="electricity") + prob.model.add_subsystem("comp", comp, promotes=["*"]) + + ivc = om.IndepVarComp() + ivc.add_output("electricity_produced", val=np.zeros(8760), units="kW") + prob.model.add_subsystem("ivc", ivc, promotes=["*"]) + + prob.setup() + prob.set_val("ivc.electricity_produced", val=electricity_input, units="kW") + prob.run_model() + + with subtests.test("Check rated_electricity_production calc"): + assert ( + pytest.approx( + prob.model.get_val("comp.rated_electricity_production", units="kW"), rel=1e-6 + ) + == electricity_input.mean() + ) + + with subtests.test("Check capacity factor"): + assert np.all(prob.model.get_val("comp.capacity_factor", units="unitless")) == 1.0 + + with subtests.test("Capacity factor length"): + assert len(prob.model.get_val("comp.capacity_factor", units="unitless")) == 30