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,858 @@
|
|
|
1
|
+
"""Tests for :mod:`polyphys.simulate.data`.
|
|
2
|
+
|
|
3
|
+
Tier A tests read data files written in the test, by the shared
|
|
4
|
+
``make_lammps_data`` factory or as hand-written text, and write records built
|
|
5
|
+
in the test; each expected value is a number the test writes, or the record
|
|
6
|
+
itself after a round trip.
|
|
7
|
+
|
|
8
|
+
Tier B tests read the data files and dumps in
|
|
9
|
+
``polyphys/tests/data/lammps_dump/``, written by LAMMPS (22 Jul 2025 -
|
|
10
|
+
Update 4) from the inputs beside them. Their expected values come from the
|
|
11
|
+
LAMMPS input scripts (box, tilt, masses, charges, ring topology) or from the
|
|
12
|
+
file text read with :func:`numpy.loadtxt`, not with the module under test.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
import gzip
|
|
17
|
+
import logging
|
|
18
|
+
from operator import attrgetter
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable, Sequence
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import pytest
|
|
24
|
+
from hypothesis import HealthCheck, given, settings
|
|
25
|
+
from hypothesis import strategies as st
|
|
26
|
+
from hypothesis.extra.numpy import arrays
|
|
27
|
+
|
|
28
|
+
from polyphys.simulate.build import chain_data
|
|
29
|
+
from polyphys.simulate.core import Box
|
|
30
|
+
from polyphys.simulate.data import (
|
|
31
|
+
BrokenDataError,
|
|
32
|
+
DataFile,
|
|
33
|
+
read_data,
|
|
34
|
+
with_frame,
|
|
35
|
+
write_data,
|
|
36
|
+
)
|
|
37
|
+
from polyphys.simulate.dump import DumpFrame, read_dump_frame
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
def data_dir() -> Path:
|
|
42
|
+
"""Return the directory of the LAMMPS-written fixtures."""
|
|
43
|
+
return Path(__file__).resolve().parents[1] / "data" / "lammps_dump"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def section_rows() -> Callable[[Path, str, int], np.ndarray]:
|
|
48
|
+
"""Return a reader of one data-file section through numpy.loadtxt.
|
|
49
|
+
|
|
50
|
+
It returns the `count` lines after the line that starts with `keyword`
|
|
51
|
+
and the blank line below it, as a float array sorted by its first
|
|
52
|
+
column: an independent reading of LAMMPS output.
|
|
53
|
+
"""
|
|
54
|
+
def _read(path: Path, keyword: str, count: int) -> np.ndarray:
|
|
55
|
+
lines = path.read_text().splitlines()
|
|
56
|
+
first = next(i for i, line in enumerate(lines)
|
|
57
|
+
if line.startswith(keyword)) + 2
|
|
58
|
+
rows = np.loadtxt(lines[first:first + count], ndmin=2)
|
|
59
|
+
return rows[np.argsort(rows[:, 0])]
|
|
60
|
+
return _read
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.fixture
|
|
64
|
+
def make_record() -> Callable[..., DataFile]:
|
|
65
|
+
"""Return a factory of small records, one per atom style.
|
|
66
|
+
|
|
67
|
+
Three atoms in a box of side 10, with floats that only an exact format
|
|
68
|
+
keeps (1/3, 1e-17); molecule IDs, charges, two bonds, and one angle as
|
|
69
|
+
the style allows. Keyword arguments replace fields.
|
|
70
|
+
"""
|
|
71
|
+
def _make(atom_style: str = "bond", **overrides: Any) -> DataFile:
|
|
72
|
+
fields: dict[str, Any] = {
|
|
73
|
+
"atom_style": atom_style,
|
|
74
|
+
"box": Box(lo=np.zeros(3), hi=np.full(3, 10.0)),
|
|
75
|
+
"ids": np.array([1, 2, 3]),
|
|
76
|
+
"types": np.array([1, 1, 2]),
|
|
77
|
+
"positions": np.array([[0.1, 1 / 3, 2.0], [1e-17, 2.5e-5, 3.0],
|
|
78
|
+
[9.75, 5.0, 7.125]]),
|
|
79
|
+
"n_atom_types": 2,
|
|
80
|
+
}
|
|
81
|
+
if atom_style != "atomic":
|
|
82
|
+
fields.update(mol_ids=np.array([1, 1, 2]),
|
|
83
|
+
bonds=np.array([[1, 1, 2], [1, 2, 3]]),
|
|
84
|
+
n_bond_types=1)
|
|
85
|
+
if atom_style in ("angle", "molecular", "full"):
|
|
86
|
+
fields.update(angles=np.array([[1, 1, 2, 3]]), n_angle_types=1)
|
|
87
|
+
if atom_style == "full":
|
|
88
|
+
fields["charges"] = np.array([0.25, -0.5, 0.25])
|
|
89
|
+
fields.update(overrides)
|
|
90
|
+
return DataFile(**fields)
|
|
91
|
+
return _make
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.fixture
|
|
95
|
+
def data_text() -> Callable[..., str]:
|
|
96
|
+
"""Return a factory for the text of a hand-written data file.
|
|
97
|
+
|
|
98
|
+
The default is two bonded atoms in atom style ``bond``; `header` and
|
|
99
|
+
`sections` (pairs of a keyword line and its entry lines) replace it.
|
|
100
|
+
"""
|
|
101
|
+
def _make(
|
|
102
|
+
header: Sequence[str] = ("2 atoms", "1 atom types", "1 bonds",
|
|
103
|
+
"1 bond types", "0 5 xlo xhi", "0 5 ylo yhi",
|
|
104
|
+
"0 5 zlo zhi"),
|
|
105
|
+
sections: Sequence[tuple[str, Sequence[str]]] = (
|
|
106
|
+
("Atoms # bond", ("1 1 1 2.0 2.5 2.5", "2 1 1 3.0 2.5 2.5")),
|
|
107
|
+
("Bonds", ("1 1 1 2",)),
|
|
108
|
+
),
|
|
109
|
+
) -> str:
|
|
110
|
+
lines = ["hand-written data file", "", *header, ""]
|
|
111
|
+
for keyword, rows in sections:
|
|
112
|
+
lines += [keyword, "", *rows, ""]
|
|
113
|
+
return "\n".join(lines)
|
|
114
|
+
return _make
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.fixture
|
|
118
|
+
def write_text(tmp_path: Path) -> Callable[..., Path]:
|
|
119
|
+
"""Return a factory that writes text to a file under ``tmp_path``."""
|
|
120
|
+
def _write(text: str, name: str = "system.data") -> Path:
|
|
121
|
+
path = tmp_path / name
|
|
122
|
+
path.write_text(text)
|
|
123
|
+
return path
|
|
124
|
+
return _write
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.fixture
|
|
128
|
+
def record_fields() -> Callable[[DataFile], dict[str, Any]]:
|
|
129
|
+
"""Return a function that lists a record's fields as plain values."""
|
|
130
|
+
def _plain(value: Any) -> Any:
|
|
131
|
+
if isinstance(value, np.ndarray):
|
|
132
|
+
return value.tolist()
|
|
133
|
+
if isinstance(value, Box):
|
|
134
|
+
return {name: _plain(getattr(value, name))
|
|
135
|
+
for name in ("lo", "hi", "tilt", "boundary", "triclinic")}
|
|
136
|
+
return value
|
|
137
|
+
|
|
138
|
+
def _fields(record: DataFile) -> dict[str, Any]:
|
|
139
|
+
return {field.name: _plain(getattr(record, field.name))
|
|
140
|
+
for field in dataclasses.fields(record)}
|
|
141
|
+
return _fields
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# read_data ------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.mark.parametrize(
|
|
148
|
+
("atom_style", "bonds", "angles", "mol_ids"),
|
|
149
|
+
[
|
|
150
|
+
pytest.param("atomic", [], [], None, id="atomic"),
|
|
151
|
+
pytest.param("bond", [[1, 1, 2], [1, 2, 3], [1, 3, 4]], [],
|
|
152
|
+
[1, 1, 2, 2], id="bond"),
|
|
153
|
+
pytest.param("angle", [[1, 1, 2], [1, 2, 3], [1, 3, 4]],
|
|
154
|
+
[[1, 1, 2, 3], [1, 2, 3, 4]], [1, 1, 2, 2], id="angle"),
|
|
155
|
+
],
|
|
156
|
+
)
|
|
157
|
+
def test_read_data_reads_the_atoms_and_topology_written(
|
|
158
|
+
make_lammps_data, record_fields, atom_style, bonds, angles, mol_ids):
|
|
159
|
+
"""Positions, types, molecule IDs, bonds, and angles read as written."""
|
|
160
|
+
positions = np.array([[1.5, 2.0, 2.5], [2.5, 2.0, 2.5],
|
|
161
|
+
[3.5, 2.0, 2.5], [4.5, 2.0, 2.5]])
|
|
162
|
+
path = make_lammps_data(atom_style=atom_style, positions=positions,
|
|
163
|
+
atom_types=[1, 2, 1, 1], mol_ids=[1, 1, 2, 2])
|
|
164
|
+
|
|
165
|
+
fields = record_fields(read_data(path))
|
|
166
|
+
|
|
167
|
+
assert fields["positions"] == positions.tolist()
|
|
168
|
+
assert fields["types"] == [1, 2, 1, 1]
|
|
169
|
+
assert fields["mol_ids"] == mol_ids
|
|
170
|
+
assert fields["bonds"] == bonds
|
|
171
|
+
assert fields["angles"] == angles
|
|
172
|
+
assert fields["masses"] == [1.0, 1.0]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@pytest.mark.parametrize(
|
|
176
|
+
("name", "n_atoms", "x_column"),
|
|
177
|
+
[
|
|
178
|
+
pytest.param("atomic.data", 2, 2, id="atomic"),
|
|
179
|
+
pytest.param("ring.bond.data", 8, 3, id="bond"),
|
|
180
|
+
pytest.param("ring.angle.data", 8, 3, id="angle"),
|
|
181
|
+
pytest.param("ring.molecular.data", 8, 3, id="molecular"),
|
|
182
|
+
pytest.param("ring.full.data", 8, 4, id="full"),
|
|
183
|
+
pytest.param("triclinic.data", 8, 3, id="triclinic"),
|
|
184
|
+
],
|
|
185
|
+
)
|
|
186
|
+
def test_read_data_reads_lammps_atoms_and_image_flags(
|
|
187
|
+
data_dir, section_rows, name, n_atoms, x_column):
|
|
188
|
+
"""Atoms of a LAMMPS write_data file read back, sorted by atom ID."""
|
|
189
|
+
path = data_dir / name
|
|
190
|
+
rows = section_rows(path, "Atoms", n_atoms)
|
|
191
|
+
|
|
192
|
+
data = read_data(path)
|
|
193
|
+
|
|
194
|
+
np.testing.assert_array_equal(data.ids, rows[:, 0])
|
|
195
|
+
np.testing.assert_array_equal(data.positions,
|
|
196
|
+
rows[:, x_column:x_column + 3])
|
|
197
|
+
np.testing.assert_array_equal(data.image_flags, rows[:, -3:])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_read_data_reads_a_gzipped_file(make_lammps_data, tmp_path):
|
|
201
|
+
"""A data file whose name ends in .gz reads like its plain text."""
|
|
202
|
+
plain = make_lammps_data(atom_style="bond")
|
|
203
|
+
gzipped = tmp_path / "chain.data.gz"
|
|
204
|
+
with gzip.open(gzipped, "wt") as stream:
|
|
205
|
+
stream.write(plain.read_text())
|
|
206
|
+
|
|
207
|
+
data = read_data(gzipped)
|
|
208
|
+
|
|
209
|
+
np.testing.assert_array_equal(data.positions, read_data(plain).positions)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_read_data_orders_velocities_by_atom_id(data_dir, section_rows):
|
|
213
|
+
"""Velocities, listed by LAMMPS in its own atom order, follow atom ID."""
|
|
214
|
+
path = data_dir / "ring.bond.data"
|
|
215
|
+
rows = section_rows(path, "Velocities", 8)
|
|
216
|
+
|
|
217
|
+
data = read_data(path)
|
|
218
|
+
|
|
219
|
+
np.testing.assert_array_equal(data.velocities, rows[:, 1:])
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.parametrize(
|
|
223
|
+
("name", "field", "expected"),
|
|
224
|
+
[
|
|
225
|
+
# in.atomic: mass 1 1.0 and mass 2 2.0.
|
|
226
|
+
pytest.param("atomic.data", "masses", [1.0, 2.0], id="masses"),
|
|
227
|
+
# in.ring with style full: set type 1 charge 0.25.
|
|
228
|
+
pytest.param("ring.full.data", "charges", [0.25] * 8, id="charges"),
|
|
229
|
+
# in.triclinic: region prism 0 6 0 6 0 6 1.0 0.5 -0.5.
|
|
230
|
+
pytest.param("triclinic.data", "box.tilt", [1.0, 0.5, -0.5],
|
|
231
|
+
id="tilt"),
|
|
232
|
+
pytest.param("triclinic.data", "box.hi", [6.0, 6.0, 6.0], id="hi"),
|
|
233
|
+
],
|
|
234
|
+
)
|
|
235
|
+
def test_read_data_reads_values_set_by_the_input_script(
|
|
236
|
+
data_dir, name, field, expected):
|
|
237
|
+
"""Masses, charges, and the box equal those the LAMMPS script set."""
|
|
238
|
+
data = read_data(data_dir / name)
|
|
239
|
+
|
|
240
|
+
np.testing.assert_array_equal(attrgetter(field)(data), expected)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@pytest.mark.parametrize(
|
|
244
|
+
("name", "field", "expected"),
|
|
245
|
+
[
|
|
246
|
+
# ring8.mol: bond i joins beads i and i + 1, and bead 8 joins bead 1.
|
|
247
|
+
pytest.param("ring.bond.data", "bonds",
|
|
248
|
+
{(1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (6, 7), (7, 8),
|
|
249
|
+
(8, 1)}, id="bonds"),
|
|
250
|
+
# ring8_angles.mol: angle i is beads i, i + 1, i + 2, cyclically.
|
|
251
|
+
pytest.param("ring.angle.data", "angles",
|
|
252
|
+
{(1, 2, 3), (2, 3, 4), (3, 4, 5), (4, 5, 6), (5, 6, 7),
|
|
253
|
+
(6, 7, 8), (7, 8, 1), (8, 1, 2)}, id="angles"),
|
|
254
|
+
],
|
|
255
|
+
)
|
|
256
|
+
def test_read_data_reads_the_ring_topology(data_dir, name, field, expected):
|
|
257
|
+
"""The bonds and angles are those of the ring molecule LAMMPS built."""
|
|
258
|
+
data = read_data(data_dir / name)
|
|
259
|
+
|
|
260
|
+
members = {tuple(row) for row in getattr(data, field)[:, 1:].tolist()}
|
|
261
|
+
|
|
262
|
+
assert members == expected
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_read_data_accepts_any_header_order_spacing_and_comments(
|
|
266
|
+
data_text, write_text):
|
|
267
|
+
"""Header lines in any order, runs of blanks, and comments are read."""
|
|
268
|
+
text = data_text(
|
|
269
|
+
header=("0 5\tzlo zhi", "1 bond types # one", "0 5 xlo xhi",
|
|
270
|
+
"2 atoms", "1 bonds", "0 5 ylo yhi", "1 atom types",
|
|
271
|
+
"2 extra bond per atom"),
|
|
272
|
+
sections=(("Atoms # bond", ("2 1 1 3.0 2.5 2.5 # second",
|
|
273
|
+
"1\t1 1 2.0 2.5 2.5")),
|
|
274
|
+
("Bonds", ("1 1 1 2",))),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
data = read_data(write_text(text))
|
|
278
|
+
|
|
279
|
+
np.testing.assert_array_equal(data.positions, [[2.0, 2.5, 2.5],
|
|
280
|
+
[3.0, 2.5, 2.5]])
|
|
281
|
+
assert data.bonds.tolist() == [[1, 1, 2]]
|
|
282
|
+
np.testing.assert_array_equal(data.box.hi, [5.0, 5.0, 5.0])
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_read_data_takes_the_style_from_its_argument(data_text, write_text):
|
|
286
|
+
"""An Atoms line without a style comment uses the style passed in."""
|
|
287
|
+
text = data_text(sections=(
|
|
288
|
+
("Atoms", ("1 1 1 2.0 2.5 2.5", "2 1 1 3.0 2.5 2.5")),
|
|
289
|
+
("Bonds", ("1 1 1 2",)),
|
|
290
|
+
))
|
|
291
|
+
|
|
292
|
+
data = read_data(write_text(text), atom_style="bond")
|
|
293
|
+
|
|
294
|
+
assert data.atom_style == "bond"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@pytest.mark.parametrize(
|
|
298
|
+
("atoms", "atom_style", "match"),
|
|
299
|
+
[
|
|
300
|
+
pytest.param("Atoms", None, "names no atom style", id="no-style"),
|
|
301
|
+
pytest.param("Atoms # bond", "angle", "'angle' but the file says "
|
|
302
|
+
"'bond'", id="disagree"),
|
|
303
|
+
pytest.param("Atoms # sphere", None, "'sphere' is not supported",
|
|
304
|
+
id="unsupported"),
|
|
305
|
+
],
|
|
306
|
+
)
|
|
307
|
+
def test_read_data_raises_without_one_supported_atom_style(
|
|
308
|
+
data_text, write_text, atoms, atom_style, match):
|
|
309
|
+
"""A missing, conflicting, or unsupported atom style raises."""
|
|
310
|
+
text = data_text(sections=(
|
|
311
|
+
(atoms, ("1 1 1 2.0 2.5 2.5", "2 1 1 3.0 2.5 2.5")),
|
|
312
|
+
("Bonds", ("1 1 1 2",)),
|
|
313
|
+
))
|
|
314
|
+
|
|
315
|
+
with pytest.raises(BrokenDataError, match=match):
|
|
316
|
+
read_data(write_text(text), atom_style=atom_style)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_read_data_skips_coefficient_sections(data_text, write_text, caplog):
|
|
320
|
+
"""Sections whose names end in Coeffs are skipped and logged."""
|
|
321
|
+
text = data_text(sections=(
|
|
322
|
+
("Pair Coeffs # lj/cut", ("1 1.0 1.0",)),
|
|
323
|
+
("Atoms # bond", ("1 1 1 2.0 2.5 2.5", "2 1 1 3.0 2.5 2.5")),
|
|
324
|
+
("Bond Coeffs # fene", ("1 30.0 1.5 1.0 1.0",)),
|
|
325
|
+
("Bonds", ("1 1 1 2",)),
|
|
326
|
+
))
|
|
327
|
+
|
|
328
|
+
with caplog.at_level(logging.DEBUG, logger="polyphys.simulate.data"):
|
|
329
|
+
data = read_data(write_text(text))
|
|
330
|
+
|
|
331
|
+
assert data.n_bonds == 1
|
|
332
|
+
assert "skipped section 'Pair Coeffs'" in caplog.text
|
|
333
|
+
assert "skipped section 'Bond Coeffs'" in caplog.text
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@pytest.mark.parametrize(
|
|
337
|
+
("parts", "match"),
|
|
338
|
+
[
|
|
339
|
+
pytest.param({"header": ("2 atoms", "1 atom types", "1 dihedrals",
|
|
340
|
+
"0 5 xlo xhi", "0 5 ylo yhi",
|
|
341
|
+
"0 5 zlo zhi")},
|
|
342
|
+
"dihedrals are not supported", id="dihedrals"),
|
|
343
|
+
pytest.param({"header": ("2 atoms", "1 atom types", "1 bonds",
|
|
344
|
+
"1 bond types", "6 0 0 avec", "0 5 xlo xhi",
|
|
345
|
+
"0 5 ylo yhi", "0 5 zlo zhi")},
|
|
346
|
+
"general triclinic", id="general-triclinic"),
|
|
347
|
+
pytest.param({"sections": (
|
|
348
|
+
("Atoms # bond", ("1 1 1 2.0 2.5 2.5", "2 1 1 3.0 2.5 2.5")),
|
|
349
|
+
("Bonds", ("1 1 1 2",)), ("Ellipsoids", ("1 1.0 1.0 1.0",)))},
|
|
350
|
+
"section 'Ellipsoids' is not supported", id="ellipsoids"),
|
|
351
|
+
],
|
|
352
|
+
)
|
|
353
|
+
def test_read_data_rejects_unsupported_features(
|
|
354
|
+
data_text, write_text, parts, match):
|
|
355
|
+
"""Dihedrals, general triclinic boxes, and other sections raise."""
|
|
356
|
+
path = write_text(data_text(**parts))
|
|
357
|
+
|
|
358
|
+
with pytest.raises(BrokenDataError, match=match):
|
|
359
|
+
read_data(path)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@pytest.mark.parametrize(
|
|
363
|
+
("parts", "match"),
|
|
364
|
+
[
|
|
365
|
+
pytest.param({"sections": (("Bonds", ("1 1 1 2",)),)},
|
|
366
|
+
"no Atoms section", id="no-atoms"),
|
|
367
|
+
pytest.param({"sections": (
|
|
368
|
+
("Atoms # bond", ("1 1 1 2.0 2.5 2.5",)),
|
|
369
|
+
("Bonds", ("1 1 1 2",)))},
|
|
370
|
+
"Atoms has 1 entries, the header says 2", id="atom-count"),
|
|
371
|
+
pytest.param({"sections": (
|
|
372
|
+
("Atoms # bond", ("1 1 1 2.0 2.5 2.5", "2 1 1 3.0 2.5 2.5")),)},
|
|
373
|
+
"1 bonds but no Bonds", id="no-bonds-section"),
|
|
374
|
+
pytest.param({"header": ("2 atoms", "1 atom types", "0 bonds",
|
|
375
|
+
"1 bond types", "1 angles", "1 angle types",
|
|
376
|
+
"0 5 xlo xhi", "0 5 ylo yhi", "0 5 zlo zhi"),
|
|
377
|
+
"sections": (("Atoms # angle", ("1 1 1 2.0 2.5 2.5",
|
|
378
|
+
"2 1 1 3.0 2.5 2.5")),)},
|
|
379
|
+
"1 angles but no Angles", id="no-angles-section"),
|
|
380
|
+
pytest.param({"sections": (
|
|
381
|
+
("Atoms # bond", ("1 1 1 2.0 2.5 2.5", "2 1 1 3.0 2.5 2.5")),
|
|
382
|
+
("Bonds", ("1 1 1 3",)))},
|
|
383
|
+
r"system.data: a bond names atom IDs that do not exist: \[3\]",
|
|
384
|
+
id="bond-to-missing-atom"),
|
|
385
|
+
pytest.param({"sections": (
|
|
386
|
+
("Atoms # bond", ("1 1 1 2.0 2.5 2.5 0 0 0",
|
|
387
|
+
"2 1 1 3.0 2.5 2.5")),
|
|
388
|
+
("Bonds", ("1 1 1 2",)))},
|
|
389
|
+
"the same on every line", id="image-flags-on-one-line"),
|
|
390
|
+
pytest.param({"sections": (
|
|
391
|
+
("Atoms # bond", ("1 1 1 2.0 2.5 2.5", "2 1 1 3.0 2.5 2.5")),
|
|
392
|
+
("Bonds", ("1 1 1 2",)), ("Bonds", ("1 1 1 2",)))},
|
|
393
|
+
"section 'Bonds' repeats", id="repeated-section"),
|
|
394
|
+
pytest.param({"sections": (
|
|
395
|
+
("Atoms # bond", ("1 1 1 2.0 2.5 2.5", "2 1 1 3.0 2.5 2.5")),
|
|
396
|
+
("Bonds", ("1 1 1 2",)),
|
|
397
|
+
("Velocities", ("1 0 0 0", "3 0 0 0")))},
|
|
398
|
+
"Velocities atom IDs differ", id="velocity-ids"),
|
|
399
|
+
pytest.param({"sections": (
|
|
400
|
+
("Masses", ("2 1.0",)),
|
|
401
|
+
("Atoms # bond", ("1 1 1 2.0 2.5 2.5", "2 1 1 3.0 2.5 2.5")),
|
|
402
|
+
("Bonds", ("1 1 1 2",)))},
|
|
403
|
+
"each atom type from 1 to 1 once", id="masses"),
|
|
404
|
+
pytest.param({"sections": (
|
|
405
|
+
("Atoms # bond", ("1 1 1 2.0 2.5 2.5", "2 1 1 3.0 2.5 2.5")),
|
|
406
|
+
("Bonds", ("1 1 1 2.5",)))},
|
|
407
|
+
"'Bonds' must hold integers", id="bond-not-integer"),
|
|
408
|
+
pytest.param({"header": ("2 atoms", "1 atom types", "1 bonds",
|
|
409
|
+
"1 bond types", "0 5 xlo xhi",
|
|
410
|
+
"0 5 ylo yhi")},
|
|
411
|
+
r"no \['zlo'\] bounds", id="no-z-bounds"),
|
|
412
|
+
pytest.param({"header": ("2 atoms", "1 atom types", "1 bonds",
|
|
413
|
+
"1 bond types", "5 0 xlo xhi",
|
|
414
|
+
"0 5 ylo yhi", "0 5 zlo zhi")},
|
|
415
|
+
"must be below", id="xlo-above-xhi"),
|
|
416
|
+
pytest.param({"header": ("two atoms", "1 atom types")},
|
|
417
|
+
"invalid literal for int", id="count-not-integer"),
|
|
418
|
+
pytest.param({"header": ("2 atoms", "1 atom types", "1 bonds",
|
|
419
|
+
"1 bond types", "0 5 xlo xhi",
|
|
420
|
+
"0 5 ylo yhi", "0 5 zlo zhi", "1.0 atoms")},
|
|
421
|
+
"invalid literal", id="count-not-whole"),
|
|
422
|
+
pytest.param({"sections": (
|
|
423
|
+
("Atoms # bond", ("1 1 1 2.0 2.5 2.5", "2 1 1 3.0 2.5 abc")),
|
|
424
|
+
("Bonds", ("1 1 1 2",)))},
|
|
425
|
+
"not a number", id="atom-value-not-number"),
|
|
426
|
+
],
|
|
427
|
+
)
|
|
428
|
+
def test_read_data_raises_on_a_malformed_file(
|
|
429
|
+
data_text, write_text, parts, match):
|
|
430
|
+
"""A file that breaks the format raises BrokenDataError naming it."""
|
|
431
|
+
path = write_text(data_text(**parts))
|
|
432
|
+
|
|
433
|
+
with pytest.raises(BrokenDataError, match=match):
|
|
434
|
+
read_data(path)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_read_data_reads_an_empty_section_as_none(data_text, write_text):
|
|
438
|
+
"""A section keyword with no entries, as for zero bonds, reads as empty."""
|
|
439
|
+
text = data_text(
|
|
440
|
+
header=("2 atoms", "1 atom types", "0 bonds", "0 bond types",
|
|
441
|
+
"0 5 xlo xhi", "0 5 ylo yhi", "0 5 zlo zhi"),
|
|
442
|
+
sections=(("Atoms # bond", ("1 1 1 2.0 2.5 2.5", "2 1 1 3.0 2.5 2.5")),
|
|
443
|
+
("Bonds", ())))
|
|
444
|
+
|
|
445
|
+
data = read_data(write_text(text))
|
|
446
|
+
|
|
447
|
+
assert data.bonds.shape == (0, 3)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@pytest.mark.parametrize(
|
|
451
|
+
("text", "match"),
|
|
452
|
+
[
|
|
453
|
+
pytest.param("title\n\n2 atoms\n0 5 xlo xhi\n", "no body",
|
|
454
|
+
id="header-only"),
|
|
455
|
+
pytest.param("title\n\n2 atoms\n0 5 xlo xhi\n0 5 ylo yhi\n"
|
|
456
|
+
"0 5 zlo zhi\n1 2 3 4 5\n",
|
|
457
|
+
"an entry before any section", id="entry-first"),
|
|
458
|
+
],
|
|
459
|
+
)
|
|
460
|
+
def test_read_data_raises_on_a_file_without_a_body(write_text, text, match):
|
|
461
|
+
"""A file with no section keyword raises BrokenDataError."""
|
|
462
|
+
with pytest.raises(BrokenDataError, match=match):
|
|
463
|
+
read_data(write_text(text))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# write_data -----------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def test_write_data_declares_bonds_with_their_own_keywords(
|
|
470
|
+
make_record, tmp_path):
|
|
471
|
+
"""The header counts bonds as bonds and bond types, not as atoms."""
|
|
472
|
+
record = make_record("bond")
|
|
473
|
+
|
|
474
|
+
text = write_data(record, tmp_path / "out.data").read_text()
|
|
475
|
+
|
|
476
|
+
assert text.splitlines()[2:6] == [
|
|
477
|
+
"3 atoms", "2 atom types", "2 bonds", "1 bond types"]
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def test_write_data_counts_every_bond_of_a_ring(make_record, tmp_path):
|
|
481
|
+
"""A ring of four atoms declares and lists its four bonds."""
|
|
482
|
+
ring = make_record(
|
|
483
|
+
"bond", ids=np.arange(1, 5), types=np.ones(4, dtype=int),
|
|
484
|
+
mol_ids=np.ones(4, dtype=int), n_atom_types=1,
|
|
485
|
+
positions=np.array([[1.0, 1.0, 1.0], [2.0, 1.0, 1.0],
|
|
486
|
+
[2.0, 2.0, 1.0], [1.0, 2.0, 1.0]]),
|
|
487
|
+
bonds=np.array([[1, 1, 2], [1, 2, 3], [1, 3, 4], [1, 4, 1]]))
|
|
488
|
+
|
|
489
|
+
lines = write_data(ring, tmp_path / "ring.data").read_text().splitlines()
|
|
490
|
+
|
|
491
|
+
assert "4 bonds" in lines
|
|
492
|
+
assert lines[lines.index("Bonds") + 2:] == [
|
|
493
|
+
"1 1 1 2", "2 1 2 3", "3 1 3 4", "4 1 4 1"]
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@pytest.mark.parametrize(
|
|
497
|
+
"atom_style", ["atomic", "bond", "angle", "molecular", "full"])
|
|
498
|
+
def test_write_data_round_trips_exactly(
|
|
499
|
+
make_record, record_fields, tmp_path, atom_style):
|
|
500
|
+
"""Writing and reading back returns every field unchanged."""
|
|
501
|
+
record = make_record(
|
|
502
|
+
atom_style, masses=np.array([1.0, 1 / 7]),
|
|
503
|
+
image_flags=np.array([[0, 0, 0], [-1, 2, 0], [0, 0, 1]]),
|
|
504
|
+
velocities=np.array([[0.1, -0.2, 1e-300], [0.0, 1.0, 2.0],
|
|
505
|
+
[3.5, -1 / 3, 0.0]]))
|
|
506
|
+
|
|
507
|
+
back = read_data(write_data(record, tmp_path / "out.data"))
|
|
508
|
+
|
|
509
|
+
assert record_fields(back) == record_fields(record)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def test_write_data_round_trips_a_ring_with_angles(
|
|
513
|
+
make_record, record_fields, tmp_path):
|
|
514
|
+
"""A ring's closing bond and two wrap-around angles read back."""
|
|
515
|
+
t = np.linspace(0, 2 * np.pi, 6, endpoint=False)
|
|
516
|
+
ring = make_record(
|
|
517
|
+
"angle", ids=np.arange(1, 7), types=np.ones(6, dtype=int),
|
|
518
|
+
mol_ids=np.ones(6, dtype=int), n_atom_types=1,
|
|
519
|
+
positions=np.column_stack([5 + np.cos(t), 5 + np.sin(t),
|
|
520
|
+
np.full(6, 5.0)]),
|
|
521
|
+
bonds=np.array([[1, 1, 2], [1, 2, 3], [1, 3, 4], [1, 4, 5],
|
|
522
|
+
[1, 5, 6], [1, 6, 1]]),
|
|
523
|
+
angles=np.array([[1, 1, 2, 3], [1, 2, 3, 4], [1, 3, 4, 5],
|
|
524
|
+
[1, 4, 5, 6], [1, 5, 6, 1], [1, 6, 1, 2]]))
|
|
525
|
+
|
|
526
|
+
back = read_data(write_data(ring, tmp_path / "ring.data"))
|
|
527
|
+
|
|
528
|
+
assert record_fields(back) == record_fields(ring)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@pytest.mark.parametrize(
|
|
532
|
+
("image_flags", "n_fields"),
|
|
533
|
+
[
|
|
534
|
+
pytest.param(np.array([[1, 0, -1], [0, 0, 0], [0, 0, 0]]), 9,
|
|
535
|
+
id="given"),
|
|
536
|
+
pytest.param(None, 6, id="none"),
|
|
537
|
+
],
|
|
538
|
+
)
|
|
539
|
+
def test_write_data_writes_image_flags_only_when_given(
|
|
540
|
+
make_record, tmp_path, image_flags, n_fields):
|
|
541
|
+
"""Atom lines end with image flags only when the record has them."""
|
|
542
|
+
record = make_record("bond", image_flags=image_flags)
|
|
543
|
+
|
|
544
|
+
lines = write_data(record, tmp_path / "out.data").read_text().splitlines()
|
|
545
|
+
|
|
546
|
+
assert len(lines[lines.index("Atoms # bond") + 2].split()) == n_fields
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@pytest.mark.parametrize(
|
|
550
|
+
("velocities", "written"),
|
|
551
|
+
[
|
|
552
|
+
pytest.param(np.zeros((3, 3)), True, id="given"),
|
|
553
|
+
pytest.param(None, False, id="none"),
|
|
554
|
+
],
|
|
555
|
+
)
|
|
556
|
+
def test_write_data_writes_velocities_only_when_given(
|
|
557
|
+
make_record, tmp_path, velocities, written):
|
|
558
|
+
"""The Velocities section appears only when the record has velocities."""
|
|
559
|
+
record = make_record("bond", velocities=velocities)
|
|
560
|
+
|
|
561
|
+
lines = write_data(record, tmp_path / "out.data").read_text().splitlines()
|
|
562
|
+
|
|
563
|
+
assert ("Velocities" in lines) is written
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def test_write_data_writes_no_masses_when_none(make_record, tmp_path):
|
|
567
|
+
"""Without masses there is no Masses section; the type count stays."""
|
|
568
|
+
record = make_record("bond", types=np.ones(3, dtype=int), masses=None)
|
|
569
|
+
|
|
570
|
+
lines = write_data(record, tmp_path / "out.data").read_text().splitlines()
|
|
571
|
+
|
|
572
|
+
assert "Masses" not in lines
|
|
573
|
+
assert "2 atom types" in lines
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def test_write_data_writes_and_reads_tilt_factors(make_record, tmp_path):
|
|
577
|
+
"""A triclinic record gets an xy xz yz line that reads back exactly."""
|
|
578
|
+
record = make_record("bond", box=Box(
|
|
579
|
+
lo=np.zeros(3), hi=np.full(3, 10.0), tilt=np.array([1.0, 0.5, -0.5]),
|
|
580
|
+
triclinic=True))
|
|
581
|
+
|
|
582
|
+
path = write_data(record, tmp_path / "out.data")
|
|
583
|
+
|
|
584
|
+
assert "1.0 0.5 -0.5 xy xz yz" in path.read_text().splitlines()
|
|
585
|
+
np.testing.assert_array_equal(read_data(path).box.tilt,
|
|
586
|
+
[1.0, 0.5, -0.5])
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def test_write_data_rejects_a_title_with_a_line_break(make_record, tmp_path):
|
|
590
|
+
"""A title of more than one line raises ValueError."""
|
|
591
|
+
with pytest.raises(ValueError, match="one line"):
|
|
592
|
+
write_data(make_record(), tmp_path / "out.data", title="a\nb")
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
# DataFile -------------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
@pytest.mark.parametrize(
|
|
599
|
+
("atom_style", "overrides", "match"),
|
|
600
|
+
[
|
|
601
|
+
pytest.param("sphere", {}, "'atom_style' must be one of",
|
|
602
|
+
id="unknown-style"),
|
|
603
|
+
pytest.param("bond", {"positions": np.zeros((2, 3))},
|
|
604
|
+
r"'positions' must have shape \(3, 3\)",
|
|
605
|
+
id="positions-shape"),
|
|
606
|
+
pytest.param("bond", {"bonds": np.array([[1, 1, 4]])},
|
|
607
|
+
r"names atom IDs that do not exist: \[4\]",
|
|
608
|
+
id="bond-to-missing-atom"),
|
|
609
|
+
pytest.param("angle", {"angles": np.array([[1, 1, 2, 9]])},
|
|
610
|
+
r"names atom IDs that do not exist: \[9\]",
|
|
611
|
+
id="angle-to-missing-atom"),
|
|
612
|
+
pytest.param("bond", {"ids": np.array([1, 1, 2])}, "unique",
|
|
613
|
+
id="repeated-id"),
|
|
614
|
+
pytest.param("bond", {"ids": np.array([0, 1, 2]),
|
|
615
|
+
"bonds": np.array([[1, 1, 2]])},
|
|
616
|
+
"1 or more", id="id-zero"),
|
|
617
|
+
pytest.param("bond", {"types": np.array([1, 2, 3])},
|
|
618
|
+
"atom types must be from 1 to 2", id="type-range"),
|
|
619
|
+
pytest.param("bond", {"types": np.array([1.0, 1.5, 2.0])},
|
|
620
|
+
"'types' must hold integers", id="type-not-integer"),
|
|
621
|
+
pytest.param("bond", {"types": np.array(["a", "b", "c"])},
|
|
622
|
+
"'types' must hold integers", id="type-not-number"),
|
|
623
|
+
pytest.param("bond", {"mol_ids": None}, "needs 'mol_ids'",
|
|
624
|
+
id="no-mol-ids"),
|
|
625
|
+
pytest.param("atomic", {"mol_ids": np.ones(3, dtype=int)},
|
|
626
|
+
"has no 'mol_ids'", id="mol-ids-in-atomic"),
|
|
627
|
+
pytest.param("full", {"charges": None}, "needs 'charges'",
|
|
628
|
+
id="no-charges"),
|
|
629
|
+
pytest.param("atomic", {"bonds": np.array([[1, 1, 2]]),
|
|
630
|
+
"n_bond_types": 1}, "has no bonds",
|
|
631
|
+
id="bonds-in-atomic"),
|
|
632
|
+
pytest.param("bond", {"angles": np.array([[1, 1, 2, 3]]),
|
|
633
|
+
"n_angle_types": 1}, "has no angles",
|
|
634
|
+
id="angles-in-bond"),
|
|
635
|
+
pytest.param("bond", {"n_bond_types": 0},
|
|
636
|
+
"'n_bond_types' must be 1 or more", id="no-bond-types"),
|
|
637
|
+
pytest.param("bond", {"masses": np.array([1.0])},
|
|
638
|
+
r"'masses' must have shape \(2,\)", id="masses-shape"),
|
|
639
|
+
],
|
|
640
|
+
)
|
|
641
|
+
def test_data_file_rejects_an_invalid_record(
|
|
642
|
+
make_record, atom_style, overrides, match):
|
|
643
|
+
"""A record that LAMMPS could not read raises ValueError."""
|
|
644
|
+
with pytest.raises(ValueError, match=match):
|
|
645
|
+
make_record(atom_style, **overrides)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def test_data_file_rejects_a_box_that_is_not_a_box(make_record):
|
|
649
|
+
"""A box given as bounds instead of a Box raises TypeError."""
|
|
650
|
+
with pytest.raises(TypeError, match="'box' must be a Box"):
|
|
651
|
+
make_record("bond", box=(np.zeros(3), np.full(3, 10.0)))
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@pytest.mark.parametrize(
|
|
655
|
+
("image_flags", "expected_x"),
|
|
656
|
+
[
|
|
657
|
+
# Box of side 10: each image flag along x moves the atom by 10.
|
|
658
|
+
pytest.param(np.array([[-1, 0, 0], [0, 0, 0], [2, 0, 0]]),
|
|
659
|
+
[-9.9, 0.0, 29.75], id="image-flags"),
|
|
660
|
+
pytest.param(None, [0.1, 1e-17, 9.75], id="none"),
|
|
661
|
+
],
|
|
662
|
+
)
|
|
663
|
+
def test_data_file_unwraps_positions_by_image_flags(
|
|
664
|
+
make_record, image_flags, expected_x):
|
|
665
|
+
"""Unwrapped x moves by the box length per image flag, or not at all."""
|
|
666
|
+
record = make_record("bond", image_flags=image_flags)
|
|
667
|
+
|
|
668
|
+
unwrapped = record.unwrapped_positions()
|
|
669
|
+
|
|
670
|
+
# One addition of a multiple of 10 to each x: float64 round-off.
|
|
671
|
+
np.testing.assert_allclose(unwrapped[:, 0], expected_x, rtol=0,
|
|
672
|
+
atol=1e-12)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def test_data_file_accepts_empty_bond_lists(make_record):
|
|
676
|
+
"""An empty list of bonds is a record with no bonds."""
|
|
677
|
+
record = make_record("bond", bonds=[], n_bond_types=0)
|
|
678
|
+
|
|
679
|
+
assert record.bonds.shape == (0, 3)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def test_data_file_arrays_are_read_only(make_record):
|
|
683
|
+
"""A record's arrays cannot be changed in place."""
|
|
684
|
+
record = make_record()
|
|
685
|
+
|
|
686
|
+
with pytest.raises(ValueError, match="read-only"):
|
|
687
|
+
record.positions[0, 0] = 99.0
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
# with_frame -----------------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
@pytest.mark.parametrize(
|
|
694
|
+
("template", "dump", "atol"),
|
|
695
|
+
[
|
|
696
|
+
# Same step, %.16g against write_data's %.16e: float64 round-off.
|
|
697
|
+
pytest.param("ring.bond.data", "ring.bond.lammpstrj", 1e-12,
|
|
698
|
+
id="unscaled"),
|
|
699
|
+
# dump atom's %g: 5e-7 of a scaled value, times the box length 6.
|
|
700
|
+
pytest.param("ring.bond.data", "format.atom.lammpstrj", 3.0e-6,
|
|
701
|
+
id="scaled"),
|
|
702
|
+
# Triclinic x adds the errors of lx = 6, xy = 1, and xz = 0.5.
|
|
703
|
+
pytest.param("triclinic.data", "triclinic.atom.lammpstrj", 3.75e-6,
|
|
704
|
+
id="triclinic-scaled"),
|
|
705
|
+
],
|
|
706
|
+
)
|
|
707
|
+
def test_with_frame_takes_positions_by_atom_id(
|
|
708
|
+
data_dir, section_rows, template, dump, atol):
|
|
709
|
+
"""The frame LAMMPS dumped at write_data's step gives its positions."""
|
|
710
|
+
base = read_data(data_dir / template)
|
|
711
|
+
frame = read_dump_frame(data_dir / dump, 2, sort_by_id=False)
|
|
712
|
+
expected = section_rows(data_dir / template, "Atoms", 8)
|
|
713
|
+
|
|
714
|
+
moved = with_frame(base, frame)
|
|
715
|
+
|
|
716
|
+
np.testing.assert_allclose(moved.positions, expected[:, 3:6],
|
|
717
|
+
rtol=0, atol=atol)
|
|
718
|
+
np.testing.assert_array_equal(moved.image_flags, expected[:, -3:])
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def test_with_frame_takes_the_frame_box(make_lammps_data, make_lammps_dump):
|
|
722
|
+
"""The box bounds come from the frame, not from the template."""
|
|
723
|
+
base = read_data(make_lammps_data())
|
|
724
|
+
frame = read_dump_frame(
|
|
725
|
+
make_lammps_dump(box=((-1.0, 9.0), (0.0, 12.0), (2.0, 7.0))), 0)
|
|
726
|
+
|
|
727
|
+
moved = with_frame(base, frame)
|
|
728
|
+
|
|
729
|
+
np.testing.assert_array_equal(moved.box.lo, [-1.0, 0.0, 2.0])
|
|
730
|
+
np.testing.assert_array_equal(moved.box.hi, [9.0, 12.0, 7.0])
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def test_with_frame_takes_the_frame_tilt(data_dir):
|
|
734
|
+
"""A triclinic frame gives its tilt factors to an orthogonal template."""
|
|
735
|
+
base = read_data(data_dir / "ring.bond.data")
|
|
736
|
+
frame = read_dump_frame(data_dir / "triclinic.atom.lammpstrj", 0)
|
|
737
|
+
|
|
738
|
+
moved = with_frame(base, frame)
|
|
739
|
+
|
|
740
|
+
# in.triclinic: region prism 0 6 0 6 0 6 1.0 0.5 -0.5.
|
|
741
|
+
np.testing.assert_array_equal(moved.box.tilt, [1.0, 0.5, -0.5])
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def test_with_frame_matches_a_template_listed_out_of_id_order(make_record):
|
|
745
|
+
"""Atoms of a template not sorted by ID still get their own positions."""
|
|
746
|
+
record = make_record("bond", ids=np.array([3, 1, 2]),
|
|
747
|
+
bonds=np.array([[1, 3, 1], [1, 1, 2]]))
|
|
748
|
+
box = Box(lo=np.zeros(3), hi=np.full(3, 10.0))
|
|
749
|
+
frame = DumpFrame(timestep=0, box=box, columns=("id", "x", "y", "z"),
|
|
750
|
+
data=np.array([[1, 1.0, 0.0, 0.0], [2, 2.0, 0.0, 0.0],
|
|
751
|
+
[3, 3.0, 0.0, 0.0]]))
|
|
752
|
+
|
|
753
|
+
moved = with_frame(record, frame)
|
|
754
|
+
|
|
755
|
+
np.testing.assert_array_equal(moved.positions[:, 0], [3.0, 1.0, 2.0])
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def test_with_frame_keeps_the_template_topology(data_dir):
|
|
759
|
+
"""Masses, bonds, molecule IDs, and types come from the template."""
|
|
760
|
+
base = read_data(data_dir / "ring.angle.data")
|
|
761
|
+
frame = read_dump_frame(data_dir / "ring.angle.lammpstrj", 0)
|
|
762
|
+
|
|
763
|
+
moved = with_frame(base, frame)
|
|
764
|
+
|
|
765
|
+
np.testing.assert_array_equal(moved.bonds, base.bonds)
|
|
766
|
+
np.testing.assert_array_equal(moved.angles, base.angles)
|
|
767
|
+
np.testing.assert_array_equal(moved.masses, base.masses)
|
|
768
|
+
np.testing.assert_array_equal(moved.mol_ids, base.mol_ids)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def test_with_frame_takes_frame_velocities_and_drops_the_template_ones(
|
|
772
|
+
make_record):
|
|
773
|
+
"""Velocities come from the frame when it has them, else none."""
|
|
774
|
+
record = make_record("bond", velocities=np.ones((3, 3)))
|
|
775
|
+
box = Box(lo=np.zeros(3), hi=np.full(3, 10.0))
|
|
776
|
+
frame = DumpFrame(
|
|
777
|
+
timestep=0, box=box, columns=("id", "x", "y", "z", "vx", "vy", "vz"),
|
|
778
|
+
data=np.array([[3, 1.0, 1.0, 1.0, 0.3, 0.0, 0.0],
|
|
779
|
+
[1, 2.0, 2.0, 2.0, 0.1, 0.0, 0.0],
|
|
780
|
+
[2, 3.0, 3.0, 3.0, 0.2, 0.0, 0.0]]))
|
|
781
|
+
|
|
782
|
+
moved = with_frame(record, frame)
|
|
783
|
+
|
|
784
|
+
np.testing.assert_array_equal(moved.velocities[:, 0], [0.1, 0.2, 0.3])
|
|
785
|
+
assert moved.image_flags is None
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
@pytest.mark.parametrize(
|
|
789
|
+
("columns", "rows", "match"),
|
|
790
|
+
[
|
|
791
|
+
pytest.param(("x", "y", "z"), [[1.0, 1.0, 1.0]] * 3,
|
|
792
|
+
"no 'id' column", id="no-ids"),
|
|
793
|
+
pytest.param(("id", "x", "y", "z"),
|
|
794
|
+
[[1, 1.0, 1.0, 1.0], [2, 2.0, 2.0, 2.0]],
|
|
795
|
+
"atom IDs differ", id="fewer-atoms"),
|
|
796
|
+
pytest.param(("id", "type", "x", "y", "z"),
|
|
797
|
+
[[1, 2, 1.0, 1.0, 1.0], [2, 1, 2.0, 2.0, 2.0],
|
|
798
|
+
[3, 2, 3.0, 3.0, 3.0]],
|
|
799
|
+
"atom types differ", id="other-types"),
|
|
800
|
+
],
|
|
801
|
+
)
|
|
802
|
+
def test_with_frame_rejects_a_frame_of_another_system(
|
|
803
|
+
make_record, columns, rows, match):
|
|
804
|
+
"""A frame whose atoms do not match the template's raises ValueError."""
|
|
805
|
+
box = Box(lo=np.zeros(3), hi=np.full(3, 10.0))
|
|
806
|
+
frame = DumpFrame(timestep=0, box=box, columns=columns,
|
|
807
|
+
data=np.array(rows))
|
|
808
|
+
|
|
809
|
+
with pytest.raises(ValueError, match=match):
|
|
810
|
+
with_frame(make_record("bond"), frame)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@pytest.mark.parametrize(
|
|
814
|
+
"columns",
|
|
815
|
+
[
|
|
816
|
+
pytest.param(("id", "xu", "yu", "zu"), id="unwrapped"),
|
|
817
|
+
pytest.param(("id", "xsu", "ysu", "zsu"), id="scaled-unwrapped"),
|
|
818
|
+
],
|
|
819
|
+
)
|
|
820
|
+
def test_with_frame_rejects_a_frame_with_only_unwrapped_coordinates(
|
|
821
|
+
make_record, columns):
|
|
822
|
+
"""A frame without wrapped coordinates raises KeyError saying so."""
|
|
823
|
+
box = Box(lo=np.zeros(3), hi=np.full(3, 10.0))
|
|
824
|
+
frame = DumpFrame(timestep=0, box=box, columns=columns,
|
|
825
|
+
data=np.array([[1, 0.1, 0.1, 0.1], [2, 0.2, 0.2, 0.2],
|
|
826
|
+
[3, 0.3, 0.3, 0.3]]))
|
|
827
|
+
|
|
828
|
+
with pytest.raises(KeyError, match="unwrapped coordinates only"):
|
|
829
|
+
with_frame(make_record("bond"), frame)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
# Generated chains -----------------------------------------------------------
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
@pytest.mark.parametrize(("ring", "angles"), [
|
|
836
|
+
pytest.param(False, False, id="linear"),
|
|
837
|
+
pytest.param(False, True, id="linear-angles"),
|
|
838
|
+
pytest.param(True, False, id="ring"),
|
|
839
|
+
pytest.param(True, True, id="ring-angles"),
|
|
840
|
+
])
|
|
841
|
+
# tmp_path is reused across the examples of one case: each example
|
|
842
|
+
# overwrites the one file, and record_fields only reads its argument.
|
|
843
|
+
@settings(max_examples=50, deadline=None,
|
|
844
|
+
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
845
|
+
@given(positions=arrays(
|
|
846
|
+
np.float64,
|
|
847
|
+
st.tuples(st.integers(min_value=3, max_value=50), st.just(3)),
|
|
848
|
+
elements=st.floats(min_value=0.5, max_value=9.5)))
|
|
849
|
+
def test_write_data_round_trips_a_generated_chain(
|
|
850
|
+
record_fields, tmp_path, ring, angles, positions):
|
|
851
|
+
"""A generated linear or ring chain, with or without angles, reads back
|
|
852
|
+
with every field unchanged: the round trip, with no reference value."""
|
|
853
|
+
record = chain_data(positions, Box(lo=np.zeros(3), hi=np.full(3, 10.0)),
|
|
854
|
+
ring=ring, angles=angles)
|
|
855
|
+
|
|
856
|
+
back = read_data(write_data(record, tmp_path / "chain.data"))
|
|
857
|
+
|
|
858
|
+
assert record_fields(back) == record_fields(record)
|