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