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