polyphys 1.0.0__py3-none-any.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.
Files changed (69) hide show
  1. polyphys/__init__.py +7 -0
  2. polyphys/__version__.py +22 -0
  3. polyphys/analyze/__init__.py +51 -0
  4. polyphys/analyze/clusters.py +1738 -0
  5. polyphys/analyze/correlations.py +995 -0
  6. polyphys/analyze/density.py +1138 -0
  7. polyphys/analyze/measurer.py +1338 -0
  8. polyphys/analyze/pipeline.py +1868 -0
  9. polyphys/analyze/stats.py +810 -0
  10. polyphys/analyze/sumrule.py +1159 -0
  11. polyphys/manage/__init__.py +11 -0
  12. polyphys/manage/organizer.py +2188 -0
  13. polyphys/manage/parser.py +1264 -0
  14. polyphys/manage/types.py +171 -0
  15. polyphys/manage/utils.py +777 -0
  16. polyphys/probe/__init__.py +25 -0
  17. polyphys/probe/binders.py +711 -0
  18. polyphys/probe/chain_size.py +583 -0
  19. polyphys/probe/core.py +437 -0
  20. polyphys/probe/histograms.py +863 -0
  21. polyphys/probe/pairs.py +555 -0
  22. polyphys/simulate/__init__.py +48 -0
  23. polyphys/simulate/build.py +1231 -0
  24. polyphys/simulate/core.py +237 -0
  25. polyphys/simulate/data.py +977 -0
  26. polyphys/simulate/dump.py +752 -0
  27. polyphys/simulate/log.py +788 -0
  28. polyphys/spec.py +1359 -0
  29. polyphys/tests/analyze/test_clusters.py +1058 -0
  30. polyphys/tests/analyze/test_correlations.py +708 -0
  31. polyphys/tests/analyze/test_density.py +813 -0
  32. polyphys/tests/analyze/test_measurer.py +785 -0
  33. polyphys/tests/analyze/test_pipeline.py +1881 -0
  34. polyphys/tests/analyze/test_stats.py +732 -0
  35. polyphys/tests/analyze/test_sumrule.py +917 -0
  36. polyphys/tests/conftest.py +753 -0
  37. polyphys/tests/data/lammps_build/make_starts.py +70 -0
  38. polyphys/tests/manage/test_organizer.py +1495 -0
  39. polyphys/tests/manage/test_parser.py +1222 -0
  40. polyphys/tests/manage/test_utils.py +498 -0
  41. polyphys/tests/probe/conftest.py +407 -0
  42. polyphys/tests/probe/test_binders.py +686 -0
  43. polyphys/tests/probe/test_chain_size.py +557 -0
  44. polyphys/tests/probe/test_core.py +391 -0
  45. polyphys/tests/probe/test_histograms.py +779 -0
  46. polyphys/tests/probe/test_pairs.py +475 -0
  47. polyphys/tests/simulate/test_build.py +1042 -0
  48. polyphys/tests/simulate/test_core.py +106 -0
  49. polyphys/tests/simulate/test_data.py +858 -0
  50. polyphys/tests/simulate/test_dump.py +487 -0
  51. polyphys/tests/simulate/test_log.py +667 -0
  52. polyphys/tests/strategies.py +120 -0
  53. polyphys/tests/synthetic.py +410 -0
  54. polyphys/tests/test_conftest.py +414 -0
  55. polyphys/tests/test_docs.py +82 -0
  56. polyphys/tests/test_packaging.py +97 -0
  57. polyphys/tests/test_spec.py +772 -0
  58. polyphys/tests/viz/conftest.py +32 -0
  59. polyphys/tests/viz/test_colors.py +113 -0
  60. polyphys/tests/viz/test_helpers.py +292 -0
  61. polyphys/viz/__init__.py +15 -0
  62. polyphys/viz/colors.py +204 -0
  63. polyphys/viz/helpers.py +549 -0
  64. polyphys-1.0.0.dist-info/METADATA +339 -0
  65. polyphys-1.0.0.dist-info/RECORD +69 -0
  66. polyphys-1.0.0.dist-info/WHEEL +5 -0
  67. polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
  68. polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
  69. polyphys-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1231 @@
1
+ """\
2
+ =====================================================================
3
+ Start configurations --- :mod:`polyphys.simulate.build`
4
+ =====================================================================
5
+
6
+ The :mod:`~polyphys.simulate.build` module builds LAMMPS start
7
+ configurations of a bead-spring polymer in a predictable, unphysical shape,
8
+ a helix or a closed plectoneme, which a LAMMPS run then equilibrates. It
9
+ places beads along a curve a fixed bond length apart, adds the chain
10
+ topology, checks that LAMMPS can start from the result, and hands the
11
+ configuration to :func:`~polyphys.simulate.data.write_data` as a
12
+ :class:`~polyphys.simulate.data.DataFile`. The design follows moltemplate's
13
+ confined-DNA example (Jewett et al., 2021): a curve, beads placed along it,
14
+ the topology, then the file. Nothing is copied from moltemplate.
15
+
16
+ .. versionadded:: 0.6.0
17
+
18
+ Contents
19
+ ========
20
+
21
+ :func:`helix` and :func:`plectoneme_ring` place beads on the two built-in
22
+ shapes; :func:`resample_curve` places them along any curve given as points.
23
+ :func:`chain_bonds` and :func:`chain_angles` give the topology of a linear
24
+ or ring chain, and :func:`chain_data` wraps positions and topology in a
25
+ :class:`~polyphys.simulate.data.DataFile`. :func:`check_conformation` tests
26
+ a data file against the conditions under which LAMMPS stops or warns, and
27
+ raises :class:`ConformationError` or returns a :class:`ConformationReport`.
28
+
29
+ Notation
30
+ ========
31
+
32
+ :math:`N`
33
+ The number of beads.
34
+ :math:`b`
35
+ The bond length, the distance between bonded bead centers.
36
+ :math:`\\sigma`, :math:`\\epsilon`
37
+ The bead diameter and the energy unit of the Lennard-Jones (LJ) units of
38
+ the run; all lengths here are in :math:`\\sigma`.
39
+ :math:`R`, :math:`p`
40
+ The radius and the pitch (the axial rise per turn) of a helix;
41
+ :math:`c = p / 2\\pi` is the rise per radian.
42
+ :math:`\\Delta t`
43
+ The angle between neighboring beads on a helix.
44
+ :math:`\\theta_A`
45
+ The helical angle, between a strand's tangent and the axis:
46
+ :math:`\\tan\\theta_A = 2\\pi R / p`.
47
+ :math:`L_A`
48
+ The arclength of one strand of a plectoneme.
49
+ :math:`K`, :math:`R_0`
50
+ The spring constant and the maximum extension of a FENE bond.
51
+ :math:`d_{\\min}`
52
+ The smallest distance :func:`check_conformation` allows between two
53
+ beads that are not bonded.
54
+ :math:`D`, :math:`a`
55
+ The diameter of a cylindrical confinement and the bead diameter;
56
+ :math:`D - a` is the effective diameter, the diameter the bead centers
57
+ can reach.
58
+
59
+ Physical background
60
+ ===================
61
+
62
+ The chains are Kremer-Grest bead-spring chains. Every pair of beads repels
63
+ through the Weeks-Chandler-Andersen (WCA) potential, the LJ potential cut
64
+ at its minimum :math:`2^{1/6}\\sigma` and shifted up by :math:`\\epsilon`
65
+ (Weeks, Chandler, and Andersen, 1971, Eq. 4). Bonded beads add the finitely
66
+ extensible nonlinear elastic (FENE) spring, whose force
67
+ :math:`K r / (1 - (r/R_0)^2)` diverges at :math:`R_0` (Warner, 1972, Eq.
68
+ 4). Grest and Kremer (1986, Eqs. 8 and 9) set :math:`K = 30\\,
69
+ \\epsilon/\\sigma^2` and :math:`R_0 = 1.5\\,\\sigma` so that chains cannot
70
+ cross; with these values at :math:`k_B T = \\epsilon` the bond never
71
+ exceeds :math:`1.2\\,\\sigma` (Kremer and Grest, 1990, Appendix). The LAMMPS
72
+ ``bond_style fene`` holds both terms of the bond,
73
+
74
+ .. math::
75
+
76
+ E = -\\tfrac{1}{2} K R_0^2 \\ln\\left[1 - \\left(\\frac{r}{R_0}\\right)^2
77
+ \\right] + 4\\epsilon\\left[\\left(\\frac{\\sigma}{r}\\right)^{12} -
78
+ \\left(\\frac{\\sigma}{r}\\right)^{6}\\right] + \\epsilon,
79
+
80
+ the second part cut at :math:`2^{1/6}\\sigma`, and ``special_bonds fene``
81
+ switches the pair interaction off between bonded beads only, so beads two
82
+ apart along the chain repel like any other pair.
83
+
84
+ *The start bond length.* Kremer and Grest (1990, Sec. II.B) built their
85
+ start configurations as random walks with a bond length of :math:`0.97
86
+ \\sigma`, kept beads two apart more than :math:`1.02\\,\\sigma` from each
87
+ other, and measured a mean bond length of :math:`0.97\\,\\sigma` in the
88
+ equilibrated melt. The force of the FENE and WCA terms together is zero at
89
+ :math:`0.961\\,\\sigma`, so a bond of :math:`0.97\\,\\sigma` stores
90
+ :math:`0.04\\,\\epsilon`; the same :math:`0.97` is the default of Pizza.py's
91
+ ``chain.py``, of LAMMPS's ``tools/def.chain``, and of
92
+ ``BondFENE::equilibrium_distance``. The builders here use it as their
93
+ default, `bond_length` ``= 0.97``.
94
+
95
+ *What stops a LAMMPS run.* LAMMPS warns ``FENE bond too long`` and clamps
96
+ the bond force when :math:`1 - (r/R_0)^2 < 0.1`, that is for :math:`r >
97
+ \\sqrt{0.9}\\,R_0 = 1.423\\,\\sigma` at :math:`R_0 = 1.5\\,\\sigma`, and
98
+ stops with ``Bad FENE bond`` at :math:`r \\ge 2R_0` (``bond_fene.cpp``). A
99
+ bond longer than the ghost-atom cutoff, the pair cutoff plus the neighbor
100
+ skin (:math:`1.42\\,\\sigma` for a WCA cutoff and a skin of :math:`0.3`),
101
+ stops a run with ``Bond atoms missing`` or a wrong periodic image as soon
102
+ as it crosses a periodic face or a processor boundary. ``read_data`` loses
103
+ an atom outside the box on a non-periodic axis, and ``fix wall/region``
104
+ stops when a bead sits on or outside its surface. Two beads at one position
105
+ give a non-finite force under every integrator. A bond of :math:`1.425\\,
106
+ \\sigma`, that is :math:`0.95\\,R_0`, is above both the warning length and
107
+ the default ghost cutoff; in test runs of this model (:math:`K = 30`,
108
+ :math:`R_0 = 1.5`, skin 0.3, :math:`k_B T = \\epsilon`) starts whose bonds
109
+ were all between :math:`0.97` and :math:`1.10\\,\\sigma` ran under plain
110
+ ``nve``, under Langevin dynamics, and under ``nve/limit`` alike, while a
111
+ start with every bond at :math:`1.40\\,\\sigma` stopped a plain ``nve`` run
112
+ within 70 steps. :func:`check_conformation` tests each of these conditions.
113
+
114
+ Shapes
115
+ ======
116
+
117
+ *Helix.* Bead :math:`k` sits at :math:`(R\\cos k\\Delta t, R\\sin k\\Delta
118
+ t, c k\\Delta t)`, centered on :math:`z = 0`. The distance between
119
+ neighboring beads is the chord
120
+
121
+ .. math::
122
+
123
+ b^2 = 4R^2 \\sin^2(\\Delta t / 2) + c^2 \\Delta t^2,
124
+
125
+ whose right side increases on :math:`(0, \\pi]`, so :math:`\\Delta t` is
126
+ its one root there whenever :math:`b^2 \\le 4R^2 + c^2\\pi^2`, that is
127
+ whenever a bond spans at most half a turn. Stepping by arclength instead
128
+ would give bonds shorter than the arc, by an amount that depends on the
129
+ curvature. Adjacent turns are :math:`p\\sin\\theta_A` apart, so a helix
130
+ needs :math:`p \\ge d_{\\min} / \\sin\\theta_A` to pass the pair check.
131
+
132
+ *Plectoneme ring.* The closed curve is the plasmid of Neukirch and
133
+ Starostin (2008, Appendix A, Eqs. A1 to A4): a right-handed helix of radius
134
+ :math:`R` and helical angle :math:`\\theta_A` (strand A), an end loop
135
+ (B), the same helix rotated by :math:`\\pi` about the axis and run back
136
+ down (strand C), and a second end loop (D). The loops pass through the axis
137
+ and keep the curve inside the cylinder of radius :math:`R`; the tangent is
138
+ continuous everywhere and the curvature jumps at the four joints. It is the
139
+ interwound superhelix of Boles, White, and Cozzarelli (1990, Fig. 3), with
140
+ their semicircular end caps replaced by the smooth loops. Beads are placed
141
+ along the curve by chord stepping, each at the first point of the curve a
142
+ distance :math:`b` from the one before, and the strand arclength
143
+ :math:`L_A` is solved so that the :math:`N` chords close the ring exactly,
144
+ the bond between the last bead and the first included. :math:`R`,
145
+ :math:`p`, :math:`b`, and :math:`N` are therefore exactly as given, and only
146
+ the height of the ring is a result. The two strands are half a pitch apart
147
+ along the axis, so a ring needs :math:`p \\ge 2 d_{\\min} / \\sin\\theta_A`
148
+ (about :math:`2.0` to :math:`2.1\\,\\sigma` for :math:`R \\ge 2\\sigma` and
149
+ :math:`d_{\\min} = \\sigma`) to pass the pair check.
150
+
151
+ *Any curve.* :func:`resample_curve` applies the same chord stepping to a
152
+ polyline. A closed polyline has no height to solve for, so it is scaled
153
+ uniformly about its centroid until :math:`N` chords close it; the shape is
154
+ kept and the size changes by about :math:`(N b - \\ell) / \\ell` for a
155
+ polyline of length :math:`\\ell`. Closing needs a curve whose features are
156
+ large compared with :math:`b`; a curve with smaller corners or teeth is
157
+ refused rather than closed with one odd bond.
158
+
159
+ Equilibration
160
+ =============
161
+
162
+ A start configuration only has to be one LAMMPS runs from; the run removes
163
+ its memory. Kremer and Grest (1990, Sec. II.B) relaxed their starts with a
164
+ soft pair potential, :math:`A[1 + \\cos(\\pi r / 2^{1/6}\\sigma)]`, raising
165
+ :math:`A` from 1 to about 60, and moltemplate's confined-DNA example uses
166
+ ``fix nve/limit`` with a Langevin thermostat and recommends soft potentials
167
+ for a long confined chain; LAMMPS provides both (``pair_style soft`` with
168
+ ``fix adapt``, and ``fix nve/limit``). Neither is part of this module.
169
+
170
+ Complexity
171
+ ==========
172
+
173
+ :func:`helix` takes :math:`O(N)` time and memory. :func:`plectoneme_ring`
174
+ and a closed :func:`resample_curve` take :math:`O(N)` time and memory per
175
+ march along the curve, and about ten marches to close the ring.
176
+ :func:`check_conformation` takes :math:`O(N^2)` time, in row chunks, so
177
+ its memory is :math:`O(N)` beyond the positions.
178
+
179
+ References
180
+ ==========
181
+
182
+ - Kremer, K. and Grest, G. S., "Dynamics of entangled linear polymer melts:
183
+ A molecular-dynamics simulation", J. Chem. Phys. 92, 5057 (1990),
184
+ `doi:10.1063/1.458541 <https://doi.org/10.1063/1.458541>`_: Sec. II.B,
185
+ Eqs. (2.3) and (2.4), p. 5060, and the Appendix, Eqs. (A1) and (A2).
186
+ - Grest, G. S. and Kremer, K., "Molecular dynamics simulation for polymers
187
+ in the presence of a heat bath", Phys. Rev. A 33, 3628 (1986),
188
+ `doi:10.1103/PhysRevA.33.3628
189
+ <https://doi.org/10.1103/PhysRevA.33.3628>`_: Eqs. (8) and (9).
190
+ - Weeks, J. D., Chandler, D., and Andersen, H. C., "Role of repulsive
191
+ forces in determining the equilibrium structure of simple liquids",
192
+ J. Chem. Phys. 54, 5237 (1971), `doi:10.1063/1.1674820
193
+ <https://doi.org/10.1063/1.1674820>`_: Eq. (4).
194
+ - Warner, H. R., "Kinetic theory and rheology of dilute suspensions of
195
+ finitely extendible dumbbells", Ind. Eng. Chem. Fundam. 11, 379 (1972),
196
+ `doi:10.1021/i160043a017 <https://doi.org/10.1021/i160043a017>`_: Eq. (4).
197
+ - Neukirch, S. and Starostin, E. L., "Writhe formulas and antipodal points
198
+ in plectonemic DNA configurations", Phys. Rev. E 78, 041912 (2008),
199
+ `doi:10.1103/PhysRevE.78.041912
200
+ <https://doi.org/10.1103/PhysRevE.78.041912>`_: Appendix A.
201
+ - Boles, T. C., White, J. H., and Cozzarelli, N. R., "Structure of
202
+ plectonemically supercoiled DNA", J. Mol. Biol. 213, 931 (1990),
203
+ `doi:10.1016/s0022-2836(05)80272-4
204
+ <https://doi.org/10.1016/s0022-2836(05)80272-4>`_: Fig. 3 and Sec. 2(f).
205
+ - Jewett, A. I. et al., "Moltemplate: A tool for coarse-grained modeling of
206
+ complex biological matter and soft condensed matter physics", J. Mol.
207
+ Biol. 433, 166841 (2021), `doi:10.1016/j.jmb.2021.166841
208
+ <https://doi.org/10.1016/j.jmb.2021.166841>`_, and its example
209
+ `confined_viral_DNA
210
+ <https://github.com/jewettaij/moltemplate/tree/031ecd4efc7abdad8d49238404de2d3ef388b37b/examples/coarse_grained/DNA_models/dsDNA_only/2strands/3bp_2particles/confined_viral_DNA>`_.
211
+ - The LAMMPS documentation of `bond_style fene
212
+ <https://docs.lammps.org/bond_fene.html>`_, `special_bonds
213
+ <https://docs.lammps.org/special_bonds.html>`_, `read_data
214
+ <https://docs.lammps.org/read_data.html>`_, `comm_modify
215
+ <https://docs.lammps.org/comm_modify.html>`_, `fix wall/region
216
+ <https://docs.lammps.org/fix_wall_region.html>`_, and `fix nve/limit
217
+ <https://docs.lammps.org/fix_nve_limit.html>`_, and the LAMMPS source at
218
+ release ``stable_30Sep2026_update1``: `bond_fene.cpp, lines 89 to 94
219
+ <https://github.com/lammps/lammps/blob/098ffdefa525cabc82272925de02e9884f58c657/src/MOLECULE/bond_fene.cpp#L89-L94>`_
220
+ (the warning and the stop) and `bond_fene.cpp, lines 192 to 195
221
+ <https://github.com/lammps/lammps/blob/098ffdefa525cabc82272925de02e9884f58c657/src/MOLECULE/bond_fene.cpp#L192-L195>`_
222
+ (the equilibrium distance :math:`0.97\\,\\sigma`).
223
+ - Plimpton, S., `Pizza.py chain.py
224
+ <https://github.com/thatgeeman/pizza/blob/master/src/chain.py>`_ (2005)
225
+ and LAMMPS `tools/def.chain
226
+ <https://github.com/lammps/lammps/blob/098ffdefa525cabc82272925de02e9884f58c657/tools/def.chain>`_:
227
+ bond 0.97 and 1-3 distance 1.02.
228
+
229
+ Dependencies
230
+ ============
231
+ - Python standard library: `dataclasses`, `logging`, `math`
232
+ - Third-party: `numpy`, `scipy` (:func:`scipy.optimize.brentq`)
233
+ - PolyPhys: :mod:`polyphys.simulate.core`, :mod:`polyphys.simulate.data`
234
+ """
235
+
236
+ import logging
237
+ import math
238
+ from collections.abc import Callable, Sequence
239
+ from dataclasses import dataclass
240
+
241
+ import numpy as np
242
+ import numpy.typing as npt
243
+ from scipy.optimize import brentq
244
+
245
+ from polyphys.simulate.core import Box, FloatArray, IntArray
246
+ from polyphys.simulate.data import DataFile
247
+
248
+ __all__ = [
249
+ "FENE_WARNING_FRACTION",
250
+ "ConformationError",
251
+ "ConformationReport",
252
+ "chain_angles",
253
+ "chain_bonds",
254
+ "chain_data",
255
+ "check_conformation",
256
+ "helix",
257
+ "plectoneme_ring",
258
+ "resample_curve",
259
+ ]
260
+
261
+ logger = logging.getLogger(__name__)
262
+
263
+ #: LAMMPS warns ``FENE bond too long`` when :math:`1 - (r/R_0)^2 < 0.1`,
264
+ #: that is for a bond longer than :math:`\sqrt{0.9}\,R_0`
265
+ #: (``bond_fene.cpp``, lines 89 to 94).
266
+ FENE_WARNING_FRACTION = math.sqrt(0.9)
267
+
268
+ _Curve = Callable[[FloatArray], FloatArray]
269
+ _SAMPLES_PER_BOND = 64
270
+ _COINCIDENT = 1e-9
271
+ _TINY = 1e-9
272
+
273
+
274
+ class ConformationError(ValueError):
275
+ """A start configuration would make LAMMPS stop or warn.
276
+
277
+ Raised by :func:`check_conformation`; the message names every failed
278
+ condition with its worst value and the atoms involved.
279
+
280
+ .. versionadded:: 0.6.0
281
+
282
+ Examples
283
+ --------
284
+ >>> box = Box(lo=np.full(3, -5.0), hi=np.full(3, 5.0))
285
+ >>> pair = chain_data([[0.0, 0.0, 0.0], [1.5, 0.0, 0.0]], box)
286
+ >>> try:
287
+ ... check_conformation(pair, r0=1.5, ghost_cutoff=1.42,
288
+ ... periodic=(True, True, True))
289
+ ... except ConformationError as err:
290
+ ... print(err)
291
+ 1 bond at or above sqrt(0.9) R0 = 1.423: longest 1.5 (atoms 1 2); \
292
+ 1 bond at or above the ghost cutoff 1.42: longest 1.5 (atoms 1 2)
293
+ """
294
+
295
+
296
+ @dataclass(frozen=True)
297
+ class ConformationReport:
298
+ """The extremes :func:`check_conformation` measured on a passing file.
299
+
300
+ .. versionadded:: 0.6.0
301
+
302
+ Attributes
303
+ ----------
304
+ n_atoms : int
305
+ The number of atoms.
306
+ min_bond, max_bond : float
307
+ The shortest and the longest bond, in the length unit of the file;
308
+ NaN when there are no bonds.
309
+ min_pair : float
310
+ The shortest distance between two atoms that are not bonded, with
311
+ the minimum image on the periodic axes; NaN when there is no such
312
+ pair.
313
+ max_radial : float or None
314
+ The largest distance of an atom from the cylinder axis, or None
315
+ when no cylinder was given.
316
+ """
317
+
318
+ n_atoms: int
319
+ min_bond: float
320
+ max_bond: float
321
+ min_pair: float
322
+ max_radial: float | None
323
+
324
+
325
+ def helix(
326
+ n_beads: int, *, radius: float, pitch: float, bond_length: float = 0.97
327
+ ) -> FloatArray:
328
+ r"""Place beads a fixed distance apart along a helix.
329
+
330
+ Bead :math:`k` sits at :math:`(R\cos k\Delta t, R\sin k\Delta t, c k
331
+ \Delta t)` on a right-handed helix of radius :math:`R` and pitch
332
+ :math:`p` about the :math:`z` axis, with :math:`c = p/2\pi`, centered on
333
+ :math:`z = 0`. The angle step :math:`\Delta t` is the root of the chord
334
+ equation :math:`b^2 = 4R^2\sin^2(\Delta t/2) + c^2\Delta t^2`, so every
335
+ bond has the length :math:`b` exactly.
336
+
337
+ .. versionadded:: 0.6.0
338
+
339
+ Parameters
340
+ ----------
341
+ n_beads : int
342
+ The number of beads :math:`N`, 1 or more.
343
+ radius : float
344
+ The helix radius :math:`R`, positive, in the length unit of the run.
345
+ pitch : float
346
+ The pitch :math:`p`, the rise per turn, positive. Adjacent turns are
347
+ :math:`p\sin\theta_A` apart, with :math:`\tan\theta_A = 2\pi R/p`.
348
+ bond_length : float, optional
349
+ The bond length :math:`b`, positive; by default :math:`0.97`, the
350
+ start bond length of Kremer and Grest (1990) in units of the bead
351
+ diameter.
352
+
353
+ Returns
354
+ -------
355
+ numpy.ndarray
356
+ Shape ``(n_beads, 3)``, float64: the bead positions, the first at
357
+ angle zero.
358
+
359
+ Raises
360
+ ------
361
+ ValueError
362
+ If `n_beads` is below 1, a length is not positive, or the bond does
363
+ not fit in half a turn, :math:`b^2 > 4R^2 + c^2\pi^2`.
364
+
365
+ Notes
366
+ -----
367
+ The chord equation's right side increases on :math:`(0, \pi]`, so
368
+ :func:`scipy.optimize.brentq` finds its one root there. Time and memory
369
+ are :math:`O(N)`.
370
+
371
+ Examples
372
+ --------
373
+ A bond of :math:`\sqrt 2` on a helix of radius 1 and pitch 6 steps by
374
+ :math:`\pi/3` and rises by 1 per bead:
375
+
376
+ >>> beads = helix(4, radius=1.0, pitch=6.0, bond_length=2 ** 0.5)
377
+ >>> np.round(beads, 6) + 0.0
378
+ array([[ 1. , 0. , -1.5 ],
379
+ [ 0.5 , 0.866025, -0.5 ],
380
+ [-0.5 , 0.866025, 0.5 ],
381
+ [-1. , 0. , 1.5 ]])
382
+ """
383
+ _check_count("n_beads", n_beads, 1)
384
+ for name, value in (("radius", radius), ("pitch", pitch),
385
+ ("bond_length", bond_length)):
386
+ _check_positive(name, value)
387
+ rise = pitch / (2.0 * math.pi)
388
+ step = _chord_angle(bond_length, radius, rise)
389
+ angles = np.arange(n_beads, dtype=np.float64) * step
390
+ z = rise * angles
391
+ z -= 0.5 * (z[0] + z[-1])
392
+ positions: FloatArray = np.column_stack(
393
+ [radius * np.cos(angles), radius * np.sin(angles), z])
394
+ return positions
395
+
396
+
397
+ def plectoneme_ring(
398
+ n_beads: int, *, radius: float, pitch: float, bond_length: float = 0.97
399
+ ) -> FloatArray:
400
+ r"""Place beads a fixed distance apart along a closed plectoneme.
401
+
402
+ The curve is the plasmid of Neukirch and Starostin (2008, Appendix A):
403
+ two coaxial right-handed helices of radius :math:`R` and pitch
404
+ :math:`p`, half a turn apart, one run up and one run down, joined at
405
+ each end by a smooth loop through the axis. The beads are placed by
406
+ chord stepping, each at the first point of the curve a distance
407
+ :math:`b` from the one before, and the strand arclength :math:`L_A` is
408
+ solved so that :math:`N` chords close the curve: every bond, the one
409
+ between the last bead and the first included, has the length :math:`b`,
410
+ and :math:`R` and :math:`p` are exactly as given. The ring is centered on
411
+ the origin with its axis along :math:`z`; no bead lies farther than
412
+ :math:`R` from the axis.
413
+
414
+ .. versionadded:: 0.6.0
415
+
416
+ Parameters
417
+ ----------
418
+ n_beads : int
419
+ The number of beads :math:`N`. The two end loops alone take about
420
+ :math:`7.7 R / b` beads each; fewer raises.
421
+ radius : float
422
+ The helix radius :math:`R`, positive, in the length unit of the run.
423
+ pitch : float
424
+ The pitch :math:`p` of each strand, positive. The two strands are
425
+ :math:`(p/2)\sin\theta_A` apart and adjacent turns of one strand
426
+ :math:`p\sin\theta_A`, with :math:`\tan\theta_A = 2\pi R/p`; a
427
+ pitch of about :math:`2\sigma` or more keeps the strands a bead
428
+ diameter apart.
429
+ bond_length : float, optional
430
+ The bond length :math:`b`, positive; by default :math:`0.97`, as in
431
+ :func:`helix`.
432
+
433
+ Returns
434
+ -------
435
+ numpy.ndarray
436
+ Shape ``(n_beads, 3)``, float64: the bead positions, from the foot
437
+ of the first strand up and around.
438
+
439
+ Raises
440
+ ------
441
+ ValueError
442
+ If `n_beads` is too small for the loops, a length is not positive,
443
+ or the bond does not fit in half a turn of the helix.
444
+
445
+ Notes
446
+ -----
447
+ The closing gap, the distance from the last bead back to the first
448
+ minus :math:`b`, grows with :math:`L_A` and is continuous, so
449
+ :func:`scipy.optimize.brentq` finds its root in a bracket around the
450
+ estimate :math:`(N s - \ell_{loops}) / 2`, with :math:`s` the arc a
451
+ chord of :math:`b` spans on the helix and :math:`\ell_{loops}` the
452
+ length of the two loops; the root lies within a fraction of a bond of
453
+ the estimate. Each evaluation is one march along the curve, :math:`O(N)`
454
+ in time and memory, and the solve takes about ten marches.
455
+
456
+ Examples
457
+ --------
458
+ >>> ring = plectoneme_ring(60, radius=2.0, pitch=2.5)
459
+ >>> ring.shape
460
+ (60, 3)
461
+ >>> bonds = np.linalg.norm(np.roll(ring, -1, axis=0) - ring, axis=1)
462
+ >>> bool(np.allclose(bonds, 0.97, rtol=1e-12, atol=0.0))
463
+ True
464
+ >>> round(float(np.hypot(ring[:, 0], ring[:, 1]).max()), 6)
465
+ 2.0
466
+ """
467
+ _check_count("n_beads", n_beads, 3)
468
+ for name, value in (("radius", radius), ("pitch", pitch),
469
+ ("bond_length", bond_length)):
470
+ _check_positive(name, value)
471
+ theta = math.atan2(2.0 * math.pi * radius, pitch)
472
+ bond = bond_length / radius # in units of R
473
+ cot = 1.0 / math.tan(theta)
474
+ arc_per_chord = _chord_angle(bond, 1.0, cot) / math.sin(theta)
475
+ sample_step = min(bond, 1.0) / _SAMPLES_PER_BOND
476
+ loops = _plectoneme_loop_length(theta, sample_step)
477
+ estimate = (n_beads * arc_per_chord - loops) / 2.0
478
+
479
+ def closing_gap(strand: float) -> float:
480
+ beads, gap = _march_curve(
481
+ lambda u: _plectoneme_curve(u, strand, theta),
482
+ 2.0 * strand + 2.0 * math.pi, n_beads, bond, sample_step)
483
+ return -bond if gap is None else gap - bond
484
+
485
+ too_few = ValueError(
486
+ f"'n_beads' must be at least about "
487
+ f"{math.ceil((loops + 2 * arc_per_chord) / arc_per_chord)} for a "
488
+ f"plectoneme ring of radius {radius} and pitch {pitch} with bonds "
489
+ f"of {bond_length}, got {n_beads}")
490
+ if estimate <= 0.0:
491
+ raise too_few
492
+ lo, hi = _bracket(closing_gap, estimate, arc_per_chord)
493
+ strand = brentq(closing_gap, lo, hi, xtol=1e-14, maxiter=200)
494
+ beads, gap = _march_curve(
495
+ lambda u: _plectoneme_curve(u, strand, theta),
496
+ 2.0 * strand + 2.0 * math.pi, n_beads, bond, sample_step)
497
+ _check_closed(gap, bond)
498
+ beads *= radius
499
+ beads[:, 2] -= 0.5 * (beads[:, 2].min() + beads[:, 2].max())
500
+ logger.debug("plectoneme_ring: strand arclength %.6f, height %.6f",
501
+ strand * radius, strand * radius * math.cos(theta))
502
+ return beads
503
+
504
+
505
+ def resample_curve(
506
+ points: npt.ArrayLike, n_beads: int, bond_length: float = 0.97, *,
507
+ closed: bool = False,
508
+ ) -> FloatArray:
509
+ r"""Place beads a fixed distance apart along a curve given as points.
510
+
511
+ The curve is the polyline through `points`. The first bead sits on the
512
+ first point, and each next bead at the first point of the polyline a
513
+ distance :math:`b` from the bead before (the first crossing of the
514
+ sphere of radius :math:`b` about it), so every bond has the length
515
+ :math:`b` exactly. On an open curve the beads cover the first
516
+ :math:`N - 1` chords and the rest of the curve is unused. A closed
517
+ curve is scaled uniformly about the centroid of its points until
518
+ :math:`N` chords close it, the bond from the last bead to the first
519
+ included; the shape is kept and the size changes by about :math:`(Nb -
520
+ \ell)/\ell` for a polyline of length :math:`\ell`.
521
+
522
+ .. versionadded:: 0.6.0
523
+
524
+ Parameters
525
+ ----------
526
+ points : array_like
527
+ Shape ``(n_points, 3)``: the vertices of the polyline, 2 or more,
528
+ in the length unit of the run. Repeated consecutive points are
529
+ dropped. A closed curve may or may not repeat its first point last.
530
+ n_beads : int
531
+ The number of beads :math:`N`: 1 or more on an open curve, 3 or more
532
+ on a closed one.
533
+ bond_length : float, optional
534
+ The bond length :math:`b`, positive; by default :math:`0.97`, as in
535
+ :func:`helix`.
536
+ closed : bool, optional
537
+ Whether the curve is closed and the ring must close with the bond
538
+ length too; False by default.
539
+
540
+ Returns
541
+ -------
542
+ numpy.ndarray
543
+ Shape ``(n_beads, 3)``, float64: the bead positions, in the order of
544
+ the curve.
545
+
546
+ Raises
547
+ ------
548
+ ValueError
549
+ If `points` is not ``(n_points, 3)`` with 2 or more distinct
550
+ consecutive points, a value is not finite, `n_beads` is too small,
551
+ `bond_length` is not positive, an open curve is too short for
552
+ `n_beads` beads, or a closed curve cannot be closed with equal
553
+ bonds (see Notes).
554
+
555
+ Notes
556
+ -----
557
+ Each chord is a line-sphere intersection, exact to round-off. A march
558
+ takes :math:`O(N + n_{points})` time and memory; closing a curve takes
559
+ about ten marches, the scale found by :func:`scipy.optimize.brentq`.
560
+ The polyline, not the smooth curve it may stand for, carries the beads:
561
+ sample a smooth curve finely compared with :math:`b`. Closing needs the
562
+ beads to move continuously as the curve is scaled, which holds when the
563
+ curve's features, its radius of curvature and the spacing of its
564
+ corners, are large compared with :math:`b`; on a curve with corners or
565
+ teeth smaller than a bond the closing gap jumps, no scale closes the
566
+ curve exactly, and ``ValueError`` is raised rather than a ring with one
567
+ odd bond.
568
+
569
+ Examples
570
+ --------
571
+ A corner: the second bead is where the sphere of radius :math:`\sqrt 2`
572
+ about the origin first meets the polyline, past the corner.
573
+
574
+ >>> corner = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 5.0, 0.0]]
575
+ >>> np.round(resample_curve(corner, 3, 2 ** 0.5), 6)
576
+ array([[0. , 0. , 0. ],
577
+ [1. , 1. , 0. ],
578
+ [1. , 2.414214, 0. ]])
579
+
580
+ A closed square of side 4 holds 16 bonds of length 1 as it is:
581
+
582
+ >>> square = [[0, 0, 0], [4, 0, 0], [4, 4, 0], [0, 4, 0]]
583
+ >>> beads = resample_curve(square, 16, 1.0, closed=True)
584
+ >>> np.round(beads[3:6], 6)
585
+ array([[3., 0., 0.],
586
+ [4., 0., 0.],
587
+ [4., 1., 0.]])
588
+ """
589
+ vertices = np.array(points, dtype=np.float64)
590
+ if vertices.ndim != 2 or vertices.shape[1] != 3:
591
+ raise ValueError(
592
+ f"'points' must have shape (n_points, 3), got {vertices.shape}")
593
+ if not np.all(np.isfinite(vertices)):
594
+ raise ValueError("'points' must be finite")
595
+ _check_count("n_beads", n_beads, 3 if closed else 1)
596
+ _check_positive("bond_length", bond_length)
597
+ keep = np.ones(vertices.shape[0], dtype=bool)
598
+ keep[1:] = np.any(np.diff(vertices, axis=0) != 0.0, axis=1)
599
+ vertices = vertices[keep]
600
+ if closed and vertices.shape[0] > 1 and np.array_equal(
601
+ vertices[0], vertices[-1]
602
+ ):
603
+ vertices = vertices[:-1]
604
+ if vertices.shape[0] < 2:
605
+ raise ValueError("'points' must hold 2 or more distinct points")
606
+ if not closed:
607
+ beads, _ = _march_polyline(vertices, n_beads, bond_length)
608
+ if beads.shape[0] < n_beads:
609
+ raise ValueError(
610
+ f"the curve holds {beads.shape[0]} beads {bond_length} "
611
+ f"apart, not {n_beads}")
612
+ return beads
613
+ centroid = vertices.mean(axis=0)
614
+ polyline = np.vstack([vertices, vertices[:1]])
615
+ length = float(np.linalg.norm(np.diff(polyline, axis=0), axis=1).sum())
616
+
617
+ def closing_gap(scale: float) -> float:
618
+ beads, gap = _march_polyline(
619
+ centroid + scale * (polyline - centroid), n_beads, bond_length)
620
+ return -bond_length if gap is None else gap - bond_length
621
+
622
+ lo, hi = _bracket(closing_gap, n_beads * bond_length / length,
623
+ 2.0 * bond_length / length)
624
+ scale = brentq(closing_gap, lo, hi, xtol=1e-15, maxiter=200)
625
+ beads, gap = _march_polyline(
626
+ centroid + scale * (polyline - centroid), n_beads, bond_length)
627
+ _check_closed(gap, bond_length)
628
+ logger.debug("resample_curve: scaled the closed curve by %.9f", scale)
629
+ return beads
630
+
631
+
632
+ def chain_bonds(n_beads: int, *, ring: bool = False,
633
+ bond_type: int = 1) -> IntArray:
634
+ """Return the bonds of a linear or ring chain of beads numbered from 1.
635
+
636
+ .. versionadded:: 0.6.0
637
+
638
+ Parameters
639
+ ----------
640
+ n_beads : int
641
+ The number of beads, 2 or more for a linear chain and 3 or more for
642
+ a ring.
643
+ ring : bool, optional
644
+ Whether to add the bond from the last bead to the first; False by
645
+ default.
646
+ bond_type : int, optional
647
+ The bond type of every bond, 1 or more; 1 by default.
648
+
649
+ Returns
650
+ -------
651
+ numpy.ndarray
652
+ Shape ``(n_bonds, 3)``, int64: ``type, atom1, atom2`` per bond, the
653
+ layout of :attr:`~polyphys.simulate.data.DataFile.bonds`, with
654
+ ``n_bonds`` equal to `n_beads` minus one, or to `n_beads` for a
655
+ ring. Time and memory are :math:`O(N)`.
656
+
657
+ Raises
658
+ ------
659
+ ValueError
660
+ If `n_beads` or `bond_type` is too small.
661
+
662
+ Examples
663
+ --------
664
+ >>> chain_bonds(4, ring=True)
665
+ array([[1, 1, 2],
666
+ [1, 2, 3],
667
+ [1, 3, 4],
668
+ [1, 4, 1]])
669
+ """
670
+ _check_count("n_beads", n_beads, 3 if ring else 2)
671
+ _check_count("bond_type", bond_type, 1)
672
+ first = np.arange(1, n_beads + 1 if ring else n_beads, dtype=np.int64)
673
+ second = first % n_beads + 1
674
+ bonds: IntArray = np.column_stack(
675
+ [np.full_like(first, bond_type), first, second])
676
+ return bonds
677
+
678
+
679
+ def chain_angles(n_beads: int, *, ring: bool = False,
680
+ angle_type: int = 1) -> IntArray:
681
+ """Return the angles of a linear or ring chain of beads numbered from 1.
682
+
683
+ .. versionadded:: 0.6.0
684
+
685
+ Parameters
686
+ ----------
687
+ n_beads : int
688
+ The number of beads, 3 or more.
689
+ ring : bool, optional
690
+ Whether to add the two angles that span the closing bond; False by
691
+ default.
692
+ angle_type : int, optional
693
+ The angle type of every angle, 1 or more; 1 by default.
694
+
695
+ Returns
696
+ -------
697
+ numpy.ndarray
698
+ Shape ``(n_angles, 4)``, int64: ``type, atom1, atom2, atom3`` per
699
+ angle with ``atom2`` the vertex, the layout of
700
+ :attr:`~polyphys.simulate.data.DataFile.angles`, with ``n_angles``
701
+ equal to `n_beads` minus two, or to `n_beads` for a ring. Time and
702
+ memory are :math:`O(N)`.
703
+
704
+ Raises
705
+ ------
706
+ ValueError
707
+ If `n_beads` or `angle_type` is too small.
708
+
709
+ Examples
710
+ --------
711
+ >>> chain_angles(4, ring=True)
712
+ array([[1, 1, 2, 3],
713
+ [1, 2, 3, 4],
714
+ [1, 3, 4, 1],
715
+ [1, 4, 1, 2]])
716
+ """
717
+ _check_count("n_beads", n_beads, 3)
718
+ _check_count("angle_type", angle_type, 1)
719
+ first = np.arange(1, n_beads + 1 if ring else n_beads - 1,
720
+ dtype=np.int64)
721
+ angles: IntArray = np.column_stack(
722
+ [np.full_like(first, angle_type), first, first % n_beads + 1,
723
+ (first + 1) % n_beads + 1])
724
+ return angles
725
+
726
+
727
+ def chain_data(
728
+ positions: npt.ArrayLike, box: Box, *, ring: bool = False,
729
+ angles: bool = False, types: npt.ArrayLike | None = None,
730
+ ) -> DataFile:
731
+ """Return a data file of one chain from bead positions.
732
+
733
+ The atoms get the IDs 1 to :math:`N` in the order of `positions`,
734
+ molecule 1, and the bonds of :func:`chain_bonds` and, if asked, the
735
+ angles of :func:`chain_angles`, all of type 1. The masses and the
736
+ velocities are left to the input script.
737
+
738
+ .. versionadded:: 0.6.0
739
+
740
+ Parameters
741
+ ----------
742
+ positions : array_like
743
+ Shape ``(n_beads, 3)``: the bead positions along the chain, as
744
+ :func:`helix`, :func:`plectoneme_ring`, or :func:`resample_curve`
745
+ return them.
746
+ box : Box
747
+ The simulation box; it is not checked against the positions here,
748
+ :func:`check_conformation` does that.
749
+ ring : bool, optional
750
+ Whether the chain is a ring; False by default.
751
+ angles : bool, optional
752
+ Whether to add the angles and use the atom style ``angle`` instead
753
+ of ``bond``; False by default.
754
+ types : array_like, optional
755
+ Shape ``(n_beads,)``: the atom types, from 1; all 1 by default.
756
+
757
+ Returns
758
+ -------
759
+ DataFile
760
+ With ``n_atom_types`` the largest type, ``n_bond_types`` 1, and
761
+ ``n_angle_types`` 1 if `angles` else 0. Time and memory are
762
+ :math:`O(N)`.
763
+
764
+ Raises
765
+ ------
766
+ ValueError
767
+ If `positions` is not ``(n_beads, 3)`` with enough beads for the
768
+ topology, or `types` has the wrong shape or a type below 1.
769
+ TypeError
770
+ If `box` is not a :class:`~polyphys.simulate.core.Box`.
771
+
772
+ Examples
773
+ --------
774
+ >>> box = Box(lo=np.full(3, -5.0), hi=np.full(3, 5.0))
775
+ >>> data = chain_data(helix(6, radius=1.0, pitch=2.0), box, angles=True)
776
+ >>> data.atom_style, data.n_atoms, data.n_bonds, data.n_angles
777
+ ('angle', 6, 5, 4)
778
+ >>> data.masses is None, data.mol_ids.tolist()
779
+ (True, [1, 1, 1, 1, 1, 1])
780
+ """
781
+ coordinates = np.array(positions, dtype=np.float64)
782
+ if coordinates.ndim != 2 or coordinates.shape[1] != 3:
783
+ raise ValueError(
784
+ f"'positions' must have shape (n_beads, 3), got "
785
+ f"{coordinates.shape}")
786
+ n_beads = coordinates.shape[0]
787
+ if types is None:
788
+ atom_types = np.ones(n_beads, dtype=np.int64)
789
+ else:
790
+ atom_types = np.array(types)
791
+ if atom_types.shape != (n_beads,):
792
+ raise ValueError(
793
+ f"'types' must have shape ({n_beads},), got "
794
+ f"{atom_types.shape}")
795
+ return DataFile(
796
+ atom_style="angle" if angles else "bond",
797
+ box=box,
798
+ ids=np.arange(1, n_beads + 1),
799
+ types=atom_types,
800
+ positions=coordinates,
801
+ n_atom_types=int(atom_types.max()) if n_beads else 1,
802
+ mol_ids=np.ones(n_beads, dtype=np.int64),
803
+ bonds=chain_bonds(n_beads, ring=ring),
804
+ angles=chain_angles(n_beads, ring=ring) if angles else np.empty(
805
+ (0, 4), dtype=np.int64),
806
+ n_bond_types=1,
807
+ n_angle_types=1 if angles else 0,
808
+ )
809
+
810
+
811
+ def check_conformation(
812
+ data: DataFile, *, r0: float, ghost_cutoff: float,
813
+ periodic: Sequence[bool], min_distance: float = 1.0,
814
+ dcyl: float | None = None, dmon: float | None = None,
815
+ ) -> ConformationReport:
816
+ r"""Check that LAMMPS can start a Kremer-Grest run from a data file.
817
+
818
+ Each condition is one that makes LAMMPS warn or stop (see the module
819
+ documentation). The file fails if any holds:
820
+
821
+ 1. A bond is at or above the FENE warning length :math:`\sqrt{0.9}\,
822
+ R_0`, where LAMMPS warns ``FENE bond too long`` and clamps the force
823
+ (and stops at :math:`2R_0`).
824
+ 2. A bond is at or above `ghost_cutoff`, the pair cutoff plus the
825
+ neighbor skin, beyond which a bond across a periodic face or a
826
+ processor boundary stops the run.
827
+ 3. Two atoms, bonded or not, are closer than :math:`10^{-9}`, which
828
+ gives a non-finite force.
829
+ 4. Two atoms that are not bonded are closer than `min_distance`;
830
+ beads two apart along the chain count, as ``special_bonds fene``
831
+ treats them.
832
+ 5. An atom lies outside the box, or on its upper face, along a
833
+ non-periodic axis, where ``read_data`` loses it. Along a periodic
834
+ axis ``read_data`` remaps it, and the distances above use the
835
+ minimum image.
836
+ 6. With a cylinder, an atom lies farther than :math:`(D - a)/2` from
837
+ the :math:`z` axis through the box center, the effective radius the
838
+ bead centers can reach; a bead there is one bead diameter from a
839
+ wall of diameter :math:`D` built from beads of diameter :math:`a`.
840
+
841
+ .. versionadded:: 0.6.0
842
+
843
+ Parameters
844
+ ----------
845
+ data : DataFile
846
+ The start configuration; its positions as written, in the length
847
+ unit of the run.
848
+ r0 : float
849
+ The FENE maximum extension :math:`R_0` of the run, positive; 1.5 in
850
+ the Kremer-Grest model.
851
+ ghost_cutoff : float
852
+ The ghost-atom cutoff of the run, positive: the largest pair cutoff
853
+ plus the neighbor skin, unless ``comm_modify cutoff`` sets more;
854
+ :math:`2^{1/6} + 0.3 = 1.42` for a WCA run with the LJ default skin.
855
+ periodic : sequence of bool
856
+ Which of the x, y, and z axes are periodic in the run; a data file
857
+ does not record it.
858
+ min_distance : float, optional
859
+ :math:`d_{\min}`, the smallest allowed distance between two atoms
860
+ that are not bonded, 0 or more; by default 1.0, one bead diameter,
861
+ where the WCA energy of the pair is :math:`\epsilon`.
862
+ dcyl, dmon : float, optional
863
+ The cylinder diameter :math:`D` and the bead diameter :math:`a`,
864
+ given together or not at all, with :math:`D > a`.
865
+
866
+ Returns
867
+ -------
868
+ ConformationReport
869
+ The extremes measured, when every condition passes.
870
+
871
+ Raises
872
+ ------
873
+ ConformationError
874
+ If a condition fails; the message lists every failed condition
875
+ with its count, its worst value, and the atoms involved.
876
+ ValueError
877
+ If an argument is out of range, `periodic` does not have three
878
+ entries, only one of `dcyl` and `dmon` is given, or the box is
879
+ triclinic and an axis is periodic.
880
+
881
+ Notes
882
+ -----
883
+ Bonds are checked for length only; the builders never make a short
884
+ bond, and a bond below about :math:`0.9\,\sigma` stores several
885
+ :math:`\epsilon` in the WCA term of the FENE bond that this check does
886
+ not see. Time is :math:`O(N^2)`, in row chunks, so memory is
887
+ :math:`O(N)` beyond the positions.
888
+
889
+ Examples
890
+ --------
891
+ >>> box = Box(lo=np.array([-4.0, -4.0, -8.0]),
892
+ ... hi=np.array([4.0, 4.0, 8.0]))
893
+ >>> data = chain_data(helix(12, radius=2.0, pitch=2.5), box)
894
+ >>> report = check_conformation(data, r0=1.5, ghost_cutoff=1.42,
895
+ ... periodic=(False, False, True),
896
+ ... dcyl=6.0, dmon=1.0)
897
+ >>> round(report.max_bond, 6), round(report.max_radial, 6)
898
+ (0.97, 2.0)
899
+ """
900
+ for name, value in (("r0", r0), ("ghost_cutoff", ghost_cutoff)):
901
+ _check_positive(name, value)
902
+ if min_distance < 0.0:
903
+ raise ValueError(f"'min_distance' must be 0 or more, got "
904
+ f"{min_distance}")
905
+ axes = tuple(bool(axis) for axis in periodic)
906
+ if len(axes) != 3:
907
+ raise ValueError(f"'periodic' must have three entries, got "
908
+ f"{len(axes)}")
909
+ if (dcyl is None) != (dmon is None):
910
+ raise ValueError("'dcyl' and 'dmon' must be given together")
911
+ if dcyl is not None and dmon is not None and not 0.0 < dmon < dcyl:
912
+ raise ValueError(f"'dcyl' must exceed 'dmon' and both must be "
913
+ f"positive, got {dcyl} and {dmon}")
914
+ box = data.box
915
+ if box.triclinic and any(axes):
916
+ raise ValueError("a periodic axis needs an orthogonal box")
917
+ positions = data.positions
918
+ n_atoms = data.n_atoms
919
+ lengths = box.lengths
920
+ periodic_axes = np.array(axes)
921
+ failures: list[str] = []
922
+
923
+ def minimum_image(delta: FloatArray) -> FloatArray:
924
+ if periodic_axes.any():
925
+ span = lengths[periodic_axes]
926
+ delta[..., periodic_axes] -= span * np.round(
927
+ delta[..., periodic_axes] / span)
928
+ return delta
929
+
930
+ order = np.argsort(data.ids, kind="stable")
931
+ rows = order[np.searchsorted(data.ids[order], data.bonds[:, 1:])]
932
+ bond_vectors = minimum_image(
933
+ positions[rows[:, 1]] - positions[rows[:, 0]])
934
+ bond_lengths = np.linalg.norm(bond_vectors, axis=1)
935
+ warning_length = FENE_WARNING_FRACTION * r0
936
+ for limit, label in ((warning_length,
937
+ f"sqrt(0.9) R0 = {warning_length:.4g}"),
938
+ (ghost_cutoff,
939
+ f"the ghost cutoff {ghost_cutoff:.4g}")):
940
+ over = bond_lengths >= limit
941
+ if over.any():
942
+ worst = int(np.argmax(bond_lengths))
943
+ failures.append(
944
+ f"{_count(int(over.sum()), 'bond', 'bonds')} at or above "
945
+ f"{label}: longest {bond_lengths[worst]:.4g} (atoms "
946
+ f"{data.bonds[worst, 1]} {data.bonds[worst, 2]})")
947
+ bonded_keys = np.unique(rows.min(axis=1) * n_atoms + rows.max(axis=1))
948
+ min_pair, pair_atoms = math.inf, (0, 0)
949
+ min_any, any_atoms = math.inf, (0, 0)
950
+ n_close = n_coincident = 0
951
+ chunk = max(1, 2_000_000 // max(n_atoms, 1))
952
+ for start in range(0, n_atoms, chunk):
953
+ stop = min(start + chunk, n_atoms)
954
+ delta = minimum_image(
955
+ positions[None, :, :] - positions[start:stop, None, :])
956
+ squared = np.einsum("ijk,ijk->ij", delta, delta)
957
+ i_rows = np.arange(start, stop)[:, None]
958
+ j_cols = np.arange(n_atoms)[None, :]
959
+ squared = np.where(j_cols > i_rows, squared, math.inf)
960
+ n_coincident += int(np.count_nonzero(squared < _COINCIDENT ** 2))
961
+ flat = int(np.argmin(squared))
962
+ if squared.flat[flat] < min_any ** 2:
963
+ min_any = math.sqrt(squared.flat[flat])
964
+ any_atoms = (start + flat // n_atoms, flat % n_atoms)
965
+ bonded = np.isin(i_rows * n_atoms + j_cols, bonded_keys)
966
+ squared = np.where(bonded, math.inf, squared)
967
+ n_close += int(np.count_nonzero(squared < min_distance ** 2))
968
+ flat = int(np.argmin(squared))
969
+ if squared.flat[flat] < min_pair ** 2:
970
+ min_pair = math.sqrt(squared.flat[flat])
971
+ pair_atoms = (start + flat // n_atoms, flat % n_atoms)
972
+ if n_coincident:
973
+ failures.append(
974
+ f"{_count(n_coincident, 'pair of atoms', 'pairs of atoms')} "
975
+ f"closer than "
976
+ f"{_COINCIDENT:g}: {min_any:.3g} (atoms "
977
+ f"{data.ids[any_atoms[0]]} {data.ids[any_atoms[1]]})")
978
+ if n_close:
979
+ failures.append(
980
+ f"{_count(n_close, 'non-bonded pair', 'non-bonded pairs')} "
981
+ f"closer than "
982
+ f"{min_distance:.4g}: closest {min_pair:.4g} (atoms "
983
+ f"{data.ids[pair_atoms[0]]} {data.ids[pair_atoms[1]]})")
984
+ if box.triclinic:
985
+ fractions = (positions - box.lo) @ np.linalg.inv(box.edges())
986
+ else:
987
+ fractions = (positions - box.lo) / lengths
988
+ outside = ((fractions < 0.0) | (fractions >= 1.0))[:, ~periodic_axes]
989
+ out_rows = np.flatnonzero(outside.any(axis=1))
990
+ if out_rows.size:
991
+ failures.append(
992
+ f"{_count(out_rows.size, 'atom', 'atoms')} outside the box on a "
993
+ f"non-periodic axis (atoms "
994
+ f"{' '.join(map(str, data.ids[out_rows[:3]].tolist()))}"
995
+ f"{' ...' if out_rows.size > 3 else ''})")
996
+ max_radial: float | None = None
997
+ if dcyl is not None and dmon is not None:
998
+ center = 0.5 * (box.lo + box.hi)
999
+ radial = np.hypot(positions[:, 0] - center[0],
1000
+ positions[:, 1] - center[1])
1001
+ max_radial = float(radial.max()) if n_atoms else math.nan
1002
+ limit = 0.5 * (dcyl - dmon)
1003
+ beyond = radial > limit
1004
+ if beyond.any():
1005
+ worst = int(np.argmax(radial))
1006
+ failures.append(
1007
+ f"{_count(int(beyond.sum()), 'atom', 'atoms')} farther than "
1008
+ f"(D - a)/2 = {limit:.4g} from the cylinder axis: farthest "
1009
+ f"{radial[worst]:.4g} (atom {data.ids[worst]})")
1010
+ if failures:
1011
+ raise ConformationError("; ".join(failures))
1012
+ return ConformationReport(
1013
+ n_atoms=n_atoms,
1014
+ min_bond=float(bond_lengths.min()) if bond_lengths.size else math.nan,
1015
+ max_bond=float(bond_lengths.max()) if bond_lengths.size else math.nan,
1016
+ min_pair=min_pair if math.isfinite(min_pair) else math.nan,
1017
+ max_radial=max_radial,
1018
+ )
1019
+
1020
+
1021
+ def _count(n: int, singular: str, plural: str) -> str:
1022
+ """Return `n` with the fitting one of `singular` and `plural`."""
1023
+ return f"{n} {singular if n == 1 else plural}"
1024
+
1025
+
1026
+ def _check_count(name: str, value: int, minimum: int) -> None:
1027
+ """Raise unless `value` is an integer of at least `minimum`."""
1028
+ if isinstance(value, bool) or not isinstance(value, (int, np.integer)):
1029
+ raise ValueError(f"'{name}' must be an integer, got {value!r}")
1030
+ if value < minimum:
1031
+ raise ValueError(f"'{name}' must be {minimum} or more, got {value}")
1032
+
1033
+
1034
+ def _check_positive(name: str, value: float) -> None:
1035
+ """Raise unless `value` is a finite positive number."""
1036
+ if not (math.isfinite(value) and value > 0.0):
1037
+ raise ValueError(f"'{name}' must be positive, got {value}")
1038
+
1039
+
1040
+ def _bracket(
1041
+ gap: Callable[[float], float], estimate: float, width: float
1042
+ ) -> tuple[float, float]:
1043
+ """Return ``(lo, hi)`` about `estimate` with ``gap(lo) < 0 <= gap(hi)``
1044
+ for a closing gap that grows with its argument: a bound with the wrong
1045
+ sign moves outward by a doubling `width`, `lo` never below ``_TINY``.
1046
+ Raises ``ValueError`` when the gap is still non-negative at ``_TINY``."""
1047
+ lo, hi = max(estimate - width, _TINY), estimate + width
1048
+ while gap(lo) >= 0.0:
1049
+ if lo <= _TINY:
1050
+ raise ValueError("the closing gap has no sign change")
1051
+ width *= 2.0
1052
+ lo = max(lo - width, _TINY)
1053
+ while gap(hi) <= 0.0:
1054
+ width *= 2.0
1055
+ hi += width
1056
+ return lo, hi
1057
+
1058
+
1059
+ def _check_closed(gap: float | None, bond: float) -> None:
1060
+ """Raise unless the closing chord `gap` equals `bond` to round-off; the
1061
+ root finder lands on a jump of the closing gap otherwise, which happens
1062
+ when a curve has features smaller than the bond."""
1063
+ if gap is None or abs(gap - bond) > 1e-9 * bond:
1064
+ raise ValueError(
1065
+ "the curve cannot be closed with equal bonds: its corners or "
1066
+ "features are smaller than the bond length")
1067
+
1068
+
1069
+ def _chord_angle(bond: float, radius: float, rise: float) -> float:
1070
+ """Return the angle step at which the chord of the helix of `radius`
1071
+ and rise per radian `rise` equals `bond`; the root of the chord
1072
+ equation on (0, pi]."""
1073
+ def chord_excess(angle: float) -> float:
1074
+ return (4.0 * radius * radius * math.sin(0.5 * angle) ** 2
1075
+ + rise * rise * angle * angle - bond * bond)
1076
+ if chord_excess(math.pi) < 0.0:
1077
+ raise ValueError(
1078
+ f"a bond of {bond} does not fit in half a turn of a helix of "
1079
+ f"radius {radius} and pitch {2 * math.pi * rise}")
1080
+ angle: float = brentq(chord_excess, 0.0, math.pi, xtol=1e-14,
1081
+ maxiter=200)
1082
+ return angle
1083
+
1084
+
1085
+ def _plectoneme_curve(u: FloatArray, strand: float,
1086
+ theta: float) -> FloatArray:
1087
+ """Return points of the unit-radius Neukirch-Starostin plasmid at the
1088
+ parameters `u` in [0, 2 strand + 2 pi]: strand A for u in [0, strand),
1089
+ loop B for the next pi, strand C for the next `strand`, loop D for the
1090
+ last pi (Neukirch and Starostin, 2008, Eqs. A1 to A4)."""
1091
+ u = np.asarray(u, dtype=np.float64)
1092
+ sin_t, cos_t = math.sin(theta), math.cos(theta)
1093
+ turn = 1.0 + 2.0 * theta / math.pi
1094
+ twist = strand * sin_t # the angle strand A turns
1095
+ points = np.empty(u.shape + (3,), dtype=np.float64)
1096
+ in_a = u < strand
1097
+ s = u[in_a]
1098
+ points[in_a] = np.stack(
1099
+ [np.sin(s * sin_t), -np.cos(s * sin_t), s * cos_t], axis=-1)
1100
+ in_b = (u >= strand) & (u < strand + math.pi)
1101
+ s = u[in_b] - strand
1102
+ theta_b = theta - s * turn
1103
+ points[in_b] = np.stack([
1104
+ math.sin(twist) * np.cos(s)
1105
+ + math.cos(twist) * sin_t * np.sin(s) * np.cos(s),
1106
+ -math.cos(twist) * np.cos(s)
1107
+ + math.sin(twist) * sin_t * np.sin(s) * np.cos(s),
1108
+ strand * cos_t + (sin_t - np.sin(theta_b)) / turn], axis=-1)
1109
+ in_c = (u >= strand + math.pi) & (u < 2.0 * strand + math.pi)
1110
+ s = u[in_c] - strand - math.pi
1111
+ psi = twist - s * sin_t
1112
+ points[in_c] = np.stack(
1113
+ [-np.sin(psi), np.cos(psi), (strand - s) * cos_t], axis=-1)
1114
+ in_d = u >= 2.0 * strand + math.pi
1115
+ s = np.minimum(u[in_d] - 2.0 * strand - math.pi, math.pi)
1116
+ theta_d = -math.pi - theta + s * turn
1117
+ points[in_d] = np.stack([
1118
+ sin_t * np.sin(s) * np.cos(s), np.cos(s),
1119
+ (np.sin(theta_d) - sin_t) / turn], axis=-1)
1120
+ return points
1121
+
1122
+
1123
+ def _plectoneme_loop_length(theta: float, step: float) -> float:
1124
+ """Return the arclength of the two end loops of the unit-radius
1125
+ plasmid, the whole curve when the strands have zero length."""
1126
+ u = np.arange(0.0, 2.0 * math.pi + step, step)
1127
+ points = _plectoneme_curve(u, 0.0, theta)
1128
+ return float(np.linalg.norm(np.diff(points, axis=0), axis=1).sum())
1129
+
1130
+
1131
+ def _march_curve(
1132
+ curve: _Curve, u_end: float, n_beads: int, bond: float, step: float
1133
+ ) -> tuple[FloatArray, float | None]:
1134
+ """Place up to `n_beads` beads `bond` apart along `curve`, sampled at
1135
+ parameter steps of `step` from 0 to `u_end`, each bead at the first
1136
+ crossing of the sphere about the bead before; return the beads and the
1137
+ distance from the last bead back to the first, or None when the curve
1138
+ ran out. O(n_beads + u_end / step) time and memory."""
1139
+ u = np.arange(0.0, u_end, step)
1140
+ if u[-1] < u_end:
1141
+ u = np.append(u, u_end)
1142
+ samples = curve(u)
1143
+ beads = np.empty((n_beads, 3), dtype=np.float64)
1144
+ beads[0] = samples[0]
1145
+ window = int(3.0 * bond / step) + 16
1146
+ index = 0
1147
+ for k in range(1, n_beads):
1148
+ last = beads[k - 1]
1149
+ beyond = _first_beyond(samples, index, window, last, bond)
1150
+ if beyond is None:
1151
+ return beads[:k], None
1152
+ beyond = max(beyond, index + 1)
1153
+
1154
+ def excess(parameter: float) -> float:
1155
+ point = curve(np.array([parameter]))[0]
1156
+ return float(np.dot(point - last, point - last)) - bond * bond
1157
+
1158
+ lo, hi = u[beyond - 1], u[beyond]
1159
+ if excess(lo) >= 0.0:
1160
+ crossing = lo
1161
+ else:
1162
+ crossing = brentq(excess, lo, hi, xtol=1e-14, maxiter=200)
1163
+ beads[k] = curve(np.array([crossing]))[0]
1164
+ index = beyond - 1
1165
+ return beads, float(np.linalg.norm(beads[-1] - beads[0]))
1166
+
1167
+
1168
+ def _first_beyond(
1169
+ samples: FloatArray, start: int, window: int, center: FloatArray,
1170
+ radius: float,
1171
+ ) -> int | None:
1172
+ """Return the index of the first sample from `start` on that is farther
1173
+ than `radius` from `center`, scanning `window` samples at a time."""
1174
+ while True:
1175
+ block = samples[start:start + window]
1176
+ delta = block - center
1177
+ outside = np.flatnonzero(
1178
+ np.einsum("ij,ij->i", delta, delta) > radius * radius)
1179
+ if outside.size:
1180
+ return start + int(outside[0])
1181
+ if start + window >= samples.shape[0]:
1182
+ return None
1183
+ start += window - 1
1184
+
1185
+
1186
+ def _march_polyline(
1187
+ vertices: FloatArray, n_beads: int, bond: float
1188
+ ) -> tuple[FloatArray, float | None]:
1189
+ """Place up to `n_beads` beads `bond` apart along the polyline through
1190
+ `vertices`, each at the first crossing of the sphere about the bead
1191
+ before, by exact line-sphere intersection; return the beads and the
1192
+ distance from the last bead back to the first vertex, or None when the
1193
+ polyline ran out. O(n_beads + n_vertices) time and memory."""
1194
+ segments = np.diff(vertices, axis=0)
1195
+ beads = np.empty((n_beads, 3), dtype=np.float64)
1196
+ beads[0] = vertices[0]
1197
+ segment = 0
1198
+ for k in range(1, n_beads):
1199
+ last = beads[k - 1]
1200
+ end = _first_beyond_vertex(vertices, segment + 1, last, bond)
1201
+ if end is None:
1202
+ return beads[:k], None
1203
+ segment = end - 1
1204
+ offset = vertices[segment] - last
1205
+ along = segments[segment]
1206
+ alpha = float(np.dot(along, along))
1207
+ beta = float(np.dot(offset, along))
1208
+ gamma = float(np.dot(offset, offset)) - bond * bond
1209
+ root = math.sqrt(max(beta * beta - alpha * gamma, 0.0))
1210
+ if beta <= 0.0:
1211
+ fraction = (root - beta) / alpha
1212
+ else:
1213
+ fraction = -gamma / (beta + root)
1214
+ beads[k] = vertices[segment] + fraction * along
1215
+ return beads, float(np.linalg.norm(beads[-1] - vertices[0]))
1216
+
1217
+
1218
+ def _first_beyond_vertex(
1219
+ vertices: FloatArray, start: int, center: FloatArray, radius: float
1220
+ ) -> int | None:
1221
+ """Return the index of the first vertex from `start` on that is at
1222
+ least `radius` from `center`, scanning 256 vertices at a time."""
1223
+ while start < vertices.shape[0]:
1224
+ block = vertices[start:start + 256]
1225
+ delta = block - center
1226
+ outside = np.flatnonzero(
1227
+ np.einsum("ij,ij->i", delta, delta) >= radius * radius)
1228
+ if outside.size:
1229
+ return start + int(outside[0])
1230
+ start += 256
1231
+ return None