pyafv 0.3.5__cp312-cp312-win_amd64.whl → 0.4.1__cp312-cp312-win_amd64.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 +2 -0
- pyafv/_version.py +1 -1
- pyafv/calibrate/__init__.py +15 -0
- pyafv/calibrate/core.py +90 -0
- pyafv/calibrate/deformable_polygon.py +372 -0
- pyafv/cell_geom.cp310-win32.pyd +0 -0
- pyafv/cell_geom.cp310-win_amd64.pyd +0 -0
- pyafv/cell_geom.cp311-win32.pyd +0 -0
- pyafv/cell_geom.cp311-win_amd64.pyd +0 -0
- pyafv/cell_geom.cp312-win32.pyd +0 -0
- pyafv/cell_geom.cp312-win_amd64.pyd +0 -0
- pyafv/finite_voronoi.py +69 -44
- pyafv/physical_params.py +109 -39
- pyafv-0.4.1.dist-info/METADATA +172 -0
- pyafv-0.4.1.dist-info/RECORD +19 -0
- {pyafv-0.3.5.dist-info → pyafv-0.4.1.dist-info}/WHEEL +1 -2
- pyafv-0.3.5.dist-info/METADATA +0 -117
- pyafv-0.3.5.dist-info/RECORD +0 -12
- pyafv-0.3.5.dist-info/top_level.txt +0 -1
- {pyafv-0.3.5.dist-info → pyafv-0.4.1.dist-info}/licenses/LICENSE +0 -0
pyafv/__init__.py
CHANGED
|
@@ -4,6 +4,7 @@ PyAFV - A Python implementation of the **active-finite-Voronoi (AFV) model** in
|
|
|
4
4
|
|
|
5
5
|
from .physical_params import PhysicalParams, target_delta
|
|
6
6
|
from .finite_voronoi import FiniteVoronoiSimulator
|
|
7
|
+
from . import calibrate
|
|
7
8
|
|
|
8
9
|
try:
|
|
9
10
|
from ._version import __version__
|
|
@@ -15,4 +16,5 @@ __all__ = [
|
|
|
15
16
|
"PhysicalParams",
|
|
16
17
|
"FiniteVoronoiSimulator",
|
|
17
18
|
"target_delta",
|
|
19
|
+
"calibrate",
|
|
18
20
|
]
|
pyafv/_version.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# pyafv/_version.py
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.4.1"
|
|
@@ -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
|
+
]
|
pyafv/calibrate/core.py
ADDED
|
@@ -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 an initially steady-state cell doublet under increasing external force dipoles using the DP model; the external force starts 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 cell.
|
|
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 is basically 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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|