diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 09df3ff1..4590de66 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.65 +current_version = 3.10.14 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 232e4711..dc5bc0c5 100644 --- a/.cookiecutterrc +++ b/.cookiecutterrc @@ -54,7 +54,7 @@ default_context: sphinx_doctest: "no" sphinx_theme: "sphinx-py3doc-enhanced-theme" test_matrix_separate_coverage: "no" - version: 3.9.65 + version: 3.10.14 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 30be8b34..1e63f57e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ # pre-commit install --install-hooks # To update the versions: # pre-commit autoupdate -exclude: '^(\.tox|ci/templates|\.bumpversion\.cfg|src/geophires_x(?!/(GEOPHIRESv3|EconomicsSam|EconomicsSamCashFlow)\.py))(/|$)' +exclude: '^(\.tox|ci/templates|\.bumpversion\.cfg|src/geophires_x(?!/(GEOPHIRESv3|EconomicsSam|EconomicsSamCashFlow|EconomicsUtils|EconomicsSamPreRevenue)\.py))(/|$)' # Note the order is intentional to avoid multiple passes of the hooks repos: - repo: https://github.com/astral-sh/ruff-pre-commit diff --git a/README.rst b/README.rst index 6531987d..9f473e04 100644 --- a/README.rst +++ b/README.rst @@ -58,9 +58,9 @@ Free software: `MIT 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.9.65.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.10.14.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.65...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.10.14...main .. |docs| image:: https://readthedocs.org/projects/GEOPHIRES-X/badge/?style=flat :target: https://nrel.github.io/GEOPHIRES-X diff --git a/docs/conf.py b/docs/conf.py index a36c1647..c138b35a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ year = '2025' author = 'NREL' copyright = f'{year}, {author}' -version = release = '3.9.65' +version = release = '3.10.14' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index bd70a3a4..30deb483 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.65', + version='3.10.14', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index 8a2cd185..bc2b8b77 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -13,12 +13,14 @@ from geophires_x.EconomicsUtils import BuildPricingModel, wacc_output_parameter, nominal_discount_rate_parameter, \ real_discount_rate_parameter, after_tax_irr_parameter, moic_parameter, project_vir_parameter, \ project_payback_period_parameter, inflation_cost_during_construction_output_parameter, \ - total_capex_parameter_output_parameter + interest_during_construction_output_parameter, total_capex_parameter_output_parameter, \ + overnight_capital_cost_output_parameter from geophires_x.GeoPHIRESUtils import quantity from geophires_x.OptionList import Configuration, WellDrillingCostCorrelation, EconomicModel, EndUseOptions, PlantType, \ _WellDrillingCostCorrelationCitation from geophires_x.Parameter import intParameter, floatParameter, OutputParameter, ReadParameter, boolParameter, \ - coerce_int_params_to_enum_values + coerce_int_params_to_enum_values, listParameter, Parameter +from geophires_x.SurfacePlantUtils import MAX_CONSTRUCTION_YEARS from geophires_x.Units import * from geophires_x.WellBores import calculate_total_drilling_lengths_m @@ -1050,28 +1052,43 @@ def __init__(self, model: Model): 'See https://github.com/NREL/GEOPHIRES-X/discussions/344 for further details.' ) + default_fraction_in_bonds = 0.5 self.FIB = self.ParameterDict[self.FIB.Name] = floatParameter( "Fraction of Investment in Bonds", - DefaultValue=0.5, + DefaultValue=default_fraction_in_bonds, Min=0.0, Max=1.0, UnitType=Units.PERCENT, PreferredUnits=PercentUnit.TENTH, CurrentUnits=PercentUnit.TENTH, - ErrMessage="assume default fraction of investment in bonds (0.5)", - ToolTipText="Fraction of geothermal project financing through bonds (debt)." + ErrMessage=f"assume default fraction of investment in bonds ({default_fraction_in_bonds})", + ToolTipText="Fraction of geothermal project financing through bonds (debt/loans)." ) + + default_bond_interest_rate = 0.05 self.BIR = self.ParameterDict[self.BIR.Name] = floatParameter( "Inflated Bond Interest Rate", - DefaultValue=0.05, + DefaultValue=default_bond_interest_rate, Min=0.0, Max=1.0, UnitType=Units.PERCENT, PreferredUnits=PercentUnit.TENTH, CurrentUnits=PercentUnit.TENTH, - ErrMessage="assume default inflated bond interest rate (0.05)", - ToolTipText="Inflated bond interest rate (see docs)" + ErrMessage=f"assume default inflated bond interest rate ({default_bond_interest_rate})", + ToolTipText="Inflated bond interest rate (for debt/loans)" ) + + self.bond_interest_rate_during_construction = self.ParameterDict[self.bond_interest_rate_during_construction.Name] = floatParameter( + 'Inflated Bond Interest Rate During Construction', + DefaultValue=self.BIR.DefaultValue, + Min=0.0, + Max=1.0, + UnitType=Units.PERCENT, + PreferredUnits=PercentUnit.TENTH, + CurrentUnits=PercentUnit.TENTH, + ToolTipText='Inflated bond interest rate during construction (for debt/loans)' + ) + self.EIR = self.ParameterDict[self.EIR.Name] = floatParameter( "Inflated Equity Interest Rate", DefaultValue=0.1, @@ -1155,6 +1172,52 @@ def __init__(self, model: Model): 'calculated automatically by compounding Inflation Rate over Construction Years.' ) + construction_capex_schedule_name = 'Construction CAPEX Schedule' + self.construction_capex_schedule = self.ParameterDict[self.construction_capex_schedule.Name] = listParameter( + construction_capex_schedule_name, + DefaultValue=[1.], + Min=0.0, + Max=1.0, + ToolTipText=f'A list of fractions of the total overnight CAPEX spent in each construction year. ' + f'For example, for 3 construction years with 10% in the first year, 40% in the second, ' + f'and 50% in the third, provide {construction_capex_schedule_name} = 0.1,0.4,0.5. ' + f'The schedule will be automatically interpolated to match the number of construction years ' + f'and normalized to sum to 1.0.' + ) + + bond_financing_start_year_name = 'Bond Financing Start Year' + min_bond_financing_start_year = -1*(MAX_CONSTRUCTION_YEARS - 1) + default_bond_financing_start_year = min_bond_financing_start_year + latest_allowed_bond_financing_start_year_index = 0 + self.bond_financing_start_year = self.ParameterDict[self.bond_financing_start_year.Name] = intParameter( + bond_financing_start_year_name, + DefaultValue=default_bond_financing_start_year, + AllowableRange=list(range( + min_bond_financing_start_year, + latest_allowed_bond_financing_start_year_index + 1, + 1)), + UnitType=Units.TIME, + PreferredUnits=TimeUnit.YEAR, + CurrentUnits=TimeUnit.YEAR, + ToolTipText=f'By default, bond financing (debt/loans) starts during the first construction year ' + f'(if {self.FIB.Name} is >0). ' + f'Provide {bond_financing_start_year_name} to delay the ' + f'start of bond financing during construction; years prior to {bond_financing_start_year_name} ' + f'will be financed with equity only. ' + f'The value is specified as a project year index corresponding to the Year row in the cash ' + f'flow profile; the first construction year has the year index ' + f'{{({model.surfaceplant.construction_years.Name} - 1) * -1}})' + f' and the final construction year index is 0. ' + f'For example, a project with 4 construction years ' + f'where bond financing starts on the third ' + f'{model.surfaceplant.construction_years.Name[:-1].lower()} ' + f'would have a {bond_financing_start_year_name} value of -1; construction starts in Year -3, ' + f'the second year is Year -2, and the final 2 bond-financed construction years are Year -1 ' + f'and Year 0. ' + f'Bond financing will start on the first construction year if the specified year index is ' + f'prior to the first construction year.' + ) + self.contingency_percentage = self.ParameterDict[self.contingency_percentage.Name] = floatParameter( 'Contingency Percentage', DefaultValue=15., @@ -2160,6 +2223,10 @@ def __init__(self, model: Model): PreferredUnits=PercentUnit.PERCENT, CurrentUnits=PercentUnit.PERCENT ) + + self.overnight_capital_cost = self.OutputParameterDict[ + self.overnight_capital_cost.Name] = overnight_capital_cost_output_parameter() + self.accrued_financing_during_construction_percentage = self.OutputParameterDict[ self.accrued_financing_during_construction_percentage.Name] = OutputParameter( Name='Accrued financing during construction', @@ -2167,15 +2234,15 @@ def __init__(self, model: Model): PreferredUnits=PercentUnit.PERCENT, CurrentUnits=PercentUnit.PERCENT, ToolTipText='The accrued inflation on total capital costs over the construction period, ' - f'as defined by {self.inflrateconstruction.Name}. ' - 'For SAM Economic Models, this is calculated automatically by compounding ' - f'{self.RINFL.Name} over Construction Years ' - f'if {self.inflrateconstruction.Name} is not provided.' + f'as defined by {self.inflrateconstruction.Name}.' ) self.inflation_cost_during_construction = self.OutputParameterDict[ self.inflation_cost_during_construction.Name] = inflation_cost_during_construction_output_parameter() + self.interest_during_construction = self.OutputParameterDict[ + self.interest_during_construction.Name] = interest_during_construction_output_parameter() + self.after_tax_irr = self.OutputParameterDict[self.after_tax_irr.Name] = ( after_tax_irr_parameter()) self.real_discount_rate = self.OutputParameterDict[self.real_discount_rate.Name] = ( @@ -2551,10 +2618,16 @@ def _warn(_msg: str) -> None: if self.econmodel.value == EconomicModel.SAM_SINGLE_OWNER_PPA: EconomicsSam.validate_read_parameters(model) else: - if self.royalty_rate.Provided: - raise NotImplementedError('Royalties are only supported for SAM Economic Models') + sam_em_only_params: list[Parameter] = [ + self.royalty_rate, + # TODO other royalty params + self.construction_capex_schedule, + self.bond_financing_start_year + ] + for sam_em_only_param in sam_em_only_params: + if sam_em_only_param.Provided: + raise NotImplementedError(f'{sam_em_only_param.Name} is only supported for SAM Economic Models') - # TODO validate that other SAM-EM-only parameters have not been provided else: model.logger.info("No parameters read because no content provided") @@ -3436,7 +3509,7 @@ def calculate_cashflow(self, model: Model) -> None: def _calculate_sam_economics(self, model: Model) -> None: non_calculated_output_placeholder_val = -1 - self.sam_economics_calculations = calculate_sam_economics(model) + self.sam_economics_calculations: SamEconomicsCalculations = calculate_sam_economics(model) # Setting capex_total distinguishes capex from CCap's display name of 'Total capital costs', # since SAM Economic Model doesn't subtract ITC from this value. @@ -3445,6 +3518,14 @@ def _calculate_sam_economics(self, model: Model) -> None: self.CCap.value = (self.sam_economics_calculations.capex.quantity() .to(self.CCap.CurrentUnits.value).magnitude) + self.overnight_capital_cost.value = (self.sam_economics_calculations.overnight_capital_cost.quantity() + .to(self.overnight_capital_cost.CurrentUnits.value).magnitude) + + self.interest_during_construction.value = quantity( + self.sam_economics_calculations.pre_revenue_costs_and_cash_flow.interest_during_construction_usd, + 'USD' + ).to(self.interest_during_construction.CurrentUnits.value).magnitude + if self.royalty_rate.Provided: # ignore pre-revenue year(s) (e.g. Year 0) diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index 0a58b8be..9b749cd1 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import math import os from dataclasses import dataclass, field from functools import lru_cache @@ -28,7 +27,7 @@ from tabulate import tabulate from geophires_x import Model as Model -from geophires_x.EconomicsSamCashFlow import _calculate_sam_economics_cash_flow +from geophires_x.EconomicsSamCashFlow import _calculate_sam_economics_cash_flow, _SAM_CASH_FLOW_NAN_STR from geophires_x.EconomicsUtils import ( BuildPricingModel, wacc_output_parameter, @@ -39,6 +38,14 @@ project_payback_period_parameter, total_capex_parameter_output_parameter, royalty_cost_output_parameter, + overnight_capital_cost_output_parameter, +) +from geophires_x.EconomicsSamPreRevenue import ( + _TOTAL_AFTER_TAX_RETURNS_CASH_FLOW_ROW_NAME, + PreRevenueCostsAndCashflow, + calculate_pre_revenue_costs_and_cashflow, + _calculate_pre_revenue_costs_and_cashflow, + adjust_phased_schedule_to_new_length, ) from geophires_x.GeoPHIRESUtils import is_float, is_int, sig_figs, quantity from geophires_x.OptionList import EconomicModel, EndUseOptions @@ -49,6 +56,7 @@ @dataclass class SamEconomicsCalculations: sam_cash_flow_profile: list[list[Any]] + pre_revenue_costs_and_cash_flow: PreRevenueCostsAndCashflow lcoe_nominal: OutputParameter = field( default_factory=lambda: OutputParameter( @@ -57,6 +65,8 @@ class SamEconomicsCalculations: ) ) + overnight_capital_cost: OutputParameter = field(default_factory=overnight_capital_cost_output_parameter) + capex: OutputParameter = field(default_factory=total_capex_parameter_output_parameter) royalties_opex: OutputParameter = field(default_factory=royalty_cost_output_parameter) @@ -77,8 +87,83 @@ class SamEconomicsCalculations: project_payback_period: OutputParameter = field(default_factory=project_payback_period_parameter) """TODO remove or clarify project payback period: https://github.com/NREL/GEOPHIRES-X/issues/413""" + @property + def _pre_revenue_years_count(self) -> int: + return len( + self.pre_revenue_costs_and_cash_flow.pre_revenue_cash_flow_profile_dict[ + _TOTAL_AFTER_TAX_RETURNS_CASH_FLOW_ROW_NAME + ] + ) + + @property + def sam_cash_flow_profile_all_years(self) -> list[list[Any]]: + ret: list[list[Any]] = self.sam_cash_flow_profile.copy() + col_count = len(self.sam_cash_flow_profile[0]) + + pre_revenue_years_to_insert = self._pre_revenue_years_count - 1 + + construction_rows: list[list[Any]] = [['CONSTRUCTION'] + [''] * (len(self.sam_cash_flow_profile[0]) - 1)] + + for row_index in range(len(self.sam_cash_flow_profile)): + pre_revenue_row_content = [''] * pre_revenue_years_to_insert + insert_index = 1 + + if row_index == 0: + for pre_revenue_year in range(pre_revenue_years_to_insert): + negative_year_index: int = self._pre_revenue_years_count - 1 - pre_revenue_year + pre_revenue_row_content[pre_revenue_year] = f'Year -{negative_year_index}' + + for _, row_ in enumerate(self.pre_revenue_costs_and_cash_flow.pre_revenue_cash_flow_profile): + pre_revenue_row = row_.copy() + pre_revenue_row.extend([''] * (col_count - len(pre_revenue_row))) + construction_rows.append(pre_revenue_row) + + # TODO zero-vectors e.g. Debt principal payment ($) + + adjusted_row = [ret[row_index][0]] + pre_revenue_row_content + ret[row_index][insert_index:] + ret[row_index] = adjusted_row + + construction_rows.append([''] * len(self.sam_cash_flow_profile[0])) + for construction_row in reversed(construction_rows): + ret.insert(1, construction_row) + + def _get_row_index(row_name_: str) -> list[Any]: + return [it[0] for it in ret].index(row_name_) + + def _get_row(row_name__: str) -> list[Any]: + for r in ret: + if r[0] == row_name__: + return r[1:] + + raise ValueError(f'Could not find row with name {row_name__}') + + after_tax_cash_flow: list[float] = ( + _get_row('Total after-tax returns [construction] ($)') + + _get_row('Total after-tax returns ($)')[self._pre_revenue_years_count :] + ) + after_tax_cash_flow = [float(it) for it in after_tax_cash_flow if is_float(it)] + npv_usd = [] + irr_pct = [] + for year in range(len(after_tax_cash_flow)): + npv_usd.append( + round( + npf.npv( + self.nominal_discount_rate.quantity().to('dimensionless').magnitude, + after_tax_cash_flow[: year + 1], + ) + ) + ) + + year_irr = npf.irr(after_tax_cash_flow[: year + 1]) * 100.0 + irr_pct.append(year_irr if not isnan(year_irr) else _SAM_CASH_FLOW_NAN_STR) + + ret[_get_row_index('After-tax cumulative NPV ($)')] = ['After-tax cumulative NPV ($)'] + npv_usd + ret[_get_row_index('After-tax cumulative IRR (%)')] = ['After-tax cumulative IRR (%)'] + irr_pct + + return ret -def validate_read_parameters(model: Model): + +def validate_read_parameters(model: Model) -> None: def _inv_msg(param_name: str, invalid_value: Any, supported_description: str) -> str: return ( f'Invalid {param_name} ({invalid_value}) for ' @@ -96,15 +181,6 @@ def _inv_msg(param_name: str, invalid_value: Any, supported_description: str) -> ) ) - if model.surfaceplant.construction_years.value != 1: - raise ValueError( - _inv_msg( - model.surfaceplant.construction_years.Name, - model.surfaceplant.construction_years.value, - f'{model.surfaceplant.construction_years.Name} = 1', - ) - ) - gtr: floatParameter = model.economics.GTR if gtr.Provided: model.logger.warning( @@ -118,6 +194,47 @@ def _inv_msg(param_name: str, invalid_value: Any, supported_description: str) -> f'{eir.Name} provided value ({eir.value}) will be ignored. (SAM Economics does not support {eir.Name}.)' ) + econ = model.economics + + econ.construction_capex_schedule.value = _validate_construction_capex_schedule( + econ.construction_capex_schedule, + model.surfaceplant.construction_years.value, + model.logger, + ) + + construction_years = model.surfaceplant.construction_years.value + if abs(econ.bond_financing_start_year.value) >= construction_years: + model.logger.debug( + f'{econ.bond_financing_start_year.Name} ({econ.bond_financing_start_year.value}) is earlier than ' + f'first {model.surfaceplant.construction_years.Name[:-1]} ({-1 * (construction_years - 1)}). (OK)' + ) + + +def _validate_construction_capex_schedule( + econ_capex_schedule: listParameter, construction_years: int, model_logger +) -> list[float]: + capex_schedule: list[float] = econ_capex_schedule.value.copy() + + adjust_schedule_reasons: list[str] = [] + if sum(capex_schedule) != 1.0: + adjust_schedule_reasons.append(f'does not sum to 1.0 (sums to {sum(capex_schedule)})') + + capex_schedule_len = len(capex_schedule) + if capex_schedule_len != construction_years: + adjust_schedule_reasons.append( + f'length ({capex_schedule_len}) does not match ' f'construction years ({construction_years})' + ) + + if len(adjust_schedule_reasons) > 0: + capex_schedule = adjust_phased_schedule_to_new_length(econ_capex_schedule.value, construction_years) + msg = f'{econ_capex_schedule.Name} ({econ_capex_schedule.value}) ' + msg += ' and '.join(adjust_schedule_reasons) + msg += f'. It has been adjusted to: {capex_schedule}' + + model_logger.warning(msg) + + return capex_schedule + @lru_cache(maxsize=12) def calculate_sam_economics(model: Model) -> SamEconomicsCalculations: @@ -165,19 +282,30 @@ def calculate_sam_economics(model: Model) -> SamEconomicsCalculations: def sf(_v: float, num_sig_figs: int = 5) -> float: return sig_figs(_v, num_sig_figs) - sam_economics: SamEconomicsCalculations = SamEconomicsCalculations(sam_cash_flow_profile=cash_flow) + sam_economics: SamEconomicsCalculations = SamEconomicsCalculations( + sam_cash_flow_profile=cash_flow, + pre_revenue_costs_and_cash_flow=calculate_pre_revenue_costs_and_cashflow(model), + ) + + sam_economics.overnight_capital_cost.value = ( + model.economics.CCap.quantity().to(sam_economics.overnight_capital_cost.CurrentUnits.value).magnitude + ) + sam_economics.lcoe_nominal.value = sf(single_owner.Outputs.lcoe_nom) sam_economics.after_tax_irr.value = sf(_get_after_tax_irr_pct(single_owner, cash_flow, model)) - sam_economics.project_npv.value = sf(single_owner.Outputs.project_return_aftertax_npv * 1e-6) + sam_economics.project_npv.value = sf(_get_project_npv_musd(single_owner, cash_flow, model)) sam_economics.capex.value = single_owner.Outputs.adjusted_installed_cost * 1e-6 if model.economics.royalty_rate.Provided: # Assumes that royalties opex is the only possible O&M production-based expense - this logic will need to be # updated if more O&M production-based expenses are added to SAM-EM sam_economics.royalties_opex.value = [ - quantity(it, 'USD / year').to(sam_economics.royalties_opex.CurrentUnits).magnitude - for it in _cash_flow_profile_row(cash_flow, 'O&M production-based expense ($)') + *_pre_revenue_years_vector(model), + *[ + quantity(it, 'USD / year').to(sam_economics.royalties_opex.CurrentUnits).magnitude + for it in _cash_flow_profile_row(cash_flow, 'O&M production-based expense ($)') + ], ] sam_economics.nominal_discount_rate.value, sam_economics.wacc.value = _calculate_nominal_discount_rate_and_wacc( @@ -190,15 +318,39 @@ def sf(_v: float, num_sig_figs: int = 5) -> float: return sam_economics +def _get_project_npv_musd(single_owner: Singleowner, cash_flow: list[list[Any]], model: Model) -> float: + """FIXME WIP""" + + pre_revenue_costs: PreRevenueCostsAndCashflow = calculate_pre_revenue_costs_and_cashflow(model) + pre_revenue_cash_flow = pre_revenue_costs.total_after_tax_returns_cash_flow_usd + operational_cash_flow = _cash_flow_profile_row(cash_flow, 'Total after-tax returns ($)') + combined_cash_flow = pre_revenue_cash_flow + operational_cash_flow[1:] + + # WIP + + true_npv_usd = npf.npv( + _calculate_nominal_discount_rate_and_wacc(model, single_owner)[0] / 100.0, combined_cash_flow + ) + return true_npv_usd * 1e-6 # Convert to M$ + + # return single_owner.Outputs.project_return_aftertax_npv * 1e-6 + + def _get_after_tax_irr_pct(single_owner: Singleowner, cash_flow: list[list[Any]], model: Model) -> float: - after_tax_irr_pct = single_owner.Outputs.project_return_aftertax_irr - if math.isnan(after_tax_irr_pct): - try: - after_tax_returns_cash_flow = _cash_flow_profile_row(cash_flow, 'Total after-tax returns ($)') - after_tax_irr_pct = npf.irr(after_tax_returns_cash_flow) * 100.0 - model.logger.info(f'After-tax IRR was NaN, calculated with numpy-financial: {after_tax_irr_pct}%') - except Exception as e: - model.logger.warning(f'After-tax IRR was NaN and calculation with numpy-financial failed: {e}') + pre_revenue_costs: PreRevenueCostsAndCashflow = calculate_pre_revenue_costs_and_cashflow(model) + pre_revenue_cash_flow = pre_revenue_costs.total_after_tax_returns_cash_flow_usd + operational_cash_flow = _cash_flow_profile_row(cash_flow, 'Total after-tax returns ($)') + combined_cash_flow = pre_revenue_cash_flow + operational_cash_flow[1:] + after_tax_irr_pct = npf.irr(combined_cash_flow) * 100.0 + + # after_tax_irr_pct = single_owner.Outputs.project_return_aftertax_irr + # if math.isnan(after_tax_irr_pct): + # try: + # after_tax_returns_cash_flow = _cash_flow_profile_row(cash_flow, 'Total after-tax returns ($)') + # after_tax_irr_pct = npf.irr(after_tax_returns_cash_flow) * 100.0 + # model.logger.info(f'After-tax IRR was NaN, calculated with numpy-financial: {after_tax_irr_pct}%') + # except Exception as e: + # model.logger.warning(f'After-tax IRR was NaN and calculation with numpy-financial failed: {e}') return after_tax_irr_pct @@ -212,6 +364,8 @@ def _calculate_nominal_discount_rate_and_wacc(model: Model, single_owner: Single Calculation per SAM Help -> Financial Parameters -> Commercial -> Commercial Loan Parameters -> WACC :return: tuple of Nominal Discount Rate (%), WACC (%) + + FIXME WIP account for Bond Financing Start Year & Construction Bond Interest Rate """ econ = model.economics @@ -307,7 +461,7 @@ def get_entry_display(entry: Any) -> str: return entry_display return entry - profile_display = model.economics.sam_economics_calculations.sam_cash_flow_profile.copy() + profile_display = model.economics.sam_economics_calculations.sam_cash_flow_profile_all_years.copy() for i in range(len(profile_display)): for j in range(len(profile_display[i])): profile_display[i][j] = get_entry_display(profile_display[i][j]) @@ -315,11 +469,15 @@ def get_entry_display(entry: Any) -> str: return tabulate(profile_display, **_tabulate_kw_args) +def _analysis_period(model: Model) -> int: + return model.surfaceplant.plant_lifetime.value # + _pre_revenue_years_count(model) - 1 + + def _get_custom_gen_parameters(model: Model) -> dict[str, Any]: # fmt:off ret: dict[str, Any] = { # Project lifetime - 'analysis_period': model.surfaceplant.plant_lifetime.value, + 'analysis_period': _analysis_period(model), 'user_capacity_factor': _pct(model.surfaceplant.utilization_factor), } # fmt:on @@ -327,9 +485,18 @@ def _get_custom_gen_parameters(model: Model) -> dict[str, Any]: return ret +def _pre_revenue_years_count(model: Model) -> int: + return model.surfaceplant.construction_years.value + + +def _pre_revenue_years_vector(model: Model, v: float = 0.0) -> list[float]: + return [v] * (_pre_revenue_years_count(model) - 1) + + def _get_utility_rate_parameters(m: Model) -> dict[str, Any]: econ = m.economics + # noinspection PyDictCreation ret: dict[str, Any] = {} ret['inflation_rate'] = econ.RINFL.quantity().to(convertible_unit('%')).magnitude @@ -355,37 +522,40 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]: # noinspection PyDictCreation ret: dict[str, Any] = {} - ret['analysis_period'] = model.surfaceplant.plant_lifetime.value + ret['analysis_period'] = _analysis_period(model) # SAM docs claim that specifying flip target year, aka "year in which you want the IRR to be achieved" influences # how after-tax cumulative IRR is reported (https://samrepo.nrelcloud.org/help/mtf_irr.html). This claim seems to # be erroneous, however, as setting this value appears to have no effect in either the SAM desktop app nor when # calling with PySAM. But, we set it here anyway for the sake of technical compliance. - ret['flip_target_year'] = model.surfaceplant.plant_lifetime.value + ret['flip_target_year'] = _analysis_period(model) - total_capex = econ.CCap.quantity() + total_overnight_capex_usd = econ.CCap.quantity().to('USD').magnitude + + total_installed_cost_usd: float + construction_financing_cost_usd: float + pre_revenue_costs: PreRevenueCostsAndCashflow = calculate_pre_revenue_costs_and_cashflow(model) + total_installed_cost_usd: float = pre_revenue_costs.total_installed_cost_usd + construction_financing_cost_usd: float = pre_revenue_costs.construction_financing_cost_usd - if econ.inflrateconstruction.Provided: - inflation_during_construction_factor = 1.0 + econ.inflrateconstruction.quantity().to('dimensionless').magnitude - else: - inflation_during_construction_factor = math.pow( - 1.0 + econ.RINFL.value, model.surfaceplant.construction_years.value - ) econ.accrued_financing_during_construction_percentage.value = ( - quantity(inflation_during_construction_factor - 1, 'dimensionless') + quantity(construction_financing_cost_usd / total_overnight_capex_usd, 'dimensionless') .to(convertible_unit(econ.accrued_financing_during_construction_percentage.CurrentUnits)) .magnitude ) econ.inflation_cost_during_construction.value = ( - (total_capex * (inflation_during_construction_factor - 1)) + quantity(pre_revenue_costs.inflation_cost_usd, 'USD') .to(econ.inflation_cost_during_construction.CurrentUnits) .magnitude ) - ret['total_installed_cost'] = (total_capex * inflation_during_construction_factor).to('USD').magnitude + + # Pass the final, correct values to SAM + ret['total_installed_cost'] = total_installed_cost_usd opex_musd = econ.Coam.value - ret['om_fixed'] = [opex_musd * 1e6] + ret['om_fixed'] = [opex_musd * 1e6] * model.surfaceplant.plant_lifetime.value + # GEOPHIRES assumes O&M fixed costs are not affected by inflation ret['om_fixed_escal'] = -1.0 * _pct(econ.RINFL) @@ -399,6 +569,7 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]: if econ.PTCElec.Provided: ret['ptc_fed_amount'] = [econ.PTCElec.quantity().to(convertible_unit('USD/kWh')).magnitude] + ret['ptc_fed_term'] = econ.PTCDuration.quantity().to(convertible_unit('yr')).magnitude if econ.PTCInflationAdjusted.value: @@ -414,8 +585,8 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]: if model.economics.royalty_rate.Provided: ret['om_production'] = _get_royalties_variable_om_USD_per_MWh_schedule(model) - # Debt/equity ratio ('Fraction of Investment in Bonds' parameter) - ret['debt_percent'] = _pct(econ.FIB) + # Debt/equity ratio + ret['debt_percent'] = pre_revenue_costs.effective_debt_percent # Interest rate ret['real_discount_rate'] = _pct(econ.discountrate) @@ -428,7 +599,7 @@ def _get_single_owner_parameters(model: Model) -> dict[str, Any]: if model.economics.DoAddOnCalculations.value: add_on_profit_per_year = np.sum(model.addeconomics.AddOnProfitGainedPerYear.quantity().to('USD/yr').magnitude) - add_on_profit_series = [add_on_profit_per_year] + add_on_profit_series = [add_on_profit_per_year] * model.surfaceplant.plant_lifetime.value ret['cp_capacity_payment_amount'] = add_on_profit_series ret['cp_capacity_payment_type'] = 1 diff --git a/src/geophires_x/EconomicsSamCashFlow.py b/src/geophires_x/EconomicsSamCashFlow.py index 814f0e9d..532dc837 100644 --- a/src/geophires_x/EconomicsSamCashFlow.py +++ b/src/geophires_x/EconomicsSamCashFlow.py @@ -14,6 +14,8 @@ import geophires_x.Model as Model +_SAM_CASH_FLOW_NAN_STR = 'NaN' + @lru_cache(maxsize=12) def _calculate_sam_economics_cash_flow(model: Model, single_owner: Singleowner) -> list[list[Any]]: @@ -25,7 +27,8 @@ def _calculate_sam_economics_cash_flow(model: Model, single_owner: Singleowner) # TODO this and/or related logic will need to be adjusted when multiple construction years are supported # https://github.com/NREL/GEOPHIRES-X/issues/406 - total_duration = model.surfaceplant.plant_lifetime.value + model.surfaceplant.construction_years.value + # FIXME WIP update above comment for impl + total_duration = model.surfaceplant.plant_lifetime.value + 1 # + model.surfaceplant.construction_years.value # Prefix with 'Year ' partially as workaround for tabulate applying float formatting to ints, possibly related # to https://github.com/astanin/python-tabulate/issues/18 @@ -60,7 +63,7 @@ def adj(x_): return x_ else: if math.isnan(x_): - return 'NaN' + return _SAM_CASH_FLOW_NAN_STR return rnd(x_) diff --git a/src/geophires_x/EconomicsSamPreRevenue.py b/src/geophires_x/EconomicsSamPreRevenue.py new file mode 100644 index 00000000..9c4ff5fc --- /dev/null +++ b/src/geophires_x/EconomicsSamPreRevenue.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +from geophires_x.GeoPHIRESUtils import is_float +from scipy.interpolate import interp1d + +_TOTAL_AFTER_TAX_RETURNS_CASH_FLOW_ROW_NAME = 'Total after-tax returns ($)' +_IDC_CASH_FLOW_ROW_NAME = 'Debt interest payment ($)' + + +@dataclass +class PreRevenueCostsAndCashflow: + total_installed_cost_usd: float + construction_financing_cost_usd: float + debt_balance_usd: float + inflation_cost_usd: float = 0.0 + + pre_revenue_cash_flow_profile: list[list[float | str]] = field(default_factory=list) + + @property + def effective_debt_percent(self) -> float: + return self.debt_balance_usd / self.total_installed_cost_usd * 100.0 + + @property + def total_after_tax_returns_cash_flow_usd(self): + return self.pre_revenue_cash_flow_profile_dict[_TOTAL_AFTER_TAX_RETURNS_CASH_FLOW_ROW_NAME] + + @property + def pre_revenue_cash_flow_profile_dict(self) -> dict[str, list[float]]: + """Maps SAM's row names (str) to a list of pre-revenue values""" + ret = {} + + for i in range(len(self.pre_revenue_cash_flow_profile)): + row_name = self.pre_revenue_cash_flow_profile[i][0] + if row_name == '': + continue + + row_name = row_name.replace(f'{_CONSTRUCTION_LINE_ITEM_DESIGNATOR} ', '') + + row_values = self.pre_revenue_cash_flow_profile[i][1:] + ret[row_name] = row_values + + return ret + + @property + def interest_during_construction_usd(self) -> float: + return sum( + [float(it) for it in self.pre_revenue_cash_flow_profile_dict[_IDC_CASH_FLOW_ROW_NAME] if is_float(it)] + ) + + +def calculate_pre_revenue_costs_and_cashflow(model: 'Model') -> PreRevenueCostsAndCashflow: + econ = model.economics + if econ.inflrateconstruction.Provided: + pre_revenue_inflation_rate = econ.inflrateconstruction.quantity().to('dimensionless').magnitude + else: + pre_revenue_inflation_rate = econ.RINFL.quantity().to('dimensionless').magnitude + + pre_revenue_bond_interest_rate_param = econ.BIR + if econ.bond_interest_rate_during_construction.Provided: + pre_revenue_bond_interest_rate_param = econ.bond_interest_rate_during_construction + pre_revenue_bond_interest_rate = pre_revenue_bond_interest_rate_param.quantity().to('dimensionless').magnitude + + construction_years: int = model.surfaceplant.construction_years.value + + # Translate from negative year index input value to start-year-0-indexed calculation value + debt_financing_start_year: int = max( + construction_years - abs(econ.bond_financing_start_year.value) - 1, + 0, # Treat bond financing years prior to construction as starting in the first year of construction + ) + + return _calculate_pre_revenue_costs_and_cashflow( + total_overnight_capex_usd=econ.CCap.quantity().to('USD').magnitude, + pre_revenue_years_count=construction_years, + phased_capex_schedule=econ.construction_capex_schedule.value, + pre_revenue_bond_interest_rate=pre_revenue_bond_interest_rate, + inflation_rate=pre_revenue_inflation_rate, + debt_fraction=econ.FIB.quantity().to('dimensionless').magnitude, + debt_financing_start_year=debt_financing_start_year, + logger=model.logger, + ) + + +_CONSTRUCTION_LINE_ITEM_DESIGNATOR = '[construction]' + + +def _calculate_pre_revenue_costs_and_cashflow( + total_overnight_capex_usd: float, + pre_revenue_years_count: int, + phased_capex_schedule: list[float], + pre_revenue_bond_interest_rate: float, + inflation_rate: float, + debt_fraction: float, + debt_financing_start_year: int, + logger: logging.Logger, + include_summary_line_items: bool = False, +) -> PreRevenueCostsAndCashflow: + """ + Calculates the true capitalized cost and interest during pre-revenue years (exploration/permitting/appraisal, + construction) by simulating a year-by-year phased expenditure with inflation. + + Also builds a pre-revenue cash flow profile for construction revenue years. + + :param include_summary_line_items: Include cash flow from investment and financing activities and pre-tax returns + in the summary line items. Disabled by default since they are redundant with other construction line items and + confusing to reconcile with their non-construction equivalents. + """ + + logger.info(f"Using Phased CAPEX Schedule: {phased_capex_schedule}") + + current_debt_balance_usd = 0.0 + total_capitalized_cost_usd = 0.0 + total_interest_accrued_usd = 0.0 + total_inflation_cost_usd = 0.0 + + capex_spend_vec: list[float] = [] + equity_spend_vec: list[float] = [] + debt_draw_vec: list[float] = [] + debt_balance_usd_vec: list[float] = [] + interest_accrued_vec: list[float] = [] + + for year_index in range(pre_revenue_years_count): + base_capex_this_year_usd = total_overnight_capex_usd * phased_capex_schedule[year_index] + + inflation_factor = (1.0 + inflation_rate) ** (year_index + 1) + inflation_cost_this_year_usd = base_capex_this_year_usd * (inflation_factor - 1.0) + + capex_this_year_usd = base_capex_this_year_usd + inflation_cost_this_year_usd + + # Interest is calculated on the opening balance (from previous years' draws) + interest_this_year_usd = current_debt_balance_usd * pre_revenue_bond_interest_rate + + debt_fraction_this_year = debt_fraction if year_index >= debt_financing_start_year else 0 + new_debt_draw_usd = capex_this_year_usd * debt_fraction_this_year + + # Equity spend is the cash portion of CAPEX not funded by new debt + equity_spent_this_year_usd = capex_this_year_usd - new_debt_draw_usd + + capex_spend_vec.append(capex_this_year_usd) + equity_spend_vec.append(equity_spent_this_year_usd) + debt_draw_vec.append(new_debt_draw_usd) + interest_accrued_vec.append(interest_this_year_usd) + + total_capitalized_cost_usd += capex_this_year_usd + interest_this_year_usd + total_interest_accrued_usd += interest_this_year_usd + total_inflation_cost_usd += inflation_cost_this_year_usd + + current_debt_balance_usd += new_debt_draw_usd + interest_this_year_usd + debt_balance_usd_vec.append(current_debt_balance_usd) + + logger.info( + f"Phased CAPEX calculation complete: " + f"Total Installed Cost: ${total_capitalized_cost_usd:,.2f}, " + f"Final Debt Balance: ${current_debt_balance_usd:,.2f}, " + f"Total Capitalized Interest: ${total_interest_accrued_usd:,.2f}" + ) + + pre_revenue_cf_profile: list[list[float | str]] = [] + + blank_row = [''] * len(capex_spend_vec) + + def _rnd(k_, v_: Any) -> Any: + return round(float(v_)) if k_.endswith('($)') and is_float(v_) else v_ + + def _append_row(row_name: str, row_vals: list[float | str]) -> None: + row_name_adjusted = row_name.split('(')[0] + f'{_CONSTRUCTION_LINE_ITEM_DESIGNATOR} (' + row_name.split('(')[1] + pre_revenue_cf_profile.append([row_name_adjusted] + [_rnd(row_name, it) for it in row_vals]) + + # --- Investing Activities --- + _append_row(f'Purchase of property ($)', [-x for x in capex_spend_vec]) + + if include_summary_line_items: + _append_row( + f'Cash flow from investing activities ($)', + # 'CAPEX spend ($)' + [-x for x in capex_spend_vec], + ) + + pre_revenue_cf_profile.append(blank_row.copy()) + + # --- Financing Activities --- + _append_row( + f'Issuance of equity ($)', + [abs(it) for it in equity_spend_vec], + ) + + _append_row( + # 'Debt draw ($)' + f'Issuance of debt ($)', + debt_draw_vec, + ) + + _append_row( + f'Debt balance ($)' + # 'Size of debt ($)' + , + debt_balance_usd_vec, + ) + + _append_row(_IDC_CASH_FLOW_ROW_NAME, interest_accrued_vec) + + if include_summary_line_items: + _append_row( + f'Cash flow from financing activities ($)', [e + d for e, d in zip(equity_spend_vec, debt_draw_vec)] + ) + + pre_revenue_cf_profile.append(blank_row.copy()) + + # --- Returns --- + equity_cash_flow_usd = [-x for x in equity_spend_vec] + + if include_summary_line_items: + _append_row(f'Total pre-tax returns ($)', equity_cash_flow_usd) + + _append_row(_TOTAL_AFTER_TAX_RETURNS_CASH_FLOW_ROW_NAME, equity_cash_flow_usd) + + return PreRevenueCostsAndCashflow( + total_installed_cost_usd=total_capitalized_cost_usd, + construction_financing_cost_usd=total_interest_accrued_usd, + debt_balance_usd=current_debt_balance_usd, + inflation_cost_usd=total_inflation_cost_usd, + pre_revenue_cash_flow_profile=pre_revenue_cf_profile, + ) + + +def adjust_phased_schedule_to_new_length(original_schedule: list[float], new_length: int) -> list[float]: + """ + Adjusts a schedule (list of fractions) to a new length by interpolation, + then normalizes the result to ensure it sums to 1.0. + + Args: + original_schedule: The initial list of fractional values. + new_length: The desired length of the new schedule. + + Returns: + A new schedule of the desired length with its values summing to 1.0. + """ + + if new_length < 1: + raise ValueError + + if not original_schedule: + raise ValueError + + original_len = len(original_schedule) + if original_len == new_length: + # Even if lengths match, we must normalize to ensure sum is 1.0 + total = sum(original_schedule) + if total == 0: + return [1.0 / new_length] * new_length + return [x / total for x in original_schedule] + + if original_len == 1: + # Interpolation is not possible with a single value; return a constant schedule + return [1.0 / new_length] * new_length + + # Create an interpolation function based on the original schedule + x_original = np.arange(original_len) + y_original = np.array(original_schedule) + + # Use linear interpolation, and extrapolate if the new schedule is longer + f = interp1d(x_original, y_original, kind='nearest', fill_value="extrapolate") + + # Create new x-points for the desired length + x_new = np.linspace(0, original_len - 1, new_length) + + # Get the new, projected y-values + y_new = f(x_new) + + # Normalize the new schedule so it sums to 1.0 + total = np.sum(y_new) + if total == 0: + # Avoid division by zero; return an equal distribution + return [1.0 / new_length] * new_length + + normalized_schedule = (y_new / total).tolist() + return normalized_schedule diff --git a/src/geophires_x/EconomicsUtils.py b/src/geophires_x/EconomicsUtils.py index ecce0953..8e93ffc2 100644 --- a/src/geophires_x/EconomicsUtils.py +++ b/src/geophires_x/EconomicsUtils.py @@ -4,8 +4,14 @@ from geophires_x.Units import Units, PercentUnit, TimeUnit, CurrencyUnit, CurrencyFrequencyUnit -def BuildPricingModel(plantlifetime: int, StartPrice: float, EndPrice: float, - EscalationStartYear: int, EscalationRate: float, PTCAddition: list) -> list: +def BuildPricingModel( + plantlifetime: int, + StartPrice: float, + EndPrice: float, + EscalationStartYear: int, + EscalationRate: float, + PTCAddition: list, +) -> list: """ BuildPricingModel builds the price model array for the project lifetime. It is used to calculate the revenue stream for the project. @@ -41,11 +47,11 @@ def moic_parameter() -> OutputParameter: return OutputParameter( "Project MOIC", ToolTipText='Project Multiple of Invested Capital. For SAM Economic Models, this is calculated as the ' - 'sum of Total pre-tax returns (total value received) ' - 'divided by Issuance of equity (total capital invested).', + 'sum of Total pre-tax returns (total value received) ' + 'divided by Issuance of equity (total capital invested).', UnitType=Units.PERCENT, PreferredUnits=PercentUnit.TENTH, - CurrentUnits=PercentUnit.TENTH + CurrentUnits=PercentUnit.TENTH, ) @@ -55,7 +61,7 @@ def project_vir_parameter() -> OutputParameter: display_name='Project VIR=PI=PIR', UnitType=Units.PERCENT, PreferredUnits=PercentUnit.TENTH, - CurrentUnits=PercentUnit.TENTH + CurrentUnits=PercentUnit.TENTH, ) @@ -66,8 +72,8 @@ def project_payback_period_parameter() -> OutputParameter: PreferredUnits=TimeUnit.YEAR, CurrentUnits=TimeUnit.YEAR, ToolTipText='The time at which cumulative cash flow reaches zero. ' - 'For projects that never pay back, the calculated value will be "N/A". ' - 'For SAM Economic Models, total after-tax returns are used to calculate cumulative cash flow.', + 'For projects that never pay back, the calculated value will be "N/A". ' + 'For SAM Economic Models, total after-tax returns are used to calculate cumulative cash flow.', ) @@ -78,10 +84,10 @@ def after_tax_irr_parameter() -> OutputParameter: CurrentUnits=PercentUnit.PERCENT, PreferredUnits=PercentUnit.PERCENT, ToolTipText='The After-tax IRR (internal rate of return) is the nominal discount rate that corresponds to ' - 'a net present value (NPV) of zero for PPA SAM Economic models. ' - 'See https://samrepo.nrelcloud.org/help/mtf_irr.html. If SAM calculates After-tax IRR as NaN, ' - 'numpy-financial.irr (https://numpy.org/numpy-financial/latest/irr.html) ' - 'is used to calculate the value from SAM\'s total after-tax returns.' + 'a net present value (NPV) of zero for PPA SAM Economic models. ' + 'See https://samrepo.nrelcloud.org/help/mtf_irr.html. If SAM calculates After-tax IRR as NaN, ' + 'numpy-financial.irr (https://numpy.org/numpy-financial/latest/irr.html) ' + 'is used to calculate the value from SAM\'s total after-tax returns.', ) @@ -98,10 +104,10 @@ def nominal_discount_rate_parameter() -> OutputParameter: return OutputParameter( Name="Nominal Discount Rate", ToolTipText="Nominal Discount Rate is displayed for SAM Economic Models. " - "It is calculated " - "per https://samrepo.nrelcloud.org/help/fin_single_owner.html?q=nominal+discount+rate: " - "Nominal Discount Rate = [ ( 1 + Real Discount Rate ÷ 100 ) " - "× ( 1 + Inflation Rate ÷ 100 ) - 1 ] × 100.", + "It is calculated " + "per https://samrepo.nrelcloud.org/help/fin_single_owner.html?q=nominal+discount+rate: " + "Nominal Discount Rate = [ ( 1 + Real Discount Rate ÷ 100 ) " + "× ( 1 + Inflation Rate ÷ 100 ) - 1 ] × 100.", UnitType=Units.PERCENT, CurrentUnits=PercentUnit.PERCENT, PreferredUnits=PercentUnit.PERCENT, @@ -112,24 +118,49 @@ def wacc_output_parameter() -> OutputParameter: return OutputParameter( Name='WACC', ToolTipText='Weighted Average Cost of Capital displayed for SAM Economic Models. ' - 'It is calculated per https://samrepo.nrelcloud.org/help/fin_commercial.html?q=wacc: ' - 'WACC = [ Nominal Discount Rate ÷ 100 × (1 - Debt Percent ÷ 100) ' - '+ Debt Percent ÷ 100 × Loan Rate ÷ 100 × (1 - Effective Tax Rate ÷ 100 ) ] × 100; ' - 'Effective Tax Rate = [ Federal Tax Rate ÷ 100 × ( 1 - State Tax Rate ÷ 100 ) ' - '+ State Tax Rate ÷ 100 ] × 100; ', + 'It is calculated per https://samrepo.nrelcloud.org/help/fin_commercial.html?q=wacc: ' + 'WACC = [ Nominal Discount Rate ÷ 100 × (1 - Debt Percent ÷ 100) ' + '+ Debt Percent ÷ 100 × Loan Rate ÷ 100 × (1 - Effective Tax Rate ÷ 100 ) ] × 100; ' + 'Effective Tax Rate = [ Federal Tax Rate ÷ 100 × ( 1 - State Tax Rate ÷ 100 ) ' + '+ State Tax Rate ÷ 100 ] × 100; ', UnitType=Units.PERCENT, CurrentUnits=PercentUnit.PERCENT, PreferredUnits=PercentUnit.PERCENT, ) +def overnight_capital_cost_output_parameter() -> OutputParameter: + return OutputParameter( + Name='Overnight Capital Cost', + UnitType=Units.CURRENCY, + PreferredUnits=CurrencyUnit.MDOLLARS, + CurrentUnits=CurrencyUnit.MDOLLARS, + ToolTipText='Overnight Capital Cost (OCC) represents the total capital cost required ' + 'to construct the plant if it were built instantly ("overnight"). ' + 'This value excludes time-dependent costs such as inflation and ' + 'interest incurred during the construction period.', + ) + + def inflation_cost_during_construction_output_parameter() -> OutputParameter: return OutputParameter( Name='Inflation costs during construction', UnitType=Units.CURRENCY, PreferredUnits=CurrencyUnit.MDOLLARS, CurrentUnits=CurrencyUnit.MDOLLARS, - ToolTipText='The calculated amount of cost escalation due to inflation over the construction period.' + ToolTipText='The calculated amount of cost escalation due to inflation over the construction period.', + ) + + +def interest_during_construction_output_parameter() -> OutputParameter: + return OutputParameter( + Name='Interest during construction', + UnitType=Units.CURRENCY, + PreferredUnits=CurrencyUnit.MDOLLARS, + CurrentUnits=CurrencyUnit.MDOLLARS, + ToolTipText='Interest During Construction (IDC) is the total accumulated interest ' + 'incurred on debt during the construction phase. This cost is capitalized ' + '(added to the loan principal and total installed cost) rather than paid in cash.', ) @@ -140,18 +171,18 @@ def total_capex_parameter_output_parameter() -> OutputParameter: CurrentUnits=CurrencyUnit.MDOLLARS, PreferredUnits=CurrencyUnit.MDOLLARS, ToolTipText='The total capital expenditure (CAPEX) required to construct the plant. ' - 'This value includes all direct and indirect costs, and contingency. ' - 'For SAM Economic models, it also includes any cost escalation from inflation during construction. ' - 'It is used as the total installed cost input for SAM Economic Models.' + 'This value includes all direct and indirect costs, and contingency. ' + 'For SAM Economic models, it also includes any cost escalation from inflation during construction. ' + 'It is used as the total installed cost input for SAM Economic Models.', ) def royalty_cost_output_parameter() -> OutputParameter: return OutputParameter( - Name='Royalty Cost', - UnitType=Units.CURRENCYFREQUENCY, - PreferredUnits=CurrencyFrequencyUnit.DOLLARSPERYEAR, - CurrentUnits=CurrencyFrequencyUnit.DOLLARSPERYEAR, - ToolTipText='The annual costs paid to a royalty holder, calculated as a percentage of the ' - 'project\'s gross annual revenue. This is modeled as a variable operating expense.' - ) + Name='Royalty Cost', + UnitType=Units.CURRENCYFREQUENCY, + PreferredUnits=CurrencyFrequencyUnit.DOLLARSPERYEAR, + CurrentUnits=CurrencyFrequencyUnit.DOLLARSPERYEAR, + ToolTipText='The annual costs paid to a royalty holder, calculated as a percentage of the ' + 'project\'s gross annual revenue. This is modeled as a variable operating expense.', + ) diff --git a/src/geophires_x/Outputs.py b/src/geophires_x/Outputs.py index 6dbd57c6..de083546 100644 --- a/src/geophires_x/Outputs.py +++ b/src/geophires_x/Outputs.py @@ -274,9 +274,10 @@ def PrintOutputs(self, model: Model): label = Outputs._field_label(field.Name, 49) f.write(f' {label}{field.value:10.2f} {field.CurrentUnits.value}\n') - acf: OutputParameter = econ.accrued_financing_during_construction_percentage - acf_label = Outputs._field_label(acf.display_name, 49) - f.write(f' {acf_label}{acf.value:10.2f} {acf.CurrentUnits.value}\n') + if not is_sam_econ_model: # (parameter is ambiguous to the point of meaninglessness for SAM-EM) + acf: OutputParameter = econ.accrued_financing_during_construction_percentage + acf_label = Outputs._field_label(acf.display_name, 49) + f.write(f' {acf_label}{acf.value:10.2f} {acf.CurrentUnits.value}\n') display_inflation_costs_in_economic_parameters: bool = ( econ.econmodel.value in [EconomicModel.BICYCLE, @@ -514,15 +515,27 @@ def PrintOutputs(self, model: Model): # expenditure. pass - display_inflation_during_construction_in_capital_costs = is_sam_econ_model - if display_inflation_during_construction_in_capital_costs: + display_occ_and_inflation_during_construction_in_capital_costs = is_sam_econ_model + if display_occ_and_inflation_during_construction_in_capital_costs: + occ_label = Outputs._field_label(econ.overnight_capital_cost.display_name, 47) + f.write( + f' {occ_label}{econ.overnight_capital_cost.value:10.2f} {econ.overnight_capital_cost.CurrentUnits.value}\n') + + display_idc_in_capital_costs = is_sam_econ_model \ + and model.surfaceplant.construction_years.value > 1 + if display_idc_in_capital_costs: + idc_label = Outputs._field_label(econ.interest_during_construction.display_name, 47) + f.write( + f' {idc_label}{econ.interest_during_construction.value:10.2f} {econ.interest_during_construction.CurrentUnits.value}\n') + + if display_occ_and_inflation_during_construction_in_capital_costs: icc_label = Outputs._field_label(econ.inflation_cost_during_construction.display_name, 47) f.write(f' {icc_label}{econ.inflation_cost_during_construction.value:10.2f} {econ.inflation_cost_during_construction.CurrentUnits.value}\n') - if econ.DoAddOnCalculations.value: - # Non-SAM econ models print this in Extended Economics profile - aoc_label = Outputs._field_label(model.addeconomics.AddOnCAPEXTotal.display_name, 47) - f.write(f' {aoc_label}{model.addeconomics.AddOnCAPEXTotal.value:10.2f} {model.addeconomics.AddOnCAPEXTotal.CurrentUnits.value}\n') + if is_sam_econ_model and econ.DoAddOnCalculations.value: + # Non-SAM econ models print this in Extended Economics profile + aoc_label = Outputs._field_label(model.addeconomics.AddOnCAPEXTotal.display_name, 47) + f.write(f' {aoc_label}{model.addeconomics.AddOnCAPEXTotal.value:10.2f} {model.addeconomics.AddOnCAPEXTotal.CurrentUnits.value}\n') capex_param = econ.CCap if not is_sam_econ_model else econ.capex_total capex_label = Outputs._field_label(capex_param.display_name, 50) diff --git a/src/geophires_x/Parameter.py b/src/geophires_x/Parameter.py index 135df672..8d725336 100644 --- a/src/geophires_x/Parameter.py +++ b/src/geophires_x/Parameter.py @@ -428,7 +428,18 @@ def _read_list_parameter(ParameterReadIn: ParameterEntry, ParamToModify, model) :type model: :class:`~geophires_x.Model.Model` """ - if ' ' in ParamToModify.Name: + def _is_int(o: Any) -> bool: + try: + float_n = float(o) + int_n = int(float_n) + except ValueError: + return False + else: + return float_n == int_n + + is_positional_parameter = ' ' in ParameterReadIn.Name and _is_int(ParamToModify.Name.split(' ')[-1]) + #if ' ' in ParamToModify.Name: + if is_positional_parameter: New_val = float(ParameterReadIn.sValue) # Some list parameters are read in with enumerated parameter names; in these cases we use the last # character of the description to get the position i.e., "Gradient 1" is position 0. diff --git a/src/geophires_x/SurfacePlant.py b/src/geophires_x/SurfacePlant.py index 9ac33f6d..798cbcca 100644 --- a/src/geophires_x/SurfacePlant.py +++ b/src/geophires_x/SurfacePlant.py @@ -4,6 +4,7 @@ from .OptionList import EndUseOptions, PlantType from .Parameter import floatParameter, intParameter, OutputParameter, ReadParameter, \ coerce_int_params_to_enum_values +from .SurfacePlantUtils import MAX_CONSTRUCTION_YEARS from .Units import * import geophires_x.Model as Model @@ -408,15 +409,17 @@ def __init__(self, model: Model): ErrMessage="assume default heat rate ($0.02/kWh)", ToolTipText="Price of heat to calculate revenue from heat sales in CHP mode." ) + + default_construction_years = 1 self.construction_years = self.ParameterDict[self.construction_years.Name] = intParameter( "Construction Years", - DefaultValue=1, - AllowableRange=list(range(1, 15, 1)), + DefaultValue=default_construction_years, + AllowableRange=list(range(1, MAX_CONSTRUCTION_YEARS + 1, 1)), UnitType=Units.NONE, - ErrMessage="assume default number of years in construction (1)", + ErrMessage=f'assume default number of years in construction ({default_construction_years})', ToolTipText='Number of years spent in construction (assumes whole years, no fractions). ' - 'Capital costs are spread evenly over construction years e.g. if total capital costs are ' - '$500M and there are 2 construction years, ' + 'By default, capital costs are spread evenly over construction years e.g. if total capital ' + 'costs are $500M and there are 2 construction years, ' 'then $250M will be spent in both the first and second construction years.' ) self.cp_fluid = self.ParameterDict[self.cp_fluid.Name] = floatParameter( diff --git a/src/geophires_x/SurfacePlantUtils.py b/src/geophires_x/SurfacePlantUtils.py new file mode 100644 index 00000000..7dc5650d --- /dev/null +++ b/src/geophires_x/SurfacePlantUtils.py @@ -0,0 +1 @@ +MAX_CONSTRUCTION_YEARS = 15 diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index 72e0110f..2c05c344 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.65' +__version__ = '3.10.14' diff --git a/src/geophires_x_client/geophires_x_result.py b/src/geophires_x_client/geophires_x_result.py index e4d143fa..91b14c26 100644 --- a/src/geophires_x_client/geophires_x_result.py +++ b/src/geophires_x_client/geophires_x_result.py @@ -266,8 +266,10 @@ class GeophiresXResult: 'Total surface equipment costs', 'Exploration costs', 'Investment Tax Credit', + 'Overnight Capital Cost', # Displayed for economic models that treat inflation costs as capital costs (SAM-EM) 'Inflation costs during construction', + 'Interest during construction', 'Total Add-on CAPEX', 'Total capital costs', 'Annualized capital costs', diff --git a/src/geophires_x_schema_generator/geophires-request.json b/src/geophires_x_schema_generator/geophires-request.json index 0788b878..b974f6e1 100644 --- a/src/geophires_x_schema_generator/geophires-request.json +++ b/src/geophires_x_schema_generator/geophires-request.json @@ -1261,13 +1261,13 @@ "maximum": 1.0 }, "Construction Years": { - "description": "Number of years spent in construction (assumes whole years, no fractions). Capital costs are spread evenly over construction years e.g. if total capital costs are $500M and there are 2 construction years, then $250M will be spent in both the first and second construction years.", + "description": "Number of years spent in construction (assumes whole years, no fractions). By default, capital costs are spread evenly over construction years e.g. if total capital costs are $500M and there are 2 construction years, then $250M will be spent in both the first and second construction years.", "type": "integer", "units": null, "category": "Surface Plant", "default": 1, "minimum": 1, - "maximum": 14 + "maximum": 15 }, "Working Fluid Heat Capacity": { "description": "Heat capacity of the working fluid", @@ -1684,7 +1684,7 @@ "maximum": null }, "Fraction of Investment in Bonds": { - "description": "Fraction of geothermal project financing through bonds (debt).", + "description": "Fraction of geothermal project financing through bonds (debt/loans).", "type": "number", "units": "", "category": "Economics", @@ -1693,7 +1693,16 @@ "maximum": 1.0 }, "Inflated Bond Interest Rate": { - "description": "Inflated bond interest rate (see docs)", + "description": "Inflated bond interest rate (for debt/loans)", + "type": "number", + "units": "", + "category": "Economics", + "default": 0.05, + "minimum": 0.0, + "maximum": 1.0 + }, + "Inflated Bond Interest Rate During Construction": { + "description": "Inflated bond interest rate during construction (for debt/loans)", "type": "number", "units": "", "category": "Economics", @@ -1764,6 +1773,26 @@ "minimum": 0.0, "maximum": 1.0 }, + "Construction CAPEX Schedule": { + "description": "A list of fractions of the total overnight CAPEX spent in each construction year. For example, for 3 construction years with 10% in the first year, 40% in the second, and 50% in the third, provide Construction CAPEX Schedule = 0.1,0.4,0.5. The schedule will be automatically interpolated to match the number of construction years and normalized to sum to 1.0.", + "type": "array", + "units": null, + "category": "Economics", + "default": [ + 1.0 + ], + "minimum": 0.0, + "maximum": 1.0 + }, + "Bond Financing Start Year": { + "description": "By default, bond financing (debt/loans) starts during the first construction year (if Fraction of Investment in Bonds is >0). Provide Bond Financing Start Year to delay the start of bond financing during construction; years prior to Bond Financing Start Year will be financed with equity only. The value is specified as a project year index corresponding to the Year row in the cash flow profile; the first construction year has the year index {(Construction Years - 1) * -1}) and the final construction year index is 0. For example, a project with 4 construction years where bond financing starts on the third construction year would have a Bond Financing Start Year value of -1; construction starts in Year -3, the second year is Year -2, and the final 2 bond-financed construction years are Year -1 and Year 0. Bond financing will start on the first construction year if the specified year index is prior to the first construction year.", + "type": "integer", + "units": "yr", + "category": "Economics", + "default": -14, + "minimum": -14, + "maximum": 0 + }, "Contingency Percentage": { "description": "The contingency percentage applied to the direct capital costs for stimulation, field gathering system, exploration, and surface plant. (Note: well drilling and completion costs do not have contingency applied and are not affected by this parameter.)", "type": "number", diff --git a/src/geophires_x_schema_generator/geophires-result.json b/src/geophires_x_schema_generator/geophires-result.json index d14644b6..59eb9969 100644 --- a/src/geophires_x_schema_generator/geophires-result.json +++ b/src/geophires_x_schema_generator/geophires-result.json @@ -101,7 +101,7 @@ }, "Accrued financing during construction": { "type": "number", - "description": "The accrued inflation on total capital costs over the construction period, as defined by Inflation Rate During Construction. For SAM Economic Models, this is calculated automatically by compounding Inflation Rate over Construction Years if Inflation Rate During Construction is not provided.", + "description": "The accrued inflation on total capital costs over the construction period, as defined by Inflation Rate During Construction.", "units": "%" }, "Inflation costs during construction": { @@ -442,11 +442,21 @@ "description": "Investment Tax Credit Value", "units": "MUSD" }, + "Overnight Capital Cost": { + "type": "number", + "description": "Overnight Capital Cost (OCC) represents the total capital cost required to construct the plant if it were built instantly (\"overnight\"). This value excludes time-dependent costs such as inflation and interest incurred during the construction period.", + "units": "MUSD" + }, "Inflation costs during construction": { "type": "number", "description": "The calculated amount of cost escalation due to inflation over the construction period.", "units": "MUSD" }, + "Interest during construction": { + "type": "number", + "description": "Interest During Construction (IDC) is the total accumulated interest incurred on debt during the construction phase. This cost is capitalized (added to the loan principal and total installed cost) rather than paid in cash.", + "units": "MUSD" + }, "Total Add-on CAPEX": { "type": "number", "description": "AddOn CAPEX Total", diff --git a/tests/.gitignore b/tests/.gitignore index 028f74fc..dd02483d 100644 --- a/tests/.gitignore +++ b/tests/.gitignore @@ -3,3 +3,5 @@ HIP.out MC_*Result.json MC_*Result.txt *.png +examples/Deadwood_M8.txt +examples/Doublet_v1.dat diff --git a/tests/base_test_case.py b/tests/base_test_case.py index c500ce3c..5311786d 100644 --- a/tests/base_test_case.py +++ b/tests/base_test_case.py @@ -30,7 +30,10 @@ def assertAlmostEqualWithinPercentage(self, expected, actual, msg: str | None = try: self.assertAlmostEqual(expected, actual, msg=msg, delta=abs(percent / 100.0 * expected)) except AssertionError as ae: - difference_percent = abs(100.0 * (actual - expected) / expected) + try: + difference_percent = abs(100.0 * (actual - expected) / expected) + except ZeroDivisionError: + difference_percent = float('nan') raise AssertionError(f'{actual} != {expected} within {percent}% ({difference_percent:.2f}%)') from ae else: if isinstance(expected, list) and isinstance(actual, list): @@ -103,5 +106,10 @@ def assertFileContentsEqual(self, expected, actual): self.assertListEqual(f1_lines, f2_lines, msg=f'{expected}, {actual}') # noinspection PyPep8Naming,PyMethodMayBeStatic - def assertHasLogRecordWithMessage(self, logs_, message): - assert message in [record.message for record in logs_.records] + def assertHasLogRecordWithMessage(self, logs_, message, treat_substring_match_as_match: bool = False): + messages = [record.message for record in logs_.records] + assert any(it == message or (treat_substring_match_as_match and message in it) for it in messages) + + # noinspection PyMethodMayBeStatic + def _is_github_actions(self): + return 'CI' in os.environ or 'TOXPYTHON' in os.environ diff --git a/tests/examples/Fervo_Project_Cape-4.out b/tests/examples/Fervo_Project_Cape-4.out index f37410a8..f9d3561b 100644 --- a/tests/examples/Fervo_Project_Cape-4.out +++ b/tests/examples/Fervo_Project_Cape-4.out @@ -4,10 +4,10 @@ Simulation Metadata ---------------------- - GEOPHIRES Version: 3.9.48 - Simulation Date: 2025-08-11 - Simulation Time: 10:37 - Calculation Time: 1.730 sec + GEOPHIRES Version: 3.10.13 + Simulation Date: 2025-11-29 + Simulation Time: 11:36 + Calculation Time: 1.769 sec ***SUMMARY OF RESULTS*** @@ -28,7 +28,6 @@ Simulation Metadata Real Discount Rate: 12.00 % Nominal Discount Rate: 14.58 % WACC: 8.30 % - Accrued financing during construction: 2.30 % Project lifetime: 30 yr Capacity factor: 90.0 % Project NPV: 483.35 MUSD @@ -104,6 +103,7 @@ Simulation Metadata Field gathering system costs: 56.44 MUSD Total surface equipment costs: 1560.49 MUSD Exploration costs: 30.00 MUSD + Overnight Capital Cost: 2601.04 MUSD Inflation costs during construction: 59.82 MUSD Total CAPEX: 2660.87 MUSD @@ -214,6 +214,16 @@ Simulation Metadata *************************** ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Year 0 Year 1 Year 2 Year 3 Year 4 Year 5 Year 6 Year 7 Year 8 Year 9 Year 10 Year 11 Year 12 Year 13 Year 14 Year 15 Year 16 Year 17 Year 18 Year 19 Year 20 Year 21 Year 22 Year 23 Year 24 Year 25 Year 26 Year 27 Year 28 Year 29 Year 30 +CONSTRUCTION +Purchase of property [construction] ($) -2,660,866,376 + +Issuance of equity [construction] ($) 1,064,346,550 +Issuance of debt [construction] ($) 1,596,519,826 +Debt balance [construction] ($) 1,596,519,826 +Debt interest payment [construction] ($) 0 + +Total after-tax returns [construction] ($) -1,064,346,550 + ENERGY Electricity to grid (kWh) 0.0 4,193,273,525 4,219,573,970 4,227,516,388 4,232,001,035 4,233,245,126 4,225,570,913 4,194,325,606 4,114,710,394 4,101,737,992 4,212,547,398 4,224,519,385 4,230,441,060 4,233,667,186 4,231,750,368 4,214,888,525 4,163,207,043 4,055,985,743 4,194,671,056 4,220,853,770 4,228,811,003 4,233,321,393 4,234,588,146 4,226,928,684 4,195,686,141 4,116,056,232 4,103,061,353 4,213,834,812 4,225,738,401 4,231,569,184 4,234,649,495 Electricity from grid (kWh) 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 @@ -305,7 +315,7 @@ State tax benefit (liability) ($) 0 -6,640,769 - Total after-tax returns ($) -1,064,346,550 902,842,830 121,097,763 123,082,690 124,811,004 126,292,727 127,116,866 126,217,632 121,770,976 122,079,444 131,404,893 133,524,258 135,176,596 136,602,262 137,613,009 137,463,025 134,627,892 127,485,460 139,023,528 141,981,439 143,506,316 129,720,834 115,634,823 115,795,735 114,030,354 108,335,718 107,831,906 117,184,960 118,583,135 119,424,446 1,097,437,209 After-tax cumulative IRR (%) NaN -15.17 -3.40 5.89 12.37 16.79 19.80 21.87 23.28 24.29 25.09 25.68 26.13 26.46 26.72 26.91 27.06 27.16 27.25 27.32 27.37 27.41 27.44 27.46 27.47 27.49 27.49 27.50 27.51 27.51 27.55 -After-tax cumulative NPV ($) -1,064,346,550 -276,360,559 -184,114,290 -102,283,638 -29,860,348 34,099,888 90,287,586 138,980,351 179,981,359 215,856,997 249,560,495 279,450,773 305,861,334 329,155,135 349,636,000 367,491,871 382,754,753 395,369,208 407,375,323 418,077,007 427,517,570 434,965,626 440,760,289 445,824,811 450,177,651 453,787,012 456,922,551 459,896,566 462,523,206 464,831,957 483,348,930 +After-tax cumulative NPV ($) -1,064,346,550 -276,360,558 -184,114,290 -102,283,638 -29,860,348 34,099,889 90,287,587 138,980,351 179,981,359 215,856,998 249,560,495 279,450,773 305,861,334 329,155,135 349,636,000 367,491,872 382,754,754 395,369,209 407,375,324 418,077,007 427,517,570 434,965,626 440,760,290 445,824,812 450,177,652 453,787,012 456,922,551 459,896,566 462,523,206 464,831,958 483,348,931 AFTER-TAX LCOE AND PPA PRICE Annual costs ($) -1,064,346,550 504,481,845 -279,761,764 -280,941,051 -282,053,576 -283,104,409 -283,946,673 -284,197,129 -283,198,821 -283,951,600 -287,996,326 -289,476,868 -290,828,818 -292,141,214 -293,348,448 -294,183,709 -294,099,170 -292,511,863 -297,725,623 -299,899,742 -301,618,330 -318,291,569 -334,925,356 -336,358,826 -337,173,733 -336,651,121 -338,088,802 -343,176,493 -345,487,456 -347,698,476 627,560,501 diff --git a/tests/examples/example_SAM-single-owner-PPA-2.out b/tests/examples/example_SAM-single-owner-PPA-2.out index 0dada8ab..8eb7c26e 100644 --- a/tests/examples/example_SAM-single-owner-PPA-2.out +++ b/tests/examples/example_SAM-single-owner-PPA-2.out @@ -4,10 +4,10 @@ Simulation Metadata ---------------------- - GEOPHIRES Version: 3.9.40 - Simulation Date: 2025-07-26 - Simulation Time: 14:13 - Calculation Time: 0.967 sec + GEOPHIRES Version: 3.10.13 + Simulation Date: 2025-11-29 + Simulation Time: 11:36 + Calculation Time: 0.972 sec ***SUMMARY OF RESULTS*** @@ -28,7 +28,6 @@ Simulation Metadata Real Discount Rate: 7.00 % Nominal Discount Rate: 9.14 % WACC: 6.41 % - Accrued financing during construction: 5.00 % Project lifetime: 20 yr Capacity factor: 90.0 % Project NPV: 2877.00 MUSD @@ -105,6 +104,7 @@ Simulation Metadata Field gathering system costs: 70.43 MUSD Total surface equipment costs: 969.26 MUSD Exploration costs: 30.00 MUSD + Overnight Capital Cost: 1532.78 MUSD Inflation costs during construction: 76.64 MUSD Total CAPEX: 1609.42 MUSD @@ -194,6 +194,16 @@ Simulation Metadata *************************** ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Year 0 Year 1 Year 2 Year 3 Year 4 Year 5 Year 6 Year 7 Year 8 Year 9 Year 10 Year 11 Year 12 Year 13 Year 14 Year 15 Year 16 Year 17 Year 18 Year 19 Year 20 +CONSTRUCTION +Purchase of property [construction] ($) -1,609,421,820 + +Issuance of equity [construction] ($) 804,710,910 +Issuance of debt [construction] ($) 804,710,910 +Debt balance [construction] ($) 804,710,910 +Debt interest payment [construction] ($) 0 + +Total after-tax returns [construction] ($) -804,710,910 + ENERGY Electricity to grid (kWh) 0.0 3,161,197,316 3,175,786,856 3,180,379,788 3,183,105,655 3,185,018,384 3,186,478,581 3,187,652,210 3,188,628,989 3,189,462,709 3,190,188,061 3,190,828,668 3,191,401,306 3,191,918,303 3,192,388,967 3,192,820,492 3,193,218,548 3,193,587,678 3,193,931,576 3,194,253,285 3,194,540,808 Electricity from grid (kWh) 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 @@ -285,7 +295,7 @@ State tax benefit (liability) ($) 0 -26,094,739 - Total after-tax returns ($) -804,710,910 766,573,150 294,104,937 303,742,997 313,174,585 322,507,735 331,778,331 341,002,154 350,187,156 359,337,597 368,455,767 377,542,798 386,599,090 395,624,545 404,618,711 413,580,864 422,510,066 431,405,201 440,264,996 449,088,035 1,049,091,500 After-tax cumulative IRR (%) NaN -4.74 24.59 40.43 48.73 53.26 55.83 57.34 58.25 58.80 59.15 59.36 59.50 59.58 59.64 59.67 59.69 59.71 59.72 59.72 59.73 -After-tax cumulative NPV ($) -804,710,910 -102,334,925 144,572,650 378,216,539 598,941,126 807,208,091 1,003,518,949 1,188,390,241 1,362,341,918 1,525,890,624 1,679,545,330 1,823,804,273 1,959,152,769 2,086,061,612 2,204,985,930 2,316,364,386 2,420,618,660 2,518,153,155 2,609,354,883 2,694,593,509 2,877,039,523 +After-tax cumulative NPV ($) -804,710,910 -102,334,925 144,572,651 378,216,539 598,941,126 807,208,091 1,003,518,949 1,188,390,241 1,362,341,917 1,525,890,624 1,679,545,329 1,823,804,273 1,959,152,768 2,086,061,612 2,204,985,930 2,316,364,386 2,420,618,660 2,518,153,154 2,609,354,882 2,694,593,509 2,877,039,523 AFTER-TAX LCOE AND PPA PRICE Annual costs ($) -804,710,910 292,393,552 -182,263,092 -186,204,759 -190,094,937 -193,973,792 -197,855,490 -201,747,003 -205,652,538 -209,575,034 -213,516,791 -217,479,763 -221,465,718 -225,476,324 -229,513,207 -233,577,985 -237,672,292 -241,797,796 -245,956,212 -250,149,313 336,843,026 diff --git a/tests/examples/example_SAM-single-owner-PPA-3.out b/tests/examples/example_SAM-single-owner-PPA-3.out index 50a70f80..71a954c7 100644 --- a/tests/examples/example_SAM-single-owner-PPA-3.out +++ b/tests/examples/example_SAM-single-owner-PPA-3.out @@ -4,10 +4,10 @@ Simulation Metadata ---------------------- - GEOPHIRES Version: 3.9.43 - Simulation Date: 2025-07-28 - Simulation Time: 13:40 - Calculation Time: 1.157 sec + GEOPHIRES Version: 3.10.13 + Simulation Date: 2025-11-29 + Simulation Time: 11:36 + Calculation Time: 1.162 sec ***SUMMARY OF RESULTS*** @@ -28,7 +28,6 @@ Simulation Metadata Real Discount Rate: 8.00 % Nominal Discount Rate: 10.16 % WACC: 7.57 % - Accrued financing during construction: 5.00 % Project lifetime: 20 yr Capacity factor: 90.0 % Project NPV: 210.63 MUSD @@ -106,6 +105,7 @@ Simulation Metadata Field gathering system costs: 5.80 MUSD Total surface equipment costs: 150.23 MUSD Exploration costs: 3.89 MUSD + Overnight Capital Cost: 262.36 MUSD Inflation costs during construction: 13.12 MUSD Total Add-on CAPEX: 50.00 MUSD Total CAPEX: 275.47 MUSD @@ -196,6 +196,16 @@ Simulation Metadata *************************** ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Year 0 Year 1 Year 2 Year 3 Year 4 Year 5 Year 6 Year 7 Year 8 Year 9 Year 10 Year 11 Year 12 Year 13 Year 14 Year 15 Year 16 Year 17 Year 18 Year 19 Year 20 +CONSTRUCTION +Purchase of property [construction] ($) -275,473,424 + +Issuance of equity [construction] ($) 165,284,055 +Issuance of debt [construction] ($) 110,189,370 +Debt balance [construction] ($) 110,189,370 +Debt interest payment [construction] ($) 0 + +Total after-tax returns [construction] ($) -165,284,055 + ENERGY Electricity to grid (kWh) 0.0 459,393,200 462,061,296 462,867,882 463,343,815 463,676,568 463,929,931 464,133,155 464,302,013 464,445,938 464,571,006 464,681,345 464,779,886 464,868,777 464,949,642 465,023,726 465,092,011 465,155,313 465,214,308 465,269,475 465,319,040 Electricity from grid (kWh) 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 @@ -286,8 +296,8 @@ State PTC income ($) 0 0 0 State tax benefit (liability) ($) 0 -2,295,232 -1,912,071 -2,033,164 -2,153,234 -2,273,262 -2,393,599 -2,514,421 -2,635,841 -2,757,939 -2,880,784 -3,004,433 -3,128,945 -3,254,373 -3,380,772 -3,508,197 -3,636,704 -3,766,351 -3,897,200 -4,029,313 -13,804,303 Total after-tax returns ($) -165,284,055 109,253,530 28,277,134 29,373,150 30,449,662 31,516,557 32,577,051 33,632,517 34,683,616 35,730,674 36,773,837 37,813,148 38,848,579 39,880,058 40,907,478 41,930,703 42,949,579 43,963,932 44,973,574 45,978,291 148,172,798 -After-tax cumulative IRR (%) NaN -33.90 -14.01 0.64 10.10 16.19 20.21 22.94 24.84 26.19 27.16 27.87 28.40 28.80 29.10 29.32 29.50 29.63 29.73 29.81 30.0 -After-tax cumulative NPV ($) -165,284,055 -66,106,921 -42,805,225 -20,832,763 -155,799 19,271,800 37,501,025 54,585,116 70,578,228 85,534,586 99,507,908 112,550,973 124,715,298 136,050,903 146,606,134 156,427,530 165,559,744 174,045,485 181,925,494 189,238,538 210,632,437 +After-tax cumulative IRR (%) NaN -33.90 -14.01 0.64 10.10 16.19 20.21 22.94 24.84 26.19 27.16 27.87 28.40 28.80 29.10 29.32 29.50 29.63 29.73 29.81 30.00 +After-tax cumulative NPV ($) -165,284,055 -66,106,922 -42,805,226 -20,832,763 -155,799 19,271,800 37,501,025 54,585,116 70,578,227 85,534,586 99,507,908 112,550,972 124,715,297 136,050,903 146,606,133 156,427,530 165,559,743 174,045,484 181,925,493 189,238,538 210,632,436 AFTER-TAX LCOE AND PPA PRICE Annual costs ($) -165,284,055 57,502,074 -23,687,770 -24,146,716 -24,601,778 -25,056,684 -25,512,761 -25,970,679 -26,430,860 -26,893,613 -27,359,192 -27,827,825 -28,299,724 -28,775,096 -29,254,148 -29,737,088 -30,224,130 -30,715,494 -31,211,412 -31,712,118 68,977,383 diff --git a/tests/examples/example_SAM-single-owner-PPA-4.out b/tests/examples/example_SAM-single-owner-PPA-4.out index 29d3496f..2df7ba32 100644 --- a/tests/examples/example_SAM-single-owner-PPA-4.out +++ b/tests/examples/example_SAM-single-owner-PPA-4.out @@ -4,10 +4,10 @@ Simulation Metadata ---------------------- - GEOPHIRES Version: 3.9.61 - Simulation Date: 2025-10-01 - Simulation Time: 09:25 - Calculation Time: 1.189 sec + GEOPHIRES Version: 3.10.13 + Simulation Date: 2025-11-29 + Simulation Time: 11:37 + Calculation Time: 1.163 sec ***SUMMARY OF RESULTS*** @@ -28,7 +28,6 @@ Simulation Metadata Real Discount Rate: 8.00 % Nominal Discount Rate: 10.16 % WACC: 7.57 % - Accrued financing during construction: 5.00 % Project lifetime: 20 yr Capacity factor: 90.0 % Project NPV: 103.00 MUSD @@ -107,6 +106,7 @@ Simulation Metadata Field gathering system costs: 8.50 MUSD Total surface equipment costs: 152.93 MUSD Exploration costs: 3.89 MUSD + Overnight Capital Cost: 215.06 MUSD Inflation costs during construction: 10.75 MUSD Total CAPEX: 225.81 MUSD @@ -197,6 +197,16 @@ Simulation Metadata *************************** -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Year 0 Year 1 Year 2 Year 3 Year 4 Year 5 Year 6 Year 7 Year 8 Year 9 Year 10 Year 11 Year 12 Year 13 Year 14 Year 15 Year 16 Year 17 Year 18 Year 19 Year 20 +CONSTRUCTION +Purchase of property [construction] ($) -225,808,536 + +Issuance of equity [construction] ($) 135,485,121 +Issuance of debt [construction] ($) 90,323,414 +Debt balance [construction] ($) 90,323,414 +Debt interest payment [construction] ($) 0 + +Total after-tax returns [construction] ($) -135,485,121 + ENERGY Electricity to grid (kWh) 0.0 428,554,957 431,240,089 432,051,822 432,530,792 432,865,667 433,120,646 433,325,167 433,495,101 433,639,945 433,765,810 433,876,853 433,976,022 434,065,480 434,146,861 434,221,417 434,290,137 434,353,843 434,413,216 434,468,735 434,518,614 Electricity from grid (kWh) 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 @@ -287,8 +297,8 @@ State PTC income ($) 0 0 0 State tax benefit (liability) ($) 0 -1,164,060 -827,866 -908,550 -986,182 -1,061,709 -1,135,466 -1,236,769 -1,338,549 -1,440,876 -1,543,808 -1,647,396 -1,751,687 -1,856,725 -1,962,558 -2,069,229 -2,176,786 -2,285,277 -2,394,752 -2,505,261 -10,520,139 Total after-tax returns ($) -135,485,121 82,027,021 15,417,714 16,121,144 16,785,366 17,419,964 18,028,078 18,917,012 19,802,233 20,684,048 21,562,604 22,437,950 23,310,079 24,178,937 25,044,443 25,906,493 26,764,961 27,619,710 28,470,586 29,317,417 113,110,583 -After-tax cumulative IRR (%) NaN -39.46 -24.40 -11.44 -2.16 4.24 8.70 11.92 14.28 16.04 17.38 18.40 19.20 19.82 20.31 20.71 21.02 21.27 21.48 21.64 22.13 -After-tax cumulative NPV ($) -135,485,121 -61,023,410 -48,318,484 -36,259,129 -24,860,960 -14,122,855 -4,034,837 5,574,315 14,705,407 23,363,459 31,556,817 39,296,444 46,595,330 53,468,010 59,930,151 65,998,209 71,689,146 77,020,191 82,008,642 86,671,703 103,003,151 +After-tax cumulative IRR (%) NaN -39.46 -24.40 -11.44 -2.16 4.24 8.70 11.92 14.28 16.04 17.38 18.40 19.20 19.82 20.31 20.71 21.02 21.27 21.48 21.65 22.13 +After-tax cumulative NPV ($) -135,485,121 -61,023,410 -48,318,484 -36,259,129 -24,860,960 -14,122,856 -4,034,837 5,574,314 14,705,407 23,363,459 31,556,817 39,296,443 46,595,330 53,468,010 59,930,151 65,998,209 71,689,146 77,020,191 82,008,642 86,671,703 103,003,151 AFTER-TAX LCOE AND PPA PRICE Annual costs ($) -135,485,121 47,742,625 -19,081,493 -19,834,209 -20,602,596 -21,390,771 -22,200,167 -22,725,537 -23,252,501 -23,781,392 -24,312,468 -24,845,949 -25,382,031 -25,920,901 -26,462,740 -27,007,729 -27,556,049 -28,107,888 -28,663,440 -29,222,900 53,164,395 diff --git a/tests/examples/example_SAM-single-owner-PPA-5.out b/tests/examples/example_SAM-single-owner-PPA-5.out new file mode 100644 index 00000000..4ac7452f --- /dev/null +++ b/tests/examples/example_SAM-single-owner-PPA-5.out @@ -0,0 +1,444 @@ + ***************** + ***CASE REPORT*** + ***************** + +Simulation Metadata +---------------------- + GEOPHIRES Version: 3.10.13 + Simulation Date: 2025-11-29 + Simulation Time: 11:37 + Calculation Time: 1.743 sec + + ***SUMMARY OF RESULTS*** + + End-Use Option: Electricity + Average Net Electricity Production: 110.58 MW + Electricity breakeven price: 7.03 cents/kWh + Total CAPEX: 710.63 MUSD + Number of production wells: 15 + Number of injection wells: 15 + Flowrate per production well: 80.0 kg/sec + Well depth: 2.6 kilometer + Geothermal gradient: 74 degC/km + + + ***ECONOMIC PARAMETERS*** + + Economic Model = SAM Single Owner PPA + Real Discount Rate: 8.00 % + Nominal Discount Rate: 10.48 % + WACC: 7.25 % + Project lifetime: 30 yr + Capacity factor: 90.0 % + Project NPV: 108.32 MUSD + After-tax IRR: 16.54 % + Project VIR=PI=PIR: 1.91 + Project MOIC: 6.88 + Project Payback Period: 2.92 yr + Estimated Jobs Created: 250 + + ***ENGINEERING PARAMETERS*** + + Number of Production Wells: 15 + Number of Injection Wells: 15 + Well depth: 2.6 kilometer + Water loss rate: 10.0 % + Pump efficiency: 80.0 % + Injection temperature: 56.7 degC + Production Wellbore heat transmission calculated with Ramey's model + Average production well temperature drop: 2.6 degC + Flowrate per production well: 80.0 kg/sec + Injection well casing ID: 8.500 in + Production well casing ID: 8.500 in + Number of times redrilling: 0 + Power plant type: Supercritical ORC + + + ***RESOURCE CHARACTERISTICS*** + + Maximum reservoir temperature: 500.0 degC + Number of segments: 1 + Geothermal gradient: 74 degC/km + + + ***RESERVOIR PARAMETERS*** + + Reservoir Model = Multiple Parallel Fractures Model (Gringarten) + Bottom-hole temperature: 202.40 degC + Fracture model = Square + Well separation: fracture height: 500.00 meter + Fracture area: 250000.00 m**2 + Reservoir volume calculated with fracture separation and number of fractures as input + Number of fractures: 1663 + Fracture separation: 26.00 meter + Reservoir volume: 10803000000 m**3 + Reservoir hydrostatic pressure: 24578.69 kPa + Plant outlet pressure: 6894.76 kPa + Production wellhead pressure: 2240.80 kPa + Productivity Index: 2.47 kg/sec/bar + Injectivity Index: 3.00 kg/sec/bar + Reservoir density: 2800.00 kg/m**3 + Reservoir thermal conductivity: 3.05 W/m/K + Reservoir heat capacity: 790.00 J/kg/K + + + ***RESERVOIR SIMULATION RESULTS*** + + Maximum Production Temperature: 200.1 degC + Average Production Temperature: 199.8 degC + Minimum Production Temperature: 197.9 degC + Initial Production Temperature: 197.9 degC + Average Reservoir Heat Extraction: 719.46 MW + Production Wellbore Heat Transmission Model = Ramey Model + Average Production Well Temperature Drop: 2.6 degC + Average Injection Well Pump Pressure Drop: -4103.9 kPa + Average Production Well Pump Pressure Drop: 3876.5 kPa + + + ***CAPITAL COSTS (M$)*** + + Drilling and completion costs: 106.12 MUSD + Drilling and completion costs per well: 3.54 MUSD + Stimulation costs: 75.00 MUSD + Surface power plant costs: 287.61 MUSD + Field gathering system costs: 10.69 MUSD + Total surface equipment costs: 298.30 MUSD + Exploration costs: 120.00 MUSD + Overnight Capital Cost: 599.42 MUSD + Interest during construction: 28.51 MUSD + Inflation costs during construction: 82.70 MUSD + Total CAPEX: 710.63 MUSD + + + ***OPERATING AND MAINTENANCE COSTS (M$/yr)*** + + Wellfield maintenance costs: 1.86 MUSD/yr + Power plant maintenance costs: 6.38 MUSD/yr + Water costs: 3.15 MUSD/yr + Total operating and maintenance costs: 11.39 MUSD/yr + + + ***SURFACE EQUIPMENT SIMULATION RESULTS*** + + Initial geofluid availability: 0.19 MW/(kg/s) + Maximum Total Electricity Generation: 117.52 MW + Average Total Electricity Generation: 117.19 MW + Minimum Total Electricity Generation: 114.41 MW + Initial Total Electricity Generation: 114.41 MW + Maximum Net Electricity Generation: 110.92 MW + Average Net Electricity Generation: 110.58 MW + Minimum Net Electricity Generation: 107.79 MW + Initial Net Electricity Generation: 107.79 MW + Average Annual Total Electricity Generation: 923.94 GWh + Average Annual Net Electricity Generation: 871.83 GWh + Initial pumping power/net installed power: 6.15 % + Average Pumping Power: 6.61 MW + Heat to Power Conversion Efficiency: 15.37 % + + ************************************************************ + * HEATING, COOLING AND/OR ELECTRICITY PRODUCTION PROFILE * + ************************************************************ + YEAR THERMAL GEOFLUID PUMP NET FIRST LAW + DRAWDOWN TEMPERATURE POWER POWER EFFICIENCY + (degC) (MW) (MW) (%) + 1 1.0000 197.90 6.6279 107.7857 15.1865 + 2 1.0064 199.16 6.6158 109.6141 15.3068 + 3 1.0076 199.40 6.6136 109.9549 15.3291 + 4 1.0082 199.52 6.6124 110.1323 15.3406 + 5 1.0086 199.60 6.6117 110.2497 15.3483 + 6 1.0089 199.66 6.6111 110.3363 15.3539 + 7 1.0092 199.71 6.6107 110.4044 15.3583 + 8 1.0093 199.75 6.6103 110.4601 15.3620 + 9 1.0095 199.78 6.6100 110.5072 15.3650 + 10 1.0097 199.81 6.6097 110.5477 15.3676 + 11 1.0098 199.83 6.6095 110.5833 15.3700 + 12 1.0099 199.85 6.6093 110.6149 15.3720 + 13 1.0100 199.87 6.6091 110.6432 15.3738 + 14 1.0101 199.89 6.6089 110.6690 15.3755 + 15 1.0102 199.91 6.6088 110.6925 15.3770 + 16 1.0102 199.92 6.6086 110.7141 15.3784 + 17 1.0103 199.94 6.6085 110.7340 15.3797 + 18 1.0104 199.95 6.6084 110.7526 15.3809 + 19 1.0104 199.96 6.6083 110.7699 15.3821 + 20 1.0105 199.97 6.6082 110.7862 15.3831 + 21 1.0105 199.98 6.6081 110.8015 15.3841 + 22 1.0106 199.99 6.6080 110.8159 15.3850 + 23 1.0106 200.00 6.6079 110.8295 15.3859 + 24 1.0107 200.01 6.6078 110.8424 15.3868 + 25 1.0107 200.02 6.6077 110.8547 15.3876 + 26 1.0108 200.03 6.6076 110.8664 15.3883 + 27 1.0108 200.04 6.6076 110.8775 15.3890 + 28 1.0108 200.04 6.6075 110.8882 15.3897 + 29 1.0109 200.05 6.6074 110.8984 15.3904 + 30 1.0109 200.06 6.6074 110.9082 15.3910 + + + ******************************************************************* + * ANNUAL HEATING, COOLING AND/OR ELECTRICITY PRODUCTION PROFILE * + ******************************************************************* + YEAR ELECTRICITY HEAT RESERVOIR PERCENTAGE OF + PROVIDED EXTRACTED HEAT CONTENT TOTAL HEAT MINED + (GWh/year) (GWh/year) (10^15 J) (%) + 1 859.5 5629.4 3310.87 0.61 + 2 865.7 5651.2 3290.53 1.22 + 3 867.6 5657.8 3270.16 1.83 + 4 868.8 5661.7 3249.78 2.44 + 5 869.6 5664.5 3229.38 3.05 + 6 870.2 5666.6 3208.98 3.67 + 7 870.7 5668.3 3188.58 4.28 + 8 871.1 5669.6 3168.17 4.89 + 9 871.4 5670.8 3147.75 5.51 + 10 871.7 5671.9 3127.33 6.12 + 11 872.0 5672.8 3106.91 6.73 + 12 872.2 5673.6 3086.49 7.34 + 13 872.4 5674.4 3066.06 7.96 + 14 872.6 5675.0 3045.63 8.57 + 15 872.8 5675.6 3025.20 9.18 + 16 872.9 5676.2 3004.76 9.80 + 17 873.1 5676.7 2984.33 10.41 + 18 873.2 5677.2 2963.89 11.02 + 19 873.4 5677.7 2943.45 11.64 + 20 873.5 5678.1 2923.01 12.25 + 21 873.6 5678.5 2902.56 12.87 + 22 873.7 5678.9 2882.12 13.48 + 23 873.8 5679.3 2861.67 14.09 + 24 873.9 5679.6 2841.23 14.71 + 25 874.0 5679.9 2820.78 15.32 + 26 874.1 5680.2 2800.33 15.93 + 27 874.2 5680.5 2779.88 16.55 + 28 874.3 5680.8 2759.43 17.16 + 29 874.4 5681.1 2738.98 17.78 + 30 874.4 5681.4 2718.53 18.39 + + *************************** + * SAM CASH FLOW PROFILE * + *************************** +--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + Year -6 Year -5 Year -4 Year -3 Year -2 Year -1 Year 0 Year 1 Year 2 Year 3 Year 4 Year 5 Year 6 Year 7 Year 8 Year 9 Year 10 Year 11 Year 12 Year 13 Year 14 Year 15 Year 16 Year 17 Year 18 Year 19 Year 20 Year 21 Year 22 Year 23 Year 24 Year 25 Year 26 Year 27 Year 28 Year 29 Year 30 +CONSTRUCTION +Purchase of property [construction] ($) -6,132,082 -12,546,240 -44,921,812 -65,650,020 -134,319,940 -137,409,299 -281,139,426 + +Issuance of equity [construction] ($) 6,132,082 12,546,240 44,921,812 22,977,507 47,011,979 48,093,255 98,398,799 +Issuance of debt [construction] ($) 0 0 0 42,672,513 87,307,961 89,316,044 182,740,627 +Debt balance [construction] ($) 0 0 0 42,672,513 132,967,550 231,591,323 430,543,342 +Debt interest payment [construction] ($) 0 0 0 0 2,987,076 9,307,728 16,211,393 + +Total after-tax returns [construction] ($) -6,132,082 -12,546,240 -44,921,812 -22,977,507 -47,011,979 -48,093,255 -98,398,799 + +ENERGY +Electricity to grid (kWh) 0.0 859,490,068 865,758,172 867,671,460 868,803,529 869,596,370 870,200,786 870,686,054 871,089,570 871,433,728 871,732,959 871,997,081 872,233,061 872,446,015 872,639,805 872,817,414 872,981,192 873,133,019 873,274,427 873,406,675 873,530,811 873,647,716 873,758,141 873,862,724 873,962,017 874,056,500 874,146,590 874,232,654 874,315,016 874,393,962 874,466,670 +Electricity from grid (kWh) 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 +Electricity to grid net (kWh) 0.0 859,490,068 865,758,172 867,671,460 868,803,529 869,596,370 870,200,786 870,686,054 871,089,570 871,433,728 871,732,959 871,997,081 872,233,061 872,446,015 872,639,805 872,817,414 872,981,192 873,133,019 873,274,427 873,406,675 873,530,811 873,647,716 873,758,141 873,862,724 873,962,017 874,056,500 874,146,590 874,232,654 874,315,016 874,393,962 874,466,670 + +REVENUE +PPA price (cents/kWh) 0.0 8.0 8.0 8.32 8.64 8.97 9.29 9.61 9.93 10.25 10.58 10.90 11.22 11.54 11.86 12.19 12.51 12.83 13.15 13.47 13.80 14.12 14.44 14.76 15.08 15.41 15.73 16.05 16.37 16.69 17.02 +PPA revenue ($) 0 68,759,205 69,260,654 72,207,619 75,099,377 77,968,011 80,824,249 83,672,930 86,516,616 89,356,814 92,194,478 95,030,242 97,864,549 100,697,719 103,529,986 106,361,530 109,192,487 112,022,966 114,853,053 117,682,815 120,512,311 123,341,585 126,170,676 128,999,615 131,828,431 134,657,144 137,485,776 140,314,341 143,142,854 145,971,328 148,799,249 +Curtailment payment revenue ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Capacity payment revenue ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Salvage value ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 355,312,508 +Total revenue ($) 0 68,759,205 69,260,654 72,207,619 75,099,377 77,968,011 80,824,249 83,672,930 86,516,616 89,356,814 92,194,478 95,030,242 97,864,549 100,697,719 103,529,986 106,361,530 109,192,487 112,022,966 114,853,053 117,682,815 120,512,311 123,341,585 126,170,676 128,999,615 131,828,431 134,657,144 137,485,776 140,314,341 143,142,854 145,971,328 504,111,756 + +Property tax net assessed value ($) 0 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 710,625,015 + +OPERATING EXPENSES +O&M fixed expense ($) 0 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 +O&M production-based expense ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +O&M capacity-based expense ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Fuel expense ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Electricity purchase ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Property tax expense ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Insurance expense ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Total operating expenses ($) 0 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 11,386,596 + +EBITDA ($) 0 57,372,609 57,874,058 60,821,023 63,712,781 66,581,414 69,437,653 72,286,334 75,130,020 77,970,218 80,807,882 83,643,646 86,477,953 89,311,123 92,143,390 94,974,934 97,805,891 100,636,370 103,466,456 106,296,219 109,125,714 111,954,988 114,784,079 117,613,019 120,441,834 123,270,548 126,099,179 128,927,745 131,756,258 134,584,732 492,725,160 + +OPERATING ACTIVITIES +EBITDA ($) 0 57,372,609 57,874,058 60,821,023 63,712,781 66,581,414 69,437,653 72,286,334 75,130,020 77,970,218 80,807,882 83,643,646 86,477,953 89,311,123 92,143,390 94,974,934 97,805,891 100,636,370 103,466,456 106,296,219 109,125,714 111,954,988 114,784,079 117,613,019 120,441,834 123,270,548 126,099,179 128,927,745 131,756,258 134,584,732 492,725,160 +Interest earned on reserves ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +plus PBI if not available for debt service: +Federal PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Utility PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Other PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Debt interest payment ($) 0 30,138,034 29,818,981 29,477,593 29,112,309 28,721,455 28,303,241 27,855,752 27,376,939 26,864,609 26,316,416 25,729,849 25,102,223 24,430,663 23,712,094 22,943,224 22,120,534 21,240,256 20,298,358 19,290,527 18,212,149 17,058,283 15,823,647 14,502,587 13,089,052 11,576,570 9,958,214 8,226,573 6,373,718 4,391,162 2,269,828 +Cash flow from operating activities ($) 0 27,234,575 28,055,077 31,343,429 34,600,472 37,859,959 41,134,412 44,430,581 47,753,081 51,105,609 54,491,465 57,913,796 61,375,730 64,880,460 68,431,297 72,031,709 75,685,357 79,396,114 83,168,098 87,005,692 90,913,566 94,896,705 98,960,432 103,110,432 107,352,782 111,693,978 116,140,965 120,701,171 125,382,540 130,193,570 490,455,332 + +INVESTING ACTIVITIES +Total installed cost ($) -710,625,015 +Debt closing costs ($) 0 +Debt up-front fee ($) 0 +minus: +Total IBI income ($) 0 +Total CBI income ($) 0 +equals: +Purchase of property ($) -710,625,015 +plus: +Reserve (increase)/decrease debt service ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve (increase)/decrease working capital ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve (increase)/decrease receivables ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve (increase)/decrease major equipment 1 ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve (increase)/decrease major equipment 2 ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve (increase)/decrease major equipment 3 ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve capital spending major equipment 1 ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve capital spending major equipment 2 ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserve capital spending major equipment 3 ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +equals: +Cash flow from investing activities ($) -710,625,015 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +FINANCING ACTIVITIES +Issuance of equity ($) 280,081,674 +Size of debt ($) 430,543,342 +minus: +Debt principal payment ($) 0 4,557,906 4,876,959 5,218,346 5,583,630 5,974,484 6,392,698 6,840,187 7,319,000 7,831,330 8,379,523 8,966,090 9,593,716 10,265,277 10,983,846 11,752,715 12,575,405 13,455,684 14,397,581 15,405,412 16,483,791 17,637,656 18,872,292 20,193,353 21,606,887 23,119,369 24,737,725 26,469,366 28,322,222 30,304,777 32,426,112 +equals: +Cash flow from financing activities ($) 710,625,015 -4,557,906 -4,876,959 -5,218,346 -5,583,630 -5,974,484 -6,392,698 -6,840,187 -7,319,000 -7,831,330 -8,379,523 -8,966,090 -9,593,716 -10,265,277 -10,983,846 -11,752,715 -12,575,405 -13,455,684 -14,397,581 -15,405,412 -16,483,791 -17,637,656 -18,872,292 -20,193,353 -21,606,887 -23,119,369 -24,737,725 -26,469,366 -28,322,222 -30,304,777 -32,426,112 + +PROJECT RETURNS +Pre-tax Cash Flow: +Cash flow from operating activities ($) 0 27,234,575 28,055,077 31,343,429 34,600,472 37,859,959 41,134,412 44,430,581 47,753,081 51,105,609 54,491,465 57,913,796 61,375,730 64,880,460 68,431,297 72,031,709 75,685,357 79,396,114 83,168,098 87,005,692 90,913,566 94,896,705 98,960,432 103,110,432 107,352,782 111,693,978 116,140,965 120,701,171 125,382,540 130,193,570 490,455,332 +Cash flow from investing activities ($) -710,625,015 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Cash flow from financing activities ($) 710,625,015 -4,557,906 -4,876,959 -5,218,346 -5,583,630 -5,974,484 -6,392,698 -6,840,187 -7,319,000 -7,831,330 -8,379,523 -8,966,090 -9,593,716 -10,265,277 -10,983,846 -11,752,715 -12,575,405 -13,455,684 -14,397,581 -15,405,412 -16,483,791 -17,637,656 -18,872,292 -20,193,353 -21,606,887 -23,119,369 -24,737,725 -26,469,366 -28,322,222 -30,304,777 -32,426,112 +Total pre-tax cash flow ($) 0 22,676,670 23,178,118 26,125,083 29,016,841 31,885,475 34,741,713 37,590,394 40,434,080 43,274,279 46,111,942 48,947,706 51,782,014 54,615,183 57,447,451 60,278,994 63,109,952 65,940,431 68,770,517 71,600,280 74,429,775 77,259,049 80,088,140 82,917,080 85,745,895 88,574,609 91,403,240 94,231,805 97,060,319 99,888,792 458,029,221 + +Pre-tax Returns: +Issuance of equity ($) 280,081,674 +Total pre-tax cash flow ($) 0 22,676,670 23,178,118 26,125,083 29,016,841 31,885,475 34,741,713 37,590,394 40,434,080 43,274,279 46,111,942 48,947,706 51,782,014 54,615,183 57,447,451 60,278,994 63,109,952 65,940,431 68,770,517 71,600,280 74,429,775 77,259,049 80,088,140 82,917,080 85,745,895 88,574,609 91,403,240 94,231,805 97,060,319 99,888,792 458,029,221 +Total pre-tax returns ($) -280,081,674 22,676,670 23,178,118 26,125,083 29,016,841 31,885,475 34,741,713 37,590,394 40,434,080 43,274,279 46,111,942 48,947,706 51,782,014 54,615,183 57,447,451 60,278,994 63,109,952 65,940,431 68,770,517 71,600,280 74,429,775 77,259,049 80,088,140 82,917,080 85,745,895 88,574,609 91,403,240 94,231,805 97,060,319 99,888,792 458,029,221 + +After-tax Returns: +Total pre-tax returns ($) -280,081,674 22,676,670 23,178,118 26,125,083 29,016,841 31,885,475 34,741,713 37,590,394 40,434,080 43,274,279 46,111,942 48,947,706 51,782,014 54,615,183 57,447,451 60,278,994 63,109,952 65,940,431 68,770,517 71,600,280 74,429,775 77,259,049 80,088,140 82,917,080 85,745,895 88,574,609 91,403,240 94,231,805 97,060,319 99,888,792 458,029,221 +Federal ITC total income ($) 0 213,187,505 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Federal PTC income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Federal tax benefit (liability) ($) 0 -2,369,730 419,209 -223,006 -859,107 -1,495,685 -2,135,185 -2,778,927 -3,427,811 -4,082,560 -4,743,818 -5,412,199 -6,088,315 -6,772,789 -7,466,267 -8,169,428 -8,882,985 -9,607,696 -10,344,364 -11,093,846 -11,857,054 -15,584,144 -19,326,972 -20,137,467 -20,965,998 -21,813,834 -22,682,331 -23,572,939 -24,487,210 -25,426,804 -95,785,926 +State ITC total income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State PTC income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State tax benefit (liability) ($) 0 -849,366 150,254 -79,931 -307,924 -536,088 -765,299 -996,031 -1,228,606 -1,463,283 -1,700,293 -1,939,856 -2,182,192 -2,427,523 -2,676,081 -2,928,110 -3,183,866 -3,443,619 -3,707,657 -3,976,289 -4,249,840 -5,585,715 -6,927,230 -7,217,730 -7,514,695 -7,818,578 -8,129,868 -8,449,082 -8,776,778 -9,113,550 -34,331,873 +Total after-tax returns ($) -280,081,674 232,645,079 23,747,581 25,822,146 27,849,811 29,853,702 31,841,229 33,815,436 35,777,663 37,728,435 39,667,831 41,595,651 43,511,507 45,414,872 47,305,102 49,181,457 51,043,101 52,889,116 54,718,495 56,530,144 58,322,881 56,089,190 53,833,937 55,561,882 57,265,202 58,942,196 60,591,042 62,209,784 63,796,331 65,348,438 327,911,421 + +After-tax cumulative IRR (%) NaN NaN NaN NaN NaN NaN NaN -6.77 -3.11 0.25 3.13 5.52 7.47 9.04 10.32 11.37 12.22 12.93 13.51 14.00 14.40 14.74 15.03 15.27 15.47 15.65 15.80 15.92 16.01 16.09 16.16 16.22 16.28 16.32 16.36 16.40 16.54 +After-tax cumulative NPV ($) -6,132,082 -17,487,790 -54,288,694 -71,326,149 -102,876,993 -132,090,737 -186,190,264 -70,419,701 -59,723,630 -49,196,794 -38,920,694 -28,950,470 -19,325,549 -10,073,820 -1,214,089 7,242,164 15,289,427 22,927,052 30,158,331 36,989,731 43,430,240 49,490,820 55,183,942 60,523,194 65,522,949 70,198,097 74,563,807 78,363,913 81,665,123 84,748,983 87,625,780 90,305,844 92,799,451 95,116,733 97,267,614 99,261,759 108,318,637 + +AFTER-TAX LCOE AND PPA PRICE +Annual costs ($) -280,081,674 163,885,873 -45,513,073 -46,385,473 -47,249,566 -48,114,308 -48,983,020 -49,857,494 -50,738,953 -51,628,379 -52,526,647 -53,434,591 -54,353,042 -55,282,847 -56,224,884 -57,180,074 -58,149,386 -59,133,850 -60,134,558 -61,152,671 -62,189,430 -67,252,394 -72,336,738 -73,437,733 -74,563,229 -75,714,948 -76,894,734 -78,104,556 -79,346,524 -80,622,890 179,112,172 +PPA revenue ($) 0 68,759,205 69,260,654 72,207,619 75,099,377 77,968,011 80,824,249 83,672,930 86,516,616 89,356,814 92,194,478 95,030,242 97,864,549 100,697,719 103,529,986 106,361,530 109,192,487 112,022,966 114,853,053 117,682,815 120,512,311 123,341,585 126,170,676 128,999,615 131,828,431 134,657,144 137,485,776 140,314,341 143,142,854 145,971,328 148,799,249 +Electricity to grid (kWh) 0.0 859,490,068 865,758,172 867,671,460 868,803,529 869,596,370 870,200,786 870,686,054 871,089,570 871,433,728 871,732,959 871,997,081 872,233,061 872,446,015 872,639,805 872,817,414 872,981,192 873,133,019 873,274,427 873,406,675 873,530,811 873,647,716 873,758,141 873,862,724 873,962,017 874,056,500 874,146,590 874,232,654 874,315,016 874,393,962 874,466,670 + +Present value of annual costs ($) 554,074,192 +Present value of annual energy nominal (kWh) 7,877,183,891 +LCOE Levelized cost of energy nominal (cents/kWh) 7.03 + +Present value of PPA revenue ($) 809,659,354 +Present value of annual energy nominal (kWh) 7,877,183,891 +LPPA Levelized PPA price nominal (cents/kWh) 10.28 + +PROJECT STATE INCOME TAXES +EBITDA ($) 0 57,372,609 57,874,058 60,821,023 63,712,781 66,581,414 69,437,653 72,286,334 75,130,020 77,970,218 80,807,882 83,643,646 86,477,953 89,311,123 92,143,390 94,974,934 97,805,891 100,636,370 103,466,456 106,296,219 109,125,714 111,954,988 114,784,079 117,613,019 120,441,834 123,270,548 126,099,179 128,927,745 131,756,258 134,584,732 492,725,160 +State taxable PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Interest earned on reserves ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State taxable IBI income ($) 0 +State taxable CBI income ($) 0 +minus: +Debt interest payment ($) 0 30,138,034 29,818,981 29,477,593 29,112,309 28,721,455 28,303,241 27,855,752 27,376,939 26,864,609 26,316,416 25,729,849 25,102,223 24,430,663 23,712,094 22,943,224 22,120,534 21,240,256 20,298,358 19,290,527 18,212,149 17,058,283 15,823,647 14,502,587 13,089,052 11,576,570 9,958,214 8,226,573 6,373,718 4,391,162 2,269,828 +Total state tax depreciation ($) 0 15,100,782 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 15,100,782 0 0 0 0 0 0 0 0 0 +equals: +State taxable income ($) 0 12,133,794 -2,146,486 1,141,866 4,398,908 7,658,396 10,932,848 14,229,018 17,551,517 20,904,046 24,289,902 27,712,233 31,174,167 34,678,897 38,229,733 41,830,146 45,483,794 49,194,551 52,966,535 56,804,129 60,712,003 79,795,924 98,960,432 103,110,432 107,352,782 111,693,978 116,140,965 120,701,171 125,382,540 130,193,570 490,455,332 + +State income tax rate (frac) 0.0 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 0.07 +State tax benefit (liability) ($) 0 -849,366 150,254 -79,931 -307,924 -536,088 -765,299 -996,031 -1,228,606 -1,463,283 -1,700,293 -1,939,856 -2,182,192 -2,427,523 -2,676,081 -2,928,110 -3,183,866 -3,443,619 -3,707,657 -3,976,289 -4,249,840 -5,585,715 -6,927,230 -7,217,730 -7,514,695 -7,818,578 -8,129,868 -8,449,082 -8,776,778 -9,113,550 -34,331,873 + +PROJECT FEDERAL INCOME TAXES +EBITDA ($) 0 57,372,609 57,874,058 60,821,023 63,712,781 66,581,414 69,437,653 72,286,334 75,130,020 77,970,218 80,807,882 83,643,646 86,477,953 89,311,123 92,143,390 94,974,934 97,805,891 100,636,370 103,466,456 106,296,219 109,125,714 111,954,988 114,784,079 117,613,019 120,441,834 123,270,548 126,099,179 128,927,745 131,756,258 134,584,732 492,725,160 +Interest earned on reserves ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State tax benefit (liability) ($) 0 -849,366 150,254 -79,931 -307,924 -536,088 -765,299 -996,031 -1,228,606 -1,463,283 -1,700,293 -1,939,856 -2,182,192 -2,427,523 -2,676,081 -2,928,110 -3,183,866 -3,443,619 -3,707,657 -3,976,289 -4,249,840 -5,585,715 -6,927,230 -7,217,730 -7,514,695 -7,818,578 -8,129,868 -8,449,082 -8,776,778 -9,113,550 -34,331,873 +State ITC total income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State PTC income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Federal taxable IBI income ($) 0 +Federal taxable CBI income ($) 0 +Federal taxable PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +minus: +Debt interest payment ($) 0 30,138,034 29,818,981 29,477,593 29,112,309 28,721,455 28,303,241 27,855,752 27,376,939 26,864,609 26,316,416 25,729,849 25,102,223 24,430,663 23,712,094 22,943,224 22,120,534 21,240,256 20,298,358 19,290,527 18,212,149 17,058,283 15,823,647 14,502,587 13,089,052 11,576,570 9,958,214 8,226,573 6,373,718 4,391,162 2,269,828 +Total federal tax depreciation ($) 0 15,100,782 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 30,201,563 15,100,782 0 0 0 0 0 0 0 0 0 +equals: +Federal taxable income ($) 0 11,284,428 -1,996,232 1,061,935 4,090,985 7,122,308 10,167,549 13,232,987 16,322,911 19,440,763 22,589,609 25,772,377 28,991,975 32,251,374 35,553,652 38,902,036 42,299,928 45,750,932 49,258,878 52,827,840 56,462,162 74,210,209 92,033,202 95,892,702 99,838,088 103,875,400 108,011,098 112,252,089 116,605,763 121,080,020 456,123,459 + +Federal income tax rate (frac) 0.0 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 +Federal tax benefit (liability) ($) 0 -2,369,730 419,209 -223,006 -859,107 -1,495,685 -2,135,185 -2,778,927 -3,427,811 -4,082,560 -4,743,818 -5,412,199 -6,088,315 -6,772,789 -7,466,267 -8,169,428 -8,882,985 -9,607,696 -10,344,364 -11,093,846 -11,857,054 -15,584,144 -19,326,972 -20,137,467 -20,965,998 -21,813,834 -22,682,331 -23,572,939 -24,487,210 -25,426,804 -95,785,926 + +CASH INCENTIVES +Federal IBI income ($) 0 +State IBI income ($) 0 +Utility IBI income ($) 0 +Other IBI income ($) 0 +Total IBI income ($) 0 + +Federal CBI income ($) 0 +State CBI income ($) 0 +Utility CBI income ($) 0 +Other CBI income ($) 0 +Total CBI income ($) 0 + +Federal PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Utility PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Other PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Total PBI income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +TAX CREDITS +Federal PTC income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State PTC income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +Federal ITC amount income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Federal ITC percent income ($) 0 213,187,505 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Federal ITC total income ($) 0 213,187,505 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +State ITC amount income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State ITC percent income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +State ITC total income ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +DEBT REPAYMENT +Debt balance ($) 430,543,342 425,985,436 421,108,477 415,890,131 410,306,501 404,332,017 397,939,318 391,099,131 383,780,131 375,948,800 367,569,277 358,603,187 349,009,471 338,744,194 327,760,348 316,007,633 303,432,228 289,976,544 275,578,963 260,173,551 243,689,760 226,052,104 207,179,812 186,986,459 165,379,572 142,260,202 117,522,477 91,053,111 62,730,889 32,426,112 0 +Debt interest payment ($) 0 30,138,034 29,818,981 29,477,593 29,112,309 28,721,455 28,303,241 27,855,752 27,376,939 26,864,609 26,316,416 25,729,849 25,102,223 24,430,663 23,712,094 22,943,224 22,120,534 21,240,256 20,298,358 19,290,527 18,212,149 17,058,283 15,823,647 14,502,587 13,089,052 11,576,570 9,958,214 8,226,573 6,373,718 4,391,162 2,269,828 +Debt principal payment ($) 0 4,557,906 4,876,959 5,218,346 5,583,630 5,974,484 6,392,698 6,840,187 7,319,000 7,831,330 8,379,523 8,966,090 9,593,716 10,265,277 10,983,846 11,752,715 12,575,405 13,455,684 14,397,581 15,405,412 16,483,791 17,637,656 18,872,292 20,193,353 21,606,887 23,119,369 24,737,725 26,469,366 28,322,222 30,304,777 32,426,112 +Debt total payment ($) 0 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 + +DSCR (DEBT FRACTION) +EBITDA ($) 0 57,372,609 57,874,058 60,821,023 63,712,781 66,581,414 69,437,653 72,286,334 75,130,020 77,970,218 80,807,882 83,643,646 86,477,953 89,311,123 92,143,390 94,974,934 97,805,891 100,636,370 103,466,456 106,296,219 109,125,714 111,954,988 114,784,079 117,613,019 120,441,834 123,270,548 126,099,179 128,927,745 131,756,258 134,584,732 492,725,160 +minus: +Reserves major equipment 1 funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 2 funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 3 funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves receivables funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +equals: +Cash available for debt service (CAFDS) ($) 0 57,372,609 57,874,058 60,821,023 63,712,781 66,581,414 69,437,653 72,286,334 75,130,020 77,970,218 80,807,882 83,643,646 86,477,953 89,311,123 92,143,390 94,974,934 97,805,891 100,636,370 103,466,456 106,296,219 109,125,714 111,954,988 114,784,079 117,613,019 120,441,834 123,270,548 126,099,179 128,927,745 131,756,258 134,584,732 492,725,160 +Debt total payment ($) 0 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 34,695,939 +DSCR (pre-tax) 0.0 1.65 1.67 1.75 1.84 1.92 2.0 2.08 2.17 2.25 2.33 2.41 2.49 2.57 2.66 2.74 2.82 2.90 2.98 3.06 3.15 3.23 3.31 3.39 3.47 3.55 3.63 3.72 3.80 3.88 14.20 + +RESERVES +Reserves working capital funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves working capital disbursement ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves working capital balance ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +Reserves debt service funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves debt service disbursement ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves debt service balance ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +Reserves receivables funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves receivables disbursement ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves receivables balance ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +Reserves major equipment 1 funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 1 disbursement ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 1 balance ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +Reserves major equipment 2 funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 2 disbursement ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 2 balance ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +Reserves major equipment 3 funding ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 3 disbursement ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Reserves major equipment 3 balance ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + +Reserves total reserves balance ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Interest on reserves (%/year) 1.75 +Interest earned on reserves ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- diff --git a/tests/examples/example_SAM-single-owner-PPA-5.txt b/tests/examples/example_SAM-single-owner-PPA-5.txt new file mode 100644 index 00000000..926cdfe7 --- /dev/null +++ b/tests/examples/example_SAM-single-owner-PPA-5.txt @@ -0,0 +1,93 @@ +# Example: SAM Single Owner PPA Economic Model: Multiple Construction Years + +# *** ECONOMIC/FINANCIAL PARAMETERS *** +# ************************************* +Economic Model, 5, -- SAM Single Owner PPA +Construction Years, 7 +Construction CAPEX Schedule, 0.01,0.02,0.07,0.1,0.2,0.2,0.4 +Bond Financing Start Year, -3 + +Capital Cost for Power Plant for Electricity Generation, 1900 +Exploration Capital Cost, 120 + +Starting Electricity Sale Price, 0.08 +Ending Electricity Sale Price, 1.00 +Electricity Escalation Rate Per Year, 0.00322 +Electricity Escalation Start Year, 1 + +Fraction of Investment in Bonds, .65 +Inflated Bond Interest Rate, .07 +Discount Rate, 0.08 +Inflation Rate, .023 + +Combined Income Tax Rate, .28 +Investment Tax Credit Rate, 0.3 +Property Tax Rate, 0 + +Well Drilling Cost Correlation, 10, -- VERTICAL_LARGE_INT1 + +Reservoir Stimulation Capital Cost per Injection Well, 2.1739130435, -- $2.5M/well after contingency +Reservoir Stimulation Capital Cost per Production Well, 2.1739130435, -- Baseline stimulation cost of $4.0M, calibrated from high-intensity U.S. shale well analogue (~$39k/frac stage for 102 stages). This is a pre-contingency value and excludes EGS-specific cost premiums such as ceramic proppant and HPHT hardware. +Reservoir Stimulation Indirect Capital Cost Percentage, 0, -- Baseline stimulation cost includes indirect costs + +Field Gathering System Capital Cost Adjustment Factor, 0.54, -- Gathering costs represent 2% of facilities CAPEX per https://www.linkedin.com/pulse/fervo-energy-technology-day-2024-entering-geothermal-decade-matson-n4stc/ + + + +# *** SURFACE & SUBSURFACE TECHNICAL PARAMETERS *** +# ************************************************* +End-Use Option, 1, -- Electricity +Power Plant Type, 2, -- Supercritical ORC +Plant Lifetime, 30 + +Reservoir Model, 1 + +Reservoir Volume Option, 1, -- FRAC_NUM_SEP: Reservoir volume calculated with fracture separation and number of fractures as input +Number of Fractures, 1663, -- 55 fractures per well +Fracture Shape, 3, -- Square +Fracture Separation, 26, + +Reservoir Density, 2800 +Reservoir Depth, 2.6, -- km +Reservoir Heat Capacity, 790 +Reservoir Thermal Conductivity, 3.05 +Reservoir Porosity, 0.0118 +Injectivity Index, 3, -- [kg/s/bar] NREL ATB conservative scenario (https://atb.nrel.gov/electricity/2024/geothermal) +Productivity Index, 2.4742, -- [kg/s/bar] NREL ATB conservative scenario (https://atb.nrel.gov/electricity/2024/geothermal) + +Number of Segments, 1 +Gradient 1, 74 + +Number of Doublets, 15 + +Production Flow Rate per Well, 80 + +Production Well Diameter, 8.5 +Injection Well Diameter, 8.5 + +Well Separation, 365 feet + +Ramey Production Wellbore Model, 1 +Injection Temperature, 60 degC +Injection Wellbore Temperature Gain, 3 +Plant Outlet Pressure, 1000 psi +Production Wellhead Pressure, 325 psi + +Utilization Factor, .9 +Water Loss Fraction, 0.10 +Maximum Drawdown, 1, +Ambient Temperature, 10, -- degC +Surface Temperature, 10, -- degC +Circulation Pump Efficiency, 0.80 + +Well Geometry Configuration, 4 +Has Nonvertical Section, True +Multilaterals Cased, True +Number of Multilateral Sections, 3 +Nonvertical Length per Multilateral Section, 1433, -- meters +Number of Multilateral Sections, 0, -- This parameter is set to 0 because, for this case study, the cost of horizontal drilling is assumed to be included within the 'vertical drilling cost.' This approach allows us to more directly convey the overall well drilling and completion cost. + +# *** SIMULATION PARAMETERS *** +# ***************************** +Maximum Temperature, 500 +Time steps per year, 12 diff --git a/tests/examples/example_SAM-single-owner-PPA.out b/tests/examples/example_SAM-single-owner-PPA.out index 1762a941..ff9474be 100644 --- a/tests/examples/example_SAM-single-owner-PPA.out +++ b/tests/examples/example_SAM-single-owner-PPA.out @@ -4,10 +4,10 @@ Simulation Metadata ---------------------- - GEOPHIRES Version: 3.9.64 - Simulation Date: 2025-10-11 + GEOPHIRES Version: 3.10.13 + Simulation Date: 2025-11-29 Simulation Time: 11:36 - Calculation Time: 1.180 sec + Calculation Time: 1.164 sec ***SUMMARY OF RESULTS*** @@ -28,7 +28,6 @@ Simulation Metadata Real Discount Rate: 8.00 % Nominal Discount Rate: 10.16 % WACC: 7.57 % - Accrued financing during construction: 5.00 % Project lifetime: 20 yr Capacity factor: 90.0 % Project NPV: 126.12 MUSD @@ -107,6 +106,7 @@ Simulation Metadata Field gathering system costs: 8.50 MUSD Total surface equipment costs: 152.93 MUSD Exploration costs: 3.89 MUSD + Overnight Capital Cost: 215.06 MUSD Inflation costs during construction: 10.75 MUSD Total CAPEX: 225.81 MUSD @@ -196,6 +196,16 @@ Simulation Metadata *************************** -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Year 0 Year 1 Year 2 Year 3 Year 4 Year 5 Year 6 Year 7 Year 8 Year 9 Year 10 Year 11 Year 12 Year 13 Year 14 Year 15 Year 16 Year 17 Year 18 Year 19 Year 20 +CONSTRUCTION +Purchase of property [construction] ($) -225,808,536 + +Issuance of equity [construction] ($) 135,485,121 +Issuance of debt [construction] ($) 90,323,414 +Debt balance [construction] ($) 90,323,414 +Debt interest payment [construction] ($) 0 + +Total after-tax returns [construction] ($) -135,485,121 + ENERGY Electricity to grid (kWh) 0.0 428,554,957 431,240,089 432,051,822 432,530,792 432,865,667 433,120,646 433,325,167 433,495,101 433,639,945 433,765,810 433,876,853 433,976,022 434,065,480 434,146,861 434,221,417 434,290,137 434,353,843 434,413,216 434,468,735 434,518,614 Electricity from grid (kWh) 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 @@ -286,8 +296,8 @@ State PTC income ($) 0 0 0 State tax benefit (liability) ($) 0 -1,284,056 -972,763 -1,084,732 -1,195,555 -1,306,217 -1,417,063 -1,528,267 -1,639,932 -1,752,134 -1,864,934 -1,978,383 -2,092,531 -2,207,424 -2,323,108 -2,439,629 -2,557,033 -2,675,370 -2,794,690 -2,915,044 -10,939,762 Total after-tax returns ($) -135,485,121 83,286,459 16,938,508 17,970,292 18,982,881 19,986,247 20,983,647 21,976,490 22,965,464 23,950,924 24,933,045 25,911,899 26,887,488 27,859,772 28,828,676 29,794,101 30,755,926 31,714,016 32,668,223 33,618,374 117,514,829 -After-tax cumulative IRR (%) NaN -38.53 -22.41 -8.85 0.69 7.21 11.71 14.89 17.19 18.87 20.13 21.08 21.81 22.38 22.82 23.17 23.44 23.66 23.83 23.98 24.35 -After-tax cumulative NPV ($) -135,485,121 -59,880,130 -45,921,998 -32,479,395 -19,588,995 -7,268,969 4,472,905 15,636,159 26,225,863 36,251,384 45,725,442 54,663,353 63,082,403 71,001,333 78,439,908 85,418,557 91,958,078 98,079,390 103,803,327 109,150,473 126,117,827 +After-tax cumulative IRR (%) NaN -38.53 -22.41 -8.85 0.69 7.21 11.71 14.89 17.19 18.87 20.13 21.08 21.81 22.38 22.82 23.17 23.44 23.66 23.84 23.98 24.35 +After-tax cumulative NPV ($) -135,485,121 -59,880,129 -45,921,997 -32,479,395 -19,588,994 -7,268,969 4,472,905 15,636,160 26,225,864 36,251,384 45,725,442 54,663,354 63,082,404 71,001,334 78,439,909 85,418,558 91,958,079 98,079,391 103,803,327 109,150,474 126,117,828 AFTER-TAX LCOE AND PPA PRICE Annual costs ($) -135,485,121 49,002,062 -17,560,699 -17,985,061 -18,405,081 -18,824,489 -19,244,598 -19,666,059 -20,089,270 -20,514,516 -20,942,027 -21,372,001 -21,804,622 -22,240,066 -22,678,507 -23,120,121 -23,565,084 -24,013,582 -24,465,803 -24,921,943 57,568,641 diff --git a/tests/geophires_x_tests/test_economics_sam.py b/tests/geophires_x_tests/test_economics_sam.py index c0d62f1d..3d22e867 100644 --- a/tests/geophires_x_tests/test_economics_sam.py +++ b/tests/geophires_x_tests/test_economics_sam.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import math import os import sys @@ -8,6 +9,7 @@ import numpy as np import numpy_financial as npf +from geophires_x.Parameter import listParameter from base_test_case import BaseTestCase @@ -22,15 +24,24 @@ _get_fed_and_state_tax_rates, SamEconomicsCalculations, _get_royalty_rate_schedule, + _validate_construction_capex_schedule, ) -from geophires_x.GeoPHIRESUtils import sig_figs, quantity +from geophires_x.GeoPHIRESUtils import sig_figs, quantity, is_float # noinspection PyProtectedMember -from geophires_x.EconomicsSamCashFlow import _clean_profile, _is_category_row_label, _is_designator_row_label +from geophires_x.EconomicsSamCashFlow import ( + _clean_profile, + _is_category_row_label, + _is_designator_row_label, + _SAM_CASH_FLOW_NAN_STR, +) +from geophires_x.Units import convertible_unit from geophires_x_client import GeophiresInputParameters from geophires_x_client import GeophiresXClient from geophires_x_client import GeophiresXResult +_log = logging.getLogger(__name__) + class EconomicsSamTestCase(BaseTestCase): @@ -180,7 +191,7 @@ def get_single_value(name: str) -> list[float]: ) @staticmethod - def _get_cash_flow_row(cash_flow, name): + def _get_cash_flow_row(cash_flow: list[list[Any]], name: str) -> list[Any]: def r_0(r): if r is not None and len(r) > 0: @@ -200,17 +211,199 @@ def test_only_electricity_end_use_supported(self): self.assertIn('Invalid End-Use Option (Direct-Use Heat)', str(e.exception)) - def test_only_1_construction_year_supported(self): - # TODO remove this test and uncomment test_multiple_construction_years_supported below once multiple - # construction years are supported https://github.com/NREL/GEOPHIRES-X/issues/406 - with self.assertRaises(RuntimeError) as e: - self._get_result({'Construction Years': 2}) + def test_multiple_construction_years(self): + construction_years_2: GeophiresXResult = self._get_result( + { + 'Construction Years': 2, + 'Construction CAPEX Schedule': '0.5,0.5', + 'Fraction of Investment in Bonds': 0.25, + } + ) + self.assertIsNotNone(construction_years_2) + cy2_cf = construction_years_2.result['SAM CASH FLOW PROFILE'] + self.assertEqual('Year -1', cy2_cf[0][1]) + self.assertEqual('Year 20', cy2_cf[0][-1]) + + try: + with self.assertLogs(level='INFO') as logs: + construction_years_4 = self._get_result( + {'Construction Years': 4, 'Construction CAPEX Schedule': '0.5,0.5'} + ) + + self.assertHasLogRecordWithMessage( + logs, 'has been adjusted to: [0.25, 0.25, 0.25, 0.25]', treat_substring_match_as_match=True + ) + except AssertionError as ae: + self._handle_assert_logs_failure(ae) + + cy4_cf = construction_years_4.result['SAM CASH FLOW PROFILE'] + + cy4_result_npv = construction_years_4.result['ECONOMIC PARAMETERS']['Project NPV'] + self.assertAlmostEqualWithinSigFigs( + quantity(cy4_result_npv['value'], cy4_result_npv['unit']).to('USD').magnitude, + self._get_cash_flow_row(cy4_cf, 'After-tax cumulative NPV ($)')[-1], + num_sig_figs=4, + ) + + cy4_result_irr = construction_years_4.result['ECONOMIC PARAMETERS']['After-tax IRR'] + + self.assertAlmostEqualWithinSigFigs( + quantity(cy4_result_irr['value'], cy4_result_irr['unit']).to(convertible_unit('percent')).magnitude, + self._get_cash_flow_row(cy4_cf, 'After-tax cumulative IRR (%)')[-1], + ) + + def _floats(_cf: list[Any]) -> list[float]: + return [float(it) for it in _cf if is_float(it)] + + self.assertEqual( + _floats(self._get_cash_flow_row(cy4_cf, 'Debt balance [construction] ($)'))[-1], + _floats(self._get_cash_flow_row(cy4_cf, 'Debt balance ($)'))[0], + ) + + def _sum(cf_row_name: str, abs_val: bool = False) -> float: + return sum([abs(it) if abs_val else it for it in _floats(self._get_cash_flow_row(cy4_cf, cf_row_name))]) + + idc_sum = _sum('Debt interest payment [construction] ($)') + + cy4_idc = construction_years_4.result['CAPITAL COSTS (M$)']['Interest during construction'] + self.assertAlmostEqualWithinSigFigs( + idc_sum, quantity(cy4_idc['value'], cy4_idc['unit']).to('USD').magnitude, num_sig_figs=4 + ) + + installed_cost_from_construction_cash_flow = ( + _sum('Purchase of property [construction] ($)', abs_val=True) + idc_sum + ) + + self.assertEqual( + abs(_floats(self._get_cash_flow_row(cy4_cf, 'Total installed cost ($)'))[0]), + installed_cost_from_construction_cash_flow, + ) + + self.assertLess( + construction_years_4.result['CAPITAL COSTS (M$)']['Overnight Capital Cost']['value'], + installed_cost_from_construction_cash_flow, + ) - self.assertIn('Invalid Construction Years (2)', str(e.exception)) - self.assertIn('SAM_SINGLE_OWNER_PPA only supports Construction Years = 1.', str(e.exception)) + self.assertLess( + construction_years_4.result['CAPITAL COSTS (M$)']['Overnight Capital Cost']['value'], + construction_years_4.result['CAPITAL COSTS (M$)']['Total CAPEX']['value'], + ) + + self.assertEqual( + _sum('Issuance of equity [construction] ($)'), + _floats(self._get_cash_flow_row(cy4_cf, 'Issuance of equity ($)'))[0], + ) + + def test_validate_construction_capex_schedule(self): + model_logger = self._new_model(self._egs_test_file_path()).logger + + def _sched(sched: list[float]) -> listParameter: + construction_capex_schedule_name = 'Construction CAPEX Schedule' + schedule_param = listParameter( + construction_capex_schedule_name, + DefaultValue=[1.0], + Min=0.0, + Max=1.0, + ToolTipText=construction_capex_schedule_name, + ) + schedule_param.value = sched + return schedule_param - # def test_multiple_construction_years_supported(self): - # self.assertIsNotNone(self._get_result({'Construction Years': 2})) + half_half = [0.5, 0.5] + self.assertListEqual(half_half, _validate_construction_capex_schedule(_sched(half_half), 2, model_logger)) + + try: + with self.assertLogs(logger=model_logger.name, level='WARNING') as logs: + quarters = [0.25] * 4 + self.assertListEqual( + half_half, _validate_construction_capex_schedule(_sched(quarters), 2, model_logger) + ) + self.assertHasLogRecordWithMessage( + logs, 'has been adjusted to: [0.5, 0.5]', treat_substring_match_as_match=True + ) + except AssertionError as ae: + self._handle_assert_logs_failure(ae) + + try: + with self.assertLogs(logger=model_logger.name, level='WARNING') as logs2: + double_ones = [1.0, 1.0] + self.assertListEqual( + half_half, _validate_construction_capex_schedule(_sched(double_ones), 2, model_logger) + ) + self.assertHasLogRecordWithMessage(logs2, 'does not sum to 1.0', treat_substring_match_as_match=True) + except AssertionError as ae: + self._handle_assert_logs_failure(ae) + + def assertAlmostEqualWithinSigFigs(self, expected: float | int, actual: float | int, num_sig_figs: int = 3): + """ + TODO move to parent class (BaseTestCase) + """ + + self.assertEqual( + sig_figs(expected, num_sig_figs), + sig_figs(actual, num_sig_figs), + ) + + def test_bond_interest_rate_during_construction(self): + fraction_in_bonds: float = 0.5 + r: GeophiresXResult = self._get_result( + { + 'Construction Years': 2, + 'Inflation Rate During Construction': 0, + 'Fraction of Investment in Bonds': fraction_in_bonds, + 'Inflated Bond Interest Rate During Construction': 0, + } + ) + + def get_equity_usd(_r: GeophiresXResult) -> float: + equity_str = self._get_cash_flow_row(_r.result['SAM CASH FLOW PROFILE'], 'Issuance of equity ($)')[-1] + + return float(equity_str) + + equity_musd = quantity(get_equity_usd(r), 'USD').to('MUSD').magnitude + total_capex_musd = r.result['SUMMARY OF RESULTS']['Total CAPEX']['value'] + self.assertAlmostEqual(total_capex_musd * fraction_in_bonds, equity_musd, places=2) + + def test_bond_financing_start_year(self): + construction_years = 4 + + def _get_result_(_financing_start_year: int, _construction_years: int = construction_years) -> GeophiresXResult: + return self._get_result( + { + 'Construction Years': _construction_years, + 'Inflation Rate During Construction': 0, + 'Fraction of Investment in Bonds': 0.5, + 'Bond Financing Start Year': _financing_start_year, + } + ) + + def get_debt_issuance_usd(_r: GeophiresXResult) -> list[float]: + return self._get_cash_flow_row(_r.result['SAM CASH FLOW PROFILE'], 'Issuance of debt [construction] ($)') + + def _assert_debt_issuance_cash_flow_reflects_bond_financing_start_year(_r, _financing_start_year: int) -> None: + di = get_debt_issuance_usd(_r) + year_indexes = [int(it.replace('Year ', '')) for it in _r.result['SAM CASH FLOW PROFILE'][0][1:]] + bond_financing_start_cash_flow_index = ( + year_indexes.index(_financing_start_year) if year_indexes[0] <= _financing_start_year else 0 + ) + self.assertTrue(all(it == 0 for it in di[:bond_financing_start_cash_flow_index])) + self.assertTrue(all(it > 0 for it in di[bond_financing_start_cash_flow_index:])) + + for financing_start_year in range(-1 * (construction_years - 1), 1): + r: GeophiresXResult = _get_result_(financing_start_year) + _assert_debt_issuance_cash_flow_reflects_bond_financing_start_year(r, financing_start_year) + + fsy_min = -13 + r_prior_construction: GeophiresXResult = _get_result_(fsy_min) + _assert_debt_issuance_cash_flow_reflects_bond_financing_start_year(r_prior_construction, fsy_min) + + max_construction_years = 14 + _assert_debt_issuance_cash_flow_reflects_bond_financing_start_year( + _get_result_(fsy_min, _construction_years=max_construction_years), fsy_min + ) + + with self.assertRaises(RuntimeError): + _get_result_(1) # Bond financing start year is negative year indexed, so value must be less than 0 def test_ppa_pricing_model(self): self.assertListEqual( @@ -290,6 +483,50 @@ def test_inflation_rate_during_construction(self): self.assertAlmostEqual(tic_no_infl * (1 + infl_rate), tic_infl, places=0) + def _infl_cost_musd(r: GeophiresXResult) -> float: + return r.result['CAPITAL COSTS (M$)']['Inflation costs during construction']['value'] + + params_3 = { + 'Construction Years': 3, + 'Inflation Rate': 0.04769, + 'Inflated Bond Interest Rate During Construction': 0, + } + r3: GeophiresXResult = self._get_result( + params_3, file_path=self._get_test_file_path('generic-egs-case-3_no-inflation-rate-during-construction.txt') + ) + + # Validate that inflation during construction is calculated by compounding the inflation rate over construction years + occ_3 = r3.result['CAPITAL COSTS (M$)']['Overnight Capital Cost']['value'] + infl_rate_3 = params_3['Inflation Rate'] + # Default uniform schedule for 3 years + schedule = [1 / 3, 1 / 3, 1 / 3] + + expected_infl_cost_3 = sum([occ_3 * s * ((1 + infl_rate_3) ** (y + 1) - 1) for y, s in enumerate(schedule)]) + + self.assertAlmostEqual(expected_infl_cost_3, _infl_cost_musd(r3), places=1) + + cash_flow_3 = r3.result['SAM CASH FLOW PROFILE'] + tic_3 = EconomicsSamTestCase._get_cash_flow_row(cash_flow_3, tic)[-1] + + # Verify TIC matches OCC + Inflation Cost (IDC is 0) + self.assertAlmostEqual(occ_3 + expected_infl_cost_3, quantity(abs(tic_3), 'USD').to('MUSD').magnitude, places=0) + + params4 = { + 'Construction Years': 3, + 'Inflation Rate During Construction': 0.15, + 'Inflated Bond Interest Rate During Construction': 0, + } + r4: GeophiresXResult = self._get_result( + params4, file_path=self._get_test_file_path('generic-egs-case-3_no-inflation-rate-during-construction.txt') + ) + + # r4 treats 'Inflation Rate During Construction' as the annual inflation rate in the current implementation + infl_rate_4 = params4['Inflation Rate During Construction'] + occ_4 = r4.result['CAPITAL COSTS (M$)']['Overnight Capital Cost']['value'] + + expected_infl_cost_4 = sum([occ_4 * s * ((1 + infl_rate_4) ** (y + 1) - 1) for y, s in enumerate(schedule)]) + self.assertAlmostEqual(expected_infl_cost_4, _infl_cost_musd(r4), places=1) + def test_ptc(self): def assert_ptc(params, expected_ptc_usd_per_kWh): m: Model = EconomicsSamTestCase._new_model(self._egs_test_file_path(), additional_params=params) @@ -490,7 +727,7 @@ def test_get_fed_and_state_tax_rates(self): self.assertEqual(([21], [9]), _get_fed_and_state_tax_rates(0.3)) self.assertEqual(([10], [0]), _get_fed_and_state_tax_rates(0.1)) - def test_nan_after_tax_irr(self): + def test_nan_after_tax_irr_output_param(self): """ Verify that After-tax IRRs that would have been calculated as NaN by SAM are instead calculated with numpy-financial.irr @@ -509,9 +746,14 @@ def _irr(_r: GeophiresXResult) -> float: ) sam_after_tax_irr_calc = float(after_tax_irr_cash_flow_entries[-1]) - # Test case condition - we expect SAM to have calculated NaN here. If this assertion fails, adjust params passed - # to _get_result such that final year of After-tax cumulative IRR is NaN. - assert math.isnan(sam_after_tax_irr_calc) + try: + # As of 2025-11-14, this assertion is expected to fail because After-tax cumulative IRR is now backfilled + # upstream by the SAM-EM as part of adjusting IRR for multi-year construction periods. + # However, we would want to run the remainder of the test if the assertion does pass, hence why skipping + # is conditional. + assert math.isnan(sam_after_tax_irr_calc) + except AssertionError: + self.skipTest('Skipping because NaN after-tax IRR is now handled upstream by SAM-EM') after_tax_cash_flow = EconomicsSamTestCase._get_cash_flow_row( r.result['SAM CASH FLOW PROFILE'], 'Total after-tax returns ($)' @@ -522,6 +764,30 @@ def _irr(_r: GeophiresXResult) -> float: self.assertFalse(math.isnan(r_irr)) self.assertAlmostEqual(npf_irr, r_irr, places=2) + def test_nan__irr_cash_flow_line_items_for_multiple_construction_years(self): + """ + IRR during construction years is expected to be nan - serialized as 'NaN' + """ + + def _irr(_r: GeophiresXResult) -> float: + return _r.result['ECONOMIC PARAMETERS']['After-tax IRR']['value'] + + construction_years = 2 + + rate_params = { + 'Electricity Escalation Rate Per Year': 0.00348993288590604, + 'Starting Electricity Sale Price': 0.13, + 'Construction Years': construction_years, + } + r: GeophiresXResult = self._get_result(rate_params) + after_tax_irr_cash_flow_entries = EconomicsSamTestCase._get_cash_flow_row( + r.result['SAM CASH FLOW PROFILE'], 'After-tax cumulative IRR (%)' + ) + self.assertTrue( + all(it == _SAM_CASH_FLOW_NAN_STR for it in after_tax_irr_cash_flow_entries[:construction_years]) + ) + self.assertTrue(all(is_float(it) for it in after_tax_irr_cash_flow_entries[construction_years:])) + def test_nan_project_payback_period(self): def _payback_period(_r: GeophiresXResult) -> float: return _r.result['ECONOMIC PARAMETERS']['Project Payback Period']['value'] @@ -535,7 +801,12 @@ def _payback_period(_r: GeophiresXResult) -> float: def test_accrued_financing_during_construction(self): def _accrued_financing(_r: GeophiresXResult) -> float: - return _r.result['ECONOMIC PARAMETERS']['Accrued financing during construction']['value'] + econ_params = _r.result['ECONOMIC PARAMETERS'] + acf_key = 'Accrued financing during construction' + if econ_params[acf_key] is None: + self.skipTest(f'Economic parameters do not contain {acf_key}. (This is expected/OK.)') + + return econ_params[acf_key]['value'] params1 = { 'Construction Years': 1, @@ -544,7 +815,7 @@ def _accrued_financing(_r: GeophiresXResult) -> float: r1: GeophiresXResult = self._get_result( params1, file_path=self._get_test_file_path('generic-egs-case-3_no-inflation-rate-during-construction.txt') ) - self.assertAlmostEqual(4.769, _accrued_financing(r1), places=1) + self.assertAlmostEqual(0, _accrued_financing(r1), places=1) params2 = { 'Construction Years': 1, @@ -553,28 +824,7 @@ def _accrued_financing(_r: GeophiresXResult) -> float: r2: GeophiresXResult = self._get_result( params2, file_path=self._get_test_file_path('generic-egs-case-3_no-inflation-rate-during-construction.txt') ) - self.assertEqual(15.0, _accrued_financing(r2)) - - # TODO enable when multiple construction years are supported https://github.com/NREL/GEOPHIRES-X/issues/406 - # params3 = { - # 'Construction Years': 3, - # 'Inflation Rate': 0.04769, - # } - # r3: GeophiresXResult = self._get_result( - # params3, - # file_path=self._get_test_file_path('generic-egs-case-3_no-inflation-rate-during-construction.txt') - # ) - # self.assertEqual(15.0, _accrued_financing(r3)) - # - # params4 = { - # 'Construction Years': 3, - # 'Inflation Rate During Construction': 0.15, - # } - # r4: GeophiresXResult = self._get_result( - # params4, - # file_path=self._get_test_file_path('generic-egs-case-3_no-inflation-rate-during-construction.txt') - # ) - # self.assertEqual(15.0, _accrued_financing(r4)) + self.assertEqual(0, _accrued_financing(r2)) def test_add_ons(self): no_add_ons_result = self._get_result( @@ -620,6 +870,19 @@ def test_add_ons(self): file_path=self._get_test_file_path('egs-sam-em-add-ons.txt'), ) + add_ons_multiple_construction_years_result = self._get_result( + {'Do AddOn Calculations': True, 'Construction Years': 3}, + file_path=self._get_test_file_path('egs-sam-em-add-ons.txt'), + ) + self.assertGreater( + add_ons_multiple_construction_years_result.result['SUMMARY OF RESULTS']['Total CAPEX']['value'], + add_ons_result.result['SUMMARY OF RESULTS']['Total CAPEX']['value'], + ) + self.assertEqual( + add_ons_multiple_construction_years_result.result['CAPITAL COSTS (M$)']['Total Add-on CAPEX']['value'], + add_ons_result.result['CAPITAL COSTS (M$)']['Total Add-on CAPEX']['value'], + ) + def _assert_capex_line_items_sum_to_total(self, r: GeophiresXResult): capex_line_items = {key: value for key, value in r.result['CAPITAL COSTS (M$)'].items() if value is not None} @@ -629,6 +892,7 @@ def _assert_capex_line_items_sum_to_total(self, r: GeophiresXResult): capex_line_item_sum = 0 for line_item_name, capex_line_item in capex_line_items.items(): if line_item_name not in [ + 'Overnight Capital Cost', 'Total CAPEX', 'Total surface equipment costs', 'Drilling and completion costs per well', @@ -677,28 +941,7 @@ def test_royalty_rate_schedule(self): schedule: list[float] = _get_royalty_rate_schedule(m) self.assertListAlmostEqual( - [ - 0.1, - 0.11, - 0.12, - 0.13, - 0.14, - 0.15, - 0.15, - 0.15, - 0.15, - 0.15, - 0.15, - 0.15, - 0.15, - 0.15, - 0.15, - 0.15, - 0.15, - 0.15, - 0.15, - 0.15, - ], + [0.1, 0.11, 0.12, 0.13, 0.14, *[0.15] * 15], schedule, places=3, ) @@ -724,3 +967,14 @@ def _new_model(input_file: Path, additional_params: dict[str, Any] | None = None m.Calculate() return m + + def _handle_assert_logs_failure(self, ae: AssertionError): + if sys.version_info[:2] == (3, 8) and self._is_github_actions(): + # FIXME - see + # https://github.com/softwareengineerprogrammer/GEOPHIRES/actions/runs/19646240874/job/56262028512#step:5:344 + _log.warning( + f'WARNING: Skipping logs assertion in GitHub Actions ' + f'for Python {sys.version_info.major}.{sys.version_info.minor}' + ) + else: + raise ae diff --git a/tests/geophires_x_tests/test_economics_sam_pre_revenue.py b/tests/geophires_x_tests/test_economics_sam_pre_revenue.py new file mode 100644 index 00000000..c5f212b7 --- /dev/null +++ b/tests/geophires_x_tests/test_economics_sam_pre_revenue.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from base_test_case import BaseTestCase +from geophires_x.EconomicsSamPreRevenue import adjust_phased_schedule_to_new_length + + +class EconomicsSamPreRevenueTestCase(BaseTestCase): + + def test_adjust_phased_schedule_to_new_length(self) -> None: + def asrt(original_schedule: list[float], new_length: int, expected_schedule: list[float]) -> None: + adjusted_schedule = adjust_phased_schedule_to_new_length(original_schedule, new_length) + self.assertListAlmostEqual(expected_schedule, adjusted_schedule, percent=3) + + # fmt:off + asrt( + [1.], + 2, + [0.5, 0.5] + ) + + asrt( + [0.5, 0.5], + 4, + [0.25, 0.25, 0.25, 0.25] + ) + + asrt( + [0.25, 0.25, 0.25, 0.25], + 2, + [0.5, 0.5], + ) + + asrt( + [0.5, 0.25, 0.25], + 6, + [0.25] * 2 + [0.1278]*4 + ) + # fmt:on diff --git a/tests/test_base_test_case.py b/tests/test_base_test_case.py index 47f6a576..872966c2 100644 --- a/tests/test_base_test_case.py +++ b/tests/test_base_test_case.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import unittest from tests.base_test_case import BaseTestCase @@ -41,6 +43,26 @@ def test_assertAlmostEqualWithinPercentage_bad_arguments(self): 'self.assertListAlmostEqual([1, 2, 3], [1.1, 2.2, 3.3], msg=None, percent=10.5)', ) + def test_assertHasLogRecordWithMessage(self): + class _Message: + def __init__(self, msg: str): + self.message = msg + + class _Logs: + def __init__(self, records: list[str]): + self.records: list[_Message] = [_Message(record) for record in records] + + logs = _Logs( + [ + 'Parameter given (0.0) for Property Tax Rate is the same as the default value. Consider removing Property ' + 'Tax Rate from the input file unless you wish to change it from the default value of (0.0)', + 'Construction CAPEX Schedule length (2) did not match construction years (4). It has been adjusted to: ' + '[0.25, 0.25, 0.25, 0.25]', + "complete : read_parameters", + ] + ) + self.assertHasLogRecordWithMessage(logs, 'has been adjusted to', treat_substring_match_as_match=True) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index 65c4ed1b..868bb060 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -198,7 +198,13 @@ def get_output_file_for_example(example_file: str): example_files.remove(ef) example_files.append(ef) - assert len(example_files) > 0 # test integrity check - no files means something is misconfigured + # Test integrity check - no files means something is misconfigured + assert len(example_files) > 0, 'Test integrity check failed: example files is misconfigured.' + if self._is_github_actions(): + # Additional integrity check to catch when temporary local overrides to example file list are accidentally + # checked in. + assert len(example_files) > 10, 'Test integrity check failed: list of example files is too small.' + regenerate_cmds = [] for example_file_path in example_files: with self.subTest(msg=example_file_path): @@ -1312,62 +1318,82 @@ def test_royalty_rate(self): zero_royalty_npv = None for royalty_rate in [0, 0.1]: - result = GeophiresXClient().get_geophires_result( - ImmutableGeophiresInputParameters( - from_file_path=self._get_test_file_path( - 'geophires_x_tests/generic-egs-case-2_sam-single-owner-ppa.txt' - ), - params={ - 'Royalty Rate': royalty_rate, - }, - ) - ) - opex_result = result.result['OPERATING AND MAINTENANCE COSTS (M$/yr)'] + with self.subTest(msg=f'royalty_rate={royalty_rate}'): + + def _get_result( + _royalty_rate: float, additional_params: dict[str, Any] | None = None + ) -> GeophiresXResult: + if additional_params is None: + additional_params = {} + return GeophiresXClient().get_geophires_result( + ImmutableGeophiresInputParameters( + from_file_path=self._get_test_file_path( + 'geophires_x_tests/generic-egs-case-2_sam-single-owner-ppa.txt' + ), + params={'Royalty Rate': _royalty_rate, **additional_params}, + ) + ) - self.assertIsNotNone(opex_result[royalties_output_name]) - self.assertEqual('MUSD/yr', opex_result[royalties_output_name]['unit']) + result = _get_result(royalty_rate) + opex_result = result.result['OPERATING AND MAINTENANCE COSTS (M$/yr)'] - total_opex_MUSD = opex_result['Total operating and maintenance costs']['value'] + self.assertIsNotNone(opex_result[royalties_output_name]) + self.assertEqual('MUSD/yr', opex_result[royalties_output_name]['unit']) - opex_line_item_sum = 0 - for line_item_names in [ - 'Wellfield maintenance costs', - 'Power plant maintenance costs', - 'Water costs', - royalties_output_name, - ]: - opex_line_item_sum += opex_result[line_item_names]['value'] + total_opex_MUSD = opex_result['Total operating and maintenance costs']['value'] - self.assertEqual(opex_line_item_sum, total_opex_MUSD) + opex_line_item_sum = 0 + for line_item_names in [ + 'Wellfield maintenance costs', + 'Power plant maintenance costs', + 'Water costs', + royalties_output_name, + ]: + opex_line_item_sum += opex_result[line_item_names]['value'] - econ_result = result.result['EXTENDED ECONOMICS'] - royalty_holder_npv_MUSD = econ_result['Royalty Holder NPV']['value'] + self.assertEqual(opex_line_item_sum, total_opex_MUSD) - if royalty_rate > 0.0: - self.assertEqual(58.88, opex_result[royalties_output_name]['value']) - self.assertGreater(royalty_holder_npv_MUSD, 0) + def _royalty_holder_npv_MUSD(r: GeophiresXResult) -> float: + econ_result = r.result['EXTENDED ECONOMICS'] + return econ_result['Royalty Holder NPV']['value'] - # Owner NPV is lower when royalty rate is non-zero - self.assertGreater(zero_royalty_npv, result.result['ECONOMIC PARAMETERS']['Project NPV']['value']) + # econ_result = result.result['EXTENDED ECONOMICS'] + royalty_holder_npv_MUSD = _royalty_holder_npv_MUSD( + result + ) # econ_result['Royalty Holder NPV']['value'] - royalties_cash_flow_MUSD = [ - it * 1e-6 - for it in _cash_flow_profile_row( - result.result['SAM CASH FLOW PROFILE'], 'O&M production-based expense ($)' + if royalty_rate > 0.0: + self.assertEqual(58.88, opex_result[royalties_output_name]['value']) + self.assertGreater(royalty_holder_npv_MUSD, 0) + + # Owner NPV is lower when royalty rate is non-zero + self.assertGreater(zero_royalty_npv, result.result['ECONOMIC PARAMETERS']['Project NPV']['value']) + + royalties_cash_flow_MUSD = [ + it * 1e-6 + for it in _cash_flow_profile_row( + result.result['SAM CASH FLOW PROFILE'], 'O&M production-based expense ($)' + ) + ] + + self.assertAlmostEqual( + np.average(royalties_cash_flow_MUSD[1:]), opex_result[royalties_output_name]['value'], places=1 ) - ] - self.assertAlmostEqual( - np.average(royalties_cash_flow_MUSD[1:]), opex_result[royalties_output_name]['value'], places=1 - ) + if royalty_rate == 0.1: + base_expected_royalty_holder_npv_MUSD = 708.07 + self.assertAlmostEqual(base_expected_royalty_holder_npv_MUSD, royalty_holder_npv_MUSD, places=2) - if royalty_rate == 0.1: - self.assertAlmostEqual(708.07, royalty_holder_npv_MUSD, places=2) + result_multiple_construction_years = _get_result( + royalty_rate, additional_params={'Construction Years': 5} + ) + mcy_royalty_npv = _royalty_holder_npv_MUSD(result_multiple_construction_years) + self.assertLess(mcy_royalty_npv, base_expected_royalty_holder_npv_MUSD) - if royalty_rate == 0.0: - self.assertEqual(0, opex_result[royalties_output_name]['value']) - self.assertEqual(0, royalty_holder_npv_MUSD) - zero_royalty_npv = result.result['ECONOMIC PARAMETERS']['Project NPV']['value'] + if royalty_rate == 0.0: + self.assertEqual(0, opex_result[royalties_output_name]['value']) + self.assertEqual(0, royalty_holder_npv_MUSD) + zero_royalty_npv = result.result['ECONOMIC PARAMETERS']['Project NPV']['value'] def test_royalty_rate_escalation(self): royalties_output_name = 'Average Annual Royalty Cost' @@ -1376,59 +1402,61 @@ def test_royalty_rate_escalation(self): escalation_rate = 0.01 for max_rate in [0.08, 1.0]: - result = GeophiresXClient().get_geophires_result( - ImmutableGeophiresInputParameters( - from_file_path=self._get_test_file_path( - 'geophires_x_tests/generic-egs-case-2_sam-single-owner-ppa.txt' - ), - params={ - 'Royalty Rate': base_royalty_rate, - 'Royalty Rate Escalation': escalation_rate, - 'Royalty Rate Maximum': max_rate, - }, + with self.subTest(msg=f'max_rate={max_rate}'): + result = GeophiresXClient().get_geophires_result( + ImmutableGeophiresInputParameters( + from_file_path=self._get_test_file_path( + 'geophires_x_tests/generic-egs-case-2_sam-single-owner-ppa.txt' + ), + params={ + 'Royalty Rate': base_royalty_rate, + 'Royalty Rate Escalation': escalation_rate, + 'Royalty Rate Maximum': max_rate, + }, + ) ) - ) - opex_result = result.result['OPERATING AND MAINTENANCE COSTS (M$/yr)'] + opex_result = result.result['OPERATING AND MAINTENANCE COSTS (M$/yr)'] - self.assertIsNotNone(opex_result[royalties_output_name]) - self.assertEqual('MUSD/yr', opex_result[royalties_output_name]['unit']) + self.assertIsNotNone(opex_result[royalties_output_name]) + self.assertEqual('MUSD/yr', opex_result[royalties_output_name]['unit']) - total_opex_MUSD = opex_result['Total operating and maintenance costs']['value'] + total_opex_MUSD = opex_result['Total operating and maintenance costs']['value'] - opex_line_item_sum = 0 - for line_item_names in [ - 'Wellfield maintenance costs', - 'Power plant maintenance costs', - 'Water costs', - royalties_output_name, - ]: - opex_line_item_sum += opex_result[line_item_names]['value'] + opex_line_item_sum = 0 + for line_item_names in [ + 'Wellfield maintenance costs', + 'Power plant maintenance costs', + 'Water costs', + royalties_output_name, + ]: + opex_line_item_sum += opex_result[line_item_names]['value'] - self.assertAlmostEqual(opex_line_item_sum, total_opex_MUSD, places=4) + self.assertAlmostEqual(opex_line_item_sum, total_opex_MUSD, places=4) - project_lifetime_yrs = result.result['ECONOMIC PARAMETERS']['Project lifetime']['value'] + project_lifetime_yrs = result.result['ECONOMIC PARAMETERS']['Project lifetime']['value'] - royalties_cash_flow_MUSD = [ - it * 1e-6 - for it in _cash_flow_profile_row( - result.result['SAM CASH FLOW PROFILE'], 'O&M production-based expense ($)' - ) - ][1:] + royalties_cash_flow_MUSD = [ + it * 1e-6 + for it in _cash_flow_profile_row( + result.result['SAM CASH FLOW PROFILE'], 'O&M production-based expense ($)' + ) + ][1:] - ppa_revenue_MUSD = [ - it * 1e-6 for it in _cash_flow_profile_row(result.result['SAM CASH FLOW PROFILE'], 'PPA revenue ($)') - ][1:] + ppa_revenue_MUSD = [ + it * 1e-6 + for it in _cash_flow_profile_row(result.result['SAM CASH FLOW PROFILE'], 'PPA revenue ($)') + ][1:] - actual_royalty_rate = [None] * len(ppa_revenue_MUSD) - for i in range(len(ppa_revenue_MUSD)): - actual_royalty_rate[i] = royalties_cash_flow_MUSD[i] / ppa_revenue_MUSD[i] + actual_royalty_rate = [None] * len(ppa_revenue_MUSD) + for i in range(len(ppa_revenue_MUSD)): + actual_royalty_rate[i] = royalties_cash_flow_MUSD[i] / ppa_revenue_MUSD[i] - max_expected_rate = ( - max_rate if max_rate < 1.0 else base_royalty_rate + escalation_rate * (project_lifetime_yrs - 1) - ) + max_expected_rate = ( + max_rate if max_rate < 1.0 else base_royalty_rate + escalation_rate * (project_lifetime_yrs - 1) + ) - expected_last_year_revenue = ppa_revenue_MUSD[-1] * max_expected_rate - self.assertAlmostEqual(expected_last_year_revenue, royalties_cash_flow_MUSD[-1], places=3) + expected_last_year_revenue = ppa_revenue_MUSD[-1] * max_expected_rate + self.assertAlmostEqual(expected_last_year_revenue, royalties_cash_flow_MUSD[-1], places=3) def test_royalty_rate_with_addon(self): """ @@ -1469,4 +1497,4 @@ def test_royalty_rate_not_supported_for_non_sam_economic_models(self): ) ) - self.assertIn('Royalties are only supported for SAM Economic Models', str(re.exception)) + self.assertIn('Royalty Rate is only supported for SAM Economic Models', str(re.exception))