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
@@ -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
@@ -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)