Skip to content

Commit d0548e6

Browse files
Merge pull request #107 from softwareengineerprogrammer/royalty-economics-3
Royalties [v3.9.61]
2 parents 973a581 + 10568b8 commit d0548e6

21 files changed

+1209
-71
lines changed

.bumpversion.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 3.9.54
2+
current_version = 3.9.61
33
commit = True
44
tag = True
55

.cookiecutterrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ default_context:
5454
sphinx_doctest: "no"
5555
sphinx_theme: "sphinx-py3doc-enhanced-theme"
5656
test_matrix_separate_coverage: "no"
57-
version: 3.9.54
57+
version: 3.9.61
5858
version_manager: "bump2version"
5959
website: "https://github.com/NREL"
6060
year_from: "2023"

README.rst

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,9 @@ Free software: `MIT license <LICENSE>`__
5858
:alt: Supported implementations
5959
:target: https://pypi.org/project/geophires-x
6060

61-
.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.54.svg
61+
.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.61.svg
6262
:alt: Commits since latest release
63-
:target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.54...main
63+
:target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.61...main
6464

6565
.. |docs| image:: https://readthedocs.org/projects/GEOPHIRES-X/badge/?style=flat
6666
:target: https://nrel.github.io/GEOPHIRES-X
@@ -308,10 +308,14 @@ Example-specific web interface deeplinks are listed in the Link column.
308308
- `example_SAM-single-owner-PPA-2.txt <tests/examples/example_SAM-single-owner-PPA-2.txt>`__
309309
- `.out <tests/examples/example_SAM-single-owner-PPA-2.out>`__
310310
- `link <https://gtp.scientificwebservices.com/geophires?geophires-example-id=example_SAM-single-owner-PPA-2>`__
311-
* - SAM Single Owner PPA: 50 MWe with Add-on
311+
* - SAM Single Owner PPA: 50 MWe with Add-ons
312312
- `example_SAM-single-owner-PPA-3.txt <tests/examples/example_SAM-single-owner-PPA-3.txt>`__
313313
- `.out <tests/examples/example_SAM-single-owner-PPA-3.out>`__
314314
- `link <https://gtp.scientificwebservices.com/geophires?geophires-example-id=example_SAM-single-owner-PPA-3>`__
315+
* - SAM Single Owner PPA: 50 MWe with Royalties
316+
- `example_SAM-single-owner-PPA-4.txt <tests/examples/example_SAM-single-owner-PPA-4.txt>`__
317+
- `.out <tests/examples/example_SAM-single-owner-PPA-4.out>`__
318+
- `link <https://gtp.scientificwebservices.com/geophires?geophires-example-id=example_SAM-single-owner-PPA-4>`__
315319
.. raw:: html
316320

317321
<embed>

docs/SAM-Economic-Models.md

Lines changed: 68 additions & 24 deletions
Large diffs are not rendered by default.

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
year = '2025'
1919
author = 'NREL'
2020
copyright = f'{year}, {author}'
21-
version = release = '3.9.54'
21+
version = release = '3.9.61'
2222

2323
pygments_style = 'trac'
2424
templates_path = ['./templates']

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def read(*names, **kwargs):
1313

1414
setup(
1515
name='geophires-x',
16-
version='3.9.54',
16+
version='3.9.61',
1717
license='MIT',
1818
description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.',
1919
long_description='{}\n{}'.format(

src/geophires_x/Economics.py

Lines changed: 190 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,60 @@ def __init__(self, model: Model):
967967
"will be automatically set to the same value."
968968
)
969969

970+
self.royalty_rate = self.ParameterDict[self.royalty_rate.Name] = floatParameter(
971+
'Royalty Rate',
972+
DefaultValue=0.,
973+
Min=0.0,
974+
Max=1.0,
975+
UnitType=Units.PERCENT,
976+
PreferredUnits=PercentUnit.TENTH,
977+
CurrentUnits=PercentUnit.TENTH,
978+
ToolTipText="The fraction of the project's gross annual revenue paid to the royalty holder. "
979+
"This is modeled as a variable production-based operating expense, reducing the developer's "
980+
"taxable income."
981+
)
982+
983+
self.royalty_escalation_rate = self.ParameterDict[self.royalty_escalation_rate.Name] = floatParameter(
984+
'Royalty Rate Escalation',
985+
DefaultValue=0.,
986+
Min=0.0,
987+
Max=1.0,
988+
UnitType=Units.PERCENT,
989+
PreferredUnits=PercentUnit.TENTH,
990+
CurrentUnits=PercentUnit.TENTH,
991+
ToolTipText="The additive amount the royalty rate increases each year. For example, a value of 0.001 "
992+
"increases a 4% rate (0.04) to 4.1% (0.041) in the next year."
993+
)
994+
995+
maximum_royalty_rate_default_val = 1.0
996+
self.maximum_royalty_rate = self.ParameterDict[self.maximum_royalty_rate.Name] = floatParameter(
997+
'Royalty Rate Maximum',
998+
DefaultValue=maximum_royalty_rate_default_val,
999+
Min=0.0,
1000+
Max=1.0,
1001+
UnitType=Units.PERCENT,
1002+
PreferredUnits=PercentUnit.TENTH,
1003+
CurrentUnits=PercentUnit.TENTH,
1004+
ToolTipText=f"The maximum royalty rate after escalation, expressed as a fraction (e.g., 0.06 for a 6% cap)."
1005+
f"{' Defaults to 100% (no effective cap).' if maximum_royalty_rate_default_val == 1.0 else ''}"
1006+
)
1007+
1008+
# TODO support custom royalty rate schedule as a list parameter
1009+
# (as an alternative to specifying rate/escalation/max)
1010+
1011+
self.royalty_holder_discount_rate = self.ParameterDict[self.royalty_holder_discount_rate.Name] = floatParameter(
1012+
'Royalty Holder Discount Rate',
1013+
DefaultValue=0.05,
1014+
Min=0.0,
1015+
Max=1.0,
1016+
UnitType=Units.PERCENT,
1017+
PreferredUnits=PercentUnit.TENTH,
1018+
CurrentUnits=PercentUnit.TENTH,
1019+
ToolTipText="The discount rate used to calculate the Net Present Value (NPV) of the royalty holder's "
1020+
"income stream. This rate should reflect the royalty holder's specific risk profile and is "
1021+
"separate from the main project discount rate."
1022+
)
1023+
9701024

9711025
self.discount_initial_year_cashflow = self.ParameterDict[self.discount_initial_year_cashflow.Name] = boolParameter(
9721026
'Discount Initial Year Cashflow',
@@ -1896,6 +1950,15 @@ def __init__(self, model: Model):
18961950
PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR,
18971951
CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR
18981952
)
1953+
self.royalties_average_annual_cost = self.OutputParameterDict[self.royalties_average_annual_cost.Name] = OutputParameter(
1954+
Name='Average Annual Royalty Cost',
1955+
UnitType=Units.CURRENCYFREQUENCY,
1956+
PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR,
1957+
CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR,
1958+
ToolTipText='The average annual cost paid to a royalty holder, calculated as a percentage of the '
1959+
'project\'s gross annual revenue. This is modeled as a variable operating expense.'
1960+
)
1961+
18991962

19001963
# district heating
19011964
self.peakingboilercost = self.OutputParameterDict[self.peakingboilercost.Name] = OutputParameter(
@@ -2115,6 +2178,37 @@ def __init__(self, model: Model):
21152178
UnitType=Units.NONE,
21162179
)
21172180

2181+
# Results for the Royalty Holder
2182+
self.royalty_holder_npv = self.OutputParameterDict[self.royalty_holder_npv.Name] = OutputParameter(
2183+
'Royalty Holder NPV',
2184+
UnitType=Units.CURRENCY,
2185+
PreferredUnits=CurrencyUnit.MDOLLARS,
2186+
CurrentUnits=CurrencyUnit.MDOLLARS,
2187+
ToolTipText=f"The pre-tax Net Present Value (NPV) of the royalty holder's income stream, "
2188+
f"calculated using the {self.royalty_holder_discount_rate.Name}. "
2189+
f"This is a pre-tax value because the model does not account for the royalty holder's specific "
2190+
f"tax liabilities."
2191+
)
2192+
self.royalty_holder_annual_revenue = self.OutputParameterDict[
2193+
self.royalty_holder_annual_revenue.Name
2194+
] = OutputParameter(
2195+
'Royalty Holder Average Annual Revenue',
2196+
UnitType=Units.CURRENCYFREQUENCY,
2197+
PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR,
2198+
CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR,
2199+
ToolTipText="The royalty holder's gross (pre-tax) annual revenue stream from the royalty agreement."
2200+
)
2201+
self.royalty_holder_total_revenue = self.OutputParameterDict[
2202+
self.royalty_holder_total_revenue.Name
2203+
] = OutputParameter(
2204+
'Royalty Holder Total Revenue',
2205+
UnitType=Units.CURRENCY,
2206+
PreferredUnits=CurrencyUnit.MDOLLARS,
2207+
CurrentUnits=CurrencyUnit.MDOLLARS,
2208+
ToolTipText='The total gross (pre-tax), undiscounted revenue received by the royalty holder over the '
2209+
'project lifetime.'
2210+
)
2211+
21182212
model.logger.info(f'Complete {__class__!s}: {sys._getframe().f_code.co_name}')
21192213

21202214
def read_parameters(self, model: Model) -> None:
@@ -2367,6 +2461,11 @@ def _warn(_msg: str) -> None:
23672461

23682462
if self.econmodel.value == EconomicModel.SAM_SINGLE_OWNER_PPA:
23692463
EconomicsSam.validate_read_parameters(model)
2464+
else:
2465+
if self.royalty_rate.Provided:
2466+
raise NotImplementedError('Royalties are only supported for SAM Economic Models')
2467+
2468+
# TODO validate that other SAM-EM-only parameters have not been provided
23702469
else:
23712470
model.logger.info("No parameters read because no content provided")
23722471

@@ -2485,29 +2584,8 @@ def Calculate(self, model: Model) -> None:
24852584
self.discount_initial_year_cashflow.value
24862585
)
24872586

2488-
non_calculated_output_placeholder_val = -1
24892587
if self.econmodel.value == EconomicModel.SAM_SINGLE_OWNER_PPA:
2490-
self.sam_economics_calculations = calculate_sam_economics(model)
2491-
2492-
# Setting capex_total distinguishes capex from CCap's display name of 'Total capital costs',
2493-
# since SAM Economic Model doesn't subtract ITC from this value.
2494-
self.capex_total.value = (self.sam_economics_calculations.capex.quantity()
2495-
.to(self.capex_total.CurrentUnits.value).magnitude)
2496-
self.CCap.value = (self.sam_economics_calculations.capex.quantity()
2497-
.to(self.CCap.CurrentUnits.value).magnitude)
2498-
2499-
self.wacc.value = self.sam_economics_calculations.wacc.value
2500-
self.nominal_discount_rate.value = self.sam_economics_calculations.nominal_discount_rate.value
2501-
self.ProjectNPV.value = self.sam_economics_calculations.project_npv.quantity().to(
2502-
convertible_unit(self.ProjectNPV.CurrentUnits)).magnitude
2503-
2504-
self.ProjectIRR.value = non_calculated_output_placeholder_val # SAM calculates After-Tax IRR instead
2505-
self.after_tax_irr.value = self.sam_economics_calculations.after_tax_irr.quantity().to(
2506-
convertible_unit(self.ProjectIRR.CurrentUnits)).magnitude
2507-
2508-
self.ProjectMOIC.value = self.sam_economics_calculations.moic.value
2509-
self.ProjectVIR.value = self.sam_economics_calculations.project_vir.value
2510-
self.ProjectPaybackPeriod.value = self.sam_economics_calculations.project_payback_period.value
2588+
self._calculate_sam_economics(model)
25112589

25122590
# Calculate the project payback period
25132591
if self.econmodel.value != EconomicModel.SAM_SINGLE_OWNER_PPA:
@@ -3143,6 +3221,34 @@ def build_price_models(self, model: Model) -> None:
31433221
self.CarbonEscalationStart.value, self.CarbonEscalationRate.value,
31443222
self.PTCCarbonPrice)
31453223

3224+
def get_royalty_rate_schedule(self, model: Model) -> list[float]:
3225+
"""
3226+
Builds a year-by-year schedule of royalty rates based on escalation and cap.
3227+
3228+
:type model: :class:`~geophires_x.Model.Model`
3229+
:return: schedule: A list of rates as fractions (e.g., 0.05 for 5%).
3230+
"""
3231+
3232+
def r(x: float) -> float:
3233+
"""Ignore apparent float precision issue"""
3234+
_precision = 8
3235+
return round(x, _precision)
3236+
3237+
plant_lifetime = model.surfaceplant.plant_lifetime.value
3238+
3239+
escalation_rate = r(self.royalty_escalation_rate.value)
3240+
max_rate = r(self.maximum_royalty_rate.value)
3241+
3242+
schedule = []
3243+
current_rate = r(self.royalty_rate.value)
3244+
for _ in range(plant_lifetime):
3245+
current_rate = r(current_rate)
3246+
schedule.append(min(current_rate, max_rate))
3247+
current_rate += escalation_rate
3248+
3249+
return schedule
3250+
3251+
31463252
def calculate_cashflow(self, model: Model) -> None:
31473253
"""
31483254
Calculate cashflow and cumulative cash flow
@@ -3239,6 +3345,68 @@ def calculate_cashflow(self, model: Model) -> None:
32393345
for i in range(1, model.surfaceplant.plant_lifetime.value + model.surfaceplant.construction_years.value, 1):
32403346
self.TotalCummRevenue.value[i] = self.TotalCummRevenue.value[i-1] + self.TotalRevenue.value[i]
32413347

3348+
def _calculate_sam_economics(self, model: Model) -> None:
3349+
non_calculated_output_placeholder_val = -1
3350+
self.sam_economics_calculations = calculate_sam_economics(model)
3351+
3352+
# Setting capex_total distinguishes capex from CCap's display name of 'Total capital costs',
3353+
# since SAM Economic Model doesn't subtract ITC from this value.
3354+
self.capex_total.value = (self.sam_economics_calculations.capex.quantity()
3355+
.to(self.capex_total.CurrentUnits.value).magnitude)
3356+
self.CCap.value = (self.sam_economics_calculations.capex.quantity()
3357+
.to(self.CCap.CurrentUnits.value).magnitude)
3358+
3359+
3360+
if self.royalty_rate.Provided:
3361+
# ignore pre-revenue year(s) (e.g. Year 0)
3362+
pre_revenue_years_slice_index = model.surfaceplant.construction_years.value
3363+
3364+
average_annual_royalties = np.average(
3365+
self.sam_economics_calculations.royalties_opex.value[pre_revenue_years_slice_index:]
3366+
)
3367+
3368+
self.royalties_average_annual_cost.value = (quantity(
3369+
average_annual_royalties,
3370+
self.sam_economics_calculations.royalties_opex.CurrentUnits
3371+
).to(self.royalties_average_annual_cost.CurrentUnits).magnitude)
3372+
3373+
self.Coam.value += (self.royalties_average_annual_cost.quantity()
3374+
.to(self.Coam.CurrentUnits.value).magnitude)
3375+
3376+
self.royalty_holder_npv.value = quantity(
3377+
calculate_npv(
3378+
self.royalty_holder_discount_rate.value,
3379+
self.sam_economics_calculations.royalties_opex.value,
3380+
self.discount_initial_year_cashflow.value
3381+
),
3382+
self.sam_economics_calculations.royalties_opex.CurrentUnits.get_currency_unit_str()
3383+
).to(self.royalty_holder_npv.CurrentUnits).magnitude
3384+
3385+
self.royalty_holder_annual_revenue.value = self.royalties_average_annual_cost.value
3386+
3387+
self.royalty_holder_total_revenue.value = quantity(
3388+
np.sum(
3389+
self.sam_economics_calculations.royalties_opex.value[pre_revenue_years_slice_index:]
3390+
),
3391+
self.sam_economics_calculations.royalties_opex.CurrentUnits.get_currency_unit_str()
3392+
).to(self.royalty_holder_total_revenue.CurrentUnits).magnitude
3393+
3394+
3395+
self.wacc.value = self.sam_economics_calculations.wacc.value
3396+
self.nominal_discount_rate.value = self.sam_economics_calculations.nominal_discount_rate.value
3397+
self.ProjectNPV.value = self.sam_economics_calculations.project_npv.quantity().to(
3398+
convertible_unit(self.ProjectNPV.CurrentUnits)).magnitude
3399+
3400+
self.ProjectIRR.value = non_calculated_output_placeholder_val # SAM calculates After-Tax IRR instead
3401+
self.after_tax_irr.value = self.sam_economics_calculations.after_tax_irr.quantity().to(
3402+
convertible_unit(self.ProjectIRR.CurrentUnits)).magnitude
3403+
3404+
self.ProjectMOIC.value = self.sam_economics_calculations.moic.value
3405+
self.ProjectVIR.value = self.sam_economics_calculations.project_vir.value
3406+
3407+
# TODO remove or clarify project payback period: https://github.com/NREL/GEOPHIRES-X/issues/413
3408+
self.ProjectPaybackPeriod.value = self.sam_economics_calculations.project_payback_period.value
3409+
32423410
# noinspection SpellCheckingInspection
32433411
def _calculate_derived_outputs(self, model: Model) -> None:
32443412
"""

0 commit comments

Comments
 (0)