cmflow 0.3.3.dev2__tar.gz → 0.4.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cmflow
3
- Version: 0.3.3.dev2
3
+ Version: 0.4.1
4
4
  Summary: Building TOUGH2/Waiwera models from layers of conceptual models
5
5
  Project-URL: Homepage, https://github.com/cyeh015/cmflow
6
6
  Project-URL: Source Code, https://github.com/cyeh015/cmflow
@@ -38,6 +38,94 @@ On Linux (Ubuntu shown here) these can be installed via apt-get:
38
38
  sudo apt-get install -y python-shapely
39
39
  sudo apt-get install -y python-rtree
40
40
 
41
+ # Using Leapfrog Geological Models
42
+
43
+ In Leapfrog Energy, it ispossible to generate rocktype in a Flow Model using a
44
+ Geological Model. This allows user to `Export Block Rock Types`, which
45
+ generates a .csv file, containing block by block lithology and faults. This
46
+ csv file can be imported into a LeapfrogGM object. These objects provide
47
+ useful ways to access model block's lithology and faults as generated by
48
+ Leapfrog Energy:
49
+
50
+ ```python
51
+ from cmflow.conceptual_models import LeapfrogGM
52
+
53
+ gm = LeapfrogGM()
54
+ gm.import_leapfrog_csv('gAB12345_rocktypes.csv', report=True)
55
+
56
+ litho = gm.lf_block['xyz12'] # Leapfrog assignment for block, as LeapfrogLitho
57
+ print(litho.litho_name) # name used in Leapfrog .csv
58
+ print(litho.faults) # faults that crosses the block
59
+ print(litho.rock) # pure lithology/formation name
60
+
61
+ ```
62
+
63
+ ## Faults from Leapfrog
64
+
65
+ It is also possible to automatically generate rocktype naming to be used in
66
+ AUT2/Waiwera based on certain rules. Here is an example that generate TWO
67
+ letter codes that represents either no fault, single fault or multiple faults
68
+ crossed a block. By calling `.gmf_fault_rocktype_2L()`, a rocktype naming of
69
+ faults are generated.
70
+
71
+ This also adds extra LeapfrogLitho object properties such as `.rocktype_fault`
72
+ and `.faults_sorted`. They contain the TWO letter code and list of crossing
73
+ faults respectively.
74
+
75
+ ```python
76
+ gmf_fault = gm.gmf_fault_rocktype_2L(gm_def["faults"], report=True)
77
+
78
+ code = gm.lf_block['xyz12'].rocktype_fault
79
+ # TWO character code that can be used as part of the rocktype naming
80
+ ```
81
+
82
+ The method `.gmf_fault_rocktype_2L()` also generates a `FaultRocktypes` object.
83
+ FaultRocktypes serves as a central registry for the fault related rocktype
84
+ naming convention. This can be saved and loaded for alter use. Ideally each
85
+ fault should have a preset direction. This can be done by `.set_directions
86
+ ()` which expects a mapping between the original Leapfrog Fault name
87
+ (from .csv) and their directions.
88
+
89
+ Now the object can workout any rocktype's fault direction by working through the
90
+ faults exists ina particular rocktype. If all faults going through a rocktype
91
+ are with the same direction, that direction is used. Otherwise it returns
92
+ None. The `FaultRocktypes` object can keep user specified directions.
93
+
94
+ ```python
95
+
96
+ # sets single fault directions
97
+ gmf_fault.set_directions({
98
+ "F1": 1,
99
+ "F2": 1,
100
+ "F3": 1,
101
+ "F4": 2,
102
+ })
103
+
104
+ print(gmf_fault.rocktype_faults)
105
+
106
+ # {
107
+ # "A0": ("F1"),
108
+ # "AB": ("F1", "F2"),
109
+ # "AX": ("F1", "F2", "F3"),
110
+ # "AY": ("F1", "F2", "F4"),
111
+ # "D0": ("F4"),
112
+ # ...
113
+ # }
114
+
115
+ print(gmf_fault['AB'].faults) # ('F1', 'F2')
116
+
117
+ print(gmf_fault['A0'].direction) # 1
118
+ print(gmf_fault['AX'].direction) # 1, because all faults uses 1
119
+ print(gmf_fault['D0'].direction) # 2
120
+ print(gmf_fault['AY'].direction) # None
121
+
122
+ gmf_fault.set_rock_fault_dir('AY', 1) # user decides that this fault combo should be 1 anyways
123
+
124
+ print(gmf_fault['AY'].direction) # 1
125
+
126
+ ```
127
+
128
+
41
129
  # Example
42
130
 
43
131
  Creates BMStats that can be used later, from Leapfrog Geology:
@@ -15,6 +15,94 @@ On Linux (Ubuntu shown here) these can be installed via apt-get:
15
15
  sudo apt-get install -y python-shapely
16
16
  sudo apt-get install -y python-rtree
17
17
 
18
+ # Using Leapfrog Geological Models
19
+
20
+ In Leapfrog Energy, it ispossible to generate rocktype in a Flow Model using a
21
+ Geological Model. This allows user to `Export Block Rock Types`, which
22
+ generates a .csv file, containing block by block lithology and faults. This
23
+ csv file can be imported into a LeapfrogGM object. These objects provide
24
+ useful ways to access model block's lithology and faults as generated by
25
+ Leapfrog Energy:
26
+
27
+ ```python
28
+ from cmflow.conceptual_models import LeapfrogGM
29
+
30
+ gm = LeapfrogGM()
31
+ gm.import_leapfrog_csv('gAB12345_rocktypes.csv', report=True)
32
+
33
+ litho = gm.lf_block['xyz12'] # Leapfrog assignment for block, as LeapfrogLitho
34
+ print(litho.litho_name) # name used in Leapfrog .csv
35
+ print(litho.faults) # faults that crosses the block
36
+ print(litho.rock) # pure lithology/formation name
37
+
38
+ ```
39
+
40
+ ## Faults from Leapfrog
41
+
42
+ It is also possible to automatically generate rocktype naming to be used in
43
+ AUT2/Waiwera based on certain rules. Here is an example that generate TWO
44
+ letter codes that represents either no fault, single fault or multiple faults
45
+ crossed a block. By calling `.gmf_fault_rocktype_2L()`, a rocktype naming of
46
+ faults are generated.
47
+
48
+ This also adds extra LeapfrogLitho object properties such as `.rocktype_fault`
49
+ and `.faults_sorted`. They contain the TWO letter code and list of crossing
50
+ faults respectively.
51
+
52
+ ```python
53
+ gmf_fault = gm.gmf_fault_rocktype_2L(gm_def["faults"], report=True)
54
+
55
+ code = gm.lf_block['xyz12'].rocktype_fault
56
+ # TWO character code that can be used as part of the rocktype naming
57
+ ```
58
+
59
+ The method `.gmf_fault_rocktype_2L()` also generates a `FaultRocktypes` object.
60
+ FaultRocktypes serves as a central registry for the fault related rocktype
61
+ naming convention. This can be saved and loaded for alter use. Ideally each
62
+ fault should have a preset direction. This can be done by `.set_directions
63
+ ()` which expects a mapping between the original Leapfrog Fault name
64
+ (from .csv) and their directions.
65
+
66
+ Now the object can workout any rocktype's fault direction by working through the
67
+ faults exists ina particular rocktype. If all faults going through a rocktype
68
+ are with the same direction, that direction is used. Otherwise it returns
69
+ None. The `FaultRocktypes` object can keep user specified directions.
70
+
71
+ ```python
72
+
73
+ # sets single fault directions
74
+ gmf_fault.set_directions({
75
+ "F1": 1,
76
+ "F2": 1,
77
+ "F3": 1,
78
+ "F4": 2,
79
+ })
80
+
81
+ print(gmf_fault.rocktype_faults)
82
+
83
+ # {
84
+ # "A0": ("F1"),
85
+ # "AB": ("F1", "F2"),
86
+ # "AX": ("F1", "F2", "F3"),
87
+ # "AY": ("F1", "F2", "F4"),
88
+ # "D0": ("F4"),
89
+ # ...
90
+ # }
91
+
92
+ print(gmf_fault['AB'].faults) # ('F1', 'F2')
93
+
94
+ print(gmf_fault['A0'].direction) # 1
95
+ print(gmf_fault['AX'].direction) # 1, because all faults uses 1
96
+ print(gmf_fault['D0'].direction) # 2
97
+ print(gmf_fault['AY'].direction) # None
98
+
99
+ gmf_fault.set_rock_fault_dir('AY', 1) # user decides that this fault combo should be 1 anyways
100
+
101
+ print(gmf_fault['AY'].direction) # 1
102
+
103
+ ```
104
+
105
+
18
106
  # Example
19
107
 
20
108
  Creates BMStats that can be used later, from Leapfrog Geology:
@@ -12,8 +12,11 @@ from cmflow.geom_3dface_utils import Face3D
12
12
  from cmflow.geom_surface_utils import get_columns_intersect_polygon
13
13
  from cmflow.geom_surface_utils import geo_column_polygon
14
14
 
15
+ from dataclasses import dataclass
15
16
  import json
16
17
  import time
18
+ import string
19
+ import re
17
20
 
18
21
  START = [time.time()]
19
22
  def print_wall_time(msg, loop_stop=False, total=False):
@@ -25,14 +28,346 @@ def print_wall_time(msg, loop_stop=False, total=False):
25
28
  if loop_stop:
26
29
  START.append(t)
27
30
 
31
+ def assign_chars(names, chars=string.ascii_uppercase):
32
+ """ Assign unique characters to each name.
33
+
34
+ Returns
35
+ -------
36
+ tuple(dict, str)
37
+ A mapping from name to assigned character, and the remaining
38
+ unused characters.
39
+ """
40
+ if len(names) > len(chars):
41
+ raise Exception("Not enough characters for names")
42
+ name_index = {n: chars[i] for i, n in enumerate(names)}
43
+ remaining_chars = chars[len(names):]
44
+ return name_index, remaining_chars
45
+
46
+ def natural_key(text):
47
+ """ Convert text chunks and number chunks into a tuple of string
48
+ and int, useful for sorting strings naturally. eg.
49
+
50
+ data = ["F24", "F8", "F2", "F10"]
51
+ sorted_data = sorted(data, key=natural_key)
52
+ print(sorted_data) # Output: ['F2', 'F8', 'F10', 'F24']
53
+ """
54
+ return [
55
+ int(c) if c.isdigit() else c.lower()
56
+ for c in re.split(r'(\d+)', text)
57
+ ]
58
+
59
+ def remove_duplicate_chars(text: str) -> str:
60
+ """ Removes duplicate characters while preserving their original order. """
61
+ return "".join(dict.fromkeys(text))
62
+
63
+ def unique_names(length, chars=None):
64
+ """ Generate fixed-length unique names using chars in their specified order
65
+ """
66
+ if length < 1:
67
+ raise ValueError("length must be positive")
68
+ if not chars:
69
+ chars = string.digits + string.ascii_uppercase + string.ascii_lowercase
70
+ chars.replace('0', '')
71
+
72
+ indices = [0] * length
73
+ while True:
74
+ yield ''.join(chars[i] for i in indices)
75
+
76
+ for i in reversed(range(length)):
77
+ indices[i] += 1
78
+ if indices[i] < len(chars):
79
+ break
80
+ indices[i] = 0
81
+ else:
82
+ return
83
+
84
+ class LeapfrogLitho():
85
+ """ Block Lithology Output generated by Leapfrog Energy """
86
+ def __init__(self, litho_code, litho_name):
87
+ self.litho_code = litho_code # Leapfrog LithoCode
88
+ self.litho_name = litho_name # Leapfrog Lithology, exported name
89
+ # as parsed from .litho_name
90
+ self.faults = []
91
+ self.rock = None # pure formation/lithology name
92
+ self.parseLithoName()
93
+
94
+ def __repr__(self):
95
+ return f"LeapfrogLitho(code={self.litho_code}, name='{self.litho_name}', faults={self.faults}, rock='{self.rock}')"
96
+
97
+ def parseLithoName(self):
98
+ """ parse Leapfrog's Lithology names and determine the faults and pure
99
+ lithos
100
+
101
+ NOTE Leapfrog remove spaces from fault names, and then truncated into
102
+ 7 characters, not counting other non-ASCII characters. Lithology
103
+ names remain as they are in the Leapfrog project.
104
+
105
+ Observation:
106
+ - a string can have two parts separated by ', '
107
+ - if there is only a single part, then is pure litho (to be confirmed!?)
108
+ - if there are two parts, then they are faults and pure litho
109
+ - faults are one or more faults joined by '+'
110
+ - fault names are shorteded by removing spaces
111
+ """
112
+ parts = self.litho_name.split(', ')
113
+ if len(parts) == 1:
114
+ # As of 9 Aug 2026, I have never found any single part litho_name
115
+ # that contains a single fault by itself. It is always:
116
+ # - a pure rock lithi OR
117
+ # - multiple faults joined by '+'
118
+ if '+' in parts[0]:
119
+ self.faults = parts[0].split('+')
120
+ else:
121
+ self.rock = parts[0]
122
+ elif len(parts) == 2:
123
+ self.faults = parts[0].split('+')
124
+ self.rock = parts[1]
125
+ else:
126
+ raise Exception("Unexpected Lithology name format: '%s'" % lf_litho)
127
+
128
+
129
+ class MethodProxy:
130
+ """ A reusable proxy that maps dictionary syntax to any Python method.
131
+
132
+ eg.
133
+ For a class with methods get_value(key) and set_value(key, value), you
134
+ can create a proxy like this:
135
+ @property
136
+ def rock(self):
137
+ return MethodProxy(getter_method=self.get_rock,
138
+ setter_method=self.set_rock)
139
+ This enables use:
140
+ obj.rock['AB'] = 4
141
+ print(obj.rock['AB'])
142
+ """
143
+ def __init__(self, getter_method, setter_method=None,):
144
+ self._getter = getter_method
145
+ self._setter = setter_method
146
+
147
+ def __getitem__(self, key):
148
+ # Calls the passed-in getter method dynamically
149
+ return self._getter(key)
150
+
151
+ def __setitem__(self, key, value):
152
+ if self._setter is None:
153
+ raise TypeError("This property is read-only")
154
+ self._setter(key, value)
155
+
156
+ @dataclass
157
+ class FaultRocktype:
158
+ name: str
159
+ faults: tuple[str, ...] = () # tuple of zero or more Leapfrog fault names
160
+ direction: int | None = None # 1 or 2 or None
161
+
162
+ class FaultRocktypes:
163
+ """ obj that keeps fault info of created rocktypes (usually 2 chars in GMF)
164
+
165
+ As more information is loaded, the registery becomes more useful.
166
+
167
+ leapfrog name - names Leapfrog uses in the exported rocktype .csv
168
+ display name - user can have custom full name for display
169
+ rocktype name - characters (usually 2 in GMF) is the key used as part of rocktype naming
170
+
171
+ .set_rocktypes() expects a rocktype code map to tuples of fault leapfrog names
172
+ .set_directions() expects a dict mapping fault leapfrog names to int direction (or None)
173
+ .set_display_names() expects a dict mapping fault leapfrog names to display/full names
174
+ """
175
+ def __init__(self, leapfrog_names):
176
+ # initialise with a list of fault full names (as in)
177
+ self.lf_faults = leapfrog_names
178
+
179
+ self.display_name = {}
180
+ self.rocktype_faults = {}
181
+ self.fault_dir = {}
182
+
183
+ # normally rocktype fault direction are worked out using individual
184
+ # fault direction of faults in this rocktype, here user can overwrite
185
+ # specific rocktype direction
186
+ self.user_rocktype_dir = {} # user overwrite fault dir for combos
187
+
188
+ def __getitem__(self, rocktype_name):
189
+ """ convenience function to get a FaultRocktype object by rocktype name
190
+
191
+ Use:
192
+ a_faultrocktype['AB'].faults
193
+ a_faultrocktype['AB'].directions
194
+
195
+ """
196
+ if rocktype_name not in self.rocktype_faults:
197
+ raise KeyError(f"Rocktype {rocktype_name} not found in FaultRocktypes.")
198
+ return FaultRocktype(
199
+ name=rocktype_name,
200
+ faults=self.rocktype_faults[rocktype_name],
201
+ direction=self.get_rock_fault_dir(rocktype_name)
202
+ )
203
+
204
+ @property
205
+ def faults(self):
206
+ """ a list of leapfrog fault names """
207
+ return self.lf_faults
208
+
209
+ @property
210
+ def rocktypes(self):
211
+ """ a list rocktype names """
212
+ return list(self.rocktype_faults.keys())
213
+
214
+ @property
215
+ def fault_direction(self):
216
+ """ a dict of all leapfrog faults and their direction """
217
+ return self.fault_dir
218
+
219
+ def set_display_names(self, display_name):
220
+ """ display_name is a dict mapping leapfrog names to display/full names
221
+ """
222
+ self.display_name = display_name
223
+
224
+ def set_rocktypes(self, rock_table):
225
+ """ rock_table is dict mapping rocktype code to tuple of leapfrog fault names
226
+
227
+ This is usually constructed within something like .gmf_fault_rocktype_2L()
228
+ from LeapfrogGM object.
229
+ """
230
+ self.rocktype_faults = rock_table
231
+
232
+ def set_directions(self, fault_dir):
233
+ """ fault_dir is a dict mapping leapfrog names to int direction (or None)
234
+
235
+ Usually user should setup these manually.
236
+ """
237
+ for r,d in fault_dir.items():
238
+ if r not in self.lf_faults:
239
+ raise Exception(f"Fault {r} not found in FaultRocktypes.")
240
+ if d not in [1,2]:
241
+ raise Exception(f"Fault direction {d} for {r} must be 1 or 2.")
242
+ for f in self.lf_faults:
243
+ if f not in fault_dir:
244
+ raise Exception(f"Fault {f} not found in fault_dir, must specify direction.")
245
+ self.fault_dir = fault_dir
246
+
247
+ def get_rock_faults(self, rocktype_name, warn=False):
248
+ if not self.rocktype_faults and warn:
249
+ raise Exception("Fault table not set, see .set_rocktypes()")
250
+ return self.rocktype_faults.get(rocktype_name, None)
251
+
252
+ def set_rock_fault_dir(self, rocktype_name, direction):
253
+ """ update the direction of a rocktype
254
+
255
+ NOTE this is different from setting Leapfrog faults directions, see:
256
+ .set_directions()
257
+ """
258
+ if rocktype_name not in self.rocktype_faults:
259
+ raise Exception(f"Rocktype {rocktype_name} not found in FaultRocktypes.")
260
+ self.user_rocktype_dir[rocktype_name] = direction
261
+
262
+ def get_rock_fault_dir(self, rocktype_name, warn=False):
263
+ """ return the rocktype fault direction (computed)
264
+
265
+ Working out direction if not overwritten with self.user_rocktype_dir:
266
+ if single fault -> fault dir
267
+ if all faults same dir -> fault dir
268
+ if some faults not specified -> None
269
+ if intersected by faults with multiple direction -> None
270
+ """
271
+ if not self.fault_dir and warn:
272
+ raise Exception("Fault direction not set, see .set_directions()")
273
+ if not self.rocktype_faults and warn:
274
+ raise Exception("Fault table not set, see .set_rocktypes()")
275
+
276
+ # return user specified if specified
277
+ try:
278
+ return self.user_rocktype_dir[rocktype_name]
279
+ except KeyError:
280
+ pass
281
+
282
+ # otherwise work out by using individual faults
283
+ directions = [self.fault_dir.get(f, None) for f in self.rocktype_faults[rocktype_name]]
284
+ if len(directions) == 1:
285
+ return directions[0]
286
+ elif len(set(directions)) == 1:
287
+ return next(iter(directions))
288
+ else:
289
+ return None
290
+
291
+ def export_gmf_v0(self):
292
+ """ export a section of the GMF _dict_rocktypes.json that deals with
293
+ faults
294
+ """
295
+ # GMF contents for _dict_rocktype.json
296
+ gmf1, gmf2 = {'0':''}, {'0':''}
297
+ for rocktype, faults in self.rocktype_faults.items():
298
+ if len(faults) == 1:
299
+ gmf1[rocktype[0]] = faults[0]
300
+ elif len(faults) >= 2:
301
+ gmf2[rocktype[1]] = "Intersection " + ", ".join(rocktype[1:])
302
+ # sort the dict for nicer output
303
+ gmf1 = dict(sorted(gmf1.items()))
304
+ gmf2 = dict(sorted(gmf2.items()))
305
+ gmf_fault = {
306
+ "faults": {
307
+ "rank": [1],
308
+ "legend": gmf1,
309
+ "direction": {f:None for f in gmf1.keys()},
310
+ "color": {rt[:1]: None for rt, fs in self.rocktype_faults.items() if len(fs)==1},
311
+ },
312
+ "intersections": {
313
+ "rank": [2],
314
+ "legend": gmf2,
315
+ "direction": {rt:self.get_rock_fault_dir(
316
+ rt, warn=False) for rt in self.rocktype_faults.keys()},
317
+ },
318
+ }
319
+ return gmf_fault
320
+
321
+ def export_gmf_convention(self, rank=[1, 2]):
322
+ """ export a section of the GMF _dict_rocktypes.json that deals with
323
+ faults
324
+
325
+ NOTE This is a proposed new _dict_rocktype.json format. I think this
326
+ has a few advantages over the orginal (v0) format:
327
+ + less assumptions is made about convention
328
+ + flexibility for change in convention less likely to break toolchain
329
+ + more information can be logically stored
330
+ """
331
+ gmf_fault = {
332
+ "faults": {
333
+ "rank": rank, # default to [1,2] ie. 2nd and 3rd character
334
+ # WIP
335
+ "data": {
336
+ "leapfrog_names": self.lf_faults,
337
+ "display_names": self.display_name,
338
+ "rocktype_faults": self.rocktype_faults,
339
+ "fault_dir": self.fault_dir,
340
+ },
341
+ # framework needs extras
342
+ "legend": {rt: ", ".join(fs) for rt, fs in self.rocktype_faults.items()},
343
+ "color": {rt: None for rt, fs in self.rocktype_faults.items() if len(fs)==1},
344
+ },
345
+ }
346
+ return gmf_fault
347
+
348
+
28
349
  class LeapfrogGM(object):
350
+ """ Tough2 Block Lithology Output generated by Leapfrog Energy (.csv)
351
+
352
+ .lithocodes is a list of leapfrog litho codes as ordered in the .csv
353
+ .litholist is a list of leapfrog litho names as ordered in the .csv
354
+ .blocklist is a dict of blocks with value of index for accessing .litholist
355
+
356
+ """
29
357
  def __init__(self, geometry=''):
30
358
  super(LeapfrogGM, self).__init__()
359
+ self.lithocodes = []
31
360
  self.litholist = []
32
361
  self.blocklitho = {}
33
362
  self.import_from = '' # optional, if imported from leapfrog
34
363
  self.geometry = geometry # optional, matching mulgrid geometry file
35
364
 
365
+ # the following provides parsed info of the Leapfrog GM
366
+ self.lf_lithos = [] # list of LeapfrogLitho objects (ordered as litholist)
367
+ self.lf_block = {} # access LeapfrogLitho objects by block name
368
+ self.rocks = [] # sorted pure lithos found
369
+ self.faults = [] # sorted faults found
370
+
36
371
  def import_leapfrog_csv(self, filename, report=False):
37
372
  """ load geology info from Leapfrog's 'Generate rock types' feature. The
38
373
  .csv file from Leapfrog usually starts with a table of 'LithoCode,Lithology'
@@ -41,9 +376,11 @@ class LeapfrogGM(object):
41
376
  import csv
42
377
  f = open(filename,'r')
43
378
  allrows = csv.reader(f)
44
- lithocodes, self.litholist = {}, []
379
+ self.lithocodes = []
380
+ self.litholist = []
45
381
  self.blocklitho = {}
46
382
  # TODO: this file reading is ugly, need work
383
+ code_index = {}
47
384
  read_litho_name, read_block_litho = False, False
48
385
  for row in allrows:
49
386
  if len(row) == 1: continue
@@ -57,17 +394,275 @@ class LeapfrogGM(object):
57
394
  read_block_litho = True
58
395
  continue
59
396
  if read_litho_name:
60
- if not row[0] == '#': raise Exception
397
+ if row[0] != '#': raise Exception
398
+ self.lithocodes.append(row[1])
61
399
  self.litholist.append(row[2])
62
- lithocodes[int(row[1])] = len(self.litholist)-1
400
+ code_index[int(row[1])] = len(self.litholist)-1
63
401
  if read_block_litho:
64
402
  if len(row) != 2: break
65
- self.blocklitho[row[0]] = lithocodes[int(row[1])]
403
+ self.blocklitho[row[0]] = code_index[int(row[1])]
66
404
  f.close()
67
405
  self.import_from = filename
406
+
407
+ self.parse_lithology()
408
+
68
409
  if report:
69
- print('%8s Lithology found.' % len(self.litholist))
70
- print('%8s Blocks allocated.' % len(self.blocklitho))
410
+ print("\n".join([
411
+ f"Loaded from file: {self.import_from}:",
412
+ f"{len(self.lf_lithos):>8} Leapfrog LithoCode",
413
+ f"{len(self.lf_block):>8} Blocks allocated",
414
+ f"{len(self.lf_faults):>8} Faults found",
415
+ f"{len(self.lf_rocks):>8} Pure rocks (lithos) found",
416
+ ]) + "\n")
417
+
418
+ def parse_lithology(self):
419
+ """ parse Leapfrog's Lithology names and determine the faults and pure
420
+ lithos
421
+
422
+ Returns a list of lithology and a list of faults.
423
+
424
+ """
425
+ self.lf_lithos = [] # list of LeapfrogLitho objects (ordered as litholist)
426
+ self.lf_block = {} # access LeapfrogLitho objects by block name
427
+ for code, name in zip(self.lithocodes, self.litholist):
428
+ lf_litho = LeapfrogLitho(code, name)
429
+ self.lf_lithos.append(lf_litho)
430
+ for block, idx in self.blocklitho.items():
431
+ self.lf_block[block] = self.lf_lithos[idx]
432
+
433
+ # compile lists of faults and pure (litho) rocks
434
+ rocks, faults = set(), set()
435
+ for litho in self.lf_lithos:
436
+ if litho.rock is not None:
437
+ rocks.add(litho.rock)
438
+ faults.update(litho.faults)
439
+ self.lf_rocks = sorted(rocks, key=natural_key)
440
+ self.lf_faults = sorted(faults, key=natural_key)
441
+
442
+ for f in self.lf_faults:
443
+ if f in self.lf_rocks:
444
+ raise Exception(f"Fault {f} also found in pure litho list")
445
+ for r in self.lf_rocks:
446
+ if r in self.lf_faults:
447
+ raise Exception(f"Pure litho {r} also found in fault list")
448
+
449
+ return self.lf_rocks, self.lf_faults
450
+
451
+ def gmf_litho_rocktype_1L(self, litho_codes={}, chars=None, ignore=None,
452
+ report=False):
453
+ """ Generates GMF style rocktype names (length of 1) based on lithologies.
454
+ Returns a dict of mapping from GMF rocktype name to Leapfrog lithology(s).
455
+
456
+ litho_codes is a user-defined dict with keys and values a single char.
457
+ The order is important, will affect how the final names are sorted.
458
+ If not specified, the names will be sorted *naturally*. Multiple litho
459
+ names mapping to the same code is allowed (i.e. merging rocktypes).
460
+
461
+ chars is a string containing possible characters to use. NOTE '0' is
462
+ special in GMF and is reserved, it will be removed from user's chars.
463
+
464
+ It is possible to ignore certain lithology by sepcifying names in ignore
465
+ list. Note that they will produce GMF rocktype '0'. There is a default
466
+ set of ignored litho names, if you want to include them, you will have
467
+ to set ignore=[].
468
+
469
+ Each block's rocktype code (length=1) can be accessed by:
470
+ GM.lf_block[block_name].rocktype_litho
471
+ """
472
+ if chars is None:
473
+ chars = string.ascii_uppercase + string.ascii_lowercase + string.digits[1:]
474
+ else:
475
+ chars = chars.replace('0', '')
476
+ chars = remove_duplicate_chars(chars)
477
+
478
+ default_ignore = [None, 'Water', 'Unknown', 'Outside Geological Model']
479
+ if ignore is None:
480
+ ignore = [ig for ig in default_ignore if ig in self.lf_rocks]
481
+ else:
482
+ ignore = [ig for ig in ignore if ig in self.lf_rocks]
483
+
484
+ if litho_codes:
485
+ # some checks and remove used characters
486
+ for l,c in litho_codes.items():
487
+ if l in ignore and c != '0':
488
+ print(f"Warning: ignoring lithology {l}, "
489
+ f"overwrite user-defined litho_codes '{c}' -> '0'")
490
+ if len(c) != 1:
491
+ raise Exception(f"Lithology code for {l} must be a single character")
492
+ if l not in self.lf_rocks:
493
+ raise Exception(f"Lithology {l} not found in Leapfrog lithologies list")
494
+ chars = chars.replace(c, '')
495
+
496
+ for ig in ignore:
497
+ litho_codes[ig] = '0'
498
+
499
+ # deal with all other rocks not specified by user
500
+ remaining_rocks = [r for r in self.lf_rocks if r not in litho_codes]
501
+ new_litho_codes, chars = assign_chars(sorted(remaining_rocks, key=natural_key), chars)
502
+ litho_codes.update(new_litho_codes)
503
+
504
+ # keep as a property of LeapfrogLitho object
505
+ for litho in self.lf_lithos:
506
+ if litho.rock is None:
507
+ litho.rocktype_litho = '0'
508
+ else:
509
+ litho.rocktype_litho = litho_codes[litho.rock]
510
+
511
+ # create reverse index for GMF
512
+ rocktype_litho_index = {}
513
+ for name, rt in litho_codes.items():
514
+ if rt not in rocktype_litho_index:
515
+ rocktype_litho_index[rt] = name
516
+ else:
517
+ rocktype_litho_index[rt] += ", " + name
518
+ # sort the dict for nicer output
519
+ rocktype_litho_index = dict(sorted(rocktype_litho_index.items()))
520
+
521
+ # GMF contents for _dict_rocktype.json
522
+ gmf_litho = {
523
+ "rank": [0],
524
+ "legend": rocktype_litho_index,
525
+ }
526
+
527
+ if report:
528
+ print()
529
+ for code, names in rocktype_litho_index.items():
530
+ print(f"{code} -> {names}")
531
+ print()
532
+
533
+ return rocktype_litho_index, gmf_litho
534
+
535
+ def gmf_fault_rocktype_2L(self, fault_codes={}, chars=None,
536
+ arbitrary_if_3plus=False, report=False):
537
+ """ Generates GMF style rocktype names (length of 2) based on faults.
538
+ Returns a dict of mapping from GMF rocktype name to tuple of faults.
539
+
540
+ fault_code is a user-defined dict with keys and values a single char.
541
+ The order is important, will affect how the final names are sorted.
542
+ If not specified, the names will be sorted *naturally*.
543
+
544
+ chars is a string containing possible characters to use. NOTE '0' is
545
+ special in GMF and is reserved, it will be removed from user's chars.
546
+
547
+ GMF uses two chars (usually 2nd and 3rd in the TOUGH-style 5 chars name)
548
+ - 2nd char (3rd char is zero) single fault
549
+ - 3rd char (combined with 2nd char as unique combination of faults)
550
+
551
+ It may be tricky to have descriptive naming when there are many
552
+ combinations with more than 3 faults. By default, attempt is made by
553
+ keeping the first character as the first fault code, and the second
554
+ character is a unique char that is not used by any single fault.
555
+ However there may not be enough available charactrers. In that case,
556
+ arbitrary_if_3plus can be set to True or a list of chars to use for
557
+ generating arbitrary 2-char unique names.
558
+ """
559
+ if chars is None:
560
+ chars = string.ascii_uppercase + string.ascii_lowercase + string.digits[1:]
561
+ else:
562
+ chars = chars.replace('0', '')
563
+ chars = remove_duplicate_chars(chars)
564
+
565
+ if fault_codes:
566
+ # some checks and remove used characters
567
+ used = []
568
+ for f,c in fault_codes.items():
569
+ if c == '0':
570
+ raise Exception(f"Fault code for {f} cannot be '0'")
571
+ if len(c) != 1:
572
+ raise Exception(f"Fault code for {f} must be a single character")
573
+ if f not in fault_codes:
574
+ raise Exception(f"Fault {f} not found in Leapfrog faults list")
575
+ chars = chars.replace(c, '')
576
+ if c in used:
577
+ raise Exception(f"Fault code '{c}' for {f} is already used")
578
+ used.append(c)
579
+ # tolerate if user only specifies some of the faults
580
+ remaining_lf_faults = [f for f in self.lf_faults if f not in fault_codes]
581
+ new_fault_codes, chars = assign_chars(sorted(remaining_lf_faults, key=natural_key), chars=chars)
582
+ fault_codes.update(new_fault_codes)
583
+ else:
584
+ fault_codes, chars = assign_chars(sorted(self.lf_faults, key=natural_key), chars=chars)
585
+
586
+ name_generator = None
587
+ if arbitrary_if_3plus:
588
+ if arbitrary_if_3plus is True:
589
+ arbitrary_if_3plus = string.digits[1:] + string.ascii_lowercase + string.ascii_uppercase
590
+ arbitrary_if_3plus = arbitrary_if_3plus.replace('0', '')
591
+ # remove already used fault-specific codes
592
+ for c in fault_codes.values():
593
+ chars = chars.replace(c, '')
594
+ name_generator = unique_names(length=2, chars=arbitrary_if_3plus)
595
+
596
+ fault_rank = {name: idx for idx, name in enumerate(fault_codes)}
597
+
598
+ # sort combinations: by num of faults, then by ranking of 1st fault, then 2nd...
599
+ def combo_id(items):
600
+ # id (a tuple of faults) is also ordered by rank
601
+ return tuple(sorted(items, key=lambda x: fault_rank.get(x, 0)))
602
+ # sorting the faults to ensure unique fault sets
603
+ combinations = list({combo_id(c.faults) for c in self.lf_lithos})
604
+ # sort by combo length, then by rank sequentially in combo
605
+ combinations = sorted(combinations, key=lambda x: (len(x), tuple(fault_rank[y] for y in x)))
606
+
607
+ if report:
608
+ print("\n".join([
609
+ f"Generating GMF fault names:",
610
+ f"{len(self.lf_lithos):>8} Leapfrog LithoCode",
611
+ f"{len(self.lf_faults):>8} Faults found",
612
+ f"{len(combinations):>8} Total unique fault combinations found",
613
+ f"{len([c for c in combinations if len(c)==1]):>8} Single-fault combinations found",
614
+ f"{len([c for c in combinations if len(c)==2]):>8} Fault intersections found with 2 faults",
615
+ f"{len([c for c in combinations if len(c)>=3]):>8} Fault intersections found with 3 or more faults",
616
+ f"{max([len(c) for c in combinations]):>8} Maximum number of fault intersect in one block",
617
+ ]) + "\n")
618
+
619
+ final_codes = {}
620
+ for ii,fid in enumerate(combinations):
621
+ if len(fid) == 0:
622
+ final_codes[fid] = '00'
623
+ elif len(fid) == 1:
624
+ # single item ones first -> fault code + '0'
625
+ final_codes[fid] = fault_codes[fid[0]] + '0'
626
+ elif len(fid) == 2:
627
+ # two items combo -> first + second
628
+ final_codes[fid] = ''
629
+ for fault in fid:
630
+ final_codes[fid] += fault_codes[fault]
631
+ else:
632
+ if name_generator:
633
+ # use arbitrary/opaque names for 3 or more faults (allow many more combos)
634
+ try:
635
+ final_codes[fid] = next(name_generator)
636
+ except StopIteration:
637
+ raise RuntimeError("Not enough characters for remaining fault combinations,")
638
+ else:
639
+ # three or more -> first fault code + nunique code
640
+ if not chars:
641
+ need_more = len(combinations) - ii
642
+ raise RuntimeError(f"Not enough characters for remaining fault combinations,"
643
+ f" need {need_more} more.")
644
+ code = chars[0]
645
+ chars = chars[1:]
646
+ final_codes[fid] = fault_codes[fid[0]] + code
647
+
648
+ # useful properties added to LeapfrogLitho objects
649
+ for litho in self.lf_lithos:
650
+ fid = combo_id(litho.faults)
651
+ litho.rocktype_fault = final_codes[fid] # eg. 'AB', 'A0', '00'
652
+ litho.faults_sorted = fid # eg. ('Fault1', 'Fault2')
653
+
654
+ # create reverse index for general use (key has two characters)
655
+ rocktype_index = {}
656
+ for faults, rocktype_name in final_codes.items():
657
+ if rocktype_name in rocktype_index:
658
+ raise Exception(f"Rocktype Name should be unique, {rocktype_name} repeated")
659
+ rocktype_index[rocktype_name] = faults
660
+
661
+ # create FaultRocktypes object to return
662
+ fault_rocktypes = FaultRocktypes(self.lf_faults)
663
+ fault_rocktypes.set_rocktypes(rocktype_index)
664
+
665
+ return fault_rocktypes
71
666
 
72
667
  def write(self, filename):
73
668
  with open(filename, 'w') as f:
@@ -80,6 +675,8 @@ class LeapfrogGM(object):
80
675
  "geometry" : self.geometry,
81
676
  "zones": self.litholist,
82
677
  "blocks": self.blocklitho,
678
+ # Non standard field, for LeapfrogGM only
679
+ "import_litho_codes": self.lithocodes,
83
680
  }
84
681
  json.dump(data, f, indent=4, sort_keys=True)
85
682
 
@@ -90,6 +687,9 @@ class LeapfrogGM(object):
90
687
  self.geometry = data['geometry']
91
688
  self.litholist = data['zones']
92
689
  self.blocklitho = data['blocks']
690
+ # Non standard field, for LeapfrogGM only
691
+ self.lithocodes = data["import_litho_codes"]
692
+ self.parse_lithology()
93
693
 
94
694
  class CM(object):
95
695
  def populate_model(self, np_dtype):
File without changes
@@ -296,7 +296,7 @@ def update_rocktype_bycopy(dat, blk_names, to_rocktype, convention='++***'):
296
296
  else:
297
297
  dat.grid.block[b].rocktype = dat.grid.rocktype[r_name]
298
298
 
299
- def update_block_geology(dat, blk_name, rock_name):
299
+ def update_block_geology(dat, blk_name, rock_name, report=False):
300
300
  """ only updates the block's rocktype name. rock_name is a 5 chars string,
301
301
  can contain '+' character to indicate preservin that part of name. If the
302
302
  final rocktype name does not exist in dat.grid, it will be created by
@@ -322,7 +322,8 @@ def update_block_geology(dat, blk_name, rock_name):
322
322
  new_rock.name = new_rock_name
323
323
  dat.grid.add_rocktype(new_rock)
324
324
  dat.grid.block[blk_name].rocktype = new_rock
325
- print(' new rocktype added: ', new_rock_name)
325
+ if report:
326
+ print(' new rocktype added: ', new_rock_name)
326
327
  return new_rock_name
327
328
 
328
329
  def setup_rockless(grid, base_rocktype=None, atm_rocktype=None):
File without changes
File without changes
File without changes