Skip to content

Commit c3fd03d

Browse files
Add Cashflow Series Start Year parameter to enable NPV calculation parity with Excel/Google Sheets/etc.
1 parent b74859f commit c3fd03d

File tree

3 files changed

+98
-18
lines changed

3 files changed

+98
-18
lines changed

src/geophires_x/Economics.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,8 @@ def CalculateFinancialPerformance(plantlifetime: int,
309309
TotalRevenue: list,
310310
TotalCummRevenue: list,
311311
CAPEX: float,
312-
OPEX: float):
312+
OPEX: float,
313+
cashflow_series_start_year: float = 0.):
313314
"""
314315
CalculateFinancialPerformance calculates the financial performance of the project. It is used to calculate the
315316
financial performance of the project. It is used to calculate the revenue stream for the project.
@@ -325,6 +326,9 @@ def CalculateFinancialPerformance(plantlifetime: int,
325326
:type CAPEX: float
326327
:param OPEX: The total annual operating cost of the project in MUSD
327328
:type OPEX: float
329+
:param cashflow_series_start_year: The starting year of the cashflow series used to calculate NPV
330+
:type cashflow_series_start_year: float
331+
328332
:return: NPV: The net present value of the project in MUSD
329333
:rtype: float
330334
:return: IRR: The internal rate of return of the project in %
@@ -336,8 +340,19 @@ def CalculateFinancialPerformance(plantlifetime: int,
336340
:rtype: tuple
337341
"""
338342
# Calculate financial performance values using numpy financials
339-
NPV = npf.npv(FixedInternalRate / 100, TotalRevenue)
340-
IRR = npf.irr(TotalRevenue)
343+
344+
cashflow_series = TotalRevenue.copy()
345+
346+
if cashflow_series_start_year not in [0., 1.]:
347+
param_name = 'Cashflow Series Start Year' # TODO reference name defined in parameter dict
348+
raise NotImplementedError(f'Unsupported value for {param_name}: {cashflow_series_start_year}')
349+
350+
351+
if cashflow_series_start_year == 1.:
352+
cashflow_series = [0, *cashflow_series]
353+
354+
NPV = npf.npv(FixedInternalRate / 100, cashflow_series)
355+
IRR = npf.irr(cashflow_series)
341356
if math.isnan(IRR):
342357
IRR = 0.0
343358
else:
@@ -859,6 +874,19 @@ def __init__(self, model: Model):
859874
"Discount Rate is synonymous with Fixed Internal Rate. If one is provided, the other's value "
860875
"will be automatically set to the same value."
861876
)
877+
878+
# TODO add support for float values
879+
self.cashflow_series_start_year = self.ParameterDict[self.discountrate.Name] = intParameter(
880+
"Cashflow Series Start Year",
881+
DefaultValue=0,
882+
AllowableRange=[0,1],
883+
UnitType=Units.NONE,
884+
ErrMessage=f'assume default Cashflow Series Start Year ({0})',
885+
ToolTipText="Cashflow Series Start Year used to calculate NPV"
886+
# "in the Standard Levelized Cost Model." # (?)
887+
# TODO documentation re: values/conventions
888+
)
889+
862890
self.FIB = self.ParameterDict[self.FIB.Name] = floatParameter(
863891
"Fraction of Investment in Bonds",
864892
DefaultValue=0.5,
@@ -2881,9 +2909,15 @@ def Calculate(self, model: Model) -> None:
28812909

28822910
# Calculate more financial values using numpy financials
28832911
self.ProjectNPV.value, self.ProjectIRR.value, self.ProjectVIR.value, self.ProjectMOIC.value = \
2884-
CalculateFinancialPerformance(model.surfaceplant.plant_lifetime.value, self.FixedInternalRate.value,
2885-
self.TotalRevenue.value, self.TotalCummRevenue.value, self.CCap.value,
2886-
self.Coam.value)
2912+
CalculateFinancialPerformance(
2913+
model.surfaceplant.plant_lifetime.value,
2914+
self.FixedInternalRate.value,
2915+
self.TotalRevenue.value,
2916+
self.TotalCummRevenue.value,
2917+
self.CCap.value,
2918+
self.Coam.value,
2919+
self.cashflow_series_start_year.value
2920+
)
28872921

28882922
# Calculate the project payback period
28892923
self.ProjectPaybackPeriod.value = 0.0 # start by assuming the project never pays back

tests/geophires_x_tests/test_economics.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,22 @@
1111

1212

1313
class EconomicsTestCase(BaseTestCase):
14+
@staticmethod
15+
def cumm_revenue(total_revenue):
16+
cumm_revenue = [total_revenue[0]] * len(total_revenue)
17+
cumm_revenue[1] = total_revenue[1]
18+
for i in range(2, len(total_revenue)):
19+
cumm_revenue[i] = cumm_revenue[i - 1] + total_revenue[i]
20+
return cumm_revenue
21+
1422
def test_irr(self):
1523
"""
1624
Test cases adapted from https://numpy.org/numpy-financial/latest/irr.html
1725
"""
1826

19-
def cumm_revenue(total_revenue):
20-
cumm_revenue = [total_revenue[0]] * len(total_revenue)
21-
cumm_revenue[1] = total_revenue[1]
22-
for i in range(2, len(total_revenue)):
23-
cumm_revenue[i] = cumm_revenue[i - 1] + total_revenue[i]
24-
return cumm_revenue
25-
2627
def calc_irr(total_revenue):
2728
NPV, IRR, VIR, MOIC = CalculateFinancialPerformance(
28-
30, 5, total_revenue, cumm_revenue(total_revenue), 1000, 10
29+
30, 5, total_revenue, EconomicsTestCase.cumm_revenue(total_revenue), 1000, 10
2930
)
3031

3132
return IRR
@@ -36,19 +37,45 @@ def calc_irr(total_revenue):
3637
self.assertAlmostEqual(6.21, calc_irr([-100, 100, 0, 7]), places=2)
3738
self.assertAlmostEqual(8.86, calc_irr([-5, 10.5, 1, -8, 1]), places=2)
3839

39-
def test_numpy_financial_npv(self):
40-
# https://www.nrel.gov/docs/legosti/old/5173.pdf, p. 41
40+
def test_npv(self):
41+
"""
42+
Includes sanity checks that numpy-financial.npv used by CalculateFinancialPerformance
43+
matches reference calculations
44+
"""
45+
4146
rate = 0.12
47+
48+
def calc_npv(total_revenue, cashflow_series_start_year=0):
49+
NPV, IRR, VIR, MOIC = CalculateFinancialPerformance(
50+
len(total_revenue) + 1,
51+
rate * 100,
52+
total_revenue,
53+
EconomicsTestCase.cumm_revenue(total_revenue),
54+
total_revenue[0],
55+
10,
56+
cashflow_series_start_year=cashflow_series_start_year,
57+
)
58+
59+
return NPV
60+
61+
# https://www.nrel.gov/docs/legosti/old/5173.pdf, p. 41
4262
cashflow_series = [-10000, 7274, 6558, 6223, 6087, 6259]
43-
npv = npf.npv(rate, cashflow_series)
44-
self.assertEqual(13572, round(npv))
63+
64+
npf_npv = npf.npv(rate, cashflow_series)
65+
self.assertEqual(13572, round(npf_npv))
66+
67+
geophires_npv = calc_npv(cashflow_series)
68+
self.assertEqual(13572, round(geophires_npv))
4569

4670
# https://support.microsoft.com/en-us/office/npv-function-8672cb67-2576-4d07-b67b-ac28acf2a568
4771
rate = 0.1
4872
cashflow_series = [-10000, 3000, 4200, 6800]
4973
excel_npv = npf.npv(rate, [0, *cashflow_series])
5074
self.assertEqual(1188.44, round(excel_npv, 2))
5175

76+
geophires_npv = calc_npv(cashflow_series, cashflow_series_start_year=1)
77+
self.assertEqual(1188.44, round(geophires_npv, 2))
78+
5279
def test_well_drilling_cost_correlation_tooltiptext(self):
5380
ec = self._new_model().economics
5481
self.assertEqual(

tests/test_geophires_x.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,25 @@ def assertHasLogRecordWithMessage(logs_, message):
590590
logs2, 'Set Discount Rate to 0.042 because Fixed Internal Rate was provided (4.2 percent)'
591591
)
592592

593+
def test_cashflow_series_start_year(self):
594+
def _get_result(series_start_year: int) -> GeophiresXResult:
595+
return GeophiresXClient().get_geophires_result(
596+
GeophiresInputParameters(
597+
# TODO switch over to generic EGS case to avoid thrash from example updates
598+
# from_file_path=self._get_test_file_path('geophires_x_tests/generic-egs-case.txt'),
599+
from_file_path=self._get_test_file_path('examples/Fervo_Project_Cape-3.txt'),
600+
params={
601+
'Cashflow Series Start Year': series_start_year,
602+
},
603+
)
604+
)
605+
606+
def _npv(r: GeophiresXResult) -> dict:
607+
return r.result['ECONOMIC PARAMETERS']['Project NPV']['value']
608+
609+
self.assertEqual(4561.96, _npv(_get_result(0)))
610+
self.assertEqual(4263.51, _npv(_get_result(1)))
611+
593612
def test_transmission_pipeline_cost(self):
594613
result = GeophiresXClient().get_geophires_result(
595614
GeophiresInputParameters(

0 commit comments

Comments
 (0)