diff --git a/docs/_toc.yml b/docs/_toc.yml index 40569c10e..08d048a91 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -12,6 +12,7 @@ parts: chapters: - file: user_guide/model_overview - file: user_guide/how_to_set_up_an_analysis + - file: user_guide/expected_naming_convention - file: user_guide/connecting_technologies - file: user_guide/defining_sites_and_resources - file: user_guide/design_optimization_in_h2i diff --git a/docs/user_guide/expected_naming_convention.md b/docs/user_guide/expected_naming_convention.md new file mode 100644 index 000000000..447e57297 --- /dev/null +++ b/docs/user_guide/expected_naming_convention.md @@ -0,0 +1,39 @@ +# Expected Technology Naming Convention in H2I + +Some logic within H2I relies on expected naming conventions of technologies in the technology configuration file. The following details the naming convention required for H2I to run properly. + +```yaml +technologies: + tech_name_A: #these are the keys with specific required naming convention + performance_model: + model: + cost_model: + model: + model_inputs: + performance_parameters: +``` + +- if using the `GenericCombinerPerformanceModel` as the technology performance model, the technology name must include the name `combiner`. Some examples of valid technology names are: + + 'combiner' + + 'combiner_1' + + 'electricity_combiner' + + 'hydrogen_combiner_2' +- if using the `GenericSplitterPerformanceModel` as the technology performance model, the technology name must include the name `splitter`. Some examples of valid technology names are: + + 'splitter' + + 'splitter_1' + + 'electricity_splitter' + + 'hydrogen_splitter_2' +- if using the `FeedstockPerformanceModel` as the technology performance model and the `FeedstockCostModel` as the technology cost model, the technology name must include the name `feedstock`. Some examples of valid technology names are: + + 'feedstock' + + 'water_feedstock' + + 'feedstock_iron_ore' + + 'natural_gas_feedstock_tank' +- If a transport component is defined in the technology configuration file (most likely to transport commodities that cannot be transported by 'pipe' or 'cable'), the technology name must include the name `transport`. Some examples of valid technology names are: + + 'transport' + + 'lime_transport' + + 'transport_iron_ore' + + 'reformer_catalyst_transport_tube' + +```{note} +These naming convention limitations are known and you can track them in this issue: https://github.com/NatLabRockies/H2Integrate/issues/374 +``` diff --git a/examples/01_onshore_steel_mn/run_onshore_steel_mn.py b/examples/01_onshore_steel_mn/run_onshore_steel_mn.py index 8ce32f468..04af71ab7 100644 --- a/examples/01_onshore_steel_mn/run_onshore_steel_mn.py +++ b/examples/01_onshore_steel_mn/run_onshore_steel_mn.py @@ -1,8 +1,13 @@ +import os +from pathlib import Path + import numpy as np from h2integrate.core.h2integrate_model import H2IntegrateModel +os.chdir(Path(__file__).parent) + # Create a H2Integrate model model = H2IntegrateModel("01_onshore_steel_mn.yaml") # Set battery demand profile to electrolyzer capacity diff --git a/examples/08_wind_electrolyzer/user_finance_model/simple_lco.py b/examples/08_wind_electrolyzer/user_finance_model/simple_lco.py index 89f993f9b..993cfd9cf 100644 --- a/examples/08_wind_electrolyzer/user_finance_model/simple_lco.py +++ b/examples/08_wind_electrolyzer/user_finance_model/simple_lco.py @@ -61,6 +61,11 @@ def setup(self): for tech in tech_config: self.add_input(f"capex_adjusted_{tech}", val=0.0, units="USD") self.add_input(f"opex_adjusted_{tech}", val=0.0, units="USD/year") + # Below is required for all system-level finance models but is unused in this one + self.add_input( + f"replacement_schedule_{tech}", val=0.0, shape=plant_life, units="unitless" + ) + self.add_input(f"varopex_adjusted_{tech}", val=0.0, shape=plant_life, units="USD/year") # add plant life to the input config dictionary finance_config = self.options["plant_config"]["finance_parameters"]["model_inputs"] diff --git a/h2integrate/core/feedstocks.py b/h2integrate/core/feedstocks.py index 02541bfdc..3a4fc5fd0 100644 --- a/h2integrate/core/feedstocks.py +++ b/h2integrate/core/feedstocks.py @@ -90,6 +90,7 @@ def setup(self): additional_cls_name=self.__class__.__name__, ) n_timesteps = self.options["plant_config"]["plant"]["simulation"]["n_timesteps"] + plant_life = int(self.options["plant_config"]["plant"]["plant_life"]) super().setup() @@ -107,6 +108,9 @@ def setup(self): desc=f"Consumption profile of {self.config.commodity}", ) + # lifetime estimate of item replacements, represented as a fraction of the capacity. + self.add_output("replacement_schedule", val=0.0, shape=plant_life, units="unitless") + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): price = inputs["price"] hourly_consumption = inputs[f"{self.config.commodity}_consumed"] diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index c0d643108..3ec18e528 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -766,6 +766,7 @@ def create_finance_model(self): "tech_configs": tech_configs, "commodity": commodity, "commodity_stream": commodity_stream, + "system_finance_model": True, } } ) @@ -836,6 +837,9 @@ def create_finance_model(self): "adjusted_capex_opex_comp", adjusted_capex_opex_comp, promotes=["*"] ) + # Initialize counter to check if invalid combination of finance + # groups exist within a finance subgroup + n_tech_finances_in_group = 0 for finance_group_name in finance_group_names: # check if using tech-specific finance model if any( @@ -848,11 +852,13 @@ def create_finance_model(self): # this is created in create_technologies() if tech_finance_group_name is not None: + n_tech_finances_in_group += 1 # tech specific finance models are created in create_technologies() # and do not need to be included in the general finance models. # set commodity_stream to None so that inputs needed for system-level # finance models are not connected to tech-specific finance models. - finance_subgroups[subgroup_name].update({"commodity_stream": None}) + # finance_subgroups[subgroup_name].update({"commodity_stream": None}) + finance_subgroups[subgroup_name].update({"system_finance_model": False}) continue # if not using a tech-specific finance group, get the finance model and inputs for @@ -896,12 +902,18 @@ def create_finance_model(self): # check if multiple finance models are specified for the subgroup if len(finance_group_names) > 1: # check that the finance model groups do not include tech-specific finances - non_tech_finances = [ - k - for k in finance_group_names - if k in self.plant_config["finance_parameters"]["finance_groups"] - ] + finance_groups = self.plant_config["finance_parameters"]["finance_groups"] + non_tech_finances = [k for k in finance_group_names if k in finance_groups] + tech_finances = [k for k in finance_group_names if k not in finance_groups] + if n_tech_finances_in_group > 0 and non_tech_finances: + msg = ( + f"Cannot run a tech-specific finance model ({tech_finances}) in the " + f"same finance subgroup as a system-level finance model " + f"({non_tech_finances}). Please modify the finance_groups in finance " + f"subgroup {subgroup_name}." + ) + raise ValueError(msg) # if multiple non-tech specific finance model groups are specified for the # subgroup, the outputs of the finance model must have unique names to # avoid errors. @@ -998,12 +1010,6 @@ def connect_technologies(self): f"{connection_name}.{transport_item}_in", ) - elif "storage" in source_tech: - # Connect the source technology to the connection component - self.plant.connect( - f"{source_tech}.{transport_item}_out", - f"{connection_name}.{transport_item}_in", - ) else: # Connect the source technology to the connection component self.plant.connect( @@ -1035,13 +1041,6 @@ def connect_technologies(self): f"{dest_tech}.{transport_item}_capacity_factor{combiner_counts[dest_tech]}", ) - elif "storage" in dest_tech: - # Connect the connection component to the destination technology - self.plant.connect( - f"{connection_name}.{transport_item}_out", - f"{dest_tech}.{transport_item}_in", - ) - else: # Connect the connection component to the destination technology self.plant.connect( @@ -1126,7 +1125,11 @@ def connect_technologies(self): tech_configs = group_configs.get("tech_configs") primary_commodity_type = group_configs.get("commodity") commodity_stream = group_configs.get("commodity_stream") - if commodity_stream is not None: + is_system_finance_model = group_configs.get("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", @@ -1142,6 +1145,8 @@ def connect_technologies(self): # For now, assume splitters and combiners do not add any costs if "splitter" in tech_name or "combiner" in tech_name: continue + if tech_name == "cable" or tech_name == "pipe": + continue self.plant.connect( f"{tech_name}.CapEx", @@ -1158,10 +1163,11 @@ def connect_technologies(self): f"finance_subgroup_{group_id}.cost_year_{tech_name}", ) - if "electrolyzer" in tech_name: + if is_system_finance_model and "transport" not in tech_name: + # connect replacement schedule to system-level finance models self.plant.connect( - f"{tech_name}.time_until_replacement", - f"finance_subgroup_{group_id}.{tech_name}_time_until_replacement", + f"{tech_name}.replacement_schedule", + f"finance_subgroup_{group_id}.replacement_schedule_{tech_name}", ) self.plant.options["auto_order"] = True diff --git a/h2integrate/core/test/test_framework.py b/h2integrate/core/test/test_framework.py index aab13e483..7625ac599 100644 --- a/h2integrate/core/test/test_framework.py +++ b/h2integrate/core/test/test_framework.py @@ -395,3 +395,41 @@ def test_reports_turned_off(temp_dir): assert ( len(report_dirs) == 0 ), f"Report directories were created despite create_om_reports=False: {report_dirs}" + + +@pytest.mark.unit +def test_invalid_finance_group_combination(subtests): + driver_config = load_driver_yaml(EXAMPLE_DIR / "01_onshore_steel_mn" / "driver_config.yaml") + tech_config = load_tech_yaml(EXAMPLE_DIR / "01_onshore_steel_mn" / "tech_config.yaml") + plant_config = load_plant_yaml(EXAMPLE_DIR / "01_onshore_steel_mn" / "plant_config.yaml") + + invalid_finance_subgroup = { + "commodity": "steel", + "finance_groups": ["steel", "profast_model"], + "technologies": ["steel"], + } + + plant_config["finance_parameters"]["finance_subgroups"].update( + {"steel_buggy": invalid_finance_subgroup} + ) + + h2i_config = { + "name": "H2I", + "system_summary": "", + "driver_config": driver_config, + "technology_config": tech_config, + "plant_config": plant_config, + } + + with subtests.test("Test invalid finance groups"): + expected_msg = ( + "Cannot run a tech-specific finance model (['steel']) in the " + "same finance subgroup as a system-level finance model " + "(['profast_model']). Please modify the finance_groups in finance " + "subgroup steel_buggy." + ) + + with pytest.raises(ValueError) as excinfo: + h2i = H2IntegrateModel(h2i_config) + h2i.setup() + assert expected_msg == str(excinfo.value) diff --git a/h2integrate/finances/numpy_financial_npv.py b/h2integrate/finances/numpy_financial_npv.py index c7509eff7..4ab6c1396 100644 --- a/h2integrate/finances/numpy_financial_npv.py +++ b/h2integrate/finances/numpy_financial_npv.py @@ -50,7 +50,7 @@ class NumpyFinancialNPV(om.ExplicitComponent): positive. This follows the NumPy Financial convention: Reference: - NumpPy Financial NPV documentation: + NumPy Financial NPV documentation: https://numpy.org/numpy-financial/latest/npv.html#numpy_financial.npv By convention: @@ -133,10 +133,9 @@ def setup(self): shape=plant_config["plant"]["plant_life"], units="USD/year", ) - - # TODO: update below with standardized naming - if "electrolyzer" in tech_config: - self.add_input("electrolyzer_time_until_replacement", units="h") + self.add_input( + f"replacement_schedule_{tech}", val=0.0, shape=plant_life, units="unitless" + ) self.add_input( f"sell_price_{self.output_txt}", @@ -238,20 +237,20 @@ def compute(self, inputs, outputs): # Find refurbishment period either from explicit config or calculated from hours if "refurbishment_period_years" in tech_capex_info: + refurb_schedule = np.zeros(self.params.plant_life) refurb_period = tech_capex_info["refurbishment_period_years"] + # Set replacement cost at regular intervals (every refurb_period years) + # replacement_cost_percent is fraction of original CAPEX (e.g., 0.15 = 15%) + refurb_schedule[refurb_period : self.params.plant_life : refurb_period] = ( + tech_capex_info["replacement_cost_percent"] + ) else: - # Calculate from hours until replacement (e.g., electrolyzer lifetime hours) - # Convert hours to years: divide by (24 hours/day * 365 days/year) - refurb_period = round( - float(inputs[f"{tech}_time_until_replacement"][0]) / (24 * 365) + # Multiply the technology replacement schedule by the replacement cost + refurb_schedule = ( + inputs[f"replacement_schedule_{tech}"] + * tech_capex_info["replacement_cost_percent"] ) - # Set replacement cost at regular intervals (every refurb_period years) - # replacement_cost_percent is fraction of original CAPEX (e.g., 0.15 = 15%) - refurb_schedule[refurb_period : self.config.plant_life : refurb_period] = ( - tech_capex_info["replacement_cost_percent"] - ) - # Calculate actual replacement costs by multiplying CAPEX by schedule percentages # capex is negative, so refurb_cost will also be negative (cash outflow) refurb_cost = capex * refurb_schedule diff --git a/h2integrate/finances/profast_base.py b/h2integrate/finances/profast_base.py index 0a40f1e66..0c5ca21f5 100644 --- a/h2integrate/finances/profast_base.py +++ b/h2integrate/finances/profast_base.py @@ -299,7 +299,7 @@ class ProFASTDefaultCapitalItem(BaseConfig): depr_type (str, optional): depreciation "MACRS" or "Straight line". Defaults to 'MACRS'. refurb (list[float], optional): Replacement schedule as a fraction of the capital cost. Defaults to [0.]. - replacement_cost_percent (float | int, optional): Replacement cost as a fraction of CapEx. + replacement_cost_percent (float, optional): Replacement cost as a fraction of CapEx. Defaults to 0.0 """ @@ -307,7 +307,7 @@ class ProFASTDefaultCapitalItem(BaseConfig): depr_period: int = field(converter=int, validator=contains([3, 5, 7, 10, 15, 20])) depr_type: str = field(converter=str.strip, validator=contains(["MACRS", "Straight line"])) refurb: int | float | list[float] = field(default=[0.0]) - replacement_cost_percent: int | float = field(default=0.0, validator=range_val(0, 1)) + replacement_cost_percent: float = field(default=0.0, validator=range_val(0, 1)) def create_dict(self): """Create a ProFAST-compatible dictionary of attributes. @@ -473,10 +473,14 @@ class ProFastBase(om.ExplicitComponent): user-defined technology, in USD. opex_adjusted_{tech} (float): Adjusted operational expenditure for each user-defined technology, in USD/year. - total_{commodity}_produced (float): Total annual production of the selected commodity + varopex_adjusted_{tech} (np.ndarray): Adjusted variable operational expenditure + for each user-defined technology, in USD/year. + rated_{commodity}_production (float): Rated production of the selected commodity (units depend on commodity type). - {tech}_time_until_replacement (float): Time until technology is replaced, in hours - (currently only supported if "electrolyzer" is in tech_config). + capacity_factor (np.ndarray): Capacity factor of the commodity producing tech(s) + per year of the plant life. + replacement_schedule_{tech} (np.ndarray): Fraction of the technology capacity that + is replaced in each year of the plant life. Methods: @@ -547,10 +551,9 @@ def setup(self): self.add_input(f"capex_adjusted_{tech}", val=0.0, units="USD") self.add_input(f"opex_adjusted_{tech}", val=0.0, units="USD/year") self.add_input(f"varopex_adjusted_{tech}", val=0.0, shape=plant_life, units="USD/year") - - # Include electrolyzer replacement time if applicable - if tech.startswith("electrolyzer"): - self.add_input(f"{tech}_time_until_replacement", units="h") + self.add_input( + f"replacement_schedule_{tech}", val=0.0, shape=plant_life, units="unitless" + ) # Load plant configuration and financial parameters plant_config = self.options["plant_config"] @@ -592,13 +595,6 @@ def setup(self): coproduct_cost_params.setdefault("unit", self.price_units.replace("USD", "$")) self.coproduct_cost_settings = ProFASTDefaultCoproduct.from_dict(coproduct_cost_params) - # incentives - unused for now - # incentive_params = plant_config["finance_parameters"]["model_inputs"].get( - # "incentives", {} - # ) - # incentive_params.setdefault("decay", -1 * self.params.inflation_rate) - # self.incentive_params_settings = ProFASTDefaultIncentive.from_dict(incentive_params) - def populate_profast(self, inputs): """Populate and configure the ProFAST financial model for analysis. @@ -666,18 +662,23 @@ def populate_profast(self, inputs): # see if any refurbishment information was input if "replacement_cost_percent" in tech_capex_info: - refurb_schedule = np.zeros(self.params.plant_life) - if "refurbishment_period_years" in tech_capex_info: + # Calculate replacement schedule using a user-defined replacement period + refurb_schedule = np.zeros(self.params.plant_life) refurb_period = tech_capex_info["refurbishment_period_years"] + # Multiply the replacement schedule by the replacement cost + # replacement_cost_percent is fraction of original CAPEX (e.g., 0.15 = 15%) + refurb_schedule[refurb_period : self.params.plant_life : refurb_period] = ( + tech_capex_info["replacement_cost_percent"] + ) + else: - refurb_period = round( - float(inputs[f"{tech}_time_until_replacement"][0]) / (24 * 365) + # Use the replacement schedule from the technology performance model + refurb_schedule = ( + inputs[f"replacement_schedule_{tech}"] + * tech_capex_info["replacement_cost_percent"] ) - refurb_schedule[refurb_period : self.params.plant_life : refurb_period] = ( - tech_capex_info["replacement_cost_percent"] - ) # add refurbishment schedule to tech-specific capital item entry tech_capex_info["refurb"] = list(refurb_schedule) diff --git a/h2integrate/finances/test/test_finances.py b/h2integrate/finances/test/test_finances.py index 7c3474373..9656e1739 100644 --- a/h2integrate/finances/test/test_finances.py +++ b/h2integrate/finances/test/test_finances.py @@ -1,5 +1,6 @@ import copy +import numpy as np import pytest import openmdao.api as om from pytest import approx @@ -101,7 +102,10 @@ def test_electrolyzer_refurb_results(model_configs): prob.set_val("capex_adjusted_electrolyzer1", 1.0e7, units="USD") prob.set_val("opex_adjusted_electrolyzer1", 1.0e4, units="USD/year") - prob.set_val("electrolyzer1_time_until_replacement", 5.0e3, units="h") + refurb_schedule = np.zeros(30) + replacement_period = round(5.0e3 / 8760) + refurb_schedule[replacement_period:30:replacement_period] = 1.0 + prob.set_val("replacement_schedule_electrolyzer1", refurb_schedule, units="unitless") prob.run_model() @@ -148,7 +152,10 @@ def test_modified_lcoe_calc(): prob.set_val("opex_adjusted_h2_storage", 5.0e3, units="USD/year") prob.set_val("capex_adjusted_steel", 3.0e6, units="USD") prob.set_val("opex_adjusted_steel", 3.0e3, units="USD/year") - prob.set_val("electrolyzer_time_until_replacement", 80000.0, units="h") + refurb_schedule = np.zeros(30) + replacement_period = round(80000.0 / 8760) + refurb_schedule[replacement_period:30:replacement_period] = 1.0 + prob.set_val("replacement_schedule_electrolyzer", refurb_schedule, units="unitless") prob.run_model() @@ -203,7 +210,10 @@ def test_lcoe_with_selected_technologies(): prob.set_val("opex_adjusted_h2_storage", 5.0e3, units="USD/year") prob.set_val("capex_adjusted_steel", 3.0e6, units="USD") prob.set_val("opex_adjusted_steel", 3.0e3, units="USD/year") - prob.set_val("electrolyzer_time_until_replacement", 80000.0, units="h") + refurb_schedule = np.zeros(30) + replacement_period = round(80000.0 / 8760) + refurb_schedule[replacement_period:30:replacement_period] = 1.0 + prob.set_val("replacement_schedule_electrolyzer", refurb_schedule, units="unitless") prob.run_model() @@ -338,7 +348,10 @@ def test_profast_config_provided(): prob.set_val("capex_adjusted_electrolyzer", 1.0e7, units="USD") prob.set_val("opex_adjusted_electrolyzer", 1.0e4, units="USD/year") - prob.set_val("electrolyzer_time_until_replacement", 5.0e3, units="h") + refurb_schedule = np.zeros(30) + replacement_period = round(5.0e3 / 8760) + refurb_schedule[replacement_period:30:replacement_period] = 1.0 + prob.set_val("replacement_schedule_electrolyzer", refurb_schedule, units="unitless") prob.run_model()