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,487 @@
1
+ """Tests for :mod:`polyphys.simulate.dump`.
2
+
3
+ Tier A tests read dumps written in the test, by the shared
4
+ ``make_lammps_dump`` factory or as hand-written frames; each expected value is
5
+ a number the test writes into the file, or follows from it by the box
6
+ formulas of the LAMMPS ``Howto_triclinic`` page.
7
+
8
+ Tier B tests read the dumps in ``polyphys/tests/data/lammps_dump/``, written
9
+ by LAMMPS (22 Jul 2025 - Update 4) from the inputs beside them. Their
10
+ expected values are other LAMMPS output for the same frame, such as the
11
+ full-precision ``x y z`` that LAMMPS wrote next to the scaled ``xs ys zs``,
12
+ read with :func:`numpy.loadtxt`, not with the module under test.
13
+ """
14
+
15
+ import logging
16
+ from pathlib import Path
17
+ from typing import Callable, Sequence
18
+
19
+ import numpy as np
20
+ import pytest
21
+
22
+ from polyphys.simulate.core import Box
23
+ from polyphys.simulate.dump import (
24
+ BrokenDumpError,
25
+ DumpFrame,
26
+ iter_dump,
27
+ read_dump_frame,
28
+ )
29
+
30
+
31
+ @pytest.fixture
32
+ def dump_dir() -> Path:
33
+ """Return the directory of the LAMMPS-written dump fixtures."""
34
+ return Path(__file__).resolve().parents[1] / "data" / "lammps_dump"
35
+
36
+
37
+ @pytest.fixture
38
+ def frame_text() -> Callable[..., str]:
39
+ """Return a factory for the text of one hand-written dump frame.
40
+
41
+ Each argument replaces one part of a valid two-atom frame; `n_atoms`
42
+ defaults to the number of `atoms` lines, and `before` holds lines that
43
+ come before ``ITEM: TIMESTEP``.
44
+ """
45
+ def _make(
46
+ timestep: str = "0",
47
+ n_atoms: str | None = None,
48
+ box_header: str = "ITEM: BOX BOUNDS pp pp pp",
49
+ box: Sequence[str] = ("0.0 10.0", "0.0 10.0", "0.0 10.0"),
50
+ columns: str = "id type x y z",
51
+ atoms: Sequence[str] = ("1 1 1.0 5.0 5.0", "2 1 2.0 5.0 5.0"),
52
+ before: Sequence[str] = (),
53
+ ) -> str:
54
+ count = str(len(atoms)) if n_atoms is None else n_atoms
55
+ lines = [*before, "ITEM: TIMESTEP", timestep,
56
+ "ITEM: NUMBER OF ATOMS", count, box_header, *box,
57
+ f"ITEM: ATOMS {columns}".rstrip(), *atoms]
58
+ return "\n".join(lines) + "\n"
59
+ return _make
60
+
61
+
62
+ @pytest.fixture
63
+ def write_dump(tmp_path: Path) -> Callable[..., Path]:
64
+ """Return a factory that writes dump text to a file under ``tmp_path``."""
65
+ def _write(text: str, name: str = "frame.lammpstrj") -> Path:
66
+ path = tmp_path / name
67
+ path.write_text(text)
68
+ return path
69
+ return _write
70
+
71
+
72
+ @pytest.fixture
73
+ def lammps_atoms() -> Callable[[Path, int, int], np.ndarray]:
74
+ """Return a reader of one frame's atom lines through numpy.loadtxt.
75
+
76
+ It returns the ``n_atoms`` lines after the frame's ``ITEM: ATOMS`` line
77
+ as a float array sorted by its first column (the atom ID): an
78
+ independent reading of LAMMPS output, used as the expected value.
79
+ """
80
+ def _read(path: Path, frame: int, n_atoms: int) -> np.ndarray:
81
+ lines = path.read_text().splitlines()
82
+ starts = [i for i, line in enumerate(lines)
83
+ if line.startswith("ITEM: ATOMS")]
84
+ first = starts[frame] + 1
85
+ rows = np.loadtxt(lines[first:first + n_atoms], ndmin=2)
86
+ return rows[np.argsort(rows[:, 0])]
87
+ return _read
88
+
89
+
90
+ # Reading frames -------------------------------------------------------------
91
+
92
+
93
+ def test_iter_dump_reads_every_frame_header(make_lammps_dump):
94
+ """Each frame gives its timestep, atom count, and column names."""
95
+ path = make_lammps_dump(timesteps=[0, 1000, 2000])
96
+
97
+ frames = list(iter_dump(path))
98
+
99
+ assert [frame.timestep for frame in frames] == [0, 1000, 2000]
100
+ assert [frame.n_atoms for frame in frames] == [4, 4, 4]
101
+ assert frames[0].columns == ("id", "type", "x", "y", "z")
102
+
103
+
104
+ def test_iter_dump_reads_atom_values(make_lammps_dump):
105
+ """The per-atom values equal the numbers written in the file."""
106
+ positions = np.array([[[1.25, 2.5, 3.75], [4.0, 5.0, 6.0]],
107
+ [[1.5, 2.5, 3.75], [4.0, 5.5, 6.0]]])
108
+ path = make_lammps_dump(positions=positions, atom_types=[2, 1])
109
+
110
+ frames = list(iter_dump(path))
111
+
112
+ np.testing.assert_array_equal(frames[1].data, [[1, 2, 1.5, 2.5, 3.75],
113
+ [2, 1, 4.0, 5.5, 6.0]])
114
+
115
+
116
+ def test_iter_dump_yields_a_frame_before_reading_the_next(
117
+ frame_text, write_dump):
118
+ """A frame is yielded before the next one is read, broken or not."""
119
+ text = frame_text() + frame_text(timestep="not a step")
120
+ frames = iter_dump(write_dump(text))
121
+
122
+ first = next(frames)
123
+
124
+ assert first.timestep == 0
125
+
126
+
127
+ def test_iter_dump_ignores_trailing_blank_lines(make_lammps_dump):
128
+ """Blank lines at the end of the file end the iteration cleanly."""
129
+ path = make_lammps_dump()
130
+ path.write_text(path.read_text() + "\n\n")
131
+
132
+ frames = list(iter_dump(path))
133
+
134
+ assert len(frames) == 3
135
+
136
+
137
+ @pytest.mark.parametrize(
138
+ ("sort_by_id", "expected"),
139
+ [
140
+ pytest.param(True, [1, 2, 3, 4, 5, 6, 7, 8], id="sorted"),
141
+ pytest.param(False, [1, 2, 8, 3, 7, 4, 6, 5], id="file-order"),
142
+ ],
143
+ )
144
+ def test_iter_dump_sort_by_id_orders_rows(dump_dir, sort_by_id, expected):
145
+ """Rows follow atom ID when sorting and LAMMPS's own order otherwise."""
146
+ path = dump_dir / "ring.bond.lammpstrj"
147
+
148
+ frame = next(iter_dump(path, sort_by_id=sort_by_id))
149
+
150
+ np.testing.assert_array_equal(frame.column("id"), expected)
151
+
152
+
153
+ def test_iter_dump_keeps_file_order_without_an_id_column(
154
+ frame_text, write_dump):
155
+ """A frame without an id column keeps its rows in file order."""
156
+ text = frame_text(columns="type x y z",
157
+ atoms=("2 3.0 5.0 5.0", "1 1.0 5.0 5.0"))
158
+
159
+ frame = next(iter_dump(write_dump(text)))
160
+
161
+ np.testing.assert_array_equal(frame.column("type"), [2, 1])
162
+
163
+
164
+ def test_iter_dump_reads_units_and_time(dump_dir):
165
+ """UNITS, written in the first frame only, and TIME reach every frame."""
166
+ path = dump_dir / "format.xu.lammpstrj"
167
+
168
+ frames = list(iter_dump(path))
169
+
170
+ # in.ring: timestep 0.005 and a dump every 500 steps, so 2.5 tau apart.
171
+ assert [frame.units for frame in frames] == ["lj", "lj", "lj"]
172
+ assert [frame.time for frame in frames] == [0.0, 2.5, 5.0]
173
+
174
+
175
+ def test_iter_dump_reads_gzip_like_plain_text(dump_dir):
176
+ """A gzipped dump gives the same frames as its plain-text twin."""
177
+ plain = list(iter_dump(dump_dir / "ring.bond.lammpstrj"))
178
+
179
+ gzipped = list(iter_dump(dump_dir / "format.x.lammpstrj.gz"))
180
+
181
+ np.testing.assert_array_equal(gzipped[2].data, plain[2].data)
182
+
183
+
184
+ # Boxes ----------------------------------------------------------------------
185
+
186
+
187
+ def test_iter_dump_reads_an_orthogonal_box(make_lammps_dump):
188
+ """An orthogonal box gives its bounds, zero tilt, and boundary flags."""
189
+ path = make_lammps_dump(box=((0.0, 10.0), (-1.0, 9.0), (2.0, 12.0)),
190
+ boundary="ff ff pp")
191
+
192
+ box = next(iter_dump(path)).box
193
+
194
+ np.testing.assert_array_equal(box.lo, [0.0, -1.0, 2.0])
195
+ np.testing.assert_array_equal(box.hi, [10.0, 9.0, 12.0])
196
+ np.testing.assert_array_equal(box.tilt, [0.0, 0.0, 0.0])
197
+ assert box.boundary == ("ff", "ff", "pp")
198
+ assert not box.triclinic
199
+
200
+
201
+ @pytest.mark.parametrize(
202
+ ("bound_lines", "tilt"),
203
+ [
204
+ # lo (0, 0, 0), hi (6, 6, 6): x bounds 0 + min(0, 1, 0.5, 1.5) and
205
+ # 6 + max(...) = 7.5; y bounds 0 + min(0, -0.5) = -0.5 and 6.
206
+ pytest.param(("0.0 7.5 1.0", "-0.5 6.0 0.5", "0.0 6.0 -0.5"),
207
+ [1.0, 0.5, -0.5], id="xy-positive"),
208
+ # x bounds 0 + min(0, -1, 0.5, -0.5) = -1 and 6 + 0.5; y bounds 0
209
+ # and 6 + 0.5.
210
+ pytest.param(("-1.0 6.5 -1.0", "0.0 6.5 0.5", "0.0 6.0 0.5"),
211
+ [-1.0, 0.5, 0.5], id="xy-negative"),
212
+ ],
213
+ )
214
+ def test_iter_dump_triclinic_box_removes_the_bounding_offsets(
215
+ frame_text, write_dump, bound_lines, tilt):
216
+ """A triclinic header gives the box's own lo and hi, not the bounds."""
217
+ text = frame_text(box_header="ITEM: BOX BOUNDS xy xz yz pp pp pp",
218
+ box=bound_lines)
219
+
220
+ box = next(iter_dump(write_dump(text))).box
221
+
222
+ np.testing.assert_array_equal(box.lo, [0.0, 0.0, 0.0])
223
+ np.testing.assert_array_equal(box.hi, [6.0, 6.0, 6.0])
224
+ np.testing.assert_array_equal(box.tilt, tilt)
225
+ assert box.triclinic
226
+
227
+
228
+ # Columns and coordinates ----------------------------------------------------
229
+
230
+
231
+ def test_iter_dump_maps_column_names_to_their_columns(frame_text, write_dump):
232
+ """Columns in any order are found by name."""
233
+ text = frame_text(columns="x id z type y",
234
+ atoms=("1.5 7 3.5 2 2.5",))
235
+
236
+ frame = next(iter_dump(write_dump(text)))
237
+
238
+ assert frame.column("id").tolist() == [7]
239
+ assert frame.column("type").tolist() == [2]
240
+ np.testing.assert_array_equal(frame.positions(), [[1.5, 2.5, 3.5]])
241
+
242
+
243
+ @pytest.mark.parametrize("name", ["id", "type", "ix"])
244
+ def test_dump_frame_column_returns_integer_attributes_as_int64(
245
+ dump_dir, name):
246
+ """IDs, types, and image flags come back as int64."""
247
+ frame = next(iter_dump(dump_dir / "ring.bond.lammpstrj"))
248
+
249
+ values = frame.column(name)
250
+
251
+ assert values.dtype == np.int64
252
+
253
+
254
+ def test_dump_frame_column_returns_other_attributes_as_float64(
255
+ frame_text, write_dump):
256
+ """A non-integer column comes back as its float64 values."""
257
+ frame = next(iter_dump(write_dump(frame_text())))
258
+
259
+ values = frame.column("x")
260
+
261
+ assert values.dtype == np.float64
262
+ np.testing.assert_array_equal(values, [1.0, 2.0])
263
+
264
+
265
+ def test_dump_frame_column_raises_for_a_missing_name(make_lammps_dump):
266
+ """Asking for a column the frame lacks raises KeyError."""
267
+ frame = next(iter_dump(make_lammps_dump()))
268
+
269
+ with pytest.raises(KeyError, match="no column 'vx'"):
270
+ frame.column("vx")
271
+
272
+
273
+ @pytest.mark.parametrize(
274
+ ("scaled", "reference", "atol"),
275
+ [
276
+ # dump atom writes %g, 6 significant digits: a scaled value below 1
277
+ # is within 5e-7, times the box length 6.
278
+ pytest.param("format.atom.lammpstrj", "ring.bond.lammpstrj", 3.0e-6,
279
+ id="orthogonal"),
280
+ # Triclinic: x adds the errors of lx = 6, xy = 1, and xz = 0.5.
281
+ pytest.param("triclinic.atom.lammpstrj", "triclinic.x.lammpstrj",
282
+ 3.75e-6, id="triclinic"),
283
+ ],
284
+ )
285
+ def test_positions_unscales_scaled_coordinates(
286
+ dump_dir, lammps_atoms, scaled, reference, atol):
287
+ """Scaled xs ys zs unscale to the x y z that LAMMPS wrote."""
288
+ frame = read_dump_frame(dump_dir / scaled, 2)
289
+ expected = lammps_atoms(dump_dir / reference, 2, 8)[:, -6:-3]
290
+
291
+ positions = frame.positions()
292
+
293
+ np.testing.assert_allclose(positions, expected, rtol=0, atol=atol)
294
+
295
+
296
+ @pytest.mark.parametrize(
297
+ ("dump", "reference"),
298
+ [
299
+ pytest.param("ring.bond.lammpstrj", "format.xu.lammpstrj",
300
+ id="x-and-image-flags"),
301
+ pytest.param("format.xsu.lammpstrj", "format.xu.lammpstrj",
302
+ id="xsu"),
303
+ pytest.param("triclinic.x.lammpstrj", "triclinic.xu.lammpstrj",
304
+ id="triclinic-x-and-image-flags"),
305
+ pytest.param("triclinic.xsu.lammpstrj", "triclinic.xu.lammpstrj",
306
+ id="triclinic-xsu"),
307
+ ],
308
+ )
309
+ def test_positions_unwraps_to_lammps_unwrapped_coordinates(
310
+ dump_dir, lammps_atoms, dump, reference):
311
+ """Unwrapped positions from image flags or xsu equal LAMMPS's xu yu zu."""
312
+ frame = read_dump_frame(dump_dir / dump, 0)
313
+ expected = lammps_atoms(dump_dir / reference, 0, 8)[:, -3:]
314
+
315
+ positions = frame.positions(unwrapped=True)
316
+
317
+ # %.16g keeps 16 significant digits of coordinates below 20; LAMMPS
318
+ # computes xu and xsu in float64 separately, so 1e-12 bounds both.
319
+ np.testing.assert_allclose(positions, expected, rtol=0, atol=1e-12)
320
+
321
+
322
+ @pytest.mark.parametrize("unwrapped", [False, True])
323
+ def test_positions_raises_without_coordinate_columns(
324
+ frame_text, write_dump, unwrapped):
325
+ """A frame with no coordinate columns raises KeyError."""
326
+ text = frame_text(columns="id type", atoms=("1 1", "2 1"))
327
+ frame = next(iter_dump(write_dump(text)))
328
+
329
+ with pytest.raises(KeyError, match="no coordinate columns"):
330
+ frame.positions(unwrapped=unwrapped)
331
+
332
+
333
+ def test_iter_dump_reads_a_frame_without_atoms(frame_text, write_dump):
334
+ """A frame of zero atoms gives an empty (0, n_columns) array."""
335
+ text = frame_text(atoms=())
336
+
337
+ frame = next(iter_dump(write_dump(text)))
338
+
339
+ assert frame.data.shape == (0, 5)
340
+
341
+
342
+ # Broken files ---------------------------------------------------------------
343
+
344
+
345
+ def test_iter_dump_raises_on_a_cut_off_last_frame(make_lammps_dump):
346
+ """A last frame short of atom lines raises, naming that frame."""
347
+ path = make_lammps_dump(timesteps=[0, 1000, 2000])
348
+ path.write_text("\n".join(path.read_text().splitlines()[:-2]) + "\n")
349
+
350
+ with pytest.raises(BrokenDumpError,
351
+ match=r"frame 2 \(timestep 2000\), line 37: 2 of 4"):
352
+ list(iter_dump(path))
353
+
354
+
355
+ def test_iter_dump_raises_when_a_header_is_cut_off(frame_text, write_dump):
356
+ """A file that ends inside a frame header raises."""
357
+ text = frame_text() + "ITEM: TIMESTEP\n100\n"
358
+
359
+ with pytest.raises(BrokenDumpError, match="ends inside the frame header"):
360
+ list(iter_dump(write_dump(text)))
361
+
362
+
363
+ @pytest.mark.parametrize(
364
+ ("parts", "match"),
365
+ [
366
+ pytest.param({"before": ("garbage",)}, "expected an ITEM: line",
367
+ id="not-an-item"),
368
+ pytest.param({"before": ("ITEM: FOO",)}, "expected ITEM: TIMESTEP",
369
+ id="unknown-item"),
370
+ pytest.param({"before": ("ITEM: TIME", "soon")},
371
+ "TIME is not followed by a number", id="time-not-number"),
372
+ pytest.param({"timestep": "1e3"}, "TIMESTEP is not followed by an "
373
+ "integer", id="timestep-not-integer"),
374
+ pytest.param({"timestep": ""}, "TIMESTEP is not followed by a value",
375
+ id="timestep-empty"),
376
+ pytest.param({"n_atoms": "two"}, "NUMBER OF ATOMS is not followed",
377
+ id="count-not-integer"),
378
+ pytest.param({"n_atoms": "-1"}, "negative number of atoms",
379
+ id="count-negative"),
380
+ pytest.param({"box_header": "ITEM: BOX"}, "expected ITEM: BOX BOUNDS",
381
+ id="no-box-bounds"),
382
+ pytest.param({"box_header": "ITEM: BOX BOUNDS pp pp"},
383
+ "three boundary flags", id="two-flags"),
384
+ pytest.param({"box": ("0.0", "0.0 10.0", "0.0 10.0")},
385
+ "needs 2 numbers", id="box-line-short"),
386
+ pytest.param({"box": ("a b", "0.0 10.0", "0.0 10.0")},
387
+ "needs 2 numbers", id="box-line-not-numbers"),
388
+ pytest.param({"box": ("10.0 0.0", "0.0 10.0", "0.0 10.0")},
389
+ "must be below", id="box-lo-above-hi"),
390
+ pytest.param({"columns": ""}, "names no columns", id="no-columns"),
391
+ pytest.param({"columns": "id id x y z"}, "repeats",
392
+ id="repeated-column"),
393
+ pytest.param({"atoms": ("1 1 1.0 5.0", "2 1 2.0 5.0 5.0")},
394
+ r"line 10: an atom line needs 5 numbers, got '1 1 1.0 "
395
+ r"5.0'", id="atom-line-short"),
396
+ pytest.param({"atoms": ("1 1 1.0 5.0 5.0", "2 1 2.0 5.0 abc")},
397
+ r"line 11: an atom line needs 5 numbers",
398
+ id="atom-value-not-number"),
399
+ pytest.param({"atoms": ("1 1 1.0 5.0", "2 1 2.0 5.0 5.0 5.0")},
400
+ r"line 10: an atom line needs 5 numbers, got '1 1 1.0 "
401
+ r"5.0'", id="short-and-long-lines-with-the-right-total"),
402
+ ],
403
+ )
404
+ def test_iter_dump_raises_on_a_malformed_frame(
405
+ frame_text, write_dump, parts, match):
406
+ """A frame that breaks the format raises BrokenDumpError."""
407
+ path = write_dump(frame_text(**parts))
408
+
409
+ with pytest.raises(BrokenDumpError, match=match):
410
+ list(iter_dump(path))
411
+
412
+
413
+ def test_iter_dump_rejects_general_triclinic_boxes(frame_text, write_dump):
414
+ """The general triclinic box form raises BrokenDumpError."""
415
+ text = frame_text(box_header="ITEM: BOX BOUNDS abc origin pp pp pp",
416
+ box=("6 0 0 0", "1 6 0 0", "0.5 -0.5 6 0"))
417
+
418
+ with pytest.raises(BrokenDumpError, match="general triclinic"):
419
+ list(iter_dump(write_dump(text)))
420
+
421
+
422
+ def test_iter_dump_warns_when_a_timestep_repeats(make_lammps_dump, caplog):
423
+ """A frame whose timestep does not increase is logged as a warning."""
424
+ path = make_lammps_dump(timesteps=[0, 1000, 1000])
425
+
426
+ with caplog.at_level(logging.WARNING,
427
+ logger="polyphys.simulate.dump"):
428
+ frames = list(iter_dump(path))
429
+
430
+ assert len(frames) == 3
431
+ assert "frame 2 has timestep 1000, not after the previous frame's 1000" \
432
+ in caplog.text
433
+
434
+
435
+ # read_dump_frame ------------------------------------------------------------
436
+
437
+
438
+ @pytest.mark.parametrize("index", [0, 2])
439
+ def test_read_dump_frame_returns_the_frame_at_index(make_lammps_dump, index):
440
+ """read_dump_frame returns the frame at the given position."""
441
+ path = make_lammps_dump(timesteps=[0, 1000, 2000])
442
+
443
+ frame = read_dump_frame(path, index)
444
+
445
+ assert frame.timestep == 1000 * index
446
+
447
+
448
+ @pytest.mark.parametrize(
449
+ ("index", "match"),
450
+ [
451
+ pytest.param(3, "has 3 frames; there is no frame 3", id="past-end"),
452
+ pytest.param(-1, "must be 0 or more", id="negative"),
453
+ ],
454
+ )
455
+ def test_read_dump_frame_raises_for_a_missing_index(
456
+ make_lammps_dump, index, match):
457
+ """An index outside the file raises IndexError."""
458
+ path = make_lammps_dump()
459
+
460
+ with pytest.raises(IndexError, match=match):
461
+ read_dump_frame(path, index)
462
+
463
+
464
+ # Records --------------------------------------------------------------------
465
+
466
+
467
+ def test_dump_frame_data_is_read_only(make_lammps_dump):
468
+ """A frame's data cannot be changed in place."""
469
+ frame = next(iter_dump(make_lammps_dump()))
470
+
471
+ with pytest.raises(ValueError, match="read-only"):
472
+ frame.data[0, 0] = 99.0
473
+
474
+
475
+ @pytest.mark.parametrize(
476
+ ("columns", "data", "match"),
477
+ [
478
+ pytest.param(("id", "x"), np.zeros((2, 3)), "shape", id="width"),
479
+ pytest.param(("id", "id"), np.zeros((2, 2)), "repeat", id="repeat"),
480
+ ],
481
+ )
482
+ def test_dump_frame_rejects_inconsistent_data(columns, data, match):
483
+ """Data whose width does not match the column names raises ValueError."""
484
+ box = Box(lo=np.zeros(3), hi=np.ones(3))
485
+
486
+ with pytest.raises(ValueError, match=match):
487
+ DumpFrame(timestep=0, box=box, columns=columns, data=data)