diff --git a/CHANGELOG.md b/CHANGELOG.md index c1f6d87a9..fdaa45244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ data between functions in a module. [PR 590](https://github.com/NatLabRockies/H2Integrate/pull/590) - Adds `H2IntegrateModel.state` as an `IntEnum` to handle setup and run status checks. [PR 590](https://github.com/NatLabRockies/H2Integrate/pull/590) +- Added standardized outputs to feedstock model [PR 523](https://github.com/NatLabRockies/H2Integrate/pull/523) - Reclassified open-loop converter control strategies as demand components and updated output naming convention to align with output naming convention in storage performance models [PR 631](https://github.com/NatLabRockies/H2Integrate/pull/631). - The `FlexibleDemandOpenLoopConverterController` has been renamed to `FlexibleDemandComponent` - The `DemandOpenLoopConverterController` has been renamed to `GenericDemandComponent` diff --git a/docs/technology_models/feedstocks.md b/docs/technology_models/feedstocks.md index 2d722b351..dc5db0986 100644 --- a/docs/technology_models/feedstocks.md +++ b/docs/technology_models/feedstocks.md @@ -1,6 +1,6 @@ # Feedstock Models -Feedstock models in H2Integrate represent any resource input that is consumed by technologies in your plant, such as natural gas, water, electricity from the grid, or any other material input. +Feedstock models in H2Integrate represent any resource input that is consumed by technologies in your plant that comes from outside your designed system boundary (and not generated internally), such as natural gas, water, electricity from the grid, or any other material input. The feedstock modeling approach provides a flexible way to track resource consumption and calculate associated costs for any type of input material or energy source. Please see the example `16_natural_gas` in the `examples` directory for a complete setup using natural gas as a feedstock. @@ -19,6 +19,7 @@ Each feedstock type requires two model components: - Calculates consumption costs based on actual usage - Takes `{commodity}_consumed` as input - Located after all consuming technologies in the chain + - Calculates the capacity factor of the consumed feedstock ### Technology Interconnections @@ -56,8 +57,8 @@ ng_feedstock: commodity_amount_units: "MMBtu" # optional, if not specified defaults to `commodity_rate_units*h` cost_year: 2023 price: 4.2 # cost in USD/commodity_amount_units - annual_cost: 0. - start_up_cost: 100000. + annual_cost: 0. #cost in USD/year + start_up_cost: 100000. #cost in USD ``` ### Performance Model Parameters @@ -81,3 +82,11 @@ ng_feedstock: ```{tip} The `price` parameter is flexible - you can specify constant pricing with a single value or time-varying pricing with an array of values matching the number of simulation timesteps. ``` + +### Consumed Feedstock Outputs +The feedstock model outputs cost and performance information about the consumed feedstock. The most notable outputs are: +- `VarOpEx`: cost of the feedstock consumed (in `USD/yr`) +- `total_{commodity}_consumed`: total feedstock consumed over simulation (in `commodity_amount_units`) +- `annual_{commodity}_consumed`: annual feedstock consumed (in `commodity_amount_units/yr`) +- `rated_{commodity}_production`: this is equal to the the `rated_capacity` of the feedstock model (in `commodity_rate_units`) +- `capacity_factor`: ratio of the feedstock consumed to the maximum feedstock available diff --git a/examples/12_ammonia_synloop/plant_config.yaml b/examples/12_ammonia_synloop/plant_config.yaml index fe90d4177..c87eba16d 100644 --- a/examples/12_ammonia_synloop/plant_config.yaml +++ b/examples/12_ammonia_synloop/plant_config.yaml @@ -74,5 +74,8 @@ finance_parameters: - battery - electrolyzer - h2_storage - - n2_feedstock - ammonia + n2: + commodity: nitrogen + commodity_stream: n2_feedstock + technologies: [n2_feedstock] diff --git a/examples/12_ammonia_synloop/tech_config.yaml b/examples/12_ammonia_synloop/tech_config.yaml index 258047f7e..cf5de640a 100644 --- a/examples/12_ammonia_synloop/tech_config.yaml +++ b/examples/12_ammonia_synloop/tech_config.yaml @@ -149,7 +149,7 @@ technologies: rated_capacity: 50.0 # metric tonnes of N2/hour cost_parameters: cost_year: 2022 - price: 0.0 + price: 5.0 annual_cost: 0. start_up_cost: 0.0 electricity_feedstock: diff --git a/examples/16_natural_gas/tech_config.yaml b/examples/16_natural_gas/tech_config.yaml index 7dd73ffb8..3d8b96e8f 100644 --- a/examples/16_natural_gas/tech_config.yaml +++ b/examples/16_natural_gas/tech_config.yaml @@ -67,9 +67,10 @@ technologies: rated_capacity: 750. # MMBtu/h cost_parameters: cost_year: 2023 - price: 4.2 - annual_cost: 0. - start_up_cost: 100000. + commodity_amount_units: MMBtu + price: 4.2 # USD/commodity_amount_units + annual_cost: 0. # USD + start_up_cost: 100000. # USD natural_gas_plant: performance_model: model: NaturalGasPerformanceModel diff --git a/examples/test/test_all_examples.py b/examples/test/test_all_examples.py index 9fd18637e..c17920400 100644 --- a/examples/test/test_all_examples.py +++ b/examples/test/test_all_examples.py @@ -375,6 +375,13 @@ def test_ammonia_synloop_example(subtests, temp_copy_of_example): ) == 1.1018637096646757 ) + with subtests.test("Check LCON"): + assert ( + pytest.approx( + model.prob.get_val("finance_subgroup_n2.LCON", units="USD/t")[0], rel=1e-6 + ) + == 5.03140888 + ) @pytest.mark.integration @@ -1058,6 +1065,10 @@ def test_natural_gas_example(subtests, temp_copy_of_example): expected_opex = 4.2 * ng_consumed.sum() # price = 4.2 $/MMBtu assert pytest.approx(ng_opex, rel=1e-6) == expected_opex + with subtests.test("Check feedstock capacity factor"): + ng_cf = model.prob.get_val("ng_feedstock.capacity_factor", units="unitless").mean() + assert pytest.approx(ng_cf, rel=1e-6) == 0.5676562763739097 + @pytest.mark.integration @pytest.mark.parametrize( diff --git a/h2integrate/converters/natural_gas/natural_gas_cc_ct.py b/h2integrate/converters/natural_gas/natural_gas_cc_ct.py index 36f1b0010..a2d7bd985 100644 --- a/h2integrate/converters/natural_gas/natural_gas_cc_ct.py +++ b/h2integrate/converters/natural_gas/natural_gas_cc_ct.py @@ -84,7 +84,7 @@ def setup(self): self.add_input( "heat_rate_mmbtu_per_mwh", val=self.config.heat_rate_mmbtu_per_mwh, - units="MMBtu/MW/h", + units="MMBtu/(MW*h)", desc="Plant heat rate in MMBtu/MWh", ) diff --git a/h2integrate/core/feedstocks.py b/h2integrate/core/feedstocks.py index 3a4fc5fd0..23987a79c 100644 --- a/h2integrate/core/feedstocks.py +++ b/h2integrate/core/feedstocks.py @@ -75,7 +75,6 @@ class FeedstockCostConfig(CostModelBaseConfig): price: int | float | list = field() annual_cost: float = field(default=0.0) start_up_cost: float = field(default=0.0) - commodity_amount_units: str | None = field(default=None) def __attrs_post_init__(self): @@ -89,29 +88,97 @@ def setup(self): merge_shared_inputs(self.options["tech_config"]["model_inputs"], "cost"), additional_cls_name=self.__class__.__name__, ) - n_timesteps = self.options["plant_config"]["plant"]["simulation"]["n_timesteps"] + self.n_timesteps = int(self.options["plant_config"]["plant"]["simulation"]["n_timesteps"]) plant_life = int(self.options["plant_config"]["plant"]["plant_life"]) + # Set cost outputs super().setup() self.add_input( f"{self.config.commodity}_consumed", val=0.0, - shape=int(n_timesteps), + shape=self.n_timesteps, units=self.config.commodity_rate_units, desc=f"Consumption profile of {self.config.commodity}", ) + self.add_input( + f"{self.config.commodity}_out", + val=0, + shape=self.n_timesteps, + units=self.config.commodity_rate_units, + ) + self.add_input( "price", val=self.config.price, units=f"USD/({self.config.commodity_amount_units})", - desc=f"Consumption profile of {self.config.commodity}", + desc=f"Price profile of {self.config.commodity}", + ) + + self.dt = self.options["plant_config"]["plant"]["simulation"]["dt"] + self.plant_life = int(self.options["plant_config"]["plant"]["plant_life"]) + hours_per_year = 8760 + hours_simulated = (self.dt / 3600) * self.n_timesteps + self.fraction_of_year_simulated = hours_simulated / hours_per_year + # since feedstocks are consumed, some outputs are appended + # with 'consumed' rather than 'produced' + + self.add_output( + f"total_{self.config.commodity}_consumed", + val=0.0, + units=self.config.commodity_amount_units, + ) + + self.add_output( + f"annual_{self.config.commodity}_consumed", + val=0.0, + shape=self.plant_life, + units=f"({self.config.commodity_amount_units})/year", + ) + + # Capacity factor is feedstock_consumed/max_feedstock_available + self.add_output( + "capacity_factor", + val=0.0, + shape=self.plant_life, + units="unitless", + desc="Capacity factor", + ) + + # The should be equal to the commodity_capacity input of the FeedstockPerformanceModel + self.add_output( + f"rated_{self.config.commodity}_production", + val=0, + units=self.config.commodity_rate_units, ) # 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): + # Capacity factor is the total amount consumed / the total amount available + outputs["capacity_factor"] = ( + inputs[f"{self.config.commodity}_consumed"].sum() + / inputs[f"{self.config.commodity}_out"].sum() + ) + + # Sum the amount consumed + outputs[f"total_{self.config.commodity}_consumed"] = inputs[ + f"{self.config.commodity}_consumed" + ].sum() * (self.dt / 3600) + + # Estimate annual consumption based on consumption over the simulation + # NOTE: once we standardize feedstock consumption outputs in models, this should + # be updated to handle consumption that varies over years of operation + outputs[f"annual_{self.config.commodity}_consumed"] = outputs[ + f"total_{self.config.commodity}_consumed" + ] * (1 / self.fraction_of_year_simulated) + + outputs[f"rated_{self.config.commodity}_production"] = inputs[ + f"{self.config.commodity}_out" + ].max() + + # Calculate costs price = inputs["price"] hourly_consumption = inputs[f"{self.config.commodity}_consumed"] cost_per_year = sum(price * hourly_consumption) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index cafdaca80..dd5d57fd0 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -1053,6 +1053,11 @@ def connect_technologies(self): f"{dest_tech}.{transport_item}_consumed", f"{source_tech}.{transport_item}_consumed", ) + # Connect the feedstock performance model output to the cost model input + self.plant.connect( + f"{source_tech}_source.{transport_item}_out", + f"{source_tech}.{transport_item}_out", + ) if perf_model_name == "FeedstockPerformanceModel": source_tech = f"{source_tech}_source" diff --git a/h2integrate/core/test/test_feedstocks.py b/h2integrate/core/test/test_feedstocks.py index ef9ca7acb..886658c6f 100644 --- a/h2integrate/core/test/test_feedstocks.py +++ b/h2integrate/core/test/test_feedstocks.py @@ -8,10 +8,90 @@ import numpy as np import pytest import openmdao.api as om +from pytest import fixture from h2integrate.core.feedstocks import FeedstockCostModel, FeedstockPerformanceModel +@fixture +def plant_config(): + return { + "plant": { + "plant_life": 30, + "simulation": { + "n_timesteps": 8760, + "dt": 3600, + }, + }, + } + + +@fixture +def ng_feedstock_input_config(): + tech_config = { + "model_inputs": { + "shared_parameters": { + "commodity": "natural_gas", + "commodity_rate_units": "MMBtu/h", + }, + "performance_parameters": { + "rated_capacity": 100.0, + }, + "cost_parameters": { + "price": 4.2, # USD/MMBtu + "annual_cost": 0, + "start_up_cost": 0, + "cost_year": 2023, + "commodity_amount_units": "MMBtu", # optional + }, + } + } + return tech_config + + +@pytest.mark.unit +def test_feedstock_standard_outputs(plant_config, ng_feedstock_input_config, subtests): + perf_model = FeedstockPerformanceModel( + plant_config=plant_config, tech_config=ng_feedstock_input_config, driver_config={} + ) + cost_model = FeedstockCostModel( + plant_config=plant_config, tech_config=ng_feedstock_input_config, driver_config={} + ) + prob = om.Problem() + prob.model.add_subsystem("ng_feedstock_source", perf_model) + prob.model.add_subsystem("ng_feedstock", cost_model) + # Connect the feedstock performance model output to the cost model input + prob.model.connect( + "ng_feedstock_source.natural_gas_out", + "ng_feedstock.natural_gas_out", + ) + + prob.setup() + # Set some consumption values + consumption = np.full(8760, 50.0) # 50 MMBtu/hour + prob.set_val("ng_feedstock.natural_gas_consumed", consumption) + prob.run_model() + with subtests.test("Check feedstock capacity factor"): + ng_cf = prob.get_val("ng_feedstock.capacity_factor", units="unitless").mean() + assert pytest.approx(ng_cf, rel=1e-6) == 0.5 + with subtests.test("Check feedstock rated production"): + rated_production_source = prob.get_val( + "ng_feedstock_source.natural_gas_capacity", units="MMBtu/h" + ) + rated_production = prob.get_val( + "ng_feedstock.rated_natural_gas_production", units="MMBtu/h" + ) + assert pytest.approx(rated_production, rel=1e-6) == rated_production_source + with subtests.test("Check feedstock total consumption"): + total_consumption = prob.get_val("ng_feedstock.total_natural_gas_consumed", units="MMBtu") + assert pytest.approx(total_consumption, rel=1e-6) == consumption.sum() + with subtests.test("Check feedstock annual consumption"): + annual_consumption = prob.get_val( + "ng_feedstock.annual_natural_gas_consumed", units="MMBtu/yr" + ) + assert pytest.approx(annual_consumption, rel=1e-6) == consumption.sum() + + def create_basic_feedstock_config( feedstock_type="natural_gas", units="MMBtu/h", @@ -38,7 +118,7 @@ def create_basic_feedstock_config( }, } } - plant_config = {"plant": {"plant_life": 30, "simulation": {"n_timesteps": 8760}}} + plant_config = {"plant": {"plant_life": 30, "simulation": {"n_timesteps": 8760, "dt": 3600}}} driver_config = {} return tech_config, plant_config, driver_config diff --git a/h2integrate/tools/profast_tools.py b/h2integrate/tools/profast_tools.py index 8aed16123..33d0c810b 100644 --- a/h2integrate/tools/profast_tools.py +++ b/h2integrate/tools/profast_tools.py @@ -134,11 +134,16 @@ def make_price_breakdown(price_breakdown, pf_config): total_price_capex += cash_outflow_prices.loc[ cash_outflow_prices["Name"] == item, "NPV" ].tolist()[0] - for item in capital_items: - capex_fraction[item] = ( - cash_outflow_prices.loc[cash_outflow_prices["Name"] == item, "NPV"].tolist()[0] - / total_price_capex - ) + if total_price_capex != 0: + for item in capital_items: + capex_fraction[item] = ( + cash_outflow_prices.loc[cash_outflow_prices["Name"] == item, "NPV"].tolist()[0] + / total_price_capex + ) + else: + for item in capital_items: + capex_fraction[item] = 0 + cap_expense = ( price_breakdown.loc[price_breakdown["Name"] == "Repayment of debt", "NPV"].tolist()[0] + price_breakdown.loc[price_breakdown["Name"] == "Interest expense", "NPV"].tolist()[0]