Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
b3cf5b5
Initial implementation of royalty rate. WIP - TODO to implement royal…
softwareengineerprogrammer Sep 15, 2025
59b6950
Add entry in SAM Economic Models docs
softwareengineerprogrammer Sep 15, 2025
842a2b0
Regenerate schema (with Royalty Rate)
softwareengineerprogrammer Sep 15, 2025
0a1dad9
Mark TODO remove or clarify project payback period: https://github.co…
softwareengineerprogrammer Sep 15, 2025
f071d90
Include royalties in opex output/total opex (WIP)
softwareengineerprogrammer Sep 15, 2025
2046f90
Regenerate schema with Royalties output param
softwareengineerprogrammer Sep 15, 2025
5cf8f45
Assert that opex line items, including royalties, sum up to total
softwareengineerprogrammer Sep 15, 2025
777b840
Bump version: 3.9.54 → 3.9.55
softwareengineerprogrammer Sep 15, 2025
e89a4e2
update unit test
softwareengineerprogrammer Sep 15, 2025
4215254
Change output param name to 'Average Annual Royalty Cost'
softwareengineerprogrammer Sep 15, 2025
44d87d0
Fix incorrect method of setting royalties output param value
softwareengineerprogrammer Sep 15, 2025
dab0994
Internally distinguish between SAM econ royalty cost time series and …
softwareengineerprogrammer Sep 15, 2025
e898ea4
WIP - calculate Royalty Holder NPV
softwareengineerprogrammer Sep 15, 2025
1ba8df4
assert royalty holder NPV
softwareengineerprogrammer Sep 15, 2025
4f53b33
finish impl of royalty holder outputs
softwareengineerprogrammer Sep 15, 2025
3ded083
Update documentation. Throw exception if royalty rate provided for no…
softwareengineerprogrammer Sep 15, 2025
281a74b
SAM-EM royalties documentation (parameter mapping + dedicated section)
softwareengineerprogrammer Sep 15, 2025
cfbf614
Bump version: 3.9.55 → 3.9.56
softwareengineerprogrammer Sep 15, 2025
7ced29b
Move royalty outputs to EXTENDED ECONOMICS
softwareengineerprogrammer Sep 16, 2025
890748b
Outputs py38 future annotations
softwareengineerprogrammer Sep 16, 2025
580e384
Royalty escalation rate + max (WIP to add unit tests, implement full …
softwareengineerprogrammer Sep 16, 2025
46eb832
Mark TODO support custom royalty rate schedule as a list parameter
softwareengineerprogrammer Sep 16, 2025
39213f1
test_royalty_rate_schedule
softwareengineerprogrammer Sep 16, 2025
291898d
move logic to Economics.get_royalty_rate_schedule
softwareengineerprogrammer Sep 16, 2025
bb37109
test_royalty_rate_escalation
softwareengineerprogrammer Sep 16, 2025
ee1efe8
Regenerate schema
softwareengineerprogrammer Sep 16, 2025
f60bcac
Update SAM-EM Royalties docs
softwareengineerprogrammer Sep 16, 2025
7f2f9f3
example_SAM-single-owner-PPA-4: 50 MWe with Royalties
softwareengineerprogrammer Sep 16, 2025
772a276
Bump version: 3.9.56 → 3.9.57
softwareengineerprogrammer Sep 16, 2025
148b88c
Add example_SAM-single-owner-PPA-4 to README examples table
softwareengineerprogrammer Sep 16, 2025
f97a527
SAM-EM doc web interface links
softwareengineerprogrammer Sep 16, 2025
87bb03d
Bump version: 3.9.57 → 3.9.58
softwareengineerprogrammer Sep 18, 2025
bcbe923
test_royalty_rate_with_addon
softwareengineerprogrammer Sep 18, 2025
8c0de3f
Clarify that royalty holder revenue/NPV are pre-tax
softwareengineerprogrammer Sep 18, 2025
9b87c45
regnerate schema
softwareengineerprogrammer Sep 18, 2025
d68322a
Bump version: 3.9.58 → 3.9.59
softwareengineerprogrammer Sep 18, 2025
78a3942
slice pre-revenue years based on construction years (instead of hardc…
softwareengineerprogrammer Sep 22, 2025
0da0340
Fix unit conversion issue that was causing incorrect royalty calculat…
softwareengineerprogrammer Sep 24, 2025
7ad9fe2
Clean up unit conversion
softwareengineerprogrammer Sep 24, 2025
668c6fe
Bump version: 3.9.59 → 3.9.60
softwareengineerprogrammer Sep 24, 2025
fbc1301
Document no effective cap for max royalty rate. Add unit test to asse…
softwareengineerprogrammer Sep 24, 2025
c3c05d9
remove unused import
softwareengineerprogrammer Sep 24, 2025
5d30e2f
Fix unit test. Remove unused import
softwareengineerprogrammer Sep 24, 2025
10568b8
Bump version: 3.9.60 → 3.9.61
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.61
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.61
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.61.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.61...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.61'

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.61',
license='MIT',
description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.',
long_description='{}\n{}'.format(
Expand Down
212 changes: 190 additions & 22 deletions src/geophires_x/Economics.py
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,60 @@ 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."
)

maximum_royalty_rate_default_val = 1.0
self.maximum_royalty_rate = self.ParameterDict[self.maximum_royalty_rate.Name] = floatParameter(
'Royalty Rate Maximum',
DefaultValue=maximum_royalty_rate_default_val,
Min=0.0,
Max=1.0,
UnitType=Units.PERCENT,
PreferredUnits=PercentUnit.TENTH,
CurrentUnits=PercentUnit.TENTH,
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
# (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 +1950,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 +2178,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 +2461,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 +2584,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 +3221,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 +3345,68 @@ 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:
# 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 = (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_annual_revenue.value = self.royalties_average_annual_cost.value

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
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