diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 09df3ff1..03087d57 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.65 +current_version = 3.10.22 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 232e4711..a3ed5236 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.22 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..38babf58 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|SurfacePlantUtils)\.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..8768db1e 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.22.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.22...main .. |docs| image:: https://readthedocs.org/projects/GEOPHIRES-X/badge/?style=flat :target: https://nrel.github.io/GEOPHIRES-X @@ -316,6 +316,10 @@ Example-specific web interface deeplinks are listed in the Link column. - `example_SAM-single-owner-PPA-4.txt `__ - `.out `__ - `link `__ + * - SAM Single Owner PPA: Multiple Construction Years + - `example_SAM-single-owner-PPA-5.txt `__ + - `.out `__ + - `link `__ .. raw:: html diff --git a/docs/SAM-Economic-Models.md b/docs/SAM-Economic-Models.md index a162b627..c8340539 100644 --- a/docs/SAM-Economic-Models.md +++ b/docs/SAM-Economic-Models.md @@ -15,28 +15,28 @@ The following table describes how GEOPHIRES parameters are transformed into SAM [EconomicsSam.py](https://github.com/softwareengineerprogrammer/GEOPHIRES/blob/274786e6799d32dad3f42a2a04297818b811f24c/src/geophires_x/EconomicsSam.py#L135-L195). (Note that the source code implementation determines actual behavior in the case of any discrepancies.) -| GEOPHIRES Parameter(s) | SAM Category | SAM Input(s) | SAM Module(s) | SAM Parameter Name(s) | Comment | -|-------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|-----------------------------------|--------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `Maximum Total Electricity Generation` | Generation Profile | `Nameplate capacity` | `Singleowner` | `system_capacity` | .. N/A | -| `Utilization Factor` | Generation Profile | `Nominal capacity factor` | `Singleowner` | `user_capacity_factor` | .. N/A | -| `Net Electricity Generation` | AC Degradation | `Annual AC degradation rate` schedule | `Utilityrate5` | `degradation` | Percentage difference of each year's `Net Electricity Generation` from `Maximum Total Electricity Generation` is input as SAM as the degradation rate schedule in order to match SAM's generation profile to GEOPHIRES | -| {`Total CAPEX` before inflation} × (1 + `Accrued financing during construction (%)`/100); | Installation Costs | `Total Installed Cost` | `Singleowner` | `total_installed_cost` | `Accrued financing during construction (%)` = (1+`Inflation Rate During Construction`) × 100 if `Inflation Rate During Construction` is provided or ((1+`Inflation Rate`) ^ `Construction Years`) × 100 if not. | -| `Total O&M Cost`, `Inflation Rate` | Operating Costs | `Fixed operating cost`, `Escalation rate` set to `Inflation Rate` × -1 | `Singleowner` | `om_fixed`, `om_fixed_escal` | .. N/A | -| `Royalty Rate`, `Royalty Rate Escalation`, `Royalty Rate Maximum` | Operating Costs | `Variable operating cost` | `Singleowner` | `om_production` | The royalty is modeled as a tax-deductible variable operating expense. GEOPHIRES calculates a schedule of $/MWh values based on the PPA price and Royalty Rate for each year, with optional escalation and cap (maximum). This ensures the total annual expense in SAM accurately matches the royalty payment due on gross revenue. | -| `Plant Lifetime` | Financial Parameters → Analysis Parameters | `Analysis period` | `CustomGeneration`, `Singleowner` | `CustomGeneration.analysis_period`, `Singleowner.term_tenor` | .. N/A | -| `Inflation Rate` | Financial Parameters → Analysis Parameters | `Inflation rate` | `Utilityrate5` | `inflation_rate` | .. N/A | -| `Discount Rate` | Financial Parameters → Analysis Parameters | `Real discount rate` | `Singleowner` | `real_discount_rate` | .. N/A | -| `Combined Income Tax Rate` | Financial Parameters → Project Tax and Insurance Rates | `Federal income tax rate`\: minimum of {21%, CITR}; and `State income tax rate`: maximum of {0%; CITR - 21%} | `Singleowner` | `federal_tax_rate`, `state_tax_rate` | GEOPHIRES does not have separate parameters for federal and state income tax so the rates are split from the combined rate based on an assumption of a maximum federal tax rate of 21% and the residual amount being the state tax rate. | -| `Property Tax Rate` | Financial Parameters | `Property tax rate` | `Singleowner` | `property_tax_rate` | .. N/A | -| `Fraction of Investment in Bonds` | Financial Parameters → Project Term Debt | `Debt percent` | `Singleowner` | `debt_percent` | .. N/A | -| `Inflated Bond Interest Rate` | Financial Parameters → Project Term Debt | `Annual interest rate` | `Singleowner` | `term_int_rate` | .. N/A | -| `Starting Electricity Sale Price`, `Ending Electricity Sale Price`, `Electricity Escalation Rate Per Year`, `Electricity Escalation Start Year` | Revenue | `PPA price` | `Singleowner` | `ppa_price_input` | GEOPHIRES's pricing model is used to create a PPA price schedule that is passed to SAM. | -| `Total AddOn Profit Gained` | Revenue → Capacity Payments | `Fixed amount`, `Capacity payment amount` | `Singleowner` | `cp_capacity_payment_type = 1`, `cp_capacity_payment_amount` | | -| `Investment Tax Credit Rate` | Incentives → Investment Tax Credit (ITC) | `Federal` → `Percentage (%)` | `Singleowner` | `itc_fed_percent` | Note that unlike the BICYCLE Economic Model's `Total capital costs`, SAM Economic Model's `Total CAPEX` is the total installed cost and does not subtract ITC value (if present). | -| `Production Tax Credit Electricity` | Incentives → Production Tax Credit (PTC) | `Federal` → `Amount ($/kWh)` | `Singleowner` | `ptc_fed_amount` | .. N/A | -| `Production Tax Credit Duration` | Incentives → Production Tax Credit (PTC) | `Federal` → `Term (years)` | `Singleowner` | `ptc_fed_term` | .. N/A | -| `Production Tax Credit Inflation Adjusted`, `Inflation Rate` | Incentives → Production Tax Credit (PTC) | `Federal` → `Escalation (%/yr)` | `Singleowner` | `ptc_fed_escal` | If `Production Tax Credit Inflation Adjusted` = True, GEOPHIRES set's SAM's PTC escalation rate to the inflation rate. SAM applies the escalation rate to years 2 and later of the project cash flow. Note that this produces escalation rates that are similar to inflation-adjusted equivalents, but not exactly equal. | -| `Other Incentives` + `One-time Grants Etc` | Incentives → Investment Based Incentive (IBI) | `Other` → `Amount ($)` | `Singleowner` | `ibi_oth_amount` | .. N/A | +| GEOPHIRES Parameter(s) | SAM Category | SAM Input(s) | SAM Module(s) | SAM Parameter Name(s) | Comment | +|-------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|-----------------------------------|--------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Maximum Total Electricity Generation` | Generation Profile | `Nameplate capacity` | `Singleowner` | `system_capacity` | .. N/A | +| `Utilization Factor` | Generation Profile | `Nominal capacity factor` | `Singleowner` | `user_capacity_factor` | .. N/A | +| `Net Electricity Generation` | AC Degradation | `Annual AC degradation rate` schedule | `Utilityrate5` | `degradation` | Percentage difference of each year's `Net Electricity Generation` from `Maximum Total Electricity Generation` is input as SAM as the degradation rate schedule in order to match SAM's generation profile to GEOPHIRES | +| `Total CAPEX` | Installation Costs | `Total Installed Cost` | `Singleowner` | `total_installed_cost` | `Total CAPEX` = `Overnight Capital Cost` + `Inflation costs during construction` + `Interest during construction` | +| `Total O&M Cost`, `Inflation Rate` | Operating Costs | `Fixed operating cost`, `Escalation rate` set to `Inflation Rate` × -1 | `Singleowner` | `om_fixed`, `om_fixed_escal` | .. N/A | +| `Royalty Rate`, `Royalty Rate Escalation`, `Royalty Rate Escalation Start Year`, `Royalty Rate Maximum` | Operating Costs | `Variable operating cost` | `Singleowner` | `om_production` | The royalty is modeled as a tax-deductible variable operating expense. GEOPHIRES calculates a schedule of $/MWh values based on the PPA price and Royalty Rate for each year, with optional escalation, escalation start year, and cap (maximum). This ensures the total annual expense in SAM accurately matches the royalty payment due on gross revenue. | +| `Plant Lifetime` | Financial Parameters → Analysis Parameters | `Analysis period` | `CustomGeneration`, `Singleowner` | `CustomGeneration.analysis_period`, `Singleowner.term_tenor` | .. N/A | +| `Inflation Rate` | Financial Parameters → Analysis Parameters | `Inflation rate` | `Utilityrate5` | `inflation_rate` | .. N/A | +| `Discount Rate` | Financial Parameters → Analysis Parameters | `Real discount rate` | `Singleowner` | `real_discount_rate` | .. N/A | +| `Combined Income Tax Rate` | Financial Parameters → Project Tax and Insurance Rates | `Federal income tax rate`\: minimum of {21%, CITR}; and `State income tax rate`: maximum of {0%; CITR - 21%} | `Singleowner` | `federal_tax_rate`, `state_tax_rate` | GEOPHIRES does not have separate parameters for federal and state income tax so the rates are split from the combined rate based on an assumption of a maximum federal tax rate of 21% and the residual amount being the state tax rate. | +| `Property Tax Rate` | Financial Parameters | `Property tax rate` | `Singleowner` | `property_tax_rate` | .. N/A | +| `Fraction of Investment in Bonds` | Financial Parameters → Project Term Debt | `Debt percent` | `Singleowner` | `debt_percent` | .. N/A | +| `Inflated Bond Interest Rate` | Financial Parameters → Project Term Debt | `Annual interest rate` | `Singleowner` | `term_int_rate` | .. N/A | +| `Starting Electricity Sale Price`, `Ending Electricity Sale Price`, `Electricity Escalation Rate Per Year`, `Electricity Escalation Start Year` | Revenue | `PPA price` | `Singleowner` | `ppa_price_input` | GEOPHIRES's pricing model is used to create a PPA price schedule that is passed to SAM. | +| `Total AddOn Profit Gained` | Revenue → Capacity Payments | `Fixed amount`, `Capacity payment amount` | `Singleowner` | `cp_capacity_payment_type = 1`, `cp_capacity_payment_amount` | | +| `Investment Tax Credit Rate` | Incentives → Investment Tax Credit (ITC) | `Federal` → `Percentage (%)` | `Singleowner` | `itc_fed_percent` | Note that unlike the BICYCLE Economic Model's `Total capital costs`, SAM Economic Model's `Total CAPEX` is the total installed cost and does not subtract ITC value (if present). | +| `Production Tax Credit Electricity` | Incentives → Production Tax Credit (PTC) | `Federal` → `Amount ($/kWh)` | `Singleowner` | `ptc_fed_amount` | .. N/A | +| `Production Tax Credit Duration` | Incentives → Production Tax Credit (PTC) | `Federal` → `Term (years)` | `Singleowner` | `ptc_fed_term` | .. N/A | +| `Production Tax Credit Inflation Adjusted`, `Inflation Rate` | Incentives → Production Tax Credit (PTC) | `Federal` → `Escalation (%/yr)` | `Singleowner` | `ptc_fed_escal` | If `Production Tax Credit Inflation Adjusted` = True, GEOPHIRES set's SAM's PTC escalation rate to the inflation rate. SAM applies the escalation rate to years 2 and later of the project cash flow. Note that this produces escalation rates that are similar to inflation-adjusted equivalents, but not exactly equal. | +| `Other Incentives` + `One-time Grants Etc` | Incentives → Investment Based Incentive (IBI) | `Other` → `Amount ($)` | `Singleowner` | `ibi_oth_amount` | .. N/A | .. .. Comment entries of ".. N/A" render as blank in the final RST, by design. @@ -50,9 +50,7 @@ The following table describes how GEOPHIRES parameters are transformed into SAM ### Limitations 1. Only Electricity end-use is supported -2. Only 1 construction year is supported. Note that the `Inflation Rate During Construction` parameter can be used to - partially account for longer construction periods. -3. Add-ons with electricity and heat are not currently supported. (Add-ons CAPEX, OPEX, and profit are supported.) +2. Add-ons with electricity and heat are not currently supported. (Add-ons CAPEX, OPEX, and profit are supported.) ## Using SAM Economic Models with Existing GEOPHIRES Inputs @@ -139,6 +137,17 @@ You can then manually enter the parameters from the logged mapping into the SAM ![](_images/sam-desktop-app-manually-enter-system-capacity-from-geophires-log.png) +## Multiple Construction Years + +Multiple construction years are supported by providing the `Construction Years` parameter. +GEOPHIRES simulates the pre-revenue construction phase to calculate the project's Year 0 equivalent capitalized cost, +which serves as the basis for depreciation and permanent debt sizing. +This calculation accounts for the timing of capital deployment defined in by `Construction CAPEX Schedule`, +capturing both inflation costs and interest during construction (IDC) accrued prior to the start of operations. + + +[Multiple Construction Years example web interface link](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=example_SAM-single-owner-PPA-5) + ## Add-Ons SAM Economic Models incorporate add-ons directly, unlike other GEOPHIRES economic models, which calculate separate @@ -195,11 +204,12 @@ Output Parameters: See [Case Study: 500 MWe EGS Project Modeled on Fervo Cape Station](Fervo_Project_Cape-4.html). -### SAM Single Owner PPA: 50 MWe +### SAM Single Owner PPA 1. [SAM Single Owner PPA: 50 MWe](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=example_SAM-single-owner-PPA) 2. [SAM Single Owner PPA: 50 MWe with Add-ons](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=example_SAM-single-owner-PPA-3) 3. [SAM Single Owner PPA: 50 MWe with Royalties](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=example_SAM-single-owner-PPA-4) +4. [SAM Economic Model Multiple Construction Years](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=example_SAM-single-owner-PPA-5) ### SAM Single Owner PPA: 400 MWe BICYCLE Comparison diff --git a/docs/conf.py b/docs/conf.py index a36c1647..460037da 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.22' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index bd70a3a4..e41d6d78 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.22', 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..f47b02d5 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -13,12 +13,15 @@ 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, CONSTRUCTION_CAPEX_SCHEDULE_PARAMETER_NAME, \ + _YEAR_INDEX_VALUE_EXPLANATION_SNIPPET 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 @@ -1004,6 +1007,17 @@ def __init__(self, model: Model): "increases a 4% rate (0.04) to 4.1% (0.041) in the next year." ) + self.royalty_escalation_rate_start_year = self.ParameterDict[self.royalty_escalation_rate_start_year.Name] = intParameter( + 'Royalty Rate Escalation Start Year', + DefaultValue=1, + AllowableRange=list(range(1, model.surfaceplant.plant_lifetime.AllowableRange[-1], 1)), + UnitType=Units.PERCENT, + PreferredUnits=PercentUnit.TENTH, + CurrentUnits=PercentUnit.TENTH, + ToolTipText=f'The first year that the {self.royalty_escalation_rate.Name} is applied. ' + f'{_YEAR_INDEX_VALUE_EXPLANATION_SNIPPET}.' + ) + maximum_royalty_rate_default_val = 1.0 self.maximum_royalty_rate = self.ParameterDict[self.maximum_royalty_rate.Name] = floatParameter( 'Royalty Rate Maximum', @@ -1050,28 +1064,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=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, - ErrMessage="assume default inflated bond interest rate (0.05)", - ToolTipText="Inflated bond interest rate (see docs)" + 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 +1184,50 @@ def __init__(self, model: Model): 'calculated automatically by compounding Inflation Rate over Construction Years.' ) + self.construction_capex_schedule = self.ParameterDict[self.construction_capex_schedule.Name] = listParameter( + CONSTRUCTION_CAPEX_SCHEDULE_PARAMETER_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_PARAMETER_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'{_YEAR_INDEX_VALUE_EXPLANATION_SNIPPET}; 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 +2233,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 +2244,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 +2628,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") @@ -3330,10 +3413,11 @@ def r(x: float) -> float: schedule = [] current_rate = r(self.royalty_rate.value) - for _ in range(plant_lifetime): + for year_index in range(plant_lifetime): current_rate = r(current_rate) schedule.append(min(current_rate, max_rate)) - current_rate += escalation_rate + if year_index >= (model.economics.royalty_escalation_rate_start_year.value - 2): + current_rate += escalation_rate return schedule @@ -3436,7 +3520,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 +3529,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..4c4a96fc 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,10 @@ 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_operational_years, + _SAM_CASH_FLOW_NAN_STR, +) from geophires_x.EconomicsUtils import ( BuildPricingModel, wacc_output_parameter, @@ -39,16 +41,32 @@ project_payback_period_parameter, total_capex_parameter_output_parameter, royalty_cost_output_parameter, + overnight_capital_cost_output_parameter, + _SAM_EM_MOIC_RETURNS_TAX_QUALIFIER, +) +from geophires_x.EconomicsSamPreRevenue import ( + _AFTER_TAX_NET_CASH_FLOW_ROW_NAME, + PreRevenueCostsAndCashflow, + 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 -from geophires_x.Parameter import Parameter, OutputParameter, floatParameter +from geophires_x.Parameter import Parameter, OutputParameter, floatParameter, listParameter from geophires_x.Units import convertible_unit, EnergyCostUnit, CurrencyUnit, Units +ROYALTIES_OPEX_CASH_FLOW_LINE_ITEM_KEY = 'O&M production-based expense ($)' + + @dataclass class SamEconomicsCalculations: - sam_cash_flow_profile: list[list[Any]] + _sam_cash_flow_profile_operational_years: list[list[Any]] + """ + Operational cash flow profile from SAM financial engine + """ + + pre_revenue_costs_and_cash_flow: PreRevenueCostsAndCashflow lcoe_nominal: OutputParameter = field( default_factory=lambda: OutputParameter( @@ -57,8 +75,11 @@ 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_rate_schedule: list[float] | None = None royalties_opex: OutputParameter = field(default_factory=royalty_cost_output_parameter) project_npv: OutputParameter = field( @@ -77,8 +98,122 @@ 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[_AFTER_TAX_NET_CASH_FLOW_ROW_NAME] + ) + + @property + def sam_cash_flow_profile(self) -> list[list[Any]]: + ret: list[list[Any]] = self._sam_cash_flow_profile_operational_years.copy() + col_count = len(self._sam_cash_flow_profile_operational_years[0]) + + # TODO support/insert calendar year line item https://github.com/NREL/GEOPHIRES-X/issues/439 + + pre_revenue_years_to_insert = self._pre_revenue_years_count - 1 + + construction_rows: list[list[Any]] = [ + ['CONSTRUCTION'] + [''] * (len(self._sam_cash_flow_profile_operational_years[0]) - 1) + ] + + for row_index in range(len(self._sam_cash_flow_profile_operational_years)): + 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 for non-construction years 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_operational_years[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 validate_read_parameters(model: Model): + 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] = ( + self.pre_revenue_costs_and_cash_flow.pre_revenue_cash_flow_profile_dict[_AFTER_TAX_NET_CASH_FLOW_ROW_NAME] + + _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)] + irr_row_name = 'After-tax cumulative IRR (%)' + ret.insert( + _get_row_index(irr_row_name), ['After-tax net cash flow ($)', *[int(it) for it in after_tax_cash_flow]] + ) + + 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 (%)')] = [irr_row_name] + irr_pct + + if self._royalties_rate_schedule is not None: + ret = self._insert_royalties_rate_schedule(ret) + + return ret + + def _insert_royalties_rate_schedule(self, cf_ret: list[list[Any]]) -> list[list[Any]]: + """ + TODO update user-facing documentation to mention this feature + (https://nrel.github.io/GEOPHIRES/SAM-Economic-Models.html#royalties) + """ + + ret = cf_ret.copy() + + def _get_row_index(row_name_: str) -> list[Any]: + return [it[0] for it in ret].index(row_name_) + + ret.insert( + _get_row_index(ROYALTIES_OPEX_CASH_FLOW_LINE_ITEM_KEY), + [ + *['Royalty rate (%)'], + *([''] * (self._pre_revenue_years_count)), + *[ + quantity(it, 'dimensionless').to(convertible_unit('percent')).magnitude + for it in self._royalties_rate_schedule + ], + ], + ) + + return ret + + @property + def sam_after_tax_net_cash_flow_all_years(self) -> list[float]: + return _after_tax_net_cash_flow_all_years(self.sam_cash_flow_profile, self._pre_revenue_years_count) + + +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 +231,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 +244,52 @@ 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, + ) + + 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: Model +) -> 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) + + if model.outputs.printoutput.value: + # Use printoutput as a proxy for whether the user has requested logging; + # TODO to implement/support logging-specific config + + 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: @@ -160,45 +332,110 @@ def calculate_sam_economics(model: Model) -> SamEconomicsCalculations: for module in modules: module.execute() - cash_flow = _calculate_sam_economics_cash_flow(model, single_owner) + cash_flow_operational_years = _calculate_sam_economics_cash_flow_operational_years(model, single_owner) 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_operational_years=cash_flow_operational_years, + 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.after_tax_irr.value = sf(_get_after_tax_irr_pct(single_owner, cash_flow_operational_years, 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_operational_years, 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_operational_years, ROYALTIES_OPEX_CASH_FLOW_LINE_ITEM_KEY) + ], ] + sam_economics._royalties_rate_schedule = model.economics.get_royalty_rate_schedule(model) + sam_economics.nominal_discount_rate.value, sam_economics.wacc.value = _calculate_nominal_discount_rate_and_wacc( model, single_owner ) - sam_economics.moic.value = _calculate_moic(cash_flow, model) - sam_economics.project_vir.value = _calculate_project_vir(cash_flow, model) - sam_economics.project_payback_period.value = _calculate_project_payback_period(cash_flow, model) + sam_economics.moic.value = _calculate_moic(sam_economics.sam_cash_flow_profile, model) + sam_economics.project_vir.value = _calculate_project_vir(sam_economics.sam_cash_flow_profile, model) + sam_economics.project_payback_period.value = _calculate_project_payback_period( + sam_economics.sam_cash_flow_profile, model + ) return sam_economics +def _after_tax_net_cash_flow_all_years(cash_flow: list[list[Any]], pre_revenue_years_count: int) -> list[float]: + return _net_cash_flow_all_years(cash_flow, pre_revenue_years_count, tax_qualifier='after-tax') + + +def _net_cash_flow_all_years( + cash_flow: list[list[Any]], pre_revenue_years_count: int, tax_qualifier='after-tax' +) -> list[float]: + if tax_qualifier not in ['after-tax', 'pre-tax']: + raise ValueError(f'Invalid tax qualifier: {tax_qualifier}') + + def _get_row(row_name__: str) -> list[Any]: + for r in cash_flow: + if r[0] == row_name__: + return r[1:] + + raise ValueError(f'Could not find row with name {row_name__}') + + def _construction_returns_row(_construction_tax_qualifier: str) -> list[Any]: + returns_row_name = ( + f'Total {_construction_tax_qualifier} returns [construction] ($)' + if tax_qualifier == 'pre-tax' + else f'After-tax net cash flow [construction] ($)' + ) + return _get_row(returns_row_name) + + try: + construction_returns_row = _construction_returns_row(tax_qualifier) + except ValueError as ve: + if tax_qualifier == 'pre-tax': + # TODO log warning + construction_returns_row = _construction_returns_row('after-tax') + else: + raise ve + + return [ + *[float(it) for it in construction_returns_row if is_float(it)], + *[float(it) for it in _get_row(f'Total {tax_qualifier} returns ($)')[pre_revenue_years_count:] if is_float(it)], + ] + + +def _get_project_npv_musd(single_owner: Singleowner, cash_flow: list[list[Any]], model: Model) -> float: + pre_revenue_costs: PreRevenueCostsAndCashflow = calculate_pre_revenue_costs_and_cashflow(model) + pre_revenue_cash_flow = pre_revenue_costs.after_tax_net_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:] + + 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$ + + +# noinspection PyUnusedLocal 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.after_tax_net_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 return after_tax_irr_pct @@ -207,6 +444,11 @@ def _cash_flow_profile_row(cash_flow: list[list[Any]], row_name: str) -> list[An return next(row for row in cash_flow if len(row) > 0 and row[0] == row_name)[1:] # type: ignore[no-any-return] +def _cash_flow_profile_entry(cash_flow: list[list[Any]], row_name: str, year_index: int) -> list[Any]: + col_index = cash_flow[0].index(f'Year {year_index}') + return _cash_flow_profile_row(cash_flow, row_name)[col_index - 1] + + def _calculate_nominal_discount_rate_and_wacc(model: Model, single_owner: Singleowner) -> tuple[float]: """ Calculation per SAM Help -> Financial Parameters -> Commercial -> Commercial Loan Parameters -> WACC @@ -230,9 +472,17 @@ def _calculate_nominal_discount_rate_and_wacc(model: Model, single_owner: Single def _calculate_moic(cash_flow: list[list[Any]], model) -> float | None: try: - total_capital_invested_USD: Decimal = Decimal(_cash_flow_profile_row(cash_flow, 'Issuance of equity ($)')[0]) + total_capital_invested_USD: Decimal = Decimal( + next(it for it in _cash_flow_profile_row(cash_flow, 'Issuance of equity ($)') if is_float(it)) + ) + total_value_received_from_investment_USD: Decimal = sum( - [Decimal(it) for it in _cash_flow_profile_row(cash_flow, 'Total pre-tax returns ($)')] + [ + Decimal(it) + for it in _net_cash_flow_all_years( + cash_flow, _pre_revenue_years_count(model), tax_qualifier=_SAM_EM_MOIC_RETURNS_TAX_QUALIFIER + ) + ] ) return float(total_value_received_from_investment_USD / total_capital_invested_USD) except Exception as e: @@ -246,10 +496,13 @@ def _calculate_project_vir(cash_flow: list[list[Any]], model) -> float | None: Where CF_0 is the cash flow at Year 0 (the initial investment). NPV = CF_0 + PV(Future Cash Flows) PV(Future Cash Flows) = NPV - CF_0 + + TODO add user-facing documentation (including clarification of CF_0 being Year 0, not first construction year) """ + try: npv_USD = Decimal(_cash_flow_profile_row(cash_flow, 'After-tax cumulative NPV ($)')[-1]) - cf_0_USD = Decimal(_cash_flow_profile_row(cash_flow, 'Total after-tax returns ($)')[0]) + cf_0_USD = _cash_flow_profile_entry(cash_flow, 'Total after-tax returns ($)', 0) pv_of_future_cash_flows_USD = npv_USD - cf_0_USD vir = pv_of_future_cash_flows_USD / abs(cf_0_USD) @@ -260,9 +513,12 @@ def _calculate_project_vir(cash_flow: list[list[Any]], model) -> float | None: def _calculate_project_payback_period(cash_flow: list[list[Any]], model) -> float | None: - """TODO remove or clarify project payback period: https://github.com/NREL/GEOPHIRES-X/issues/413""" + """ + TODO remove or clarify project payback period: https://github.com/NREL/GEOPHIRES-X/issues/413 + """ + try: - after_tax_cash_flow = _cash_flow_profile_row(cash_flow, 'Total after-tax returns ($)') + after_tax_cash_flow = _after_tax_net_cash_flow_all_years(cash_flow, _pre_revenue_years_count(model)) cumm_cash_flow = np.zeros(len(after_tax_cash_flow)) cumm_cash_flow[0] = after_tax_cash_flow[0] for year in range(1, len(after_tax_cash_flow)): @@ -293,7 +549,6 @@ def get_sam_cash_flow_profile_tabulated_output(model: Model, **tabulate_kw_args) 'floatfmt': ',.2f', **tabulate_kw_args } - # fmt:on def get_entry_display(entry: Any) -> str: @@ -315,11 +570,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 +586,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 +623,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 +670,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 +686,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 +700,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..ac642c1f 100644 --- a/src/geophires_x/EconomicsSamCashFlow.py +++ b/src/geophires_x/EconomicsSamCashFlow.py @@ -14,18 +14,20 @@ 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]]: +def _calculate_sam_economics_cash_flow_operational_years(model: Model, single_owner: Singleowner) -> list[list[Any]]: log = model.logger _soo = single_owner.Outputs profile = [] - # 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 + total_duration = model.surfaceplant.plant_lifetime.value + 1 + # Note we do not include construction years in total_duration because we only calculating cash flow for operational + # years. # 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 +62,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..5cc030cd --- /dev/null +++ b/src/geophires_x/EconomicsSamPreRevenue.py @@ -0,0 +1,293 @@ +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, quantity, sig_figs +from scipy.interpolate import interp1d + +from geophires_x.Units import convertible_unit + +_AFTER_TAX_NET_CASH_FLOW_ROW_NAME = 'After-tax net cash flow ($)' +_IDC_CASH_FLOW_ROW_NAME = 'Debt interest payment ($)' +_INSTALLED_COST_CASH_FLOW_ROW_NAME = 'Installed cost ($)' + + +@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 after_tax_net_cash_flow_usd(self): + return self.pre_revenue_cash_flow_profile_dict[_AFTER_TAX_NET_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, +) -> 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 + + inflation_cost_vec: list[float] = [] + base_capex_vec: list[float] = [] + 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] = [] + + total_installed_cost_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] + base_capex_vec.append(base_capex_this_year_usd) + + inflation_factor = (1.0 + inflation_rate) ** (year_index + 1) + inflation_cost_this_year_usd = base_capex_this_year_usd * (inflation_factor - 1.0) + + inflation_cost_vec.append(inflation_cost_this_year_usd) + + 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) + + total_installed_cost_vec.append(-capex_this_year_usd + -interest_this_year_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 + if '(' in row_name_adjusted: # don't apply to plus:/equals: lines + row_name_adjusted = ( + row_name_adjusted.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'Capital expenditure schedule (%)', + [ + sig_figs(quantity(x, 'dimensionless').to(convertible_unit('percent')).magnitude, 3) + for x in phased_capex_schedule + ], + ) + _append_row(f'Overnight capital expenditure ($)', [round(-it) for it in base_capex_vec]) + _append_row(f'plus:', []) + _append_row(f'Inflation cost ($)', [round(-it) for it in inflation_cost_vec]) + _append_row(f'equals:', []) + _append_row(f'Nominal capital expenditure ($)', [-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) + + pre_revenue_cf_profile.append(blank_row.copy()) + + # --- Total installed cost & Returns --- + equity_cash_flow_usd = [-x for x in equity_spend_vec] + + _append_row(_INSTALLED_COST_CASH_FLOW_ROW_NAME, [round(it) for it in total_installed_cost_vec]) + _append_row(_AFTER_TAX_NET_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..adb5bdb4 100644 --- a/src/geophires_x/EconomicsUtils.py +++ b/src/geophires_x/EconomicsUtils.py @@ -3,9 +3,21 @@ from geophires_x.Parameter import OutputParameter from geophires_x.Units import Units, PercentUnit, TimeUnit, CurrencyUnit, CurrencyFrequencyUnit +CONSTRUCTION_CAPEX_SCHEDULE_PARAMETER_NAME = 'Construction CAPEX Schedule' -def BuildPricingModel(plantlifetime: int, StartPrice: float, EndPrice: float, - EscalationStartYear: int, EscalationRate: float, PTCAddition: list) -> list: +_YEAR_INDEX_VALUE_EXPLANATION_SNIPPET = ( + f'The value is specified as a project year index corresponding to the ' f'Year row in the cash flow profile' +) + + +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. @@ -37,15 +49,18 @@ def BuildPricingModel(plantlifetime: int, StartPrice: float, EndPrice: float, return Price +_SAM_EM_MOIC_RETURNS_TAX_QUALIFIER = 'after-tax' + + 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).', + f'sum of Total {_SAM_EM_MOIC_RETURNS_TAX_QUALIFIER} 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 +70,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 +81,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, after-tax net cash flow is used to calculate the cumulative cash flow.', ) @@ -78,10 +93,9 @@ 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. ' + # TODO describe backfilled calculation using After-tax net cash flow + 'See https://samrepo.nrelcloud.org/help/mtf_irr.html.', ) @@ -98,10 +112,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 +126,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 +179,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/GEOPHIRESv3.py b/src/geophires_x/GEOPHIRESv3.py index 6583e73e..e76543e3 100644 --- a/src/geophires_x/GEOPHIRESv3.py +++ b/src/geophires_x/GEOPHIRESv3.py @@ -79,7 +79,7 @@ def main(enable_geophires_logging_config=True): f.write(json.dumps(json_merged)) # if the user has asked for it, copy the output file to the screen - if model.outputs.printoutput: + if model.outputs.printoutput.value: outputfile = Path(original_cwd, 'HDR.out') if len(sys.argv) > 2: outputfile = sys.argv[2] diff --git a/src/geophires_x/Outputs.py b/src/geophires_x/Outputs.py index 6dbd57c6..98bc6a68 100644 --- a/src/geophires_x/Outputs.py +++ b/src/geophires_x/Outputs.py @@ -17,7 +17,7 @@ from geophires_x.EconomicsSam import get_sam_cash_flow_profile_tabulated_output from geophires_x.OutputsRich import print_outputs_rich from geophires_x.Parameter import ConvertUnitsBack, ConvertOutputUnits, LookupUnits, strParameter, boolParameter, \ - OutputParameter, ReadParameter + OutputParameter, ReadParameter, ParameterEntry from geophires_x.OptionList import EndUseOptions, EconomicModel, ReservoirModel, FractureShape, ReservoirVolume, \ PlantType from geophires_x.Parameter import Parameter @@ -32,8 +32,11 @@ class Outputs: VERTICAL_WELL_DEPTH_OUTPUT_NAME = 'Well depth' - def __init__(self, model:Model, output_file:str ='HDR.out'): + def __init__(self, model: Model, output_file: str = 'HDR.out'): model.logger.info(f'Init {__class__!s}: {__name__}') + + self.output_file = output_file + self.ParameterDict = {} self.OutputParameterDict = {} self.filepath_parameter_names = [] @@ -60,6 +63,7 @@ def filepath_parameter(p: Parameter) -> Parameter: ToolTipText='Provide a HTML output name if you want to have HTML output (no output if not provided)', )) + # noinspection SpellCheckingInspection self.printoutput = self.ParameterDict[self.printoutput.Name] = boolParameter( 'Print Output to Console', DefaultValue=True, @@ -69,11 +73,6 @@ def filepath_parameter(p: Parameter) -> Parameter: ToolTipText='Provide a 0 if you do not want to print output to the console', ) - # Dictionary to hold the Units definitions that the user wants for outputs created by GEOPHIRES. - # It is empty by default initially - this will expand as the user desires are read from the input file - self.printoutput = True - self.output_file = output_file - model.logger.info(f'Complete {__class__!s}: {__name__}') def __str__(self): @@ -105,7 +104,7 @@ def read_parameters(self, model: Model, default_output_path: Path = None) -> Non ParameterToModify = item[1] key = ParameterToModify.Name.strip() if key in model.InputParameters: - ParameterReadIn = model.InputParameters[key] + ParameterReadIn: ParameterEntry = model.InputParameters[key] if key in self.filepath_parameter_names: if not Path(ParameterReadIn.sValue).is_absolute() and default_output_path is not None: @@ -120,13 +119,6 @@ def read_parameters(self, model: Model, default_output_path: Path = None) -> Non # handle the special cases if len(model.InputParameters) > 0: - # if the user wants it, we need to know if the user wants to copy the contents of the - # output file to the screen - this serves as the screen report - if 'Print Output to Console' in model.InputParameters: - ParameterReadIn = model.InputParameters['Print Output to Console'] - if ParameterReadIn.sValue == '0': - self.printoutput = False - # loop through all the parameters that the user wishes to set, looking for parameters that contain the # prefix "Units:" - that means we want to set a special case for converting this # output parameter to new units @@ -274,9 +266,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 +507,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..f187a8a3 100644 --- a/src/geophires_x/Parameter.py +++ b/src/geophires_x/Parameter.py @@ -428,7 +428,17 @@ 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 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/Reservoir.py b/src/geophires_x/Reservoir.py index b152b862..3eea04ce 100644 --- a/src/geophires_x/Reservoir.py +++ b/src/geophires_x/Reservoir.py @@ -14,6 +14,9 @@ from geophires_x.GeoPHIRESUtils import heat_capacity_water_J_per_kg_per_K, quantity, static_pressure_MPa from geophires_x.GeoPHIRESUtils import density_water_kg_per_m3 +_MAX_ALLOWED_FRACTURES = 1_000_000 + + class Reservoir: """ This class is the parent class for modeling the Reservoir. @@ -284,15 +287,32 @@ def __init__(self, model: Model): ToolTipText="Width of each fracture" ) + fracnumb_allowable_range = list(range(1, _MAX_ALLOWED_FRACTURES + 1, 1)) self.fracnumb = self.ParameterDict[self.fracnumb.Name] = intParameter( "Number of Fractures", DefaultValue=10, - AllowableRange=list(range(1, 100_000, 1)), + AllowableRange=fracnumb_allowable_range, UnitType=Units.NONE, ErrMessage="assume default number of fractures (10)", ToolTipText="Number of identical parallel fractures in EGS fracture-based reservoir model." ) + # Variable is a workaround for the fact that model.economics has not been initialized yet. + model_economics_stimulation_cost_per_production_well_name = \ + 'Reservoir Stimulation Capital Cost per Production Well' + # noinspection SpellCheckingInspection + self.fracnumb_per_stimulated_well = self.ParameterDict[self.fracnumb_per_stimulated_well.Name] = intParameter( + 'Number of Fractures per Stimulated Well', + DefaultValue=10, + AllowableRange=fracnumb_allowable_range, + UnitType=Units.NONE, + ToolTipText=f'Number of identical parallel fractures per stimulated well ' + f'in EGS fracture-based reservoir model. ' + f'(Note that injection wells are assumed to be stimulated by default; ' + f'production wells are assumed to be stimulated if ' + f'{model_economics_stimulation_cost_per_production_well_name} is provided.)' + ) + self.fracsep = self.ParameterDict[self.fracsep.Name] = floatParameter( "Fracture Separation", DefaultValue=50.0, @@ -617,8 +637,6 @@ def read_parameters(self, model: Model) -> None: elif ParameterToModify.Name.startswith("Fracture Separation"): self.fracsepcalc.value = self.fracsep.value - elif ParameterToModify.Name.startswith("Number of Fractures"): - self.fracnumbcalc.value = self.fracnumb.value elif ParameterToModify.Name.startswith("Fracture Width"): self.fracwidthcalc.value = self.fracwidth.value elif ParameterToModify.Name.startswith("Fracture Height"): @@ -656,6 +674,11 @@ def read_parameters(self, model: Model) -> None: model.reserv.layerthickness.value[model.reserv.numseg.value-1] = 100_000.0 + if self.fracnumb.Provided and self.fracnumb_per_stimulated_well.Provided: + raise ValueError(f'Only one of {self.fracnumb_per_stimulated_well.Name} and {self.fracnumb.Name}' + f'may be provided. ' + f'Please provide only one of these parameters.') + model.logger.info(f'complete {str(__class__)}: {sys._getframe().f_code.co_name}') @@ -676,9 +699,22 @@ def Calculate(self, model: Model) -> None: # If you choose to subclass this master class, you can also choose to override this method (or not), # and if you do, do it before or after you call you own version of this method. If you do, you can also # choose to call this method from you class, which can effectively run the calculations of the superclass, - # making all thr values available to your methods. but you had n better set all the parameters! + # making all the values available to your methods. but you had n better set all the parameters! + + # calculate fracture count and geometry + if self.fracnumb_per_stimulated_well.Provided: + stimulated_wells_count = model.wellbores.ninj.value + if model.economics.stimulation_cost_per_production_well.Provided: + # Only injection wells are assumed to be stimulated unless stimulation cost per + # production well parameter is provided (even if provided cost is $0). + stimulated_wells_count += model.wellbores.nprod.value + self.fracnumbcalc.value = self.fracnumb_per_stimulated_well.value * stimulated_wells_count + else: + self.fracnumbcalc.value = self.fracnumb.value + if self.fracnumbcalc.value > _MAX_ALLOWED_FRACTURES: + raise ValueError(f'{self.fracnumb.Name} ({self.fracnumbcalc.value}) must not exceed ' + f'{_MAX_ALLOWED_FRACTURES}.') - # calculate fracture geometry if self.fracshape.value == FractureShape.CIRCULAR_AREA: self.fracheightcalc.value = math.sqrt(4 / math.pi * self.fracareacalc.value) self.fracwidthcalc.value = self.fracheightcalc.value diff --git a/src/geophires_x/SUTRAOutputs.py b/src/geophires_x/SUTRAOutputs.py index 93172b3a..07108242 100644 --- a/src/geophires_x/SUTRAOutputs.py +++ b/src/geophires_x/SUTRAOutputs.py @@ -9,12 +9,14 @@ import numpy as np -NL="\n" +from geophires_x.Parameter import boolParameter + +NL = "\n" class SUTRAOutputs(Outputs): - def __init__(self, model:Model, output_file:str ='HDR.out'): + def __init__(self, model: Model, output_file: str = 'HDR.out'): """ The __init__ function is called automatically when a class is instantiated. It initializes the attributes of an object, and sets default values for certain arguments that can be @@ -27,12 +29,22 @@ def __init__(self, model:Model, output_file:str ='HDR.out'): model.logger.info(f'Init {str(__class__)}: {sys._getframe().f_code.co_name}') - # Dictionary to hold the Units definitions that the user wants for outputs created by GEOPHIRES. - # It is empty by default initially - this will expand as the user desires are read from the input file - self.ParameterDict = {} - self.printoutput = True self.output_file = output_file + self.ParameterDict = {} + self.OutputParameterDict = {} + self.filepath_parameter_names = [] + + # noinspection SpellCheckingInspection + self.printoutput = self.ParameterDict[self.printoutput.Name] = boolParameter( + 'Print Output to Console', + DefaultValue=True, + Required=False, + Provided=False, + ErrMessage='assume no output to console', + ToolTipText='Provide a 0 if you do not want to print output to the console', + ) + model.logger.info(f'Complete {str(__class__)}: {sys._getframe().f_code.co_name}') def __str__(self): diff --git a/src/geophires_x/SurfacePlant.py b/src/geophires_x/SurfacePlant.py index 9ac33f6d..946f791e 100644 --- a/src/geophires_x/SurfacePlant.py +++ b/src/geophires_x/SurfacePlant.py @@ -1,9 +1,11 @@ import numpy as np +from .EconomicsUtils import CONSTRUCTION_CAPEX_SCHEDULE_PARAMETER_NAME from .GeoPHIRESUtils import quantity 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,17 +410,22 @@ 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)", - 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, ' - 'then $250M will be spent in both the first and second construction years.' + ErrMessage=f'assume default number of years in construction ({default_construction_years})', + ToolTipText=f'Number of years spent in construction (assumes whole years, no fractions). ' + f'By default, capital costs are spread evenly over construction years e.g. if total capital ' + f'costs are $500M and there are 2 construction years, ' + f'then $250M will be spent in both the first and second construction years. ' + f'For SAM Economic Models, provide {CONSTRUCTION_CAPEX_SCHEDULE_PARAMETER_NAME} to use a ' + f'custom spread instead.' ) + self.cp_fluid = self.ParameterDict[self.cp_fluid.Name] = floatParameter( "Working Fluid Heat Capacity", UnitType=Units.HEAT_CAPACITY, 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..58d4609e 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.65' +__version__ = '3.10.22' 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..be86aef3 100644 --- a/src/geophires_x_schema_generator/geophires-request.json +++ b/src/geophires_x_schema_generator/geophires-request.json @@ -320,7 +320,16 @@ "category": "Reservoir", "default": 10, "minimum": 1, - "maximum": 99999 + "maximum": 1000000 + }, + "Number of Fractures per Stimulated Well": { + "description": "Number of identical parallel fractures per stimulated well in EGS fracture-based reservoir model. (Note that injection wells are assumed to be stimulated by default; production wells are assumed to be stimulated if Reservoir Stimulation Capital Cost per Production Well is provided.)", + "type": "integer", + "units": null, + "category": "Reservoir", + "default": 10, + "minimum": 1, + "maximum": 1000000 }, "Fracture Separation": { "description": "Separation of identical parallel fractures with uniform spatial distribution in EGS fracture-based reservoir", @@ -1261,13 +1270,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. For SAM Economic Models, provide Construction CAPEX Schedule to use a custom spread instead.", "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", @@ -1656,6 +1665,15 @@ "minimum": 0.0, "maximum": 1.0 }, + "Royalty Rate Escalation Start Year": { + "description": "The first year that the Royalty Rate Escalation is applied. The value is specified as a project year index corresponding to the Year row in the cash flow profile.", + "type": "integer", + "units": "", + "category": "Economics", + "default": 1, + "minimum": 1, + "maximum": 99 + }, "Royalty Rate Maximum": { "description": "The maximum royalty rate after escalation, expressed as a fraction (e.g., 0.06 for a 6% cap). Defaults to 100% (no effective cap).", "type": "number", @@ -1684,7 +1702,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 +1711,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 +1791,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..6e801811 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": { @@ -123,7 +123,7 @@ }, "After-tax IRR": { "type": "number", - "description": "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.", + "description": "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.", "units": "%" }, "Project VIR=PI=PIR": { @@ -133,13 +133,13 @@ }, "Project MOIC": { "type": "number", - "description": "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).", + "description": "Project Multiple of Invested Capital. For SAM Economic Models, this is calculated as the sum of Total after-tax returns (total value received) divided by Issuance of equity (total capital invested).", "units": "" }, "Fixed Charge Rate (FCR)": {}, "Project Payback Period": { "type": "number", - "description": "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.", + "description": "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, after-tax net cash flow is used to calculate the cumulative cash flow.", "units": "yr" }, "CHP: Percent cost allocation for electrical plant": {}, @@ -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..390c5a44 100644 --- a/tests/base_test_case.py +++ b/tests/base_test_case.py @@ -5,6 +5,9 @@ import os.path import unittest +from geophires_x.GeoPHIRESUtils import sig_figs +from geophires_x_client import GeophiresInputParameters + # noinspection PyProtectedMember from geophires_x_client import _get_logger @@ -30,7 +33,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 +109,36 @@ 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) + + def assertAlmostEqualWithinSigFigs(self, expected: float | int, actual: float | int, num_sig_figs: int = 3): + self.assertEqual( + sig_figs(expected, num_sig_figs), + sig_figs(actual, num_sig_figs), + ) + + # noinspection PyMethodMayBeStatic + def _is_github_actions(self): + return 'CI' in os.environ or 'TOXPYTHON' in os.environ + + @staticmethod + def get_input_parameter(params: GeophiresInputParameters, param_name: str) -> float | str | None: + """ + TODO refactor into generic utility method + TODO should return quantity + """ + + for line in reversed(params.as_text().split('\n')): + parts = line.strip().split(',') + if parts[0].strip() == param_name: + ret = parts[1].strip() + try: + return float(ret) + except ValueError: + pass + + return str(ret) + + return None diff --git a/tests/examples/Fervo_Project_Cape-4.out b/tests/examples/Fervo_Project_Cape-4.out index f37410a8..c68ea187 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.21 + Simulation Date: 2025-12-13 + Simulation Time: 13:35 + Calculation Time: 1.757 sec ***SUMMARY OF RESULTS*** @@ -28,13 +28,12 @@ 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 After-tax IRR: 27.55 % Project VIR=PI=PIR: 1.45 - Project MOIC: 4.84 + Project MOIC: 4.20 Project Payback Period: 2.33 yr Estimated Jobs Created: 1300 @@ -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,22 @@ 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 +Capital expenditure schedule [construction] (%) 100.0 +Overnight capital expenditure [construction] ($) -2,601,042,401 +plus: +Inflation cost [construction] ($) -59,823,975 +equals: +Nominal capital expenditure [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 + +Installed cost [construction] ($) -2,660,866,376 +After-tax net cash flow [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 @@ -304,8 +320,9 @@ State PTC income ($) 0 0 0 State tax benefit (liability) ($) 0 -6,640,769 -2,942,535 -3,253,693 -3,547,235 -3,824,500 -4,046,733 -4,112,816 -3,849,410 -4,048,033 -5,115,243 -5,505,887 -5,862,602 -6,208,881 -6,527,412 -6,747,798 -6,725,492 -6,306,678 -7,682,340 -8,255,986 -8,709,439 -13,108,711 -17,497,573 -17,875,797 -18,090,812 -17,952,920 -18,332,255 -19,674,654 -20,284,407 -20,867,789 -114,581,301 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 net cash flow ($) -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..637cd016 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.21 + Simulation Date: 2025-12-13 + Simulation Time: 13:35 + Calculation Time: 1.007 sec ***SUMMARY OF RESULTS*** @@ -28,13 +28,12 @@ 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 After-tax IRR: 59.73 % Project VIR=PI=PIR: 4.58 - Project MOIC: 12.36 + Project MOIC: 9.59 Project Payback Period: 1.13 yr Estimated Jobs Created: 976 @@ -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,22 @@ 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 +Capital expenditure schedule [construction] (%) 100.0 +Overnight capital expenditure [construction] ($) -1,532,782,686 +plus: +Inflation cost [construction] ($) -76,639,134 +equals: +Nominal capital expenditure [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 + +Installed cost [construction] ($) -1,609,421,820 +After-tax net cash flow [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 @@ -284,8 +300,9 @@ State PTC income ($) 0 0 0 State tax benefit (liability) ($) 0 -26,094,739 -23,939,093 -24,979,110 -26,005,543 -27,028,987 -28,053,182 -29,079,966 -30,110,450 -31,145,410 -32,185,451 -33,231,090 -34,282,793 -35,341,001 -36,406,141 -37,478,642 -38,558,934 -39,647,458 -40,744,665 -41,851,024 -99,296,557 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 net cash flow ($) -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..95cce362 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.21 + Simulation Date: 2025-12-13 + Simulation Time: 13:35 + Calculation Time: 1.192 sec ***SUMMARY OF RESULTS*** @@ -28,13 +28,12 @@ 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 After-tax IRR: 30.00 % Project VIR=PI=PIR: 2.27 - Project MOIC: 5.70 + Project MOIC: 4.61 Project Payback Period: 2.94 yr Estimated Jobs Created: 125 @@ -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,22 @@ 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 +Capital expenditure schedule [construction] (%) 100.0 +Overnight capital expenditure [construction] ($) -262,355,642 +plus: +Inflation cost [construction] ($) -13,117,782 +equals: +Nominal capital expenditure [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 + +Installed cost [construction] ($) -275,473,424 +After-tax net cash flow [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 +302,9 @@ 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 net cash flow ($) -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.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-3.txt b/tests/examples/example_SAM-single-owner-PPA-3.txt index 792e315a..110b61bc 100644 --- a/tests/examples/example_SAM-single-owner-PPA-3.txt +++ b/tests/examples/example_SAM-single-owner-PPA-3.txt @@ -36,6 +36,7 @@ Property Tax Rate, 0 End-Use Option, 1, -- Electricity Power Plant Type, 2, -- Supercritical ORC Plant Lifetime, 20 +Construction Years, 1 Reservoir Model, 1 diff --git a/tests/examples/example_SAM-single-owner-PPA-4.out b/tests/examples/example_SAM-single-owner-PPA-4.out index 29d3496f..b0147042 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.21 + Simulation Date: 2025-12-13 + Simulation Time: 13:35 + Calculation Time: 1.191 sec ***SUMMARY OF RESULTS*** @@ -28,13 +28,12 @@ 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 After-tax IRR: 22.13 % Project VIR=PI=PIR: 1.76 - Project MOIC: 4.02 + Project MOIC: 3.38 Project Payback Period: 4.29 yr Estimated Jobs Created: 125 @@ -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,22 @@ 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 +Capital expenditure schedule [construction] (%) 100.0 +Overnight capital expenditure [construction] ($) -215,055,748 +plus: +Inflation cost [construction] ($) -10,752,787 +equals: +Nominal capital expenditure [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 + +Installed cost [construction] ($) -225,808,536 +After-tax net cash flow [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 @@ -214,6 +230,7 @@ Property tax net assessed value ($) 0 225,808,536 225, OPERATING EXPENSES O&M fixed expense ($) 0 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 6,626,143 +Royalty rate (%) 5.0 6.0 7.00 8.0 9.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.0 O&M production-based expense ($) 0 1,714,220 2,069,952 2,516,875 2,991,037 3,492,966 4,022,825 4,164,255 4,305,473 4,446,544 4,587,507 4,728,390 4,869,211 5,009,984 5,150,718 5,291,422 5,432,101 5,572,760 5,713,403 5,854,032 5,994,619 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 Fuel expense ($) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 @@ -287,8 +304,9 @@ 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 net cash flow ($) -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.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-4.txt b/tests/examples/example_SAM-single-owner-PPA-4.txt index 142d1ad5..f261f371 100644 --- a/tests/examples/example_SAM-single-owner-PPA-4.txt +++ b/tests/examples/example_SAM-single-owner-PPA-4.txt @@ -33,6 +33,7 @@ Capital Cost for Power Plant for Electricity Generation, 1900 End-Use Option, 1, -- Electricity Power Plant Type, 2, -- Supercritical ORC Plant Lifetime, 20 +Construction Years, 1 Reservoir Model, 1 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..ea27c339 --- /dev/null +++ b/tests/examples/example_SAM-single-owner-PPA-5.out @@ -0,0 +1,451 @@ + ***************** + ***CASE REPORT*** + ***************** + +Simulation Metadata +---------------------- + GEOPHIRES Version: 3.10.21 + Simulation Date: 2025-12-13 + Simulation Time: 13:35 + Calculation Time: 1.766 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.39 + Project MOIC: 5.72 + Project Payback Period: 8.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 +Capital expenditure schedule [construction] (%) 1.0 2.0 7.0 10.0 20.0 20.0 40.0 +Overnight capital expenditure [construction] ($) -5,994,215 -11,988,430 -41,959,506 -59,942,151 -119,884,303 -119,884,303 -239,768,605 +plus: +Inflation cost [construction] ($) -137,867 -557,810 -2,962,306 -5,707,868 -14,435,638 -17,524,996 -41,370,820 +equals: +Nominal capital expenditure [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 + +Installed cost [construction] ($) -6,132,082 -12,546,240 -44,921,812 -65,650,020 -137,307,016 -146,717,027 -297,350,818 +After-tax net cash flow [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 net cash flow ($) -6,132,082 -12,546,240 -44,921,812 -22,977,507 -47,011,979 -48,093,255 -98,398,799 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..70942393 --- /dev/null +++ b/tests/examples/example_SAM-single-owner-PPA-5.txt @@ -0,0 +1,96 @@ +# Example: SAM Single Owner PPA Economic Model: Multiple Construction Years +# Models a 120 MW nameplate capacity EGS project with a 7-year construction period. The first 3 years of construction +# are financed with equity and the remaining 4 years are financed with both bonds (debt) and equity. +# See "SAM Economic Models" in GEOPHIRES documentation: https://nrel.github.io/GEOPHIRES-X/SAM-Economic-Models.html + +# *** 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..5d5c4520 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 - Simulation Time: 11:36 - Calculation Time: 1.180 sec + GEOPHIRES Version: 3.10.21 + Simulation Date: 2025-12-13 + Simulation Time: 13:35 + Calculation Time: 1.184 sec ***SUMMARY OF RESULTS*** @@ -28,13 +28,12 @@ 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 After-tax IRR: 24.35 % Project VIR=PI=PIR: 1.93 - Project MOIC: 4.67 + Project MOIC: 3.85 Project Payback Period: 3.91 yr Estimated Jobs Created: 125 @@ -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,22 @@ 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 +Capital expenditure schedule [construction] (%) 100.0 +Overnight capital expenditure [construction] ($) -215,055,748 +plus: +Inflation cost [construction] ($) -10,752,787 +equals: +Nominal capital expenditure [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 + +Installed cost [construction] ($) -225,808,536 +After-tax net cash flow [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 +302,9 @@ 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 net cash flow ($) -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.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/generic-egs-case-4_no-fractures-specified.txt b/tests/geophires_x_tests/generic-egs-case-4_no-fractures-specified.txt new file mode 100644 index 00000000..c190d75f --- /dev/null +++ b/tests/geophires_x_tests/generic-egs-case-4_no-fractures-specified.txt @@ -0,0 +1,61 @@ +Reservoir Model, 1 +Reservoir Volume Option, 1 +Reservoir Density, 2800 +Reservoir Heat Capacity, 790 +Reservoir Thermal Conductivity, 3.05 +Reservoir Porosity, 0.0118 +Reservoir Impedance, 0.001 + +Fracture Shape, 4 +Fracture Height, 2000 +Fracture Width, 10000 +Fracture Separation, 30 + +Number of Segments, 1 + +Production Well Diameter, 7 +Injection Well Diameter, 7 +Well Separation, 365 feet +Injection Temperature, 60 degC +Injection Wellbore Temperature Gain, 3 +Plant Outlet Pressure, 1000 psi +Ramey Production Wellbore Model, 1 +Utilization Factor, .9 +Water Loss Fraction, 0.05 +Maximum Drawdown, 1 +Ambient Temperature, 10 degC +#Surface Temperature, 10 degC +End-Use Option, 1 + +Plant Lifetime, 25 + +Circulation Pump Efficiency, 0.80 + +Economic Model, 3 +Starting Electricity Sale Price, 0.15 +Ending Electricity Sale Price, 1.00 +Electricity Escalation Rate Per Year, 0.004053223 +Electricity Escalation Start Year, 1 +Fraction of Investment in Bonds, .5 +Combined Income Tax Rate, .3 +Gross Revenue Tax Rate, 0 +Inflated Bond Interest Rate, .05 +Inflated Equity Interest Rate, .08 +Inflation Rate, .02 +Investment Tax Credit Rate, .3, -- https://programs.dsireusa.org/system/program/detail/658 +Production Tax Credit Electricity, 0.0275, -- https://programs.dsireusa.org/system/program/detail/734 +Inflation Rate During Construction, 0.05 +Property Tax Rate, 0 +Time steps per year, 10 +Maximum Temperature, 500 + + +Print Output to Console, 0 +Surface Temperature, 12 +Reservoir Depth, 5.4 +Gradient 1, 36.7 +Power Plant Type, 4 + +Number of Injection Wells, 54 +Number of Production Wells, 54 +Production Flow Rate per Well, 80 diff --git a/tests/geophires_x_tests/test_economics_sam.py b/tests/geophires_x_tests/test_economics_sam.py index c0d62f1d..a3d892e1 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_client import GeophiresInputParameters +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, ImmutableGeophiresInputParameters 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,191 @@ 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))]) - self.assertIn('Invalid Construction Years (2)', str(e.exception)) - self.assertIn('SAM_SINGLE_OWNER_PPA only supports Construction Years = 1.', str(e.exception)) + idc_sum = _sum('Debt interest payment [construction] ($)') - # def test_multiple_construction_years_supported(self): - # self.assertIsNotNone(self._get_result({'Construction Years': 2})) + 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('Nominal capital expenditure [construction] ($)', abs_val=True) + idc_sum + ) + + sam_total_installed_cost_usd = abs(_floats(self._get_cash_flow_row(cy4_cf, 'Total installed cost ($)'))[0]) + self.assertEqual( + sam_total_installed_cost_usd, + installed_cost_from_construction_cash_flow, + ) + + installed_cost_construction_line_item_sum = _sum('Installed cost [construction] ($)', abs_val=True) + self.assertAlmostEqualWithinSigFigs(sam_total_installed_cost_usd, installed_cost_construction_line_item_sum, 8) + + self.assertLess( + construction_years_4.result['CAPITAL COSTS (M$)']['Overnight Capital Cost']['value'], + installed_cost_from_construction_cash_flow, + ) + + self.assertLess( + construction_years_4.result['CAPITAL COSTS (M$)']['Overnight Capital Cost']['value'], + construction_years_4.result['CAPITAL COSTS (M$)']['Total CAPEX']['value'], + ) + + self.assertAlmostEqualWithinSigFigs( + _sum('Issuance of equity [construction] ($)'), + _floats(self._get_cash_flow_row(cy4_cf, 'Issuance of equity ($)'))[0], + 8, + ) + + def test_validate_construction_capex_schedule(self): + model = self._new_model(self._egs_test_file_path(), {'Print Output to Console': 1}) + model_logger = model.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 + + half_half = [0.5, 0.5] + self.assertListEqual(half_half, _validate_construction_capex_schedule(_sched(half_half), 2, model)) + + 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)) + 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)) + 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 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,12 +475,56 @@ 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) sam_econ = calculate_sam_economics(m) - cash_flow = sam_econ.sam_cash_flow_profile + cash_flow = sam_econ._sam_cash_flow_profile_operational_years def get_row(name: str): return EconomicsSamTestCase._get_cash_flow_row(cash_flow, name) @@ -490,7 +719,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 +738,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 +756,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 +793,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 +807,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 +816,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 +862,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 +884,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,32 +933,91 @@ 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, ) + def test_royalty_rate_escalation_start_year(self) -> None: + construction_years: int = 5 + plant_lifetime: int = 20 + + def _get_result(start_year: int) -> tuple[str, dict[str, Any], GeophiresXResult]: + _input_file_path = self._get_test_file_path('generic-egs-case.txt') + _additional_params = { + 'Economic Model': 5, + 'Construction Years': construction_years, + 'Plant Lifetime': plant_lifetime, + 'Royalty Rate': 0.04, + 'Royalty Rate Maximum': 0.06, + 'Royalty Rate Escalation': 0.02, + 'Royalty Rate Escalation Start Year': start_year, + 'Print Output to Console': 1, + } + input_params: GeophiresInputParameters = GeophiresInputParameters( + from_file_path=_input_file_path, + params=_additional_params, + ) + return _input_file_path, _additional_params, GeophiresXClient().get_geophires_result(input_params) + + def __cash_flow_row(r: GeophiresXResult, row_name: str) -> str: + from geophires_x.EconomicsSam import _cash_flow_profile_row + + return [it for it in _cash_flow_profile_row(r.result['SAM CASH FLOW PROFILE'], row_name) if is_float(it)] + + def _royalty_cash_flow(r: GeophiresXResult) -> list[float]: + return __cash_flow_row(r, 'O&M production-based expense ($)')[1:] # Drop year 0 ($0 revenue) + + def _royalty_rates_from_cash_flow(r: GeophiresXResult) -> list[float]: + return __cash_flow_row(r, 'Royalty rate (%)') + + input_file_path, additional_params, result_4 = _get_result(4) + + expected_royalty_rate_schedule_4 = [*([0.04] * 3), *([0.06] * (plant_lifetime - 3))] + model = EconomicsSamTestCase._new_model(input_file_path, additional_params=additional_params) + econ_royalty_rate_schedule_4 = model.economics.get_royalty_rate_schedule(model) + self.assertListEqual(expected_royalty_rate_schedule_4, econ_royalty_rate_schedule_4) + + result_4_royalty_cash_flow_usd = _royalty_cash_flow(result_4) + self.assertEqual(len(expected_royalty_rate_schedule_4), len(result_4_royalty_cash_flow_usd)) + + econ_royalty_rate_schedule_4_percent = [ + quantity(it, 'dimensionless').to(convertible_unit('percent')).magnitude + for it in econ_royalty_rate_schedule_4 + ] + royalty_rates_from_cash_flow_4 = _royalty_rates_from_cash_flow(result_4) + self.assertListEqual(econ_royalty_rate_schedule_4_percent, royalty_rates_from_cash_flow_4) + + expected_royalties_based_on_cash_flow_ppa_revenue = [] + cash_flow_ppa_revenue = __cash_flow_row(result_4, 'PPA revenue ($)')[1:] + for i, _rev_usd in enumerate(econ_royalty_rate_schedule_4_percent): + expected_royalties_based_on_cash_flow_ppa_revenue.append( + cash_flow_ppa_revenue[i] * econ_royalty_rate_schedule_4[i] + ) + + self.assertListAlmostEqual( + expected_royalties_based_on_cash_flow_ppa_revenue, result_4_royalty_cash_flow_usd, percent=0.0001 + ) + + def test_sam_cash_flow_total_after_tax_returns_all_years(self): + input_file = self._egs_test_file_path() + additional_params = {'Construction Years': 2} + m: Model = EconomicsSamTestCase._new_model(input_file, additional_params=additional_params) + + input_params = ImmutableGeophiresInputParameters(additional_params, from_file_path=Path(input_file)) + + sam_econ: SamEconomicsCalculations = calculate_sam_economics(m) + after_tax_returns_cash_flow = sam_econ.sam_after_tax_net_cash_flow_all_years + construction_years = EconomicsSamTestCase.get_input_parameter(input_params, 'Construction Years') + plant_lifetime = EconomicsSamTestCase.get_input_parameter(input_params, 'Plant Lifetime') + + self.assertEqual(construction_years + plant_lifetime, len(after_tax_returns_cash_flow)) + + self.assertListEqual( + EconomicsSamTestCase._get_cash_flow_row(sam_econ.sam_cash_flow_profile, 'After-tax net cash flow ($)'), + sam_econ.sam_after_tax_net_cash_flow_all_years, + ) + @staticmethod def _new_model(input_file: Path, additional_params: dict[str, Any] | None = None, read_and_calculate=True) -> Model: if additional_params is not None: @@ -724,3 +1039,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..ae38fd8e --- /dev/null +++ b/tests/geophires_x_tests/test_economics_sam_pre_revenue.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from base_test_case import BaseTestCase + +# noinspection PyProtectedMember +from geophires_x.EconomicsSamCashFlow import _get_logger +from geophires_x.EconomicsSamPreRevenue import PreRevenueCostsAndCashflow + +# noinspection PyProtectedMember +from geophires_x.EconomicsSamPreRevenue import _calculate_pre_revenue_costs_and_cashflow +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 + + def test_calculate_pre_revenue_costs_and_cashflow(self) -> None: + _log = _get_logger() + pre_rev: PreRevenueCostsAndCashflow = _calculate_pre_revenue_costs_and_cashflow( + 100_000_000, 3, [0.25, 0.25, 0.5], 0.1, 0.05, 0.5, 1, _log + ) + + def _get_row(row_name: str) -> list[float]: + cf_line_item = next(row for row in pre_rev.pre_revenue_cash_flow_profile if row[0] == row_name)[1:] + + # Ensure dict property consistency + self.assertListEqual( + cf_line_item, pre_rev.pre_revenue_cash_flow_profile_dict[row_name.replace('[construction] ', '')] + ) + + return cf_line_item + + self.assertListEqual([-25e6, -25e6, -50e6], _get_row('Overnight capital expenditure [construction] ($)')) diff --git a/tests/geophires_x_tests/test_reservoir.py b/tests/geophires_x_tests/test_reservoir.py index 199bdf2e..d2fc7343 100644 --- a/tests/geophires_x_tests/test_reservoir.py +++ b/tests/geophires_x_tests/test_reservoir.py @@ -10,6 +10,9 @@ from geophires_x.GeoPHIRESUtils import static_pressure_MPa from geophires_x.Model import Model + +# noinspection PyProtectedMember +from geophires_x.Reservoir import _MAX_ALLOWED_FRACTURES from geophires_x.Reservoir import Reservoir from geophires_x_client import GeophiresInputParameters from geophires_x_client import GeophiresXClient @@ -228,3 +231,55 @@ def _get_result() -> GeophiresXResult: for k, v in expected.items(): self.assertEqual(summary[k], v) + + def test_number_of_fractures_per_stimulated_well(self): + def _get_result( + fracs_per_stimulated_well: int | None, + inj_wells: int, + prod_wells: int | None = None, + prod_wells_stimulated: bool = True, + fracs_total: int | None = None, + ) -> GeophiresXResult: + if prod_wells is None: + prod_wells = inj_wells + + params = { + 'Number of Production Wells': prod_wells, + 'Number of Injection Wells': inj_wells, + } + + if fracs_per_stimulated_well is not None: + params['Number of Fractures per Stimulated Well'] = fracs_per_stimulated_well + + if fracs_total is not None: + params['Number of Fractures'] = fracs_total + + if prod_wells_stimulated: + # stim cost per production well indicates prod wells are stimulated (cost doesn't matter for this test) + params['Reservoir Stimulation Capital Cost per Production Well'] = 1 + + return GeophiresXClient().get_geophires_result( + GeophiresInputParameters( + from_file_path=self._get_test_file_path('generic-egs-case-4_no-fractures-specified.txt'), + params=params, + ) + ) + + r_102_per = _get_result(102, 59) + self.assertEqual(12_036, r_102_per.result['RESERVOIR PARAMETERS']['Number of fractures']['value']) + + r_102_per_total_equivalent = _get_result(None, 59, fracs_total=12_036) + self.assertEqual( + 12_036, r_102_per_total_equivalent.result['RESERVOIR PARAMETERS']['Number of fractures']['value'] + ) + + r_102_per_inj = _get_result(102, 59, prod_wells_stimulated=False) + self.assertEqual(12_036 / 2, r_102_per_inj.result['RESERVOIR PARAMETERS']['Number of fractures']['value']) + + with self.assertRaises(RuntimeError) as e: + _get_result(102, 59, fracs_total=12_036) + self.assertIn('provide only one', str(e.exception)) + + with self.assertRaises(RuntimeError) as e: + _get_result(_MAX_ALLOWED_FRACTURES, 59) + self.assertIn(f'({_MAX_ALLOWED_FRACTURES*59*2}) must not exceed {_MAX_ALLOWED_FRACTURES}', str(e.exception)) 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..e80c9929 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,73 @@ 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)) + + def test_royalty_rate_with_multiple_construction_years(self): + royalty_rate_example_stem = 'examples/example_SAM-single-owner-PPA-4' # Royalty rate example + + royalty_rate_example_input_file_path = self._get_test_file_path(f'{royalty_rate_example_stem}.txt') + + # Test assumes example has 1 construction year; if this changes for some reason, a version of the example with + # a single construction year should be used instead. + assert ( + BaseTestCase.get_input_parameter( + ImmutableGeophiresInputParameters( + from_file_path=royalty_rate_example_input_file_path, + ), + 'Construction Years', + ) + == 1 + ) + + base_result = GeophiresXResult(self._get_test_file_path(f'{royalty_rate_example_stem}.out')) + + mcy_result = GeophiresXClient().get_geophires_result( + ImmutableGeophiresInputParameters( + from_file_path=royalty_rate_example_input_file_path, + params={ + 'Construction Years': 3, + }, + ) + ) + + def _royalty_holder_npv(r: GeophiresXResult) -> float: + econ_result = r.result['EXTENDED ECONOMICS'] + return econ_result['Royalty Holder NPV']['value'] + + self.assertGreater(_royalty_holder_npv(base_result), _royalty_holder_npv(mcy_result)) + + def test_sam_em_add_ons_with_multiple_construction_years(self): + example_stem = 'examples/example_SAM-single-owner-PPA-3' # SAM-EM Add-Ons example + + example_input_file_path = self._get_test_file_path(f'{example_stem}.txt') + + # Test assumes example has 1 construction year; if this changes for some reason, a version of the example with + # a single construction year should be used instead. + assert ( + BaseTestCase.get_input_parameter( + ImmutableGeophiresInputParameters( + from_file_path=example_input_file_path, + ), + 'Construction Years', + ) + == 1 + ) + + base_result = GeophiresXResult(self._get_test_file_path(f'{example_stem}.out')) + + mcy_years = 3 + mcy_result = GeophiresXClient().get_geophires_result( + ImmutableGeophiresInputParameters( + from_file_path=example_input_file_path, + params={'Construction Years': mcy_years}, + ) + ) + + def _add_on_cash_flow(r: GeophiresXResult) -> list[float]: + return _cash_flow_profile_row(r.result['SAM CASH FLOW PROFILE'], 'Capacity payment revenue ($)') + + base_addon_cash_flow = _add_on_cash_flow(base_result) + mcy_addon_cash_flow = _add_on_cash_flow(mcy_result) + + self.assertListEqual(base_addon_cash_flow, mcy_addon_cash_flow[mcy_years - 1 :])