diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 151fc175..90950de5 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.10.18 +current_version = 3.10.20 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 5415b217..9b8c358c 100644 --- a/.cookiecutterrc +++ b/.cookiecutterrc @@ -54,7 +54,7 @@ default_context: sphinx_doctest: "no" sphinx_theme: "sphinx-py3doc-enhanced-theme" test_matrix_separate_coverage: "no" - version: 3.10.18 + version: 3.10.20 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index cfb62130..70bd815a 100644 --- a/README.rst +++ b/README.rst @@ -58,9 +58,9 @@ Free software: `MIT license `__ :alt: Supported implementations :target: https://pypi.org/project/geophires-x -.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.10.18.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.10.20.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.10.18...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.10.20...main .. |docs| image:: https://readthedocs.org/projects/GEOPHIRES-X/badge/?style=flat :target: https://nrel.github.io/GEOPHIRES-X diff --git a/docs/conf.py b/docs/conf.py index 4f9f554d..19d4ff05 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ year = '2025' author = 'NREL' copyright = f'{year}, {author}' -version = release = '3.10.18' +version = release = '3.10.20' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index bd4ad349..2808cbdc 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.10.18', + version='3.10.20', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index 5d386341..f47b02d5 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -14,7 +14,8 @@ real_discount_rate_parameter, after_tax_irr_parameter, moic_parameter, project_vir_parameter, \ project_payback_period_parameter, inflation_cost_during_construction_output_parameter, \ interest_during_construction_output_parameter, total_capex_parameter_output_parameter, \ - overnight_capital_cost_output_parameter, CONSTRUCTION_CAPEX_SCHEDULE_PARAMETER_NAME + overnight_capital_cost_output_parameter, CONSTRUCTION_CAPEX_SCHEDULE_PARAMETER_NAME, \ + _YEAR_INDEX_VALUE_EXPLANATION_SNIPPET from geophires_x.GeoPHIRESUtils import quantity from geophires_x.OptionList import Configuration, WellDrillingCostCorrelation, EconomicModel, EndUseOptions, PlantType, \ _WellDrillingCostCorrelationCitation @@ -1006,6 +1007,17 @@ def __init__(self, model: Model): "increases a 4% rate (0.04) to 4.1% (0.041) in the next year." ) + self.royalty_escalation_rate_start_year = self.ParameterDict[self.royalty_escalation_rate_start_year.Name] = intParameter( + 'Royalty Rate Escalation Start Year', + DefaultValue=1, + AllowableRange=list(range(1, model.surfaceplant.plant_lifetime.AllowableRange[-1], 1)), + UnitType=Units.PERCENT, + PreferredUnits=PercentUnit.TENTH, + CurrentUnits=PercentUnit.TENTH, + ToolTipText=f'The first year that the {self.royalty_escalation_rate.Name} is applied. ' + f'{_YEAR_INDEX_VALUE_EXPLANATION_SNIPPET}.' + ) + maximum_royalty_rate_default_val = 1.0 self.maximum_royalty_rate = self.ParameterDict[self.maximum_royalty_rate.Name] = floatParameter( 'Royalty Rate Maximum', @@ -1203,8 +1215,7 @@ def __init__(self, model: Model): f'Provide {bond_financing_start_year_name} to delay the ' f'start of bond financing during construction; years prior to {bond_financing_start_year_name} ' f'will be financed with equity only. ' - f'The value is specified as a project year index corresponding to the Year row in the cash ' - f'flow profile; the first construction year has the year index ' + f'{_YEAR_INDEX_VALUE_EXPLANATION_SNIPPET}; the first construction year has the year index ' f'{{({model.surfaceplant.construction_years.Name} - 1) * -1}})' f' and the final construction year index is 0. ' f'For example, a project with 4 construction years ' @@ -3402,10 +3413,11 @@ def r(x: float) -> float: schedule = [] current_rate = r(self.royalty_rate.value) - for _ in range(plant_lifetime): + for year_index in range(plant_lifetime): current_rate = r(current_rate) schedule.append(min(current_rate, max_rate)) - current_rate += escalation_rate + if year_index >= (model.economics.royalty_escalation_rate_start_year.value - 2): + current_rate += escalation_rate return schedule diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index d43c1116..7eb1760d 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -53,6 +53,9 @@ from geophires_x.Units import convertible_unit, EnergyCostUnit, CurrencyUnit, Units +ROYALTIES_OPEX_CASH_FLOW_LINE_ITEM_KEY = 'O&M production-based expense ($)' + + @dataclass class SamEconomicsCalculations: _sam_cash_flow_profile_operational_years: list[list[Any]] @@ -73,6 +76,7 @@ class SamEconomicsCalculations: capex: OutputParameter = field(default_factory=total_capex_parameter_output_parameter) + _royalties_rate_schedule: list[float] | None = None royalties_opex: OutputParameter = field(default_factory=royalty_cost_output_parameter) project_npv: OutputParameter = field( @@ -104,6 +108,8 @@ def sam_cash_flow_profile(self) -> list[list[Any]]: ret: list[list[Any]] = self._sam_cash_flow_profile_operational_years.copy() col_count = len(self._sam_cash_flow_profile_operational_years[0]) + # TODO support/insert calendar year line item + pre_revenue_years_to_insert = self._pre_revenue_years_count - 1 construction_rows: list[list[Any]] = [ @@ -124,7 +130,7 @@ def sam_cash_flow_profile(self) -> list[list[Any]]: pre_revenue_row.extend([''] * (col_count - len(pre_revenue_row))) construction_rows.append(pre_revenue_row) - # TODO zero-vectors e.g. Debt principal payment ($) + # TODO zero-vectors for non-construction years e.g. Debt principal payment ($) adjusted_row = [ret[row_index][0]] + pre_revenue_row_content + ret[row_index][insert_index:] ret[row_index] = adjusted_row @@ -166,6 +172,34 @@ def _get_row(row_name__: str) -> list[Any]: ret[_get_row_index('After-tax cumulative NPV ($)')] = ['After-tax cumulative NPV ($)'] + npv_usd ret[_get_row_index('After-tax cumulative IRR (%)')] = ['After-tax cumulative IRR (%)'] + irr_pct + if self._royalties_rate_schedule is not None: + ret = self._insert_royalties_rate_schedule(ret) + + return ret + + def _insert_royalties_rate_schedule(self, cf_ret: list[list[Any]]) -> list[list[Any]]: + """ + TODO update user-facing documentation to mention this feature + (https://nrel.github.io/GEOPHIRES/SAM-Economic-Models.html#royalties) + """ + + ret = cf_ret.copy() + + def _get_row_index(row_name_: str) -> list[Any]: + return [it[0] for it in ret].index(row_name_) + + ret.insert( + _get_row_index(ROYALTIES_OPEX_CASH_FLOW_LINE_ITEM_KEY), + [ + *['Royalty rate (%)'], + *([''] * (self._pre_revenue_years_count)), + *[ + quantity(it, 'dimensionless').to(convertible_unit('percent')).magnitude + for it in self._royalties_rate_schedule + ], + ], + ) + return ret @property @@ -319,10 +353,12 @@ def sf(_v: float, num_sig_figs: int = 5) -> float: *_pre_revenue_years_vector(model), *[ quantity(it, 'USD / year').to(sam_economics.royalties_opex.CurrentUnits).magnitude - for it in _cash_flow_profile_row(cash_flow_operational_years, 'O&M production-based expense ($)') + for it in _cash_flow_profile_row(cash_flow_operational_years, ROYALTIES_OPEX_CASH_FLOW_LINE_ITEM_KEY) ], ] + sam_economics._royalties_rate_schedule = model.economics.get_royalty_rate_schedule(model) + sam_economics.nominal_discount_rate.value, sam_economics.wacc.value = _calculate_nominal_discount_rate_and_wacc( model, single_owner ) diff --git a/src/geophires_x/EconomicsUtils.py b/src/geophires_x/EconomicsUtils.py index bc800306..459a2dcf 100644 --- a/src/geophires_x/EconomicsUtils.py +++ b/src/geophires_x/EconomicsUtils.py @@ -5,6 +5,10 @@ CONSTRUCTION_CAPEX_SCHEDULE_PARAMETER_NAME = 'Construction CAPEX Schedule' +_YEAR_INDEX_VALUE_EXPLANATION_SNIPPET = ( + f'The value is specified as a project year index corresponding to the ' f'Year row in the cash flow profile' +) + def BuildPricingModel( plantlifetime: int, diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index 25ebd022..304949ce 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.10.18' +__version__ = '3.10.20' diff --git a/src/geophires_x_schema_generator/geophires-request.json b/src/geophires_x_schema_generator/geophires-request.json index 41ae2785..be86aef3 100644 --- a/src/geophires_x_schema_generator/geophires-request.json +++ b/src/geophires_x_schema_generator/geophires-request.json @@ -1665,6 +1665,15 @@ "minimum": 0.0, "maximum": 1.0 }, + "Royalty Rate Escalation Start Year": { + "description": "The first year that the Royalty Rate Escalation is applied. The value is specified as a project year index corresponding to the Year row in the cash flow profile.", + "type": "integer", + "units": "", + "category": "Economics", + "default": 1, + "minimum": 1, + "maximum": 99 + }, "Royalty Rate Maximum": { "description": "The maximum royalty rate after escalation, expressed as a fraction (e.g., 0.06 for a 6% cap). Defaults to 100% (no effective cap).", "type": "number", diff --git a/tests/examples/example_SAM-single-owner-PPA-4.out b/tests/examples/example_SAM-single-owner-PPA-4.out index f5e62891..53e83a3b 100644 --- a/tests/examples/example_SAM-single-owner-PPA-4.out +++ b/tests/examples/example_SAM-single-owner-PPA-4.out @@ -4,10 +4,10 @@ Simulation Metadata ---------------------- - GEOPHIRES Version: 3.10.17 - Simulation Date: 2025-12-10 - Simulation Time: 11:17 - Calculation Time: 1.184 sec + GEOPHIRES Version: 3.10.19 + Simulation Date: 2025-12-11 + Simulation Time: 09:11 + Calculation Time: 1.234 sec ***SUMMARY OF RESULTS*** @@ -230,6 +230,7 @@ Property tax net assessed value ($) 0 225,808,536 225, OPERATING EXPENSES O&M fixed expense ($) 0 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 +Royalty rate (%) 5.0 6.0 7.00 8.0 9.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 O&M production-based expense ($) 0 1,714,220 2,069,952 2,516,875 2,991,037 3,492,966 4,022,825 4,164,255 4,305,473 4,446,544 4,587,507 4,728,390 4,869,211 5,009,984 5,150,718 5,291,422 5,432,101 5,572,760 5,713,403 5,854,032 5,994,619 O&M capacity-based expense ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 Fuel expense ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 diff --git a/tests/geophires_x_tests/test_economics_sam.py b/tests/geophires_x_tests/test_economics_sam.py index 9720f712..76c25437 100644 --- a/tests/geophires_x_tests/test_economics_sam.py +++ b/tests/geophires_x_tests/test_economics_sam.py @@ -938,6 +938,67 @@ def test_royalty_rate_schedule(self): places=3, ) + def test_royalty_rate_escalation_start_year(self) -> None: + construction_years: int = 5 + plant_lifetime: int = 20 + + def _get_result(start_year: int) -> tuple[str, dict[str, Any], GeophiresXResult]: + _input_file_path = self._get_test_file_path('generic-egs-case.txt') + _additional_params = { + 'Economic Model': 5, + 'Construction Years': construction_years, + 'Plant Lifetime': plant_lifetime, + 'Royalty Rate': 0.04, + 'Royalty Rate Maximum': 0.06, + 'Royalty Rate Escalation': 0.02, + 'Royalty Rate Escalation Start Year': start_year, + 'Print Output to Console': 1, + } + input_params: GeophiresInputParameters = GeophiresInputParameters( + from_file_path=_input_file_path, + params=_additional_params, + ) + return _input_file_path, _additional_params, GeophiresXClient().get_geophires_result(input_params) + + def __cash_flow_row(r: GeophiresXResult, row_name: str) -> str: + from geophires_x.EconomicsSam import _cash_flow_profile_row + + return [it for it in _cash_flow_profile_row(r.result['SAM CASH FLOW PROFILE'], row_name) if is_float(it)] + + def _royalty_cash_flow(r: GeophiresXResult) -> list[float]: + return __cash_flow_row(r, 'O&M production-based expense ($)')[1:] # Drop year 0 ($0 revenue) + + def _royalty_rates_from_cash_flow(r: GeophiresXResult) -> list[float]: + return __cash_flow_row(r, 'Royalty rate (%)') + + input_file_path, additional_params, result_4 = _get_result(4) + + expected_royalty_rate_schedule_4 = [*([0.04] * 3), *([0.06] * (plant_lifetime - 3))] + model = EconomicsSamTestCase._new_model(input_file_path, additional_params=additional_params) + econ_royalty_rate_schedule_4 = model.economics.get_royalty_rate_schedule(model) + self.assertListEqual(expected_royalty_rate_schedule_4, econ_royalty_rate_schedule_4) + + result_4_royalty_cash_flow_usd = _royalty_cash_flow(result_4) + self.assertEqual(len(expected_royalty_rate_schedule_4), len(result_4_royalty_cash_flow_usd)) + + econ_royalty_rate_schedule_4_percent = [ + quantity(it, 'dimensionless').to(convertible_unit('percent')).magnitude + for it in econ_royalty_rate_schedule_4 + ] + royalty_rates_from_cash_flow_4 = _royalty_rates_from_cash_flow(result_4) + self.assertListEqual(econ_royalty_rate_schedule_4_percent, royalty_rates_from_cash_flow_4) + + expected_royalties_based_on_cash_flow_ppa_revenue = [] + cash_flow_ppa_revenue = __cash_flow_row(result_4, 'PPA revenue ($)')[1:] + for i, _rev_usd in enumerate(econ_royalty_rate_schedule_4_percent): + expected_royalties_based_on_cash_flow_ppa_revenue.append( + cash_flow_ppa_revenue[i] * econ_royalty_rate_schedule_4[i] + ) + + self.assertListAlmostEqual( + expected_royalties_based_on_cash_flow_ppa_revenue, result_4_royalty_cash_flow_usd, percent=0.0001 + ) + def test_sam_cash_flow_total_after_tax_returns_all_years(self): input_file = self._egs_test_file_path() additional_params = {'Construction Years': 2}