Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
- Added dynamic operating constraints (turndown, ramping, warm/cold start delays) to `AmmoniaSynLoopPerformanceModel` and split `AmmoniaSynLoopCostModel` into its own module. [PR 770](https://github.com/NatLabRockies/H2Integrate/pull/770)
- Speed up the slowest tests in the suite by swapping the Floris wind model for `PYSAMWindPlantPerformanceModel` in examples 01 (`01_onshore_steel_mn`) and 02 (`02_texas_ammonia`), updating the affected `test_steel_example`/`test_simple_ammonia_example` expected values, fixing a pre-existing `cases.sql` cache-path bug and module-scoping the fixtures in `h2integrate/postprocess/test/test_sql_timeseries_to_csv.py` so the example only runs once for all four tests. [PR 782](https://github.com/NatLabRockies/H2Integrate/pull/782)
- Exposed `n_timesteps`, `dt`, `plant_life`, and `fraction_of_year_simulated` as attributes on `CostModelBaseClass` (matching `PerformanceModelBaseClass`) and updated all cost and performance model subclasses across `h2integrate/` to use these attributes instead of reading them from `plant_config`, removing redundant boilerplate from individual components. [PR 783](https://github.com/NatLabRockies/H2Integrate/pull/783)
- Added an optional `inflation_rate` input to `NumpyFinancialNPVFinanceConfig` that is combined with `real_discount_rate` via the Fisher equation `(1 + r_eff) = (1 + real_discount_rate) * (1 + inflation_rate)` to form the effective rate passed to `numpy_financial.npv`, allowing users to supply either a nominal discount rate (with `inflation_rate=0`, the default) or a real discount rate together with an explicit inflation rate. Matches how ProFAST combines its real discount rate and `general_inflation` inputs. [PR 788](https://github.com/NatLabRockies/H2Integrate/pull/788)
- Renamed the `discount_rate` input to `real_discount_rate` in `NumpyFinancialNPVFinanceConfig` to clarify that it is combined with `inflation_rate` via the Fisher equation. The ProFAST models keep their existing `discount_rate` name. Updated the numpy NPV model, tests, docs, and the example 19 config accordingly. [PR TBD](https://github.com/NatLabRockies/H2Integrate/pull/TBD)
- Connect each cost-aware system-level controller's `{tech}_buy_price` input directly to the technology's own buy-price input via OpenMDAO 3.44 input-to-input connections, so a single `prob.set_val()` on (for example) `grid.electricity_buy_price` now propagates to the SLC. [PR 791](https://github.com/NatLabRockies/H2Integrate/pull/791)
- Added system-level-controller unit tests that confirm the `cost_per_tech: feedstock` mode correctly sums `VarOpEx` from all upstream feedstocks for a multi-feedstock dispatchable, including a fuel-cell-style hydrogen-plus-oxygen scenario and a case with feedstocks at different graph depths. [PR 793](https://github.com/NatLabRockies/H2Integrate/pull/793)
- Rewrote the "Adding a new technology" developer guide. Also replaced the hand-maintained model overview page with an auto-generated section (`docs/generate_model_overview.py`) that classifies every registered class in `supported_models` and appends the first sentence of each class's docstring. Migrated `.readthedocs.yaml` to invoke `docs/build_book.sh` via `build.commands` so hosted and local builds stay in sync. [PR 787](https://github.com/NatLabRockies/H2Integrate/pull/787)
Expand Down
8 changes: 5 additions & 3 deletions docs/finance_models/numpy_financial_npv.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ Implements validation and default handling using the `attrs` library.
| Attribute | Type | Description | Default |
| --------------------------------- | ---------------- | ----------------------------------------------------- | ----------- |
| `plant_life` | `int` | Operating life of the plant in years. Must be ≥ 0. | — |
| `discount_rate` | `float` | Discount rate (0–1). | — |
| `real_discount_rate` | `float` | Discount rate (0-1). Can be either a real or nominal rate depending on how `inflation_rate` is specified. | - |
| `inflation_rate` | `float` | Inflation rate (0-1). Combined with `real_discount_rate` via the Fisher equation `(1 + r_eff) = (1 + real_discount_rate) * (1 + inflation_rate)` to form the effective discount rate. Set to 0 if `real_discount_rate` is already a nominal rate. This matches how ProFAST combines its real discount rate and `general_inflation` inputs. | `0.0` |
| `commodity_sell_price` | `int` or `float` | Sale price of the commodity (USD/unit). | `0.0` |
| `commodity_sell_price_units` | `str` | OpenMDAO unit string for `commodity_sell_price` (e.g. `"USD/(kW*h)"` for electricity or `"USD/kg"` for hydrogen). | — |
| `save_cost_breakdown` | `bool` | Whether to save annual cost breakdowns to CSV. | `False` |
Expand All @@ -31,7 +32,8 @@ An example of what to include in the `plant_config` to use the `NPVFinance` mode
npv:
finance_model: "NumpyFinancialNPV"
model_inputs:
discount_rate: 0.09 # each period is discounted at a rate of `discount_rate`
real_discount_rate: 0.09 # each period is discounted at a rate of `(1 + real_discount_rate) * (1 + inflation_rate) - 1`
inflation_rate: 0.0 # optional, defaults to 0; provide e.g. 0.025 if `real_discount_rate` is a real rate
commodity_sell_price: 0.078 # if commodity is electricity $/kwh
commodity_sell_price_units: "USD/(kW*h)" # OpenMDAO unit string for the sell price
save_cost_breakdown: True
Expand Down Expand Up @@ -66,7 +68,7 @@ npv:
- Technologies with `replacement_cost_percent` and a refurbishment period incur periodic capital costs.

3. Discounting
- Each series of cash flows is discounted using NumPy Financial’s `npf.npv(discount_rate, values)`.
- Each series of cash flows is discounted using NumPy Financial’s `npf.npv(effective_rate, values)`, where `effective_rate = (1 + real_discount_rate) * (1 + inflation_rate) - 1` is the Fisher-equation combination of `real_discount_rate` and `inflation_rate`. When `inflation_rate` is left at its default of 0, `effective_rate` reduces to `real_discount_rate`, so the value passed as `real_discount_rate` is used directly as the (nominal) discount rate. To model inflation explicitly, supply a real `real_discount_rate` together with a nonzero `inflation_rate`. The multiplicative (Fisher) form matches the way ProFAST combines its real discount rate and `general_inflation` inputs.

4. Summation
- Total NPV = sum of all discounted cash flows.
Expand Down
2 changes: 1 addition & 1 deletion docs/user_guide/specifying_finance_parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ finance_parameters:
model_inputs: {discount_rate: 0.08}
group_b:
finance_model: "NPVFinancial"
model_inputs: {discount_rate: 0.05}
model_inputs: {real_discount_rate: 0.05}
finance_subgroups:
subgroup_a:
commodity: "hydrogen"
Expand Down
2 changes: 1 addition & 1 deletion examples/19_simple_dispatch/plant_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ finance_parameters:
npv:
finance_model: NumpyFinancialNPV
model_inputs:
discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind
real_discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind
commodity_sell_price: 0.078
commodity_sell_price_units: USD/(kW*h)
save_cost_breakdown: true
Expand Down
42 changes: 38 additions & 4 deletions h2integrate/finances/numpy_financial_npv.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,30 @@
class NumpyFinancialNPVFinanceConfig(BaseConfig):
"""Configuration for NumpyFinancialNPVFinance.

The effective rate used to discount future cash flows combines
``real_discount_rate`` and ``inflation_rate`` via the Fisher equation:

(1 + effective_rate) = (1 + real_discount_rate) * (1 + inflation_rate)

This lets the user provide either a nominal discount rate (with
``inflation_rate`` left at 0) or a real discount rate together with an
explicit ``inflation_rate``, depending on whether inflation is already
baked into ``real_discount_rate``. The multiplicative (Fisher) form is the
exact relationship between real and nominal rates and matches how
ProFAST combines its real discount rate and ``general_inflation`` inputs,
keeping the two finance backends consistent.

Attributes:
plant_life (int): operating life of plant in years
discount_rate (float): discount rate, expressed as a fraction between 0 and 1.
real_discount_rate (float): discount rate, expressed as a fraction between 0 and 1.
Can be either a real or nominal rate depending on how ``inflation_rate``
is specified.
inflation_rate (float, optional): inflation rate, expressed as a fraction
between 0 and 1. Combined with ``real_discount_rate`` via the Fisher
equation to form the effective discount rate used by
``numpy_financial.npv``. Defaults to 0.0, in which case
``real_discount_rate`` is used as-is (and should be a nominal rate if
inflation effects are desired).
commodity_sell_price (int | float, optional): sell price of commodity in
USD/unit of commodity. Defaults to 0.0
commodity_sell_price_units (str): OpenMDAO unit string for ``commodity_sell_price``
Expand All @@ -33,7 +54,8 @@ class NumpyFinancialNPVFinanceConfig(BaseConfig):
"""

plant_life: int = field(converter=int, validator=gte_zero)
discount_rate: float = field(validator=range_val(0, 1))
real_discount_rate: float = field(validator=range_val(0, 1))
inflation_rate: float = field(default=0.0, validator=range_val(0, 1))
commodity_sell_price: int | float = field(default=0.0)
commodity_sell_price_units: str = field()
save_cost_breakdown: bool = field(default=False)
Expand Down Expand Up @@ -274,11 +296,23 @@ def compute(self, inputs, outputs):

# Calculate NPV for each cost category and sum to get total NPV
# This iterative approach also builds npv_cost_breakdown for optional reporting
# Effective rate combines real_discount_rate and inflation_rate via the Fisher
# equation: (1 + effective) = (1 + real_discount_rate) * (1 + inflation_rate).
# This is the exact relationship between real and nominal rates (rather
# than the additive approximation r_nominal ~= r_real + inflation) and matches how
# ProFAST combines its real discount rate and general inflation inputs,
# keeping the two finance backends consistent. When inflation_rate is 0
# (the default), this reduces to real_discount_rate, so the user can either
# supply a nominal discount rate alone or a real discount rate together
# with an explicit inflation rate.
effective_rate = (1.0 + self.config.real_discount_rate) * (
1.0 + self.config.inflation_rate
) - 1.0
npv_item_check = 0
npv_cost_breakdown = {}
for cost_type, cost_vals in cost_breakdown.items():
# Apply NPV formula: NPV = sum(cash_flow[t] / (1 + discount_rate)^t) for all t
npv_item = npf.npv(self.config.discount_rate, cost_vals)
# Apply NPV formula: NPV = sum(cash_flow[t] / (1 + effective_rate)^t) for all t
npv_item = npf.npv(effective_rate, cost_vals)
npv_item_check += float(npv_item)
npv_cost_breakdown[cost_type] = float(npv_item)

Expand Down
128 changes: 102 additions & 26 deletions h2integrate/finances/test/test_numpy_financial_npv.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
import openmdao.api as om
from pytest import fixture

from h2integrate.finances.numpy_financial_npv import NumpyFinancialNPV
from h2integrate.finances.numpy_financial_npv import (
NumpyFinancialNPV,
NumpyFinancialNPVFinanceConfig,
)


@fixture
def npv_finance_inputs():
npv_dict = {
"discount_rate": 0.09,
"real_discount_rate": 0.09,
"commodity_sell_price": 0.04,
"commodity_sell_price_units": "USD/(kW*h)",
"save_cost_breakdown": False,
Expand Down Expand Up @@ -147,23 +150,16 @@ def test_simple_npv_positive(
assert pytest.approx(npv_value, rel=1e-6) == 3597582813.8071656


@pytest.mark.regression
def test_simple_npv_positive_nonstandard_units(
npv_finance_inputs, fake_filtered_tech_config, fake_cost_dict, subtests
):
def _build_npv_problem(npv_finance_inputs, fake_filtered_tech_config, fake_cost_dict):
"""Build and run an NPV problem with the given finance inputs."""
mean_hourly_production = 500000.0
prob = om.Problem()

# Increase commodity sell price to get positive NPV
npv_finance_inputs_positive = npv_finance_inputs.copy()
npv_finance_inputs_positive["commodity_sell_price"] = 0.15
npv_finance_inputs_positive["commodity_sell_price_units"] = "USD/(kW*min)"

plant_config = {
"plant": {
"plant_life": 30,
},
"finance_parameters": {"model_inputs": npv_finance_inputs_positive},
"finance_parameters": {"model_inputs": npv_finance_inputs},
}
pf = NumpyFinancialNPV(
driver_config={},
Expand All @@ -185,22 +181,102 @@ def test_simple_npv_positive_nonstandard_units(
units = "USD" if "capex" in variable else "USD/year"
prob.set_val(f"npv.{variable}", cost, units=units)

prob.set_val(
"npv.sell_price_electricity_no1",
npv_finance_inputs_positive["commodity_sell_price"],
units="USD/(kW*h)",
prob.run_model()
return prob


@pytest.mark.unit
def test_inflation_rate_defaults_to_zero(
npv_finance_inputs, fake_filtered_tech_config, fake_cost_dict, subtests
):
"""Omitting inflation_rate should reproduce the baseline NPV (nominal-rate behavior)."""
inputs_no_inflation = npv_finance_inputs.copy()
inputs_with_zero_inflation = npv_finance_inputs.copy()
inputs_with_zero_inflation["inflation_rate"] = 0.0

prob_default = _build_npv_problem(
inputs_no_inflation, fake_filtered_tech_config, fake_cost_dict
)
prob_explicit_zero = _build_npv_problem(
inputs_with_zero_inflation, fake_filtered_tech_config, fake_cost_dict
)

prob.run_model()
npv_default = prob_default.get_val("npv.NPV_electricity_no1", units="USD")[0]
npv_explicit_zero = prob_explicit_zero.get_val("npv.NPV_electricity_no1", units="USD")[0]

with subtests.test("Sell price"):
assert (
pytest.approx(
prob.get_val("npv.sell_price_electricity_no1", units="USD/(kW*h)"), rel=1e-6
)
== npv_finance_inputs_positive["commodity_sell_price"]
with subtests.test("Default inflation_rate matches baseline NPV"):
assert pytest.approx(npv_default, rel=1e-6) == -1352263704.120

with subtests.test("Explicit inflation_rate=0 matches default"):
assert pytest.approx(npv_explicit_zero, rel=1e-12) == npv_default


@pytest.mark.unit
def test_inflation_rate_combines_via_fisher_equation(
npv_finance_inputs, fake_filtered_tech_config, fake_cost_dict, subtests
):
"""Real and inflation rates should combine via the Fisher equation:
(1 + r_nominal) = (1 + r_real) * (1 + inflation), matching ProFAST."""
# Real discount rate 0.05 + inflation 0.04 should give the same NPV as a
# nominal rate of (1.05 * 1.04 - 1) = 0.092, NOT 0.09 (the additive form).
nominal_inputs = npv_finance_inputs.copy()
nominal_inputs["real_discount_rate"] = 1.05 * 1.04 - 1.0 # 0.092

split_inputs = npv_finance_inputs.copy()
split_inputs["real_discount_rate"] = 0.05
split_inputs["inflation_rate"] = 0.04

inflation_only_inputs = npv_finance_inputs.copy()
inflation_only_inputs["real_discount_rate"] = 0.0
inflation_only_inputs["inflation_rate"] = 0.09

inflation_only_nominal_inputs = npv_finance_inputs.copy()
inflation_only_nominal_inputs["real_discount_rate"] = 0.09

prob_nominal = _build_npv_problem(nominal_inputs, fake_filtered_tech_config, fake_cost_dict)
prob_split = _build_npv_problem(split_inputs, fake_filtered_tech_config, fake_cost_dict)
prob_inflation_only = _build_npv_problem(
inflation_only_inputs, fake_filtered_tech_config, fake_cost_dict
)
prob_inflation_only_nominal = _build_npv_problem(
inflation_only_nominal_inputs, fake_filtered_tech_config, fake_cost_dict
)

npv_nominal = prob_nominal.get_val("npv.NPV_electricity_no1", units="USD")[0]
npv_split = prob_split.get_val("npv.NPV_electricity_no1", units="USD")[0]
npv_inflation_only = prob_inflation_only.get_val("npv.NPV_electricity_no1", units="USD")[0]
npv_inflation_only_nominal = prob_inflation_only_nominal.get_val(
"npv.NPV_electricity_no1", units="USD"
)[0]

with subtests.test("Real + inflation matches Fisher-equivalent nominal"):
assert pytest.approx(npv_split, rel=1e-12) == npv_nominal

with subtests.test("Inflation-only matches equivalent nominal (real=0)"):
# With real_discount_rate=0, (1+0)*(1+pi)-1 = pi, so this should match a
# nominal rate equal to the inflation rate exactly.
assert pytest.approx(npv_inflation_only, rel=1e-12) == npv_inflation_only_nominal


@pytest.mark.unit
def test_inflation_rate_validator_rejects_out_of_range():
"""inflation_rate must be in [0, 1]."""
with pytest.raises(ValueError, match="inflation_rate"):
NumpyFinancialNPVFinanceConfig.from_dict(
{
"plant_life": 30,
"real_discount_rate": 0.05,
"inflation_rate": -0.01,
"commodity_sell_price_units": "USD/(kW*h)",
}
)

with subtests.test("NPV positive"):
npv_value = prob.get_val("npv.NPV_electricity_no1", units="USD")[0]
assert pytest.approx(npv_value, rel=1e-6) == 3597582813.8071656
with pytest.raises(ValueError, match="inflation_rate"):
NumpyFinancialNPVFinanceConfig.from_dict(
{
"plant_life": 30,
"real_discount_rate": 0.05,
"inflation_rate": 1.5,
"commodity_sell_price_units": "USD/(kW*h)",
}
)
Loading