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/finite_voronoi.py CHANGED
@@ -12,14 +12,18 @@ Key public entry points:
12
12
  - update_params(): update physical parameters.
13
13
  """
14
14
 
15
- from typing import Literal
16
- import numpy as np
17
- from scipy.spatial import Voronoi
18
- from matplotlib import pyplot as plt
19
- from matplotlib.axes import Axes
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
20
24
 
25
+ import numpy as np
21
26
  from .physical_params import PhysicalParams
22
- from .backend import backend_impl, _BACKEND_NAME
23
27
 
24
28
 
25
29
  # ---- tiny helpers to avoid tiny allocations in hot loops ----
@@ -36,19 +40,26 @@ class FiniteVoronoiSimulator:
36
40
  either a Cython-accelerated version or a pure Python fallback.
37
41
 
38
42
  Args:
39
- pts: (N,2) array of initial cell center positions.
43
+ pts (numpy.ndarray): (N,2) array of initial cell center positions.
40
44
  phys: Physical parameters used within this simulator.
41
45
  backend: Optional, specify "python" to force the use of the pure Python fallback implementation.
42
46
 
43
47
  Raises:
44
48
  ValueError: If *pts* does not have shape (N,2).
45
- TypeError: If *phys* is not an instance of PhysicalParams.
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.
46
54
  """
47
55
 
48
- def __init__(self, pts: np.ndarray, phys: PhysicalParams, backend: Literal["cython", "python"] | None = None):
56
+ def __init__(self, pts: np.ndarray, phys: PhysicalParams, backend: typing.Literal["cython", "python"] | None = None):
49
57
  """
50
58
  Constructor of the simulator.
51
59
  """
60
+ from scipy.spatial import Voronoi
61
+ self._voronoi = Voronoi
62
+
52
63
  pts = np.asarray(pts, dtype=float)
53
64
  if pts.ndim != 2 or pts.shape[1] != 2:
54
65
  raise ValueError("pts must have shape (N,2)")
@@ -61,6 +72,7 @@ class FiniteVoronoiSimulator:
61
72
  self._preferred_areas = np.full(self.N, phys.A0, dtype=float) # (N,) preferred areas A0
62
73
 
63
74
  if backend != "python":
75
+ from .backend import backend_impl, _BACKEND_NAME
64
76
  self._BACKEND = _BACKEND_NAME
65
77
  self._impl = backend_impl
66
78
 
@@ -93,15 +105,18 @@ class FiniteVoronoiSimulator:
93
105
  This is an internal method. Use with caution.
94
106
 
95
107
  Returns:
96
- tuple[scipy.spatial.Voronoi, numpy.ndarray, list[list[int]], int, dict[tuple[int,int], int], dict[int, list[int]]] : A tuple containing:
108
+ tuple[scipy.spatial.Voronoi, numpy.ndarray, list[list[int]], int, dict[tuple[int,int], int], dict[int, list[int]]] : A *tuple* containing:
97
109
 
98
110
  - **vor**: SciPy Voronoi object for current points with extensions.
99
111
  - **vertices_all**: (M,2) array of all Voronoi vertices including extensions.
100
- - **ridge_vertices_all**: list of lists of vertex indices for each ridge, including extensions.
112
+ - **ridge_vertices_all**: List of lists of vertex indices for each ridge, including extensions.
101
113
  - **num_vertices**: Number of Voronoi vertices before adding extension.
102
- - **vertexpair2ridge**: dict mapping vertex index pairs to ridge index.
103
- - **vertex_points**: dict mapping vertex index to list of associated point indices.
114
+ - **vertexpair2ridge**: *dict* mapping vertex index pairs to ridge index.
115
+ - **vertex_points**: *dict* mapping vertex index to list of associated point indices.
104
116
  """
117
+
118
+ Voronoi = self._voronoi
119
+
105
120
  r = self.phys.r
106
121
  pts = self.pts
107
122
  N = self.N
@@ -271,26 +286,26 @@ class FiniteVoronoiSimulator:
271
286
  Args:
272
287
  vor: SciPy Voronoi object for current points with extensions.
273
288
  vertices_all: (M,2) array of all Voronoi vertices including extensions.
274
- ridge_vertices_all: list of lists of vertex indices for each ridge, including extensions.
289
+ ridge_vertices_all: (R,2) array of vertex indices for each ridge.
275
290
  num_vertices: Number of Voronoi vertices before adding extension.
276
- vertexpair2ridge: dict mapping vertex index pairs to ridge index.
291
+ vertexpair2ridge: *dict* mapping vertex index pairs to ridge index.
277
292
 
278
293
  Returns:
279
294
  dict[str, object]: A diagnostics dictionary containing:
280
295
 
281
- - **vertex_in_id**: set of inner vertex ids.
282
- - **vertex_out_id**: set of outer vertex ids.
296
+ - **vertex_in_id**: *set* of inner vertex ids.
297
+ - **vertex_out_id**: *set* of outer vertex ids.
283
298
  - **vertices_out**: (L,2) array of outer vertex coordinates.
284
299
  - **vertex_out_points**: (L,2) array of point index pairs associated with each outer vertex.
285
- - **vertex_out_da_dtheta**: array of dA/dtheta for all outer vertices.
286
- - **vertex_out_dl_dtheta**: array of dL/dtheta for all outer vertices.
287
- - **dA_poly_dh**: array of dA_polygon/dh for each vertex.
288
- - **dP_poly_dh**: array of dP_polygon/dh for each vertex.
289
- - **area_list**: array of polygon areas for each cell.
290
- - **perimeter_list**: array of polygon perimeters for each cell.
291
- - **point_edges_type**: list of lists of edge types per cell.
292
- - **point_vertices_f_idx**: list of lists of vertex ids per cell.
293
- - **num_vertices_ext**: number of vertices including infinite extension vertices.
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.
294
309
  """
295
310
  N = self.N
296
311
  r = self.phys.r
@@ -696,7 +711,7 @@ class FiniteVoronoiSimulator:
696
711
  return F
697
712
 
698
713
  # --------------------- One integration step ---------------------
699
- def build(self) -> dict[str, object]:
714
+ def build(self, connect: bool = True) -> dict[str, object]:
700
715
  """ Build the finite-Voronoi structure and compute forces, returning a dictionary of diagnostics.
701
716
 
702
717
  Do the following:
@@ -705,6 +720,10 @@ class FiniteVoronoiSimulator:
705
720
  - Compute per-cell quantities and derivatives
706
721
  - Assemble forces
707
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.
708
727
 
709
728
  Returns:
710
729
  dict[str, object]: A dictionary containing geometric properties with keys:
@@ -713,14 +732,18 @@ class FiniteVoronoiSimulator:
713
732
  - **areas**: (N,) array of cell areas
714
733
  - **perimeters**: (N,) array of cell perimeters
715
734
  - **vertices**: (M,2) array of all Voronoi + extension vertices
716
- - **edges_type**: list-of-lists of edge types per cell (1=straight, 0=circular arc)
717
- - **regions**: list-of-lists of vertex indices per cell
718
- - **connections**: (M',2) array of connected cell index pairs
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
719
738
  """
720
739
  (vor, vertices_all, ridge_vertices_all, num_vertices,
721
740
  vertexpair2ridge, vertex_points) = self._build_voronoi_with_extensions()
722
741
 
723
- connections = self._get_connections(vor.ridge_points, vertices_all, ridge_vertices_all)
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)
724
747
 
725
748
  geom, vertices_all = self._per_cell_geometry(vor, vertices_all, ridge_vertices_all, num_vertices, vertexpair2ridge)
726
749
 
@@ -750,11 +773,11 @@ class FiniteVoronoiSimulator:
750
773
  )
751
774
 
752
775
  # --------------------- 2D plotting utilities ---------------------
753
- def plot_2d(self, ax: Axes | None = None, show: bool = False) -> Axes:
776
+ def plot_2d(self, ax: matplotlib.axes.Axes | None = None, show: bool = False) -> matplotlib.axes.Axes:
754
777
  """
755
778
  Build the finite-Voronoi structure and render a 2D snapshot.
756
779
 
757
- Basically a wrapper of :py:func:`_build_voronoi_with_extensions` and :py:func:`_per_cell_geometry` functions + plot.
780
+ Basically a wrapper of :py:meth:`_build_voronoi_with_extensions` and :py:meth:`_per_cell_geometry` functions + plot.
758
781
 
759
782
  Args:
760
783
  ax: If provided, draw into this axes; otherwise get the current axes.
@@ -768,6 +791,8 @@ class FiniteVoronoiSimulator:
768
791
 
769
792
  geom, vertices_all = self._per_cell_geometry(vor, vertices_all, ridge_vertices_all, num_vertices, vertexpair2ridge)
770
793
 
794
+ from matplotlib import pyplot as plt
795
+
771
796
  if ax is None:
772
797
  ax = plt.gca()
773
798
 
@@ -779,7 +804,7 @@ class FiniteVoronoiSimulator:
779
804
  return ax
780
805
 
781
806
  # --------------------- Paradigm of plotting ---------------------
782
- def _plot_routine(self, ax: Axes, vor: Voronoi, vertices_all: np.ndarray, ridge_vertices_all: list[list[int]],
807
+ def _plot_routine(self, ax: matplotlib.axes.Axes, vor: Voronoi, vertices_all: np.ndarray, ridge_vertices_all: list[list[int]],
783
808
  point_edges_type: list[list[int]], point_vertices_f_idx: list[list[int]]) -> None:
784
809
  """
785
810
  Low-level plot routine. Draws:
@@ -887,13 +912,12 @@ class FiniteVoronoiSimulator:
887
912
  Update cell center positions.
888
913
 
889
914
  .. note::
890
- If the number of points changes, the preferred areas for all cells
891
- are reset to the default value (set when initializing the simulator
892
- instance or by :py:func:`update_params`) unless specified via the
893
- *A0* argument.
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.
894
918
 
895
919
  Args:
896
- pts: New cell center positions.
920
+ pts : New cell center positions.
897
921
  A0: Optional, set new preferred area(s).
898
922
 
899
923
  Raises:
@@ -923,10 +947,10 @@ class FiniteVoronoiSimulator:
923
947
  Update physical parameters.
924
948
 
925
949
  Args:
926
- phys: New PhysicalParams object.
950
+ phys: New :py:class:`PhysicalParams` object.
927
951
 
928
952
  Raises:
929
- TypeError: If *phys* is not an instance of PhysicalParams.
953
+ TypeError: If *phys* is not an instance of :py:class:`PhysicalParams`.
930
954
 
931
955
  .. warning::
932
956
  This also resets all preferred cell areas to the new value of *A0*.
@@ -943,7 +967,8 @@ class FiniteVoronoiSimulator:
943
967
  Update the preferred areas for all cells.
944
968
 
945
969
  Args:
946
- A0: New preferred area(s) for all cells.
970
+ A0: New preferred area(s) for all cells, either as a scalar or
971
+ as an array of shape (N,).
947
972
 
948
973
  Raises:
949
974
  ValueError: If *A0* does not match cell number.
@@ -963,8 +988,8 @@ class FiniteVoronoiSimulator:
963
988
 
964
989
  @property
965
990
  def preferred_areas(self) -> np.ndarray:
966
- """
967
- Preferred areas for all cells (read-only).
991
+ r"""
992
+ Preferred areas :math:`\{A_{0,i}\}` for all cells (read-only).
968
993
 
969
994
  Returns:
970
995
  numpy.ndarray: A copy of the internal preferred area array.
pyafv/physical_params.py CHANGED
@@ -1,9 +1,27 @@
1
1
  from __future__ import annotations
2
2
  import numpy as np
3
- from scipy.optimize import minimize
4
3
  from dataclasses import dataclass, replace
5
4
 
6
5
 
6
+ def _require_float_scalar(x: object, name: str) -> float: # pragma: no cover
7
+ """
8
+ Accept Python real scalars (int/float) and NumPy real scalars.
9
+ Reject other types (including bool).
10
+ Return a normalized Python float.
11
+ """
12
+ # Reject bool explicitly (since bool is a subclass of int)
13
+ if isinstance(x, bool):
14
+ raise TypeError(f"{name} must be a real scalar (float-like), got bool")
15
+ elif isinstance(x, (int, float, np.integer, np.floating)):
16
+ xf = float(x)
17
+ if not np.isfinite(xf):
18
+ raise ValueError(f"{name} must be finite, got {x}")
19
+ return xf
20
+
21
+ # Reject everything else
22
+ raise TypeError(f"{name} must be a real scalar (float-like), got {type(x).__name__}")
23
+
24
+
7
25
  def sigmoid(x):
8
26
  # stable sigmoid that handles large |x|
9
27
  if x >= 0:
@@ -16,35 +34,55 @@ def sigmoid(x):
16
34
 
17
35
  @dataclass(frozen=True)
18
36
  class PhysicalParams:
19
- """Physical parameters for the active-finite-Voronoi (AFV) model.
37
+ r"""Physical parameters for the active-finite-Voronoi (AFV) model.
20
38
 
21
- Caveat:
22
- Frozen dataclass is used for :py:class:`PhysicalParams` to ensure immutability of instances.
39
+ .. warning::
40
+ * **Frozen dataclass** is used for :py:class:`PhysicalParams` to ensure immutability of instances.
41
+ * Do not set :py:attr:`delta` unless you know what you are doing.
23
42
 
24
43
  Args:
25
- r: Radius (maximal) of the Voronoi cells.
44
+ r: Radius (maximal) of the Voronoi cells, sometimes denoted as :math:`\ell`.
26
45
  A0: Preferred area of the Voronoi cells.
27
46
  P0: Preferred perimeter of the Voronoi cells.
28
47
  KA: Area elasticity constant.
29
48
  KP: Perimeter elasticity constant.
30
49
  lambda_tension: Tension difference between non-contacting edges and contacting edges.
31
- delta: Small offset to avoid singularities in computations.
50
+ delta: Contact truncation threshold to avoid singularities in computations; if None, set to 0.45*r.
32
51
  """
33
52
 
34
- r: float = 1.0 #: Radius (maximal) of the Voronoi cells.
53
+ r: float = 1.0 #: Radius (maximal) of the Voronoi cells, sometimes denoted as :math:`\ell`.
35
54
  A0: float = np.pi #: Preferred area of the Voronoi cells.
36
55
  P0: float = 4.8 #: Preferred perimeter of the Voronoi cells.
37
56
  KA: float = 1.0 #: Area elasticity constant.
38
57
  KP: float = 1.0 #: Perimeter elasticity constant.
39
58
  lambda_tension: float = 0.2 #: Tension difference between non-contacting edges and contacting edges.
40
- delta: float = 0.0 #: Small offset to avoid singularities in computations.
41
-
59
+ delta: float | None = None #: Contact truncation threshold to avoid singularities in computations.
60
+
61
+ def __post_init__(self):
62
+ # Normalize and validate required scalar floats
63
+ object.__setattr__(self, "r", _require_float_scalar(self.r, "r"))
64
+ object.__setattr__(self, "A0", _require_float_scalar(self.A0, "A0"))
65
+ object.__setattr__(self, "P0", _require_float_scalar(self.P0, "P0"))
66
+ object.__setattr__(self, "KA", _require_float_scalar(self.KA, "KA"))
67
+ object.__setattr__(self, "KP", _require_float_scalar(self.KP, "KP"))
68
+ object.__setattr__(self, "lambda_tension", _require_float_scalar(self.lambda_tension, "lambda_tension"))
69
+
70
+ if self.delta is None:
71
+ object.__setattr__(self, "delta", 0.45 * self.r)
72
+ else:
73
+ try:
74
+ object.__setattr__(self, "delta", _require_float_scalar(self.delta, "delta"))
75
+ except TypeError: # pragma: no cover
76
+ raise TypeError(f"delta must be a real scalar (float-like) or None, got {type(self.delta).__name__}") from None
42
77
 
43
78
  def get_steady_state(self) -> tuple[float, float]:
44
- r"""Compute steady-state (l,d) for the given physical parameters.
45
-
79
+ r"""Search for the steady-state :math:`(\ell,d)` of a cell doublet for the given physical parameters (by minimizing total energy).
80
+
46
81
  Returns:
47
- Steady-state :math:`(\ell,d)` values.
82
+ Steady-state (optimal) :math:`(\ell_0,d_0)` values.
83
+
84
+ .. note::
85
+ :math:`\ell` is the maximal cell radius, and :math:`2d` is the cell-center distance of a doublet (rather than :math:`d`).
48
86
  """
49
87
  params = [self.KA, self.KP, self.A0, self.P0, self.lambda_tension]
50
88
  result = self._minimize_energy(params, restarts=10)
@@ -52,23 +90,30 @@ class PhysicalParams:
52
90
  return l, d
53
91
 
54
92
  def with_optimal_radius(self) -> PhysicalParams:
55
- """Returns a new instance with the radius updated to steady state.
93
+ r"""Returns a new instance of :py:class:`PhysicalParams` with the maximum radius :math:`\ell` (or :py:attr:`r`) updated to the steady state value :math:`\ell_0` of cell doublets.
94
+ Other parameters (except :py:attr:`delta`) remain unchanged.
56
95
 
96
+ Basically a wrapper around :py:meth:`get_steady_state` + creating a new instance.
97
+
57
98
  Returns:
58
99
  New instance with optimal radius.
100
+
101
+ .. important::
102
+ In the returned instance, the contact truncation threshold :py:attr:`delta` is set to 0.45*r by default.
59
103
  """
60
104
  l, d = self.get_steady_state()
61
- new_params = replace(self, r=l)
105
+ new_params = replace(self, r=l, delta=0.45*l)
62
106
  return new_params
63
107
 
64
108
  def with_delta(self, delta_new: float) -> PhysicalParams:
65
- """Returns a new instance with the specified delta.
66
-
109
+ """Returns a new instance of :py:class:`PhysicalParams` with the new contact truncation threshold *delta_new*.
110
+ Other parameters remain unchanged.
111
+
67
112
  Args:
68
- delta_new: New delta value.
113
+ delta_new: New :py:attr:`delta` value.
69
114
 
70
115
  Returns:
71
- New instance with updated delta.
116
+ New instance with the updated delta value.
72
117
  """
73
118
  return replace(self, delta=delta_new)
74
119
 
@@ -86,6 +131,9 @@ class PhysicalParams:
86
131
  return KA * (A - A0)**2 + KP * (P - P0)**2 + Lambda * ln
87
132
 
88
133
  def _minimize_energy(self, params, restarts=10, seed=None):
134
+
135
+ from scipy.optimize import minimize
136
+
89
137
  rng = np.random.default_rng(seed)
90
138
  best = None
91
139
  for _ in range(restarts):
@@ -117,34 +165,56 @@ def target_delta(params: PhysicalParams, target_force: float) -> float:
117
165
  params: Physical parameters of the AFV model.
118
166
  target_force: Target detachment force.
119
167
 
120
- Returns:
121
- Corresponding small cutoff :math:`\delta`'s value.
122
- """
123
- KP, A0, P0, Lambda = params.KP, params.A0, params.P0, params.lambda_tension
124
- l = params.r
168
+ Raises:
169
+ TypeError: If *params* is not an instance of :py:class:`PhysicalParams`.
170
+ ValueError: If the target force is not within the achievable range.
125
171
 
126
- distances = np.linspace(1e-6, 2*l-(1e-6), 10_000)
127
- detachment_forces = []
128
- for distance in distances:
129
- epsilon = l - (distance/2.)
172
+ Returns:
173
+ Corresponding value of the truncation threshold :math:`\delta`.
130
174
 
131
- theta = 2 * np.pi - 2 * np.arctan2(np.sqrt(l**2 - (l - epsilon)**2), l - epsilon)
132
- A = (l - epsilon) * np.sqrt(l**2 -
133
- (l - epsilon)**2) + 0.5 * (l**2 * theta)
134
- P = 2 * np.sqrt(l**2 - (l - epsilon)**2) + l * theta
175
+ .. note::
176
+ We search for the cell-cell separation at which the intercellular force
177
+ equals the target force, scanning distances from :math:`10^{-6}\ell` to
178
+ :math:`(2-10^{-6})\ell` in steps of :math:`10^{-6}\ell`, and select the
179
+ **largest distance** at which the match occurs.
180
+ """
135
181
 
136
- f = 4. * np.sqrt((2-epsilon) * epsilon) * (A - A0 + KP * ((P - P0)/(2 - epsilon))
137
- + (Lambda/2) * (1./((2-epsilon)*epsilon)))
138
- detachment_forces.append(f)
182
+ if not isinstance(params, PhysicalParams): # pragma: no cover
183
+ raise TypeError("params must be an instance of PhysicalParams")
139
184
 
140
- # print(detachment_forces)
185
+ KA, KP, A0, P0, Lambda = params.KA, params.KP, params.A0, params.P0, params.lambda_tension
186
+ l = params.r
141
187
 
142
- detachment_forces = np.array(detachment_forces)
188
+ distances = np.linspace(1e-6, 2.-(1e-6), 10**6) * l
189
+
190
+ epsilon = l - (distances/2.)
143
191
 
144
- idx = np.abs(detachment_forces[None, :] - target_force).argmin()
145
- target_distances = distances[idx]
192
+ theta = 2 * np.pi - 2 * np.arctan2(np.sqrt(l**2 - (l - epsilon)**2), l - epsilon)
193
+ A = (l - epsilon) * np.sqrt(l**2 -
194
+ (l - epsilon)**2) + 0.5 * (l**2 * theta)
195
+ P = 2 * np.sqrt(l**2 - (l - epsilon)**2) + l * theta
146
196
 
147
- delta = np.sqrt(4*(l**2) - target_distances**2)
197
+ detachment_forces = 4. * np.sqrt((2 * l - epsilon) * epsilon) * (KA * (A - A0) + KP * ((P - P0)/(2 * l - epsilon))
198
+ + (Lambda/2) * l /((2 * l - epsilon) * epsilon))
199
+
200
+ # idx = np.abs(detachment_forces[None, :] - target_force).argmin()
201
+ # target_distances = distances[idx]
202
+
203
+ # ---------------------- Better way to search foot ----------------------------
204
+ f = detachment_forces - target_force # find root of f=0
205
+ cross = (f[:-1] == 0) | (np.signbit(f[:-1]) != np.signbit(f[1:])) # crossing points
206
+
207
+ if np.any(cross):
208
+ i = np.flatnonzero(cross)[-1] # last crossing interval [i, i+1]
209
+ # optional: linear interpolation for a better distance estimate
210
+ x0, x1 = distances[i], distances[i+1]
211
+ f0, f1 = f[i], f[i+1]
212
+ target_distance = x0 if f1 == f0 else x0 + (0 - f0) * (x1 - x0) / (f1 - f0)
213
+ else:
214
+ raise ValueError("No valid delta found for the given target force.")
215
+ # ------------------------------------------------------------------------------
216
+
217
+ delta = np.sqrt(4*(l**2) - target_distance**2)
148
218
 
149
219
  return delta
150
220
 
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyafv
3
+ Version: 0.4.1
4
+ Summary: Python implementation of the active-finite-Voronoi (AFV) model
5
+ Project-URL: Homepage, https://pyafv.github.io
6
+ Project-URL: Download, https://pypi.org/project/pyafv/#files
7
+ Project-URL: Source Code, https://github.com/wwang721/pyafv
8
+ Project-URL: Documentation, https://pyafv.readthedocs.io/
9
+ Project-URL: Changelog, https://github.com/wwang721/pyafv/releases/latest
10
+ Author: Wei Wang
11
+ Author-email: ww000721@gmail.com
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: biological-modeling,cellular-patterns,voronoi-model
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: Science/Research
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Topic :: Scientific/Engineering
26
+ Requires-Python: <3.15,>=3.10
27
+ Requires-Dist: matplotlib>=3.8.4
28
+ Requires-Dist: numpy>=1.26.4
29
+ Requires-Dist: scipy>=1.13.1
30
+ Provides-Extra: examples
31
+ Requires-Dist: ipywidgets>=8.1.5; extra == 'examples'
32
+ Requires-Dist: jupyter>=1.1.0; extra == 'examples'
33
+ Requires-Dist: tqdm>=4.67.1; extra == 'examples'
34
+ Description-Content-Type: text/markdown
35
+
36
+ [![PyPi](https://img.shields.io/pypi/v/pyafv?cacheSeconds=300)](https://pypi.org/project/pyafv/)
37
+ [![Downloads](https://img.shields.io/pypi/dm/pyafv.svg?cacheSeconds=43200)](https://pypi.org/project/pyafv/)
38
+ [![Documentation](https://img.shields.io/badge/documentation-pyafv.readthedocs.io-yellow.svg?logo=readthedocs)](https://pyafv.readthedocs.io)
39
+ <!--[![pytest](https://github.com/wwang721/pyafv/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/wwang721/pyafv/actions/workflows/tests.yml?query=branch:main)-->
40
+ [![Tests on all platforms](https://github.com/wwang721/pyafv/actions/workflows/tests_all_platform.yml/badge.svg)](https://github.com/wwang721/pyafv/actions/workflows/tests_all_platform.yml)
41
+ [![pytest](https://github.com/wwang721/pyafv/actions/workflows/tests.yml/badge.svg)](https://github.com/wwang721/pyafv/actions/workflows/tests.yml)
42
+ [![Codecov](https://codecov.io/github/wwang721/pyafv/branch/main/graph/badge.svg?token=VSXSOX8HVS)](https://codecov.io/github/wwang721/pyafv/tree/main)
43
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
44
+
45
+ <!--
46
+ [![arXiv:2503.03126](https://img.shields.io/badge/arXiv-2503.03126-grey.svg?colorA=a42c25&colorB=grey&logo=arxiv)](https://doi.org/10.48550/arXiv.2503.03126)
47
+ [![PhysRevE.109.054408](https://img.shields.io/badge/Phys.%20Rev.%20E-109.054408-grey.svg?colorA=8c6040)](https://doi.org/10.1103/PhysRevE.109.054408)
48
+ [![Soft Matter](https://img.shields.io/badge/Soft%20Matter-XXXXX-63a7c2.svg?colorA=63a7c2&colorB=grey)](https://doi.org/10.1103/PhysRevE.109.054408)
49
+ -->
50
+
51
+
52
+ # PyAFV
53
+
54
+ Python code that implements the **active-finite-Voronoi (AFV) model** in 2D.
55
+ The AFV framework was introduced and developed in, for example, Refs. [[1](#huang2023bridging)&ndash;[3](#wang2026divergence)].
56
+
57
+
58
+ ## Installation
59
+
60
+ **PyAFV** is available on **PyPI** and can be installed using *pip* directly:
61
+ ```bash
62
+ pip install pyafv
63
+ ```
64
+ The package supports Python ≥ 3.10 and < 3.15, including Python 3.14t (the free-threaded, no-GIL build).
65
+ To verify that the installation was successful and that the correct version is installed, run the following in Python:
66
+ ```python
67
+ import pyafv
68
+ print(pyafv.__version__)
69
+ ```
70
+
71
+ > On HPC clusters, global Python path can contaminate the runtime environment. You may need to clear it explicitly using `unset PYTHONPATH` or prefixing the *pip* command with `PYTHONPATH=""`.
72
+
73
+ ### Install from source
74
+
75
+ Installing from source can be necessary if *pip* installation does not work. First, download and unzip the source code, then navigate to the root directory of the package and run:
76
+ ```bash
77
+ pip install .
78
+ ```
79
+
80
+ > **Note:** A C/C++ compiler is required if you are building from source, since some components of **PyAFV** are implemented in Cython for performance optimization.
81
+
82
+
83
+ #### Windows MinGW GCC
84
+
85
+ If you are using **MinGW GCC** (rather than **MSVC**) on *Windows*, to build from the source code, add a `setup.cfg` at the repository root before running `pip install .` with the following content:
86
+ ```ini
87
+ # setup.cfg
88
+ [build_ext]
89
+ compiler=mingw32
90
+ ```
91
+
92
+
93
+ ### Install offline
94
+
95
+ If you need to install **PyAFV** on a machine without internet access, you can download the corresponding wheel file from **PyPI** and transfer it to the target machine, and then run the following command to install using *pip*:
96
+ ```bash
97
+ pip install pyafv-<version>-<platform>.whl
98
+ ```
99
+ Alternatively, you can build **PyAFV** from source as described in the previous section. In this case, in addition to the required prerequisites of the package, the build-time dependencies **hatchling** and **hatch-cython** must also be available.
100
+
101
+
102
+ ## Usage
103
+
104
+ Here is a simple example to get you started, demonstrating how to construct a finite-Voronoi diagram:
105
+ ```python
106
+ import numpy as np
107
+ import pyafv as afv
108
+
109
+ N = 100 # number of cells
110
+ pts = np.random.rand(N, 2) * 10 # initial positions
111
+ params = afv.PhysicalParams() # use default parameter values
112
+ sim = afv.FiniteVoronoiSimulator(pts, params) # initialize the simulator
113
+ sim.plot_2d(show=True) # visualize the Voronoi diagram
114
+ ```
115
+ To compute the conservative forces and extract detailed geometric information (e.g., cell areas, vertices, and edges), call:
116
+ ```python
117
+ diag = sim.build()
118
+ ```
119
+ The returned object `diag` is a Python `dict` containing these quantities.
120
+
121
+
122
+ ## Simulation previews
123
+
124
+ Below are representative simulation snapshots generated using the code:
125
+
126
+ | Model illustration | Periodic boundary conditions |
127
+ |-----------------|-----------------|
128
+ | <img src="https://media.githubusercontent.com/media/wwang721/pyafv/main/assets/model_illustration.png" width="540"> | <img src="https://media.githubusercontent.com/media/wwang721/pyafv/main/assets/pbc.png" width="385">|
129
+
130
+ | Initial configuration | After relaxation | Active dynamics enabled |
131
+ |-----------------------|-----------------------|-----------------------|
132
+ | <img src="https://media.githubusercontent.com/media/wwang721/pyafv/main/assets/initial_configuration.png" width="300"> | <img src="https://media.githubusercontent.com/media/wwang721/pyafv/main/assets/relaxed_configuration.png" width="300"> | <img src="https://media.githubusercontent.com/media/wwang721/pyafv/main/assets/active_FV.png" width="300"> |
133
+
134
+
135
+ ## More information
136
+
137
+ - **Full documentation** on [readthedocs](https://pyafv.readthedocs.io) or as [a single PDF file](https://pyafv.readthedocs.io/_/downloads/en/latest/pdf/).
138
+
139
+ - See [CONTRIBUTING.md](https://github.com/wwang721/pyafv/blob/main/.github/CONTRIBUTING.md) or the [documentation](https://pyafv.readthedocs.io/latest/contributing.html) for **local development instructions**.
140
+
141
+ - See some important [**issues**](https://github.com/wwang721/pyafv/issues?q=is%3Aissue%20state%3Aclosed) for additional context, such as:
142
+ * [QhullError when 3+ points are collinear #1](https://github.com/wwang721/pyafv/issues/1) [Closed - see [comments](https://github.com/wwang721/pyafv/issues/1#issuecomment-3701355742)]
143
+ * [Add customized plotting to examples illustrating access to vertices and edges #5](https://github.com/wwang721/pyafv/issues/5) [Completed in PR [#7](https://github.com/wwang721/pyafv/pull/7)]
144
+ * [Time step dependence of intercellular adhesion in simulations #8](https://github.com/wwang721/pyafv/issues/8) [Closed in PR [#9](https://github.com/wwang721/pyafv/pull/9)]
145
+
146
+ - Some releases of this repository are cross-listed on [Zenodo](https://doi.org/10.5281/zenodo.18091659):
147
+
148
+ [![Zenodo](https://zenodo.org/badge/1124385738.svg)](https://doi.org/10.5281/zenodo.18091659)
149
+
150
+
151
+ ## References
152
+
153
+ <table>
154
+ <tr>
155
+ <td id="huang2023bridging" valign="top">[1]</td>
156
+ <td>
157
+ J. Huang, H. Levine, and D. Bi, <em>Bridging the gap between collective motility and epithelial-mesenchymal transitions through the active finite Voronoi model</em>, <a href="https://doi.org/10.1039/D3SM00327B">Soft Matter <strong>19</strong>, 9389 (2023)</a>.
158
+ </td>
159
+ </tr>
160
+ <tr>
161
+ <td id="teomy2018confluent" valign="top">[2]</td>
162
+ <td>
163
+ E. Teomy, D. A. Kessler, and H. Levine, <em>Confluent and nonconfluent phases in a model of cell tissue</em>, <a href="https://doi.org/10.1103/PhysRevE.98.042418">Phys. Rev. E <strong>98</strong>, 042418 (2018)</a>.
164
+ </td>
165
+ </tr>
166
+ <tr>
167
+ <td id="wang2026divergence" valign="top">[3]</td>
168
+ <td>
169
+ W. Wang (汪巍) and B. A. Camley, <em>Divergence of detachment forces in the finite-Voronoi model</em>, manuscript in preparation (2026).
170
+ </td>
171
+ </tr>
172
+ </table>