Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
875f598
ignore ai.md
softwareengineerprogrammer Sep 15, 2025
e3b92c9
Initial implementation of royalty rate. WIP - TODO to implement royal…
softwareengineerprogrammer Sep 15, 2025
86af5fd
Add entry in SAM Economic Models docs
softwareengineerprogrammer Sep 15, 2025
1dc6dcb
Regenerate schema (with Royalty Rate)
softwareengineerprogrammer Sep 15, 2025
b5191f2
Mark TODO remove or clarify project payback period: https://github.co…
softwareengineerprogrammer Sep 15, 2025
784bccc
Include royalties in opex output/total opex (WIP)
softwareengineerprogrammer Sep 15, 2025
11c3314
Regenerate schema with Royalties output param
softwareengineerprogrammer Sep 15, 2025
8af6e17
Assert that opex line items, including royalties, sum up to total
softwareengineerprogrammer Sep 15, 2025
a999d09
Bump version: 3.9.54 → 3.9.55
softwareengineerprogrammer Sep 15, 2025
5c989e9
update unit test
softwareengineerprogrammer Sep 15, 2025
274ca28
Change output param name to 'Average Annual Royalty Cost'
softwareengineerprogrammer Sep 15, 2025
ef7ac09
Fix incorrect method of setting royalties output param value
softwareengineerprogrammer Sep 15, 2025
97203de
Internally distinguish between SAM econ royalty cost time series and …
softwareengineerprogrammer Sep 15, 2025
a002381
WIP - calculate Royalty Holder NPV
softwareengineerprogrammer Sep 15, 2025
0929835
assert royalty holder NPV
softwareengineerprogrammer Sep 15, 2025
4adc2a1
finish impl of royalty holder outputs
softwareengineerprogrammer Sep 15, 2025
7dd1dbe
Update documentation. Throw exception if royalty rate provided for no…
softwareengineerprogrammer Sep 15, 2025
2a522cc
SAM-EM royalties documentation (parameter mapping + dedicated section)
softwareengineerprogrammer Sep 15, 2025
ecab0f7
Bump version: 3.9.55 → 3.9.56
softwareengineerprogrammer Sep 15, 2025
1847b65
Move royalty outputs to EXTENDED ECONOMICS
softwareengineerprogrammer Sep 16, 2025
7c978a9
Outputs py38 future annotations
softwareengineerprogrammer Sep 16, 2025
367fedd
Royalty escalation rate + max (WIP to add unit tests, implement full …
softwareengineerprogrammer Sep 16, 2025
ad513d3
Mark TODO support custom royalty rate schedule as a list parameter
softwareengineerprogrammer Sep 16, 2025
030c17b
test_royalty_rate_schedule
softwareengineerprogrammer Sep 16, 2025
b9a3322
move logic to Economics.get_royalty_rate_schedule
softwareengineerprogrammer Sep 16, 2025
7a3cd96
test_royalty_rate_escalation
softwareengineerprogrammer Sep 16, 2025
2c70fb1
Regenerate schema
softwareengineerprogrammer Sep 16, 2025
f19a2cc
Merge branch 'royalty-rate-schedule' into royalty-economics
softwareengineerprogrammer Sep 16, 2025
4f42e5d
Update SAM-EM Royalties docs
softwareengineerprogrammer Sep 16, 2025
e7dac29
example_SAM-single-owner-PPA-4: 50 MWe with Royalties
softwareengineerprogrammer Sep 16, 2025
e7b2af7
Bump version: 3.9.56 → 3.9.57
softwareengineerprogrammer Sep 16, 2025
eccd838
Add example_SAM-single-owner-PPA-4 to README examples table
softwareengineerprogrammer Sep 16, 2025
7a5767b
SAM-EM doc web interface links
softwareengineerprogrammer Sep 16, 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.57
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.57
version_manager: "bump2version"
website: "https://github.com/NREL"
year_from: "2023"
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ nosetests.xml
.pydevproject
.vscode

ai.md

# Complexity
output/*.html
output/*/index.html
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.57.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.57...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
91 changes: 67 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.57'

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.57',
license='MIT',
description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.',
long_description='{}\n{}'.format(
Expand Down
190 changes: 168 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,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 Average 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:
Expand Down Expand Up @@ -2367,6 +2455,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 +2578,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 +3215,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 +3339,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