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,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)