Skip to content
Closed
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
4146e30
Initial implementation of royalty rate. WIP - TODO to implement royal…
softwareengineerprogrammer Sep 15, 2025
0b45fbe
Add entry in SAM Economic Models docs
softwareengineerprogrammer Sep 15, 2025
a20d3e4
Regenerate schema (with Royalty Rate)
softwareengineerprogrammer Sep 15, 2025
e175e95
Mark TODO remove or clarify project payback period: https://github.co…
softwareengineerprogrammer Sep 15, 2025
94b8873
Include royalties in opex output/total opex (WIP)
softwareengineerprogrammer Sep 15, 2025
59476a0
Regenerate schema with Royalties output param
softwareengineerprogrammer Sep 15, 2025
e0617fb
Assert that opex line items, including royalties, sum up to total
softwareengineerprogrammer Sep 15, 2025
0aed3c2
Bump version: 3.9.54 → 3.9.55
softwareengineerprogrammer Sep 15, 2025
883ec0c
update unit test
softwareengineerprogrammer Sep 15, 2025
a8defad
Change output param name to 'Average Annual Royalty Cost'
softwareengineerprogrammer Sep 15, 2025
8459931
Fix incorrect method of setting royalties output param value
softwareengineerprogrammer Sep 15, 2025
39dbf39
Internally distinguish between SAM econ royalty cost time series and …
softwareengineerprogrammer Sep 15, 2025
a102aa4
WIP - calculate Royalty Holder NPV
softwareengineerprogrammer Sep 15, 2025
af65dcb
assert royalty holder NPV
softwareengineerprogrammer Sep 15, 2025
1c1deeb
finish impl of royalty holder outputs
softwareengineerprogrammer Sep 15, 2025
3b8ce49
Update documentation. Throw exception if royalty rate provided for no…
softwareengineerprogrammer Sep 15, 2025
f5e5b35
SAM-EM royalties documentation (parameter mapping + dedicated section)
softwareengineerprogrammer Sep 15, 2025
969f547
Bump version: 3.9.55 → 3.9.56
softwareengineerprogrammer Sep 15, 2025
c583cec
Move royalty outputs to EXTENDED ECONOMICS
softwareengineerprogrammer Sep 16, 2025
5dd16d2
Outputs py38 future annotations
softwareengineerprogrammer Sep 16, 2025
c5fe65b
Royalty escalation rate + max (WIP to add unit tests, implement full …
softwareengineerprogrammer Sep 16, 2025
71116b4
Mark TODO support custom royalty rate schedule as a list parameter
softwareengineerprogrammer Sep 16, 2025
fdddcd9
test_royalty_rate_schedule
softwareengineerprogrammer Sep 16, 2025
4cac77b
move logic to Economics.get_royalty_rate_schedule
softwareengineerprogrammer Sep 16, 2025
85a323e
test_royalty_rate_escalation
softwareengineerprogrammer Sep 16, 2025
63ea5b9
Regenerate schema
softwareengineerprogrammer Sep 16, 2025
5dfcbc1
Update SAM-EM Royalties docs
softwareengineerprogrammer Sep 16, 2025
96d0ce4
example_SAM-single-owner-PPA-4: 50 MWe with Royalties
softwareengineerprogrammer Sep 16, 2025
80c255c
Bump version: 3.9.56 → 3.9.57
softwareengineerprogrammer Sep 16, 2025
eaa9eb9
Add example_SAM-single-owner-PPA-4 to README examples table
softwareengineerprogrammer Sep 16, 2025
2005c94
SAM-EM doc web interface links
softwareengineerprogrammer Sep 16, 2025
50e6f97
Bump version: 3.9.57 → 3.9.58
softwareengineerprogrammer Sep 18, 2025
a782cd7
test_royalty_rate_with_addon
softwareengineerprogrammer Sep 18, 2025
a0d0c38
Clarify that royalty holder revenue/NPV are pre-tax
softwareengineerprogrammer Sep 18, 2025
b3ffad8
regnerate schema
softwareengineerprogrammer Sep 18, 2025
b23a6fe
Bump version: 3.9.58 → 3.9.59
softwareengineerprogrammer Sep 18, 2025
a60199c
slice pre-revenue years based on construction years (instead of hardc…
softwareengineerprogrammer Sep 22, 2025
8b2d3af
Fix unit conversion issue that was causing incorrect royalty calculat…
softwareengineerprogrammer Sep 24, 2025
b1752e6
Clean up unit conversion
softwareengineerprogrammer Sep 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 3.9.54
current_version = 3.9.59
commit = True
tag = True

Expand Down
2 changes: 1 addition & 1 deletion .cookiecutterrc
Original file line number Diff line number Diff line change
Expand Up @@ -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.59
version_manager: "bump2version"
website: "https://github.com/NREL"
year_from: "2023"
Expand Down
10 changes: 7 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@ Free software: `MIT license <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.59.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.59...main

.. |docs| image:: https://readthedocs.org/projects/GEOPHIRES-X/badge/?style=flat
:target: https://nrel.github.io/GEOPHIRES-X
Expand Down Expand Up @@ -308,10 +308,14 @@ Example-specific web interface deeplinks are listed in the Link column.
- `example_SAM-single-owner-PPA-2.txt <tests/examples/example_SAM-single-owner-PPA-2.txt>`__
- `.out <tests/examples/example_SAM-single-owner-PPA-2.out>`__
- `link <https://gtp.scientificwebservices.com/geophires?geophires-example-id=example_SAM-single-owner-PPA-2>`__
* - 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 <tests/examples/example_SAM-single-owner-PPA-3.txt>`__
- `.out <tests/examples/example_SAM-single-owner-PPA-3.out>`__
- `link <https://gtp.scientificwebservices.com/geophires?geophires-example-id=example_SAM-single-owner-PPA-3>`__
* - SAM Single Owner PPA: 50 MWe with Royalties
- `example_SAM-single-owner-PPA-4.txt <tests/examples/example_SAM-single-owner-PPA-4.txt>`__
- `.out <tests/examples/example_SAM-single-owner-PPA-4.out>`__
- `link <https://gtp.scientificwebservices.com/geophires?geophires-example-id=example_SAM-single-owner-PPA-4>`__
.. raw:: html

<embed>
Expand Down
92 changes: 68 additions & 24 deletions docs/SAM-Economic-Models.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
year = '2025'
author = 'NREL'
copyright = f'{year}, {author}'
version = release = '3.9.54'
version = release = '3.9.59'

pygments_style = 'trac'
templates_path = ['./templates']
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def read(*names, **kwargs):

setup(
name='geophires-x',
version='3.9.54',
version='3.9.59',
license='MIT',
description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.',
long_description='{}\n{}'.format(
Expand Down
194 changes: 172 additions & 22 deletions src/geophires_x/Economics.py
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,58 @@ 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="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 Rate Escalation',
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(
'Royalty Rate Maximum',
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)."
)

# 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',
DefaultValue=0.05,
Min=0.0,
Max=1.0,
UnitType=Units.PERCENT,
PreferredUnits=PercentUnit.TENTH,
CurrentUnits=PercentUnit.TENTH,
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."
)


self.discount_initial_year_cashflow = self.ParameterDict[self.discount_initial_year_cashflow.Name] = boolParameter(
'Discount Initial Year Cashflow',
Expand Down Expand Up @@ -1896,6 +1948,15 @@ def __init__(self, model: Model):
PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR,
CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR
)
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(
Expand Down Expand Up @@ -2115,6 +2176,37 @@ 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=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
] = OutputParameter(
'Royalty Holder Average Annual Revenue',
UnitType=Units.CURRENCYFREQUENCY,
PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR,
CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR,
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
] = OutputParameter(
'Royalty Holder Total Revenue',
UnitType=Units.CURRENCY,
PreferredUnits=CurrencyUnit.MDOLLARS,
CurrentUnits=CurrencyUnit.MDOLLARS,
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}')

def read_parameters(self, model: Model) -> None:
Expand Down Expand Up @@ -2367,6 +2459,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")

Expand Down Expand Up @@ -2485,29 +2582,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)

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
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:
Expand Down Expand Up @@ -3143,6 +3219,34 @@ 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%).
"""

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 = r(self.royalty_escalation_rate.value)
max_rate = r(self.maximum_royalty_rate.value)

schedule = []
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

return schedule


def calculate_cashflow(self, model: Model) -> None:
"""
Calculate cashflow and cumulative cash flow
Expand Down Expand Up @@ -3239,6 +3343,52 @@ 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( # TODO unit conversion
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
)
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
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:
"""
Expand Down
Loading
Loading