Skip to content

Commit 0da0340

Browse files
Fix unit conversion issue that was causing incorrect royalty calculations
1 parent 78a3942 commit 0da0340

File tree

6 files changed

+86
-47
lines changed

6 files changed

+86
-47
lines changed

src/geophires_x/Economics.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3363,18 +3363,31 @@ def _calculate_sam_economics(self, model: Model) -> None:
33633363
self.sam_economics_calculations.royalties_opex.value[pre_revenue_years_slice_index:]
33643364
)
33653365

3366-
self.royalties_average_annual_cost.value = average_annual_royalties # TODO unit conversion
3367-
self.Coam.value += self.royalties_average_annual_cost.quantity().to(self.Coam.CurrentUnits.value).magnitude
3366+
self.royalties_average_annual_cost.value = (quantity(
3367+
average_annual_royalties,
3368+
self.sam_economics_calculations.royalties_opex.CurrentUnits
3369+
).to(self.royalties_average_annual_cost.CurrentUnits).magnitude)
3370+
3371+
self.Coam.value += (self.royalties_average_annual_cost.quantity()
3372+
.to(self.Coam.CurrentUnits.value).magnitude)
3373+
3374+
self.royalty_holder_npv.value = quantity(
3375+
calculate_npv(
3376+
self.royalty_holder_discount_rate.value,
3377+
self.sam_economics_calculations.royalties_opex.value,
3378+
self.discount_initial_year_cashflow.value
3379+
),
3380+
self.sam_economics_calculations.royalties_opex.CurrentUnits.get_currency_unit_str()
3381+
).to(self.royalty_holder_npv.CurrentUnits).magnitude
33683382

3369-
self.royalty_holder_npv.value = calculate_npv(
3370-
self.royalty_holder_discount_rate.value,
3371-
self.sam_economics_calculations.royalties_opex.value,
3372-
self.discount_initial_year_cashflow.value
3373-
)
33743383
self.royalty_holder_annual_revenue.value = self.royalties_average_annual_cost.value
3375-
self.royalty_holder_total_revenue.value = np.sum( # TODO unit conversion
3376-
self.sam_economics_calculations.royalties_opex.value[pre_revenue_years_slice_index:]
3377-
)
3384+
3385+
self.royalty_holder_total_revenue.value = quantity(
3386+
np.sum(
3387+
self.sam_economics_calculations.royalties_opex.value[pre_revenue_years_slice_index:]
3388+
),
3389+
self.sam_economics_calculations.royalties_opex.CurrentUnits.get_currency_unit_str()
3390+
).to(self.royalty_holder_total_revenue.CurrentUnits).magnitude
33783391

33793392

33803393
self.wacc.value = self.sam_economics_calculations.wacc.value

src/geophires_x/EconomicsSam.py

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,13 @@ def sf(_v: float, num_sig_figs: int = 5) -> float:
173173
sam_economics.project_npv.value = sf(single_owner.Outputs.project_return_aftertax_npv * 1e-6)
174174
sam_economics.capex.value = single_owner.Outputs.adjusted_installed_cost * 1e-6
175175

176-
royalty_rate = model.economics.royalty_rate.quantity().to('dimensionless').magnitude
177-
ppa_revenue_row = _cash_flow_profile_row(cash_flow, 'PPA revenue ($)')
178-
royalties_unit = sam_economics.royalties_opex.CurrentUnits.value.replace('/yr', '')
179-
sam_economics.royalties_opex.value = [
180-
quantity(x * royalty_rate, 'USD').to(royalties_unit).magnitude for x in ppa_revenue_row
181-
]
176+
if model.economics.royalty_rate.Provided:
177+
# Assumes that royalties opex is the only possible O&M production-based expense - this logic will need to be
178+
# updated if more O&M production-based expenses are added to SAM-EM
179+
sam_economics.royalties_opex.value = [
180+
quantity(it, 'USD / year').to(sam_economics.royalties_opex.CurrentUnits).magnitude
181+
for it in _cash_flow_profile_row(cash_flow, 'O&M production-based expense ($)')
182+
]
182183

183184
sam_economics.nominal_discount_rate.value, sam_economics.wacc.value = _calculate_nominal_discount_rate_and_wacc(
184185
model, single_owner
@@ -408,28 +409,11 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]:
408409
geophires_ptr_tenths = Decimal(econ.PTR.value)
409410
ret['property_tax_rate'] = float(geophires_ptr_tenths * Decimal(100))
410411

411-
ppa_price_schedule_per_kWh = _ppa_pricing_model(
412-
model.surfaceplant.plant_lifetime.value,
413-
econ.ElecStartPrice.value,
414-
econ.ElecEndPrice.value,
415-
econ.ElecEscalationStart.value,
416-
econ.ElecEscalationRate.value,
417-
)
412+
ppa_price_schedule_per_kWh = _get_ppa_price_schedule_per_kWh(model)
418413
ret['ppa_price_input'] = ppa_price_schedule_per_kWh
419414

420-
royalty_rate_schedule = _get_royalty_rate_schedule(model)
421415
if model.economics.royalty_rate.Provided:
422-
# For each year, calculate the royalty as a $/MWh variable cost.
423-
# The royalty is a percentage of revenue (MWh * $/MWh). By setting the
424-
# variable O&M rate to (PPA Price * Royalty Rate), SAM's calculation
425-
# (Rate * MWh) will correctly yield the total royalty payment.
426-
variable_om_schedule_per_MWh = [
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)
429-
]
430-
431-
# The PySAM parameter for variable operating cost in $/MWh is 'om_production'.
432-
ret['om_production'] = variable_om_schedule_per_MWh
416+
ret['om_production'] = _get_royalties_variable_om_per_MWh_schedule(model)
433417

434418
# Debt/equity ratio ('Fraction of Investment in Bonds' parameter)
435419
ret['debt_percent'] = _pct(econ.FIB)
@@ -452,6 +436,24 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]:
452436
return ret
453437

454438

439+
def _get_royalties_variable_om_per_MWh_schedule(model: Model):
440+
"""TODO price unit in method name"""
441+
442+
royalty_rate_schedule = _get_royalty_rate_schedule(model)
443+
ppa_price_schedule_per_kWh = _get_ppa_price_schedule_per_kWh(model)
444+
445+
# For each year, calculate the royalty as a $/MWh variable cost.
446+
# The royalty is a percentage of revenue (MWh * $/MWh). By setting the
447+
# variable O&M rate to (PPA Price * Royalty Rate), SAM's calculation
448+
# (Rate * MWh) will correctly yield the total royalty payment.
449+
variable_om_schedule_per_MWh = [
450+
(price_kWh * 1000) * royalty_fraction # TODO use pint unit conversion instead
451+
for price_kWh, royalty_fraction in zip(ppa_price_schedule_per_kWh, royalty_rate_schedule)
452+
]
453+
454+
return variable_om_schedule_per_MWh
455+
456+
455457
def _get_fed_and_state_tax_rates(geophires_ctr_tenths: float) -> tuple[list[float]]:
456458
geophires_ctr_tenths = Decimal(geophires_ctr_tenths)
457459
max_fed_rate_tenths = Decimal(0.21)
@@ -469,9 +471,22 @@ def _pct(econ_value: Parameter) -> float:
469471
return econ_value.quantity().to(convertible_unit('%')).magnitude
470472

471473

474+
def _get_ppa_price_schedule_per_kWh(model: Model) -> list[float]:
475+
"""TODO price unit in method name"""
476+
477+
econ = model.economics
478+
return _ppa_pricing_model(
479+
model.surfaceplant.plant_lifetime.value,
480+
econ.ElecStartPrice.value,
481+
econ.ElecEndPrice.value,
482+
econ.ElecEscalationStart.value,
483+
econ.ElecEscalationRate.value,
484+
)
485+
486+
472487
def _ppa_pricing_model(
473488
plant_lifetime: int, start_price: float, end_price: float, escalation_start_year: int, escalation_rate: float
474-
) -> list:
489+
) -> list[float]:
475490
# See relevant comment in geophires_x.EconomicsUtils.BuildPricingModel re:
476491
# https://github.com/NREL/GEOPHIRES-X/issues/340?title=Price+Escalation+Start+Year+seemingly+off+by+1.
477492
# We use the same utility method here for the sake of consistency despite technical incorrectness.

src/geophires_x/EconomicsUtils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,8 @@ def royalty_cost_output_parameter() -> OutputParameter:
150150
return OutputParameter(
151151
Name='Royalty Cost',
152152
UnitType=Units.CURRENCYFREQUENCY,
153-
PreferredUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR,
154-
CurrentUnits=CurrencyFrequencyUnit.MDOLLARSPERYEAR,
153+
PreferredUnits=CurrencyFrequencyUnit.DOLLARSPERYEAR,
154+
CurrentUnits=CurrencyFrequencyUnit.DOLLARSPERYEAR,
155155
ToolTipText='The annual costs paid to a royalty holder, calculated as a percentage of the '
156156
'project\'s gross annual revenue. This is modeled as a variable operating expense.'
157157
)

src/geophires_x/Units.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ class CurrencyFrequencyUnit(str, Enum):
201201
KMXNPERYEAR = "KMXN/yr"
202202
MXNPERYEAR = "MXN/yr"
203203

204+
def get_currency_unit_str(self) -> str:
205+
return self.value.split('/')[0]
206+
204207

205208
class EnergyCostUnit(str, Enum):
206209
DOLLARSPERKWH = "USD/kWh"

tests/examples/example_SAM-single-owner-PPA-4.out

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44

55
Simulation Metadata
66
----------------------
7-
GEOPHIRES Version: 3.9.56
8-
Simulation Date: 2025-09-16
9-
Simulation Time: 09:04
10-
Calculation Time: 1.182 sec
7+
GEOPHIRES Version: 3.9.59
8+
Simulation Date: 2025-09-24
9+
Simulation Time: 08:47
10+
Calculation Time: 1.162 sec
1111

1212
***SUMMARY OF RESULTS***
1313

@@ -115,8 +115,8 @@ Simulation Metadata
115115
Wellfield maintenance costs: 1.13 MUSD/yr
116116
Power plant maintenance costs: 3.90 MUSD/yr
117117
Water costs: 1.58 MUSD/yr
118-
Average Annual Royalty Cost: 2.50 MUSD/yr
119-
Total operating and maintenance costs: 9.10 MUSD/yr
118+
Average Annual Royalty Cost: 4.71 MUSD/yr
119+
Total operating and maintenance costs: 11.31 MUSD/yr
120120

121121

122122
***SURFACE EQUIPMENT SIMULATION RESULTS***
@@ -417,6 +417,6 @@ Interest earned on reserves ($) 0 0 0
417417

418418
***EXTENDED ECONOMICS***
419419

420-
Royalty Holder NPV: 29.63 MUSD
421-
Royalty Holder Average Annual Revenue: 2.50 MUSD/yr
422-
Royalty Holder Total Revenue: 49.93 MUSD
420+
Royalty Holder NPV: 54.18 MUSD
421+
Royalty Holder Average Annual Revenue: 4.71 MUSD/yr
422+
Royalty Holder Total Revenue: 94.17 MUSD
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from base_test_case import BaseTestCase
2+
from geophires_x.Units import CurrencyFrequencyUnit
3+
4+
5+
class UnitsTestCase(BaseTestCase):
6+
7+
def test_get_currency_frequency_unit_currency_unit_str(self):
8+
self.assertEqual('USD', CurrencyFrequencyUnit.DOLLARSPERYEAR.get_currency_unit_str())

0 commit comments

Comments
 (0)