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.
@@ -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])