resfo-utilities 0.3.0b0__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.

Potentially problematic release.


This version of resfo-utilities might be problematic. Click here for more details.

@@ -0,0 +1,29 @@
1
+ from ._cornerpoint_grid import (
2
+ CornerpointGrid,
3
+ InvalidEgridFileError,
4
+ MapAxes,
5
+ InvalidGridError,
6
+ )
7
+ from ._summary_reader import SummaryReader, InvalidSummaryError, SummaryKeyword
8
+ from ._summary_keys import (
9
+ SummaryKeyType,
10
+ history_key,
11
+ is_rate,
12
+ make_summary_key,
13
+ InvalidSummaryKeyError,
14
+ )
15
+
16
+ __all__ = [
17
+ "CornerpointGrid",
18
+ "InvalidEgridFileError",
19
+ "MapAxes",
20
+ "InvalidGridError",
21
+ "SummaryReader",
22
+ "SummaryKeyword",
23
+ "InvalidSummaryError",
24
+ "SummaryKeyType",
25
+ "history_key",
26
+ "is_rate",
27
+ "make_summary_key",
28
+ "InvalidSummaryKeyError",
29
+ ]
@@ -0,0 +1,569 @@
1
+ from __future__ import annotations
2
+ import os
3
+ from typing import Self, Any, IO, TypeVar, Callable
4
+ from dataclasses import dataclass
5
+ from numpy import typing as npt
6
+ import numpy as np
7
+ import resfo
8
+ import scipy.optimize
9
+ import warnings
10
+ import heapq
11
+ from functools import cached_property
12
+
13
+
14
+ class InvalidEgridFileError(ValueError):
15
+ pass
16
+
17
+
18
+ class InvalidGridError(ValueError):
19
+ pass
20
+
21
+
22
+ @dataclass
23
+ class MapAxes:
24
+ """The axes of the map coordinate system.
25
+
26
+ Usually, a corner-point grid contains x,y values that needs to be transformed
27
+ into a map coordinate system (which could be :term:`UTM-coordinates`). That
28
+ coordinate system is represented by MapAxes.
29
+
30
+ Note that regardless of the size of the axes, when transforming from the grid
31
+ coordinate system to the map coordinate system, scaling is not applied.
32
+
33
+ Attributes:
34
+ y_axis:
35
+ A point along the map y axis.
36
+ origin:
37
+ The origin of the map coordinate system.
38
+ x_axis:
39
+ A point along the map x axis.
40
+ """
41
+
42
+ y_axis: tuple[np.float32, np.float32]
43
+ origin: tuple[np.float32, np.float32]
44
+ x_axis: tuple[np.float32, np.float32]
45
+
46
+ def transform_map_points(
47
+ self, points: npt.NDArray[np.float32]
48
+ ) -> npt.NDArray[np.float32]:
49
+ """Transforms points from map coordinates to grid coordinates.
50
+
51
+ Scaling according to length of the axes is not applied.
52
+
53
+ Returns:
54
+ The given map points in the grid coordinate system.
55
+ """
56
+ translated = points - np.array([*self.origin, 0])
57
+ tx = translated[:, 0]
58
+ ty = translated[:, 1]
59
+ x_vec = (self.x_axis[0] - self.origin[0], self.x_axis[1] - self.origin[1])
60
+ y_vec = (self.y_axis[0] - self.origin[0], self.y_axis[1] - self.origin[1])
61
+ x_norm = np.sqrt(x_vec[0] ** 2 + x_vec[1] ** 2)
62
+ x_unit = (x_vec[0] / x_norm, x_vec[1] / x_norm)
63
+ y_norm = np.sqrt(y_vec[0] ** 2 + y_vec[1] ** 2)
64
+ y_unit = (y_vec[0] / y_norm, y_vec[1] / y_norm)
65
+ norm = 1.0 / (x_unit[0] * y_unit[1] - x_unit[1] * y_unit[0])
66
+ return np.column_stack(
67
+ [
68
+ (tx * y_unit[1] - ty * y_unit[0]) * norm,
69
+ (-tx * x_unit[1] + ty * x_unit[0]) * norm,
70
+ translated[:, 2],
71
+ ]
72
+ )
73
+
74
+
75
+ @dataclass
76
+ class CornerpointGrid:
77
+ """
78
+ A :term:`corner-point grid` is a tessellation of a 3D volume where
79
+ each cell is a hexahedron.
80
+
81
+ Each cell is identified by a integer coordinate (i,j,k).
82
+ For each i,j there is are four straight lines, defined by their end-points
83
+ called a :term:`pillar`. The end-points form two surfaces, one
84
+ for the top end-points and one for the bottom end points, which
85
+ are in the CornerpointGrid.coord array.
86
+
87
+ For the cell at position i,j,k, its eight corner vertices are defined by
88
+ giving the z values along the pillars at [(i,j), (i+1, j), (i, j+1), (i+1, j+1)]
89
+ which are in the CornerpointGrid.zcorn array.
90
+
91
+
92
+ Attributes:
93
+ coord:
94
+ A (ni+1, nj+1, 2, 3) array where coord[i,j,0] is the top end point
95
+ of the i,j pillar and coord[i,j,1] is the corresponding bottom end point.
96
+ zcorn:
97
+ A (ni, nj, nk, 8) array where zcorn[i,j,k] is the z value of
98
+ the 8 corners of the cell at i,j,k. The order of the corner z values
99
+ are as follows:
100
+ [TSW, TSE, TNW, TNE, BSW, BSE, BNW, BNE] where N(orth) means higher y,
101
+ E(east) means higher x, T(op) means lower z (when z is interpreted as depth).
102
+
103
+ map_axes:
104
+ Optionally each point is interpreted to be relative to some map
105
+ coordinate system. Defaults to the unit coordinate system with
106
+ origin at (0,0).
107
+
108
+ """
109
+
110
+ coord: npt.NDArray[np.float32]
111
+ zcorn: npt.NDArray[np.float32]
112
+ map_axes: MapAxes | None = None
113
+
114
+ def __post_init__(self) -> None:
115
+ if len(self.coord.shape) != 4 or self.coord.shape[2:4] != (2, 3):
116
+ raise InvalidGridError(f"coord had invalid dimensions {self.coord.shape}")
117
+ if len(self.zcorn.shape) != 4 or self.zcorn.shape[-1] != 8:
118
+ raise InvalidGridError(f"zcorn had invalid dimensions {self.zcorn.shape}")
119
+ ni = self.coord.shape[0] - 1
120
+ nj = self.coord.shape[1] - 1
121
+ if self.zcorn.shape[0] != ni or self.zcorn.shape[1] != nj:
122
+ raise InvalidGridError(
123
+ "zcorn and coord dimensions do not match:"
124
+ f" {self.zcorn.shape} vs {self.coord.shape}"
125
+ )
126
+
127
+ @classmethod
128
+ def read_egrid(cls, file_like: str | os.PathLike[str] | IO[Any]) -> Self:
129
+ """Read the global grid from an .EGRID or .FEGRID file.
130
+
131
+ If the EGRID contains Local Grid Refinements or Coarsening Groups,
132
+ that is silently ignored and only the host grid is read. Radial grids
133
+ are not supported and will cause InvalidEgridFileError to be raised.
134
+
135
+ Args:
136
+ file_like:
137
+ The EGRID file, could either be a filename, pathlike or an opened
138
+ EGRID file. The function also handles formatted egrid files (.FEGRID).
139
+ Whether the file is formatted or not is determined by looking at the
140
+ extension a filepath is given and by whether the stream is a byte-stream
141
+ (unformatted) or a text-stream when an opened file is given.
142
+ Raises:
143
+ InvalidEgridFileError:
144
+ When the egrid file is not valid, or contains a radial grid.
145
+ OSError:
146
+ If the given filepath cannot be opened.
147
+
148
+ """
149
+ coord = None
150
+ dims = None
151
+ zcorn = None
152
+ opened = False
153
+ stream = None
154
+ map_axes = None
155
+
156
+ try:
157
+ if isinstance(file_like, str):
158
+ filename = file_like
159
+ mode = "rt" if filename.lower().endswith("fegrid") else "rb"
160
+ stream = open(filename, mode=mode)
161
+ opened = True
162
+ elif isinstance(file_like, os.PathLike):
163
+ filename = str(file_like)
164
+ mode = "rt" if filename.lower().endswith("fegrid") else "rb"
165
+ stream = open(filename, mode=mode)
166
+ opened = True
167
+ else:
168
+ filename = getattr(file_like, "name", "unknown stream")
169
+ stream = file_like
170
+
171
+ T = TypeVar("T", bound=np.generic)
172
+
173
+ def validate_array(
174
+ name: str,
175
+ array: npt.NDArray[T] | resfo.MessType,
176
+ min_length: int | None = None,
177
+ ) -> npt.NDArray[T]:
178
+ if isinstance(array, resfo.MessType):
179
+ raise InvalidEgridFileError(
180
+ f"Expected Array for keyword {name} in {filename} but got MESS"
181
+ )
182
+ if min_length is not None and len(array) < min_length:
183
+ raise InvalidEgridFileError(
184
+ f"{name} in EGRID file {filename} contained too few elements"
185
+ )
186
+
187
+ return array
188
+
189
+ def optional_get(array: npt.NDArray[T] | None, index: int) -> T | None:
190
+ if array is None:
191
+ return None
192
+ if len(array) <= index:
193
+ return None
194
+ return array[index]
195
+
196
+ for entry in resfo.lazy_read(stream):
197
+ kw = entry.read_keyword()
198
+ match kw:
199
+ case "ZCORN ":
200
+ zcorn = validate_array(kw, entry.read_array())
201
+ case "COORD ":
202
+ coord = validate_array(kw, entry.read_array())
203
+ case "GRIDHEAD":
204
+ array = validate_array(kw, entry.read_array(), 4)
205
+ if (reference_number := optional_get(array, 4)) != 0:
206
+ warnings.warn(
207
+ f"The global grid in {filename} had "
208
+ f"reference number {reference_number}, expected 0."
209
+ " This could indicate that the grid being read"
210
+ " is actually an LGR grid."
211
+ )
212
+ if optional_get(array, 26) not in {0, None}:
213
+ raise InvalidEgridFileError(
214
+ f"EGRID file {filename} contains a radial grid"
215
+ " which is not supported by resfo-utilities."
216
+ )
217
+
218
+ dims = tuple(array[1:4])
219
+ case "MAPAXES ":
220
+ array = validate_array(kw, entry.read_array(), 6)
221
+ map_axes = MapAxes(
222
+ (array[0], array[1]),
223
+ (array[2], array[3]),
224
+ (array[4], array[5]),
225
+ )
226
+ case "ENDGRID ":
227
+ break
228
+
229
+ if coord is None:
230
+ raise InvalidEgridFileError(
231
+ f"EGRID file {filename} did not contain COORD"
232
+ )
233
+ if zcorn is None:
234
+ raise InvalidEgridFileError(
235
+ f"EGRID file {filename} did not contain ZCORN"
236
+ )
237
+ if dims is None:
238
+ raise InvalidEgridFileError(
239
+ f"EGRID file {filename} did not contain dimensions"
240
+ )
241
+ except resfo.ResfoParsingError as err:
242
+ raise InvalidEgridFileError(f"Could not parse EGRID file: {err}") from err
243
+ finally:
244
+ if opened and stream is not None:
245
+ stream.close()
246
+ try:
247
+ coord = np.swapaxes(coord.reshape((dims[1] + 1, dims[0] + 1, 2, 3)), 0, 1)
248
+ except ValueError as err:
249
+ raise InvalidEgridFileError(
250
+ f"COORD size {len(coord)} did not match"
251
+ f" grid dimensions {dims} in {filename}"
252
+ ) from err
253
+ try:
254
+ zcorn = zcorn.reshape(2, dims[0], 2, dims[1], 2, dims[2], order="F")
255
+ zcorn = np.moveaxis(zcorn, [1, 3, 5, 4, 2], [0, 1, 2, 3, 4])
256
+ zcorn = zcorn.reshape((dims[0], dims[1], dims[2], 8))
257
+ except ValueError as err:
258
+ raise InvalidEgridFileError(
259
+ f"ZCORN size {len(zcorn)} did not match"
260
+ f" grid dimensions {dims} in {filename}"
261
+ ) from err
262
+ return cls(coord, zcorn, map_axes)
263
+
264
+ def find_cell_containing_point(
265
+ self,
266
+ points: npt.ArrayLike,
267
+ map_coordinates: bool = True,
268
+ tolerance: float = 1.0e-6,
269
+ ) -> list[tuple[int, int, int] | None]:
270
+ """Find a cell in the grid which contains the given point.
271
+
272
+ Args:
273
+ points:
274
+ The points to find cells for.
275
+ map_coordinates:
276
+ Whether points are in the map coordinate system.
277
+ Defaults to True.
278
+ tolerance:
279
+ The maximum distance to the cell boundary a point can have to
280
+ be considered to be contained in the cell.
281
+
282
+ Returns:
283
+ list of i,j,k indices for each point (or None if the
284
+ point is not contained in any cell.
285
+ """
286
+ points = np.asarray(points)
287
+ result: list[tuple[int, int, int] | None] = []
288
+ if map_coordinates and self.map_axes is not None:
289
+ points = self.map_axes.transform_map_points(points)
290
+
291
+ dims = self.zcorn.shape[0:3]
292
+ top = self._pillars_z_plane_intersection(self.zcorn.min())
293
+ bot = self._pillars_z_plane_intersection(self.zcorn.max())
294
+
295
+ # This algorithm will for each point p calculate the mesh surface that
296
+ # is the intersection of the pillars with the plane z=p[2]. Then it searches
297
+ # through the quad with a heuristical search that orders each neighbour by
298
+ # the points manhattan distance to the bounding box.
299
+ found = False
300
+ # The use case that the previous point is close to the
301
+ # next point is very common, so we optimize for that.
302
+ prev_ij = None # The i,j index the previous point was found at
303
+
304
+ @dataclass
305
+ class Quad:
306
+ """The quad at index i,j"""
307
+
308
+ i: int
309
+ j: int
310
+ p: npt.NDArray[np.float32]
311
+ i_neighbourhood: int
312
+ j_neighbourhood: int
313
+
314
+ @cached_property
315
+ def vertices(self) -> npt.NDArray[np.float32]:
316
+ return np.array(
317
+ [
318
+ top[self.i, self.j],
319
+ top[self.i + 1, self.j],
320
+ top[self.i + 1, self.j + 1],
321
+ top[self.i, self.j + 1],
322
+ bot[self.i, self.j],
323
+ bot[self.i + 1, self.j],
324
+ bot[self.i + 1, self.j + 1],
325
+ bot[self.i, self.j + 1],
326
+ ],
327
+ dtype=np.float32,
328
+ )
329
+
330
+ @cached_property
331
+ def min_x(self) -> np.float32:
332
+ return self.vertices[:, 0].min()
333
+
334
+ @cached_property
335
+ def min_y(self) -> np.float32:
336
+ return self.vertices[:, 1].min()
337
+
338
+ @cached_property
339
+ def max_x(self) -> np.float32:
340
+ return self.vertices[:, 0].max()
341
+
342
+ @cached_property
343
+ def max_y(self) -> np.float32:
344
+ return self.vertices[:, 1].max()
345
+
346
+ @cached_property
347
+ def distance_from_bounds(self) -> np.float32:
348
+ """Manhattan distance from the point to the quad bounding box."""
349
+ x_dist = max(self.min_x - self.p[0], self.p[0] - self.max_x, 0)
350
+ y_dist = max(self.min_y - self.p[1], self.p[1] - self.max_y, 0)
351
+ return x_dist + y_dist
352
+
353
+ def __lt__(self, other: object) -> bool:
354
+ """Used to order elements in the search queue.
355
+
356
+ The Quads are ordered by distance_from_bounds.
357
+ """
358
+ if not isinstance(other, Quad):
359
+ return False
360
+ return bool(self.distance_from_bounds < other.distance_from_bounds)
361
+
362
+ if dims[0] <= 0 or dims[1] <= 0:
363
+ return [None] * len(points)
364
+
365
+ for p in points:
366
+ found = False
367
+ if prev_ij is None:
368
+ queue = [
369
+ Quad(dims[0] // 2, dims[1] // 2, p, dims[0] // 2, dims[1] // 2)
370
+ ]
371
+ else:
372
+ queue = [Quad(*prev_ij, p, 1, 1)]
373
+ visited = set([(queue[0].i, queue[0].j)])
374
+ while queue:
375
+ node = heapq.heappop(queue)
376
+ i = node.i
377
+ j = node.j
378
+
379
+ # If the quad contains the point then search through each k index
380
+ # for that quad
381
+ if node.distance_from_bounds <= 2 * tolerance:
382
+ for k in range(dims[2]):
383
+ zcorn = self.zcorn[i, j, k]
384
+ z = p[2]
385
+ # Prune by bounding box first then check whether point_in_cell
386
+ if (
387
+ zcorn.min() - 2 * tolerance
388
+ <= z
389
+ <= zcorn.max() + 2 * tolerance
390
+ and self.point_in_cell(
391
+ p, i, j, k, tolerance=tolerance, map_coordinates=False
392
+ )
393
+ ):
394
+ prev_ij = (i, j)
395
+ result.append((i, j, k))
396
+ found = True
397
+ break
398
+ if found:
399
+ break
400
+
401
+ # Add each neighbour to the queue if not visited
402
+ size_i = node.i_neighbourhood
403
+ for di in (-1 * size_i, 0, size_i):
404
+ ni = np.clip(i + di, 0, dims[0] - 1)
405
+ size_j = node.j_neighbourhood
406
+ for dj in (-1 * size_j, 0, size_j):
407
+ nj = np.clip(j + dj, 0, dims[1] - 1)
408
+ if (ni, nj) not in visited:
409
+ heapq.heappush(
410
+ queue,
411
+ Quad(
412
+ ni,
413
+ nj,
414
+ p,
415
+ max(size_i // 2, 1),
416
+ max(size_j // 2, 1),
417
+ ),
418
+ )
419
+ visited.add((ni, nj))
420
+ if not found:
421
+ result.append(None)
422
+
423
+ return result
424
+
425
+ def cell_corners(self, i: int, j: int, k: int) -> npt.NDArray[np.float32]:
426
+ """Array of coordinates for all corners of the cell at i,j,k
427
+
428
+ The order of the corners are the same as in zcorn.
429
+ """
430
+ pillar_vertices = np.concatenate(
431
+ [
432
+ self.coord[i, j, :],
433
+ self.coord[i, j + 1, :],
434
+ self.coord[i + 1, j, :],
435
+ self.coord[i + 1, j + 1, :],
436
+ ]
437
+ )
438
+ top = pillar_vertices[::2][[0, 2, 1, 3]]
439
+ bot = pillar_vertices[1::2][[0, 2, 1, 3]]
440
+ top_z = top[:, 2]
441
+ bot_z = bot[:, 2]
442
+
443
+ def twice(a: npt.NDArray[Any]) -> npt.NDArray[Any]:
444
+ return np.concatenate([a, a])
445
+
446
+ height_diff = twice(bot_z - top_z)
447
+
448
+ if np.any(height_diff == 0):
449
+ raise InvalidGridError(
450
+ f"Grid contains zero height pillars with different for cell {i, j, k}"
451
+ )
452
+
453
+ t = (self.zcorn[i, j, k] - twice(top_z)) / height_diff
454
+
455
+ result = twice(top) + t[:, np.newaxis] * twice(bot - top)
456
+
457
+ if not np.all(np.isfinite(result)):
458
+ raise InvalidGridError(
459
+ f"The corners of the cell at {i, j, k} is not well defined"
460
+ )
461
+
462
+ return result
463
+
464
+ def point_in_cell(
465
+ self,
466
+ points: npt.ArrayLike,
467
+ i: int,
468
+ j: int,
469
+ k: int,
470
+ tolerance: float = 1e-6,
471
+ map_coordinates: bool = True,
472
+ ) -> npt.NDArray[np.bool_]:
473
+ """Whether the points (x,y,z) is in the cell at (i,j,k).
474
+
475
+ For containment the cell are considered to have bilinear faces.
476
+
477
+ Param:
478
+ points:
479
+ x,y,z triple or array of x,y,z triples to be tested for containment.
480
+ tolerance:
481
+ The tolerance used for numerical precision in the linear
482
+ interpolation calculation.
483
+ map_coordinates:
484
+ Whether the given points are in the mapaxes coordinate system,
485
+ defaults to true.
486
+
487
+ Returns:
488
+ Array of boolean values for each triplet describing whether
489
+ it is contained in the cell.
490
+ """
491
+ points = np.asarray(points)
492
+ if len(points.shape) == 1:
493
+ points = points[np.newaxis, :]
494
+ if map_coordinates and self.map_axes is not None:
495
+ points = self.map_axes.transform_map_points(points)
496
+
497
+ vertices = self.cell_corners(i, j, k)
498
+
499
+ corner_signs = np.array(
500
+ [
501
+ [-1, -1, -1],
502
+ [1, -1, -1],
503
+ [-1, 1, -1],
504
+ [1, 1, -1],
505
+ [-1, -1, 1],
506
+ [1, -1, 1],
507
+ [-1, 1, 1],
508
+ [1, 1, 1],
509
+ ]
510
+ )
511
+
512
+ def residual(
513
+ point: tuple[float, float, float],
514
+ ) -> Callable[[npt.NDArray[np.float64]], npt.NDArray[np.float64]]:
515
+ def inner(
516
+ xi_eta_zeta: npt.NDArray[np.float64],
517
+ ) -> npt.NDArray[np.float64]:
518
+ xi, eta, zeta = xi_eta_zeta
519
+ shape_matrix = (
520
+ 1
521
+ / 8
522
+ * (1 + xi * corner_signs[:, 0])
523
+ * (1 + eta * corner_signs[:, 1])
524
+ * (1 + zeta * corner_signs[:, 2])
525
+ )
526
+ mapped = shape_matrix @ vertices
527
+ return mapped - point
528
+
529
+ return inner
530
+
531
+ solutions = []
532
+ for point in points:
533
+ point = point
534
+ with warnings.catch_warnings():
535
+ warnings.simplefilter("ignore")
536
+ initial_guess = (
537
+ 2 * (point - vertices[0]) / (vertices[7] - vertices[0]) - 1
538
+ )
539
+ initial_guess = np.clip(initial_guess, -1, 1)
540
+ np.nan_to_num(initial_guess, copy=False)
541
+ sol = scipy.optimize.least_squares(
542
+ residual(point),
543
+ initial_guess,
544
+ method="trf",
545
+ )
546
+ if not sol.success:
547
+ solutions.append(False)
548
+ else:
549
+ solutions.append(
550
+ bool(
551
+ np.all(np.abs(sol.x) <= 1.0 + tolerance)
552
+ and np.linalg.norm(residual(point)(sol.x)) <= tolerance
553
+ )
554
+ )
555
+ return np.array(solutions, dtype=np.bool_)
556
+
557
+ def _pillars_z_plane_intersection(self, z: np.float32) -> npt.NDArray[np.float32]:
558
+ shape = self.coord.shape
559
+ coord = self.coord.reshape(shape[0] * shape[1], shape[2] * shape[3])
560
+ x1, y1, z1, x2, y2, z2 = coord.T
561
+ t = (z - z1) / (z2 - z1)
562
+
563
+ # Compute x and y for all lines
564
+ x = x1 + t * (x2 - x1)
565
+ y = y1 + t * (y2 - y1)
566
+
567
+ # Result: (x, y) coordinates for all lines at z
568
+ result = np.column_stack((x, y))
569
+ return result.reshape(shape[0], shape[1], 2)