Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b74859f
numpy-financial NPV test in test_economics
softwareengineerprogrammer Mar 7, 2025
c3fd03d
Add Cashflow Series Start Year parameter to enable NPV calculation pa…
softwareengineerprogrammer Mar 7, 2025
ae7a8f0
fix incorrect parameter dict declaration
softwareengineerprogrammer Mar 7, 2025
7213230
sync cashflow series start year test npv test values
softwareengineerprogrammer Mar 12, 2025
467ef07
Switch from integer 'Cashflow Series Start Year' to boolean 'Discount…
softwareengineerprogrammer Mar 12, 2025
37a59c2
regenerate schema with Discount Initial Year Cashflow
softwareengineerprogrammer Mar 12, 2025
cb1e0c6
Bump version: 3.8.0 → 3.8.1
softwareengineerprogrammer Mar 12, 2025
b206bb5
tweak Discount Initial Year Cashflow tooltip
softwareengineerprogrammer Mar 13, 2025
00f937c
honor discount initial year cashflow in add-ons economics
softwareengineerprogrammer Mar 13, 2025
ba94b45
unit test add-ons/extended economics npv with/without discount initia…
softwareengineerprogrammer Mar 13, 2025
5571a28
remove python 3.8-incompatible type annotation
softwareengineerprogrammer Mar 13, 2025
bb32558
Missing space typo
softwareengineerprogrammer Mar 13, 2025
6f088ba
Bump version: 3.8.1 → 3.8.2
softwareengineerprogrammer Mar 13, 2025
fa958f3
Tweak discount initial year cashflow documentation
softwareengineerprogrammer Mar 14, 2025
3796088
Bump version: 3.8.2 → 3.8.3
softwareengineerprogrammer Mar 14, 2025
62af12e
Bump version: 3.8.3 → 3.8.4
softwareengineerprogrammer Mar 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 3.8.0
current_version = 3.8.4
commit = True
tag = True

Expand Down
2 changes: 1 addition & 1 deletion .cookiecutterrc
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ default_context:
sphinx_doctest: "no"
sphinx_theme: "sphinx-py3doc-enhanced-theme"
test_matrix_separate_coverage: "no"
version: 3.8.0
version: 3.8.4
version_manager: "bump2version"
website: "https://github.com/NREL"
year_from: "2023"
Expand Down
4 changes: 2 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ Free software: `MIT license <LICENSE>`__
:alt: Supported implementations
:target: https://pypi.org/project/geophires-x

.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.8.0.svg
.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.8.4.svg
:alt: Commits since latest release
:target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.8.0...main
:target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.8.4...main

.. |docs| image:: https://readthedocs.org/projects/GEOPHIRES-X/badge/?style=flat
:target: https://nrel.github.io/GEOPHIRES-X
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
year = '2025'
author = 'NREL'
copyright = f'{year}, {author}'
version = release = '3.8.0'
version = release = '3.8.4'

pygments_style = 'trac'
templates_path = ['./templates']
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def read(*names, **kwargs):

setup(
name='geophires-x',
version='3.8.0',
version='3.8.4',
license='MIT',
description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.',
long_description='{}\n{}'.format(
Expand Down
55 changes: 50 additions & 5 deletions src/geophires_x/Economics.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,12 +304,29 @@ def CalculateCarbonRevenue(model, plant_lifetime: int, construction_years: int,
return cash_flow_musd, cumm_cash_flow_musd, carbon_that_would_have_been_produced_annually_lbs, carbon_that_would_have_been_produced_total_lbs


def calculate_npv(
discount_rate_tenths: float,
cashflow_series: list,
discount_initial_year_cashflow: bool
) -> float:
# TODO warn/raise exception if discount rate > 1 (i.e. it's probably not converted from percent to tenths)

npv_cashflow_series = cashflow_series.copy() # Copy to guard against unintentional mutation of consumer field

if discount_initial_year_cashflow:
# Enable Excel-style NPV calculation - see https://github.com/NREL/GEOPHIRES-X/discussions/344
npv_cashflow_series = [0, *npv_cashflow_series]

return npf.npv(discount_rate_tenths, npv_cashflow_series)


def CalculateFinancialPerformance(plantlifetime: int,
FixedInternalRate: float,
TotalRevenue: list,
TotalCummRevenue: list,
CAPEX: float,
OPEX: float):
OPEX: float,
discount_initial_year_cashflow: bool = False):
"""
CalculateFinancialPerformance calculates the financial performance of the project. It is used to calculate the
financial performance of the project. It is used to calculate the revenue stream for the project.
Expand All @@ -325,6 +342,9 @@ def CalculateFinancialPerformance(plantlifetime: int,
:type CAPEX: float
:param OPEX: The total annual operating cost of the project in MUSD
:type OPEX: float
:param discount_initial_year_cashflow: Whether to discount the initial year of cashflow used to calculate NPV
:type discount_initial_year_cashflow: bool

:return: NPV: The net present value of the project in MUSD
:rtype: float
:return: IRR: The internal rate of return of the project in %
Expand All @@ -336,7 +356,8 @@ def CalculateFinancialPerformance(plantlifetime: int,
:rtype: tuple
"""
# Calculate financial performance values using numpy financials
NPV = npf.npv(FixedInternalRate / 100, TotalRevenue)

NPV = calculate_npv(FixedInternalRate / 100, TotalRevenue.copy(), discount_initial_year_cashflow)
IRR = npf.irr(TotalRevenue)
if math.isnan(IRR):
IRR = 0.0
Expand Down Expand Up @@ -859,6 +880,24 @@ def __init__(self, model: Model):
"Discount Rate is synonymous with Fixed Internal Rate. If one is provided, the other's value "
"will be automatically set to the same value."
)


self.discount_initial_year_cashflow = self.ParameterDict[self.discount_initial_year_cashflow.Name] = boolParameter(
'Discount Initial Year Cashflow',
DefaultValue=False,
UnitType=Units.NONE,
ToolTipText='Whether to discount cashflow in the initial project year when calculating NPV '
'(Net Present Value). '
'The default value of False conforms to NREL\'s standard convention for NPV calculation '
'(Short W et al, 1995. https://www.nrel.gov/docs/legosti/old/5173.pdf). '
'A value of True will, by contrast, cause NPV calculation to follow the convention used by '
'Excel, Google Sheets, and other common spreadsheet software. '
'Although NREL\'s NPV convention may typically be considered more technically correct, '
'Excel-style NPV calculation might be preferred for familiarity '
'or compatibility with existing business processes. '
'See https://github.com/NREL/GEOPHIRES-X/discussions/344 for further details.'
)

self.FIB = self.ParameterDict[self.FIB.Name] = floatParameter(
"Fraction of Investment in Bonds",
DefaultValue=0.5,
Expand Down Expand Up @@ -2881,9 +2920,15 @@ def Calculate(self, model: Model) -> None:

# Calculate more financial values using numpy financials
self.ProjectNPV.value, self.ProjectIRR.value, self.ProjectVIR.value, self.ProjectMOIC.value = \
CalculateFinancialPerformance(model.surfaceplant.plant_lifetime.value, self.FixedInternalRate.value,
self.TotalRevenue.value, self.TotalCummRevenue.value, self.CCap.value,
self.Coam.value)
CalculateFinancialPerformance(
model.surfaceplant.plant_lifetime.value,
self.FixedInternalRate.value,
self.TotalRevenue.value,
self.TotalCummRevenue.value,
self.CCap.value,
self.Coam.value,
self.discount_initial_year_cashflow.value
)

# Calculate the project payback period
self.ProjectPaybackPeriod.value = 0.0 # start by assuming the project never pays back
Expand Down
7 changes: 6 additions & 1 deletion src/geophires_x/EconomicsAddOns.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,12 @@ def Calculate(self, model: Model) -> None:

# Now calculate a new "NPV", "IRR", "VIR", "Payback Period", and "MOIC"
# Calculate more financial values using numpy financials
self.ProjectNPV.value = npf.npv(self.FixedInternalRate.value / 100, self.ProjectCashFlow.value)
self.ProjectNPV.value = Economics.calculate_npv(
self.FixedInternalRate.value / 100,
self.ProjectCashFlow.value.copy(),
self.discount_initial_year_cashflow.value
)

self.ProjectIRR.value = npf.irr(self.ProjectCashFlow.value)
if math.isnan(self.ProjectIRR.value):
self.ProjectIRR.value = 0.0
Expand Down
7 changes: 6 additions & 1 deletion src/geophires_x/Outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1643,7 +1643,12 @@ def PrintOutputs(self, model: Model):
f.write(f' Accrued financing during construction: {model.economics.inflrateconstruction.value*100:10.2f} ' + model.economics.inflrateconstruction.CurrentUnits.value + NL)
f.write(f' Project lifetime: {model.surfaceplant.plant_lifetime.value:10.0f} ' + model.surfaceplant.plant_lifetime.CurrentUnits.value + NL)
f.write(f' Capacity factor: {model.surfaceplant.utilization_factor.value * 100:10.1f} %' + NL)
f.write(f' Project NPV: {model.economics.ProjectNPV.value:10.2f} ' + model.economics.ProjectNPV.PreferredUnits.value + NL)

e_npv = model.economics.ProjectNPV
npv_field_label = Outputs._field_label('Project NPV', 49)
# TODO should use CurrentUnits instead of PreferredUnits
f.write(f' {npv_field_label}{e_npv.value:10.2f} {e_npv.PreferredUnits.value}\n')

f.write(f' Project IRR: {model.economics.ProjectIRR.value:10.2f} ' + model.economics.ProjectIRR.PreferredUnits.value + NL)
f.write(f' Project VIR=PI=PIR: {model.economics.ProjectVIR.value:10.2f}' + NL)
f.write(f' {model.economics.ProjectMOIC.Name}: {model.economics.ProjectMOIC.value:10.2f}' + NL)
Expand Down
2 changes: 1 addition & 1 deletion src/geophires_x/OutputsAddOns.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def PrintOutputs(self, model) -> tuple:
addon_results.append(OutputTableItem('Adjusted Project CAPEX (after incentives, grants, AddOns, etc)', '{0:10.2f}'.format(model.addeconomics.AdjustedProjectCAPEX.value), model.addeconomics.AdjustedProjectCAPEX.PreferredUnits.value))
f.write(f" Adjusted Project OPEX (after incentives, grants, AddOns, etc): {model.addeconomics.AdjustedProjectOPEX.value:10.2f} " + model.addeconomics.AdjustedProjectOPEX.PreferredUnits.value + NL)
addon_results.append(OutputTableItem('Adjusted Project OPEX (after incentives, grants, AddOns, etc)', '{0:10.2f}'.format(model.addeconomics.AdjustedProjectOPEX.value), model.addeconomics.AdjustedProjectOPEX.PreferredUnits.value))
f.write(f" Project NPV (including AddOns): {model.addeconomics.ProjectNPV.value:10.2f} " + model.addeconomics.ProjectNPV.PreferredUnits.value + NL)
f.write(f' Project NPV (including AddOns): {model.addeconomics.ProjectNPV.value:10.2f} {model.addeconomics.ProjectNPV.PreferredUnits.value}\n')
addon_results.append(OutputTableItem('Project NPV (including AddOns)', '{0:10.2f}'.format(model.addeconomics.ProjectNPV.value), model.addeconomics.ProjectNPV.PreferredUnits.value))
f.write(f" Project IRR (including AddOns): {model.addeconomics.ProjectIRR.value:10.2f} " + model.addeconomics.ProjectIRR.PreferredUnits.value + NL)
addon_results.append(OutputTableItem('Project IRR (including AddOns)', '{0:10.2f}'.format(model.addeconomics.ProjectIRR.value), model.addeconomics.ProjectIRR.PreferredUnits.value))
Expand Down
2 changes: 1 addition & 1 deletion src/geophires_x/Parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ class boolParameter(Parameter):

def __post_init__(self):
if self.value is None:
self.value:bool = self.DefaultValue
self.value: bool = self.DefaultValue

value: bool = None
DefaultValue: bool = value
Expand Down
2 changes: 1 addition & 1 deletion src/geophires_x/WellBores.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ def ProdPressureDropAndPumpingPowerUsingIndexes(
pumpdepthfinal_m = np.max(pumpdepth_m)
if pumpdepthfinal_m < 0.0:
pumpdepthfinal_m = 0.0
msg = (f'GEOPHIRES calculates negative production well pumping depth. ({pumpdepthfinal_m:.2f}m)'
msg = (f'GEOPHIRES calculates negative production well pumping depth. ({pumpdepthfinal_m:.2f}m). '
f'No production well pumps will be assumed')
print(f'Warning: {msg}')
model.logger.warning(msg)
Expand Down
2 changes: 1 addition & 1 deletion src/geophires_x/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '3.8.0'
__version__ = '3.8.4'
9 changes: 9 additions & 0 deletions src/geophires_x_schema_generator/geophires-request.json
Original file line number Diff line number Diff line change
Expand Up @@ -1580,6 +1580,15 @@
"minimum": 0.0,
"maximum": 1.0
},
"Discount Initial Year Cashflow": {
"description": "Whether to discount cashflow in the initial project year when calculating NPV (Net Present Value). The default value of False conforms to NREL's standard convention for NPV calculation (Short W et al, 1995. https://www.nrel.gov/docs/legosti/old/5173.pdf). A value of True will, by contrast, cause NPV calculation to follow the convention used by Excel, Google Sheets, and other common spreadsheet software. Although NREL's NPV convention may typically be considered more technically correct, Excel-style NPV calculation might be preferred for familiarity or compatibility with existing business processes. See https://github.com/NREL/GEOPHIRES-X/discussions/344 for further details.",
"type": "boolean",
"units": null,
"category": "Economics",
"default": false,
"minimum": null,
"maximum": null
},
"Fraction of Investment in Bonds": {
"description": "Fraction of geothermal project financing through bonds (see docs)",
"type": "number",
Expand Down
61 changes: 52 additions & 9 deletions tests/geophires_x_tests/test_economics.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,31 @@
import sys
from pathlib import Path

from geophires_x.Economics import CalculateFinancialPerformance
import numpy_financial as npf

# ruff: noqa: I001 # Successful module initialization is dependent on this specific import order.
from geophires_x.Model import Model
from geophires_x.Economics import CalculateFinancialPerformance
from tests.base_test_case import BaseTestCase


class EconomicsTestCase(BaseTestCase):
@staticmethod
def cumm_revenue(total_revenue):
cumm_revenue = [total_revenue[0]] * len(total_revenue)
cumm_revenue[1] = total_revenue[1]
for i in range(2, len(total_revenue)):
cumm_revenue[i] = cumm_revenue[i - 1] + total_revenue[i]
return cumm_revenue

def test_irr(self):
"""
Test cases adapted from https://numpy.org/numpy-financial/latest/irr.html
"""

def cumm_revenue(total_revenue):
cumm_revenue = [total_revenue[0]] * len(total_revenue)
cumm_revenue[1] = total_revenue[1]
for i in range(2, len(total_revenue)):
cumm_revenue[i] = cumm_revenue[i - 1] + total_revenue[i]
return cumm_revenue

def calc_irr(total_revenue):
NPV, IRR, VIR, MOIC = CalculateFinancialPerformance(
30, 5, total_revenue, cumm_revenue(total_revenue), 1000, 10
30, 5, total_revenue, EconomicsTestCase.cumm_revenue(total_revenue), 1000, 10
)

return IRR
Expand All @@ -33,6 +37,45 @@ def calc_irr(total_revenue):
self.assertAlmostEqual(6.21, calc_irr([-100, 100, 0, 7]), places=2)
self.assertAlmostEqual(8.86, calc_irr([-5, 10.5, 1, -8, 1]), places=2)

def test_npv(self):
"""
Includes sanity checks that numpy-financial.npv used by CalculateFinancialPerformance
matches reference calculations
"""

rate = 0.12

def calc_npv(total_revenue, discount_initial_year_cashflow=False):
NPV, IRR, VIR, MOIC = CalculateFinancialPerformance(
len(total_revenue) + 1,
rate * 100,
total_revenue,
EconomicsTestCase.cumm_revenue(total_revenue),
total_revenue[0],
10,
discount_initial_year_cashflow=discount_initial_year_cashflow,
)

return NPV

# https://www.nrel.gov/docs/legosti/old/5173.pdf, p. 41
cashflow_series = [-10000, 7274, 6558, 6223, 6087, 6259]

npf_npv = npf.npv(rate, cashflow_series)
self.assertEqual(13572, round(npf_npv))

geophires_npv = calc_npv(cashflow_series)
self.assertEqual(13572, round(geophires_npv))

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

geophires_npv = calc_npv(cashflow_series, discount_initial_year_cashflow=True)
self.assertEqual(1188.44, round(geophires_npv, 2))

def test_well_drilling_cost_correlation_tooltiptext(self):
ec = self._new_model().economics
self.assertEqual(
Expand Down
51 changes: 45 additions & 6 deletions tests/test_geophires_x.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,24 +572,63 @@ def assertHasLogRecordWithMessage(logs_, message):
with self.assertLogs(level='INFO') as logs:
result = client.get_geophires_result(input_params(discount_rate='0.042'))

assert result is not None
assert result.result['ECONOMIC PARAMETERS']['Interest Rate']['value'] == 4.2
assert result.result['ECONOMIC PARAMETERS']['Interest Rate']['unit'] == '%'
self.assertIsNotNone(result)
self.assertEqual(4.2, result.result['ECONOMIC PARAMETERS']['Interest Rate']['value'])
self.assertEqual('%', result.result['ECONOMIC PARAMETERS']['Interest Rate']['unit'])
assertHasLogRecordWithMessage(
logs, 'Set Fixed Internal Rate to 4.2 percent because Discount Rate was provided (0.042)'
)

with self.assertLogs(level='INFO') as logs2:
result2 = client.get_geophires_result(input_params(fixed_internal_rate='4.2'))

assert result2 is not None
assert result2.result['ECONOMIC PARAMETERS']['Interest Rate']['value'] == 4.2
assert result2.result['ECONOMIC PARAMETERS']['Interest Rate']['unit'] == '%'
self.assertIsNotNone(result2)
self.assertEqual(4.2, result2.result['ECONOMIC PARAMETERS']['Interest Rate']['value'])
self.assertEqual('%', result2.result['ECONOMIC PARAMETERS']['Interest Rate']['unit'])

assertHasLogRecordWithMessage(
logs2, 'Set Discount Rate to 0.042 because Fixed Internal Rate was provided (4.2 percent)'
)

def test_discount_initial_year_cashflow(self):
def _get_result(base_example: str, do_discount: bool) -> GeophiresXResult:
return GeophiresXClient().get_geophires_result(
GeophiresInputParameters(
# TODO switch over to generic EGS case to avoid thrash from example updates
# from_file_path=self._get_test_file_path('geophires_x_tests/generic-egs-case.txt'),
from_file_path=self._get_test_file_path(f'examples/{base_example}.txt'),
params={
'Discount Initial Year Cashflow': do_discount,
},
)
)

def _npv(r: GeophiresXResult) -> dict:
return r.result['ECONOMIC PARAMETERS']['Project NPV']['value']

self.assertEqual(4580.36, _npv(_get_result('Fervo_Project_Cape-3', False)))
self.assertEqual(4280.71, _npv(_get_result('Fervo_Project_Cape-3', True)))

def _extended_economics_npv(r: GeophiresXResult) -> dict:
return r.result['EXTENDED ECONOMICS']['Project NPV (including AddOns)']['value']

add_ons_result_without_discount = _get_result('example1_addons', False)
add_ons_result_with_discount = _get_result('example1_addons', True)

self.assertGreater(_npv(add_ons_result_without_discount), _npv(add_ons_result_with_discount))

ee_npv_without_discount = _extended_economics_npv(add_ons_result_without_discount)
assert ee_npv_without_discount < 0, (
'Test is expecting example1_addons extended economics NPV to be negative '
'as a precondition - if this error is encountered, '
'create a test-only copy of the previous version of example1_addons and '
'use it in this test (like geophires_x_tests/generic-egs-case.txt).'
)

# Discounting first year causes negative NPVs to be less negative (according to Google Sheets,
# which was used to manually validate the expected NPVs here).
self.assertLess(ee_npv_without_discount, _extended_economics_npv(add_ons_result_with_discount))

def test_transmission_pipeline_cost(self):
result = GeophiresXClient().get_geophires_result(
GeophiresInputParameters(
Expand Down