From b3cf5b52eda5ee2471834b170b03d1f76f76244c Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 09:53:48 -0700 Subject: [PATCH 01/44] Initial implementation of royalty rate. WIP - TODO to implement royalty collector revenue/NPV/etc., update documentation, add more unit tests --- src/geophires_x/Economics.py | 10 ++++++++++ src/geophires_x/EconomicsSam.py | 18 +++++++++++++++++- src/geophires_x/Units.py | 3 +++ tests/geophires_x_tests/test_economics_sam.py | 18 ++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index a5781a49e..f8457138f 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -967,6 +967,16 @@ def __init__(self, model: Model): "will be automatically set to the same value." ) + self.royalty_rate = self.ParameterDict[self.royalty_rate.Name] = floatParameter( + "Royalty Rate", + DefaultValue=0., + Min=0.0, + Max=1.0, + UnitType=Units.PERCENT, + PreferredUnits=PercentUnit.TENTH, + CurrentUnits=PercentUnit.TENTH, + ToolTipText="Royalty rate used in SAM Economic Models." # FIXME WIP TODO documentation + ) self.discount_initial_year_cashflow = self.ParameterDict[self.discount_initial_year_cashflow.Name] = boolParameter( 'Discount Initial Year Cashflow', diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index e1b14ccca..d2ced510d 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -395,13 +395,29 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]: geophires_ptr_tenths = Decimal(econ.PTR.value) ret['property_tax_rate'] = float(geophires_ptr_tenths * Decimal(100)) - ret['ppa_price_input'] = _ppa_pricing_model( + ppa_price_schedule_per_kWh = _ppa_pricing_model( model.surfaceplant.plant_lifetime.value, econ.ElecStartPrice.value, econ.ElecEndPrice.value, econ.ElecEscalationStart.value, econ.ElecEscalationRate.value, ) + ret['ppa_price_input'] = ppa_price_schedule_per_kWh + + if hasattr(econ, 'royalty_rate') and econ.royalty_rate.value > 0.0: + royalty_rate_fraction = econ.royalty_rate.quantity().to(convertible_unit('dimensionless')).magnitude + + # For each year, calculate the royalty as a $/MWh variable cost. + # The royalty is a percentage of revenue (MWh * $/MWh). By setting the + # variable O&M rate to (PPA Price * Royalty Rate), SAM's calculation + # (Rate * MWh) will correctly yield the total royalty payment. + variable_om_schedule_per_MWh = [ + (price_per_kWh * 1000) * royalty_rate_fraction # TODO pint unit conversion (kWh -> MWh) + for price_per_kWh in ppa_price_schedule_per_kWh + ] + + # The PySAM parameter for variable operating cost in $/MWh is 'om_production'. + ret['om_production'] = variable_om_schedule_per_MWh # Debt/equity ratio ('Fraction of Investment in Bonds' parameter) ret['debt_percent'] = _pct(econ.FIB) diff --git a/src/geophires_x/Units.py b/src/geophires_x/Units.py index 50509c735..acda1b94a 100644 --- a/src/geophires_x/Units.py +++ b/src/geophires_x/Units.py @@ -26,6 +26,9 @@ def convertible_unit(unit: Any) -> Any: if unit == Units.PERCENT or unit == PercentUnit.PERCENT or unit == Units.PERCENT.value: return 'percent' + if unit == PercentUnit.TENTH or unit == PercentUnit.TENTH.value: + return 'dimensionless' + return unit diff --git a/tests/geophires_x_tests/test_economics_sam.py b/tests/geophires_x_tests/test_economics_sam.py index 55e76d50c..cb203f70a 100644 --- a/tests/geophires_x_tests/test_economics_sam.py +++ b/tests/geophires_x_tests/test_economics_sam.py @@ -638,6 +638,24 @@ def _assert_capex_line_items_sum_to_total(self, r: GeophiresXResult): self.assertEqual(total_capex, capex_line_item_sum) + def test_royalty_rate(self): + royalty_rate = 0.1 + m: Model = EconomicsSamTestCase._new_model( + self._egs_test_file_path(), additional_params={'Royalty Rate': royalty_rate} + ) + + sam_econ = calculate_sam_economics(m) + cash_flow = sam_econ.sam_cash_flow_profile + + def get_row(name: str): + return EconomicsSamTestCase._get_cash_flow_row(cash_flow, name) + + ppa_revenue_row = get_row('PPA revenue ($)') + expected_royalties = [x * royalty_rate for x in ppa_revenue_row] + + om_prod_based_expense_row = get_row('O&M production-based expense ($)') + self.assertListAlmostEqual(expected_royalties, om_prod_based_expense_row, places=0) + @staticmethod def _new_model(input_file: Path, additional_params: dict[str, Any] | None = None, read_and_calculate=True) -> Model: if additional_params is not None: From 59b6950ba888b26d8bcb1f23db6b2a56e7100a6b Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:01:09 -0700 Subject: [PATCH 02/44] Add entry in SAM Economic Models docs --- docs/SAM-Economic-Models.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/SAM-Economic-Models.md b/docs/SAM-Economic-Models.md index b54fdddd6..0e79a3a28 100644 --- a/docs/SAM-Economic-Models.md +++ b/docs/SAM-Economic-Models.md @@ -22,6 +22,7 @@ The following table describes how GEOPHIRES parameters are transformed into SAM | `Net Electricity Generation` | AC Degradation | `Annual AC degradation rate` schedule | `Utilityrate5` | `degradation` | Percentage difference of each year's `Net Electricity Generation` from `Maximum Total Electricity Generation` is input as SAM as the degradation rate schedule in order to match SAM's generation profile to GEOPHIRES | | {`Total CAPEX` before inflation} × (1 + `Accrued financing during construction (%)`/100); | Installation Costs | `Total Installed Cost` | `Singleowner` | `total_installed_cost` | `Accrued financing during construction (%)` = (1+`Inflation Rate During Construction`) × 100 if `Inflation Rate During Construction` is provided or ((1+`Inflation Rate`) ^ `Construction Years`) × 100 if not. | | `Total O&M Cost`, `Inflation Rate` | Operating Costs | `Fixed operating cost`, `Escalation rate` set to `Inflation Rate` × -1 | `Singleowner` | `om_fixed`, `om_fixed_escal` | .. N/A | +| `Royalty Rate` | Operating Costs | `Variable operating cost` | `Singleowner` | `om_production` | Royalties are treated as a variable operating cost to the owner | | `Plant Lifetime` | Financial Parameters → Analysis Parameters | `Analysis period` | `CustomGeneration`, `Singleowner` | `CustomGeneration.analysis_period`, `Singleowner.term_tenor` | .. N/A | | `Inflation Rate` | Financial Parameters → Analysis Parameters | `Inflation rate` | `Utilityrate5` | `inflation_rate` | .. N/A | | `Discount Rate` | Financial Parameters → Analysis Parameters | `Real discount rate` | `Singleowner` | `real_discount_rate` | .. N/A | From 842a2b06ec197fd6603b9b3a5a725bbae8427583 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:01:22 -0700 Subject: [PATCH 03/44] Regenerate schema (with Royalty Rate) --- src/geophires_x_schema_generator/geophires-request.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/geophires_x_schema_generator/geophires-request.json b/src/geophires_x_schema_generator/geophires-request.json index d7420d613..bfdd138dc 100644 --- a/src/geophires_x_schema_generator/geophires-request.json +++ b/src/geophires_x_schema_generator/geophires-request.json @@ -1638,6 +1638,15 @@ "minimum": 0.0, "maximum": 1.0 }, + "Royalty Rate": { + "description": "Royalty rate used in SAM Economic Models.", + "type": "number", + "units": "", + "category": "Economics", + "default": 0.0, + "minimum": 0.0, + "maximum": 1.0 + }, "Discount Initial Year Cashflow": { "description": "Whether to discount cashflow in the initial project year when calculating NPV (Net Present Value). The default value of False conforms to NREL's standard convention for NPV calculation (Short W et al, 1995. https://www.nrel.gov/docs/legosti/old/5173.pdf). A value of True will, by contrast, cause NPV calculation to follow the convention used by Excel, Google Sheets, and other common spreadsheet software. Although NREL's NPV convention may typically be considered more technically correct, Excel-style NPV calculation might be preferred for familiarity or compatibility with existing business processes. See https://github.com/NREL/GEOPHIRES-X/discussions/344 for further details.", "type": "boolean", From 0a1dad9ac9cd12039c421551721021ab01c7049a Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:27:55 -0700 Subject: [PATCH 04/44] Mark TODO remove or clarify project payback period: https://github.com/NREL/GEOPHIRES-X/issues/413 --- src/geophires_x/Economics.py | 2 ++ src/geophires_x/EconomicsSam.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index f8457138f..345442652 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -2517,6 +2517,8 @@ def Calculate(self, model: Model) -> None: self.ProjectMOIC.value = self.sam_economics_calculations.moic.value self.ProjectVIR.value = self.sam_economics_calculations.project_vir.value + + # TODO remove or clarify project payback period: https://github.com/NREL/GEOPHIRES-X/issues/413 self.ProjectPaybackPeriod.value = self.sam_economics_calculations.project_payback_period.value # Calculate the project payback period diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index d2ced510d..139d53ff0 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -71,7 +71,9 @@ class SamEconomicsCalculations: wacc: OutputParameter = field(default_factory=wacc_output_parameter) moic: OutputParameter = field(default_factory=moic_parameter) project_vir: OutputParameter = field(default_factory=project_vir_parameter) + project_payback_period: OutputParameter = field(default_factory=project_payback_period_parameter) + """TODO remove or clarify project payback period: https://github.com/NREL/GEOPHIRES-X/issues/413""" def validate_read_parameters(model: Model): @@ -248,6 +250,7 @@ def _calculate_project_vir(cash_flow: list[list[Any]], model) -> float | None: def _calculate_project_payback_period(cash_flow: list[list[Any]], model) -> float | None: + """TODO remove or clarify project payback period: https://github.com/NREL/GEOPHIRES-X/issues/413""" try: after_tax_cash_flow = _cash_flow_profile_row(cash_flow, 'Total after-tax returns ($)') cumm_cash_flow = np.zeros(len(after_tax_cash_flow)) From f071d90b269fb90c0dd788a2ed0c522de364f0c1 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:04:47 -0700 Subject: [PATCH 05/44] Include royalties in opex output/total opex (WIP) --- src/geophires_x/Economics.py | 12 +++++++++-- src/geophires_x/EconomicsSam.py | 12 ++++++++++- src/geophires_x/EconomicsUtils.py | 12 ++++++++++- src/geophires_x/Outputs.py | 4 ++++ src/geophires_x_client/geophires_x_result.py | 1 + tests/geophires_x_tests/test_economics_sam.py | 7 ++++++- tests/test_geophires_x.py | 21 +++++++++++++++++++ 7 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index 345442652..f82e31195 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -13,7 +13,7 @@ from geophires_x.EconomicsUtils import BuildPricingModel, wacc_output_parameter, nominal_discount_rate_parameter, \ real_discount_rate_parameter, after_tax_irr_parameter, moic_parameter, project_vir_parameter, \ project_payback_period_parameter, inflation_cost_during_construction_output_parameter, \ - total_capex_parameter_output_parameter + total_capex_parameter_output_parameter, royalties_opex_parameter_output_parameter from geophires_x.GeoPHIRESUtils import quantity from geophires_x.OptionList import Configuration, WellDrillingCostCorrelation, EconomicModel, EndUseOptions, PlantType, \ _WellDrillingCostCorrelationCitation @@ -1906,6 +1906,7 @@ def __init__(self, model: Model): PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR ) + self.royalties_opex = self.OutputParameterDict[self.royalties_opex.Name] = royalties_opex_parameter_output_parameter() # district heating self.peakingboilercost = self.OutputParameterDict[self.peakingboilercost.Name] = OutputParameter( @@ -2502,10 +2503,17 @@ def Calculate(self, model: Model) -> None: # Setting capex_total distinguishes capex from CCap's display name of 'Total capital costs', # since SAM Economic Model doesn't subtract ITC from this value. self.capex_total.value = (self.sam_economics_calculations.capex.quantity() - .to(self.capex_total.CurrentUnits.value).magnitude) + .to(self.capex_total.CurrentUnits.value).magnitude) self.CCap.value = (self.sam_economics_calculations.capex.quantity() .to(self.CCap.CurrentUnits.value).magnitude) + # FIXME WIP adjust OPEX for royalties + # FIXME WIP unit conversion + average_annual_royalties = np.average(self.sam_economics_calculations.royalties_opex[1:]) # ignore Year 0 + if average_annual_royalties > 0: + self.royalties_opex.value = average_annual_royalties + self.Coam.value += self.royalties_opex.quantity().to(self.Coam.CurrentUnits.value).magnitude + self.wacc.value = self.sam_economics_calculations.wacc.value self.nominal_discount_rate.value = self.sam_economics_calculations.nominal_discount_rate.value self.ProjectNPV.value = self.sam_economics_calculations.project_npv.quantity().to( diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index 139d53ff0..b19311e5f 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -39,11 +39,12 @@ project_payback_period_parameter, inflation_cost_during_construction_output_parameter, total_capex_parameter_output_parameter, + royalties_opex_parameter_output_parameter, ) from geophires_x.GeoPHIRESUtils import is_float, is_int, sig_figs, quantity from geophires_x.OptionList import EconomicModel, EndUseOptions from geophires_x.Parameter import Parameter, OutputParameter, floatParameter -from geophires_x.Units import convertible_unit, EnergyCostUnit, CurrencyUnit, Units +from geophires_x.Units import convertible_unit, EnergyCostUnit, CurrencyUnit, Units, CurrencyFrequencyUnit @dataclass @@ -59,6 +60,8 @@ class SamEconomicsCalculations: capex: OutputParameter = field(default_factory=total_capex_parameter_output_parameter) + royalties_opex: OutputParameter = field(default_factory=royalties_opex_parameter_output_parameter) + project_npv: OutputParameter = field( default_factory=lambda: OutputParameter( UnitType=Units.CURRENCY, @@ -170,6 +173,13 @@ def sf(_v: float, num_sig_figs: int = 5) -> float: sam_economics.project_npv.value = sf(single_owner.Outputs.project_return_aftertax_npv * 1e-6) sam_economics.capex.value = single_owner.Outputs.adjusted_installed_cost * 1e-6 + royalty_rate = model.economics.royalty_rate.quantity().to('dimensionless').magnitude + ppa_revenue_row = _cash_flow_profile_row(cash_flow, 'PPA revenue ($)') + royalties_unit = sam_economics.royalties_opex.CurrentUnits.value.replace('/yr', '') + sam_economics.royalties_opex = [ + quantity(x * royalty_rate, 'USD').to(royalties_unit).magnitude for x in ppa_revenue_row + ] + 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 1f586a145..fed0bb2e1 100644 --- a/src/geophires_x/EconomicsUtils.py +++ b/src/geophires_x/EconomicsUtils.py @@ -1,7 +1,7 @@ from __future__ import annotations from geophires_x.Parameter import OutputParameter -from geophires_x.Units import Units, PercentUnit, TimeUnit, CurrencyUnit +from geophires_x.Units import Units, PercentUnit, TimeUnit, CurrencyUnit, CurrencyFrequencyUnit def BuildPricingModel(plantlifetime: int, StartPrice: float, EndPrice: float, @@ -144,3 +144,13 @@ def total_capex_parameter_output_parameter() -> OutputParameter: 'For SAM Economic models, it also includes any cost escalation from inflation during construction. ' 'It is used as the total installed cost input for SAM Economic Models.' ) + + +def royalties_opex_parameter_output_parameter() -> OutputParameter: + return OutputParameter( + Name='Royalties', + UnitType=Units.CURRENCYFREQUENCY, + PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, + CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, + ToolTipText='Average annual royalties' # TODO WIP clarify + ) diff --git a/src/geophires_x/Outputs.py b/src/geophires_x/Outputs.py index 26c082fa9..b6a943b49 100644 --- a/src/geophires_x/Outputs.py +++ b/src/geophires_x/Outputs.py @@ -555,6 +555,10 @@ def PrintOutputs(self, model: Model): aoc_label = Outputs._field_label(model.addeconomics.AddOnOPEXTotalPerYear.display_name, 47) f.write(f' {aoc_label}{model.addeconomics.AddOnOPEXTotalPerYear.value:10.2f} {model.addeconomics.AddOnOPEXTotalPerYear.CurrentUnits.value}\n') + if econ.royalty_rate.value > 0.0: + royalties_label = Outputs._field_label(econ.royalties_opex.display_name, 47) + f.write(f' {royalties_label}{econ.royalties_opex.value:10.2f} {econ.royalties_opex.CurrentUnits.value}\n') + f.write(f' {econ.Coam.display_name}: {(econ.Coam.value + econ.averageannualpumpingcosts.value + econ.averageannualheatpumpelectricitycost.value):10.2f} {econ.Coam.CurrentUnits.value}\n') else: f.write(f' {econ.Coam.display_name}: {econ.Coam.value:10.2f} {econ.Coam.CurrentUnits.value}\n') diff --git a/src/geophires_x_client/geophires_x_result.py b/src/geophires_x_client/geophires_x_result.py index aabb76199..a8049608c 100644 --- a/src/geophires_x_client/geophires_x_result.py +++ b/src/geophires_x_client/geophires_x_result.py @@ -290,6 +290,7 @@ class GeophiresXResult: 'Annual District Heating O&M Cost', 'Average Annual Peaking Fuel Cost', 'Average annual pumping costs', + 'Royalties', # SUTRA 'Average annual auxiliary fuel cost', 'Average annual pumping cost', diff --git a/tests/geophires_x_tests/test_economics_sam.py b/tests/geophires_x_tests/test_economics_sam.py index cb203f70a..6cb025aeb 100644 --- a/tests/geophires_x_tests/test_economics_sam.py +++ b/tests/geophires_x_tests/test_economics_sam.py @@ -20,6 +20,7 @@ get_sam_cash_flow_profile_tabulated_output, _ppa_pricing_model, _get_fed_and_state_tax_rates, + SamEconomicsCalculations, ) from geophires_x.GeoPHIRESUtils import sig_figs, quantity @@ -644,7 +645,7 @@ def test_royalty_rate(self): self._egs_test_file_path(), additional_params={'Royalty Rate': royalty_rate} ) - sam_econ = calculate_sam_economics(m) + sam_econ: SamEconomicsCalculations = calculate_sam_economics(m) cash_flow = sam_econ.sam_cash_flow_profile def get_row(name: str): @@ -653,8 +654,12 @@ def get_row(name: str): ppa_revenue_row = get_row('PPA revenue ($)') expected_royalties = [x * royalty_rate for x in ppa_revenue_row] + self.assertListEqual(expected_royalties, sam_econ.royalties_opex) + om_prod_based_expense_row = get_row('O&M production-based expense ($)') self.assertListAlmostEqual(expected_royalties, om_prod_based_expense_row, places=0) + # Note the above assertion assumes royalties are the only production-based O&M expenses. If this changes, + # the assertion will need to be updated. @staticmethod def _new_model(input_file: Path, additional_params: dict[str, Any] | None = None, read_and_calculate=True) -> Model: diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index 0e50e5d4a..08ae56f8f 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -1294,3 +1294,24 @@ def test_redrilling_costs(self): ) / result.result['ECONOMIC PARAMETERS']['Project lifetime']['value'] self.assertAlmostEqual(expected_annual_redrilling_cost, result_opex['Redrilling costs']['value'], places=2) + + def test_royalty_rate(self): + royalties_output_name = 'Royalties' + + for royalty_rate in [0, 0.1]: + result = GeophiresXClient().get_geophires_result( + ImmutableGeophiresInputParameters( + from_file_path=self._get_test_file_path( + 'geophires_x_tests/generic-egs-case-2_sam-single-owner-ppa.txt' + ), + params={'Royalty Rate': royalty_rate}, + ) + ) + opex_result = result.result['OPERATING AND MAINTENANCE COSTS (M$/yr)'] + if royalty_rate > 0.0: + self.assertIsNotNone(opex_result[royalties_output_name]) + self.assertEqual(58.88, opex_result[royalties_output_name]['value']) + self.assertEqual('MUSD/yr', opex_result[royalties_output_name]['unit']) + # FIXME WIP assert total opex includes royalties + else: + self.assertIsNone(opex_result[royalties_output_name]) From 2046f909d3e494606a8ca111bb1da5ac6128fc01 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:06:08 -0700 Subject: [PATCH 06/44] Regenerate schema with Royalties output param --- src/geophires_x/EconomicsUtils.py | 2 +- src/geophires_x_schema_generator/geophires-result.json | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/geophires_x/EconomicsUtils.py b/src/geophires_x/EconomicsUtils.py index fed0bb2e1..8cc6ab2b2 100644 --- a/src/geophires_x/EconomicsUtils.py +++ b/src/geophires_x/EconomicsUtils.py @@ -152,5 +152,5 @@ def royalties_opex_parameter_output_parameter() -> OutputParameter: UnitType=Units.CURRENCYFREQUENCY, PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, - ToolTipText='Average annual royalties' # TODO WIP clarify + ToolTipText='Average annual royalties paid (operating expense)' ) diff --git a/src/geophires_x_schema_generator/geophires-result.json b/src/geophires_x_schema_generator/geophires-result.json index 4edc6f710..d07189a9b 100644 --- a/src/geophires_x_schema_generator/geophires-result.json +++ b/src/geophires_x_schema_generator/geophires-result.json @@ -480,6 +480,11 @@ "units": "MUSD/yr" }, "Average annual pumping costs": {}, + "Royalties": { + "type": "number", + "description": "Average annual royalties paid (operating expense)", + "units": "MUSD/yr" + }, "Average annual auxiliary fuel cost": {}, "Average annual pumping cost": {}, "Redrilling costs": { From 5cf8f451287d933b1debb010d03c4f7bec7a5e95 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:10:58 -0700 Subject: [PATCH 07/44] Assert that opex line items, including royalties, sum up to total --- src/geophires_x/Economics.py | 2 -- tests/test_geophires_x.py | 14 +++++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index f82e31195..a5ebc3d6a 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -2507,8 +2507,6 @@ def Calculate(self, model: Model) -> None: self.CCap.value = (self.sam_economics_calculations.capex.quantity() .to(self.CCap.CurrentUnits.value).magnitude) - # FIXME WIP adjust OPEX for royalties - # FIXME WIP unit conversion average_annual_royalties = np.average(self.sam_economics_calculations.royalties_opex[1:]) # ignore Year 0 if average_annual_royalties > 0: self.royalties_opex.value = average_annual_royalties diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index 08ae56f8f..7b81b9b89 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -1312,6 +1312,18 @@ def test_royalty_rate(self): self.assertIsNotNone(opex_result[royalties_output_name]) self.assertEqual(58.88, opex_result[royalties_output_name]['value']) self.assertEqual('MUSD/yr', opex_result[royalties_output_name]['unit']) - # FIXME WIP assert total opex includes royalties + + total_opex_MUSD = opex_result['Total operating and maintenance costs']['value'] + + opex_line_item_sum = 0 + for line_item_names in [ + 'Wellfield maintenance costs', + 'Power plant maintenance costs', + 'Water costs', + royalties_output_name, + ]: + opex_line_item_sum += opex_result[line_item_names]['value'] + + self.assertEqual(opex_line_item_sum, total_opex_MUSD) else: self.assertIsNone(opex_result[royalties_output_name]) From 777b840804be5d4e78f7159267fe853410e08ccf Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:22:47 -0700 Subject: [PATCH 08/44] =?UTF-8?q?Bump=20version:=203.9.54=20=E2=86=92=203.?= =?UTF-8?q?9.55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- .cookiecutterrc | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- src/geophires_x/__init__.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 5e89dded1..61879902c 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.54 +current_version = 3.9.55 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 20b327d68..77d07fef2 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.9.54 + version: 3.9.55 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index 20f34431a..2f51b5ff5 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.9.54.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.55.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.54...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.55...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 091ee8507..149dfcdf1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ year = '2025' author = 'NREL' copyright = f'{year}, {author}' -version = release = '3.9.54' +version = release = '3.9.55' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index 399416965..1d45721df 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.54', + version='3.9.55', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index ba7552b02..c2fa4fff8 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.54' +__version__ = '3.9.55' From e89a4e24098ce7c29a123e52dc945ac697d8efda Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:33:19 -0700 Subject: [PATCH 09/44] update unit test --- tests/geophires_x_tests/test_economics_sam.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/geophires_x_tests/test_economics_sam.py b/tests/geophires_x_tests/test_economics_sam.py index 6cb025aeb..ee4037cd9 100644 --- a/tests/geophires_x_tests/test_economics_sam.py +++ b/tests/geophires_x_tests/test_economics_sam.py @@ -651,13 +651,14 @@ def test_royalty_rate(self): def get_row(name: str): return EconomicsSamTestCase._get_cash_flow_row(cash_flow, name) - ppa_revenue_row = get_row('PPA revenue ($)') - expected_royalties = [x * royalty_rate for x in ppa_revenue_row] + ppa_revenue_row_USD = get_row('PPA revenue ($)') + expected_royalties_USD = [x * royalty_rate for x in ppa_revenue_row_USD] + expected_royalties_MUSD = [x * 1e-6 for x in expected_royalties_USD] - self.assertListEqual(expected_royalties, sam_econ.royalties_opex) + self.assertListEqual(expected_royalties_MUSD, sam_econ.royalties_opex) om_prod_based_expense_row = get_row('O&M production-based expense ($)') - self.assertListAlmostEqual(expected_royalties, om_prod_based_expense_row, places=0) + self.assertListAlmostEqual(expected_royalties_USD, om_prod_based_expense_row, places=0) # Note the above assertion assumes royalties are the only production-based O&M expenses. If this changes, # the assertion will need to be updated. From 4215254eef42761ecd1c375a1f209612ee04edd0 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:38:26 -0700 Subject: [PATCH 10/44] Change output param name to 'Average Annual Royalty Cost' --- src/geophires_x/EconomicsUtils.py | 5 +++-- src/geophires_x_client/geophires_x_result.py | 2 +- src/geophires_x_schema_generator/geophires-result.json | 4 ++-- tests/test_geophires_x.py | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/geophires_x/EconomicsUtils.py b/src/geophires_x/EconomicsUtils.py index 8cc6ab2b2..3f7b271c3 100644 --- a/src/geophires_x/EconomicsUtils.py +++ b/src/geophires_x/EconomicsUtils.py @@ -148,9 +148,10 @@ def total_capex_parameter_output_parameter() -> OutputParameter: def royalties_opex_parameter_output_parameter() -> OutputParameter: return OutputParameter( - Name='Royalties', + Name='Average Annual Royalty Cost', UnitType=Units.CURRENCYFREQUENCY, PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, - ToolTipText='Average annual royalties paid (operating expense)' + ToolTipText='The average annual cost paid to a royalty holder, calculated as a percentage of the ' + 'project\'s gross annual revenue. This is modeled as a variable operating expense.' ) diff --git a/src/geophires_x_client/geophires_x_result.py b/src/geophires_x_client/geophires_x_result.py index a8049608c..04a0b7325 100644 --- a/src/geophires_x_client/geophires_x_result.py +++ b/src/geophires_x_client/geophires_x_result.py @@ -290,7 +290,7 @@ class GeophiresXResult: 'Annual District Heating O&M Cost', 'Average Annual Peaking Fuel Cost', 'Average annual pumping costs', - 'Royalties', + 'Average Annual Royalty Cost', # SUTRA 'Average annual auxiliary fuel cost', 'Average annual pumping cost', diff --git a/src/geophires_x_schema_generator/geophires-result.json b/src/geophires_x_schema_generator/geophires-result.json index d07189a9b..c87095087 100644 --- a/src/geophires_x_schema_generator/geophires-result.json +++ b/src/geophires_x_schema_generator/geophires-result.json @@ -480,9 +480,9 @@ "units": "MUSD/yr" }, "Average annual pumping costs": {}, - "Royalties": { + "Average Annual Royalty Cost": { "type": "number", - "description": "Average annual royalties paid (operating expense)", + "description": "The average annual cost paid to a royalty holder, calculated as a percentage of the project's gross annual revenue. This is modeled as a variable operating expense.", "units": "MUSD/yr" }, "Average annual auxiliary fuel cost": {}, diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index 7b81b9b89..6231f515c 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -1296,7 +1296,7 @@ def test_redrilling_costs(self): self.assertAlmostEqual(expected_annual_redrilling_cost, result_opex['Redrilling costs']['value'], places=2) def test_royalty_rate(self): - royalties_output_name = 'Royalties' + royalties_output_name = 'Average Annual Royalty Cost' for royalty_rate in [0, 0.1]: result = GeophiresXClient().get_geophires_result( From 44d87d089a10422448c5fe9572acb8b3568fa3c9 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:52:19 -0700 Subject: [PATCH 11/44] Fix incorrect method of setting royalties output param value --- src/geophires_x/Economics.py | 4 +++- src/geophires_x/EconomicsSam.py | 2 +- tests/geophires_x_tests/test_economics_sam.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index a5ebc3d6a..bd8176ebe 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -2507,7 +2507,9 @@ def Calculate(self, model: Model) -> None: self.CCap.value = (self.sam_economics_calculations.capex.quantity() .to(self.CCap.CurrentUnits.value).magnitude) - average_annual_royalties = np.average(self.sam_economics_calculations.royalties_opex[1:]) # ignore Year 0 + average_annual_royalties = np.average( + self.sam_economics_calculations.royalties_opex.value[1:] # ignore pre-revenue year(s) (Year 0) + ) if average_annual_royalties > 0: self.royalties_opex.value = average_annual_royalties self.Coam.value += self.royalties_opex.quantity().to(self.Coam.CurrentUnits.value).magnitude diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index b19311e5f..b4a59bef7 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -176,7 +176,7 @@ def sf(_v: float, num_sig_figs: int = 5) -> float: royalty_rate = model.economics.royalty_rate.quantity().to('dimensionless').magnitude ppa_revenue_row = _cash_flow_profile_row(cash_flow, 'PPA revenue ($)') royalties_unit = sam_economics.royalties_opex.CurrentUnits.value.replace('/yr', '') - sam_economics.royalties_opex = [ + sam_economics.royalties_opex.value = [ quantity(x * royalty_rate, 'USD').to(royalties_unit).magnitude for x in ppa_revenue_row ] diff --git a/tests/geophires_x_tests/test_economics_sam.py b/tests/geophires_x_tests/test_economics_sam.py index ee4037cd9..e7eb7f4cd 100644 --- a/tests/geophires_x_tests/test_economics_sam.py +++ b/tests/geophires_x_tests/test_economics_sam.py @@ -655,7 +655,7 @@ def get_row(name: str): expected_royalties_USD = [x * royalty_rate for x in ppa_revenue_row_USD] expected_royalties_MUSD = [x * 1e-6 for x in expected_royalties_USD] - self.assertListEqual(expected_royalties_MUSD, sam_econ.royalties_opex) + self.assertListEqual(expected_royalties_MUSD, sam_econ.royalties_opex.value) om_prod_based_expense_row = get_row('O&M production-based expense ($)') self.assertListAlmostEqual(expected_royalties_USD, om_prod_based_expense_row, places=0) From dab0994f288969ee59408ffc04d05fab0534e087 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:57:08 -0700 Subject: [PATCH 12/44] Internally distinguish between SAM econ royalty cost time series and main econ average annual value --- src/geophires_x/Economics.py | 16 ++++++++++++---- src/geophires_x/EconomicsSam.py | 4 ++-- src/geophires_x/EconomicsUtils.py | 6 +++--- src/geophires_x/Outputs.py | 4 ++-- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index bd8176ebe..11e935ed1 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -13,7 +13,7 @@ from geophires_x.EconomicsUtils import BuildPricingModel, wacc_output_parameter, nominal_discount_rate_parameter, \ real_discount_rate_parameter, after_tax_irr_parameter, moic_parameter, project_vir_parameter, \ project_payback_period_parameter, inflation_cost_during_construction_output_parameter, \ - total_capex_parameter_output_parameter, royalties_opex_parameter_output_parameter + total_capex_parameter_output_parameter from geophires_x.GeoPHIRESUtils import quantity from geophires_x.OptionList import Configuration, WellDrillingCostCorrelation, EconomicModel, EndUseOptions, PlantType, \ _WellDrillingCostCorrelationCitation @@ -1906,7 +1906,15 @@ def __init__(self, model: Model): PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR ) - self.royalties_opex = self.OutputParameterDict[self.royalties_opex.Name] = royalties_opex_parameter_output_parameter() + self.royalties_average_annual_cost = self.OutputParameterDict[self.royalties_average_annual_cost.Name] = OutputParameter( + Name='Average Annual Royalty Cost', + UnitType=Units.CURRENCYFREQUENCY, + PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, + CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, + ToolTipText='The average annual cost paid to a royalty holder, calculated as a percentage of the ' + 'project\'s gross annual revenue. This is modeled as a variable operating expense.' + ) + # district heating self.peakingboilercost = self.OutputParameterDict[self.peakingboilercost.Name] = OutputParameter( @@ -2511,8 +2519,8 @@ def Calculate(self, model: Model) -> None: self.sam_economics_calculations.royalties_opex.value[1:] # ignore pre-revenue year(s) (Year 0) ) if average_annual_royalties > 0: - self.royalties_opex.value = average_annual_royalties - self.Coam.value += self.royalties_opex.quantity().to(self.Coam.CurrentUnits.value).magnitude + self.royalties_average_annual_cost.value = average_annual_royalties + self.Coam.value += self.royalties_average_annual_cost.quantity().to(self.Coam.CurrentUnits.value).magnitude self.wacc.value = self.sam_economics_calculations.wacc.value self.nominal_discount_rate.value = self.sam_economics_calculations.nominal_discount_rate.value diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index b4a59bef7..22091103b 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -39,7 +39,7 @@ project_payback_period_parameter, inflation_cost_during_construction_output_parameter, total_capex_parameter_output_parameter, - royalties_opex_parameter_output_parameter, + royalty_cost_output_parameter, ) from geophires_x.GeoPHIRESUtils import is_float, is_int, sig_figs, quantity from geophires_x.OptionList import EconomicModel, EndUseOptions @@ -60,7 +60,7 @@ class SamEconomicsCalculations: capex: OutputParameter = field(default_factory=total_capex_parameter_output_parameter) - royalties_opex: OutputParameter = field(default_factory=royalties_opex_parameter_output_parameter) + royalties_opex: OutputParameter = field(default_factory=royalty_cost_output_parameter) project_npv: OutputParameter = field( default_factory=lambda: OutputParameter( diff --git a/src/geophires_x/EconomicsUtils.py b/src/geophires_x/EconomicsUtils.py index 3f7b271c3..782383ecd 100644 --- a/src/geophires_x/EconomicsUtils.py +++ b/src/geophires_x/EconomicsUtils.py @@ -146,12 +146,12 @@ def total_capex_parameter_output_parameter() -> OutputParameter: ) -def royalties_opex_parameter_output_parameter() -> OutputParameter: +def royalty_cost_output_parameter() -> OutputParameter: return OutputParameter( - Name='Average Annual Royalty Cost', + Name='Royalty Cost', UnitType=Units.CURRENCYFREQUENCY, PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, - ToolTipText='The average annual cost paid to a royalty holder, calculated as a percentage of the ' + ToolTipText='The annual costs paid to a royalty holder, calculated as a percentage of the ' 'project\'s gross annual revenue. This is modeled as a variable operating expense.' ) diff --git a/src/geophires_x/Outputs.py b/src/geophires_x/Outputs.py index b6a943b49..5fa1e2c3d 100644 --- a/src/geophires_x/Outputs.py +++ b/src/geophires_x/Outputs.py @@ -556,8 +556,8 @@ def PrintOutputs(self, model: Model): f.write(f' {aoc_label}{model.addeconomics.AddOnOPEXTotalPerYear.value:10.2f} {model.addeconomics.AddOnOPEXTotalPerYear.CurrentUnits.value}\n') if econ.royalty_rate.value > 0.0: - royalties_label = Outputs._field_label(econ.royalties_opex.display_name, 47) - f.write(f' {royalties_label}{econ.royalties_opex.value:10.2f} {econ.royalties_opex.CurrentUnits.value}\n') + royalties_label = Outputs._field_label(econ.royalties_average_annual_cost.display_name, 47) + f.write(f' {royalties_label}{econ.royalties_average_annual_cost.value:10.2f} {econ.royalties_average_annual_cost.CurrentUnits.value}\n') f.write(f' {econ.Coam.display_name}: {(econ.Coam.value + econ.averageannualpumpingcosts.value + econ.averageannualheatpumpelectricitycost.value):10.2f} {econ.Coam.CurrentUnits.value}\n') else: From e898ea405afec6703f67bf8e95f9973dca10d48f Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:26:01 -0700 Subject: [PATCH 13/44] WIP - calculate Royalty Holder NPV --- src/geophires_x/Economics.py | 118 ++++++++++++++----- src/geophires_x/EconomicsSam.py | 2 +- src/geophires_x/Outputs.py | 7 +- src/geophires_x_client/geophires_x_result.py | 1 + tests/test_geophires_x.py | 41 ++++--- 5 files changed, 119 insertions(+), 50 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index 11e935ed1..4f8e16ff2 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -968,7 +968,7 @@ def __init__(self, model: Model): ) self.royalty_rate = self.ParameterDict[self.royalty_rate.Name] = floatParameter( - "Royalty Rate", + 'Royalty Rate', DefaultValue=0., Min=0.0, Max=1.0, @@ -978,6 +978,18 @@ def __init__(self, model: Model): ToolTipText="Royalty rate used in SAM Economic Models." # FIXME WIP TODO documentation ) + self.royalty_holder_discount_rate = self.ParameterDict[self.royalty_holder_discount_rate.Name] = floatParameter( + 'Royalty Holder Discount Rate', + DefaultValue=0.05, + Min=0.0, + Max=1.0, + UnitType=Units.PERCENT, + PreferredUnits=PercentUnit.TENTH, + CurrentUnits=PercentUnit.TENTH, + ToolTipText="Royalty holder discount rate used in SAM Economic Models." # FIXME WIP TODO documentation + ) + + self.discount_initial_year_cashflow = self.ParameterDict[self.discount_initial_year_cashflow.Name] = boolParameter( 'Discount Initial Year Cashflow', DefaultValue=False, @@ -2134,6 +2146,33 @@ def __init__(self, model: Model): UnitType=Units.NONE, ) + # Results for the Royalty Holder + self.royalty_holder_npv = self.OutputParameterDict[self.royalty_holder_npv.Name] = OutputParameter( + 'Royalty Holder NPV', + UnitType=Units.CURRENCY, + PreferredUnits=CurrencyUnit.MDOLLARS, + CurrentUnits=CurrencyUnit.MDOLLARS, + ToolTipText="Net Present Value (NPV) of the royalty holder's cash flow stream." + ) + self.royalty_holder_annual_revenue = self.OutputParameterDict[ + self.royalty_holder_annual_revenue.Name + ] = OutputParameter( + 'Royalty Holder Annual Revenue', + UnitType=Units.CURRENCYFREQUENCY, + PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, + CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, + ToolTipText="The royalty holder's annual revenue stream from the royalty agreement." + ) + self.royalty_holder_total_revenue = self.OutputParameterDict[ + self.royalty_holder_total_revenue.Name + ] = OutputParameter( + 'Royalty Holder Total Revenue', + UnitType=Units.CURRENCY, + PreferredUnits=CurrencyUnit.MDOLLARS, + CurrentUnits=CurrencyUnit.MDOLLARS, + ToolTipText='The total (undiscounted) revenue received by the royalty holder over the project lifetime.' + ) + model.logger.info(f'Complete {__class__!s}: {sys._getframe().f_code.co_name}') def read_parameters(self, model: Model) -> None: @@ -2504,38 +2543,8 @@ def Calculate(self, model: Model) -> None: self.discount_initial_year_cashflow.value ) - non_calculated_output_placeholder_val = -1 if self.econmodel.value == EconomicModel.SAM_SINGLE_OWNER_PPA: - self.sam_economics_calculations = calculate_sam_economics(model) - - # Setting capex_total distinguishes capex from CCap's display name of 'Total capital costs', - # since SAM Economic Model doesn't subtract ITC from this value. - self.capex_total.value = (self.sam_economics_calculations.capex.quantity() - .to(self.capex_total.CurrentUnits.value).magnitude) - self.CCap.value = (self.sam_economics_calculations.capex.quantity() - .to(self.CCap.CurrentUnits.value).magnitude) - - average_annual_royalties = np.average( - self.sam_economics_calculations.royalties_opex.value[1:] # ignore pre-revenue year(s) (Year 0) - ) - if average_annual_royalties > 0: - self.royalties_average_annual_cost.value = average_annual_royalties - self.Coam.value += self.royalties_average_annual_cost.quantity().to(self.Coam.CurrentUnits.value).magnitude - - self.wacc.value = self.sam_economics_calculations.wacc.value - self.nominal_discount_rate.value = self.sam_economics_calculations.nominal_discount_rate.value - self.ProjectNPV.value = self.sam_economics_calculations.project_npv.quantity().to( - convertible_unit(self.ProjectNPV.CurrentUnits)).magnitude - - self.ProjectIRR.value = non_calculated_output_placeholder_val # SAM calculates After-Tax IRR instead - self.after_tax_irr.value = self.sam_economics_calculations.after_tax_irr.quantity().to( - convertible_unit(self.ProjectIRR.CurrentUnits)).magnitude - - self.ProjectMOIC.value = self.sam_economics_calculations.moic.value - self.ProjectVIR.value = self.sam_economics_calculations.project_vir.value - - # TODO remove or clarify project payback period: https://github.com/NREL/GEOPHIRES-X/issues/413 - self.ProjectPaybackPeriod.value = self.sam_economics_calculations.project_payback_period.value + self._calculate_sam_economics(model) # Calculate the project payback period if self.econmodel.value != EconomicModel.SAM_SINGLE_OWNER_PPA: @@ -3267,6 +3276,51 @@ def calculate_cashflow(self, model: Model) -> None: for i in range(1, model.surfaceplant.plant_lifetime.value + model.surfaceplant.construction_years.value, 1): self.TotalCummRevenue.value[i] = self.TotalCummRevenue.value[i-1] + self.TotalRevenue.value[i] + def _calculate_sam_economics(self, model: Model) -> None: + non_calculated_output_placeholder_val = -1 + self.sam_economics_calculations = calculate_sam_economics(model) + + # Setting capex_total distinguishes capex from CCap's display name of 'Total capital costs', + # since SAM Economic Model doesn't subtract ITC from this value. + self.capex_total.value = (self.sam_economics_calculations.capex.quantity() + .to(self.capex_total.CurrentUnits.value).magnitude) + self.CCap.value = (self.sam_economics_calculations.capex.quantity() + .to(self.CCap.CurrentUnits.value).magnitude) + + + if self.royalty_rate.Provided: + average_annual_royalties = np.average( + self.sam_economics_calculations.royalties_opex.value[1:] # ignore pre-revenue year(s) (Year 0) + ) + + self.royalties_average_annual_cost.value = average_annual_royalties + self.Coam.value += self.royalties_average_annual_cost.quantity().to(self.Coam.CurrentUnits.value).magnitude + + self.royalty_holder_npv.value = calculate_npv( + self.royalty_holder_discount_rate.value, + self.sam_economics_calculations.royalties_opex.value, + self.discount_initial_year_cashflow.value + ) + # FIXME WIP + # self.royalty_holder_annual_revenue + # self.royalty_holder_total_revenue + + + self.wacc.value = self.sam_economics_calculations.wacc.value + self.nominal_discount_rate.value = self.sam_economics_calculations.nominal_discount_rate.value + self.ProjectNPV.value = self.sam_economics_calculations.project_npv.quantity().to( + convertible_unit(self.ProjectNPV.CurrentUnits)).magnitude + + self.ProjectIRR.value = non_calculated_output_placeholder_val # SAM calculates After-Tax IRR instead + self.after_tax_irr.value = self.sam_economics_calculations.after_tax_irr.quantity().to( + convertible_unit(self.ProjectIRR.CurrentUnits)).magnitude + + self.ProjectMOIC.value = self.sam_economics_calculations.moic.value + self.ProjectVIR.value = self.sam_economics_calculations.project_vir.value + + # TODO remove or clarify project payback period: https://github.com/NREL/GEOPHIRES-X/issues/413 + self.ProjectPaybackPeriod.value = self.sam_economics_calculations.project_payback_period.value + # noinspection SpellCheckingInspection def _calculate_derived_outputs(self, model: Model) -> None: """ diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index 22091103b..3ff97dabe 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -417,7 +417,7 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]: ) ret['ppa_price_input'] = ppa_price_schedule_per_kWh - if hasattr(econ, 'royalty_rate') and econ.royalty_rate.value > 0.0: + if hasattr(econ, 'royalty_rate') and econ.royalty_rate.Provided: royalty_rate_fraction = econ.royalty_rate.quantity().to(convertible_unit('dimensionless')).magnitude # For each year, calculate the royalty as a $/MWh variable cost. diff --git a/src/geophires_x/Outputs.py b/src/geophires_x/Outputs.py index 5fa1e2c3d..a0c651961 100644 --- a/src/geophires_x/Outputs.py +++ b/src/geophires_x/Outputs.py @@ -324,6 +324,11 @@ def PrintOutputs(self, model: Model): if model.surfaceplant.enduse_option.value in [EndUseOptions.ELECTRICITY]: f.write(f' Estimated Jobs Created: {model.economics.jobs_created.value}\n') + if econ.royalty_rate.Provided: + royalty_holder_npv_label = Outputs._field_label(econ.royalty_holder_npv.display_name, 49) + f.write( + f' {royalty_holder_npv_label}{econ.royalty_holder_npv.value:10.2f} {econ.royalty_holder_npv.CurrentUnits.value}\n') + f.write(NL) f.write(' ***ENGINEERING PARAMETERS***\n') @@ -555,7 +560,7 @@ def PrintOutputs(self, model: Model): aoc_label = Outputs._field_label(model.addeconomics.AddOnOPEXTotalPerYear.display_name, 47) f.write(f' {aoc_label}{model.addeconomics.AddOnOPEXTotalPerYear.value:10.2f} {model.addeconomics.AddOnOPEXTotalPerYear.CurrentUnits.value}\n') - if econ.royalty_rate.value > 0.0: + if econ.royalty_rate.Provided: royalties_label = Outputs._field_label(econ.royalties_average_annual_cost.display_name, 47) f.write(f' {royalties_label}{econ.royalties_average_annual_cost.value:10.2f} {econ.royalties_average_annual_cost.CurrentUnits.value}\n') diff --git a/src/geophires_x_client/geophires_x_result.py b/src/geophires_x_client/geophires_x_result.py index 04a0b7325..55aa0540e 100644 --- a/src/geophires_x_client/geophires_x_result.py +++ b/src/geophires_x_client/geophires_x_result.py @@ -93,6 +93,7 @@ class GeophiresXResult: 'Project Payback Period', 'CHP: Percent cost allocation for electrical plant', 'Estimated Jobs Created', + 'Royalty Holder NPV', ], 'EXTENDED ECONOMICS': [ 'Adjusted Project LCOE (after incentives, grants, AddOns,etc)', diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index 6231f515c..ec6b6b6b1 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -1025,7 +1025,7 @@ def _get_result( 'Number of Injection Wells': doublets, # offset contingency - 'Reservoir Stimulation Capital Cost Adjustment Factor': 1/default_contingency_factor, + 'Reservoir Stimulation Capital Cost Adjustment Factor': 1 / default_contingency_factor, } ) # fmt:on @@ -1285,6 +1285,7 @@ def test_redrilling_costs(self): capex_field_suffix = ( '' if result_capex.get('Drilling and completion costs') is not None else ' (for redrilling)' ) + # @formatter:off expected_annual_redrilling_cost = ( ( result_capex[f'Drilling and completion costs{capex_field_suffix}']['value'] @@ -1292,6 +1293,7 @@ def test_redrilling_costs(self): ) * result_redrills ) / result.result['ECONOMIC PARAMETERS']['Project lifetime']['value'] + # @formatter:on self.assertAlmostEqual(expected_annual_redrilling_cost, result_opex['Redrilling costs']['value'], places=2) @@ -1308,22 +1310,29 @@ def test_royalty_rate(self): ) ) opex_result = result.result['OPERATING AND MAINTENANCE COSTS (M$/yr)'] - if royalty_rate > 0.0: - self.assertIsNotNone(opex_result[royalties_output_name]) - self.assertEqual(58.88, opex_result[royalties_output_name]['value']) - self.assertEqual('MUSD/yr', opex_result[royalties_output_name]['unit']) - total_opex_MUSD = opex_result['Total operating and maintenance costs']['value'] + self.assertIsNotNone(opex_result[royalties_output_name]) + self.assertEqual('MUSD/yr', opex_result[royalties_output_name]['unit']) - opex_line_item_sum = 0 - for line_item_names in [ - 'Wellfield maintenance costs', - 'Power plant maintenance costs', - 'Water costs', - royalties_output_name, - ]: - opex_line_item_sum += opex_result[line_item_names]['value'] + total_opex_MUSD = opex_result['Total operating and maintenance costs']['value'] - self.assertEqual(opex_line_item_sum, total_opex_MUSD) + opex_line_item_sum = 0 + for line_item_names in [ + 'Wellfield maintenance costs', + 'Power plant maintenance costs', + 'Water costs', + royalties_output_name, + ]: + opex_line_item_sum += opex_result[line_item_names]['value'] + + self.assertEqual(opex_line_item_sum, total_opex_MUSD) + + econ_result = result.result['ECONOMIC PARAMETERS'] + royalty_holder_npv_MUSD = econ_result['Royalty Holder NPV']['value'] + + if royalty_rate > 0.0: + self.assertEqual(58.88, opex_result[royalties_output_name]['value']) + self.assertGreater(royalty_holder_npv_MUSD, 0) # FIXME WIP else: - self.assertIsNone(opex_result[royalties_output_name]) + self.assertEqual(0, opex_result[royalties_output_name]['value']) + self.assertEqual(0, royalty_holder_npv_MUSD) From 1ba8df4fe3b8f594813e4dd998b824a3fb04b461 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:53:07 -0700 Subject: [PATCH 14/44] assert royalty holder NPV --- tests/test_geophires_x.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index ec6b6b6b1..3de33c604 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -7,10 +7,16 @@ from pathlib import Path from typing import Any +import numpy as np + +# noinspection PyProtectedMember +from geophires_x.EconomicsSam import _cash_flow_profile_row from geophires_x.OptionList import PlantType from geophires_x.OptionList import WellDrillingCostCorrelation from geophires_x_client import GeophiresXClient from geophires_x_client import GeophiresXResult + +# noinspection PyProtectedMember from geophires_x_client import _get_logger from geophires_x_client.geophires_input_parameters import EndUseOption from geophires_x_client.geophires_input_parameters import GeophiresInputParameters @@ -1306,7 +1312,9 @@ def test_royalty_rate(self): from_file_path=self._get_test_file_path( 'geophires_x_tests/generic-egs-case-2_sam-single-owner-ppa.txt' ), - params={'Royalty Rate': royalty_rate}, + params={ + 'Royalty Rate': royalty_rate, + }, ) ) opex_result = result.result['OPERATING AND MAINTENANCE COSTS (M$/yr)'] @@ -1332,7 +1340,22 @@ def test_royalty_rate(self): if royalty_rate > 0.0: self.assertEqual(58.88, opex_result[royalties_output_name]['value']) - self.assertGreater(royalty_holder_npv_MUSD, 0) # FIXME WIP - else: + self.assertGreater(royalty_holder_npv_MUSD, 0) + + royalties_cash_flow_MUSD = [ + it * 1e-6 + for it in _cash_flow_profile_row( + result.result['SAM CASH FLOW PROFILE'], 'O&M production-based expense ($)' + ) + ] + + self.assertAlmostEqual( + np.average(royalties_cash_flow_MUSD[1:]), opex_result[royalties_output_name]['value'], places=1 + ) + + if royalty_rate == 0.1: + self.assertAlmostEqual(708.07, royalty_holder_npv_MUSD, places=2) + + if royalty_rate == 0.0: self.assertEqual(0, opex_result[royalties_output_name]['value']) self.assertEqual(0, royalty_holder_npv_MUSD) From 4f53b33eaa465942998d8331a76b5f465d4f9b44 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:05:04 -0700 Subject: [PATCH 15/44] finish impl of royalty holder outputs --- src/geophires_x/Economics.py | 11 ++++++----- src/geophires_x/Outputs.py | 13 ++++++++++--- src/geophires_x_client/geophires_x_result.py | 2 ++ .../geophires-request.json | 9 +++++++++ .../geophires-result.json | 15 +++++++++++++++ tests/test_geophires_x.py | 3 +++ 6 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index 4f8e16ff2..ac5e13ef2 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -2157,7 +2157,7 @@ def __init__(self, model: Model): self.royalty_holder_annual_revenue = self.OutputParameterDict[ self.royalty_holder_annual_revenue.Name ] = OutputParameter( - 'Royalty Holder Annual Revenue', + 'Royalty Holder Average Annual Revenue', UnitType=Units.CURRENCYFREQUENCY, PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, @@ -3289,7 +3289,7 @@ def _calculate_sam_economics(self, model: Model) -> None: if self.royalty_rate.Provided: - average_annual_royalties = np.average( + average_annual_royalties = np.average( # TODO unit conversion self.sam_economics_calculations.royalties_opex.value[1:] # ignore pre-revenue year(s) (Year 0) ) @@ -3301,9 +3301,10 @@ def _calculate_sam_economics(self, model: Model) -> None: self.sam_economics_calculations.royalties_opex.value, self.discount_initial_year_cashflow.value ) - # FIXME WIP - # self.royalty_holder_annual_revenue - # self.royalty_holder_total_revenue + self.royalty_holder_annual_revenue.value = self.royalties_average_annual_cost.value + self.royalty_holder_total_revenue.value = np.sum( # TODO unit conversion + self.sam_economics_calculations.royalties_opex.value[1:] # ignore pre-revenue year(s) (Year 0) + ) self.wacc.value = self.sam_economics_calculations.wacc.value diff --git a/src/geophires_x/Outputs.py b/src/geophires_x/Outputs.py index a0c651961..b62d62d4a 100644 --- a/src/geophires_x/Outputs.py +++ b/src/geophires_x/Outputs.py @@ -325,9 +325,16 @@ def PrintOutputs(self, model: Model): f.write(f' Estimated Jobs Created: {model.economics.jobs_created.value}\n') if econ.royalty_rate.Provided: - royalty_holder_npv_label = Outputs._field_label(econ.royalty_holder_npv.display_name, 49) - f.write( - f' {royalty_holder_npv_label}{econ.royalty_holder_npv.value:10.2f} {econ.royalty_holder_npv.CurrentUnits.value}\n') + for royalty_output in [ + econ.royalty_holder_npv, + econ.royalty_holder_annual_revenue, + econ.royalty_holder_total_revenue + ]: + label = Outputs._field_label(royalty_output.display_name, 49) + f.write( + f' {label}{royalty_output.value:10.2f} {royalty_output.CurrentUnits.value}\n') + + f.write(NL) diff --git a/src/geophires_x_client/geophires_x_result.py b/src/geophires_x_client/geophires_x_result.py index 55aa0540e..cd90b777d 100644 --- a/src/geophires_x_client/geophires_x_result.py +++ b/src/geophires_x_client/geophires_x_result.py @@ -94,6 +94,8 @@ class GeophiresXResult: 'CHP: Percent cost allocation for electrical plant', 'Estimated Jobs Created', 'Royalty Holder NPV', + 'Royalty Holder Average Annual Revenue', + 'Royalty Holder Total Revenue', ], 'EXTENDED ECONOMICS': [ 'Adjusted Project LCOE (after incentives, grants, AddOns,etc)', diff --git a/src/geophires_x_schema_generator/geophires-request.json b/src/geophires_x_schema_generator/geophires-request.json index bfdd138dc..46ce9c37f 100644 --- a/src/geophires_x_schema_generator/geophires-request.json +++ b/src/geophires_x_schema_generator/geophires-request.json @@ -1647,6 +1647,15 @@ "minimum": 0.0, "maximum": 1.0 }, + "Royalty Holder Discount Rate": { + "description": "Royalty holder discount rate used in SAM Economic Models.", + "type": "number", + "units": "", + "category": "Economics", + "default": 0.05, + "minimum": 0.0, + "maximum": 1.0 + }, "Discount Initial Year Cashflow": { "description": "Whether to discount cashflow in the initial project year when calculating NPV (Net Present Value). The default value of False conforms to NREL's standard convention for NPV calculation (Short W et al, 1995. https://www.nrel.gov/docs/legosti/old/5173.pdf). A value of True will, by contrast, cause NPV calculation to follow the convention used by Excel, Google Sheets, and other common spreadsheet software. Although NREL's NPV convention may typically be considered more technically correct, Excel-style NPV calculation might be preferred for familiarity or compatibility with existing business processes. See https://github.com/NREL/GEOPHIRES-X/discussions/344 for further details.", "type": "boolean", diff --git a/src/geophires_x_schema_generator/geophires-result.json b/src/geophires_x_schema_generator/geophires-result.json index c87095087..73a906548 100644 --- a/src/geophires_x_schema_generator/geophires-result.json +++ b/src/geophires_x_schema_generator/geophires-result.json @@ -143,6 +143,21 @@ "type": "number", "description": "", "units": null + }, + "Royalty Holder NPV": { + "type": "number", + "description": "Net Present Value (NPV) of the royalty holder's cash flow stream.", + "units": "MUSD" + }, + "Royalty Holder Average Annual Revenue": { + "type": "number", + "description": "The royalty holder's annual revenue stream from the royalty agreement.", + "units": "MUSD/yr" + }, + "Royalty Holder Total Revenue": { + "type": "number", + "description": "The total (undiscounted) revenue received by the royalty holder over the project lifetime.", + "units": "MUSD" } } }, diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index 3de33c604..77c2a55b0 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -9,6 +9,7 @@ import numpy as np +# @formatter:off # noinspection PyProtectedMember from geophires_x.EconomicsSam import _cash_flow_profile_row from geophires_x.OptionList import PlantType @@ -22,6 +23,8 @@ from geophires_x_client.geophires_input_parameters import GeophiresInputParameters from geophires_x_client.geophires_input_parameters import ImmutableGeophiresInputParameters from geophires_x_tests.test_options_list import WellDrillingCostCorrelationTestCase + +# @formatter:on from tests.base_test_case import BaseTestCase From 3ded083943b15387bb5c9cb730f0456696bf6658 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:35:08 -0700 Subject: [PATCH 16/44] Update documentation. Throw exception if royalty rate provided for non-SAM-EM inputs --- src/geophires_x/Economics.py | 13 +++++++++++-- .../geophires-request.json | 4 ++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index ac5e13ef2..ba0da28d9 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -975,7 +975,9 @@ def __init__(self, model: Model): UnitType=Units.PERCENT, PreferredUnits=PercentUnit.TENTH, CurrentUnits=PercentUnit.TENTH, - ToolTipText="Royalty rate used in SAM Economic Models." # FIXME WIP TODO documentation + ToolTipText="The percentage of the project's gross annual revenue paid to the royalty holder. " + "This is modeled as a variable production-based operating expense, reducing the developer's " + "taxable income." ) self.royalty_holder_discount_rate = self.ParameterDict[self.royalty_holder_discount_rate.Name] = floatParameter( @@ -986,7 +988,9 @@ def __init__(self, model: Model): UnitType=Units.PERCENT, PreferredUnits=PercentUnit.TENTH, CurrentUnits=PercentUnit.TENTH, - ToolTipText="Royalty holder discount rate used in SAM Economic Models." # FIXME WIP TODO documentation + ToolTipText="The discount rate used to calculate the Net Present Value (NPV) of the royalty holder's " + "income stream. This rate should reflect the royalty holder's specific risk profile and is " + "separate from the main project discount rate." ) @@ -2425,6 +2429,11 @@ def _warn(_msg: str) -> None: if self.econmodel.value == EconomicModel.SAM_SINGLE_OWNER_PPA: EconomicsSam.validate_read_parameters(model) + else: + if self.royalty_rate.Provided: + raise NotImplementedError('Royalties are only supported for SAM Economic Models') + + # TODO validate that other SAM-EM-only parameters have not been provided else: model.logger.info("No parameters read because no content provided") diff --git a/src/geophires_x_schema_generator/geophires-request.json b/src/geophires_x_schema_generator/geophires-request.json index 46ce9c37f..580420e28 100644 --- a/src/geophires_x_schema_generator/geophires-request.json +++ b/src/geophires_x_schema_generator/geophires-request.json @@ -1639,7 +1639,7 @@ "maximum": 1.0 }, "Royalty Rate": { - "description": "Royalty rate used in SAM Economic Models.", + "description": "The percentage of the project's gross annual revenue paid to the royalty holder. This is modeled as a variable production-based operating expense, reducing the developer's taxable income.", "type": "number", "units": "", "category": "Economics", @@ -1648,7 +1648,7 @@ "maximum": 1.0 }, "Royalty Holder Discount Rate": { - "description": "Royalty holder discount rate used in SAM Economic Models.", + "description": "The discount rate used to calculate the Net Present Value (NPV) of the royalty holder's income stream. This rate should reflect the royalty holder's specific risk profile and is separate from the main project discount rate.", "type": "number", "units": "", "category": "Economics", From 281a74b6d1bb1931f24813728291fedf614f8497 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:53:37 -0700 Subject: [PATCH 17/44] SAM-EM royalties documentation (parameter mapping + dedicated section) --- docs/SAM-Economic-Models.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/SAM-Economic-Models.md b/docs/SAM-Economic-Models.md index 0e79a3a28..4629a7bf7 100644 --- a/docs/SAM-Economic-Models.md +++ b/docs/SAM-Economic-Models.md @@ -22,7 +22,7 @@ The following table describes how GEOPHIRES parameters are transformed into SAM | `Net Electricity Generation` | AC Degradation | `Annual AC degradation rate` schedule | `Utilityrate5` | `degradation` | Percentage difference of each year's `Net Electricity Generation` from `Maximum Total Electricity Generation` is input as SAM as the degradation rate schedule in order to match SAM's generation profile to GEOPHIRES | | {`Total CAPEX` before inflation} × (1 + `Accrued financing during construction (%)`/100); | Installation Costs | `Total Installed Cost` | `Singleowner` | `total_installed_cost` | `Accrued financing during construction (%)` = (1+`Inflation Rate During Construction`) × 100 if `Inflation Rate During Construction` is provided or ((1+`Inflation Rate`) ^ `Construction Years`) × 100 if not. | | `Total O&M Cost`, `Inflation Rate` | Operating Costs | `Fixed operating cost`, `Escalation rate` set to `Inflation Rate` × -1 | `Singleowner` | `om_fixed`, `om_fixed_escal` | .. N/A | -| `Royalty Rate` | Operating Costs | `Variable operating cost` | `Singleowner` | `om_production` | Royalties are treated as a variable operating cost to the owner | +| `Royalty Rate` | Operating Costs | `Variable operating cost` | `Singleowner` | `om_production` | The royalty is modeled as a tax-deductible variable operating expense. GEOPHIRES calculates a schedule of $/MWh values based on the PPA price and Royalty Rate for each year. This ensures the total annual expense in SAM accurately matches the royalty payment due on gross revenue. | | `Plant Lifetime` | Financial Parameters → Analysis Parameters | `Analysis period` | `CustomGeneration`, `Singleowner` | `CustomGeneration.analysis_period`, `Singleowner.term_tenor` | .. N/A | | `Inflation Rate` | Financial Parameters → Analysis Parameters | `Inflation rate` | `Utilityrate5` | `inflation_rate` | .. N/A | | `Discount Rate` | Financial Parameters → Analysis Parameters | `Real discount rate` | `Singleowner` | `real_discount_rate` | .. N/A | @@ -150,6 +150,27 @@ Total AddOn Profit Gained per year is treated as fixed amount Capacity payment r Add-ons CAPEX, OPEX, and profit are supported. Add-ons with electricity and heat are not currently supported, but may be supported in the future. +## Royalties + +SAM Economic Models can model a royalty agreement where a percentage of the project's gross revenue is paid to a third party (the "royalty holder"). This feature is enabled by providing the `Royalty Rate` parameter. + +The royalty payment is modeled as a tax-deductible variable operating expense from the perspective of the project developer (Single Owner). +This reduces the developer's taxable income and ensures their final after-tax metrics (NPV, IRR, etc.) are calculated accurately. + +This is implemented by having GEOPHIRES create a year-by-year schedule for SAM's Variable operating cost (`om_production`) input. +The value for each year is calculated based on that year's PPA price and the user-provided `Royalty Rate`, ensuring the expense in SAM matches the royalty due on gross revenue. + +Input Parameters: + +1. `Royalty Rate`: The percentage of the project's gross annual revenue paid to the royalty holder. +1. `Royalty Holder Discount Rate` (optional): The discount rate used to calculate the Net Present Value (NPV) of the royalty holder's income stream. This is separate from the project's main discount rate to reflect the different risk profiles of the two parties. + +Output Parameters: + +1. `Average Annual Royalty Cost`: The developer's average annual royalty expense over the project's lifetime after construction is complete (Year 1). The same value is also output as `Royalty Holder Average Annual Revenue`. The individual royalties for each year are included in the cash flow line item `O&M production-based expense ($)`. +1. `Royalty Holder Total Revenue`: The total undiscounted royalty income over the project's lifetime. +1. `Royalty Holder NPV`: The Net Present Value of the royalty holder's income stream, calculated using the `Royalty Holder Discount Rate`. + ## Examples ### SAM Single Owner PPA: 50 MWe From cfbf614932149d8acf9ae5e428ccabcc8bdfd7c9 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 15 Sep 2025 14:10:32 -0700 Subject: [PATCH 18/44] =?UTF-8?q?Bump=20version:=203.9.55=20=E2=86=92=203.?= =?UTF-8?q?9.56?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- .cookiecutterrc | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- src/geophires_x/__init__.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 61879902c..2517cc418 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.55 +current_version = 3.9.56 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 77d07fef2..73ce91080 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.9.55 + version: 3.9.56 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index 2f51b5ff5..e307452da 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.9.55.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.56.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.55...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.56...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 149dfcdf1..344ab81ec 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ year = '2025' author = 'NREL' copyright = f'{year}, {author}' -version = release = '3.9.55' +version = release = '3.9.56' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index 1d45721df..a612e457c 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.55', + version='3.9.56', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index c2fa4fff8..807095dfc 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.55' +__version__ = '3.9.56' From 7ced29b6db362eddb4e2fbd0839b9f4d5d378c66 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Tue, 16 Sep 2025 07:18:23 -0700 Subject: [PATCH 19/44] Move royalty outputs to EXTENDED ECONOMICS --- src/geophires_x/Outputs.py | 49 ++++++++++++++----- src/geophires_x/OutputsAddOns.py | 6 +-- src/geophires_x_client/geophires_x_result.py | 6 +-- .../geophires-result.json | 32 ++++++------ tests/test_geophires_x.py | 10 ++-- 5 files changed, 62 insertions(+), 41 deletions(-) diff --git a/src/geophires_x/Outputs.py b/src/geophires_x/Outputs.py index b62d62d4a..aaaa8c836 100644 --- a/src/geophires_x/Outputs.py +++ b/src/geophires_x/Outputs.py @@ -2,7 +2,9 @@ import math import time import sys +from io import TextIOWrapper from pathlib import Path +from typing import Any # noinspection PyPackageRequirements import numpy as np @@ -324,19 +326,6 @@ def PrintOutputs(self, model: Model): if model.surfaceplant.enduse_option.value in [EndUseOptions.ELECTRICITY]: f.write(f' Estimated Jobs Created: {model.economics.jobs_created.value}\n') - if econ.royalty_rate.Provided: - for royalty_output in [ - econ.royalty_holder_npv, - econ.royalty_holder_annual_revenue, - econ.royalty_holder_total_revenue - ]: - label = Outputs._field_label(royalty_output.display_name, 49) - f.write( - f' {label}{royalty_output.value:10.2f} {royalty_output.CurrentUnits.value}\n') - - - - f.write(NL) f.write(' ***ENGINEERING PARAMETERS***\n') f.write(NL) @@ -803,9 +792,25 @@ def PrintOutputs(self, model: Model): addon_df = pd.DataFrame() addon_results = [] + extended_economics_header_printed = False if model.economics.DoAddOnCalculations.value and not is_sam_econ_model: # SAM econ models incorporate add-on economics into main economics, not as separate extended economics. addon_df, addon_results = model.addoutputs.PrintOutputs(model) + extended_economics_header_printed = True + + if econ.royalty_rate.Provided: + with open(self.output_file, 'a', encoding='UTF-8') as f_: + if not extended_economics_header_printed: + self._print_extended_economics_header(f_) + + for royalty_output in [ + econ.royalty_holder_npv, + econ.royalty_holder_annual_revenue, + econ.royalty_holder_total_revenue + ]: + label = Outputs._field_label(royalty_output.display_name, 49) + f_.write( + f' {label}{royalty_output.value:10.2f} {royalty_output.CurrentUnits.value}\n') sdac_df = pd.DataFrame() sdac_results = [] @@ -909,6 +914,24 @@ def get_sam_cash_flow_profile_output(self, model): return ret + def _print_extended_economics_header(self, f_output_file: TextIOWrapper | None = None) -> None: + """ + Header may be printed by either OutputsAddOns, or parent class if royalties are calculated and add-ons are not. + """ + + close_f = False + if f_output_file is None: + f_output_file = open(self.output_file, 'a', encoding='UTF-8') + close_f = True + + f_output_file.write(NL) + f_output_file.write(NL) + f_output_file.write(" ***EXTENDED ECONOMICS***\n") + f_output_file.write(NL) + + if close_f: + f_output_file.close() + @staticmethod def _field_label(field_name: str, print_width_before_value: int) -> str: return f'{field_name}:{" " * (print_width_before_value - len(field_name) - 1)}' diff --git a/src/geophires_x/OutputsAddOns.py b/src/geophires_x/OutputsAddOns.py index d9b66ab9d..77055e701 100644 --- a/src/geophires_x/OutputsAddOns.py +++ b/src/geophires_x/OutputsAddOns.py @@ -29,10 +29,8 @@ def PrintOutputs(self, model) -> tuple[pd.DataFrame, list]: try: with open(self.output_file, 'a', encoding='UTF-8') as f: addon_results: list[OutputTableItem] = [] - f.write(NL) - f.write(NL) - f.write(" ***EXTENDED ECONOMICS***\n") - f.write(NL) + self._print_extended_economics_header(f) + if model.economics.LCOE.value > -999.0: f.write(f" Adjusted Project LCOE (after incentives, grants, AddOns,etc): {model.economics.LCOE.value:10.2f} " + model.economics.LCOE.PreferredUnits.value + NL) addon_results.append(OutputTableItem('Adjusted Project LCOE (after incentives, grants, AddOns,etc)', '{0:10.2f}'.format(model.economics.LCOE.value), model.economics.LCOE.PreferredUnits.value)) diff --git a/src/geophires_x_client/geophires_x_result.py b/src/geophires_x_client/geophires_x_result.py index cd90b777d..e4d143fa3 100644 --- a/src/geophires_x_client/geophires_x_result.py +++ b/src/geophires_x_client/geophires_x_result.py @@ -93,9 +93,6 @@ class GeophiresXResult: 'Project Payback Period', 'CHP: Percent cost allocation for electrical plant', 'Estimated Jobs Created', - 'Royalty Holder NPV', - 'Royalty Holder Average Annual Revenue', - 'Royalty Holder Total Revenue', ], 'EXTENDED ECONOMICS': [ 'Adjusted Project LCOE (after incentives, grants, AddOns,etc)', @@ -113,6 +110,9 @@ class GeophiresXResult: 'Total Add-on Net Heat', 'Total Add-on Profit', 'AddOns Payback Period', + 'Royalty Holder NPV', + 'Royalty Holder Average Annual Revenue', + 'Royalty Holder Total Revenue', ], 'CCUS ECONOMICS': [ 'Total Avoided Carbon Production', diff --git a/src/geophires_x_schema_generator/geophires-result.json b/src/geophires_x_schema_generator/geophires-result.json index 73a906548..321b08e17 100644 --- a/src/geophires_x_schema_generator/geophires-result.json +++ b/src/geophires_x_schema_generator/geophires-result.json @@ -143,21 +143,6 @@ "type": "number", "description": "", "units": null - }, - "Royalty Holder NPV": { - "type": "number", - "description": "Net Present Value (NPV) of the royalty holder's cash flow stream.", - "units": "MUSD" - }, - "Royalty Holder Average Annual Revenue": { - "type": "number", - "description": "The royalty holder's annual revenue stream from the royalty agreement.", - "units": "MUSD/yr" - }, - "Royalty Holder Total Revenue": { - "type": "number", - "description": "The total (undiscounted) revenue received by the royalty holder over the project lifetime.", - "units": "MUSD" } } }, @@ -186,7 +171,22 @@ "Total Add-on Net Elec": {}, "Total Add-on Net Heat": {}, "Total Add-on Profit": {}, - "AddOns Payback Period": {} + "AddOns Payback Period": {}, + "Royalty Holder NPV": { + "type": "number", + "description": "Net Present Value (NPV) of the royalty holder's cash flow stream.", + "units": "MUSD" + }, + "Royalty Holder Average Annual Revenue": { + "type": "number", + "description": "The royalty holder's annual revenue stream from the royalty agreement.", + "units": "MUSD/yr" + }, + "Royalty Holder Total Revenue": { + "type": "number", + "description": "The total (undiscounted) revenue received by the royalty holder over the project lifetime.", + "units": "MUSD" + } } }, "CCUS ECONOMICS": { diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index 77c2a55b0..79c10fb4e 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -9,9 +9,6 @@ import numpy as np -# @formatter:off -# noinspection PyProtectedMember -from geophires_x.EconomicsSam import _cash_flow_profile_row from geophires_x.OptionList import PlantType from geophires_x.OptionList import WellDrillingCostCorrelation from geophires_x_client import GeophiresXClient @@ -24,7 +21,10 @@ from geophires_x_client.geophires_input_parameters import ImmutableGeophiresInputParameters from geophires_x_tests.test_options_list import WellDrillingCostCorrelationTestCase -# @formatter:on +# noinspection PyProtectedMember +# ruff: noqa: I001 # Successful module initialization is dependent on this specific import order. +from geophires_x.EconomicsSam import _cash_flow_profile_row + from tests.base_test_case import BaseTestCase @@ -1338,7 +1338,7 @@ def test_royalty_rate(self): self.assertEqual(opex_line_item_sum, total_opex_MUSD) - econ_result = result.result['ECONOMIC PARAMETERS'] + econ_result = result.result['EXTENDED ECONOMICS'] royalty_holder_npv_MUSD = econ_result['Royalty Holder NPV']['value'] if royalty_rate > 0.0: From 890748b5bcf66853582ae8264cf4ec15af7799f7 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Tue, 16 Sep 2025 07:21:45 -0700 Subject: [PATCH 20/44] Outputs py38 future annotations --- src/geophires_x/Outputs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/geophires_x/Outputs.py b/src/geophires_x/Outputs.py index aaaa8c836..6f8546cde 100644 --- a/src/geophires_x/Outputs.py +++ b/src/geophires_x/Outputs.py @@ -1,10 +1,11 @@ +from __future__ import annotations + import datetime import math import time import sys from io import TextIOWrapper from pathlib import Path -from typing import Any # noinspection PyPackageRequirements import numpy as np From 580e384c20650965002480a2c64437a91e2aa7cb Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Tue, 16 Sep 2025 07:37:29 -0700 Subject: [PATCH 21/44] Royalty escalation rate + max (WIP to add unit tests, implement full schedule support) --- src/geophires_x/Economics.py | 25 +++++++++++++- src/geophires_x/EconomicsSam.py | 34 +++++++++++++++---- .../geophires-request.json | 20 ++++++++++- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index ba0da28d9..ad26ead59 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -975,11 +975,34 @@ def __init__(self, model: Model): UnitType=Units.PERCENT, PreferredUnits=PercentUnit.TENTH, CurrentUnits=PercentUnit.TENTH, - ToolTipText="The percentage of the project's gross annual revenue paid to the royalty holder. " + ToolTipText="The fraction of the project's gross annual revenue paid to the royalty holder. " "This is modeled as a variable production-based operating expense, reducing the developer's " "taxable income." ) + self.royalty_escalation_rate = self.ParameterDict[self.royalty_escalation_rate.Name] = floatParameter( + 'Royalty Escalation Rate', + DefaultValue=0., + Min=0.0, + Max=1.0, + UnitType=Units.PERCENT, + PreferredUnits=PercentUnit.TENTH, + CurrentUnits=PercentUnit.TENTH, + ToolTipText="The additive amount the royalty rate increases each year. For example, a value of 0.001 " + "increases a 4% rate (0.04) to 4.1% (0.041) in the next year." + ) + + self.maximum_royalty_rate = self.ParameterDict[self.maximum_royalty_rate.Name] = floatParameter( + 'Maximum Royalty Rate', + DefaultValue=1.0, # Default to 100% (no effective cap) + Min=0.0, + Max=1.0, + UnitType=Units.PERCENT, + PreferredUnits=PercentUnit.TENTH, + CurrentUnits=PercentUnit.TENTH, + ToolTipText="The maximum royalty rate after escalation, expressed as a fraction (e.g., 0.06 for a 6% cap)." + ) + self.royalty_holder_discount_rate = self.ParameterDict[self.royalty_holder_discount_rate.Name] = floatParameter( 'Royalty Holder Discount Rate', DefaultValue=0.05, diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index 3ff97dabe..d362ebd20 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -417,20 +417,19 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]: ) ret['ppa_price_input'] = ppa_price_schedule_per_kWh - if hasattr(econ, 'royalty_rate') and econ.royalty_rate.Provided: - royalty_rate_fraction = econ.royalty_rate.quantity().to(convertible_unit('dimensionless')).magnitude - + royalty_rate_schedule = _get_royalty_rate_schedule(model) + if model.economics.royalty_rate.Provided: # For each year, calculate the royalty as a $/MWh variable cost. # The royalty is a percentage of revenue (MWh * $/MWh). By setting the # variable O&M rate to (PPA Price * Royalty Rate), SAM's calculation # (Rate * MWh) will correctly yield the total royalty payment. - variable_om_schedule_per_MWh = [ - (price_per_kWh * 1000) * royalty_rate_fraction # TODO pint unit conversion (kWh -> MWh) - for price_per_kWh in ppa_price_schedule_per_kWh + variable_om_schedule_per_mwh = [ + (price_kwh * 1000) * royalty_fraction # TODO use pint unit conversion instead + for price_kwh, royalty_fraction in zip(ppa_price_schedule_per_kWh, royalty_rate_schedule) ] # The PySAM parameter for variable operating cost in $/MWh is 'om_production'. - ret['om_production'] = variable_om_schedule_per_MWh + ret['om_production'] = variable_om_schedule_per_mwh # Debt/equity ratio ('Fraction of Investment in Bonds' parameter) ret['debt_percent'] = _pct(econ.FIB) @@ -481,5 +480,26 @@ def _ppa_pricing_model( ) +def _get_royalty_rate_schedule(model: Model) -> list[float]: + """ + Builds a year-by-year schedule of royalty rates based on escalation and cap. + Returns a list of rates as fractions (e.g., 0.05 for 5%). + """ + + econ = model.economics + plant_lifetime = model.surfaceplant.plant_lifetime.value + + escalation_rate = econ.royalty_escalation_rate.value + max_rate = econ.maximum_royalty_rate.value + + schedule = [] + current_rate = econ.royalty_rate.value + for _ in range(plant_lifetime): + schedule.append(min(current_rate, max_rate)) + current_rate += escalation_rate + + return schedule + + def _get_max_total_generation_kW(model: Model) -> float: return np.max(model.surfaceplant.ElectricityProduced.quantity().to(convertible_unit('kW')).magnitude) diff --git a/src/geophires_x_schema_generator/geophires-request.json b/src/geophires_x_schema_generator/geophires-request.json index 580420e28..70b80da68 100644 --- a/src/geophires_x_schema_generator/geophires-request.json +++ b/src/geophires_x_schema_generator/geophires-request.json @@ -1639,7 +1639,7 @@ "maximum": 1.0 }, "Royalty Rate": { - "description": "The percentage of the project's gross annual revenue paid to the royalty holder. This is modeled as a variable production-based operating expense, reducing the developer's taxable income.", + "description": "The fraction of the project's gross annual revenue paid to the royalty holder. This is modeled as a variable production-based operating expense, reducing the developer's taxable income.", "type": "number", "units": "", "category": "Economics", @@ -1647,6 +1647,24 @@ "minimum": 0.0, "maximum": 1.0 }, + "Royalty Escalation Rate": { + "description": "The additive amount the royalty rate increases each year. For example, a value of 0.001 increases a 4% rate (0.04) to 4.1% (0.041) in the next year.", + "type": "number", + "units": "", + "category": "Economics", + "default": 0.0, + "minimum": 0.0, + "maximum": 1.0 + }, + "Maximum Royalty Rate": { + "description": "The maximum royalty rate after escalation, expressed as a fraction (e.g., 0.06 for a 6% cap).", + "type": "number", + "units": "", + "category": "Economics", + "default": 1.0, + "minimum": 0.0, + "maximum": 1.0 + }, "Royalty Holder Discount Rate": { "description": "The discount rate used to calculate the Net Present Value (NPV) of the royalty holder's income stream. This rate should reflect the royalty holder's specific risk profile and is separate from the main project discount rate.", "type": "number", From 46eb832f2bd73174f2c0f0abc5c1833c1068e41a Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Tue, 16 Sep 2025 07:40:58 -0700 Subject: [PATCH 22/44] Mark TODO support custom royalty rate schedule as a list parameter --- src/geophires_x/Economics.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index ad26ead59..6d6466bdf 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -1003,6 +1003,8 @@ def __init__(self, model: Model): ToolTipText="The maximum royalty rate after escalation, expressed as a fraction (e.g., 0.06 for a 6% cap)." ) + # TODO support custom royalty rate schedule as a list parameter + self.royalty_holder_discount_rate = self.ParameterDict[self.royalty_holder_discount_rate.Name] = floatParameter( 'Royalty Holder Discount Rate', DefaultValue=0.05, From 39213f1504b323dcdebfd4022f796f6a6a274b8d Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Tue, 16 Sep 2025 08:04:49 -0700 Subject: [PATCH 23/44] test_royalty_rate_schedule --- src/geophires_x/Economics.py | 4 +- tests/geophires_x_tests/test_economics_sam.py | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index 6d6466bdf..5f4bd00d4 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -981,7 +981,7 @@ def __init__(self, model: Model): ) self.royalty_escalation_rate = self.ParameterDict[self.royalty_escalation_rate.Name] = floatParameter( - 'Royalty Escalation Rate', + 'Royalty Rate Escalation', DefaultValue=0., Min=0.0, Max=1.0, @@ -993,7 +993,7 @@ def __init__(self, model: Model): ) self.maximum_royalty_rate = self.ParameterDict[self.maximum_royalty_rate.Name] = floatParameter( - 'Maximum Royalty Rate', + 'Royalty Rate Maximum', DefaultValue=1.0, # Default to 100% (no effective cap) Min=0.0, Max=1.0, diff --git a/tests/geophires_x_tests/test_economics_sam.py b/tests/geophires_x_tests/test_economics_sam.py index e7eb7f4cd..997b48482 100644 --- a/tests/geophires_x_tests/test_economics_sam.py +++ b/tests/geophires_x_tests/test_economics_sam.py @@ -21,6 +21,7 @@ _ppa_pricing_model, _get_fed_and_state_tax_rates, SamEconomicsCalculations, + _get_royalty_rate_schedule, ) from geophires_x.GeoPHIRESUtils import sig_figs, quantity @@ -662,6 +663,48 @@ def get_row(name: str): # Note the above assertion assumes royalties are the only production-based O&M expenses. If this changes, # the assertion will need to be updated. + def test_royalty_rate_schedule(self): + royalty_rate = 0.1 + escalation_rate = 0.01 + max_rate = royalty_rate + 5 * escalation_rate + m: Model = EconomicsSamTestCase._new_model( + self._egs_test_file_path(), + additional_params={ + 'Royalty Rate': royalty_rate, + 'Royalty Rate Escalation': escalation_rate, + 'Royalty Rate Maximum': max_rate, + }, + ) + + schedule: list[float] = _get_royalty_rate_schedule(m) + + self.assertListAlmostEqual( + [ + 0.1, + 0.11, + 0.12, + 0.13, + 0.14, + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + 0.15, + ], + schedule, + places=3, + ) + @staticmethod def _new_model(input_file: Path, additional_params: dict[str, Any] | None = None, read_and_calculate=True) -> Model: if additional_params is not None: From 291898d8434c7d4a2477ca37add556af1d85ef90 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Tue, 16 Sep 2025 08:10:48 -0700 Subject: [PATCH 24/44] move logic to Economics.get_royalty_rate_schedule --- src/geophires_x/Economics.py | 22 ++++++++++++++++++++++ src/geophires_x/EconomicsSam.py | 19 +------------------ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index 5f4bd00d4..b67f2e329 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -3214,6 +3214,28 @@ def build_price_models(self, model: Model) -> None: self.CarbonEscalationStart.value, self.CarbonEscalationRate.value, self.PTCCarbonPrice) + def get_royalty_rate_schedule(self, model: Model) -> list[float]: + """ + Builds a year-by-year schedule of royalty rates based on escalation and cap. + + :type model: :class:`~geophires_x.Model.Model` + :return: schedule: A list of rates as fractions (e.g., 0.05 for 5%). + """ + + plant_lifetime = model.surfaceplant.plant_lifetime.value + + escalation_rate = self.royalty_escalation_rate.value + max_rate = self.maximum_royalty_rate.value + + schedule = [] + current_rate = self.royalty_rate.value + for _ in range(plant_lifetime): + schedule.append(min(current_rate, max_rate)) + current_rate += escalation_rate + + return schedule + + def calculate_cashflow(self, model: Model) -> None: """ Calculate cashflow and cumulative cash flow diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index d362ebd20..cfbc02364 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -481,24 +481,7 @@ def _ppa_pricing_model( def _get_royalty_rate_schedule(model: Model) -> list[float]: - """ - Builds a year-by-year schedule of royalty rates based on escalation and cap. - Returns a list of rates as fractions (e.g., 0.05 for 5%). - """ - - econ = model.economics - plant_lifetime = model.surfaceplant.plant_lifetime.value - - escalation_rate = econ.royalty_escalation_rate.value - max_rate = econ.maximum_royalty_rate.value - - schedule = [] - current_rate = econ.royalty_rate.value - for _ in range(plant_lifetime): - schedule.append(min(current_rate, max_rate)) - current_rate += escalation_rate - - return schedule + return model.economics.get_royalty_rate_schedule(model) def _get_max_total_generation_kW(model: Model) -> float: From bb3710963e06bd82c024b80df498b53688cb5ea7 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Tue, 16 Sep 2025 08:48:18 -0700 Subject: [PATCH 25/44] test_royalty_rate_escalation --- src/geophires_x/Economics.py | 12 ++++-- src/geophires_x/EconomicsSam.py | 4 +- tests/test_geophires_x.py | 66 +++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index b67f2e329..70832c44f 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -3222,14 +3222,20 @@ def get_royalty_rate_schedule(self, model: Model) -> list[float]: :return: schedule: A list of rates as fractions (e.g., 0.05 for 5%). """ + def r(x: float) -> float: + """Ignore apparent float precision issue""" + _precision = 8 + return round(x, _precision) + plant_lifetime = model.surfaceplant.plant_lifetime.value - escalation_rate = self.royalty_escalation_rate.value - max_rate = self.maximum_royalty_rate.value + escalation_rate = r(self.royalty_escalation_rate.value) + max_rate = r(self.maximum_royalty_rate.value) schedule = [] - current_rate = self.royalty_rate.value + current_rate = r(self.royalty_rate.value) for _ in range(plant_lifetime): + current_rate = r(current_rate) schedule.append(min(current_rate, max_rate)) current_rate += escalation_rate diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index cfbc02364..829a4d8a8 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -423,13 +423,13 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]: # The royalty is a percentage of revenue (MWh * $/MWh). By setting the # variable O&M rate to (PPA Price * Royalty Rate), SAM's calculation # (Rate * MWh) will correctly yield the total royalty payment. - variable_om_schedule_per_mwh = [ + variable_om_schedule_per_MWh = [ (price_kwh * 1000) * royalty_fraction # TODO use pint unit conversion instead for price_kwh, royalty_fraction in zip(ppa_price_schedule_per_kWh, royalty_rate_schedule) ] # The PySAM parameter for variable operating cost in $/MWh is 'om_production'. - ret['om_production'] = variable_om_schedule_per_mwh + ret['om_production'] = variable_om_schedule_per_MWh # Debt/equity ratio ('Fraction of Investment in Bonds' parameter) ret['debt_percent'] = _pct(econ.FIB) diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index 79c10fb4e..9545b7ed5 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -1309,6 +1309,7 @@ def test_redrilling_costs(self): def test_royalty_rate(self): royalties_output_name = 'Average Annual Royalty Cost' + zero_royalty_npv = None for royalty_rate in [0, 0.1]: result = GeophiresXClient().get_geophires_result( ImmutableGeophiresInputParameters( @@ -1345,6 +1346,9 @@ def test_royalty_rate(self): self.assertEqual(58.88, opex_result[royalties_output_name]['value']) self.assertGreater(royalty_holder_npv_MUSD, 0) + # Owner NPV is lower when royalty rate is non-zero + self.assertGreater(zero_royalty_npv, result.result['ECONOMIC PARAMETERS']['Project NPV']['value']) + royalties_cash_flow_MUSD = [ it * 1e-6 for it in _cash_flow_profile_row( @@ -1362,3 +1366,65 @@ def test_royalty_rate(self): if royalty_rate == 0.0: self.assertEqual(0, opex_result[royalties_output_name]['value']) self.assertEqual(0, royalty_holder_npv_MUSD) + zero_royalty_npv = result.result['ECONOMIC PARAMETERS']['Project NPV']['value'] + + def test_royalty_rate_escalation(self): + royalties_output_name = 'Average Annual Royalty Cost' + + base_royalty_rate = 0.05 + escalation_rate = 0.01 + + for max_rate in [0.08, 1.0]: + result = GeophiresXClient().get_geophires_result( + ImmutableGeophiresInputParameters( + from_file_path=self._get_test_file_path( + 'geophires_x_tests/generic-egs-case-2_sam-single-owner-ppa.txt' + ), + params={ + 'Royalty Rate': base_royalty_rate, + 'Royalty Rate Escalation': escalation_rate, + 'Royalty Rate Maximum': max_rate, + }, + ) + ) + opex_result = result.result['OPERATING AND MAINTENANCE COSTS (M$/yr)'] + + self.assertIsNotNone(opex_result[royalties_output_name]) + self.assertEqual('MUSD/yr', opex_result[royalties_output_name]['unit']) + + total_opex_MUSD = opex_result['Total operating and maintenance costs']['value'] + + opex_line_item_sum = 0 + for line_item_names in [ + 'Wellfield maintenance costs', + 'Power plant maintenance costs', + 'Water costs', + royalties_output_name, + ]: + opex_line_item_sum += opex_result[line_item_names]['value'] + + self.assertAlmostEqual(opex_line_item_sum, total_opex_MUSD, places=4) + + project_lifetime_yrs = result.result['ECONOMIC PARAMETERS']['Project lifetime']['value'] + + royalties_cash_flow_MUSD = [ + it * 1e-6 + for it in _cash_flow_profile_row( + result.result['SAM CASH FLOW PROFILE'], 'O&M production-based expense ($)' + ) + ][1:] + + ppa_revenue_MUSD = [ + it * 1e-6 for it in _cash_flow_profile_row(result.result['SAM CASH FLOW PROFILE'], 'PPA revenue ($)') + ][1:] + + actual_royalty_rate = [None] * len(ppa_revenue_MUSD) + for i in range(len(ppa_revenue_MUSD)): + actual_royalty_rate[i] = royalties_cash_flow_MUSD[i] / ppa_revenue_MUSD[i] + + max_expected_rate = ( + max_rate if max_rate < 1.0 else base_royalty_rate + escalation_rate * (project_lifetime_yrs - 1) + ) + + expected_last_year_revenue = ppa_revenue_MUSD[-1] * max_expected_rate + self.assertAlmostEqual(expected_last_year_revenue, royalties_cash_flow_MUSD[-1], places=3) From ee1efe8184c32598f73cef4b2cf9c007a17bfff3 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Tue, 16 Sep 2025 08:50:48 -0700 Subject: [PATCH 26/44] Regenerate schema --- src/geophires_x/Economics.py | 1 + src/geophires_x_schema_generator/geophires-request.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index 70832c44f..16dcf9fa1 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -1004,6 +1004,7 @@ def __init__(self, model: Model): ) # TODO support custom royalty rate schedule as a list parameter + # (as an alternative to specifying rate/escalation/max) self.royalty_holder_discount_rate = self.ParameterDict[self.royalty_holder_discount_rate.Name] = floatParameter( 'Royalty Holder Discount Rate', diff --git a/src/geophires_x_schema_generator/geophires-request.json b/src/geophires_x_schema_generator/geophires-request.json index 70b80da68..70a75e79f 100644 --- a/src/geophires_x_schema_generator/geophires-request.json +++ b/src/geophires_x_schema_generator/geophires-request.json @@ -1647,7 +1647,7 @@ "minimum": 0.0, "maximum": 1.0 }, - "Royalty Escalation Rate": { + "Royalty Rate Escalation": { "description": "The additive amount the royalty rate increases each year. For example, a value of 0.001 increases a 4% rate (0.04) to 4.1% (0.041) in the next year.", "type": "number", "units": "", @@ -1656,7 +1656,7 @@ "minimum": 0.0, "maximum": 1.0 }, - "Maximum Royalty Rate": { + "Royalty Rate Maximum": { "description": "The maximum royalty rate after escalation, expressed as a fraction (e.g., 0.06 for a 6% cap).", "type": "number", "units": "", From f60bcacb375d6d37c59cda018bf2ec052876d5fc Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:00:31 -0700 Subject: [PATCH 27/44] Update SAM-EM Royalties docs --- docs/SAM-Economic-Models.md | 73 +++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/docs/SAM-Economic-Models.md b/docs/SAM-Economic-Models.md index 4629a7bf7..8197733f8 100644 --- a/docs/SAM-Economic-Models.md +++ b/docs/SAM-Economic-Models.md @@ -15,28 +15,28 @@ The following table describes how GEOPHIRES parameters are transformed into SAM [EconomicsSam.py](https://github.com/softwareengineerprogrammer/GEOPHIRES/blob/274786e6799d32dad3f42a2a04297818b811f24c/src/geophires_x/EconomicsSam.py#L135-L195). (Note that the source code implementation determines actual behavior in the case of any discrepancies.) -| GEOPHIRES Parameter(s) | SAM Category | SAM Input(s) | SAM Module(s) | SAM Parameter Name(s) | Comment | -|-------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|-----------------------------------|--------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `Maximum Total Electricity Generation` | Generation Profile | `Nameplate capacity` | `Singleowner` | `system_capacity` | .. N/A | -| `Utilization Factor` | Generation Profile | `Nominal capacity factor` | `Singleowner` | `user_capacity_factor` | .. N/A | -| `Net Electricity Generation` | AC Degradation | `Annual AC degradation rate` schedule | `Utilityrate5` | `degradation` | Percentage difference of each year's `Net Electricity Generation` from `Maximum Total Electricity Generation` is input as SAM as the degradation rate schedule in order to match SAM's generation profile to GEOPHIRES | -| {`Total CAPEX` before inflation} × (1 + `Accrued financing during construction (%)`/100); | Installation Costs | `Total Installed Cost` | `Singleowner` | `total_installed_cost` | `Accrued financing during construction (%)` = (1+`Inflation Rate During Construction`) × 100 if `Inflation Rate During Construction` is provided or ((1+`Inflation Rate`) ^ `Construction Years`) × 100 if not. | -| `Total O&M Cost`, `Inflation Rate` | Operating Costs | `Fixed operating cost`, `Escalation rate` set to `Inflation Rate` × -1 | `Singleowner` | `om_fixed`, `om_fixed_escal` | .. N/A | -| `Royalty Rate` | Operating Costs | `Variable operating cost` | `Singleowner` | `om_production` | The royalty is modeled as a tax-deductible variable operating expense. GEOPHIRES calculates a schedule of $/MWh values based on the PPA price and Royalty Rate for each year. This ensures the total annual expense in SAM accurately matches the royalty payment due on gross revenue. | -| `Plant Lifetime` | Financial Parameters → Analysis Parameters | `Analysis period` | `CustomGeneration`, `Singleowner` | `CustomGeneration.analysis_period`, `Singleowner.term_tenor` | .. N/A | -| `Inflation Rate` | Financial Parameters → Analysis Parameters | `Inflation rate` | `Utilityrate5` | `inflation_rate` | .. N/A | -| `Discount Rate` | Financial Parameters → Analysis Parameters | `Real discount rate` | `Singleowner` | `real_discount_rate` | .. N/A | -| `Combined Income Tax Rate` | Financial Parameters → Project Tax and Insurance Rates | `Federal income tax rate`\: minimum of {21%, CITR}; and `State income tax rate`: maximum of {0%; CITR - 21%} | `Singleowner` | `federal_tax_rate`, `state_tax_rate` | GEOPHIRES does not have separate parameters for federal and state income tax so the rates are split from the combined rate based on an assumption of a maximum federal tax rate of 21% and the residual amount being the state tax rate. | -| `Property Tax Rate` | Financial Parameters | `Property tax rate` | `Singleowner` | `property_tax_rate` | .. N/A | -| `Fraction of Investment in Bonds` | Financial Parameters → Project Term Debt | `Debt percent` | `Singleowner` | `debt_percent` | .. N/A | -| `Inflated Bond Interest Rate` | Financial Parameters → Project Term Debt | `Annual interest rate` | `Singleowner` | `term_int_rate` | .. N/A | -| `Starting Electricity Sale Price`, `Ending Electricity Sale Price`, `Electricity Escalation Rate Per Year`, `Electricity Escalation Start Year` | Revenue | `PPA price` | `Singleowner` | `ppa_price_input` | GEOPHIRES's pricing model is used to create a PPA price schedule that is passed to SAM. | -| `Total AddOn Profit Gained` | Revenue → Capacity Payments | `Fixed amount`, `Capacity payment amount` | `Singleowner` | `cp_capacity_payment_type = 1`, `cp_capacity_payment_amount` | | -| `Investment Tax Credit Rate` | Incentives → Investment Tax Credit (ITC) | `Federal` → `Percentage (%)` | `Singleowner` | `itc_fed_percent` | Note that unlike the BICYCLE Economic Model's `Total capital costs`, SAM Economic Model's `Total CAPEX` is the total installed cost and does not subtract ITC value (if present). | -| `Production Tax Credit Electricity` | Incentives → Production Tax Credit (PTC) | `Federal` → `Amount ($/kWh)` | `Singleowner` | `ptc_fed_amount` | .. N/A | -| `Production Tax Credit Duration` | Incentives → Production Tax Credit (PTC) | `Federal` → `Term (years)` | `Singleowner` | `ptc_fed_term` | .. N/A | -| `Production Tax Credit Inflation Adjusted`, `Inflation Rate` | Incentives → Production Tax Credit (PTC) | `Federal` → `Escalation (%/yr)` | `Singleowner` | `ptc_fed_escal` | If `Production Tax Credit Inflation Adjusted` = True, GEOPHIRES set's SAM's PTC escalation rate to the inflation rate. SAM applies the escalation rate to years 2 and later of the project cash flow. Note that this produces escalation rates that are similar to inflation-adjusted equivalents, but not exactly equal. | -| `Other Incentives` + `One-time Grants Etc` | Incentives → Investment Based Incentive (IBI) | `Other` → `Amount ($)` | `Singleowner` | `ibi_oth_amount` | .. N/A | +| GEOPHIRES Parameter(s) | SAM Category | SAM Input(s) | SAM Module(s) | SAM Parameter Name(s) | Comment | +|-------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|-----------------------------------|--------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Maximum Total Electricity Generation` | Generation Profile | `Nameplate capacity` | `Singleowner` | `system_capacity` | .. N/A | +| `Utilization Factor` | Generation Profile | `Nominal capacity factor` | `Singleowner` | `user_capacity_factor` | .. N/A | +| `Net Electricity Generation` | AC Degradation | `Annual AC degradation rate` schedule | `Utilityrate5` | `degradation` | Percentage difference of each year's `Net Electricity Generation` from `Maximum Total Electricity Generation` is input as SAM as the degradation rate schedule in order to match SAM's generation profile to GEOPHIRES | +| {`Total CAPEX` before inflation} × (1 + `Accrued financing during construction (%)`/100); | Installation Costs | `Total Installed Cost` | `Singleowner` | `total_installed_cost` | `Accrued financing during construction (%)` = (1+`Inflation Rate During Construction`) × 100 if `Inflation Rate During Construction` is provided or ((1+`Inflation Rate`) ^ `Construction Years`) × 100 if not. | +| `Total O&M Cost`, `Inflation Rate` | Operating Costs | `Fixed operating cost`, `Escalation rate` set to `Inflation Rate` × -1 | `Singleowner` | `om_fixed`, `om_fixed_escal` | .. N/A | +| `Royalty Rate`, `Royalty Rate Escalation`, `Royalty Rate Maximum` | Operating Costs | `Variable operating cost` | `Singleowner` | `om_production` | The royalty is modeled as a tax-deductible variable operating expense. GEOPHIRES calculates a schedule of $/MWh values based on the PPA price and Royalty Rate for each year, with optional escalation and cap (maximum). This ensures the total annual expense in SAM accurately matches the royalty payment due on gross revenue. | +| `Plant Lifetime` | Financial Parameters → Analysis Parameters | `Analysis period` | `CustomGeneration`, `Singleowner` | `CustomGeneration.analysis_period`, `Singleowner.term_tenor` | .. N/A | +| `Inflation Rate` | Financial Parameters → Analysis Parameters | `Inflation rate` | `Utilityrate5` | `inflation_rate` | .. N/A | +| `Discount Rate` | Financial Parameters → Analysis Parameters | `Real discount rate` | `Singleowner` | `real_discount_rate` | .. N/A | +| `Combined Income Tax Rate` | Financial Parameters → Project Tax and Insurance Rates | `Federal income tax rate`\: minimum of {21%, CITR}; and `State income tax rate`: maximum of {0%; CITR - 21%} | `Singleowner` | `federal_tax_rate`, `state_tax_rate` | GEOPHIRES does not have separate parameters for federal and state income tax so the rates are split from the combined rate based on an assumption of a maximum federal tax rate of 21% and the residual amount being the state tax rate. | +| `Property Tax Rate` | Financial Parameters | `Property tax rate` | `Singleowner` | `property_tax_rate` | .. N/A | +| `Fraction of Investment in Bonds` | Financial Parameters → Project Term Debt | `Debt percent` | `Singleowner` | `debt_percent` | .. N/A | +| `Inflated Bond Interest Rate` | Financial Parameters → Project Term Debt | `Annual interest rate` | `Singleowner` | `term_int_rate` | .. N/A | +| `Starting Electricity Sale Price`, `Ending Electricity Sale Price`, `Electricity Escalation Rate Per Year`, `Electricity Escalation Start Year` | Revenue | `PPA price` | `Singleowner` | `ppa_price_input` | GEOPHIRES's pricing model is used to create a PPA price schedule that is passed to SAM. | +| `Total AddOn Profit Gained` | Revenue → Capacity Payments | `Fixed amount`, `Capacity payment amount` | `Singleowner` | `cp_capacity_payment_type = 1`, `cp_capacity_payment_amount` | | +| `Investment Tax Credit Rate` | Incentives → Investment Tax Credit (ITC) | `Federal` → `Percentage (%)` | `Singleowner` | `itc_fed_percent` | Note that unlike the BICYCLE Economic Model's `Total capital costs`, SAM Economic Model's `Total CAPEX` is the total installed cost and does not subtract ITC value (if present). | +| `Production Tax Credit Electricity` | Incentives → Production Tax Credit (PTC) | `Federal` → `Amount ($/kWh)` | `Singleowner` | `ptc_fed_amount` | .. N/A | +| `Production Tax Credit Duration` | Incentives → Production Tax Credit (PTC) | `Federal` → `Term (years)` | `Singleowner` | `ptc_fed_term` | .. N/A | +| `Production Tax Credit Inflation Adjusted`, `Inflation Rate` | Incentives → Production Tax Credit (PTC) | `Federal` → `Escalation (%/yr)` | `Singleowner` | `ptc_fed_escal` | If `Production Tax Credit Inflation Adjusted` = True, GEOPHIRES set's SAM's PTC escalation rate to the inflation rate. SAM applies the escalation rate to years 2 and later of the project cash flow. Note that this produces escalation rates that are similar to inflation-adjusted equivalents, but not exactly equal. | +| `Other Incentives` + `One-time Grants Etc` | Incentives → Investment Based Incentive (IBI) | `Other` → `Amount ($)` | `Singleowner` | `ibi_oth_amount` | .. N/A | .. .. Comment entries of ".. N/A" render as blank in the final RST, by design. @@ -152,24 +152,35 @@ Add-ons with electricity and heat are not currently supported, but may be suppor ## Royalties -SAM Economic Models can model a royalty agreement where a percentage of the project's gross revenue is paid to a third party (the "royalty holder"). This feature is enabled by providing the `Royalty Rate` parameter. +SAM Economic Models can model a royalty agreement where a percentage of the project's gross revenue is paid to a third +party (the "royalty holder"). This feature is enabled by providing the `Royalty Rate` parameter. -The royalty payment is modeled as a tax-deductible variable operating expense from the perspective of the project developer (Single Owner). -This reduces the developer's taxable income and ensures their final after-tax metrics (NPV, IRR, etc.) are calculated accurately. +The royalty payment is modeled as a tax-deductible variable operating expense from the perspective of the project +developer (Single Owner). +This reduces the developer's taxable income and ensures their final after-tax metrics (NPV, IRR, etc.) are calculated +accurately. -This is implemented by having GEOPHIRES create a year-by-year schedule for SAM's Variable operating cost (`om_production`) input. -The value for each year is calculated based on that year's PPA price and the user-provided `Royalty Rate`, ensuring the expense in SAM matches the royalty due on gross revenue. +This is implemented by having GEOPHIRES create a year-by-year schedule for SAM's Variable operating cost ( +`om_production`) input. +The value for each year is calculated based on that year's PPA price and the user-provided `Royalty Rate`, ensuring the +expense in SAM matches the royalty due on gross revenue. Input Parameters: -1. `Royalty Rate`: The percentage of the project's gross annual revenue paid to the royalty holder. -1. `Royalty Holder Discount Rate` (optional): The discount rate used to calculate the Net Present Value (NPV) of the royalty holder's income stream. This is separate from the project's main discount rate to reflect the different risk profiles of the two parties. +1. `Royalty Rate`: The percentage of the project's gross annual revenue paid to the royalty holder. It can be optionally + escalated by providing `Royalty Rate Escalation` and capped with `Royalty Rate Maximum`. +1. `Royalty Holder Discount Rate` (optional): The discount rate used to calculate the Net Present Value (NPV) of the + royalty holder's income stream. This is separate from the project's main discount rate to reflect the different risk + profiles of the two parties. Output Parameters: -1. `Average Annual Royalty Cost`: The developer's average annual royalty expense over the project's lifetime after construction is complete (Year 1). The same value is also output as `Royalty Holder Average Annual Revenue`. The individual royalties for each year are included in the cash flow line item `O&M production-based expense ($)`. +1. `Average Annual Royalty Cost`: The developer's average annual royalty expense over the project's lifetime after + construction is complete (Year 1). The same value is also output as `Royalty Holder Average Annual Revenue`. The + individual royalties for each year are included in the cash flow line item `O&M production-based expense ($)`. 1. `Royalty Holder Total Revenue`: The total undiscounted royalty income over the project's lifetime. -1. `Royalty Holder NPV`: The Net Present Value of the royalty holder's income stream, calculated using the `Royalty Holder Discount Rate`. +1. `Royalty Holder NPV`: The Net Present Value of the royalty holder's income stream, calculated using the + `Royalty Holder Discount Rate`. ## Examples From 7f2f9f392a64e6317498a29ea71861724e0a0cb9 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:10:37 -0700 Subject: [PATCH 28/44] example_SAM-single-owner-PPA-4: 50 MWe with Royalties --- .../example_SAM-single-owner-PPA-4.out | 422 ++++++++++++++++++ .../example_SAM-single-owner-PPA-4.txt | 87 ++++ 2 files changed, 509 insertions(+) create mode 100644 tests/examples/example_SAM-single-owner-PPA-4.out create mode 100644 tests/examples/example_SAM-single-owner-PPA-4.txt diff --git a/tests/examples/example_SAM-single-owner-PPA-4.out b/tests/examples/example_SAM-single-owner-PPA-4.out new file mode 100644 index 000000000..d7d1e526d --- /dev/null +++ b/tests/examples/example_SAM-single-owner-PPA-4.out @@ -0,0 +1,422 @@ + ***************** + ***CASE REPORT*** + ***************** + +Simulation Metadata +---------------------- + GEOPHIRES Version: 3.9.56 + Simulation Date: 2025-09-16 + Simulation Time: 09:04 + Calculation Time: 1.182 sec + + ***SUMMARY OF RESULTS*** + + End-Use Option: Electricity + Average Net Electricity Production: 58.87 MW + Electricity breakeven price: 6.69 cents/kWh + Total CAPEX: 222.97 MUSD + Number of production wells: 6 + Number of injection wells: 6 + Flowrate per production well: 100.0 kg/sec + Well depth: 2.6 kilometer + Geothermal gradient: 74 degC/km + + + ***ECONOMIC PARAMETERS*** + + Economic Model = SAM Single Owner PPA + Real Discount Rate: 8.00 % + Nominal Discount Rate: 10.16 % + WACC: 7.57 % + Accrued financing during construction: 5.00 % + Project lifetime: 20 yr + Capacity factor: 90.0 % + Project NPV: 121.39 MUSD + After-tax IRR: 24.28 % + Project VIR=PI=PIR: 1.91 + Project MOIC: 4.54 + Project Payback Period: 3.84 yr + Estimated Jobs Created: 125 + + ***ENGINEERING PARAMETERS*** + + Number of Production Wells: 6 + Number of Injection Wells: 6 + Well depth: 2.6 kilometer + Water loss rate: 10.0 % + Pump efficiency: 80.0 % + Injection temperature: 56.7 degC + Production Wellbore heat transmission calculated with Ramey's model + Average production well temperature drop: 2.2 degC + Flowrate per production well: 100.0 kg/sec + Injection well casing ID: 9.625 in + Production well casing ID: 9.625 in + Number of times redrilling: 0 + Power plant type: Supercritical ORC + + + ***RESOURCE CHARACTERISTICS*** + + Maximum reservoir temperature: 500.0 degC + Number of segments: 1 + Geothermal gradient: 74 degC/km + + + ***RESERVOIR PARAMETERS*** + + Reservoir Model = Multiple Parallel Fractures Model (Gringarten) + Bottom-hole temperature: 202.40 degC + Fracture model = Square + Well separation: fracture height: 165.00 meter + Fracture area: 27225.00 m**2 + Number of fractures calculated with reservoir volume and fracture separation as input + Number of fractures: 4083 + Fracture separation: 18.00 meter + Reservoir volume: 2000000000 m**3 + Reservoir impedance: 0.0010 GPa.s/m**3 + Reservoir density: 2800.00 kg/m**3 + Reservoir thermal conductivity: 3.05 W/m/K + Reservoir heat capacity: 790.00 J/kg/K + + + ***RESERVOIR SIMULATION RESULTS*** + + Maximum Production Temperature: 200.4 degC + Average Production Temperature: 200.2 degC + Minimum Production Temperature: 198.6 degC + Initial Production Temperature: 198.6 degC + Average Reservoir Heat Extraction: 360.65 MW + Production Wellbore Heat Transmission Model = Ramey Model + Average Production Well Temperature Drop: 2.2 degC + Total Average Pressure Drop: -1320.2 kPa + Average Injection Well Pressure Drop: 483.7 kPa + Average Reservoir Pressure Drop: 630.2 kPa + Average Production Well Pressure Drop: 442.7 kPa + Average Buoyancy Pressure Drop: -2876.7 kPa + + + ***CAPITAL COSTS (M$)*** + + Drilling and completion costs: 49.18 MUSD + Drilling and completion costs per vertical production well: 3.37 MUSD + Drilling and completion costs per vertical injection well: 3.37 MUSD + Drilling and completion costs per non-vertical section: 2.14 MUSD + Stimulation costs: 9.06 MUSD + Surface power plant costs: 144.44 MUSD + Field gathering system costs: 5.80 MUSD + Total surface equipment costs: 150.23 MUSD + Exploration costs: 3.89 MUSD + Inflation costs during construction: 10.62 MUSD + Total CAPEX: 222.97 MUSD + + + ***OPERATING AND MAINTENANCE COSTS (M$/yr)*** + + Wellfield maintenance costs: 1.13 MUSD/yr + Power plant maintenance costs: 3.90 MUSD/yr + Water costs: 1.58 MUSD/yr + Average Annual Royalty Cost: 2.50 MUSD/yr + Total operating and maintenance costs: 9.10 MUSD/yr + + + ***SURFACE EQUIPMENT SIMULATION RESULTS*** + + Initial geofluid availability: 0.19 MW/(kg/s) + Maximum Total Electricity Generation: 59.02 MW + Average Total Electricity Generation: 58.87 MW + Minimum Total Electricity Generation: 57.74 MW + Initial Total Electricity Generation: 57.74 MW + Maximum Net Electricity Generation: 59.02 MW + Average Net Electricity Generation: 58.87 MW + Minimum Net Electricity Generation: 57.74 MW + Initial Net Electricity Generation: 57.74 MW + Average Annual Total Electricity Generation: 464.13 GWh + Average Annual Net Electricity Generation: 464.13 GWh + Average Pumping Power: 0.00 MW + Heat to Power Conversion Efficiency: 16.32 % + + ************************************************************ + * HEATING, COOLING AND/OR ELECTRICITY PRODUCTION PROFILE * + ************************************************************ + YEAR THERMAL GEOFLUID PUMP NET FIRST LAW + DRAWDOWN TEMPERATURE POWER POWER EFFICIENCY + (degC) (MW) (MW) (%) + 1 1.0000 198.64 0.0000 57.7389 16.1868 + 2 1.0055 199.73 0.0000 58.5226 16.2814 + 3 1.0065 199.93 0.0000 58.6666 16.2987 + 4 1.0070 200.03 0.0000 58.7412 16.3077 + 5 1.0074 200.10 0.0000 58.7905 16.3136 + 6 1.0076 200.15 0.0000 58.8268 16.3179 + 7 1.0078 200.19 0.0000 58.8554 16.3213 + 8 1.0080 200.22 0.0000 58.8787 16.3241 + 9 1.0081 200.25 0.0000 58.8984 16.3265 + 10 1.0082 200.27 0.0000 58.9153 16.3285 + 11 1.0083 200.30 0.0000 58.9302 16.3303 + 12 1.0084 200.31 0.0000 58.9434 16.3318 + 13 1.0085 200.33 0.0000 58.9552 16.3332 + 14 1.0086 200.34 0.0000 58.9660 16.3345 + 15 1.0087 200.36 0.0000 58.9758 16.3357 + 16 1.0087 200.37 0.0000 58.9848 16.3368 + 17 1.0088 200.38 0.0000 58.9931 16.3378 + 18 1.0088 200.39 0.0000 59.0009 16.3387 + 19 1.0089 200.40 0.0000 59.0081 16.3396 + 20 1.0089 200.41 0.0000 59.0149 16.3404 + + + ******************************************************************* + * ANNUAL HEATING, COOLING AND/OR ELECTRICITY PRODUCTION PROFILE * + ******************************************************************* + YEAR ELECTRICITY HEAT RESERVOIR PERCENTAGE OF + PROVIDED EXTRACTED HEAT CONTENT TOTAL HEAT MINED + (GWh/year) (GWh/year) (10^15 J) (%) + 1 459.4 2826.8 606.53 1.65 + 2 462.0 2836.1 596.32 3.31 + 3 462.8 2838.9 586.10 4.96 + 4 463.3 2840.6 575.87 6.62 + 5 463.7 2841.7 565.64 8.28 + 6 463.9 2842.6 555.41 9.94 + 7 464.1 2843.3 545.17 11.60 + 8 464.3 2843.9 534.94 13.26 + 9 464.4 2844.4 524.70 14.92 + 10 464.5 2844.9 514.45 16.58 + 11 464.7 2845.2 504.21 18.24 + 12 464.8 2845.6 493.97 19.90 + 13 464.8 2845.9 483.72 21.56 + 14 464.9 2846.2 473.48 23.23 + 15 465.0 2846.4 463.23 24.89 + 16 465.1 2846.7 452.98 26.55 + 17 465.1 2846.9 442.73 28.21 + 18 465.2 2847.1 432.48 29.87 + 19 465.2 2847.3 422.23 31.53 + 20 465.3 2847.5 411.98 33.20 + + *************************** + * SAM CASH FLOW PROFILE * + *************************** +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + Year 0 Year 1 Year 2 Year 3 Year 4 Year 5 Year 6 Year 7 Year 8 Year 9 Year 10 Year 11 Year 12 Year 13 Year 14 Year 15 Year 16 Year 17 Year 18 Year 19 Year 20 +ENERGY +Electricity to grid (kWh) 0.0 459,393,200 462,061,296 462,867,882 463,343,815 463,676,568 463,929,931 464,133,155 464,302,013 464,445,938 464,571,006 464,681,345 464,779,886 464,868,777 464,949,642 465,023,726 465,092,011 465,155,313 465,214,308 465,269,475 465,319,040 +Electricity from grid (kWh) 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 +Electricity to grid net (kWh) 0.0 459,393,200 462,061,296 462,867,882 463,343,815 463,676,568 463,929,931 464,133,155 464,302,013 464,445,938 464,571,006 464,681,345 464,779,886 464,868,777 464,949,642 465,023,726 465,092,011 465,155,313 465,214,308 465,269,475 465,319,040 + +REVENUE +PPA price (cents/kWh) 0.0 8.0 8.0 8.32 8.64 8.97 9.29 9.61 9.93 10.25 10.58 10.90 11.22 11.54 11.86 12.19 12.51 12.83 13.15 13.47 13.80 +PPA revenue ($) 0 36,751,456 36,964,904 38,519,865 40,051,439 41,573,241 43,089,812 44,603,196 46,114,476 47,624,287 49,133,030 50,640,973 52,148,303 53,655,154 55,161,626 56,667,791 58,173,709 59,679,427 61,184,986 62,690,409 64,195,415 +Curtailment payment revenue ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Capacity payment revenue ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Salvage value ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 111,486,712 +Total revenue ($) 0 36,751,456 36,964,904 38,519,865 40,051,439 41,573,241 43,089,812 44,603,196 46,114,476 47,624,287 49,133,030 50,640,973 52,148,303 53,655,154 55,161,626 56,667,791 58,173,709 59,679,427 61,184,986 62,690,409 175,682,127 + +Property tax net assessed value ($) 0 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 222,973,424 + +OPERATING EXPENSES +O&M fixed expense ($) 0 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 6,599,142 +O&M production-based expense ($) 0 1,837,573 2,217,894 2,696,391 3,204,115 3,741,592 4,308,981 4,460,320 4,611,448 4,762,429 4,913,303 5,064,097 5,214,830 5,365,515 5,516,163 5,666,779 5,817,371 5,967,943 6,118,499 6,269,041 6,419,541 +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 +Electricity purchase ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Property tax expense ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Insurance expense ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Total operating expenses ($) 0 8,436,715 8,817,037 9,295,533 9,803,258 10,340,734 10,908,124 11,059,462 11,210,590 11,361,571 11,512,445 11,663,240 11,813,973 11,964,658 12,115,305 12,265,921 12,416,513 12,567,085 12,717,641 12,868,183 13,018,684 + +EBITDA ($) 0 28,314,741 28,147,867 29,224,332 30,248,182 31,232,507 32,181,688 33,543,734 34,903,886 36,262,716 37,620,584 38,977,733 40,334,330 41,690,496 43,046,321 44,401,870 45,757,195 47,112,342 48,467,345 49,822,226 162,663,443 + +OPERATING ACTIVITIES +EBITDA ($) 0 28,314,741 28,147,867 29,224,332 30,248,182 31,232,507 32,181,688 33,543,734 34,903,886 36,262,716 37,620,584 38,977,733 40,334,330 41,690,496 43,046,321 44,401,870 45,757,195 47,112,342 48,467,345 49,822,226 162,663,443 +Interest earned on reserves ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +plus PBI if not available for debt service: +Federal PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Utility PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Other PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Debt interest payment ($) 0 4,459,468 4,324,603 4,182,993 4,034,304 3,878,180 3,714,249 3,542,123 3,361,389 3,171,620 2,972,361 2,763,140 2,543,458 2,312,791 2,070,592 1,816,282 1,549,257 1,268,880 974,485 665,370 340,799 +Cash flow from operating activities ($) 0 23,855,272 23,823,264 25,041,339 26,213,878 27,354,327 28,467,439 30,001,612 31,542,496 33,091,096 34,648,223 36,214,593 37,790,873 39,377,705 40,975,729 42,585,588 44,207,939 45,843,461 47,492,860 49,156,856 162,322,644 + +INVESTING ACTIVITIES +Total installed cost ($) -222,973,424 +Debt closing costs ($) 0 +Debt up-front fee ($) 0 +minus: +Total IBI income ($) 0 +Total CBI income ($) 0 +equals: +Purchase of property ($) -222,973,424 +plus: +Reserve (increase)/decrease debt service ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve (increase)/decrease working capital ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve (increase)/decrease receivables ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve (increase)/decrease major equipment 1 ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve (increase)/decrease major equipment 2 ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve (increase)/decrease major equipment 3 ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve capital spending major equipment 1 ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve capital spending major equipment 2 ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve capital spending major equipment 3 ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +equals: +Cash flow from investing activities ($) -222,973,424 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +FINANCING ACTIVITIES +Issuance of equity ($) 133,784,055 +Size of debt ($) 89,189,370 +minus: +Debt principal payment ($) 0 2,697,317 2,832,183 2,973,792 3,122,482 3,278,606 3,442,536 3,614,663 3,795,396 3,985,166 4,184,424 4,393,646 4,613,328 4,843,994 5,086,194 5,340,504 5,607,529 5,887,905 6,182,301 6,491,416 6,815,986 +equals: +Cash flow from financing activities ($) 222,973,424 -2,697,317 -2,832,183 -2,973,792 -3,122,482 -3,278,606 -3,442,536 -3,614,663 -3,795,396 -3,985,166 -4,184,424 -4,393,646 -4,613,328 -4,843,994 -5,086,194 -5,340,504 -5,607,529 -5,887,905 -6,182,301 -6,491,416 -6,815,986 + +PROJECT RETURNS +Pre-tax Cash Flow: +Cash flow from operating activities ($) 0 23,855,272 23,823,264 25,041,339 26,213,878 27,354,327 28,467,439 30,001,612 31,542,496 33,091,096 34,648,223 36,214,593 37,790,873 39,377,705 40,975,729 42,585,588 44,207,939 45,843,461 47,492,860 49,156,856 162,322,644 +Cash flow from investing activities ($) -222,973,424 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Cash flow from financing activities ($) 222,973,424 -2,697,317 -2,832,183 -2,973,792 -3,122,482 -3,278,606 -3,442,536 -3,614,663 -3,795,396 -3,985,166 -4,184,424 -4,393,646 -4,613,328 -4,843,994 -5,086,194 -5,340,504 -5,607,529 -5,887,905 -6,182,301 -6,491,416 -6,815,986 +Total pre-tax cash flow ($) 0 21,157,955 20,991,081 22,067,546 23,091,396 24,075,721 25,024,903 26,386,948 27,747,100 29,105,930 30,463,799 31,820,948 33,177,545 34,533,711 35,889,535 37,245,084 38,600,410 39,955,556 41,310,559 42,665,440 155,506,657 + +Pre-tax Returns: +Issuance of equity ($) 133,784,055 +Total pre-tax cash flow ($) 0 21,157,955 20,991,081 22,067,546 23,091,396 24,075,721 25,024,903 26,386,948 27,747,100 29,105,930 30,463,799 31,820,948 33,177,545 34,533,711 35,889,535 37,245,084 38,600,410 39,955,556 41,310,559 42,665,440 155,506,657 +Total pre-tax returns ($) -133,784,055 21,157,955 20,991,081 22,067,546 23,091,396 24,075,721 25,024,903 26,386,948 27,747,100 29,105,930 30,463,799 31,820,948 33,177,545 34,533,711 35,889,535 37,245,084 38,600,410 39,955,556 41,310,559 42,665,440 155,506,657 + +After-tax Returns: +Total pre-tax returns ($) -133,784,055 21,157,955 20,991,081 22,067,546 23,091,396 24,075,721 25,024,903 26,386,948 27,747,100 29,105,930 30,463,799 31,820,948 33,177,545 34,533,711 35,889,535 37,245,084 38,600,410 39,955,556 41,310,559 42,665,440 155,506,657 +Federal ITC total income ($) 0 66,892,027 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Federal PTC income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Federal tax benefit (liability) ($) 0 -3,733,567 -2,801,948 -3,039,838 -3,268,835 -3,491,565 -3,708,956 -4,008,580 -4,309,514 -4,611,956 -4,916,063 -5,221,975 -5,529,822 -5,839,731 -6,151,825 -6,466,230 -6,783,075 -7,102,493 -7,424,620 -7,749,599 -29,850,877 +State ITC total income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State PTC income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State tax benefit (liability) ($) 0 -1,338,196 -1,004,283 -1,089,548 -1,171,626 -1,251,457 -1,329,375 -1,436,767 -1,544,629 -1,653,031 -1,762,030 -1,871,676 -1,982,015 -2,093,093 -2,204,955 -2,317,645 -2,431,210 -2,545,696 -2,661,154 -2,777,634 -10,699,239 +Total after-tax returns ($) -133,784,055 82,978,219 17,184,850 17,938,160 18,650,935 19,332,699 19,986,572 20,941,602 21,892,957 22,840,943 23,785,706 24,727,297 25,665,707 26,600,887 27,532,755 28,461,209 29,386,125 30,307,367 31,224,785 32,138,207 114,956,541 + +After-tax cumulative IRR (%) NaN -37.98 -21.59 -8.12 1.23 7.56 11.90 14.98 17.22 18.87 20.10 21.04 21.76 22.32 22.76 23.10 23.37 23.59 23.76 23.90 24.28 +After-tax cumulative NPV ($) -133,784,055 -58,458,874 -44,297,744 -30,879,178 -18,214,186 -6,297,024 4,886,915 15,524,484 25,619,639 35,180,537 44,218,630 52,747,931 60,784,415 68,345,517 75,449,709 82,116,156 88,364,421 94,214,227 99,685,253 104,796,972 121,394,949 + +AFTER-TAX LCOE AND PPA PRICE +Annual costs ($) -133,784,055 46,226,763 -19,780,053 -20,581,705 -21,400,504 -22,240,542 -23,103,240 -23,661,594 -24,221,519 -24,783,343 -25,347,324 -25,913,676 -26,482,596 -27,054,268 -27,628,870 -28,206,583 -28,787,584 -29,372,060 -29,960,201 -30,552,202 50,761,126 +PPA revenue ($) 0 36,751,456 36,964,904 38,519,865 40,051,439 41,573,241 43,089,812 44,603,196 46,114,476 47,624,287 49,133,030 50,640,973 52,148,303 53,655,154 55,161,626 56,667,791 58,173,709 59,679,427 61,184,986 62,690,409 64,195,415 +Electricity to grid (kWh) 0.0 459,393,200 462,061,296 462,867,882 463,343,815 463,676,568 463,929,931 464,133,155 464,302,013 464,445,938 464,571,006 464,681,345 464,779,886 464,868,777 464,949,642 465,023,726 465,092,011 465,155,313 465,214,308 465,269,475 465,319,040 + +Present value of annual costs ($) 261,106,355 +Present value of annual energy nominal (kWh) 3,903,105,303.0 +LCOE Levelized cost of energy nominal (cents/kWh) 6.69 + +Present value of PPA revenue ($) 382,501,304 +Present value of annual energy nominal (kWh) 3,903,105,303.0 +LPPA Levelized PPA price nominal (cents/kWh) 9.80 + +PROJECT STATE INCOME TAXES +EBITDA ($) 0 28,314,741 28,147,867 29,224,332 30,248,182 31,232,507 32,181,688 33,543,734 34,903,886 36,262,716 37,620,584 38,977,733 40,334,330 41,690,496 43,046,321 44,401,870 45,757,195 47,112,342 48,467,345 49,822,226 162,663,443 +State taxable PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Interest earned on reserves ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State taxable IBI income ($) 0 +State taxable CBI income ($) 0 +minus: +Debt interest payment ($) 0 4,459,468 4,324,603 4,182,993 4,034,304 3,878,180 3,714,249 3,542,123 3,361,389 3,171,620 2,972,361 2,763,140 2,543,458 2,312,791 2,070,592 1,816,282 1,549,257 1,268,880 974,485 665,370 340,799 +Total state tax depreciation ($) 0 4,738,185 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 +equals: +State taxable income ($) 0 19,117,087 14,346,894 15,564,968 16,737,508 17,877,957 18,991,068 20,525,241 22,066,126 23,614,725 25,171,852 26,738,223 28,314,502 29,901,335 31,499,358 33,109,217 34,731,568 36,367,091 38,016,489 39,680,485 152,846,273 + +State income tax rate (frac) 0.0 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 +State tax benefit (liability) ($) 0 -1,338,196 -1,004,283 -1,089,548 -1,171,626 -1,251,457 -1,329,375 -1,436,767 -1,544,629 -1,653,031 -1,762,030 -1,871,676 -1,982,015 -2,093,093 -2,204,955 -2,317,645 -2,431,210 -2,545,696 -2,661,154 -2,777,634 -10,699,239 + +PROJECT FEDERAL INCOME TAXES +EBITDA ($) 0 28,314,741 28,147,867 29,224,332 30,248,182 31,232,507 32,181,688 33,543,734 34,903,886 36,262,716 37,620,584 38,977,733 40,334,330 41,690,496 43,046,321 44,401,870 45,757,195 47,112,342 48,467,345 49,822,226 162,663,443 +Interest earned on reserves ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State tax benefit (liability) ($) 0 -1,338,196 -1,004,283 -1,089,548 -1,171,626 -1,251,457 -1,329,375 -1,436,767 -1,544,629 -1,653,031 -1,762,030 -1,871,676 -1,982,015 -2,093,093 -2,204,955 -2,317,645 -2,431,210 -2,545,696 -2,661,154 -2,777,634 -10,699,239 +State ITC total income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State PTC income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Federal taxable IBI income ($) 0 +Federal taxable CBI income ($) 0 +Federal taxable PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +minus: +Debt interest payment ($) 0 4,459,468 4,324,603 4,182,993 4,034,304 3,878,180 3,714,249 3,542,123 3,361,389 3,171,620 2,972,361 2,763,140 2,543,458 2,312,791 2,070,592 1,816,282 1,549,257 1,268,880 974,485 665,370 340,799 +Total federal tax depreciation ($) 0 4,738,185 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 9,476,371 +equals: +Federal taxable income ($) 0 17,778,891 13,342,611 14,475,420 15,565,882 16,626,500 17,661,694 19,088,474 20,521,497 21,961,695 23,409,823 24,866,547 26,332,487 27,808,241 29,294,403 30,791,572 32,300,358 33,821,394 35,355,335 36,902,851 142,147,034 + +Federal income tax rate (frac) 0.0 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 +Federal tax benefit (liability) ($) 0 -3,733,567 -2,801,948 -3,039,838 -3,268,835 -3,491,565 -3,708,956 -4,008,580 -4,309,514 -4,611,956 -4,916,063 -5,221,975 -5,529,822 -5,839,731 -6,151,825 -6,466,230 -6,783,075 -7,102,493 -7,424,620 -7,749,599 -29,850,877 + +CASH INCENTIVES +Federal IBI income ($) 0 +State IBI income ($) 0 +Utility IBI income ($) 0 +Other IBI income ($) 0 +Total IBI income ($) 0 + +Federal CBI income ($) 0 +State CBI income ($) 0 +Utility CBI income ($) 0 +Other CBI income ($) 0 +Total CBI income ($) 0 + +Federal PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Utility PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Other PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Total PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +TAX CREDITS +Federal PTC income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State PTC income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +Federal ITC amount income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Federal ITC percent income ($) 0 66,892,027 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Federal ITC total income ($) 0 66,892,027 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +State ITC amount income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State ITC percent income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State ITC total income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +DEBT REPAYMENT +Debt balance ($) 89,189,370 86,492,052 83,659,869 80,686,077 77,563,595 74,284,989 70,842,453 67,227,790 63,432,393 59,447,227 55,262,803 50,869,157 46,255,829 41,411,835 36,325,641 30,985,137 25,377,608 19,489,703 13,307,402 6,815,986 0 +Debt interest payment ($) 0 4,459,468 4,324,603 4,182,993 4,034,304 3,878,180 3,714,249 3,542,123 3,361,389 3,171,620 2,972,361 2,763,140 2,543,458 2,312,791 2,070,592 1,816,282 1,549,257 1,268,880 974,485 665,370 340,799 +Debt principal payment ($) 0 2,697,317 2,832,183 2,973,792 3,122,482 3,278,606 3,442,536 3,614,663 3,795,396 3,985,166 4,184,424 4,393,646 4,613,328 4,843,994 5,086,194 5,340,504 5,607,529 5,887,905 6,182,301 6,491,416 6,815,986 +Debt total payment ($) 0 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 + +DSCR (DEBT FRACTION) +EBITDA ($) 0 28,314,741 28,147,867 29,224,332 30,248,182 31,232,507 32,181,688 33,543,734 34,903,886 36,262,716 37,620,584 38,977,733 40,334,330 41,690,496 43,046,321 44,401,870 45,757,195 47,112,342 48,467,345 49,822,226 162,663,443 +minus: +Reserves major equipment 1 funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 2 funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 3 funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves receivables funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +equals: +Cash available for debt service (CAFDS) ($) 0 28,314,741 28,147,867 29,224,332 30,248,182 31,232,507 32,181,688 33,543,734 34,903,886 36,262,716 37,620,584 38,977,733 40,334,330 41,690,496 43,046,321 44,401,870 45,757,195 47,112,342 48,467,345 49,822,226 162,663,443 +Debt total payment ($) 0 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 7,156,786 +DSCR (pre-tax) 0.0 3.96 3.93 4.08 4.23 4.36 4.50 4.69 4.88 5.07 5.26 5.45 5.64 5.83 6.01 6.20 6.39 6.58 6.77 6.96 22.73 + +RESERVES +Reserves working capital funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves working capital disbursement ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves working capital balance ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +Reserves debt service funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves debt service disbursement ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves debt service balance ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +Reserves receivables funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves receivables disbursement ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves receivables balance ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +Reserves major equipment 1 funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 1 disbursement ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 1 balance ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +Reserves major equipment 2 funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 2 disbursement ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 2 balance ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +Reserves major equipment 3 funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 3 disbursement ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 3 balance ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +Reserves total reserves balance ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Interest on reserves (%/year) 1.75 +Interest earned on reserves ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + + + + ***EXTENDED ECONOMICS*** + + Royalty Holder NPV: 29.63 MUSD + Royalty Holder Average Annual Revenue: 2.50 MUSD/yr + Royalty Holder Total Revenue: 49.93 MUSD diff --git a/tests/examples/example_SAM-single-owner-PPA-4.txt b/tests/examples/example_SAM-single-owner-PPA-4.txt new file mode 100644 index 000000000..872c704c4 --- /dev/null +++ b/tests/examples/example_SAM-single-owner-PPA-4.txt @@ -0,0 +1,87 @@ +# Example: SAM Single Owner PPA Economic Model: 50 MWe with Royalties +# This example models example_SAM-single-owner with royalties that start at 5% and escalate to 10% +# See "SAM Economic Models" in GEOPHIRES documentation: https://nrel.github.io/GEOPHIRES-X/SAM-Economic-Models.html#add-ons + + +# *** ECONOMIC/FINANCIAL PARAMETERS *** +# ************************************* +Economic Model, 5, -- SAM Single Owner PPA + +Royalty Rate, 0.05 +Royalty Rate Escalation, 0.01 +Royalty Rate Maximum, 0.1 + +Starting Electricity Sale Price, 0.08 +Ending Electricity Sale Price, 1.00 +Electricity Escalation Rate Per Year, 0.00322 +Electricity Escalation Start Year, 1 + +Fraction of Investment in Bonds, .4 +Inflated Bond Interest Rate, .05 +Discount Rate, 0.08 +Inflation Rate, .02 +Inflation Rate During Construction, 0.05 + +Combined Income Tax Rate, .28 +Investment Tax Credit Rate, 0.3 +Property Tax Rate, 0 + +Capital Cost for Power Plant for Electricity Generation, 1900 + +# *** SURFACE & SUBSURFACE TECHNICAL PARAMETERS *** +# ************************************************* +End-Use Option, 1, -- Electricity +Power Plant Type, 2, -- Supercritical ORC +Plant Lifetime, 20 + +Reservoir Model, 1 + +Reservoir Volume Option, 2, -- RES_VOL_FRAC_SEP (Specify reservoir volume and fracture separation) +Reservoir Volume, 2000000000, -- m**3 +Fracture Shape, 3, -- Square +Fracture Separation, 18 +Fracture Height, 165 + +Reservoir Density, 2800 +Reservoir Depth, 2.6, -- km +Reservoir Heat Capacity, 790 +Reservoir Thermal Conductivity, 3.05 +Reservoir Porosity, 0.0118 +Reservoir Impedance, 0.001 + +Number of Segments, 1 +Gradient 1, 74 + +Number of Injection Wells, 6 +Number of Production Wells, 6 + +Production Flow Rate per Well, 100 + +Production Well Diameter, 9.625 +Injection Well Diameter, 9.625 + +Well Separation, 365 feet + +Ramey Production Wellbore Model, 1 +Injection Temperature, 60 degC +Injection Wellbore Temperature Gain, 3 +Plant Outlet Pressure, 1000 psi +Production Wellhead Pressure, 325 psi + +Utilization Factor, .9 +Water Loss Fraction, 0.10 +Maximum Drawdown, 0.0066 +Ambient Temperature, 10, -- degC +Surface Temperature, 10, -- degC +Circulation Pump Efficiency, 0.80 + +Well Geometry Configuration, 4 +Has Nonvertical Section, True +Multilaterals Cased, True +Number of Multilateral Sections, 3 +Nonvertical Length per Multilateral Section, 1433, -- meters + +# *** SIMULATION PARAMETERS *** +# ***************************** +Maximum Temperature, 500 +Time steps per year, 12 From 772a276a8a910ccb805fe4c9a4c1211b81919767 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:11:47 -0700 Subject: [PATCH 29/44] =?UTF-8?q?Bump=20version:=203.9.56=20=E2=86=92=203.?= =?UTF-8?q?9.57?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- .cookiecutterrc | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- src/geophires_x/__init__.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 2517cc418..3aa89e26b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.56 +current_version = 3.9.57 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 73ce91080..0ebd31605 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.9.56 + version: 3.9.57 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index e307452da..024073c17 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.9.56.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.57.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.56...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.57...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 344ab81ec..cda29f863 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ year = '2025' author = 'NREL' copyright = f'{year}, {author}' -version = release = '3.9.56' +version = release = '3.9.57' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index a612e457c..b5f4e9b31 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.56', + version='3.9.57', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index 807095dfc..42d91d076 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.56' +__version__ = '3.9.57' From 148b88c46a805107b5877c0e0c2a1801498383f6 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:24:24 -0700 Subject: [PATCH 30/44] Add example_SAM-single-owner-PPA-4 to README examples table --- README.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 024073c17..fa4aaed65 100644 --- a/README.rst +++ b/README.rst @@ -308,10 +308,14 @@ Example-specific web interface deeplinks are listed in the Link column. - `example_SAM-single-owner-PPA-2.txt `__ - `.out `__ - `link `__ - * - SAM Single Owner PPA: 50 MWe with Add-on + * - SAM Single Owner PPA: 50 MWe with Add-ons - `example_SAM-single-owner-PPA-3.txt `__ - `.out `__ - `link `__ + * - SAM Single Owner PPA: 50 MWe with Royalties + - `example_SAM-single-owner-PPA-4.txt `__ + - `.out `__ + - `link `__ .. raw:: html From f97a527edc74cc8e2ec0e49aaae8405b21024543 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:53:46 -0700 Subject: [PATCH 31/44] SAM-EM doc web interface links --- docs/SAM-Economic-Models.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/SAM-Economic-Models.md b/docs/SAM-Economic-Models.md index 8197733f8..7a328b000 100644 --- a/docs/SAM-Economic-Models.md +++ b/docs/SAM-Economic-Models.md @@ -150,6 +150,8 @@ Total AddOn Profit Gained per year is treated as fixed amount Capacity payment r Add-ons CAPEX, OPEX, and profit are supported. Add-ons with electricity and heat are not currently supported, but may be supported in the future. +[Add-Ons example web interface link](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=example_SAM-single-owner-PPA-3) + ## Royalties SAM Economic Models can model a royalty agreement where a percentage of the project's gross revenue is paid to a third @@ -182,14 +184,22 @@ Output Parameters: 1. `Royalty Holder NPV`: The Net Present Value of the royalty holder's income stream, calculated using the `Royalty Holder Discount Rate`. -## Examples +[Royalties example web interface link](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=example_SAM-single-owner-PPA-4) -### SAM Single Owner PPA: 50 MWe - -[Web interface link](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=example_SAM-single-owner-PPA) +## Examples ### Case Study: 500 MWe EGS Project Modeled on Fervo Cape Station [Web interface link](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=Fervo_Project_Cape-4) See [Case Study: 500 MWe EGS Project Modeled on Fervo Cape Station](Fervo_Project_Cape-4.html). + +### SAM Single Owner PPA: 50 MWe + +1. [SAM Single Owner PPA: 50 MWe](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=example_SAM-single-owner-PPA) +2. [SAM Single Owner PPA: 50 MWe with Add-ons](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=example_SAM-single-owner-PPA-3) +3. [SAM Single Owner PPA: 50 MWe with Royalties](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=example_SAM-single-owner-PPA-4) + +### SAM Single Owner PPA: 400 MWe BICYCLE Comparison + +[Web interface link](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=example_SAM-single-owner-PPA-2) From 87bb03de999dc816904aa8f2224284a54be93782 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 18 Sep 2025 07:35:53 -0700 Subject: [PATCH 32/44] =?UTF-8?q?Bump=20version:=203.9.57=20=E2=86=92=203.?= =?UTF-8?q?9.58?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- .cookiecutterrc | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- src/geophires_x/__init__.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 3aa89e26b..196198794 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.57 +current_version = 3.9.58 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 0ebd31605..1bc81b1bb 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.9.57 + version: 3.9.58 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index fa4aaed65..159b5e24f 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.9.57.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.58.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.57...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.58...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 cda29f863..aec8b067b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ year = '2025' author = 'NREL' copyright = f'{year}, {author}' -version = release = '3.9.57' +version = release = '3.9.58' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index b5f4e9b31..4ba90e6cb 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.57', + version='3.9.58', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index 42d91d076..42944d78f 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.57' +__version__ = '3.9.58' From bcbe923d6a4b3146bb656dee64924e068d29d2a1 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 18 Sep 2025 07:47:37 -0700 Subject: [PATCH 33/44] test_royalty_rate_with_addon --- tests/test_geophires_x.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index 9545b7ed5..5a607e456 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -9,6 +9,7 @@ import numpy as np +from geophires_x.GeoPHIRESUtils import sig_figs from geophires_x.OptionList import PlantType from geophires_x.OptionList import WellDrillingCostCorrelation from geophires_x_client import GeophiresXClient @@ -1428,3 +1429,31 @@ def test_royalty_rate_escalation(self): expected_last_year_revenue = ppa_revenue_MUSD[-1] * max_expected_rate self.assertAlmostEqual(expected_last_year_revenue, royalties_cash_flow_MUSD[-1], places=3) + + def test_royalty_rate_with_addon(self): + """ + Verifies that custom EXTENDED ECONOMICS header print logic in Outputs works as expected + (geophires_x.Outputs.Outputs._print_extended_economics_header) + """ + + addon_profit_MUSD = 15 + + result = GeophiresXClient().get_geophires_result( + ImmutableGeophiresInputParameters( + from_file_path=self._get_test_file_path( + 'examples/example_SAM-single-owner-PPA-4.txt' # Royalty rate example + ), + params={ + 'AddOn Nickname 1': 'Waste Heat Absorption Chiller', + 'AddOn CAPEX 1': 50, + 'AddOn OPEX 1': 1, + 'AddOn Profit Gained 1': addon_profit_MUSD, + }, + ) + ) + + self.assertEqual(30, sig_figs(result.result['EXTENDED ECONOMICS']['Royalty Holder NPV']['value'], 1)) + + addon_cash_flow = _cash_flow_profile_row(result.result['SAM CASH FLOW PROFILE'], 'Capacity payment revenue ($)') + self.assertEqual(0, addon_cash_flow[0]) + self.assertTrue(all(it == addon_profit_MUSD * 1e6 for it in addon_cash_flow[1:])) From 8c0de3fe4488632b2d14a62237fd6c6a6f81e358 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 18 Sep 2025 07:59:00 -0700 Subject: [PATCH 34/44] Clarify that royalty holder revenue/NPV are pre-tax --- docs/SAM-Economic-Models.md | 7 ++++--- src/geophires_x/Economics.py | 10 +++++++--- src/geophires_x_schema_generator/geophires-result.json | 6 +++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/SAM-Economic-Models.md b/docs/SAM-Economic-Models.md index 7a328b000..a162b627a 100644 --- a/docs/SAM-Economic-Models.md +++ b/docs/SAM-Economic-Models.md @@ -180,9 +180,10 @@ Output Parameters: 1. `Average Annual Royalty Cost`: The developer's average annual royalty expense over the project's lifetime after construction is complete (Year 1). The same value is also output as `Royalty Holder Average Annual Revenue`. The individual royalties for each year are included in the cash flow line item `O&M production-based expense ($)`. -1. `Royalty Holder Total Revenue`: The total undiscounted royalty income over the project's lifetime. -1. `Royalty Holder NPV`: The Net Present Value of the royalty holder's income stream, calculated using the - `Royalty Holder Discount Rate`. +1. `Royalty Holder Total Revenue`: The total gross (pre-tax), undiscounted royalty income over the project's lifetime. +1. `Royalty Holder NPV`: The pre-tax Net Present Value of the royalty holder's income stream, calculated using the + `Royalty Holder Discount Rate`. This is a pre-tax value because the model does not account for the royalty holder's + specific tax liabilities. [Royalties example web interface link](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=example_SAM-single-owner-PPA-4) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index 16dcf9fa1..4cf303a42 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -2182,7 +2182,10 @@ def __init__(self, model: Model): UnitType=Units.CURRENCY, PreferredUnits=CurrencyUnit.MDOLLARS, CurrentUnits=CurrencyUnit.MDOLLARS, - ToolTipText="Net Present Value (NPV) of the royalty holder's cash flow stream." + ToolTipText=f"The pre-tax Net Present Value (NPV) of the royalty holder's income stream, " + f"calculated using the {self.royalty_holder_discount_rate.Name}. " + f"This is a pre-tax value because the model does not account for the royalty holder's specific " + f"tax liabilities." ) self.royalty_holder_annual_revenue = self.OutputParameterDict[ self.royalty_holder_annual_revenue.Name @@ -2191,7 +2194,7 @@ def __init__(self, model: Model): UnitType=Units.CURRENCYFREQUENCY, PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, - ToolTipText="The royalty holder's annual revenue stream from the royalty agreement." + ToolTipText="The royalty holder's gross (pre-tax) annual revenue stream from the royalty agreement." ) self.royalty_holder_total_revenue = self.OutputParameterDict[ self.royalty_holder_total_revenue.Name @@ -2200,7 +2203,8 @@ def __init__(self, model: Model): UnitType=Units.CURRENCY, PreferredUnits=CurrencyUnit.MDOLLARS, CurrentUnits=CurrencyUnit.MDOLLARS, - ToolTipText='The total (undiscounted) revenue received by the royalty holder over the project lifetime.' + ToolTipText='The total gross (pre-tax), undiscounted revenue received by the royalty holder over the ' + 'project lifetime.' ) model.logger.info(f'Complete {__class__!s}: {sys._getframe().f_code.co_name}') diff --git a/src/geophires_x_schema_generator/geophires-result.json b/src/geophires_x_schema_generator/geophires-result.json index 321b08e17..1b251481c 100644 --- a/src/geophires_x_schema_generator/geophires-result.json +++ b/src/geophires_x_schema_generator/geophires-result.json @@ -174,17 +174,17 @@ "AddOns Payback Period": {}, "Royalty Holder NPV": { "type": "number", - "description": "Net Present Value (NPV) of the royalty holder's cash flow stream.", + "description": "The pre-tax Net Present Value (NPV) of the royalty holder's income stream. This is a pre-tax value because the model does not account for the royalty holder's specific tax liabilities.", "units": "MUSD" }, "Royalty Holder Average Annual Revenue": { "type": "number", - "description": "The royalty holder's annual revenue stream from the royalty agreement.", + "description": "The royalty holder's gross (pre-tax) annual revenue stream from the royalty agreement.", "units": "MUSD/yr" }, "Royalty Holder Total Revenue": { "type": "number", - "description": "The total (undiscounted) revenue received by the royalty holder over the project lifetime.", + "description": "The total gross (pre-tax), undiscounted revenue received by the royalty holder over the project lifetime.", "units": "MUSD" } } From 9b87c451cae918776da2f071594b74c02f75adce Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 18 Sep 2025 09:52:34 -0700 Subject: [PATCH 35/44] regnerate schema --- src/geophires_x_schema_generator/geophires-result.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/geophires_x_schema_generator/geophires-result.json b/src/geophires_x_schema_generator/geophires-result.json index 1b251481c..a4fa2c10b 100644 --- a/src/geophires_x_schema_generator/geophires-result.json +++ b/src/geophires_x_schema_generator/geophires-result.json @@ -174,7 +174,7 @@ "AddOns Payback Period": {}, "Royalty Holder NPV": { "type": "number", - "description": "The pre-tax Net Present Value (NPV) of the royalty holder's income stream. This is a pre-tax value because the model does not account for the royalty holder's specific tax liabilities.", + "description": "The pre-tax Net Present Value (NPV) of the royalty holder's income stream, calculated using the Royalty Holder Discount Rate. This is a pre-tax value because the model does not account for the royalty holder's specific tax liabilities.", "units": "MUSD" }, "Royalty Holder Average Annual Revenue": { From d68322a840ab0639079a8a51729c724b8b46765a Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 18 Sep 2025 09:52:38 -0700 Subject: [PATCH 36/44] =?UTF-8?q?Bump=20version:=203.9.58=20=E2=86=92=203.?= =?UTF-8?q?9.59?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- .cookiecutterrc | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- src/geophires_x/__init__.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 196198794..df30bc900 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.58 +current_version = 3.9.59 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 1bc81b1bb..73b43f393 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.9.58 + version: 3.9.59 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index 159b5e24f..c986c336d 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.9.58.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.59.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.58...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.59...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 aec8b067b..742064139 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ year = '2025' author = 'NREL' copyright = f'{year}, {author}' -version = release = '3.9.58' +version = release = '3.9.59' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index 4ba90e6cb..a71ef22ec 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.58', + version='3.9.59', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index 42944d78f..b7f665a20 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.58' +__version__ = '3.9.59' From 78a3942598d9bcc48446c14940bc28b8d5c4332d Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Mon, 22 Sep 2025 07:31:14 -0700 Subject: [PATCH 37/44] slice pre-revenue years based on construction years (instead of hardcoding to 1). note this doesn't have any effect until SAM-EM supports multiple construction years (https://github.com/NREL/GEOPHIRES-X/issues/406) --- src/geophires_x/Economics.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index 4cf303a42..ce446f9c7 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -3356,11 +3356,14 @@ def _calculate_sam_economics(self, model: Model) -> None: if self.royalty_rate.Provided: - average_annual_royalties = np.average( # TODO unit conversion - self.sam_economics_calculations.royalties_opex.value[1:] # ignore pre-revenue year(s) (Year 0) + # ignore pre-revenue year(s) (e.g. Year 0) + pre_revenue_years_slice_index = model.surfaceplant.construction_years.value + + average_annual_royalties = np.average( + self.sam_economics_calculations.royalties_opex.value[pre_revenue_years_slice_index:] ) - self.royalties_average_annual_cost.value = average_annual_royalties + self.royalties_average_annual_cost.value = average_annual_royalties # TODO unit conversion self.Coam.value += self.royalties_average_annual_cost.quantity().to(self.Coam.CurrentUnits.value).magnitude self.royalty_holder_npv.value = calculate_npv( @@ -3370,7 +3373,7 @@ def _calculate_sam_economics(self, model: Model) -> None: ) self.royalty_holder_annual_revenue.value = self.royalties_average_annual_cost.value self.royalty_holder_total_revenue.value = np.sum( # TODO unit conversion - self.sam_economics_calculations.royalties_opex.value[1:] # ignore pre-revenue year(s) (Year 0) + self.sam_economics_calculations.royalties_opex.value[pre_revenue_years_slice_index:] ) From 0da03408f117e1b4e0fb0ca1455e9def41858461 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Wed, 24 Sep 2025 08:54:12 -0700 Subject: [PATCH 38/44] Fix unit conversion issue that was causing incorrect royalty calculations --- src/geophires_x/Economics.py | 33 ++++++--- src/geophires_x/EconomicsSam.py | 67 ++++++++++++------- src/geophires_x/EconomicsUtils.py | 4 +- src/geophires_x/Units.py | 3 + .../example_SAM-single-owner-PPA-4.out | 18 ++--- tests/geophires_x_tests/test_units.py | 8 +++ 6 files changed, 86 insertions(+), 47 deletions(-) create mode 100644 tests/geophires_x_tests/test_units.py diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index ce446f9c7..c358fcf1c 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -3363,18 +3363,31 @@ def _calculate_sam_economics(self, model: Model) -> None: self.sam_economics_calculations.royalties_opex.value[pre_revenue_years_slice_index:] ) - self.royalties_average_annual_cost.value = average_annual_royalties # TODO unit conversion - self.Coam.value += self.royalties_average_annual_cost.quantity().to(self.Coam.CurrentUnits.value).magnitude + self.royalties_average_annual_cost.value = (quantity( + average_annual_royalties, + self.sam_economics_calculations.royalties_opex.CurrentUnits + ).to(self.royalties_average_annual_cost.CurrentUnits).magnitude) + + self.Coam.value += (self.royalties_average_annual_cost.quantity() + .to(self.Coam.CurrentUnits.value).magnitude) + + self.royalty_holder_npv.value = quantity( + calculate_npv( + self.royalty_holder_discount_rate.value, + self.sam_economics_calculations.royalties_opex.value, + self.discount_initial_year_cashflow.value + ), + self.sam_economics_calculations.royalties_opex.CurrentUnits.get_currency_unit_str() + ).to(self.royalty_holder_npv.CurrentUnits).magnitude - self.royalty_holder_npv.value = calculate_npv( - self.royalty_holder_discount_rate.value, - self.sam_economics_calculations.royalties_opex.value, - self.discount_initial_year_cashflow.value - ) self.royalty_holder_annual_revenue.value = self.royalties_average_annual_cost.value - self.royalty_holder_total_revenue.value = np.sum( # TODO unit conversion - self.sam_economics_calculations.royalties_opex.value[pre_revenue_years_slice_index:] - ) + + self.royalty_holder_total_revenue.value = quantity( + np.sum( + self.sam_economics_calculations.royalties_opex.value[pre_revenue_years_slice_index:] + ), + self.sam_economics_calculations.royalties_opex.CurrentUnits.get_currency_unit_str() + ).to(self.royalty_holder_total_revenue.CurrentUnits).magnitude self.wacc.value = self.sam_economics_calculations.wacc.value diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index 829a4d8a8..88ce85574 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -173,12 +173,13 @@ def sf(_v: float, num_sig_figs: int = 5) -> float: sam_economics.project_npv.value = sf(single_owner.Outputs.project_return_aftertax_npv * 1e-6) sam_economics.capex.value = single_owner.Outputs.adjusted_installed_cost * 1e-6 - royalty_rate = model.economics.royalty_rate.quantity().to('dimensionless').magnitude - ppa_revenue_row = _cash_flow_profile_row(cash_flow, 'PPA revenue ($)') - royalties_unit = sam_economics.royalties_opex.CurrentUnits.value.replace('/yr', '') - sam_economics.royalties_opex.value = [ - quantity(x * royalty_rate, 'USD').to(royalties_unit).magnitude for x in ppa_revenue_row - ] + if model.economics.royalty_rate.Provided: + # Assumes that royalties opex is the only possible O&M production-based expense - this logic will need to be + # updated if more O&M production-based expenses are added to SAM-EM + sam_economics.royalties_opex.value = [ + quantity(it, 'USD / year').to(sam_economics.royalties_opex.CurrentUnits).magnitude + for it in _cash_flow_profile_row(cash_flow, 'O&M production-based expense ($)') + ] sam_economics.nominal_discount_rate.value, sam_economics.wacc.value = _calculate_nominal_discount_rate_and_wacc( model, single_owner @@ -408,28 +409,11 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]: geophires_ptr_tenths = Decimal(econ.PTR.value) ret['property_tax_rate'] = float(geophires_ptr_tenths * Decimal(100)) - ppa_price_schedule_per_kWh = _ppa_pricing_model( - model.surfaceplant.plant_lifetime.value, - econ.ElecStartPrice.value, - econ.ElecEndPrice.value, - econ.ElecEscalationStart.value, - econ.ElecEscalationRate.value, - ) + ppa_price_schedule_per_kWh = _get_ppa_price_schedule_per_kWh(model) ret['ppa_price_input'] = ppa_price_schedule_per_kWh - royalty_rate_schedule = _get_royalty_rate_schedule(model) if model.economics.royalty_rate.Provided: - # For each year, calculate the royalty as a $/MWh variable cost. - # The royalty is a percentage of revenue (MWh * $/MWh). By setting the - # variable O&M rate to (PPA Price * Royalty Rate), SAM's calculation - # (Rate * MWh) will correctly yield the total royalty payment. - variable_om_schedule_per_MWh = [ - (price_kwh * 1000) * royalty_fraction # TODO use pint unit conversion instead - for price_kwh, royalty_fraction in zip(ppa_price_schedule_per_kWh, royalty_rate_schedule) - ] - - # The PySAM parameter for variable operating cost in $/MWh is 'om_production'. - ret['om_production'] = variable_om_schedule_per_MWh + ret['om_production'] = _get_royalties_variable_om_per_MWh_schedule(model) # Debt/equity ratio ('Fraction of Investment in Bonds' parameter) ret['debt_percent'] = _pct(econ.FIB) @@ -452,6 +436,24 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]: return ret +def _get_royalties_variable_om_per_MWh_schedule(model: Model): + """TODO price unit in method name""" + + royalty_rate_schedule = _get_royalty_rate_schedule(model) + ppa_price_schedule_per_kWh = _get_ppa_price_schedule_per_kWh(model) + + # For each year, calculate the royalty as a $/MWh variable cost. + # The royalty is a percentage of revenue (MWh * $/MWh). By setting the + # variable O&M rate to (PPA Price * Royalty Rate), SAM's calculation + # (Rate * MWh) will correctly yield the total royalty payment. + variable_om_schedule_per_MWh = [ + (price_kWh * 1000) * royalty_fraction # TODO use pint unit conversion instead + for price_kWh, royalty_fraction in zip(ppa_price_schedule_per_kWh, royalty_rate_schedule) + ] + + return variable_om_schedule_per_MWh + + def _get_fed_and_state_tax_rates(geophires_ctr_tenths: float) -> tuple[list[float]]: geophires_ctr_tenths = Decimal(geophires_ctr_tenths) max_fed_rate_tenths = Decimal(0.21) @@ -469,9 +471,22 @@ def _pct(econ_value: Parameter) -> float: return econ_value.quantity().to(convertible_unit('%')).magnitude +def _get_ppa_price_schedule_per_kWh(model: Model) -> list[float]: + """TODO price unit in method name""" + + econ = model.economics + return _ppa_pricing_model( + model.surfaceplant.plant_lifetime.value, + econ.ElecStartPrice.value, + econ.ElecEndPrice.value, + econ.ElecEscalationStart.value, + econ.ElecEscalationRate.value, + ) + + def _ppa_pricing_model( plant_lifetime: int, start_price: float, end_price: float, escalation_start_year: int, escalation_rate: float -) -> list: +) -> list[float]: # See relevant comment in geophires_x.EconomicsUtils.BuildPricingModel re: # https://github.com/NREL/GEOPHIRES-X/issues/340?title=Price+Escalation+Start+Year+seemingly+off+by+1. # We use the same utility method here for the sake of consistency despite technical incorrectness. diff --git a/src/geophires_x/EconomicsUtils.py b/src/geophires_x/EconomicsUtils.py index 782383ecd..ecce0953f 100644 --- a/src/geophires_x/EconomicsUtils.py +++ b/src/geophires_x/EconomicsUtils.py @@ -150,8 +150,8 @@ def royalty_cost_output_parameter() -> OutputParameter: return OutputParameter( Name='Royalty Cost', UnitType=Units.CURRENCYFREQUENCY, - PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, - CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR, + PreferredUnits=CurrencyFrequencyUnit.DOLLARSPERYEAR, + CurrentUnits=CurrencyFrequencyUnit.DOLLARSPERYEAR, ToolTipText='The annual costs paid to a royalty holder, calculated as a percentage of the ' 'project\'s gross annual revenue. This is modeled as a variable operating expense.' ) diff --git a/src/geophires_x/Units.py b/src/geophires_x/Units.py index acda1b94a..0e52f4c73 100644 --- a/src/geophires_x/Units.py +++ b/src/geophires_x/Units.py @@ -201,6 +201,9 @@ class CurrencyFrequencyUnit(str, Enum): KMXNPERYEAR = "KMXN/yr" MXNPERYEAR = "MXN/yr" + def get_currency_unit_str(self) -> str: + return self.value.split('/')[0] + class EnergyCostUnit(str, Enum): DOLLARSPERKWH = "USD/kWh" diff --git a/tests/examples/example_SAM-single-owner-PPA-4.out b/tests/examples/example_SAM-single-owner-PPA-4.out index d7d1e526d..0f692bdcf 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.9.56 - Simulation Date: 2025-09-16 - Simulation Time: 09:04 - Calculation Time: 1.182 sec + GEOPHIRES Version: 3.9.59 + Simulation Date: 2025-09-24 + Simulation Time: 08:47 + Calculation Time: 1.162 sec ***SUMMARY OF RESULTS*** @@ -115,8 +115,8 @@ Simulation Metadata Wellfield maintenance costs: 1.13 MUSD/yr Power plant maintenance costs: 3.90 MUSD/yr Water costs: 1.58 MUSD/yr - Average Annual Royalty Cost: 2.50 MUSD/yr - Total operating and maintenance costs: 9.10 MUSD/yr + Average Annual Royalty Cost: 4.71 MUSD/yr + Total operating and maintenance costs: 11.31 MUSD/yr ***SURFACE EQUIPMENT SIMULATION RESULTS*** @@ -417,6 +417,6 @@ Interest earned on reserves ($) 0 0 0 ***EXTENDED ECONOMICS*** - Royalty Holder NPV: 29.63 MUSD - Royalty Holder Average Annual Revenue: 2.50 MUSD/yr - Royalty Holder Total Revenue: 49.93 MUSD + Royalty Holder NPV: 54.18 MUSD + Royalty Holder Average Annual Revenue: 4.71 MUSD/yr + Royalty Holder Total Revenue: 94.17 MUSD diff --git a/tests/geophires_x_tests/test_units.py b/tests/geophires_x_tests/test_units.py new file mode 100644 index 000000000..f388f3fc2 --- /dev/null +++ b/tests/geophires_x_tests/test_units.py @@ -0,0 +1,8 @@ +from base_test_case import BaseTestCase +from geophires_x.Units import CurrencyFrequencyUnit + + +class UnitsTestCase(BaseTestCase): + + def test_get_currency_frequency_unit_currency_unit_str(self): + self.assertEqual('USD', CurrencyFrequencyUnit.DOLLARSPERYEAR.get_currency_unit_str()) From 7ad9fe2f473ecb0b7163a7d162ff7897fdab9c53 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Wed, 24 Sep 2025 09:16:21 -0700 Subject: [PATCH 39/44] Clean up unit conversion --- src/geophires_x/EconomicsSam.py | 23 +++++++++++-------- tests/geophires_x_tests/test_economics_sam.py | 4 +--- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index 88ce85574..19c3c0c30 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -413,7 +413,7 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]: ret['ppa_price_input'] = ppa_price_schedule_per_kWh if model.economics.royalty_rate.Provided: - ret['om_production'] = _get_royalties_variable_om_per_MWh_schedule(model) + ret['om_production'] = _get_royalties_variable_om_USD_per_MWh_schedule(model) # Debt/equity ratio ('Fraction of Investment in Bonds' parameter) ret['debt_percent'] = _pct(econ.FIB) @@ -436,9 +436,7 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]: return ret -def _get_royalties_variable_om_per_MWh_schedule(model: Model): - """TODO price unit in method name""" - +def _get_royalties_variable_om_USD_per_MWh_schedule(model: Model): royalty_rate_schedule = _get_royalty_rate_schedule(model) ppa_price_schedule_per_kWh = _get_ppa_price_schedule_per_kWh(model) @@ -446,12 +444,13 @@ def _get_royalties_variable_om_per_MWh_schedule(model: Model): # The royalty is a percentage of revenue (MWh * $/MWh). By setting the # variable O&M rate to (PPA Price * Royalty Rate), SAM's calculation # (Rate * MWh) will correctly yield the total royalty payment. - variable_om_schedule_per_MWh = [ - (price_kWh * 1000) * royalty_fraction # TODO use pint unit conversion instead + variable_om_schedule_USD_per_MWh = [ + quantity(price_kWh, model.economics.ElecStartPrice.CurrentUnits).to('USD / megawatt_hour').magnitude + * royalty_fraction for price_kWh, royalty_fraction in zip(ppa_price_schedule_per_kWh, royalty_rate_schedule) ] - return variable_om_schedule_per_MWh + return variable_om_schedule_USD_per_MWh def _get_fed_and_state_tax_rates(geophires_ctr_tenths: float) -> tuple[list[float]]: @@ -471,11 +470,13 @@ def _pct(econ_value: Parameter) -> float: return econ_value.quantity().to(convertible_unit('%')).magnitude -def _get_ppa_price_schedule_per_kWh(model: Model) -> list[float]: - """TODO price unit in method name""" +def _get_ppa_price_schedule_per_kWh(model: Model) -> list: + """ + :return: quantity list of PPA price schedule per kWh in econ.ElecStartPrice.CurrentUnits + """ econ = model.economics - return _ppa_pricing_model( + pricing_model = _ppa_pricing_model( model.surfaceplant.plant_lifetime.value, econ.ElecStartPrice.value, econ.ElecEndPrice.value, @@ -483,6 +484,8 @@ def _get_ppa_price_schedule_per_kWh(model: Model) -> list[float]: econ.ElecEscalationRate.value, ) + return [quantity(it, econ.ElecStartPrice.CurrentUnits).magnitude for it in pricing_model] + def _ppa_pricing_model( plant_lifetime: int, start_price: float, end_price: float, escalation_start_year: int, escalation_rate: float diff --git a/tests/geophires_x_tests/test_economics_sam.py b/tests/geophires_x_tests/test_economics_sam.py index 997b48482..c0d62f1d6 100644 --- a/tests/geophires_x_tests/test_economics_sam.py +++ b/tests/geophires_x_tests/test_economics_sam.py @@ -654,9 +654,7 @@ def get_row(name: str): ppa_revenue_row_USD = get_row('PPA revenue ($)') expected_royalties_USD = [x * royalty_rate for x in ppa_revenue_row_USD] - expected_royalties_MUSD = [x * 1e-6 for x in expected_royalties_USD] - - self.assertListEqual(expected_royalties_MUSD, sam_econ.royalties_opex.value) + self.assertListAlmostEqual(expected_royalties_USD, sam_econ.royalties_opex.value, places=0) om_prod_based_expense_row = get_row('O&M production-based expense ($)') self.assertListAlmostEqual(expected_royalties_USD, om_prod_based_expense_row, places=0) From 668c6fe4886da7d46ca94438317484cb1aab20a0 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Wed, 24 Sep 2025 09:18:00 -0700 Subject: [PATCH 40/44] =?UTF-8?q?Bump=20version:=203.9.59=20=E2=86=92=203.?= =?UTF-8?q?9.60?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- .cookiecutterrc | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- src/geophires_x/__init__.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index df30bc900..3eb934127 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.59 +current_version = 3.9.60 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 73b43f393..35897b004 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.9.59 + version: 3.9.60 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index c986c336d..f29172483 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.9.59.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.60.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.59...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.60...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 742064139..339505efa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ year = '2025' author = 'NREL' copyright = f'{year}, {author}' -version = release = '3.9.59' +version = release = '3.9.60' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index a71ef22ec..8d8fbb17d 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.59', + version='3.9.60', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index b7f665a20..60443e80c 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.59' +__version__ = '3.9.60' From fbc13010257d988765b0a8c51020d299d130ffb5 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Wed, 24 Sep 2025 09:44:03 -0700 Subject: [PATCH 41/44] Document no effective cap for max royalty rate. Add unit test to assert that royalty rate is not supported for non-SAM-EM --- src/geophires_x/Economics.py | 6 ++++-- .../geophires-request.json | 2 +- tests/test_geophires_x.py | 13 +++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index c358fcf1c..5624f166d 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -992,15 +992,17 @@ def __init__(self, model: Model): "increases a 4% rate (0.04) to 4.1% (0.041) in the next year." ) + maximum_royalty_rate_default_val = 1.0 self.maximum_royalty_rate = self.ParameterDict[self.maximum_royalty_rate.Name] = floatParameter( 'Royalty Rate Maximum', - DefaultValue=1.0, # Default to 100% (no effective cap) + DefaultValue=maximum_royalty_rate_default_val, Min=0.0, Max=1.0, UnitType=Units.PERCENT, PreferredUnits=PercentUnit.TENTH, CurrentUnits=PercentUnit.TENTH, - ToolTipText="The maximum royalty rate after escalation, expressed as a fraction (e.g., 0.06 for a 6% cap)." + ToolTipText=f"The maximum royalty rate after escalation, expressed as a fraction (e.g., 0.06 for a 6% cap)." + f"{' Defaults to 100% (no effective cap).' if maximum_royalty_rate_default_val == 1.0 else ''}" ) # TODO support custom royalty rate schedule as a list parameter diff --git a/src/geophires_x_schema_generator/geophires-request.json b/src/geophires_x_schema_generator/geophires-request.json index 70a75e79f..7c9ff1132 100644 --- a/src/geophires_x_schema_generator/geophires-request.json +++ b/src/geophires_x_schema_generator/geophires-request.json @@ -1657,7 +1657,7 @@ "maximum": 1.0 }, "Royalty Rate Maximum": { - "description": "The maximum royalty rate after escalation, expressed as a fraction (e.g., 0.06 for a 6% cap).", + "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", "units": "", "category": "Economics", diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index 5a607e456..2a82b4bd7 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -1457,3 +1457,16 @@ def test_royalty_rate_with_addon(self): addon_cash_flow = _cash_flow_profile_row(result.result['SAM CASH FLOW PROFILE'], 'Capacity payment revenue ($)') self.assertEqual(0, addon_cash_flow[0]) self.assertTrue(all(it == addon_profit_MUSD * 1e6 for it in addon_cash_flow[1:])) + + def test_royalty_rate_not_supported_for_non_sam_economic_models(self): + with self.assertRaises(RuntimeError) as re: + GeophiresXClient().get_geophires_result( + ImmutableGeophiresInputParameters( + from_file_path=self._get_test_file_path('examples/Fervo_Project_Cape-3.txt'), + params={ + 'Royalty Rate': 0.1, + }, + ) + ) + + self.assertIn('Royalties are only supported for SAM Economic Models', str(re.exception)) From c3c05d9cc520c651fcd3267362193906e0265002 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Wed, 24 Sep 2025 09:49:47 -0700 Subject: [PATCH 42/44] remove unused import --- src/geophires_x/EconomicsSam.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index 19c3c0c30..5f07136d8 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -37,7 +37,6 @@ moic_parameter, project_vir_parameter, project_payback_period_parameter, - inflation_cost_during_construction_output_parameter, total_capex_parameter_output_parameter, royalty_cost_output_parameter, ) From 5d30e2fc426a2050bd8e9c42a2aec6e2ba4f59e6 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Wed, 24 Sep 2025 09:56:30 -0700 Subject: [PATCH 43/44] Fix unit test. Remove unused import --- src/geophires_x/EconomicsSam.py | 2 +- tests/test_geophires_x.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index 5f07136d8..0a58b8be7 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -43,7 +43,7 @@ from geophires_x.GeoPHIRESUtils import is_float, is_int, sig_figs, quantity from geophires_x.OptionList import EconomicModel, EndUseOptions from geophires_x.Parameter import Parameter, OutputParameter, floatParameter -from geophires_x.Units import convertible_unit, EnergyCostUnit, CurrencyUnit, Units, CurrencyFrequencyUnit +from geophires_x.Units import convertible_unit, EnergyCostUnit, CurrencyUnit, Units @dataclass diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index 2a82b4bd7..65c4ed1b0 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -1452,7 +1452,7 @@ def test_royalty_rate_with_addon(self): ) ) - self.assertEqual(30, sig_figs(result.result['EXTENDED ECONOMICS']['Royalty Holder NPV']['value'], 1)) + self.assertEqual(50, sig_figs(result.result['EXTENDED ECONOMICS']['Royalty Holder NPV']['value'], 1)) addon_cash_flow = _cash_flow_profile_row(result.result['SAM CASH FLOW PROFILE'], 'Capacity payment revenue ($)') self.assertEqual(0, addon_cash_flow[0]) From 10568b8e483a21b3c2dac4374c7ee986a1bada1b Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Wed, 24 Sep 2025 10:17:10 -0700 Subject: [PATCH 44/44] =?UTF-8?q?Bump=20version:=203.9.60=20=E2=86=92=203.?= =?UTF-8?q?9.61?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- .cookiecutterrc | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- src/geophires_x/__init__.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 3eb934127..a37bc3143 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.60 +current_version = 3.9.61 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 35897b004..34c905982 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.9.60 + version: 3.9.61 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index f29172483..f1c11e3e7 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.9.60.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.61.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.60...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.61...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 339505efa..c1da9bc51 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ year = '2025' author = 'NREL' copyright = f'{year}, {author}' -version = release = '3.9.60' +version = release = '3.9.61' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index 8d8fbb17d..7950f745a 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.60', + version='3.9.61', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index 60443e80c..84ff97f7d 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.60' +__version__ = '3.9.61'