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.
pyafv/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """
2
+ PyAFV - A Python implementation of the **active-finite-Voronoi (AFV) model** in 2D.
3
+ """
4
+
5
+ from .physical_params import PhysicalParams, target_delta
6
+ from .finite_voronoi import FiniteVoronoiSimulator
7
+ from . import calibrate
8
+
9
+ try:
10
+ from ._version import __version__
11
+ except ImportError: # pragma: no cover
12
+ __version__ = "unknown"
13
+
14
+
15
+ __all__ = [
16
+ "PhysicalParams",
17
+ "FiniteVoronoiSimulator",
18
+ "target_delta",
19
+ "calibrate",
20
+ ]
pyafv/_version.py ADDED
@@ -0,0 +1,2 @@
1
+ # pyafv/_version.py
2
+ __version__ = "0.4.0"
pyafv/backend.py ADDED
@@ -0,0 +1,20 @@
1
+ # chooses fast vs fallback implementation
2
+
3
+ try:
4
+ from . import cell_geom as _impl
5
+ _BACKEND_NAME = "cython"
6
+ _IMPORT_ERROR = None
7
+ except Exception as e: # pragma: no cover
8
+ from . import cell_geom_fallback as _impl
9
+ _BACKEND_NAME = "python"
10
+ _IMPORT_ERROR = e
11
+
12
+
13
+ # ---- for explicit API ----
14
+ backend_impl = _impl
15
+
16
+ __all__ = [
17
+ "backend_impl",
18
+ "_BACKEND_NAME",
19
+ "_IMPORT_ERROR"
20
+ ]
@@ -0,0 +1,15 @@
1
+ """
2
+ Subpackage for calibrating the detachment forces against the deformable-polygon (DP) model.
3
+ """
4
+
5
+ from .core import auto_calibrate
6
+ from .deformable_polygon import DeformablePolygonSimulator, polygon_centroid, polygon_area_perimeter, resample_polyline
7
+
8
+
9
+ __all__ = [
10
+ "auto_calibrate",
11
+ "DeformablePolygonSimulator",
12
+ "polygon_centroid",
13
+ "polygon_area_perimeter",
14
+ "resample_polyline",
15
+ ]
@@ -0,0 +1,90 @@
1
+ import numpy as np
2
+ from typing import Iterable, TypeVar
3
+
4
+ from ..physical_params import PhysicalParams, target_delta
5
+ from .deformable_polygon import DeformablePolygonSimulator
6
+
7
+
8
+ T = TypeVar("T") # Declare the type variable
9
+
10
+ def _in_notebook() -> bool: # pragma: no cover
11
+ """Check if the code is running in a Jupyter notebook environment."""
12
+ try:
13
+ from IPython import get_ipython
14
+ ip = get_ipython()
15
+ if ip is None:
16
+ return False
17
+ return ip.__class__.__name__ == "ZMQInteractiveShell"
18
+ except Exception:
19
+ return False
20
+
21
+ def _tqdm(it: Iterable[T], desc: str = "") -> tuple[Iterable[T], bool]: # pragma: no cover
22
+ """Wrap an iterable with tqdm if enabled and available; otherwise return it unchanged."""
23
+ try:
24
+ if _in_notebook():
25
+ from tqdm.notebook import tqdm # type: ignore
26
+ else:
27
+ from tqdm import tqdm # type: ignore
28
+ return tqdm(it, desc=desc), True
29
+ except ImportError:
30
+ return it, False
31
+
32
+
33
+ def auto_calibrate(phys: PhysicalParams, ext_forces: np.ndarray | None = None,
34
+ dt: float = 1e-3, nsteps: int = 50_000, show: bool = False) -> tuple[float, PhysicalParams]:
35
+ """
36
+ Auto-calibrate the parameters *phys* against the deformable-polygon (DP) model.
37
+
38
+ In this calibration, we simulate the DP model under increasing external force dipoles, starting from 0 to max(*ext_forces*).
39
+ We identify the detachment force as the first external force at which detachment occurs.
40
+ We then search for the :py:attr:`delta` value in the finite-Voronoi (FV) model to match this detachment force.
41
+
42
+ Args:
43
+ phys: The initial physical parameters.
44
+ ext_forces: An array of external forces to apply during calibration;
45
+ defaults to None, which uses ``np.linspace(0, 10, 101)[1:]``;
46
+ should start from a small positive value.
47
+ dt: Time step for each simulation step.
48
+ nsteps: Number of simulation steps to run for each external force.
49
+ show: Whether to print progress information; no need to use it if **tqdm** is installed.
50
+
51
+ Raises:
52
+ TypeError: If *phys* is not an instance of *PhysicalParams*.
53
+
54
+ Returns:
55
+ A *tuple* containing the detachment force and the calibrated *PhysicalParams*.
56
+ If detachment does not occur within the given force range, return a *NaN* force.
57
+
58
+ .. warning::
59
+ This function may take some time to run, depending on the parameters.
60
+ (If **tqdm** is installed, a progress bar will be shown automatically.)
61
+ Do not change defaults unless you understand the implications.
62
+
63
+ If you only need a rough or faster calibration, you may change the external force range or interval.
64
+ Adjusting *dt* and *nsteps* may also speed up simulations, but may affect accuracy; test the
65
+ :py:class:`DeformablePolygonSimulator` model separately to ensure accuracy is acceptable.
66
+ """
67
+
68
+ sim = DeformablePolygonSimulator(phys)
69
+
70
+ if sim.detached: # already detached at zero force (steady state)
71
+ detachment_force = 0.0
72
+ return float(detachment_force), sim.phys.with_delta(0.0)
73
+ else:
74
+ if ext_forces is None: # pragma: no cover
75
+ ext_forces = np.linspace(0, 10, 101)[1:]
76
+ pbar, has_tqdm = _tqdm(ext_forces, desc="Calibrating")
77
+
78
+ for ext_force in pbar:
79
+ if has_tqdm: # with tqdm # pragma: no cover
80
+ pbar.set_description(f"Applying F={ext_force:.1f}")
81
+ elif show: # no tqdm, but show is True # pragma: no cover
82
+ print(f"Applying F={ext_force:.1f}")
83
+
84
+ sim.simulate(ext_force, dt, nsteps)
85
+ if sim.detached:
86
+ detachment_force = ext_force
87
+ return float(detachment_force), sim.phys.with_delta(target_delta(sim.phys, detachment_force))
88
+
89
+ # did not detach within given forces
90
+ return float('nan'), sim.phys.with_delta(0.45 * sim.phys.r) # pragma: no cover
@@ -0,0 +1,372 @@
1
+ # Enable postponed evaluation of annotations
2
+ from __future__ import annotations
3
+
4
+ # Only import typing modules when type checking, e.g., in VS Code or IDEs.
5
+ from typing import TYPE_CHECKING
6
+ if TYPE_CHECKING: # pragma: no cover
7
+ import matplotlib.axes
8
+
9
+ import numpy as np
10
+ from dataclasses import replace
11
+
12
+ from ..physical_params import PhysicalParams
13
+
14
+
15
+ # ---------- geometry helpers (vectorized) ----------
16
+ def polygon_centroid(pts: np.ndarray) -> np.ndarray:
17
+ """
18
+ Centroid (center of mass) of a simple polygon with uniform density.
19
+
20
+ Args:
21
+ pts: (N,2) array of vertices in order (first need not repeat at end).
22
+
23
+ Returns:
24
+ (2,) centroid array. (If degenerate area, returns vertex mean.)
25
+ """
26
+ pts = np.asarray(pts, dtype=float)
27
+ x, y = pts[:, 0], pts[:, 1]
28
+ x1, y1 = np.roll(x, -1), np.roll(y, -1)
29
+
30
+ cross = x * y1 - x1 * y # per-edge cross terms
31
+ A2 = cross.sum() # equals 2 * signed area
32
+
33
+ if np.isclose(A2, 0.0): # pragma: no cover
34
+ # degenerate: fall back to average of vertices
35
+ return pts.mean(axis=0)
36
+
37
+ cx = ((x + x1) * cross).sum() / (3.0 * A2)
38
+ cy = ((y + y1) * cross).sum() / (3.0 * A2)
39
+ return np.array([cx, cy])
40
+
41
+
42
+ def polygon_area_perimeter(pts: np.ndarray) -> tuple[float, float]:
43
+ """
44
+ Compute the area and perimeter for a Counter-ClockWise (CCW) polygon.
45
+
46
+ Args:
47
+ pts: (N,2) array of vertices in CCW order (first need not repeat at end).
48
+
49
+ Returns:
50
+ A *tuple* of (area, perimeter).
51
+ """
52
+ x = pts[:, 0]
53
+ y = pts[:, 1]
54
+ x_next = np.roll(x, -1)
55
+ y_next = np.roll(y, -1)
56
+ # Shoelace
57
+ A = 0.5 * np.abs(np.dot(x, y_next) - np.dot(y, x_next))
58
+ # Perimeter
59
+ edges = np.roll(pts, -1, axis=0) - pts
60
+ P = np.sum(np.linalg.norm(edges, axis=1))
61
+ return A, P
62
+
63
+
64
+ def resample_polyline(pts: np.ndarray, M: int | None = None) -> np.ndarray:
65
+ """
66
+ Resample an open polyline to M points with uniform arclength spacing.
67
+ Keeps endpoints exactly. If M is None, uses original number of points.
68
+
69
+ Args:
70
+ pts: (N,2) array of polyline vertices.
71
+ M: Number of output points; if None, uses N.
72
+
73
+ Returns:
74
+ (M,2) array of resampled polyline vertices.
75
+ """
76
+ pts = np.asarray(pts, dtype=float)
77
+ N = pts.shape[0]
78
+ if M is None:
79
+ M = N
80
+
81
+ # cumulative arclength
82
+ seg = np.diff(pts, axis=0)
83
+ s = np.concatenate(([0.0], np.cumsum(np.linalg.norm(seg, axis=1))))
84
+ L = s[-1]
85
+ if L == 0.0: # pragma: no cover
86
+ return np.tile(pts[0], (M, 1))
87
+
88
+ # target arclengths (uniform)
89
+ t = np.linspace(0.0, L, M)
90
+
91
+ # locate segment for each t and linearly interpolate
92
+ idx = np.searchsorted(s, t, side='right') - 1
93
+ idx = np.clip(idx, 0, N - 2)
94
+
95
+ s0 = s[idx]
96
+ s1 = s[idx + 1]
97
+ p0 = pts[idx]
98
+ p1 = pts[idx + 1]
99
+
100
+ denom = (s1 - s0)
101
+ alpha = (t - s0) / denom
102
+ alpha = np.nan_to_num(alpha, nan=0.0)
103
+
104
+ out = p0 + (p1 - p0) * alpha[:, None]
105
+ out[0] = pts[0]
106
+ out[-1] = pts[-1]
107
+ return out
108
+
109
+
110
+ # ----------- Internal use only helpers ------------------
111
+
112
+ def _grad_area(pts):
113
+ """
114
+ ∂A/∂v_i for polygon vertices v_i = (x_i, y_i), CCW order.
115
+ Using: ∂A/∂x_i = 0.5*(y_{i+1} - y_{i-1}), ∂A/∂y_i = 0.5*(x_{i-1} - x_{i+1})
116
+ """
117
+ x = pts[:, 0]
118
+ y = pts[:, 1]
119
+ y_next = np.roll(y, -1)
120
+ y_prev = np.roll(y, 1)
121
+ x_next = np.roll(x, -1)
122
+ x_prev = np.roll(x, 1)
123
+ gx = 0.5 * (y_next - y_prev)
124
+ gy = 0.5 * (x_prev - x_next)
125
+ return np.stack((gx, gy), axis=1)
126
+
127
+
128
+ def _grad_perimeter_and_tension(pts, lam):
129
+ """
130
+ Return (dP/dv_i, tension term) in one pass.
131
+ dP/dv_i = (v_i - v_{i-1})/|e_{i-1}| + (v_i - v_{i+1})/|e_i|
132
+ Tension term = lam_i * (v_i - v_{i+1})/|e_i| + lam_{i-1} * (v_i - v_{i-1})/|e_{i-1}|
133
+ """
134
+ v = pts
135
+ v_next = np.roll(v, -1, axis=0)
136
+ v_prev = np.roll(v, 1, axis=0)
137
+
138
+ # directed edges
139
+ e_fwd = v_next - v # e_i = v_{i+1} - v_i
140
+ e_bwd = v - v_prev # e_{i-1}= v_i - v_{i-1}
141
+
142
+ # norms (avoid divide-by-zero)
143
+ nf = np.linalg.norm(e_fwd, axis=1)
144
+ nb = np.linalg.norm(e_bwd, axis=1)
145
+
146
+ # unit directions for derivative pieces
147
+ dP_md = - e_fwd / nf[:, None] # (v_i - v_{i+1})/|e_i|
148
+ dP_0d = e_bwd / nb[:, None] # (v_i - v_{i-1})/|e_{i-1}|
149
+ dP = dP_md + dP_0d
150
+
151
+ lam_prev = np.roll(lam, 1)
152
+ tension = lam[:, None] * dP_md + lam_prev[:, None] * dP_0d
153
+ return dP, tension
154
+
155
+
156
+ def _energy_gradient(pts, KA, KP, A0, P0, lam):
157
+ """Return ∂E/∂v for one polygon with area–perimeter + edge tensions."""
158
+ A, P = polygon_area_perimeter(pts)
159
+ gA = _grad_area(pts)
160
+ gP, gT = _grad_perimeter_and_tension(pts, lam)
161
+
162
+ dE = 2.0 * KA * (A - A0) * gA + 2.0 * KP * P * gP + gT
163
+ return dE, A, P
164
+
165
+
166
+ # ------------------------------------------------
167
+
168
+
169
+ class DeformablePolygonSimulator:
170
+ """
171
+ Simulator for the deformable-polygon (DP) model of cell doublets.
172
+
173
+ Args:
174
+ phys: An instance of PhysicalParams containing the physical parameters, while *phys.r* and *phys.delta* are ignored.
175
+ num_vertices: Number of vertices :math:`M` to use for each polygon.
176
+
177
+ Raises:
178
+ TypeError: If *phys* is not an instance of *PhysicalParams*.
179
+
180
+ Warnings:
181
+ If the target shape index (based on *phys.P0* and *phys.A0*) indicates a non-circular shape,
182
+ a **UserWarning** is raised since the DP model is not valid in that regime.
183
+
184
+ """
185
+
186
+ def __init__(self, phys: PhysicalParams, num_vertices: int = 100):
187
+ """
188
+ Initialize the Deformable Polygon model for a cell doublet at steady state.
189
+ """
190
+ if not isinstance(phys, PhysicalParams): # pragma: no cover
191
+ raise TypeError("phys must be an instance of PhysicalParams")
192
+
193
+ self.phys = phys
194
+ self.num_vertices = num_vertices
195
+ self._get_target_shape_index() # check model validity
196
+
197
+ P0 = phys.P0
198
+ KP = phys.KP
199
+ Lambda = phys.lambda_tension
200
+
201
+ lambda_c = -P0 * 2 * KP
202
+ lambda_n = Lambda + lambda_c
203
+
204
+ l0, d0 = phys.get_steady_state()
205
+ self.phys = replace(phys, r=l0)
206
+
207
+ theta = np.arctan2(np.sqrt(l0**2 - (d0)**2), d0)
208
+ # ---------- initial shapes ----------
209
+ angles1 = np.linspace(theta, 2*np.pi-theta, num_vertices)
210
+ angles2 = np.linspace(-np.pi+theta, np.pi-theta, num_vertices)
211
+
212
+ pts1 = np.vstack((np.cos(angles1), np.sin(angles1))).T
213
+ pts1 *= l0
214
+ pts1[:, 0] -= pts1[0, 0]
215
+
216
+ pts2 = np.vstack((np.cos(angles2), np.sin(angles2))).T
217
+ pts2 *= l0
218
+ pts2[:, 0] -= pts2[0, 0]
219
+
220
+ # stitch endpoints
221
+ pts2[0] = pts1[-1]
222
+ pts2[-1] = pts1[0]
223
+
224
+ # per-vertex tensions (last index uses lambda_c, others lambda_n)
225
+ lam1 = np.full(len(pts1), lambda_n)
226
+ lam1[-1] = lambda_c
227
+ lam2 = np.full(len(pts2), lambda_n)
228
+ lam2[-1] = lambda_c
229
+
230
+ self.pts1 : np.ndarray = pts1 #: (N,2) array of vertices in cell 1.
231
+ self.pts2 : np.ndarray = pts2 #: (N,2) array of vertices in cell 2.
232
+ self.lam1 = lam1
233
+ self.lam2 = lam2
234
+
235
+ self.contact_length : float = np.linalg.norm(pts1[0] - pts1[-1]) #: Current contact length.
236
+ self.detach_criterion : float = 2.*np.pi*l0/num_vertices #: Contact length at which detachment occurs; defaults to :math:`2\pi \ell_0/M`.
237
+ self.detached : bool = True if self.contact_length <= self.detach_criterion else False #: Indicates whether the doublet has detached.
238
+
239
+
240
+ def _get_target_shape_index(self) -> float:
241
+ """Compute the target shape index
242
+ """
243
+ P0 = self.phys.P0
244
+ A0 = self.phys.A0
245
+ target_shape_index = P0 / np.sqrt(A0)
246
+
247
+ # 2.0 * np.sqrt(np.pi) is for circular shape
248
+ if target_shape_index > 2.0 * np.sqrt(np.pi): # pragma: no cover
249
+ # raise warning
250
+ import warnings
251
+ warnings.warn(
252
+ "Target shape index indicates non-circular shape; "
253
+ "the deformable-polygon (DP) model may not be valid in this regime. "
254
+ "Do not use the DP model for calibration if this warning appears.",
255
+ UserWarning,
256
+ stacklevel=3,
257
+ )
258
+
259
+ return target_shape_index
260
+
261
+
262
+ def _step_update(self, ext_force: float, dt: float) -> None:
263
+ r"""Single simulation step under external force.
264
+
265
+ Args:
266
+ ext_force: The external force applied to the cell doublet.
267
+ dt: Time step size.
268
+
269
+ .. warning::
270
+ This is an internal method. Use with caution.
271
+ We have implicitly assumed that the vertex mobility is :math:`\mu=1` so that :math:`\Delta x= F \Delta t`.
272
+ """
273
+
274
+ KA = self.phys.KA
275
+ KP = self.phys.KP
276
+ A0 = self.phys.A0
277
+ P0 = self.phys.P0
278
+
279
+ # gradients for each polygon
280
+ g1, A1, P1 = _energy_gradient(self.pts1, KA, KP, A0, P0, self.lam1)
281
+ g2, A2, P2 = _energy_gradient(self.pts2, KA, KP, A0, P0, self.lam2)
282
+
283
+ # ext_force * contrib1 / P1 # uniform force in -x direction, note force is the negative gradient
284
+ g1[:, 0] += ext_force / len(self.pts1)
285
+ # ext_force * contrib2 / P2 # uniform force in +x direction
286
+ g2[:, 0] -= ext_force / len(self.pts2)
287
+
288
+ # combine gradients at the two shared vertices:
289
+ # shared verts: pts1[-1] == pts2[0] and pts1[0] == pts2[-1]
290
+ g1_comb = g1.copy()
291
+ g2_comb = g2.copy()
292
+
293
+ g1_comb[-1] = g1[-1] + g2[0]
294
+ g1_comb[0] = g1[0] + g2[-1]
295
+ # For pts2, we will overwrite endpoints from pts1 after stepping, so their own update is not needed.
296
+
297
+ # update interior vertices
298
+ self.pts1 -= dt * g1_comb
299
+
300
+ # update interior of pts2
301
+ self.pts2[1:-1] -= dt * g2_comb[1:-1]
302
+
303
+ # synchronize the two shared endpoints to be identical (take the updated pts1 values)
304
+ self.pts2[0] = self.pts1[-1]
305
+ self.pts2[-1] = self.pts1[0]
306
+
307
+
308
+ def simulate(self, ext_force: float, dt: float, nsteps: int, resample_every: int = 1000) -> None:
309
+ """
310
+ Simulate the DP model for a number of time steps under an external force.
311
+
312
+ This basically is just a wrapper around :py:meth:`_step_update` and :py:func:`resample_polyline` with some bookkeeping.
313
+
314
+ Args:
315
+ ext_force: The external force applied to the cell doublet.
316
+ dt: Time step size.
317
+ nsteps: Number of time steps to simulate.
318
+ resample_every: How often (in steps) to resample the polygon vertices for uniform spacing.
319
+ """
320
+ if self.detached:
321
+ return
322
+
323
+ for _ in range(nsteps):
324
+
325
+ self._step_update(ext_force, dt)
326
+
327
+ if (_ + 1) % resample_every == 0:
328
+ self.pts1 = resample_polyline(self.pts1)
329
+ self.pts2 = resample_polyline(self.pts2)
330
+
331
+ self.contact_length = np.linalg.norm(self.pts1[0] - self.pts1[-1])
332
+
333
+ if self.contact_length <= self.detach_criterion:
334
+ self.detached = True
335
+ break
336
+
337
+
338
+ def plot_2d(self, ax: matplotlib.axes.Axes | None = None, show: bool = False) -> matplotlib.axes.Axes:
339
+ """
340
+ Render a 2D snapshot of the cell doublet in DP model.
341
+
342
+ Args:
343
+ ax: If provided, draw into this axes; otherwise get the current axes.
344
+ show: Whether to call ``plt.show()`` at the end.
345
+
346
+ Returns:
347
+ The matplotlib axes containing the plot.
348
+ """
349
+
350
+ import matplotlib.pyplot as plt
351
+
352
+ if ax is None: # pragma: no cover
353
+ ax = plt.gca()
354
+
355
+ pts1 = self.pts1
356
+ pts2 = self.pts2
357
+
358
+ ax.plot(pts1[:, 0], pts1[:, 1], 'o-', color=(21/255, 174/255, 22/255), ms=4, zorder=1)
359
+ ax.plot([pts1[0, 0], pts1[-1, 0]],
360
+ [pts1[0, 1], pts1[-1, 1]], '-', color=(222/255, 49/255, 34/255), lw=3, zorder=0) # contact
361
+ ax.plot(pts2[:, 0], pts2[:, 1], 'o-', color=(21/255, 174/255, 22/255), ms=4, zorder=1)
362
+
363
+
364
+ box_size = 2.5 * self.phys.r
365
+ ax.set_xlim(-box_size, box_size)
366
+ ax.set_ylim(-box_size/2, box_size/2)
367
+ ax.set_aspect('equal')
368
+ ax.axis('off') # <- hides ticks, labels, and spines
369
+
370
+ if show: # pragma: no cover
371
+ plt.show()
372
+ return ax
Binary file