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.
- polyphys/__init__.py +7 -0
- polyphys/__version__.py +22 -0
- polyphys/analyze/__init__.py +51 -0
- polyphys/analyze/clusters.py +1738 -0
- polyphys/analyze/correlations.py +995 -0
- polyphys/analyze/density.py +1138 -0
- polyphys/analyze/measurer.py +1338 -0
- polyphys/analyze/pipeline.py +1868 -0
- polyphys/analyze/stats.py +810 -0
- polyphys/analyze/sumrule.py +1159 -0
- polyphys/manage/__init__.py +11 -0
- polyphys/manage/organizer.py +2188 -0
- polyphys/manage/parser.py +1264 -0
- polyphys/manage/types.py +171 -0
- polyphys/manage/utils.py +777 -0
- polyphys/probe/__init__.py +25 -0
- polyphys/probe/binders.py +711 -0
- polyphys/probe/chain_size.py +583 -0
- polyphys/probe/core.py +437 -0
- polyphys/probe/histograms.py +863 -0
- polyphys/probe/pairs.py +555 -0
- polyphys/simulate/__init__.py +48 -0
- polyphys/simulate/build.py +1231 -0
- polyphys/simulate/core.py +237 -0
- polyphys/simulate/data.py +977 -0
- polyphys/simulate/dump.py +752 -0
- polyphys/simulate/log.py +788 -0
- polyphys/spec.py +1359 -0
- polyphys/tests/analyze/test_clusters.py +1058 -0
- polyphys/tests/analyze/test_correlations.py +708 -0
- polyphys/tests/analyze/test_density.py +813 -0
- polyphys/tests/analyze/test_measurer.py +785 -0
- polyphys/tests/analyze/test_pipeline.py +1881 -0
- polyphys/tests/analyze/test_stats.py +732 -0
- polyphys/tests/analyze/test_sumrule.py +917 -0
- polyphys/tests/conftest.py +753 -0
- polyphys/tests/data/lammps_build/make_starts.py +70 -0
- polyphys/tests/manage/test_organizer.py +1495 -0
- polyphys/tests/manage/test_parser.py +1222 -0
- polyphys/tests/manage/test_utils.py +498 -0
- polyphys/tests/probe/conftest.py +407 -0
- polyphys/tests/probe/test_binders.py +686 -0
- polyphys/tests/probe/test_chain_size.py +557 -0
- polyphys/tests/probe/test_core.py +391 -0
- polyphys/tests/probe/test_histograms.py +779 -0
- polyphys/tests/probe/test_pairs.py +475 -0
- polyphys/tests/simulate/test_build.py +1042 -0
- polyphys/tests/simulate/test_core.py +106 -0
- polyphys/tests/simulate/test_data.py +858 -0
- polyphys/tests/simulate/test_dump.py +487 -0
- polyphys/tests/simulate/test_log.py +667 -0
- polyphys/tests/strategies.py +120 -0
- polyphys/tests/synthetic.py +410 -0
- polyphys/tests/test_conftest.py +414 -0
- polyphys/tests/test_docs.py +82 -0
- polyphys/tests/test_packaging.py +97 -0
- polyphys/tests/test_spec.py +772 -0
- polyphys/tests/viz/conftest.py +32 -0
- polyphys/tests/viz/test_colors.py +113 -0
- polyphys/tests/viz/test_helpers.py +292 -0
- polyphys/viz/__init__.py +15 -0
- polyphys/viz/colors.py +204 -0
- polyphys/viz/helpers.py +549 -0
- polyphys-1.0.0.dist-info/METADATA +339 -0
- polyphys-1.0.0.dist-info/RECORD +69 -0
- polyphys-1.0.0.dist-info/WHEEL +5 -0
- polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
- polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
- 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
|