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