diff --git a/h2integrate/finances/profast_npv.py b/h2integrate/finances/profast_npv.py index 67179f5d9..643b1f63e 100644 --- a/h2integrate/finances/profast_npv.py +++ b/h2integrate/finances/profast_npv.py @@ -1,3 +1,6 @@ +from collections.abc import Iterable + +import numpy as np from openmdao.utils.units import convert_units from h2integrate.finances.tools import _compute_rate_units @@ -40,7 +43,8 @@ def setup(self): Retrieves the commodity sell price and its units from the plant configuration and registers it as an input for the component. Calls the base `setup()` method - to initialize other ProFAST inputs and outputs. + to initialize other ProFAST inputs and outputs. The commodity sell price can be + either a scalar value or a list with the same length as plant life. Raises: ValueError: If `commodity_sell_price` or `commodity_sell_price_units` is not @@ -53,8 +57,6 @@ def setup(self): self.commodity_sell_price = model_inputs.get("commodity_sell_price", None) self.commodity_sell_price_units = model_inputs.get("commodity_sell_price_units", None) - if self.commodity_sell_price is None: - raise ValueError("commodity_sell_price is missing as an input") if self.commodity_sell_price_units is None: raise ValueError( "commodity_sell_price_units is missing as an input. " @@ -65,9 +67,22 @@ def setup(self): super().setup() + if self.commodity_sell_price is None: + raise ValueError("commodity_sell_price is missing as an input") + + if isinstance(self.commodity_sell_price, Iterable): + if len(self.commodity_sell_price) != int(self.params.plant_life): + raise ValueError( + f"`commodity_sell_price` has an invalid length of " + f"{len(self.commodity_sell_price)}. `commodity_sell_price`" + f"must be the same length as the plant life ({self.params.plant_life}) " + "or a single value." + ) + self.add_input( f"sell_price_{self.output_txt}", val=self.commodity_sell_price, + shape=int(self.params.plant_life), units=self.commodity_sell_price_units, ) @@ -112,6 +127,8 @@ def compute(self, inputs, outputs): else: pf = self.populate_profast(inputs) - outputs[f"NPV_{self.output_txt}"] = pf.cash_flow( - price=inputs[f"sell_price_{self.output_txt}"][0] + non_op_Nyears = int(np.ceil(self.params.installation_time / 12) + 1) + sell_profile = np.concatenate( + [np.zeros(non_op_Nyears), inputs[f"sell_price_{self.output_txt}"]] ) + outputs[f"NPV_{self.output_txt}"] = pf.cash_flow(price=sell_profile) diff --git a/h2integrate/finances/test/test_profast_npv.py b/h2integrate/finances/test/test_profast_npv.py index a09b0009c..798ffd92c 100644 --- a/h2integrate/finances/test/test_profast_npv.py +++ b/h2integrate/finances/test/test_profast_npv.py @@ -1,3 +1,4 @@ +import numpy as np import pytest import openmdao.api as om from pytest import fixture @@ -332,3 +333,186 @@ def test_profast_npv_nonstandard_price_units( pytest.approx(prob.get_val("pf.NPV_electricity_no2", units="USD")[0], rel=1e-6) == 611288384.412 ) + + +@pytest.mark.regression +def test_profast_npv_multi_year_sell_price( + profast_inputs_no2, fake_filtered_tech_config, fake_cost_dict, subtests +): + mean_hourly_production = 500000.0 + prob = om.Problem() + profast_inputs_no2["commodity_sell_price"] = [0.07] * 30 + plant_config = { + "plant": { + "plant_life": 30, + }, + "finance_parameters": {"model_inputs": profast_inputs_no2}, + } + pf = ProFastNPV( + driver_config={}, + plant_config=plant_config, + tech_config=fake_filtered_tech_config, + commodity_type="electricity", + description="no2", + ) + + ivc = om.IndepVarComp() + ivc.add_output("rated_electricity_production", mean_hourly_production, units="kW") + ivc.add_output("capacity_factor", [1.0] * plant_config["plant"]["plant_life"], units="unitless") + + prob.model.add_subsystem("ivc", ivc, promotes=["*"]) + prob.model.add_subsystem("pf", pf, promotes=["rated_electricity_production", "capacity_factor"]) + prob.setup() + for variable, cost in fake_cost_dict.items(): + units = "USD" if "capex" in variable else "USD/year" + prob.set_val(f"pf.{variable}", cost, units=units) + + prob.run_model() + + with subtests.test("Sell price"): + assert ( + pytest.approx( + prob.get_val("pf.sell_price_electricity_no2", units="USD/(kW*h)"), rel=1e-6 + ) + == profast_inputs_no2["commodity_sell_price"] + ) + + with subtests.test("NPV"): + assert ( + pytest.approx(prob.get_val("pf.NPV_electricity_no2", units="USD")[0], rel=1e-6) + == 611288384.412 + ) + + +@pytest.mark.regression +def test_profast_npv_multi_year_error(profast_inputs_no2, fake_filtered_tech_config, subtests): + prob = om.Problem() + profast_inputs_no2["commodity_sell_price"] = [0.07] * 10 + plant_config = { + "plant": { + "plant_life": 30, + }, + "finance_parameters": {"model_inputs": profast_inputs_no2}, + } + mean_hourly_production = 500000.0 + pf = ProFastNPV( + driver_config={}, + plant_config=plant_config, + tech_config=fake_filtered_tech_config, + commodity_type="electricity", + description="no2", + ) + + ivc = om.IndepVarComp() + ivc.add_output("rated_electricity_production", mean_hourly_production, units="kW") + ivc.add_output("capacity_factor", [1.0] * plant_config["plant"]["plant_life"], units="unitless") + + prob.model.add_subsystem("ivc", ivc, promotes=["*"]) + prob.model.add_subsystem("pf", pf, promotes=["rated_electricity_production", "capacity_factor"]) + + expected_message = "`commodity_sell_price` has an invalid length of 10" + with subtests.test("Incorrect sell price length"): + with pytest.raises(ValueError) as excinfo: + prob.setup() + assert expected_message in str(excinfo.value) + + +@pytest.mark.regression +def test_profast_npv_missing_sell_price(profast_inputs_no2, fake_filtered_tech_config, subtests): + prob = om.Problem() + profast_inputs_no2["commodity_sell_price"] = None + plant_config = { + "plant": { + "plant_life": 30, + }, + "finance_parameters": {"model_inputs": profast_inputs_no2}, + } + mean_hourly_production = 500000.0 + pf = ProFastNPV( + driver_config={}, + plant_config=plant_config, + tech_config=fake_filtered_tech_config, + commodity_type="electricity", + description="no2", + ) + + ivc = om.IndepVarComp() + ivc.add_output("rated_electricity_production", mean_hourly_production, units="kW") + ivc.add_output("capacity_factor", [1.0] * plant_config["plant"]["plant_life"], units="unitless") + + prob.model.add_subsystem("ivc", ivc, promotes=["*"]) + prob.model.add_subsystem("pf", pf, promotes=["rated_electricity_production", "capacity_factor"]) + + expected_message = "commodity_sell_price is missing as an input" + with subtests.test("Missing sell price"): + with pytest.raises(ValueError) as excinfo: + prob.setup() + assert expected_message in str(excinfo.value) + + +@pytest.mark.regression +def test_profast_npv_with_inflation( + profast_inputs_no2, fake_filtered_tech_config, fake_cost_dict, subtests +): + mean_hourly_production = 500000.0 + prob = om.Problem() + price_escalation = 0.02 # 2% inflation in price + profast_inputs_no2["commodity_sell_price"] = [0.07] * 30 + profast_inputs_no2["params"]["inflation_rate"] = price_escalation # 2% inflation + plant_config = { + "plant": { + "plant_life": 30, + }, + "finance_parameters": {"model_inputs": profast_inputs_no2}, + } + pf = ProFastNPV( + driver_config={}, + plant_config=plant_config, + tech_config=fake_filtered_tech_config, + commodity_type="electricity", + description="no2", + ) + + ivc = om.IndepVarComp() + ivc.add_output("rated_electricity_production", mean_hourly_production, units="kW") + ivc.add_output("capacity_factor", [1.0] * plant_config["plant"]["plant_life"], units="unitless") + + prob.model.add_subsystem("ivc", ivc, promotes=["*"]) + prob.model.add_subsystem("pf", pf, promotes=["rated_electricity_production", "capacity_factor"]) + prob.setup() + for variable, cost in fake_cost_dict.items(): + units = "USD" if "capex" in variable else "USD/year" + prob.set_val(f"pf.{variable}", cost, units=units) + + prob.run_model() + + with subtests.test("Sell price (flat rate)"): + assert ( + pytest.approx( + prob.get_val("pf.sell_price_electricity_no2", units="USD/(kW*h)"), rel=1e-6 + ) + == profast_inputs_no2["commodity_sell_price"] + ) + + # This NPV is about 2.2x times the NPV when inflation is zero + with subtests.test("NPV"): + assert ( + pytest.approx(prob.get_val("pf.NPV_electricity_no2", units="USD")[0], rel=1e-6) + == 1336544230.1139889 + ) + + nominal_price = np.concatenate( + [np.zeros(4), prob.get_val("pf.sell_price_electricity_no2", units="USD/(kW*h)")] + ) + years = np.arange(0, 34, 1) + # Remove inflation from nominal price + real_price = nominal_price / ((1.0 + price_escalation) ** (years - 1)) + + prob.set_val("pf.sell_price_electricity_no2", real_price[4:], units="USD/(kW*h)") + prob.run_model() + # Removing inflation from nominal price gives NPV of 0.4 what is what before + with subtests.test("NPV (real sell price)"): + assert ( + pytest.approx(prob.get_val("pf.NPV_electricity_no2", units="USD")[0], rel=1e-6) + == 520290489.52904046 + )