Skip to content

Commit f19a2cc

Browse files
Merge branch 'royalty-rate-schedule' into royalty-economics
2 parents 7c978a9 + 2c70fb1 commit f19a2cc

File tree

5 files changed

+191
-7
lines changed

5 files changed

+191
-7
lines changed

src/geophires_x/Economics.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -975,11 +975,37 @@ def __init__(self, model: Model):
975975
UnitType=Units.PERCENT,
976976
PreferredUnits=PercentUnit.TENTH,
977977
CurrentUnits=PercentUnit.TENTH,
978-
ToolTipText="The percentage of the project's gross annual revenue paid to the royalty holder. "
978+
ToolTipText="The fraction of the project's gross annual revenue paid to the royalty holder. "
979979
"This is modeled as a variable production-based operating expense, reducing the developer's "
980980
"taxable income."
981981
)
982982

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+
self.maximum_royalty_rate = self.ParameterDict[self.maximum_royalty_rate.Name] = floatParameter(
996+
'Royalty Rate Maximum',
997+
DefaultValue=1.0, # Default to 100% (no effective cap)
998+
Min=0.0,
999+
Max=1.0,
1000+
UnitType=Units.PERCENT,
1001+
PreferredUnits=PercentUnit.TENTH,
1002+
CurrentUnits=PercentUnit.TENTH,
1003+
ToolTipText="The maximum royalty rate after escalation, expressed as a fraction (e.g., 0.06 for a 6% cap)."
1004+
)
1005+
1006+
# TODO support custom royalty rate schedule as a list parameter
1007+
# (as an alternative to specifying rate/escalation/max)
1008+
9831009
self.royalty_holder_discount_rate = self.ParameterDict[self.royalty_holder_discount_rate.Name] = floatParameter(
9841010
'Royalty Holder Discount Rate',
9851011
DefaultValue=0.05,
@@ -3189,6 +3215,34 @@ def build_price_models(self, model: Model) -> None:
31893215
self.CarbonEscalationStart.value, self.CarbonEscalationRate.value,
31903216
self.PTCCarbonPrice)
31913217

3218+
def get_royalty_rate_schedule(self, model: Model) -> list[float]:
3219+
"""
3220+
Builds a year-by-year schedule of royalty rates based on escalation and cap.
3221+
3222+
:type model: :class:`~geophires_x.Model.Model`
3223+
:return: schedule: A list of rates as fractions (e.g., 0.05 for 5%).
3224+
"""
3225+
3226+
def r(x: float) -> float:
3227+
"""Ignore apparent float precision issue"""
3228+
_precision = 8
3229+
return round(x, _precision)
3230+
3231+
plant_lifetime = model.surfaceplant.plant_lifetime.value
3232+
3233+
escalation_rate = r(self.royalty_escalation_rate.value)
3234+
max_rate = r(self.maximum_royalty_rate.value)
3235+
3236+
schedule = []
3237+
current_rate = r(self.royalty_rate.value)
3238+
for _ in range(plant_lifetime):
3239+
current_rate = r(current_rate)
3240+
schedule.append(min(current_rate, max_rate))
3241+
current_rate += escalation_rate
3242+
3243+
return schedule
3244+
3245+
31923246
def calculate_cashflow(self, model: Model) -> None:
31933247
"""
31943248
Calculate cashflow and cumulative cash flow

src/geophires_x/EconomicsSam.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -417,16 +417,15 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]:
417417
)
418418
ret['ppa_price_input'] = ppa_price_schedule_per_kWh
419419

420-
if hasattr(econ, 'royalty_rate') and econ.royalty_rate.Provided:
421-
royalty_rate_fraction = econ.royalty_rate.quantity().to(convertible_unit('dimensionless')).magnitude
422-
420+
royalty_rate_schedule = _get_royalty_rate_schedule(model)
421+
if model.economics.royalty_rate.Provided:
423422
# For each year, calculate the royalty as a $/MWh variable cost.
424423
# The royalty is a percentage of revenue (MWh * $/MWh). By setting the
425424
# variable O&M rate to (PPA Price * Royalty Rate), SAM's calculation
426425
# (Rate * MWh) will correctly yield the total royalty payment.
427426
variable_om_schedule_per_MWh = [
428-
(price_per_kWh * 1000) * royalty_rate_fraction # TODO pint unit conversion (kWh -> MWh)
429-
for price_per_kWh in ppa_price_schedule_per_kWh
427+
(price_kwh * 1000) * royalty_fraction # TODO use pint unit conversion instead
428+
for price_kwh, royalty_fraction in zip(ppa_price_schedule_per_kWh, royalty_rate_schedule)
430429
]
431430

432431
# The PySAM parameter for variable operating cost in $/MWh is 'om_production'.
@@ -481,5 +480,9 @@ def _ppa_pricing_model(
481480
)
482481

483482

483+
def _get_royalty_rate_schedule(model: Model) -> list[float]:
484+
return model.economics.get_royalty_rate_schedule(model)
485+
486+
484487
def _get_max_total_generation_kW(model: Model) -> float:
485488
return np.max(model.surfaceplant.ElectricityProduced.quantity().to(convertible_unit('kW')).magnitude)

src/geophires_x_schema_generator/geophires-request.json

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1639,14 +1639,32 @@
16391639
"maximum": 1.0
16401640
},
16411641
"Royalty Rate": {
1642-
"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.",
1642+
"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.",
16431643
"type": "number",
16441644
"units": "",
16451645
"category": "Economics",
16461646
"default": 0.0,
16471647
"minimum": 0.0,
16481648
"maximum": 1.0
16491649
},
1650+
"Royalty Rate Escalation": {
1651+
"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.",
1652+
"type": "number",
1653+
"units": "",
1654+
"category": "Economics",
1655+
"default": 0.0,
1656+
"minimum": 0.0,
1657+
"maximum": 1.0
1658+
},
1659+
"Royalty Rate Maximum": {
1660+
"description": "The maximum royalty rate after escalation, expressed as a fraction (e.g., 0.06 for a 6% cap).",
1661+
"type": "number",
1662+
"units": "",
1663+
"category": "Economics",
1664+
"default": 1.0,
1665+
"minimum": 0.0,
1666+
"maximum": 1.0
1667+
},
16501668
"Royalty Holder Discount Rate": {
16511669
"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.",
16521670
"type": "number",

tests/geophires_x_tests/test_economics_sam.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
_ppa_pricing_model,
2222
_get_fed_and_state_tax_rates,
2323
SamEconomicsCalculations,
24+
_get_royalty_rate_schedule,
2425
)
2526
from geophires_x.GeoPHIRESUtils import sig_figs, quantity
2627

@@ -662,6 +663,48 @@ def get_row(name: str):
662663
# Note the above assertion assumes royalties are the only production-based O&M expenses. If this changes,
663664
# the assertion will need to be updated.
664665

666+
def test_royalty_rate_schedule(self):
667+
royalty_rate = 0.1
668+
escalation_rate = 0.01
669+
max_rate = royalty_rate + 5 * escalation_rate
670+
m: Model = EconomicsSamTestCase._new_model(
671+
self._egs_test_file_path(),
672+
additional_params={
673+
'Royalty Rate': royalty_rate,
674+
'Royalty Rate Escalation': escalation_rate,
675+
'Royalty Rate Maximum': max_rate,
676+
},
677+
)
678+
679+
schedule: list[float] = _get_royalty_rate_schedule(m)
680+
681+
self.assertListAlmostEqual(
682+
[
683+
0.1,
684+
0.11,
685+
0.12,
686+
0.13,
687+
0.14,
688+
0.15,
689+
0.15,
690+
0.15,
691+
0.15,
692+
0.15,
693+
0.15,
694+
0.15,
695+
0.15,
696+
0.15,
697+
0.15,
698+
0.15,
699+
0.15,
700+
0.15,
701+
0.15,
702+
0.15,
703+
],
704+
schedule,
705+
places=3,
706+
)
707+
665708
@staticmethod
666709
def _new_model(input_file: Path, additional_params: dict[str, Any] | None = None, read_and_calculate=True) -> Model:
667710
if additional_params is not None:

tests/test_geophires_x.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,6 +1309,7 @@ def test_redrilling_costs(self):
13091309
def test_royalty_rate(self):
13101310
royalties_output_name = 'Average Annual Royalty Cost'
13111311

1312+
zero_royalty_npv = None
13121313
for royalty_rate in [0, 0.1]:
13131314
result = GeophiresXClient().get_geophires_result(
13141315
ImmutableGeophiresInputParameters(
@@ -1345,6 +1346,9 @@ def test_royalty_rate(self):
13451346
self.assertEqual(58.88, opex_result[royalties_output_name]['value'])
13461347
self.assertGreater(royalty_holder_npv_MUSD, 0)
13471348

1349+
# Owner NPV is lower when royalty rate is non-zero
1350+
self.assertGreater(zero_royalty_npv, result.result['ECONOMIC PARAMETERS']['Project NPV']['value'])
1351+
13481352
royalties_cash_flow_MUSD = [
13491353
it * 1e-6
13501354
for it in _cash_flow_profile_row(
@@ -1362,3 +1366,65 @@ def test_royalty_rate(self):
13621366
if royalty_rate == 0.0:
13631367
self.assertEqual(0, opex_result[royalties_output_name]['value'])
13641368
self.assertEqual(0, royalty_holder_npv_MUSD)
1369+
zero_royalty_npv = result.result['ECONOMIC PARAMETERS']['Project NPV']['value']
1370+
1371+
def test_royalty_rate_escalation(self):
1372+
royalties_output_name = 'Average Annual Royalty Cost'
1373+
1374+
base_royalty_rate = 0.05
1375+
escalation_rate = 0.01
1376+
1377+
for max_rate in [0.08, 1.0]:
1378+
result = GeophiresXClient().get_geophires_result(
1379+
ImmutableGeophiresInputParameters(
1380+
from_file_path=self._get_test_file_path(
1381+
'geophires_x_tests/generic-egs-case-2_sam-single-owner-ppa.txt'
1382+
),
1383+
params={
1384+
'Royalty Rate': base_royalty_rate,
1385+
'Royalty Rate Escalation': escalation_rate,
1386+
'Royalty Rate Maximum': max_rate,
1387+
},
1388+
)
1389+
)
1390+
opex_result = result.result['OPERATING AND MAINTENANCE COSTS (M$/yr)']
1391+
1392+
self.assertIsNotNone(opex_result[royalties_output_name])
1393+
self.assertEqual('MUSD/yr', opex_result[royalties_output_name]['unit'])
1394+
1395+
total_opex_MUSD = opex_result['Total operating and maintenance costs']['value']
1396+
1397+
opex_line_item_sum = 0
1398+
for line_item_names in [
1399+
'Wellfield maintenance costs',
1400+
'Power plant maintenance costs',
1401+
'Water costs',
1402+
royalties_output_name,
1403+
]:
1404+
opex_line_item_sum += opex_result[line_item_names]['value']
1405+
1406+
self.assertAlmostEqual(opex_line_item_sum, total_opex_MUSD, places=4)
1407+
1408+
project_lifetime_yrs = result.result['ECONOMIC PARAMETERS']['Project lifetime']['value']
1409+
1410+
royalties_cash_flow_MUSD = [
1411+
it * 1e-6
1412+
for it in _cash_flow_profile_row(
1413+
result.result['SAM CASH FLOW PROFILE'], 'O&M production-based expense ($)'
1414+
)
1415+
][1:]
1416+
1417+
ppa_revenue_MUSD = [
1418+
it * 1e-6 for it in _cash_flow_profile_row(result.result['SAM CASH FLOW PROFILE'], 'PPA revenue ($)')
1419+
][1:]
1420+
1421+
actual_royalty_rate = [None] * len(ppa_revenue_MUSD)
1422+
for i in range(len(ppa_revenue_MUSD)):
1423+
actual_royalty_rate[i] = royalties_cash_flow_MUSD[i] / ppa_revenue_MUSD[i]
1424+
1425+
max_expected_rate = (
1426+
max_rate if max_rate < 1.0 else base_royalty_rate + escalation_rate * (project_lifetime_yrs - 1)
1427+
)
1428+
1429+
expected_last_year_revenue = ppa_revenue_MUSD[-1] * max_expected_rate
1430+
self.assertAlmostEqual(expected_last_year_revenue, royalties_cash_flow_MUSD[-1], places=3)

0 commit comments

Comments
 (0)