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.
Files changed (62) hide show
  1. simgeometry/__init__.py +38 -0
  2. simgeometry/_bounds.py +16 -0
  3. simgeometry/_normalize.py +23 -0
  4. simgeometry/_particle_boundaries.py +37 -0
  5. simgeometry/_types.py +22 -0
  6. simgeometry/api.py +247 -0
  7. simgeometry/boundingbox.py +231 -0
  8. simgeometry/collection.py +65 -0
  9. simgeometry/crossings.py +319 -0
  10. simgeometry/cut_edges.py +309 -0
  11. simgeometry/cylindrical.py +43 -0
  12. simgeometry/domain_decomposition.py +496 -0
  13. simgeometry/evaluation.py +105 -0
  14. simgeometry/fractions.py +68 -0
  15. simgeometry/grid.py +238 -0
  16. simgeometry/kernels/__init__.py +1 -0
  17. simgeometry/kernels/_build.py +53 -0
  18. simgeometry/kernels/_metal.py +41 -0
  19. simgeometry/kernels/compile.py +24 -0
  20. simgeometry/kernels/contains_ball/__init__.py +12 -0
  21. simgeometry/kernels/contains_ball/contains_ball_cuda.cu +27 -0
  22. simgeometry/kernels/contains_ball/contains_ball_metal.py +53 -0
  23. simgeometry/kernels/contains_ball/contains_ball_numpy.py +23 -0
  24. simgeometry/kernels/contains_ball/contains_ball_pyccel.py +31 -0
  25. simgeometry/kernels/contains_box/__init__.py +12 -0
  26. simgeometry/kernels/contains_box/contains_box_cuda.cu +26 -0
  27. simgeometry/kernels/contains_box/contains_box_metal.py +50 -0
  28. simgeometry/kernels/contains_box/contains_box_numpy.py +24 -0
  29. simgeometry/kernels/contains_box/contains_box_pyccel.py +32 -0
  30. simgeometry/kernels/contains_polygon_2d/__init__.py +12 -0
  31. simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_cuda.cu +35 -0
  32. simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_metal.py +58 -0
  33. simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_numpy.py +28 -0
  34. simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_pyccel.py +40 -0
  35. simgeometry/mesh.py +147 -0
  36. simgeometry/objects/__init__.py +35 -0
  37. simgeometry/objects/aperture2d.py +183 -0
  38. simgeometry/objects/aperture3d.py +361 -0
  39. simgeometry/objects/circle.py +138 -0
  40. simgeometry/objects/cuboid.py +173 -0
  41. simgeometry/objects/function_object.py +214 -0
  42. simgeometry/objects/line.py +127 -0
  43. simgeometry/objects/polygon.py +182 -0
  44. simgeometry/objects/polyline.py +67 -0
  45. simgeometry/objects/rectangle.py +130 -0
  46. simgeometry/objects/sphere.py +181 -0
  47. simgeometry/objects/triangle.py +148 -0
  48. simgeometry/operations.py +386 -0
  49. simgeometry/panels.py +80 -0
  50. simgeometry/plotting.py +179 -0
  51. simgeometry/py.typed +0 -0
  52. simgeometry/queries.py +445 -0
  53. simgeometry/sampling.py +75 -0
  54. simgeometry/shapes.py +402 -0
  55. simgeometry/simulation_domain.py +277 -0
  56. simgeometry/utils.py +24 -0
  57. simgeometry/visualization.py +381 -0
  58. simgeometry-0.1.0.dist-info/METADATA +128 -0
  59. simgeometry-0.1.0.dist-info/RECORD +62 -0
  60. simgeometry-0.1.0.dist-info/WHEEL +5 -0
  61. simgeometry-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
  62. 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