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