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.
- resfo_utilities/__init__.py +29 -0
- resfo_utilities/_cornerpoint_grid.py +569 -0
- resfo_utilities/_summary_keys.py +404 -0
- resfo_utilities/_summary_reader.py +594 -0
- resfo_utilities/testing/__init__.py +88 -0
- resfo_utilities/testing/_egrid_generator.py +422 -0
- resfo_utilities/testing/_summary_generator.py +568 -0
- resfo_utilities-0.3.0b0.dist-info/METADATA +74 -0
- resfo_utilities-0.3.0b0.dist-info/RECORD +12 -0
- resfo_utilities-0.3.0b0.dist-info/WHEEL +5 -0
- resfo_utilities-0.3.0b0.dist-info/licenses/LICENSE.md +166 -0
- resfo_utilities-0.3.0b0.dist-info/top_level.txt +1 -0
|
@@ -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)
|