pyafv 0.3.8__cp311-cp311-musllinux_1_2_x86_64.whl

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