@@ -967,6 +967,60 @@ def __init__(self, model: Model):
967
967
"will be automatically set to the same value."
968
968
)
969
969
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
+
970
1024
971
1025
self .discount_initial_year_cashflow = self .ParameterDict [self .discount_initial_year_cashflow .Name ] = boolParameter (
972
1026
'Discount Initial Year Cashflow' ,
@@ -1896,6 +1950,15 @@ def __init__(self, model: Model):
1896
1950
PreferredUnits = CurrencyFrequencyUnit .MDOLLARSPERYEAR ,
1897
1951
CurrentUnits = CurrencyFrequencyUnit .MDOLLARSPERYEAR
1898
1952
)
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
+
1899
1962
1900
1963
# district heating
1901
1964
self .peakingboilercost = self .OutputParameterDict [self .peakingboilercost .Name ] = OutputParameter (
@@ -2115,6 +2178,37 @@ def __init__(self, model: Model):
2115
2178
UnitType = Units .NONE ,
2116
2179
)
2117
2180
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
+
2118
2212
model .logger .info (f'Complete { __class__ !s} : { sys ._getframe ().f_code .co_name } ' )
2119
2213
2120
2214
def read_parameters (self , model : Model ) -> None :
@@ -2367,6 +2461,11 @@ def _warn(_msg: str) -> None:
2367
2461
2368
2462
if self .econmodel .value == EconomicModel .SAM_SINGLE_OWNER_PPA :
2369
2463
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
2370
2469
else :
2371
2470
model .logger .info ("No parameters read because no content provided" )
2372
2471
@@ -2485,29 +2584,8 @@ def Calculate(self, model: Model) -> None:
2485
2584
self .discount_initial_year_cashflow .value
2486
2585
)
2487
2586
2488
- non_calculated_output_placeholder_val = - 1
2489
2587
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 )
2511
2589
2512
2590
# Calculate the project payback period
2513
2591
if self .econmodel .value != EconomicModel .SAM_SINGLE_OWNER_PPA :
@@ -3143,6 +3221,34 @@ def build_price_models(self, model: Model) -> None:
3143
3221
self .CarbonEscalationStart .value , self .CarbonEscalationRate .value ,
3144
3222
self .PTCCarbonPrice )
3145
3223
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
+
3146
3252
def calculate_cashflow (self , model : Model ) -> None :
3147
3253
"""
3148
3254
Calculate cashflow and cumulative cash flow
@@ -3239,6 +3345,68 @@ def calculate_cashflow(self, model: Model) -> None:
3239
3345
for i in range (1 , model .surfaceplant .plant_lifetime .value + model .surfaceplant .construction_years .value , 1 ):
3240
3346
self .TotalCummRevenue .value [i ] = self .TotalCummRevenue .value [i - 1 ] + self .TotalRevenue .value [i ]
3241
3347
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
+
3242
3410
# noinspection SpellCheckingInspection
3243
3411
def _calculate_derived_outputs (self , model : Model ) -> None :
3244
3412
"""
0 commit comments