Skip to content

Commit 76a9b01

Browse files
Support redrilling based on well integrity parameterization (Well Integrity Maximum Lifetime)
1 parent 1f160d5 commit 76a9b01

7 files changed

Lines changed: 577 additions & 48 deletions

File tree

src/geophires_x/WellBores.py

Lines changed: 94 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,9 @@ def __init__(self, model: Model):
950950
ToolTipText="Productivity index defined as ratio of production well flow rate over production well inflow "
951951
"pressure drop (see docs)"
952952
)
953+
954+
well_integrity_max_lifetime_param_name = "Well Integrity Maximum Lifetime"
955+
# noinspection SpellCheckingInspection
953956
self.maxdrawdown = self.ParameterDict[self.maxdrawdown.Name] = floatParameter(
954957
"Maximum Drawdown",
955958
DefaultValue=1.0,
@@ -959,11 +962,33 @@ def __init__(self, model: Model):
959962
PreferredUnits=PercentUnit.TENTH,
960963
CurrentUnits=PercentUnit.TENTH,
961964
ErrMessage="assume default maximum drawdown (1)",
962-
ToolTipText="Maximum allowable thermal drawdown before redrilling of all wells into new reservoir "
963-
"(most applicable to EGS-type reservoirs with heat farming strategies). E.g. a value of 0.2 "
964-
"means that all wells are redrilled after the production temperature (at the wellhead) has "
965-
"dropped by 20% of its initial temperature"
965+
ToolTipText=f"Maximum allowable thermal drawdown before redrilling of all wells into new reservoir "
966+
f"(most applicable to EGS-type reservoirs with heat farming strategies). E.g. a value of 0.2 "
967+
f"means that all wells are redrilled after the production temperature (at the wellhead) has "
968+
f"dropped by 20% of its initial temperature. "
969+
f"Note that redrilling is triggered by whichever occurs first: this thermal drawdown limit or "
970+
f"the chronological limit defined by {well_integrity_max_lifetime_param_name}."
966971
)
972+
self.well_integrity_max_lifetime = self.ParameterDict[self.well_integrity_max_lifetime.Name] = floatParameter(
973+
well_integrity_max_lifetime_param_name,
974+
DefaultValue=-1.0,
975+
Min=0.1,
976+
Max=100.0,
977+
UnitType=Units.TIME,
978+
PreferredUnits=TimeUnit.YEAR,
979+
CurrentUnits=TimeUnit.YEAR,
980+
Required=False,
981+
Provided=False,
982+
ToolTipText=f"Maximum chronological lifetime of the wellbore infrastructure before mechanical/chemical "
983+
f"failure forces a redrilling event, independent of thermal drawdown. Models a deterministic "
984+
f"first-order approximation of well integrity failure (compressive yielding, high-temperature "
985+
f"creep, low-cycle fatigue, cement degradation, sulfide stress cracking, etc.) that is "
986+
f"particularly relevant for Superhot Rock systems where wellbore infrastructure "
987+
f"may typically fail before thermal depletion. Redrilling is triggered at the minimum of the "
988+
f"thermal drawdown index defined by {self.maxdrawdown.Name} and this chronological index. "
989+
f"If not provided, defaults to project lifetime (no mechanical failure)."
990+
)
991+
967992
self.IsAGS = self.ParameterDict[self.IsAGS.Name] = boolParameter(
968993
"Is AGS",
969994
DefaultValue=False,
@@ -1615,22 +1640,71 @@ def Calculate(self, model: Model) -> None:
16151640
model.logger.info(f'complete {self.__class__.__name__}: {__name__}')
16161641

16171642
def calculate_redrilling(self, model: Model) -> None:
1618-
# Redrilling applies to the built-in analytical reservoir models and user-provided profile.
1619-
if model.reserv.resoption.value in \
1620-
[ReservoirModel.MULTIPLE_PARALLEL_FRACTURES, ReservoirModel.LINEAR_HEAT_SWEEP,
1621-
ReservoirModel.SINGLE_FRACTURE, ReservoirModel.ANNUAL_PERCENTAGE, ReservoirModel.USER_PROVIDED_PROFILE]:
1622-
index_first_max_drawdown = np.argmax(
1623-
self.ProducedTemperature.value < (1 - model.wellbores.maxdrawdown.value) *
1624-
self.ProducedTemperature.value[0])
1625-
1626-
if index_first_max_drawdown > 0: # redrilling necessary
1627-
self.redrill.value = int(np.floor(len(self.ProducedTemperature.value) / index_first_max_drawdown))
1628-
ProducedTemperatureRepeated = np.tile(self.ProducedTemperature.value[0:index_first_max_drawdown],
1629-
self.redrill.value + 1)
1630-
self.ProducedTemperature.value = ProducedTemperatureRepeated[0:len(self.ProducedTemperature.value)]
1631-
TResOutputRepeated = np.tile(model.reserv.Tresoutput.value[0:index_first_max_drawdown],
1632-
self.redrill.value + 1)
1633-
model.reserv.Tresoutput.value = TResOutputRepeated[0:len(self.ProducedTemperature.value)]
1643+
"""
1644+
Redrilling applies to the built-in analytical reservoir models and user-provided profile.
1645+
"""
1646+
1647+
if model.reserv.resoption.value not in [
1648+
ReservoirModel.MULTIPLE_PARALLEL_FRACTURES, ReservoirModel.LINEAR_HEAT_SWEEP,
1649+
ReservoirModel.SINGLE_FRACTURE, ReservoirModel.ANNUAL_PERCENTAGE,
1650+
ReservoirModel.USER_PROVIDED_PROFILE,
1651+
]:
1652+
return
1653+
1654+
total_steps = len(self.ProducedTemperature.value)
1655+
project_lifetime_yr = model.surfaceplant.plant_lifetime.value
1656+
1657+
# Thermal drawdown trigger
1658+
index_first_max_drawdown = int(np.argmax(
1659+
self.ProducedTemperature.value
1660+
< (1 - model.wellbores.maxdrawdown.value) * self.ProducedTemperature.value[0]
1661+
))
1662+
1663+
# Well integrity (chronological) trigger
1664+
if self.well_integrity_max_lifetime.Provided and self.well_integrity_max_lifetime.value > 0:
1665+
timesteps_per_year = total_steps / project_lifetime_yr
1666+
integrity_failure_index = int(self.well_integrity_max_lifetime.value * timesteps_per_year)
1667+
# Clamp: a lifetime >= project lifetime means "no mechanical failure"
1668+
if integrity_failure_index >= total_steps:
1669+
integrity_failure_index = 0 # treat as "no trigger" sentinel
1670+
else:
1671+
integrity_failure_index = 0 # no mechanical failure modeled
1672+
1673+
# Competing-risk: take the earliest non-zero trigger
1674+
candidates = [i for i in (index_first_max_drawdown, integrity_failure_index) if i > 0]
1675+
if not candidates:
1676+
return # no redrilling
1677+
redrill_trigger_index = min(candidates)
1678+
1679+
if redrill_trigger_index <= 0 or redrill_trigger_index >= total_steps:
1680+
return
1681+
1682+
self.redrill.value = int(np.floor(total_steps / redrill_trigger_index))
1683+
ProducedTemperatureRepeated = np.tile(
1684+
self.ProducedTemperature.value[0:redrill_trigger_index],
1685+
self.redrill.value + 1,
1686+
)
1687+
self.ProducedTemperature.value = ProducedTemperatureRepeated[0:total_steps]
1688+
TResOutputRepeated = np.tile(
1689+
model.reserv.Tresoutput.value[0:redrill_trigger_index],
1690+
self.redrill.value + 1,
1691+
)
1692+
model.reserv.Tresoutput.value = TResOutputRepeated[0:total_steps]
1693+
1694+
# Log which mechanism dominated
1695+
if (integrity_failure_index > 0
1696+
and (index_first_max_drawdown == 0 or integrity_failure_index < index_first_max_drawdown)):
1697+
model.logger.info(
1698+
f"Redrilling driven by {self.well_integrity_max_lifetime.Name} "
1699+
f"({self.well_integrity_max_lifetime.value} {self.well_integrity_max_lifetime.CurrentUnits}); "
1700+
f"redrill events = {self.redrill.value}."
1701+
)
1702+
else:
1703+
model.logger.info(
1704+
f"Redrilling driven by thermal drawdown ({self.maxdrawdown.Name}; "
1705+
f"{self.maxdrawdown.quantity().to(convertible_unit('percent')).magnitude:.2f}%); "
1706+
f"redrill events = {self.redrill.value}."
1707+
)
16341708

16351709
def _sync_output_params_from_input_params(self) -> None:
16361710
"""

src/geophires_x_schema_generator/geophires-request.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -818,14 +818,23 @@
818818
"maximum": 10000.0
819819
},
820820
"Maximum Drawdown": {
821-
"description": "Maximum allowable thermal drawdown before redrilling of all wells into new reservoir (most applicable to EGS-type reservoirs with heat farming strategies). E.g. a value of 0.2 means that all wells are redrilled after the production temperature (at the wellhead) has dropped by 20% of its initial temperature",
821+
"description": "Maximum allowable thermal drawdown before redrilling of all wells into new reservoir (most applicable to EGS-type reservoirs with heat farming strategies). E.g. a value of 0.2 means that all wells are redrilled after the production temperature (at the wellhead) has dropped by 20% of its initial temperature. Note that redrilling is triggered by whichever occurs first: this thermal drawdown limit or the chronological limit defined by Well Integrity Maximum Lifetime.",
822822
"type": "number",
823823
"units": "",
824824
"category": "Well Bores",
825825
"default": 1.0,
826826
"minimum": 0.0,
827827
"maximum": "1.0"
828828
},
829+
"Well Integrity Maximum Lifetime": {
830+
"description": "Maximum chronological lifetime of the wellbore infrastructure before mechanical/chemical failure forces a redrilling event, independent of thermal drawdown. Models a deterministic first-order approximation of well integrity failure (compressive yielding, high-temperature creep, low-cycle fatigue, cement degradation, sulfide stress cracking, etc.) that is particularly relevant for Superhot Rock systems where wellbore infrastructure may typically fail before thermal depletion. Redrilling is triggered at the minimum of the thermal drawdown index defined by Maximum Drawdown and this chronological index. If not provided, defaults to project lifetime (no mechanical failure).",
831+
"type": "number",
832+
"units": "yr",
833+
"category": "Well Bores",
834+
"default": -1.0,
835+
"minimum": 0.1,
836+
"maximum": 100.0
837+
},
829838
"Is AGS": {
830839
"description": "Set to true if the model is for an Advanced Geothermal System (AGS)",
831840
"type": "boolean",

tests/base_test_case.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import inspect
4+
import math
45
import numbers
56
import os.path
67
import sys
@@ -11,6 +12,7 @@
1112

1213
from geophires_x.GeoPHIRESUtils import sig_figs
1314
from geophires_x_client import GeophiresInputParameters
15+
from geophires_x_client import GeophiresXResult
1416

1517
# noinspection PyProtectedMember
1618
from geophires_x_client import _get_logger
@@ -29,6 +31,41 @@ def _get_test_file_content(self, test_file_name, **open_kw_args) -> str:
2931
def _list_test_files_dir(self, test_files_dir: str):
3032
return os.listdir(self._get_test_file_path(test_files_dir)) # noqa: PTH208
3133

34+
# noinspection PyMethodMayBeStatic
35+
def _sanitize_nan(self, r: GeophiresXResult) -> GeophiresXResult:
36+
"""
37+
Workaround for float('nan') != float('nan')
38+
See https://stackoverflow.com/questions/51728427/unittest-how-to-assert-if-the-two-possibly-nan-values-are-equal
39+
40+
TODO generalize beyond After-tax IRR
41+
42+
Mutates passed-in result object.
43+
"""
44+
45+
irr_key = 'After-tax IRR'
46+
if irr_key in r.result['ECONOMIC PARAMETERS']:
47+
try:
48+
if math.isnan(r.result['ECONOMIC PARAMETERS'][irr_key]['value']):
49+
r.result['ECONOMIC PARAMETERS'][irr_key]['value'] = 'NaN'
50+
except TypeError:
51+
pass
52+
53+
return r
54+
55+
# noinspection PyMethodMayBeStatic
56+
def _strip_metadata(self, geophires_result: GeophiresXResult) -> GeophiresXResult:
57+
"""
58+
Useful for comparing results from different runs.
59+
60+
Mutates passed-in result object.
61+
"""
62+
63+
for key in ['metadata', 'Simulation Metadata']:
64+
if key in geophires_result.result:
65+
del geophires_result.result[key]
66+
67+
return geophires_result
68+
3269
def assertAlmostEqualWithinPercentage(self, expected, actual, msg: str | None = None, percent=5):
3370
if msg is not None and not isinstance(msg, str):
3471
raise ValueError(f'msg must be a string (you may have meant to pass percent={msg})')

0 commit comments

Comments
 (0)