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
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Process grid of the MPI ranks and the physical subdomain each one owns.
|
|
2
|
+
|
|
3
|
+
The process grid decides which rank owns which part of the domain. It is used for
|
|
4
|
+
particles (:meth:`DomainDecomposition.owner_rank_for_position`) and, through
|
|
5
|
+
:meth:`DomainDecomposition.layout`, for the :class:`mpiarray.DistributedArray` of the
|
|
6
|
+
fields, so that markers and fields agree on who owns what.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
import mpiarray as mpa
|
|
15
|
+
import numpy as np
|
|
16
|
+
from maybempi import MPI
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DomainDecomposition:
|
|
20
|
+
"""Represent the MPI process grid of a decomposed domain."""
|
|
21
|
+
|
|
22
|
+
def _calculate_neighbor_ranks(self) -> list[tuple[int, int]]:
|
|
23
|
+
"""
|
|
24
|
+
Given a Cartesian decomposition of MPI ranks and your own rank,
|
|
25
|
+
compute the neighbor ranks in each dimension.
|
|
26
|
+
|
|
27
|
+
Returns
|
|
28
|
+
-------
|
|
29
|
+
neighbor_ranks : List[Tuple[int, int]]
|
|
30
|
+
For each dim i, a tuple (left_rank, right_rank). If you're at the
|
|
31
|
+
boundary on the left (resp. right), left_rank (resp. right_rank)
|
|
32
|
+
is MPI.PROC_NULL.
|
|
33
|
+
"""
|
|
34
|
+
proc_sizes = self.proc_sizes
|
|
35
|
+
rank = self.mpi_rank
|
|
36
|
+
|
|
37
|
+
ndim = len(proc_sizes)
|
|
38
|
+
# compute strides so that flat_rank = sum(coords[i] * strides[i])
|
|
39
|
+
strides = [math.prod(proc_sizes[i + 1 :]) for i in range(ndim)]
|
|
40
|
+
|
|
41
|
+
# decode flat rank → multi‐dim coords
|
|
42
|
+
coords = []
|
|
43
|
+
rem = rank
|
|
44
|
+
for i in range(ndim):
|
|
45
|
+
stride = strides[i]
|
|
46
|
+
c = rem // stride
|
|
47
|
+
coords.append(c)
|
|
48
|
+
rem %= stride
|
|
49
|
+
|
|
50
|
+
def rank_from_coords(process_coords: list[int]) -> int:
|
|
51
|
+
"""Convert process coordinates to a flat MPI rank."""
|
|
52
|
+
return sum(process_coords[j] * strides[j] for j in range(ndim))
|
|
53
|
+
|
|
54
|
+
neighbors = []
|
|
55
|
+
for i in range(ndim):
|
|
56
|
+
# LEFT neighbor
|
|
57
|
+
if coords[i] > 0:
|
|
58
|
+
left_coords = coords.copy()
|
|
59
|
+
left_coords[i] -= 1
|
|
60
|
+
left_rank = rank_from_coords(left_coords)
|
|
61
|
+
else:
|
|
62
|
+
if self.periodic[i]:
|
|
63
|
+
left_coords = coords.copy()
|
|
64
|
+
left_coords[i] = proc_sizes[i] - 1
|
|
65
|
+
left_rank = rank_from_coords(left_coords)
|
|
66
|
+
else:
|
|
67
|
+
left_rank = MPI.PROC_NULL
|
|
68
|
+
|
|
69
|
+
# RIGHT neighbor
|
|
70
|
+
if coords[i] < proc_sizes[i] - 1:
|
|
71
|
+
right_coords = coords.copy()
|
|
72
|
+
right_coords[i] += 1
|
|
73
|
+
right_rank = rank_from_coords(right_coords)
|
|
74
|
+
else:
|
|
75
|
+
if self.periodic[i]:
|
|
76
|
+
right_coords = coords.copy()
|
|
77
|
+
right_coords[i] = 0
|
|
78
|
+
right_rank = rank_from_coords(right_coords)
|
|
79
|
+
else:
|
|
80
|
+
right_rank = MPI.PROC_NULL
|
|
81
|
+
|
|
82
|
+
neighbors.append((left_rank, right_rank))
|
|
83
|
+
|
|
84
|
+
return neighbors
|
|
85
|
+
|
|
86
|
+
def _calculate_proc_sizes(self) -> list[int]:
|
|
87
|
+
"""
|
|
88
|
+
Calculate the optimal number of processes in each dimension based on the
|
|
89
|
+
total number of processes and the domain decomposition flags.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
size: Total number of MPI processes.
|
|
93
|
+
domain_decomposition: List of booleans indicating whether to decompose
|
|
94
|
+
along each dimension (e.g., [True, True, False]
|
|
95
|
+
for decomposing in x and y but not z in 3D).
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
proc_sizes: List of integers indicating the number of processes in each
|
|
99
|
+
dimension, matching the length of domain_decomposition.
|
|
100
|
+
For example: [2, 6, 1] for 12 processes in a 2x6 grid in 3D.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: If the size cannot be evenly distributed among any set of requested dimensions.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
size = self.mpi_size
|
|
107
|
+
domain_decomposition = self.decompose
|
|
108
|
+
|
|
109
|
+
ndim = self.ndim
|
|
110
|
+
proc_sizes = [1] * ndim # Initialize proc_sizes with 1 in each dimension
|
|
111
|
+
|
|
112
|
+
# Priority Decomposition: Start with all requested dimensions
|
|
113
|
+
active_decomposition = domain_decomposition[:]
|
|
114
|
+
|
|
115
|
+
while True:
|
|
116
|
+
# Calculate the target number of processes per active dimension
|
|
117
|
+
decompose_dims = sum(active_decomposition)
|
|
118
|
+
|
|
119
|
+
if decompose_dims == 0:
|
|
120
|
+
# No decomposable dimensions left, fallback to 1D decomposition
|
|
121
|
+
proc_sizes = [1] * ndim
|
|
122
|
+
for i in range(ndim):
|
|
123
|
+
if domain_decomposition[i]:
|
|
124
|
+
proc_sizes[i] = size
|
|
125
|
+
break
|
|
126
|
+
return proc_sizes
|
|
127
|
+
|
|
128
|
+
# Reset proc_sizes and remaining_size for current attempt
|
|
129
|
+
proc_sizes = [1] * ndim
|
|
130
|
+
remaining_size = size
|
|
131
|
+
|
|
132
|
+
for i in range(ndim):
|
|
133
|
+
if active_decomposition[i]:
|
|
134
|
+
# Calculate the approximate target number of processes in this dimension
|
|
135
|
+
target_procs = round(remaining_size ** (1 / decompose_dims))
|
|
136
|
+
|
|
137
|
+
# Adjust target_procs to be a divisor of the remaining_size
|
|
138
|
+
while remaining_size % target_procs != 0 and target_procs > 1:
|
|
139
|
+
target_procs -= 1
|
|
140
|
+
|
|
141
|
+
# Assign the calculated target_procs to this dimension
|
|
142
|
+
proc_sizes[i] = target_procs
|
|
143
|
+
remaining_size //= target_procs
|
|
144
|
+
decompose_dims -= 1
|
|
145
|
+
|
|
146
|
+
# Check if the decomposition works
|
|
147
|
+
product_of_procs = math.prod(
|
|
148
|
+
[proc_sizes[i] for i in range(ndim) if active_decomposition[i]],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if product_of_procs == size:
|
|
152
|
+
return proc_sizes
|
|
153
|
+
|
|
154
|
+
# If not, disable the last dimension in active_decomposition and try again
|
|
155
|
+
for j in reversed(range(ndim)):
|
|
156
|
+
if active_decomposition[j]:
|
|
157
|
+
active_decomposition[j] = False
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
comm: MPI.Comm | None,
|
|
163
|
+
decompose: list[bool] | None = None,
|
|
164
|
+
periodic: tuple[bool, ...] | None = None,
|
|
165
|
+
ndim: int | None = None,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Initialize the process grid of ``comm`` for the decomposed axes."""
|
|
168
|
+
self._comm = comm
|
|
169
|
+
if self.comm is None:
|
|
170
|
+
self._mpi_size = 1
|
|
171
|
+
self._mpi_rank = 0
|
|
172
|
+
else:
|
|
173
|
+
self._mpi_size = comm.Get_size()
|
|
174
|
+
self._mpi_rank = comm.Get_rank()
|
|
175
|
+
|
|
176
|
+
assert ndim is not None or decompose is not None, (
|
|
177
|
+
"DomainDecomposition must take either ndim or decompose!"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if ndim is None:
|
|
181
|
+
ndim = len(decompose)
|
|
182
|
+
self._ndim = ndim
|
|
183
|
+
|
|
184
|
+
if decompose is None:
|
|
185
|
+
self._decompose = [True] * self.ndim
|
|
186
|
+
else:
|
|
187
|
+
self._decompose = list(decompose)
|
|
188
|
+
assert len(self.decompose) == self.ndim
|
|
189
|
+
|
|
190
|
+
if periodic is None:
|
|
191
|
+
periodic = [False] * self.ndim
|
|
192
|
+
self._periodic = tuple(periodic)
|
|
193
|
+
assert len(self.periodic) == self.ndim
|
|
194
|
+
|
|
195
|
+
self._proc_sizes = self._calculate_proc_sizes()
|
|
196
|
+
|
|
197
|
+
# With no decomposed axis the process grid has a single slot and every
|
|
198
|
+
# rank holds the whole domain; all ranks then share process coordinate 0.
|
|
199
|
+
self._replicated = self.mpi_size > 1 and math.prod(self.proc_sizes) == 1
|
|
200
|
+
|
|
201
|
+
# Ranks are placed row-major on the process grid, as mpiarray does.
|
|
202
|
+
if self.replicated:
|
|
203
|
+
self._neighbour_ranks = [
|
|
204
|
+
(
|
|
205
|
+
(self.mpi_rank, self.mpi_rank)
|
|
206
|
+
if is_periodic
|
|
207
|
+
else (MPI.PROC_NULL, MPI.PROC_NULL)
|
|
208
|
+
)
|
|
209
|
+
for is_periodic in self.periodic
|
|
210
|
+
]
|
|
211
|
+
else:
|
|
212
|
+
self._neighbour_ranks = self._calculate_neighbor_ranks()
|
|
213
|
+
|
|
214
|
+
def layout(
|
|
215
|
+
self,
|
|
216
|
+
shape: tuple[int, ...],
|
|
217
|
+
halo: int | Sequence[int] = 0,
|
|
218
|
+
periodic: Sequence[bool] | None = None,
|
|
219
|
+
) -> mpa.Layout:
|
|
220
|
+
"""Return the :class:`mpiarray.Layout` of an array of ``shape`` on this process grid.
|
|
221
|
+
|
|
222
|
+
Axes after ``ndim`` (for example the component axis of a field) are not
|
|
223
|
+
split. ``periodic`` defaults to this decomposition's flags, padded with
|
|
224
|
+
``False`` for the extra axes.
|
|
225
|
+
"""
|
|
226
|
+
extra = len(shape) - self.ndim
|
|
227
|
+
if extra < 0:
|
|
228
|
+
raise ValueError(f"shape {shape} has fewer than {self.ndim} axes")
|
|
229
|
+
if periodic is None:
|
|
230
|
+
periodic = (*self.periodic, *([False] * extra))
|
|
231
|
+
comm = MPI.COMM_SELF if self.comm is None else self.comm
|
|
232
|
+
if self.replicated:
|
|
233
|
+
# No axis is split: every rank holds the whole array.
|
|
234
|
+
return mpa.Layout(
|
|
235
|
+
shape, comm=comm, split=None, halo=halo, periodic=periodic
|
|
236
|
+
)
|
|
237
|
+
return mpa.Layout(
|
|
238
|
+
shape,
|
|
239
|
+
comm=comm,
|
|
240
|
+
halo=halo,
|
|
241
|
+
periodic=periodic,
|
|
242
|
+
process_grid=(*self.proc_sizes, *([1] * extra)),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def node_cuts(self, shape: Sequence[int]) -> tuple[tuple[int, ...] | None, ...]:
|
|
246
|
+
"""Return where the blocks of a grid of ``shape`` nodes start and end, per axis.
|
|
247
|
+
|
|
248
|
+
``(0, c1, ..., n)`` along a split axis (process coordinate ``c`` owns
|
|
249
|
+
nodes ``cuts[c] <= i < cuts[c + 1]``), ``None`` along the others. This
|
|
250
|
+
is the one split of the grid: the field arrays (:meth:`layout`), the
|
|
251
|
+
solver's matrix rows and the markers (:meth:`subdomain_edges`) all
|
|
252
|
+
follow it.
|
|
253
|
+
"""
|
|
254
|
+
if len(shape) != self.ndim:
|
|
255
|
+
raise ValueError(f"Expected a shape with {self.ndim} axes, got {shape}")
|
|
256
|
+
cuts: list[tuple[int, ...] | None] = []
|
|
257
|
+
for length, num_procs in zip(shape, self.proc_sizes, strict=True):
|
|
258
|
+
if num_procs == 1 or self.replicated:
|
|
259
|
+
cuts.append(None)
|
|
260
|
+
continue
|
|
261
|
+
starts = [
|
|
262
|
+
mpa.chunk_bounds(int(length), num_procs, c)[0] for c in range(num_procs)
|
|
263
|
+
]
|
|
264
|
+
cuts.append((*starts, int(length)))
|
|
265
|
+
return tuple(cuts)
|
|
266
|
+
|
|
267
|
+
def get_proc_coord(self, rank: int) -> tuple[int, ...]:
|
|
268
|
+
"""Return process coordinates for one rank."""
|
|
269
|
+
if self.replicated:
|
|
270
|
+
rank = 0
|
|
271
|
+
coords = []
|
|
272
|
+
for size in reversed(self.proc_sizes):
|
|
273
|
+
coords.append(rank % size)
|
|
274
|
+
rank //= size
|
|
275
|
+
return tuple(reversed(coords))
|
|
276
|
+
|
|
277
|
+
def rank_from_proc_coord(self, proc_coord: tuple[int, ...]) -> int:
|
|
278
|
+
"""Return the flat MPI rank for process coordinates."""
|
|
279
|
+
if len(proc_coord) != self.ndim:
|
|
280
|
+
raise ValueError(f"Expected {self.ndim} process coordinates")
|
|
281
|
+
strides = [math.prod(self.proc_sizes[i + 1 :]) for i in range(self.ndim)]
|
|
282
|
+
return int(sum(proc_coord[i] * strides[i] for i in range(self.ndim)))
|
|
283
|
+
|
|
284
|
+
def get_index_bounds(
|
|
285
|
+
self,
|
|
286
|
+
shape: tuple[int, ...],
|
|
287
|
+
rank: int | None = None,
|
|
288
|
+
) -> list[tuple[int, int]]:
|
|
289
|
+
"""Return global index bounds for one rank over a decomposed shape."""
|
|
290
|
+
if rank is None:
|
|
291
|
+
rank = self.mpi_rank
|
|
292
|
+
if len(shape) != self.ndim:
|
|
293
|
+
raise ValueError(f"Expected shape with {self.ndim} dimensions")
|
|
294
|
+
coord = self.get_proc_coord(rank)
|
|
295
|
+
cuts = self.node_cuts(shape)
|
|
296
|
+
return [
|
|
297
|
+
(0, int(length)) if axis_cuts is None else axis_cuts[c : c + 2]
|
|
298
|
+
for length, axis_cuts, c in zip(shape, cuts, coord, strict=True)
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
def subdomain_edges(
|
|
302
|
+
self, dim: int, lower: float, upper: float, num_nodes: int
|
|
303
|
+
) -> list[float]:
|
|
304
|
+
"""Return the ``proc_sizes[dim] + 1`` edges of the subdomains along one axis.
|
|
305
|
+
|
|
306
|
+
The edges sit on the nodes where :meth:`node_cuts` splits the
|
|
307
|
+
``num_nodes`` nodes of the axis (``n - 1`` cells over ``[lower,
|
|
308
|
+
upper]``, ``n`` on a periodic axis). Process coordinate ``c`` owns
|
|
309
|
+
``[edges[c], edges[c + 1])``, the cells whose lower node it owns; the
|
|
310
|
+
last one also owns ``upper``. This is the split
|
|
311
|
+
``owner_rank_for_position`` uses.
|
|
312
|
+
"""
|
|
313
|
+
num_procs = self.proc_sizes[dim]
|
|
314
|
+
if num_procs == 1 or self.replicated:
|
|
315
|
+
return [lower, upper]
|
|
316
|
+
cells = num_nodes if self.periodic[dim] else num_nodes - 1
|
|
317
|
+
spacing = (upper - lower) / cells
|
|
318
|
+
starts = [
|
|
319
|
+
mpa.chunk_bounds(int(num_nodes), num_procs, c)[0] for c in range(num_procs)
|
|
320
|
+
]
|
|
321
|
+
return [lower + start * spacing for start in starts] + [upper]
|
|
322
|
+
|
|
323
|
+
def subdomain_bounds(
|
|
324
|
+
self,
|
|
325
|
+
rank: int,
|
|
326
|
+
lower_bound: tuple[float, ...],
|
|
327
|
+
upper_bound: tuple[float, ...],
|
|
328
|
+
num_gridpoints: tuple[int, ...],
|
|
329
|
+
) -> tuple[tuple[float, ...], tuple[float, ...]]:
|
|
330
|
+
"""Return the physical ``(lower, upper)`` corners of one rank's subdomain."""
|
|
331
|
+
proc_coord = self.get_proc_coord(rank)
|
|
332
|
+
lower = []
|
|
333
|
+
upper = []
|
|
334
|
+
for dim in range(self.ndim):
|
|
335
|
+
edges = self.subdomain_edges(
|
|
336
|
+
dim,
|
|
337
|
+
float(lower_bound[dim]),
|
|
338
|
+
float(upper_bound[dim]),
|
|
339
|
+
int(num_gridpoints[dim]),
|
|
340
|
+
)
|
|
341
|
+
lower.append(edges[proc_coord[dim]])
|
|
342
|
+
upper.append(edges[proc_coord[dim] + 1])
|
|
343
|
+
return tuple(lower), tuple(upper)
|
|
344
|
+
|
|
345
|
+
def owner_rank_for_position(
|
|
346
|
+
self,
|
|
347
|
+
position: Sequence[float] | np.ndarray,
|
|
348
|
+
lower_bound: tuple[float, ...],
|
|
349
|
+
upper_bound: tuple[float, ...],
|
|
350
|
+
num_gridpoints: tuple[int, ...],
|
|
351
|
+
) -> int:
|
|
352
|
+
"""Return the rank owning a physical position in a decomposed grid."""
|
|
353
|
+
if len(lower_bound) != self.ndim or len(upper_bound) != self.ndim:
|
|
354
|
+
raise ValueError("Bounds must match decomposition dimensions")
|
|
355
|
+
if len(num_gridpoints) != self.ndim:
|
|
356
|
+
raise ValueError("num_gridpoints must match decomposition dimensions")
|
|
357
|
+
|
|
358
|
+
proc_coord = []
|
|
359
|
+
for dim, (lower, upper, num_procs) in enumerate(
|
|
360
|
+
zip(
|
|
361
|
+
lower_bound,
|
|
362
|
+
upper_bound,
|
|
363
|
+
self.proc_sizes,
|
|
364
|
+
),
|
|
365
|
+
):
|
|
366
|
+
coord = num_procs - 1
|
|
367
|
+
value = float(position[dim])
|
|
368
|
+
edges = self.subdomain_edges(
|
|
369
|
+
dim, float(lower), float(upper), int(num_gridpoints[dim])
|
|
370
|
+
)
|
|
371
|
+
for candidate_coord in range(num_procs):
|
|
372
|
+
subdomain_lower = edges[candidate_coord]
|
|
373
|
+
subdomain_upper = edges[candidate_coord + 1]
|
|
374
|
+
if subdomain_lower <= value < subdomain_upper or (
|
|
375
|
+
candidate_coord == num_procs - 1 and value == subdomain_upper
|
|
376
|
+
):
|
|
377
|
+
coord = candidate_coord
|
|
378
|
+
break
|
|
379
|
+
proc_coord.append(coord)
|
|
380
|
+
return self.rank_from_proc_coord(tuple(proc_coord))
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def comm(self) -> MPI.Comm | None:
|
|
384
|
+
"""Return the MPI communicator."""
|
|
385
|
+
return self._comm
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def mpi_size(self) -> int:
|
|
389
|
+
"""Return the MPI size."""
|
|
390
|
+
return self._mpi_size
|
|
391
|
+
|
|
392
|
+
@property
|
|
393
|
+
def mpi_rank(self) -> int:
|
|
394
|
+
"""Return the MPI rank."""
|
|
395
|
+
return self._mpi_rank
|
|
396
|
+
|
|
397
|
+
@property
|
|
398
|
+
def ndim(self) -> int:
|
|
399
|
+
"""Return the number of spatial dimensions."""
|
|
400
|
+
return self._ndim
|
|
401
|
+
|
|
402
|
+
@property
|
|
403
|
+
def decompose(self) -> list[bool]:
|
|
404
|
+
"""Return flags indicating decomposed axes."""
|
|
405
|
+
return self._decompose
|
|
406
|
+
|
|
407
|
+
@property
|
|
408
|
+
def replicated(self) -> bool:
|
|
409
|
+
"""Return whether every rank holds the whole domain (no decomposed axis)."""
|
|
410
|
+
return self._replicated
|
|
411
|
+
|
|
412
|
+
@property
|
|
413
|
+
def proc_sizes(self) -> list[int]:
|
|
414
|
+
"""Return process counts along each axis."""
|
|
415
|
+
return self._proc_sizes
|
|
416
|
+
|
|
417
|
+
@property
|
|
418
|
+
def proc_coord(self) -> tuple[int, ...]:
|
|
419
|
+
"""Return this rank process coordinates."""
|
|
420
|
+
return self.get_proc_coord(self.mpi_rank)
|
|
421
|
+
|
|
422
|
+
@property
|
|
423
|
+
def neighbour_ranks(self) -> list[tuple[int, int]]:
|
|
424
|
+
"""Return neighboring MPI ranks."""
|
|
425
|
+
return self._neighbour_ranks
|
|
426
|
+
|
|
427
|
+
@property
|
|
428
|
+
def periodic(self) -> tuple[bool, ...]:
|
|
429
|
+
"""Return periodicity flags for each axis."""
|
|
430
|
+
return self._periodic
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def calculate_proc_sizes(size: int, domain_decomposition: list[bool]) -> list[int]:
|
|
434
|
+
"""
|
|
435
|
+
Calculate the optimal number of processes in each dimension based on the
|
|
436
|
+
total number of processes and the domain decomposition flags.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
size: Total number of MPI processes. (e.g., [True, True, False])
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
proc_sizes: Number of processes in each dimension, (e.g., [2, 6, 1])
|
|
443
|
+
|
|
444
|
+
Raises:
|
|
445
|
+
ValueError: If the size cannot be evenly distributed among any set of requested dimensions.
|
|
446
|
+
"""
|
|
447
|
+
ndim = len(domain_decomposition)
|
|
448
|
+
proc_sizes = [1] * ndim # Initialize proc_sizes with 1 in each dimension
|
|
449
|
+
|
|
450
|
+
# Priority Decomposition: Start with all requested dimensions
|
|
451
|
+
active_decomposition = domain_decomposition[:]
|
|
452
|
+
|
|
453
|
+
while True:
|
|
454
|
+
# Calculate the target number of processes per active dimension
|
|
455
|
+
decompose_dims = sum(active_decomposition)
|
|
456
|
+
|
|
457
|
+
if decompose_dims == 0:
|
|
458
|
+
# No decomposable dimensions left, fallback to 1D decomposition
|
|
459
|
+
proc_sizes = [1] * ndim
|
|
460
|
+
for i in range(ndim):
|
|
461
|
+
if domain_decomposition[i]:
|
|
462
|
+
proc_sizes[i] = size
|
|
463
|
+
break
|
|
464
|
+
return proc_sizes
|
|
465
|
+
|
|
466
|
+
# Reset proc_sizes and remaining_size for current attempt
|
|
467
|
+
proc_sizes = [1] * ndim
|
|
468
|
+
remaining_size = size
|
|
469
|
+
|
|
470
|
+
for i in range(ndim):
|
|
471
|
+
if active_decomposition[i]:
|
|
472
|
+
# Calculate the approximate target number of processes in this dimension
|
|
473
|
+
target_procs = round(remaining_size ** (1 / decompose_dims))
|
|
474
|
+
|
|
475
|
+
# Adjust target_procs to be a divisor of the remaining_size
|
|
476
|
+
while remaining_size % target_procs != 0 and target_procs > 1:
|
|
477
|
+
target_procs -= 1
|
|
478
|
+
|
|
479
|
+
# Assign the calculated target_procs to this dimension
|
|
480
|
+
proc_sizes[i] = target_procs
|
|
481
|
+
remaining_size //= target_procs
|
|
482
|
+
decompose_dims -= 1
|
|
483
|
+
|
|
484
|
+
# Check if the decomposition works
|
|
485
|
+
product_of_procs = math.prod(
|
|
486
|
+
[proc_sizes[i] for i in range(ndim) if active_decomposition[i]],
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
if product_of_procs == size:
|
|
490
|
+
return proc_sizes
|
|
491
|
+
|
|
492
|
+
# If not, disable the last dimension in active_decomposition and try again
|
|
493
|
+
for j in reversed(range(ndim)):
|
|
494
|
+
if active_decomposition[j]:
|
|
495
|
+
active_decomposition[j] = False
|
|
496
|
+
break
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Labelled coordinate sampling, with no dependency on Picnic fields."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from simgeometry.operations import mask
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def sampling_axes(shape, resolution=128, bounds=None):
|
|
11
|
+
"""Return finite axes over the requested bounds."""
|
|
12
|
+
lower, upper = (
|
|
13
|
+
bounds if bounds is not None else (shape.lower_bound, shape.upper_bound)
|
|
14
|
+
)
|
|
15
|
+
lower, upper = np.asarray(lower, float), np.asarray(upper, float)
|
|
16
|
+
counts = np.broadcast_to(resolution, (shape.ndim,))
|
|
17
|
+
if (
|
|
18
|
+
lower.shape != (shape.ndim,)
|
|
19
|
+
or upper.shape != lower.shape
|
|
20
|
+
or not np.all(np.isfinite([lower, upper]))
|
|
21
|
+
or np.any(upper <= lower)
|
|
22
|
+
):
|
|
23
|
+
raise ValueError("Sampling requires finite, increasing bounds for every axis")
|
|
24
|
+
if np.any(counts < 2) or np.any(counts != counts.astype(int)):
|
|
25
|
+
raise ValueError("Resolution must be an integer >= 2 for each axis")
|
|
26
|
+
return [np.linspace(a, b, int(n)) for a, b, n in zip(lower, upper, counts)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def evaluate(
|
|
30
|
+
shape,
|
|
31
|
+
*,
|
|
32
|
+
x=None,
|
|
33
|
+
y=None,
|
|
34
|
+
z=None,
|
|
35
|
+
grid=None,
|
|
36
|
+
bounds=None,
|
|
37
|
+
resolution=128,
|
|
38
|
+
quantity="occupancy",
|
|
39
|
+
):
|
|
40
|
+
try:
|
|
41
|
+
import xarray as xr
|
|
42
|
+
except ImportError as exc:
|
|
43
|
+
raise ImportError("evaluate() requires simgeometry[evaluate]") from exc
|
|
44
|
+
names = getattr(shape, "axis_names", tuple("xyz"[: shape.ndim]))
|
|
45
|
+
requested = dict(zip("xyz", (x, y, z)))
|
|
46
|
+
if any(
|
|
47
|
+
value is not None and name not in names for name, value in requested.items()
|
|
48
|
+
):
|
|
49
|
+
raise ValueError(f"Available coordinate axes are {names}")
|
|
50
|
+
if grid is not None:
|
|
51
|
+
if grid.ndim != shape.ndim:
|
|
52
|
+
raise ValueError("Grid and geometry dimensions must match")
|
|
53
|
+
if bounds is not None:
|
|
54
|
+
raise ValueError("Use grid or bounds, not both")
|
|
55
|
+
defaults = grid.get_grid_points()
|
|
56
|
+
else:
|
|
57
|
+
defaults = (
|
|
58
|
+
sampling_axes(shape, resolution, bounds)
|
|
59
|
+
if any(requested[n] is None for n in names)
|
|
60
|
+
else [None] * shape.ndim
|
|
61
|
+
)
|
|
62
|
+
coordinates = []
|
|
63
|
+
for name, default in zip(names, defaults):
|
|
64
|
+
value = requested[name] if requested[name] is not None else default
|
|
65
|
+
if isinstance(value, xr.DataArray):
|
|
66
|
+
coord = value.astype(float)
|
|
67
|
+
else:
|
|
68
|
+
value = np.asarray(value, float)
|
|
69
|
+
if value.ndim > 1:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"Use DataArrays with shared dimensions for scattered points"
|
|
72
|
+
)
|
|
73
|
+
coord = xr.DataArray(value, dims=(name,) if value.ndim else ())
|
|
74
|
+
if coord.size == 0 or not np.all(np.isfinite(coord.values)):
|
|
75
|
+
raise ValueError("Coordinates must be finite and nonempty")
|
|
76
|
+
coordinates.append(coord)
|
|
77
|
+
coordinates = xr.align(*coordinates, join="exact", copy=False)
|
|
78
|
+
broadcast = xr.broadcast(*coordinates)
|
|
79
|
+
points = np.stack([c.values.ravel() for c in broadcast])
|
|
80
|
+
if quantity == "occupancy":
|
|
81
|
+
values = mask(shape, points)
|
|
82
|
+
elif quantity == "labels":
|
|
83
|
+
values = (
|
|
84
|
+
shape.labels(points)
|
|
85
|
+
if hasattr(shape, "labels")
|
|
86
|
+
else mask(shape, points).astype(int)
|
|
87
|
+
)
|
|
88
|
+
elif quantity == "signed_distance":
|
|
89
|
+
values = shape.signed_distance(points)
|
|
90
|
+
else:
|
|
91
|
+
raise ValueError("quantity must be occupancy, labels, or signed_distance")
|
|
92
|
+
result = xr.DataArray(
|
|
93
|
+
np.asarray(values).reshape(broadcast[0].shape),
|
|
94
|
+
dims=broadcast[0].dims,
|
|
95
|
+
name=quantity,
|
|
96
|
+
)
|
|
97
|
+
for name, coord in zip(names, coordinates):
|
|
98
|
+
result = result.assign_coords({name: coord})
|
|
99
|
+
if quantity == "labels":
|
|
100
|
+
result.attrs["labels"] = {
|
|
101
|
+
0: "background",
|
|
102
|
+
**dict(enumerate(getattr(shape, "names", ("inside",)), 1)),
|
|
103
|
+
}
|
|
104
|
+
result.attrs["sampling"] = "direct geometry queries; host arrays; no MPI gather"
|
|
105
|
+
return result
|
simgeometry/fractions.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Subcell quadrature for approximate solid volume and face-area fractions."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from itertools import product
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from simgeometry.operations import mask
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class CellFractions:
|
|
13
|
+
"""Sampled solid fractions in [0, 1]; fluid fractions are one minus these.
|
|
14
|
+
|
|
15
|
+
``volume`` has one entry per cell. ``faces[axis]`` has one extra entry along
|
|
16
|
+
that axis, including both periodic boundary copies. Fraction times the cell
|
|
17
|
+
volume/face area gives the corresponding solid measure. This is midpoint
|
|
18
|
+
quadrature, not a reconstructed interface; sub-sample features can be missed.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
volume: np.ndarray
|
|
22
|
+
faces: tuple[np.ndarray, ...]
|
|
23
|
+
samples_per_axis: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cell_fractions(geometry, grid, *, samples_per_axis=4):
|
|
27
|
+
"""Estimate cell and face occupancy using a regular subcell sample lattice."""
|
|
28
|
+
if geometry.ndim != grid.ndim:
|
|
29
|
+
raise ValueError("Geometry and grid dimensions must match")
|
|
30
|
+
if int(samples_per_axis) != samples_per_axis or samples_per_axis < 1:
|
|
31
|
+
raise ValueError("samples_per_axis must be a positive integer")
|
|
32
|
+
n = int(samples_per_axis)
|
|
33
|
+
axes = [np.asarray(a, float) for a in grid.get_grid_points()]
|
|
34
|
+
periodic = getattr(grid, "periodic", None) or (False,) * grid.ndim
|
|
35
|
+
h = np.asarray(grid.grid_spacing)
|
|
36
|
+
lower = np.asarray(grid.lower_bound)
|
|
37
|
+
upper = np.asarray(grid.upper_bound)
|
|
38
|
+
cell_counts = tuple(
|
|
39
|
+
len(a) if flag else len(a) - 1 for a, flag in zip(axes, periodic)
|
|
40
|
+
)
|
|
41
|
+
starts = [a if flag else a[:-1] for a, flag in zip(axes, periodic)]
|
|
42
|
+
offsets = (np.arange(n) + 0.5) / n
|
|
43
|
+
|
|
44
|
+
def sample(base_axes, fixed=None):
|
|
45
|
+
result = np.zeros(tuple(len(a) for a in base_axes))
|
|
46
|
+
base = np.stack([a.ravel() for a in np.meshgrid(*base_axes, indexing="ij")])
|
|
47
|
+
combinations = list(
|
|
48
|
+
product(*([0.0] if axis == fixed else offsets for axis in range(grid.ndim)))
|
|
49
|
+
)
|
|
50
|
+
for delta in combinations:
|
|
51
|
+
points = base + h[:, None] * np.asarray(delta)[:, None]
|
|
52
|
+
for axis, flag in enumerate(periodic):
|
|
53
|
+
if flag:
|
|
54
|
+
points[axis] = lower[axis] + (points[axis] - lower[axis]) % (
|
|
55
|
+
upper[axis] - lower[axis]
|
|
56
|
+
)
|
|
57
|
+
result += mask(geometry, points).reshape(result.shape)
|
|
58
|
+
return result / len(combinations)
|
|
59
|
+
|
|
60
|
+
volume = sample(starts).reshape(cell_counts)
|
|
61
|
+
faces = []
|
|
62
|
+
for axis in range(grid.ndim):
|
|
63
|
+
face_axes = list(starts)
|
|
64
|
+
face_axes[axis] = (
|
|
65
|
+
np.r_[axes[axis], upper[axis]] if periodic[axis] else axes[axis]
|
|
66
|
+
)
|
|
67
|
+
faces.append(sample(face_axes, fixed=axis))
|
|
68
|
+
return CellFractions(volume, tuple(faces), n)
|