From 46baaf2634e00f392a5536fa85b8d7b4399bae7d Mon Sep 17 00:00:00 2001 From: John Jasa Date: Mon, 22 Jun 2026 11:37:41 -0600 Subject: [PATCH 1/3] Adding inflation rate to numpy NPV calc --- CHANGELOG.md | 1 + docs/finance_models/numpy_financial_npv.md | 8 +- h2integrate/finances/numpy_financial_npv.py | 38 +++++- .../finances/test/test_numpy_financial_npv.py | 126 +++++++++++++++++- 4 files changed, 167 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01151283a..1c1f1b609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ - 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 `discount_rate` via the Fisher equation `(1 + r_eff) = (1 + 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) ## 0.8 [April 15, 2026] diff --git a/docs/finance_models/numpy_financial_npv.md b/docs/finance_models/numpy_financial_npv.md index e1378c0e3..1bbd4eaad 100644 --- a/docs/finance_models/numpy_financial_npv.md +++ b/docs/finance_models/numpy_financial_npv.md @@ -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). | — | +| `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 `discount_rate` via the Fisher equation `(1 + r_eff) = (1 + discount_rate) * (1 + inflation_rate)` to form the effective discount rate. Set to 0 if `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` | | `save_cost_breakdown` | `bool` | Whether to save annual cost breakdowns to CSV. | `False` | | `save_npv_breakdown` | `bool` | Whether to save per-technology NPV breakdowns to CSV. | `False` | @@ -30,7 +31,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` + discount_rate: 0.09 # each period is discounted at a rate of `(1 + discount_rate) * (1 + inflation_rate) - 1` + inflation_rate: 0.0 # optional, defaults to 0; provide e.g. 0.025 if `discount_rate` is a real rate commodity_sell_price: 0.078 # if commodity is electricity $/kwh save_cost_breakdown: True save_npv_breakdown: True @@ -64,7 +66,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 + discount_rate) * (1 + inflation_rate) - 1` is the Fisher-equation combination of the user-supplied real (or nominal) discount rate and inflation rate. When `inflation_rate` is left at its default of 0, this reduces to `discount_rate`, so the user can supply either a nominal discount rate alone or split it into a real discount rate plus an 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. diff --git a/h2integrate/finances/numpy_financial_npv.py b/h2integrate/finances/numpy_financial_npv.py index 7330548df..467acd2e7 100644 --- a/h2integrate/finances/numpy_financial_npv.py +++ b/h2integrate/finances/numpy_financial_npv.py @@ -15,9 +15,30 @@ class NumpyFinancialNPVFinanceConfig(BaseConfig): """Configuration for NumpyFinancialNPVFinance. + The effective rate used to discount future cash flows combines + ``discount_rate`` and ``inflation_rate`` via the Fisher equation: + + (1 + effective_rate) = (1 + 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 ``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. + 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 ``discount_rate`` via the Fisher + equation to form the effective discount rate used by + ``numpy_financial.npv``. Defaults to 0.0, in which case + ``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 save_cost_breakdown (bool, optional): whether to save the cost breakdown per year. @@ -31,6 +52,7 @@ class NumpyFinancialNPVFinanceConfig(BaseConfig): plant_life: int = field(converter=int, validator=gte_zero) 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) save_cost_breakdown: bool = field(default=False) save_npv_breakdown: bool = field(default=False) @@ -260,11 +282,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 discount_rate and inflation_rate via the Fisher + # equation: (1 + effective) = (1 + 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 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.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) diff --git a/h2integrate/finances/test/test_numpy_financial_npv.py b/h2integrate/finances/test/test_numpy_financial_npv.py index f48383ed0..d2c77c6fe 100644 --- a/h2integrate/finances/test/test_numpy_financial_npv.py +++ b/h2integrate/finances/test/test_numpy_financial_npv.py @@ -2,7 +2,10 @@ 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 @@ -144,3 +147,124 @@ def test_simple_npv_positive( 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 + + +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() + plant_config = { + "plant": { + "plant_life": 30, + }, + "finance_parameters": {"model_inputs": npv_finance_inputs}, + } + pf = NumpyFinancialNPV( + driver_config={}, + plant_config=plant_config, + tech_config=fake_filtered_tech_config, + commodity_type="electricity", + description="no1", + ) + + ivc = om.IndepVarComp() + ivc.add_output("rated_electricity_production", mean_hourly_production, units="kW") + ivc.add_output("capacity_factor", [1.0] * 30, units="unitless") + + prob.model.add_subsystem("ivc", ivc, promotes=["*"]) + prob.model.add_subsystem("npv", pf, promotes=["*"]) + prob.setup() + + for variable, cost in fake_cost_dict.items(): + units = "USD" if "capex" in variable else "USD/year" + prob.set_val(f"npv.{variable}", cost, units=units) + + 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 + ) + + 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("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["discount_rate"] = 1.05 * 1.04 - 1.0 # 0.092 + + split_inputs = npv_finance_inputs.copy() + split_inputs["discount_rate"] = 0.05 + split_inputs["inflation_rate"] = 0.04 + + inflation_only_inputs = npv_finance_inputs.copy() + inflation_only_inputs["discount_rate"] = 0.0 + inflation_only_inputs["inflation_rate"] = 0.09 + + inflation_only_nominal_inputs = npv_finance_inputs.copy() + inflation_only_nominal_inputs["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 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, "discount_rate": 0.05, "inflation_rate": -0.01} + ) + + with pytest.raises(ValueError, match="inflation_rate"): + NumpyFinancialNPVFinanceConfig.from_dict( + {"plant_life": 30, "discount_rate": 0.05, "inflation_rate": 1.5} + ) From 91964d1ae2cd08a79a98cfa048842d92d39753f5 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 23 Jul 2026 13:54:16 -0600 Subject: [PATCH 2/3] Update docs/finance_models/numpy_financial_npv.md Co-authored-by: Jared Thomas --- docs/finance_models/numpy_financial_npv.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/finance_models/numpy_financial_npv.md b/docs/finance_models/numpy_financial_npv.md index f72d823bd..3a469d42a 100644 --- a/docs/finance_models/numpy_financial_npv.md +++ b/docs/finance_models/numpy_financial_npv.md @@ -33,7 +33,7 @@ npv: finance_model: "NumpyFinancialNPV" model_inputs: discount_rate: 0.09 # each period is discounted at a rate of `(1 + discount_rate) * (1 + inflation_rate) - 1` - inflation_rate: 0.0 # optional, defaults to 0; provide e.g. 0.025 if `discount_rate` is a real rate + inflation_rate: 0.0 # optional, defaults to 0; provide e.g. 0.025 if inflation rate is 2.5%. 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 From e9fb532d5f23a969d9e4092c57ee4ce502789adf Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 23 Jul 2026 14:24:22 -0600 Subject: [PATCH 3/3] renaming discount_rate to real_discount_rate for numpy npv --- CHANGELOG.md | 3 ++- docs/finance_models/numpy_financial_npv.md | 10 ++++----- .../specifying_finance_parameters.md | 2 +- examples/19_simple_dispatch/plant_config.yaml | 2 +- h2integrate/finances/numpy_financial_npv.py | 22 +++++++++---------- .../finances/test/test_numpy_financial_npv.py | 16 +++++++------- 6 files changed, 28 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f5223db..3e7763a85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +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 `discount_rate` via the Fisher equation `(1 + r_eff) = (1 + 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) +- 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) diff --git a/docs/finance_models/numpy_financial_npv.md b/docs/finance_models/numpy_financial_npv.md index f72d823bd..f8ba546b5 100644 --- a/docs/finance_models/numpy_financial_npv.md +++ b/docs/finance_models/numpy_financial_npv.md @@ -17,8 +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). Can be either a real or nominal rate depending on how `inflation_rate` is specified. | — | -| `inflation_rate` | `float` | Inflation rate (0–1). Combined with `discount_rate` via the Fisher equation `(1 + r_eff) = (1 + discount_rate) * (1 + inflation_rate)` to form the effective discount rate. Set to 0 if `discount_rate` is already a nominal rate. This matches how ProFAST combines its real discount rate and `general_inflation` inputs. | `0.0` | +| `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` | @@ -32,8 +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 `(1 + discount_rate) * (1 + inflation_rate) - 1` - inflation_rate: 0.0 # optional, defaults to 0; provide e.g. 0.025 if `discount_rate` is a real 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 @@ -68,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(effective_rate, values)`, where `effective_rate = (1 + discount_rate) * (1 + inflation_rate) - 1` is the Fisher-equation combination of the user-supplied real (or nominal) discount rate and inflation rate. When `inflation_rate` is left at its default of 0, this reduces to `discount_rate`, so the user can supply either a nominal discount rate alone or split it into a real discount rate plus an inflation rate. The multiplicative (Fisher) form matches the way ProFAST combines its real discount rate and `general_inflation` inputs. + - 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. diff --git a/docs/user_guide/specifying_finance_parameters.md b/docs/user_guide/specifying_finance_parameters.md index 0a48378d0..301a38be5 100644 --- a/docs/user_guide/specifying_finance_parameters.md +++ b/docs/user_guide/specifying_finance_parameters.md @@ -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" diff --git a/examples/19_simple_dispatch/plant_config.yaml b/examples/19_simple_dispatch/plant_config.yaml index 8f0a30522..1d52326a8 100644 --- a/examples/19_simple_dispatch/plant_config.yaml +++ b/examples/19_simple_dispatch/plant_config.yaml @@ -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 diff --git a/h2integrate/finances/numpy_financial_npv.py b/h2integrate/finances/numpy_financial_npv.py index 2cedb1ba9..db1e4e3e0 100644 --- a/h2integrate/finances/numpy_financial_npv.py +++ b/h2integrate/finances/numpy_financial_npv.py @@ -17,28 +17,28 @@ class NumpyFinancialNPVFinanceConfig(BaseConfig): """Configuration for NumpyFinancialNPVFinance. The effective rate used to discount future cash flows combines - ``discount_rate`` and ``inflation_rate`` via the Fisher equation: + ``real_discount_rate`` and ``inflation_rate`` via the Fisher equation: - (1 + effective_rate) = (1 + discount_rate) * (1 + inflation_rate) + (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 ``discount_rate``. The multiplicative (Fisher) form is the + 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 ``discount_rate`` via the Fisher + 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 - ``discount_rate`` is used as-is (and should be a nominal rate if + ``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 @@ -54,7 +54,7 @@ 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() @@ -296,16 +296,16 @@ 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 discount_rate and inflation_rate via the Fisher - # equation: (1 + effective) = (1 + discount_rate) * (1 + inflation_rate). + # 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 discount_rate, so the user can either + # (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.discount_rate) * ( + effective_rate = (1.0 + self.config.real_discount_rate) * ( 1.0 + self.config.inflation_rate ) - 1.0 npv_item_check = 0 diff --git a/h2integrate/finances/test/test_numpy_financial_npv.py b/h2integrate/finances/test/test_numpy_financial_npv.py index 620e949c1..380175fda 100644 --- a/h2integrate/finances/test/test_numpy_financial_npv.py +++ b/h2integrate/finances/test/test_numpy_financial_npv.py @@ -11,7 +11,7 @@ @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, @@ -220,18 +220,18 @@ def test_inflation_rate_combines_via_fisher_equation( # 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["discount_rate"] = 1.05 * 1.04 - 1.0 # 0.092 + nominal_inputs["real_discount_rate"] = 1.05 * 1.04 - 1.0 # 0.092 split_inputs = npv_finance_inputs.copy() - split_inputs["discount_rate"] = 0.05 + split_inputs["real_discount_rate"] = 0.05 split_inputs["inflation_rate"] = 0.04 inflation_only_inputs = npv_finance_inputs.copy() - inflation_only_inputs["discount_rate"] = 0.0 + 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["discount_rate"] = 0.09 + 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) @@ -253,7 +253,7 @@ def test_inflation_rate_combines_via_fisher_equation( assert pytest.approx(npv_split, rel=1e-12) == npv_nominal with subtests.test("Inflation-only matches equivalent nominal (real=0)"): - # With discount_rate=0, (1+0)*(1+pi)-1 = pi, so this should match a + # 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 @@ -265,7 +265,7 @@ def test_inflation_rate_validator_rejects_out_of_range(): NumpyFinancialNPVFinanceConfig.from_dict( { "plant_life": 30, - "discount_rate": 0.05, + "real_discount_rate": 0.05, "inflation_rate": -0.01, "commodity_sell_price_units": "USD/(kW*h)", } @@ -275,7 +275,7 @@ def test_inflation_rate_validator_rejects_out_of_range(): NumpyFinancialNPVFinanceConfig.from_dict( { "plant_life": 30, - "discount_rate": 0.05, + "real_discount_rate": 0.05, "inflation_rate": 1.5, "commodity_sell_price_units": "USD/(kW*h)", }