cmflow 0.3.3.dev2__tar.gz → 0.4.0__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.0
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
@@ -14,6 +14,8 @@ from cmflow.geom_surface_utils import geo_column_polygon
14
14
 
15
15
  import json
16
16
  import time
17
+ import string
18
+ import re
17
19
 
18
20
  START = [time.time()]
19
21
  def print_wall_time(msg, loop_stop=False, total=False):
@@ -25,14 +27,101 @@ def print_wall_time(msg, loop_stop=False, total=False):
25
27
  if loop_stop:
26
28
  START.append(t)
27
29
 
30
+ def assign_chars(names, chars=string.ascii_uppercase):
31
+ """ Assign unique characters to each name.
32
+
33
+ Returns
34
+ -------
35
+ tuple(dict, str)
36
+ A mapping from name to assigned character, and the remaining
37
+ unused characters.
38
+ """
39
+ if len(names) > len(chars):
40
+ raise Exception("Not enough characters for names")
41
+ name_index = {n: chars[i] for i, n in enumerate(names)}
42
+ remaining_chars = chars[len(names):]
43
+ return name_index, remaining_chars
44
+
45
+ def natural_key(text):
46
+ """ Convert text chunks and number chunks into a tuple of string
47
+ and int, useful for sorting strings naturally. eg.
48
+
49
+ data = ["F24", "F8", "F2", "F10"]
50
+ sorted_data = sorted(data, key=natural_key)
51
+ print(sorted_data) # Output: ['F2', 'F8', 'F10', 'F24']
52
+ """
53
+ return [
54
+ int(c) if c.isdigit() else c.lower()
55
+ for c in re.split(r'(\d+)', text)
56
+ ]
57
+
58
+ class LeapfrogLitho():
59
+ """ Block Lithology Output generated by Leapfrog Energy """
60
+ def __init__(self, litho_code, litho_name):
61
+ self.litho_code = litho_code # Leapfrog LithoCode
62
+ self.litho_name = litho_name # Leapfrog Lithology, exported name
63
+ # as parsed from .litho_name
64
+ self.faults = []
65
+ self.rock = None # pure formation/lithology name
66
+ self.parseLithoName()
67
+
68
+ def __repr__(self):
69
+ return f"LeapfrogLitho(code={self.litho_code}, name='{self.litho_name}', faults={self.faults}, rock='{self.rock}')"
70
+
71
+ def parseLithoName(self):
72
+ """ parse Leapfrog's Lithology names and determine the faults and pure
73
+ lithos
74
+
75
+ NOTE Leapfrog remove spaces from fault names, and then truncated into
76
+ 7 characters, not counting other non-ASCII characters. Lithology
77
+ names remain as they are in the Leapfrog project.
78
+
79
+ Observation:
80
+ - a string can have two parts separated by ', '
81
+ - if there is only a single part, then is pure litho (to be confirmed!?)
82
+ - if there are two parts, then they are faults and pure litho
83
+ - faults are one or more faults joined by '+'
84
+ - fault names are shorteded by removing spaces
85
+ """
86
+ parts = self.litho_name.split(', ')
87
+ if len(parts) == 1:
88
+ # As of 9 Aug 2026, I have never found any single part litho_name
89
+ # that contains a single fault by itself. It is always:
90
+ # - a pure rock lithi OR
91
+ # - multiple faults joined by '+'
92
+ if '+' in parts[0]:
93
+ self.faults = parts[0].split('+')
94
+ else:
95
+ self.rock = parts[0]
96
+ elif len(parts) == 2:
97
+ self.faults = parts[0].split('+')
98
+ self.rock = parts[1]
99
+ else:
100
+ raise Exception("Unexpected Lithology name format: '%s'" % lf_litho)
101
+
102
+
28
103
  class LeapfrogGM(object):
104
+ """ Tough2 Block Lithology Output generated by Leapfrog Energy (.csv)
105
+
106
+ .lithocodes is a list of leapfrog litho codes as ordered in the .csv
107
+ .litholist is a list of leapfrog litho names as ordered in the .csv
108
+ .blocklist is a dict of blocks with value of index for accessing .litholist
109
+
110
+ """
29
111
  def __init__(self, geometry=''):
30
112
  super(LeapfrogGM, self).__init__()
113
+ self.lithocodes = []
31
114
  self.litholist = []
32
115
  self.blocklitho = {}
33
116
  self.import_from = '' # optional, if imported from leapfrog
34
117
  self.geometry = geometry # optional, matching mulgrid geometry file
35
118
 
119
+ # the following provides parsed info of the Leapfrog GM
120
+ self.lf_lithos = [] # list of LeapfrogLitho objects (ordered as litholist)
121
+ self.lf_block = {} # access LeapfrogLitho objects by block name
122
+ self.rocks = [] # sorted pure lithos found
123
+ self.faults = [] # sorted faults found
124
+
36
125
  def import_leapfrog_csv(self, filename, report=False):
37
126
  """ load geology info from Leapfrog's 'Generate rock types' feature. The
38
127
  .csv file from Leapfrog usually starts with a table of 'LithoCode,Lithology'
@@ -41,9 +130,11 @@ class LeapfrogGM(object):
41
130
  import csv
42
131
  f = open(filename,'r')
43
132
  allrows = csv.reader(f)
44
- lithocodes, self.litholist = {}, []
133
+ self.lithocodes = []
134
+ self.litholist = []
45
135
  self.blocklitho = {}
46
136
  # TODO: this file reading is ugly, need work
137
+ code_index = {}
47
138
  read_litho_name, read_block_litho = False, False
48
139
  for row in allrows:
49
140
  if len(row) == 1: continue
@@ -57,17 +148,263 @@ class LeapfrogGM(object):
57
148
  read_block_litho = True
58
149
  continue
59
150
  if read_litho_name:
60
- if not row[0] == '#': raise Exception
151
+ if row[0] != '#': raise Exception
152
+ self.lithocodes.append(row[1])
61
153
  self.litholist.append(row[2])
62
- lithocodes[int(row[1])] = len(self.litholist)-1
154
+ code_index[int(row[1])] = len(self.litholist)-1
63
155
  if read_block_litho:
64
156
  if len(row) != 2: break
65
- self.blocklitho[row[0]] = lithocodes[int(row[1])]
157
+ self.blocklitho[row[0]] = code_index[int(row[1])]
66
158
  f.close()
67
159
  self.import_from = filename
160
+
161
+ self.parse_lithology()
162
+
163
+ if report:
164
+ print("\n".join([
165
+ f"Loaded from file: {self.import_from}:",
166
+ f"{len(self.lf_lithos):>8} Leapfrog LithoCode",
167
+ f"{len(self.lf_block):>8} Blocks allocated",
168
+ f"{len(self.lf_faults):>8} Faults found",
169
+ f"{len(self.lf_rocks):>8} Pure rocks (lithos) found",
170
+ ]) + "\n")
171
+
172
+ def parse_lithology(self):
173
+ """ parse Leapfrog's Lithology names and determine the faults and pure
174
+ lithos
175
+
176
+ Returns a list of lithology and a list of faults.
177
+
178
+ """
179
+ self.lf_lithos = [] # list of LeapfrogLitho objects (ordered as litholist)
180
+ self.lf_block = {} # access LeapfrogLitho objects by block name
181
+ for code, name in zip(self.lithocodes, self.litholist):
182
+ lf_litho = LeapfrogLitho(code, name)
183
+ self.lf_lithos.append(lf_litho)
184
+ for block, idx in self.blocklitho.items():
185
+ self.lf_block[block] = self.lf_lithos[idx]
186
+
187
+ # compile lists of faults and pure (litho) rocks
188
+ rocks, faults = set(), set()
189
+ for litho in self.lf_lithos:
190
+ if litho.rock is not None:
191
+ rocks.add(litho.rock)
192
+ faults.update(litho.faults)
193
+ self.lf_rocks = sorted(rocks, key=natural_key)
194
+ self.lf_faults = sorted(faults, key=natural_key)
195
+
196
+ for f in self.lf_faults:
197
+ if f in self.lf_rocks:
198
+ raise Exception(f"Fault {f} also found in pure litho list")
199
+ for r in self.lf_rocks:
200
+ if r in self.lf_faults:
201
+ raise Exception(f"Pure litho {r} also found in fault list")
202
+
203
+ return self.lf_rocks, self.lf_faults
204
+
205
+ def gmf_litho_rocktype_1L(self, litho_codes={}, chars=None, ignore=None,
206
+ report=False):
207
+ """ Generates GMF style rocktype names (length of 1) based on lithologies.
208
+ Returns a dict of mapping from GMF rocktype name to Leapfrog lithology(s).
209
+
210
+ litho_codes is a user-defined dict with keys and values a single char.
211
+ The order is important, will affect how the final names are sorted.
212
+ If not specified, the names will be sorted *naturally*. Multiple litho
213
+ names mapping to the same code is allowed (i.e. merging rocktypes).
214
+
215
+ chars is a string containing possible characters to use. NOTE '0' is
216
+ special in GMF and is reserved, it will be removed from user's chars.
217
+
218
+ It is possible to ignore certain lithology by sepcifying names in ignore
219
+ list. Note that they will produce GMF rocktype '0'. There is a default
220
+ set of ignored litho names, if you want to include them, you will have
221
+ to set ignore=[].
222
+
223
+ Each block's rocktype code (length=1) can be accessed by:
224
+ GM.lf_block[block_name].rocktype_litho
225
+ """
226
+ if chars is None:
227
+ chars = string.ascii_uppercase + string.ascii_lowercase + string.digits[1:]
228
+ else:
229
+ chars.replace('0', '')
230
+
231
+ default_ignore = [None, 'Water', 'Unknown', 'Outside Geological Model']
232
+ if ignore is None:
233
+ ignore = [ig for ig in default_ignore if ig in self.lf_rocks]
234
+ else:
235
+ ignore = [ig for ig in ignore if ig in self.lf_rocks]
236
+
237
+ if litho_codes:
238
+ # some checks and remove used characters
239
+ for l,c in litho_codes.items():
240
+ if l in ignore and c != '0':
241
+ print(f"Warning: ignoring lithology {l}, "
242
+ f"overwrite user-defined litho_codes '{c}' -> '0'")
243
+ if len(c) != 1:
244
+ raise Exception(f"Lithology code for {l} must be a single character")
245
+ if l not in self.lf_rocks:
246
+ raise Exception(f"Lithology {l} not found in Leapfrog lithologies list")
247
+ chars = chars.replace(c, '')
248
+
249
+ for ig in ignore:
250
+ litho_codes[ig] = '0'
251
+
252
+ # deal with all other rocks not specified by user
253
+ remaining_rocks = [r for r in self.lf_rocks if r not in litho_codes]
254
+ new_litho_codes, chars = assign_chars(sorted(remaining_rocks, key=natural_key), chars)
255
+ litho_codes.update(new_litho_codes)
256
+
257
+ # keep as a property of LeapfrogLitho object
258
+ for litho in self.lf_lithos:
259
+ if litho.rock is None:
260
+ litho.rocktype_litho = '0'
261
+ else:
262
+ litho.rocktype_litho = litho_codes[litho.rock]
263
+
264
+ # create reverse index for GMF
265
+ rocktype_litho_index = {}
266
+ for name, rt in litho_codes.items():
267
+ if rt not in rocktype_litho_index:
268
+ rocktype_litho_index[rt] = name
269
+ else:
270
+ rocktype_litho_index[rt] += ", " + name
271
+ # sort the dict for nicer output
272
+ rocktype_litho_index = dict(sorted(rocktype_litho_index.items()))
273
+
274
+ # GMF contents for _dict_rocktype.json
275
+ gmf_litho = {
276
+ "rank": [0],
277
+ "legend": rocktype_litho_index,
278
+ }
279
+
280
+ if report:
281
+ print()
282
+ for code, names in rocktype_litho_index.items():
283
+ print(f"{code} -> {names}")
284
+ print()
285
+
286
+ return rocktype_litho_index, gmf_litho
287
+
288
+ def gmf_fault_rocktype_2L(self, fault_codes={}, chars=None, report=False):
289
+ """ Generates GMF style rocktype names (length of 2) based on faults.
290
+ Returns a dict of mapping from GMF rocktype name to tuple of faults.
291
+
292
+ fault_code is a user-defined dict with keys and values a single char.
293
+ The order is important, will affect how the final names are sorted.
294
+ If not specified, the names will be sorted *naturally*.
295
+
296
+ chars is a string containing possible characters to use. NOTE '0' is
297
+ special in GMF and is reserved, it will be removed from user's chars.
298
+
299
+ GMF uses two characters:
300
+ - 2nd char (3rd char is zero) single fault
301
+ - 3rd char (combined with 2nd char as unique combination of faults)
302
+ """
303
+ if chars is None:
304
+ chars = string.ascii_uppercase + string.ascii_lowercase + string.digits[1:]
305
+ else:
306
+ chars.replace('0', '')
307
+
308
+ if fault_codes:
309
+ # some checks and remove used characters
310
+ for f,c in fault_codes.items():
311
+ if c == '0':
312
+ raise Exception(f"Fault code for {f} cannot be '0'")
313
+ if len(c) != 1:
314
+ raise Exception(f"Fault code for {f} must be a single character")
315
+ if f not in fault_codes:
316
+ raise Exception(f"Fault {f} not found in Leapfrog faults list")
317
+ chars = chars.replace(c, '')
318
+ # tolerate if user only specifies some of the faults
319
+ remaining_lf_faults = [f for f in self.lf_faults if f not in fault_codes]
320
+ new_fault_codes, chars = assign_chars(sorted(remaining_lf_faults, key=natural_key), chars=chars)
321
+ fault_codes.update(new_fault_codes)
322
+ else:
323
+ fault_codes, chars = assign_chars(sorted(self.lf_faults, key=natural_key), chars=chars)
324
+
325
+ fault_rank = {name: idx for idx, name in enumerate(fault_codes)}
326
+
327
+ # sort combinations: by num of faults, then by ranking of 1st fault, then 2nd...
328
+ def combo_id(items):
329
+ # id (a tuple of faults) is also ordered by rank
330
+ return tuple(sorted(items, key=lambda x: fault_rank.get(x, 0)))
331
+ # sorting the faults to ensure unique fault sets
332
+ combinations = list({combo_id(c.faults) for c in self.lf_lithos})
333
+ # sort by combo length, then by rank sequentially in combo
334
+ combinations = sorted(combinations, key=lambda x: (len(x), tuple(fault_rank[y] for y in x)))
335
+
336
+ final_codes = {}
337
+ for ii,fid in enumerate(combinations):
338
+ if len(fid) == 0:
339
+ final_codes[fid] = '00'
340
+ elif len(fid) == 1:
341
+ # single item ones first -> fault code + '0'
342
+ final_codes[fid] = fault_codes[fid[0]] + '0'
343
+ elif len(fid) == 2:
344
+ # two items combo -> first + second
345
+ final_codes[fid] = ''
346
+ for fault in fid:
347
+ final_codes[fid] += fault_codes[fault]
348
+ else:
349
+ # three or more -> first fault code + nunique code
350
+ if not chars:
351
+ need_more = len(combinations) - ii
352
+ raise Exception(f"Not enough characters for remaining fault combinations,"
353
+ f" need {need_more} more.")
354
+ code = chars[0]
355
+ chars = chars[1:]
356
+ final_codes[fid] = fault_codes[fid[0]] + code
357
+
358
+ for litho in self.lf_lithos:
359
+ fid = combo_id(litho.faults)
360
+ litho.rocktype_fault = final_codes[fid]
361
+ litho.faults_sorted = fid
362
+
363
+ # create reverse index for general use (key has two characters)
364
+ rocktype_fault_index = {}
365
+ for fid, code in final_codes.items():
366
+ if code in rocktype_fault_index:
367
+ raise Exception(f"Should be unique")
368
+ rocktype_fault_index[code] = ", ".join(fid)
369
+ # sort the dict for nicer output
370
+ rocktype_fault_index = dict(sorted(rocktype_fault_index.items()))
371
+
372
+ # GMF contents for _dict_rocktype.json
373
+ rocktype_fault_gmf1, rocktype_fault_gmf2 = {'0':''}, {'0':''}
374
+ for fid, code in final_codes.items():
375
+ if len(fid) == 1:
376
+ rocktype_fault_gmf1[code[0]] = fid[0]
377
+ elif len(fid) >= 2:
378
+ rocktype_fault_gmf2[code[1]] = "Intersection " + ", ".join(fid[1:])
379
+ # sort the dict for nicer output
380
+ rocktype_fault_gmf1 = dict(sorted(rocktype_fault_gmf1.items()))
381
+ rocktype_fault_gmf2 = dict(sorted(rocktype_fault_gmf2.items()))
382
+ gmf_fault = {
383
+ "faults": {
384
+ "rank": [1],
385
+ "lengend": rocktype_fault_gmf1,
386
+ "direction": {f:None for f in rocktype_fault_gmf1.keys()},
387
+ },
388
+ "intersections": {
389
+ "rank": [2],
390
+ "lengend": rocktype_fault_gmf2,
391
+ "direction": {f:None for f in rocktype_fault_index.keys()},
392
+ },
393
+ }
394
+
68
395
  if report:
69
- print('%8s Lithology found.' % len(self.litholist))
70
- print('%8s Blocks allocated.' % len(self.blocklitho))
396
+ print("\n".join([
397
+ f"Generating GMF fault names:",
398
+ f"{len(self.lf_lithos):>8} Leapfrog LithoCode",
399
+ f"{len(self.lf_faults):>8} Faults found",
400
+ f"{len(final_codes):>8} Total unique fault combinations found",
401
+ f"{len([fid for fid in final_codes.keys() if len(fid)==1]):>8} Single-fault combinations found",
402
+ f"{len([fid for fid in final_codes.keys() if len(fid)==2]):>8} Fault intersections found with 2 faults",
403
+ f"{len([fid for fid in final_codes.keys() if len(fid)>=3]):>8} Fault intersections found with 3 or more faults",
404
+ f"{max([len(fid) for fid in final_codes.keys()]):>8} Maximum number of fault intersect in one block",
405
+ ]) + "\n")
406
+
407
+ return rocktype_fault_index, gmf_fault
71
408
 
72
409
  def write(self, filename):
73
410
  with open(filename, 'w') as f:
@@ -80,6 +417,8 @@ class LeapfrogGM(object):
80
417
  "geometry" : self.geometry,
81
418
  "zones": self.litholist,
82
419
  "blocks": self.blocklitho,
420
+ # Non standard field, for LeapfrogGM only
421
+ "import_litho_codes": self.lithocodes,
83
422
  }
84
423
  json.dump(data, f, indent=4, sort_keys=True)
85
424
 
@@ -90,6 +429,9 @@ class LeapfrogGM(object):
90
429
  self.geometry = data['geometry']
91
430
  self.litholist = data['zones']
92
431
  self.blocklitho = data['blocks']
432
+ # Non standard field, for LeapfrogGM only
433
+ self.lithocodes = data["import_litho_codes"]
434
+ self.parse_lithology()
93
435
 
94
436
  class CM(object):
95
437
  def populate_model(self, np_dtype):
File without changes
File without changes
File without changes
File without changes
File without changes