diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f80cdc6..7f1651fde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/finance_models/numpy_financial_npv.md b/docs/finance_models/numpy_financial_npv.md index 4c41208a7..f8ba546b5 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). | — | +| `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` | @@ -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 @@ -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. 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 cffdd9add..db1e4e3e0 100644 --- a/h2integrate/finances/numpy_financial_npv.py +++ b/h2integrate/finances/numpy_financial_npv.py @@ -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`` @@ -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) @@ -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) diff --git a/h2integrate/finances/test/test_numpy_financial_npv.py b/h2integrate/finances/test/test_numpy_financial_npv.py index f3092cdad..380175fda 100644 --- a/h2integrate/finances/test/test_numpy_financial_npv.py +++ b/h2integrate/finances/test/test_numpy_financial_npv.py @@ -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, @@ -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={}, @@ -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)", + } + )