simgeometry 0.1.0__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.
- simgeometry/__init__.py +38 -0
- simgeometry/_bounds.py +16 -0
- simgeometry/_normalize.py +23 -0
- simgeometry/_particle_boundaries.py +37 -0
- simgeometry/_types.py +22 -0
- simgeometry/api.py +247 -0
- simgeometry/boundingbox.py +231 -0
- simgeometry/collection.py +65 -0
- simgeometry/crossings.py +319 -0
- simgeometry/cut_edges.py +309 -0
- simgeometry/cylindrical.py +43 -0
- simgeometry/domain_decomposition.py +496 -0
- simgeometry/evaluation.py +105 -0
- simgeometry/fractions.py +68 -0
- simgeometry/grid.py +238 -0
- simgeometry/kernels/__init__.py +1 -0
- simgeometry/kernels/_build.py +53 -0
- simgeometry/kernels/_metal.py +41 -0
- simgeometry/kernels/compile.py +24 -0
- simgeometry/kernels/contains_ball/__init__.py +12 -0
- simgeometry/kernels/contains_ball/contains_ball_cuda.cu +27 -0
- simgeometry/kernels/contains_ball/contains_ball_metal.py +53 -0
- simgeometry/kernels/contains_ball/contains_ball_numpy.py +23 -0
- simgeometry/kernels/contains_ball/contains_ball_pyccel.py +31 -0
- simgeometry/kernels/contains_box/__init__.py +12 -0
- simgeometry/kernels/contains_box/contains_box_cuda.cu +26 -0
- simgeometry/kernels/contains_box/contains_box_metal.py +50 -0
- simgeometry/kernels/contains_box/contains_box_numpy.py +24 -0
- simgeometry/kernels/contains_box/contains_box_pyccel.py +32 -0
- simgeometry/kernels/contains_polygon_2d/__init__.py +12 -0
- simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_cuda.cu +35 -0
- simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_metal.py +58 -0
- simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_numpy.py +28 -0
- simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_pyccel.py +40 -0
- simgeometry/mesh.py +147 -0
- simgeometry/objects/__init__.py +35 -0
- simgeometry/objects/aperture2d.py +183 -0
- simgeometry/objects/aperture3d.py +361 -0
- simgeometry/objects/circle.py +138 -0
- simgeometry/objects/cuboid.py +173 -0
- simgeometry/objects/function_object.py +214 -0
- simgeometry/objects/line.py +127 -0
- simgeometry/objects/polygon.py +182 -0
- simgeometry/objects/polyline.py +67 -0
- simgeometry/objects/rectangle.py +130 -0
- simgeometry/objects/sphere.py +181 -0
- simgeometry/objects/triangle.py +148 -0
- simgeometry/operations.py +386 -0
- simgeometry/panels.py +80 -0
- simgeometry/plotting.py +179 -0
- simgeometry/py.typed +0 -0
- simgeometry/queries.py +445 -0
- simgeometry/sampling.py +75 -0
- simgeometry/shapes.py +402 -0
- simgeometry/simulation_domain.py +277 -0
- simgeometry/utils.py +24 -0
- simgeometry/visualization.py +381 -0
- simgeometry-0.1.0.dist-info/METADATA +128 -0
- simgeometry-0.1.0.dist-info/RECORD +62 -0
- simgeometry-0.1.0.dist-info/WHEEL +5 -0
- simgeometry-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
- simgeometry-0.1.0.dist-info/top_level.txt +1 -0
simgeometry/shapes.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Shapes helpers for geometry, grids, and simulation domains."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
import cunumpy as xp
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from simgeometry._types import Array
|
|
13
|
+
from simgeometry.api import GeometryAPI
|
|
14
|
+
from simgeometry.boundingbox import BoundingBox
|
|
15
|
+
from simgeometry.grid import Grid
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from simgeometry.panels import Panels
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _component_major(points: Array, ndim: int) -> Array:
|
|
22
|
+
"""Return ``points`` as a ``(ndim, n)`` float array, checking its layout."""
|
|
23
|
+
points = xp.asarray(points, dtype=float)
|
|
24
|
+
if points.ndim != 2 or points.shape[0] != ndim:
|
|
25
|
+
raise ValueError(f"Expected points with shape ({ndim}, n), got {points.shape}")
|
|
26
|
+
return points
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _signed_area(loop: np.ndarray) -> float:
|
|
30
|
+
"""Shoelace area of a ``(2, n)`` loop: positive when counter-clockwise."""
|
|
31
|
+
x, y = loop
|
|
32
|
+
return 0.5 * float(np.sum(x * np.roll(y, -1) - np.roll(x, -1) * y))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
#: A spacing: a length, or a function of ``(2, m)`` points returning ``(m,)`` lengths.
|
|
36
|
+
Spacing = float | Callable[[np.ndarray], np.ndarray]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _pieces(length: float, max_spacing: float) -> int:
|
|
40
|
+
"""Number of equal pieces that keeps each one at most ``max_spacing`` long.
|
|
41
|
+
|
|
42
|
+
The tolerance keeps ``length / (length / n)`` from rounding up to ``n + 1``.
|
|
43
|
+
"""
|
|
44
|
+
return max(1, int(np.ceil(length / max_spacing * (1 - 1e-12))))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _distribute(
|
|
48
|
+
curve: Callable[[np.ndarray], np.ndarray],
|
|
49
|
+
length: float,
|
|
50
|
+
max_spacing: Spacing,
|
|
51
|
+
*,
|
|
52
|
+
min_points: int = 1,
|
|
53
|
+
samples: int = 256,
|
|
54
|
+
) -> np.ndarray:
|
|
55
|
+
"""Parameters in ``[0, 1)`` of points along ``curve`` at about ``max_spacing`` apart.
|
|
56
|
+
|
|
57
|
+
``curve`` maps parameters ``(m,)`` in ``[0, 1]`` to ``(2, m)`` points at uniform
|
|
58
|
+
arc length, ``length`` long in total. A constant spacing gives equal pieces of at most
|
|
59
|
+
that length; a callable one places points at equal steps of the integral of
|
|
60
|
+
``1 / spacing`` along the curve, sampled at ``samples`` intervals.
|
|
61
|
+
"""
|
|
62
|
+
if not callable(max_spacing):
|
|
63
|
+
n = max(min_points, _pieces(length, max_spacing))
|
|
64
|
+
return np.arange(n) / n
|
|
65
|
+
t = np.linspace(0.0, 1.0, samples + 1)
|
|
66
|
+
spacing = np.broadcast_to(np.asarray(max_spacing(curve(t)), dtype=float), t.shape)
|
|
67
|
+
if not np.all(spacing > 0):
|
|
68
|
+
raise ValueError("max_spacing must be positive")
|
|
69
|
+
density = length / spacing
|
|
70
|
+
cumulative = np.concatenate(
|
|
71
|
+
([0.0], np.cumsum(0.5 * (density[1:] + density[:-1]) * np.diff(t)))
|
|
72
|
+
)
|
|
73
|
+
n = max(min_points, _pieces(cumulative[-1], 1.0))
|
|
74
|
+
return np.interp(np.arange(n) / n * cumulative[-1], cumulative, t)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _refine_loop(
|
|
78
|
+
loop: np.ndarray, max_spacing: Spacing, *, closed: bool = True
|
|
79
|
+
) -> np.ndarray:
|
|
80
|
+
"""Split each edge of a ``(2, n)`` loop into pieces of about ``max_spacing``.
|
|
81
|
+
|
|
82
|
+
A closed loop also has the edge from its last vertex back to its first; an open
|
|
83
|
+
one (a polyline) keeps its last vertex.
|
|
84
|
+
"""
|
|
85
|
+
ends = np.roll(loop, -1, axis=1) if closed else loop[:, 1:]
|
|
86
|
+
pieces = []
|
|
87
|
+
for a, b in zip(loop.T, ends.T):
|
|
88
|
+
length = float(np.hypot(*(b - a)))
|
|
89
|
+
a, b = a[:, None], b[:, None]
|
|
90
|
+
t = _distribute(lambda t: a + t * (b - a), length, max_spacing)
|
|
91
|
+
pieces.append(a + t * (b - a))
|
|
92
|
+
if not closed:
|
|
93
|
+
pieces.append(loop[:, -1:])
|
|
94
|
+
return np.concatenate(pieces, axis=1)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Shape(GeometryAPI, ABC):
|
|
98
|
+
"""Define the base interface for geometric shapes.
|
|
99
|
+
|
|
100
|
+
A shape created with ``inverted=True`` covers everything *except* its
|
|
101
|
+
geometry (e.g. a chamber wall around a circular cavity): ``contains`` is
|
|
102
|
+
negated and the shape extends over the whole simulation domain. The
|
|
103
|
+
bounding box still describes the geometry itself, so consumers must not
|
|
104
|
+
use it to skip points for inverted shapes.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
_inverted: bool = False
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
ndim: int,
|
|
112
|
+
lower_bound: tuple[float, ...],
|
|
113
|
+
upper_bound: tuple[float, ...],
|
|
114
|
+
) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Initialize a shape.
|
|
117
|
+
|
|
118
|
+
Parameters:
|
|
119
|
+
ndim (int): Number of dimensions.
|
|
120
|
+
bounds (list of tuples): List of tuples specifying min and max for each dimension.
|
|
121
|
+
"""
|
|
122
|
+
assert isinstance(ndim, int) and ndim > 0, "ndim must be a positive integer."
|
|
123
|
+
assert ndim in [1, 2, 3]
|
|
124
|
+
self.ndim = ndim
|
|
125
|
+
self.set_bounding_box(lower_bound, upper_bound)
|
|
126
|
+
|
|
127
|
+
def set_bounding_box(
|
|
128
|
+
self,
|
|
129
|
+
lower_bound: tuple[float, ...],
|
|
130
|
+
upper_bound: tuple[float, ...],
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Set the bounding box for the shape."""
|
|
133
|
+
self._bounding_box = BoundingBox(
|
|
134
|
+
lower_bound=lower_bound,
|
|
135
|
+
upper_bound=upper_bound,
|
|
136
|
+
)
|
|
137
|
+
assert self.ndim == self.bounding_box.ndim
|
|
138
|
+
|
|
139
|
+
@abstractmethod
|
|
140
|
+
def contains(self, coords: tuple[float, ...]) -> bool:
|
|
141
|
+
"""
|
|
142
|
+
Check if the given coordinates are inside the shape.
|
|
143
|
+
|
|
144
|
+
Parameters:
|
|
145
|
+
coords (list or tuple): Coordinates for each dimension.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
bool: True if the coordinates are inside the shape, False otherwise.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def contains_points(self, points: Array) -> Array:
|
|
152
|
+
"""Return a mask of the ``(ndim, n)`` points inside the shape.
|
|
153
|
+
|
|
154
|
+
The default calls :meth:`contains` point by point; shapes override it with a
|
|
155
|
+
vectorised test.
|
|
156
|
+
"""
|
|
157
|
+
points = _component_major(points, self.ndim)
|
|
158
|
+
return xp.asarray([bool(self.contains(p)) for p in points.T], dtype=bool)
|
|
159
|
+
|
|
160
|
+
def node_mask(self, grid: Grid) -> np.ndarray:
|
|
161
|
+
"""Return a host boolean array of the grid's shape, set at the nodes inside.
|
|
162
|
+
|
|
163
|
+
One vectorised :meth:`contains_points` over :meth:`Grid.node_points`, so
|
|
164
|
+
the cost is that of a few array passes over the grid.
|
|
165
|
+
"""
|
|
166
|
+
inside = self.contains_points(grid.node_points())
|
|
167
|
+
shape = tuple(int(n) for n in grid.num_gridpoints)
|
|
168
|
+
return np.asarray(xp.to_numpy(inside), dtype=bool).reshape(shape)
|
|
169
|
+
|
|
170
|
+
def get_indices_in_object(self, grid: Grid) -> list[int] | list[tuple[int, ...]]:
|
|
171
|
+
"""
|
|
172
|
+
Get the indices of grid points that are inside the shape.
|
|
173
|
+
|
|
174
|
+
Parameters:
|
|
175
|
+
grid (object): Grid object containing indices and cell sizes.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
list: The :attr:`Grid.indices` of the nodes inside, in their order.
|
|
179
|
+
"""
|
|
180
|
+
if hasattr(grid, "indices_from_mask"):
|
|
181
|
+
return grid.indices_from_mask(self.node_mask(grid))
|
|
182
|
+
# any object with ``indices`` and ``node_coordinates`` (e.g. a few nodes)
|
|
183
|
+
indices = list(grid.indices)
|
|
184
|
+
if not indices:
|
|
185
|
+
return []
|
|
186
|
+
points = np.stack(
|
|
187
|
+
[np.atleast_1d(grid.node_coordinates(index)) for index in indices], axis=1
|
|
188
|
+
)
|
|
189
|
+
inside = np.asarray(xp.to_numpy(self.contains_points(points)), dtype=bool)
|
|
190
|
+
return [index for index, keep in zip(indices, inside) if keep]
|
|
191
|
+
|
|
192
|
+
def get_bound_vertices(self, slice_axis: int) -> xp.ndarray:
|
|
193
|
+
"""Return vertices that bound the shape."""
|
|
194
|
+
b0 = self.lower_bound
|
|
195
|
+
b1 = self.upper_bound
|
|
196
|
+
# print(b0, b1)
|
|
197
|
+
axis1 = (slice_axis + 1) % 3
|
|
198
|
+
axis2 = (slice_axis + 2) % 3
|
|
199
|
+
bound_vertices = xp.array(
|
|
200
|
+
[
|
|
201
|
+
[b0[axis1], b0[axis2]],
|
|
202
|
+
[b1[axis1], b0[axis2]],
|
|
203
|
+
[b1[axis1], b1[axis2]],
|
|
204
|
+
[b0[axis1], b1[axis2]],
|
|
205
|
+
],
|
|
206
|
+
)
|
|
207
|
+
return bound_vertices
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def inverted(self) -> bool:
|
|
211
|
+
"""Return whether this object represents an inverted region."""
|
|
212
|
+
return self._inverted
|
|
213
|
+
|
|
214
|
+
def _apply_inversion(self, inside: bool) -> bool:
|
|
215
|
+
"""Return ``inside`` for the geometry, negated for an inverted shape."""
|
|
216
|
+
return (not inside) if self.inverted else bool(inside)
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def bounding_box(self) -> BoundingBox:
|
|
220
|
+
"""Return the bounding box."""
|
|
221
|
+
return self._bounding_box
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def lower_bound(self) -> tuple[float, ...]:
|
|
225
|
+
"""Return the lower coordinate bounds."""
|
|
226
|
+
return self.bounding_box.lower_bound
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def upper_bound(self) -> tuple[float, ...]:
|
|
230
|
+
"""Return the upper coordinate bounds."""
|
|
231
|
+
return self.bounding_box.upper_bound
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class Shape1D(Shape):
|
|
235
|
+
"""Represent a one-dimensional shape."""
|
|
236
|
+
|
|
237
|
+
def __init__(
|
|
238
|
+
self,
|
|
239
|
+
lower_bound: tuple[float, float],
|
|
240
|
+
upper_bound: tuple[float, float],
|
|
241
|
+
) -> None:
|
|
242
|
+
"""
|
|
243
|
+
Initialize a 1D shape.
|
|
244
|
+
|
|
245
|
+
Parameters:
|
|
246
|
+
bounds (list of tuples): List of tuples specifying min and max for each dimension.
|
|
247
|
+
"""
|
|
248
|
+
super().__init__(ndim=1, lower_bound=lower_bound, upper_bound=upper_bound)
|
|
249
|
+
|
|
250
|
+
@abstractmethod
|
|
251
|
+
def get_vertices(self) -> list[list[float]]:
|
|
252
|
+
"""
|
|
253
|
+
Get the vertices of the 1D shape.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
list: List of vertices as [x] coordinates.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
@abstractmethod
|
|
260
|
+
def contains(self, coords: float) -> bool:
|
|
261
|
+
"""
|
|
262
|
+
Check if the given coordinates are inside the shape.
|
|
263
|
+
|
|
264
|
+
Parameters:
|
|
265
|
+
coords (float): Coordinates for each dimension.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
bool: True if the coordinates are inside the shape, False otherwise.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class Shape2D(Shape):
|
|
273
|
+
"""Represent a two-dimensional shape.
|
|
274
|
+
|
|
275
|
+
``exact_outline`` is True for shapes whose :meth:`outlines` are their exact
|
|
276
|
+
boundary (polygons), False for curved ones. ``closed`` is False for open curves
|
|
277
|
+
(:class:`~simgeometry.Polyline`), which enclose no area.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
exact_outline: bool = False
|
|
281
|
+
closed: bool = True
|
|
282
|
+
|
|
283
|
+
def __init__(
|
|
284
|
+
self,
|
|
285
|
+
lower_bound: tuple[float, float],
|
|
286
|
+
upper_bound: tuple[float, float],
|
|
287
|
+
) -> None:
|
|
288
|
+
"""
|
|
289
|
+
Initialize a 2D shape.
|
|
290
|
+
|
|
291
|
+
Parameters:
|
|
292
|
+
bounds (list of tuples): List of tuples specifying min and max for each dimension.
|
|
293
|
+
"""
|
|
294
|
+
super().__init__(ndim=2, lower_bound=lower_bound, upper_bound=upper_bound)
|
|
295
|
+
|
|
296
|
+
@abstractmethod
|
|
297
|
+
def contains(self, coords: tuple[float, float]) -> bool:
|
|
298
|
+
"""
|
|
299
|
+
Check if the given coordinates are inside the shape.
|
|
300
|
+
|
|
301
|
+
Parameters:
|
|
302
|
+
coords (tuple): Coordinates for each dimension.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
bool: True if the coordinates are inside the shape, False otherwise.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
def outlines(self, max_spacing: Spacing | None = None) -> list[np.ndarray]:
|
|
309
|
+
"""Return the boundary loops of the shape as ``(2, n)`` host arrays.
|
|
310
|
+
|
|
311
|
+
Each loop is counter-clockwise, without repeating its first vertex. A shape
|
|
312
|
+
made of several pieces (such as :class:`~simgeometry.Aperture2D`'s two
|
|
313
|
+
plates) has one loop per piece. An open curve (``closed`` is False) gives
|
|
314
|
+
its polyline instead, from its first vertex to its last. With
|
|
315
|
+
``max_spacing``, edges longer than it are split evenly so that no two
|
|
316
|
+
consecutive vertices are further apart; curved shapes then place every
|
|
317
|
+
vertex on the curve. ``max_spacing`` may also be a function of ``(2, m)``
|
|
318
|
+
points returning ``(m,)`` spacings, to grade the vertices (finer near a
|
|
319
|
+
corner, say); the spacing then holds approximately. Inversion does not
|
|
320
|
+
change the outline.
|
|
321
|
+
"""
|
|
322
|
+
if (
|
|
323
|
+
max_spacing is not None
|
|
324
|
+
and not callable(max_spacing)
|
|
325
|
+
and not max_spacing > 0
|
|
326
|
+
):
|
|
327
|
+
raise ValueError("max_spacing must be positive")
|
|
328
|
+
loops = self._outline_loops(max_spacing)
|
|
329
|
+
if not self.closed:
|
|
330
|
+
return loops
|
|
331
|
+
return [loop[:, ::-1] if _signed_area(loop) < 0.0 else loop for loop in loops]
|
|
332
|
+
|
|
333
|
+
def boundary_panels(self, max_spacing: Spacing | None = None) -> Panels:
|
|
334
|
+
"""The edges of :meth:`outlines` as panels, for boundary-element solvers.
|
|
335
|
+
|
|
336
|
+
See :class:`~simgeometry.panels.Panels` for the arrays and the normal
|
|
337
|
+
convention.
|
|
338
|
+
"""
|
|
339
|
+
from simgeometry.panels import boundary_panels
|
|
340
|
+
|
|
341
|
+
return boundary_panels(self, max_spacing)
|
|
342
|
+
|
|
343
|
+
def _corner_loops(self) -> list[np.ndarray]:
|
|
344
|
+
"""The unrefined ``(2, n)`` boundary loops; polygonal shapes define this."""
|
|
345
|
+
raise NotImplementedError(f"{type(self).__name__} has no outline")
|
|
346
|
+
|
|
347
|
+
def _outline_loops(self, max_spacing: Spacing | None) -> list[np.ndarray]:
|
|
348
|
+
"""The loops of :meth:`_corner_loops`, refined; curved shapes override this."""
|
|
349
|
+
loops = self._corner_loops()
|
|
350
|
+
if max_spacing is None:
|
|
351
|
+
return loops
|
|
352
|
+
return [_refine_loop(loop, max_spacing, closed=self.closed) for loop in loops]
|
|
353
|
+
|
|
354
|
+
def plot(self, ax: Any = None, **kwargs: Any) -> Any:
|
|
355
|
+
"""Draw the shape as filled polygons into ``ax`` and return the axes.
|
|
356
|
+
|
|
357
|
+
Keyword arguments style the polygons (``facecolor``, ``alpha``, ...).
|
|
358
|
+
"""
|
|
359
|
+
if any(
|
|
360
|
+
key in kwargs
|
|
361
|
+
for key in ("x", "y", "z", "grid", "nodes", "normals", "resolution")
|
|
362
|
+
):
|
|
363
|
+
return self.show(ax=ax, display=False, **kwargs)
|
|
364
|
+
from simgeometry.plotting import draw_shape
|
|
365
|
+
|
|
366
|
+
return draw_shape(self, ax=ax, **kwargs)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class Shape3D(Shape):
|
|
370
|
+
"""Represent a three-dimensional shape."""
|
|
371
|
+
|
|
372
|
+
def __init__(
|
|
373
|
+
self,
|
|
374
|
+
lower_bound: tuple[float, float, float],
|
|
375
|
+
upper_bound: tuple[float, float, float],
|
|
376
|
+
) -> None:
|
|
377
|
+
"""
|
|
378
|
+
Initialize a 3D shape.
|
|
379
|
+
|
|
380
|
+
Parameters:
|
|
381
|
+
bounds (list of tuples): List of tuples specifying min and max for each dimension.
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
super().__init__(ndim=3, lower_bound=lower_bound, upper_bound=upper_bound)
|
|
385
|
+
|
|
386
|
+
@abstractmethod
|
|
387
|
+
def volume(self) -> float:
|
|
388
|
+
"""
|
|
389
|
+
Calculate the volume of the 3D shape.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
float: Volume of the shape.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
@abstractmethod
|
|
396
|
+
def surface_area(self) -> float:
|
|
397
|
+
"""
|
|
398
|
+
Calculate the surface area of the 3D shape.
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
float: Surface area of the shape.
|
|
402
|
+
"""
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Simulation domain helpers for geometry, grids, and simulation domains."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import cunumpy as xp
|
|
8
|
+
from maybempi import MPI
|
|
9
|
+
|
|
10
|
+
from simgeometry._particle_boundaries import apply_particle_boundaries
|
|
11
|
+
from simgeometry._types import Array, ConfigValue
|
|
12
|
+
from simgeometry.boundingbox import BoundingBox
|
|
13
|
+
from simgeometry.grid import Grid
|
|
14
|
+
from simgeometry.shapes import Shape
|
|
15
|
+
|
|
16
|
+
# The particle boundaries ``apply_bc_arrays`` knows.
|
|
17
|
+
_PARTICLE_BC_TYPES = ("kill", "reflect", "periodic", "reinject")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
#: The ``apply_particle_boundaries`` kernel's code of each particle boundary type.
|
|
21
|
+
PARTICLE_BOUNDARY_CODES = {"kill": 0, "reflect": 1, "periodic": 2, "reinject": 3}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SimulationDomain(BoundingBox):
|
|
25
|
+
"""Class representing the simulation domain with boundary conditions."""
|
|
26
|
+
|
|
27
|
+
boundary_kernel = staticmethod(apply_particle_boundaries)
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
ndim: int,
|
|
32
|
+
lower_bound: tuple,
|
|
33
|
+
upper_bound: tuple,
|
|
34
|
+
num_gridpoints: int | tuple[int, ...],
|
|
35
|
+
objects: list[Shape] | None = None,
|
|
36
|
+
bcs_field: Any | None = None,
|
|
37
|
+
bcs_particles: Any | None = None,
|
|
38
|
+
comm: MPI.Comm | None = None,
|
|
39
|
+
domain_decomposition: ConfigValue = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Initialize the simulation domain with boundary conditions.
|
|
43
|
+
|
|
44
|
+
Parameters:
|
|
45
|
+
ndim (int): Number of dimensions (e.g., 1, 2, or 3).
|
|
46
|
+
bounds (list of tuples): List of tuples specifying min and max for each dimension.
|
|
47
|
+
bcs (dict): Dictionary specifying the boundary conditions for each dimension.
|
|
48
|
+
"""
|
|
49
|
+
# A periodic field axis has ``n`` cells over the length (its last node
|
|
50
|
+
# neighbours its first); the grid, and everything deposited on or
|
|
51
|
+
# gathered from it, must use that node layout.
|
|
52
|
+
axis_names = ["x", "y", "z"][:ndim]
|
|
53
|
+
periodic = tuple(
|
|
54
|
+
bcs_field is not None
|
|
55
|
+
and bcs_field.get_bc(dir=axis, side="min").bc_type == "periodic"
|
|
56
|
+
for axis in axis_names
|
|
57
|
+
)
|
|
58
|
+
super().__init__(
|
|
59
|
+
lower_bound=lower_bound,
|
|
60
|
+
upper_bound=upper_bound,
|
|
61
|
+
comm=comm,
|
|
62
|
+
domain_decomposition=domain_decomposition,
|
|
63
|
+
periodic=periodic,
|
|
64
|
+
)
|
|
65
|
+
if objects is None:
|
|
66
|
+
objects = []
|
|
67
|
+
self._objects = objects
|
|
68
|
+
self._dimensions = ["x", "y", "z"][: self.ndim]
|
|
69
|
+
self._bcs_field = bcs_field
|
|
70
|
+
self._bcs_particles = bcs_particles
|
|
71
|
+
|
|
72
|
+
if self.bcs_particles is not None:
|
|
73
|
+
for dim in self._dimensions:
|
|
74
|
+
for side in ("min", "max"):
|
|
75
|
+
bc_type = self.bcs_particles.get_bc(dir=dim, side=side).bc_type
|
|
76
|
+
if bc_type not in _PARTICLE_BC_TYPES:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"Unknown particle boundary {bc_type!r} on {dim} {side}; "
|
|
79
|
+
f"expected one of {_PARTICLE_BC_TYPES}.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if ndim == 1:
|
|
83
|
+
bounds_injection_zone = ((1e-3,), (5e-3,))
|
|
84
|
+
elif ndim == 2:
|
|
85
|
+
bounds_injection_zone = ((1e-3, 0e-3), (5e-3, 20e-3))
|
|
86
|
+
elif ndim == 3:
|
|
87
|
+
bounds_injection_zone = ((1e-3, 0e-3, 0e-3), (5e-3, 20e-3, 20e-3))
|
|
88
|
+
|
|
89
|
+
self.injection_zone = BoundingBox(
|
|
90
|
+
lower_bound=bounds_injection_zone[0],
|
|
91
|
+
upper_bound=bounds_injection_zone[1],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
self.grid = Grid(
|
|
95
|
+
ndim=ndim,
|
|
96
|
+
lower_bound=self.lower_bound,
|
|
97
|
+
upper_bound=upper_bound,
|
|
98
|
+
num_gridpoints=num_gridpoints,
|
|
99
|
+
comm=comm,
|
|
100
|
+
domain_decomposition=domain_decomposition,
|
|
101
|
+
periodic=periodic,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def objects(self) -> list[Shape]:
|
|
106
|
+
"""Return objects embedded in the domain."""
|
|
107
|
+
return self._objects
|
|
108
|
+
|
|
109
|
+
def plot(
|
|
110
|
+
self,
|
|
111
|
+
ax: Any = None,
|
|
112
|
+
show_grid: bool = False,
|
|
113
|
+
objects: bool = True,
|
|
114
|
+
**kwargs: Any,
|
|
115
|
+
) -> Any:
|
|
116
|
+
"""Draw the domain outline, its grid lines and its objects into ``ax``.
|
|
117
|
+
|
|
118
|
+
Keyword arguments style the outline (``color``, ``linewidth``, ...).
|
|
119
|
+
Returns the axes. Draw a field first with ``field.plot(ax=ax)``.
|
|
120
|
+
"""
|
|
121
|
+
if self.ndim == 3 or any(axis in kwargs for axis in ("x", "y", "z")):
|
|
122
|
+
from simgeometry.collection import GeometryCollection
|
|
123
|
+
|
|
124
|
+
geometry = (
|
|
125
|
+
self.geometry
|
|
126
|
+
if objects
|
|
127
|
+
else GeometryCollection({}, bounds=(self.lower_bound, self.upper_bound))
|
|
128
|
+
)
|
|
129
|
+
return geometry.show(
|
|
130
|
+
ax=ax, grid=self.grid if show_grid else None, display=False, **kwargs
|
|
131
|
+
)
|
|
132
|
+
from simgeometry.plotting import draw_domain
|
|
133
|
+
|
|
134
|
+
return draw_domain(self, ax=ax, show_grid=show_grid, objects=objects, **kwargs)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def geometry(self):
|
|
138
|
+
"""Named geometry view using the domain bounds and current objects."""
|
|
139
|
+
from simgeometry.collection import GeometryCollection
|
|
140
|
+
|
|
141
|
+
return GeometryCollection(
|
|
142
|
+
self.objects, bounds=(self.lower_bound, self.upper_bound)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def evaluate(self, *args, **kwargs):
|
|
146
|
+
"""Evaluate object labels on the domain grid or requested coordinates."""
|
|
147
|
+
if "grid" not in kwargs and "bounds" not in kwargs:
|
|
148
|
+
kwargs["grid"] = self.grid
|
|
149
|
+
return self.geometry.evaluate(*args, **kwargs)
|
|
150
|
+
|
|
151
|
+
def slice(self, **kwargs):
|
|
152
|
+
"""Return a queryable section through the domain objects."""
|
|
153
|
+
return self.geometry.slice(**kwargs)
|
|
154
|
+
|
|
155
|
+
def show(self, **kwargs):
|
|
156
|
+
"""Display objects, optionally with grid lines or a coordinate slice."""
|
|
157
|
+
if kwargs.get("grid") is True:
|
|
158
|
+
kwargs["grid"] = self.grid
|
|
159
|
+
return self.geometry.show(**kwargs)
|
|
160
|
+
|
|
161
|
+
def show_3d(self, **kwargs):
|
|
162
|
+
"""Display the domain objects as 3D surfaces."""
|
|
163
|
+
if kwargs.get("grid") is True:
|
|
164
|
+
kwargs["grid"] = self.grid
|
|
165
|
+
return self.geometry.show_3d(**kwargs)
|
|
166
|
+
|
|
167
|
+
def cut_edges(self, grid=None, **kwargs):
|
|
168
|
+
"""Geometry of object intersections with grid edges, without clamping."""
|
|
169
|
+
return self.geometry.cut_edges(self.grid if grid is None else grid, **kwargs)
|
|
170
|
+
|
|
171
|
+
def diagnose(self, grid=None, **kwargs):
|
|
172
|
+
"""Inspect object overlaps and resolution on the domain grid."""
|
|
173
|
+
return self.geometry.diagnose(self.grid if grid is None else grid, **kwargs)
|
|
174
|
+
|
|
175
|
+
def cell_fractions(self, grid=None, **kwargs):
|
|
176
|
+
"""Approximate solid volume and face-area fractions on the domain grid."""
|
|
177
|
+
return self.geometry.cell_fractions(
|
|
178
|
+
self.grid if grid is None else grid, **kwargs
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def bcs_field(self) -> Any | None:
|
|
183
|
+
"""Return field boundary conditions."""
|
|
184
|
+
return self._bcs_field
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def bcs_particles(self) -> Any | None:
|
|
188
|
+
"""Return particle boundary conditions."""
|
|
189
|
+
return self._bcs_particles
|
|
190
|
+
|
|
191
|
+
def particle_boundary_type(
|
|
192
|
+
self,
|
|
193
|
+
dir: int,
|
|
194
|
+
side: str,
|
|
195
|
+
particle_bcs: Any | None = None,
|
|
196
|
+
) -> str:
|
|
197
|
+
"""Return the particle boundary type for one axis side."""
|
|
198
|
+
particle_bcs = particle_bcs or self.bcs_particles
|
|
199
|
+
if particle_bcs is None:
|
|
200
|
+
raise ValueError("No particle boundary conditions are configured")
|
|
201
|
+
return particle_bcs.get_bc(
|
|
202
|
+
dir=self._dimensions[dir],
|
|
203
|
+
side=side,
|
|
204
|
+
).bc_type
|
|
205
|
+
|
|
206
|
+
def particle_boundary_codes(
|
|
207
|
+
self, particle_bcs: Any | None = None
|
|
208
|
+
) -> tuple[Array, Array]:
|
|
209
|
+
"""The kernel codes of the lower and upper side of every axis (int64 arrays).
|
|
210
|
+
|
|
211
|
+
``PARTICLE_BOUNDARY_CODES``: 0 kill, 1 reflect, 2 periodic, 3 reinject,
|
|
212
|
+
as ``apply_particle_boundaries`` and the fused pushes take them.
|
|
213
|
+
"""
|
|
214
|
+
return tuple(
|
|
215
|
+
xp.asarray(
|
|
216
|
+
[
|
|
217
|
+
PARTICLE_BOUNDARY_CODES[
|
|
218
|
+
self.particle_boundary_type(axis, side, particle_bcs)
|
|
219
|
+
]
|
|
220
|
+
for axis in range(self.ndim)
|
|
221
|
+
],
|
|
222
|
+
dtype=xp.int64,
|
|
223
|
+
)
|
|
224
|
+
for side in ("min", "max")
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _wrap_periodic(values: Array, lower: float, upper: float) -> Array:
|
|
229
|
+
"""Wrap ``values`` into ``[lower, upper)``.
|
|
230
|
+
|
|
231
|
+
The domain holds ``lower <= x < upper``. The modulo can round a
|
|
232
|
+
value just below ``lower`` up to exactly ``upper``, which would then
|
|
233
|
+
count as outside; such a value is the periodic image of ``lower``.
|
|
234
|
+
"""
|
|
235
|
+
wrapped = lower + (values - lower) % (upper - lower)
|
|
236
|
+
return xp.where(wrapped >= upper, lower, wrapped)
|
|
237
|
+
|
|
238
|
+
def apply_bc_arrays(
|
|
239
|
+
self,
|
|
240
|
+
positions: Array,
|
|
241
|
+
velocities: Array,
|
|
242
|
+
alives: ConfigValue,
|
|
243
|
+
particle_bcs: Any | None = None,
|
|
244
|
+
) -> Array:
|
|
245
|
+
"""Apply particle boundaries and return markers reinjected this push.
|
|
246
|
+
|
|
247
|
+
``positions`` is ``(ndim, N)`` and ``velocities`` ``(nvel, N)``. One
|
|
248
|
+
pass of the ``apply_particle_boundaries`` kernel reflects, wraps or
|
|
249
|
+
kills the markers in place; markers crossing a reinject side get a new
|
|
250
|
+
position from the injection zone here.
|
|
251
|
+
"""
|
|
252
|
+
particle_bcs = particle_bcs or self.bcs_particles
|
|
253
|
+
assert positions.shape[0] == self.ndim, (
|
|
254
|
+
f"Expected positions with shape ({self.ndim}, N), got {positions.shape}"
|
|
255
|
+
)
|
|
256
|
+
num_markers = positions.shape[1]
|
|
257
|
+
if particle_bcs is None or num_markers == 0:
|
|
258
|
+
return xp.zeros(num_markers, dtype=bool)
|
|
259
|
+
|
|
260
|
+
lower_codes, upper_codes = self.particle_boundary_codes(particle_bcs)
|
|
261
|
+
reinjected = xp.zeros(num_markers, dtype=bool)
|
|
262
|
+
self.boundary_kernel(
|
|
263
|
+
positions,
|
|
264
|
+
velocities,
|
|
265
|
+
alives,
|
|
266
|
+
reinjected,
|
|
267
|
+
xp.asarray(self.lower_bound, dtype=float),
|
|
268
|
+
xp.asarray(self.upper_bound, dtype=float),
|
|
269
|
+
lower_codes,
|
|
270
|
+
upper_codes,
|
|
271
|
+
)
|
|
272
|
+
num_reinjected = int(xp.count_nonzero(reinjected))
|
|
273
|
+
if num_reinjected > 0:
|
|
274
|
+
positions[:, reinjected] = self.injection_zone.draw_random_positions(
|
|
275
|
+
num_reinjected,
|
|
276
|
+
)
|
|
277
|
+
return reinjected
|
simgeometry/utils.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Utils helpers for geometry, grids, and simulation domains."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from numpy import ndarray
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def vertices_to_bounds(
|
|
9
|
+
vertices: ndarray,
|
|
10
|
+
) -> tuple[tuple[float, float], tuple[float, float]]:
|
|
11
|
+
"""
|
|
12
|
+
Convert a list of vertices to bounding box coordinates.
|
|
13
|
+
|
|
14
|
+
Parameters:
|
|
15
|
+
vertices (ndarray): Array of shape (N, 2) representing N vertices in 2D.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Tuple[Tuple[float, float], Tuple[float, float]]: Lower and upper bounds as tuples.
|
|
19
|
+
"""
|
|
20
|
+
xs = vertices[:, 0]
|
|
21
|
+
ys = vertices[:, 1]
|
|
22
|
+
lower_bound = (float(xs.min()), float(ys.min()))
|
|
23
|
+
upper_bound = (float(xs.max()), float(ys.max()))
|
|
24
|
+
return lower_bound, upper_bound
|