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/main.py ADDED
@@ -0,0 +1,486 @@
1
+ """Main module of Completor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import re
8
+ import time
9
+ from collections.abc import Mapping
10
+ from typing import overload
11
+
12
+ import numpy as np
13
+
14
+ import completor
15
+ from completor import parse
16
+ from completor.completion import WellSchedule
17
+ from completor.constants import Keywords
18
+ from completor.create_output import CreateOutput
19
+ from completor.create_wells import CreateWells
20
+ from completor.exceptions import CompletorError
21
+ from completor.launch_args_parser import get_parser
22
+ from completor.logger import handle_error_messages, logger
23
+ from completor.read_casefile import ReadCasefile
24
+ from completor.utils import abort, clean_file_line, clean_file_lines
25
+ from completor.visualization import close_figure, create_pdfpages
26
+
27
+ try:
28
+ from typing import Literal
29
+ except ImportError:
30
+ pass
31
+
32
+
33
+ class FileWriter:
34
+ """Functionality for writing a new schedule file."""
35
+
36
+ def __init__(self, file: str, mapper: Mapping[str, str] | None):
37
+ """Initialize the FileWriter.
38
+
39
+ Args:
40
+ file: Name of file to be written. Does not check if it already exists.
41
+ mapper: A dictionary for mapping strings.
42
+ Typically used for mapping pre-processor reservoir modelling tools to reservoir simulator well names.
43
+ """
44
+ self.fh = open(file, "w", encoding="utf-8") # create new output file
45
+ self.mapper = mapper
46
+
47
+ @overload
48
+ def write(self, keyword: Literal[None], content: str, chunk: bool = True, end_of_record: bool = False) -> None: ...
49
+
50
+ @overload
51
+ def write(
52
+ self, keyword: str, content: list[list[str]], chunk: Literal[True] = True, end_of_record: bool = False
53
+ ) -> None: ...
54
+
55
+ @overload
56
+ def write(
57
+ self, keyword: str, content: list[str] | str, chunk: Literal[False] = False, end_of_record: bool = False
58
+ ) -> None: ...
59
+
60
+ @overload
61
+ def write(
62
+ self, keyword: str, content: list[list[str]] | list[str] | str, chunk: bool = True, end_of_record: bool = False
63
+ ) -> None: ...
64
+
65
+ def write(
66
+ self,
67
+ keyword: str | None,
68
+ content: list[list[str]] | list[str] | str,
69
+ chunk: bool = True,
70
+ end_of_record: bool = False,
71
+ ) -> None:
72
+ """Write the content of a keyword to the output file.
73
+
74
+ Args:
75
+ keyword: Reservoir simulator keyword.
76
+ content: Text to be written.
77
+ chunk: Flag for indicating this is a list of records.
78
+ end_of_record: Flag for adding end-of-record ('/').
79
+ """
80
+ txt = ""
81
+
82
+ if keyword is None:
83
+ txt = content # type: ignore # it's really a formatted string
84
+ else:
85
+ self.fh.write(f"{keyword:s}\n")
86
+ if chunk:
87
+ for recs in content:
88
+ txt += " " + " ".join(recs) + " /\n"
89
+ else:
90
+ for line in content:
91
+ if isinstance(line, list):
92
+ logger.warning(
93
+ "Chunk is False, but content contains lists of lists, "
94
+ "instead of a list of strings the lines will be concatenated."
95
+ )
96
+ line = " ".join(line)
97
+ txt += line + "\n"
98
+ if self.mapper:
99
+ txt = self._replace_preprocessing_names(txt)
100
+ if end_of_record:
101
+ txt += "/\n"
102
+ self.fh.write(txt)
103
+
104
+ def _replace_preprocessing_names(self, text: str) -> str:
105
+ """Expand start and end marker pairs for well pattern recognition as needed.
106
+
107
+ Args:
108
+ text: Text with pre-processor reservoir modelling well names.
109
+
110
+ Returns:
111
+ Text with reservoir simulator well names.
112
+ """
113
+ if self.mapper is None:
114
+ raise ValueError(
115
+ f"{self._replace_preprocessing_names.__name__} requires a file containing two "
116
+ "columns with input and output names given by the MAPFILE keyword in "
117
+ f"case file to be set when creating {self.__class__.__name__}."
118
+ )
119
+ start_marks = ["'", " ", "\n", "\t"]
120
+ end_marks = ["'", " ", " ", " "]
121
+ for key, value in self.mapper.items():
122
+ for start, end in zip(start_marks, end_marks):
123
+ my_key = start + str(key) + start
124
+ if my_key in text:
125
+ my_value = start + str(value) + end
126
+ text = text.replace(my_key, my_value)
127
+ return text
128
+
129
+ def close(self) -> None:
130
+ """Close FileWriter."""
131
+ self.fh.close()
132
+
133
+
134
+ class ProgressStatus:
135
+ """Bookmark the reading progress of a schedule file.
136
+
137
+ See https://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console
138
+ for improved functionality.
139
+ """
140
+
141
+ def __init__(self, num_lines: int, percent: float):
142
+ """Initialize ProgressStatus.
143
+
144
+ Args:
145
+ num_lines: Number of lines in schedule file.
146
+ percent: Indicates schedule file processing progress (in percent).
147
+ """
148
+ self.percent = percent
149
+ self.nlines = num_lines
150
+ self.prev_n = 0
151
+
152
+ def update(self, line_number: int) -> None:
153
+ """Update logger information.
154
+
155
+ Args:
156
+ line_number: Input schedule file line number.
157
+
158
+ Returns:
159
+ Logger info message.
160
+ """
161
+ # If the divisor, or numerator is a float, the integer division gives a float
162
+ n = int((line_number / self.nlines * 100) // self.percent)
163
+ if n > self.prev_n:
164
+ logger.info("=" * 80)
165
+ logger.info("Done processing %i %% of schedule/data file", n * self.percent)
166
+ logger.info("=" * 80)
167
+ self.prev_n = n
168
+
169
+
170
+ def get_content_and_path(case_content: str, file_path: str | None, keyword: str) -> tuple[str | None, str | None]:
171
+ """Get the contents of a file from a path defined by user or case file.
172
+
173
+ The method prioritizes paths given as input argument over the paths found in the case file.
174
+
175
+ Args:
176
+ case_content: The case file content.
177
+ file_path: Path to file if given.
178
+ keyword: Reservoir simulator keyword.
179
+
180
+ Returns:
181
+ File content, file path.
182
+
183
+ Raises:
184
+ CompletorError: If the keyword cannot be found.
185
+ CompletorError: If the file cannot be found.
186
+ """
187
+ if file_path is None:
188
+ # Find the path/name of file from case file
189
+ case_file_lines = clean_file_lines(case_content.splitlines())
190
+ start_idx, end_idx = parse.locate_keyword(case_file_lines, keyword)
191
+ # If the keyword is defined correctly
192
+ if end_idx == start_idx + 2:
193
+ # preprocess the text, remove leading/trailing whitespace and quotes
194
+ file_path = " ".join(case_file_lines[start_idx + 1].strip("'").strip(" ").split())
195
+ file_path = re.sub("[\"']+", "", file_path)
196
+
197
+ else:
198
+ # OUTFILE is optional, if it's needed but not supplied the error is caught in ReadCasefile:check_pvt_file()
199
+ if keyword == "OUTFILE":
200
+ return None, None
201
+ raise CompletorError(f"The keyword {keyword} is not defined correctly in the casefile")
202
+ if keyword != "OUTFILE":
203
+ try:
204
+ with open(file_path, encoding="utf-8") as file:
205
+ file_content = file.read()
206
+ except FileNotFoundError as e:
207
+ raise CompletorError(f"Could not find the file: '{file_path}'!") from e
208
+ except (PermissionError, IsADirectoryError) as e:
209
+ raise CompletorError("Could not read SCHFILE, this is likely because the path is missing quotes.") from e
210
+ return file_content, file_path
211
+ return None, file_path
212
+
213
+
214
+ # noinspection TimingAttack
215
+ # caused by `if token == '...'` and token is interpreted as a security token / JWT
216
+ # or otherwise sensitive, but in this context, `token` refers to a token of parsed
217
+ # text / semantic token
218
+ def create(
219
+ input_file: str,
220
+ schedule_file: str,
221
+ new_file: str,
222
+ show_fig: bool = False,
223
+ percent: float = 5.0,
224
+ paths: tuple[str, str] | None = None,
225
+ ) -> (
226
+ tuple[list[tuple[str, list[list[str]]]], ReadCasefile, WellSchedule, CreateWells, CreateOutput]
227
+ | tuple[list[tuple[str, list[list[str]]]], ReadCasefile, WellSchedule, CreateWells]
228
+ ):
229
+ """Create a new Completor schedule file from input case- and schedule files.
230
+
231
+ Args:
232
+ input_file: Input case file.
233
+ schedule_file: Input schedule file.
234
+ new_file: Output schedule file.
235
+ show_fig: Flag indicating if a figure is to be shown.
236
+ percent: ProgressStatus percentage steps to be shown (in percent, %).
237
+ paths: Optional additional paths.
238
+
239
+ Returns:
240
+ Completor schedule file.
241
+ """
242
+ case = ReadCasefile(case_file=input_file, schedule_file=schedule_file, output_file=new_file)
243
+ wells = CreateWells(case)
244
+ schedule = WellSchedule(wells.active_wells) # container for MSW-data
245
+
246
+ lines = schedule_file.splitlines()
247
+
248
+ clean_lines_map = {}
249
+ for line_number, line in enumerate(lines):
250
+ line = clean_file_line(line, remove_quotation_marks=True)
251
+ if line:
252
+ clean_lines_map[line_number] = line
253
+
254
+ outfile = FileWriter(new_file, case.mapper)
255
+ chunks = [] # for debug..
256
+ figno = 0
257
+ written = set() # Keep track of which MSW's has been written
258
+ line_number = 0
259
+ progress_status = ProgressStatus(len(lines), percent)
260
+
261
+ pdf_file = None
262
+ if show_fig:
263
+ figure_no = 1
264
+ fnm = f"Well_schematic_{figure_no:03d}.pdf"
265
+ while os.path.isfile(fnm):
266
+ figure_no += 1
267
+ fnm = f"Well_schematic_{figure_no:03d}.pdf"
268
+ pdf_file = create_pdfpages(fnm)
269
+ # loop lines
270
+ while line_number < len(lines):
271
+ progress_status.update(line_number)
272
+ line = lines[line_number]
273
+ keyword = line[:8].rstrip() # look for keywords
274
+
275
+ # most lines will just be duplicated
276
+ if keyword not in Keywords.main_keywords:
277
+ outfile.write(None, f"{line}\n")
278
+ else:
279
+ # This is a (potential) MSW keyword.
280
+ logger.debug(keyword)
281
+
282
+ well_name = _get_well_name(clean_lines_map, line_number)
283
+ if keyword in Keywords.segments: # check if it is an active well
284
+ logger.debug(well_name)
285
+ if well_name not in list(schedule.active_wells):
286
+ outfile.write(keyword, "")
287
+ line_number += 1
288
+ continue # not an active well
289
+
290
+ # first, collect data for this keyword into a 'chunk'
291
+ chunk_str = ""
292
+ raw = [] # only used for WELSPECS which we dont modify
293
+ # concatenate and look for 'end of records' => //
294
+ while not re.search(r"/\s*/$", chunk_str):
295
+ line_number += 1
296
+ raw.append(lines[line_number])
297
+ if line_number in clean_lines_map:
298
+ chunk_str += clean_lines_map[line_number]
299
+ chunk = _format_chunk(chunk_str)
300
+ chunks.append((keyword, chunk)) # for debug ...
301
+
302
+ # use data to update our schedule
303
+ if keyword == Keywords.WELSPECS:
304
+ schedule.set_welspecs(chunk) # update with new data
305
+ outfile.write(keyword, raw, chunk=False) # but write it back 'untouched'
306
+ line_number += 1 # ready for next line
307
+ continue
308
+
309
+ elif keyword == Keywords.COMPDAT:
310
+ remains = schedule.handle_compdat(chunk) # update with new data
311
+ if remains:
312
+ # Add single quotes to non-active well names
313
+ for remain in remains:
314
+ remain[0] = "'" + remain[0] + "'"
315
+ outfile.write(keyword, remains, end_of_record=True) # write any 'none-active' wells here
316
+ line_number += 1 # ready for next line
317
+ continue
318
+
319
+ elif keyword == Keywords.WELSEGS:
320
+ schedule.set_welsegs(chunk) # update with new data
321
+
322
+ elif keyword == Keywords.COMPSEGS:
323
+ # this is COMPSEGS'. will now update and write out new data
324
+ schedule.set_compsegs(chunk)
325
+
326
+ try:
327
+ case.check_input(well_name, schedule)
328
+ except NameError as err:
329
+ # This might mean that `Keywords.segments` has changed to
330
+ # not include `Keywords.COMPSEGS`
331
+ raise SystemError(
332
+ "Well name not defined, even though it should be defined when "
333
+ f"token ({keyword} is one of "
334
+ f"{', '.join(Keywords.segments)})"
335
+ ) from err
336
+
337
+ if well_name not in written:
338
+ write_welsegs = True # will only write WELSEGS once
339
+ written.add(well_name)
340
+ else:
341
+ write_welsegs = False
342
+ figno += 1
343
+ logger.debug("Writing new MSW info for well %s", well_name)
344
+ wells.update(well_name, schedule)
345
+ output = CreateOutput(
346
+ case,
347
+ schedule,
348
+ wells,
349
+ well_name,
350
+ schedule.get_well_number(well_name),
351
+ completor.__version__,
352
+ show_fig,
353
+ pdf_file,
354
+ write_welsegs,
355
+ paths,
356
+ )
357
+ outfile.write(None, output.finalprint)
358
+ else:
359
+ raise ValueError(f"The keyword '{keyword}' has not been implemented in Completor, but should have been")
360
+
361
+ line_number += 1 # ready for next line
362
+ logger.debug(line_number)
363
+ outfile.close()
364
+ close_figure()
365
+ if pdf_file is not None:
366
+ pdf_file.close()
367
+
368
+ try:
369
+ return chunks, case, schedule, wells, output # for debug ...
370
+ except NameError:
371
+ if len(schedule.active_wells) == 0:
372
+ return chunks, case, schedule, wells
373
+ else:
374
+ raise ValueError(
375
+ "Inconsistent case and input schedule files. "
376
+ "Check well names and WELSPECS, COMPDAT, WELSEGS and COMPSEGS."
377
+ )
378
+
379
+
380
+ def main() -> None:
381
+ """Generate a Completor output schedule file from the input given from user.
382
+
383
+ Also set the correct loglevel based on user input. Defaults to WARNING if not set.
384
+
385
+ Raises:
386
+ CompletorError: If input schedule file is not defined as input or in case file.
387
+ """
388
+ parser = get_parser()
389
+ inputs = parser.parse_args()
390
+
391
+ if inputs.loglevel is not None:
392
+ loglevel = inputs.loglevel
393
+ else:
394
+ loglevel = logging.WARNING
395
+ # Loglevel NOTSET (0) gets overwritten by higher up loggers to WARNING, setting loglevel to 1 is a lazy workaround.
396
+ loglevel = 1 if loglevel == 0 else loglevel
397
+
398
+ logger.setLevel(loglevel)
399
+
400
+ # Open the case file
401
+ if inputs.inputfile is not None:
402
+ with open(inputs.inputfile, encoding="utf-8") as file:
403
+ case_file_content = file.read()
404
+ else:
405
+ raise CompletorError("Need input case file to run Completor")
406
+
407
+ schedule_file_content, inputs.schedulefile = get_content_and_path(
408
+ case_file_content, inputs.schedulefile, Keywords.SCHFILE
409
+ )
410
+
411
+ if isinstance(schedule_file_content, str):
412
+ parse.read_schedule_keywords(clean_file_lines(schedule_file_content.splitlines()), Keywords.main_keywords)
413
+
414
+ _, inputs.outputfile = get_content_and_path(case_file_content, inputs.outputfile, Keywords.OUTFILE)
415
+
416
+ if inputs.outputfile is None:
417
+ if inputs.schedulefile is None:
418
+ raise ValueError("No schedule provided, or none where found " "in the case file (keyword 'SCHFILE')")
419
+ inputs.outputfile = inputs.schedulefile.split(".")[0] + "_advanced.wells"
420
+
421
+ paths_input_schedule = (inputs.inputfile, inputs.schedulefile)
422
+
423
+ logger.debug("Running Completor %s. An advanced well modelling tool.", completor.__version__)
424
+ logger.debug("-" * 60)
425
+ start_a = time.time()
426
+
427
+ handle_error_messages(create)(
428
+ case_file_content, schedule_file_content, inputs.outputfile, inputs.figure, paths=paths_input_schedule
429
+ )
430
+
431
+ logger.debug("Total runtime: %d", (time.time() - start_a))
432
+ logger.debug("-" * 60)
433
+
434
+
435
+ def _get_well_name(schedule_lines: dict[int, str], i: int) -> str:
436
+ """Get the well name from line number
437
+
438
+ Args:
439
+ schedule_lines: Dictionary of lines in schedule file.
440
+ i: Line index.
441
+
442
+ Returns:
443
+ Well name.
444
+ """
445
+ keys = np.array(sorted(list(schedule_lines.keys())))
446
+ j = np.where(keys == i)[0][0]
447
+ next_line = schedule_lines[int(keys[j + 1])]
448
+ return next_line.split()[0]
449
+
450
+
451
+ def _format_chunk(chunk_str: str) -> list[list[str]]:
452
+ """Format the data-records and resolve the repeat-mechanism.
453
+
454
+ E.g. 3* == 1* 1* 1*, 3*250 == 250 250 250.
455
+
456
+ Args:
457
+ chunk_str: A chunk data-record.
458
+
459
+ Returns:
460
+ Expanded values.
461
+ """
462
+ chunk = re.split(r"\s+/", chunk_str)[:-1]
463
+ expanded_data = []
464
+ for line in chunk:
465
+ new_record = ""
466
+ for record in line.split():
467
+ if not record[0].isdigit():
468
+ new_record += record + " "
469
+ continue
470
+ if "*" not in record:
471
+ new_record += record + " "
472
+ continue
473
+
474
+ # need to handle things like 3* or 3*250
475
+ multiplier, number = record.split("*")
476
+ new_record += f"{number if number else '1*'} " * int(multiplier)
477
+ if new_record:
478
+ expanded_data.append(new_record.split())
479
+ return expanded_data
480
+
481
+
482
+ if __name__ == "__main__":
483
+ try:
484
+ main()
485
+ except CompletorError as e:
486
+ raise abort(str(e)) from e