completor 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- completor/__init__.py +3 -0
- completor/completion.py +1042 -0
- completor/config_jobs/run_completor +18 -0
- completor/constants.py +256 -0
- completor/create_output.py +462 -0
- completor/create_wells.py +314 -0
- completor/exceptions/__init__.py +4 -0
- completor/exceptions/clean_exceptions.py +10 -0
- completor/exceptions/exceptions.py +134 -0
- completor/hook_implementations/jobs.py +63 -0
- completor/input_validation.py +340 -0
- completor/launch_args_parser.py +41 -0
- completor/logger.py +138 -0
- completor/main.py +486 -0
- completor/parse.py +581 -0
- completor/prepare_outputs.py +1473 -0
- completor/pvt_model.py +14 -0
- completor/read_casefile.py +677 -0
- completor/read_schedule.py +160 -0
- completor/utils.py +185 -0
- completor/visualization.py +164 -0
- completor/visualize_well.py +217 -0
- completor-0.1.2.dist-info/LICENSE +165 -0
- completor-0.1.2.dist-info/METADATA +205 -0
- completor-0.1.2.dist-info/RECORD +27 -0
- completor-0.1.2.dist-info/WHEEL +4 -0
- completor-0.1.2.dist-info/entry_points.txt +6 -0
completor/completion.py
ADDED
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
"""Completion related methods. Completion is the area where there is production."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import overload
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import numpy.typing as npt
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from completor.constants import Headers, Keywords, Method
|
|
12
|
+
from completor.exceptions import CompletorError
|
|
13
|
+
from completor.logger import logger
|
|
14
|
+
from completor.read_schedule import fix_compsegs, fix_welsegs
|
|
15
|
+
from completor.utils import as_data_frame, log_and_raise_exception
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from typing import Literal, TypeAlias # type: ignore
|
|
19
|
+
except ImportError:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
# Use more precise type information, if possible
|
|
23
|
+
DeviceType: TypeAlias = 'Literal["AICD", "ICD", "DAR", "VALVE", "AICV", "ICV"]'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Information:
|
|
27
|
+
"""Holds information from `get_completion`."""
|
|
28
|
+
|
|
29
|
+
# TODO(#85): Improve the class.
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
number_of_devices: float | list[float] | None = None,
|
|
34
|
+
device_type: DeviceType | list[DeviceType] | None = None,
|
|
35
|
+
device_number: int | list[int] | None = None,
|
|
36
|
+
inner_diameter: float | list[float] | None = None,
|
|
37
|
+
outer_diameter: float | list[float] | None = None,
|
|
38
|
+
roughness: float | list[float] | None = None,
|
|
39
|
+
annulus_zone: int | list[int] | None = None,
|
|
40
|
+
):
|
|
41
|
+
"""Initialize Information class."""
|
|
42
|
+
self.number_of_devices = number_of_devices
|
|
43
|
+
self.device_type = device_type
|
|
44
|
+
self.device_number = device_number
|
|
45
|
+
self.inner_diameter = inner_diameter
|
|
46
|
+
self.outer_diameter = outer_diameter
|
|
47
|
+
self.roughness = roughness
|
|
48
|
+
self.annulus_zone = annulus_zone
|
|
49
|
+
|
|
50
|
+
def __iadd__(self, other: Information):
|
|
51
|
+
"""Implement value-wise addition between two Information instances."""
|
|
52
|
+
attributes = [
|
|
53
|
+
attribute for attribute in dir(self) if not attribute.startswith("__") and not attribute.endswith("__")
|
|
54
|
+
]
|
|
55
|
+
for attribute in attributes:
|
|
56
|
+
value = getattr(self, attribute)
|
|
57
|
+
if not isinstance(value, list):
|
|
58
|
+
if value is None:
|
|
59
|
+
value = []
|
|
60
|
+
else:
|
|
61
|
+
value = [value]
|
|
62
|
+
setattr(self, attribute, value)
|
|
63
|
+
|
|
64
|
+
value = getattr(other, attribute)
|
|
65
|
+
attr: list = getattr(self, attribute)
|
|
66
|
+
if attr is None:
|
|
67
|
+
attr = []
|
|
68
|
+
if isinstance(value, list):
|
|
69
|
+
attr.extend(value)
|
|
70
|
+
else:
|
|
71
|
+
attr.append(value)
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def well_trajectory(df_well_segments_header: pd.DataFrame, df_well_segments_content: pd.DataFrame) -> pd.DataFrame:
|
|
76
|
+
"""Create trajectory relation between measured depth and true vertical depth.
|
|
77
|
+
|
|
78
|
+
Well segments must be defined with absolute values (ABS) and not incremental (INC).
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
df_well_segments_header: First record of well segments.
|
|
82
|
+
df_well_segments_content: Second record of well segments.
|
|
83
|
+
|
|
84
|
+
Return:
|
|
85
|
+
Measured depth versus true vertical depth.
|
|
86
|
+
|
|
87
|
+
"""
|
|
88
|
+
measured_depth = df_well_segments_content[Headers.TUBINGMD].to_numpy()
|
|
89
|
+
measured_depth = np.insert(measured_depth, 0, df_well_segments_header[Headers.SEGMENTMD].iloc[0])
|
|
90
|
+
true_vertical_depth = df_well_segments_content[Headers.TUBINGTVD].to_numpy()
|
|
91
|
+
true_vertical_depth = np.insert(true_vertical_depth, 0, df_well_segments_header[Headers.SEGMENTTVD].iloc[0])
|
|
92
|
+
df_measured_true_vertical_depth = as_data_frame({Headers.MD: measured_depth, Headers.TVD: true_vertical_depth})
|
|
93
|
+
# sort based on md
|
|
94
|
+
df_measured_true_vertical_depth = df_measured_true_vertical_depth.sort_values(by=[Headers.MD, Headers.TVD])
|
|
95
|
+
# reset index after sorting
|
|
96
|
+
return df_measured_true_vertical_depth.reset_index(drop=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def define_annulus_zone(df_completion: pd.DataFrame) -> pd.DataFrame:
|
|
100
|
+
"""Define annulus zones based on completion data.
|
|
101
|
+
|
|
102
|
+
Zones are divided to better track individual separated areas of completion.
|
|
103
|
+
The divisions are based on depths, packer location, and the annulus content.
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
df_completion: Raw completion data, must contain start/end measured depth, and annulus content.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Updated completion data with additional column `ANNULUS_ZONE`.
|
|
111
|
+
|
|
112
|
+
Raise:
|
|
113
|
+
ValueError: If the dimensions are incorrect.
|
|
114
|
+
"""
|
|
115
|
+
start_measured_depth = df_completion[Headers.START_MEASURED_DEPTH].iloc[0]
|
|
116
|
+
end_measured_depth = df_completion[Headers.END_MEASURED_DEPTH].iloc[-1]
|
|
117
|
+
gravel_pack_location = df_completion[df_completion[Headers.ANNULUS] == "GP"][
|
|
118
|
+
[Headers.START_MEASURED_DEPTH, Headers.END_MEASURED_DEPTH]
|
|
119
|
+
].to_numpy()
|
|
120
|
+
packer_location = df_completion[df_completion[Headers.ANNULUS] == "PA"][
|
|
121
|
+
[Headers.START_MEASURED_DEPTH, Headers.END_MEASURED_DEPTH]
|
|
122
|
+
].to_numpy()
|
|
123
|
+
# update df_completion by removing PA rows
|
|
124
|
+
df_completion = df_completion[df_completion[Headers.ANNULUS] != "PA"].copy()
|
|
125
|
+
# reset index after filter
|
|
126
|
+
df_completion.reset_index(drop=True, inplace=True)
|
|
127
|
+
annulus_content = df_completion[Headers.ANNULUS].to_numpy()
|
|
128
|
+
df_completion[Headers.ANNULUS_ZONE] = 0
|
|
129
|
+
if "OA" in annulus_content:
|
|
130
|
+
# only if there is an open annulus
|
|
131
|
+
boundary = np.concatenate((packer_location.flatten(), gravel_pack_location.flatten()))
|
|
132
|
+
boundary = np.sort(np.append(np.insert(boundary, 0, start_measured_depth), end_measured_depth))
|
|
133
|
+
boundary = np.unique(boundary)
|
|
134
|
+
start_bound = boundary[:-1]
|
|
135
|
+
end_bound = boundary[1:]
|
|
136
|
+
# get annulus zone
|
|
137
|
+
# initiate with 0
|
|
138
|
+
annulus_zone = np.full(len(start_bound), 0)
|
|
139
|
+
for idx, start_measured_depth in enumerate(start_bound):
|
|
140
|
+
end_measured_depth = end_bound[idx]
|
|
141
|
+
is_gravel_pack_location = np.any(
|
|
142
|
+
(gravel_pack_location[:, 0] == start_measured_depth)
|
|
143
|
+
& (gravel_pack_location[:, 1] == end_measured_depth)
|
|
144
|
+
)
|
|
145
|
+
if not is_gravel_pack_location:
|
|
146
|
+
annulus_zone[idx] = max(annulus_zone) + 1
|
|
147
|
+
# else it is 0
|
|
148
|
+
df_annulus = as_data_frame(
|
|
149
|
+
{
|
|
150
|
+
Headers.START_MEASURED_DEPTH: start_bound,
|
|
151
|
+
Headers.END_MEASURED_DEPTH: end_bound,
|
|
152
|
+
Headers.ANNULUS_ZONE: annulus_zone,
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
annulus_zone = np.full(df_completion.shape[0], 0)
|
|
157
|
+
for idx in range(df_completion.shape[0]):
|
|
158
|
+
start_measured_depth = df_completion[Headers.START_MEASURED_DEPTH].iloc[idx]
|
|
159
|
+
end_measured_depth = df_completion[Headers.END_MEASURED_DEPTH].iloc[idx]
|
|
160
|
+
idx0, idx1 = completion_index(df_annulus, start_measured_depth, end_measured_depth)
|
|
161
|
+
if idx0 != idx1 or idx0 == -1:
|
|
162
|
+
raise ValueError("Check Define Annulus Zone")
|
|
163
|
+
annulus_zone[idx] = df_annulus[Headers.ANNULUS_ZONE].iloc[idx0]
|
|
164
|
+
df_completion[Headers.ANNULUS_ZONE] = annulus_zone
|
|
165
|
+
df_completion[Headers.ANNULUS_ZONE] = df_completion[Headers.ANNULUS_ZONE].astype(np.int64)
|
|
166
|
+
return df_completion
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@overload
|
|
170
|
+
def create_tubing_segments(
|
|
171
|
+
df_reservoir: pd.DataFrame,
|
|
172
|
+
df_completion: pd.DataFrame,
|
|
173
|
+
df_measured_depth_true_vertical_depth: pd.DataFrame,
|
|
174
|
+
method: Literal[Method.FIX] = ...,
|
|
175
|
+
segment_length: float = ...,
|
|
176
|
+
minimum_segment_length: float = 0.0,
|
|
177
|
+
) -> pd.DataFrame: ...
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@overload
|
|
181
|
+
def create_tubing_segments(
|
|
182
|
+
df_reservoir: pd.DataFrame,
|
|
183
|
+
df_completion: pd.DataFrame,
|
|
184
|
+
df_measured_depth_true_vertical_depth: pd.DataFrame,
|
|
185
|
+
method: Method = ...,
|
|
186
|
+
segment_length: float | str = ...,
|
|
187
|
+
minimum_segment_length: float = 0.0,
|
|
188
|
+
) -> pd.DataFrame: ...
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def create_tubing_segments(
|
|
192
|
+
df_reservoir: pd.DataFrame,
|
|
193
|
+
# Technically, df_completion is only required for SegmentCreationMethod.USER
|
|
194
|
+
df_completion: pd.DataFrame,
|
|
195
|
+
df_measured_depth_true_vertical_depth: pd.DataFrame,
|
|
196
|
+
method: Method = Method.CELLS,
|
|
197
|
+
segment_length: float | str = 0.0,
|
|
198
|
+
minimum_segment_length: float = 0.0,
|
|
199
|
+
) -> pd.DataFrame:
|
|
200
|
+
"""Create segments in the tubing layer.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
df_reservoir: Must contain start and end measured depth.
|
|
204
|
+
df_completion: Must contain annulus, start and end measured depth, and annulus zone.
|
|
205
|
+
The packers must be removed in the completion.
|
|
206
|
+
df_measured_depth_true_vertical_depth: Measured and true vertical depths.
|
|
207
|
+
method: Method for segmentation. Defaults to cells.
|
|
208
|
+
segment_length: Only if fix is selected in the method.
|
|
209
|
+
minimum_segment_length: User input minimum segment length.
|
|
210
|
+
|
|
211
|
+
Segmentation methods:
|
|
212
|
+
cells: Create one segment per cell.
|
|
213
|
+
user: Create segment based on the completion definition.
|
|
214
|
+
fix: Create segment based on a fixed interval.
|
|
215
|
+
well_segments: Create segment based on well segments keyword.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
DataFrame with start and end measured depth, tubing measured depth, and tubing true vertical depth.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
ValueError: If the method is unknown.
|
|
222
|
+
"""
|
|
223
|
+
start_measured_depth: npt.NDArray[np.float64]
|
|
224
|
+
end_measured_depth: npt.NDArray[np.float64]
|
|
225
|
+
if method == Method.CELLS:
|
|
226
|
+
# Create the tubing layer one cell one segment while honoring df_reservoir[Headers.SEGMENT]
|
|
227
|
+
start_measured_depth = df_reservoir[Headers.START_MEASURED_DEPTH].to_numpy()
|
|
228
|
+
end_measured_depth = df_reservoir[Headers.END_MEASURED_DEPTH].to_numpy()
|
|
229
|
+
if Headers.SEGMENT in df_reservoir.columns:
|
|
230
|
+
if not df_reservoir[Headers.SEGMENT].isin(["1*"]).any():
|
|
231
|
+
create_start_measured_depths = []
|
|
232
|
+
create_end_measured_depths = []
|
|
233
|
+
create_start_measured_depths.append(df_reservoir[Headers.START_MEASURED_DEPTH].iloc[0])
|
|
234
|
+
current_segment = df_reservoir[Headers.SEGMENT].iloc[0]
|
|
235
|
+
for i in range(1, len(df_reservoir[Headers.SEGMENT])):
|
|
236
|
+
if df_reservoir[Headers.SEGMENT].iloc[i] != current_segment:
|
|
237
|
+
create_end_measured_depths.append(df_reservoir[Headers.END_MEASURED_DEPTH].iloc[i - 1])
|
|
238
|
+
create_start_measured_depths.append(df_reservoir[Headers.START_MEASURED_DEPTH].iloc[i])
|
|
239
|
+
current_segment = df_reservoir[Headers.SEGMENT].iloc[i]
|
|
240
|
+
create_end_measured_depths.append(df_reservoir[Headers.END_MEASURED_DEPTH].iloc[-1])
|
|
241
|
+
start_measured_depth = np.array(create_start_measured_depths)
|
|
242
|
+
end_measured_depth = np.array(create_end_measured_depths)
|
|
243
|
+
|
|
244
|
+
minimum_segment_length = float(minimum_segment_length)
|
|
245
|
+
if minimum_segment_length > 0.0:
|
|
246
|
+
new_start_measured_depth = []
|
|
247
|
+
new_end_measured_depth = []
|
|
248
|
+
diff_measured_depth = end_measured_depth - start_measured_depth
|
|
249
|
+
current_diff_measured_depth = 0.0
|
|
250
|
+
i_start = 0
|
|
251
|
+
i_end = 0
|
|
252
|
+
for i in range(0, len(diff_measured_depth) - 1):
|
|
253
|
+
current_diff_measured_depth += diff_measured_depth[i]
|
|
254
|
+
if current_diff_measured_depth >= minimum_segment_length:
|
|
255
|
+
new_start_measured_depth.append(start_measured_depth[i_start])
|
|
256
|
+
new_end_measured_depth.append(end_measured_depth[i_end])
|
|
257
|
+
current_diff_measured_depth = 0.0
|
|
258
|
+
i_start = i + 1
|
|
259
|
+
i_end = i + 1
|
|
260
|
+
if current_diff_measured_depth < minimum_segment_length:
|
|
261
|
+
new_start_measured_depth.append(start_measured_depth[i_start])
|
|
262
|
+
new_end_measured_depth.append(end_measured_depth[i_end])
|
|
263
|
+
start_measured_depth = np.array(new_start_measured_depth)
|
|
264
|
+
end_measured_depth = np.array(new_end_measured_depth)
|
|
265
|
+
elif method == Method.USER:
|
|
266
|
+
# Create tubing layer based on the definition of COMPLETION keyword in the case file.
|
|
267
|
+
# Read all segments except PA (which has no segment length).
|
|
268
|
+
df_temp = df_completion.copy(deep=True)
|
|
269
|
+
start_measured_depth = df_temp[Headers.START_MEASURED_DEPTH].to_numpy()
|
|
270
|
+
end_measured_depth = df_temp[Headers.END_MEASURED_DEPTH].to_numpy()
|
|
271
|
+
# Fix the start and end.
|
|
272
|
+
start_measured_depth[0] = max(
|
|
273
|
+
df_reservoir[Headers.START_MEASURED_DEPTH].iloc[0], float(start_measured_depth[0])
|
|
274
|
+
)
|
|
275
|
+
end_measured_depth[-1] = min(df_reservoir[Headers.END_MEASURED_DEPTH].iloc[-1], float(end_measured_depth[-1]))
|
|
276
|
+
if start_measured_depth[0] >= end_measured_depth[0]:
|
|
277
|
+
start_measured_depth = np.delete(start_measured_depth, 0)
|
|
278
|
+
end_measured_depth = np.delete(end_measured_depth, 0)
|
|
279
|
+
if start_measured_depth[-1] >= end_measured_depth[-1]:
|
|
280
|
+
start_measured_depth = np.delete(start_measured_depth, -1)
|
|
281
|
+
end_measured_depth = np.delete(end_measured_depth, -1)
|
|
282
|
+
elif method == Method.FIX:
|
|
283
|
+
# Create tubing layer with fix interval according to the user input in the case file keyword SEGMENTLENGTH.
|
|
284
|
+
min_measured_depth = df_reservoir[Headers.START_MEASURED_DEPTH].min()
|
|
285
|
+
max_measured_depth = df_reservoir[Headers.END_MEASURED_DEPTH].max()
|
|
286
|
+
if not isinstance(segment_length, (float, int)):
|
|
287
|
+
raise ValueError(f"Segment length must be a number, when using method fix (was {segment_length}).")
|
|
288
|
+
start_measured_depth = np.arange(min_measured_depth, max_measured_depth, segment_length)
|
|
289
|
+
end_measured_depth = start_measured_depth + segment_length
|
|
290
|
+
# Update the end point of the last segment.
|
|
291
|
+
end_measured_depth[-1] = min(float(end_measured_depth[-1]), max_measured_depth)
|
|
292
|
+
elif method == Method.WELSEGS:
|
|
293
|
+
# Create the tubing layer from segment measured depths in the WELSEGS keyword that are missing from COMPSEGS.
|
|
294
|
+
# WELSEGS segment depths are collected in the df_measured_depth_true_vertical_depth dataframe, which is available here.
|
|
295
|
+
# Completor interprets WELSEGS depths as segment midpoint depths.
|
|
296
|
+
# Obtain the well_segments segment midpoint depth.
|
|
297
|
+
well_segments = df_measured_depth_true_vertical_depth[Headers.MD].to_numpy()
|
|
298
|
+
end_welsegs_depth = 0.5 * (well_segments[:-1] + well_segments[1:])
|
|
299
|
+
# The start of the very first segment in any branch is the actual startMD of the first segment.
|
|
300
|
+
start_welsegs_depth = np.insert(end_welsegs_depth[:-1], 0, well_segments[0], axis=None)
|
|
301
|
+
start_compsegs_depth: npt.NDArray[np.float64] = df_reservoir[Headers.START_MEASURED_DEPTH].to_numpy()
|
|
302
|
+
end_compsegs_depth = df_reservoir[Headers.END_MEASURED_DEPTH].to_numpy()
|
|
303
|
+
# If there are gaps in compsegs and there are well_segments segments that fit in the gaps,
|
|
304
|
+
# insert well_segments segments into the compsegs gaps.
|
|
305
|
+
gaps_compsegs = start_compsegs_depth[1:] - end_compsegs_depth[:-1]
|
|
306
|
+
# Indices of gaps in compsegs.
|
|
307
|
+
indices_gaps = np.nonzero(gaps_compsegs)
|
|
308
|
+
# Start of the gaps.
|
|
309
|
+
start_gaps_depth = end_compsegs_depth[indices_gaps[0]]
|
|
310
|
+
# End of the gaps.
|
|
311
|
+
end_gaps_depth = start_compsegs_depth[indices_gaps[0] + 1]
|
|
312
|
+
# Check the gaps between COMPSEGS and fill it out with WELSEGS.
|
|
313
|
+
start = np.abs(start_welsegs_depth[:, np.newaxis] - start_gaps_depth).argmin(axis=0)
|
|
314
|
+
end = np.abs(end_welsegs_depth[:, np.newaxis] - end_gaps_depth).argmin(axis=0)
|
|
315
|
+
welsegs_to_add = np.setxor1d(start_welsegs_depth[start], end_welsegs_depth[end])
|
|
316
|
+
start_welsegs_outside = start_welsegs_depth[np.argwhere(start_welsegs_depth < start_compsegs_depth[0])]
|
|
317
|
+
end_welsegs_outside = end_welsegs_depth[np.argwhere(end_welsegs_depth > end_compsegs_depth[-1])]
|
|
318
|
+
welsegs_to_add = np.append(welsegs_to_add, start_welsegs_outside)
|
|
319
|
+
welsegs_to_add = np.append(welsegs_to_add, end_welsegs_outside)
|
|
320
|
+
# Find well_segments start and end in gaps.
|
|
321
|
+
start_compsegs_depth = np.append(start_compsegs_depth, welsegs_to_add)
|
|
322
|
+
end_compsegs_depth = np.append(end_compsegs_depth, welsegs_to_add)
|
|
323
|
+
start_measured_depth = np.sort(start_compsegs_depth)
|
|
324
|
+
end_measured_depth = np.sort(end_compsegs_depth)
|
|
325
|
+
# Check for missing segment.
|
|
326
|
+
shift_start_measured_depth = np.append(start_measured_depth[1:], end_measured_depth[-1])
|
|
327
|
+
missing_index = np.argwhere(shift_start_measured_depth > end_measured_depth).flatten()
|
|
328
|
+
missing_index += 1
|
|
329
|
+
new_missing_start_measured_depth = end_measured_depth[missing_index - 1]
|
|
330
|
+
new_missing_end_measured_depth = start_measured_depth[missing_index]
|
|
331
|
+
start_measured_depth = np.sort(np.append(start_measured_depth, new_missing_start_measured_depth))
|
|
332
|
+
end_measured_depth = np.sort(np.append(end_measured_depth, new_missing_end_measured_depth))
|
|
333
|
+
# drop duplicate
|
|
334
|
+
duplicate_indexes = np.argwhere(start_measured_depth == end_measured_depth)
|
|
335
|
+
start_measured_depth = np.delete(start_measured_depth, duplicate_indexes)
|
|
336
|
+
end_measured_depth = np.delete(end_measured_depth, duplicate_indexes)
|
|
337
|
+
else:
|
|
338
|
+
raise ValueError(f"Unknown method '{method}'.")
|
|
339
|
+
|
|
340
|
+
# md for tubing segments
|
|
341
|
+
measured_depth_ = 0.5 * (start_measured_depth + end_measured_depth)
|
|
342
|
+
# estimate TVD
|
|
343
|
+
true_vertical_depth = np.interp(
|
|
344
|
+
measured_depth_,
|
|
345
|
+
df_measured_depth_true_vertical_depth[Headers.MD].to_numpy(),
|
|
346
|
+
df_measured_depth_true_vertical_depth[Headers.TVD].to_numpy(),
|
|
347
|
+
)
|
|
348
|
+
# create data frame
|
|
349
|
+
return as_data_frame(
|
|
350
|
+
{
|
|
351
|
+
Headers.START_MEASURED_DEPTH: start_measured_depth,
|
|
352
|
+
Headers.END_MEASURED_DEPTH: end_measured_depth,
|
|
353
|
+
Headers.TUB_MD: measured_depth_,
|
|
354
|
+
Headers.TUB_TVD: true_vertical_depth,
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def insert_missing_segments(df_tubing_segments: pd.DataFrame, well_name: str | None) -> pd.DataFrame:
|
|
360
|
+
"""Create segments for inactive cells.
|
|
361
|
+
|
|
362
|
+
Sometimes inactive cells have no segments.
|
|
363
|
+
It is required to create segments for these cells to get the scaling factor correct.
|
|
364
|
+
Inactive cells are indicated by segments starting at measured depth deeper than the end of the previous cell.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
df_tubing_segments: Must contain start and end measured depth.
|
|
368
|
+
well_name: Name of well.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
DataFrame with the gaps filled.
|
|
372
|
+
|
|
373
|
+
Raises:
|
|
374
|
+
CompletorError: If the Schedule file is missing data for one or more branches in the case file.
|
|
375
|
+
"""
|
|
376
|
+
if df_tubing_segments.empty:
|
|
377
|
+
raise CompletorError(
|
|
378
|
+
"Schedule file is missing data for one or more branches defined in the case file. "
|
|
379
|
+
f"Please check the data for well {well_name}."
|
|
380
|
+
)
|
|
381
|
+
# sort the data frame based on STARTMD
|
|
382
|
+
df_tubing_segments.sort_values(by=[Headers.START_MEASURED_DEPTH], inplace=True)
|
|
383
|
+
# add column to indicate original segment
|
|
384
|
+
df_tubing_segments[Headers.SEGMENT_DESC] = [Headers.ORIGINAL_SEGMENT] * df_tubing_segments.shape[0]
|
|
385
|
+
end_measured_depth = df_tubing_segments[Headers.END_MEASURED_DEPTH].to_numpy()
|
|
386
|
+
# get start_measured_depth and start from segment 2 and add the last item to be the last end_measured_depth
|
|
387
|
+
start_measured_depth = np.append(
|
|
388
|
+
df_tubing_segments[Headers.START_MEASURED_DEPTH].to_numpy()[1:], end_measured_depth[-1]
|
|
389
|
+
)
|
|
390
|
+
# find rows where start_measured_depth > end_measured_depth
|
|
391
|
+
missing_index = np.argwhere(start_measured_depth > end_measured_depth).flatten()
|
|
392
|
+
# proceed only if there are missing index
|
|
393
|
+
if missing_index.size == 0:
|
|
394
|
+
return df_tubing_segments
|
|
395
|
+
# shift one row down because we move it up one row
|
|
396
|
+
missing_index += 1
|
|
397
|
+
df_copy = df_tubing_segments.iloc[missing_index, :].copy(deep=True)
|
|
398
|
+
# new start measured depth is the previous segment end measured depth
|
|
399
|
+
df_copy[Headers.START_MEASURED_DEPTH] = df_tubing_segments[Headers.END_MEASURED_DEPTH].to_numpy()[missing_index - 1]
|
|
400
|
+
df_copy[Headers.END_MEASURED_DEPTH] = df_tubing_segments[Headers.START_MEASURED_DEPTH].to_numpy()[missing_index]
|
|
401
|
+
df_copy[Headers.SEGMENT_DESC] = [Headers.ADDITIONAL_SEGMENT] * df_copy.shape[0]
|
|
402
|
+
# combine the two data frame
|
|
403
|
+
df_tubing_segments = pd.concat([df_tubing_segments, df_copy])
|
|
404
|
+
df_tubing_segments.sort_values(by=[Headers.START_MEASURED_DEPTH], inplace=True)
|
|
405
|
+
df_tubing_segments.reset_index(drop=True, inplace=True)
|
|
406
|
+
return df_tubing_segments
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def completion_index(df_completion: pd.DataFrame, start: float, end: float) -> tuple[int, int]:
|
|
410
|
+
"""Find the indices in the completion DataFrame of start and end measured depth.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
df_completion: Must contain start and end measured depth.
|
|
414
|
+
start: Start measured depth.
|
|
415
|
+
end: End measured depth.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Indices - Tuple of int.
|
|
419
|
+
"""
|
|
420
|
+
start_md = df_completion[Headers.START_MEASURED_DEPTH].to_numpy()
|
|
421
|
+
end_md = df_completion[Headers.END_MEASURED_DEPTH].to_numpy()
|
|
422
|
+
_start = np.argwhere((start_md <= start) & (end_md > start)).flatten()
|
|
423
|
+
_end = np.argwhere((start_md < end) & (end_md >= end)).flatten()
|
|
424
|
+
if _start.size == 0 or _end.size == 0:
|
|
425
|
+
# completion index not found then give negative value for both
|
|
426
|
+
return -1, -1
|
|
427
|
+
return int(_start[0]), int(_end[0])
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def get_completion(start: float, end: float, df_completion: pd.DataFrame, joint_length: float) -> Information:
|
|
431
|
+
"""Get information from the completion.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
start: Start measured depth of the segment.
|
|
435
|
+
end: End measured depth of the segment.
|
|
436
|
+
df_completion: COMPLETION table that must contain columns: `STARTMD`, `ENDMD`, `NVALVEPERJOINT`,
|
|
437
|
+
`INNER_DIAMETER`, `OUTER_DIAMETER`, `ROUGHNESS`, `DEVICETYPE`, `DEVICENUMBER`, and `ANNULUS_ZONE`.
|
|
438
|
+
joint_length: Length of a joint.
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Instance of Information.
|
|
442
|
+
|
|
443
|
+
Raises:
|
|
444
|
+
ValueError:
|
|
445
|
+
If the completion is not defined from start to end.
|
|
446
|
+
If outer diameter is smaller than inner diameter.
|
|
447
|
+
If the completion data contains illegal / invalid rows.
|
|
448
|
+
If information class is None.
|
|
449
|
+
"""
|
|
450
|
+
information = None
|
|
451
|
+
device_type = None
|
|
452
|
+
device_number = None
|
|
453
|
+
inner_diameter = None
|
|
454
|
+
outer_diameter = None
|
|
455
|
+
roughness = None
|
|
456
|
+
annulus_zone = None
|
|
457
|
+
|
|
458
|
+
start_completion = df_completion[Headers.START_MEASURED_DEPTH].to_numpy()
|
|
459
|
+
end_completion = df_completion[Headers.END_MEASURED_DEPTH].to_numpy()
|
|
460
|
+
idx0, idx1 = completion_index(df_completion, start, end)
|
|
461
|
+
|
|
462
|
+
if idx0 == -1 or idx1 == -1:
|
|
463
|
+
well_name = df_completion[Headers.WELL].iloc[0]
|
|
464
|
+
log_and_raise_exception(f"No completion is defined on well {well_name} from {start} to {end}.")
|
|
465
|
+
|
|
466
|
+
# previous length start with 0
|
|
467
|
+
prev_length = 0.0
|
|
468
|
+
num_device = 0.0
|
|
469
|
+
|
|
470
|
+
for completion_idx in range(idx0, idx1 + 1):
|
|
471
|
+
completion_length = min(end_completion[completion_idx], end) - max(start_completion[completion_idx], start)
|
|
472
|
+
if completion_length <= 0:
|
|
473
|
+
_ = "equals" if completion_length == 0 else "less than"
|
|
474
|
+
logger.warning(
|
|
475
|
+
f"Start depth {_} stop depth, in row {completion_idx}, "
|
|
476
|
+
f"for well {df_completion[Headers.WELL][completion_idx]}"
|
|
477
|
+
)
|
|
478
|
+
# calculate cumulative parameter
|
|
479
|
+
num_device += (completion_length / joint_length) * df_completion[Headers.VALVES_PER_JOINT].iloc[completion_idx]
|
|
480
|
+
|
|
481
|
+
if completion_length > prev_length:
|
|
482
|
+
# get well geometry
|
|
483
|
+
inner_diameter = df_completion[Headers.INNER_DIAMETER].iloc[completion_idx]
|
|
484
|
+
outer_diameter = df_completion[Headers.OUTER_DIAMETER].iloc[completion_idx]
|
|
485
|
+
roughness = df_completion[Headers.ROUGHNESS].iloc[completion_idx]
|
|
486
|
+
if outer_diameter > inner_diameter:
|
|
487
|
+
outer_diameter = (outer_diameter**2 - inner_diameter**2) ** 0.5
|
|
488
|
+
else:
|
|
489
|
+
raise ValueError("Check screen/tubing and well/casing ID in case file.")
|
|
490
|
+
|
|
491
|
+
# get device information
|
|
492
|
+
device_type = df_completion[Headers.DEVICE_TYPE].iloc[completion_idx]
|
|
493
|
+
device_number = df_completion[Headers.DEVICE_NUMBER].iloc[completion_idx]
|
|
494
|
+
# other information
|
|
495
|
+
annulus_zone = df_completion[Headers.ANNULUS_ZONE].iloc[completion_idx]
|
|
496
|
+
# set prev_length to this segment
|
|
497
|
+
prev_length = completion_length
|
|
498
|
+
|
|
499
|
+
if all(
|
|
500
|
+
x is not None for x in [device_type, device_number, inner_diameter, outer_diameter, roughness, annulus_zone]
|
|
501
|
+
):
|
|
502
|
+
information = Information(
|
|
503
|
+
num_device, device_type, device_number, inner_diameter, outer_diameter, roughness, annulus_zone
|
|
504
|
+
)
|
|
505
|
+
else:
|
|
506
|
+
# I.e. if completion_length > prev_length never happens
|
|
507
|
+
raise ValueError(
|
|
508
|
+
f"The completion data for well '{df_completion[Headers.WELL][completion_idx]}' "
|
|
509
|
+
"contains illegal / invalid row(s). "
|
|
510
|
+
"Please check their start mD / end mD columns, and ensure that they start before they end."
|
|
511
|
+
)
|
|
512
|
+
if information is None:
|
|
513
|
+
raise ValueError(
|
|
514
|
+
f"idx0 == idx1 + 1 (idx0={idx0}). For the time being, the reason is unknown. "
|
|
515
|
+
"Please reach out to the Equinor Inflow Control Team if you encounter this."
|
|
516
|
+
)
|
|
517
|
+
return information
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def complete_the_well(
|
|
521
|
+
df_tubing_segments: pd.DataFrame, df_completion: pd.DataFrame, joint_length: float
|
|
522
|
+
) -> pd.DataFrame:
|
|
523
|
+
"""Complete the well with the user completion.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
df_tubing_segments: Output from function create_tubing_segments.
|
|
527
|
+
df_completion: Output from define_annulus_zone.
|
|
528
|
+
joint_length: Length of a joint.
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
Well information.
|
|
532
|
+
"""
|
|
533
|
+
start = df_tubing_segments[Headers.START_MEASURED_DEPTH].to_numpy()
|
|
534
|
+
end = df_tubing_segments[Headers.END_MEASURED_DEPTH].to_numpy()
|
|
535
|
+
# initiate completion
|
|
536
|
+
information = Information()
|
|
537
|
+
# loop through the cells
|
|
538
|
+
for i in range(df_tubing_segments.shape[0]):
|
|
539
|
+
information += get_completion(start[i], end[i], df_completion, joint_length)
|
|
540
|
+
|
|
541
|
+
df_well = as_data_frame(
|
|
542
|
+
{
|
|
543
|
+
Headers.TUB_MD: df_tubing_segments[Headers.TUB_MD].to_numpy(),
|
|
544
|
+
Headers.TUB_TVD: df_tubing_segments[Headers.TUB_TVD].to_numpy(),
|
|
545
|
+
Headers.LENGTH: end - start,
|
|
546
|
+
Headers.SEGMENT_DESC: df_tubing_segments[Headers.SEGMENT_DESC].to_numpy(),
|
|
547
|
+
Headers.NUMBER_OF_DEVICES: information.number_of_devices,
|
|
548
|
+
Headers.DEVICE_NUMBER: information.device_number,
|
|
549
|
+
Headers.DEVICE_TYPE: information.device_type,
|
|
550
|
+
Headers.INNER_DIAMETER: information.inner_diameter,
|
|
551
|
+
Headers.OUTER_DIAMETER: information.outer_diameter,
|
|
552
|
+
Headers.ROUGHNESS: information.roughness,
|
|
553
|
+
Headers.ANNULUS_ZONE: information.annulus_zone,
|
|
554
|
+
}
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# lumping segments
|
|
558
|
+
df_well = lumping_segments(df_well)
|
|
559
|
+
|
|
560
|
+
# create scaling factor
|
|
561
|
+
df_well[Headers.SCALING_FACTOR] = np.where(
|
|
562
|
+
df_well[Headers.NUMBER_OF_DEVICES] > 0.0, -1.0 / df_well[Headers.NUMBER_OF_DEVICES], 0.0
|
|
563
|
+
)
|
|
564
|
+
return df_well
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def lumping_segments(df_well: pd.DataFrame) -> pd.DataFrame:
|
|
568
|
+
"""Lump additional segments to the original segments.
|
|
569
|
+
|
|
570
|
+
This only applies if the additional segments have an annulus zone.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
df_well: Must contain data on annulus zone, number of devices and the segments descending.
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
Updated well information.
|
|
577
|
+
"""
|
|
578
|
+
number_of_devices = df_well[Headers.NUMBER_OF_DEVICES].to_numpy()
|
|
579
|
+
annulus_zone = df_well[Headers.ANNULUS_ZONE].to_numpy()
|
|
580
|
+
segments_descending = df_well[Headers.SEGMENT_DESC].to_numpy()
|
|
581
|
+
number_of_rows = df_well.shape[0]
|
|
582
|
+
for i in range(number_of_rows):
|
|
583
|
+
if segments_descending[i] != Headers.ADDITIONAL_SEGMENT:
|
|
584
|
+
continue
|
|
585
|
+
|
|
586
|
+
# only additional segments
|
|
587
|
+
if annulus_zone[i] > 0:
|
|
588
|
+
# meaning only annular zones
|
|
589
|
+
# compare it to the segment before and after
|
|
590
|
+
been_lumped = False
|
|
591
|
+
if i - 1 >= 0 and not been_lumped and annulus_zone[i] == annulus_zone[i - 1]:
|
|
592
|
+
# compare it to the segment before
|
|
593
|
+
number_of_devices[i - 1] = number_of_devices[i - 1] + number_of_devices[i]
|
|
594
|
+
been_lumped = True
|
|
595
|
+
if i + 1 < number_of_rows and not been_lumped and annulus_zone[i] == annulus_zone[i + 1]:
|
|
596
|
+
# compare it to the segment after
|
|
597
|
+
number_of_devices[i + 1] = number_of_devices[i + 1] + number_of_devices[i]
|
|
598
|
+
# update the number of devices to 0 for this segment
|
|
599
|
+
# because it is lumped to others
|
|
600
|
+
# and it is 0 if it has no annulus zone
|
|
601
|
+
number_of_devices[i] = 0.0
|
|
602
|
+
df_well[Headers.NUMBER_OF_DEVICES] = number_of_devices
|
|
603
|
+
# from now on it is only original segment
|
|
604
|
+
df_well = df_well[df_well[Headers.SEGMENT_DESC] == Headers.ORIGINAL_SEGMENT].copy()
|
|
605
|
+
# reset index after filter
|
|
606
|
+
return df_well.reset_index(drop=True, inplace=False)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def get_device(df_well: pd.DataFrame, df_device: pd.DataFrame, device_type: DeviceType) -> pd.DataFrame:
|
|
610
|
+
"""Get device characteristics.
|
|
611
|
+
|
|
612
|
+
Args:
|
|
613
|
+
df_well: Must contain device type, device number, and the scaling factor.
|
|
614
|
+
df_device: Device table.
|
|
615
|
+
device_type: Device type. `AICD`, `ICD`, `DAR`, `VALVE`, `AICV`, `ICV`.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
Updated well information with device characteristics.
|
|
619
|
+
|
|
620
|
+
Raises:
|
|
621
|
+
ValueError: If missing device type in input files.
|
|
622
|
+
"""
|
|
623
|
+
columns = [Headers.DEVICE_TYPE, Headers.DEVICE_NUMBER]
|
|
624
|
+
try:
|
|
625
|
+
df_well = pd.merge(df_well, df_device, how="left", on=columns)
|
|
626
|
+
except KeyError as err:
|
|
627
|
+
if f"'{Headers.DEVICE_TYPE}'" in str(err):
|
|
628
|
+
raise ValueError(f"Missing keyword 'DEVICETYPE {device_type}' in input files.") from err
|
|
629
|
+
raise err
|
|
630
|
+
if device_type == "VALVE":
|
|
631
|
+
# rescale the Cv
|
|
632
|
+
# because no scaling factor in WSEGVALV
|
|
633
|
+
df_well[Headers.CV] = -df_well[Headers.CV] / df_well[Headers.SCALING_FACTOR]
|
|
634
|
+
elif device_type == "DAR":
|
|
635
|
+
# rescale the Cv
|
|
636
|
+
# because no scaling factor in WSEGVALV
|
|
637
|
+
df_well[Headers.CV_DAR] = -df_well[Headers.CV_DAR] / df_well[Headers.SCALING_FACTOR]
|
|
638
|
+
return df_well
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def correct_annulus_zone(df_well: pd.DataFrame) -> pd.DataFrame:
|
|
642
|
+
"""Correct the annulus zone.
|
|
643
|
+
|
|
644
|
+
If there are no connections to the tubing in the annulus zone, then there is no annulus zone.
|
|
645
|
+
|
|
646
|
+
Args:
|
|
647
|
+
df_well: Must contain annulus zone, number of devices, and device type.
|
|
648
|
+
|
|
649
|
+
Returns:
|
|
650
|
+
Updated DataFrame with corrected annulus zone.
|
|
651
|
+
"""
|
|
652
|
+
zones = df_well[Headers.ANNULUS_ZONE].unique()
|
|
653
|
+
for zone in zones:
|
|
654
|
+
if zone == 0:
|
|
655
|
+
continue
|
|
656
|
+
df_zone = df_well[df_well[Headers.ANNULUS_ZONE] == zone]
|
|
657
|
+
df_zone_device = df_zone[
|
|
658
|
+
(df_zone[Headers.NUMBER_OF_DEVICES].to_numpy() > 0) | (df_zone[Headers.DEVICE_TYPE].to_numpy() == "PERF")
|
|
659
|
+
]
|
|
660
|
+
if df_zone_device.shape[0] == 0:
|
|
661
|
+
df_well[Headers.ANNULUS_ZONE].replace(zone, 0, inplace=True)
|
|
662
|
+
return df_well
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def connect_cells_to_segments(
|
|
666
|
+
df_well: pd.DataFrame, df_reservoir: pd.DataFrame, df_tubing_segments: pd.DataFrame, method: Method
|
|
667
|
+
) -> pd.DataFrame:
|
|
668
|
+
"""Connect cells to segments.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
df_well: Segment table. Must contain tubing measured depth.
|
|
672
|
+
df_reservoir: COMPSEGS table. Must contain start and end measured depth.
|
|
673
|
+
df_tubing_segments: Tubing segment dataframe. Must contain start and end measured depth.
|
|
674
|
+
method: Segmentation method indicator. Must be one of 'user', 'fix', 'welsegs', or 'cells'.
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
Merged DataFrame.
|
|
678
|
+
"""
|
|
679
|
+
# Calculate mid cell measured depth
|
|
680
|
+
df_reservoir[Headers.MD] = (
|
|
681
|
+
df_reservoir[Headers.START_MEASURED_DEPTH] + df_reservoir[Headers.END_MEASURED_DEPTH]
|
|
682
|
+
) * 0.5
|
|
683
|
+
if method == Method.USER:
|
|
684
|
+
df_res = df_reservoir.copy(deep=True)
|
|
685
|
+
df_wel = df_well.copy(deep=True)
|
|
686
|
+
# Ensure that tubing segment boundaries as described in the case file are honored.
|
|
687
|
+
# Associate reservoir cells with tubing segment midpoints using markers
|
|
688
|
+
marker = 1
|
|
689
|
+
df_res[Headers.MARKER] = np.full(df_reservoir.shape[0], 0)
|
|
690
|
+
df_wel[Headers.MARKER] = np.arange(df_well.shape[0]) + 1
|
|
691
|
+
for idx in df_wel[Headers.TUB_MD].index:
|
|
692
|
+
start_measured_depth = df_tubing_segments[Headers.START_MEASURED_DEPTH].iloc[idx]
|
|
693
|
+
end_measured_depth = df_tubing_segments[Headers.END_MEASURED_DEPTH].iloc[idx]
|
|
694
|
+
df_res.loc[df_res[Headers.MD].between(start_measured_depth, end_measured_depth), Headers.MARKER] = marker
|
|
695
|
+
marker += 1
|
|
696
|
+
# Merge
|
|
697
|
+
tmp = df_res.merge(df_wel, on=[Headers.MARKER])
|
|
698
|
+
return tmp.drop([Headers.MARKER], axis=1, inplace=False)
|
|
699
|
+
|
|
700
|
+
return pd.merge_asof(
|
|
701
|
+
left=df_reservoir, right=df_well, left_on=[Headers.MD], right_on=[Headers.TUB_MD], direction="nearest"
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
class WellSchedule:
|
|
706
|
+
"""A collection of all the active multi-segment wells.
|
|
707
|
+
|
|
708
|
+
Attributes:
|
|
709
|
+
msws: Multisegmented well segments.
|
|
710
|
+
active_wells: The active wells for completor to work on.
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
active_wells: Active multi-segment wells defined in a case file.
|
|
714
|
+
"""
|
|
715
|
+
|
|
716
|
+
def __init__(self, active_wells: npt.NDArray[np.unicode_] | list[str]):
|
|
717
|
+
"""Initialize WellSchedule."""
|
|
718
|
+
self.msws: dict[str, dict] = {}
|
|
719
|
+
self.active_wells = np.array(active_wells)
|
|
720
|
+
|
|
721
|
+
def set_welspecs(self, records: list[list[str]]) -> None:
|
|
722
|
+
"""Convert the well specifications (WELSPECS) record to a Pandas DataFrame.
|
|
723
|
+
|
|
724
|
+
* Sets DataFrame column titles.
|
|
725
|
+
* Formats column values.
|
|
726
|
+
* Pads missing columns at the end of the DataFrame with default values (1*).
|
|
727
|
+
|
|
728
|
+
Args:
|
|
729
|
+
records: Raw well specification.
|
|
730
|
+
|
|
731
|
+
Returns:
|
|
732
|
+
Record of inactive wells (in `self.msws`).
|
|
733
|
+
"""
|
|
734
|
+
columns = [
|
|
735
|
+
Headers.WELL,
|
|
736
|
+
Headers.GROUP,
|
|
737
|
+
Headers.I,
|
|
738
|
+
Headers.J,
|
|
739
|
+
Headers.BHP_DEPTH,
|
|
740
|
+
Headers.PHASE,
|
|
741
|
+
Headers.DR,
|
|
742
|
+
Headers.FLAG,
|
|
743
|
+
Headers.SHUT,
|
|
744
|
+
Headers.CROSS,
|
|
745
|
+
Headers.PRESSURE_TABLE,
|
|
746
|
+
Headers.DENSCAL,
|
|
747
|
+
Headers.REGION,
|
|
748
|
+
Headers.ITEM_14,
|
|
749
|
+
Headers.ITEM_15,
|
|
750
|
+
Headers.ITEM_16,
|
|
751
|
+
Headers.ITEM_17,
|
|
752
|
+
]
|
|
753
|
+
_records = records[0] + ["1*"] * (len(columns) - len(records[0])) # pad with default values (1*)
|
|
754
|
+
df = pd.DataFrame(np.array(_records).reshape((1, len(columns))), columns=columns)
|
|
755
|
+
# datatypes
|
|
756
|
+
df[columns[2:4]] = df[columns[2:4]].astype(np.int64)
|
|
757
|
+
try:
|
|
758
|
+
df[columns[4]] = df[columns[4]].astype(np.float64)
|
|
759
|
+
except ValueError:
|
|
760
|
+
pass
|
|
761
|
+
# welspecs could be for multiple wells - split it
|
|
762
|
+
for well_name in df[Headers.WELL].unique():
|
|
763
|
+
if well_name not in self.msws:
|
|
764
|
+
self.msws[well_name] = {}
|
|
765
|
+
self.msws[well_name][Keywords.WELSPECS] = df[df[Headers.WELL] == well_name]
|
|
766
|
+
logger.debug("set_welspecs for %s", well_name)
|
|
767
|
+
|
|
768
|
+
def handle_compdat(self, records: list[list[str]]) -> list[list[str]]:
|
|
769
|
+
"""Convert completion data (COMPDAT) record to a DataFrame.
|
|
770
|
+
|
|
771
|
+
* Sets DataFrame column titles.
|
|
772
|
+
* Pads missing values with default values (1*).
|
|
773
|
+
* Sets column data types.
|
|
774
|
+
|
|
775
|
+
Args:
|
|
776
|
+
records: Record set of COMPDAT data.
|
|
777
|
+
|
|
778
|
+
Returns:
|
|
779
|
+
Records for inactive wells.
|
|
780
|
+
"""
|
|
781
|
+
well_names = set() # the active well-names found in this chunk
|
|
782
|
+
remains = [] # the other wells
|
|
783
|
+
for rec in records:
|
|
784
|
+
well_name = rec[0]
|
|
785
|
+
if well_name in list(self.active_wells):
|
|
786
|
+
well_names.add(well_name)
|
|
787
|
+
else:
|
|
788
|
+
remains.append(rec)
|
|
789
|
+
columns = [
|
|
790
|
+
Headers.WELL,
|
|
791
|
+
Headers.I,
|
|
792
|
+
Headers.J,
|
|
793
|
+
Headers.K,
|
|
794
|
+
Headers.K2,
|
|
795
|
+
Headers.STATUS,
|
|
796
|
+
Headers.SATURATION_FUNCTION_REGION_NUMBERS,
|
|
797
|
+
Headers.CONNECTION_FACTOR,
|
|
798
|
+
Headers.DIAMETER,
|
|
799
|
+
Headers.FORAMTION_PERMEABILITY_THICKNESS,
|
|
800
|
+
Headers.SKIN,
|
|
801
|
+
Headers.DFACT,
|
|
802
|
+
Headers.COMPDAT_DIRECTION,
|
|
803
|
+
Headers.RO,
|
|
804
|
+
]
|
|
805
|
+
df = pd.DataFrame(records, columns=columns[0 : len(records[0])])
|
|
806
|
+
if Headers.RO in df.columns:
|
|
807
|
+
df[Headers.RO] = df[Headers.RO].fillna("1*")
|
|
808
|
+
for i in range(len(records[0]), len(columns)):
|
|
809
|
+
df[columns[i]] = ["1*"] * len(records)
|
|
810
|
+
# data types
|
|
811
|
+
df[columns[1:5]] = df[columns[1:5]].astype(np.int64)
|
|
812
|
+
# Change default value '1*' to equivalent float
|
|
813
|
+
df["SKIN"] = df["SKIN"].replace(["1*"], 0.0)
|
|
814
|
+
df[[Headers.DIAMETER, Headers.SKIN]] = df[[Headers.DIAMETER, Headers.SKIN]].astype(np.float64)
|
|
815
|
+
# check if CONNECTION_FACTOR, FORAMTION_PERMEABILITY_THICKNESS, and RO are defaulted by the users
|
|
816
|
+
try:
|
|
817
|
+
df[[Headers.CONNECTION_FACTOR]] = df[[Headers.CONNECTION_FACTOR]].astype(np.float64)
|
|
818
|
+
except ValueError:
|
|
819
|
+
pass
|
|
820
|
+
try:
|
|
821
|
+
df[[Headers.FORAMTION_PERMEABILITY_THICKNESS]] = df[[Headers.FORAMTION_PERMEABILITY_THICKNESS]].astype(
|
|
822
|
+
np.float64
|
|
823
|
+
)
|
|
824
|
+
except ValueError:
|
|
825
|
+
pass
|
|
826
|
+
try:
|
|
827
|
+
df[[Headers.RO]] = df[[Headers.RO]].astype(np.float64)
|
|
828
|
+
except ValueError:
|
|
829
|
+
pass
|
|
830
|
+
# compdat could be for multiple wells - split it
|
|
831
|
+
for well_name in well_names:
|
|
832
|
+
if well_name not in self.msws:
|
|
833
|
+
self.msws[well_name] = {}
|
|
834
|
+
self.msws[well_name][Keywords.COMPDAT] = df[df[Headers.WELL] == well_name]
|
|
835
|
+
logger.debug("handle_compdat for %s", well_name)
|
|
836
|
+
return remains
|
|
837
|
+
|
|
838
|
+
def set_welsegs(self, recs: list[list[str]]) -> str | None:
|
|
839
|
+
"""Update the well segments (WELSEGS) for a given well if it is an active well.
|
|
840
|
+
|
|
841
|
+
* Pads missing record columns in header and contents with default values.
|
|
842
|
+
* Convert header and column records to DataFrames.
|
|
843
|
+
* Sets proper DataFrame column types and titles.
|
|
844
|
+
* Converts segment depth specified in incremental (INC) to absolute (ABS) values using fix_welsegs.
|
|
845
|
+
|
|
846
|
+
Args:
|
|
847
|
+
recs: Record set of header and contents data.
|
|
848
|
+
|
|
849
|
+
Returns:
|
|
850
|
+
Name of well if it was updated, or None if it is not in the active_wells list.
|
|
851
|
+
"""
|
|
852
|
+
well_name = recs[0][0] # each WELSEGS-chunk is for one well only
|
|
853
|
+
if well_name not in self.active_wells:
|
|
854
|
+
return None
|
|
855
|
+
|
|
856
|
+
# make df for header record
|
|
857
|
+
columns_header = [
|
|
858
|
+
Headers.WELL,
|
|
859
|
+
Headers.SEGMENTTVD,
|
|
860
|
+
Headers.SEGMENTMD,
|
|
861
|
+
Headers.WBVOLUME,
|
|
862
|
+
Headers.INFO_TYPE,
|
|
863
|
+
Headers.PDROPCOMP,
|
|
864
|
+
Headers.MPMODEL,
|
|
865
|
+
Headers.ITEM_8,
|
|
866
|
+
Headers.ITEM_9,
|
|
867
|
+
Headers.ITEM_10,
|
|
868
|
+
Headers.ITEM_11,
|
|
869
|
+
Headers.ITEM_12,
|
|
870
|
+
]
|
|
871
|
+
# pad header with default values (1*)
|
|
872
|
+
header = recs[0] + ["1*"] * (len(columns_header) - len(recs[0]))
|
|
873
|
+
df_header = pd.DataFrame(np.array(header).reshape((1, len(columns_header))), columns=columns_header)
|
|
874
|
+
df_header[columns_header[1:3]] = df_header[columns_header[1:3]].astype(np.float64) # data types
|
|
875
|
+
|
|
876
|
+
# make df for data records
|
|
877
|
+
columns_data = [
|
|
878
|
+
Headers.TUBING_SEGMENT,
|
|
879
|
+
Headers.TUBING_SEGMENT_2,
|
|
880
|
+
Headers.TUBINGBRANCH,
|
|
881
|
+
Headers.TUBING_OUTLET,
|
|
882
|
+
Headers.TUBINGMD,
|
|
883
|
+
Headers.TUBINGTVD,
|
|
884
|
+
Headers.TUBING_INNER_DIAMETER,
|
|
885
|
+
Headers.TUBING_ROUGHNESS,
|
|
886
|
+
Headers.CROSS,
|
|
887
|
+
Headers.VSEG,
|
|
888
|
+
Headers.ITEM_11,
|
|
889
|
+
Headers.ITEM_12,
|
|
890
|
+
Headers.ITEM_13,
|
|
891
|
+
Headers.ITEM_14,
|
|
892
|
+
Headers.ITEM_15,
|
|
893
|
+
]
|
|
894
|
+
# pad with default values (1*)
|
|
895
|
+
recs = [rec + ["1*"] * (len(columns_data) - len(rec)) for rec in recs[1:]]
|
|
896
|
+
df_records = pd.DataFrame(recs, columns=columns_data)
|
|
897
|
+
# data types
|
|
898
|
+
df_records[columns_data[:4]] = df_records[columns_data[:4]].astype(np.int64)
|
|
899
|
+
df_records[columns_data[4:7]] = df_records[columns_data[4:7]].astype(np.float64)
|
|
900
|
+
# fix abs/inc issue with welsegs
|
|
901
|
+
df_header, df_records = fix_welsegs(df_header, df_records)
|
|
902
|
+
|
|
903
|
+
# Warn user if the tubing segments' measured depth for a branch
|
|
904
|
+
# is not sorted in ascending order (monotonic)
|
|
905
|
+
for branch_num in df_records[Headers.TUBINGBRANCH].unique():
|
|
906
|
+
if (
|
|
907
|
+
not df_records[Headers.TUBINGMD]
|
|
908
|
+
.loc[df_records[Headers.TUBINGBRANCH] == branch_num]
|
|
909
|
+
.is_monotonic_increasing
|
|
910
|
+
):
|
|
911
|
+
logger.warning(
|
|
912
|
+
"The branch %s in well %s contains negative length segments. "
|
|
913
|
+
"Check the input schedulefile WELSEGS keyword for inconsistencies "
|
|
914
|
+
"in measured depth (MD) of Tubing layer.",
|
|
915
|
+
branch_num,
|
|
916
|
+
well_name,
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
if well_name not in self.msws:
|
|
920
|
+
self.msws[well_name] = {}
|
|
921
|
+
self.msws[well_name][Keywords.WELSEGS] = df_header, df_records
|
|
922
|
+
return well_name
|
|
923
|
+
|
|
924
|
+
def set_compsegs(self, recs: list[list[str]]) -> str | None:
|
|
925
|
+
"""Update COMPSEGS for a well if it is an active well.
|
|
926
|
+
|
|
927
|
+
* Pads missing record columns in header and contents with default 1*.
|
|
928
|
+
* Convert header and column records to DataFrames.
|
|
929
|
+
* Sets proper DataFrame column types and titles.
|
|
930
|
+
|
|
931
|
+
Args:
|
|
932
|
+
recs: Record set of header and contents data.
|
|
933
|
+
|
|
934
|
+
Returns:
|
|
935
|
+
Name of well if it was updated, or None if it is not in active_wells.
|
|
936
|
+
"""
|
|
937
|
+
well_name = recs[0][0] # each COMPSEGS-chunk is for one well only
|
|
938
|
+
if well_name not in self.active_wells:
|
|
939
|
+
return None
|
|
940
|
+
columns = [
|
|
941
|
+
Headers.I,
|
|
942
|
+
Headers.J,
|
|
943
|
+
Headers.K,
|
|
944
|
+
Headers.BRANCH,
|
|
945
|
+
Headers.START_MEASURED_DEPTH,
|
|
946
|
+
Headers.END_MEASURED_DEPTH,
|
|
947
|
+
Headers.COMPSEGS_DIRECTION,
|
|
948
|
+
Headers.ENDGRID,
|
|
949
|
+
Headers.PERFDEPTH,
|
|
950
|
+
Headers.THERM,
|
|
951
|
+
Headers.SEGMENT,
|
|
952
|
+
]
|
|
953
|
+
recs = [rec + ["1*"] * (len(columns) - len(rec)) for rec in recs[1:]] # pad with default values (1*)
|
|
954
|
+
df = pd.DataFrame(recs, columns=columns)
|
|
955
|
+
df[columns[:4]] = df[columns[:4]].astype(np.int64)
|
|
956
|
+
df[columns[4:6]] = df[columns[4:6]].astype(np.float64)
|
|
957
|
+
if well_name not in self.msws:
|
|
958
|
+
self.msws[well_name] = {}
|
|
959
|
+
self.msws[well_name][Keywords.COMPSEGS] = df
|
|
960
|
+
logger.debug("set_compsegs for %s", well_name)
|
|
961
|
+
return well_name
|
|
962
|
+
|
|
963
|
+
def get_welspecs(self, well_name: str) -> pd.DataFrame:
|
|
964
|
+
"""Get-function for WELSPECS.
|
|
965
|
+
|
|
966
|
+
Args:
|
|
967
|
+
well_name: Well name.
|
|
968
|
+
|
|
969
|
+
Returns:
|
|
970
|
+
Well specifications.
|
|
971
|
+
"""
|
|
972
|
+
return self.msws[well_name][Keywords.WELSPECS]
|
|
973
|
+
|
|
974
|
+
def get_compdat(self, well_name: str) -> pd.DataFrame:
|
|
975
|
+
"""Get-function for COMPDAT.
|
|
976
|
+
|
|
977
|
+
Args:
|
|
978
|
+
well_name: Well name.
|
|
979
|
+
|
|
980
|
+
Returns:
|
|
981
|
+
Completion data.
|
|
982
|
+
|
|
983
|
+
Raises:
|
|
984
|
+
ValueError: If completion data keyword is missing in input schedule file.
|
|
985
|
+
"""
|
|
986
|
+
try:
|
|
987
|
+
return self.msws[well_name][Keywords.COMPDAT]
|
|
988
|
+
except KeyError as err:
|
|
989
|
+
if f"'{Keywords.COMPDAT}'" in str(err):
|
|
990
|
+
raise ValueError("Input schedule file missing COMPDAT keyword.") from err
|
|
991
|
+
raise err
|
|
992
|
+
|
|
993
|
+
def get_compsegs(self, well_name: str, branch: int | None = None) -> pd.DataFrame:
|
|
994
|
+
"""Get-function for COMPSEGS.
|
|
995
|
+
|
|
996
|
+
Args:
|
|
997
|
+
well_name: Well name.
|
|
998
|
+
branch: Branch number.
|
|
999
|
+
|
|
1000
|
+
Returns:
|
|
1001
|
+
Completion segment data.
|
|
1002
|
+
"""
|
|
1003
|
+
df = self.msws[well_name][Keywords.COMPSEGS].copy()
|
|
1004
|
+
if branch is not None:
|
|
1005
|
+
df = df[df[Headers.BRANCH] == branch]
|
|
1006
|
+
df.reset_index(drop=True, inplace=True) # reset index after filtering
|
|
1007
|
+
return fix_compsegs(df, well_name)
|
|
1008
|
+
|
|
1009
|
+
def get_well_segments(self, well_name: str, branch: int | None = None) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
1010
|
+
"""Get-function for well segments.
|
|
1011
|
+
|
|
1012
|
+
Args:
|
|
1013
|
+
well_name: Well name.
|
|
1014
|
+
branch: Branch number.
|
|
1015
|
+
|
|
1016
|
+
Returns:
|
|
1017
|
+
Well segments headers and content.
|
|
1018
|
+
|
|
1019
|
+
Raises:
|
|
1020
|
+
ValueError: If WELSEGS keyword missing in input schedule file.
|
|
1021
|
+
"""
|
|
1022
|
+
try:
|
|
1023
|
+
columns, content = self.msws[well_name][Keywords.WELSEGS]
|
|
1024
|
+
except KeyError as err:
|
|
1025
|
+
if f"'{Keywords.WELSEGS}'" in str(err):
|
|
1026
|
+
raise ValueError("Input schedule file missing WELSEGS keyword.") from err
|
|
1027
|
+
raise err
|
|
1028
|
+
if branch is not None:
|
|
1029
|
+
content = content[content[Headers.TUBINGBRANCH] == branch]
|
|
1030
|
+
content.reset_index(drop=True, inplace=True)
|
|
1031
|
+
return columns, content
|
|
1032
|
+
|
|
1033
|
+
def get_well_number(self, well_name: str) -> int:
|
|
1034
|
+
"""Well number in the active_wells list.
|
|
1035
|
+
|
|
1036
|
+
Args:
|
|
1037
|
+
well_name: Well name.
|
|
1038
|
+
|
|
1039
|
+
Returns:
|
|
1040
|
+
Well number.
|
|
1041
|
+
"""
|
|
1042
|
+
return int(np.where(self.active_wells == well_name)[0][0])
|