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/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Shapes, grids, and domains for numerical simulations."""
|
|
2
|
+
|
|
3
|
+
from simgeometry.collection import GeometryCollection
|
|
4
|
+
from simgeometry.grid import Grid
|
|
5
|
+
from simgeometry.objects import (
|
|
6
|
+
Aperture2D,
|
|
7
|
+
Aperture3D,
|
|
8
|
+
Circle,
|
|
9
|
+
Cuboid,
|
|
10
|
+
FunctionObject2D,
|
|
11
|
+
FunctionObject3D,
|
|
12
|
+
Line,
|
|
13
|
+
Polygon,
|
|
14
|
+
Polyline,
|
|
15
|
+
Rectangle,
|
|
16
|
+
Sphere,
|
|
17
|
+
Triangle,
|
|
18
|
+
)
|
|
19
|
+
from simgeometry.simulation_domain import SimulationDomain
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
__all__ = [
|
|
23
|
+
"GeometryCollection",
|
|
24
|
+
"Grid",
|
|
25
|
+
"SimulationDomain",
|
|
26
|
+
"Aperture2D",
|
|
27
|
+
"Aperture3D",
|
|
28
|
+
"Circle",
|
|
29
|
+
"Cuboid",
|
|
30
|
+
"FunctionObject2D",
|
|
31
|
+
"FunctionObject3D",
|
|
32
|
+
"Line",
|
|
33
|
+
"Polygon",
|
|
34
|
+
"Polyline",
|
|
35
|
+
"Rectangle",
|
|
36
|
+
"Sphere",
|
|
37
|
+
"Triangle",
|
|
38
|
+
]
|
simgeometry/_bounds.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Array bounds shared by geometric regions and grids."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Bounds:
|
|
7
|
+
def __init__(self, lower_bound, upper_bound) -> None:
|
|
8
|
+
assert len(lower_bound) == len(upper_bound)
|
|
9
|
+
self.lower_bound = lower_bound
|
|
10
|
+
self.upper_bound = upper_bound
|
|
11
|
+
self.ndim = len(lower_bound)
|
|
12
|
+
assert self.ndim in (1, 2, 3)
|
|
13
|
+
for axis in range(self.ndim):
|
|
14
|
+
assert upper_bound[axis] >= lower_bound[axis]
|
|
15
|
+
self.sidelengths = upper_bound - lower_bound
|
|
16
|
+
self.midpoint = lower_bound + 0.5 * self.sidelengths
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Normalize scalar and sequence geometry arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ensure_dim_tuple(value: object, *, ndim: int | None = None) -> tuple:
|
|
9
|
+
if isinstance(value, tuple):
|
|
10
|
+
normalized = value
|
|
11
|
+
elif isinstance(value, list):
|
|
12
|
+
normalized = tuple(value)
|
|
13
|
+
elif isinstance(value, (str, bytes, dict)):
|
|
14
|
+
raise TypeError(
|
|
15
|
+
f"value must be a scalar number/bool or sequence, got {type(value)}"
|
|
16
|
+
)
|
|
17
|
+
elif isinstance(value, Iterable):
|
|
18
|
+
normalized = tuple(value)
|
|
19
|
+
else:
|
|
20
|
+
normalized = (value,)
|
|
21
|
+
if ndim is not None and len(normalized) != ndim:
|
|
22
|
+
raise ValueError(f"value must have length {ndim}, got {len(normalized)}")
|
|
23
|
+
return normalized
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Apply particle boundary conditions on the active array backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import cunumpy as xp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def apply_particle_boundaries(
|
|
9
|
+
positions, velocities, alive, reinject, lower, upper, lower_type, upper_type
|
|
10
|
+
) -> None:
|
|
11
|
+
"""Update component-major particle arrays in place.
|
|
12
|
+
|
|
13
|
+
Boundary codes are kill=0, reflect=1, periodic=2, reinject=3.
|
|
14
|
+
"""
|
|
15
|
+
reinject[:] = False
|
|
16
|
+
in_domain = alive.copy()
|
|
17
|
+
for axis in range(positions.shape[0]):
|
|
18
|
+
active = alive & ~reinject
|
|
19
|
+
x = positions[axis]
|
|
20
|
+
low, high = lower[axis], upper[axis]
|
|
21
|
+
below = active & (x < low)
|
|
22
|
+
above = active & ((x >= high) if upper_type[axis] == 2 else (x > high))
|
|
23
|
+
for mask, bound, side_type in (
|
|
24
|
+
(below, low, lower_type[axis]),
|
|
25
|
+
(above, high, upper_type[axis]),
|
|
26
|
+
):
|
|
27
|
+
if side_type == 1:
|
|
28
|
+
x[mask] = 2.0 * bound - x[mask]
|
|
29
|
+
velocities[axis, mask] *= -1.0
|
|
30
|
+
elif side_type == 2:
|
|
31
|
+
wrapped = low + (x[mask] - low) % (high - low)
|
|
32
|
+
x[mask] = xp.where(wrapped >= high, low, wrapped)
|
|
33
|
+
elif side_type == 3:
|
|
34
|
+
reinject |= mask
|
|
35
|
+
checked = alive & ~reinject
|
|
36
|
+
in_domain &= ~checked | ((x >= low) & (x < high))
|
|
37
|
+
alive &= in_domain
|
simgeometry/_types.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Types used by geometry without importing simulation utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import TypeAlias
|
|
7
|
+
|
|
8
|
+
from numpy import ndarray
|
|
9
|
+
|
|
10
|
+
Array: TypeAlias = ndarray
|
|
11
|
+
Scalar: TypeAlias = str | int | float | bool | None
|
|
12
|
+
ConfigValue: TypeAlias = (
|
|
13
|
+
Scalar
|
|
14
|
+
| Array
|
|
15
|
+
| list["ConfigValue"]
|
|
16
|
+
| tuple["ConfigValue", ...]
|
|
17
|
+
| set["ConfigValue"]
|
|
18
|
+
| dict[str, "ConfigValue"]
|
|
19
|
+
)
|
|
20
|
+
Printable: TypeAlias = str | int | float | bool | None
|
|
21
|
+
BoundsLike: TypeAlias = Sequence[int | float] | ndarray
|
|
22
|
+
Vertices2DLike: TypeAlias = Sequence[BoundsLike] | ndarray
|
simgeometry/api.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Shared inspection and construction methods for geometry objects.
|
|
2
|
+
|
|
3
|
+
Array queries use component-major coordinates, ``(ndim, n)``. Inspection,
|
|
4
|
+
meshing and labelled evaluation return host arrays; containment retains the
|
|
5
|
+
selected cunumpy backend. Optional dependencies are imported on demand.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GeometryAPI:
|
|
14
|
+
"""Convenience operations shared by shapes, sections and collections."""
|
|
15
|
+
|
|
16
|
+
def evaluate(
|
|
17
|
+
self,
|
|
18
|
+
x=None,
|
|
19
|
+
y=None,
|
|
20
|
+
z=None,
|
|
21
|
+
*,
|
|
22
|
+
grid=None,
|
|
23
|
+
bounds=None,
|
|
24
|
+
resolution=128,
|
|
25
|
+
quantity="occupancy",
|
|
26
|
+
):
|
|
27
|
+
"""Sample geometry as an xarray.DataArray (requires the ``evaluate`` extra).
|
|
28
|
+
|
|
29
|
+
Scalars fix axes, sequences form a tensor product, and DataArrays with
|
|
30
|
+
shared dimension names describe scattered points. Unspecified axes use
|
|
31
|
+
grid nodes, or ``resolution`` samples across ``bounds``/the shape bounds.
|
|
32
|
+
``quantity`` is occupancy, labels (collections), or signed_distance.
|
|
33
|
+
This is a local host operation, with no implicit MPI collectives.
|
|
34
|
+
"""
|
|
35
|
+
from simgeometry.evaluation import evaluate
|
|
36
|
+
|
|
37
|
+
return evaluate(
|
|
38
|
+
self,
|
|
39
|
+
x=x,
|
|
40
|
+
y=y,
|
|
41
|
+
z=z,
|
|
42
|
+
grid=grid,
|
|
43
|
+
bounds=bounds,
|
|
44
|
+
resolution=resolution,
|
|
45
|
+
quantity=quantity,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def slice(self, *, x=None, y=None, z=None, origin=None, normal=None):
|
|
49
|
+
"""Return a queryable section with local-to-world coordinate mappings.
|
|
50
|
+
|
|
51
|
+
Fix exactly one coordinate, or give a 3D plane's origin and normal.
|
|
52
|
+
Axis-aligned sections retain the remaining coordinate names; oblique
|
|
53
|
+
sections use x/y as their local plane coordinates.
|
|
54
|
+
"""
|
|
55
|
+
from simgeometry.operations import Section
|
|
56
|
+
|
|
57
|
+
return Section(self, x=x, y=y, z=z, origin=origin, normal=normal)
|
|
58
|
+
|
|
59
|
+
def show(self, **kwargs):
|
|
60
|
+
"""Display geometry, dispatching to 3D unless a slice is requested.
|
|
61
|
+
|
|
62
|
+
Returns Matplotlib axes, or a PyVista plotter for ``backend='pyvista'``.
|
|
63
|
+
Set ``display=False`` for scripts. ``save=path`` saves the figure.
|
|
64
|
+
"""
|
|
65
|
+
from simgeometry.visualization import show
|
|
66
|
+
|
|
67
|
+
return show(self, **kwargs)
|
|
68
|
+
|
|
69
|
+
def show_3d(self, **kwargs):
|
|
70
|
+
"""Display a surface with optional wireframe, normals and clipping.
|
|
71
|
+
|
|
72
|
+
``backend='pyvista'`` enables an interactive clipping-plane widget via
|
|
73
|
+
``clip=True`` (requires the ``interactive`` extra). Matplotlib supports
|
|
74
|
+
a static ``clip=(origin, normal)`` plane. Meshes are resolution-dependent.
|
|
75
|
+
"""
|
|
76
|
+
from simgeometry.visualization import show_3d
|
|
77
|
+
|
|
78
|
+
return show_3d(self, **kwargs)
|
|
79
|
+
|
|
80
|
+
def plot(self, ax=None, **kwargs):
|
|
81
|
+
"""Draw without displaying and return axes for composition."""
|
|
82
|
+
return self.show(ax=ax, display=False, **kwargs)
|
|
83
|
+
|
|
84
|
+
def copy(self):
|
|
85
|
+
"""Return an independent deep copy."""
|
|
86
|
+
return copy.deepcopy(self)
|
|
87
|
+
|
|
88
|
+
def transformed(self, matrix, offset=None):
|
|
89
|
+
"""Return an affine-transformed snapshot; the source is unchanged."""
|
|
90
|
+
from simgeometry.operations import TransformedShape
|
|
91
|
+
|
|
92
|
+
return TransformedShape(self, matrix, offset)
|
|
93
|
+
|
|
94
|
+
def translated(self, offset):
|
|
95
|
+
"""Return a translated snapshot (unlike legacy in-place translate)."""
|
|
96
|
+
import numpy as np
|
|
97
|
+
|
|
98
|
+
return self.transformed(np.eye(self.ndim), offset)
|
|
99
|
+
|
|
100
|
+
def rotated(self, angle, *, axis=(0, 0, 1), origin=None):
|
|
101
|
+
"""Return a rotated snapshot; angle is in degrees, about origin (default 0)."""
|
|
102
|
+
from simgeometry.operations import rotation
|
|
103
|
+
|
|
104
|
+
return rotation(self, angle, axis=axis, origin=origin)
|
|
105
|
+
|
|
106
|
+
def scaled(self, factors, *, origin=None):
|
|
107
|
+
"""Return a snapshot scaled by a nonzero scalar or per-axis factors."""
|
|
108
|
+
import numpy as np
|
|
109
|
+
|
|
110
|
+
factors = np.broadcast_to(factors, (self.ndim,)).astype(float)
|
|
111
|
+
centre = np.zeros(self.ndim) if origin is None else np.asarray(origin, float)
|
|
112
|
+
return self.transformed(np.diag(factors), centre * (1 - factors))
|
|
113
|
+
|
|
114
|
+
def union(self, other):
|
|
115
|
+
from simgeometry.operations import BooleanShape
|
|
116
|
+
|
|
117
|
+
return BooleanShape(self, other, "union")
|
|
118
|
+
|
|
119
|
+
def intersection(self, other):
|
|
120
|
+
from simgeometry.operations import BooleanShape
|
|
121
|
+
|
|
122
|
+
return BooleanShape(self, other, "intersection")
|
|
123
|
+
|
|
124
|
+
def difference(self, other):
|
|
125
|
+
from simgeometry.operations import BooleanShape
|
|
126
|
+
|
|
127
|
+
return BooleanShape(self, other, "difference")
|
|
128
|
+
|
|
129
|
+
def __or__(self, other):
|
|
130
|
+
return self.union(other)
|
|
131
|
+
|
|
132
|
+
def __and__(self, other):
|
|
133
|
+
return self.intersection(other)
|
|
134
|
+
|
|
135
|
+
def __sub__(self, other):
|
|
136
|
+
return self.difference(other)
|
|
137
|
+
|
|
138
|
+
def intersect_segment(self, start, end, *, samples=256, tolerance=1e-10):
|
|
139
|
+
"""Return ordered surface hits, including multiple crossings and tangencies.
|
|
140
|
+
|
|
141
|
+
Primitive intersections are analytic. Generic membership functions use
|
|
142
|
+
``samples`` intervals and may miss features smaller than that sampling.
|
|
143
|
+
Result.exact distinguishes these cases. Coincident surface segments do
|
|
144
|
+
not have a discrete set of hits; their end contacts may be returned.
|
|
145
|
+
"""
|
|
146
|
+
from simgeometry.queries import intersect_segment
|
|
147
|
+
|
|
148
|
+
return intersect_segment(self, start, end, samples=samples, tolerance=tolerance)
|
|
149
|
+
|
|
150
|
+
def crossings(self, start, end, *, samples=64, iterations=40):
|
|
151
|
+
"""First boundary crossing of every segment ``start[:, i] -> end[:, i]``.
|
|
152
|
+
|
|
153
|
+
``start`` and ``end`` are ``(ndim, N)``, such as particle positions before and
|
|
154
|
+
after a step. Returns a :class:`~simgeometry.crossings.Crossings` with ``hit``,
|
|
155
|
+
``fraction``, ``point`` and ``normal`` (out of the region where ``contains`` is
|
|
156
|
+
true) on the active array backend. Polygons, circles, spheres, boxes and their
|
|
157
|
+
affine transforms are analytic; other shapes sample ``samples`` points per
|
|
158
|
+
segment and bisect ``iterations`` times.
|
|
159
|
+
"""
|
|
160
|
+
from simgeometry.crossings import crossings
|
|
161
|
+
|
|
162
|
+
return crossings(self, start, end, samples=samples, iterations=iterations)
|
|
163
|
+
|
|
164
|
+
def closest_point(self, points, *, resolution=64, bounds=None):
|
|
165
|
+
"""Closest surface coordinates; primitives are exact, others use a mesh.
|
|
166
|
+
|
|
167
|
+
Mesh fallback requires ``mesh`` extras. Its accuracy is controlled by
|
|
168
|
+
resolution and may miss unresolved components. Points are (ndim, n),
|
|
169
|
+
or one (ndim,) point; the returned array has the same layout.
|
|
170
|
+
"""
|
|
171
|
+
from simgeometry.queries import closest_point
|
|
172
|
+
|
|
173
|
+
return closest_point(self, points, resolution=resolution, bounds=bounds)
|
|
174
|
+
|
|
175
|
+
def signed_distance(self, points, *, resolution=64, bounds=None):
|
|
176
|
+
"""Distance to the boundary, negative inside, positive outside.
|
|
177
|
+
|
|
178
|
+
Exact for balls, boxes and simple polygons; otherwise distance to the
|
|
179
|
+
sampled surface mesh. A membership function is not itself a distance.
|
|
180
|
+
"""
|
|
181
|
+
from simgeometry.queries import signed_distance
|
|
182
|
+
|
|
183
|
+
return signed_distance(self, points, resolution=resolution, bounds=bounds)
|
|
184
|
+
|
|
185
|
+
def surface_normal(self, points, *, scale=None):
|
|
186
|
+
"""Outward unit normals at boundary points; NaN where undefined.
|
|
187
|
+
|
|
188
|
+
Primitive faces use analytic normals. Other geometries estimate the
|
|
189
|
+
normal from nearby membership samples. At corners normals are not unique.
|
|
190
|
+
"""
|
|
191
|
+
from simgeometry.queries import surface_normal
|
|
192
|
+
|
|
193
|
+
return surface_normal(self, points, scale=scale)
|
|
194
|
+
|
|
195
|
+
def to_mesh(self, *, resolution=64, bounds=None):
|
|
196
|
+
"""Return sampled contours (2D) or triangles (3D), with spacing metadata.
|
|
197
|
+
|
|
198
|
+
Requires the ``mesh`` extra. Open contours/surfaces at the sampling bounds
|
|
199
|
+
remain open; no artificial caps are added. Refine to resolve thin features.
|
|
200
|
+
"""
|
|
201
|
+
from simgeometry.mesh import to_mesh
|
|
202
|
+
|
|
203
|
+
return to_mesh(self, resolution=resolution, bounds=bounds)
|
|
204
|
+
|
|
205
|
+
def export(self, path, **kwargs):
|
|
206
|
+
"""Export a sampled mesh as NPZ, OBJ, or STL (3D only)."""
|
|
207
|
+
return self.to_mesh(**kwargs).save(path)
|
|
208
|
+
|
|
209
|
+
def sample_interior(self, count, *, rng=None, bounds=None, max_draws=1_000_000):
|
|
210
|
+
"""Uniform rejection samples, returned as (ndim, count).
|
|
211
|
+
|
|
212
|
+
Supply bounds for inverted/unbounded shapes. Raises if max_draws is
|
|
213
|
+
exhausted; it never silently returns too few samples.
|
|
214
|
+
"""
|
|
215
|
+
from simgeometry.sampling import sample_interior
|
|
216
|
+
|
|
217
|
+
return sample_interior(self, count, rng=rng, bounds=bounds, max_draws=max_draws)
|
|
218
|
+
|
|
219
|
+
def sample_surface(self, count, *, rng=None, resolution=64, bounds=None):
|
|
220
|
+
"""Uniform-by-length/area samples from the faceted boundary mesh."""
|
|
221
|
+
from simgeometry.sampling import sample_surface
|
|
222
|
+
|
|
223
|
+
return sample_surface(
|
|
224
|
+
self, count, rng=rng, resolution=resolution, bounds=bounds
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def cut_edges(self, grid, **kwargs):
|
|
228
|
+
"""Locate all detected grid-edge surface hits, without solver clamping."""
|
|
229
|
+
from simgeometry.cut_edges import cut_edges
|
|
230
|
+
|
|
231
|
+
return cut_edges(self, grid, **kwargs)
|
|
232
|
+
|
|
233
|
+
def diagnose(self, grid, **kwargs):
|
|
234
|
+
"""Report sampled overlaps, unresolved components, and short cut distances."""
|
|
235
|
+
from simgeometry.cut_edges import diagnose
|
|
236
|
+
|
|
237
|
+
return diagnose(self, grid, **kwargs)
|
|
238
|
+
|
|
239
|
+
def cell_fractions(self, grid, *, samples_per_axis=4):
|
|
240
|
+
"""Estimate solid cell-volume and face-area fractions by subcell sampling.
|
|
241
|
+
|
|
242
|
+
Increase samples_per_axis to check convergence; unresolved thin features
|
|
243
|
+
can be missed. This is not an exact finite-volume interface reconstruction.
|
|
244
|
+
"""
|
|
245
|
+
from simgeometry.fractions import cell_fractions
|
|
246
|
+
|
|
247
|
+
return cell_fractions(self, grid, samples_per_axis=samples_per_axis)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Boundingbox helpers for geometry, grids, and simulation domains."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import cunumpy as xp
|
|
6
|
+
from maybempi import MPI
|
|
7
|
+
|
|
8
|
+
from simgeometry._bounds import Bounds
|
|
9
|
+
from simgeometry._normalize import ensure_dim_tuple
|
|
10
|
+
from simgeometry._types import Array, ConfigValue
|
|
11
|
+
from simgeometry.domain_decomposition import DomainDecomposition
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BoundingBox:
|
|
15
|
+
"""Represent axis-aligned bounds for a simulation region."""
|
|
16
|
+
|
|
17
|
+
# Applications may supply their own seeded stream. The default follows
|
|
18
|
+
# the active cunumpy backend and works without any Picnic configuration.
|
|
19
|
+
random = staticmethod(xp.random.random)
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
lower_bound: tuple,
|
|
24
|
+
upper_bound: tuple,
|
|
25
|
+
comm: MPI.Comm | None = None,
|
|
26
|
+
domain_decomposition: ConfigValue = None,
|
|
27
|
+
periodic: bool | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Initialize the simulation domain.
|
|
31
|
+
|
|
32
|
+
Parameters:
|
|
33
|
+
ndim (int): Number of dimensions (e.g., 1, 2, or 3).
|
|
34
|
+
bounds (list of tuples): List of tuples specifying min and max for each dimension.
|
|
35
|
+
For example, [(x_min, y_min, z_min), (y_max, y_max, z_max)] for 3D.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
lower_bound = ensure_dim_tuple(lower_bound)
|
|
39
|
+
upper_bound = ensure_dim_tuple(upper_bound)
|
|
40
|
+
if len(lower_bound) != len(upper_bound):
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"lower_bound and upper_bound must have the same length, "
|
|
43
|
+
f"got {len(lower_bound)} and {len(upper_bound)}",
|
|
44
|
+
)
|
|
45
|
+
ndim = len(lower_bound)
|
|
46
|
+
if ndim not in [1, 2, 3]:
|
|
47
|
+
raise ValueError("Number of dimensions must be 1, 2, or 3")
|
|
48
|
+
if domain_decomposition is not None:
|
|
49
|
+
domain_decomposition = ensure_dim_tuple(
|
|
50
|
+
domain_decomposition,
|
|
51
|
+
ndim=ndim,
|
|
52
|
+
)
|
|
53
|
+
if periodic is not None:
|
|
54
|
+
periodic = ensure_dim_tuple(periodic, ndim=ndim)
|
|
55
|
+
for dir in range(ndim):
|
|
56
|
+
if upper_bound[dir] < lower_bound[dir]:
|
|
57
|
+
raise ValueError("upper bound must be >= lower bound in each dimension")
|
|
58
|
+
# Set attributes
|
|
59
|
+
self._bounds = Bounds(
|
|
60
|
+
lower_bound=xp.array(lower_bound, dtype=float),
|
|
61
|
+
upper_bound=xp.array(upper_bound, dtype=float),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
self._lower_bound = lower_bound
|
|
65
|
+
self._upper_bound = upper_bound
|
|
66
|
+
self._comm = comm
|
|
67
|
+
|
|
68
|
+
if isinstance(comm, MPI.Comm):
|
|
69
|
+
self._mpi_rank = comm.Get_rank()
|
|
70
|
+
self._mpi_size = comm.Get_size()
|
|
71
|
+
else:
|
|
72
|
+
self._mpi_rank = 0
|
|
73
|
+
self._mpi_size = 1
|
|
74
|
+
|
|
75
|
+
self._periodic = periodic
|
|
76
|
+
self._domain_decomposition = DomainDecomposition(
|
|
77
|
+
comm=comm,
|
|
78
|
+
decompose=domain_decomposition,
|
|
79
|
+
periodic=periodic,
|
|
80
|
+
ndim=self.ndim,
|
|
81
|
+
)
|
|
82
|
+
self._proc_sizes = self.domain_decomposition_class.proc_sizes
|
|
83
|
+
|
|
84
|
+
# TODO: Use a tuple here instead
|
|
85
|
+
self._sidelengths = tuple(
|
|
86
|
+
upper_bound[dir] - lower_bound[dir] for dir in range(ndim)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def contains(self, coords: tuple) -> bool:
|
|
90
|
+
"""
|
|
91
|
+
Check if the given coordinates are inside the domain.
|
|
92
|
+
|
|
93
|
+
Parameters:
|
|
94
|
+
coords (float): Coordinates for each dimension.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
bool: True if all coordinates are within the domain bounds, False otherwise.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
assert len(coords) == self.ndim, (
|
|
101
|
+
f"Expected {self.ndim} dimensions, got {len(coords)}."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return all(
|
|
105
|
+
self.lower_bound[i] <= coord < self.upper_bound[i]
|
|
106
|
+
for i, coord in enumerate(coords)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def get_relative_position(self, coords: Array) -> Array:
|
|
110
|
+
"""
|
|
111
|
+
Determine the relative position of the given coordinates with respect to the domain bounds.
|
|
112
|
+
|
|
113
|
+
Parameters:
|
|
114
|
+
coords (float): Coordinates for each dimension.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
list: A list representing the relative position.
|
|
118
|
+
[0, 0] if inside,
|
|
119
|
+
[-1, 0] if to the left,
|
|
120
|
+
[1, 0] if to the right,
|
|
121
|
+
[0, -1] if below,
|
|
122
|
+
[0, 1] if above,
|
|
123
|
+
etc.
|
|
124
|
+
"""
|
|
125
|
+
assert len(coords) == self.ndim, (
|
|
126
|
+
f"Expected {self.ndim} dimensions, got {len(coords)}."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
position = [0] * self.ndim
|
|
130
|
+
|
|
131
|
+
for i, coord in enumerate(coords):
|
|
132
|
+
if coord < self.lower_bound[i]:
|
|
133
|
+
position[i] = -1
|
|
134
|
+
elif coord >= self.upper_bound[i]:
|
|
135
|
+
position[i] = 1
|
|
136
|
+
else:
|
|
137
|
+
position[i] = 0
|
|
138
|
+
|
|
139
|
+
return position
|
|
140
|
+
|
|
141
|
+
def draw_random_position(self) -> Array:
|
|
142
|
+
"""Draw one random position inside the bounding box."""
|
|
143
|
+
pos = self.random(self.ndim)
|
|
144
|
+
for i, (b0, b1) in enumerate(zip(self.lower_bound, self.upper_bound)):
|
|
145
|
+
pos[i] = b0 + pos[i] * (b1 - b0)
|
|
146
|
+
return pos
|
|
147
|
+
|
|
148
|
+
def draw_random_positions(self, num_positions: int, out: Array = None) -> Array:
|
|
149
|
+
"""Draw `num_positions` random positions inside the bounding box.
|
|
150
|
+
|
|
151
|
+
The result has shape ``(ndim, num_positions)``; ``out`` must have it.
|
|
152
|
+
"""
|
|
153
|
+
shape = (self.ndim, num_positions)
|
|
154
|
+
if out is None:
|
|
155
|
+
out = self.random(shape)
|
|
156
|
+
ret = True
|
|
157
|
+
else:
|
|
158
|
+
assert out.shape == shape, (
|
|
159
|
+
f"Expected out with shape {shape}, got {out.shape}"
|
|
160
|
+
)
|
|
161
|
+
out[:] = self.random(shape)
|
|
162
|
+
ret = False
|
|
163
|
+
|
|
164
|
+
for dir in range(self.ndim):
|
|
165
|
+
out[dir] = out[dir] * self.bounds.sidelengths[dir] + self.lower_bound[dir]
|
|
166
|
+
|
|
167
|
+
if ret:
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def ndim(self) -> int:
|
|
172
|
+
"""Return the number of spatial dimensions."""
|
|
173
|
+
return self._bounds.ndim
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def bounds(self) -> Bounds:
|
|
177
|
+
"""Return the lower and upper bounds."""
|
|
178
|
+
return self._bounds
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def lower_bound(self) -> tuple:
|
|
182
|
+
"""Return the lower coordinate bounds."""
|
|
183
|
+
return self._lower_bound
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def upper_bound(self) -> tuple:
|
|
187
|
+
"""Return the upper coordinate bounds."""
|
|
188
|
+
return self._upper_bound
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def comm(self) -> MPI.Comm | None:
|
|
192
|
+
"""Return the MPI communicator."""
|
|
193
|
+
return self._comm
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def mpi_rank(self) -> int:
|
|
197
|
+
"""Return the MPI rank."""
|
|
198
|
+
return self._mpi_rank
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def mpi_size(self) -> int:
|
|
202
|
+
"""Return the MPI size."""
|
|
203
|
+
return self._mpi_size
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def domain_decomposition_class(self) -> ConfigValue:
|
|
207
|
+
"""Return the domain decomposition class."""
|
|
208
|
+
return self._domain_decomposition
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def periodic(self) -> tuple[bool, ...]:
|
|
212
|
+
"""Return periodicity flags for each axis."""
|
|
213
|
+
return self._periodic
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def proc_sizes(self) -> list[int]:
|
|
217
|
+
"""Return process counts along each axis."""
|
|
218
|
+
return self._proc_sizes
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def midpoint(self) -> tuple[float, ...]:
|
|
222
|
+
"""Return the center point of the bounding box."""
|
|
223
|
+
return tuple(
|
|
224
|
+
0.5 * (self.lower_bound[dir] + self.upper_bound[dir])
|
|
225
|
+
for dir in range(self.ndim)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def sidelengths(self) -> tuple[float, ...]:
|
|
230
|
+
"""Return the side lengths."""
|
|
231
|
+
return self._sidelengths
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Named collections with explicit overlap precedence and display styles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
import cunumpy as xp
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from simgeometry.operations import mask
|
|
11
|
+
from simgeometry.shapes import Shape, _component_major
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GeometryCollection(Shape):
|
|
15
|
+
"""Named objects; last listed object wins labels in overlaps.
|
|
16
|
+
|
|
17
|
+
Label 0 is background, labels 1..N follow insertion order. Styles map object
|
|
18
|
+
names to plotting keyword dictionaries. Explicit bounds also permit an empty
|
|
19
|
+
collection and define the default viewing/sampling region, without clipping
|
|
20
|
+
object membership. Collection objects are references, not snapshots.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, objects, *, bounds=None, styles=None):
|
|
24
|
+
if not isinstance(objects, Mapping):
|
|
25
|
+
objects = {f"object_{i + 1}": obj for i, obj in enumerate(objects)}
|
|
26
|
+
self.named_objects = dict(objects)
|
|
27
|
+
if any(not isinstance(name, str) or not name for name in self.named_objects):
|
|
28
|
+
raise ValueError("Object names must be nonempty strings")
|
|
29
|
+
self.names = tuple(self.named_objects)
|
|
30
|
+
self.objects = list(self.named_objects.values())
|
|
31
|
+
self.styles = dict(styles or {})
|
|
32
|
+
if set(self.styles) - set(self.names):
|
|
33
|
+
raise ValueError("Style names must identify objects")
|
|
34
|
+
if bounds is None:
|
|
35
|
+
if not self.objects:
|
|
36
|
+
raise ValueError("An empty collection needs bounds")
|
|
37
|
+
bounds = (
|
|
38
|
+
np.min([s.lower_bound for s in self.objects], axis=0),
|
|
39
|
+
np.max([s.upper_bound for s in self.objects], axis=0),
|
|
40
|
+
)
|
|
41
|
+
ndim = len(bounds[0])
|
|
42
|
+
if any(obj.ndim != ndim for obj in self.objects):
|
|
43
|
+
raise ValueError("All collection objects must have the same dimension")
|
|
44
|
+
self._inverted = any(obj.inverted for obj in self.objects)
|
|
45
|
+
super().__init__(ndim, *bounds)
|
|
46
|
+
|
|
47
|
+
def __getitem__(self, name):
|
|
48
|
+
return self.named_objects[name]
|
|
49
|
+
|
|
50
|
+
def labels(self, points):
|
|
51
|
+
"""Integer object identities for (ndim, n) points; last object wins."""
|
|
52
|
+
points = np.asarray(xp.to_numpy(_component_major(points, self.ndim)), float)
|
|
53
|
+
result = np.zeros(points.shape[1], dtype=np.int64)
|
|
54
|
+
for label, obj in enumerate(self.objects, start=1):
|
|
55
|
+
result[mask(obj, points)] = label
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
def contains_points(self, points):
|
|
59
|
+
return xp.asarray(self.labels(points) != 0)
|
|
60
|
+
|
|
61
|
+
def contains(self, coords):
|
|
62
|
+
return any(obj.contains(coords) for obj in self.objects)
|
|
63
|
+
|
|
64
|
+
def evaluate(self, *args, quantity="labels", **kwargs):
|
|
65
|
+
return super().evaluate(*args, quantity=quantity, **kwargs)
|