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,1042 @@
|
|
|
1
|
+
"""Tests of :mod:`polyphys.simulate.build`.
|
|
2
|
+
|
|
3
|
+
Every expected value is analytic, a property of the construction, or a
|
|
4
|
+
threshold read from the LAMMPS source and measured with LAMMPS (see the
|
|
5
|
+
module documentation); the ``lammps``-marked tests take LAMMPS itself as
|
|
6
|
+
the oracle and are skipped without it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pytest
|
|
16
|
+
from numpy.testing import assert_allclose, assert_array_equal
|
|
17
|
+
|
|
18
|
+
from polyphys.simulate.build import (
|
|
19
|
+
ConformationError,
|
|
20
|
+
_bracket,
|
|
21
|
+
_march_curve,
|
|
22
|
+
_plectoneme_curve,
|
|
23
|
+
chain_angles,
|
|
24
|
+
chain_bonds,
|
|
25
|
+
chain_data,
|
|
26
|
+
check_conformation,
|
|
27
|
+
helix,
|
|
28
|
+
plectoneme_ring,
|
|
29
|
+
resample_curve,
|
|
30
|
+
)
|
|
31
|
+
from polyphys.simulate.core import Box
|
|
32
|
+
from polyphys.simulate.data import DataFile, read_data, write_data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def bond_lengths() -> Callable[..., np.ndarray]:
|
|
37
|
+
"""The lengths of the bonds along a chain, the closing one for a ring."""
|
|
38
|
+
def _lengths(positions: np.ndarray, ring: bool = False) -> np.ndarray:
|
|
39
|
+
if ring:
|
|
40
|
+
return np.linalg.norm(np.roll(positions, -1, axis=0) - positions,
|
|
41
|
+
axis=1)
|
|
42
|
+
return np.linalg.norm(np.diff(positions, axis=0), axis=1)
|
|
43
|
+
return _lengths
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def make_box() -> Callable[..., Box]:
|
|
48
|
+
"""A box centered on the origin, by default a cube of side 10."""
|
|
49
|
+
def _box(half: float = 5.0, half_height: float | None = None,
|
|
50
|
+
**kwargs: Any) -> Box:
|
|
51
|
+
height = half if half_height is None else half_height
|
|
52
|
+
return Box(lo=np.array([-half, -half, -height]),
|
|
53
|
+
hi=np.array([half, half, height]), **kwargs)
|
|
54
|
+
return _box
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.fixture
|
|
58
|
+
def make_record(make_box: Callable[..., Box]) -> Callable[..., DataFile]:
|
|
59
|
+
"""A ``bond``-style data file from positions, with the bonds given as
|
|
60
|
+
``(atom1, atom2)`` pairs of IDs from 1, none by default."""
|
|
61
|
+
def _record(positions: Any, bonds: Any = (), box: Box | None = None,
|
|
62
|
+
ids: Any = None) -> DataFile:
|
|
63
|
+
coordinates = np.array(positions, dtype=np.float64)
|
|
64
|
+
n = coordinates.shape[0]
|
|
65
|
+
pairs = np.array(bonds, dtype=np.int64).reshape(-1, 2)
|
|
66
|
+
return DataFile(
|
|
67
|
+
atom_style="bond",
|
|
68
|
+
box=make_box() if box is None else box,
|
|
69
|
+
ids=np.arange(1, n + 1) if ids is None else np.array(ids),
|
|
70
|
+
types=np.ones(n, dtype=np.int64),
|
|
71
|
+
positions=coordinates,
|
|
72
|
+
n_atom_types=1,
|
|
73
|
+
mol_ids=np.ones(n, dtype=np.int64),
|
|
74
|
+
bonds=np.column_stack([np.ones(len(pairs), dtype=np.int64),
|
|
75
|
+
pairs]) if len(pairs) else np.empty(
|
|
76
|
+
(0, 3), dtype=np.int64),
|
|
77
|
+
n_bond_types=1 if len(pairs) else 0,
|
|
78
|
+
)
|
|
79
|
+
return _record
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# helix
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_helix_steps_by_the_chord_root_angle() -> None:
|
|
86
|
+
"""A bond of sqrt(2) on a helix of radius 1 and pitch 6 steps by pi/3 and
|
|
87
|
+
rises by 1 per bead, centered on z = 0."""
|
|
88
|
+
# 4 sin^2(pi/6) + (6 / 2 pi)^2 (pi/3)^2 = 1 + 1 = 2 = b^2 (analytic)
|
|
89
|
+
k = np.arange(7)
|
|
90
|
+
expected = np.column_stack(
|
|
91
|
+
[np.cos(k * np.pi / 3), np.sin(k * np.pi / 3), k - 3.0])
|
|
92
|
+
|
|
93
|
+
beads = helix(7, radius=1.0, pitch=6.0, bond_length=math.sqrt(2.0))
|
|
94
|
+
|
|
95
|
+
# float64 round-off of the root and the trigonometry
|
|
96
|
+
assert_allclose(beads, expected, rtol=1e-12, atol=1e-12)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.parametrize(
|
|
100
|
+
("radius", "pitch", "bond"),
|
|
101
|
+
[
|
|
102
|
+
pytest.param(2.0, 2.5, 0.97, id="narrow"),
|
|
103
|
+
pytest.param(10.0, 1.5, 0.97, id="wide"),
|
|
104
|
+
pytest.param(1.0, 0.5, 1.99, id="near-half-a-turn"),
|
|
105
|
+
],
|
|
106
|
+
)
|
|
107
|
+
def test_helix_bonds_equal_the_bond_length(
|
|
108
|
+
bond_lengths: Callable[..., np.ndarray], radius: float, pitch: float,
|
|
109
|
+
bond: float,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Every bond of a helix has the requested length."""
|
|
112
|
+
beads = helix(50, radius=radius, pitch=pitch, bond_length=bond)
|
|
113
|
+
|
|
114
|
+
# exactness up to float64 round-off is the contract
|
|
115
|
+
assert_allclose(bond_lengths(beads), bond, rtol=1e-12, atol=0.0)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.parametrize(
|
|
119
|
+
("radius", "pitch"),
|
|
120
|
+
[pytest.param(2.0, 2.5, id="narrow"), pytest.param(10.0, 1.5, id="wide")],
|
|
121
|
+
)
|
|
122
|
+
def test_helix_lies_on_its_cylinder_centered_on_z(
|
|
123
|
+
radius: float, pitch: float
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Every bead is the radius from the z axis and the beads are centered on
|
|
126
|
+
z = 0."""
|
|
127
|
+
beads = helix(50, radius=radius, pitch=pitch)
|
|
128
|
+
|
|
129
|
+
assert_allclose(np.hypot(beads[:, 0], beads[:, 1]), radius, rtol=1e-12)
|
|
130
|
+
assert beads[:, 2].mean() == pytest.approx(0.0, abs=1e-12) # round-off
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.parametrize(
|
|
134
|
+
("kwargs", "match"),
|
|
135
|
+
[
|
|
136
|
+
pytest.param({"n_beads": 0}, "'n_beads' must be 1 or more",
|
|
137
|
+
id="no-beads"),
|
|
138
|
+
pytest.param({"n_beads": 5.0}, "'n_beads' must be an integer",
|
|
139
|
+
id="float-beads"),
|
|
140
|
+
pytest.param({"radius": 0.0}, "'radius' must be positive",
|
|
141
|
+
id="zero-radius"),
|
|
142
|
+
pytest.param({"pitch": -1.0}, "'pitch' must be positive",
|
|
143
|
+
id="negative-pitch"),
|
|
144
|
+
pytest.param({"bond_length": 0.0}, "'bond_length' must be positive",
|
|
145
|
+
id="zero-bond"),
|
|
146
|
+
# 4 R^2 + (p / 2 pi)^2 pi^2 = 4.0625 < 2.2^2 (analytic)
|
|
147
|
+
pytest.param({"radius": 1.0, "pitch": 0.5, "bond_length": 2.2},
|
|
148
|
+
"does not fit in half a turn", id="bond-too-long"),
|
|
149
|
+
],
|
|
150
|
+
)
|
|
151
|
+
def test_helix_rejects_impossible_parameters(
|
|
152
|
+
kwargs: dict[str, Any], match: str
|
|
153
|
+
) -> None:
|
|
154
|
+
"""A bead count below 1, a non-positive length, or a bond longer than
|
|
155
|
+
half a turn raises ``ValueError``."""
|
|
156
|
+
arguments: dict[str, Any] = {"n_beads": 5, "radius": 1.0, "pitch": 1.0}
|
|
157
|
+
arguments.update(kwargs)
|
|
158
|
+
|
|
159
|
+
with pytest.raises(ValueError, match=match):
|
|
160
|
+
helix(**arguments)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@pytest.mark.parametrize(
|
|
164
|
+
("build", "ring"),
|
|
165
|
+
[
|
|
166
|
+
pytest.param(lambda: helix(30, radius=2.0, pitch=2.5), False,
|
|
167
|
+
id="helix"),
|
|
168
|
+
pytest.param(lambda: plectoneme_ring(60, radius=2.0, pitch=2.5),
|
|
169
|
+
True, id="plectoneme-ring"),
|
|
170
|
+
pytest.param(lambda: resample_curve(
|
|
171
|
+
[[0.0, 0.0, 0.0], [30.0, 0.0, 0.0]], 30), False, id="resample"),
|
|
172
|
+
],
|
|
173
|
+
)
|
|
174
|
+
def test_builders_default_to_the_kremer_grest_bond(
|
|
175
|
+
bond_lengths: Callable[..., np.ndarray], build: Callable[[], np.ndarray],
|
|
176
|
+
ring: bool,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Without a bond length, every builder uses 0.97, the start bond length
|
|
179
|
+
of Kremer and Grest (1990)."""
|
|
180
|
+
beads = build()
|
|
181
|
+
|
|
182
|
+
assert_allclose(bond_lengths(beads, ring=ring), 0.97, rtol=1e-12,
|
|
183
|
+
atol=0.0)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# resample_curve
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_resample_curve_spaces_beads_along_a_line() -> None:
|
|
190
|
+
"""On a straight segment the beads sit one bond length apart from the
|
|
191
|
+
first point."""
|
|
192
|
+
expected = np.column_stack(
|
|
193
|
+
[0.97 * np.arange(10), np.zeros(10), np.zeros(10)])
|
|
194
|
+
|
|
195
|
+
beads = resample_curve([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]], 10, 0.97)
|
|
196
|
+
|
|
197
|
+
assert_allclose(beads, expected, rtol=1e-12, atol=1e-12) # round-off
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_resample_curve_crosses_a_corner_at_the_sphere() -> None:
|
|
201
|
+
"""The next bead is where the sphere of one bond length about the last
|
|
202
|
+
bead first meets the polyline, past a corner."""
|
|
203
|
+
# |(1, y) - (0, 0)| = sqrt(2) at y = 1; then one more bond along y
|
|
204
|
+
corner = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 5.0, 0.0]]
|
|
205
|
+
expected = [[0.0, 0.0, 0.0], [1.0, 1.0, 0.0],
|
|
206
|
+
[1.0, 1.0 + math.sqrt(2.0), 0.0]]
|
|
207
|
+
|
|
208
|
+
beads = resample_curve(corner, 3, math.sqrt(2.0))
|
|
209
|
+
|
|
210
|
+
assert_allclose(beads, expected, rtol=1e-12, atol=1e-12) # round-off
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_resample_curve_rejects_a_short_curve() -> None:
|
|
214
|
+
"""An open curve that holds fewer beads than asked raises ``ValueError``
|
|
215
|
+
naming how many it holds."""
|
|
216
|
+
# a segment of length 1.5 holds beads at 0 and 1 only
|
|
217
|
+
with pytest.raises(ValueError, match="holds 2 beads 1.0 apart, not 4"):
|
|
218
|
+
resample_curve([[0.0, 0.0, 0.0], [1.5, 0.0, 0.0]], 4, 1.0)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_resample_curve_closes_a_circle_as_a_regular_polygon(
|
|
222
|
+
bond_lengths: Callable[..., np.ndarray]
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Twelve unit bonds close a circle as a regular 12-gon, whose
|
|
225
|
+
circumradius is 1 / (2 sin(pi / 12))."""
|
|
226
|
+
angles = np.linspace(0.0, 2.0 * np.pi, 2000, endpoint=False)
|
|
227
|
+
circle = np.column_stack(
|
|
228
|
+
[np.cos(angles), np.sin(angles), np.zeros_like(angles)])
|
|
229
|
+
circumradius = 1.0 / (2.0 * math.sin(math.pi / 12.0)) # analytic
|
|
230
|
+
|
|
231
|
+
beads = resample_curve(circle, 12, 1.0, closed=True)
|
|
232
|
+
|
|
233
|
+
# the polyline's vertices are on the circle and its edges are inside it
|
|
234
|
+
# by 1 - cos(pi / 2000) = 1.2e-6 of the radius
|
|
235
|
+
assert_allclose(np.hypot(beads[:, 0], beads[:, 1]), circumradius,
|
|
236
|
+
rtol=1e-5)
|
|
237
|
+
assert_allclose(bond_lengths(beads, ring=True), 1.0, rtol=1e-12,
|
|
238
|
+
atol=0.0)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_resample_curve_keeps_a_closed_curve_that_already_fits() -> None:
|
|
242
|
+
"""A square of side 4 holds 16 unit bonds as it is: the beads land on
|
|
243
|
+
the corners and the unit marks, unscaled."""
|
|
244
|
+
square = [[0.0, 0.0, 0.0], [4.0, 0.0, 0.0], [4.0, 4.0, 0.0],
|
|
245
|
+
[0.0, 4.0, 0.0]]
|
|
246
|
+
marks = np.arange(4, dtype=np.float64)
|
|
247
|
+
expected = np.vstack([
|
|
248
|
+
np.column_stack([marks, np.zeros(4), np.zeros(4)]),
|
|
249
|
+
np.column_stack([np.full(4, 4.0), marks, np.zeros(4)]),
|
|
250
|
+
np.column_stack([4.0 - marks, np.full(4, 4.0), np.zeros(4)]),
|
|
251
|
+
np.column_stack([np.zeros(4), 4.0 - marks, np.zeros(4)]),
|
|
252
|
+
])
|
|
253
|
+
|
|
254
|
+
beads = resample_curve(square, 16, 1.0, closed=True)
|
|
255
|
+
|
|
256
|
+
assert_allclose(beads, expected, rtol=0.0, atol=1e-12) # round-off
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@pytest.mark.parametrize(
|
|
260
|
+
("curve", "n_beads"),
|
|
261
|
+
[
|
|
262
|
+
pytest.param("ellipse", 40, id="ellipse"),
|
|
263
|
+
pytest.param("figure-eight", 60, id="figure-eight-in-3d"),
|
|
264
|
+
],
|
|
265
|
+
)
|
|
266
|
+
def test_resample_curve_closes_with_equal_bonds(
|
|
267
|
+
bond_lengths: Callable[..., np.ndarray], curve: str, n_beads: int
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Every bond of a closed curve, the closing one included, has the
|
|
270
|
+
requested length."""
|
|
271
|
+
t = np.linspace(0.0, 2.0 * np.pi, 5000, endpoint=False)
|
|
272
|
+
if curve == "ellipse":
|
|
273
|
+
points = np.column_stack([3.0 * np.cos(t), 1.5 * np.sin(t), 0 * t])
|
|
274
|
+
else:
|
|
275
|
+
points = np.column_stack(
|
|
276
|
+
[np.cos(t), 0.5 * np.sin(2.0 * t), 0.3 * np.sin(t)])
|
|
277
|
+
|
|
278
|
+
beads = resample_curve(points, n_beads, 0.97, closed=True)
|
|
279
|
+
|
|
280
|
+
assert beads.shape == (n_beads, 3)
|
|
281
|
+
assert_allclose(bond_lengths(beads, ring=True), 0.97, rtol=1e-12,
|
|
282
|
+
atol=0.0)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_resample_curve_drops_a_repeated_first_point() -> None:
|
|
286
|
+
"""A closed curve that repeats its first point last gives the same beads
|
|
287
|
+
as one that does not."""
|
|
288
|
+
square = [[0.0, 0.0, 0.0], [4.0, 0.0, 0.0], [4.0, 4.0, 0.0],
|
|
289
|
+
[0.0, 4.0, 0.0]]
|
|
290
|
+
|
|
291
|
+
beads = resample_curve(square + square[:1], 16, 1.0, closed=True)
|
|
292
|
+
|
|
293
|
+
assert_allclose(beads, resample_curve(square, 16, 1.0, closed=True),
|
|
294
|
+
rtol=0.0, atol=1e-12) # round-off
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_resample_curve_refuses_a_curve_it_cannot_close(
|
|
298
|
+
) -> None:
|
|
299
|
+
"""A closed curve with teeth smaller than the bond is refused: no scale
|
|
300
|
+
closes it with equal bonds, so it raises rather than returning a ring
|
|
301
|
+
with one odd bond."""
|
|
302
|
+
t = np.linspace(0.0, 2.0 * np.pi, 4000, endpoint=False)
|
|
303
|
+
radius = 2.0 + np.sign(np.sin(12.0 * t)) * np.abs(np.sin(12.0 * t)) ** 0.2
|
|
304
|
+
teeth = np.column_stack([radius * np.cos(t), radius * np.sin(t), 0 * t])
|
|
305
|
+
|
|
306
|
+
with pytest.raises(ValueError, match="cannot be closed with equal bonds"):
|
|
307
|
+
resample_curve(teeth, 30, 0.97, closed=True)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@pytest.mark.parametrize(
|
|
311
|
+
("points", "kwargs", "match"),
|
|
312
|
+
[
|
|
313
|
+
pytest.param([[0.0, 0.0], [1.0, 0.0]], {}, r"shape \(n_points, 3\)",
|
|
314
|
+
id="not-3d"),
|
|
315
|
+
pytest.param([[0.0, 0.0, 0.0], [np.nan, 0.0, 0.0]], {},
|
|
316
|
+
"must be finite", id="nan"),
|
|
317
|
+
pytest.param([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], {},
|
|
318
|
+
"2 or more distinct points", id="one-point"),
|
|
319
|
+
pytest.param([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]],
|
|
320
|
+
{"n_beads": 2, "closed": True},
|
|
321
|
+
"'n_beads' must be 3 or more", id="closed-two-beads"),
|
|
322
|
+
],
|
|
323
|
+
)
|
|
324
|
+
def test_resample_curve_rejects_bad_input(
|
|
325
|
+
points: Any, kwargs: dict[str, Any], match: str
|
|
326
|
+
) -> None:
|
|
327
|
+
"""Points that are not ``(n, 3)``, not finite, or not two distinct, and
|
|
328
|
+
a closed curve with fewer than three beads, raise ``ValueError``."""
|
|
329
|
+
arguments: dict[str, Any] = {"n_beads": 2, "bond_length": 0.5}
|
|
330
|
+
arguments.update(kwargs)
|
|
331
|
+
|
|
332
|
+
with pytest.raises(ValueError, match=match):
|
|
333
|
+
resample_curve(points, **arguments)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# plectoneme_ring
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@pytest.mark.parametrize(
|
|
340
|
+
("n_beads", "radius", "pitch"),
|
|
341
|
+
[
|
|
342
|
+
pytest.param(200, 2.0, 2.5, id="docs-ring"),
|
|
343
|
+
pytest.param(60, 2.0, 2.5, id="short"),
|
|
344
|
+
pytest.param(90, 1.0, 2.0, id="narrow"),
|
|
345
|
+
],
|
|
346
|
+
)
|
|
347
|
+
def test_plectoneme_ring_bonds_equal_the_bond_length(
|
|
348
|
+
bond_lengths: Callable[..., np.ndarray], n_beads: int, radius: float,
|
|
349
|
+
pitch: float,
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Every bond of the ring, the closing one included, has the requested
|
|
352
|
+
length, and the ring has the requested number of beads."""
|
|
353
|
+
beads = plectoneme_ring(n_beads, radius=radius, pitch=pitch)
|
|
354
|
+
|
|
355
|
+
assert beads.shape == (n_beads, 3)
|
|
356
|
+
assert_allclose(bond_lengths(beads, ring=True), 0.97, rtol=1e-12,
|
|
357
|
+
atol=0.0)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@pytest.mark.parametrize(
|
|
361
|
+
("n_beads", "radius", "pitch"),
|
|
362
|
+
[pytest.param(200, 2.0, 2.5, id="docs-ring"),
|
|
363
|
+
pytest.param(90, 1.0, 2.0, id="narrow")],
|
|
364
|
+
)
|
|
365
|
+
def test_plectoneme_ring_stays_inside_its_radius_centered_on_z(
|
|
366
|
+
n_beads: int, radius: float, pitch: float
|
|
367
|
+
) -> None:
|
|
368
|
+
"""No bead is farther than the radius from the axis, the strands reach
|
|
369
|
+
it, and the ring is centered on z = 0."""
|
|
370
|
+
beads = plectoneme_ring(n_beads, radius=radius, pitch=pitch)
|
|
371
|
+
radial = np.hypot(beads[:, 0], beads[:, 1])
|
|
372
|
+
|
|
373
|
+
assert radial.max() == pytest.approx(radius, rel=1e-12) # round-off
|
|
374
|
+
assert np.all(radial <= radius * (1.0 + 1e-12))
|
|
375
|
+
assert beads[:, 2].max() == pytest.approx(-beads[:, 2].min(), abs=1e-12)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_plectoneme_curve_follows_neukirch_starostin_appendix_a() -> None:
|
|
379
|
+
"""The plasmid curve is continuous at its four joints and closes, loop B
|
|
380
|
+
crosses the axis at its midpoint, strand C is strand A rotated by pi
|
|
381
|
+
about the axis and run downward, and the loops' interior points are
|
|
382
|
+
those of Eqs. (A2) and (A4) of Neukirch and Starostin (2008)."""
|
|
383
|
+
strand, theta = 5.0, 1.0
|
|
384
|
+
turn = 1.0 + 2.0 * theta / np.pi
|
|
385
|
+
twist = strand * np.sin(theta)
|
|
386
|
+
joints = np.array([strand, strand + np.pi, 2.0 * strand + np.pi,
|
|
387
|
+
2.0 * strand + 2.0 * np.pi])
|
|
388
|
+
quarter = np.pi / 4.0
|
|
389
|
+
# Eq. (A2) at s_B = pi/4 and Eq. (A4) at s_D = pi/4, transcribed
|
|
390
|
+
theta_b = theta - quarter * turn
|
|
391
|
+
half_sine = np.sin(theta) * np.sin(quarter) * np.cos(quarter)
|
|
392
|
+
loop_b = [np.sin(twist) * np.cos(quarter) + np.cos(twist) * half_sine,
|
|
393
|
+
-np.cos(twist) * np.cos(quarter) + np.sin(twist) * half_sine,
|
|
394
|
+
strand * np.cos(theta)
|
|
395
|
+
+ (np.sin(theta) - np.sin(theta_b)) / turn]
|
|
396
|
+
theta_d = -np.pi - theta + quarter * turn
|
|
397
|
+
loop_d = [half_sine, np.cos(quarter),
|
|
398
|
+
(np.sin(theta_d) - np.sin(theta)) / turn]
|
|
399
|
+
|
|
400
|
+
after = _plectoneme_curve(joints, strand, theta)
|
|
401
|
+
before = _plectoneme_curve(joints - 1e-9, strand, theta)
|
|
402
|
+
apex = _plectoneme_curve(np.array([strand + np.pi / 2.0]), strand, theta)
|
|
403
|
+
s = np.linspace(0.0, strand, 7)
|
|
404
|
+
strand_a = _plectoneme_curve(s, strand, theta)
|
|
405
|
+
strand_c = _plectoneme_curve(2.0 * strand + np.pi - s, strand, theta)
|
|
406
|
+
points = _plectoneme_curve(
|
|
407
|
+
np.array([strand + quarter, 2.0 * strand + np.pi + quarter]),
|
|
408
|
+
strand, theta)
|
|
409
|
+
|
|
410
|
+
assert_allclose(after, before, rtol=0.0, atol=1e-7) # 1e-9 x speed
|
|
411
|
+
assert_allclose(after[3], _plectoneme_curve(np.zeros(1), strand, theta)[0],
|
|
412
|
+
rtol=0.0, atol=1e-12)
|
|
413
|
+
assert_allclose(apex[0], [0.0, 0.0, strand * np.cos(theta)
|
|
414
|
+
+ (1.0 + np.sin(theta)) / turn],
|
|
415
|
+
rtol=0.0, atol=1e-12)
|
|
416
|
+
assert_allclose(strand_c[:, :2], -strand_a[:, :2], rtol=0.0, atol=1e-12)
|
|
417
|
+
assert_allclose(strand_c[:, 2], strand_a[:, 2], rtol=0.0, atol=1e-12)
|
|
418
|
+
assert_allclose(points, [loop_b, loop_d], rtol=1e-12, atol=1e-12)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@pytest.mark.parametrize(
|
|
422
|
+
("estimate", "width"),
|
|
423
|
+
[
|
|
424
|
+
pytest.param(2.0, 1.0, id="estimate-below-the-root"),
|
|
425
|
+
pytest.param(30.0, 1.0, id="estimate-above-the-root"),
|
|
426
|
+
pytest.param(10.5, 1.0, id="estimate-at-the-root"),
|
|
427
|
+
],
|
|
428
|
+
)
|
|
429
|
+
def test_bracket_widens_until_the_gap_changes_sign(
|
|
430
|
+
estimate: float, width: float
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Wherever the estimate starts, the bracket ends with the gap negative
|
|
433
|
+
at its low end and non-negative at its high end, and above zero; here
|
|
434
|
+
for the gap x - 10, whose root is 10."""
|
|
435
|
+
lo, hi = _bracket(lambda x: x - 10.0, estimate, width)
|
|
436
|
+
|
|
437
|
+
assert lo < 10.0 <= hi
|
|
438
|
+
assert lo > 0.0
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_bracket_rejects_a_gap_that_never_turns_negative() -> None:
|
|
442
|
+
"""A gap that stays positive down to the lower bound raises
|
|
443
|
+
``ValueError`` instead of searching forever."""
|
|
444
|
+
with pytest.raises(ValueError, match="no sign change"):
|
|
445
|
+
_bracket(lambda x: 1.0, 5.0, 1.0)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def test_march_curve_takes_a_sample_on_the_sphere_as_the_crossing() -> None:
|
|
449
|
+
"""On a line sampled every quarter bond, the beads fall on samples that
|
|
450
|
+
lie exactly one bond from the bead before, and the chord back to the
|
|
451
|
+
first bead is the line's length."""
|
|
452
|
+
def line(u: np.ndarray) -> np.ndarray:
|
|
453
|
+
return np.column_stack([u, 0.0 * u, 0.0 * u])
|
|
454
|
+
|
|
455
|
+
beads, gap = _march_curve(line, 2.5, 3, 1.0, 0.25)
|
|
456
|
+
|
|
457
|
+
assert_allclose(beads[:, 0], [0.0, 1.0, 2.0], rtol=0.0, atol=1e-12)
|
|
458
|
+
assert gap == pytest.approx(2.0)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def test_march_curve_extends_its_window_and_reports_a_curve_that_ends(
|
|
462
|
+
) -> None:
|
|
463
|
+
"""On a line that advances a tenth of a bond per unit of parameter, the
|
|
464
|
+
next crossing lies beyond the window the march scans first, so it scans
|
|
465
|
+
on; when the curve ends before the fifth bead it returns the four
|
|
466
|
+
placed and no closing chord."""
|
|
467
|
+
def slow(u: np.ndarray) -> np.ndarray:
|
|
468
|
+
return np.column_stack([0.1 * u, 0.0 * u, 0.0 * u])
|
|
469
|
+
|
|
470
|
+
beads, gap = _march_curve(slow, 35.0, 5, 1.0, 0.05)
|
|
471
|
+
|
|
472
|
+
assert_allclose(beads[:, 0], [0.0, 1.0, 2.0, 3.0], rtol=0.0, atol=1e-12)
|
|
473
|
+
assert gap is None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def test_plectoneme_ring_starts_at_the_foot_of_the_first_strand() -> None:
|
|
477
|
+
"""The first bead is the foot of strand A, at angle zero on the
|
|
478
|
+
cylinder, (0, -R) in Neukirch and Starostin's frame, and the chain
|
|
479
|
+
climbs from there."""
|
|
480
|
+
beads = plectoneme_ring(60, radius=2.0, pitch=2.5)
|
|
481
|
+
|
|
482
|
+
assert_allclose(beads[0, :2], [0.0, -2.0], rtol=0.0, atol=1e-12)
|
|
483
|
+
assert beads[1, 2] > beads[0, 2]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def test_plectoneme_ring_passes_the_conformation_check(
|
|
487
|
+
make_box: Callable[..., Box]
|
|
488
|
+
) -> None:
|
|
489
|
+
"""The ring of the documentation, 200 beads of radius 2 and pitch 2.5,
|
|
490
|
+
starts a Kremer-Grest run: its strands stay a bead diameter apart."""
|
|
491
|
+
beads = plectoneme_ring(200, radius=2.0, pitch=2.5)
|
|
492
|
+
box = make_box(half=5.0, half_height=0.5 * np.ptp(beads[:, 2]) + 2.0)
|
|
493
|
+
data = chain_data(beads, box, ring=True)
|
|
494
|
+
|
|
495
|
+
report = check_conformation(data, r0=1.5, ghost_cutoff=1.122462 + 0.3,
|
|
496
|
+
periodic=(False, False, True),
|
|
497
|
+
min_distance=1.0)
|
|
498
|
+
|
|
499
|
+
assert report.min_pair >= 1.0
|
|
500
|
+
assert report.max_bond == pytest.approx(0.97, rel=1e-12)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def test_plectoneme_ring_with_a_close_pitch_fails_the_pair_check(
|
|
504
|
+
make_box: Callable[..., Box]
|
|
505
|
+
) -> None:
|
|
506
|
+
"""A pitch of 1.5 at radius 3 puts the two strands about 0.75 apart, so
|
|
507
|
+
the ring fails the non-bonded distance check."""
|
|
508
|
+
# (p / 2) sin(theta_A) with tan(theta_A) = 2 pi R / p gives 0.748; the
|
|
509
|
+
# measured closest pair is 0.7555 (reproducible: this ring)
|
|
510
|
+
beads = plectoneme_ring(1000, radius=3.0, pitch=1.5)
|
|
511
|
+
box = make_box(half=6.0, half_height=0.5 * np.ptp(beads[:, 2]) + 2.0)
|
|
512
|
+
data = chain_data(beads, box, ring=True)
|
|
513
|
+
|
|
514
|
+
with pytest.raises(ConformationError,
|
|
515
|
+
match=r"non-bonded pairs closer than 1: closest 0.75"):
|
|
516
|
+
check_conformation(data, r0=1.5, ghost_cutoff=1.122462 + 0.3,
|
|
517
|
+
periodic=(False, False, True))
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@pytest.mark.parametrize(
|
|
521
|
+
("kwargs", "match"),
|
|
522
|
+
[
|
|
523
|
+
# the two loops alone are 15.4 long at radius 2: about 16 beads
|
|
524
|
+
pytest.param({"n_beads": 12}, "'n_beads' must be at least about",
|
|
525
|
+
id="too-few-for-the-loops"),
|
|
526
|
+
pytest.param({"n_beads": 2}, "'n_beads' must be 3 or more",
|
|
527
|
+
id="two-beads"),
|
|
528
|
+
pytest.param({"radius": -2.0}, "'radius' must be positive",
|
|
529
|
+
id="negative-radius"),
|
|
530
|
+
pytest.param({"bond_length": 5.0}, "does not fit in half a turn",
|
|
531
|
+
id="bond-too-long"),
|
|
532
|
+
],
|
|
533
|
+
)
|
|
534
|
+
def test_plectoneme_ring_rejects_impossible_parameters(
|
|
535
|
+
kwargs: dict[str, Any], match: str
|
|
536
|
+
) -> None:
|
|
537
|
+
"""Too few beads for the two end loops, a non-positive length, or a bond
|
|
538
|
+
longer than half a turn raises ``ValueError``."""
|
|
539
|
+
arguments: dict[str, Any] = {"n_beads": 60, "radius": 2.0, "pitch": 2.5}
|
|
540
|
+
arguments.update(kwargs)
|
|
541
|
+
|
|
542
|
+
with pytest.raises(ValueError, match=match):
|
|
543
|
+
plectoneme_ring(**arguments)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# topology
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@pytest.mark.parametrize(
|
|
550
|
+
("ring", "expected"),
|
|
551
|
+
[
|
|
552
|
+
pytest.param(False, [[1, 1, 2], [1, 2, 3], [1, 3, 4]], id="linear"),
|
|
553
|
+
pytest.param(True, [[1, 1, 2], [1, 2, 3], [1, 3, 4], [1, 4, 1]],
|
|
554
|
+
id="ring"),
|
|
555
|
+
],
|
|
556
|
+
)
|
|
557
|
+
def test_chain_bonds_rows(ring: bool, expected: list[list[int]]) -> None:
|
|
558
|
+
"""A chain of four beads has the three bonds along it, and a ring the
|
|
559
|
+
bond from the last bead to the first as well."""
|
|
560
|
+
assert_array_equal(chain_bonds(4, ring=ring), expected)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@pytest.mark.parametrize(
|
|
564
|
+
("ring", "expected"),
|
|
565
|
+
[
|
|
566
|
+
pytest.param(False, [[2, 1, 2, 3], [2, 2, 3, 4]], id="linear"),
|
|
567
|
+
pytest.param(True, [[2, 1, 2, 3], [2, 2, 3, 4], [2, 3, 4, 1],
|
|
568
|
+
[2, 4, 1, 2]], id="ring"),
|
|
569
|
+
],
|
|
570
|
+
)
|
|
571
|
+
def test_chain_angles_rows(ring: bool, expected: list[list[int]]) -> None:
|
|
572
|
+
"""A chain of four beads has the two angles with an inner vertex, and a
|
|
573
|
+
ring the two that span the closing bond as well, all of the given type."""
|
|
574
|
+
assert_array_equal(chain_angles(4, ring=ring, angle_type=2), expected)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def test_chain_topology_defaults_to_a_linear_chain_without_angles(
|
|
578
|
+
make_box: Callable[..., Box]
|
|
579
|
+
) -> None:
|
|
580
|
+
"""Without ``ring`` or ``angles``, the topology is that of a linear
|
|
581
|
+
chain in the ``bond`` style: n - 1 bonds, no closing bond, no angles."""
|
|
582
|
+
positions = np.column_stack([0.97 * np.arange(4), np.zeros(4),
|
|
583
|
+
np.zeros(4)])
|
|
584
|
+
|
|
585
|
+
data = chain_data(positions, make_box())
|
|
586
|
+
|
|
587
|
+
assert_array_equal(chain_bonds(4), [[1, 1, 2], [1, 2, 3], [1, 3, 4]])
|
|
588
|
+
assert_array_equal(chain_angles(4), [[1, 1, 2, 3], [1, 2, 3, 4]])
|
|
589
|
+
assert (data.atom_style, data.n_bonds, data.n_angles) == ("bond", 3, 0)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@pytest.mark.parametrize(
|
|
593
|
+
("call", "match"),
|
|
594
|
+
[
|
|
595
|
+
pytest.param(lambda: chain_bonds(1), "'n_beads' must be 2 or more",
|
|
596
|
+
id="one-bead-chain"),
|
|
597
|
+
pytest.param(lambda: chain_bonds(2, ring=True),
|
|
598
|
+
"'n_beads' must be 3 or more", id="two-bead-ring"),
|
|
599
|
+
pytest.param(lambda: chain_angles(2), "'n_beads' must be 3 or more",
|
|
600
|
+
id="two-bead-angles"),
|
|
601
|
+
pytest.param(lambda: chain_bonds(3, bond_type=0),
|
|
602
|
+
"'bond_type' must be 1 or more", id="type-zero"),
|
|
603
|
+
],
|
|
604
|
+
)
|
|
605
|
+
def test_chain_topology_rejects_too_few_beads(
|
|
606
|
+
call: Callable[[], np.ndarray], match: str
|
|
607
|
+
) -> None:
|
|
608
|
+
"""Too few beads for the topology, or a type below 1, raises
|
|
609
|
+
``ValueError``."""
|
|
610
|
+
with pytest.raises(ValueError, match=match):
|
|
611
|
+
call()
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@pytest.mark.parametrize(
|
|
615
|
+
("angles", "style", "n_angles"),
|
|
616
|
+
[pytest.param(False, "bond", 0, id="bonds"),
|
|
617
|
+
pytest.param(True, "angle", 3, id="angles")],
|
|
618
|
+
)
|
|
619
|
+
def test_chain_data_builds_the_record(
|
|
620
|
+
make_box: Callable[..., Box], angles: bool, style: str, n_angles: int
|
|
621
|
+
) -> None:
|
|
622
|
+
"""The record numbers the atoms from 1 in order, puts them in molecule
|
|
623
|
+
1, gives the types, bonds, and angles asked for, and leaves masses and
|
|
624
|
+
velocities to the input script."""
|
|
625
|
+
positions = np.column_stack([0.97 * np.arange(5), np.zeros(5),
|
|
626
|
+
np.zeros(5)])
|
|
627
|
+
|
|
628
|
+
data = chain_data(positions, make_box(), angles=angles,
|
|
629
|
+
types=[1, 2, 1, 2, 1])
|
|
630
|
+
|
|
631
|
+
assert data.atom_style == style
|
|
632
|
+
assert_array_equal(data.ids, [1, 2, 3, 4, 5])
|
|
633
|
+
assert_array_equal(data.mol_ids, [1, 1, 1, 1, 1])
|
|
634
|
+
assert_array_equal(data.types, [1, 2, 1, 2, 1])
|
|
635
|
+
assert (data.n_atom_types, data.n_bonds, data.n_angles) == (
|
|
636
|
+
2, 4, n_angles)
|
|
637
|
+
assert_array_equal(data.positions, positions)
|
|
638
|
+
assert data.masses is None and data.velocities is None
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def test_chain_data_round_trips_through_write_and_read(
|
|
642
|
+
make_box: Callable[..., Box], tmp_path: Path
|
|
643
|
+
) -> None:
|
|
644
|
+
"""A ring with angles written by ``write_data`` reads back with the same
|
|
645
|
+
positions, bonds, and angles."""
|
|
646
|
+
data = chain_data(helix(6, radius=1.0, pitch=2.0), make_box(), ring=True,
|
|
647
|
+
angles=True)
|
|
648
|
+
|
|
649
|
+
read = read_data(write_data(data, tmp_path / "ring.data"))
|
|
650
|
+
|
|
651
|
+
assert read.atom_style == "angle"
|
|
652
|
+
assert_array_equal(read.positions, data.positions)
|
|
653
|
+
assert_array_equal(read.bonds, chain_bonds(6, ring=True))
|
|
654
|
+
assert_array_equal(read.angles, chain_angles(6, ring=True))
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@pytest.mark.parametrize(
|
|
658
|
+
("positions", "types", "match"),
|
|
659
|
+
[
|
|
660
|
+
pytest.param([[0.0, 0.0], [1.0, 0.0]], None,
|
|
661
|
+
r"shape \(n_beads, 3\)", id="not-3d"),
|
|
662
|
+
pytest.param([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], [1, 1, 1],
|
|
663
|
+
r"'types' must have shape \(2,\)", id="types-shape"),
|
|
664
|
+
],
|
|
665
|
+
)
|
|
666
|
+
def test_chain_data_rejects_bad_shapes(
|
|
667
|
+
make_box: Callable[..., Box], positions: Any, types: Any, match: str
|
|
668
|
+
) -> None:
|
|
669
|
+
"""Positions that are not ``(n, 3)`` or types of another length raise
|
|
670
|
+
``ValueError``."""
|
|
671
|
+
with pytest.raises(ValueError, match=match):
|
|
672
|
+
chain_data(positions, make_box(), types=types)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# check_conformation
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@pytest.mark.parametrize(
|
|
679
|
+
("length", "passes"),
|
|
680
|
+
[pytest.param(1.4230, True, id="below"),
|
|
681
|
+
pytest.param(1.4231, False, id="above")],
|
|
682
|
+
)
|
|
683
|
+
def test_check_conformation_bond_warning_threshold(
|
|
684
|
+
make_record: Callable[..., DataFile], length: float, passes: bool
|
|
685
|
+
) -> None:
|
|
686
|
+
"""A bond just below sqrt(0.9) R0 = 1.4230 passes and one just above
|
|
687
|
+
fails, the length at which LAMMPS warns ``FENE bond too long``."""
|
|
688
|
+
# bond_fene.cpp lines 89 to 94: 1 - (r / R0)^2 < 0.1; R0 = 1.5
|
|
689
|
+
data = make_record([[0.0, 0.0, 0.0], [length, 0.0, 0.0]], bonds=[(1, 2)])
|
|
690
|
+
arguments: dict[str, Any] = {"r0": 1.5, "ghost_cutoff": 2.0,
|
|
691
|
+
"periodic": (False, False, False)}
|
|
692
|
+
|
|
693
|
+
if passes:
|
|
694
|
+
assert check_conformation(data, **arguments).max_bond == length
|
|
695
|
+
else:
|
|
696
|
+
with pytest.raises(ConformationError,
|
|
697
|
+
match=r"1 bond at or above sqrt\(0.9\) R0"):
|
|
698
|
+
check_conformation(data, **arguments)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
@pytest.mark.parametrize(
|
|
702
|
+
("length", "passes"),
|
|
703
|
+
[pytest.param(1.4224, True, id="below"),
|
|
704
|
+
pytest.param(1.4226, False, id="above")],
|
|
705
|
+
)
|
|
706
|
+
def test_check_conformation_ghost_cutoff_threshold(
|
|
707
|
+
make_record: Callable[..., DataFile], length: float, passes: bool
|
|
708
|
+
) -> None:
|
|
709
|
+
"""A bond just below the ghost cutoff passes and one at or above it
|
|
710
|
+
fails: measured with LAMMPS, a bond of 1.4224 across a periodic face
|
|
711
|
+
runs and one of 1.4226 stops with ``Bad FENE bond``."""
|
|
712
|
+
data = make_record([[0.0, 0.0, 0.0], [length, 0.0, 0.0]], bonds=[(1, 2)])
|
|
713
|
+
arguments: dict[str, Any] = {"r0": 3.0, "ghost_cutoff": 1.122462 + 0.3,
|
|
714
|
+
"periodic": (True, True, True)}
|
|
715
|
+
|
|
716
|
+
if passes:
|
|
717
|
+
assert check_conformation(data, **arguments).max_bond == length
|
|
718
|
+
else:
|
|
719
|
+
with pytest.raises(ConformationError,
|
|
720
|
+
match="1 bond at or above the ghost cutoff"):
|
|
721
|
+
check_conformation(data, **arguments)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def test_check_conformation_lets_a_bonded_pair_below_min_distance_pass(
|
|
725
|
+
make_record: Callable[..., DataFile]
|
|
726
|
+
) -> None:
|
|
727
|
+
"""The non-bonded distance rule does not apply to a bonded pair."""
|
|
728
|
+
data = make_record([[0.0, 0.0, 0.0], [0.95, 0.0, 0.0]], bonds=[(1, 2)])
|
|
729
|
+
|
|
730
|
+
report = check_conformation(data, r0=1.5, ghost_cutoff=1.42,
|
|
731
|
+
periodic=(False, False, False),
|
|
732
|
+
min_distance=1.0)
|
|
733
|
+
|
|
734
|
+
assert report.min_bond == 0.95
|
|
735
|
+
assert math.isnan(report.min_pair)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def test_check_conformation_counts_a_1_3_pair_as_non_bonded(
|
|
739
|
+
make_record: Callable[..., DataFile]
|
|
740
|
+
) -> None:
|
|
741
|
+
"""Beads two apart along the chain repel like any other pair under
|
|
742
|
+
``special_bonds fene``, so a 1-3 distance below the minimum fails."""
|
|
743
|
+
# bead 3 one bond from bead 2 and 0.9 from bead 1: x = 0.9^2 / (2 b)
|
|
744
|
+
x = 0.81 / (2.0 * 0.97)
|
|
745
|
+
trimer = [[0.0, 0.0, 0.0], [0.97, 0.0, 0.0],
|
|
746
|
+
[x, math.sqrt(0.81 - x * x), 0.0]]
|
|
747
|
+
data = make_record(trimer, bonds=[(1, 2), (2, 3)])
|
|
748
|
+
|
|
749
|
+
with pytest.raises(ConformationError,
|
|
750
|
+
match="1 non-bonded pair closer than 1: closest 0.9"):
|
|
751
|
+
check_conformation(data, r0=1.5, ghost_cutoff=1.42,
|
|
752
|
+
periodic=(False, False, False), min_distance=1.0)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def test_check_conformation_rejects_coincident_atoms(
|
|
756
|
+
make_record: Callable[..., DataFile]
|
|
757
|
+
) -> None:
|
|
758
|
+
"""Two atoms at one position fail even when they are bonded, since their
|
|
759
|
+
force is not finite."""
|
|
760
|
+
data = make_record([[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], bonds=[(1, 2)])
|
|
761
|
+
|
|
762
|
+
with pytest.raises(ConformationError, match="1 pair of atoms closer"):
|
|
763
|
+
check_conformation(data, r0=1.5, ghost_cutoff=1.42,
|
|
764
|
+
periodic=(False, False, False))
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
@pytest.mark.parametrize(
|
|
768
|
+
("periodic", "passes"),
|
|
769
|
+
[pytest.param((False, False, True), False, id="periodic-z"),
|
|
770
|
+
pytest.param((False, False, False), True, id="fixed-z")],
|
|
771
|
+
)
|
|
772
|
+
def test_check_conformation_uses_the_minimum_image_on_periodic_axes(
|
|
773
|
+
make_record: Callable[..., DataFile], periodic: tuple[bool, bool, bool],
|
|
774
|
+
passes: bool,
|
|
775
|
+
) -> None:
|
|
776
|
+
"""Two beads 0.5 apart through the z faces of a box of height 10 are an
|
|
777
|
+
overlap on a periodic z axis and 9.5 apart on a fixed one."""
|
|
778
|
+
data = make_record([[0.0, 0.0, -4.8], [0.0, 0.0, 4.7]])
|
|
779
|
+
arguments: dict[str, Any] = {"r0": 1.5, "ghost_cutoff": 1.42,
|
|
780
|
+
"periodic": periodic, "min_distance": 1.0}
|
|
781
|
+
|
|
782
|
+
if passes:
|
|
783
|
+
assert check_conformation(data, **arguments).min_pair == (
|
|
784
|
+
pytest.approx(9.5))
|
|
785
|
+
else:
|
|
786
|
+
with pytest.raises(ConformationError, match="closest 0.5"):
|
|
787
|
+
check_conformation(data, **arguments)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
@pytest.mark.parametrize(
|
|
791
|
+
("x", "periodic", "passes"),
|
|
792
|
+
[
|
|
793
|
+
pytest.param(5.1, (True, False, False), True, id="periodic-outside"),
|
|
794
|
+
pytest.param(5.1, (False, False, False), False, id="fixed-outside"),
|
|
795
|
+
pytest.param(5.0, (False, False, False), False, id="fixed-on-face"),
|
|
796
|
+
pytest.param(-5.0, (False, False, False), True, id="fixed-on-lo"),
|
|
797
|
+
],
|
|
798
|
+
)
|
|
799
|
+
def test_check_conformation_box_rule_depends_on_periodicity(
|
|
800
|
+
make_record: Callable[..., DataFile], x: float,
|
|
801
|
+
periodic: tuple[bool, bool, bool], passes: bool,
|
|
802
|
+
) -> None:
|
|
803
|
+
"""An atom past the box, or on its upper face, fails on a fixed axis,
|
|
804
|
+
where ``read_data`` loses it, and passes on a periodic one, where
|
|
805
|
+
``read_data`` remaps it."""
|
|
806
|
+
data = make_record([[x, 0.0, 0.0]])
|
|
807
|
+
arguments: dict[str, Any] = {"r0": 1.5, "ghost_cutoff": 1.42,
|
|
808
|
+
"periodic": periodic}
|
|
809
|
+
|
|
810
|
+
if passes:
|
|
811
|
+
assert check_conformation(data, **arguments).n_atoms == 1
|
|
812
|
+
else:
|
|
813
|
+
with pytest.raises(ConformationError,
|
|
814
|
+
match=r"1 atom outside the box.*\(atoms 1\)"):
|
|
815
|
+
check_conformation(data, **arguments)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
@pytest.mark.parametrize(
|
|
819
|
+
("x", "passes"),
|
|
820
|
+
[pytest.param(5.5, True, id="inside-the-tilt"),
|
|
821
|
+
pytest.param(6.5, False, id="beyond-the-tilt")],
|
|
822
|
+
)
|
|
823
|
+
def test_check_conformation_box_rule_uses_fractions_in_a_triclinic_box(
|
|
824
|
+
make_record: Callable[..., DataFile], make_box: Callable[..., Box],
|
|
825
|
+
x: float, passes: bool,
|
|
826
|
+
) -> None:
|
|
827
|
+
"""In a box tilted by xy = 2 the atom at (x, 1) is inside when its
|
|
828
|
+
fractional coordinate along A is below 1: (x + 5 - 0.6 * 2) / 10."""
|
|
829
|
+
box = make_box(tilt=np.array([2.0, 0.0, 0.0]), triclinic=True)
|
|
830
|
+
data = make_record([[x, 1.0, 0.0]], box=box)
|
|
831
|
+
arguments: dict[str, Any] = {"r0": 1.5, "ghost_cutoff": 1.42,
|
|
832
|
+
"periodic": (False, False, False)}
|
|
833
|
+
|
|
834
|
+
if passes:
|
|
835
|
+
assert check_conformation(data, **arguments).n_atoms == 1
|
|
836
|
+
else:
|
|
837
|
+
with pytest.raises(ConformationError, match="outside the box"):
|
|
838
|
+
check_conformation(data, **arguments)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@pytest.mark.parametrize(
|
|
842
|
+
("position", "passes"),
|
|
843
|
+
[pytest.param([7.49, 3.0, 0.0], True, id="inside-along-x"),
|
|
844
|
+
pytest.param([5.0, 5.51, 0.0], False, id="beyond-along-y")],
|
|
845
|
+
)
|
|
846
|
+
def test_check_conformation_cylinder_axis_is_the_box_center(
|
|
847
|
+
make_record: Callable[..., DataFile], position: list[float], passes: bool
|
|
848
|
+
) -> None:
|
|
849
|
+
"""The cylinder axis runs through the box center, here (5, 3), not the
|
|
850
|
+
origin: a bead 2.49 from it passes and one 2.51 from it fails."""
|
|
851
|
+
box = Box(lo=np.array([0.0, 0.0, -5.0]), hi=np.array([10.0, 6.0, 5.0]))
|
|
852
|
+
data = make_record([position], box=box)
|
|
853
|
+
arguments: dict[str, Any] = {"r0": 1.5, "ghost_cutoff": 1.42,
|
|
854
|
+
"periodic": (False, False, True),
|
|
855
|
+
"dcyl": 6.0, "dmon": 1.0}
|
|
856
|
+
|
|
857
|
+
if passes:
|
|
858
|
+
assert check_conformation(data, **arguments).max_radial == (
|
|
859
|
+
pytest.approx(2.49))
|
|
860
|
+
else:
|
|
861
|
+
with pytest.raises(ConformationError, match="farthest 2.51"):
|
|
862
|
+
check_conformation(data, **arguments)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
@pytest.mark.parametrize(
|
|
866
|
+
("radial", "passes"),
|
|
867
|
+
[pytest.param(2.49, True, id="inside"),
|
|
868
|
+
pytest.param(2.5, True, id="on-the-limit"),
|
|
869
|
+
pytest.param(2.51, False, id="beyond")],
|
|
870
|
+
)
|
|
871
|
+
def test_check_conformation_cylinder_limit(
|
|
872
|
+
make_record: Callable[..., DataFile], radial: float, passes: bool
|
|
873
|
+
) -> None:
|
|
874
|
+
"""A bead within (D - a)/2 = 2.5 of the axis passes for D = 6 and a = 1,
|
|
875
|
+
and one beyond fails: the effective diameter D - a."""
|
|
876
|
+
data = make_record([[radial, 0.0, 0.0]])
|
|
877
|
+
arguments: dict[str, Any] = {"r0": 1.5, "ghost_cutoff": 1.42,
|
|
878
|
+
"periodic": (False, False, True),
|
|
879
|
+
"dcyl": 6.0, "dmon": 1.0}
|
|
880
|
+
|
|
881
|
+
if passes:
|
|
882
|
+
assert check_conformation(data, **arguments).max_radial == radial
|
|
883
|
+
else:
|
|
884
|
+
with pytest.raises(ConformationError,
|
|
885
|
+
match=r"farther than \(D - a\)/2 = 2.5"):
|
|
886
|
+
check_conformation(data, **arguments)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def test_check_conformation_chunked_minimum_matches_brute_force(
|
|
890
|
+
make_record: Callable[..., DataFile], make_box: Callable[..., Box]
|
|
891
|
+
) -> None:
|
|
892
|
+
"""The closest non-bonded pair found chunk by chunk equals the one a
|
|
893
|
+
full distance matrix gives, minimum image included: here a pair 0.02
|
|
894
|
+
apart through the z faces, closer than any of 1498 random beads."""
|
|
895
|
+
rng = np.random.default_rng(20261102)
|
|
896
|
+
positions = np.vstack([
|
|
897
|
+
rng.uniform(-10.0, 10.0, size=(1498, 3)), # two row chunks in all
|
|
898
|
+
[[0.0, 0.0, -9.99], [0.0, 0.0, 9.99]],
|
|
899
|
+
])
|
|
900
|
+
delta = positions[:, None, :] - positions[None, :, :]
|
|
901
|
+
delta -= 20.0 * np.round(delta / 20.0)
|
|
902
|
+
distances = np.linalg.norm(delta, axis=-1)
|
|
903
|
+
distances[np.diag_indices(1500)] = np.inf
|
|
904
|
+
data = make_record(positions, box=make_box(half=10.0))
|
|
905
|
+
|
|
906
|
+
report = check_conformation(data, r0=1.5, ghost_cutoff=1.42,
|
|
907
|
+
periodic=(True, True, True),
|
|
908
|
+
min_distance=0.0)
|
|
909
|
+
|
|
910
|
+
assert report.min_pair == pytest.approx(distances.min(), rel=1e-12)
|
|
911
|
+
assert report.min_pair == pytest.approx(0.02) # the pair across the face
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def test_check_conformation_reports_every_failure_at_once(
|
|
915
|
+
make_record: Callable[..., DataFile]
|
|
916
|
+
) -> None:
|
|
917
|
+
"""One error names every failed condition: here a long bond, a close
|
|
918
|
+
non-bonded pair, and an atom outside the box."""
|
|
919
|
+
data = make_record(
|
|
920
|
+
[[0.0, 0.0, 0.0], [1.5, 0.0, 0.0], [0.0, 0.6, 0.0], [9.0, 0.0, 0.0]],
|
|
921
|
+
bonds=[(1, 2)])
|
|
922
|
+
|
|
923
|
+
with pytest.raises(ConformationError) as caught:
|
|
924
|
+
check_conformation(data, r0=1.5, ghost_cutoff=2.0,
|
|
925
|
+
periodic=(False, False, False), min_distance=1.0)
|
|
926
|
+
|
|
927
|
+
message = str(caught.value)
|
|
928
|
+
assert "1 bond at or above sqrt(0.9) R0" in message
|
|
929
|
+
assert "1 non-bonded pair closer than 1" in message
|
|
930
|
+
assert "1 atom outside the box" in message
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def test_check_conformation_report_gives_the_extremes(
|
|
934
|
+
make_record: Callable[..., DataFile]
|
|
935
|
+
) -> None:
|
|
936
|
+
"""A passing file's report holds the shortest and longest bond, the
|
|
937
|
+
closest non-bonded pair, and the largest distance from the axis."""
|
|
938
|
+
trimer = [[0.0, 0.0, 0.0], [0.97, 0.0, 0.0], [0.97, 1.0, 0.0]]
|
|
939
|
+
data = make_record(trimer, bonds=[(1, 2), (2, 3)])
|
|
940
|
+
|
|
941
|
+
report = check_conformation(data, r0=1.5, ghost_cutoff=1.42,
|
|
942
|
+
periodic=(False, False, False),
|
|
943
|
+
dcyl=6.0, dmon=1.0)
|
|
944
|
+
|
|
945
|
+
assert report.n_atoms == 3
|
|
946
|
+
assert (report.min_bond, report.max_bond) == (0.97, 1.0)
|
|
947
|
+
assert report.min_pair == pytest.approx(math.hypot(0.97, 1.0))
|
|
948
|
+
assert report.max_radial == pytest.approx(math.hypot(0.97, 1.0))
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def test_check_conformation_rejects_a_periodic_triclinic_box(
|
|
952
|
+
make_record: Callable[..., DataFile], make_box: Callable[..., Box]
|
|
953
|
+
) -> None:
|
|
954
|
+
"""A triclinic box with a periodic axis raises ``ValueError``."""
|
|
955
|
+
box = make_box(tilt=np.array([1.0, 0.0, 0.0]), triclinic=True)
|
|
956
|
+
data = make_record([[0.0, 0.0, 0.0]], box=box)
|
|
957
|
+
|
|
958
|
+
with pytest.raises(ValueError, match="orthogonal box"):
|
|
959
|
+
check_conformation(data, r0=1.5, ghost_cutoff=1.42,
|
|
960
|
+
periodic=(True, False, False))
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
@pytest.mark.parametrize(
|
|
964
|
+
("kwargs", "match"),
|
|
965
|
+
[
|
|
966
|
+
pytest.param({"r0": 0.0}, "'r0' must be positive", id="r0"),
|
|
967
|
+
pytest.param({"min_distance": -1.0}, "'min_distance' must be 0",
|
|
968
|
+
id="negative-min-distance"),
|
|
969
|
+
pytest.param({"periodic": (True, False)}, "three entries",
|
|
970
|
+
id="two-flags"),
|
|
971
|
+
pytest.param({"dcyl": 6.0}, "given together", id="dcyl-alone"),
|
|
972
|
+
pytest.param({"dcyl": 1.0, "dmon": 1.0}, "'dcyl' must exceed 'dmon'",
|
|
973
|
+
id="dcyl-not-above-dmon"),
|
|
974
|
+
],
|
|
975
|
+
)
|
|
976
|
+
def test_check_conformation_rejects_bad_arguments(
|
|
977
|
+
make_record: Callable[..., DataFile], kwargs: dict[str, Any], match: str
|
|
978
|
+
) -> None:
|
|
979
|
+
"""An argument out of range raises ``ValueError`` before any check."""
|
|
980
|
+
data = make_record([[0.0, 0.0, 0.0]])
|
|
981
|
+
arguments: dict[str, Any] = {"r0": 1.5, "ghost_cutoff": 1.42,
|
|
982
|
+
"periodic": (False, False, False)}
|
|
983
|
+
arguments.update(kwargs)
|
|
984
|
+
|
|
985
|
+
with pytest.raises(ValueError, match=match):
|
|
986
|
+
check_conformation(data, **arguments)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
# LAMMPS
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
@pytest.mark.lammps
|
|
993
|
+
@pytest.mark.parametrize(
|
|
994
|
+
("kind", "dcyl"),
|
|
995
|
+
[
|
|
996
|
+
pytest.param("helix", 0.0, id="helix"),
|
|
997
|
+
pytest.param("ring", 0.0, id="ring"),
|
|
998
|
+
pytest.param("ring_angle", 0.0, id="semiflexible-ring"),
|
|
999
|
+
pytest.param("helix", 5.2, id="helix-in-cylinder"),
|
|
1000
|
+
],
|
|
1001
|
+
)
|
|
1002
|
+
@pytest.mark.parametrize(
|
|
1003
|
+
("mode", "dt"),
|
|
1004
|
+
[
|
|
1005
|
+
pytest.param("nve", 0.005, id="nve"),
|
|
1006
|
+
pytest.param("nvelang", 0.01, id="langevin"),
|
|
1007
|
+
pytest.param("limitlang", 0.005, id="nve-limit"),
|
|
1008
|
+
],
|
|
1009
|
+
)
|
|
1010
|
+
def test_lammps_runs_from_a_built_start( # pragma: no cover - needs LAMMPS
|
|
1011
|
+
lammps_executable: str, tmp_path: Path, make_box: Callable[..., Box],
|
|
1012
|
+
kind: str, dcyl: float, mode: str, dt: float,
|
|
1013
|
+
) -> None:
|
|
1014
|
+
"""LAMMPS runs 10 000 steps of a Kremer-Grest chain from a built start
|
|
1015
|
+
of 200 beads (radius 2, pitch 2.5, the configurations of
|
|
1016
|
+
``make_starts.py``) under plain nve, Langevin dynamics, and nve/limit,
|
|
1017
|
+
with no error and no warning beyond the communication-cutoff
|
|
1018
|
+
heuristic."""
|
|
1019
|
+
if kind == "helix":
|
|
1020
|
+
positions = helix(200, radius=2.0, pitch=2.5)
|
|
1021
|
+
else:
|
|
1022
|
+
positions = plectoneme_ring(200, radius=2.0, pitch=2.5)
|
|
1023
|
+
box = make_box(half=5.0, half_height=0.5 * np.ptp(positions[:, 2]) + 2.0)
|
|
1024
|
+
data = chain_data(positions, box, ring=kind != "helix",
|
|
1025
|
+
angles=kind == "ring_angle")
|
|
1026
|
+
path = write_data(data, tmp_path / f"{kind}.data")
|
|
1027
|
+
script = Path(__file__).parent.parent / "data" / "lammps_build" / "in.kg"
|
|
1028
|
+
log = tmp_path / f"{kind}-{mode}.log"
|
|
1029
|
+
|
|
1030
|
+
subprocess.run(
|
|
1031
|
+
[lammps_executable, "-in", str(script), "-log", str(log), "-screen",
|
|
1032
|
+
"none", "-var", "data", str(path), "-var", "style",
|
|
1033
|
+
data.atom_style, "-var", "mode", mode, "-var", "dt", str(dt),
|
|
1034
|
+
"-var", "steps", "10000", "-var", "dcyl", str(dcyl)],
|
|
1035
|
+
cwd=tmp_path, capture_output=True, text=True, timeout=600,
|
|
1036
|
+
check=False)
|
|
1037
|
+
|
|
1038
|
+
lines = log.read_text().splitlines()
|
|
1039
|
+
assert any(line.startswith("Loop time") for line in lines)
|
|
1040
|
+
assert [line for line in lines if line.startswith("ERROR")] == []
|
|
1041
|
+
assert [line for line in lines if line.startswith("WARNING")
|
|
1042
|
+
and "Communication cutoff" not in line] == []
|