From d7eb31e6a284b682bcec3f7e89276701b6074ac1 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 09:42:58 -0700 Subject: [PATCH 01/39] Add ImmutableGeophiresInputParameters to enable caching behavior in GeophiresXClient (GeophiresInputParameters faulty hashing implementation prevents caching from working) --- .../geophires_input_parameters.py | 85 ++- .../caching-test-result.out | 489 ++++++++++++++++++ .../test_geophires_client_caching.py | 109 ++++ .../test_geophires_input_parameters.py | 3 +- tests/test_geophires_x.py | 10 +- 5 files changed, 688 insertions(+), 8 deletions(-) create mode 100644 tests/geophires_x_client_tests/caching-test-result.out create mode 100644 tests/geophires_x_client_tests/test_geophires_client_caching.py diff --git a/src/geophires_x_client/geophires_input_parameters.py b/src/geophires_x_client/geophires_input_parameters.py index 057c01019..9946b17e9 100644 --- a/src/geophires_x_client/geophires_input_parameters.py +++ b/src/geophires_x_client/geophires_input_parameters.py @@ -1,9 +1,14 @@ import tempfile import uuid +from dataclasses import dataclass +from dataclasses import field from enum import Enum from pathlib import Path from types import MappingProxyType +from typing import Any +from typing import Mapping from typing import Optional +from typing import Union class EndUseOption(Enum): @@ -48,6 +53,7 @@ def __init__(self, params: Optional[MappingProxyType] = None, from_file_path: Op self._file_path = Path(tempfile.gettempdir(), f'geophires-input-params_{uuid.uuid4()!s}.txt') if from_file_path is not None: + # Note: This has a potential race condition if the file doesn't exist at the time of 'a', with open(from_file_path, encoding='UTF-8') as base_file: with open(self._file_path, 'a', encoding='UTF-8') as f: f.writelines(base_file.readlines()) @@ -74,5 +80,82 @@ def as_text(self): return f.read() def __hash__(self): - """TODO make hashes for equivalent parameters equal""" + """ + Note hashes for equivalent parameters may not be equal. + Use ImmutableGeophiresInputParameters instead. + """ + return self._id + + +@dataclass(frozen=True) +class ImmutableGeophiresInputParameters(GeophiresInputParameters): + """ + An immutable, self-contained, and content-hashable set of GEOPHIRES + input parameters. + + This class is hashable based on its logical content, making it safe for + caching. It generates its file representation on-demand and is designed + for use cases where parameter sets must be treated as immutable values. + """ + + params: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({})) + from_file_path: Union[Path, None] = None + + # A unique ID for this instance, used for file I/O but not for hashing or equality. + _instance_id: uuid.UUID = field(default_factory=uuid.uuid4, init=False, repr=False, compare=False) + + def __post_init__(self): + """Ensures that the parameters dictionary is immutable.""" + if not isinstance(self.params, MappingProxyType): + # object.__setattr__ is required to modify a field in a frozen dataclass + object.__setattr__(self, 'params', MappingProxyType(self.params)) + + def __hash__(self) -> int: + """ + Computes a hash based on the content of the parameters. + If a base file is used, its content is read and hashed to ensure + the hash reflects a true snapshot of all inputs. + """ + + param_hash = hash(frozenset(self.params.items())) + + if self.from_file_path is not None and self.from_file_path.exists(): + file_content_hash = hash(self.from_file_path.read_bytes()) + else: + file_content_hash = hash(self.from_file_path) + + return hash((param_hash, file_content_hash)) + + def as_file_path(self) -> Path: + """ + Creates a temporary file representation of the parameters on demand. + The resulting file path is cached for efficiency. + """ + + # Return the cached path if the file has already been generated for this instance. + if hasattr(self, '_cached_file_path'): + return self._cached_file_path + + file_path = Path(tempfile.gettempdir(), f'geophires-input-params_{self._instance_id!s}.txt') + + with open(file_path, 'w', encoding='UTF-8') as f: + if self.from_file_path is not None: + with open(self.from_file_path, encoding='UTF-8') as base_file: + f.write(base_file.read()) + + if self.params: + # Ensure there is a newline between the base file content and appended params. + if self.from_file_path is not None and f.tell() > 0: + f.seek(f.tell() - 1) + if f.read(1) != '\n': + f.write('\n') + f.writelines([f'{key}, {value}\n' for key, value in self.params.items()]) + + # Cache the path on the instance after creation. + object.__setattr__(self, '_cached_file_path', file_path) + return file_path + + def get_output_file_path(self) -> Path: + """Returns a unique path for the GEOPHIRES output file.""" + return Path(tempfile.gettempdir(), f'geophires-result_{self._instance_id!s}.out') diff --git a/tests/geophires_x_client_tests/caching-test-result.out b/tests/geophires_x_client_tests/caching-test-result.out new file mode 100644 index 000000000..4d2d6d749 --- /dev/null +++ b/tests/geophires_x_client_tests/caching-test-result.out @@ -0,0 +1,489 @@ + ***************** + ***CASE REPORT*** + ***************** + +Simulation Metadata +---------------------- + GEOPHIRES Version: 3.9.16 + Simulation Date: 2025-06-19 + Simulation Time: 09:30 + Calculation Time: 0.041 sec + + ***SUMMARY OF RESULTS*** + + End-Use Option: Electricity + Average Net Electricity Production: 3.51 MW + Electricity breakeven price: 13.60 cents/kWh + Number of production wells: 2 + Number of injection wells: 2 + Flowrate per production well: 50.0 kg/sec + Well depth: 3.0 kilometer + Geothermal gradient: 50 degC/km + + + ***ECONOMIC PARAMETERS*** + + Economic Model = Standard Levelized Cost + Interest Rate: 7.00 % + Accrued financing during construction: 0.00 % + Project lifetime: 30 yr + Capacity factor: 90.0 % + Project NPV: -31.59 MUSD + Project IRR: -5.22 % + Project VIR=PI=PIR: 0.20 + Project MOIC: -0.33 + Project Payback Period: N/A + Estimated Jobs Created: 8 + + ***ENGINEERING PARAMETERS*** + + Number of Production Wells: 2 + Number of Injection Wells: 2 + Well depth: 3.0 kilometer + Water loss rate: 0.0 % + Pump efficiency: 75.0 % + Injection temperature: 70.0 degC + Production Wellbore heat transmission calculated with Ramey's model + Average production well temperature drop: 3.3 degC + Flowrate per production well: 50.0 kg/sec + Injection well casing ID: 8.000 in + Production well casing ID: 8.000 in + Number of times redrilling: 0 + Power plant type: Subcritical ORC + + + ***RESOURCE CHARACTERISTICS*** + + Maximum reservoir temperature: 400.0 degC + Number of segments: 1 + Geothermal gradient: 50 degC/km + + + ***RESERVOIR PARAMETERS*** + + Reservoir Model = Annual Percentage Thermal Drawdown Model + Annual Thermal Drawdown: 0.500 1/year + Bottom-hole temperature: 165.00 degC + Warning: the reservoir dimensions and thermo-physical properties + listed below are default values if not provided by the user. + They are only used for calculating remaining heat content. + Reservoir volume: 125000000 m**3 + Reservoir hydrostatic pressure: 28892.26 kPa + Plant outlet pressure: 976.68 kPa + Production wellhead pressure: 1045.63 kPa + Productivity Index: 10.00 kg/sec/bar + Injectivity Index: 10.00 kg/sec/bar + Reservoir density: 2700.00 kg/m**3 + Reservoir thermal conductivity: 3.00 W/m/K + Reservoir heat capacity: 1000.00 J/kg/K + + + ***RESERVOIR SIMULATION RESULTS*** + + Maximum Production Temperature: 159.9 degC + Average Production Temperature: 154.5 degC + Minimum Production Temperature: 148.0 degC + Initial Production Temperature: 159.5 degC + Average Reservoir Heat Extraction: 35.23 MW + Production Wellbore Heat Transmission Model = Ramey Model + Average Production Well Temperature Drop: 3.3 degC + Average Injection Well Pump Pressure Drop: -421.5 kPa + Average Production Well Pump Pressure Drop: 235.6 kPa + + + ***CAPITAL COSTS (M$)*** + + Drilling and completion costs: 16.42 MUSD + Drilling and completion costs per well: 4.11 MUSD + Stimulation costs: 3.02 MUSD + Surface power plant costs: 13.68 MUSD + Field gathering system costs: 2.11 MUSD + Total surface equipment costs: 15.79 MUSD + Exploration costs: 4.31 MUSD + Total capital costs: 39.54 MUSD + + + ***OPERATING AND MAINTENANCE COSTS (M$/yr)*** + + Wellfield maintenance costs: 0.32 MUSD/yr + Power plant maintenance costs: 0.62 MUSD/yr + Water costs: 0.00 MUSD/yr + Total operating and maintenance costs: 0.95 MUSD/yr + + + ***SURFACE EQUIPMENT SIMULATION RESULTS*** + + Initial geofluid availability: 0.11 MW/(kg/s) + Maximum Total Electricity Generation: 3.95 MW + Average Total Electricity Generation: 3.54 MW + Minimum Total Electricity Generation: 3.07 MW + Initial Total Electricity Generation: 3.92 MW + Maximum Net Electricity Generation: 3.94 MW + Average Net Electricity Generation: 3.51 MW + Minimum Net Electricity Generation: 3.01 MW + Initial Net Electricity Generation: 3.91 MW + Average Annual Total Electricity Generation: 27.88 GWh + Average Annual Net Electricity Generation: 27.61 GWh + Initial pumping power/net installed power: 0.23 % + Average Pumping Power: 0.03 MW + Heat to Power Conversion Efficiency: 9.94 % + + ************************************************************ + * 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 159.53 0.0089 3.9099 10.4810 + 2 1.0026 159.94 0.0099 3.9416 10.5180 + 3 1.0018 159.82 0.0115 3.9306 10.5025 + 4 1.0001 159.54 0.0131 3.9065 10.4708 + 5 0.9979 159.20 0.0148 3.8777 10.4331 + 6 0.9956 158.83 0.0166 3.8465 10.3925 + 7 0.9932 158.44 0.0183 3.8140 10.3501 + 8 0.9906 158.04 0.0201 3.7806 10.3065 + 9 0.9881 157.63 0.0218 3.7468 10.2621 + 10 0.9854 157.21 0.0236 3.7126 10.2172 + 11 0.9828 156.78 0.0253 3.6782 10.1718 + 12 0.9801 156.36 0.0271 3.6436 10.1261 + 13 0.9774 155.93 0.0289 3.6090 10.0802 + 14 0.9747 155.49 0.0306 3.5744 10.0340 + 15 0.9720 155.06 0.0324 3.5398 9.9878 + 16 0.9692 154.62 0.0341 3.5053 9.9414 + 17 0.9665 154.18 0.0359 3.4708 9.8949 + 18 0.9637 153.74 0.0376 3.4364 9.8484 + 19 0.9609 153.30 0.0394 3.4021 9.8019 + 20 0.9582 152.86 0.0411 3.3679 9.7553 + 21 0.9554 152.41 0.0428 3.3339 9.7087 + 22 0.9526 151.97 0.0446 3.3000 9.6621 + 23 0.9498 151.52 0.0463 3.2662 9.6155 + 24 0.9470 151.08 0.0480 3.2326 9.5690 + 25 0.9442 150.63 0.0497 3.1991 9.5224 + 26 0.9414 150.18 0.0514 3.1658 9.4759 + 27 0.9386 149.73 0.0531 3.1327 9.4294 + 28 0.9358 149.28 0.0548 3.0997 9.3829 + 29 0.9330 148.83 0.0565 3.0669 9.3365 + 30 0.9301 148.38 0.0582 3.0342 9.2901 + + + ******************************************************************* + * 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 30.9 294.7 31.00 3.31 + 2 31.0 295.3 29.94 6.62 + 3 30.9 294.6 28.88 9.93 + 4 30.7 293.6 27.82 13.23 + 5 30.4 292.4 26.77 16.51 + 6 30.2 291.2 25.72 19.78 + 7 29.9 289.9 24.68 23.04 + 8 29.7 288.5 23.64 26.28 + 9 29.4 287.2 22.60 29.50 + 10 29.1 285.8 21.58 32.71 + 11 28.9 284.4 20.55 35.90 + 12 28.6 283.0 19.53 39.08 + 13 28.3 281.6 18.52 42.24 + 14 28.0 280.1 17.51 45.39 + 15 27.8 278.7 16.51 48.52 + 16 27.5 277.3 15.51 51.63 + 17 27.2 275.8 14.52 54.73 + 18 27.0 274.4 13.53 57.81 + 19 26.7 272.9 12.55 60.87 + 20 26.4 271.5 11.57 63.92 + 21 26.2 270.0 10.60 66.95 + 22 25.9 268.5 9.63 69.97 + 23 25.6 267.1 8.67 72.96 + 24 25.4 265.6 7.71 75.95 + 25 25.1 264.1 6.76 78.91 + 26 24.8 262.7 5.82 81.86 + 27 24.6 261.2 4.88 84.79 + 28 24.3 259.7 3.94 87.71 + 29 24.1 258.2 3.01 90.61 + 30 23.8 256.9 2.09 93.49 + + + ******************************** + * REVENUE & CASHFLOW PROFILE * + ******************************** +Year Electricity | Heat | Cooling | Carbon | Project +Since Price Ann. Rev. Cumm. Rev. | Price Ann. Rev. Cumm. Rev. | Price Ann. Rev. Cumm. Rev. | Price Ann. Rev. Cumm. Rev. | OPEX Net Rev. Net Cashflow +Start (cents/kWh)(MUSD/yr) (MUSD) |(cents/kWh) (MUSD/yr) (MUSD) |(cents/kWh) (MUSD/yr) (MUSD) |(USD/lb) (MUSD/yr) (MUSD) |(MUSD/yr) (MUSD/yr) (MUSD) +________________________________________________________________________________________________________________________________________________________________________________________ + 0 0.00 0.00 0.00 | 0.00 0.00 0.00 | 0.00 0.00 0.00 | 0.00 0.00 0.00 | 0.00 -39.54 -39.54 + 1 5.50 1.70 1.70 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.75 -38.78 + 2 5.50 1.71 3.41 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.76 -38.02 + 3 5.50 1.70 5.11 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.75 -37.27 + 4 5.50 1.69 6.80 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.74 -36.53 + 5 5.50 1.67 8.47 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.73 -35.80 + 6 5.50 1.66 10.13 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.71 -35.08 + 7 5.50 1.65 11.78 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.70 -34.38 + 8 5.50 1.63 13.41 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.69 -33.70 + 9 5.50 1.62 15.03 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.67 -33.03 + 10 5.50 1.60 16.63 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.66 -32.37 + 11 5.50 1.59 18.22 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.64 -31.73 + 12 5.50 1.57 19.79 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.63 -31.10 + 13 5.50 1.56 21.35 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.61 -30.49 + 14 5.50 1.54 22.89 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.60 -29.90 + 15 5.50 1.53 24.42 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.58 -29.32 + 16 5.50 1.51 25.93 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.57 -28.75 + 17 5.50 1.50 27.43 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.55 -28.20 + 18 5.50 1.48 28.91 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.54 -27.66 + 19 5.50 1.47 30.38 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.52 -27.14 + 20 5.50 1.45 31.83 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.51 -26.63 + 21 5.50 1.44 33.27 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.49 -26.14 + 22 5.50 1.42 34.69 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.48 -25.66 + 23 5.50 1.41 36.10 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.46 -25.20 + 24 5.50 1.39 37.50 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.45 -24.75 + 25 5.50 1.38 38.88 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.43 -24.32 + 26 5.50 1.37 40.24 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.42 -23.90 + 27 5.50 1.35 41.59 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.40 -23.50 + 28 5.50 1.34 42.93 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.39 -23.11 + 29 5.50 1.32 44.25 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.38 -22.73 + 30 5.50 1.31 45.56 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.36 -22.37 + + ***************** + ***CASE REPORT*** + ***************** + +Simulation Metadata +---------------------- + GEOPHIRES Version: 3.9.16 + Simulation Date: 2025-06-19 + Simulation Time: 09:30 + Calculation Time: 0.041 sec + + ***SUMMARY OF RESULTS*** + + End-Use Option: Electricity + Average Net Electricity Production: 3.51 MW + Electricity breakeven price: 13.60 cents/kWh + Number of production wells: 2 + Number of injection wells: 2 + Flowrate per production well: 50.0 kg/sec + Well depth: 3.0 kilometer + Geothermal gradient: 50 degC/km + + + ***ECONOMIC PARAMETERS*** + + Economic Model = Standard Levelized Cost + Interest Rate: 7.00 % + Accrued financing during construction: 0.00 % + Project lifetime: 30 yr + Capacity factor: 90.0 % + Project NPV: -31.59 MUSD + Project IRR: -5.22 % + Project VIR=PI=PIR: 0.20 + Project MOIC: -0.33 + Project Payback Period: N/A + Estimated Jobs Created: 8 + + ***ENGINEERING PARAMETERS*** + + Number of Production Wells: 2 + Number of Injection Wells: 2 + Well depth: 3.0 kilometer + Water loss rate: 0.0 % + Pump efficiency: 75.0 % + Injection temperature: 70.0 degC + Production Wellbore heat transmission calculated with Ramey's model + Average production well temperature drop: 3.3 degC + Flowrate per production well: 50.0 kg/sec + Injection well casing ID: 8.000 in + Production well casing ID: 8.000 in + Number of times redrilling: 0 + Power plant type: Subcritical ORC + + + ***RESOURCE CHARACTERISTICS*** + + Maximum reservoir temperature: 400.0 degC + Number of segments: 1 + Geothermal gradient: 50 degC/km + + + ***RESERVOIR PARAMETERS*** + + Reservoir Model = Annual Percentage Thermal Drawdown Model + Annual Thermal Drawdown: 0.500 1/year + Bottom-hole temperature: 165.00 degC + Warning: the reservoir dimensions and thermo-physical properties + listed below are default values if not provided by the user. + They are only used for calculating remaining heat content. + Reservoir volume: 125000000 m**3 + Reservoir hydrostatic pressure: 28892.26 kPa + Plant outlet pressure: 976.68 kPa + Production wellhead pressure: 1045.63 kPa + Productivity Index: 10.00 kg/sec/bar + Injectivity Index: 10.00 kg/sec/bar + Reservoir density: 2700.00 kg/m**3 + Reservoir thermal conductivity: 3.00 W/m/K + Reservoir heat capacity: 1000.00 J/kg/K + + + ***RESERVOIR SIMULATION RESULTS*** + + Maximum Production Temperature: 159.9 degC + Average Production Temperature: 154.5 degC + Minimum Production Temperature: 148.0 degC + Initial Production Temperature: 159.5 degC + Average Reservoir Heat Extraction: 35.23 MW + Production Wellbore Heat Transmission Model = Ramey Model + Average Production Well Temperature Drop: 3.3 degC + Average Injection Well Pump Pressure Drop: -421.5 kPa + Average Production Well Pump Pressure Drop: 235.6 kPa + + + ***CAPITAL COSTS (M$)*** + + Drilling and completion costs: 16.42 MUSD + Drilling and completion costs per well: 4.11 MUSD + Stimulation costs: 3.02 MUSD + Surface power plant costs: 13.68 MUSD + Field gathering system costs: 2.11 MUSD + Total surface equipment costs: 15.79 MUSD + Exploration costs: 4.31 MUSD + Total capital costs: 39.54 MUSD + + + ***OPERATING AND MAINTENANCE COSTS (M$/yr)*** + + Wellfield maintenance costs: 0.32 MUSD/yr + Power plant maintenance costs: 0.62 MUSD/yr + Water costs: 0.00 MUSD/yr + Total operating and maintenance costs: 0.95 MUSD/yr + + + ***SURFACE EQUIPMENT SIMULATION RESULTS*** + + Initial geofluid availability: 0.11 MW/(kg/s) + Maximum Total Electricity Generation: 3.95 MW + Average Total Electricity Generation: 3.54 MW + Minimum Total Electricity Generation: 3.07 MW + Initial Total Electricity Generation: 3.92 MW + Maximum Net Electricity Generation: 3.94 MW + Average Net Electricity Generation: 3.51 MW + Minimum Net Electricity Generation: 3.01 MW + Initial Net Electricity Generation: 3.91 MW + Average Annual Total Electricity Generation: 27.88 GWh + Average Annual Net Electricity Generation: 27.61 GWh + Initial pumping power/net installed power: 0.23 % + Average Pumping Power: 0.03 MW + Heat to Power Conversion Efficiency: 9.94 % + + ************************************************************ + * 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 159.53 0.0089 3.9099 10.4810 + 2 1.0026 159.94 0.0099 3.9416 10.5180 + 3 1.0018 159.82 0.0115 3.9306 10.5025 + 4 1.0001 159.54 0.0131 3.9065 10.4708 + 5 0.9979 159.20 0.0148 3.8777 10.4331 + 6 0.9956 158.83 0.0166 3.8465 10.3925 + 7 0.9932 158.44 0.0183 3.8140 10.3501 + 8 0.9906 158.04 0.0201 3.7806 10.3065 + 9 0.9881 157.63 0.0218 3.7468 10.2621 + 10 0.9854 157.21 0.0236 3.7126 10.2172 + 11 0.9828 156.78 0.0253 3.6782 10.1718 + 12 0.9801 156.36 0.0271 3.6436 10.1261 + 13 0.9774 155.93 0.0289 3.6090 10.0802 + 14 0.9747 155.49 0.0306 3.5744 10.0340 + 15 0.9720 155.06 0.0324 3.5398 9.9878 + 16 0.9692 154.62 0.0341 3.5053 9.9414 + 17 0.9665 154.18 0.0359 3.4708 9.8949 + 18 0.9637 153.74 0.0376 3.4364 9.8484 + 19 0.9609 153.30 0.0394 3.4021 9.8019 + 20 0.9582 152.86 0.0411 3.3679 9.7553 + 21 0.9554 152.41 0.0428 3.3339 9.7087 + 22 0.9526 151.97 0.0446 3.3000 9.6621 + 23 0.9498 151.52 0.0463 3.2662 9.6155 + 24 0.9470 151.08 0.0480 3.2326 9.5690 + 25 0.9442 150.63 0.0497 3.1991 9.5224 + 26 0.9414 150.18 0.0514 3.1658 9.4759 + 27 0.9386 149.73 0.0531 3.1327 9.4294 + 28 0.9358 149.28 0.0548 3.0997 9.3829 + 29 0.9330 148.83 0.0565 3.0669 9.3365 + 30 0.9301 148.38 0.0582 3.0342 9.2901 + + + ******************************************************************* + * 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 30.9 294.7 31.00 3.31 + 2 31.0 295.3 29.94 6.62 + 3 30.9 294.6 28.88 9.93 + 4 30.7 293.6 27.82 13.23 + 5 30.4 292.4 26.77 16.51 + 6 30.2 291.2 25.72 19.78 + 7 29.9 289.9 24.68 23.04 + 8 29.7 288.5 23.64 26.28 + 9 29.4 287.2 22.60 29.50 + 10 29.1 285.8 21.58 32.71 + 11 28.9 284.4 20.55 35.90 + 12 28.6 283.0 19.53 39.08 + 13 28.3 281.6 18.52 42.24 + 14 28.0 280.1 17.51 45.39 + 15 27.8 278.7 16.51 48.52 + 16 27.5 277.3 15.51 51.63 + 17 27.2 275.8 14.52 54.73 + 18 27.0 274.4 13.53 57.81 + 19 26.7 272.9 12.55 60.87 + 20 26.4 271.5 11.57 63.92 + 21 26.2 270.0 10.60 66.95 + 22 25.9 268.5 9.63 69.97 + 23 25.6 267.1 8.67 72.96 + 24 25.4 265.6 7.71 75.95 + 25 25.1 264.1 6.76 78.91 + 26 24.8 262.7 5.82 81.86 + 27 24.6 261.2 4.88 84.79 + 28 24.3 259.7 3.94 87.71 + 29 24.1 258.2 3.01 90.61 + 30 23.8 256.9 2.09 93.49 + + + ******************************** + * REVENUE & CASHFLOW PROFILE * + ******************************** +Year Electricity | Heat | Cooling | Carbon | Project +Since Price Ann. Rev. Cumm. Rev. | Price Ann. Rev. Cumm. Rev. | Price Ann. Rev. Cumm. Rev. | Price Ann. Rev. Cumm. Rev. | OPEX Net Rev. Net Cashflow +Start (cents/kWh)(MUSD/yr) (MUSD) |(cents/kWh) (MUSD/yr) (MUSD) |(cents/kWh) (MUSD/yr) (MUSD) |(USD/lb) (MUSD/yr) (MUSD) |(MUSD/yr) (MUSD/yr) (MUSD) +________________________________________________________________________________________________________________________________________________________________________________________ + 0 0.00 0.00 0.00 | 0.00 0.00 0.00 | 0.00 0.00 0.00 | 0.00 0.00 0.00 | 0.00 -39.54 -39.54 + 1 5.50 1.70 1.70 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.75 -38.78 + 2 5.50 1.71 3.41 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.76 -38.02 + 3 5.50 1.70 5.11 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.75 -37.27 + 4 5.50 1.69 6.80 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.74 -36.53 + 5 5.50 1.67 8.47 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.73 -35.80 + 6 5.50 1.66 10.13 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.71 -35.08 + 7 5.50 1.65 11.78 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.70 -34.38 + 8 5.50 1.63 13.41 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.69 -33.70 + 9 5.50 1.62 15.03 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.67 -33.03 + 10 5.50 1.60 16.63 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.66 -32.37 + 11 5.50 1.59 18.22 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.64 -31.73 + 12 5.50 1.57 19.79 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.63 -31.10 + 13 5.50 1.56 21.35 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.61 -30.49 + 14 5.50 1.54 22.89 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.60 -29.90 + 15 5.50 1.53 24.42 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.58 -29.32 + 16 5.50 1.51 25.93 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.57 -28.75 + 17 5.50 1.50 27.43 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.55 -28.20 + 18 5.50 1.48 28.91 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.54 -27.66 + 19 5.50 1.47 30.38 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.52 -27.14 + 20 5.50 1.45 31.83 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.51 -26.63 + 21 5.50 1.44 33.27 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.49 -26.14 + 22 5.50 1.42 34.69 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.48 -25.66 + 23 5.50 1.41 36.10 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.46 -25.20 + 24 5.50 1.39 37.50 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.45 -24.75 + 25 5.50 1.38 38.88 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.43 -24.32 + 26 5.50 1.37 40.24 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.42 -23.90 + 27 5.50 1.35 41.59 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.40 -23.50 + 28 5.50 1.34 42.93 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.39 -23.11 + 29 5.50 1.32 44.25 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.38 -22.73 + 30 5.50 1.31 45.56 | 2.50 0.00 0.00 | 2.50 0.00 0.00 | 0.00 0.00 0.00 | 0.95 0.36 -22.37 diff --git a/tests/geophires_x_client_tests/test_geophires_client_caching.py b/tests/geophires_x_client_tests/test_geophires_client_caching.py new file mode 100644 index 000000000..8b4ebb475 --- /dev/null +++ b/tests/geophires_x_client_tests/test_geophires_client_caching.py @@ -0,0 +1,109 @@ +import sys +import unittest +from unittest.mock import patch + +from geophires_x_client import GeophiresXClient +from geophires_x_client.geophires_input_parameters import ImmutableGeophiresInputParameters +from tests.base_test_case import BaseTestCase + + +class GeophiresClientCachingTestCase(BaseTestCase): + """ + Tests the caching functionality of the GeophiresXClient, especially + in conjunction with the content-addressable ImmutableGeophiresInputParameters. + """ + + def _create_mock_output_file(self, *args, **kwargs): + """ + A helper function to be used as a side_effect for mocking geophires.main. + It simulates the behavior of GEOPHIRES by creating an output file based on + the arguments it receives via sys.argv. + """ + # The client sets sys.argv to ['', input_path, output_path] before calling main. + # We read from sys.argv directly to correctly simulate the real process. + output_path_arg = sys.argv[2] + with open(output_path_arg, 'w') as f: + with open(self._get_test_file_path('caching-test-result.out'), encoding='utf-8') as fr: + f.write(fr.read()) + return 0 # Simulate a successful run + + @patch('geophires_x_client.geophires.main') + def test_caching_with_identical_immutable_params(self, mock_geophires_main: unittest.mock.MagicMock): + """ + Verify that when two different ImmutableGeophiresInputParameters objects + have the same content, the GeophiresXClient's caching mechanism is + triggered and the expensive geophires.main function is only called once. + """ + # Arrange + mock_geophires_main.side_effect = self._create_mock_output_file + + client = GeophiresXClient(enable_caching=True) + + # Create two distinct parameter objects with identical content. + params1 = ImmutableGeophiresInputParameters(params={'Reservoir Depth': 3, 'Gradient 1': 50}) + params2 = ImmutableGeophiresInputParameters(params={'Reservoir Depth': 3, 'Gradient 1': 50}) + + # Pre-condition check: Although they are different objects in memory, + # their content-based hashes must be identical for caching to work. + self.assertIsNot(params1, params2, 'Test setup failed: params1 and params2 should be different objects.') + self.assertEqual(hash(params1), hash(params2), 'Hashes of identical-content objects should be equal.') + + # Act + result1 = client.get_geophires_result(params1) + result2 = client.get_geophires_result(params2) + + # Assert + # The core assertion: was the expensive simulation function only called once? + mock_geophires_main.assert_called_once() + + # The results should not only be equivalent but should be the *same object* + # retrieved from the cache on the second call. + self.assertIs(result1, result2, 'The second result should be the cached object instance.') + + @patch('geophires_x_client.geophires.main') + def test_no_caching_with_different_immutable_params(self, mock_geophires_main: unittest.mock.MagicMock): + """ + Verify that when two ImmutableGeophiresInputParameters objects have + different content, the cache is not used and geophires.main is called for each. + """ + # Arrange + mock_geophires_main.side_effect = self._create_mock_output_file + + client = GeophiresXClient(enable_caching=True) + params1 = ImmutableGeophiresInputParameters(params={'Reservoir Depth': 3}) + params2 = ImmutableGeophiresInputParameters(params={'Reservoir Depth': 4}) + + self.assertNotEqual(hash(params1), hash(params2), 'Hashes of different-content objects should not be equal.') + + # Act + client.get_geophires_result(params1) + client.get_geophires_result(params2) + + # Assert + self.assertEqual( + mock_geophires_main.call_count, 2, 'geophires.main should be called for each unique set of parameters.' + ) + + @patch('geophires_x_client.geophires.main') + def test_no_caching_when_disabled(self, mock_geophires_main: unittest.mock.MagicMock): + """ + Verify that even with identical parameters, geophires.main is called + multiple times if the client has caching disabled. + """ + # Arrange + mock_geophires_main.side_effect = self._create_mock_output_file + + client = GeophiresXClient(enable_caching=False) # Caching is explicitly disabled + params1 = ImmutableGeophiresInputParameters(params={'Reservoir Depth': 3}) + params2 = ImmutableGeophiresInputParameters(params={'Reservoir Depth': 3}) + + self.assertEqual(hash(params1), hash(params2)) + + # Act + client.get_geophires_result(params1) + client.get_geophires_result(params2) + + # Assert + self.assertEqual( + mock_geophires_main.call_count, 2, 'geophires.main should be called twice when caching is disabled.' + ) diff --git a/tests/geophires_x_client_tests/test_geophires_input_parameters.py b/tests/geophires_x_client_tests/test_geophires_input_parameters.py index f105f11a2..7929fbcc7 100644 --- a/tests/geophires_x_client_tests/test_geophires_input_parameters.py +++ b/tests/geophires_x_client_tests/test_geophires_input_parameters.py @@ -9,10 +9,11 @@ class GeophiresInputParametersTestCase(BaseTestCase): - def test_id(self): + def test_internal_id_and_hash(self): input_1 = GeophiresInputParameters(from_file_path=self._get_test_file_path('client_test_input_1.txt')) input_2 = GeophiresInputParameters(from_file_path=self._get_test_file_path('client_test_input_2.txt')) self.assertIsNot(input_1._id, input_2._id) + self.assertNotEqual(hash(input_1), hash(input_2)) def test_init_with_input_file(self): file_path = self._get_test_file_path('client_test_input_1.txt') diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index 2d47bffd6..caf0e294f 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -59,14 +59,12 @@ def test_geophires_x_end_use_direct_use_heat(self): ) del result.result['metadata'] - del result_same_input.result['metadata'] - self.assertDictEqual(result.result, result_same_input.result) + if 'metadata' in result_same_input.result: + del result_same_input.result['metadata'] - # See TODO in geophires_x_client.geophires_input_parameters.GeophiresInputParameters.__hash__ - if/when hashes - # of equivalent sets of parameters are made equal, the commented assertion below will test that caching is - # working as expected. - # assert result == result_same_input + self.assertDictEqual(result.result, result_same_input.result) + # noinspection PyMethodMayBeStatic def test_geophires_x_end_use_electricity(self): client = GeophiresXClient() result = client.get_geophires_result( From 17b2e15dbe72415ca554e4843d6a7fb817cdf03c Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 09:44:39 -0700 Subject: [PATCH 02/39] =?UTF-8?q?Bump=20version:=203.9.19=20=E2=86=92=203.?= =?UTF-8?q?9.20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- .cookiecutterrc | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- src/geophires_x/__init__.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 10a7975f3..9f5749144 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.19 +current_version = 3.9.20 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 4dc023921..450f0ad77 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.19 + version: 3.9.20 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index 3e19cc793..9e083a39d 100644 --- a/README.rst +++ b/README.rst @@ -56,9 +56,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.19.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.20.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.19...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.20...main .. |docs| image:: https://readthedocs.org/projects/GEOPHIRES-X/badge/?style=flat :target: https://nrel.github.io/GEOPHIRES-X diff --git a/docs/conf.py b/docs/conf.py index 83c14c147..087a39fac 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.19' +version = release = '3.9.20' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index 3f4d32a6f..cba1d0923 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.19', + version='3.9.20', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index 6968830a4..889ac5bfa 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.19' +__version__ = '3.9.20' From 537702d57567a91eee80d4567f331715c4a0a256 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 10:07:42 -0700 Subject: [PATCH 03/39] fix issue when from_file_path is str instead of Path --- .../geophires_input_parameters.py | 36 +++++++++++---- .../test_geophires_input_parameters.py | 44 +++++++++++++++++++ 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/geophires_x_client/geophires_input_parameters.py b/src/geophires_x_client/geophires_input_parameters.py index 9946b17e9..2a2ff9c38 100644 --- a/src/geophires_x_client/geophires_input_parameters.py +++ b/src/geophires_x_client/geophires_input_parameters.py @@ -40,6 +40,13 @@ class PowerPlantType(Enum): class GeophiresInputParameters: + """ + .. deprecated:: v3.9.21 + Use :class:`~geophires_x_client.geophires_input_parameters.ImmutableGeophiresInputParameters` instead for + better performance and guardrails against erroneous usage. + This class is kept for backwards compatibility, but does not work with GeophiresXClient caching and is more + susceptible to potential bugs due to its mutability. + """ def __init__(self, params: Optional[MappingProxyType] = None, from_file_path: Optional[Path] = None): """ @@ -100,15 +107,24 @@ class ImmutableGeophiresInputParameters(GeophiresInputParameters): """ params: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({})) - from_file_path: Union[Path, None] = None + from_file_path: Union[Path, str, None] = None # A unique ID for this instance, used for file I/O but not for hashing or equality. _instance_id: uuid.UUID = field(default_factory=uuid.uuid4, init=False, repr=False, compare=False) def __post_init__(self): - """Ensures that the parameters dictionary is immutable.""" + """ + Validates input and normalizes field types for immutability and consistency. + - Ensures from_file_path is a Path object if provided as a string. + - Ensures the params dictionary is an immutable mapping proxy. + """ + # Normalize from_file_path to a Path object. object.__setattr__ is required + # because the dataclass is frozen. + if self.from_file_path and isinstance(self.from_file_path, str): + object.__setattr__(self, 'from_file_path', Path(self.from_file_path)) + + # Ensure params is an immutable proxy if not isinstance(self.params, MappingProxyType): - # object.__setattr__ is required to modify a field in a frozen dataclass object.__setattr__(self, 'params', MappingProxyType(self.params)) def __hash__(self) -> int: @@ -117,12 +133,14 @@ def __hash__(self) -> int: If a base file is used, its content is read and hashed to ensure the hash reflects a true snapshot of all inputs. """ - param_hash = hash(frozenset(self.params.items())) - if self.from_file_path is not None and self.from_file_path.exists(): + file_content_hash = None + # self.from_file_path is now guaranteed to be a Path object or None + if self.from_file_path and self.from_file_path.exists(): file_content_hash = hash(self.from_file_path.read_bytes()) else: + # Hash the path itself if it's None or doesn't exist. file_content_hash = hash(self.from_file_path) return hash((param_hash, file_content_hash)) @@ -130,23 +148,23 @@ def __hash__(self) -> int: def as_file_path(self) -> Path: """ Creates a temporary file representation of the parameters on demand. - The resulting file path is cached for efficiency. + The resulting file path is cached on the instance for efficiency. """ - # Return the cached path if the file has already been generated for this instance. + # Use hasattr to check for the cached attribute on the frozen instance if hasattr(self, '_cached_file_path'): return self._cached_file_path file_path = Path(tempfile.gettempdir(), f'geophires-input-params_{self._instance_id!s}.txt') with open(file_path, 'w', encoding='UTF-8') as f: - if self.from_file_path is not None: + if self.from_file_path: with open(self.from_file_path, encoding='UTF-8') as base_file: f.write(base_file.read()) if self.params: # Ensure there is a newline between the base file content and appended params. - if self.from_file_path is not None and f.tell() > 0: + if self.from_file_path and f.tell() > 0: f.seek(f.tell() - 1) if f.read(1) != '\n': f.write('\n') diff --git a/tests/geophires_x_client_tests/test_geophires_input_parameters.py b/tests/geophires_x_client_tests/test_geophires_input_parameters.py index 7929fbcc7..d796fe20f 100644 --- a/tests/geophires_x_client_tests/test_geophires_input_parameters.py +++ b/tests/geophires_x_client_tests/test_geophires_input_parameters.py @@ -1,9 +1,11 @@ import tempfile import uuid from pathlib import Path +from types import MappingProxyType from geophires_x_client import GeophiresInputParameters from geophires_x_client import GeophiresXClient +from geophires_x_client.geophires_input_parameters import ImmutableGeophiresInputParameters from tests.base_test_case import BaseTestCase @@ -52,3 +54,45 @@ def test_input_file_comments(self): GeophiresInputParameters(from_file_path=self._get_test_file_path('input_comments.txt')) ) self.assertIsNotNone(result) + + +class ImmutableGeophiresInputParametersTestCase(BaseTestCase): + def test_init_with_file_path_as_string(self): + """Verify that the class can be initialized with a string path without raising an AttributeError.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as tmp_file: + tmp_file_path = tmp_file.name + tmp_file.write('key,value\n') + + # This should not raise an AttributeError + params = ImmutableGeophiresInputParameters(from_file_path=tmp_file_path) + + # Verify the path was correctly converted and can be used + self.assertTrue(params.as_file_path().exists()) + self.assertIsInstance(params.from_file_path, Path) + + # Clean up the temporary file + Path(tmp_file_path).unlink() + + def test_hash_equality(self): + """Verify that two objects with the same content have the same hash.""" + params = {'Reservoir Depth': 3, 'Gradient 1': 50} + p1 = ImmutableGeophiresInputParameters(params=params) + p2 = ImmutableGeophiresInputParameters(params=params) + + self.assertIsNot(p1, p2) + self.assertEqual(hash(p1), hash(p2)) + + def test_hash_inequality(self): + """Verify that two objects with different content have different hashes.""" + p1 = ImmutableGeophiresInputParameters(params={'Reservoir Depth': 3}) + p2 = ImmutableGeophiresInputParameters(params={'Reservoir Depth': 4}) + self.assertNotEqual(hash(p1), hash(p2)) + + def test_immutability_of_params(self): + """Verify that the params dictionary is an immutable mapping proxy.""" + p1 = ImmutableGeophiresInputParameters(params={'Reservoir Depth': 3}) + self.assertIsInstance(p1.params, MappingProxyType) + + with self.assertRaises(TypeError): + # This should fail because MappingProxyType is read-only + p1.params['Reservoir Depth'] = 4 From 422af6cfaa87107c598a22370a497b81de736164 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 10:14:51 -0700 Subject: [PATCH 04/39] Fix combining a base file and params when the base file lacks a trailing newline --- .../geophires_input_parameters.py | 2 +- .../test_geophires_input_parameters.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/geophires_x_client/geophires_input_parameters.py b/src/geophires_x_client/geophires_input_parameters.py index 2a2ff9c38..6b83cab1f 100644 --- a/src/geophires_x_client/geophires_input_parameters.py +++ b/src/geophires_x_client/geophires_input_parameters.py @@ -157,7 +157,7 @@ def as_file_path(self) -> Path: file_path = Path(tempfile.gettempdir(), f'geophires-input-params_{self._instance_id!s}.txt') - with open(file_path, 'w', encoding='UTF-8') as f: + with open(file_path, 'w+', encoding='UTF-8') as f: if self.from_file_path: with open(self.from_file_path, encoding='UTF-8') as base_file: f.write(base_file.read()) diff --git a/tests/geophires_x_client_tests/test_geophires_input_parameters.py b/tests/geophires_x_client_tests/test_geophires_input_parameters.py index d796fe20f..4682d9f65 100644 --- a/tests/geophires_x_client_tests/test_geophires_input_parameters.py +++ b/tests/geophires_x_client_tests/test_geophires_input_parameters.py @@ -96,3 +96,23 @@ def test_immutability_of_params(self): with self.assertRaises(TypeError): # This should fail because MappingProxyType is read-only p1.params['Reservoir Depth'] = 4 + + def test_combining_file_and_params_with_no_trailing_newline(self): + """Verify that combining a base file and params works correctly when the base file lacks a trailing newline.""" + # Arrange + base_content = 'base_key,base_value' # Note: no trailing newline + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, newline='') as tmp_file: + base_file_path = Path(tmp_file.name) + tmp_file.write(base_content) + + # Act + params = ImmutableGeophiresInputParameters(from_file_path=base_file_path, params={'new_key': 'new_value'}) + combined_file_path = params.as_file_path() + combined_content = combined_file_path.read_text() + + # Assert + expected_content = 'base_key,base_value\nnew_key, new_value\n' + self.assertEqual(expected_content, combined_content) + + # Clean up the temporary file + base_file_path.unlink() From 863bbb62c02752b427cf50c1b1e2dae653eb2870 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 10:19:50 -0700 Subject: [PATCH 05/39] Fix deepcopy --- .../geophires_input_parameters.py | 17 ++++++++++++++++- .../test_geophires_input_parameters.py | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/geophires_x_client/geophires_input_parameters.py b/src/geophires_x_client/geophires_input_parameters.py index 6b83cab1f..31491948a 100644 --- a/src/geophires_x_client/geophires_input_parameters.py +++ b/src/geophires_x_client/geophires_input_parameters.py @@ -145,12 +145,27 @@ def __hash__(self) -> int: return hash((param_hash, file_content_hash)) + def __deepcopy__(self, memo): + """ + Return the instance itself for deepcopy, as the object is immutable. + + This implementation prevents a TypeError when `copy.deepcopy` is used + on an object containing an instance of this class, as it avoids trying + to pickle the internal `mappingproxy` object. + + Args: + memo: The memoization dictionary used by `copy.deepcopy` to prevent + infinite recursion in case of circular references. + """ + # Add self to the memoization dictionary to handle circular references correctly. + memo[id(self)] = self + return self + def as_file_path(self) -> Path: """ Creates a temporary file representation of the parameters on demand. The resulting file path is cached on the instance for efficiency. """ - # Use hasattr to check for the cached attribute on the frozen instance if hasattr(self, '_cached_file_path'): return self._cached_file_path diff --git a/tests/geophires_x_client_tests/test_geophires_input_parameters.py b/tests/geophires_x_client_tests/test_geophires_input_parameters.py index 4682d9f65..cde467796 100644 --- a/tests/geophires_x_client_tests/test_geophires_input_parameters.py +++ b/tests/geophires_x_client_tests/test_geophires_input_parameters.py @@ -1,3 +1,4 @@ +import copy import tempfile import uuid from pathlib import Path @@ -116,3 +117,16 @@ def test_combining_file_and_params_with_no_trailing_newline(self): # Clean up the temporary file base_file_path.unlink() + + def test_deepcopy(self): + """Verify that copy.deepcopy works on an instance without raising a TypeError.""" + p1 = ImmutableGeophiresInputParameters(params={'Reservoir Depth': 3}) + p2 = None + + try: + p2 = copy.deepcopy(p1) + except TypeError: + self.fail('copy.deepcopy(ImmutableGeophiresInputParameters) raised TypeError unexpectedly!') + + # For an immutable object, deepcopy should ideally return the same instance. + self.assertIs(p1, p2) From 7b2b78d18ba7cf9fa4c4ceadb63ea2f7a9df3c54 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 12:07:06 -0700 Subject: [PATCH 06/39] Make GeophiresXClient cache thread-safe --- src/geophires_x_client/__init__.py | 69 +++++++++++++++++------------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/src/geophires_x_client/__init__.py b/src/geophires_x_client/__init__.py index 9e4e08646..d253d915f 100644 --- a/src/geophires_x_client/__init__.py +++ b/src/geophires_x_client/__init__.py @@ -1,8 +1,10 @@ import json import os import sys +import threading from pathlib import Path +# noinspection PyPep8Naming from geophires_x import GEOPHIRESv3 as geophires from .common import _get_logger @@ -19,34 +21,43 @@ def __init__(self, enable_caching=True, logger_name=None): self._logger = _get_logger(logger_name=logger_name) self._enable_caching = enable_caching self._cache = {} + self._lock = threading.Lock() def get_geophires_result(self, input_params: GeophiresInputParameters) -> GeophiresXResult: - cache_key = hash(input_params) - if self._enable_caching and cache_key in self._cache: - return self._cache[cache_key] - - stash_cwd = Path.cwd() - stash_sys_argv = sys.argv - - sys.argv = ['', input_params.as_file_path(), input_params.get_output_file_path()] - try: - geophires.main(enable_geophires_logging_config=False) - except Exception as e: - raise RuntimeError(f'GEOPHIRES encountered an exception: {e!s}') from e - except SystemExit: - raise RuntimeError('GEOPHIRES exited without giving a reason') from None - - # Undo Geophires internal global settings changes - sys.argv = stash_sys_argv - os.chdir(stash_cwd) - - self._logger.info(f'GEOPHIRES-X output file: {input_params.get_output_file_path()}') - - result = GeophiresXResult(input_params.get_output_file_path()) - if self._enable_caching: - self._cache[cache_key] = result - - return result + """ + Calculates a GEOPHIRES result in a thread-safe manner. + + This method ensures thread safety by using a lock to serialize access, + preventing race conditions on the cache and corruption of global state + (sys.argv, os.cwd) that GEOPHIRES modifies. + """ + with self._lock: + cache_key = hash(input_params) + if self._enable_caching and cache_key in self._cache: + return self._cache[cache_key] + + stash_cwd = Path.cwd() + stash_sys_argv = sys.argv + + sys.argv = ['', input_params.as_file_path(), input_params.get_output_file_path()] + try: + geophires.main(enable_geophires_logging_config=False) + except Exception as e: + raise RuntimeError(f'GEOPHIRES encountered an exception: {e!s}') from e + except SystemExit: + raise RuntimeError('GEOPHIRES exited without giving a reason') from None + finally: + # Ensure global state is restored even if geophires.main() fails + sys.argv = stash_sys_argv + os.chdir(stash_cwd) + + self._logger.info(f'GEOPHIRES-X output file: {input_params.get_output_file_path()}') + + result = GeophiresXResult(input_params.get_output_file_path()) + if self._enable_caching: + self._cache[cache_key] = result + + return result if __name__ == '__main__': @@ -66,6 +77,6 @@ def get_geophires_result(self, input_params: GeophiresInputParameters) -> Geophi } ) - result = client.get_geophires_result(params) - log.info(f'Breakeven price: ${result.direct_use_heat_breakeven_price_USD_per_MMBTU}/MMBTU') - log.info(json.dumps(result.result, indent=2)) + result_ = client.get_geophires_result(params) + log.info(f'Breakeven price: ${result_.direct_use_heat_breakeven_price_USD_per_MMBTU}/MMBTU') + log.info(json.dumps(result_.result, indent=2)) From 83343e4322696ffb387f59529314629978d31016 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 12:18:57 -0700 Subject: [PATCH 07/39] Use a shared cache across instances of GeophiresXClient so consumers don't have to manage singletons --- src/geophires_x_client/__init__.py | 80 ++++++++++--------- .../test_geophires_client_caching.py | 9 ++- 2 files changed, 49 insertions(+), 40 deletions(-) diff --git a/src/geophires_x_client/__init__.py b/src/geophires_x_client/__init__.py index d253d915f..35b4c844c 100644 --- a/src/geophires_x_client/__init__.py +++ b/src/geophires_x_client/__init__.py @@ -1,7 +1,7 @@ import json import os import sys -import threading +from multiprocessing import Manager from pathlib import Path # noinspection PyPep8Naming @@ -14,51 +14,57 @@ class GeophiresXClient: + # Use a multiprocessing Manager to create a cache and lock that are + # shared across all processes spawned by a ProcessPoolExecutor. + _manager = Manager() + _cache = _manager.dict() + _lock = _manager.Lock() + def __init__(self, enable_caching=True, logger_name=None): if logger_name is None: logger_name = __name__ self._logger = _get_logger(logger_name=logger_name) - self._enable_caching = enable_caching - self._cache = {} - self._lock = threading.Lock() + self.enable_caching = enable_caching def get_geophires_result(self, input_params: GeophiresInputParameters) -> GeophiresXResult: - """ - Calculates a GEOPHIRES result in a thread-safe manner. - - This method ensures thread safety by using a lock to serialize access, - preventing race conditions on the cache and corruption of global state - (sys.argv, os.cwd) that GEOPHIRES modifies. - """ - with self._lock: - cache_key = hash(input_params) - if self._enable_caching and cache_key in self._cache: - return self._cache[cache_key] - - stash_cwd = Path.cwd() - stash_sys_argv = sys.argv - - sys.argv = ['', input_params.as_file_path(), input_params.get_output_file_path()] - try: - geophires.main(enable_geophires_logging_config=False) - except Exception as e: - raise RuntimeError(f'GEOPHIRES encountered an exception: {e!s}') from e - except SystemExit: - raise RuntimeError('GEOPHIRES exited without giving a reason') from None - finally: - # Ensure global state is restored even if geophires.main() fails - sys.argv = stash_sys_argv - os.chdir(stash_cwd) - - self._logger.info(f'GEOPHIRES-X output file: {input_params.get_output_file_path()}') - - result = GeophiresXResult(input_params.get_output_file_path()) - if self._enable_caching: - self._cache[cache_key] = result - + if not self.enable_caching: + return self._run_geophires(input_params) + + cache_key = hash(input_params) + with self.__class__._lock: + # Use a lock to ensure the check-and-set operation is atomic. + if cache_key in self.__class__._cache: + # Cache hit + return self.__class__._cache[cache_key] + + # If not in cache, we will run the simulation, still under the lock, + # to prevent a race condition with other processes. + # Cache miss + result = self._run_geophires(input_params) + self.__class__._cache[cache_key] = result return result + def _run_geophires(self, input_params: GeophiresInputParameters) -> GeophiresXResult: + """Helper method to encapsulate the actual GEOPHIRES execution.""" + stash_cwd = Path.cwd() + stash_sys_argv = sys.argv + + sys.argv = ['', input_params.as_file_path(), input_params.get_output_file_path()] + try: + geophires.main(enable_geophires_logging_config=False) + except Exception as e: + raise RuntimeError(f'GEOPHIRES encountered an exception: {e!s}') from e + except SystemExit: + raise RuntimeError('GEOPHIRES exited without giving a reason') from None + finally: + # Ensure we always restore the original state + sys.argv = stash_sys_argv + os.chdir(stash_cwd) + + self._logger.info(f'GEOPHIRES-X output file: {input_params.get_output_file_path()}') + return GeophiresXResult(input_params.get_output_file_path()) + if __name__ == '__main__': client = GeophiresXClient() diff --git a/tests/geophires_x_client_tests/test_geophires_client_caching.py b/tests/geophires_x_client_tests/test_geophires_client_caching.py index 8b4ebb475..95cc44d6c 100644 --- a/tests/geophires_x_client_tests/test_geophires_client_caching.py +++ b/tests/geophires_x_client_tests/test_geophires_client_caching.py @@ -56,9 +56,12 @@ def test_caching_with_identical_immutable_params(self, mock_geophires_main: unit # The core assertion: was the expensive simulation function only called once? mock_geophires_main.assert_called_once() - # The results should not only be equivalent but should be the *same object* - # retrieved from the cache on the second call. - self.assertIs(result1, result2, 'The second result should be the cached object instance.') + self.assertDictEqual(result1.result, result2.result) + + # TODO The results should probably not only be equivalent but also the *same object*... + # For now they not, but we probably don't care about this since the important part is performance/cache hit - + # manually verified the cache hit in debugger during development. + # self.assertIs(result1, result2, 'The second result should be the cached object instance.') @patch('geophires_x_client.geophires.main') def test_no_caching_with_different_immutable_params(self, mock_geophires_main: unittest.mock.MagicMock): From 955138a8879a618dc255b3e076cb12eb01438a2e Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 12:28:54 -0700 Subject: [PATCH 08/39] =?UTF-8?q?Bump=20version:=203.9.20=20=E2=86=92=203.?= =?UTF-8?q?9.21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- .cookiecutterrc | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- src/geophires_x/__init__.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 9f5749144..5e3436b49 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.20 +current_version = 3.9.21 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 450f0ad77..210ef1cbe 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.20 + version: 3.9.21 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index 9e083a39d..fb5e794f5 100644 --- a/README.rst +++ b/README.rst @@ -56,9 +56,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.20.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.21.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.20...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.21...main .. |docs| image:: https://readthedocs.org/projects/GEOPHIRES-X/badge/?style=flat :target: https://nrel.github.io/GEOPHIRES-X diff --git a/docs/conf.py b/docs/conf.py index 087a39fac..1a9fdb81a 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.20' +version = release = '3.9.21' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index cba1d0923..6139d9301 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.20', + version='3.9.21', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index 889ac5bfa..615d9f188 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.20' +__version__ = '3.9.21' From dfac749b8589d54ad909bbf86634d654af18f159 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 15:10:50 -0700 Subject: [PATCH 09/39] WIP - progress on multiprocessing safety --- src/geophires_x_client/__init__.py | 99 +++++++----- .../test_multiprocessing_safety.py | 152 ++++++++++++++++++ 2 files changed, 208 insertions(+), 43 deletions(-) create mode 100644 tests/geophires_x_client_tests/test_multiprocessing_safety.py diff --git a/src/geophires_x_client/__init__.py b/src/geophires_x_client/__init__.py index 35b4c844c..d002c5ea1 100644 --- a/src/geophires_x_client/__init__.py +++ b/src/geophires_x_client/__init__.py @@ -1,10 +1,12 @@ import json import os import sys + +# --- MULTIPROCESSING CHANGES --- from multiprocessing import Manager +from multiprocessing import RLock from pathlib import Path -# noinspection PyPep8Naming from geophires_x import GEOPHIRESv3 as geophires from .common import _get_logger @@ -14,63 +16,74 @@ class GeophiresXClient: - # Use a multiprocessing Manager to create a cache and lock that are - # shared across all processes spawned by a ProcessPoolExecutor. - _manager = Manager() - _cache = _manager.dict() - _lock = _manager.Lock() + # --- LAZY-LOADED, PROCESS-SAFE SINGLETONS --- + # Define class-level placeholders. These will be shared across all instances. + _manager = None + _cache = None + _lock = RLock() # Use a process-safe re-entrant lock def __init__(self, enable_caching=True, logger_name=None): if logger_name is None: logger_name = __name__ self._logger = _get_logger(logger_name=logger_name) - self.enable_caching = enable_caching + self._enable_caching = enable_caching + + # This method will safely initialize the shared manager and cache + # only when the first client instance is created. + self._initialize_shared_resources() + + @classmethod + def _initialize_shared_resources(cls): + """ + Initializes the multiprocessing Manager and shared cache dictionary. + This method is designed to be called safely by multiple processes, + ensuring the manager is only started once. + """ + with cls._lock: + if cls._manager is None: + # This code is now protected. It won't run on module import. + # It runs only when the first GeophiresXClient is instantiated. + cls._manager = Manager() + cls._cache = cls._manager.dict() def get_geophires_result(self, input_params: GeophiresInputParameters) -> GeophiresXResult: - if not self.enable_caching: - return self._run_geophires(input_params) - - cache_key = hash(input_params) - with self.__class__._lock: - # Use a lock to ensure the check-and-set operation is atomic. - if cache_key in self.__class__._cache: - # Cache hit - return self.__class__._cache[cache_key] - - # If not in cache, we will run the simulation, still under the lock, - # to prevent a race condition with other processes. - # Cache miss - result = self._run_geophires(input_params) - self.__class__._cache[cache_key] = result - return result - - def _run_geophires(self, input_params: GeophiresInputParameters) -> GeophiresXResult: - """Helper method to encapsulate the actual GEOPHIRES execution.""" - stash_cwd = Path.cwd() - stash_sys_argv = sys.argv + # Use the class-level lock to protect access to the shared cache + # and the non-reentrant GEOPHIRES core. + with GeophiresXClient._lock: + cache_key = hash(input_params) + if self._enable_caching and cache_key in GeophiresXClient._cache: + return GeophiresXClient._cache[cache_key] + + # ... (The rest of your logic remains the same) + stash_cwd = Path.cwd() + stash_sys_argv = sys.argv + + sys.argv = ['', input_params.as_file_path(), input_params.get_output_file_path()] + try: + geophires.main(enable_geophires_logging_config=False) + except Exception as e: + raise RuntimeError(f'GEOPHIRES encountered an exception: {e!s}') from e + except SystemExit: + raise RuntimeError('GEOPHIRES exited without giving a reason') from None + finally: + sys.argv = stash_sys_argv + os.chdir(stash_cwd) + + self._logger.info(f'GEOPHIRES-X output file: {input_params.get_output_file_path()}') + + result = GeophiresXResult(input_params.get_output_file_path()) + if self._enable_caching: + GeophiresXClient._cache[cache_key] = result - sys.argv = ['', input_params.as_file_path(), input_params.get_output_file_path()] - try: - geophires.main(enable_geophires_logging_config=False) - except Exception as e: - raise RuntimeError(f'GEOPHIRES encountered an exception: {e!s}') from e - except SystemExit: - raise RuntimeError('GEOPHIRES exited without giving a reason') from None - finally: - # Ensure we always restore the original state - sys.argv = stash_sys_argv - os.chdir(stash_cwd) - - self._logger.info(f'GEOPHIRES-X output file: {input_params.get_output_file_path()}') - return GeophiresXResult(input_params.get_output_file_path()) + return result if __name__ == '__main__': + # This block is safe, as it's protected from being run on import. client = GeophiresXClient() log = _get_logger() - # noinspection PyTypeChecker params = GeophiresInputParameters( { 'Print Output to Console': 0, diff --git a/tests/geophires_x_client_tests/test_multiprocessing_safety.py b/tests/geophires_x_client_tests/test_multiprocessing_safety.py new file mode 100644 index 000000000..31c9301fe --- /dev/null +++ b/tests/geophires_x_client_tests/test_multiprocessing_safety.py @@ -0,0 +1,152 @@ +import logging +import multiprocessing +import sys +import time +import unittest +from logging.handlers import QueueHandler +from queue import Empty + +from geophires_x_client import EndUseOption +from geophires_x_client import GeophiresInputParameters + +# Important: We must be able to import the client +from geophires_x_client import GeophiresXClient + + +# This is the function that each worker process will execute. +# It must be a top-level function to be picklable by multiprocessing. +def run_client_in_process(params_dict: dict, log_queue: multiprocessing.Queue, result_queue: multiprocessing.Queue): + """ + Instantiates a client and runs a calculation, reporting results + and logs back to the main process via queues. + """ + # Configure logging for this worker process to send messages to the shared queue. + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + root_logger.handlers = [QueueHandler(log_queue)] + + try: + client = GeophiresXClient(enable_caching=True) + params = GeophiresInputParameters(params_dict) + + # This now calls the REAL geophires.main via the client. + result = client.get_geophires_result(params) + + # Put the primitive result into the queue to avoid serialization issues. + result_queue.put(result.direct_use_heat_breakeven_price_USD_per_MMBTU) + except Exception as e: + # Report any exceptions back to the main process. + result_queue.put(e) + + +class TestMultiprocessingSafety(unittest.TestCase): + # Class-level attributes to manage shared resources across test runs. + _ctx = None + _client_for_setup = None + + @classmethod + def setUpClass(cls): + """ + Set up the multiprocessing context and start the shared Manager + process ONCE before any tests in this class run. + """ + if sys.platform == 'win32': + # Skip all tests in this class if not on a fork-supporting OS. + raise unittest.SkipTest("The 'fork' multiprocessing context is not available on Windows.") + + cls._ctx = multiprocessing.get_context('fork') + # Instantiating the client here creates the shared _manager and _cache + # that all child processes forked from this test will inherit. + cls._client_for_setup = GeophiresXClient() + + @classmethod + def tearDownClass(cls): + """ + Shut down the shared Manager process ONCE after all tests in this + class have finished. This is the key to preventing hanging processes. + """ + if cls._client_for_setup and hasattr(cls._client_for_setup, '_manager'): + if cls._client_for_setup._manager is not None: + cls._client_for_setup._manager.shutdown() + + def setUp(self): + """Set up a shared set of parameters for each test.""" + # This setup runs before each individual test method. + self.params_dict = { + 'Print Output to Console': 0, + 'End-Use Option': EndUseOption.DIRECT_USE_HEAT.value, + 'Reservoir Model': 1, + 'Time steps per year': 1, + # Use nanoseconds to ensure each test run gets a unique cache key (Use a different value per run) + 'Reservoir Depth': 4 + time.time_ns() / 1e19, + 'Gradient 1': 50, + 'Maximum Temperature': 550, + } + + def test_client_runs_real_geophires_and_caches_across_processes(self): + """ + Tests that GeophiresXClient can run the real geophires.main in multiple + processes and that the cache is shared between them. + """ + log_queue = self._ctx.Queue() + result_queue = self._ctx.Queue() + num_processes = 4 + # Timeout should be long enough for at least one successful run. + process_timeout_seconds = 5 + + processes = [ + self._ctx.Process(target=run_client_in_process, args=(self.params_dict, log_queue, result_queue)) + for _ in range(num_processes) + ] + + for p in processes: + p.start() + + # --- Robust Result Collection --- + results = [] + for i in range(num_processes): + try: + result = result_queue.get(timeout=process_timeout_seconds) + results.append(result) + except Empty: + # Terminate running processes before failing to avoid hanging the suite + for p_cleanup in processes: + if p_cleanup.is_alive(): + p_cleanup.terminate() + self.fail(f'Test timed out waiting for result #{i + 1}. A worker process likely crashed or is stuck.') + + # --- Process Cleanup --- + # With the robust tearDownClass, a simple join is sufficient here. + for p in processes: + p.join(timeout=process_timeout_seconds) + + # --- Assertions --- + # 1. Check that no process returned an exception. + for r in results: + self.assertNotIsInstance(r, Exception, f'A process failed with an exception: {r}') + + # 2. Check that all processes got a valid, non-None result. + for r in results: + self.assertIsNotNone(r) + self.assertIsInstance(r, float) + + # 3. CRITICAL: Assert that the expensive GEOPHIRES calculation was only run ONCE. + # This assertion is expected to fail until the caching bug in the client is fixed. + log_records = [] + while not log_queue.empty(): + log_records.append(log_queue.get().getMessage()) + + cache_indicator_log = 'GEOPHIRES-X output file:' + successful_runs = sum(1 for record in log_records if cache_indicator_log in record) + + self.assertEqual( + successful_runs, + 1, + f'FAIL: GEOPHIRES was run {successful_runs} times instead of once, indicating the cross-process cache failed.', + ) + + print(f'\nDetected {successful_runs} non-cached GEOPHIRES run(s) for {num_processes} requests.') + + +if __name__ == '__main__': + unittest.main() From 301ee84bc447f62d21eab623a1088d51ef75e7e5 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 15:20:16 -0700 Subject: [PATCH 10/39] Use ImmutableGeophiresInputParameters in TestMultiprocessingSafety --- src/geophires_x_client/__init__.py | 48 +++++++++++-------- .../test_multiprocessing_safety.py | 8 ++-- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/geophires_x_client/__init__.py b/src/geophires_x_client/__init__.py index d002c5ea1..ef49f2709 100644 --- a/src/geophires_x_client/__init__.py +++ b/src/geophires_x_client/__init__.py @@ -1,10 +1,8 @@ import json import os import sys - -# --- MULTIPROCESSING CHANGES --- +import threading from multiprocessing import Manager -from multiprocessing import RLock from pathlib import Path from geophires_x import GEOPHIRESv3 as geophires @@ -16,11 +14,14 @@ class GeophiresXClient: - # --- LAZY-LOADED, PROCESS-SAFE SINGLETONS --- - # Define class-level placeholders. These will be shared across all instances. + # --- Class-level shared resources --- + # These will be initialized lazily and shared across all instances and processes. _manager = None _cache = None - _lock = RLock() # Use a process-safe re-entrant lock + _lock = None # This will be a process-safe RLock from the manager. + + # A standard threading lock to make the one-time initialization thread-safe. + _init_lock = threading.Lock() def __init__(self, enable_caching=True, logger_name=None): if logger_name is None: @@ -29,33 +30,39 @@ def __init__(self, enable_caching=True, logger_name=None): self._logger = _get_logger(logger_name=logger_name) self._enable_caching = enable_caching - # This method will safely initialize the shared manager and cache - # only when the first client instance is created. - self._initialize_shared_resources() + # Lazy-initialize shared resources if they haven't been already. + # This approach is safe to call from multiple threads/processes. + if GeophiresXClient._manager is None: + self._initialize_shared_resources() @classmethod def _initialize_shared_resources(cls): """ - Initializes the multiprocessing Manager and shared cache dictionary. - This method is designed to be called safely by multiple processes, - ensuring the manager is only started once. + Initializes the multiprocessing Manager and shared resources (cache, lock) + in a thread-safe and process-safe manner. """ - with cls._lock: + # Use a thread-safe lock to ensure this block only ever runs once + # across all threads in the main process. + with cls._init_lock: + # The double-check locking pattern ensures we don't try to + # re-initialize if another thread finished while we were waiting. if cls._manager is None: - # This code is now protected. It won't run on module import. - # It runs only when the first GeophiresXClient is instantiated. cls._manager = Manager() cls._cache = cls._manager.dict() + cls._lock = cls._manager.RLock() # The Manager now creates the lock. def get_geophires_result(self, input_params: GeophiresInputParameters) -> GeophiresXResult: - # Use the class-level lock to protect access to the shared cache - # and the non-reentrant GEOPHIRES core. + """ + Calculates a GEOPHIRES result in a thread-safe and process-safe manner. + """ + # Use the process-safe lock from the manager to make the check-then-act + # operation on the cache fully atomic across multiple processes. with GeophiresXClient._lock: cache_key = hash(input_params) if self._enable_caching and cache_key in GeophiresXClient._cache: return GeophiresXClient._cache[cache_key] - # ... (The rest of your logic remains the same) + # --- This section is now guaranteed to run only once per unique input --- stash_cwd = Path.cwd() stash_sys_argv = sys.argv @@ -67,6 +74,7 @@ def get_geophires_result(self, input_params: GeophiresInputParameters) -> Geophi except SystemExit: raise RuntimeError('GEOPHIRES exited without giving a reason') from None finally: + # Ensure global state is restored even if geophires.main() fails sys.argv = stash_sys_argv os.chdir(stash_cwd) @@ -74,13 +82,13 @@ def get_geophires_result(self, input_params: GeophiresInputParameters) -> Geophi result = GeophiresXResult(input_params.get_output_file_path()) if self._enable_caching: - GeophiresXClient._cache[cache_key] = result + self._cache[cache_key] = result return result if __name__ == '__main__': - # This block is safe, as it's protected from being run on import. + # This block remains for direct testing of the script. client = GeophiresXClient() log = _get_logger() diff --git a/tests/geophires_x_client_tests/test_multiprocessing_safety.py b/tests/geophires_x_client_tests/test_multiprocessing_safety.py index 31c9301fe..748e4377d 100644 --- a/tests/geophires_x_client_tests/test_multiprocessing_safety.py +++ b/tests/geophires_x_client_tests/test_multiprocessing_safety.py @@ -7,10 +7,10 @@ from queue import Empty from geophires_x_client import EndUseOption -from geophires_x_client import GeophiresInputParameters # Important: We must be able to import the client from geophires_x_client import GeophiresXClient +from geophires_x_client.geophires_input_parameters import ImmutableGeophiresInputParameters # This is the function that each worker process will execute. @@ -27,7 +27,7 @@ def run_client_in_process(params_dict: dict, log_queue: multiprocessing.Queue, r try: client = GeophiresXClient(enable_caching=True) - params = GeophiresInputParameters(params_dict) + params = ImmutableGeophiresInputParameters(params_dict) # This now calls the REAL geophires.main via the client. result = client.get_geophires_result(params) @@ -90,7 +90,7 @@ def test_client_runs_real_geophires_and_caches_across_processes(self): """ log_queue = self._ctx.Queue() result_queue = self._ctx.Queue() - num_processes = 4 + num_processes = 8 # Timeout should be long enough for at least one successful run. process_timeout_seconds = 5 @@ -140,8 +140,8 @@ def test_client_runs_real_geophires_and_caches_across_processes(self): successful_runs = sum(1 for record in log_records if cache_indicator_log in record) self.assertEqual( - successful_runs, 1, + successful_runs, f'FAIL: GEOPHIRES was run {successful_runs} times instead of once, indicating the cross-process cache failed.', ) From 9f0648fa09a113cc45ad8dbf015ed460d401681c Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 15:26:16 -0700 Subject: [PATCH 11/39] test resource management to not interfere with other tests --- .../test_multiprocessing_safety.py | 174 ++++++++---------- 1 file changed, 80 insertions(+), 94 deletions(-) diff --git a/tests/geophires_x_client_tests/test_multiprocessing_safety.py b/tests/geophires_x_client_tests/test_multiprocessing_safety.py index 748e4377d..0756dea1c 100644 --- a/tests/geophires_x_client_tests/test_multiprocessing_safety.py +++ b/tests/geophires_x_client_tests/test_multiprocessing_safety.py @@ -8,7 +8,7 @@ from geophires_x_client import EndUseOption -# Important: We must be able to import the client +# Important: We must be able to import the client and all parameter classes from geophires_x_client import GeophiresXClient from geophires_x_client.geophires_input_parameters import ImmutableGeophiresInputParameters @@ -26,58 +26,26 @@ def run_client_in_process(params_dict: dict, log_queue: multiprocessing.Queue, r root_logger.handlers = [QueueHandler(log_queue)] try: + # Client initialization is now done in the worker, relying on the + # lazy-loading singleton pattern in the client itself. client = GeophiresXClient(enable_caching=True) params = ImmutableGeophiresInputParameters(params_dict) - - # This now calls the REAL geophires.main via the client. result = client.get_geophires_result(params) - - # Put the primitive result into the queue to avoid serialization issues. result_queue.put(result.direct_use_heat_breakeven_price_USD_per_MMBTU) except Exception as e: - # Report any exceptions back to the main process. result_queue.put(e) class TestMultiprocessingSafety(unittest.TestCase): - # Class-level attributes to manage shared resources across test runs. - _ctx = None - _client_for_setup = None - - @classmethod - def setUpClass(cls): - """ - Set up the multiprocessing context and start the shared Manager - process ONCE before any tests in this class run. - """ - if sys.platform == 'win32': - # Skip all tests in this class if not on a fork-supporting OS. - raise unittest.SkipTest("The 'fork' multiprocessing context is not available on Windows.") - - cls._ctx = multiprocessing.get_context('fork') - # Instantiating the client here creates the shared _manager and _cache - # that all child processes forked from this test will inherit. - cls._client_for_setup = GeophiresXClient() - - @classmethod - def tearDownClass(cls): - """ - Shut down the shared Manager process ONCE after all tests in this - class have finished. This is the key to preventing hanging processes. - """ - if cls._client_for_setup and hasattr(cls._client_for_setup, '_manager'): - if cls._client_for_setup._manager is not None: - cls._client_for_setup._manager.shutdown() + # By removing setUpClass and tearDownClass, we ensure each test is fully isolated. def setUp(self): """Set up a shared set of parameters for each test.""" - # This setup runs before each individual test method. self.params_dict = { 'Print Output to Console': 0, 'End-Use Option': EndUseOption.DIRECT_USE_HEAT.value, 'Reservoir Model': 1, 'Time steps per year': 1, - # Use nanoseconds to ensure each test run gets a unique cache key (Use a different value per run) 'Reservoir Depth': 4 + time.time_ns() / 1e19, 'Gradient 1': 50, 'Maximum Temperature': 550, @@ -88,64 +56,82 @@ def test_client_runs_real_geophires_and_caches_across_processes(self): Tests that GeophiresXClient can run the real geophires.main in multiple processes and that the cache is shared between them. """ - log_queue = self._ctx.Queue() - result_queue = self._ctx.Queue() - num_processes = 8 - # Timeout should be long enough for at least one successful run. - process_timeout_seconds = 5 - - processes = [ - self._ctx.Process(target=run_client_in_process, args=(self.params_dict, log_queue, result_queue)) - for _ in range(num_processes) - ] - - for p in processes: - p.start() - - # --- Robust Result Collection --- - results = [] - for i in range(num_processes): - try: - result = result_queue.get(timeout=process_timeout_seconds) - results.append(result) - except Empty: - # Terminate running processes before failing to avoid hanging the suite - for p_cleanup in processes: - if p_cleanup.is_alive(): - p_cleanup.terminate() - self.fail(f'Test timed out waiting for result #{i + 1}. A worker process likely crashed or is stuck.') - - # --- Process Cleanup --- - # With the robust tearDownClass, a simple join is sufficient here. - for p in processes: - p.join(timeout=process_timeout_seconds) - - # --- Assertions --- - # 1. Check that no process returned an exception. - for r in results: - self.assertNotIsInstance(r, Exception, f'A process failed with an exception: {r}') - - # 2. Check that all processes got a valid, non-None result. - for r in results: - self.assertIsNotNone(r) - self.assertIsInstance(r, float) - - # 3. CRITICAL: Assert that the expensive GEOPHIRES calculation was only run ONCE. - # This assertion is expected to fail until the caching bug in the client is fixed. - log_records = [] - while not log_queue.empty(): - log_records.append(log_queue.get().getMessage()) - - cache_indicator_log = 'GEOPHIRES-X output file:' - successful_runs = sum(1 for record in log_records if cache_indicator_log in record) - - self.assertEqual( - 1, - successful_runs, - f'FAIL: GEOPHIRES was run {successful_runs} times instead of once, indicating the cross-process cache failed.', - ) - - print(f'\nDetected {successful_runs} non-cached GEOPHIRES run(s) for {num_processes} requests.') + if sys.platform == 'win32': + self.skipTest("The 'fork' multiprocessing context is not available on Windows.") + + ctx = multiprocessing.get_context('fork') + # THE FIX: Use the Manager as a context manager within the test. + # This guarantees it and all its resources (queues, etc.) are + # properly created and shut down for each individual test run. + with ctx.Manager() as manager: + log_queue = manager.Queue() + result_queue = manager.Queue() + + # The client needs to be re-initialized inside the test to use the new manager. + # This is a bit of a workaround to reset the class-level singleton for the test. + GeophiresXClient._manager = manager + GeophiresXClient._cache = manager.dict() + GeophiresXClient._lock = manager.RLock() + + num_processes = 4 + process_timeout_seconds = 15 + + processes = [ + ctx.Process(target=run_client_in_process, args=(self.params_dict, log_queue, result_queue)) + for _ in range(num_processes) + ] + + for p in processes: + p.start() + + # --- Robust Result Collection --- + results = [] + for i in range(num_processes): + try: + result = result_queue.get(timeout=process_timeout_seconds) + results.append(result) + except Empty: + for p_cleanup in processes: + if p_cleanup.is_alive(): + p_cleanup.terminate() + self.fail( + f'Test timed out waiting for result #{i + 1}. A worker process likely crashed or is stuck.' + ) + + # --- Process Cleanup --- + for p in processes: + p.join(timeout=process_timeout_seconds) + if p.is_alive(): + p.terminate() # Forcefully end if stuck + self.fail(f'Process {p.pid} failed to terminate cleanly.') + + # --- Assertions --- + for r in results: + self.assertNotIsInstance(r, Exception, f'A process failed with an exception: {r}') + self.assertIsNotNone(r) + self.assertIsInstance(r, float) + + log_records = [] + while not log_queue.empty(): + log_records.append(log_queue.get().getMessage()) + + cache_indicator_log = 'GEOPHIRES-X output file:' + successful_runs = sum(1 for record in log_records if cache_indicator_log in record) + + self.assertEqual( + successful_runs, + 1, + f'FAIL: GEOPHIRES was run {successful_runs} times instead of once, indicating the cache failed.', + ) + + print( + f'\nTest passed: Detected {successful_runs} non-cached GEOPHIRES run(s) for {num_processes} requests.' + ) + + # Reset the client's singleton state after the test to not interfere with others. + GeophiresXClient._manager = None + GeophiresXClient._cache = None + GeophiresXClient._lock = None if __name__ == '__main__': From 247182e796f15b26251aba50d7cecfb70c105067 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 15:53:21 -0700 Subject: [PATCH 12/39] test_multiprocessing_safety.py passes --- src/geophires_x_client/__init__.py | 111 +++++++++--------- .../test_multiprocessing_safety.py | 39 +++--- 2 files changed, 71 insertions(+), 79 deletions(-) diff --git a/src/geophires_x_client/__init__.py b/src/geophires_x_client/__init__.py index ef49f2709..a418ccd84 100644 --- a/src/geophires_x_client/__init__.py +++ b/src/geophires_x_client/__init__.py @@ -1,4 +1,3 @@ -import json import os import sys import threading @@ -7,18 +6,23 @@ from geophires_x import GEOPHIRESv3 as geophires +# Assuming these are in a sibling file or accessible path from .common import _get_logger -from .geophires_input_parameters import EndUseOption from .geophires_input_parameters import GeophiresInputParameters +from .geophires_input_parameters import ImmutableGeophiresInputParameters from .geophires_x_result import GeophiresXResult class GeophiresXClient: + """ + A thread-safe and process-safe client for running GEOPHIRES simulations. + Relies on an explicit shutdown() call to clean up background processes. + """ + # --- Class-level shared resources --- - # These will be initialized lazily and shared across all instances and processes. _manager = None _cache = None - _lock = None # This will be a process-safe RLock from the manager. + _lock = None # A standard threading lock to make the one-time initialization thread-safe. _init_lock = threading.Lock() @@ -31,7 +35,6 @@ def __init__(self, enable_caching=True, logger_name=None): self._enable_caching = enable_caching # Lazy-initialize shared resources if they haven't been already. - # This approach is safe to call from multiple threads/processes. if GeophiresXClient._manager is None: self._initialize_shared_resources() @@ -41,69 +44,63 @@ def _initialize_shared_resources(cls): Initializes the multiprocessing Manager and shared resources (cache, lock) in a thread-safe and process-safe manner. """ - # Use a thread-safe lock to ensure this block only ever runs once - # across all threads in the main process. with cls._init_lock: - # The double-check locking pattern ensures we don't try to - # re-initialize if another thread finished while we were waiting. if cls._manager is None: cls._manager = Manager() cls._cache = cls._manager.dict() - cls._lock = cls._manager.RLock() # The Manager now creates the lock. + cls._lock = cls._manager.RLock() - def get_geophires_result(self, input_params: GeophiresInputParameters) -> GeophiresXResult: + @classmethod + def shutdown(cls): """ - Calculates a GEOPHIRES result in a thread-safe and process-safe manner. + Explicitly shuts down the background manager process. + This MUST be called when the application is finished with the client + to prevent orphaned processes. """ - # Use the process-safe lock from the manager to make the check-then-act - # operation on the cache fully atomic across multiple processes. - with GeophiresXClient._lock: - cache_key = hash(input_params) - if self._enable_caching and cache_key in GeophiresXClient._cache: - return GeophiresXClient._cache[cache_key] + with cls._init_lock: + if cls._manager is not None: + cls._manager.shutdown() + cls._manager = None + cls._cache = None + cls._lock = None - # --- This section is now guaranteed to run only once per unique input --- - stash_cwd = Path.cwd() - stash_sys_argv = sys.argv + def get_geophires_result(self, input_params: GeophiresInputParameters) -> GeophiresXResult: + """ + Calculates a GEOPHIRES result, using a cross-process cache to avoid + re-computing results for the same inputs. Caching is only effective + when providing an instance of ImmutableGeophiresInputParameters. + """ + is_immutable = isinstance(input_params, ImmutableGeophiresInputParameters) - sys.argv = ['', input_params.as_file_path(), input_params.get_output_file_path()] - try: - geophires.main(enable_geophires_logging_config=False) - except Exception as e: - raise RuntimeError(f'GEOPHIRES encountered an exception: {e!s}') from e - except SystemExit: - raise RuntimeError('GEOPHIRES exited without giving a reason') from None - finally: - # Ensure global state is restored even if geophires.main() fails - sys.argv = stash_sys_argv - os.chdir(stash_cwd) + if not (self._enable_caching and is_immutable): + return self._run_simulation(input_params) - self._logger.info(f'GEOPHIRES-X output file: {input_params.get_output_file_path()}') + cache_key = hash(input_params) - result = GeophiresXResult(input_params.get_output_file_path()) - if self._enable_caching: - self._cache[cache_key] = result + with GeophiresXClient._lock: + if cache_key in GeophiresXClient._cache: + return GeophiresXClient._cache[cache_key] + result = self._run_simulation(input_params) + GeophiresXClient._cache[cache_key] = result return result - -if __name__ == '__main__': - # This block remains for direct testing of the script. - client = GeophiresXClient() - log = _get_logger() - - params = GeophiresInputParameters( - { - 'Print Output to Console': 0, - 'End-Use Option': EndUseOption.DIRECT_USE_HEAT.value, - 'Reservoir Model': 1, - 'Time steps per year': 1, - 'Reservoir Depth': 3, - 'Gradient 1': 50, - 'Maximum Temperature': 250, - } - ) - - result_ = client.get_geophires_result(params) - log.info(f'Breakeven price: ${result_.direct_use_heat_breakeven_price_USD_per_MMBTU}/MMBTU') - log.info(json.dumps(result_.result, indent=2)) + def _run_simulation(self, input_params: GeophiresInputParameters) -> GeophiresXResult: + """Helper method to encapsulate the actual GEOPHIRES run.""" + stash_cwd = Path.cwd() + stash_sys_argv = sys.argv + sys.argv = ['', input_params.as_file_path(), input_params.get_output_file_path()] + + try: + geophires.main(enable_geophires_logging_config=False) + except Exception as e: + raise RuntimeError(f'GEOPHIRES encountered an exception: {e!s}') from e + except SystemExit: + raise RuntimeError('GEOPHIRES exited without giving a reason') from None + finally: + sys.argv = stash_sys_argv + os.chdir(stash_cwd) + + self._logger.info(f'GEOPHIRES-X output file: {input_params.get_output_file_path()}') + result = GeophiresXResult(input_params.get_output_file_path()) + return result diff --git a/tests/geophires_x_client_tests/test_multiprocessing_safety.py b/tests/geophires_x_client_tests/test_multiprocessing_safety.py index 0756dea1c..aa2d768d8 100644 --- a/tests/geophires_x_client_tests/test_multiprocessing_safety.py +++ b/tests/geophires_x_client_tests/test_multiprocessing_safety.py @@ -6,19 +6,16 @@ from logging.handlers import QueueHandler from queue import Empty -from geophires_x_client import EndUseOption - # Important: We must be able to import the client and all parameter classes from geophires_x_client import GeophiresXClient +from geophires_x_client.geophires_input_parameters import EndUseOption from geophires_x_client.geophires_input_parameters import ImmutableGeophiresInputParameters -# This is the function that each worker process will execute. -# It must be a top-level function to be picklable by multiprocessing. def run_client_in_process(params_dict: dict, log_queue: multiprocessing.Queue, result_queue: multiprocessing.Queue): """ - Instantiates a client and runs a calculation, reporting results - and logs back to the main process via queues. + This is the function that each worker process will execute. + It must be a top-level function to be picklable by multiprocessing. """ # Configure logging for this worker process to send messages to the shared queue. root_logger = logging.getLogger() @@ -26,8 +23,7 @@ def run_client_in_process(params_dict: dict, log_queue: multiprocessing.Queue, r root_logger.handlers = [QueueHandler(log_queue)] try: - # Client initialization is now done in the worker, relying on the - # lazy-loading singleton pattern in the client itself. + # The client will use the Manager that was injected by the test's main process. client = GeophiresXClient(enable_caching=True) params = ImmutableGeophiresInputParameters(params_dict) result = client.get_geophires_result(params) @@ -37,10 +33,8 @@ def run_client_in_process(params_dict: dict, log_queue: multiprocessing.Queue, r class TestMultiprocessingSafety(unittest.TestCase): - # By removing setUpClass and tearDownClass, we ensure each test is fully isolated. - def setUp(self): - """Set up a shared set of parameters for each test.""" + """Set up a unique set of parameters for each test.""" self.params_dict = { 'Print Output to Console': 0, 'End-Use Option': EndUseOption.DIRECT_USE_HEAT.value, @@ -54,25 +48,26 @@ def setUp(self): def test_client_runs_real_geophires_and_caches_across_processes(self): """ Tests that GeophiresXClient can run the real geophires.main in multiple - processes and that the cache is shared between them. + processes and that the cache is shared between them. This test is now + fully self-contained to prevent resource conflicts with the test runner. """ if sys.platform == 'win32': self.skipTest("The 'fork' multiprocessing context is not available on Windows.") ctx = multiprocessing.get_context('fork') - # THE FIX: Use the Manager as a context manager within the test. - # This guarantees it and all its resources (queues, etc.) are - # properly created and shut down for each individual test run. + # Use the Manager as a context manager. This is the key to ensuring + # all resources it creates (queues, etc.) are properly shut down + # at the end of the block, preventing deadlocks. with ctx.Manager() as manager: - log_queue = manager.Queue() - result_queue = manager.Queue() - - # The client needs to be re-initialized inside the test to use the new manager. - # This is a bit of a workaround to reset the class-level singleton for the test. + # For this test to work, we MUST inject the test-specific manager + # into the client's class-level singleton attributes. GeophiresXClient._manager = manager GeophiresXClient._cache = manager.dict() GeophiresXClient._lock = manager.RLock() + log_queue = manager.Queue() + result_queue = manager.Queue() + num_processes = 4 process_timeout_seconds = 15 @@ -102,7 +97,7 @@ def test_client_runs_real_geophires_and_caches_across_processes(self): for p in processes: p.join(timeout=process_timeout_seconds) if p.is_alive(): - p.terminate() # Forcefully end if stuck + p.terminate() self.fail(f'Process {p.pid} failed to terminate cleanly.') # --- Assertions --- @@ -128,7 +123,7 @@ def test_client_runs_real_geophires_and_caches_across_processes(self): f'\nTest passed: Detected {successful_runs} non-cached GEOPHIRES run(s) for {num_processes} requests.' ) - # Reset the client's singleton state after the test to not interfere with others. + # CRITICAL: Reset the client's singleton state after the test to not interfere with other tests. GeophiresXClient._manager = None GeophiresXClient._cache = None GeophiresXClient._lock = None From 0d748d804f35eb32a6751035e4c9d203fe1d4f9f Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 15:58:31 -0700 Subject: [PATCH 13/39] =?UTF-8?q?Bump=20version:=203.9.21=20=E2=86=92=203.?= =?UTF-8?q?9.22?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- .cookiecutterrc | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- src/geophires_x/__init__.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 5e3436b49..41f881b9a 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.21 +current_version = 3.9.22 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 210ef1cbe..afc5b42d6 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.21 + version: 3.9.22 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index fb5e794f5..d4e55269c 100644 --- a/README.rst +++ b/README.rst @@ -56,9 +56,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.21.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.22.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.21...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.22...main .. |docs| image:: https://readthedocs.org/projects/GEOPHIRES-X/badge/?style=flat :target: https://nrel.github.io/GEOPHIRES-X diff --git a/docs/conf.py b/docs/conf.py index 1a9fdb81a..efd65b9e8 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.21' +version = release = '3.9.22' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index 6139d9301..500578777 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.21', + version='3.9.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/__init__.py b/src/geophires_x/__init__.py index 615d9f188..56011e58e 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.21' +__version__ = '3.9.22' From 9933a6ca0be13911bbc5138ce663156b2dc31fc6 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:09:12 -0700 Subject: [PATCH 14/39] fix ImmutableGeophiresInputParameters pickling serialization --- .../geophires_input_parameters.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/geophires_x_client/geophires_input_parameters.py b/src/geophires_x_client/geophires_input_parameters.py index 31491948a..8d054e7dd 100644 --- a/src/geophires_x_client/geophires_input_parameters.py +++ b/src/geophires_x_client/geophires_input_parameters.py @@ -161,6 +161,26 @@ def __deepcopy__(self, memo): memo[id(self)] = self return self + def __getstate__(self) -> dict: + """ + Prepare the object's state for pickling. + Converts the mappingproxy to a regular dict, which is pickleable. + """ + state = self.__dict__.copy() + # Convert mappingproxy to dict for serialization + state['params'] = dict(self.params) + return state + + def __setstate__(self, state: dict): + """ + Restore the object's state after unpickling. + Converts the dict back to a mappingproxy to maintain immutability. + """ + # Convert dict back to mappingproxy + state['params'] = MappingProxyType(state['params']) + # Restore the instance's dictionary + self.__dict__.update(state) + def as_file_path(self) -> Path: """ Creates a temporary file representation of the parameters on demand. From ff90e78ff058b9037027e6dc02afa78090b6650d Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:09:17 -0700 Subject: [PATCH 15/39] =?UTF-8?q?Bump=20version:=203.9.22=20=E2=86=92=203.?= =?UTF-8?q?9.23?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- .cookiecutterrc | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- src/geophires_x/__init__.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 41f881b9a..e592ecfbf 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.22 +current_version = 3.9.23 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index afc5b42d6..a4c1c34c3 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.22 + version: 3.9.23 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index d4e55269c..fcdbd019c 100644 --- a/README.rst +++ b/README.rst @@ -56,9 +56,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.22.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.23.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.22...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.23...main .. |docs| image:: https://readthedocs.org/projects/GEOPHIRES-X/badge/?style=flat :target: https://nrel.github.io/GEOPHIRES-X diff --git a/docs/conf.py b/docs/conf.py index efd65b9e8..0df9de293 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.22' +version = release = '3.9.23' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index 500578777..dab76a25c 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.22', + version='3.9.23', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index 56011e58e..cb63a1c4e 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.22' +__version__ = '3.9.23' From c6b66051eee1816e6f005eef8f0c6f14edfe25ac Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 18:22:18 -0700 Subject: [PATCH 16/39] automatically call shutdown with atexit hook --- src/geophires_x_client/__init__.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/geophires_x_client/__init__.py b/src/geophires_x_client/__init__.py index a418ccd84..82eb40796 100644 --- a/src/geophires_x_client/__init__.py +++ b/src/geophires_x_client/__init__.py @@ -1,12 +1,13 @@ +import atexit import os import sys import threading from multiprocessing import Manager from pathlib import Path +# noinspection PyPep8Naming from geophires_x import GEOPHIRESv3 as geophires -# Assuming these are in a sibling file or accessible path from .common import _get_logger from .geophires_input_parameters import GeophiresInputParameters from .geophires_input_parameters import ImmutableGeophiresInputParameters @@ -16,7 +17,8 @@ class GeophiresXClient: """ A thread-safe and process-safe client for running GEOPHIRES simulations. - Relies on an explicit shutdown() call to clean up background processes. + It automatically manages a background process via atexit and provides an + explicit shutdown() method for advanced use cases like testing. """ # --- Class-level shared resources --- @@ -24,8 +26,8 @@ class GeophiresXClient: _cache = None _lock = None - # A standard threading lock to make the one-time initialization thread-safe. _init_lock = threading.Lock() + """A standard threading lock to make the one-time initialization thread-safe.""" def __init__(self, enable_caching=True, logger_name=None): if logger_name is None: @@ -41,25 +43,31 @@ def __init__(self, enable_caching=True, logger_name=None): @classmethod def _initialize_shared_resources(cls): """ - Initializes the multiprocessing Manager and shared resources (cache, lock) - in a thread-safe and process-safe manner. + Initializes the multiprocessing Manager and shared resources in a + thread-safe manner. It also registers the shutdown hook to ensure + automatic cleanup on application exit. """ with cls._init_lock: if cls._manager is None: cls._manager = Manager() cls._cache = cls._manager.dict() cls._lock = cls._manager.RLock() + # Register the shutdown method to be called automatically on exit. + atexit.register(cls.shutdown) @classmethod def shutdown(cls): """ - Explicitly shuts down the background manager process. - This MUST be called when the application is finished with the client - to prevent orphaned processes. + Explicitly shuts down the background manager process and de-registers + the atexit hook to prevent errors if called multiple times. + This is useful for test suites or applications that need to precisely + control the resource lifecycle. """ with cls._init_lock: if cls._manager is not None: cls._manager.shutdown() + # De-register the hook to avoid trying to shut down twice. + atexit.unregister(cls.shutdown) cls._manager = None cls._cache = None cls._lock = None From 7be5922d7dbb7a86d044356b7a17475b6d556ef2 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 18:24:43 -0700 Subject: [PATCH 17/39] =?UTF-8?q?Bump=20version:=203.9.23=20=E2=86=92=203.?= =?UTF-8?q?9.24?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- .cookiecutterrc | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- src/geophires_x/__init__.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index e592ecfbf..55713fc5a 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.23 +current_version = 3.9.24 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index a4c1c34c3..27651a889 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.23 + version: 3.9.24 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index fcdbd019c..cf85f9a3a 100644 --- a/README.rst +++ b/README.rst @@ -56,9 +56,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.23.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.24.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.23...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.24...main .. |docs| image:: https://readthedocs.org/projects/GEOPHIRES-X/badge/?style=flat :target: https://nrel.github.io/GEOPHIRES-X diff --git a/docs/conf.py b/docs/conf.py index 0df9de293..4aee1af7b 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.23' +version = release = '3.9.24' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index dab76a25c..d8fcb8bf4 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.23', + version='3.9.24', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index cb63a1c4e..84fb14ccd 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.23' +__version__ = '3.9.24' From 75b55b7c1d3b0770a4f8ba3872031e60a32b86cf Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 21:40:14 -0700 Subject: [PATCH 18/39] Don't initialize shared resources unless caching enabled --- src/geophires_x_client/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/geophires_x_client/__init__.py b/src/geophires_x_client/__init__.py index 82eb40796..c2b303585 100644 --- a/src/geophires_x_client/__init__.py +++ b/src/geophires_x_client/__init__.py @@ -37,7 +37,7 @@ def __init__(self, enable_caching=True, logger_name=None): self._enable_caching = enable_caching # Lazy-initialize shared resources if they haven't been already. - if GeophiresXClient._manager is None: + if enable_caching and GeophiresXClient._manager is None: self._initialize_shared_resources() @classmethod From b97ee906219cc0bc8c7df17ae492f1035bf2410c Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Thu, 19 Jun 2025 21:45:57 -0700 Subject: [PATCH 19/39] mark relevant methods in ImmutableGeophiresInputParameters with @override --- src/geophires_x_client/geophires_input_parameters.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/geophires_x_client/geophires_input_parameters.py b/src/geophires_x_client/geophires_input_parameters.py index 8d054e7dd..24921f05d 100644 --- a/src/geophires_x_client/geophires_input_parameters.py +++ b/src/geophires_x_client/geophires_input_parameters.py @@ -10,6 +10,8 @@ from typing import Optional from typing import Union +from typing_extensions import override + class EndUseOption(Enum): """ @@ -127,6 +129,7 @@ def __post_init__(self): if not isinstance(self.params, MappingProxyType): object.__setattr__(self, 'params', MappingProxyType(self.params)) + @override def __hash__(self) -> int: """ Computes a hash based on the content of the parameters. @@ -181,6 +184,7 @@ def __setstate__(self, state: dict): # Restore the instance's dictionary self.__dict__.update(state) + @override def as_file_path(self) -> Path: """ Creates a temporary file representation of the parameters on demand. @@ -209,6 +213,7 @@ def as_file_path(self) -> Path: object.__setattr__(self, '_cached_file_path', file_path) return file_path + @override def get_output_file_path(self) -> Path: """Returns a unique path for the GEOPHIRES output file.""" return Path(tempfile.gettempdir(), f'geophires-result_{self._instance_id!s}.out') From c6957eada30e57ba2785c27f893a13c268f27152 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 09:55:12 -0700 Subject: [PATCH 20/39] Increase max Number of Multilateral SEctions to 1199 (3 laterals per well when max number of allowed wells) --- src/geophires_x/WellBores.py | 14 ++++++++++++-- .../geophires-request.json | 4 ++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/geophires_x/WellBores.py b/src/geophires_x/WellBores.py index d4f6e51dd..ec25bf926 100644 --- a/src/geophires_x/WellBores.py +++ b/src/geophires_x/WellBores.py @@ -1035,14 +1035,24 @@ def __init__(self, model: Model): ErrMessage="assume default for Non-vertical Wellbore Diameter (0.156 m)", ToolTipText="Non-vertical Wellbore Diameter" ) + + max_allowed_total_wells = max(self.nprod.AllowableRange) + max(self.ninj.AllowableRange) + max_allowed_laterals_per_well_when_max_wells = 3 + """Arbitrary upper limit, could be increased in future if needed""" + + # noinspection SpellCheckingInspection self.numnonverticalsections = self.ParameterDict[self.numnonverticalsections.Name] = intParameter( "Number of Multilateral Sections", DefaultValue=0, - AllowableRange=list(range(0, 101, 1)), + AllowableRange=list(range(0, max_allowed_total_wells * max_allowed_laterals_per_well_when_max_wells, 1)), UnitType=Units.NONE, ErrMessage="assume default for Number of Nonvertical Wellbore Sections (0)", - ToolTipText="Number of Nonvertical Wellbore Sections" + ToolTipText='Number of Nonvertical Wellbore Sections, aka laterals or horizontals. ' + 'Note that this is the total number of sections for the entire project and not the number of ' + 'sections per well. For example, a project with 2 injectors and 2 producers with 3 laterals ' + 'per well should set Number of Multilateral Sections = 2 * 2 * 3 = 12.' ) + self.NonverticalsCased = self.ParameterDict[self.NonverticalsCased.Name] = boolParameter( "Multilaterals Cased", DefaultValue=False, diff --git a/src/geophires_x_schema_generator/geophires-request.json b/src/geophires_x_schema_generator/geophires-request.json index 34ebf8811..a7e62abdc 100644 --- a/src/geophires_x_schema_generator/geophires-request.json +++ b/src/geophires_x_schema_generator/geophires-request.json @@ -947,13 +947,13 @@ "maximum": 100.0 }, "Number of Multilateral Sections": { - "description": "Number of Nonvertical Wellbore Sections", + "description": "Number of Nonvertical Wellbore Sections, aka laterals or horizontals. Note that this is the total number of sections for the entire project and not the number of sections per well. For example, a project with 2 injectors and 2 producers with 3 laterals per well should set Number of Multilateral Sections = 2 * 2 * 3 = 12.", "type": "integer", "units": null, "category": "Well Bores", "default": 0, "minimum": 0, - "maximum": 100 + "maximum": 1199 }, "Multilaterals Cased": { "description": "If set to True, casing & cementing are assumed to comprise 50% of drilling costs (doubling cost compared to uncased).", From efefa76957228470a68ee330436ff6898bd286b3 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 11:27:01 -0700 Subject: [PATCH 21/39] Add 'Drilling and completion costs per well' as derived output (adds to schema) --- src/geophires_x/Economics.py | 24 ++++++++++++++++--- src/geophires_x/Outputs.py | 3 ++- .../geophires-result.json | 6 ++++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index 660ff1ea0..b6be4bd85 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -1633,6 +1633,7 @@ def __init__(self, model: Model): f'Provide {self.ccexplfixed.Name} to override the default correlation and set your own cost.' ) + # noinspection SpellCheckingInspection self.Cwell = self.OutputParameterDict[self.Cwell.Name] = OutputParameter( Name="Wellfield cost", display_name='Drilling and completion costs', @@ -1640,11 +1641,21 @@ def __init__(self, model: Model): PreferredUnits=CurrencyUnit.MDOLLARS, CurrentUnits=CurrencyUnit.MDOLLARS, - # See TODO re:parameterizing indirect costs at src/geophires_x/Economics.py:652 - # (https://github.com/NREL/GEOPHIRES-X/issues/383) + # TODO https://github.com/NREL/GEOPHIRES-X/issues/383?title=Parameterize+indirect+cost+factor ToolTipText="Includes total drilling and completion cost of all injection and production wells and " "laterals, plus 5% indirect costs." ) + self.drilling_and_completion_costs_per_well = self.OutputParameterDict[ + self.drilling_and_completion_costs_per_well.Name] = OutputParameter( + Name='Drilling and completion costs per well', + UnitType=Units.CURRENCY, + PreferredUnits=CurrencyUnit.MDOLLARS, + CurrentUnits=CurrencyUnit.MDOLLARS, + + # TODO https://github.com/NREL/GEOPHIRES-X/issues/383?title=Parameterize+indirect+cost+factor + ToolTipText='Includes total drilling and completion cost per well, ' + 'including injection and production wells and laterals, plus 5% indirect costs.' + ) self.Coamwell = self.OutputParameterDict[self.Coamwell.Name] = OutputParameter( Name="O&M Wellfield cost", display_name='Wellfield maintenance costs', @@ -2313,7 +2324,9 @@ def Calculate(self, model: Model) -> None: else: self.cost_lateral_section.value = 0.0 # cost of the well field - # 1.05 for 5% indirect costs - see TODO re:parameterizing at src/geophires_x/Economics.py:652 + + # 1.05 for 5% indirect costs + # TODO https://github.com/NREL/GEOPHIRES-X/issues/383?title=Parameterize+indirect+cost+factor self.Cwell.value = 1.05 * ((self.cost_one_production_well.value * model.wellbores.nprod.value) + (self.cost_one_injection_well.value * model.wellbores.ninj.value) + self.cost_lateral_section.value) @@ -2988,5 +3001,10 @@ def _calculate_derived_outputs(self, model: Model) -> None: self.real_discount_rate.value = self.discountrate.quantity().to(convertible_unit( self.real_discount_rate.CurrentUnits)).magnitude + self.drilling_and_completion_costs_per_well.value = ( + self.Cwell.value / + (model.wellbores.nprod.value + model.wellbores.ninj.value) + ) + def __str__(self): return "Economics" diff --git a/src/geophires_x/Outputs.py b/src/geophires_x/Outputs.py index ac9f7a2c8..6ae0fb7a9 100644 --- a/src/geophires_x/Outputs.py +++ b/src/geophires_x/Outputs.py @@ -458,7 +458,8 @@ def PrintOutputs(self, model: Model): f.write(f' Drilling and completion costs per production well: {econ.cost_one_production_well.value:10.2f} ' + econ.cost_one_production_well.CurrentUnits.value + NL) f.write(f' Drilling and completion costs per injection well: {econ.cost_one_injection_well.value:10.2f} ' + econ.cost_one_injection_well.CurrentUnits.value + NL) else: - f.write(f' Drilling and completion costs per well: {model.economics.Cwell.value/(model.wellbores.nprod.value+model.wellbores.ninj.value):10.2f} ' + model.economics.Cwell.CurrentUnits.value + NL) + cpw_label = Outputs._field_label(econ.drilling_and_completion_costs_per_well.display_name, 47) + f.write(f' {cpw_label}{econ.drilling_and_completion_costs_per_well.value:10.2f} {econ.Cwell.CurrentUnits.value}\n') f.write(f' {econ.Cstim.display_name}: {econ.Cstim.value:10.2f} {econ.Cstim.CurrentUnits.value}\n') f.write(f' Surface power plant costs: {model.economics.Cplant.value:10.2f} ' + model.economics.Cplant.CurrentUnits.value + NL) if model.surfaceplant.plant_type.value == PlantType.ABSORPTION_CHILLER: diff --git a/src/geophires_x_schema_generator/geophires-result.json b/src/geophires_x_schema_generator/geophires-result.json index 5294e9b96..8fadbe385 100644 --- a/src/geophires_x_schema_generator/geophires-result.json +++ b/src/geophires_x_schema_generator/geophires-result.json @@ -333,7 +333,11 @@ "description": "Wellfield cost. Includes total drilling and completion cost of all injection and production wells and laterals, plus 5% indirect costs.", "units": "MUSD" }, - "Drilling and completion costs per well": {}, + "Drilling and completion costs per well": { + "type": "number", + "description": "Includes total drilling and completion cost per well, including injection and production wells and laterals, plus 5% indirect costs.", + "units": "MUSD" + }, "Drilling and completion costs per production well": {}, "Drilling and completion costs per injection well": {}, "Drilling and completion costs per vertical production well": {}, From 6985fe5a8cff99c60324e709b42eab2de165b75f Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 11:31:17 -0700 Subject: [PATCH 22/39] Set Number of Multilateral Sections = 0 with comment explaining how vertical and horizontal costs are lumped for better result clarity. TODO to investigate using a deviated drilling correlation to possibly 'better' represent costs --- docs/Fervo_Project_Cape-4.md | 12 +- tests/examples/Fervo_Project_Cape-4.out | 144 ++++++++++++------------ tests/examples/Fervo_Project_Cape-4.txt | 7 +- 3 files changed, 81 insertions(+), 82 deletions(-) diff --git a/docs/Fervo_Project_Cape-4.md b/docs/Fervo_Project_Cape-4.md index b6d0d5a2b..fefd97e9d 100644 --- a/docs/Fervo_Project_Cape-4.md +++ b/docs/Fervo_Project_Cape-4.md @@ -8,7 +8,7 @@ Financial results are calculated using the [SAM Single Owner PPA Economic Model](https://softwareengineerprogrammer.github.io/GEOPHIRES/SAM-Economic-Models.html#sam-single-owner-ppa). -Key case study results include LCOE = $76.5/MWh and CAPEX = $4350/kW. +Key case study results include LCOE = $76.3/MWh and CAPEX = $4340/kW. [Click here](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=Fervo_Project_Cape-4) to interactively explore the case study in the GEOPHIRES web interface. @@ -82,19 +82,19 @@ in source code for the complete results. | Metric | Result Value | Reference Value(s) | Reference Source | |------------------------------------|----------------------------------------------------------|--------------------------|---------------------------------------------| -| LCOE | $76.5/MWh | $80/MWh | Horne et al, 2025 | +| LCOE | $76.3/MWh | $80/MWh | Horne et al, 2025 | | Project capital costs: Total CAPEX | $2.67B | | | -| Project capital costs: $/kW | $4350/kW (based on maximum total electricity generation) | $4500/kW, $3000–$6000/kW | Horne et al, 2025; Latimer, 2025. | -| Well Drilling and Completion Cost | $3.96M/well | $<4M/well | Latimer, 2025. | +| Project capital costs: $/kW | $4340/kW (based on maximum total electricity generation) | $4500/kW, $3000–$6000/kW | Horne et al, 2025; Latimer, 2025. | +| Well Drilling and Completion Cost | $3.96M/well (before 5% indirect costs) | $<4M/well | Latimer, 2025. | | WACC | 8.3% | 8.3% | Matson, 2024. | -| After-tax IRR | 30.7% | 15–25% | Typical levered returns for energy projects | +| After-tax IRR | 30.8% | 15–25% | Typical levered returns for energy projects | ### Technical & Engineering Results | Metric | Result Value | Reference Value(s) | Reference Source | |-------------------------------------------------|--------------|----------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Minimum Net Electricity Generation | 503 MW | 500 MW | Fervo Energy, 2025. The 500 MW PPA is interpreted to mean that Cape Station's net electricity generation must never fall below 500 MWe. | -| Maximum Total Electricity Generation | 615 MW | | Actual maximum total generation may be bounded or constrained by modular power plant design not modeled in this case study. For example, a modular design with 50MW units may constrain maximum total generation to 600 MW. | +| Maximum Total Electricity Generation | 614 MW | | Actual maximum total generation may be bounded or constrained by modular power plant design not modeled in this case study. For example, a modular design with 50MW units may constrain maximum total generation to 600 MW. | | Number of times redrilling | 3 | 3–6 | Redrilling expected to be required within 5–10 years of project start | | Average Production Temperature | 199℃ | 204℃, 190.6–198.6℃ (optimal plant operating range) | Trent, 2024; Norbeck et al, 2024. | | Total fracture surface area per production well | 2.787×10⁶ m² | 2.787×10⁶ m² (30 million ft² per well) | Fercho et al, 2025. | diff --git a/tests/examples/Fervo_Project_Cape-4.out b/tests/examples/Fervo_Project_Cape-4.out index dcdb16f0b..f4acfe0ac 100644 --- a/tests/examples/Fervo_Project_Cape-4.out +++ b/tests/examples/Fervo_Project_Cape-4.out @@ -4,17 +4,17 @@ Simulation Metadata ---------------------- - GEOPHIRES Version: 3.9.18 - Simulation Date: 2025-06-18 - Simulation Time: 13:23 - Calculation Time: 1.554 sec + GEOPHIRES Version: 3.9.19 + Simulation Date: 2025-06-20 + Simulation Time: 11:27 + Calculation Time: 1.585 sec ***SUMMARY OF RESULTS*** End-Use Option: Electricity Average Net Electricity Production: 531.71 MW - Electricity breakeven price: 7.65 cents/kWh - Total CAPEX: 2673.11 MUSD + Electricity breakeven price: 7.63 cents/kWh + Total CAPEX: 2665.57 MUSD Number of production wells: 59 Number of injection wells: 59 Flowrate per production well: 107.0 kg/sec @@ -31,11 +31,11 @@ Simulation Metadata Accrued financing during construction: 15.00 % Project lifetime: 30 yr Capacity factor: 90.0 % - Project NPV: 612.72 MUSD - After-tax IRR: 30.66 % - Project VIR=PI=PIR: 1.57 - Project MOIC: 5.55 - Project Payback Period: 2.01 yr + Project NPV: 618.50 MUSD + After-tax IRR: 30.84 % + Project VIR=PI=PIR: 1.58 + Project MOIC: 5.59 + Project Payback Period: 2.00 yr Estimated Jobs Created: 1298 ***ENGINEERING PARAMETERS*** @@ -97,25 +97,23 @@ Simulation Metadata ***CAPITAL COSTS (M$)*** - Drilling and completion costs: 497.69 MUSD - Drilling and completion costs per vertical production well: 3.96 MUSD - Drilling and completion costs per vertical injection well: 3.96 MUSD - Drilling and completion costs per non-vertical section: 2.08 MUSD + Drilling and completion costs: 491.14 MUSD + Drilling and completion costs per well: 4.16 MUSD Stimulation costs: 236.88 MUSD Surface power plant costs: 1503.42 MUSD Field gathering system costs: 56.45 MUSD Total surface equipment costs: 1559.87 MUSD Exploration costs: 30.00 MUSD - Investment Tax Credit: -697.33 MUSD - Total CAPEX: 2673.11 MUSD + Investment Tax Credit: -695.37 MUSD + Total CAPEX: 2665.57 MUSD ***OPERATING AND MAINTENANCE COSTS (M$/yr)*** - Wellfield maintenance costs: 6.50 MUSD/yr + Wellfield maintenance costs: 6.43 MUSD/yr Power plant maintenance costs: 25.42 MUSD/yr Water costs: 24.86 MUSD/yr - Total operating and maintenance costs: 130.24 MUSD/yr + Total operating and maintenance costs: 129.52 MUSD/yr ***SURFACE EQUIPMENT SIMULATION RESULTS*** @@ -225,43 +223,43 @@ PPA price (cents/kWh) 0.0 9.50 9 PPA revenue ($) 0 398,356,471 400,854,971 404,015,457 406,780,894 408,810,043 408,653,832 403,527,928 402,097,301 416,819,877 420,523,180 423,535,733 426,105,735 427,441,971 425,448,518 416,721,913 428,603,740 436,851,817 440,188,887 443,061,909 445,265,887 445,452,800 440,827,914 435,054,417 452,474,970 456,614,321 459,742,311 462,421,669 463,990,994 462,434,870 454,963,130 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 1,336,555,047 -Total revenue ($) 0 398,356,471 400,854,971 404,015,457 406,780,894 408,810,043 408,653,832 403,527,928 402,097,301 416,819,877 420,523,180 423,535,733 426,105,735 427,441,971 425,448,518 416,721,913 428,603,740 436,851,817 440,188,887 443,061,909 445,265,887 445,452,800 440,827,914 435,054,417 452,474,970 456,614,321 459,742,311 462,421,669 463,990,994 462,434,870 1,791,518,178 +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 1,332,786,096 +Total revenue ($) 0 398,356,471 400,854,971 404,015,457 406,780,894 408,810,043 408,653,832 403,527,928 402,097,301 416,819,877 420,523,180 423,535,733 426,105,735 427,441,971 425,448,518 416,721,913 428,603,740 436,851,817 440,188,887 443,061,909 445,265,887 445,452,800 440,827,914 435,054,417 452,474,970 456,614,321 459,742,311 462,421,669 463,990,994 462,434,870 1,787,749,226 -Property tax net assessed value ($) 0 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 2,673,110,095 +Property tax net assessed value ($) 0 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 OPERATING EXPENSES -O&M fixed expense ($) 0 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 +O&M fixed expense ($) 0 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 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 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 130,236,463 +Total operating expenses ($) 0 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 -EBITDA ($) 0 268,120,008 270,618,508 273,778,994 276,544,431 278,573,580 278,417,368 273,291,465 271,860,838 286,583,414 290,286,717 293,299,270 295,869,272 297,205,508 295,212,055 286,485,450 298,367,277 306,615,353 309,952,424 312,825,446 315,029,424 315,216,337 310,591,451 304,817,954 322,238,507 326,377,858 329,505,848 332,185,206 333,754,531 332,198,407 1,661,281,714 +EBITDA ($) 0 268,841,025 271,339,524 274,500,010 277,265,447 279,294,597 279,138,385 274,012,481 272,581,854 287,304,430 291,007,734 294,020,287 296,590,289 297,926,524 295,933,071 287,206,467 299,088,294 307,336,370 310,673,441 313,546,463 315,750,441 315,937,354 311,312,467 305,538,971 322,959,523 327,098,874 330,226,864 332,906,223 334,475,548 332,919,424 1,658,233,780 OPERATING ACTIVITIES -EBITDA ($) 0 268,120,008 270,618,508 273,778,994 276,544,431 278,573,580 278,417,368 273,291,465 271,860,838 286,583,414 290,286,717 293,299,270 295,869,272 297,205,508 295,212,055 286,485,450 298,367,277 306,615,353 309,952,424 312,825,446 315,029,424 315,216,337 310,591,451 304,817,954 322,238,507 326,377,858 329,505,848 332,185,206 333,754,531 332,198,407 1,661,281,714 +EBITDA ($) 0 268,841,025 271,339,524 274,500,010 277,265,447 279,294,597 279,138,385 274,012,481 272,581,854 287,304,430 291,007,734 294,020,287 296,590,289 297,926,524 295,933,071 287,206,467 299,088,294 307,336,370 310,673,441 313,546,463 315,750,441 315,937,354 311,312,467 305,538,971 322,959,523 327,098,874 330,226,864 332,906,223 334,475,548 332,919,424 1,658,233,780 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 89,816,499 88,597,952 87,311,166 85,952,321 84,517,380 83,002,082 81,401,927 79,712,164 77,927,774 76,043,459 74,053,621 71,952,353 69,733,414 67,390,214 64,915,795 62,302,808 59,543,495 56,629,659 53,552,649 50,303,327 46,872,042 43,248,605 39,422,256 35,381,631 31,114,732 26,608,886 21,850,712 16,826,081 11,520,070 5,916,923 -Cash flow from operating activities ($) 0 178,303,509 182,020,556 186,467,827 190,592,110 194,056,200 195,415,287 191,889,537 192,148,674 208,655,639 214,243,258 219,245,649 223,916,919 227,472,094 227,821,841 221,569,655 236,064,468 247,071,859 253,322,765 259,272,797 264,726,098 268,344,295 267,342,846 265,395,698 286,856,875 295,263,126 302,896,962 310,334,494 316,928,450 320,678,336 1,655,364,791 +Debt interest payment ($) 0 89,563,226 88,348,115 87,064,958 85,709,944 84,279,049 82,768,024 81,172,382 79,487,384 77,708,026 75,829,024 73,844,798 71,749,455 69,536,773 67,200,180 64,732,739 62,127,121 59,375,588 56,469,969 53,401,636 50,161,476 46,739,867 43,126,648 39,311,089 35,281,859 31,026,991 26,533,851 21,789,095 16,778,633 11,487,585 5,900,238 +Cash flow from operating activities ($) 0 179,277,799 182,991,410 187,435,053 191,555,504 195,015,548 196,370,361 192,840,099 193,094,471 209,596,404 215,178,710 220,175,489 224,840,834 228,389,752 228,732,891 222,473,728 236,961,173 247,960,782 254,203,471 260,144,826 265,588,965 269,197,487 268,185,819 266,227,882 287,677,665 296,071,883 303,693,013 311,117,128 317,696,915 321,431,839 1,652,333,542 INVESTING ACTIVITIES -Total installed cost ($) -2,673,110,095 +Total installed cost ($) -2,665,572,192 Debt closing costs ($) 0 Debt up-front fee ($) 0 minus: Total IBI income ($) 0 Total CBI income ($) 0 equals: -Purchase of property ($) -2,673,110,095 +Purchase of property ($) -2,665,572,192 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 @@ -273,86 +271,86 @@ Reserve capital spending major equipment 1 ($) 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 ($) -2,673,110,095 0 0 0 0 0 0 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 investing activities ($) -2,665,572,192 0 0 0 0 0 0 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 ($) 1,069,244,038 -Size of debt ($) 1,603,866,057 +Issuance of equity ($) 1,066,228,877 +Size of debt ($) 1,599,343,315 minus: -Debt principal payment ($) 0 21,759,769 22,978,316 24,265,102 25,623,948 27,058,889 28,574,187 30,174,341 31,864,104 33,648,494 35,532,810 37,522,647 39,623,915 41,842,855 44,186,054 46,660,474 49,273,460 52,032,774 54,946,609 58,023,619 61,272,942 64,704,227 68,327,663 72,154,013 76,194,637 80,461,537 84,967,383 89,725,556 94,750,188 100,056,198 105,659,345 +Debt principal payment ($) 0 21,698,409 22,913,520 24,196,677 25,551,691 26,982,586 28,493,610 30,089,253 31,774,251 33,553,609 35,432,611 37,416,837 39,512,180 41,724,862 44,061,454 46,528,896 49,134,514 51,886,047 54,791,665 57,859,998 61,100,158 64,521,767 68,134,986 71,950,545 75,979,776 80,234,643 84,727,783 89,472,539 94,483,002 99,774,050 105,361,396 equals: -Cash flow from financing activities ($) 2,673,110,095 -21,759,769 -22,978,316 -24,265,102 -25,623,948 -27,058,889 -28,574,187 -30,174,341 -31,864,104 -33,648,494 -35,532,810 -37,522,647 -39,623,915 -41,842,855 -44,186,054 -46,660,474 -49,273,460 -52,032,774 -54,946,609 -58,023,619 -61,272,942 -64,704,227 -68,327,663 -72,154,013 -76,194,637 -80,461,537 -84,967,383 -89,725,556 -94,750,188 -100,056,198 -105,659,345 +Cash flow from financing activities ($) 2,665,572,192 -21,698,409 -22,913,520 -24,196,677 -25,551,691 -26,982,586 -28,493,610 -30,089,253 -31,774,251 -33,553,609 -35,432,611 -37,416,837 -39,512,180 -41,724,862 -44,061,454 -46,528,896 -49,134,514 -51,886,047 -54,791,665 -57,859,998 -61,100,158 -64,521,767 -68,134,986 -71,950,545 -75,979,776 -80,234,643 -84,727,783 -89,472,539 -94,483,002 -99,774,050 -105,361,396 PROJECT RETURNS Pre-tax Cash Flow: -Cash flow from operating activities ($) 0 178,303,509 182,020,556 186,467,827 190,592,110 194,056,200 195,415,287 191,889,537 192,148,674 208,655,639 214,243,258 219,245,649 223,916,919 227,472,094 227,821,841 221,569,655 236,064,468 247,071,859 253,322,765 259,272,797 264,726,098 268,344,295 267,342,846 265,395,698 286,856,875 295,263,126 302,896,962 310,334,494 316,928,450 320,678,336 1,655,364,791 -Cash flow from investing activities ($) -2,673,110,095 0 0 0 0 0 0 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 ($) 2,673,110,095 -21,759,769 -22,978,316 -24,265,102 -25,623,948 -27,058,889 -28,574,187 -30,174,341 -31,864,104 -33,648,494 -35,532,810 -37,522,647 -39,623,915 -41,842,855 -44,186,054 -46,660,474 -49,273,460 -52,032,774 -54,946,609 -58,023,619 -61,272,942 -64,704,227 -68,327,663 -72,154,013 -76,194,637 -80,461,537 -84,967,383 -89,725,556 -94,750,188 -100,056,198 -105,659,345 -Total pre-tax cash flow ($) 0 156,543,740 159,042,239 162,202,725 164,968,162 166,997,311 166,841,100 161,715,196 160,284,569 175,007,145 178,710,449 181,723,002 184,293,003 185,629,239 183,635,786 174,909,182 186,791,008 195,039,085 198,376,155 201,249,177 203,453,156 203,640,069 199,015,182 193,241,685 210,662,238 214,801,589 217,929,579 220,608,938 222,178,263 220,622,138 1,549,705,446 +Cash flow from operating activities ($) 0 179,277,799 182,991,410 187,435,053 191,555,504 195,015,548 196,370,361 192,840,099 193,094,471 209,596,404 215,178,710 220,175,489 224,840,834 228,389,752 228,732,891 222,473,728 236,961,173 247,960,782 254,203,471 260,144,826 265,588,965 269,197,487 268,185,819 266,227,882 287,677,665 296,071,883 303,693,013 311,117,128 317,696,915 321,431,839 1,652,333,542 +Cash flow from investing activities ($) -2,665,572,192 0 0 0 0 0 0 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 ($) 2,665,572,192 -21,698,409 -22,913,520 -24,196,677 -25,551,691 -26,982,586 -28,493,610 -30,089,253 -31,774,251 -33,553,609 -35,432,611 -37,416,837 -39,512,180 -41,724,862 -44,061,454 -46,528,896 -49,134,514 -51,886,047 -54,791,665 -57,859,998 -61,100,158 -64,521,767 -68,134,986 -71,950,545 -75,979,776 -80,234,643 -84,727,783 -89,472,539 -94,483,002 -99,774,050 -105,361,396 +Total pre-tax cash flow ($) 0 157,579,390 160,077,890 163,238,376 166,003,813 168,032,962 167,876,751 162,750,847 161,320,220 176,042,796 179,746,099 182,758,652 185,328,654 186,664,890 184,671,437 175,944,832 187,826,659 196,074,736 199,411,806 202,284,828 204,488,806 204,675,719 200,050,833 194,277,336 211,697,889 215,837,240 218,965,230 221,644,588 223,213,913 221,657,789 1,546,972,146 Pre-tax Returns: -Issuance of equity ($) 1,069,244,038 -Total pre-tax cash flow ($) 0 156,543,740 159,042,239 162,202,725 164,968,162 166,997,311 166,841,100 161,715,196 160,284,569 175,007,145 178,710,449 181,723,002 184,293,003 185,629,239 183,635,786 174,909,182 186,791,008 195,039,085 198,376,155 201,249,177 203,453,156 203,640,069 199,015,182 193,241,685 210,662,238 214,801,589 217,929,579 220,608,938 222,178,263 220,622,138 1,549,705,446 -Total pre-tax returns ($) -1,069,244,038 156,543,740 159,042,239 162,202,725 164,968,162 166,997,311 166,841,100 161,715,196 160,284,569 175,007,145 178,710,449 181,723,002 184,293,003 185,629,239 183,635,786 174,909,182 186,791,008 195,039,085 198,376,155 201,249,177 203,453,156 203,640,069 199,015,182 193,241,685 210,662,238 214,801,589 217,929,579 220,608,938 222,178,263 220,622,138 1,549,705,446 +Issuance of equity ($) 1,066,228,877 +Total pre-tax cash flow ($) 0 157,579,390 160,077,890 163,238,376 166,003,813 168,032,962 167,876,751 162,750,847 161,320,220 176,042,796 179,746,099 182,758,652 185,328,654 186,664,890 184,671,437 175,944,832 187,826,659 196,074,736 199,411,806 202,284,828 204,488,806 204,675,719 200,050,833 194,277,336 211,697,889 215,837,240 218,965,230 221,644,588 223,213,913 221,657,789 1,546,972,146 +Total pre-tax returns ($) -1,066,228,877 157,579,390 160,077,890 163,238,376 166,003,813 168,032,962 167,876,751 162,750,847 161,320,220 176,042,796 179,746,099 182,758,652 185,328,654 186,664,890 184,671,437 175,944,832 187,826,659 196,074,736 199,411,806 202,284,828 204,488,806 204,675,719 200,050,833 194,277,336 211,697,889 215,837,240 218,965,230 221,644,588 223,213,913 221,657,789 1,546,972,146 After-tax Returns: -Total pre-tax returns ($) -1,069,244,038 156,543,740 159,042,239 162,202,725 164,968,162 166,997,311 166,841,100 161,715,196 160,284,569 175,007,145 178,710,449 181,723,002 184,293,003 185,629,239 183,635,786 174,909,182 186,791,008 195,039,085 198,376,155 201,249,177 203,453,156 203,640,069 199,015,182 193,241,685 210,662,238 214,801,589 217,929,579 220,608,938 222,178,263 220,622,138 1,549,705,446 -Federal ITC total income ($) 0 801,933,028 0 0 0 0 0 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 pre-tax returns ($) -1,066,228,877 157,579,390 160,077,890 163,238,376 166,003,813 168,032,962 167,876,751 162,750,847 161,320,220 176,042,796 179,746,099 182,758,652 185,328,654 186,664,890 184,671,437 175,944,832 187,826,659 196,074,736 199,411,806 202,284,828 204,488,806 204,675,719 200,050,833 194,277,336 211,697,889 215,837,240 218,965,230 221,644,588 223,213,913 221,657,789 1,546,972,146 +Federal ITC total income ($) 0 799,671,658 0 0 0 0 0 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 -23,728,934 -13,361,132 -14,229,685 -15,035,157 -15,711,694 -15,977,123 -15,288,545 -15,339,154 -18,562,964 -19,654,226 -20,631,193 -21,543,492 -22,237,818 -22,306,123 -21,085,072 -23,915,909 -26,065,652 -27,286,454 -28,448,495 -29,513,525 -41,313,900 -52,212,058 -51,831,780 -56,023,148 -57,664,889 -59,155,777 -60,608,327 -61,896,126 -62,628,479 -323,292,744 +Federal tax benefit (liability) ($) 0 -23,950,496 -13,613,307 -14,481,150 -15,285,874 -15,961,621 -16,226,216 -15,536,756 -15,586,435 -18,809,262 -19,899,486 -20,875,357 -21,786,499 -22,479,603 -22,546,618 -21,324,203 -24,153,601 -26,301,825 -27,521,022 -28,681,369 -29,744,609 -41,511,811 -52,376,690 -51,994,305 -56,183,448 -57,822,839 -59,311,246 -60,761,175 -62,046,208 -62,775,638 -322,700,741 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 -8,504,994 -4,788,936 -5,100,245 -5,388,945 -5,631,431 -5,726,568 -5,479,765 -5,497,905 -6,653,392 -7,044,526 -7,394,693 -7,721,682 -7,970,544 -7,995,026 -7,557,373 -8,572,010 -9,342,528 -9,780,091 -10,196,593 -10,578,324 -14,807,849 -18,713,999 -18,577,699 -20,079,981 -20,668,419 -21,202,787 -21,723,415 -22,184,992 -22,447,484 -115,875,535 -Total after-tax returns ($) -1,069,244,038 926,242,839 140,892,170 142,872,795 144,544,060 145,654,186 145,137,409 140,946,887 139,447,511 149,790,789 152,011,697 153,697,116 155,027,829 155,420,877 153,334,636 146,266,737 154,303,090 159,630,905 161,309,611 162,604,089 163,361,307 147,518,320 128,089,125 122,832,207 134,559,109 136,468,282 137,571,015 138,277,196 138,097,145 135,546,176 1,110,537,167 +State tax benefit (liability) ($) 0 -8,584,407 -4,879,321 -5,190,376 -5,478,808 -5,721,011 -5,815,848 -5,568,730 -5,586,536 -6,741,671 -7,132,432 -7,482,207 -7,808,781 -8,057,205 -8,081,225 -7,643,084 -8,657,205 -9,427,177 -9,864,166 -10,280,061 -10,661,150 -14,878,785 -18,773,007 -18,635,952 -20,137,437 -20,725,032 -21,258,511 -21,778,199 -22,238,784 -22,500,229 -115,663,348 +Total after-tax returns ($) -1,066,228,877 924,716,144 141,585,262 143,566,849 145,239,131 146,350,330 145,834,687 141,645,361 140,147,250 150,491,862 152,714,180 154,401,088 155,733,374 156,128,082 154,043,594 146,977,545 155,015,853 160,345,733 162,026,618 163,323,398 164,083,047 148,285,123 128,901,135 123,647,079 135,377,004 137,289,369 138,395,473 139,105,214 138,928,922 136,381,922 1,108,608,057 -After-tax cumulative IRR (%) NaN -13.37 -0.17 9.73 16.39 20.77 23.68 25.59 26.91 27.91 28.63 29.15 29.54 29.82 30.03 30.17 30.29 30.38 30.45 30.50 30.54 30.57 30.59 30.60 30.61 30.62 30.63 30.63 30.64 30.64 30.66 -After-tax cumulative NPV ($) -1,069,244,038 -260,834,913 -153,510,246 -58,522,284 25,351,382 99,117,119 163,270,187 217,645,265 264,598,067 308,617,272 347,606,124 382,012,228 412,301,287 438,804,091 461,624,797 480,624,234 498,117,711 513,912,899 527,843,647 540,099,738 550,846,462 559,316,379 565,735,149 571,107,423 576,243,900 580,790,538 584,790,831 588,300,142 591,359,021 593,979,443 612,717,451 +After-tax cumulative IRR (%) NaN -13.27 0.01 9.94 16.61 20.99 23.89 25.80 27.12 28.11 28.82 29.35 29.73 30.01 30.21 30.36 30.48 30.57 30.63 30.68 30.72 30.75 30.77 30.78 30.79 30.80 30.81 30.81 30.82 30.82 30.84 +After-tax cumulative NPV ($) -1,066,228,877 -259,152,225 -151,299,597 -55,850,197 28,426,793 102,545,088 167,006,364 221,650,902 268,839,310 313,064,541 352,233,570 386,797,263 417,224,170 443,847,569 466,773,789 485,865,556 503,439,839 519,305,759 533,298,427 545,608,736 556,402,939 564,916,883 571,376,344 576,784,258 581,951,957 586,525,950 590,550,217 594,080,542 597,157,845 599,794,424 618,499,882 AFTER-TAX LCOE AND PPA PRICE -Annual costs ($) -1,069,244,038 527,886,368 -259,962,800 -261,142,662 -262,236,834 -263,155,857 -263,516,423 -262,581,041 -262,649,790 -267,029,088 -268,511,483 -269,838,618 -271,077,906 -272,021,093 -272,113,881 -270,455,176 -274,300,650 -277,220,911 -278,879,276 -280,457,820 -281,904,581 -297,934,481 -312,738,788 -312,222,210 -317,915,861 -320,146,039 -322,171,296 -324,144,473 -325,893,849 -326,888,694 655,574,037 +Annual costs ($) -1,066,228,877 526,359,673 -259,269,709 -260,448,608 -261,541,763 -262,459,713 -262,819,145 -261,882,566 -261,950,051 -266,328,014 -267,809,000 -269,134,645 -270,372,361 -271,313,889 -271,404,924 -269,744,368 -273,587,887 -276,506,084 -278,162,269 -279,738,511 -281,182,840 -297,167,678 -311,926,779 -311,407,338 -317,097,965 -319,324,952 -321,346,837 -323,316,455 -325,062,073 -326,052,948 653,644,927 PPA revenue ($) 0 398,356,471 400,854,971 404,015,457 406,780,894 408,810,043 408,653,832 403,527,928 402,097,301 416,819,877 420,523,180 423,535,733 426,105,735 427,441,971 425,448,518 416,721,913 428,603,740 436,851,817 440,188,887 443,061,909 445,265,887 445,452,800 440,827,914 435,054,417 452,474,970 456,614,321 459,742,311 462,421,669 463,990,994 462,434,870 454,963,130 Electricity to grid (kWh) 0.0 4,193,226,013 4,219,526,008 4,227,429,703 4,231,130,578 4,227,174,470 4,200,800,078 4,123,944,075 4,085,524,292 4,210,727,110 4,223,816,595 4,229,858,515 4,231,437,288 4,220,815,353 4,177,617,023 4,069,152,556 4,162,009,516 4,218,752,453 4,227,707,328 4,232,132,093 4,230,152,834 4,209,135,409 4,143,119,490 4,067,069,431 4,207,503,902 4,223,608,553 4,230,238,414 4,232,692,624 4,225,013,608 4,189,101,096 4,100,244,505 -Present value of annual costs ($) 2,166,141,497 +Present value of annual costs ($) 2,160,359,066 Present value of annual energy nominal (kWh) 28,322,755,320 -LCOE Levelized cost of energy nominal (cents/kWh) 7.65 +LCOE Levelized cost of energy nominal (cents/kWh) 7.63 Present value of PPA revenue ($) 2,778,858,948 Present value of annual energy nominal (kWh) 28,322,755,320 LPPA Levelized PPA price nominal (cents/kWh) 9.81 PROJECT STATE INCOME TAXES -EBITDA ($) 0 268,120,008 270,618,508 273,778,994 276,544,431 278,573,580 278,417,368 273,291,465 271,860,838 286,583,414 290,286,717 293,299,270 295,869,272 297,205,508 295,212,055 286,485,450 298,367,277 306,615,353 309,952,424 312,825,446 315,029,424 315,216,337 310,591,451 304,817,954 322,238,507 326,377,858 329,505,848 332,185,206 333,754,531 332,198,407 1,661,281,714 +EBITDA ($) 0 268,841,025 271,339,524 274,500,010 277,265,447 279,294,597 279,138,385 274,012,481 272,581,854 287,304,430 291,007,734 294,020,287 296,590,289 297,926,524 295,933,071 287,206,467 299,088,294 307,336,370 310,673,441 313,546,463 315,750,441 315,937,354 311,312,467 305,538,971 322,959,523 327,098,874 330,226,864 332,906,223 334,475,548 332,919,424 1,658,233,780 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 89,816,499 88,597,952 87,311,166 85,952,321 84,517,380 83,002,082 81,401,927 79,712,164 77,927,774 76,043,459 74,053,621 71,952,353 69,733,414 67,390,214 64,915,795 62,302,808 59,543,495 56,629,659 53,552,649 50,303,327 46,872,042 43,248,605 39,422,256 35,381,631 31,114,732 26,608,886 21,850,712 16,826,081 11,520,070 5,916,923 -Total state tax depreciation ($) 0 56,803,590 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 56,803,590 0 0 0 0 0 0 0 0 0 +Debt interest payment ($) 0 89,563,226 88,348,115 87,064,958 85,709,944 84,279,049 82,768,024 81,172,382 79,487,384 77,708,026 75,829,024 73,844,798 71,749,455 69,536,773 67,200,180 64,732,739 62,127,121 59,375,588 56,469,969 53,401,636 50,161,476 46,739,867 43,126,648 39,311,089 35,281,859 31,026,991 26,533,851 21,789,095 16,778,633 11,487,585 5,900,238 +Total state tax depreciation ($) 0 56,643,409 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 56,643,409 0 0 0 0 0 0 0 0 0 equals: -State taxable income ($) 0 121,499,919 68,413,377 72,860,648 76,984,931 80,449,021 81,808,108 78,282,358 78,541,495 95,048,460 100,636,079 105,638,470 110,309,740 113,864,915 114,214,662 107,962,476 122,457,289 133,464,680 139,715,586 145,665,618 151,118,919 211,540,706 267,342,846 265,395,698 286,856,875 295,263,126 302,896,962 310,334,494 316,928,450 320,678,336 1,655,364,791 +State taxable income ($) 0 122,634,390 69,704,592 74,148,235 78,268,685 81,728,729 83,083,543 79,553,281 79,807,652 96,309,586 101,891,892 106,888,671 111,554,016 115,102,934 115,446,073 109,186,910 123,674,355 134,673,964 140,916,653 146,858,008 152,302,147 212,554,078 268,185,819 266,227,882 287,677,665 296,071,883 303,693,013 311,117,128 317,696,915 321,431,839 1,652,333,542 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 -8,504,994 -4,788,936 -5,100,245 -5,388,945 -5,631,431 -5,726,568 -5,479,765 -5,497,905 -6,653,392 -7,044,526 -7,394,693 -7,721,682 -7,970,544 -7,995,026 -7,557,373 -8,572,010 -9,342,528 -9,780,091 -10,196,593 -10,578,324 -14,807,849 -18,713,999 -18,577,699 -20,079,981 -20,668,419 -21,202,787 -21,723,415 -22,184,992 -22,447,484 -115,875,535 +State tax benefit (liability) ($) 0 -8,584,407 -4,879,321 -5,190,376 -5,478,808 -5,721,011 -5,815,848 -5,568,730 -5,586,536 -6,741,671 -7,132,432 -7,482,207 -7,808,781 -8,057,205 -8,081,225 -7,643,084 -8,657,205 -9,427,177 -9,864,166 -10,280,061 -10,661,150 -14,878,785 -18,773,007 -18,635,952 -20,137,437 -20,725,032 -21,258,511 -21,778,199 -22,238,784 -22,500,229 -115,663,348 PROJECT FEDERAL INCOME TAXES -EBITDA ($) 0 268,120,008 270,618,508 273,778,994 276,544,431 278,573,580 278,417,368 273,291,465 271,860,838 286,583,414 290,286,717 293,299,270 295,869,272 297,205,508 295,212,055 286,485,450 298,367,277 306,615,353 309,952,424 312,825,446 315,029,424 315,216,337 310,591,451 304,817,954 322,238,507 326,377,858 329,505,848 332,185,206 333,754,531 332,198,407 1,661,281,714 +EBITDA ($) 0 268,841,025 271,339,524 274,500,010 277,265,447 279,294,597 279,138,385 274,012,481 272,581,854 287,304,430 291,007,734 294,020,287 296,590,289 297,926,524 295,933,071 287,206,467 299,088,294 307,336,370 310,673,441 313,546,463 315,750,441 315,937,354 311,312,467 305,538,971 322,959,523 327,098,874 330,226,864 332,906,223 334,475,548 332,919,424 1,658,233,780 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 -8,504,994 -4,788,936 -5,100,245 -5,388,945 -5,631,431 -5,726,568 -5,479,765 -5,497,905 -6,653,392 -7,044,526 -7,394,693 -7,721,682 -7,970,544 -7,995,026 -7,557,373 -8,572,010 -9,342,528 -9,780,091 -10,196,593 -10,578,324 -14,807,849 -18,713,999 -18,577,699 -20,079,981 -20,668,419 -21,202,787 -21,723,415 -22,184,992 -22,447,484 -115,875,535 +State tax benefit (liability) ($) 0 -8,584,407 -4,879,321 -5,190,376 -5,478,808 -5,721,011 -5,815,848 -5,568,730 -5,586,536 -6,741,671 -7,132,432 -7,482,207 -7,808,781 -8,057,205 -8,081,225 -7,643,084 -8,657,205 -9,427,177 -9,864,166 -10,280,061 -10,661,150 -14,878,785 -18,773,007 -18,635,952 -20,137,437 -20,725,032 -21,258,511 -21,778,199 -22,238,784 -22,500,229 -115,663,348 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 89,816,499 88,597,952 87,311,166 85,952,321 84,517,380 83,002,082 81,401,927 79,712,164 77,927,774 76,043,459 74,053,621 71,952,353 69,733,414 67,390,214 64,915,795 62,302,808 59,543,495 56,629,659 53,552,649 50,303,327 46,872,042 43,248,605 39,422,256 35,381,631 31,114,732 26,608,886 21,850,712 16,826,081 11,520,070 5,916,923 -Total federal tax depreciation ($) 0 56,803,590 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 113,607,179 56,803,590 0 0 0 0 0 0 0 0 0 +Debt interest payment ($) 0 89,563,226 88,348,115 87,064,958 85,709,944 84,279,049 82,768,024 81,172,382 79,487,384 77,708,026 75,829,024 73,844,798 71,749,455 69,536,773 67,200,180 64,732,739 62,127,121 59,375,588 56,469,969 53,401,636 50,161,476 46,739,867 43,126,648 39,311,089 35,281,859 31,026,991 26,533,851 21,789,095 16,778,633 11,487,585 5,900,238 +Total federal tax depreciation ($) 0 56,643,409 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 56,643,409 0 0 0 0 0 0 0 0 0 equals: -Federal taxable income ($) 0 112,994,925 63,624,440 67,760,403 71,595,986 74,817,590 76,081,540 72,802,593 73,043,590 88,395,068 93,591,554 98,243,777 102,588,058 105,894,371 106,219,635 100,405,103 113,885,279 124,122,152 129,935,495 135,469,024 140,540,594 196,732,857 248,628,846 246,817,999 266,776,894 274,594,707 281,694,175 288,611,079 294,743,459 298,230,853 1,539,489,256 +Federal taxable income ($) 0 114,049,983 64,825,270 68,957,858 72,789,877 76,007,718 77,267,695 73,984,551 74,221,117 89,567,915 94,759,459 99,406,464 103,745,235 107,045,728 107,364,848 101,543,826 115,017,150 125,246,786 131,052,487 136,577,948 141,640,996 197,675,292 249,412,812 247,591,930 267,540,228 275,346,851 282,434,502 289,338,929 295,458,131 298,931,610 1,536,670,194 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 -23,728,934 -13,361,132 -14,229,685 -15,035,157 -15,711,694 -15,977,123 -15,288,545 -15,339,154 -18,562,964 -19,654,226 -20,631,193 -21,543,492 -22,237,818 -22,306,123 -21,085,072 -23,915,909 -26,065,652 -27,286,454 -28,448,495 -29,513,525 -41,313,900 -52,212,058 -51,831,780 -56,023,148 -57,664,889 -59,155,777 -60,608,327 -61,896,126 -62,628,479 -323,292,744 +Federal tax benefit (liability) ($) 0 -23,950,496 -13,613,307 -14,481,150 -15,285,874 -15,961,621 -16,226,216 -15,536,756 -15,586,435 -18,809,262 -19,899,486 -20,875,357 -21,786,499 -22,479,603 -22,546,618 -21,324,203 -24,153,601 -26,301,825 -27,521,022 -28,681,369 -29,744,609 -41,511,811 -52,376,690 -51,994,305 -56,183,448 -57,822,839 -59,311,246 -60,761,175 -62,046,208 -62,775,638 -322,700,741 CASH INCENTIVES Federal IBI income ($) 0 @@ -378,30 +376,30 @@ Federal PTC income ($) 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 801,933,028 0 0 0 0 0 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 801,933,028 0 0 0 0 0 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 799,671,658 0 0 0 0 0 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 799,671,658 0 0 0 0 0 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 ($) 1,603,866,057 1,582,106,287 1,559,127,971 1,534,862,869 1,509,238,921 1,482,180,032 1,453,605,845 1,423,431,504 1,391,567,400 1,357,918,906 1,322,386,096 1,284,863,449 1,245,239,534 1,203,396,679 1,159,210,625 1,112,550,151 1,063,276,691 1,011,243,917 956,297,308 898,273,689 837,000,747 772,296,520 703,968,857 631,814,844 555,620,207 475,158,670 390,191,287 300,465,731 205,715,543 105,659,345 0 -Debt interest payment ($) 0 89,816,499 88,597,952 87,311,166 85,952,321 84,517,380 83,002,082 81,401,927 79,712,164 77,927,774 76,043,459 74,053,621 71,952,353 69,733,414 67,390,214 64,915,795 62,302,808 59,543,495 56,629,659 53,552,649 50,303,327 46,872,042 43,248,605 39,422,256 35,381,631 31,114,732 26,608,886 21,850,712 16,826,081 11,520,070 5,916,923 -Debt principal payment ($) 0 21,759,769 22,978,316 24,265,102 25,623,948 27,058,889 28,574,187 30,174,341 31,864,104 33,648,494 35,532,810 37,522,647 39,623,915 41,842,855 44,186,054 46,660,474 49,273,460 52,032,774 54,946,609 58,023,619 61,272,942 64,704,227 68,327,663 72,154,013 76,194,637 80,461,537 84,967,383 89,725,556 94,750,188 100,056,198 105,659,345 -Debt total payment ($) 0 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 +Debt balance ($) 1,599,343,315 1,577,644,907 1,554,731,387 1,530,534,710 1,504,983,019 1,478,000,433 1,449,506,823 1,419,417,570 1,387,643,320 1,354,089,711 1,318,657,100 1,281,240,263 1,241,728,083 1,200,003,221 1,155,941,767 1,109,412,871 1,060,278,357 1,008,392,311 953,600,646 895,740,647 834,640,489 770,118,722 701,983,735 630,033,190 554,053,414 473,818,770 389,090,987 299,618,448 205,135,446 105,361,396 0 +Debt interest payment ($) 0 89,563,226 88,348,115 87,064,958 85,709,944 84,279,049 82,768,024 81,172,382 79,487,384 77,708,026 75,829,024 73,844,798 71,749,455 69,536,773 67,200,180 64,732,739 62,127,121 59,375,588 56,469,969 53,401,636 50,161,476 46,739,867 43,126,648 39,311,089 35,281,859 31,026,991 26,533,851 21,789,095 16,778,633 11,487,585 5,900,238 +Debt principal payment ($) 0 21,698,409 22,913,520 24,196,677 25,551,691 26,982,586 28,493,610 30,089,253 31,774,251 33,553,609 35,432,611 37,416,837 39,512,180 41,724,862 44,061,454 46,528,896 49,134,514 51,886,047 54,791,665 57,859,998 61,100,158 64,521,767 68,134,986 71,950,545 75,979,776 80,234,643 84,727,783 89,472,539 94,483,002 99,774,050 105,361,396 +Debt total payment ($) 0 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 DSCR (DEBT FRACTION) -EBITDA ($) 0 268,120,008 270,618,508 273,778,994 276,544,431 278,573,580 278,417,368 273,291,465 271,860,838 286,583,414 290,286,717 293,299,270 295,869,272 297,205,508 295,212,055 286,485,450 298,367,277 306,615,353 309,952,424 312,825,446 315,029,424 315,216,337 310,591,451 304,817,954 322,238,507 326,377,858 329,505,848 332,185,206 333,754,531 332,198,407 1,661,281,714 +EBITDA ($) 0 268,841,025 271,339,524 274,500,010 277,265,447 279,294,597 279,138,385 274,012,481 272,581,854 287,304,430 291,007,734 294,020,287 296,590,289 297,926,524 295,933,071 287,206,467 299,088,294 307,336,370 310,673,441 313,546,463 315,750,441 315,937,354 311,312,467 305,538,971 322,959,523 327,098,874 330,226,864 332,906,223 334,475,548 332,919,424 1,658,233,780 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 268,120,008 270,618,508 273,778,994 276,544,431 278,573,580 278,417,368 273,291,465 271,860,838 286,583,414 290,286,717 293,299,270 295,869,272 297,205,508 295,212,055 286,485,450 298,367,277 306,615,353 309,952,424 312,825,446 315,029,424 315,216,337 310,591,451 304,817,954 322,238,507 326,377,858 329,505,848 332,185,206 333,754,531 332,198,407 1,661,281,714 -Debt total payment ($) 0 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 111,576,269 -DSCR (pre-tax) 0.0 2.40 2.43 2.45 2.48 2.50 2.50 2.45 2.44 2.57 2.60 2.63 2.65 2.66 2.65 2.57 2.67 2.75 2.78 2.80 2.82 2.83 2.78 2.73 2.89 2.93 2.95 2.98 2.99 2.98 14.89 +Cash available for debt service (CAFDS) ($) 0 268,841,025 271,339,524 274,500,010 277,265,447 279,294,597 279,138,385 274,012,481 272,581,854 287,304,430 291,007,734 294,020,287 296,590,289 297,926,524 295,933,071 287,206,467 299,088,294 307,336,370 310,673,441 313,546,463 315,750,441 315,937,354 311,312,467 305,538,971 322,959,523 327,098,874 330,226,864 332,906,223 334,475,548 332,919,424 1,658,233,780 +Debt total payment ($) 0 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 +DSCR (pre-tax) 0.0 2.42 2.44 2.47 2.49 2.51 2.51 2.46 2.45 2.58 2.62 2.64 2.67 2.68 2.66 2.58 2.69 2.76 2.79 2.82 2.84 2.84 2.80 2.75 2.90 2.94 2.97 2.99 3.01 2.99 14.90 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 diff --git a/tests/examples/Fervo_Project_Cape-4.txt b/tests/examples/Fervo_Project_Cape-4.txt index 9bfbb44d6..067f20a1c 100644 --- a/tests/examples/Fervo_Project_Cape-4.txt +++ b/tests/examples/Fervo_Project_Cape-4.txt @@ -74,9 +74,10 @@ Utilization Factor, .9 Plant Outlet Pressure, 1000 psi, -- https://doi.org/10.31223/X5VH8C Production Wellhead Pressure, 325 psi, -- https://doi.org/10.31223/X5VH8C Circulation Pump Efficiency, 0.80 -Well Geometry Configuration, 4 -Number of Multilateral Sections, 3 -Nonvertical Length per Multilateral Section, 4700 feet + +Well Geometry Configuration, 4, -- L +Number of Multilateral Sections, 0, -- This parameter is set to 0 because, for this case study, the cost of horizontal drilling (which would otherwise account for approximately 118 multilateral sections) is included within the 'vertical drilling cost.' This approach allows us to more directly convey the overall well drilling and completion cost, which is under $4 million. +Nonvertical Length per Multilateral Section, 4700 feet, -- Deployment of Enhanced Geothermal System Technology Leads to Rapid Cost Reductions and Performance Improvements. p. 3. https://doi.org/10.31223/X5VH8C Multilaterals Cased, True # *** SIMULATION PARAMETERS *** From 8c133ac609152bca9878f176e1333f0c08d94cdd Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 11:43:10 -0700 Subject: [PATCH 23/39] Cost adjustment factor so well cost = .96M including 5% indirect costs --- docs/Fervo_Project_Cape-4.md | 40 +++---- tests/examples/Fervo_Project_Cape-4.out | 138 ++++++++++++------------ tests/examples/Fervo_Project_Cape-4.txt | 2 +- 3 files changed, 90 insertions(+), 90 deletions(-) diff --git a/docs/Fervo_Project_Cape-4.md b/docs/Fervo_Project_Cape-4.md index fefd97e9d..7d84f1b52 100644 --- a/docs/Fervo_Project_Cape-4.md +++ b/docs/Fervo_Project_Cape-4.md @@ -8,7 +8,7 @@ Financial results are calculated using the [SAM Single Owner PPA Economic Model](https://softwareengineerprogrammer.github.io/GEOPHIRES/SAM-Economic-Models.html#sam-single-owner-ppa). -Key case study results include LCOE = $76.3/MWh and CAPEX = $4340/kW. +Key case study results include LCOE = $75.5/MWh and CAPEX = $4300/kW. [Click here](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=Fervo_Project_Cape-4) to interactively explore the case study in the GEOPHIRES web interface. @@ -41,20 +41,20 @@ in source code for the full set of inputs. ### Economic Parameters -| Parameter | Input Value(s) | Source | -|-----------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Economic Model | SAM Single Owner PPA | The SAM Single Owner PPA economic model is used to calculate financial results including LCOE, NPV, IRR, and pro-forma cash flow analysis. See [GEOPHIRES documentation of SAM Economic Models](https://softwareengineerprogrammer.github.io/GEOPHIRES/SAM-Economic-Models.html) for details on how System Advisor Model financial models are integrated into GEOPHIRES. | -| Inflation Rate | 2.3% | US inflation rate as of April 2025 | -| PPA Price | Starting at 9.5 cents/kWh, escalating to 10 cents/kWh by project year 11 | Upper end of ranges given in 2024 NREL ATB (NREL, 2024). Both PPAs 'firm for 10 years at less than $100/MWh' estimate given in a podcast. | -| Well Drilling Cost Correlation & Adjustment Factor | Vertical large baseline correlation + adjustment factor = 0.84 to align with Fervo claimed drilling costs of <$4M/well | Akindipe & Witter, 2025; Latimer, 2025. | -| Reservoir Stimulation Capital Cost Adjustment Factor | 2.66 | Estimated cost of ~$2M per well. Typical range for Nth-of-kind projects may be $0.5–2M. | -| Capital Cost for Power Plant for Electricity Generation | $1900/kW | US DOE, 2021. | -| Discount Rate | 12% | Typical discount rates for high-risk projects may be 12–15% | -| Inflated Bond Interest Rate | 5.6% | Typical debt annual interest rate | -| Fraction of Investment in Bonds (percent debt vs. equity) | 60% | Approximate remaining percentage of CAPEX with $1 billion sponsor equity per Matson, 2024. Note that this source says that Fervo ultimately wants to target "15% sponsor equity, 15% bridge loan, and 70% construction to term loans", but this case study does not attempt to model that capital structure. | -| Exploration Capital Cost | $30M | Estimate significantly higher exploration costs than default correlation in consideration of potential risks associated with second/third/fourth-of-a-kind EGS projects | -| Investment Tax Credit Rate (ITC) | 30% | Same as 400 MWe case study (Fervo_Project_Cape-3) | -| Inflation Rate During Construction (additional indirect capital cost) | 15% | Estimate high indirect capital costs in consideration of potential risks associated with unforeseen engineering challenges or construction delays | +| Parameter | Input Value(s) | Source | +|-----------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Economic Model | SAM Single Owner PPA | The SAM Single Owner PPA economic model is used to calculate financial results including LCOE, NPV, IRR, and pro-forma cash flow analysis. See [GEOPHIRES documentation of SAM Economic Models](https://softwareengineerprogrammer.github.io/GEOPHIRES/SAM-Economic-Models.html) for details on how System Advisor Model financial models are integrated into GEOPHIRES. | +| Inflation Rate | 2.3% | US inflation rate as of April 2025 | +| PPA Price | Starting at 9.5 cents/kWh, escalating to 10 cents/kWh by project year 11 | Upper end of ranges given in 2024 NREL ATB (NREL, 2024). Both PPAs 'firm for 10 years at less than $100/MWh' estimate given in a podcast. | +| Well Drilling Cost Correlation & Adjustment Factor | Vertical large baseline correlation + adjustment factor = 0.8 to align with Fervo claimed drilling costs of <$4M/well | Akindipe & Witter, 2025; Latimer, 2025. | +| Reservoir Stimulation Capital Cost Adjustment Factor | 2.66 | Estimated cost of ~$2M per well. Typical range for Nth-of-kind projects may be $0.5–2M. | +| Capital Cost for Power Plant for Electricity Generation | $1900/kW | US DOE, 2021. | +| Discount Rate | 12% | Typical discount rates for high-risk projects may be 12–15% | +| Inflated Bond Interest Rate | 5.6% | Typical debt annual interest rate | +| Fraction of Investment in Bonds (percent debt vs. equity) | 60% | Approximate remaining percentage of CAPEX with $1 billion sponsor equity per Matson, 2024. Note that this source says that Fervo ultimately wants to target "15% sponsor equity, 15% bridge loan, and 70% construction to term loans", but this case study does not attempt to model that capital structure. | +| Exploration Capital Cost | $30M | Estimate significantly higher exploration costs than default correlation in consideration of potential risks associated with second/third/fourth-of-a-kind EGS projects | +| Investment Tax Credit Rate (ITC) | 30% | Same as 400 MWe case study (Fervo_Project_Cape-3) | +| Inflation Rate During Construction (additional indirect capital cost) | 15% | Estimate high indirect capital costs in consideration of potential risks associated with unforeseen engineering challenges or construction delays | ### Technical & Engineering Parameters @@ -82,12 +82,12 @@ in source code for the complete results. | Metric | Result Value | Reference Value(s) | Reference Source | |------------------------------------|----------------------------------------------------------|--------------------------|---------------------------------------------| -| LCOE | $76.3/MWh | $80/MWh | Horne et al, 2025 | -| Project capital costs: Total CAPEX | $2.67B | | | -| Project capital costs: $/kW | $4340/kW (based on maximum total electricity generation) | $4500/kW, $3000–$6000/kW | Horne et al, 2025; Latimer, 2025. | -| Well Drilling and Completion Cost | $3.96M/well (before 5% indirect costs) | $<4M/well | Latimer, 2025. | +| LCOE | $75.5/MWh | $80/MWh | Horne et al, 2025 | +| Project capital costs: Total CAPEX | $2.64B | | | +| Project capital costs: $/kW | $4300/kW (based on maximum total electricity generation) | $4500/kW, $3000–$6000/kW | Horne et al, 2025; Latimer, 2025. | +| Well Drilling and Completion Cost | $3.96M/well (including 5% indirect costs) | $<4M/well | Latimer, 2025. | | WACC | 8.3% | 8.3% | Matson, 2024. | -| After-tax IRR | 30.8% | 15–25% | Typical levered returns for energy projects | +| After-tax IRR | 31.5% | 15–25% | Typical levered returns for energy projects | ### Technical & Engineering Results diff --git a/tests/examples/Fervo_Project_Cape-4.out b/tests/examples/Fervo_Project_Cape-4.out index f4acfe0ac..860f26f88 100644 --- a/tests/examples/Fervo_Project_Cape-4.out +++ b/tests/examples/Fervo_Project_Cape-4.out @@ -6,15 +6,15 @@ Simulation Metadata ---------------------- GEOPHIRES Version: 3.9.19 Simulation Date: 2025-06-20 - Simulation Time: 11:27 - Calculation Time: 1.585 sec + Simulation Time: 11:37 + Calculation Time: 1.572 sec ***SUMMARY OF RESULTS*** End-Use Option: Electricity Average Net Electricity Production: 531.71 MW - Electricity breakeven price: 7.63 cents/kWh - Total CAPEX: 2665.57 MUSD + Electricity breakeven price: 7.55 cents/kWh + Total CAPEX: 2638.68 MUSD Number of production wells: 59 Number of injection wells: 59 Flowrate per production well: 107.0 kg/sec @@ -31,11 +31,11 @@ Simulation Metadata Accrued financing during construction: 15.00 % Project lifetime: 30 yr Capacity factor: 90.0 % - Project NPV: 618.50 MUSD - After-tax IRR: 30.84 % - Project VIR=PI=PIR: 1.58 - Project MOIC: 5.59 - Project Payback Period: 2.00 yr + Project NPV: 639.13 MUSD + After-tax IRR: 31.48 % + Project VIR=PI=PIR: 1.61 + Project MOIC: 5.75 + Project Payback Period: 1.95 yr Estimated Jobs Created: 1298 ***ENGINEERING PARAMETERS*** @@ -97,23 +97,23 @@ Simulation Metadata ***CAPITAL COSTS (M$)*** - Drilling and completion costs: 491.14 MUSD - Drilling and completion costs per well: 4.16 MUSD + Drilling and completion costs: 467.75 MUSD + Drilling and completion costs per well: 3.96 MUSD Stimulation costs: 236.88 MUSD Surface power plant costs: 1503.42 MUSD Field gathering system costs: 56.45 MUSD Total surface equipment costs: 1559.87 MUSD Exploration costs: 30.00 MUSD - Investment Tax Credit: -695.37 MUSD - Total CAPEX: 2665.57 MUSD + Investment Tax Credit: -688.35 MUSD + Total CAPEX: 2638.68 MUSD ***OPERATING AND MAINTENANCE COSTS (M$/yr)*** - Wellfield maintenance costs: 6.43 MUSD/yr + Wellfield maintenance costs: 6.20 MUSD/yr Power plant maintenance costs: 25.42 MUSD/yr Water costs: 24.86 MUSD/yr - Total operating and maintenance costs: 129.52 MUSD/yr + Total operating and maintenance costs: 126.94 MUSD/yr ***SURFACE EQUIPMENT SIMULATION RESULTS*** @@ -223,43 +223,43 @@ PPA price (cents/kWh) 0.0 9.50 9 PPA revenue ($) 0 398,356,471 400,854,971 404,015,457 406,780,894 408,810,043 408,653,832 403,527,928 402,097,301 416,819,877 420,523,180 423,535,733 426,105,735 427,441,971 425,448,518 416,721,913 428,603,740 436,851,817 440,188,887 443,061,909 445,265,887 445,452,800 440,827,914 435,054,417 452,474,970 456,614,321 459,742,311 462,421,669 463,990,994 462,434,870 454,963,130 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 1,332,786,096 -Total revenue ($) 0 398,356,471 400,854,971 404,015,457 406,780,894 408,810,043 408,653,832 403,527,928 402,097,301 416,819,877 420,523,180 423,535,733 426,105,735 427,441,971 425,448,518 416,721,913 428,603,740 436,851,817 440,188,887 443,061,909 445,265,887 445,452,800 440,827,914 435,054,417 452,474,970 456,614,321 459,742,311 462,421,669 463,990,994 462,434,870 1,787,749,226 +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 1,319,338,295 +Total revenue ($) 0 398,356,471 400,854,971 404,015,457 406,780,894 408,810,043 408,653,832 403,527,928 402,097,301 416,819,877 420,523,180 423,535,733 426,105,735 427,441,971 425,448,518 416,721,913 428,603,740 436,851,817 440,188,887 443,061,909 445,265,887 445,452,800 440,827,914 435,054,417 452,474,970 456,614,321 459,742,311 462,421,669 463,990,994 462,434,870 1,774,301,426 -Property tax net assessed value ($) 0 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 2,665,572,192 +Property tax net assessed value ($) 0 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 OPERATING EXPENSES -O&M fixed expense ($) 0 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 +O&M fixed expense ($) 0 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 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 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 129,515,446 +Total operating expenses ($) 0 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 -EBITDA ($) 0 268,841,025 271,339,524 274,500,010 277,265,447 279,294,597 279,138,385 274,012,481 272,581,854 287,304,430 291,007,734 294,020,287 296,590,289 297,926,524 295,933,071 287,206,467 299,088,294 307,336,370 310,673,441 313,546,463 315,750,441 315,937,354 311,312,467 305,538,971 322,959,523 327,098,874 330,226,864 332,906,223 334,475,548 332,919,424 1,658,233,780 +EBITDA ($) 0 271,413,648 273,912,147 277,072,633 279,838,070 281,867,219 281,711,008 276,585,104 275,154,477 289,877,053 293,580,357 296,592,910 299,162,911 300,499,147 298,505,694 289,779,090 301,660,916 309,908,993 313,246,063 316,119,085 318,323,064 318,509,977 313,885,090 308,111,593 325,532,146 329,671,497 332,799,487 335,478,846 337,048,171 335,492,046 1,647,358,602 OPERATING ACTIVITIES -EBITDA ($) 0 268,841,025 271,339,524 274,500,010 277,265,447 279,294,597 279,138,385 274,012,481 272,581,854 287,304,430 291,007,734 294,020,287 296,590,289 297,926,524 295,933,071 287,206,467 299,088,294 307,336,370 310,673,441 313,546,463 315,750,441 315,937,354 311,312,467 305,538,971 322,959,523 327,098,874 330,226,864 332,906,223 334,475,548 332,919,424 1,658,233,780 +EBITDA ($) 0 271,413,648 273,912,147 277,072,633 279,838,070 281,867,219 281,711,008 276,585,104 275,154,477 289,877,053 293,580,357 296,592,910 299,162,911 300,499,147 298,505,694 289,779,090 301,660,916 309,908,993 313,246,063 316,119,085 318,323,064 318,509,977 313,885,090 308,111,593 325,532,146 329,671,497 332,799,487 335,478,846 337,048,171 335,492,046 1,647,358,602 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 89,563,226 88,348,115 87,064,958 85,709,944 84,279,049 82,768,024 81,172,382 79,487,384 77,708,026 75,829,024 73,844,798 71,749,455 69,536,773 67,200,180 64,732,739 62,127,121 59,375,588 56,469,969 53,401,636 50,161,476 46,739,867 43,126,648 39,311,089 35,281,859 31,026,991 26,533,851 21,789,095 16,778,633 11,487,585 5,900,238 -Cash flow from operating activities ($) 0 179,277,799 182,991,410 187,435,053 191,555,504 195,015,548 196,370,361 192,840,099 193,094,471 209,596,404 215,178,710 220,175,489 224,840,834 228,389,752 228,732,891 222,473,728 236,961,173 247,960,782 254,203,471 260,144,826 265,588,965 269,197,487 268,185,819 266,227,882 287,677,665 296,071,883 303,693,013 311,117,128 317,696,915 321,431,839 1,652,333,542 +Debt interest payment ($) 0 88,659,533 87,456,683 86,186,473 84,845,131 83,428,674 81,932,896 80,353,353 78,685,357 76,923,953 75,063,910 73,099,704 71,025,503 68,835,147 66,522,131 64,079,586 61,500,259 58,776,489 55,900,188 52,862,814 49,655,347 46,268,262 42,691,501 38,914,441 34,925,865 30,713,929 26,266,125 21,569,243 16,609,337 11,371,675 5,840,705 +Cash flow from operating activities ($) 0 182,754,114 186,455,464 190,886,160 194,992,939 198,438,545 199,778,112 196,231,751 196,469,120 212,953,101 218,516,447 223,493,205 228,137,408 231,664,000 231,983,563 225,699,504 240,160,658 251,132,504 257,345,876 263,256,271 268,667,716 272,241,714 271,193,589 269,197,153 290,606,281 298,957,568 306,533,362 313,909,602 320,438,834 324,120,371 1,641,517,897 INVESTING ACTIVITIES -Total installed cost ($) -2,665,572,192 +Total installed cost ($) -2,638,676,591 Debt closing costs ($) 0 Debt up-front fee ($) 0 minus: Total IBI income ($) 0 Total CBI income ($) 0 equals: -Purchase of property ($) -2,665,572,192 +Purchase of property ($) -2,638,676,591 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 @@ -271,86 +271,86 @@ Reserve capital spending major equipment 1 ($) 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 ($) -2,665,572,192 0 0 0 0 0 0 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 investing activities ($) -2,638,676,591 0 0 0 0 0 0 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 ($) 1,066,228,877 -Size of debt ($) 1,599,343,315 +Issuance of equity ($) 1,055,470,636 +Size of debt ($) 1,583,205,954 minus: -Debt principal payment ($) 0 21,698,409 22,913,520 24,196,677 25,551,691 26,982,586 28,493,610 30,089,253 31,774,251 33,553,609 35,432,611 37,416,837 39,512,180 41,724,862 44,061,454 46,528,896 49,134,514 51,886,047 54,791,665 57,859,998 61,100,158 64,521,767 68,134,986 71,950,545 75,979,776 80,234,643 84,727,783 89,472,539 94,483,002 99,774,050 105,361,396 +Debt principal payment ($) 0 21,479,472 22,682,323 23,952,533 25,293,875 26,710,332 28,206,110 29,785,652 31,453,649 33,215,053 35,075,096 37,039,301 39,113,502 41,303,858 43,616,875 46,059,419 48,638,747 51,362,517 54,238,818 57,276,192 60,483,658 63,870,743 67,447,505 71,224,565 75,213,141 79,425,077 83,872,881 88,569,762 93,529,669 98,767,330 104,298,301 equals: -Cash flow from financing activities ($) 2,665,572,192 -21,698,409 -22,913,520 -24,196,677 -25,551,691 -26,982,586 -28,493,610 -30,089,253 -31,774,251 -33,553,609 -35,432,611 -37,416,837 -39,512,180 -41,724,862 -44,061,454 -46,528,896 -49,134,514 -51,886,047 -54,791,665 -57,859,998 -61,100,158 -64,521,767 -68,134,986 -71,950,545 -75,979,776 -80,234,643 -84,727,783 -89,472,539 -94,483,002 -99,774,050 -105,361,396 +Cash flow from financing activities ($) 2,638,676,591 -21,479,472 -22,682,323 -23,952,533 -25,293,875 -26,710,332 -28,206,110 -29,785,652 -31,453,649 -33,215,053 -35,075,096 -37,039,301 -39,113,502 -41,303,858 -43,616,875 -46,059,419 -48,638,747 -51,362,517 -54,238,818 -57,276,192 -60,483,658 -63,870,743 -67,447,505 -71,224,565 -75,213,141 -79,425,077 -83,872,881 -88,569,762 -93,529,669 -98,767,330 -104,298,301 PROJECT RETURNS Pre-tax Cash Flow: -Cash flow from operating activities ($) 0 179,277,799 182,991,410 187,435,053 191,555,504 195,015,548 196,370,361 192,840,099 193,094,471 209,596,404 215,178,710 220,175,489 224,840,834 228,389,752 228,732,891 222,473,728 236,961,173 247,960,782 254,203,471 260,144,826 265,588,965 269,197,487 268,185,819 266,227,882 287,677,665 296,071,883 303,693,013 311,117,128 317,696,915 321,431,839 1,652,333,542 -Cash flow from investing activities ($) -2,665,572,192 0 0 0 0 0 0 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 ($) 2,665,572,192 -21,698,409 -22,913,520 -24,196,677 -25,551,691 -26,982,586 -28,493,610 -30,089,253 -31,774,251 -33,553,609 -35,432,611 -37,416,837 -39,512,180 -41,724,862 -44,061,454 -46,528,896 -49,134,514 -51,886,047 -54,791,665 -57,859,998 -61,100,158 -64,521,767 -68,134,986 -71,950,545 -75,979,776 -80,234,643 -84,727,783 -89,472,539 -94,483,002 -99,774,050 -105,361,396 -Total pre-tax cash flow ($) 0 157,579,390 160,077,890 163,238,376 166,003,813 168,032,962 167,876,751 162,750,847 161,320,220 176,042,796 179,746,099 182,758,652 185,328,654 186,664,890 184,671,437 175,944,832 187,826,659 196,074,736 199,411,806 202,284,828 204,488,806 204,675,719 200,050,833 194,277,336 211,697,889 215,837,240 218,965,230 221,644,588 223,213,913 221,657,789 1,546,972,146 +Cash flow from operating activities ($) 0 182,754,114 186,455,464 190,886,160 194,992,939 198,438,545 199,778,112 196,231,751 196,469,120 212,953,101 218,516,447 223,493,205 228,137,408 231,664,000 231,983,563 225,699,504 240,160,658 251,132,504 257,345,876 263,256,271 268,667,716 272,241,714 271,193,589 269,197,153 290,606,281 298,957,568 306,533,362 313,909,602 320,438,834 324,120,371 1,641,517,897 +Cash flow from investing activities ($) -2,638,676,591 0 0 0 0 0 0 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 ($) 2,638,676,591 -21,479,472 -22,682,323 -23,952,533 -25,293,875 -26,710,332 -28,206,110 -29,785,652 -31,453,649 -33,215,053 -35,075,096 -37,039,301 -39,113,502 -41,303,858 -43,616,875 -46,059,419 -48,638,747 -51,362,517 -54,238,818 -57,276,192 -60,483,658 -63,870,743 -67,447,505 -71,224,565 -75,213,141 -79,425,077 -83,872,881 -88,569,762 -93,529,669 -98,767,330 -104,298,301 +Total pre-tax cash flow ($) 0 161,274,642 163,773,142 166,933,628 169,699,065 171,728,214 171,572,002 166,446,099 165,015,472 179,738,047 183,441,351 186,453,904 189,023,906 190,360,142 188,366,688 179,640,084 191,521,911 199,769,987 203,107,058 205,980,080 208,184,058 208,370,971 203,746,085 197,972,588 215,393,140 219,532,492 222,660,482 225,339,840 226,909,165 225,353,041 1,537,219,596 Pre-tax Returns: -Issuance of equity ($) 1,066,228,877 -Total pre-tax cash flow ($) 0 157,579,390 160,077,890 163,238,376 166,003,813 168,032,962 167,876,751 162,750,847 161,320,220 176,042,796 179,746,099 182,758,652 185,328,654 186,664,890 184,671,437 175,944,832 187,826,659 196,074,736 199,411,806 202,284,828 204,488,806 204,675,719 200,050,833 194,277,336 211,697,889 215,837,240 218,965,230 221,644,588 223,213,913 221,657,789 1,546,972,146 -Total pre-tax returns ($) -1,066,228,877 157,579,390 160,077,890 163,238,376 166,003,813 168,032,962 167,876,751 162,750,847 161,320,220 176,042,796 179,746,099 182,758,652 185,328,654 186,664,890 184,671,437 175,944,832 187,826,659 196,074,736 199,411,806 202,284,828 204,488,806 204,675,719 200,050,833 194,277,336 211,697,889 215,837,240 218,965,230 221,644,588 223,213,913 221,657,789 1,546,972,146 +Issuance of equity ($) 1,055,470,636 +Total pre-tax cash flow ($) 0 161,274,642 163,773,142 166,933,628 169,699,065 171,728,214 171,572,002 166,446,099 165,015,472 179,738,047 183,441,351 186,453,904 189,023,906 190,360,142 188,366,688 179,640,084 191,521,911 199,769,987 203,107,058 205,980,080 208,184,058 208,370,971 203,746,085 197,972,588 215,393,140 219,532,492 222,660,482 225,339,840 226,909,165 225,353,041 1,537,219,596 +Total pre-tax returns ($) -1,055,470,636 161,274,642 163,773,142 166,933,628 169,699,065 171,728,214 171,572,002 166,446,099 165,015,472 179,738,047 183,441,351 186,453,904 189,023,906 190,360,142 188,366,688 179,640,084 191,521,911 199,769,987 203,107,058 205,980,080 208,184,058 208,370,971 203,746,085 197,972,588 215,393,140 219,532,492 222,660,482 225,339,840 226,909,165 225,353,041 1,537,219,596 After-tax Returns: -Total pre-tax returns ($) -1,066,228,877 157,579,390 160,077,890 163,238,376 166,003,813 168,032,962 167,876,751 162,750,847 161,320,220 176,042,796 179,746,099 182,758,652 185,328,654 186,664,890 184,671,437 175,944,832 187,826,659 196,074,736 199,411,806 202,284,828 204,488,806 204,675,719 200,050,833 194,277,336 211,697,889 215,837,240 218,965,230 221,644,588 223,213,913 221,657,789 1,546,972,146 -Federal ITC total income ($) 0 799,671,658 0 0 0 0 0 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 pre-tax returns ($) -1,055,470,636 161,274,642 163,773,142 166,933,628 169,699,065 171,728,214 171,572,002 166,446,099 165,015,472 179,738,047 183,441,351 186,453,904 189,023,906 190,360,142 188,366,688 179,640,084 191,521,911 199,769,987 203,107,058 205,980,080 208,184,058 208,370,971 203,746,085 197,972,588 215,393,140 219,532,492 222,660,482 225,339,840 226,909,165 225,353,041 1,537,219,596 +Federal ITC total income ($) 0 791,602,977 0 0 0 0 0 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 -23,950,496 -13,613,307 -14,481,150 -15,285,874 -15,961,621 -16,226,216 -15,536,756 -15,586,435 -18,809,262 -19,899,486 -20,875,357 -21,786,499 -22,479,603 -22,546,618 -21,324,203 -24,153,601 -26,301,825 -27,521,022 -28,681,369 -29,744,609 -41,511,811 -52,376,690 -51,994,305 -56,183,448 -57,822,839 -59,311,246 -60,761,175 -62,046,208 -62,775,638 -322,700,741 +Federal tax benefit (liability) ($) 0 -24,741,041 -14,513,077 -15,378,392 -16,180,446 -16,853,373 -17,114,990 -16,422,386 -16,468,744 -19,688,065 -20,774,587 -21,746,548 -22,653,560 -23,342,304 -23,404,714 -22,177,438 -25,001,701 -27,144,503 -28,357,974 -29,512,274 -30,569,130 -42,217,969 -52,964,108 -52,574,204 -56,755,407 -58,386,413 -59,865,966 -61,306,545 -62,581,704 -63,300,708 -320,588,445 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 -8,584,407 -4,879,321 -5,190,376 -5,478,808 -5,721,011 -5,815,848 -5,568,730 -5,586,536 -6,741,671 -7,132,432 -7,482,207 -7,808,781 -8,057,205 -8,081,225 -7,643,084 -8,657,205 -9,427,177 -9,864,166 -10,280,061 -10,661,150 -14,878,785 -18,773,007 -18,635,952 -20,137,437 -20,725,032 -21,258,511 -21,778,199 -22,238,784 -22,500,229 -115,663,348 -Total after-tax returns ($) -1,066,228,877 924,716,144 141,585,262 143,566,849 145,239,131 146,350,330 145,834,687 141,645,361 140,147,250 150,491,862 152,714,180 154,401,088 155,733,374 156,128,082 154,043,594 146,977,545 155,015,853 160,345,733 162,026,618 163,323,398 164,083,047 148,285,123 128,901,135 123,647,079 135,377,004 137,289,369 138,395,473 139,105,214 138,928,922 136,381,922 1,108,608,057 +State tax benefit (liability) ($) 0 -8,867,757 -5,201,820 -5,511,968 -5,799,443 -6,040,635 -6,134,405 -5,886,160 -5,902,776 -7,056,654 -7,446,088 -7,794,462 -8,119,556 -8,366,417 -8,388,787 -7,948,902 -8,961,183 -9,729,212 -10,164,148 -10,577,876 -10,956,677 -15,131,889 -18,983,551 -18,843,801 -20,342,440 -20,927,030 -21,457,335 -21,973,672 -22,430,718 -22,688,426 -114,906,253 +Total after-tax returns ($) -1,055,470,636 919,268,822 144,058,245 146,043,268 147,719,176 148,834,206 148,322,607 144,137,553 142,643,952 152,993,328 155,220,676 156,912,895 158,250,790 158,651,421 156,573,187 149,513,744 157,559,027 162,896,272 164,584,935 165,889,929 166,658,251 151,021,113 131,798,425 126,554,583 138,295,294 140,219,049 141,337,181 142,059,623 141,896,743 139,363,906 1,101,724,898 -After-tax cumulative IRR (%) NaN -13.27 0.01 9.94 16.61 20.99 23.89 25.80 27.12 28.11 28.82 29.35 29.73 30.01 30.21 30.36 30.48 30.57 30.63 30.68 30.72 30.75 30.77 30.78 30.79 30.80 30.81 30.81 30.82 30.82 30.84 -After-tax cumulative NPV ($) -1,066,228,877 -259,152,225 -151,299,597 -55,850,197 28,426,793 102,545,088 167,006,364 221,650,902 268,839,310 313,064,541 352,233,570 386,797,263 417,224,170 443,847,569 466,773,789 485,865,556 503,439,839 519,305,759 533,298,427 545,608,736 556,402,939 564,916,883 571,376,344 576,784,258 581,951,957 586,525,950 590,550,217 594,080,542 597,157,845 599,794,424 618,499,882 +After-tax cumulative IRR (%) NaN -12.90 0.66 10.71 17.41 21.79 24.67 26.57 27.86 28.84 29.54 30.05 30.42 30.69 30.89 31.03 31.14 31.22 31.29 31.34 31.37 31.40 31.42 31.43 31.44 31.45 31.45 31.46 31.46 31.46 31.48 +After-tax cumulative NPV ($) -1,055,470,636 -253,148,316 -143,411,891 -46,316,062 39,400,008 114,776,248 180,337,225 235,943,211 283,972,274 328,932,614 368,744,523 403,870,500 434,789,256 461,842,941 485,145,639 504,566,849 522,429,453 538,547,744 552,761,350 565,265,108 576,228,721 584,899,754 591,504,404 597,039,482 602,318,580 606,990,179 611,099,985 614,705,290 617,848,331 620,542,558 639,131,877 AFTER-TAX LCOE AND PPA PRICE -Annual costs ($) -1,066,228,877 526,359,673 -259,269,709 -260,448,608 -261,541,763 -262,459,713 -262,819,145 -261,882,566 -261,950,051 -266,328,014 -267,809,000 -269,134,645 -270,372,361 -271,313,889 -271,404,924 -269,744,368 -273,587,887 -276,506,084 -278,162,269 -279,738,511 -281,182,840 -297,167,678 -311,926,779 -311,407,338 -317,097,965 -319,324,952 -321,346,837 -323,316,455 -325,062,073 -326,052,948 653,644,927 +Annual costs ($) -1,055,470,636 520,912,351 -256,796,726 -257,972,189 -259,061,718 -259,975,837 -260,331,224 -259,390,374 -259,453,349 -263,826,549 -265,302,504 -266,622,838 -267,854,945 -268,790,550 -268,875,330 -267,208,169 -271,044,713 -273,955,544 -275,603,952 -277,171,980 -278,607,636 -294,431,687 -309,029,488 -308,499,834 -314,179,676 -316,395,272 -318,405,130 -320,362,047 -322,094,252 -323,070,964 646,761,768 PPA revenue ($) 0 398,356,471 400,854,971 404,015,457 406,780,894 408,810,043 408,653,832 403,527,928 402,097,301 416,819,877 420,523,180 423,535,733 426,105,735 427,441,971 425,448,518 416,721,913 428,603,740 436,851,817 440,188,887 443,061,909 445,265,887 445,452,800 440,827,914 435,054,417 452,474,970 456,614,321 459,742,311 462,421,669 463,990,994 462,434,870 454,963,130 Electricity to grid (kWh) 0.0 4,193,226,013 4,219,526,008 4,227,429,703 4,231,130,578 4,227,174,470 4,200,800,078 4,123,944,075 4,085,524,292 4,210,727,110 4,223,816,595 4,229,858,515 4,231,437,288 4,220,815,353 4,177,617,023 4,069,152,556 4,162,009,516 4,218,752,453 4,227,707,328 4,232,132,093 4,230,152,834 4,209,135,409 4,143,119,490 4,067,069,431 4,207,503,902 4,223,608,553 4,230,238,414 4,232,692,624 4,225,013,608 4,189,101,096 4,100,244,505 -Present value of annual costs ($) 2,160,359,066 +Present value of annual costs ($) 2,139,727,071 Present value of annual energy nominal (kWh) 28,322,755,320 -LCOE Levelized cost of energy nominal (cents/kWh) 7.63 +LCOE Levelized cost of energy nominal (cents/kWh) 7.55 Present value of PPA revenue ($) 2,778,858,948 Present value of annual energy nominal (kWh) 28,322,755,320 LPPA Levelized PPA price nominal (cents/kWh) 9.81 PROJECT STATE INCOME TAXES -EBITDA ($) 0 268,841,025 271,339,524 274,500,010 277,265,447 279,294,597 279,138,385 274,012,481 272,581,854 287,304,430 291,007,734 294,020,287 296,590,289 297,926,524 295,933,071 287,206,467 299,088,294 307,336,370 310,673,441 313,546,463 315,750,441 315,937,354 311,312,467 305,538,971 322,959,523 327,098,874 330,226,864 332,906,223 334,475,548 332,919,424 1,658,233,780 +EBITDA ($) 0 271,413,648 273,912,147 277,072,633 279,838,070 281,867,219 281,711,008 276,585,104 275,154,477 289,877,053 293,580,357 296,592,910 299,162,911 300,499,147 298,505,694 289,779,090 301,660,916 309,908,993 313,246,063 316,119,085 318,323,064 318,509,977 313,885,090 308,111,593 325,532,146 329,671,497 332,799,487 335,478,846 337,048,171 335,492,046 1,647,358,602 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 89,563,226 88,348,115 87,064,958 85,709,944 84,279,049 82,768,024 81,172,382 79,487,384 77,708,026 75,829,024 73,844,798 71,749,455 69,536,773 67,200,180 64,732,739 62,127,121 59,375,588 56,469,969 53,401,636 50,161,476 46,739,867 43,126,648 39,311,089 35,281,859 31,026,991 26,533,851 21,789,095 16,778,633 11,487,585 5,900,238 -Total state tax depreciation ($) 0 56,643,409 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 56,643,409 0 0 0 0 0 0 0 0 0 +Debt interest payment ($) 0 88,659,533 87,456,683 86,186,473 84,845,131 83,428,674 81,932,896 80,353,353 78,685,357 76,923,953 75,063,910 73,099,704 71,025,503 68,835,147 66,522,131 64,079,586 61,500,259 58,776,489 55,900,188 52,862,814 49,655,347 46,268,262 42,691,501 38,914,441 34,925,865 30,713,929 26,266,125 21,569,243 16,609,337 11,371,675 5,840,705 +Total state tax depreciation ($) 0 56,071,878 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 56,071,878 0 0 0 0 0 0 0 0 0 equals: -State taxable income ($) 0 122,634,390 69,704,592 74,148,235 78,268,685 81,728,729 83,083,543 79,553,281 79,807,652 96,309,586 101,891,892 106,888,671 111,554,016 115,102,934 115,446,073 109,186,910 123,674,355 134,673,964 140,916,653 146,858,008 152,302,147 212,554,078 268,185,819 266,227,882 287,677,665 296,071,883 303,693,013 311,117,128 317,696,915 321,431,839 1,652,333,542 +State taxable income ($) 0 126,682,237 74,311,709 78,742,405 82,849,184 86,294,790 87,634,357 84,087,996 84,325,365 100,809,345 106,372,692 111,349,450 115,993,653 119,520,245 119,839,808 113,555,749 128,016,903 138,988,749 145,202,120 151,112,516 156,523,961 216,169,837 271,193,589 269,197,153 290,606,281 298,957,568 306,533,362 313,909,602 320,438,834 324,120,371 1,641,517,897 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 -8,584,407 -4,879,321 -5,190,376 -5,478,808 -5,721,011 -5,815,848 -5,568,730 -5,586,536 -6,741,671 -7,132,432 -7,482,207 -7,808,781 -8,057,205 -8,081,225 -7,643,084 -8,657,205 -9,427,177 -9,864,166 -10,280,061 -10,661,150 -14,878,785 -18,773,007 -18,635,952 -20,137,437 -20,725,032 -21,258,511 -21,778,199 -22,238,784 -22,500,229 -115,663,348 +State tax benefit (liability) ($) 0 -8,867,757 -5,201,820 -5,511,968 -5,799,443 -6,040,635 -6,134,405 -5,886,160 -5,902,776 -7,056,654 -7,446,088 -7,794,462 -8,119,556 -8,366,417 -8,388,787 -7,948,902 -8,961,183 -9,729,212 -10,164,148 -10,577,876 -10,956,677 -15,131,889 -18,983,551 -18,843,801 -20,342,440 -20,927,030 -21,457,335 -21,973,672 -22,430,718 -22,688,426 -114,906,253 PROJECT FEDERAL INCOME TAXES -EBITDA ($) 0 268,841,025 271,339,524 274,500,010 277,265,447 279,294,597 279,138,385 274,012,481 272,581,854 287,304,430 291,007,734 294,020,287 296,590,289 297,926,524 295,933,071 287,206,467 299,088,294 307,336,370 310,673,441 313,546,463 315,750,441 315,937,354 311,312,467 305,538,971 322,959,523 327,098,874 330,226,864 332,906,223 334,475,548 332,919,424 1,658,233,780 +EBITDA ($) 0 271,413,648 273,912,147 277,072,633 279,838,070 281,867,219 281,711,008 276,585,104 275,154,477 289,877,053 293,580,357 296,592,910 299,162,911 300,499,147 298,505,694 289,779,090 301,660,916 309,908,993 313,246,063 316,119,085 318,323,064 318,509,977 313,885,090 308,111,593 325,532,146 329,671,497 332,799,487 335,478,846 337,048,171 335,492,046 1,647,358,602 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 -8,584,407 -4,879,321 -5,190,376 -5,478,808 -5,721,011 -5,815,848 -5,568,730 -5,586,536 -6,741,671 -7,132,432 -7,482,207 -7,808,781 -8,057,205 -8,081,225 -7,643,084 -8,657,205 -9,427,177 -9,864,166 -10,280,061 -10,661,150 -14,878,785 -18,773,007 -18,635,952 -20,137,437 -20,725,032 -21,258,511 -21,778,199 -22,238,784 -22,500,229 -115,663,348 +State tax benefit (liability) ($) 0 -8,867,757 -5,201,820 -5,511,968 -5,799,443 -6,040,635 -6,134,405 -5,886,160 -5,902,776 -7,056,654 -7,446,088 -7,794,462 -8,119,556 -8,366,417 -8,388,787 -7,948,902 -8,961,183 -9,729,212 -10,164,148 -10,577,876 -10,956,677 -15,131,889 -18,983,551 -18,843,801 -20,342,440 -20,927,030 -21,457,335 -21,973,672 -22,430,718 -22,688,426 -114,906,253 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 89,563,226 88,348,115 87,064,958 85,709,944 84,279,049 82,768,024 81,172,382 79,487,384 77,708,026 75,829,024 73,844,798 71,749,455 69,536,773 67,200,180 64,732,739 62,127,121 59,375,588 56,469,969 53,401,636 50,161,476 46,739,867 43,126,648 39,311,089 35,281,859 31,026,991 26,533,851 21,789,095 16,778,633 11,487,585 5,900,238 -Total federal tax depreciation ($) 0 56,643,409 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 113,286,818 56,643,409 0 0 0 0 0 0 0 0 0 +Debt interest payment ($) 0 88,659,533 87,456,683 86,186,473 84,845,131 83,428,674 81,932,896 80,353,353 78,685,357 76,923,953 75,063,910 73,099,704 71,025,503 68,835,147 66,522,131 64,079,586 61,500,259 58,776,489 55,900,188 52,862,814 49,655,347 46,268,262 42,691,501 38,914,441 34,925,865 30,713,929 26,266,125 21,569,243 16,609,337 11,371,675 5,840,705 +Total federal tax depreciation ($) 0 56,071,878 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 56,071,878 0 0 0 0 0 0 0 0 0 equals: -Federal taxable income ($) 0 114,049,983 64,825,270 68,957,858 72,789,877 76,007,718 77,267,695 73,984,551 74,221,117 89,567,915 94,759,459 99,406,464 103,745,235 107,045,728 107,364,848 101,543,826 115,017,150 125,246,786 131,052,487 136,577,948 141,640,996 197,675,292 249,412,812 247,591,930 267,540,228 275,346,851 282,434,502 289,338,929 295,458,131 298,931,610 1,536,670,194 +Federal taxable income ($) 0 117,814,480 69,109,890 73,230,437 77,049,741 80,254,155 81,499,952 78,201,836 78,422,590 93,752,691 98,926,604 103,554,989 107,874,097 111,153,828 111,451,021 105,606,846 119,055,720 129,259,537 135,037,972 140,534,640 145,567,284 201,037,948 252,210,038 250,353,352 270,263,841 278,030,538 285,076,027 291,935,930 298,008,116 301,431,945 1,526,611,644 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 -23,950,496 -13,613,307 -14,481,150 -15,285,874 -15,961,621 -16,226,216 -15,536,756 -15,586,435 -18,809,262 -19,899,486 -20,875,357 -21,786,499 -22,479,603 -22,546,618 -21,324,203 -24,153,601 -26,301,825 -27,521,022 -28,681,369 -29,744,609 -41,511,811 -52,376,690 -51,994,305 -56,183,448 -57,822,839 -59,311,246 -60,761,175 -62,046,208 -62,775,638 -322,700,741 +Federal tax benefit (liability) ($) 0 -24,741,041 -14,513,077 -15,378,392 -16,180,446 -16,853,373 -17,114,990 -16,422,386 -16,468,744 -19,688,065 -20,774,587 -21,746,548 -22,653,560 -23,342,304 -23,404,714 -22,177,438 -25,001,701 -27,144,503 -28,357,974 -29,512,274 -30,569,130 -42,217,969 -52,964,108 -52,574,204 -56,755,407 -58,386,413 -59,865,966 -61,306,545 -62,581,704 -63,300,708 -320,588,445 CASH INCENTIVES Federal IBI income ($) 0 @@ -376,30 +376,30 @@ Federal PTC income ($) 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 799,671,658 0 0 0 0 0 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 799,671,658 0 0 0 0 0 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 791,602,977 0 0 0 0 0 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 791,602,977 0 0 0 0 0 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 ($) 1,599,343,315 1,577,644,907 1,554,731,387 1,530,534,710 1,504,983,019 1,478,000,433 1,449,506,823 1,419,417,570 1,387,643,320 1,354,089,711 1,318,657,100 1,281,240,263 1,241,728,083 1,200,003,221 1,155,941,767 1,109,412,871 1,060,278,357 1,008,392,311 953,600,646 895,740,647 834,640,489 770,118,722 701,983,735 630,033,190 554,053,414 473,818,770 389,090,987 299,618,448 205,135,446 105,361,396 0 -Debt interest payment ($) 0 89,563,226 88,348,115 87,064,958 85,709,944 84,279,049 82,768,024 81,172,382 79,487,384 77,708,026 75,829,024 73,844,798 71,749,455 69,536,773 67,200,180 64,732,739 62,127,121 59,375,588 56,469,969 53,401,636 50,161,476 46,739,867 43,126,648 39,311,089 35,281,859 31,026,991 26,533,851 21,789,095 16,778,633 11,487,585 5,900,238 -Debt principal payment ($) 0 21,698,409 22,913,520 24,196,677 25,551,691 26,982,586 28,493,610 30,089,253 31,774,251 33,553,609 35,432,611 37,416,837 39,512,180 41,724,862 44,061,454 46,528,896 49,134,514 51,886,047 54,791,665 57,859,998 61,100,158 64,521,767 68,134,986 71,950,545 75,979,776 80,234,643 84,727,783 89,472,539 94,483,002 99,774,050 105,361,396 -Debt total payment ($) 0 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 +Debt balance ($) 1,583,205,954 1,561,726,482 1,539,044,160 1,515,091,627 1,489,797,752 1,463,087,421 1,434,881,311 1,405,095,659 1,373,642,010 1,340,426,957 1,305,351,861 1,268,312,559 1,229,199,057 1,187,895,198 1,144,278,324 1,098,218,904 1,049,580,157 998,217,641 943,978,823 886,702,631 826,218,973 762,348,230 694,900,725 623,676,160 548,463,019 469,037,943 385,165,062 296,595,300 203,065,631 104,298,301 0 +Debt interest payment ($) 0 88,659,533 87,456,683 86,186,473 84,845,131 83,428,674 81,932,896 80,353,353 78,685,357 76,923,953 75,063,910 73,099,704 71,025,503 68,835,147 66,522,131 64,079,586 61,500,259 58,776,489 55,900,188 52,862,814 49,655,347 46,268,262 42,691,501 38,914,441 34,925,865 30,713,929 26,266,125 21,569,243 16,609,337 11,371,675 5,840,705 +Debt principal payment ($) 0 21,479,472 22,682,323 23,952,533 25,293,875 26,710,332 28,206,110 29,785,652 31,453,649 33,215,053 35,075,096 37,039,301 39,113,502 41,303,858 43,616,875 46,059,419 48,638,747 51,362,517 54,238,818 57,276,192 60,483,658 63,870,743 67,447,505 71,224,565 75,213,141 79,425,077 83,872,881 88,569,762 93,529,669 98,767,330 104,298,301 +Debt total payment ($) 0 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 DSCR (DEBT FRACTION) -EBITDA ($) 0 268,841,025 271,339,524 274,500,010 277,265,447 279,294,597 279,138,385 274,012,481 272,581,854 287,304,430 291,007,734 294,020,287 296,590,289 297,926,524 295,933,071 287,206,467 299,088,294 307,336,370 310,673,441 313,546,463 315,750,441 315,937,354 311,312,467 305,538,971 322,959,523 327,098,874 330,226,864 332,906,223 334,475,548 332,919,424 1,658,233,780 +EBITDA ($) 0 271,413,648 273,912,147 277,072,633 279,838,070 281,867,219 281,711,008 276,585,104 275,154,477 289,877,053 293,580,357 296,592,910 299,162,911 300,499,147 298,505,694 289,779,090 301,660,916 309,908,993 313,246,063 316,119,085 318,323,064 318,509,977 313,885,090 308,111,593 325,532,146 329,671,497 332,799,487 335,478,846 337,048,171 335,492,046 1,647,358,602 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 268,841,025 271,339,524 274,500,010 277,265,447 279,294,597 279,138,385 274,012,481 272,581,854 287,304,430 291,007,734 294,020,287 296,590,289 297,926,524 295,933,071 287,206,467 299,088,294 307,336,370 310,673,441 313,546,463 315,750,441 315,937,354 311,312,467 305,538,971 322,959,523 327,098,874 330,226,864 332,906,223 334,475,548 332,919,424 1,658,233,780 -Debt total payment ($) 0 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 111,261,635 -DSCR (pre-tax) 0.0 2.42 2.44 2.47 2.49 2.51 2.51 2.46 2.45 2.58 2.62 2.64 2.67 2.68 2.66 2.58 2.69 2.76 2.79 2.82 2.84 2.84 2.80 2.75 2.90 2.94 2.97 2.99 3.01 2.99 14.90 +Cash available for debt service (CAFDS) ($) 0 271,413,648 273,912,147 277,072,633 279,838,070 281,867,219 281,711,008 276,585,104 275,154,477 289,877,053 293,580,357 296,592,910 299,162,911 300,499,147 298,505,694 289,779,090 301,660,916 309,908,993 313,246,063 316,119,085 318,323,064 318,509,977 313,885,090 308,111,593 325,532,146 329,671,497 332,799,487 335,478,846 337,048,171 335,492,046 1,647,358,602 +Debt total payment ($) 0 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 +DSCR (pre-tax) 0.0 2.46 2.49 2.52 2.54 2.56 2.56 2.51 2.50 2.63 2.67 2.69 2.72 2.73 2.71 2.63 2.74 2.81 2.84 2.87 2.89 2.89 2.85 2.80 2.96 2.99 3.02 3.05 3.06 3.05 14.96 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 diff --git a/tests/examples/Fervo_Project_Cape-4.txt b/tests/examples/Fervo_Project_Cape-4.txt index 067f20a1c..4a27eb24e 100644 --- a/tests/examples/Fervo_Project_Cape-4.txt +++ b/tests/examples/Fervo_Project_Cape-4.txt @@ -27,7 +27,7 @@ Capital Cost for Power Plant for Electricity Generation, 1900, -- https://better Exploration Capital Cost, 30, -- Estimate significantly higher exploration costs than default correlation in consideration of potential risks associated with second/third/fourth-of-a-kind EGS projects Well Drilling Cost Correlation, 3, -- VERTICAL_LARGE (2025 NREL Geothermal Drilling Cost Curve Update) -Well Drilling and Completion Capital Cost Adjustment Factor, 0.84, -- Adjust correlation-calculated value of $4.72M/well to $3.96M/well per Tim Latimer on 2025-02-12 Volts podcast: less than $4M/well +Well Drilling and Completion Capital Cost Adjustment Factor, 0.8, -- Adjust correlation-calculated value of $4.72M/well to $3.96M/well per Tim Latimer on 2025-02-12 Volts podcast: less than $4M/well Reservoir Stimulation Capital Cost Adjustment Factor, 2.66, -- Estimated cost of ~$2M per well. Typical range for Nth-of-kind projects may be $0.5–2M. 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/ From cb253b473002984664a2c1a096d86610c2ed7cbd Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 11:46:38 -0700 Subject: [PATCH 24/39] Move test to test_fervo_project_cape_4.py --- .../test_fervo_project_cape_4.py | 43 +++++++++++++++++++ tests/test_geophires_x.py | 39 ----------------- 2 files changed, 43 insertions(+), 39 deletions(-) create mode 100644 tests/geophires_x_tests/test_fervo_project_cape_4.py diff --git a/tests/geophires_x_tests/test_fervo_project_cape_4.py b/tests/geophires_x_tests/test_fervo_project_cape_4.py new file mode 100644 index 000000000..3bfcc00a6 --- /dev/null +++ b/tests/geophires_x_tests/test_fervo_project_cape_4.py @@ -0,0 +1,43 @@ +from base_test_case import BaseTestCase +from geophires_x_client import GeophiresInputParameters +from geophires_x_client import GeophiresXClient + + +class FervoProjectCape4TestCase(BaseTestCase): + + def test_fervo_project_cape_4_results_against_reference_values(self): + """ + Asserts that results conform to some of the key reference values claimed in docs/Fervo_Project_Cape-4.md. + """ + + r = GeophiresXClient().get_geophires_result( + GeophiresInputParameters(from_file_path=self._get_test_file_path('../examples/Fervo_Project_Cape-4.txt')) + ) + + min_net_gen = r.result['SURFACE EQUIPMENT SIMULATION RESULTS']['Minimum Net Electricity Generation']['value'] + self.assertGreater(min_net_gen, 500) + self.assertLess(min_net_gen, 505) + + max_total_gen = r.result['SURFACE EQUIPMENT SIMULATION RESULTS']['Maximum Total Electricity Generation'][ + 'value' + ] + self.assertGreater(max_total_gen, 600) + self.assertLess(max_total_gen, 650) + + lcoe = r.result['SUMMARY OF RESULTS']['Electricity breakeven price']['value'] + self.assertGreater(lcoe, 7.5) + self.assertLess(lcoe, 8.5) + + redrills = r.result['ENGINEERING PARAMETERS']['Number of times redrilling']['value'] + self.assertGreater(redrills, 2) + self.assertLess(redrills, 7) + + well_cost = r.result['CAPITAL COSTS (M$)']['Drilling and completion costs per well']['value'] + self.assertLess(well_cost, 4.0) + self.assertGreater(well_cost, 3.0) + + pumping_power_pct = r.result['SURFACE EQUIPMENT SIMULATION RESULTS'][ + 'Initial pumping power/net installed power' + ]['value'] + self.assertGreater(pumping_power_pct, 13) + self.assertLess(pumping_power_pct, 17) diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index caf0e294f..5ae240b14 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -939,42 +939,3 @@ def test_sbt_coaxial_raises_error(self): ) client.get_geophires_result(params) self.assertIn('SBT with coaxial configuration is not implemented', str(e.exception)) - - def test_fervo_project_cape_4_results_against_reference_values(self): - """ - Asserts that results conform to some of the key reference values claimed in docs/Fervo_Project_Cape-4.md. - """ - - r = GeophiresXClient().get_geophires_result( - GeophiresInputParameters(from_file_path=self._get_test_file_path('examples/Fervo_Project_Cape-4.txt')) - ) - - min_net_gen = r.result['SURFACE EQUIPMENT SIMULATION RESULTS']['Minimum Net Electricity Generation']['value'] - self.assertGreater(min_net_gen, 500) - self.assertLess(min_net_gen, 505) - - max_total_gen = r.result['SURFACE EQUIPMENT SIMULATION RESULTS']['Maximum Total Electricity Generation'][ - 'value' - ] - self.assertGreater(max_total_gen, 600) - self.assertLess(max_total_gen, 650) - - lcoe = r.result['SUMMARY OF RESULTS']['Electricity breakeven price']['value'] - self.assertGreater(lcoe, 7.5) - self.assertLess(lcoe, 8.5) - - redrills = r.result['ENGINEERING PARAMETERS']['Number of times redrilling']['value'] - self.assertGreater(redrills, 2) - self.assertLess(redrills, 7) - - well_cost = r.result['CAPITAL COSTS (M$)']['Drilling and completion costs per vertical production well'][ - 'value' - ] - self.assertLess(well_cost, 4.0) - self.assertGreater(well_cost, 3.0) - - pumping_power_pct = r.result['SURFACE EQUIPMENT SIMULATION RESULTS'][ - 'Initial pumping power/net installed power' - ]['value'] - self.assertGreater(pumping_power_pct, 13) - self.assertLess(pumping_power_pct, 17) From 4d390112b6b354557aaf3bc6fadd23bb883083bd Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:14:07 -0700 Subject: [PATCH 25/39] Move sig_figs to GeoPHIRESUtils.py --- src/geophires_x/EconomicsSam.py | 24 +++---------------- src/geophires_x/GeoPHIRESUtils.py | 13 ++++++++++ tests/geophires_x_tests/test_economics_sam.py | 6 ++--- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/geophires_x/EconomicsSam.py b/src/geophires_x/EconomicsSam.py index 4b93b98d0..eedff5a8a 100644 --- a/src/geophires_x/EconomicsSam.py +++ b/src/geophires_x/EconomicsSam.py @@ -38,10 +38,10 @@ project_vir_parameter, project_payback_period_parameter, ) -from geophires_x.GeoPHIRESUtils import is_float, is_int +from geophires_x.GeoPHIRESUtils import is_float, is_int, sig_figs from geophires_x.OptionList import EconomicModel, EndUseOptions from geophires_x.Parameter import Parameter, OutputParameter, floatParameter -from geophires_x.Units import convertible_unit, EnergyCostUnit, CurrencyUnit, Units, PercentUnit +from geophires_x.Units import convertible_unit, EnergyCostUnit, CurrencyUnit, Units @dataclass @@ -162,7 +162,7 @@ def calculate_sam_economics(model: Model) -> SamEconomicsCalculations: cash_flow = _calculate_sam_economics_cash_flow(model, single_owner) def sf(_v: float, num_sig_figs: int = 5) -> float: - return _sig_figs(_v, num_sig_figs) + return sig_figs(_v, num_sig_figs) sam_economics: SamEconomicsCalculations = SamEconomicsCalculations(sam_cash_flow_profile=cash_flow) sam_economics.lcoe_nominal.value = sf(single_owner.Outputs.lcoe_nom) @@ -435,21 +435,3 @@ def _ppa_pricing_model( def _get_max_total_generation_kW(model: Model) -> float: return np.max(model.surfaceplant.ElectricityProduced.quantity().to(convertible_unit('kW')).magnitude) - - -def _sig_figs(val: float | list | tuple, num_sig_figs: int) -> float: - """ - TODO move to utilities, probably - """ - - if val is None: - return None - - if isinstance(val, list) or isinstance(val, tuple): - return [_sig_figs(v, num_sig_figs) for v in val] - - try: - return float('%s' % float(f'%.{num_sig_figs}g' % val)) # pylint: disable=consider-using-f-string - except TypeError: - # TODO warn - return val diff --git a/src/geophires_x/GeoPHIRESUtils.py b/src/geophires_x/GeoPHIRESUtils.py index e8328d8a9..1a7594aa6 100644 --- a/src/geophires_x/GeoPHIRESUtils.py +++ b/src/geophires_x/GeoPHIRESUtils.py @@ -642,3 +642,16 @@ def is_float(o: Any) -> bool: else: return True + +def sig_figs(val: float | list | tuple, num_sig_figs: int) -> float: + if val is None: + return None + + if isinstance(val, list) or isinstance(val, tuple): + return [sig_figs(v, num_sig_figs) for v in val] + + try: + return float('%s' % float(f'%.{num_sig_figs}g' % val)) # pylint: disable=consider-using-f-string + except TypeError: + # TODO warn + return val diff --git a/tests/geophires_x_tests/test_economics_sam.py b/tests/geophires_x_tests/test_economics_sam.py index 54eec89d4..7d3bc1f09 100644 --- a/tests/geophires_x_tests/test_economics_sam.py +++ b/tests/geophires_x_tests/test_economics_sam.py @@ -17,11 +17,11 @@ # noinspection PyProtectedMember from geophires_x.EconomicsSam import ( calculate_sam_economics, - _sig_figs, get_sam_cash_flow_profile_tabulated_output, _ppa_pricing_model, _get_fed_and_state_tax_rates, ) +from geophires_x.GeoPHIRESUtils import sig_figs # noinspection PyProtectedMember from geophires_x.EconomicsSamCashFlow import _clean_profile, _is_category_row_label, _is_designator_row_label @@ -459,8 +459,8 @@ def test_is_designator_row_label(self): self.assertTrue(_is_designator_row_label('plus PBI if not available for debt service:')) def test_sig_figs(self): - self.assertListEqual(_sig_figs([1.14, 2.24], 2), [1.1, 2.2]) - self.assertListEqual(_sig_figs((1.14, 2.24), 2), [1.1, 2.2]) + self.assertListEqual(sig_figs([1.14, 2.24], 2), [1.1, 2.2]) + self.assertListEqual(sig_figs((1.14, 2.24), 2), [1.1, 2.2]) def test_get_fed_and_state_tax_rates(self): self.assertEqual(([21], [7]), _get_fed_and_state_tax_rates(0.28)) From d09824db6c4729badfbc4d23334a4ade4e52b8a4 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:32:22 -0700 Subject: [PATCH 26/39] test_case_study_documentation --- .../test_fervo_project_cape_4.py | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/tests/geophires_x_tests/test_fervo_project_cape_4.py b/tests/geophires_x_tests/test_fervo_project_cape_4.py index 3bfcc00a6..8b1daf9e5 100644 --- a/tests/geophires_x_tests/test_fervo_project_cape_4.py +++ b/tests/geophires_x_tests/test_fervo_project_cape_4.py @@ -1,6 +1,12 @@ +import re +from typing import Any + from base_test_case import BaseTestCase +from geophires_x.GeoPHIRESUtils import sig_figs +from geophires_x.Parameter import HasQuantity from geophires_x_client import GeophiresInputParameters from geophires_x_client import GeophiresXClient +from geophires_x_client import GeophiresXResult class FervoProjectCape4TestCase(BaseTestCase): @@ -41,3 +47,182 @@ def test_fervo_project_cape_4_results_against_reference_values(self): ]['value'] self.assertGreater(pumping_power_pct, 13) self.assertLess(pumping_power_pct, 17) + + def test_case_study_documentation(self): + """ + Parses result values from case study documentation markdown and checks that they match the actual result. + Useful for catching when minor updates are made to the case study which need to be manually synced to the + documentation. + """ + + def _parse_value_unit(raw_string: str) -> dict: + """ + A helper function to parse a string and extract a numerical value and its unit. + It handles various formats like currency, percentages, and scientific notation. + """ + # First, strip any parenthetical notes, e.g., "(based on...)" + clean_str = re.split(r'\(', raw_string)[0].strip() + + # Case 1: LCOE format ($X.X/MWh -> cents/kWh) + match = re.match(r'^\$(\d+\.?\d*)/MWh$', clean_str) + if match: + value_ = float(match.group(1)) + # Convert $/MWh to cents/kWh by dividing by 10 + return {'value': round(value_ / 10, 2), 'unit': 'cents/kWh'} + + # Case 2: Billion dollar format ($X.XB -> MUSD) + match = re.match(r'^\$(\d+\.?\d*)B$', clean_str) + if match: + value_ = float(match.group(1)) + return {'value': value_ * 1000, 'unit': 'MUSD'} + + # Case 3: Million dollar format ($X.XM...) + match = re.match(r'^\$(\d+\.?\d*)M', clean_str) + if match: + value_ = float(match.group(1)) + return {'value': value_, 'unit': 'MUSD'} + + # Case 4: Dollar per kW format ($X/kW -> USD/kW) + match = re.match(r'^\$(\d+\.?\d*)/kW$', clean_str) + if match: + value_ = float(match.group(1)) + return {'value': value_, 'unit': 'USD/kW'} + + # Case 5: Percentage format (X.X%) + match = re.match(r'^(\d+\.?\d*)%$', clean_str) + if match: + value_ = float(match.group(1)) + return {'value': value_, 'unit': '%'} + + # Case 6: Temperature format (X℃ -> degC) + match = re.match(r'^(\d+\.?\d*)℃$', clean_str) + if match: + value_ = float(match.group(1)) + return {'value': value_, 'unit': 'degC'} + + # Case 7: Scientific notation format (X.X×10⁶ Y) # ruff: noqa: RUF003 + match = re.match(r'^(\d+\.?\d*)\s*[×xX]\s*10[⁶6]\s*(.*)$', clean_str) + if match: + base_value = float(match.group(1)) + unit = match.group(2).strip() + return {'value': base_value * 1e6, 'unit': unit} + + # Case 8: Standard number and unit (e.g., "503 MW") + match = re.match(r'^(\d+\.?\d*)\s*([a-zA-Z²³\/]+)$', clean_str) + if match: + value_ = float(match.group(1)) + unit = match.group(2) + return {'value': value_, 'unit': unit} + + # Case 9: Dimensionless integer number (e.g., "3") + match = re.match(r'^(\d+)$', clean_str) + if match: + value_ = int(match.group(1)) + return {'value': value_, 'unit': 'count'} + + # Fallback for any unhandled formats + return {'value': clean_str, 'unit': 'unknown'} + + def parse_markdown_results_structured(markdown_text: str) -> dict: + """ + Parses result values from markdown into a structured dictionary with values and units. + """ + raw_results = {} + table_pattern = re.compile(r'^\s*\|\s*(?!-)([^|]+?)\s*\|\s*([^|]+?)\s*\|', re.MULTILINE) + + try: + results_start_index = markdown_text.index('## Results') + search_area = markdown_text[results_start_index:] + + matches = table_pattern.findall(search_area) + + # Use key_ and value_ to avoid shadowing + for match in matches: + key_ = match[0].strip() + value_ = match[1].strip() + if key_.lower() not in ('metric', 'parameter'): + raw_results[key_] = value_ + except ValueError: + print("Warning: '## Results' section not found.") + return {} + + # Consistency check + special_case_pattern = re.compile(r'LCOE\s*=\s*(\S+)\s*and\s*CAPEX\s*=\s*(\S+)') + special_case_match = special_case_pattern.search(markdown_text) + if special_case_match: + lcoe_text = special_case_match.group(1).rstrip('.,;') + lcoe_table_base = raw_results.get('LCOE', '').split('(')[0].strip() + if lcoe_text != lcoe_table_base: + raise ValueError( + f'LCOE mismatch: Text value ({lcoe_text}) does not match table value ({lcoe_table_base}).' + ) + + # Now, process the raw results into the structured format + structured_results = {} + # Use key_ and value_ to avoid shadowing + for key_, value_ in raw_results.items(): + if key_ in [ + 'After-tax IRR', + 'Average Production Temperature', + 'LCOE', + 'Maximum Total Electricity Generation', + 'Minimum Net Electricity Generation', + 'Number of times redrilling', + 'Project capital costs: Total CAPEX', + 'Project capital costs: $/kW', + 'WACC', + 'Well Drilling and Completion Cost', + ]: + structured_results[key_] = _parse_value_unit(value_) + + return structured_results + + results_in_markdown = parse_markdown_results_structured( + '\n'.join(self._get_test_file_content('../../docs/Fervo_Project_Cape-4.md')) + ) + + self.assertEqual(3.96, results_in_markdown['Well Drilling and Completion Cost']['value']) + self.assertEqual('MUSD', results_in_markdown['Well Drilling and Completion Cost']['unit']) + + class Q(HasQuantity): + def __init__(self, vu: dict[str, Any]): + self.value = vu['value'] + + # https://stackoverflow.com/questions/2280334/shortest-way-of-creating-an-object-with-arbitrary-attributes-in-python + self.CurrentUnits = type('', (), {})() + + self.CurrentUnits.value = vu['unit'] + + capex_q = Q(results_in_markdown['Project capital costs: Total CAPEX']).quantity() + markdown_capex_USD_per_kW = ( + capex_q.to('USD').magnitude + / Q(results_in_markdown['Maximum Total Electricity Generation']).quantity().to('kW').magnitude + ) + self.assertAlmostEqual( + sig_figs(markdown_capex_USD_per_kW, 3), results_in_markdown['Project capital costs: $/kW']['value'] + ) + + field_mapping = { + 'LCOE': 'Electricity breakeven price', + 'Project capital costs: Total CAPEX': 'Total CAPEX', + 'Well Drilling and Completion Cost': 'Drilling and completion costs per well', + } + + ignore_keys = ['Project capital costs: $/kW', 'Total fracture surface area per production well'] + + example_result = GeophiresXResult(self._get_test_file_path('../examples/Fervo_Project_Cape-4.out')) + example_result_values_in_documentation = {} + for key, _ in results_in_markdown.items(): + if key not in ignore_keys: + mapped_key = field_mapping.get(key) if key in field_mapping else key + entry = example_result._get_result_field(mapped_key) + if entry is not None and 'value' in entry: + entry['value'] = sig_figs(entry['value'], 3) + + example_result_values_in_documentation[key] = entry + + for ignore_key in ignore_keys: + if ignore_key in results_in_markdown: + del results_in_markdown[ignore_key] + + self.assertDictAlmostEqual(results_in_markdown, example_result_values_in_documentation, places=3) From 87b8ce4c42030045582e48b002e84aecb588cfe7 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:43:19 -0700 Subject: [PATCH 27/39] test that result capex $/kW matches documentation (tangentially relevant to https://github.com/NREL/GEOPHIRES-X/issues/391?title=Add+$/kW+result+metric) --- tests/geophires_x_tests/test_fervo_project_cape_4.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/geophires_x_tests/test_fervo_project_cape_4.py b/tests/geophires_x_tests/test_fervo_project_cape_4.py index 8b1daf9e5..4654c0c4c 100644 --- a/tests/geophires_x_tests/test_fervo_project_cape_4.py +++ b/tests/geophires_x_tests/test_fervo_project_cape_4.py @@ -184,7 +184,7 @@ def parse_markdown_results_structured(markdown_text: str) -> dict: self.assertEqual(3.96, results_in_markdown['Well Drilling and Completion Cost']['value']) self.assertEqual('MUSD', results_in_markdown['Well Drilling and Completion Cost']['unit']) - class Q(HasQuantity): + class _Q(HasQuantity): def __init__(self, vu: dict[str, Any]): self.value = vu['value'] @@ -193,10 +193,10 @@ def __init__(self, vu: dict[str, Any]): self.CurrentUnits.value = vu['unit'] - capex_q = Q(results_in_markdown['Project capital costs: Total CAPEX']).quantity() + capex_q = _Q(results_in_markdown['Project capital costs: Total CAPEX']).quantity() markdown_capex_USD_per_kW = ( capex_q.to('USD').magnitude - / Q(results_in_markdown['Maximum Total Electricity Generation']).quantity().to('kW').magnitude + / _Q(results_in_markdown['Maximum Total Electricity Generation']).quantity().to('kW').magnitude ) self.assertAlmostEqual( sig_figs(markdown_capex_USD_per_kW, 3), results_in_markdown['Project capital costs: $/kW']['value'] @@ -226,3 +226,9 @@ def __init__(self, vu: dict[str, Any]): del results_in_markdown[ignore_key] self.assertDictAlmostEqual(results_in_markdown, example_result_values_in_documentation, places=3) + + result_capex_USD_per_kW = ( + _Q(example_result._get_result_field('Total CAPEX')).quantity().to('USD').magnitude + / _Q(example_result._get_result_field('Maximum Total Electricity Generation')).quantity().to('kW').magnitude + ) + self.assertAlmostEqual(sig_figs(result_capex_USD_per_kW, 3), sig_figs(markdown_capex_USD_per_kW, 3)) From 348db7b289f01d26de6d717693da104563759b43 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:46:41 -0700 Subject: [PATCH 28/39] Check relevant attributes exist before calculating derivation of drilling_and_completion_costs_per_well in Economics --- src/geophires_x/Economics.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index b6be4bd85..dc716d729 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -2985,6 +2985,7 @@ def calculate_cashflow(self, model: Model) -> None: for i in range(1, model.surfaceplant.plant_lifetime.value + model.surfaceplant.construction_years.value, 1): self.TotalCummRevenue.value[i] = self.TotalCummRevenue.value[i-1] + self.TotalRevenue.value[i] + # noinspection SpellCheckingInspection def _calculate_derived_outputs(self, model: Model) -> None: """ Subclasses should call _calculate_derived_outputs at the end of their Calculate methods to populate output @@ -3001,10 +3002,11 @@ def _calculate_derived_outputs(self, model: Model) -> None: self.real_discount_rate.value = self.discountrate.quantity().to(convertible_unit( self.real_discount_rate.CurrentUnits)).magnitude - self.drilling_and_completion_costs_per_well.value = ( - self.Cwell.value / - (model.wellbores.nprod.value + model.wellbores.ninj.value) - ) + if hasattr(self, 'Cwell') and hasattr(model.wellbores, 'nprod') and hasattr(model.wellbores, 'ninj'): + self.drilling_and_completion_costs_per_well.value = ( + self.Cwell.value / + (model.wellbores.nprod.value + model.wellbores.ninj.value) + ) def __str__(self): return "Economics" From 638d94308d8adfc2e3d87a5e5b7a48e548cdc84a Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:59:57 -0700 Subject: [PATCH 29/39] py38 annotation fix --- tests/geophires_x_tests/test_fervo_project_cape_4.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/geophires_x_tests/test_fervo_project_cape_4.py b/tests/geophires_x_tests/test_fervo_project_cape_4.py index 4654c0c4c..40076d02c 100644 --- a/tests/geophires_x_tests/test_fervo_project_cape_4.py +++ b/tests/geophires_x_tests/test_fervo_project_cape_4.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import re from typing import Any From 4a89fb49906cc035bedb840f81b8c04eda3da41a Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 13:14:07 -0700 Subject: [PATCH 30/39] Fix apparently-windows-incompatible unicode character in comment (thus revealing why ruff's complaint about it was valid - https://github.com/softwareengineerprogrammer/GEOPHIRES/actions/runs/15786817152/job/44504920698) --- tests/geophires_x_tests/test_fervo_project_cape_4.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/geophires_x_tests/test_fervo_project_cape_4.py b/tests/geophires_x_tests/test_fervo_project_cape_4.py index 40076d02c..759dc24c9 100644 --- a/tests/geophires_x_tests/test_fervo_project_cape_4.py +++ b/tests/geophires_x_tests/test_fervo_project_cape_4.py @@ -102,7 +102,7 @@ def _parse_value_unit(raw_string: str) -> dict: value_ = float(match.group(1)) return {'value': value_, 'unit': 'degC'} - # Case 7: Scientific notation format (X.X×10⁶ Y) # ruff: noqa: RUF003 + # Case 7: Scientific notation format (X.Xx10⁶ Y) match = re.match(r'^(\d+\.?\d*)\s*[×xX]\s*10[⁶6]\s*(.*)$', clean_str) if match: base_value = float(match.group(1)) From 349eb688fc4a987642356c5c6396b8aae88e0cb7 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 13:34:26 -0700 Subject: [PATCH 31/39] Open Fervo_Project_Cape-4.md with utf-8 encoding by adding open_kw_args support to BaseTestCase._get_test_file_content --- tests/base_test_case.py | 4 ++-- tests/geophires_x_tests/test_fervo_project_cape_4.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/base_test_case.py b/tests/base_test_case.py index 1e36812ec..36bf53ba2 100644 --- a/tests/base_test_case.py +++ b/tests/base_test_case.py @@ -15,8 +15,8 @@ class BaseTestCase(unittest.TestCase): def _get_test_file_path(self, test_file_name) -> str: return os.path.join(os.path.abspath(os.path.dirname(inspect.getfile(self.__class__))), test_file_name) - def _get_test_file_content(self, test_file_name): - with open(self._get_test_file_path(test_file_name)) as f: + def _get_test_file_content(self, test_file_name, **open_kw_args) -> str: + with open(self._get_test_file_path(test_file_name), **open_kw_args) as f: return f.readlines() def _list_test_files_dir(self, test_files_dir: str): diff --git a/tests/geophires_x_tests/test_fervo_project_cape_4.py b/tests/geophires_x_tests/test_fervo_project_cape_4.py index 759dc24c9..0acbf5a85 100644 --- a/tests/geophires_x_tests/test_fervo_project_cape_4.py +++ b/tests/geophires_x_tests/test_fervo_project_cape_4.py @@ -180,7 +180,7 @@ def parse_markdown_results_structured(markdown_text: str) -> dict: return structured_results results_in_markdown = parse_markdown_results_structured( - '\n'.join(self._get_test_file_content('../../docs/Fervo_Project_Cape-4.md')) + '\n'.join(self._get_test_file_content('../../docs/Fervo_Project_Cape-4.md', encoding='utf-8')) ) self.assertEqual(3.96, results_in_markdown['Well Drilling and Completion Cost']['value']) From 05d07fc229d06c326cbdf0fbd4b7a5258779a529 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 14:29:03 -0700 Subject: [PATCH 32/39] Fix number of fractures to actually be 102 per well (previous version apparently erroneously multiplied 108 * 102 instead of _118_ * 102). Corresponding and additional verifications in FervoProjectCape4TestCase --- docs/Fervo_Project_Cape-4.md | 10 +- tests/examples/Fervo_Project_Cape-4.out | 312 ++++++++--------- tests/examples/Fervo_Project_Cape-4.txt | 4 +- .../test_fervo_project_cape_4.py | 325 +++++++++++------- 4 files changed, 360 insertions(+), 291 deletions(-) diff --git a/docs/Fervo_Project_Cape-4.md b/docs/Fervo_Project_Cape-4.md index 7d84f1b52..06354385e 100644 --- a/docs/Fervo_Project_Cape-4.md +++ b/docs/Fervo_Project_Cape-4.md @@ -8,7 +8,7 @@ Financial results are calculated using the [SAM Single Owner PPA Economic Model](https://softwareengineerprogrammer.github.io/GEOPHIRES/SAM-Economic-Models.html#sam-single-owner-ppa). -Key case study results include LCOE = $75.5/MWh and CAPEX = $4300/kW. +Key case study results include LCOE = $75.5/MWh and CAPEX = $4290/kW. [Click here](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=Fervo_Project_Cape-4) to interactively explore the case study in the GEOPHIRES web interface. @@ -67,7 +67,7 @@ in source code for the full set of inputs. | Number of Fractures per well | 102 | Estimate. (Note this is not a direct GEOPHIRES input parameter but was used to calculate other case study GEOPHIRES input parameters such as reservoir volume.) | | Fracture Separation | 18 m | Per Norbeck et al, 2024: lateral length is 4700 ft = 1432 m. Dividing 1432 by 80 = ~18 m fracture spacing. | | Fracture Geometry | 165.3 m × 165.3 m (Square) | Extrapolated from 30 million ft² fracture surface area per well per Fercho et al, 2025. | -| Reservoir Volume | 5,418,039,158 m³ | Calculated from fracture area (27,324.09 m²) × fracture separation (18 m) × targeted number of fractures per well (102) | +| Reservoir Volume | 5,919,217,617 m³ | Calculated from fracture area (27,324.09 m²) × fracture separation (18 m) × targeted number of fractures per well (102) | | Water Loss Rate | 15% | Water loss rate is conservatively estimated to be between 10 and 20%. Other estimates and some simulations may suggest a significantly lower water loss rate than this conservative estimate. See [Geothermal Mythbusting: Water Use and Impacts](https://fervoenergy.com/geothermal-mythbusting-water-use-and-impacts/) (Fervo Energy, 2025). | | Maximum Drawdown | 0.0153 | Tuned to keep minimum net electricity generation ≥ 500 MWe and thermal breakthrough requiring redrilling occurring every 5–10 years | | Reservoir Impedance | 0.001565 GPa.s/m³ | Yields ~15% initial pumping power/net installed power | @@ -84,7 +84,7 @@ in source code for the complete results. |------------------------------------|----------------------------------------------------------|--------------------------|---------------------------------------------| | LCOE | $75.5/MWh | $80/MWh | Horne et al, 2025 | | Project capital costs: Total CAPEX | $2.64B | | | -| Project capital costs: $/kW | $4300/kW (based on maximum total electricity generation) | $4500/kW, $3000–$6000/kW | Horne et al, 2025; Latimer, 2025. | +| Project capital costs: $/kW | $4290/kW (based on maximum total electricity generation) | $4500/kW, $3000–$6000/kW | Horne et al, 2025; Latimer, 2025. | | Well Drilling and Completion Cost | $3.96M/well (including 5% indirect costs) | $<4M/well | Latimer, 2025. | | WACC | 8.3% | 8.3% | Matson, 2024. | | After-tax IRR | 31.5% | 15–25% | Typical levered returns for energy projects | @@ -93,8 +93,8 @@ in source code for the complete results. | Metric | Result Value | Reference Value(s) | Reference Source | |-------------------------------------------------|--------------|----------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Minimum Net Electricity Generation | 503 MW | 500 MW | Fervo Energy, 2025. The 500 MW PPA is interpreted to mean that Cape Station's net electricity generation must never fall below 500 MWe. | -| Maximum Total Electricity Generation | 614 MW | | Actual maximum total generation may be bounded or constrained by modular power plant design not modeled in this case study. For example, a modular design with 50MW units may constrain maximum total generation to 600 MW. | +| Minimum Net Electricity Generation | 504 MW | 500 MW | Fervo Energy, 2025. The 500 MW PPA is interpreted to mean that Cape Station's net electricity generation must never fall below 500 MWe. | +| Maximum Total Electricity Generation | 615 MW | | Actual maximum total generation may be bounded or constrained by modular power plant design not modeled in this case study. For example, a modular design with 50MW units may constrain maximum total generation to 600 MW. | | Number of times redrilling | 3 | 3–6 | Redrilling expected to be required within 5–10 years of project start | | Average Production Temperature | 199℃ | 204℃, 190.6–198.6℃ (optimal plant operating range) | Trent, 2024; Norbeck et al, 2024. | | Total fracture surface area per production well | 2.787×10⁶ m² | 2.787×10⁶ m² (30 million ft² per well) | Fercho et al, 2025. | diff --git a/tests/examples/Fervo_Project_Cape-4.out b/tests/examples/Fervo_Project_Cape-4.out index 860f26f88..55113f51b 100644 --- a/tests/examples/Fervo_Project_Cape-4.out +++ b/tests/examples/Fervo_Project_Cape-4.out @@ -4,17 +4,17 @@ Simulation Metadata ---------------------- - GEOPHIRES Version: 3.9.19 + GEOPHIRES Version: 3.9.24 Simulation Date: 2025-06-20 - Simulation Time: 11:37 - Calculation Time: 1.572 sec + Simulation Time: 14:04 + Calculation Time: 1.575 sec ***SUMMARY OF RESULTS*** End-Use Option: Electricity - Average Net Electricity Production: 531.71 MW + Average Net Electricity Production: 532.53 MW Electricity breakeven price: 7.55 cents/kWh - Total CAPEX: 2638.68 MUSD + Total CAPEX: 2639.39 MUSD Number of production wells: 59 Number of injection wells: 59 Flowrate per production well: 107.0 kg/sec @@ -31,12 +31,12 @@ Simulation Metadata Accrued financing during construction: 15.00 % Project lifetime: 30 yr Capacity factor: 90.0 % - Project NPV: 639.13 MUSD - After-tax IRR: 31.48 % + Project NPV: 641.24 MUSD + After-tax IRR: 31.51 % Project VIR=PI=PIR: 1.61 - Project MOIC: 5.75 + Project MOIC: 5.77 Project Payback Period: 1.95 yr - Estimated Jobs Created: 1298 + Estimated Jobs Created: 1300 ***ENGINEERING PARAMETERS*** @@ -47,7 +47,7 @@ Simulation Metadata Pump efficiency: 80.0 % Injection temperature: 56.6 degC Production Wellbore heat transmission calculated with Ramey's model - Average production well temperature drop: 0.5 degC + Average production well temperature drop: 0.6 degC Flowrate per production well: 107.0 kg/sec Injection well casing ID: 9.625 in Production well casing ID: 9.625 in @@ -69,10 +69,10 @@ Simulation Metadata Fracture model = Square Well separation: fracture height: 165.30 meter Fracture area: 27324.09 m**2 - Number of fractures calculated with reservoir volume and fracture separation as input - Number of fractures: 11018 + Reservoir volume calculated with fracture separation and number of fractures as input + Number of fractures: 12036 Fracture separation: 18.00 meter - Reservoir volume: 5418039158 m**3 + Reservoir volume: 5919217617 m**3 Reservoir impedance: 0.0016 GPa.s/m**3 Reservoir density: 2800.00 kg/m**3 Reservoir thermal conductivity: 3.05 W/m/K @@ -81,18 +81,18 @@ Simulation Metadata ***RESERVOIR SIMULATION RESULTS*** - Maximum Production Temperature: 199.5 degC - Average Production Temperature: 198.9 degC - Minimum Production Temperature: 195.2 degC + Maximum Production Temperature: 199.6 degC + Average Production Temperature: 199.0 degC + Minimum Production Temperature: 195.4 degC Initial Production Temperature: 198.2 degC - Average Reservoir Heat Extraction: 3758.70 MW + Average Reservoir Heat Extraction: 3761.51 MW Production Wellbore Heat Transmission Model = Ramey Model - Average Production Well Temperature Drop: 0.5 degC - Total Average Pressure Drop: 8523.8 kPa + Average Production Well Temperature Drop: 0.6 degC + Total Average Pressure Drop: 8521.8 kPa Average Injection Well Pressure Drop: 600.9 kPa Average Reservoir Pressure Drop: 10344.9 kPa Average Production Well Pressure Drop: 504.2 kPa - Average Buoyancy Pressure Drop: -2926.2 kPa + Average Buoyancy Pressure Drop: -2928.2 kPa ***CAPITAL COSTS (M$)*** @@ -100,38 +100,38 @@ Simulation Metadata Drilling and completion costs: 467.75 MUSD Drilling and completion costs per well: 3.96 MUSD Stimulation costs: 236.88 MUSD - Surface power plant costs: 1503.42 MUSD - Field gathering system costs: 56.45 MUSD - Total surface equipment costs: 1559.87 MUSD + Surface power plant costs: 1504.05 MUSD + Field gathering system costs: 56.44 MUSD + Total surface equipment costs: 1560.49 MUSD Exploration costs: 30.00 MUSD - Investment Tax Credit: -688.35 MUSD - Total CAPEX: 2638.68 MUSD + Investment Tax Credit: -688.54 MUSD + Total CAPEX: 2639.39 MUSD ***OPERATING AND MAINTENANCE COSTS (M$/yr)*** Wellfield maintenance costs: 6.20 MUSD/yr - Power plant maintenance costs: 25.42 MUSD/yr + Power plant maintenance costs: 25.43 MUSD/yr Water costs: 24.86 MUSD/yr - Total operating and maintenance costs: 126.94 MUSD/yr + Total operating and maintenance costs: 126.95 MUSD/yr ***SURFACE EQUIPMENT SIMULATION RESULTS*** Initial geofluid availability: 0.19 MW/(kg/s) - Maximum Total Electricity Generation: 614.34 MW - Average Total Electricity Generation: 609.40 MW - Minimum Total Electricity Generation: 581.66 MW + Maximum Total Electricity Generation: 614.60 MW + Average Total Electricity Generation: 610.21 MW + Minimum Total Electricity Generation: 583.15 MW Initial Total Electricity Generation: 604.35 MW - Maximum Net Electricity Generation: 536.88 MW - Average Net Electricity Generation: 531.71 MW - Minimum Net Electricity Generation: 502.90 MW + Maximum Net Electricity Generation: 537.14 MW + Average Net Electricity Generation: 532.53 MW + Minimum Net Electricity Generation: 504.44 MW Initial Net Electricity Generation: 526.63 MW - Average Annual Total Electricity Generation: 4804.56 GWh - Average Annual Net Electricity Generation: 4192.05 GWh + Average Annual Total Electricity Generation: 4810.97 GWh + Average Annual Net Electricity Generation: 4198.60 GWh Initial pumping power/net installed power: 14.76 % - Average Pumping Power: 77.69 MW - Heat to Power Conversion Efficiency: 14.15 % + Average Pumping Power: 77.67 MW + Heat to Power Conversion Efficiency: 14.16 % ************************************************************ * HEATING, COOLING AND/OR ELECTRICITY PRODUCTION PROFILE * @@ -141,34 +141,34 @@ Simulation Metadata (degC) (MW) (MW) (%) 1 1.0000 198.22 77.7286 526.6263 14.0769 2 1.0051 199.24 77.6662 534.3539 14.1818 - 3 1.0061 199.42 77.6548 535.7720 14.2009 - 4 1.0065 199.52 77.6495 536.4851 14.2105 - 5 1.0066 199.54 77.6560 536.6451 14.2126 - 6 1.0057 199.35 77.7115 535.1783 14.1922 - 7 1.0019 198.61 77.9043 529.3559 14.1115 - 8 0.9926 196.75 78.3717 514.8879 13.9080 - 9 1.0037 198.95 77.6277 532.2546 14.1541 - 10 1.0056 199.33 77.6188 535.1354 14.1929 - 11 1.0063 199.47 77.6071 536.1662 14.2068 - 12 1.0066 199.54 77.5953 536.7025 14.2141 - 13 1.0065 199.50 77.6023 536.4142 14.2102 - 14 1.0046 199.14 77.6869 533.5811 14.1712 - 15 0.9989 198.00 77.9623 524.7524 14.0484 - 16 0.9862 195.49 78.5792 505.2627 13.7716 - 17 1.0048 199.18 77.5103 534.1012 14.1805 - 18 1.0059 199.40 77.4976 535.7642 14.2029 - 19 1.0065 199.50 77.4875 536.5580 14.2136 - 20 1.0067 199.54 77.4864 536.8507 14.2175 - 21 1.0060 199.41 77.5261 535.7989 14.2030 - 22 1.0029 198.79 77.6864 530.9677 14.1361 - 23 0.9947 197.16 78.0990 518.2759 13.9582 - 24 1.0027 198.76 77.4615 530.9336 14.1386 - 25 1.0054 199.30 77.4599 535.0372 14.1937 - 26 1.0062 199.45 77.4589 536.1857 14.2090 - 27 1.0066 199.53 77.4598 536.7844 14.2170 - 28 1.0066 199.52 77.4767 536.6927 14.2156 - 29 1.0051 199.24 77.5592 534.4598 14.1846 - 30 1.0003 198.27 77.8106 526.9180 14.0797 + 3 1.0061 199.42 77.6547 535.7721 14.2009 + 4 1.0065 199.52 77.6489 536.5044 14.2108 + 5 1.0068 199.57 77.6474 536.9145 14.2163 + 6 1.0067 199.55 77.6626 536.7091 14.2133 + 7 1.0054 199.29 77.7347 534.6638 14.1850 + 8 1.0012 198.47 77.9449 528.2545 14.0960 + 9 0.9919 196.61 78.4108 513.7990 13.8924 + 10 1.0040 199.02 77.6265 532.7400 14.1607 + 11 1.0057 199.35 77.6184 535.2509 14.1944 + 12 1.0063 199.48 77.6077 536.2282 14.2076 + 13 1.0067 199.55 77.5951 536.8041 14.2155 + 14 1.0068 199.58 77.5874 537.0016 14.2182 + 15 1.0063 199.46 77.6101 536.1193 14.2061 + 16 1.0038 198.97 77.7249 532.3053 14.1535 + 17 0.9974 197.70 78.0349 522.3915 14.0153 + 18 1.0000 198.22 77.5160 526.8389 14.0826 + 19 1.0051 199.24 77.5030 534.5170 14.1861 + 20 1.0061 199.42 77.4920 535.9348 14.2052 + 21 1.0065 199.52 77.4831 536.6702 14.2152 + 22 1.0068 199.57 77.4784 537.0835 14.2208 + 23 1.0067 199.55 77.4911 536.8806 14.2179 + 24 1.0054 199.29 77.5620 534.8365 14.1896 + 25 1.0012 198.47 77.7729 528.4265 14.1006 + 26 0.9919 196.61 78.2420 513.9678 13.8970 + 27 1.0040 199.02 77.4599 532.9067 14.1651 + 28 1.0057 199.35 77.4589 535.4103 14.1987 + 29 1.0063 199.48 77.4584 536.3775 14.2116 + 30 1.0067 199.55 77.4587 536.9405 14.2191 ******************************************************************* @@ -177,36 +177,36 @@ Simulation Metadata YEAR ELECTRICITY HEAT RESERVOIR PERCENTAGE OF PROVIDED EXTRACTED HEAT CONTENT TOTAL HEAT MINED (GWh/year) (GWh/year) (10^15 J) (%) - 1 4192.9 29636.9 1632.52 6.13 - 2 4219.2 29728.1 1525.50 12.29 - 3 4227.1 29755.4 1418.38 18.45 - 4 4230.8 29768.3 1311.21 24.61 - 5 4226.9 29755.2 1204.09 30.77 - 6 4200.5 29666.3 1097.29 36.91 - 7 4123.6 29405.9 991.43 43.00 - 8 4085.2 29271.2 886.06 49.05 - 9 4210.4 29696.4 779.15 55.20 - 10 4223.5 29741.8 672.08 61.36 - 11 4229.5 29762.5 564.93 67.52 - 12 4231.1 29767.9 457.77 73.68 - 13 4220.5 29731.9 350.73 79.83 - 14 4177.3 29585.5 244.23 85.96 - 15 4068.9 29216.8 139.05 92.01 - 16 4161.7 29526.4 32.75 98.12 - 17 4218.4 29721.1 -74.24 104.27 - 18 4227.4 29752.0 -181.35 110.43 - 19 4231.8 29767.3 -288.51 116.59 - 20 4229.8 29760.8 -395.65 122.75 - 21 4208.8 29689.9 -502.54 128.89 - 22 4142.8 29466.4 -608.62 134.99 - 23 4066.8 29205.0 -713.75 141.04 - 24 4207.2 29680.6 -820.60 147.18 - 25 4223.3 29736.8 -927.66 153.34 - 26 4229.9 29759.9 -1034.79 159.50 - 27 4232.4 29768.7 -1141.96 165.66 - 28 4224.7 29743.1 -1249.03 171.82 - 29 4188.8 29621.9 -1355.67 177.95 - 30 4099.9 29320.6 -1461.23 184.02 + 1 4192.9 29636.9 1793.40 5.62 + 2 4219.2 29728.1 1686.38 11.25 + 3 4227.2 29755.6 1579.26 16.89 + 4 4231.6 29771.1 1472.08 22.53 + 5 4232.9 29775.6 1364.89 28.17 + 6 4225.2 29749.9 1257.79 33.80 + 7 4194.0 29644.4 1151.07 39.42 + 8 4114.4 29374.5 1045.32 44.99 + 9 4101.4 29325.8 939.75 50.54 + 10 4212.2 29702.5 832.82 56.17 + 11 4224.2 29744.1 725.74 61.80 + 12 4230.1 29764.4 618.59 67.44 + 13 4233.3 29775.3 511.40 73.09 + 14 4231.4 29768.8 404.23 78.73 + 15 4214.5 29711.6 297.27 84.36 + 16 4162.9 29536.5 190.94 89.95 + 17 4055.6 29171.2 85.92 95.48 + 18 4194.3 29636.9 -20.77 101.09 + 19 4220.5 29728.1 -127.79 106.73 + 20 4228.5 29755.6 -234.91 112.36 + 21 4233.0 29771.1 -342.09 118.00 + 22 4234.2 29775.6 -449.28 123.65 + 23 4226.6 29749.9 -556.38 129.28 + 24 4195.3 29644.4 -663.10 134.90 + 25 4115.7 29374.5 -768.85 140.46 + 26 4102.7 29325.8 -874.42 146.02 + 27 4213.5 29702.5 -981.35 151.65 + 28 4225.4 29744.1 -1088.43 157.28 + 29 4231.2 29764.4 -1195.58 162.92 + 30 4234.3 29775.2 -1302.77 168.56 *************************** * SAM CASH FLOW PROFILE * @@ -214,52 +214,52 @@ 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 ENERGY -Electricity to grid (kWh) 0.0 4,193,226,013 4,219,526,008 4,227,429,703 4,231,130,578 4,227,174,470 4,200,800,078 4,123,944,075 4,085,524,292 4,210,727,110 4,223,816,595 4,229,858,515 4,231,437,288 4,220,815,353 4,177,617,023 4,069,152,556 4,162,009,516 4,218,752,453 4,227,707,328 4,232,132,093 4,230,152,834 4,209,135,409 4,143,119,490 4,067,069,431 4,207,503,902 4,223,608,553 4,230,238,414 4,232,692,624 4,225,013,608 4,189,101,096 4,100,244,505 +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 -Electricity to grid net (kWh) 0.0 4,193,226,013 4,219,526,008 4,227,429,703 4,231,130,578 4,227,174,470 4,200,800,078 4,123,944,075 4,085,524,292 4,210,727,110 4,223,816,595 4,229,858,515 4,231,437,288 4,220,815,353 4,177,617,023 4,069,152,556 4,162,009,516 4,218,752,453 4,227,707,328 4,232,132,093 4,230,152,834 4,209,135,409 4,143,119,490 4,067,069,431 4,207,503,902 4,223,608,553 4,230,238,414 4,232,692,624 4,225,013,608 4,189,101,096 4,100,244,505 +Electricity to grid net (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 REVENUE PPA price (cents/kWh) 0.0 9.50 9.50 9.56 9.61 9.67 9.73 9.79 9.84 9.90 9.96 10.01 10.07 10.13 10.18 10.24 10.30 10.36 10.41 10.47 10.53 10.58 10.64 10.70 10.75 10.81 10.87 10.93 10.98 11.04 11.10 -PPA revenue ($) 0 398,356,471 400,854,971 404,015,457 406,780,894 408,810,043 408,653,832 403,527,928 402,097,301 416,819,877 420,523,180 423,535,733 426,105,735 427,441,971 425,448,518 416,721,913 428,603,740 436,851,817 440,188,887 443,061,909 445,265,887 445,452,800 440,827,914 435,054,417 452,474,970 456,614,321 459,742,311 462,421,669 463,990,994 462,434,870 454,963,130 +PPA revenue ($) 0 398,360,985 400,859,527 404,023,741 406,864,580 409,397,136 411,063,538 410,414,761 404,969,797 406,031,044 419,401,219 423,001,126 426,005,415 428,743,476 430,961,457 431,646,734 428,727,061 419,997,324 436,749,150 441,881,181 445,124,646 448,012,403 450,560,179 452,154,561 451,204,088 444,986,839 445,920,708 460,361,453 464,070,591 467,122,922 469,876,708 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 1,319,338,295 -Total revenue ($) 0 398,356,471 400,854,971 404,015,457 406,780,894 408,810,043 408,653,832 403,527,928 402,097,301 416,819,877 420,523,180 423,535,733 426,105,735 427,441,971 425,448,518 416,721,913 428,603,740 436,851,817 440,188,887 443,061,909 445,265,887 445,452,800 440,827,914 435,054,417 452,474,970 456,614,321 459,742,311 462,421,669 463,990,994 462,434,870 1,774,301,426 +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 1,319,696,135 +Total revenue ($) 0 398,360,985 400,859,527 404,023,741 406,864,580 409,397,136 411,063,538 410,414,761 404,969,797 406,031,044 419,401,219 423,001,126 426,005,415 428,743,476 430,961,457 431,646,734 428,727,061 419,997,324 436,749,150 441,881,181 445,124,646 448,012,403 450,560,179 452,154,561 451,204,088 444,986,839 445,920,708 460,361,453 464,070,591 467,122,922 1,789,572,843 -Property tax net assessed value ($) 0 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 2,638,676,591 +Property tax net assessed value ($) 0 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 2,639,392,270 OPERATING EXPENSES -O&M fixed expense ($) 0 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 +O&M fixed expense ($) 0 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 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 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 126,942,824 +Total operating expenses ($) 0 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 126,952,474 -EBITDA ($) 0 271,413,648 273,912,147 277,072,633 279,838,070 281,867,219 281,711,008 276,585,104 275,154,477 289,877,053 293,580,357 296,592,910 299,162,911 300,499,147 298,505,694 289,779,090 301,660,916 309,908,993 313,246,063 316,119,085 318,323,064 318,509,977 313,885,090 308,111,593 325,532,146 329,671,497 332,799,487 335,478,846 337,048,171 335,492,046 1,647,358,602 +EBITDA ($) 0 271,408,511 273,907,053 277,071,267 279,912,106 282,444,662 284,111,065 283,462,287 278,017,323 279,078,570 292,448,745 296,048,652 299,052,941 301,791,002 304,008,984 304,694,260 301,774,587 293,044,850 309,796,677 314,928,707 318,172,172 321,059,929 323,607,705 325,202,088 324,251,614 318,034,365 318,968,234 333,408,979 337,118,117 340,170,448 1,662,620,369 OPERATING ACTIVITIES -EBITDA ($) 0 271,413,648 273,912,147 277,072,633 279,838,070 281,867,219 281,711,008 276,585,104 275,154,477 289,877,053 293,580,357 296,592,910 299,162,911 300,499,147 298,505,694 289,779,090 301,660,916 309,908,993 313,246,063 316,119,085 318,323,064 318,509,977 313,885,090 308,111,593 325,532,146 329,671,497 332,799,487 335,478,846 337,048,171 335,492,046 1,647,358,602 +EBITDA ($) 0 271,408,511 273,907,053 277,071,267 279,912,106 282,444,662 284,111,065 283,462,287 278,017,323 279,078,570 292,448,745 296,048,652 299,052,941 301,791,002 304,008,984 304,694,260 301,774,587 293,044,850 309,796,677 314,928,707 318,172,172 321,059,929 323,607,705 325,202,088 324,251,614 318,034,365 318,968,234 333,408,979 337,118,117 340,170,448 1,662,620,369 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 88,659,533 87,456,683 86,186,473 84,845,131 83,428,674 81,932,896 80,353,353 78,685,357 76,923,953 75,063,910 73,099,704 71,025,503 68,835,147 66,522,131 64,079,586 61,500,259 58,776,489 55,900,188 52,862,814 49,655,347 46,268,262 42,691,501 38,914,441 34,925,865 30,713,929 26,266,125 21,569,243 16,609,337 11,371,675 5,840,705 -Cash flow from operating activities ($) 0 182,754,114 186,455,464 190,886,160 194,992,939 198,438,545 199,778,112 196,231,751 196,469,120 212,953,101 218,516,447 223,493,205 228,137,408 231,664,000 231,983,563 225,699,504 240,160,658 251,132,504 257,345,876 263,256,271 268,667,716 272,241,714 271,193,589 269,197,153 290,606,281 298,957,568 306,533,362 313,909,602 320,438,834 324,120,371 1,641,517,897 +Debt interest payment ($) 0 88,683,580 87,480,404 86,209,849 84,868,143 83,451,302 81,955,118 80,375,147 78,706,698 76,944,816 75,084,269 73,119,531 71,044,767 68,853,817 66,540,174 64,096,966 61,516,939 58,792,431 55,915,350 52,877,152 49,668,815 46,280,812 42,703,080 38,924,995 34,935,338 30,722,260 26,273,249 21,575,094 16,613,842 11,374,760 5,842,289 +Cash flow from operating activities ($) 0 182,724,931 186,426,650 190,861,418 195,043,962 198,993,360 202,155,947 203,087,139 199,310,625 202,133,754 217,364,476 222,929,121 228,008,174 232,937,185 237,468,810 240,597,294 240,257,648 234,252,419 253,881,327 262,051,556 268,503,357 274,779,118 280,904,625 286,277,092 289,316,276 287,312,106 292,694,985 311,833,886 320,504,276 328,795,689 1,656,778,080 INVESTING ACTIVITIES -Total installed cost ($) -2,638,676,591 +Total installed cost ($) -2,639,392,270 Debt closing costs ($) 0 Debt up-front fee ($) 0 minus: Total IBI income ($) 0 Total CBI income ($) 0 equals: -Purchase of property ($) -2,638,676,591 +Purchase of property ($) -2,639,392,270 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 @@ -271,86 +271,86 @@ Reserve capital spending major equipment 1 ($) 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 ($) -2,638,676,591 0 0 0 0 0 0 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 investing activities ($) -2,639,392,270 0 0 0 0 0 0 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 ($) 1,055,470,636 -Size of debt ($) 1,583,205,954 +Issuance of equity ($) 1,055,756,908 +Size of debt ($) 1,583,635,362 minus: -Debt principal payment ($) 0 21,479,472 22,682,323 23,952,533 25,293,875 26,710,332 28,206,110 29,785,652 31,453,649 33,215,053 35,075,096 37,039,301 39,113,502 41,303,858 43,616,875 46,059,419 48,638,747 51,362,517 54,238,818 57,276,192 60,483,658 63,870,743 67,447,505 71,224,565 75,213,141 79,425,077 83,872,881 88,569,762 93,529,669 98,767,330 104,298,301 +Debt principal payment ($) 0 21,485,298 22,688,475 23,959,029 25,300,735 26,717,576 28,213,760 29,793,731 31,462,180 33,224,062 35,084,609 37,049,347 39,124,111 41,315,061 43,628,705 46,071,912 48,651,939 51,376,448 54,253,529 57,291,726 60,500,063 63,888,067 67,465,798 71,243,883 75,233,540 79,446,619 83,895,629 88,593,785 93,555,037 98,794,119 104,326,589 equals: -Cash flow from financing activities ($) 2,638,676,591 -21,479,472 -22,682,323 -23,952,533 -25,293,875 -26,710,332 -28,206,110 -29,785,652 -31,453,649 -33,215,053 -35,075,096 -37,039,301 -39,113,502 -41,303,858 -43,616,875 -46,059,419 -48,638,747 -51,362,517 -54,238,818 -57,276,192 -60,483,658 -63,870,743 -67,447,505 -71,224,565 -75,213,141 -79,425,077 -83,872,881 -88,569,762 -93,529,669 -98,767,330 -104,298,301 +Cash flow from financing activities ($) 2,639,392,270 -21,485,298 -22,688,475 -23,959,029 -25,300,735 -26,717,576 -28,213,760 -29,793,731 -31,462,180 -33,224,062 -35,084,609 -37,049,347 -39,124,111 -41,315,061 -43,628,705 -46,071,912 -48,651,939 -51,376,448 -54,253,529 -57,291,726 -60,500,063 -63,888,067 -67,465,798 -71,243,883 -75,233,540 -79,446,619 -83,895,629 -88,593,785 -93,555,037 -98,794,119 -104,326,589 PROJECT RETURNS Pre-tax Cash Flow: -Cash flow from operating activities ($) 0 182,754,114 186,455,464 190,886,160 194,992,939 198,438,545 199,778,112 196,231,751 196,469,120 212,953,101 218,516,447 223,493,205 228,137,408 231,664,000 231,983,563 225,699,504 240,160,658 251,132,504 257,345,876 263,256,271 268,667,716 272,241,714 271,193,589 269,197,153 290,606,281 298,957,568 306,533,362 313,909,602 320,438,834 324,120,371 1,641,517,897 -Cash flow from investing activities ($) -2,638,676,591 0 0 0 0 0 0 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 ($) 2,638,676,591 -21,479,472 -22,682,323 -23,952,533 -25,293,875 -26,710,332 -28,206,110 -29,785,652 -31,453,649 -33,215,053 -35,075,096 -37,039,301 -39,113,502 -41,303,858 -43,616,875 -46,059,419 -48,638,747 -51,362,517 -54,238,818 -57,276,192 -60,483,658 -63,870,743 -67,447,505 -71,224,565 -75,213,141 -79,425,077 -83,872,881 -88,569,762 -93,529,669 -98,767,330 -104,298,301 -Total pre-tax cash flow ($) 0 161,274,642 163,773,142 166,933,628 169,699,065 171,728,214 171,572,002 166,446,099 165,015,472 179,738,047 183,441,351 186,453,904 189,023,906 190,360,142 188,366,688 179,640,084 191,521,911 199,769,987 203,107,058 205,980,080 208,184,058 208,370,971 203,746,085 197,972,588 215,393,140 219,532,492 222,660,482 225,339,840 226,909,165 225,353,041 1,537,219,596 +Cash flow from operating activities ($) 0 182,724,931 186,426,650 190,861,418 195,043,962 198,993,360 202,155,947 203,087,139 199,310,625 202,133,754 217,364,476 222,929,121 228,008,174 232,937,185 237,468,810 240,597,294 240,257,648 234,252,419 253,881,327 262,051,556 268,503,357 274,779,118 280,904,625 286,277,092 289,316,276 287,312,106 292,694,985 311,833,886 320,504,276 328,795,689 1,656,778,080 +Cash flow from investing activities ($) -2,639,392,270 0 0 0 0 0 0 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 ($) 2,639,392,270 -21,485,298 -22,688,475 -23,959,029 -25,300,735 -26,717,576 -28,213,760 -29,793,731 -31,462,180 -33,224,062 -35,084,609 -37,049,347 -39,124,111 -41,315,061 -43,628,705 -46,071,912 -48,651,939 -51,376,448 -54,253,529 -57,291,726 -60,500,063 -63,888,067 -67,465,798 -71,243,883 -75,233,540 -79,446,619 -83,895,629 -88,593,785 -93,555,037 -98,794,119 -104,326,589 +Total pre-tax cash flow ($) 0 161,239,633 163,738,175 166,902,389 169,743,227 172,275,784 173,942,186 173,293,409 167,848,445 168,909,692 182,279,867 185,879,774 188,884,063 191,622,124 193,840,105 194,525,382 191,605,709 182,875,972 199,627,798 204,759,829 208,003,294 210,891,051 213,438,827 215,033,209 214,082,736 207,865,487 208,799,356 223,240,101 226,949,239 230,001,570 1,552,451,491 Pre-tax Returns: -Issuance of equity ($) 1,055,470,636 -Total pre-tax cash flow ($) 0 161,274,642 163,773,142 166,933,628 169,699,065 171,728,214 171,572,002 166,446,099 165,015,472 179,738,047 183,441,351 186,453,904 189,023,906 190,360,142 188,366,688 179,640,084 191,521,911 199,769,987 203,107,058 205,980,080 208,184,058 208,370,971 203,746,085 197,972,588 215,393,140 219,532,492 222,660,482 225,339,840 226,909,165 225,353,041 1,537,219,596 -Total pre-tax returns ($) -1,055,470,636 161,274,642 163,773,142 166,933,628 169,699,065 171,728,214 171,572,002 166,446,099 165,015,472 179,738,047 183,441,351 186,453,904 189,023,906 190,360,142 188,366,688 179,640,084 191,521,911 199,769,987 203,107,058 205,980,080 208,184,058 208,370,971 203,746,085 197,972,588 215,393,140 219,532,492 222,660,482 225,339,840 226,909,165 225,353,041 1,537,219,596 +Issuance of equity ($) 1,055,756,908 +Total pre-tax cash flow ($) 0 161,239,633 163,738,175 166,902,389 169,743,227 172,275,784 173,942,186 173,293,409 167,848,445 168,909,692 182,279,867 185,879,774 188,884,063 191,622,124 193,840,105 194,525,382 191,605,709 182,875,972 199,627,798 204,759,829 208,003,294 210,891,051 213,438,827 215,033,209 214,082,736 207,865,487 208,799,356 223,240,101 226,949,239 230,001,570 1,552,451,491 +Total pre-tax returns ($) -1,055,756,908 161,239,633 163,738,175 166,902,389 169,743,227 172,275,784 173,942,186 173,293,409 167,848,445 168,909,692 182,279,867 185,879,774 188,884,063 191,622,124 193,840,105 194,525,382 191,605,709 182,875,972 199,627,798 204,759,829 208,003,294 210,891,051 213,438,827 215,033,209 214,082,736 207,865,487 208,799,356 223,240,101 226,949,239 230,001,570 1,552,451,491 After-tax Returns: -Total pre-tax returns ($) -1,055,470,636 161,274,642 163,773,142 166,933,628 169,699,065 171,728,214 171,572,002 166,446,099 165,015,472 179,738,047 183,441,351 186,453,904 189,023,906 190,360,142 188,366,688 179,640,084 191,521,911 199,769,987 203,107,058 205,980,080 208,184,058 208,370,971 203,746,085 197,972,588 215,393,140 219,532,492 222,660,482 225,339,840 226,909,165 225,353,041 1,537,219,596 -Federal ITC total income ($) 0 791,602,977 0 0 0 0 0 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 pre-tax returns ($) -1,055,756,908 161,239,633 163,738,175 166,902,389 169,743,227 172,275,784 173,942,186 173,293,409 167,848,445 168,909,692 182,279,867 185,879,774 188,884,063 191,622,124 193,840,105 194,525,382 191,605,709 182,875,972 199,627,798 204,759,829 208,003,294 210,891,051 213,438,827 215,033,209 214,082,736 207,865,487 208,799,356 223,240,101 226,949,239 230,001,570 1,552,451,491 +Federal ITC total income ($) 0 791,817,681 0 0 0 0 0 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 -24,741,041 -14,513,077 -15,378,392 -16,180,446 -16,853,373 -17,114,990 -16,422,386 -16,468,744 -19,688,065 -20,774,587 -21,746,548 -22,653,560 -23,342,304 -23,404,714 -22,177,438 -25,001,701 -27,144,503 -28,357,974 -29,512,274 -30,569,130 -42,217,969 -52,964,108 -52,574,204 -56,755,407 -58,386,413 -59,865,966 -61,306,545 -62,581,704 -63,300,708 -320,588,445 +Federal tax benefit (liability) ($) 0 -24,732,371 -14,501,509 -15,367,619 -16,184,470 -16,955,788 -17,573,441 -17,755,303 -17,017,749 -17,569,106 -20,543,667 -21,630,442 -22,622,381 -23,585,017 -24,470,043 -25,081,036 -25,014,703 -23,841,882 -27,675,407 -29,271,053 -30,531,090 -42,710,554 -54,860,673 -55,909,916 -56,503,469 -56,112,054 -57,163,331 -60,901,158 -62,594,485 -64,213,798 -323,568,759 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 -8,867,757 -5,201,820 -5,511,968 -5,799,443 -6,040,635 -6,134,405 -5,886,160 -5,902,776 -7,056,654 -7,446,088 -7,794,462 -8,119,556 -8,366,417 -8,388,787 -7,948,902 -8,961,183 -9,729,212 -10,164,148 -10,577,876 -10,956,677 -15,131,889 -18,983,551 -18,843,801 -20,342,440 -20,927,030 -21,457,335 -21,973,672 -22,430,718 -22,688,426 -114,906,253 -Total after-tax returns ($) -1,055,470,636 919,268,822 144,058,245 146,043,268 147,719,176 148,834,206 148,322,607 144,137,553 142,643,952 152,993,328 155,220,676 156,912,895 158,250,790 158,651,421 156,573,187 149,513,744 157,559,027 162,896,272 164,584,935 165,889,929 166,658,251 151,021,113 131,798,425 126,554,583 138,295,294 140,219,049 141,337,181 142,059,623 141,896,743 139,363,906 1,101,724,898 +State tax benefit (liability) ($) 0 -8,864,649 -5,197,673 -5,508,107 -5,800,885 -6,077,343 -6,298,724 -6,363,908 -6,099,552 -6,297,171 -7,363,321 -7,752,846 -8,108,380 -8,453,411 -8,770,625 -8,989,619 -8,965,843 -8,545,477 -9,919,501 -10,491,417 -10,943,043 -15,308,442 -19,663,324 -20,039,396 -20,252,139 -20,111,847 -20,488,649 -21,828,372 -22,435,299 -23,015,698 -115,974,466 +Total after-tax returns ($) -1,055,756,908 919,460,294 144,038,993 146,026,663 147,757,872 149,242,653 150,070,021 149,174,198 144,731,144 145,043,415 154,372,879 156,496,486 158,153,302 159,583,696 160,599,438 160,454,727 157,625,163 150,488,612 162,032,890 164,997,359 166,529,161 152,872,055 138,914,830 139,083,897 137,327,127 131,641,585 131,147,376 140,510,571 141,919,455 142,772,074 1,112,908,266 -After-tax cumulative IRR (%) NaN -12.90 0.66 10.71 17.41 21.79 24.67 26.57 27.86 28.84 29.54 30.05 30.42 30.69 30.89 31.03 31.14 31.22 31.29 31.34 31.37 31.40 31.42 31.43 31.44 31.45 31.45 31.46 31.46 31.46 31.48 -After-tax cumulative NPV ($) -1,055,470,636 -253,148,316 -143,411,891 -46,316,062 39,400,008 114,776,248 180,337,225 235,943,211 283,972,274 328,932,614 368,744,523 403,870,500 434,789,256 461,842,941 485,145,639 504,566,849 522,429,453 538,547,744 552,761,350 565,265,108 576,228,721 584,899,754 591,504,404 597,039,482 602,318,580 606,990,179 611,099,985 614,705,290 617,848,331 620,542,558 639,131,877 +After-tax cumulative IRR (%) NaN -12.91 0.65 10.70 17.40 21.79 24.70 26.65 27.96 28.88 29.57 30.08 30.45 30.72 30.92 31.07 31.18 31.26 31.32 31.37 31.41 31.43 31.45 31.47 31.48 31.48 31.49 31.49 31.50 31.50 31.51 +After-tax cumulative NPV ($) -1,055,756,908 -253,267,474 -143,545,715 -46,460,926 39,277,598 114,860,694 181,194,056 238,743,100 287,474,933 330,099,021 369,693,483 404,726,244 435,625,953 462,838,612 486,740,535 507,582,932 525,453,035 540,343,610 554,336,820 566,773,302 577,728,422 586,505,730 593,466,995 599,550,064 604,792,204 609,178,033 612,991,540 616,557,531 619,701,075 622,461,191 641,239,205 AFTER-TAX LCOE AND PPA PRICE -Annual costs ($) -1,055,470,636 520,912,351 -256,796,726 -257,972,189 -259,061,718 -259,975,837 -260,331,224 -259,390,374 -259,453,349 -263,826,549 -265,302,504 -266,622,838 -267,854,945 -268,790,550 -268,875,330 -267,208,169 -271,044,713 -273,955,544 -275,603,952 -277,171,980 -278,607,636 -294,431,687 -309,029,488 -308,499,834 -314,179,676 -316,395,272 -318,405,130 -320,362,047 -322,094,252 -323,070,964 646,761,768 -PPA revenue ($) 0 398,356,471 400,854,971 404,015,457 406,780,894 408,810,043 408,653,832 403,527,928 402,097,301 416,819,877 420,523,180 423,535,733 426,105,735 427,441,971 425,448,518 416,721,913 428,603,740 436,851,817 440,188,887 443,061,909 445,265,887 445,452,800 440,827,914 435,054,417 452,474,970 456,614,321 459,742,311 462,421,669 463,990,994 462,434,870 454,963,130 -Electricity to grid (kWh) 0.0 4,193,226,013 4,219,526,008 4,227,429,703 4,231,130,578 4,227,174,470 4,200,800,078 4,123,944,075 4,085,524,292 4,210,727,110 4,223,816,595 4,229,858,515 4,231,437,288 4,220,815,353 4,177,617,023 4,069,152,556 4,162,009,516 4,218,752,453 4,227,707,328 4,232,132,093 4,230,152,834 4,209,135,409 4,143,119,490 4,067,069,431 4,207,503,902 4,223,608,553 4,230,238,414 4,232,692,624 4,225,013,608 4,189,101,096 4,100,244,505 +Annual costs ($) -1,055,756,908 521,099,309 -256,820,535 -257,997,079 -259,106,708 -260,154,483 -260,993,517 -261,240,562 -260,238,653 -260,987,629 -265,028,340 -266,504,640 -267,852,113 -269,159,780 -270,362,020 -271,192,006 -271,101,898 -269,508,711 -274,716,260 -276,883,822 -278,595,485 -295,140,348 -311,645,349 -313,070,665 -313,876,960 -313,345,254 -314,773,332 -319,850,882 -322,151,136 -324,350,848 643,031,558 +PPA revenue ($) 0 398,360,985 400,859,527 404,023,741 406,864,580 409,397,136 411,063,538 410,414,761 404,969,797 406,031,044 419,401,219 423,001,126 426,005,415 428,743,476 430,961,457 431,646,734 428,727,061 419,997,324 436,749,150 441,881,181 445,124,646 448,012,403 450,560,179 452,154,561 451,204,088 444,986,839 445,920,708 460,361,453 464,070,591 467,122,922 469,876,708 +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 -Present value of annual costs ($) 2,139,727,071 -Present value of annual energy nominal (kWh) 28,322,755,320 +Present value of annual costs ($) 2,140,838,644 +Present value of annual energy nominal (kWh) 28,355,365,264 LCOE Levelized cost of energy nominal (cents/kWh) 7.55 -Present value of PPA revenue ($) 2,778,858,948 -Present value of annual energy nominal (kWh) 28,322,755,320 +Present value of PPA revenue ($) 2,782,077,849 +Present value of annual energy nominal (kWh) 28,355,365,264 LPPA Levelized PPA price nominal (cents/kWh) 9.81 PROJECT STATE INCOME TAXES -EBITDA ($) 0 271,413,648 273,912,147 277,072,633 279,838,070 281,867,219 281,711,008 276,585,104 275,154,477 289,877,053 293,580,357 296,592,910 299,162,911 300,499,147 298,505,694 289,779,090 301,660,916 309,908,993 313,246,063 316,119,085 318,323,064 318,509,977 313,885,090 308,111,593 325,532,146 329,671,497 332,799,487 335,478,846 337,048,171 335,492,046 1,647,358,602 +EBITDA ($) 0 271,408,511 273,907,053 277,071,267 279,912,106 282,444,662 284,111,065 283,462,287 278,017,323 279,078,570 292,448,745 296,048,652 299,052,941 301,791,002 304,008,984 304,694,260 301,774,587 293,044,850 309,796,677 314,928,707 318,172,172 321,059,929 323,607,705 325,202,088 324,251,614 318,034,365 318,968,234 333,408,979 337,118,117 340,170,448 1,662,620,369 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 88,659,533 87,456,683 86,186,473 84,845,131 83,428,674 81,932,896 80,353,353 78,685,357 76,923,953 75,063,910 73,099,704 71,025,503 68,835,147 66,522,131 64,079,586 61,500,259 58,776,489 55,900,188 52,862,814 49,655,347 46,268,262 42,691,501 38,914,441 34,925,865 30,713,929 26,266,125 21,569,243 16,609,337 11,371,675 5,840,705 -Total state tax depreciation ($) 0 56,071,878 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 56,071,878 0 0 0 0 0 0 0 0 0 +Debt interest payment ($) 0 88,683,580 87,480,404 86,209,849 84,868,143 83,451,302 81,955,118 80,375,147 78,706,698 76,944,816 75,084,269 73,119,531 71,044,767 68,853,817 66,540,174 64,096,966 61,516,939 58,792,431 55,915,350 52,877,152 49,668,815 46,280,812 42,703,080 38,924,995 34,935,338 30,722,260 26,273,249 21,575,094 16,613,842 11,374,760 5,842,289 +Total state tax depreciation ($) 0 56,087,086 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 56,087,086 0 0 0 0 0 0 0 0 0 equals: -State taxable income ($) 0 126,682,237 74,311,709 78,742,405 82,849,184 86,294,790 87,634,357 84,087,996 84,325,365 100,809,345 106,372,692 111,349,450 115,993,653 119,520,245 119,839,808 113,555,749 128,016,903 138,988,749 145,202,120 151,112,516 156,523,961 216,169,837 271,193,589 269,197,153 290,606,281 298,957,568 306,533,362 313,909,602 320,438,834 324,120,371 1,641,517,897 +State taxable income ($) 0 126,637,845 74,252,478 78,687,247 82,869,791 86,819,189 89,981,775 90,912,968 87,136,453 89,959,582 105,190,305 110,754,950 115,834,002 120,763,014 125,294,638 128,423,122 128,083,477 122,078,248 141,707,156 149,877,384 156,329,186 218,692,032 280,904,625 286,277,092 289,316,276 287,312,106 292,694,985 311,833,886 320,504,276 328,795,689 1,656,778,080 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 -8,867,757 -5,201,820 -5,511,968 -5,799,443 -6,040,635 -6,134,405 -5,886,160 -5,902,776 -7,056,654 -7,446,088 -7,794,462 -8,119,556 -8,366,417 -8,388,787 -7,948,902 -8,961,183 -9,729,212 -10,164,148 -10,577,876 -10,956,677 -15,131,889 -18,983,551 -18,843,801 -20,342,440 -20,927,030 -21,457,335 -21,973,672 -22,430,718 -22,688,426 -114,906,253 +State tax benefit (liability) ($) 0 -8,864,649 -5,197,673 -5,508,107 -5,800,885 -6,077,343 -6,298,724 -6,363,908 -6,099,552 -6,297,171 -7,363,321 -7,752,846 -8,108,380 -8,453,411 -8,770,625 -8,989,619 -8,965,843 -8,545,477 -9,919,501 -10,491,417 -10,943,043 -15,308,442 -19,663,324 -20,039,396 -20,252,139 -20,111,847 -20,488,649 -21,828,372 -22,435,299 -23,015,698 -115,974,466 PROJECT FEDERAL INCOME TAXES -EBITDA ($) 0 271,413,648 273,912,147 277,072,633 279,838,070 281,867,219 281,711,008 276,585,104 275,154,477 289,877,053 293,580,357 296,592,910 299,162,911 300,499,147 298,505,694 289,779,090 301,660,916 309,908,993 313,246,063 316,119,085 318,323,064 318,509,977 313,885,090 308,111,593 325,532,146 329,671,497 332,799,487 335,478,846 337,048,171 335,492,046 1,647,358,602 +EBITDA ($) 0 271,408,511 273,907,053 277,071,267 279,912,106 282,444,662 284,111,065 283,462,287 278,017,323 279,078,570 292,448,745 296,048,652 299,052,941 301,791,002 304,008,984 304,694,260 301,774,587 293,044,850 309,796,677 314,928,707 318,172,172 321,059,929 323,607,705 325,202,088 324,251,614 318,034,365 318,968,234 333,408,979 337,118,117 340,170,448 1,662,620,369 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 -8,867,757 -5,201,820 -5,511,968 -5,799,443 -6,040,635 -6,134,405 -5,886,160 -5,902,776 -7,056,654 -7,446,088 -7,794,462 -8,119,556 -8,366,417 -8,388,787 -7,948,902 -8,961,183 -9,729,212 -10,164,148 -10,577,876 -10,956,677 -15,131,889 -18,983,551 -18,843,801 -20,342,440 -20,927,030 -21,457,335 -21,973,672 -22,430,718 -22,688,426 -114,906,253 +State tax benefit (liability) ($) 0 -8,864,649 -5,197,673 -5,508,107 -5,800,885 -6,077,343 -6,298,724 -6,363,908 -6,099,552 -6,297,171 -7,363,321 -7,752,846 -8,108,380 -8,453,411 -8,770,625 -8,989,619 -8,965,843 -8,545,477 -9,919,501 -10,491,417 -10,943,043 -15,308,442 -19,663,324 -20,039,396 -20,252,139 -20,111,847 -20,488,649 -21,828,372 -22,435,299 -23,015,698 -115,974,466 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 88,659,533 87,456,683 86,186,473 84,845,131 83,428,674 81,932,896 80,353,353 78,685,357 76,923,953 75,063,910 73,099,704 71,025,503 68,835,147 66,522,131 64,079,586 61,500,259 58,776,489 55,900,188 52,862,814 49,655,347 46,268,262 42,691,501 38,914,441 34,925,865 30,713,929 26,266,125 21,569,243 16,609,337 11,371,675 5,840,705 -Total federal tax depreciation ($) 0 56,071,878 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 112,143,755 56,071,878 0 0 0 0 0 0 0 0 0 +Debt interest payment ($) 0 88,683,580 87,480,404 86,209,849 84,868,143 83,451,302 81,955,118 80,375,147 78,706,698 76,944,816 75,084,269 73,119,531 71,044,767 68,853,817 66,540,174 64,096,966 61,516,939 58,792,431 55,915,350 52,877,152 49,668,815 46,280,812 42,703,080 38,924,995 34,935,338 30,722,260 26,273,249 21,575,094 16,613,842 11,374,760 5,842,289 +Total federal tax depreciation ($) 0 56,087,086 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 112,174,171 56,087,086 0 0 0 0 0 0 0 0 0 equals: -Federal taxable income ($) 0 117,814,480 69,109,890 73,230,437 77,049,741 80,254,155 81,499,952 78,201,836 78,422,590 93,752,691 98,926,604 103,554,989 107,874,097 111,153,828 111,451,021 105,606,846 119,055,720 129,259,537 135,037,972 140,534,640 145,567,284 201,037,948 252,210,038 250,353,352 270,263,841 278,030,538 285,076,027 291,935,930 298,008,116 301,431,945 1,526,611,644 +Federal taxable income ($) 0 117,773,196 69,054,805 73,179,140 77,068,905 80,741,845 83,683,051 84,549,060 81,036,901 83,662,411 97,826,983 103,002,103 107,725,622 112,309,603 116,524,014 119,433,504 119,117,633 113,532,771 131,787,655 139,385,967 145,386,143 203,383,590 261,241,301 266,237,696 269,064,137 267,200,258 272,206,336 290,005,514 298,068,976 305,779,991 1,540,803,615 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 -24,741,041 -14,513,077 -15,378,392 -16,180,446 -16,853,373 -17,114,990 -16,422,386 -16,468,744 -19,688,065 -20,774,587 -21,746,548 -22,653,560 -23,342,304 -23,404,714 -22,177,438 -25,001,701 -27,144,503 -28,357,974 -29,512,274 -30,569,130 -42,217,969 -52,964,108 -52,574,204 -56,755,407 -58,386,413 -59,865,966 -61,306,545 -62,581,704 -63,300,708 -320,588,445 +Federal tax benefit (liability) ($) 0 -24,732,371 -14,501,509 -15,367,619 -16,184,470 -16,955,788 -17,573,441 -17,755,303 -17,017,749 -17,569,106 -20,543,667 -21,630,442 -22,622,381 -23,585,017 -24,470,043 -25,081,036 -25,014,703 -23,841,882 -27,675,407 -29,271,053 -30,531,090 -42,710,554 -54,860,673 -55,909,916 -56,503,469 -56,112,054 -57,163,331 -60,901,158 -62,594,485 -64,213,798 -323,568,759 CASH INCENTIVES Federal IBI income ($) 0 @@ -376,30 +376,30 @@ Federal PTC income ($) 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 791,602,977 0 0 0 0 0 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 791,602,977 0 0 0 0 0 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 791,817,681 0 0 0 0 0 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 791,817,681 0 0 0 0 0 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 ($) 1,583,205,954 1,561,726,482 1,539,044,160 1,515,091,627 1,489,797,752 1,463,087,421 1,434,881,311 1,405,095,659 1,373,642,010 1,340,426,957 1,305,351,861 1,268,312,559 1,229,199,057 1,187,895,198 1,144,278,324 1,098,218,904 1,049,580,157 998,217,641 943,978,823 886,702,631 826,218,973 762,348,230 694,900,725 623,676,160 548,463,019 469,037,943 385,165,062 296,595,300 203,065,631 104,298,301 0 -Debt interest payment ($) 0 88,659,533 87,456,683 86,186,473 84,845,131 83,428,674 81,932,896 80,353,353 78,685,357 76,923,953 75,063,910 73,099,704 71,025,503 68,835,147 66,522,131 64,079,586 61,500,259 58,776,489 55,900,188 52,862,814 49,655,347 46,268,262 42,691,501 38,914,441 34,925,865 30,713,929 26,266,125 21,569,243 16,609,337 11,371,675 5,840,705 -Debt principal payment ($) 0 21,479,472 22,682,323 23,952,533 25,293,875 26,710,332 28,206,110 29,785,652 31,453,649 33,215,053 35,075,096 37,039,301 39,113,502 41,303,858 43,616,875 46,059,419 48,638,747 51,362,517 54,238,818 57,276,192 60,483,658 63,870,743 67,447,505 71,224,565 75,213,141 79,425,077 83,872,881 88,569,762 93,529,669 98,767,330 104,298,301 -Debt total payment ($) 0 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 +Debt balance ($) 1,583,635,362 1,562,150,064 1,539,461,590 1,515,502,560 1,490,201,825 1,463,484,249 1,435,270,489 1,405,476,758 1,374,014,578 1,340,790,516 1,305,705,907 1,268,656,560 1,229,532,449 1,188,217,387 1,144,588,683 1,098,516,771 1,049,864,832 998,488,384 944,234,855 886,943,129 826,443,066 762,554,999 695,089,201 623,845,318 548,611,777 469,165,159 385,269,529 296,675,745 203,120,708 104,326,589 0 +Debt interest payment ($) 0 88,683,580 87,480,404 86,209,849 84,868,143 83,451,302 81,955,118 80,375,147 78,706,698 76,944,816 75,084,269 73,119,531 71,044,767 68,853,817 66,540,174 64,096,966 61,516,939 58,792,431 55,915,350 52,877,152 49,668,815 46,280,812 42,703,080 38,924,995 34,935,338 30,722,260 26,273,249 21,575,094 16,613,842 11,374,760 5,842,289 +Debt principal payment ($) 0 21,485,298 22,688,475 23,959,029 25,300,735 26,717,576 28,213,760 29,793,731 31,462,180 33,224,062 35,084,609 37,049,347 39,124,111 41,315,061 43,628,705 46,071,912 48,651,939 51,376,448 54,253,529 57,291,726 60,500,063 63,888,067 67,465,798 71,243,883 75,233,540 79,446,619 83,895,629 88,593,785 93,555,037 98,794,119 104,326,589 +Debt total payment ($) 0 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 DSCR (DEBT FRACTION) -EBITDA ($) 0 271,413,648 273,912,147 277,072,633 279,838,070 281,867,219 281,711,008 276,585,104 275,154,477 289,877,053 293,580,357 296,592,910 299,162,911 300,499,147 298,505,694 289,779,090 301,660,916 309,908,993 313,246,063 316,119,085 318,323,064 318,509,977 313,885,090 308,111,593 325,532,146 329,671,497 332,799,487 335,478,846 337,048,171 335,492,046 1,647,358,602 +EBITDA ($) 0 271,408,511 273,907,053 277,071,267 279,912,106 282,444,662 284,111,065 283,462,287 278,017,323 279,078,570 292,448,745 296,048,652 299,052,941 301,791,002 304,008,984 304,694,260 301,774,587 293,044,850 309,796,677 314,928,707 318,172,172 321,059,929 323,607,705 325,202,088 324,251,614 318,034,365 318,968,234 333,408,979 337,118,117 340,170,448 1,662,620,369 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 271,413,648 273,912,147 277,072,633 279,838,070 281,867,219 281,711,008 276,585,104 275,154,477 289,877,053 293,580,357 296,592,910 299,162,911 300,499,147 298,505,694 289,779,090 301,660,916 309,908,993 313,246,063 316,119,085 318,323,064 318,509,977 313,885,090 308,111,593 325,532,146 329,671,497 332,799,487 335,478,846 337,048,171 335,492,046 1,647,358,602 -Debt total payment ($) 0 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 110,139,006 -DSCR (pre-tax) 0.0 2.46 2.49 2.52 2.54 2.56 2.56 2.51 2.50 2.63 2.67 2.69 2.72 2.73 2.71 2.63 2.74 2.81 2.84 2.87 2.89 2.89 2.85 2.80 2.96 2.99 3.02 3.05 3.06 3.05 14.96 +Cash available for debt service (CAFDS) ($) 0 271,408,511 273,907,053 277,071,267 279,912,106 282,444,662 284,111,065 283,462,287 278,017,323 279,078,570 292,448,745 296,048,652 299,052,941 301,791,002 304,008,984 304,694,260 301,774,587 293,044,850 309,796,677 314,928,707 318,172,172 321,059,929 323,607,705 325,202,088 324,251,614 318,034,365 318,968,234 333,408,979 337,118,117 340,170,448 1,662,620,369 +Debt total payment ($) 0 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 110,168,878 +DSCR (pre-tax) 0.0 2.46 2.49 2.51 2.54 2.56 2.58 2.57 2.52 2.53 2.65 2.69 2.71 2.74 2.76 2.77 2.74 2.66 2.81 2.86 2.89 2.91 2.94 2.95 2.94 2.89 2.90 3.03 3.06 3.09 15.09 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 diff --git a/tests/examples/Fervo_Project_Cape-4.txt b/tests/examples/Fervo_Project_Cape-4.txt index 4a27eb24e..f07c8196e 100644 --- a/tests/examples/Fervo_Project_Cape-4.txt +++ b/tests/examples/Fervo_Project_Cape-4.txt @@ -50,8 +50,8 @@ Reservoir Thermal Conductivity, 3.05 Reservoir Porosity, 0.0118 Reservoir Impedance, 0.001565 -Reservoir Volume Option, 2, -- RES_VOL_FRAC_SEP (Specify reservoir volume and fracture separation) -Reservoir Volume, 5418039158, -- Based on 102 fractures per well +Reservoir Volume Option, 1, -- FRAC_NUM_SEP: Reservoir volume calculated with fracture separation and number of fractures as input +Number of Fractures, 12036, -- 102 fractures per well Fracture Separation, 18, -- Per https://eartharxiv.org/repository/view/7665/, lateral length is 4700 ft = 1432 m. Dividing 1432 by 80 = ~18 m fracture spacing. Fracture Shape, 3, -- Square Fracture Height, 165.3, -- Based on total fracture surface area of 30 million ft^2 per well https://pangea.stanford.edu/ERE/pdf/IGAstandard/SGW/2025/Fercho.pdf diff --git a/tests/geophires_x_tests/test_fervo_project_cape_4.py b/tests/geophires_x_tests/test_fervo_project_cape_4.py index 0acbf5a85..542114c12 100644 --- a/tests/geophires_x_tests/test_fervo_project_cape_4.py +++ b/tests/geophires_x_tests/test_fervo_project_cape_4.py @@ -50,141 +50,30 @@ def test_fervo_project_cape_4_results_against_reference_values(self): self.assertGreater(pumping_power_pct, 13) self.assertLess(pumping_power_pct, 17) + self.assertEqual( + r.result['SUMMARY OF RESULTS']['Number of production wells']['value'], + r.result['SUMMARY OF RESULTS']['Number of injection wells']['value'], + ) + def test_case_study_documentation(self): """ Parses result values from case study documentation markdown and checks that they match the actual result. Useful for catching when minor updates are made to the case study which need to be manually synced to the documentation. + + Note: for future case studies, generate the documentation markdown from the input/result rather than writing + (entirely) by hand so that they are guaranteed to be in sync and don't need to be tested like this, + which has proved messy. """ - def _parse_value_unit(raw_string: str) -> dict: - """ - A helper function to parse a string and extract a numerical value and its unit. - It handles various formats like currency, percentages, and scientific notation. - """ - # First, strip any parenthetical notes, e.g., "(based on...)" - clean_str = re.split(r'\(', raw_string)[0].strip() - - # Case 1: LCOE format ($X.X/MWh -> cents/kWh) - match = re.match(r'^\$(\d+\.?\d*)/MWh$', clean_str) - if match: - value_ = float(match.group(1)) - # Convert $/MWh to cents/kWh by dividing by 10 - return {'value': round(value_ / 10, 2), 'unit': 'cents/kWh'} - - # Case 2: Billion dollar format ($X.XB -> MUSD) - match = re.match(r'^\$(\d+\.?\d*)B$', clean_str) - if match: - value_ = float(match.group(1)) - return {'value': value_ * 1000, 'unit': 'MUSD'} - - # Case 3: Million dollar format ($X.XM...) - match = re.match(r'^\$(\d+\.?\d*)M', clean_str) - if match: - value_ = float(match.group(1)) - return {'value': value_, 'unit': 'MUSD'} - - # Case 4: Dollar per kW format ($X/kW -> USD/kW) - match = re.match(r'^\$(\d+\.?\d*)/kW$', clean_str) - if match: - value_ = float(match.group(1)) - return {'value': value_, 'unit': 'USD/kW'} - - # Case 5: Percentage format (X.X%) - match = re.match(r'^(\d+\.?\d*)%$', clean_str) - if match: - value_ = float(match.group(1)) - return {'value': value_, 'unit': '%'} - - # Case 6: Temperature format (X℃ -> degC) - match = re.match(r'^(\d+\.?\d*)℃$', clean_str) - if match: - value_ = float(match.group(1)) - return {'value': value_, 'unit': 'degC'} - - # Case 7: Scientific notation format (X.Xx10⁶ Y) - match = re.match(r'^(\d+\.?\d*)\s*[×xX]\s*10[⁶6]\s*(.*)$', clean_str) - if match: - base_value = float(match.group(1)) - unit = match.group(2).strip() - return {'value': base_value * 1e6, 'unit': unit} - - # Case 8: Standard number and unit (e.g., "503 MW") - match = re.match(r'^(\d+\.?\d*)\s*([a-zA-Z²³\/]+)$', clean_str) - if match: - value_ = float(match.group(1)) - unit = match.group(2) - return {'value': value_, 'unit': unit} - - # Case 9: Dimensionless integer number (e.g., "3") - match = re.match(r'^(\d+)$', clean_str) - if match: - value_ = int(match.group(1)) - return {'value': value_, 'unit': 'count'} - - # Fallback for any unhandled formats - return {'value': clean_str, 'unit': 'unknown'} - - def parse_markdown_results_structured(markdown_text: str) -> dict: - """ - Parses result values from markdown into a structured dictionary with values and units. - """ - raw_results = {} - table_pattern = re.compile(r'^\s*\|\s*(?!-)([^|]+?)\s*\|\s*([^|]+?)\s*\|', re.MULTILINE) - - try: - results_start_index = markdown_text.index('## Results') - search_area = markdown_text[results_start_index:] - - matches = table_pattern.findall(search_area) - - # Use key_ and value_ to avoid shadowing - for match in matches: - key_ = match[0].strip() - value_ = match[1].strip() - if key_.lower() not in ('metric', 'parameter'): - raw_results[key_] = value_ - except ValueError: - print("Warning: '## Results' section not found.") - return {} - - # Consistency check - special_case_pattern = re.compile(r'LCOE\s*=\s*(\S+)\s*and\s*CAPEX\s*=\s*(\S+)') - special_case_match = special_case_pattern.search(markdown_text) - if special_case_match: - lcoe_text = special_case_match.group(1).rstrip('.,;') - lcoe_table_base = raw_results.get('LCOE', '').split('(')[0].strip() - if lcoe_text != lcoe_table_base: - raise ValueError( - f'LCOE mismatch: Text value ({lcoe_text}) does not match table value ({lcoe_table_base}).' - ) - - # Now, process the raw results into the structured format - structured_results = {} - # Use key_ and value_ to avoid shadowing - for key_, value_ in raw_results.items(): - if key_ in [ - 'After-tax IRR', - 'Average Production Temperature', - 'LCOE', - 'Maximum Total Electricity Generation', - 'Minimum Net Electricity Generation', - 'Number of times redrilling', - 'Project capital costs: Total CAPEX', - 'Project capital costs: $/kW', - 'WACC', - 'Well Drilling and Completion Cost', - ]: - structured_results[key_] = _parse_value_unit(value_) - - return structured_results - - results_in_markdown = parse_markdown_results_structured( - '\n'.join(self._get_test_file_content('../../docs/Fervo_Project_Cape-4.md', encoding='utf-8')) + documentation_file_content = '\n'.join( + self._get_test_file_content('../../docs/Fervo_Project_Cape-4.md', encoding='utf-8') ) + inputs_in_markdown = self.parse_markdown_inputs_structured(documentation_file_content) + results_in_markdown = self.parse_markdown_results_structured(documentation_file_content) self.assertEqual(3.96, results_in_markdown['Well Drilling and Completion Cost']['value']) - self.assertEqual('MUSD', results_in_markdown['Well Drilling and Completion Cost']['unit']) + self.assertEqual('MUSD/well', results_in_markdown['Well Drilling and Completion Cost']['unit']) class _Q(HasQuantity): def __init__(self, vu: dict[str, Any]): @@ -213,7 +102,7 @@ def __init__(self, vu: dict[str, Any]): ignore_keys = ['Project capital costs: $/kW', 'Total fracture surface area per production well'] example_result = GeophiresXResult(self._get_test_file_path('../examples/Fervo_Project_Cape-4.out')) - example_result_values_in_documentation = {} + example_result_values = {} for key, _ in results_in_markdown.items(): if key not in ignore_keys: mapped_key = field_mapping.get(key) if key in field_mapping else key @@ -221,16 +110,196 @@ def __init__(self, vu: dict[str, Any]): if entry is not None and 'value' in entry: entry['value'] = sig_figs(entry['value'], 3) - example_result_values_in_documentation[key] = entry + example_result_values[key] = entry for ignore_key in ignore_keys: if ignore_key in results_in_markdown: del results_in_markdown[ignore_key] - self.assertDictAlmostEqual(results_in_markdown, example_result_values_in_documentation, places=3) + results_in_markdown['Well Drilling and Completion Cost']['unit'] = results_in_markdown[ + 'Well Drilling and Completion Cost' + ]['unit'].replace('/well', '') + self.assertDictAlmostEqual(example_result_values, results_in_markdown, places=3) result_capex_USD_per_kW = ( _Q(example_result._get_result_field('Total CAPEX')).quantity().to('USD').magnitude / _Q(example_result._get_result_field('Maximum Total Electricity Generation')).quantity().to('kW').magnitude ) self.assertAlmostEqual(sig_figs(result_capex_USD_per_kW, 3), sig_figs(markdown_capex_USD_per_kW, 3)) + + num_doublets = inputs_in_markdown['Number of Doublets']['value'] + self.assertEqual( + example_result.result['SUMMARY OF RESULTS']['Number of production wells']['value'], num_doublets + ) + + num_fracs_per_well = inputs_in_markdown['Number of Fractures per well']['value'] + expected_total_fracs = num_doublets * 2 * num_fracs_per_well + self.assertEqual( + expected_total_fracs, example_result.result['RESERVOIR PARAMETERS']['Number of fractures']['value'] + ) + + self.assertEqual( + example_result.result['RESERVOIR PARAMETERS']['Reservoir volume']['value'], + inputs_in_markdown['Reservoir Volume']['value'], + ) + + def parse_markdown_results_structured(self, markdown_text: str) -> dict: + """ + Parses result values from markdown into a structured dictionary with values and units. + """ + raw_results = {} + table_pattern = re.compile(r'^\s*\|\s*(?!-)([^|]+?)\s*\|\s*([^|]+?)\s*\|', re.MULTILINE) + + try: + results_start_index = markdown_text.index('## Results') + search_area = markdown_text[results_start_index:] + + matches = table_pattern.findall(search_area) + + # Use key_ and value_ to avoid shadowing + for match in matches: + key_ = match[0].strip() + value_ = match[1].strip() + if key_.lower() not in ('metric', 'parameter'): + raw_results[key_] = value_ + except ValueError: + print("Warning: '## Results' section not found.") + return {} + + # Consistency check + special_case_pattern = re.compile(r'LCOE\s*=\s*(\S+)\s*and\s*CAPEX\s*=\s*(\S+)') + special_case_match = special_case_pattern.search(markdown_text) + if special_case_match: + lcoe_text = special_case_match.group(1).rstrip('.,;') + lcoe_table_base = raw_results.get('LCOE', '').split('(')[0].strip() + if lcoe_text != lcoe_table_base: + raise ValueError( + f'LCOE mismatch: Text value ({lcoe_text}) does not match table value ({lcoe_table_base}).' + ) + + # Now, process the raw results into the structured format + structured_results = {} + # Use key_ and value_ to avoid shadowing + for key_, value_ in raw_results.items(): + if key_ in [ + 'After-tax IRR', + 'Average Production Temperature', + 'LCOE', + 'Maximum Total Electricity Generation', + 'Minimum Net Electricity Generation', + 'Number of times redrilling', + 'Project capital costs: Total CAPEX', + 'Project capital costs: $/kW', + 'WACC', + 'Well Drilling and Completion Cost', + ]: + structured_results[key_] = self._parse_value_unit(value_) + + return structured_results + + def parse_markdown_inputs_structured(self, markdown_text: str) -> dict: + """ + Parses all input values from all tables under the '## Inputs' section + of a markdown file into a structured dictionary. + """ + try: + # Isolate the content from "## Inputs" to the next "## " header + sections = re.split(r'(^##\s.*)', markdown_text, flags=re.MULTILINE) + inputs_header_index = next(i for i, s in enumerate(sections) if s.startswith('## Inputs')) + inputs_content = sections[inputs_header_index + 1] + except (StopIteration, IndexError): + print("Warning: '## Inputs' section not found or is empty.") + return {} + + raw_inputs = {} + table_pattern = re.compile(r'^\s*\|\s*(?!-)([^|]+?)\s*\|\s*([^|]+?)\s*\|', re.MULTILINE) + matches = table_pattern.findall(inputs_content) + + for match in matches: + key_ = match[0].strip() + value_ = match[1].strip() + if key_.lower() not in ('parameter', 'metric'): + raw_inputs[key_] = value_ + + structured_inputs = {} + for key_, value_ in raw_inputs.items(): + structured_inputs[key_] = self._parse_value_unit(value_) + + return structured_inputs + + # noinspection PyMethodMayBeStatic + def _parse_value_unit(self, raw_string: str) -> dict: + """ + A helper function to parse a string and extract a numerical value and its unit. + It handles various formats like currency, percentages, text, and scientific notation. + """ + clean_str = re.split(r'\s*\(|,(?!\s*\d)', raw_string)[0].strip() + + # Case 1: LCOE format ($X.X/MWh -> cents/kWh) + match = re.match(r'^\$(\d+\.?\d*)/MWh$', clean_str) + if match: + value = float(match.group(1)) + return {'value': round(value / 10, 2), 'unit': 'cents/kWh'} + + # Case 2: Billion dollar format ($X.XB -> MUSD) + match = re.match(r'^\$(\d+\.?\d*)B$', clean_str) + if match: + value = float(match.group(1)) + return {'value': value * 1000, 'unit': 'MUSD'} + + # Case 3: Million dollar format ($X.XM or $X.XM/unit) + match = re.match(r'^\$(\d+\.?\d*)M(\/.*)?$', clean_str) + if match: + value = float(match.group(1)) + unit_suffix = match.group(2) + unit = 'MUSD' + if unit_suffix: + unit = f'MUSD{unit_suffix}' + return {'value': value, 'unit': unit} + + # Case 4: Dollar per kW format ($X/kW -> USD/kW) + match = re.match(r'^\$(\d+\.?\d*)/kW$', clean_str) + if match: + value = float(match.group(1)) + return {'value': value, 'unit': 'USD/kW'} + + # Case 5: Percentage format (X.X%) + match = re.search(r'(\d+\.?\d*)%$', clean_str) + if match: + value = float(match.group(1)) + return {'value': value, 'unit': '%'} + + # Case 6: Temperature format (X℃ -> degC) + match = re.search(r'(\d+\.?\d*)\s*℃$', clean_str) + if match: + value = float(match.group(1)) + return {'value': value, 'unit': 'degC'} + + # Case 7: Scientific notation format (X.X*10⁶ Y) + match = re.match(r'^(\d+\.?\d*)\s*[×xX]\s*10[⁶6]\s*(.*)$', clean_str) + if match: + base_value = float(match.group(1)) + unit = match.group(2).strip() + return {'value': base_value * 1e6, 'unit': unit} + + # Case 8: Generic number and unit parser + if clean_str.startswith('9⅝'): + parts = clean_str.split(' ') + value = 9.0 + 5.0 / 8.0 + unit = parts[1] if len(parts) > 1 else 'unknown' + return {'value': value, 'unit': unit} + + match = re.search(r'([\d\.,]+)\s*(.*)', clean_str) + if match: + value_str = match.group(1).replace(',', '').replace(' ', '') + unit = match.group(2).strip() + + if '.' in value_str: + value = float(value_str) + else: + value = int(value_str) + + return {'value': value, 'unit': unit if unit else 'count'} + + # Fallback for text-only values + return {'value': clean_str, 'unit': 'text'} From ce3fd60dacd89dadf2fbfaeb2410062b0049a4d0 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 15:15:45 -0700 Subject: [PATCH 33/39] =?UTF-8?q?Bump=20version:=203.9.24=20=E2=86=92=203.?= =?UTF-8?q?9.25?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- .cookiecutterrc | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- src/geophires_x/__init__.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 55713fc5a..8eed370dd 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.24 +current_version = 3.9.25 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 27651a889..1c5f9e71d 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.24 + version: 3.9.25 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index cf85f9a3a..e7c688d83 100644 --- a/README.rst +++ b/README.rst @@ -56,9 +56,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.24.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.25.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.24...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.25...main .. |docs| image:: https://readthedocs.org/projects/GEOPHIRES-X/badge/?style=flat :target: https://nrel.github.io/GEOPHIRES-X diff --git a/docs/conf.py b/docs/conf.py index 4aee1af7b..4c0cad18a 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.24' +version = release = '3.9.25' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index d8fcb8bf4..1df97d722 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.24', + version='3.9.25', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index 84fb14ccd..ae9c5753d 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.24' +__version__ = '3.9.25' From 1733a0a796f7b217f8ec52322e1fd31c4d784392 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 15:56:44 -0700 Subject: [PATCH 34/39] client - only initialize shared resources if current_process().name == 'MainProcess' --- src/geophires_x_client/__init__.py | 45 +++++++----- ..._imperative_instantiation_in_subprocess.py | 71 +++++++++++++++++++ 2 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 tests/geophires_x_client_tests/test_imperative_instantiation_in_subprocess.py diff --git a/src/geophires_x_client/__init__.py b/src/geophires_x_client/__init__.py index c2b303585..df846a3e9 100644 --- a/src/geophires_x_client/__init__.py +++ b/src/geophires_x_client/__init__.py @@ -1,9 +1,8 @@ import atexit -import os import sys import threading from multiprocessing import Manager -from pathlib import Path +from multiprocessing import current_process # noinspection PyPep8Naming from geophires_x import GEOPHIRESv3 as geophires @@ -36,24 +35,30 @@ def __init__(self, enable_caching=True, logger_name=None): self._logger = _get_logger(logger_name=logger_name) self._enable_caching = enable_caching - # Lazy-initialize shared resources if they haven't been already. if enable_caching and GeophiresXClient._manager is None: + # Lazy-initialize shared resources if they haven't been already. self._initialize_shared_resources() @classmethod def _initialize_shared_resources(cls): """ Initializes the multiprocessing Manager and shared resources in a - thread-safe manner. It also registers the shutdown hook to ensure - automatic cleanup on application exit. + thread-safe and now process-safe manner. It also registers the + shutdown hook to ensure automatic cleanup on application exit. """ - with cls._init_lock: - if cls._manager is None: - cls._manager = Manager() - cls._cache = cls._manager.dict() - cls._lock = cls._manager.RLock() - # Register the shutdown method to be called automatically on exit. - atexit.register(cls.shutdown) + # Ensure that only the top-level user process can create the manager. + # A spawned child process, which re-imports this script, will have a different name + # (e.g., 'Spawn-1') and will skip this entire block, preventing a recursive crash. + if current_process().name == 'MainProcess': + with cls._init_lock: + if cls._manager is None: + cls._logger = _get_logger(__name__) # Add a logger for this class method + cls._logger.debug('MainProcess is creating the shared multiprocessing manager...') + cls._manager = Manager() + cls._cache = cls._manager.dict() + cls._lock = cls._manager.RLock() + # Register the shutdown method to be called automatically on exit. + atexit.register(cls.shutdown) @classmethod def shutdown(cls): @@ -65,9 +70,17 @@ def shutdown(cls): """ with cls._init_lock: if cls._manager is not None: + cls._logger = _get_logger(__name__) + cls._logger.debug('Shutting down the shared multiprocessing manager...') cls._manager.shutdown() # De-register the hook to avoid trying to shut down twice. - atexit.unregister(cls.shutdown) + try: + atexit.unregister(cls.shutdown) + except Exception as e: + # Fails in some environments (e.g. pytest), but is not critical + cls._logger.debug( + f'Encountered exception shutting down the shared multiprocessing manager (OK): ' f'{e!s}' + ) cls._manager = None cls._cache = None cls._lock = None @@ -80,22 +93,23 @@ def get_geophires_result(self, input_params: GeophiresInputParameters) -> Geophi """ is_immutable = isinstance(input_params, ImmutableGeophiresInputParameters) - if not (self._enable_caching and is_immutable): + if not (self._enable_caching and is_immutable and GeophiresXClient._manager is not None): return self._run_simulation(input_params) cache_key = hash(input_params) with GeophiresXClient._lock: if cache_key in GeophiresXClient._cache: + # self._logger.debug(f'Cache hit for inputs: {input_params}') return GeophiresXClient._cache[cache_key] + # Cache miss result = self._run_simulation(input_params) GeophiresXClient._cache[cache_key] = result return result def _run_simulation(self, input_params: GeophiresInputParameters) -> GeophiresXResult: """Helper method to encapsulate the actual GEOPHIRES run.""" - stash_cwd = Path.cwd() stash_sys_argv = sys.argv sys.argv = ['', input_params.as_file_path(), input_params.get_output_file_path()] @@ -107,7 +121,6 @@ def _run_simulation(self, input_params: GeophiresInputParameters) -> GeophiresXR raise RuntimeError('GEOPHIRES exited without giving a reason') from None finally: sys.argv = stash_sys_argv - os.chdir(stash_cwd) self._logger.info(f'GEOPHIRES-X output file: {input_params.get_output_file_path()}') result = GeophiresXResult(input_params.get_output_file_path()) diff --git a/tests/geophires_x_client_tests/test_imperative_instantiation_in_subprocess.py b/tests/geophires_x_client_tests/test_imperative_instantiation_in_subprocess.py new file mode 100644 index 000000000..e45810cca --- /dev/null +++ b/tests/geophires_x_client_tests/test_imperative_instantiation_in_subprocess.py @@ -0,0 +1,71 @@ +# ruff: noqa: S603 + +import subprocess +import sys +import tempfile +from pathlib import Path + +from base_test_case import BaseTestCase + + +class GeophiresClientImperativeInstantiationTestCase(BaseTestCase): + + # noinspection PyMethodMayBeStatic + def test_imperative_instantiation_in_subprocess(self): + """ + Verifies that GeophiresXClient can be instantiated at the global scope + in a script without causing a multiprocessing-related RuntimeError. + + This test directly simulates the failure condition by writing and executing + a separate Python script as a subprocess. This ensures that the fix + (checking for 'MainProcess') is working correctly on systems that use + the 'spawn' start method for multiprocessing (like macOS and Windows). + """ + project_root = Path(__file__).parent.parent.resolve() + + script_content = f""" +import sys +# We must add the project root to the path for the import to work. +sys.path.insert(0, r'{project_root}') + +from geophires_x_client import GeophiresXClient + +print("Attempting to instantiate GeophiresXClient at the global scope...") + +# This is the line that would have previously crashed with a RuntimeError. +client = GeophiresXClient() + +print("Instantiation successful.") + +# It is critical to shut down the client to release the manager process, +# otherwise it can linger and interfere with other tests in the suite. +GeophiresXClient.shutdown() + +print("Shutdown successful.") + +# A final message to confirm the script completed without errors. +print("SUCCESS") +""" + + with tempfile.TemporaryDirectory() as tmpdir: + test_script_path = Path(tmpdir) / 'run_client_test.py' + test_script_path.write_text(script_content) + + # fmt:off + result = subprocess.run( + [sys.executable, str(test_script_path)], + capture_output=True, + text=True, + timeout=60 + ) + # fmt:on + + assert result.returncode == 0, ( + f'Subprocess failed with exit code {result.returncode}. This indicates a crash.\\n' + f'--- STDOUT ---\\n{result.stdout}\\n' + f'--- STDERR ---\\n{result.stderr}' + ) + + assert 'SUCCESS' in result.stdout, ( + "Subprocess completed but did not print the final 'SUCCESS' message.\\n" f"--- STDOUT ---\\n{result.stdout}" + ) From d8e297332d232baa430ac979626b1550a928b4b6 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 15:56:44 -0700 Subject: [PATCH 35/39] client - only initialize shared resources if current_process().name == 'MainProcess' --- src/geophires_x_client/__init__.py | 45 +++++++----- ..._imperative_instantiation_in_subprocess.py | 71 +++++++++++++++++++ 2 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 tests/geophires_x_client_tests/test_imperative_instantiation_in_subprocess.py diff --git a/src/geophires_x_client/__init__.py b/src/geophires_x_client/__init__.py index c2b303585..df846a3e9 100644 --- a/src/geophires_x_client/__init__.py +++ b/src/geophires_x_client/__init__.py @@ -1,9 +1,8 @@ import atexit -import os import sys import threading from multiprocessing import Manager -from pathlib import Path +from multiprocessing import current_process # noinspection PyPep8Naming from geophires_x import GEOPHIRESv3 as geophires @@ -36,24 +35,30 @@ def __init__(self, enable_caching=True, logger_name=None): self._logger = _get_logger(logger_name=logger_name) self._enable_caching = enable_caching - # Lazy-initialize shared resources if they haven't been already. if enable_caching and GeophiresXClient._manager is None: + # Lazy-initialize shared resources if they haven't been already. self._initialize_shared_resources() @classmethod def _initialize_shared_resources(cls): """ Initializes the multiprocessing Manager and shared resources in a - thread-safe manner. It also registers the shutdown hook to ensure - automatic cleanup on application exit. + thread-safe and now process-safe manner. It also registers the + shutdown hook to ensure automatic cleanup on application exit. """ - with cls._init_lock: - if cls._manager is None: - cls._manager = Manager() - cls._cache = cls._manager.dict() - cls._lock = cls._manager.RLock() - # Register the shutdown method to be called automatically on exit. - atexit.register(cls.shutdown) + # Ensure that only the top-level user process can create the manager. + # A spawned child process, which re-imports this script, will have a different name + # (e.g., 'Spawn-1') and will skip this entire block, preventing a recursive crash. + if current_process().name == 'MainProcess': + with cls._init_lock: + if cls._manager is None: + cls._logger = _get_logger(__name__) # Add a logger for this class method + cls._logger.debug('MainProcess is creating the shared multiprocessing manager...') + cls._manager = Manager() + cls._cache = cls._manager.dict() + cls._lock = cls._manager.RLock() + # Register the shutdown method to be called automatically on exit. + atexit.register(cls.shutdown) @classmethod def shutdown(cls): @@ -65,9 +70,17 @@ def shutdown(cls): """ with cls._init_lock: if cls._manager is not None: + cls._logger = _get_logger(__name__) + cls._logger.debug('Shutting down the shared multiprocessing manager...') cls._manager.shutdown() # De-register the hook to avoid trying to shut down twice. - atexit.unregister(cls.shutdown) + try: + atexit.unregister(cls.shutdown) + except Exception as e: + # Fails in some environments (e.g. pytest), but is not critical + cls._logger.debug( + f'Encountered exception shutting down the shared multiprocessing manager (OK): ' f'{e!s}' + ) cls._manager = None cls._cache = None cls._lock = None @@ -80,22 +93,23 @@ def get_geophires_result(self, input_params: GeophiresInputParameters) -> Geophi """ is_immutable = isinstance(input_params, ImmutableGeophiresInputParameters) - if not (self._enable_caching and is_immutable): + if not (self._enable_caching and is_immutable and GeophiresXClient._manager is not None): return self._run_simulation(input_params) cache_key = hash(input_params) with GeophiresXClient._lock: if cache_key in GeophiresXClient._cache: + # self._logger.debug(f'Cache hit for inputs: {input_params}') return GeophiresXClient._cache[cache_key] + # Cache miss result = self._run_simulation(input_params) GeophiresXClient._cache[cache_key] = result return result def _run_simulation(self, input_params: GeophiresInputParameters) -> GeophiresXResult: """Helper method to encapsulate the actual GEOPHIRES run.""" - stash_cwd = Path.cwd() stash_sys_argv = sys.argv sys.argv = ['', input_params.as_file_path(), input_params.get_output_file_path()] @@ -107,7 +121,6 @@ def _run_simulation(self, input_params: GeophiresInputParameters) -> GeophiresXR raise RuntimeError('GEOPHIRES exited without giving a reason') from None finally: sys.argv = stash_sys_argv - os.chdir(stash_cwd) self._logger.info(f'GEOPHIRES-X output file: {input_params.get_output_file_path()}') result = GeophiresXResult(input_params.get_output_file_path()) diff --git a/tests/geophires_x_client_tests/test_imperative_instantiation_in_subprocess.py b/tests/geophires_x_client_tests/test_imperative_instantiation_in_subprocess.py new file mode 100644 index 000000000..e45810cca --- /dev/null +++ b/tests/geophires_x_client_tests/test_imperative_instantiation_in_subprocess.py @@ -0,0 +1,71 @@ +# ruff: noqa: S603 + +import subprocess +import sys +import tempfile +from pathlib import Path + +from base_test_case import BaseTestCase + + +class GeophiresClientImperativeInstantiationTestCase(BaseTestCase): + + # noinspection PyMethodMayBeStatic + def test_imperative_instantiation_in_subprocess(self): + """ + Verifies that GeophiresXClient can be instantiated at the global scope + in a script without causing a multiprocessing-related RuntimeError. + + This test directly simulates the failure condition by writing and executing + a separate Python script as a subprocess. This ensures that the fix + (checking for 'MainProcess') is working correctly on systems that use + the 'spawn' start method for multiprocessing (like macOS and Windows). + """ + project_root = Path(__file__).parent.parent.resolve() + + script_content = f""" +import sys +# We must add the project root to the path for the import to work. +sys.path.insert(0, r'{project_root}') + +from geophires_x_client import GeophiresXClient + +print("Attempting to instantiate GeophiresXClient at the global scope...") + +# This is the line that would have previously crashed with a RuntimeError. +client = GeophiresXClient() + +print("Instantiation successful.") + +# It is critical to shut down the client to release the manager process, +# otherwise it can linger and interfere with other tests in the suite. +GeophiresXClient.shutdown() + +print("Shutdown successful.") + +# A final message to confirm the script completed without errors. +print("SUCCESS") +""" + + with tempfile.TemporaryDirectory() as tmpdir: + test_script_path = Path(tmpdir) / 'run_client_test.py' + test_script_path.write_text(script_content) + + # fmt:off + result = subprocess.run( + [sys.executable, str(test_script_path)], + capture_output=True, + text=True, + timeout=60 + ) + # fmt:on + + assert result.returncode == 0, ( + f'Subprocess failed with exit code {result.returncode}. This indicates a crash.\\n' + f'--- STDOUT ---\\n{result.stdout}\\n' + f'--- STDERR ---\\n{result.stderr}' + ) + + assert 'SUCCESS' in result.stdout, ( + "Subprocess completed but did not print the final 'SUCCESS' message.\\n" f"--- STDOUT ---\\n{result.stdout}" + ) From be62077516d9a0264013542405f5fc904d2f91d9 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:09:48 -0700 Subject: [PATCH 36/39] Disable client caching by default - this was effectively the default behavior prior to the introduction of ImmutableGeophiresInputParameters, and we want to minimize the risk of caching-by-default causing unforeseen issues for existing consumers --- src/geophires_x_client/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/geophires_x_client/__init__.py b/src/geophires_x_client/__init__.py index df846a3e9..954e1fac5 100644 --- a/src/geophires_x_client/__init__.py +++ b/src/geophires_x_client/__init__.py @@ -28,7 +28,7 @@ class GeophiresXClient: _init_lock = threading.Lock() """A standard threading lock to make the one-time initialization thread-safe.""" - def __init__(self, enable_caching=True, logger_name=None): + def __init__(self, enable_caching=False, logger_name=None): if logger_name is None: logger_name = __name__ From ee0a1fa3c3732237de6f1ef780f5046e9ac4d79f Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:09:48 -0700 Subject: [PATCH 37/39] Disable client caching by default - this was effectively the default behavior prior to the introduction of ImmutableGeophiresInputParameters, and we want to minimize the risk of caching-by-default causing unforeseen issues for existing consumers --- src/geophires_x_client/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/geophires_x_client/__init__.py b/src/geophires_x_client/__init__.py index df846a3e9..954e1fac5 100644 --- a/src/geophires_x_client/__init__.py +++ b/src/geophires_x_client/__init__.py @@ -28,7 +28,7 @@ class GeophiresXClient: _init_lock = threading.Lock() """A standard threading lock to make the one-time initialization thread-safe.""" - def __init__(self, enable_caching=True, logger_name=None): + def __init__(self, enable_caching=False, logger_name=None): if logger_name is None: logger_name = __name__ From cb38f7f02af6974b98f8bead959c5de59e547406 Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Fri, 20 Jun 2025 17:28:27 -0700 Subject: [PATCH 38/39] =?UTF-8?q?Bump=20version:=203.9.25=20=E2=86=92=203.?= =?UTF-8?q?9.26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- .cookiecutterrc | 2 +- README.rst | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- src/geophires_x/__init__.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 8eed370dd..619fde4ad 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.25 +current_version = 3.9.26 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 1c5f9e71d..f34c10672 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.25 + version: 3.9.26 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index e7c688d83..e17288000 100644 --- a/README.rst +++ b/README.rst @@ -56,9 +56,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.25.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.26.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.25...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.26...main .. |docs| image:: https://readthedocs.org/projects/GEOPHIRES-X/badge/?style=flat :target: https://nrel.github.io/GEOPHIRES-X diff --git a/docs/conf.py b/docs/conf.py index 4c0cad18a..edf43a60a 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.25' +version = release = '3.9.26' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index 1df97d722..ebb2065e7 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.25', + version='3.9.26', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index ae9c5753d..18b11f424 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.25' +__version__ = '3.9.26' From 3fdc98d413a94f097f3dada6a05d7e0ce69e107e Mon Sep 17 00:00:00 2001 From: softwareengineerprogrammer <4056124+softwareengineerprogrammer@users.noreply.github.com> Date: Sat, 21 Jun 2025 12:44:22 -0700 Subject: [PATCH 39/39] Fix 'Peaking Boiler Cost per kW' unit capitalization ('kW' instead of 'KW') --- src/geophires_x/Economics.py | 16 ++++++++-------- .../geophires-request.json | 4 ++-- tests/geophires_x_tests/test_economics.py | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index dc716d729..5cf3f02ad 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -1125,17 +1125,17 @@ def __init__(self, model: Model): ErrMessage="assume default peaking boiler efficiency (85%)", ToolTipText="Peaking boiler efficiency" ) - self._default_peaking_boiler_cost_USD_per_kw = 65 - self.peaking_boiler_cost_per_kw = self.ParameterDict[self.peaking_boiler_cost_per_kw.Name] = floatParameter( - "Peaking Boiler Cost per KW", - DefaultValue=self._default_peaking_boiler_cost_USD_per_kw, + self._default_peaking_boiler_cost_USD_per_kW = 65 + self.peaking_boiler_cost_per_kW = self.ParameterDict[self.peaking_boiler_cost_per_kW.Name] = floatParameter( + "Peaking Boiler Cost per kW", + DefaultValue=self._default_peaking_boiler_cost_USD_per_kW, Min=0, Max=1000, UnitType=Units.ENERGYCOST, PreferredUnits=EnergyCostUnit.DOLLARSPERKW, CurrentUnits=EnergyCostUnit.DOLLARSPERKW, Required=False, - ToolTipText="Peaking boiler cost per KW of maximum peaking boiler demand" + ToolTipText="Peaking boiler cost per kW of maximum peaking boiler demand" ) self.dhpipingcostrate = self.ParameterDict[self.dhpipingcostrate.Name] = floatParameter( "District Heating Piping Cost Rate", @@ -1733,9 +1733,9 @@ def __init__(self, model: Model): UnitType=Units.CURRENCY, PreferredUnits=CurrencyUnit.MDOLLARS, CurrentUnits=CurrencyUnit.MDOLLARS, - ToolTipText=f'Default cost: ${self._default_peaking_boiler_cost_USD_per_kw}/KW ' + ToolTipText=f'Default cost: ${self._default_peaking_boiler_cost_USD_per_kW}/KW ' f'of maximum peaking boiler demand. ' - f'Provide {self.peaking_boiler_cost_per_kw.Name} override the default.' + f'Provide {self.peaking_boiler_cost_per_kW.Name} override the default.' ) self.dhdistrictcost = self.OutputParameterDict[self.dhdistrictcost.Name] = OutputParameter( @@ -2698,7 +2698,7 @@ def calculate_plant_costs(self, model:Model) -> None: model.surfaceplant.HeatExtracted.value) * 1000. # add 65$/KW for peaking boiler - self.peakingboilercost.value = (self.peaking_boiler_cost_per_kw.quantity() + self.peakingboilercost.value = (self.peaking_boiler_cost_per_kW.quantity() .to('USD / kilowatt').magnitude * model.surfaceplant.max_peaking_boiler_demand.value / 1000) diff --git a/src/geophires_x_schema_generator/geophires-request.json b/src/geophires_x_schema_generator/geophires-request.json index a7e62abdc..1928dfd54 100644 --- a/src/geophires_x_schema_generator/geophires-request.json +++ b/src/geophires_x_schema_generator/geophires-request.json @@ -1869,8 +1869,8 @@ "minimum": 0, "maximum": 1 }, - "Peaking Boiler Cost per KW": { - "description": "Peaking boiler cost per KW of maximum peaking boiler demand", + "Peaking Boiler Cost per kW": { + "description": "Peaking boiler cost per kW of maximum peaking boiler demand", "type": "number", "units": "USD/kW", "category": "Economics", diff --git a/tests/geophires_x_tests/test_economics.py b/tests/geophires_x_tests/test_economics.py index 796897363..f5d8121ac 100644 --- a/tests/geophires_x_tests/test_economics.py +++ b/tests/geophires_x_tests/test_economics.py @@ -125,7 +125,7 @@ def _get_result(peaking_boiler_cost_: int) -> GeophiresXResult: GeophiresInputParameters( from_file_path=self._get_test_file_path('../examples/example12_DH.txt'), params={ - 'Peaking Boiler Cost per KW': peaking_boiler_cost_, + 'Peaking Boiler Cost per kW': peaking_boiler_cost_, }, ) )