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/crossings.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""First boundary crossings of many segments at once, for particle pushers.
|
|
2
|
+
|
|
3
|
+
:func:`crossings` takes the start and end of every particle's step as component-major
|
|
4
|
+
``(ndim, N)`` arrays and finds, for each step, the first point where it crosses the
|
|
5
|
+
shape's boundary, entering or leaving. Because the whole step is tested, not only its
|
|
6
|
+
end point, a fast particle cannot pass through a thin plate unnoticed.
|
|
7
|
+
|
|
8
|
+
Polygonal 2D shapes (exact outlines), circles, spheres and axis-aligned boxes are
|
|
9
|
+
analytic; affine transforms of them stay analytic. Every other shape (Boolean
|
|
10
|
+
combinations, collections, function objects, ...) is sampled: membership is tested at
|
|
11
|
+
``samples`` points along each step and the first change is bisected, so features
|
|
12
|
+
thinner than ``1 / samples`` of a step can be missed, and the normals are estimated.
|
|
13
|
+
|
|
14
|
+
Conventions: fractions lie in ``(0, 1]``. A crossing at the very start of a step
|
|
15
|
+
(fraction below :data:`START_TOLERANCE`) is not reported, so a particle that stops
|
|
16
|
+
exactly on a boundary is not counted again on its next step; a crossing exactly at
|
|
17
|
+
the end is. Grazing contacts (a step that touches a circle, or slides along an edge)
|
|
18
|
+
are not crossings. Normals have unit length and point out of the region where
|
|
19
|
+
``contains`` is true, so they flip for inverted shapes. An open curve (a shape with
|
|
20
|
+
``closed = False``) has no inside: its normal faces the incoming step.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import cunumpy as xp
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from simgeometry.shapes import _component_major
|
|
32
|
+
|
|
33
|
+
#: Crossings closer than this fraction to the start of a step are ignored.
|
|
34
|
+
START_TOLERANCE = 1e-12
|
|
35
|
+
#: Largest number of (segment, edge) pairs a polygon test broadcasts at once.
|
|
36
|
+
MAX_PAIRS = 4_000_000
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Crossings:
|
|
41
|
+
"""First boundary crossing of each segment.
|
|
42
|
+
|
|
43
|
+
``hit`` is ``(N,)``; ``fraction`` is ``(N,)`` in ``(0, 1]``; ``point`` and
|
|
44
|
+
``normal`` are ``(ndim, N)``. Where ``hit`` is false they are NaN. ``exact`` is
|
|
45
|
+
true for analytic results and false for sampled ones.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
hit: Any
|
|
49
|
+
fraction: Any
|
|
50
|
+
point: Any
|
|
51
|
+
normal: Any
|
|
52
|
+
exact: bool
|
|
53
|
+
|
|
54
|
+
def __len__(self) -> int:
|
|
55
|
+
return int(self.hit.shape[0])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def crossings(shape, start, end, *, samples: int = 64, iterations: int = 40):
|
|
59
|
+
"""Return the first boundary crossing of each segment ``start[:, i] -> end[:, i]``.
|
|
60
|
+
|
|
61
|
+
See the module docstring for the conventions and which shapes are analytic.
|
|
62
|
+
"""
|
|
63
|
+
a = _component_major(start, shape.ndim)
|
|
64
|
+
b = _component_major(end, shape.ndim)
|
|
65
|
+
if a.shape != b.shape:
|
|
66
|
+
raise ValueError(f"start {a.shape} and end {b.shape} must have the same shape")
|
|
67
|
+
if int(samples) != samples or samples < 2:
|
|
68
|
+
raise ValueError("samples must be an integer >= 2")
|
|
69
|
+
if int(iterations) != iterations or iterations < 1:
|
|
70
|
+
raise ValueError("iterations must be a positive integer")
|
|
71
|
+
t, normal, exact = _first(shape, a, b - a, int(samples), int(iterations))
|
|
72
|
+
hit = xp.isfinite(t)
|
|
73
|
+
fraction = xp.where(hit, t, xp.nan)
|
|
74
|
+
point = xp.where(hit[None, :], a + xp.where(hit, t, 0.0)[None, :] * (b - a), xp.nan)
|
|
75
|
+
normal = xp.where(hit[None, :], normal, xp.nan)
|
|
76
|
+
return Crossings(hit, fraction, point, normal, exact)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _first(shape, a, d, samples, iterations):
|
|
80
|
+
"""Fraction ``(N,)`` (inf without a hit) and normal ``(ndim, N)`` of the first crossing."""
|
|
81
|
+
from simgeometry.objects import (
|
|
82
|
+
Aperture2D,
|
|
83
|
+
Circle,
|
|
84
|
+
Cuboid,
|
|
85
|
+
Line,
|
|
86
|
+
Polygon,
|
|
87
|
+
Rectangle,
|
|
88
|
+
Sphere,
|
|
89
|
+
Triangle,
|
|
90
|
+
)
|
|
91
|
+
from simgeometry.operations import TransformedShape
|
|
92
|
+
|
|
93
|
+
if isinstance(shape, TransformedShape):
|
|
94
|
+
return _transformed(shape, a, d, samples, iterations)
|
|
95
|
+
if isinstance(shape, (Circle, Sphere)):
|
|
96
|
+
return (*_ball(shape, a, d), True)
|
|
97
|
+
if isinstance(shape, (Rectangle, Cuboid, Line)):
|
|
98
|
+
return (*_box(shape, a, d), True)
|
|
99
|
+
polygonal = (Polygon, Triangle, Aperture2D)
|
|
100
|
+
if shape.ndim == 2 and (
|
|
101
|
+
getattr(shape, "exact_outline", False) or isinstance(shape, polygonal)
|
|
102
|
+
):
|
|
103
|
+
return (*_polygon(shape, a, d), True)
|
|
104
|
+
return (*_sampled(shape, a, d, samples, iterations), False)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _flip(shape, normal):
|
|
108
|
+
return -normal if shape.inverted else normal
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _first_of_interval(t0, t1):
|
|
112
|
+
"""First crossing of a convex shape whose line occupies ``(t0, t1)``, or inf."""
|
|
113
|
+
inf = xp.full(t0.shape, xp.inf)
|
|
114
|
+
entering = (t0 > START_TOLERANCE) & (t0 <= 1.0)
|
|
115
|
+
leaving = ~entering & (t1 > START_TOLERANCE) & (t1 <= 1.0)
|
|
116
|
+
proper = t0 < t1 # touching (t0 == t1) is not a crossing
|
|
117
|
+
t = xp.where(entering, t0, xp.where(leaving, t1, inf))
|
|
118
|
+
return xp.where(proper, t, inf), entering
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _ball(shape, a, d):
|
|
122
|
+
centre = xp.asarray(np.asarray(xp.to_numpy(shape.midpoint), float))[:, None]
|
|
123
|
+
radius = float(shape.radius)
|
|
124
|
+
rel = a - centre
|
|
125
|
+
aa = xp.sum(d * d, axis=0)
|
|
126
|
+
bb = xp.sum(rel * d, axis=0)
|
|
127
|
+
cc = xp.sum(rel * rel, axis=0) - radius**2
|
|
128
|
+
safe = xp.where(aa > 0, aa, 1.0)
|
|
129
|
+
disc = bb * bb - aa * cc
|
|
130
|
+
root = xp.sqrt(xp.where(disc > 0, disc, 0.0))
|
|
131
|
+
valid = (aa > 0) & (disc > 0)
|
|
132
|
+
t0 = xp.where(valid, (-bb - root) / safe, xp.inf)
|
|
133
|
+
t1 = xp.where(valid, (-bb + root) / safe, xp.inf)
|
|
134
|
+
t, _ = _first_of_interval(t0, t1)
|
|
135
|
+
finite = xp.isfinite(t)
|
|
136
|
+
point = a + xp.where(finite, t, 0.0)[None, :] * d
|
|
137
|
+
normal = (point - centre) / radius
|
|
138
|
+
return t, _flip(shape, normal)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _box(shape, a, d):
|
|
142
|
+
lower = np.asarray(xp.to_numpy(shape.lower_bound), float).reshape(-1)
|
|
143
|
+
upper = np.asarray(xp.to_numpy(shape.upper_bound), float).reshape(-1)
|
|
144
|
+
n = a.shape[1]
|
|
145
|
+
t0 = xp.full(n, -xp.inf)
|
|
146
|
+
t1 = xp.full(n, xp.inf)
|
|
147
|
+
enter_axis = xp.zeros(n, dtype=int)
|
|
148
|
+
exit_axis = xp.zeros(n, dtype=int)
|
|
149
|
+
for k in range(shape.ndim):
|
|
150
|
+
moving = d[k] != 0
|
|
151
|
+
safe = xp.where(moving, d[k], 1.0)
|
|
152
|
+
lo = (lower[k] - a[k]) / safe
|
|
153
|
+
hi = (upper[k] - a[k]) / safe
|
|
154
|
+
near = xp.where(moving, xp.minimum(lo, hi), -xp.inf)
|
|
155
|
+
far = xp.where(moving, xp.maximum(lo, hi), xp.inf)
|
|
156
|
+
# a step parallel to this axis's faces never crosses them; outside the slab
|
|
157
|
+
# it misses the box entirely
|
|
158
|
+
outside = ~moving & ((a[k] < lower[k]) | (a[k] > upper[k]))
|
|
159
|
+
near = xp.where(outside, xp.inf, near)
|
|
160
|
+
far = xp.where(outside, -xp.inf, far)
|
|
161
|
+
enter_axis = xp.where(near > t0, k, enter_axis)
|
|
162
|
+
exit_axis = xp.where(far < t1, k, exit_axis)
|
|
163
|
+
t0 = xp.maximum(t0, near)
|
|
164
|
+
t1 = xp.minimum(t1, far)
|
|
165
|
+
t, entering = _first_of_interval(t0, t1)
|
|
166
|
+
axis = xp.where(entering, enter_axis, exit_axis)
|
|
167
|
+
# leaving through a face moves along +d; entering moves against the face normal
|
|
168
|
+
sign = xp.where(entering, -1.0, 1.0)
|
|
169
|
+
normal = xp.zeros(a.shape)
|
|
170
|
+
for k in range(shape.ndim):
|
|
171
|
+
normal[k] = xp.where(axis == k, sign * xp.sign(d[k]), 0.0)
|
|
172
|
+
return t, _flip(shape, normal)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _polygon(shape, a, d):
|
|
176
|
+
closed = getattr(shape, "closed", True)
|
|
177
|
+
starts, ends = [], []
|
|
178
|
+
for loop in shape.outlines():
|
|
179
|
+
loop = np.asarray(loop, float)
|
|
180
|
+
if closed:
|
|
181
|
+
starts.append(loop)
|
|
182
|
+
ends.append(np.roll(loop, -1, axis=1))
|
|
183
|
+
else:
|
|
184
|
+
starts.append(loop[:, :-1])
|
|
185
|
+
ends.append(loop[:, 1:])
|
|
186
|
+
p = xp.asarray(np.concatenate(starts, axis=1))
|
|
187
|
+
e = xp.asarray(np.concatenate(ends, axis=1)) - p
|
|
188
|
+
edge_length = xp.sqrt(xp.sum(e * e, axis=0))
|
|
189
|
+
n = a.shape[1]
|
|
190
|
+
chunk = max(1, MAX_PAIRS // max(1, p.shape[1]))
|
|
191
|
+
t = xp.full(n, xp.inf)
|
|
192
|
+
edge = xp.zeros(n, dtype=int)
|
|
193
|
+
for lo in range(0, n, chunk):
|
|
194
|
+
sl = slice(lo, min(n, lo + chunk))
|
|
195
|
+
t_part, edge_part = _polygon_chunk(a[:, sl], d[:, sl], p, e, edge_length)
|
|
196
|
+
t[sl] = t_part
|
|
197
|
+
edge[sl] = edge_part
|
|
198
|
+
# right-hand normal of the edge: out of a counter-clockwise loop's inside
|
|
199
|
+
ex, ey = e[0][edge], e[1][edge]
|
|
200
|
+
length = edge_length[edge]
|
|
201
|
+
normal = xp.stack((ey / length, -ex / length))
|
|
202
|
+
if closed:
|
|
203
|
+
return t, _flip(shape, normal)
|
|
204
|
+
facing = xp.sum(normal * d, axis=0)
|
|
205
|
+
return t, xp.where(facing > 0, -normal, normal)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _polygon_chunk(a, d, p, e, edge_length):
|
|
209
|
+
d0, d1 = d[0][:, None], d[1][:, None]
|
|
210
|
+
w0 = p[0][None, :] - a[0][:, None]
|
|
211
|
+
w1 = p[1][None, :] - a[1][:, None]
|
|
212
|
+
den = d0 * e[1][None, :] - d1 * e[0][None, :]
|
|
213
|
+
step = xp.sqrt(d0 * d0 + d1 * d1)
|
|
214
|
+
proper = xp.abs(den) > 1e-14 * step * edge_length[None, :]
|
|
215
|
+
safe = xp.where(proper, den, 1.0)
|
|
216
|
+
t = (w0 * e[1][None, :] - w1 * e[0][None, :]) / safe
|
|
217
|
+
u = (w0 * d1 - w1 * d0) / safe
|
|
218
|
+
tol = 1e-12
|
|
219
|
+
valid = (
|
|
220
|
+
proper & (u >= -tol) & (u <= 1 + tol) & (t > START_TOLERANCE) & (t <= 1 + tol)
|
|
221
|
+
)
|
|
222
|
+
t = xp.where(valid, xp.minimum(t, 1.0), xp.inf)
|
|
223
|
+
edge = xp.argmin(t, axis=1) if t.shape[1] else xp.zeros(t.shape[0], dtype=int)
|
|
224
|
+
first = (
|
|
225
|
+
xp.take_along_axis(t, edge[:, None], axis=1)[:, 0]
|
|
226
|
+
if t.shape[1]
|
|
227
|
+
else xp.full(t.shape[0], xp.inf)
|
|
228
|
+
)
|
|
229
|
+
return first, edge
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _transformed(shape, a, d, samples, iterations):
|
|
233
|
+
inverse = xp.asarray(shape.inverse)
|
|
234
|
+
offset = xp.asarray(shape.offset)[:, None]
|
|
235
|
+
local_a = inverse @ (a - offset)
|
|
236
|
+
local_d = inverse @ d
|
|
237
|
+
t, normal, exact = _first(shape.source, local_a, local_d, samples, iterations)
|
|
238
|
+
normal = inverse.T @ xp.where(xp.isfinite(normal), normal, 0.0)
|
|
239
|
+
length = xp.sqrt(xp.sum(normal * normal, axis=0))
|
|
240
|
+
normal = normal / xp.where(length > 0, length, xp.nan)
|
|
241
|
+
return t, normal, exact
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _sampled(shape, a, d, samples, iterations):
|
|
245
|
+
"""First membership change along each segment, sampled then bisected."""
|
|
246
|
+
n = a.shape[1]
|
|
247
|
+
ndim = a.shape[0]
|
|
248
|
+
if n == 0:
|
|
249
|
+
return xp.full(0, xp.inf), xp.zeros((ndim, 0))
|
|
250
|
+
ts = xp.linspace(0.0, 1.0, samples + 1)
|
|
251
|
+
state = _inside(shape, a, d, ts) # (n, samples + 1)
|
|
252
|
+
change = state[:, :-1] != state[:, 1:]
|
|
253
|
+
has = xp.any(change, axis=1)
|
|
254
|
+
index = xp.argmax(change, axis=1)
|
|
255
|
+
lo = ts[index]
|
|
256
|
+
hi = ts[index + 1]
|
|
257
|
+
start_state = xp.take_along_axis(state, index[:, None], axis=1)[:, 0]
|
|
258
|
+
for _ in range(iterations):
|
|
259
|
+
mid = 0.5 * (lo + hi)
|
|
260
|
+
same = _contains(shape, a + mid[None, :] * d) == start_state
|
|
261
|
+
lo = xp.where(same, mid, lo)
|
|
262
|
+
hi = xp.where(same, hi, mid)
|
|
263
|
+
t = 0.5 * (lo + hi)
|
|
264
|
+
t = xp.where(has & (t > START_TOLERANCE), t, xp.inf)
|
|
265
|
+
return t, _estimated_normal(shape, a + xp.where(has, t, 0.0)[None, :] * d)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _contains(shape, points):
|
|
269
|
+
return xp.asarray(shape.contains_points(points), dtype=bool)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _inside(shape, a, d, ts):
|
|
273
|
+
"""Membership ``(n, len(ts))`` at ``a + ts * d``, in chunks of at most MAX_PAIRS."""
|
|
274
|
+
n, m = a.shape[1], ts.shape[0]
|
|
275
|
+
rows = []
|
|
276
|
+
chunk = max(1, MAX_PAIRS // m)
|
|
277
|
+
for lo in range(0, n, chunk):
|
|
278
|
+
sl = slice(lo, min(n, lo + chunk))
|
|
279
|
+
points = a[:, sl, None] + d[:, sl, None] * ts[None, None, :]
|
|
280
|
+
k = points.shape[1]
|
|
281
|
+
rows.append(_contains(shape, points.reshape(a.shape[0], k * m)).reshape(k, m))
|
|
282
|
+
return xp.concatenate(rows, axis=0)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _estimated_normal(shape, points):
|
|
286
|
+
"""Unit normal out of the contains-region from membership in many directions.
|
|
287
|
+
|
|
288
|
+
The vectorised form of :func:`simgeometry.queries.estimate_surface_normal`: each
|
|
289
|
+
point is probed a small distance away in a fan (2D) or sphere (3D) of directions.
|
|
290
|
+
"""
|
|
291
|
+
ndim, n = points.shape
|
|
292
|
+
if ndim == 1:
|
|
293
|
+
directions = np.array([[-1.0, 1.0]])
|
|
294
|
+
elif ndim == 2:
|
|
295
|
+
theta = np.linspace(0, 2 * np.pi, 64, endpoint=False)
|
|
296
|
+
directions = np.stack((np.cos(theta), np.sin(theta)))
|
|
297
|
+
else:
|
|
298
|
+
indices = np.arange(128, dtype=float) + 0.5
|
|
299
|
+
z = 1 - 2 * indices / 128
|
|
300
|
+
theta = np.pi * (1 + np.sqrt(5)) * indices
|
|
301
|
+
radial = np.sqrt(1 - z * z)
|
|
302
|
+
directions = np.stack((radial * np.cos(theta), radial * np.sin(theta), z))
|
|
303
|
+
extent = np.asarray(xp.to_numpy(shape.upper_bound), float) - np.asarray(
|
|
304
|
+
xp.to_numpy(shape.lower_bound), float
|
|
305
|
+
)
|
|
306
|
+
scale = float(np.max(extent)) if np.all(np.isfinite(extent)) else 1.0
|
|
307
|
+
epsilon = max(scale * 1e-5, np.finfo(float).eps * 128)
|
|
308
|
+
directions = xp.asarray(directions)
|
|
309
|
+
m = directions.shape[1]
|
|
310
|
+
if n == 0:
|
|
311
|
+
return xp.zeros((ndim, 0))
|
|
312
|
+
probes = points[:, :, None] + epsilon * directions[:, None, :]
|
|
313
|
+
inside = _contains(shape, probes.reshape(ndim, n * m)).reshape(n, m)
|
|
314
|
+
weights = xp.where(inside, -1.0, 1.0) # (n, m)
|
|
315
|
+
normal = weights @ directions.T # (n, ndim)
|
|
316
|
+
length = xp.sqrt(xp.sum(normal * normal, axis=1))
|
|
317
|
+
on_boundary = xp.any(inside, axis=1) & ~xp.all(inside, axis=1) & (length > 0)
|
|
318
|
+
normal = normal / xp.where(length > 0, length, 1.0)[:, None]
|
|
319
|
+
return xp.where(on_boundary[None, :], normal.T, xp.nan)
|
simgeometry/cut_edges.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Geometry of grid-edge intersections, independent of differential operators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from simgeometry.operations import mask
|
|
10
|
+
from simgeometry.queries import intersection_fractions
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class BracketedEdgeGeometry:
|
|
15
|
+
"""Single crossings from fluid nodes to solid neighbours.
|
|
16
|
+
|
|
17
|
+
``fractions[(axis, side)]`` is a grid-shaped array (NaN for uncut edges),
|
|
18
|
+
with side 0 lower and 1 upper. Fractions are *not* clamped. This fast path
|
|
19
|
+
assumes one crossing on every outside-to-inside bracket. Use ``cut_edges``
|
|
20
|
+
to inspect multiple hits or objects smaller than a grid cell.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
solid: np.ndarray
|
|
24
|
+
owners: np.ndarray
|
|
25
|
+
fractions: dict
|
|
26
|
+
cut: np.ndarray
|
|
27
|
+
nodes: np.ndarray
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def bracketed_edge_geometry(
|
|
31
|
+
grid, objects, *, spacing=None, object_masks=None, periodic=None
|
|
32
|
+
):
|
|
33
|
+
"""Vectorized edge bisections used by embedded-boundary finite differences.
|
|
34
|
+
|
|
35
|
+
Objects overlap with last-listed precedence. No boundary-condition values,
|
|
36
|
+
stencil weights, minimum-distance clamps, or solver policy are applied.
|
|
37
|
+
"""
|
|
38
|
+
objects = list(objects or [])
|
|
39
|
+
shape = tuple(int(n) for n in grid.num_gridpoints)
|
|
40
|
+
spacing = tuple(
|
|
41
|
+
float(h) for h in (grid.grid_spacing if spacing is None else spacing)
|
|
42
|
+
)
|
|
43
|
+
periodic = tuple(
|
|
44
|
+
periodic
|
|
45
|
+
if periodic is not None
|
|
46
|
+
else (getattr(grid, "periodic", None) or (False,) * grid.ndim)
|
|
47
|
+
)
|
|
48
|
+
if (
|
|
49
|
+
len(spacing) != grid.ndim
|
|
50
|
+
or any(h <= 0 for h in spacing)
|
|
51
|
+
or len(periodic) != grid.ndim
|
|
52
|
+
):
|
|
53
|
+
raise ValueError("Spacing and periodicity must match the grid dimension")
|
|
54
|
+
if any(obj.ndim != grid.ndim for obj in objects):
|
|
55
|
+
raise ValueError("Objects and grid must have the same dimension")
|
|
56
|
+
if object_masks is None:
|
|
57
|
+
object_masks = [obj.node_mask(grid) for obj in objects]
|
|
58
|
+
if len(object_masks) != len(objects):
|
|
59
|
+
raise ValueError("Each object needs one node mask")
|
|
60
|
+
solid = np.zeros(shape, dtype=bool)
|
|
61
|
+
owners = np.full(shape, -1, dtype=int)
|
|
62
|
+
for number, m in enumerate(object_masks):
|
|
63
|
+
m = np.asarray(m, dtype=bool)
|
|
64
|
+
if m.shape != shape:
|
|
65
|
+
raise ValueError("Object masks must match the grid shape")
|
|
66
|
+
solid |= m
|
|
67
|
+
owners[m] = number
|
|
68
|
+
nodes = grid.node_points().reshape(grid.ndim, *shape)
|
|
69
|
+
lower = np.asarray(grid.lower_bound)
|
|
70
|
+
periods = np.asarray(shape) * spacing
|
|
71
|
+
|
|
72
|
+
def wrap(points):
|
|
73
|
+
points = points.copy()
|
|
74
|
+
for axis, flag in enumerate(periodic):
|
|
75
|
+
if flag:
|
|
76
|
+
offset = (points[axis] - lower[axis]) % periods[axis]
|
|
77
|
+
# a node's image across the face, built as node + step, can land a
|
|
78
|
+
# rounding error short of the period; it is the first node
|
|
79
|
+
offset[periods[axis] - offset <= 1e-9 * periods[axis]] = 0.0
|
|
80
|
+
points[axis] = lower[axis] + offset
|
|
81
|
+
return points
|
|
82
|
+
|
|
83
|
+
fractions, cut = {}, np.zeros(shape, dtype=bool)
|
|
84
|
+
for dim in range(grid.ndim):
|
|
85
|
+
for side, offset in enumerate((-1, 1)):
|
|
86
|
+
fraction = np.full(shape, np.nan)
|
|
87
|
+
if periodic[dim]:
|
|
88
|
+
far_solid, far_owners = (
|
|
89
|
+
np.roll(solid, -offset, axis=dim),
|
|
90
|
+
np.roll(owners, -offset, axis=dim),
|
|
91
|
+
)
|
|
92
|
+
step = np.zeros((grid.ndim, 1))
|
|
93
|
+
step[dim] = offset * spacing[dim]
|
|
94
|
+
for number, obj in enumerate(objects):
|
|
95
|
+
mine = ~solid & far_solid & (far_owners == number)
|
|
96
|
+
if mine.any():
|
|
97
|
+
outside = nodes[:, mine]
|
|
98
|
+
fraction[mine] = intersection_fractions(
|
|
99
|
+
obj, outside, outside + step, wrap=wrap
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
near, far = [slice(None)] * grid.ndim, [slice(None)] * grid.ndim
|
|
103
|
+
near[dim] = slice(1, None) if offset == -1 else slice(None, -1)
|
|
104
|
+
far[dim] = slice(None, -1) if offset == -1 else slice(1, None)
|
|
105
|
+
near, far = tuple(near), tuple(far)
|
|
106
|
+
for number, obj in enumerate(objects):
|
|
107
|
+
mine = ~solid[near] & solid[far] & (owners[far] == number)
|
|
108
|
+
if mine.any():
|
|
109
|
+
outside = nodes[(slice(None), *near)][:, mine]
|
|
110
|
+
inside = nodes[(slice(None), *far)][:, mine]
|
|
111
|
+
fraction[near][mine] = intersection_fractions(
|
|
112
|
+
obj, outside, inside
|
|
113
|
+
)
|
|
114
|
+
fractions[dim, side] = fraction
|
|
115
|
+
cut |= np.isfinite(fraction)
|
|
116
|
+
return BracketedEdgeGeometry(solid, owners, fractions, cut, nodes)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class CutEdges:
|
|
121
|
+
"""Every detected hit, ordered by axis, lower-node index and fraction.
|
|
122
|
+
|
|
123
|
+
Multiple hits can share an edge index. ``points``/``normals`` are (ndim, n),
|
|
124
|
+
``indices`` is (n, ndim), and ``object_names`` identifies each boundary.
|
|
125
|
+
This diagnostic path visits every edge; bracketed_edge_geometry is the
|
|
126
|
+
batched solver path. ``exact=False`` identifies a sampled search.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
grid: object
|
|
130
|
+
indices: np.ndarray
|
|
131
|
+
axes: np.ndarray
|
|
132
|
+
fractions: np.ndarray
|
|
133
|
+
points: np.ndarray
|
|
134
|
+
normals: np.ndarray
|
|
135
|
+
object_names: tuple
|
|
136
|
+
exact: bool
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def distances(self):
|
|
140
|
+
"""Distance from the edge's lower node, in physical units."""
|
|
141
|
+
return self.fractions * np.asarray(self.grid.grid_spacing)[self.axes]
|
|
142
|
+
|
|
143
|
+
def show(
|
|
144
|
+
self, *, ax=None, normals=False, display=True, save=None, x=None, y=None, z=None
|
|
145
|
+
):
|
|
146
|
+
"""Plot hits and optional normals; a fixed axis selects one grid plane."""
|
|
147
|
+
import matplotlib.pyplot as plt
|
|
148
|
+
|
|
149
|
+
axes = list(range(self.grid.ndim))
|
|
150
|
+
selection = [(i, v) for i, v in enumerate((x, y, z)) if v is not None]
|
|
151
|
+
if len(selection) > 1 or any(i >= self.grid.ndim for i, _ in selection):
|
|
152
|
+
raise ValueError("Select at most one existing grid plane")
|
|
153
|
+
keep = np.ones(len(self.fractions), dtype=bool)
|
|
154
|
+
if selection:
|
|
155
|
+
axis, value = selection[0]
|
|
156
|
+
axes.remove(axis)
|
|
157
|
+
keep &= np.isclose(
|
|
158
|
+
self.points[axis],
|
|
159
|
+
value,
|
|
160
|
+
atol=float(self.grid.grid_spacing[axis]) * 1e-7,
|
|
161
|
+
rtol=0,
|
|
162
|
+
)
|
|
163
|
+
if len(axes) == 3:
|
|
164
|
+
ax = ax or plt.figure().add_subplot(projection="3d")
|
|
165
|
+
ax.scatter(*self.points[:, keep], s=12)
|
|
166
|
+
if normals:
|
|
167
|
+
ax.quiver(
|
|
168
|
+
*self.points[:, keep],
|
|
169
|
+
*self.normals[:, keep],
|
|
170
|
+
length=min(self.grid.grid_spacing) * 0.3,
|
|
171
|
+
)
|
|
172
|
+
elif len(axes) == 2:
|
|
173
|
+
ax = ax or plt.subplots()[1]
|
|
174
|
+
p, n = self.points[axes][:, keep], self.normals[axes][:, keep]
|
|
175
|
+
ax.scatter(*p, s=15)
|
|
176
|
+
if normals:
|
|
177
|
+
ax.quiver(
|
|
178
|
+
*p,
|
|
179
|
+
*n,
|
|
180
|
+
angles="xy",
|
|
181
|
+
scale_units="xy",
|
|
182
|
+
scale=3 / min(self.grid.grid_spacing),
|
|
183
|
+
)
|
|
184
|
+
ax.set_aspect("equal")
|
|
185
|
+
else:
|
|
186
|
+
ax = ax or plt.subplots()[1]
|
|
187
|
+
ax.scatter(self.points[axes[0], keep], np.zeros(keep.sum()))
|
|
188
|
+
ax.set_xlabel("xyz"[axes[0]])
|
|
189
|
+
if len(axes) > 1:
|
|
190
|
+
ax.set_ylabel("xyz"[axes[1]])
|
|
191
|
+
if save:
|
|
192
|
+
ax.figure.savefig(save, bbox_inches="tight")
|
|
193
|
+
if display:
|
|
194
|
+
plt.show()
|
|
195
|
+
return ax
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cut_edges(geometry, grid, *, samples=64, tolerance=1e-10):
|
|
199
|
+
"""Find all surface crossings, including objects between two fluid nodes.
|
|
200
|
+
|
|
201
|
+
Nonperiodic primitive edges are analytic. Periodic seam edges use wrapped
|
|
202
|
+
membership and sampling; the periodic discontinuity can itself be a hit.
|
|
203
|
+
General-function features narrower than a sampling interval can be missed.
|
|
204
|
+
"""
|
|
205
|
+
from simgeometry.shapes import Shape
|
|
206
|
+
|
|
207
|
+
if geometry.ndim != grid.ndim:
|
|
208
|
+
raise ValueError("Geometry and grid dimensions must match")
|
|
209
|
+
objects = getattr(geometry, "named_objects", {"geometry": geometry})
|
|
210
|
+
axes_coords = [np.asarray(a) for a in grid.get_grid_points()]
|
|
211
|
+
periodic = getattr(grid, "periodic", None) or (False,) * grid.ndim
|
|
212
|
+
records, exact = [], True
|
|
213
|
+
for axis in range(grid.ndim):
|
|
214
|
+
counts = [len(a) for a in axes_coords]
|
|
215
|
+
if not periodic[axis]:
|
|
216
|
+
counts[axis] -= 1
|
|
217
|
+
for index in np.ndindex(*counts):
|
|
218
|
+
a = np.array([coords[i] for coords, i in zip(axes_coords, index)])
|
|
219
|
+
b = a.copy()
|
|
220
|
+
b[axis] += grid.grid_spacing[axis]
|
|
221
|
+
for name, obj in objects.items():
|
|
222
|
+
target = obj
|
|
223
|
+
seam = periodic[axis] and index[axis] == len(axes_coords[axis]) - 1
|
|
224
|
+
if seam:
|
|
225
|
+
|
|
226
|
+
class Wrapped(Shape):
|
|
227
|
+
def __init__(self, source):
|
|
228
|
+
self.source = source
|
|
229
|
+
super().__init__(
|
|
230
|
+
source.ndim, source.lower_bound, source.upper_bound
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def contains_points(self, p):
|
|
234
|
+
p = np.asarray(p).copy()
|
|
235
|
+
p[axis] = grid.lower_bound[axis] + (
|
|
236
|
+
p[axis] - grid.lower_bound[axis]
|
|
237
|
+
) % (grid.upper_bound[axis] - grid.lower_bound[axis])
|
|
238
|
+
return self.source.contains_points(p)
|
|
239
|
+
|
|
240
|
+
def contains(self, p):
|
|
241
|
+
return bool(mask(self, np.asarray(p)[:, None])[0])
|
|
242
|
+
|
|
243
|
+
target = Wrapped(obj)
|
|
244
|
+
hit = target.intersect_segment(
|
|
245
|
+
a, b, samples=samples, tolerance=tolerance
|
|
246
|
+
)
|
|
247
|
+
exact &= hit.exact
|
|
248
|
+
for i, fraction in enumerate(hit.fractions):
|
|
249
|
+
records.append(
|
|
250
|
+
(
|
|
251
|
+
index,
|
|
252
|
+
axis,
|
|
253
|
+
fraction,
|
|
254
|
+
hit.points[:, i],
|
|
255
|
+
hit.normals[:, i],
|
|
256
|
+
name,
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
records.sort(key=lambda r: (r[1], r[0], r[2], r[5]))
|
|
260
|
+
n = len(records)
|
|
261
|
+
return CutEdges(
|
|
262
|
+
grid,
|
|
263
|
+
np.array([r[0] for r in records], dtype=int).reshape(n, grid.ndim),
|
|
264
|
+
np.array([r[1] for r in records], dtype=int),
|
|
265
|
+
np.array([r[2] for r in records]),
|
|
266
|
+
np.array([r[3] for r in records]).reshape(n, grid.ndim).T,
|
|
267
|
+
np.array([r[4] for r in records]).reshape(n, grid.ndim).T,
|
|
268
|
+
tuple(r[5] for r in records),
|
|
269
|
+
exact,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def diagnose(geometry, grid, *, min_fraction=1e-3, samples=64):
|
|
274
|
+
"""Return evidence from node masks and edge hits, not a proof of resolution.
|
|
275
|
+
|
|
276
|
+
Reports overlap node counts, objects without solid nodes, edges with multiple
|
|
277
|
+
hits, and hits within min_fraction of an endpoint. A completely unsampled
|
|
278
|
+
generic feature may be absent from both nodes and hits.
|
|
279
|
+
"""
|
|
280
|
+
if not 0 <= min_fraction <= 0.5:
|
|
281
|
+
raise ValueError("min_fraction must be between zero and one half")
|
|
282
|
+
objects = getattr(geometry, "named_objects", {"geometry": geometry})
|
|
283
|
+
masks = {name: obj.node_mask(grid) for name, obj in objects.items()}
|
|
284
|
+
names = list(objects)
|
|
285
|
+
overlaps = {
|
|
286
|
+
f"{a} / {b}": int(np.count_nonzero(masks[a] & masks[b]))
|
|
287
|
+
for i, a in enumerate(names)
|
|
288
|
+
for b in names[i + 1 :]
|
|
289
|
+
if np.any(masks[a] & masks[b])
|
|
290
|
+
}
|
|
291
|
+
hits = cut_edges(geometry, grid, samples=samples)
|
|
292
|
+
counts = {}
|
|
293
|
+
for index, axis, name in zip(hits.indices, hits.axes, hits.object_names):
|
|
294
|
+
key = (tuple(index), int(axis), name)
|
|
295
|
+
counts[key] = counts.get(key, 0) + 1
|
|
296
|
+
return {
|
|
297
|
+
"overlap_nodes": overlaps,
|
|
298
|
+
"objects_without_solid_nodes": [
|
|
299
|
+
name for name in names if not masks[name].any()
|
|
300
|
+
],
|
|
301
|
+
"multiple_hit_edges": sum(n > 1 for n in counts.values()),
|
|
302
|
+
"short_distance_hits": int(
|
|
303
|
+
np.count_nonzero(
|
|
304
|
+
np.minimum(hits.fractions, 1 - hits.fractions) < min_fraction
|
|
305
|
+
)
|
|
306
|
+
),
|
|
307
|
+
"intersection_search_exact": hits.exact,
|
|
308
|
+
"scope": "node and edge samples; does not certify feature resolution",
|
|
309
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Axisymmetric (x, r) grids: node volumes, ring volumes and radial sampling.
|
|
2
|
+
|
|
3
|
+
A marker in cylindrical coordinates stands for a ring of charge around the axis. Its weight
|
|
4
|
+
is the number of physical particles in the ring. Markers are placed uniformly in ``(x, r)``
|
|
5
|
+
and carry weights proportional to ``r`` (:func:`radial_weight_factors`), so every cell holds
|
|
6
|
+
about the same number of markers, also next to the axis where beams are extracted.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def node_volumes(num_gridpoints, spacing) -> np.ndarray:
|
|
15
|
+
"""Return the volume [m^3] belonging to each node of an ``(x, r)`` grid with the
|
|
16
|
+
axis at ``r = 0``, shape ``(nx, nr)``.
|
|
17
|
+
|
|
18
|
+
The volumes are those of linear (cloud-in-cell) weighting, so a uniform density of
|
|
19
|
+
ring markers deposits a uniform density on every node: ``2 pi r_j h_r`` per unit
|
|
20
|
+
length inside, ``pi h_r^2 / 3`` on the axis and ``pi h_r (r_N - h_r / 3)`` on the
|
|
21
|
+
outer node, times ``h_x`` (``h_x / 2`` on the first and last x nodes).
|
|
22
|
+
"""
|
|
23
|
+
nx, nr = (int(n) for n in num_gridpoints)
|
|
24
|
+
hx, hr = (float(h) for h in spacing)
|
|
25
|
+
r = hr * np.arange(nr)
|
|
26
|
+
area = 2.0 * np.pi * r * hr
|
|
27
|
+
area[0] = np.pi * hr**2 / 3.0
|
|
28
|
+
area[-1] = np.pi * hr * (r[-1] - hr / 3.0)
|
|
29
|
+
length = np.full(nx, hx)
|
|
30
|
+
length[[0, -1]] = 0.5 * hx
|
|
31
|
+
return length[:, None] * area[None, :]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ring_volume(lower, upper) -> float:
|
|
35
|
+
"""Return the volume of the annular region ``lower <= (x, r) <= upper``."""
|
|
36
|
+
(x0, r0), (x1, r1) = lower, upper
|
|
37
|
+
return float(np.pi * (r1**2 - r0**2) * (x1 - x0))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def radial_weight_factors(r, r0: float, r1: float):
|
|
41
|
+
"""Return the weight factors ``2 r / (r0 + r1)`` (mean 1) of ring markers placed
|
|
42
|
+
uniformly in ``r`` on ``[r0, r1]`` that stand for a uniform density."""
|
|
43
|
+
return 2.0 * r / (r0 + r1) if r1 + r0 > 0.0 else 0.0 * r + 1.0
|