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