Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions h2integrate/finances/profast_npv.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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. "
Expand All @@ -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,
)

Expand Down Expand Up @@ -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)
184 changes: 184 additions & 0 deletions h2integrate/finances/test/test_profast_npv.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import numpy as np
import pytest
import openmdao.api as om
from pytest import fixture
Expand Down Expand Up @@ -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={},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both test fixtures use inflation_rate: 0.0. Can you add a case with nonzero inflation to confirm the per-year array isn't getting escalated a second time by ProFAST's own calculation? If users are meant to supply already-nominal per-year prices, we may need to force infation_rate = 0 when an array is passed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, could we clarify in the docs whether commodity_sell_price should be real or nominal dollars? Per our discussion with @jaredthomas68, we need to set nominal prices with inflation_rate = 0 (since ProFAST would otherwise apply its own escalation on top). Worth spelling that out explicitly.

@elenya-grant elenya-grant Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a case with nonzero inflation to confirm the per-year array isn't getting escalated a second time by ProFAST's own calculation

I played around with ProFAST using a 2% inflation rate and have some info in case its useful:

  • pf.cash_flow(price=0.07) and pf.cash_flow(price=[0.7]*30) result in the same NPV
  • The NPV with an inflation rate of 2% is about 2.2x the NPV with 0 inflation (both using a sell price of 0.7)

I think ProFAST does escalate the sell price, it basically does this:

analysis_length = plant_life + (installation_period_months/12) 
analysis_years = np.arange(0, analysis_length + 1)
sales_price = commodity_sell_price*(1.0+inflation)**(analysis_years-1)
revenue_from_sales = sales_price*annual_production

Aka - the commodity sell price should provided

  • in non-escalated dollars (I think this means real - if I'm interpreting Jared's helpful doc page properly)
  • in the same cost year as set in plant_config["finance_parameters"]["cost_adjustment_parameters"]["target_dollar_year"] (the commodity_sell_price does not go through AdjustedCapexOpexComp)

I would be happy to update the docs! @jaredthomas68 - can you confirm that the commodity_sell_price should be input in real dollars?

@elenya-grant elenya-grant Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can yall (@vijay092 and @jaredthomas68) tell me whether this is True or false:

  • if using an inflation rate of 0 in ProFAST, then the commodity sell price should be provided in nominal dollars?
  • if using a nonzero inflation rate in ProFAST, then the commodity sell price should be provided in real dollars since ProFAST will escalate the price?

Can y'all look at the test h2integrate/finances/test/test_profast_npv.py::test_profast_npv_with_inflation and let me know what y'all think?

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
)
Loading