polyphys 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. polyphys/__init__.py +7 -0
  2. polyphys/__version__.py +22 -0
  3. polyphys/analyze/__init__.py +51 -0
  4. polyphys/analyze/clusters.py +1738 -0
  5. polyphys/analyze/correlations.py +995 -0
  6. polyphys/analyze/density.py +1138 -0
  7. polyphys/analyze/measurer.py +1338 -0
  8. polyphys/analyze/pipeline.py +1868 -0
  9. polyphys/analyze/stats.py +810 -0
  10. polyphys/analyze/sumrule.py +1159 -0
  11. polyphys/manage/__init__.py +11 -0
  12. polyphys/manage/organizer.py +2188 -0
  13. polyphys/manage/parser.py +1264 -0
  14. polyphys/manage/types.py +171 -0
  15. polyphys/manage/utils.py +777 -0
  16. polyphys/probe/__init__.py +25 -0
  17. polyphys/probe/binders.py +711 -0
  18. polyphys/probe/chain_size.py +583 -0
  19. polyphys/probe/core.py +437 -0
  20. polyphys/probe/histograms.py +863 -0
  21. polyphys/probe/pairs.py +555 -0
  22. polyphys/simulate/__init__.py +48 -0
  23. polyphys/simulate/build.py +1231 -0
  24. polyphys/simulate/core.py +237 -0
  25. polyphys/simulate/data.py +977 -0
  26. polyphys/simulate/dump.py +752 -0
  27. polyphys/simulate/log.py +788 -0
  28. polyphys/spec.py +1359 -0
  29. polyphys/tests/analyze/test_clusters.py +1058 -0
  30. polyphys/tests/analyze/test_correlations.py +708 -0
  31. polyphys/tests/analyze/test_density.py +813 -0
  32. polyphys/tests/analyze/test_measurer.py +785 -0
  33. polyphys/tests/analyze/test_pipeline.py +1881 -0
  34. polyphys/tests/analyze/test_stats.py +732 -0
  35. polyphys/tests/analyze/test_sumrule.py +917 -0
  36. polyphys/tests/conftest.py +753 -0
  37. polyphys/tests/data/lammps_build/make_starts.py +70 -0
  38. polyphys/tests/manage/test_organizer.py +1495 -0
  39. polyphys/tests/manage/test_parser.py +1222 -0
  40. polyphys/tests/manage/test_utils.py +498 -0
  41. polyphys/tests/probe/conftest.py +407 -0
  42. polyphys/tests/probe/test_binders.py +686 -0
  43. polyphys/tests/probe/test_chain_size.py +557 -0
  44. polyphys/tests/probe/test_core.py +391 -0
  45. polyphys/tests/probe/test_histograms.py +779 -0
  46. polyphys/tests/probe/test_pairs.py +475 -0
  47. polyphys/tests/simulate/test_build.py +1042 -0
  48. polyphys/tests/simulate/test_core.py +106 -0
  49. polyphys/tests/simulate/test_data.py +858 -0
  50. polyphys/tests/simulate/test_dump.py +487 -0
  51. polyphys/tests/simulate/test_log.py +667 -0
  52. polyphys/tests/strategies.py +120 -0
  53. polyphys/tests/synthetic.py +410 -0
  54. polyphys/tests/test_conftest.py +414 -0
  55. polyphys/tests/test_docs.py +82 -0
  56. polyphys/tests/test_packaging.py +97 -0
  57. polyphys/tests/test_spec.py +772 -0
  58. polyphys/tests/viz/conftest.py +32 -0
  59. polyphys/tests/viz/test_colors.py +113 -0
  60. polyphys/tests/viz/test_helpers.py +292 -0
  61. polyphys/viz/__init__.py +15 -0
  62. polyphys/viz/colors.py +204 -0
  63. polyphys/viz/helpers.py +549 -0
  64. polyphys-1.0.0.dist-info/METADATA +339 -0
  65. polyphys-1.0.0.dist-info/RECORD +69 -0
  66. polyphys-1.0.0.dist-info/WHEEL +5 -0
  67. polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
  68. polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
  69. polyphys-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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] == []