pyafv 0.3.8__cp311-cp311-musllinux_1_2_x86_64.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.
- pyafv/__init__.py +18 -0
- pyafv/_version.py +2 -0
- pyafv/backend.py +20 -0
- pyafv/cell_geom.cpython-310-x86_64-linux-gnu.so +0 -0
- pyafv/cell_geom.cpython-311-x86_64-linux-musl.so +0 -0
- pyafv/cell_geom_fallback.py +249 -0
- pyafv/finite_voronoi.py +985 -0
- pyafv/physical_params.py +157 -0
- pyafv-0.3.8.dist-info/METADATA +174 -0
- pyafv-0.3.8.dist-info/RECORD +12 -0
- pyafv-0.3.8.dist-info/WHEEL +5 -0
- pyafv-0.3.8.dist-info/licenses/LICENSE +21 -0
pyafv/finite_voronoi.py
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Finite Voronoi Model Simulator in 2D
|
|
3
|
+
====================================
|
|
4
|
+
Created by Wei Wang, 2025.
|
|
5
|
+
|
|
6
|
+
Key public entry points:
|
|
7
|
+
------------------------
|
|
8
|
+
- FiniteVoronoiSimulator: configure parameters.
|
|
9
|
+
- build(): build the finite Voronoi diagram and compute forces.
|
|
10
|
+
- plot_2d(): plot the finite Voronoi diagram with matplotlib.
|
|
11
|
+
- update_points(): update cell center positions.
|
|
12
|
+
- update_params(): update physical parameters.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# Enable postponed evaluation of annotations
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
# Only import typing modules when type checking, e.g., in VS Code or IDEs.
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
21
|
+
from scipy.spatial import Voronoi
|
|
22
|
+
import matplotlib.axes
|
|
23
|
+
import numpy
|
|
24
|
+
import typing
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
from .physical_params import PhysicalParams
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---- tiny helpers to avoid tiny allocations in hot loops ----
|
|
31
|
+
def _row_dot(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
32
|
+
"""Row-wise dot product for 2D arrays with shape (N,2)."""
|
|
33
|
+
return np.einsum("ij,ij->i", a, b)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FiniteVoronoiSimulator:
|
|
37
|
+
"""Simulator for the active-finite-Voronoi (AFV) model.
|
|
38
|
+
|
|
39
|
+
This class provides an interface to simulate the finite Voronoi model.
|
|
40
|
+
It wraps around the two backend implementations, which may be
|
|
41
|
+
either a Cython-accelerated version or a pure Python fallback.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
pts: (N,2) array of initial cell center positions.
|
|
45
|
+
phys: Physical parameters used within this simulator.
|
|
46
|
+
backend: Optional, specify "python" to force the use of the pure Python fallback implementation.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ValueError: If *pts* does not have shape (N,2).
|
|
50
|
+
TypeError: If *phys* is not an instance of PhysicalParams.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, pts: numpy.ndarray, phys: PhysicalParams, backend: typing.Literal["cython", "python"] | None = None):
|
|
54
|
+
"""
|
|
55
|
+
Constructor of the simulator.
|
|
56
|
+
"""
|
|
57
|
+
from .backend import backend_impl, _BACKEND_NAME
|
|
58
|
+
from scipy.spatial import Voronoi
|
|
59
|
+
self._voronoi = Voronoi
|
|
60
|
+
|
|
61
|
+
pts = np.asarray(pts, dtype=float)
|
|
62
|
+
if pts.ndim != 2 or pts.shape[1] != 2:
|
|
63
|
+
raise ValueError("pts must have shape (N,2)")
|
|
64
|
+
if not isinstance(phys, PhysicalParams):
|
|
65
|
+
raise TypeError("phys must be an instance of PhysicalParams")
|
|
66
|
+
|
|
67
|
+
self.pts = pts.copy() # (N,2) array of initial points
|
|
68
|
+
self.N = pts.shape[0] # Number of points
|
|
69
|
+
self.phys = phys
|
|
70
|
+
self._preferred_areas = np.full(self.N, phys.A0, dtype=float) # (N,) preferred areas A0
|
|
71
|
+
|
|
72
|
+
if backend != "python":
|
|
73
|
+
self._BACKEND = _BACKEND_NAME
|
|
74
|
+
self._impl = backend_impl
|
|
75
|
+
|
|
76
|
+
if self._BACKEND not in {"cython", "numba"}: # pragma: no cover
|
|
77
|
+
# raise warning to inform user about fallback
|
|
78
|
+
import warnings
|
|
79
|
+
warnings.warn(
|
|
80
|
+
"Could not import the Cython-built extension module. "
|
|
81
|
+
"Falling back to the pure Python implementation, which may be slower. "
|
|
82
|
+
"To enable the accelerated version, ensure that all dependencies are installed.",
|
|
83
|
+
RuntimeWarning,
|
|
84
|
+
stacklevel=2,
|
|
85
|
+
)
|
|
86
|
+
else: # force the use of the pure Python fallback implementation.
|
|
87
|
+
self._BACKEND = "python"
|
|
88
|
+
from . import cell_geom_fallback as _impl
|
|
89
|
+
self._impl = _impl
|
|
90
|
+
|
|
91
|
+
# --------------------- Voronoi construction & extension ---------------------
|
|
92
|
+
def _build_voronoi_with_extensions(self) -> tuple[Voronoi, np.ndarray, list[list[int]], int, dict[tuple[int,int], int], dict[int, list[int]]]:
|
|
93
|
+
"""
|
|
94
|
+
Build standard Voronoi structure for current points.
|
|
95
|
+
|
|
96
|
+
For N<=2, emulate regions.
|
|
97
|
+
For N>=3, extend infinite ridges, add extension vertices, and update
|
|
98
|
+
regions accordingly. Return the augmented structures.
|
|
99
|
+
|
|
100
|
+
.. warning::
|
|
101
|
+
|
|
102
|
+
This is an internal method. Use with caution.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
tuple[scipy.spatial.Voronoi, numpy.ndarray, list[list[int]], int, dict[tuple[int,int], int], dict[int, list[int]]] : A tuple containing:
|
|
106
|
+
|
|
107
|
+
- **vor**: SciPy Voronoi object for current points with extensions.
|
|
108
|
+
- **vertices_all**: (M,2) array of all Voronoi vertices including extensions.
|
|
109
|
+
- **ridge_vertices_all**: list of lists of vertex indices for each ridge, including extensions.
|
|
110
|
+
- **num_vertices**: Number of Voronoi vertices before adding extension.
|
|
111
|
+
- **vertexpair2ridge**: dict mapping vertex index pairs to ridge index.
|
|
112
|
+
- **vertex_points**: dict mapping vertex index to list of associated point indices.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
Voronoi = self._voronoi
|
|
116
|
+
|
|
117
|
+
r = self.phys.r
|
|
118
|
+
pts = self.pts
|
|
119
|
+
N = self.N
|
|
120
|
+
|
|
121
|
+
# Special handling: N == 1, 2
|
|
122
|
+
if N == 1:
|
|
123
|
+
vor = Voronoi(np.random.rand(3, 2))
|
|
124
|
+
vor.points[:N] = pts
|
|
125
|
+
vor.vertices = np.array([]).reshape(-1, 2)
|
|
126
|
+
|
|
127
|
+
vor.regions = [[]]
|
|
128
|
+
vor.ridge_vertices = np.array([]).reshape(-1, 2)
|
|
129
|
+
vor.point_region = np.array([0])
|
|
130
|
+
vor.ridge_points = np.array([]).reshape(-1, 2)
|
|
131
|
+
|
|
132
|
+
vertices_all = vor.vertices
|
|
133
|
+
ridge_vertices_all = vor.ridge_vertices
|
|
134
|
+
num_vertices = len(vor.vertices)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if N == 2:
|
|
138
|
+
vor = Voronoi(np.random.rand(3, 2))
|
|
139
|
+
vor.points[:N] = pts
|
|
140
|
+
|
|
141
|
+
p1, p2 = pts
|
|
142
|
+
center = (p1 + p2) / 2.0
|
|
143
|
+
|
|
144
|
+
t = p1 - p2
|
|
145
|
+
t_norm = np.linalg.norm(t)
|
|
146
|
+
t /= t_norm
|
|
147
|
+
|
|
148
|
+
n = np.array([-t[1], t[0]]) # Perpendicular vector of t
|
|
149
|
+
|
|
150
|
+
if t_norm >= 2 * r:
|
|
151
|
+
vor.vertices = np.array([]).reshape(-1, 2)
|
|
152
|
+
vor.regions = [[], []]
|
|
153
|
+
vor.ridge_vertices = np.array([]).reshape(-1, 2)
|
|
154
|
+
vor.ridge_points = np.array([]).reshape(-1, 2)
|
|
155
|
+
else:
|
|
156
|
+
v1 = center + 2 * r * n
|
|
157
|
+
v2 = center - 2 * r * n
|
|
158
|
+
v3 = center + 3 * r * (p1 - center) / np.linalg.norm(p2 - center)
|
|
159
|
+
v4 = center + 3 * r * (p2 - center) / np.linalg.norm(p2 - center)
|
|
160
|
+
vor.vertices = np.array([v1, v2, v3, v4]).reshape(-1, 2)
|
|
161
|
+
vor.ridge_vertices = np.array([[0, 1], [1, 2], [0, 2], [0, 3], [1, 3]])
|
|
162
|
+
vor.regions = [[0, 1, 2], [0, 1, 3]]
|
|
163
|
+
vor.ridge_points = np.array([[0, 1], [-1, 0], [-1, 0], [-1, 1], [-1, 1]])
|
|
164
|
+
|
|
165
|
+
vor.point_region = np.array([0, 1])
|
|
166
|
+
|
|
167
|
+
vertices_all = vor.vertices
|
|
168
|
+
ridge_vertices_all = vor.ridge_vertices
|
|
169
|
+
num_vertices = len(vor.vertices)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# N >= 3 (vectorized main path)
|
|
173
|
+
if N >= 3:
|
|
174
|
+
vor = Voronoi(pts)
|
|
175
|
+
"""
|
|
176
|
+
Basic info from Voronoi object:
|
|
177
|
+
-------------------------------
|
|
178
|
+
vor.vertices # (K,2) Voronoi vertices (finite)
|
|
179
|
+
vor.ridge_points # (R,2) pairs of input point indices sharing a Voronoi ridge
|
|
180
|
+
vor.ridge_vertices # list of vertex-index lists (may contain -1 for infinity)
|
|
181
|
+
vor.point_region # for each input point, the region index
|
|
182
|
+
vor.regions # list of regions; each is a list of vertex indices (may include -1)
|
|
183
|
+
"""
|
|
184
|
+
center = np.mean(pts, axis=0)
|
|
185
|
+
|
|
186
|
+
span_x = np.ptp(vor.vertices[:, 0]) # span in x
|
|
187
|
+
span_y = np.ptp(vor.vertices[:, 1]) # span in y
|
|
188
|
+
pts_span_x = np.ptp(pts[:, 0]) # span in x
|
|
189
|
+
pts_span_y = np.ptp(pts[:, 1]) # span in y
|
|
190
|
+
span = max(span_x, span_y, pts_span_x, pts_span_y, 10. * r) # overall span
|
|
191
|
+
|
|
192
|
+
# Base copies
|
|
193
|
+
vertices_base = vor.vertices # (K,2)
|
|
194
|
+
rv_arr = np.asarray(vor.ridge_vertices, dtype=int) # (R,2) may contain -1
|
|
195
|
+
rp_arr = np.asarray(vor.ridge_points, dtype=int) # (R,2)
|
|
196
|
+
|
|
197
|
+
# Remove -1 from regions (we will append extension ids later)
|
|
198
|
+
vor.regions = [[vid for vid in region if vid >= 0] for region in vor.regions]
|
|
199
|
+
|
|
200
|
+
# Identify ridges with an infinite endpoint
|
|
201
|
+
inf_mask = (rv_arr == -1).any(axis=1) # (R,)
|
|
202
|
+
num_inf = int(inf_mask.sum())
|
|
203
|
+
|
|
204
|
+
if num_inf > 0:
|
|
205
|
+
rv_inf = rv_arr[inf_mask] # (M,2)
|
|
206
|
+
rp_inf = rp_arr[inf_mask] # (M,2)
|
|
207
|
+
|
|
208
|
+
# finite endpoint index per infinite ridge
|
|
209
|
+
v_idx_finite = np.where(rv_inf[:, 0] != -1, rv_inf[:, 0], rv_inf[:, 1]) # (M,)
|
|
210
|
+
|
|
211
|
+
# geometry for normals
|
|
212
|
+
p1 = pts[rp_inf[:, 0]] # (M,2)
|
|
213
|
+
p2 = pts[rp_inf[:, 1]] # (M,2)
|
|
214
|
+
mid = (p1 + p2) / 2.0 # (M,2)
|
|
215
|
+
|
|
216
|
+
t = p1 - p2 # (M,2)
|
|
217
|
+
t_norm = np.linalg.norm(t, axis=1, keepdims=True) # (M,1)
|
|
218
|
+
t_unit = t / t_norm
|
|
219
|
+
|
|
220
|
+
# (M,2), perpendicular
|
|
221
|
+
n = np.column_stack([-t_unit[:, 1], t_unit[:, 0]])
|
|
222
|
+
|
|
223
|
+
# Ensure "outward" normal
|
|
224
|
+
sign = np.einsum("ij,ij->i", (mid - center), n) # (M,)
|
|
225
|
+
n[sign < 0] *= -1.0
|
|
226
|
+
|
|
227
|
+
# Build extension points
|
|
228
|
+
ext = vertices_base[v_idx_finite] + (100.0 * span) * n # (M,2), long rays (extension must be long enough!)
|
|
229
|
+
|
|
230
|
+
# Concatenate once
|
|
231
|
+
K = vertices_base.shape[0]
|
|
232
|
+
vertices_all = np.vstack([vertices_base, ext]) # (K+M,2)
|
|
233
|
+
|
|
234
|
+
# New vertex ids for extensions
|
|
235
|
+
ext_ids = np.arange(K, K + num_inf, dtype=int) # (M,)
|
|
236
|
+
|
|
237
|
+
# Replace -1 with ext_ids in a vectorized way
|
|
238
|
+
rv_new = rv_arr.copy() # (R,2)
|
|
239
|
+
rv_sub = rv_new[inf_mask] # view (M,2)
|
|
240
|
+
|
|
241
|
+
pos0 = (rv_sub[:, 0] == -1) # (M,)
|
|
242
|
+
rv_sub[pos0, 0] = ext_ids[pos0]
|
|
243
|
+
rv_sub[~pos0, 1] = ext_ids[~pos0]
|
|
244
|
+
rv_new[inf_mask] = rv_sub
|
|
245
|
+
|
|
246
|
+
ridge_vertices_all = rv_new.tolist()
|
|
247
|
+
|
|
248
|
+
# Append extension id to both adjacent regions (list-of-lists => tiny loop)
|
|
249
|
+
for m in range(num_inf):
|
|
250
|
+
i1, i2 = rp_inf[m]
|
|
251
|
+
e = ext_ids[m]
|
|
252
|
+
region_id = vor.point_region[i1]
|
|
253
|
+
vor.regions[region_id].append(e)
|
|
254
|
+
region_id = vor.point_region[i2]
|
|
255
|
+
vor.regions[region_id].append(e)
|
|
256
|
+
else:
|
|
257
|
+
vertices_all = vertices_base.copy()
|
|
258
|
+
ridge_vertices_all = rv_arr.tolist()
|
|
259
|
+
|
|
260
|
+
# number of native (finite) vertices
|
|
261
|
+
num_vertices = len(vor.vertices)
|
|
262
|
+
|
|
263
|
+
# Build vertexpair2ridge and vertex_points using Cython/Python backend function
|
|
264
|
+
vertexpair2ridge, vertex_points = self._impl.build_vertexpair_and_vertexpoints(ridge_vertices_all, vor.ridge_points, num_vertices, N)
|
|
265
|
+
|
|
266
|
+
return vor, vertices_all, ridge_vertices_all, num_vertices, vertexpair2ridge, vertex_points
|
|
267
|
+
|
|
268
|
+
# --------------------- Geometry & energy contributions per cell ---------------------
|
|
269
|
+
def _per_cell_geometry(self, vor: Voronoi, vertices_all: np.ndarray, ridge_vertices_all: np.ndarray, num_vertices: int, vertexpair2ridge: dict[tuple[int, int], int]) -> dict[str,object]:
|
|
270
|
+
"""
|
|
271
|
+
Build the finite-Voronoi per-cell geometry and energy contributions.
|
|
272
|
+
|
|
273
|
+
Iterate each cell to:
|
|
274
|
+
- sort polygon/arc vertices around each cell
|
|
275
|
+
- classify edges (1 = straight Voronoi edge; 0 = circular arc)
|
|
276
|
+
- compute area/perimeter for each cell
|
|
277
|
+
- accumulate derivatives w.r.t. vertices (dA_poly/dh, dP_poly/dh)
|
|
278
|
+
- register "outer" vertices created at arc intersections and track their point pairs
|
|
279
|
+
|
|
280
|
+
.. warning::
|
|
281
|
+
This is an internal method. Use with caution.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
vor: SciPy Voronoi object for current points with extensions.
|
|
285
|
+
vertices_all: (M,2) array of all Voronoi vertices including extensions.
|
|
286
|
+
ridge_vertices_all: list of lists of vertex indices for each ridge, including extensions.
|
|
287
|
+
num_vertices: Number of Voronoi vertices before adding extension.
|
|
288
|
+
vertexpair2ridge: dict mapping vertex index pairs to ridge index.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
dict[str, object]: A diagnostics dictionary containing:
|
|
292
|
+
|
|
293
|
+
- **vertex_in_id**: set of inner vertex ids.
|
|
294
|
+
- **vertex_out_id**: set of outer vertex ids.
|
|
295
|
+
- **vertices_out**: (L,2) array of outer vertex coordinates.
|
|
296
|
+
- **vertex_out_points**: (L,2) array of point index pairs associated with each outer vertex.
|
|
297
|
+
- **vertex_out_da_dtheta**: array of dA/dtheta for all outer vertices.
|
|
298
|
+
- **vertex_out_dl_dtheta**: array of dL/dtheta for all outer vertices.
|
|
299
|
+
- **dA_poly_dh**: array of dA_polygon/dh for each vertex.
|
|
300
|
+
- **dP_poly_dh**: array of dP_polygon/dh for each vertex.
|
|
301
|
+
- **area_list**: array of polygon areas for each cell.
|
|
302
|
+
- **perimeter_list**: array of polygon perimeters for each cell.
|
|
303
|
+
- **point_edges_type**: list of lists of edge types per cell.
|
|
304
|
+
- **point_vertices_f_idx**: list of lists of vertex ids per cell.
|
|
305
|
+
- **num_vertices_ext**: number of vertices including infinite extension vertices.
|
|
306
|
+
"""
|
|
307
|
+
N = self.N
|
|
308
|
+
r = self.phys.r
|
|
309
|
+
A0_list = self._preferred_areas
|
|
310
|
+
P0 = self.phys.P0
|
|
311
|
+
pts = self.pts
|
|
312
|
+
|
|
313
|
+
num_vertices_ext = len(vertices_all) # number of vertices with infinite extension points
|
|
314
|
+
|
|
315
|
+
rv = np.asarray(ridge_vertices_all, dtype=int) # (R,2)
|
|
316
|
+
rp = np.asarray(vor.ridge_points, dtype=int) # (R,2)
|
|
317
|
+
num_ridges = rp.shape[0]
|
|
318
|
+
|
|
319
|
+
# init outer-vertex arrays (same shapes you used)
|
|
320
|
+
vertices_out = np.zeros((2 * num_ridges, 2), dtype=float)
|
|
321
|
+
vertex_out_points = np.zeros((2 * num_ridges, 2), dtype=int)
|
|
322
|
+
|
|
323
|
+
# unpack ridge endpoints and vertex indices
|
|
324
|
+
p1 = rp[:, 0].copy()
|
|
325
|
+
p2 = rp[:, 1].copy()
|
|
326
|
+
v1 = rv[:, 0].copy()
|
|
327
|
+
v2 = rv[:, 1].copy()
|
|
328
|
+
|
|
329
|
+
valid_pts = (p1 >= 0) & (p2 >= 0)
|
|
330
|
+
|
|
331
|
+
# coordinates
|
|
332
|
+
P1 = np.zeros((num_ridges, 2), dtype=float); P2 = np.zeros((num_ridges, 2), dtype=float)
|
|
333
|
+
P1[valid_pts] = self.pts[p1[valid_pts]]
|
|
334
|
+
P2[valid_pts] = self.pts[p2[valid_pts]]
|
|
335
|
+
V1 = vertices_all[v1]
|
|
336
|
+
V2 = vertices_all[v2]
|
|
337
|
+
|
|
338
|
+
# enforce V1->V2 clockwise around p1 (swap only p1<->p2, not v1/v2)
|
|
339
|
+
P12 = P2 - P1
|
|
340
|
+
V12 = V2 - V1
|
|
341
|
+
swap = (P12[:, 0] * V12[:, 1] - P12[:, 1] * V12[:, 0]) > 0
|
|
342
|
+
p1_sw = p1.copy(); p2_sw = p2.copy()
|
|
343
|
+
p1_sw[swap] = p2[swap]; p2_sw[swap] = p1[swap]
|
|
344
|
+
p1, p2 = p1_sw, p2_sw
|
|
345
|
+
P1_sw = P1.copy(); P2_sw = P2.copy()
|
|
346
|
+
P1[swap] = P2_sw[swap]; P2[swap] = P1_sw[swap]
|
|
347
|
+
|
|
348
|
+
# "inner" tests relative to p1 (keep your exact choices)
|
|
349
|
+
r = self.phys.r
|
|
350
|
+
d1 = np.linalg.norm(V1 - P1, axis=1)
|
|
351
|
+
d2 = np.linalg.norm(V2 - P1, axis=1)
|
|
352
|
+
inner1 = (d1 <= r) & valid_pts
|
|
353
|
+
inner2 = (d2 <= r) & valid_pts
|
|
354
|
+
|
|
355
|
+
# segment/intersection
|
|
356
|
+
dV = V2 - V1
|
|
357
|
+
segL = np.linalg.norm(dV, axis=1)
|
|
358
|
+
denom = np.where(segL > 0.0, segL, 1.0)
|
|
359
|
+
dx, dy = dV[:, 0], dV[:, 1]
|
|
360
|
+
x, y = P1[:, 0], P1[:, 1]
|
|
361
|
+
x1, y1 = V1[:, 0], V1[:, 1]
|
|
362
|
+
t = ((x - x1) * dx + (y - y1) * dy) / denom
|
|
363
|
+
|
|
364
|
+
# mid-point C = (p1+p2)/2 (your exact choice)
|
|
365
|
+
C = 0.5 * (P1 + P2)
|
|
366
|
+
cx, cy = C[:, 0], C[:, 1]
|
|
367
|
+
t1 = -t
|
|
368
|
+
t2 = t1 + denom
|
|
369
|
+
|
|
370
|
+
d = np.linalg.norm(C - P1, axis=1)
|
|
371
|
+
has_int = (d < r) & valid_pts
|
|
372
|
+
tr = np.full_like(d, np.nan)
|
|
373
|
+
tr[has_int] = np.sqrt(r * r - d[has_int] * d[has_int])
|
|
374
|
+
|
|
375
|
+
cond1 = (-tr < t2) & (-tr > t1) & valid_pts
|
|
376
|
+
cond2 = ( tr < t2) & ( tr > t1) & valid_pts
|
|
377
|
+
|
|
378
|
+
invL = np.where(denom > 0.0, 1.0 / denom, 0.0)
|
|
379
|
+
xr1 = cx - tr * dx * invL
|
|
380
|
+
yr1 = cy - tr * dy * invL
|
|
381
|
+
xr2 = cx + tr * dx * invL
|
|
382
|
+
yr2 = cy + tr * dy * invL
|
|
383
|
+
|
|
384
|
+
# fill outer-vertex arrays only where intersections happen
|
|
385
|
+
idx1 = np.where(cond1)[0]
|
|
386
|
+
if idx1.size:
|
|
387
|
+
vertices_out[2 * idx1 + 0, 0] = xr1[idx1]
|
|
388
|
+
vertices_out[2 * idx1 + 0, 1] = yr1[idx1]
|
|
389
|
+
pairs1 = np.sort(np.stack([p1[idx1], p2[idx1]], axis=1), axis=1)
|
|
390
|
+
vertex_out_points[2 * idx1 + 0] = pairs1
|
|
391
|
+
|
|
392
|
+
idx2 = np.where(cond2)[0]
|
|
393
|
+
if idx2.size:
|
|
394
|
+
vertices_out[2 * idx2 + 1, 0] = xr2[idx2]
|
|
395
|
+
vertices_out[2 * idx2 + 1, 1] = yr2[idx2]
|
|
396
|
+
pairs2 = np.sort(np.stack([p1[idx2], p2[idx2]], axis=1), axis=1)
|
|
397
|
+
vertex_out_points[2 * idx2 + 1] = pairs2
|
|
398
|
+
|
|
399
|
+
# sets (same semantics as before)
|
|
400
|
+
vertex_out_id = set((num_vertices_ext + np.where((np.arange(2 * num_ridges) % 2 == 0) & cond1.repeat(2))[0]).tolist())
|
|
401
|
+
vertex_out_id.update((num_vertices_ext + np.where((np.arange(2 * num_ridges) % 2 == 1) & cond2.repeat(2))[0]).tolist())
|
|
402
|
+
|
|
403
|
+
vertex_in_id = set(v1[inner1].astype(int).tolist())
|
|
404
|
+
vertex_in_id.update(v2[inner2].astype(int).tolist())
|
|
405
|
+
|
|
406
|
+
# -------- NEW: packed arrays instead of ridge_info dict --------
|
|
407
|
+
# each endpoint has up to 3 entries; absent slots are -1
|
|
408
|
+
p1_edges_pack = np.full((num_ridges, 3), -1, dtype=int)
|
|
409
|
+
p1_verts_pack = np.full((num_ridges, 3), -1, dtype=int)
|
|
410
|
+
p2_edges_pack = np.full((num_ridges, 3), -1, dtype=int)
|
|
411
|
+
p2_verts_pack = np.full((num_ridges, 3), -1, dtype=int)
|
|
412
|
+
|
|
413
|
+
out_id1 = num_vertices_ext + (2 * np.arange(num_ridges) + 0)
|
|
414
|
+
out_id2 = out_id1 + 1
|
|
415
|
+
|
|
416
|
+
# p1 order: [inner1 -> (1,v1)], [cond1 -> (1,out1)], [cond2 -> (0,out2)]
|
|
417
|
+
p1_edges_pack[inner1, 0] = 1
|
|
418
|
+
p1_verts_pack[inner1, 0] = v1[inner1]
|
|
419
|
+
|
|
420
|
+
p1_edges_pack[cond1, 1] = 1
|
|
421
|
+
p1_verts_pack[cond1, 1] = out_id1[cond1]
|
|
422
|
+
|
|
423
|
+
p1_edges_pack[cond2, 2] = 0
|
|
424
|
+
p1_verts_pack[cond2, 2] = out_id2[cond2]
|
|
425
|
+
|
|
426
|
+
# p2 was "append then reverse", which yields final order: [inner2, cond2, cond1]
|
|
427
|
+
p2_edges_pack[inner2, 0] = 1
|
|
428
|
+
p2_verts_pack[inner2, 0] = v2[inner2]
|
|
429
|
+
|
|
430
|
+
p2_edges_pack[cond2, 1] = 1
|
|
431
|
+
p2_verts_pack[cond2, 1] = out_id2[cond2]
|
|
432
|
+
|
|
433
|
+
p2_edges_pack[cond1, 2] = 0
|
|
434
|
+
p2_verts_pack[cond1, 2] = out_id1[cond1]
|
|
435
|
+
|
|
436
|
+
# append outer-vertex slots (unused rows stay zero like before)
|
|
437
|
+
vertices_all = np.vstack([vertices_all, vertices_out])
|
|
438
|
+
|
|
439
|
+
# --------------------------------------------------
|
|
440
|
+
# Part 1 in Cython/Python backend
|
|
441
|
+
# --------------------------------------------------
|
|
442
|
+
vor_regions = self._impl.pad_regions(vor.regions) # (R, Kmax) int64 with -1 padding
|
|
443
|
+
point_edges_type, point_vertices_f_idx = self._impl.build_point_edges(
|
|
444
|
+
vor_regions, vor.point_region.astype(np.int64),
|
|
445
|
+
vertices_all.astype(np.float64), pts.astype(np.float64),
|
|
446
|
+
int(num_vertices), vertexpair2ridge,
|
|
447
|
+
p1.astype(np.int64), p1_edges_pack.astype(np.int64), p1_verts_pack.astype(np.int64),
|
|
448
|
+
p2.astype(np.int64), p2_edges_pack.astype(np.int64), p2_verts_pack.astype(np.int64),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# --------------------------------------------------
|
|
452
|
+
# Part 2 in Cython/Python backend
|
|
453
|
+
# --------------------------------------------------
|
|
454
|
+
vertex_out_da_dtheta, vertex_out_dl_dtheta, dA_poly_dh, dP_poly_dh, area_list, perimeter_list = self._impl.compute_vertex_derivatives(
|
|
455
|
+
point_edges_type, # list-of-lists / arrays of edge types
|
|
456
|
+
point_vertices_f_idx, # list-of-lists / arrays of vertex ids
|
|
457
|
+
vertices_all.astype(np.float64, copy=False),
|
|
458
|
+
pts.astype(np.float64, copy=False),
|
|
459
|
+
float(r),
|
|
460
|
+
A0_list.astype(np.float64, copy=False),
|
|
461
|
+
float(P0),
|
|
462
|
+
int(num_vertices_ext),
|
|
463
|
+
int(num_ridges),
|
|
464
|
+
vertex_out_points.astype(np.int64, copy=False)
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
diagnostics = dict(
|
|
468
|
+
vertex_in_id=set(vertex_in_id),
|
|
469
|
+
vertex_out_id=set(vertex_out_id),
|
|
470
|
+
vertices_out=vertices_out,
|
|
471
|
+
vertex_out_points=vertex_out_points,
|
|
472
|
+
vertex_out_da_dtheta=vertex_out_da_dtheta,
|
|
473
|
+
vertex_out_dl_dtheta=vertex_out_dl_dtheta,
|
|
474
|
+
dA_poly_dh=dA_poly_dh,
|
|
475
|
+
dP_poly_dh=dP_poly_dh,
|
|
476
|
+
area_list=area_list,
|
|
477
|
+
perimeter_list=perimeter_list,
|
|
478
|
+
point_edges_type=point_edges_type,
|
|
479
|
+
point_vertices_f_idx=point_vertices_f_idx,
|
|
480
|
+
num_vertices_ext=num_vertices_ext,
|
|
481
|
+
)
|
|
482
|
+
return diagnostics, vertices_all
|
|
483
|
+
|
|
484
|
+
# --------------------- Force assembly ---------------------
|
|
485
|
+
def _assemble_forces(self, vertices_all: np.ndarray, num_vertices_ext: int,
|
|
486
|
+
vertex_points: dict[int, list[int]], vertex_in_id: list[int], vertex_out_id: list[int],
|
|
487
|
+
vertex_out_points: list[list[int]], vertex_out_da_dtheta: np.ndarray,
|
|
488
|
+
vertex_out_dl_dtheta: np.ndarray, dA_poly_dh: np.ndarray, dP_poly_dh: np.ndarray,
|
|
489
|
+
area_list: np.ndarray, perimeter_list: np.ndarray) -> np.ndarray:
|
|
490
|
+
"""
|
|
491
|
+
Assemble forces on cell centers from polygon and arc contributions.
|
|
492
|
+
"""
|
|
493
|
+
N = self.N
|
|
494
|
+
r = self.phys.r
|
|
495
|
+
A0_list = self._preferred_areas
|
|
496
|
+
P0 = self.phys.P0
|
|
497
|
+
KA = self.phys.KA
|
|
498
|
+
KP = self.phys.KP
|
|
499
|
+
Lambda = self.phys.lambda_tension
|
|
500
|
+
pts = self.pts
|
|
501
|
+
|
|
502
|
+
dE_poly_dh = 2.0 * (KA * dA_poly_dh + KP * dP_poly_dh)
|
|
503
|
+
|
|
504
|
+
fx = np.zeros(N)
|
|
505
|
+
fy = np.zeros(N)
|
|
506
|
+
|
|
507
|
+
# ===============================================================
|
|
508
|
+
# (1) Inner vertices contributions — vectorized + bincount scatter
|
|
509
|
+
# ===============================================================
|
|
510
|
+
if len(vertex_in_id) > 0:
|
|
511
|
+
H = np.asarray(list(vertex_in_id), dtype=int) # (H,)
|
|
512
|
+
# unpack triples (i,j,k) for each inner vertex
|
|
513
|
+
I = np.empty(len(H), dtype=int)
|
|
514
|
+
J = np.empty(len(H), dtype=int)
|
|
515
|
+
K = np.empty(len(H), dtype=int)
|
|
516
|
+
for t, h in enumerate(H):
|
|
517
|
+
I[t], J[t], K[t] = vertex_points[h]
|
|
518
|
+
|
|
519
|
+
ri = pts[I] # (H,2)
|
|
520
|
+
rj = pts[J]
|
|
521
|
+
rk = pts[K]
|
|
522
|
+
|
|
523
|
+
rj_minus_rk = rj - rk
|
|
524
|
+
ri_minus_rj = ri - rj
|
|
525
|
+
ri_minus_rk = ri - rk
|
|
526
|
+
rj_minus_ri = -ri_minus_rj
|
|
527
|
+
rk_minus_ri = -ri_minus_rk
|
|
528
|
+
rk_minus_rj = rk - rj
|
|
529
|
+
|
|
530
|
+
D0 = _row_dot(ri - rj, np.column_stack((rj_minus_rk[:,1], -rj_minus_rk[:,0]))) # cross2(ri-rj, rj-rk)
|
|
531
|
+
# rewrite cross robustly:
|
|
532
|
+
D0 = (ri[:,0]-rj[:,0])*(rj_minus_rk[:,1]) - (ri[:,1]-rj[:,1])*(rj_minus_rk[:,0])
|
|
533
|
+
D = 2.0 * (D0 ** 2)
|
|
534
|
+
|
|
535
|
+
# alphas
|
|
536
|
+
alpha_i = _row_dot(rj_minus_rk, rj_minus_rk) * _row_dot(ri_minus_rj, ri_minus_rk) / D
|
|
537
|
+
alpha_j = _row_dot(ri_minus_rk, ri_minus_rk) * _row_dot(rj_minus_ri, rj_minus_rk) / D
|
|
538
|
+
alpha_k = _row_dot(ri_minus_rj, ri_minus_rj) * _row_dot(rk_minus_ri, rk_minus_rj) / D
|
|
539
|
+
|
|
540
|
+
# d_alpha_j / d_ri and d_alpha_k / d_ri
|
|
541
|
+
cross_z = np.column_stack((rj_minus_rk[:, 1], -rj_minus_rk[:, 0])) # (H,2)
|
|
542
|
+
term_j_ri = (rk_minus_rj / _row_dot(rj_minus_ri, rj_minus_rk)[:, None]) + 2.0 * (ri_minus_rk / _row_dot(ri_minus_rk, ri_minus_rk)[:, None]) - 2.0 * (cross_z / D0[:, None])
|
|
543
|
+
term_k_ri = (rj_minus_rk / _row_dot(rk_minus_ri, rk_minus_rj)[:, None]) + 2.0 * (ri_minus_rj / _row_dot(ri_minus_rj, ri_minus_rj)[:, None]) - 2.0 * (cross_z / D0[:, None])
|
|
544
|
+
d_alpha_j_d_ri = (alpha_j[:, None] * term_j_ri) # (H,2)
|
|
545
|
+
d_alpha_k_d_ri = (alpha_k[:, None] * term_k_ri)
|
|
546
|
+
|
|
547
|
+
d_h_in_d_xi = alpha_i[:, None] * np.array([1.0, 0.0]) + d_alpha_j_d_ri[:, [0]] * (rj - ri) + d_alpha_k_d_ri[:, [0]] * (rk - ri)
|
|
548
|
+
d_h_in_d_yi = alpha_i[:, None] * np.array([0.0, 1.0]) + d_alpha_j_d_ri[:, [1]] * (rj - ri) + d_alpha_k_d_ri[:, [1]] * (rk - ri)
|
|
549
|
+
|
|
550
|
+
# d_alpha_i / d_rj and d_alpha_k / d_rj
|
|
551
|
+
cross_z = np.column_stack((-(ri_minus_rk)[:, 1], (ri_minus_rk)[:, 0]))
|
|
552
|
+
term_i_rj = (rk_minus_ri / _row_dot(ri_minus_rj, ri_minus_rk)[:, None]) + 2.0 * (rj_minus_rk / _row_dot(rj_minus_rk, rj_minus_rk)[:, None]) - 2.0 * (cross_z / D0[:, None])
|
|
553
|
+
term_k_rj = (ri_minus_rk / _row_dot(rk_minus_rj, rk_minus_ri)[:, None]) + 2.0 * (rj_minus_ri / _row_dot(rj_minus_ri, rj_minus_ri)[:, None]) - 2.0 * (cross_z / D0[:, None])
|
|
554
|
+
d_alpha_i_d_rj = (alpha_i[:, None] * term_i_rj)
|
|
555
|
+
d_alpha_k_d_rj = (alpha_k[:, None] * term_k_rj)
|
|
556
|
+
|
|
557
|
+
d_h_in_d_xj = d_alpha_i_d_rj[:, [0]] * (ri - rj) + alpha_j[:, None] * np.array([1.0, 0.0]) + d_alpha_k_d_rj[:, [0]] * (rk - rj)
|
|
558
|
+
d_h_in_d_yj = d_alpha_i_d_rj[:, [1]] * (ri - rj) + alpha_j[:, None] * np.array([0.0, 1.0]) + d_alpha_k_d_rj[:, [1]] * (rk - rj)
|
|
559
|
+
|
|
560
|
+
# d_alpha_i / d_rk and d_alpha_j / d_rk
|
|
561
|
+
cross_z = np.column_stack(((ri_minus_rj)[:, 1], -(ri_minus_rj)[:, 0]))
|
|
562
|
+
term_i_rk = (rj_minus_ri / _row_dot(ri_minus_rk, ri_minus_rj)[:, None]) + 2.0 * (rk_minus_rj / _row_dot(rk_minus_rj, rk_minus_rj)[:, None]) - 2.0 * (cross_z / D0[:, None])
|
|
563
|
+
term_j_rk = (ri_minus_rj / _row_dot(rj_minus_rk, rj_minus_ri)[:, None]) + 2.0 * (rk_minus_ri / _row_dot(rk_minus_ri, rk_minus_ri)[:, None]) - 2.0 * (cross_z / D0[:, None])
|
|
564
|
+
d_alpha_i_d_rk = (alpha_i[:, None] * term_i_rk)
|
|
565
|
+
d_alpha_j_d_rk = (alpha_j[:, None] * term_j_rk)
|
|
566
|
+
|
|
567
|
+
d_h_in_d_xk = d_alpha_i_d_rk[:, [0]] * (ri - rk) + d_alpha_j_d_rk[:, [0]] * (rj - rk) + alpha_k[:, None] * np.array([1.0, 0.0])
|
|
568
|
+
d_h_in_d_yk = d_alpha_i_d_rk[:, [1]] * (ri - rk) + d_alpha_j_d_rk[:, [1]] * (rj - rk) + alpha_k[:, None] * np.array([0.0, 1.0])
|
|
569
|
+
|
|
570
|
+
deh = dE_poly_dh[H] # (H,2)
|
|
571
|
+
contrib_x_i = _row_dot(deh, d_h_in_d_xi)
|
|
572
|
+
contrib_x_j = _row_dot(deh, d_h_in_d_xj)
|
|
573
|
+
contrib_x_k = _row_dot(deh, d_h_in_d_xk)
|
|
574
|
+
contrib_y_i = _row_dot(deh, d_h_in_d_yi)
|
|
575
|
+
contrib_y_j = _row_dot(deh, d_h_in_d_yj)
|
|
576
|
+
contrib_y_k = _row_dot(deh, d_h_in_d_yk)
|
|
577
|
+
|
|
578
|
+
# bincount-based scatter (faster than repeated np.add.at)
|
|
579
|
+
fx += (
|
|
580
|
+
np.bincount(I, weights=contrib_x_i, minlength=N)
|
|
581
|
+
+ np.bincount(J, weights=contrib_x_j, minlength=N)
|
|
582
|
+
+ np.bincount(K, weights=contrib_x_k, minlength=N)
|
|
583
|
+
)
|
|
584
|
+
fy += (
|
|
585
|
+
np.bincount(I, weights=contrib_y_i, minlength=N)
|
|
586
|
+
+ np.bincount(J, weights=contrib_y_j, minlength=N)
|
|
587
|
+
+ np.bincount(K, weights=contrib_y_k, minlength=N)
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
# ===============================================================
|
|
591
|
+
# (2) Outer vertices contributions — vectorized; bincount scatter
|
|
592
|
+
# ===============================================================
|
|
593
|
+
dA_arc_dr = np.zeros((N, 2))
|
|
594
|
+
dP_arc_dr = np.zeros((N,2))
|
|
595
|
+
dL_dr = np.zeros((N,2))
|
|
596
|
+
|
|
597
|
+
if len(vertex_out_id) > 0:
|
|
598
|
+
Vsel = np.asarray(vertex_out_id, dtype=int) # absolute vertex IDs in vertices_all
|
|
599
|
+
h_idx = Vsel - num_vertices_ext # rows into vertex_out_* arrays
|
|
600
|
+
# guard against any accidental negatives / out-of-range
|
|
601
|
+
valid_mask = (h_idx >= 0) & (h_idx < len(vertex_out_points))
|
|
602
|
+
if np.any(valid_mask):
|
|
603
|
+
Vsel = Vsel[valid_mask]
|
|
604
|
+
h_idx = h_idx[valid_mask]
|
|
605
|
+
|
|
606
|
+
# geometry slices
|
|
607
|
+
h_out = vertices_all[Vsel] # (M,2)
|
|
608
|
+
IJ = np.asarray(vertex_out_points, dtype=int)[h_idx] # (M,2)
|
|
609
|
+
I = IJ[:, 0]
|
|
610
|
+
J = IJ[:, 1]
|
|
611
|
+
|
|
612
|
+
ri = pts[I] # (M,2)
|
|
613
|
+
rj = pts[J] # (M,2)
|
|
614
|
+
rij_vec = ri - rj
|
|
615
|
+
rij = np.linalg.norm(rij_vec, axis=1) # (M,)
|
|
616
|
+
root = np.sqrt(4.0 * (r ** 2) - (rij ** 2))
|
|
617
|
+
|
|
618
|
+
# sign based on orientation: sign(cross(h_out-rj, ri-rj))
|
|
619
|
+
sign = np.sign((h_out[:, 0] - rj[:, 0]) * (ri[:, 1] - rj[:, 1])
|
|
620
|
+
- (h_out[:, 1] - rj[:, 1]) * (ri[:, 0] - rj[:, 0]))
|
|
621
|
+
|
|
622
|
+
x_unit = np.array([1.0, 0.0])[None, :]
|
|
623
|
+
y_unit = np.array([0.0, 1.0])[None, :]
|
|
624
|
+
|
|
625
|
+
cross_z = rij_vec[:, [1]] * x_unit - rij_vec[:, [0]] * y_unit # (M,2)
|
|
626
|
+
denom = (np.maximum(root[:, None], self.phys.delta) * (rij ** 3)[:, None]) # small offset to avoid singularities
|
|
627
|
+
|
|
628
|
+
dx_terms = - (2.0 * (r ** 2) * rij_vec[:, [0]] * cross_z / denom) \
|
|
629
|
+
- (root / (2.0 * rij))[:, None] * y_unit
|
|
630
|
+
dy_terms = - (2.0 * (r ** 2) * rij_vec[:, [1]] * cross_z / denom) \
|
|
631
|
+
+ (root / (2.0 * rij))[:, None] * x_unit
|
|
632
|
+
|
|
633
|
+
d_h_out_d_xi = (x_unit / 2.0) + sign[:, None] * dx_terms
|
|
634
|
+
d_h_out_d_yi = (y_unit / 2.0) + sign[:, None] * dy_terms
|
|
635
|
+
d_h_out_d_xj = (x_unit / 2.0) - sign[:, None] * dx_terms
|
|
636
|
+
d_h_out_d_yj = (y_unit / 2.0) - sign[:, None] * dy_terms
|
|
637
|
+
|
|
638
|
+
# polygon part on these selected outer vertices
|
|
639
|
+
deh_out = dE_poly_dh[Vsel] # (M,2)
|
|
640
|
+
|
|
641
|
+
fx += (
|
|
642
|
+
np.bincount(I, weights=_row_dot(deh_out, d_h_out_d_xi), minlength=N)
|
|
643
|
+
+ np.bincount(J, weights=_row_dot(deh_out, d_h_out_d_xj), minlength=N)
|
|
644
|
+
)
|
|
645
|
+
fy += (
|
|
646
|
+
np.bincount(I, weights=_row_dot(deh_out, d_h_out_d_yi), minlength=N)
|
|
647
|
+
+ np.bincount(J, weights=_row_dot(deh_out, d_h_out_d_yj), minlength=N)
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
# ---- arc angle sensitivities (per-cell accumulators) ----
|
|
651
|
+
u_i = h_out - ri
|
|
652
|
+
u_j = h_out - rj
|
|
653
|
+
inv_ui2 = 1.0 / _row_dot(u_i, u_i)
|
|
654
|
+
inv_uj2 = 1.0 / _row_dot(u_j, u_j)
|
|
655
|
+
u_perp_i = np.column_stack((-u_i[:, 1], u_i[:, 0])) * inv_ui2[:, None]
|
|
656
|
+
u_perp_j = np.column_stack((-u_j[:, 1], u_j[:, 0])) * inv_uj2[:, None]
|
|
657
|
+
|
|
658
|
+
d_theta_i_d_xj = _row_dot(d_h_out_d_xj, u_perp_i)
|
|
659
|
+
d_theta_i_d_yj = _row_dot(d_h_out_d_yj, u_perp_i)
|
|
660
|
+
d_theta_i_d_xi = -d_theta_i_d_xj
|
|
661
|
+
d_theta_i_d_yi = -d_theta_i_d_yj
|
|
662
|
+
|
|
663
|
+
d_theta_j_d_xi = _row_dot(d_h_out_d_xi, u_perp_j)
|
|
664
|
+
d_theta_j_d_yi = _row_dot(d_h_out_d_yi, u_perp_j)
|
|
665
|
+
d_theta_j_d_xj = -d_theta_j_d_xi
|
|
666
|
+
d_theta_j_d_yj = -d_theta_j_d_yi
|
|
667
|
+
|
|
668
|
+
# weights (only for the selected outer vertices h_idx)
|
|
669
|
+
v_da = vertex_out_da_dtheta[h_idx] # (M,2)
|
|
670
|
+
v_dl = vertex_out_dl_dtheta[h_idx] # (M,2)
|
|
671
|
+
|
|
672
|
+
Ai_w_i = (area_list[I] - A0_list[I]) * v_da[:, 0]
|
|
673
|
+
Aj_w_j = (area_list[J] - A0_list[J]) * v_da[:, 1]
|
|
674
|
+
Pi_w_i = (perimeter_list[I] - P0) * v_dl[:, 0]
|
|
675
|
+
Pj_w_j = (perimeter_list[J] - P0) * v_dl[:, 1]
|
|
676
|
+
|
|
677
|
+
# accumulate with bincount
|
|
678
|
+
dA_arc_dr[:, 0] += np.bincount(I, Ai_w_i * d_theta_i_d_xi + Aj_w_j * d_theta_j_d_xi, minlength=N)
|
|
679
|
+
dA_arc_dr[:, 0] += np.bincount(J, Ai_w_i * d_theta_i_d_xj + Aj_w_j * d_theta_j_d_xj, minlength=N)
|
|
680
|
+
dA_arc_dr[:, 1] += np.bincount(I, Ai_w_i * d_theta_i_d_yi + Aj_w_j * d_theta_j_d_yi, minlength=N)
|
|
681
|
+
dA_arc_dr[:, 1] += np.bincount(J, Ai_w_i * d_theta_i_d_yj + Aj_w_j * d_theta_j_d_yj, minlength=N)
|
|
682
|
+
|
|
683
|
+
dP_arc_dr[:, 0] += np.bincount(I, Pi_w_i * d_theta_i_d_xi + Pj_w_j * d_theta_j_d_xi, minlength=N)
|
|
684
|
+
dP_arc_dr[:, 0] += np.bincount(J, Pi_w_i * d_theta_i_d_xj + Pj_w_j * d_theta_j_d_xj, minlength=N)
|
|
685
|
+
dP_arc_dr[:, 1] += np.bincount(I, Pi_w_i * d_theta_i_d_yi + Pj_w_j * d_theta_j_d_yi, minlength=N)
|
|
686
|
+
dP_arc_dr[:, 1] += np.bincount(J, Pi_w_i * d_theta_i_d_yj + Pj_w_j * d_theta_j_d_yj, minlength=N)
|
|
687
|
+
|
|
688
|
+
# line-tension contributions for Lambda
|
|
689
|
+
dL_arc_x = v_dl[:, 0] * d_theta_i_d_xi + v_dl[:, 1] * d_theta_j_d_xi
|
|
690
|
+
dL_arc_y = v_dl[:, 0] * d_theta_i_d_yi + v_dl[:, 1] * d_theta_j_d_yi
|
|
691
|
+
dL_arc_xJ = v_dl[:, 0] * d_theta_i_d_xj + v_dl[:, 1] * d_theta_j_d_xj
|
|
692
|
+
dL_arc_yJ = v_dl[:, 0] * d_theta_i_d_yj + v_dl[:, 1] * d_theta_j_d_yj
|
|
693
|
+
|
|
694
|
+
dL_dr[:, 0] += np.bincount(I, dL_arc_x, minlength=N)
|
|
695
|
+
dL_dr[:, 1] += np.bincount(I, dL_arc_y, minlength=N)
|
|
696
|
+
dL_dr[:, 0] += np.bincount(J, dL_arc_xJ, minlength=N)
|
|
697
|
+
dL_dr[:, 1] += np.bincount(J, dL_arc_yJ, minlength=N)
|
|
698
|
+
|
|
699
|
+
# combine arc terms
|
|
700
|
+
dE_arc_dr = 2.0 * (KA * dA_arc_dr + KP * dP_arc_dr) + Lambda * dL_dr
|
|
701
|
+
|
|
702
|
+
fx = -(fx + dE_arc_dr[:, 0])
|
|
703
|
+
fy = -(fy + dE_arc_dr[:, 1])
|
|
704
|
+
|
|
705
|
+
F = np.zeros((N, 2), dtype=float)
|
|
706
|
+
F[:, 0] = fx
|
|
707
|
+
F[:, 1] = fy
|
|
708
|
+
return F
|
|
709
|
+
|
|
710
|
+
# --------------------- One integration step ---------------------
|
|
711
|
+
def build(self) -> dict[str, object]:
|
|
712
|
+
""" Build the finite-Voronoi structure and compute forces, returning a dictionary of diagnostics.
|
|
713
|
+
|
|
714
|
+
Do the following:
|
|
715
|
+
- Build Voronoi (+ extensions)
|
|
716
|
+
- Get cell connectivity
|
|
717
|
+
- Compute per-cell quantities and derivatives
|
|
718
|
+
- Assemble forces
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
Returns:
|
|
722
|
+
dict[str, object]: A dictionary containing geometric properties with keys:
|
|
723
|
+
|
|
724
|
+
- **forces**: (N,2) array of forces on cell centers
|
|
725
|
+
- **areas**: (N,) array of cell areas
|
|
726
|
+
- **perimeters**: (N,) array of cell perimeters
|
|
727
|
+
- **vertices**: (M,2) array of all Voronoi + extension vertices
|
|
728
|
+
- **edges_type**: list-of-lists of edge types per cell (1=straight, 0=circular arc)
|
|
729
|
+
- **regions**: list-of-lists of vertex indices per cell
|
|
730
|
+
- **connections**: (M',2) array of connected cell index pairs
|
|
731
|
+
"""
|
|
732
|
+
(vor, vertices_all, ridge_vertices_all, num_vertices,
|
|
733
|
+
vertexpair2ridge, vertex_points) = self._build_voronoi_with_extensions()
|
|
734
|
+
|
|
735
|
+
connections = self._get_connections(vor.ridge_points, vertices_all, ridge_vertices_all)
|
|
736
|
+
|
|
737
|
+
geom, vertices_all = self._per_cell_geometry(vor, vertices_all, ridge_vertices_all, num_vertices, vertexpair2ridge)
|
|
738
|
+
|
|
739
|
+
F = self._assemble_forces(
|
|
740
|
+
vertices_all=vertices_all,
|
|
741
|
+
num_vertices_ext=geom["num_vertices_ext"],
|
|
742
|
+
vertex_points=vertex_points,
|
|
743
|
+
vertex_in_id=list(geom["vertex_in_id"]),
|
|
744
|
+
vertex_out_id=list(geom["vertex_out_id"]),
|
|
745
|
+
vertex_out_points=geom["vertex_out_points"],
|
|
746
|
+
vertex_out_da_dtheta=geom["vertex_out_da_dtheta"],
|
|
747
|
+
vertex_out_dl_dtheta=geom["vertex_out_dl_dtheta"],
|
|
748
|
+
dA_poly_dh=geom["dA_poly_dh"],
|
|
749
|
+
dP_poly_dh=geom["dP_poly_dh"],
|
|
750
|
+
area_list=geom["area_list"],
|
|
751
|
+
perimeter_list=geom["perimeter_list"],
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
return dict(
|
|
755
|
+
forces=F,
|
|
756
|
+
areas=geom["area_list"],
|
|
757
|
+
perimeters=geom["perimeter_list"],
|
|
758
|
+
vertices=vertices_all,
|
|
759
|
+
edges_type=geom["point_edges_type"],
|
|
760
|
+
regions=geom["point_vertices_f_idx"],
|
|
761
|
+
connections=connections,
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
# --------------------- 2D plotting utilities ---------------------
|
|
765
|
+
def plot_2d(self, ax: matplotlib.axes.Axes | None = None, show: bool = False) -> matplotlib.axes.Axes:
|
|
766
|
+
"""
|
|
767
|
+
Build the finite-Voronoi structure and render a 2D snapshot.
|
|
768
|
+
|
|
769
|
+
Basically a wrapper of :py:meth:`_build_voronoi_with_extensions` and :py:meth:`_per_cell_geometry` functions + plot.
|
|
770
|
+
|
|
771
|
+
Args:
|
|
772
|
+
ax: If provided, draw into this axes; otherwise get the current axes.
|
|
773
|
+
show: Whether to call ``plt.show()`` at the end.
|
|
774
|
+
|
|
775
|
+
Returns:
|
|
776
|
+
The matplotlib axes containing the plot.
|
|
777
|
+
"""
|
|
778
|
+
(vor, vertices_all, ridge_vertices_all, num_vertices,
|
|
779
|
+
vertexpair2ridge, vertex_points) = self._build_voronoi_with_extensions()
|
|
780
|
+
|
|
781
|
+
geom, vertices_all = self._per_cell_geometry(vor, vertices_all, ridge_vertices_all, num_vertices, vertexpair2ridge)
|
|
782
|
+
|
|
783
|
+
from matplotlib import pyplot as plt
|
|
784
|
+
|
|
785
|
+
if ax is None:
|
|
786
|
+
ax = plt.gca()
|
|
787
|
+
|
|
788
|
+
self._plot_routine(ax, vor, vertices_all, ridge_vertices_all,
|
|
789
|
+
geom["point_edges_type"], geom["point_vertices_f_idx"])
|
|
790
|
+
|
|
791
|
+
if show:
|
|
792
|
+
plt.show()
|
|
793
|
+
return ax
|
|
794
|
+
|
|
795
|
+
# --------------------- Paradigm of plotting ---------------------
|
|
796
|
+
def _plot_routine(self, ax: Axes, vor: Voronoi, vertices_all: np.ndarray, ridge_vertices_all: list[list[int]],
|
|
797
|
+
point_edges_type: list[list[int]], point_vertices_f_idx: list[list[int]]) -> None:
|
|
798
|
+
"""
|
|
799
|
+
Low-level plot routine. Draws:
|
|
800
|
+
- All Voronoi edges (solid for finite, dashed for formerly-infinite)
|
|
801
|
+
- Cell centers
|
|
802
|
+
- Each cell boundary (poly edges and circular arcs)
|
|
803
|
+
"""
|
|
804
|
+
pts = self.pts
|
|
805
|
+
r = self.phys.r
|
|
806
|
+
N = self.N
|
|
807
|
+
|
|
808
|
+
center = np.mean(pts, axis=0)
|
|
809
|
+
if N > 1:
|
|
810
|
+
span_x = np.ptp(pts[:, 0]) # pts span in x
|
|
811
|
+
span_y = np.ptp(pts[:, 1]) # pts span in y
|
|
812
|
+
L = max(span_x, span_y) + 3.0 * r
|
|
813
|
+
L *= 0.8
|
|
814
|
+
else:
|
|
815
|
+
L = 5.0 * r
|
|
816
|
+
|
|
817
|
+
# Draw Voronoi ridge segments
|
|
818
|
+
for idx in range(len(vor.ridge_vertices)):
|
|
819
|
+
x1, y1 = vertices_all[ridge_vertices_all[idx][0]]
|
|
820
|
+
x2, y2 = vertices_all[ridge_vertices_all[idx][1]]
|
|
821
|
+
if -1 not in vor.ridge_vertices[idx]:
|
|
822
|
+
ax.plot([x1, x2], [y1, y2], 'k-', lw=0.5)
|
|
823
|
+
else:
|
|
824
|
+
ax.plot([x1, x2], [y1, y2], 'k--', lw=0.5)
|
|
825
|
+
|
|
826
|
+
# Draw cell centers
|
|
827
|
+
ax.plot(pts[:, 0], pts[:, 1], 'o', color='C0', markersize=2)
|
|
828
|
+
|
|
829
|
+
# Draw each cell boundary
|
|
830
|
+
for idx in range(N):
|
|
831
|
+
edges_type = point_edges_type[idx]
|
|
832
|
+
vertices_f_idx = point_vertices_f_idx[idx]
|
|
833
|
+
|
|
834
|
+
x, y = pts[idx]
|
|
835
|
+
if len(edges_type) < 2:
|
|
836
|
+
angle = np.linspace(0, 2*np.pi, 100)
|
|
837
|
+
ax.plot(x + r * np.cos(angle), y + r * np.sin(angle), color="C6", zorder=2)
|
|
838
|
+
continue
|
|
839
|
+
|
|
840
|
+
for idx_f, edge_type in enumerate(edges_type):
|
|
841
|
+
v1_idx = vertices_f_idx[idx_f]
|
|
842
|
+
x1, y1 = vertices_all[v1_idx]
|
|
843
|
+
idx2 = idx_f + 1 if idx_f < len(edges_type)-1 else 0
|
|
844
|
+
v2_idx = vertices_f_idx[idx2]
|
|
845
|
+
x2, y2 = vertices_all[v2_idx]
|
|
846
|
+
|
|
847
|
+
if edge_type == 1:
|
|
848
|
+
ax.plot([x1, x2], [y1, y2], 'b-', zorder=1)
|
|
849
|
+
else:
|
|
850
|
+
angle1 = np.arctan2(y1-y, x1-x)
|
|
851
|
+
angle2 = np.arctan2(y2-y, x2-x)
|
|
852
|
+
dangle = np.linspace(0, (angle1 - angle2) % (2*np.pi), 100)
|
|
853
|
+
|
|
854
|
+
ax.plot(x + r * np.cos(angle2+dangle), y + r * np.sin(angle2+dangle), color="C6", zorder=2)
|
|
855
|
+
|
|
856
|
+
ax.set_aspect("equal")
|
|
857
|
+
ax.set_xlim(center[0]-L, center[0]+L)
|
|
858
|
+
ax.set_ylim(center[1]-L, center[1]+L)
|
|
859
|
+
|
|
860
|
+
# --------------------- Connections between cells ---------------------
|
|
861
|
+
def _get_connections(self, ridge_points: list[list[int]], vertices_all: np.ndarray, ridge_vertices_all: list[list[int]]) -> np.ndarray:
|
|
862
|
+
"""
|
|
863
|
+
Determine which pairs of cells are connected, i.e.,
|
|
864
|
+
the distance from the cell center to its corresponding Voronoi ridge
|
|
865
|
+
segment is < self.phys.r.
|
|
866
|
+
"""
|
|
867
|
+
ridge_points_arr = np.asarray(ridge_points, dtype=int).reshape(-1, 2) # (R, 2)
|
|
868
|
+
ridge_vertices_arr = np.asarray(ridge_vertices_all, dtype=int).reshape(-1, 2) # (R, 2)
|
|
869
|
+
|
|
870
|
+
# take p2 for each ridge, avoid -1 points (representing space)
|
|
871
|
+
p1_idx = ridge_points_arr[:, 0] # (R,)
|
|
872
|
+
p2_idx = ridge_points_arr[:, 1] # (R,)
|
|
873
|
+
p2 = self.pts[p2_idx] # (R, 2)
|
|
874
|
+
|
|
875
|
+
v1 = vertices_all[ridge_vertices_arr[:, 0]] # (R, 2)
|
|
876
|
+
v2 = vertices_all[ridge_vertices_arr[:, 1]] # (R, 2)
|
|
877
|
+
|
|
878
|
+
# vectorized point-to-segment distance
|
|
879
|
+
AB = v2 - v1 # (R, 2)
|
|
880
|
+
AP = p2 - v1 # (R, 2)
|
|
881
|
+
denom = np.einsum("ij,ij->i", AB, AB) # (R,)
|
|
882
|
+
|
|
883
|
+
t = np.einsum("ij,ij->i", AP, AB) / denom
|
|
884
|
+
t = np.clip(t, 0.0, 1.0)[:, None] # (R,1)
|
|
885
|
+
|
|
886
|
+
C = v1 + t * AB # closest point on segment, (R,2)
|
|
887
|
+
dists = np.linalg.norm(p2 - C, axis=1) # (R,)
|
|
888
|
+
|
|
889
|
+
mask = dists < self.phys.r
|
|
890
|
+
|
|
891
|
+
connect = np.stack([p1_idx[mask], p2_idx[mask]], axis=1)
|
|
892
|
+
if connect.size > 0:
|
|
893
|
+
connect = np.sort(connect, axis=1)
|
|
894
|
+
else:
|
|
895
|
+
connect = np.empty((0, 2), dtype=int)
|
|
896
|
+
return connect
|
|
897
|
+
|
|
898
|
+
# --------------------- Update positions ---------------------
|
|
899
|
+
def update_positions(self, pts: numpy.ndarray, A0: float | numpy.ndarray | None = None) -> None:
|
|
900
|
+
"""
|
|
901
|
+
Update cell center positions.
|
|
902
|
+
|
|
903
|
+
.. note::
|
|
904
|
+
If the number of cells changes, the preferred areas for all cells
|
|
905
|
+
are reset to the default value---defined either at simulator instantiation
|
|
906
|
+
or by :py:meth:`update_params`---unless *A0* is explicitly specified.
|
|
907
|
+
|
|
908
|
+
Args:
|
|
909
|
+
pts: New cell center positions.
|
|
910
|
+
A0: Optional, set new preferred area(s).
|
|
911
|
+
|
|
912
|
+
Raises:
|
|
913
|
+
ValueError: If *pts* does not have shape (N,2).
|
|
914
|
+
ValueError: If *A0* is an array and does not have shape (N,).
|
|
915
|
+
"""
|
|
916
|
+
pts = np.asarray(pts, dtype=float)
|
|
917
|
+
if pts.ndim != 2 or pts.shape[1] != 2:
|
|
918
|
+
raise ValueError("pts must have shape (N,2)")
|
|
919
|
+
|
|
920
|
+
N = pts.shape[0]
|
|
921
|
+
self.pts = pts
|
|
922
|
+
|
|
923
|
+
if N != self.N:
|
|
924
|
+
self.N = N
|
|
925
|
+
if A0 is None:
|
|
926
|
+
self._preferred_areas = np.full(N, self.phys.A0, dtype=float)
|
|
927
|
+
else:
|
|
928
|
+
self.update_preferred_areas(A0)
|
|
929
|
+
else:
|
|
930
|
+
if A0 is not None:
|
|
931
|
+
self.update_preferred_areas(A0)
|
|
932
|
+
|
|
933
|
+
# --------------------- Update physical parameters ---------------------
|
|
934
|
+
def update_params(self, phys: PhysicalParams) -> None:
|
|
935
|
+
"""
|
|
936
|
+
Update physical parameters.
|
|
937
|
+
|
|
938
|
+
Args:
|
|
939
|
+
phys: New PhysicalParams object.
|
|
940
|
+
|
|
941
|
+
Raises:
|
|
942
|
+
TypeError: If *phys* is not an instance of PhysicalParams.
|
|
943
|
+
|
|
944
|
+
.. warning::
|
|
945
|
+
This also resets all preferred cell areas to the new value of *A0*.
|
|
946
|
+
"""
|
|
947
|
+
if not isinstance(phys, PhysicalParams):
|
|
948
|
+
raise TypeError("phys must be an instance of PhysicalParams")
|
|
949
|
+
|
|
950
|
+
self.phys = phys
|
|
951
|
+
self.update_preferred_areas(phys.A0)
|
|
952
|
+
|
|
953
|
+
# --------------------- Update preferred area list ---------------------
|
|
954
|
+
def update_preferred_areas(self, A0: float | numpy.ndarray) -> None:
|
|
955
|
+
"""
|
|
956
|
+
Update the preferred areas for all cells.
|
|
957
|
+
|
|
958
|
+
Args:
|
|
959
|
+
A0: New preferred area(s) for all cells.
|
|
960
|
+
|
|
961
|
+
Raises:
|
|
962
|
+
ValueError: If *A0* does not match cell number.
|
|
963
|
+
"""
|
|
964
|
+
arr = np.asarray(A0, dtype=float)
|
|
965
|
+
|
|
966
|
+
# Accept scalar (0-d) or length-1 array as "uniform"
|
|
967
|
+
if arr.ndim == 0:
|
|
968
|
+
arr = np.full(self.N, float(arr), dtype=float)
|
|
969
|
+
elif arr.shape == (1,):
|
|
970
|
+
arr = np.full(self.N, float(arr[0]), dtype=float)
|
|
971
|
+
else:
|
|
972
|
+
if arr.shape != (self.N,):
|
|
973
|
+
raise ValueError(f"A0 must be scalar or have shape ({self.N},)")
|
|
974
|
+
|
|
975
|
+
self._preferred_areas = arr
|
|
976
|
+
|
|
977
|
+
@property
|
|
978
|
+
def preferred_areas(self) -> np.ndarray:
|
|
979
|
+
"""
|
|
980
|
+
Preferred areas for all cells (read-only).
|
|
981
|
+
|
|
982
|
+
Returns:
|
|
983
|
+
numpy.ndarray: A copy of the internal preferred area array.
|
|
984
|
+
"""
|
|
985
|
+
return self._preferred_areas.copy()
|