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,752 @@
1
+ """\
2
+ =====================================================================
3
+ LAMMPS dump files --- :mod:`polyphys.simulate.dump`
4
+ =====================================================================
5
+
6
+ The :mod:`~polyphys.simulate.dump` module reads the text dump files
7
+ written by the LAMMPS ``dump atom`` and ``dump custom`` commands into NumPy
8
+ arrays, one frame at a time. It needs NumPy and the standard library only, so
9
+ an install without MDAnalysis can read dumps.
10
+
11
+ .. versionadded:: 0.6.0
12
+
13
+ Contents
14
+ ========
15
+
16
+ :func:`iter_dump` yields the frames of a file one at a time, and
17
+ :func:`read_dump_frame` returns one of them. Each frame is a
18
+ :class:`DumpFrame`: its timestep, its box as a
19
+ :class:`~polyphys.simulate.core.Box`, the names of the per-atom columns, and
20
+ the per-atom values. :meth:`DumpFrame.positions` returns Cartesian coordinates
21
+ from whichever coordinate columns the frame has. :class:`BrokenDumpError`, a
22
+ :class:`~polyphys.simulate.core.LammpsFormatError`, reports a file that is cut
23
+ off or does not follow the format.
24
+
25
+ Format
26
+ ======
27
+
28
+ A dump is a sequence of frames, each a header and one line per atom::
29
+
30
+ ITEM: TIMESTEP
31
+ 1000
32
+ ITEM: NUMBER OF ATOMS
33
+ 2
34
+ ITEM: BOX BOUNDS pp pp pp
35
+ 0.0 10.0
36
+ 0.0 10.0
37
+ 0.0 10.0
38
+ ITEM: ATOMS id type x y z
39
+ 1 1 1.0 5.0 5.0
40
+ 2 1 2.0 5.0 5.0
41
+
42
+ - ``ITEM: UNITS`` and ``ITEM: TIME``, each followed by its value, may come
43
+ before ``ITEM: TIMESTEP``; ``dump_modify units yes`` writes the first in
44
+ the first frame only, and ``dump_modify time yes`` the second in every
45
+ frame.
46
+ - ``ITEM: BOX BOUNDS`` ends with three boundary flags, such as ``pp ff pp``,
47
+ and is followed by one ``lo hi`` line per dimension. For a restricted
48
+ triclinic box it reads ``ITEM: BOX BOUNDS xy xz yz pp pp pp`` and its lines
49
+ are ``xlo_bound xhi_bound xy``, ``ylo_bound yhi_bound xz``, and
50
+ ``zlo_bound zhi_bound yz``: the bounds of the orthogonal box that encloses
51
+ the triclinic box, then a tilt factor. The general triclinic form,
52
+ ``ITEM: BOX BOUNDS abc origin``, is not supported.
53
+ - ``ITEM: ATOMS`` names the per-atom columns. Unless ``dump_modify sort`` is
54
+ set, the atom lines are in no fixed order.
55
+
56
+ Coordinates
57
+ ===========
58
+
59
+ ``x y z`` are Cartesian coordinates wrapped into the box, and ``xu yu zu``
60
+ are unwrapped by the image flags ``ix iy iz``. ``xs ys zs`` and ``xsu ysu
61
+ zsu`` are the same coordinates scaled: fractions of the box edge vectors,
62
+ converted as :mod:`polyphys.simulate.core` describes. ``dump atom`` writes
63
+ ``id type xs ys zs`` unless ``dump_modify scale no`` is set. The module
64
+ converts no units; values are in the ``units`` of the run.
65
+
66
+ Complexity
67
+ ==========
68
+
69
+ Reading a file takes :math:`O(n_f n_a n_c)` time for :math:`n_f` frames of
70
+ :math:`n_a` atoms with :math:`n_c` columns. :func:`iter_dump` holds one frame
71
+ at a time, :math:`O(n_a n_c)` memory, plus the frames the caller keeps.
72
+
73
+ References
74
+ ==========
75
+
76
+ The format follows the LAMMPS documentation of `dump
77
+ <https://docs.lammps.org/dump.html>`_ and `Howto_triclinic
78
+ <https://docs.lammps.org/Howto_triclinic.html>`_, and these lines of the
79
+ LAMMPS source, release ``stable_30Sep2026_update1``; the coordinate formulas
80
+ are cited in :mod:`polyphys.simulate.core`:
81
+
82
+ - Frame headers: `dump_custom.cpp, lines 660 to 725
83
+ <https://github.com/lammps/lammps/blob/098ffdefa525cabc82272925de02e9884f58c657/src/dump_custom.cpp#L660-L725>`_,
84
+ and the same in `dump_atom.cpp, lines 388 to 450
85
+ <https://github.com/lammps/lammps/blob/098ffdefa525cabc82272925de02e9884f58c657/src/dump_atom.cpp#L388-L450>`_;
86
+ the bounds of a triclinic box: `dump.cpp, lines 327 to 344
87
+ <https://github.com/lammps/lammps/blob/098ffdefa525cabc82272925de02e9884f58c657/src/dump.cpp#L327-L344>`_.
88
+
89
+ Dependencies
90
+ ============
91
+ - Python standard library: `contextlib`, `dataclasses`, `itertools`,
92
+ `logging`, `pathlib`
93
+ - Third-party: `numpy`
94
+ - PolyPhys: :mod:`polyphys.simulate.core`
95
+ """
96
+
97
+ import logging
98
+ from contextlib import closing
99
+ from dataclasses import dataclass
100
+ from itertools import islice
101
+ from pathlib import Path
102
+ from typing import IO, Generator
103
+
104
+ import numpy as np
105
+ import numpy.typing as npt
106
+
107
+ from polyphys.simulate.core import (
108
+ Box,
109
+ FloatArray,
110
+ LammpsFormatError,
111
+ _frozen,
112
+ _open_text,
113
+ )
114
+
115
+ __all__ = [
116
+ "BrokenDumpError",
117
+ "DumpFrame",
118
+ "iter_dump",
119
+ "read_dump_frame",
120
+ ]
121
+
122
+ logger = logging.getLogger(__name__)
123
+
124
+ # Per-atom attributes of dump custom that hold integers (LAMMPS dump command,
125
+ # "Per-atom attributes used as arguments to the custom and cfg styles").
126
+ _INTEGER_COLUMNS = frozenset(
127
+ {"id", "mol", "proc", "procp1", "type", "ix", "iy", "iz"}
128
+ )
129
+ _WRAPPED = (("x", "y", "z"), ("xs", "ys", "zs"))
130
+ _UNWRAPPED = (("xu", "yu", "zu"), ("xsu", "ysu", "zsu"))
131
+ _SCALED = frozenset({("xs", "ys", "zs"), ("xsu", "ysu", "zsu")})
132
+ _IMAGE = ("ix", "iy", "iz")
133
+ _ITEM = "ITEM:"
134
+
135
+
136
+ class BrokenDumpError(LammpsFormatError):
137
+ """A LAMMPS dump is cut off or does not follow the dump format.
138
+
139
+ .. versionadded:: 0.6.0
140
+
141
+ Examples
142
+ --------
143
+ A run killed while writing its second frame leaves that frame short of
144
+ atom lines:
145
+
146
+ >>> import tempfile
147
+ >>> frame = '''ITEM: TIMESTEP
148
+ ... {step}
149
+ ... ITEM: NUMBER OF ATOMS
150
+ ... 2
151
+ ... ITEM: BOX BOUNDS pp pp pp
152
+ ... 0 10
153
+ ... 0 10
154
+ ... 0 10
155
+ ... ITEM: ATOMS id type x y z
156
+ ... 1 1 1.0 5.0 5.0
157
+ ... '''
158
+ >>> second_atom = "2 1 2.0 5.0 5.0\\n"
159
+ >>> text = frame.format(step=0) + second_atom + frame.format(step=100)
160
+ >>> with tempfile.TemporaryDirectory() as tmp:
161
+ ... path = Path(tmp) / "killed.lammpstrj"
162
+ ... _ = path.write_text(text)
163
+ ... steps = []
164
+ ... try:
165
+ ... for dump_frame in iter_dump(path):
166
+ ... steps.append(dump_frame.timestep)
167
+ ... except BrokenDumpError as err:
168
+ ... print(str(err).split(": ", 1)[1])
169
+ frame 1 (timestep 100), line 21: 1 of 2 atom lines; the dump may be cut off
170
+ >>> steps
171
+ [0]
172
+ """
173
+
174
+
175
+ @dataclass(frozen=True, eq=False)
176
+ class DumpFrame:
177
+ """One frame of a LAMMPS dump.
178
+
179
+ .. versionadded:: 0.6.0
180
+
181
+ Attributes
182
+ ----------
183
+ timestep : int
184
+ The ``ITEM: TIMESTEP`` value.
185
+ box : :class:`~polyphys.simulate.core.Box`
186
+ The simulation box, with the boundary flags of the frame; for a
187
+ triclinic box, its own bounds, not those the dump writes.
188
+ columns : tuple of str
189
+ The per-atom column names of ``ITEM: ATOMS``, in file order, such as
190
+ ``('id', 'type', 'xs', 'ys', 'zs')``.
191
+ data : numpy.ndarray
192
+ Shape ``(n_atoms, n_columns)``, float64: one row per atom, one column
193
+ per name in `columns`. Integer columns such as ``id`` are exact up to
194
+ :math:`2^{53}`. Read-only.
195
+ time : float or None
196
+ The ``ITEM: TIME`` value (simulated time), or None if the frame has
197
+ none.
198
+ units : str or None
199
+ The ``ITEM: UNITS`` value, such as ``'lj'``, or None. LAMMPS writes it
200
+ in the first frame only; :func:`iter_dump` gives it to every frame.
201
+
202
+ Raises
203
+ ------
204
+ ValueError
205
+ If `data` is not two-dimensional with one column per name in
206
+ `columns`, or a column name repeats.
207
+
208
+ Examples
209
+ --------
210
+ >>> box = Box(lo=np.zeros(3), hi=np.full(3, 10.0))
211
+ >>> frame = DumpFrame(timestep=0, box=box,
212
+ ... columns=("id", "type", "xs", "ys", "zs"),
213
+ ... data=np.array([[1, 1, 0.1, 0.5, 0.5],
214
+ ... [2, 1, 0.2, 0.5, 0.5]]))
215
+ >>> frame.n_atoms
216
+ 2
217
+ >>> frame.column("id")
218
+ array([1, 2])
219
+ >>> frame.positions()
220
+ array([[1., 5., 5.],
221
+ [2., 5., 5.]])
222
+ """
223
+
224
+ timestep: int
225
+ box: Box
226
+ columns: tuple[str, ...]
227
+ data: FloatArray
228
+ time: float | None = None
229
+ units: str | None = None
230
+
231
+ def __post_init__(self) -> None:
232
+ data = np.array(self.data, dtype=np.float64)
233
+ if data.ndim != 2 or data.shape[1] != len(self.columns):
234
+ raise ValueError(
235
+ f"'data' must have shape (n_atoms, {len(self.columns)}), "
236
+ f"got {data.shape}"
237
+ )
238
+ if len(set(self.columns)) != len(self.columns):
239
+ raise ValueError(f"column names repeat: {self.columns}")
240
+ object.__setattr__(self, "columns", tuple(self.columns))
241
+ object.__setattr__(self, "data", _frozen(data, np.float64))
242
+
243
+ @property
244
+ def n_atoms(self) -> int:
245
+ """The number of atoms in the frame."""
246
+ return int(self.data.shape[0])
247
+
248
+ def column(self, name: str) -> npt.NDArray[np.float64 | np.int64]:
249
+ """Return the values of one per-atom column.
250
+
251
+ Parameters
252
+ ----------
253
+ name : str
254
+ A name in :attr:`columns`.
255
+
256
+ Returns
257
+ -------
258
+ numpy.ndarray
259
+ Shape ``(n_atoms,)``: int64 for the integer attributes ``id``,
260
+ ``mol``, ``proc``, ``procp1``, ``type``, ``ix``, ``iy``, and
261
+ ``iz``, else a read-only float64 view of :attr:`data`.
262
+
263
+ Raises
264
+ ------
265
+ KeyError
266
+ If the frame has no column `name`.
267
+
268
+ Examples
269
+ --------
270
+ >>> box = Box(lo=np.zeros(3), hi=np.full(3, 10.0))
271
+ >>> frame = DumpFrame(timestep=0, box=box,
272
+ ... columns=("id", "type", "x"),
273
+ ... data=np.array([[1, 2, 0.5], [2, 1, 1.5]]))
274
+ >>> frame.column("type")
275
+ array([2, 1])
276
+ >>> frame.column("x")
277
+ array([0.5, 1.5])
278
+ """
279
+ try:
280
+ index = self.columns.index(name)
281
+ except ValueError:
282
+ raise KeyError(
283
+ f"no column {name!r}; the frame has {self.columns}"
284
+ ) from None
285
+ values = self.data[:, index]
286
+ if name in _INTEGER_COLUMNS:
287
+ return values.astype(np.int64)
288
+ return values
289
+
290
+ def positions(self, unwrapped: bool = False) -> FloatArray:
291
+ r"""Return the Cartesian coordinates of the atoms.
292
+
293
+ Parameters
294
+ ----------
295
+ unwrapped : bool
296
+ False for coordinates wrapped into the box; True for coordinates
297
+ unwrapped by the image flags.
298
+
299
+ Returns
300
+ -------
301
+ numpy.ndarray
302
+ Shape ``(n_atoms, 3)``, float64, in the length unit of the run.
303
+
304
+ Raises
305
+ ------
306
+ KeyError
307
+ If the frame lacks the columns for the requested coordinates.
308
+
309
+ Notes
310
+ -----
311
+ Wrapped coordinates come from ``x y z``, else from ``xs ys zs``.
312
+ Unwrapped ones come from ``xu yu zu``, else from ``xsu ysu zsu``,
313
+ else from the wrapped coordinates and the image flags ``ix iy iz``.
314
+ Scaled coordinates are converted by
315
+ :meth:`~polyphys.simulate.core.Box.to_cartesian` and image flags
316
+ applied by :meth:`~polyphys.simulate.core.Box.unwrap`, as LAMMPS's
317
+ ``Domain::lamda2x`` and ``Domain::unmap`` compute them. Time and
318
+ memory are :math:`O(n_a)` for :math:`n_a` atoms.
319
+
320
+ Examples
321
+ --------
322
+ >>> box = Box(lo=np.zeros(3), hi=np.full(3, 10.0))
323
+ >>> frame = DumpFrame(timestep=0, box=box,
324
+ ... columns=("id", "x", "y", "z", "ix", "iy", "iz"),
325
+ ... data=np.array([[1, 9.5, 5.0, 5.0, -1, 0, 0]]))
326
+ >>> frame.positions()
327
+ array([[9.5, 5. , 5. ]])
328
+ >>> frame.positions(unwrapped=True)
329
+ array([[-0.5, 5. , 5. ]])
330
+ """
331
+ options = _UNWRAPPED if unwrapped else _WRAPPED
332
+ for names in options:
333
+ if set(names) <= set(self.columns):
334
+ return self._coordinates(names)
335
+ if unwrapped and set(_IMAGE) <= set(self.columns):
336
+ for names in _WRAPPED:
337
+ if set(names) <= set(self.columns):
338
+ image = np.column_stack(
339
+ [self.column(name) for name in _IMAGE])
340
+ return self.box.unwrap(self._coordinates(names), image)
341
+ wanted = [" ".join(names) for names in options]
342
+ if unwrapped:
343
+ wanted.append("x y z or xs ys zs, with ix iy iz")
344
+ raise KeyError(
345
+ f"no coordinate columns for unwrapped={unwrapped}: need one of "
346
+ f"{wanted}; the frame has {self.columns}"
347
+ )
348
+
349
+ def _coordinates(self, names: tuple[str, str, str]) -> FloatArray:
350
+ """Return three coordinate columns as Cartesian coordinates."""
351
+ values = np.column_stack([self.data[:, self.columns.index(name)]
352
+ for name in names])
353
+ if names in _SCALED:
354
+ return self.box.to_cartesian(values)
355
+ return values
356
+
357
+
358
+ def iter_dump(
359
+ path: str | Path, *, sort_by_id: bool = True
360
+ ) -> Generator[DumpFrame, None, None]:
361
+ """Yield the frames of a LAMMPS dump file one at a time.
362
+
363
+ .. versionadded:: 0.6.0
364
+
365
+ Parameters
366
+ ----------
367
+ path : str or pathlib.Path
368
+ A text dump of ``dump atom`` or ``dump custom``; a name ending in
369
+ ``.gz`` is read through gzip.
370
+ sort_by_id : bool
371
+ True to order each frame's rows by the ``id`` column; LAMMPS writes
372
+ atoms in no fixed order unless ``dump_modify sort`` is set. A frame
373
+ without an ``id`` column keeps its file order.
374
+
375
+ Yields
376
+ ------
377
+ DumpFrame
378
+ The frames in file order, each read only when it is requested.
379
+
380
+ Raises
381
+ ------
382
+ BrokenDumpError
383
+ If a frame is cut off or does not follow the format; the frames
384
+ before it have been yielded. The message names the file, the frame
385
+ (from 0), its timestep when known, and the line (from 1).
386
+ FileNotFoundError
387
+ If `path` does not exist, raised when the first frame is requested.
388
+
389
+ Notes
390
+ -----
391
+ A frame whose timestep is not larger than the one before, as when a
392
+ restarted run writes its restart step again, is yielded and logged as a
393
+ warning.
394
+
395
+ Time is :math:`O(n_a n_c)` per frame for :math:`n_a` atoms and
396
+ :math:`n_c` columns; memory is that of one frame, plus the frames the
397
+ caller keeps. Each frame's atom lines are parsed by one split and one
398
+ conversion to float64, with no loop over atoms in Python.
399
+
400
+ Examples
401
+ --------
402
+ >>> import tempfile
403
+ >>> text = '''ITEM: TIMESTEP
404
+ ... 0
405
+ ... ITEM: NUMBER OF ATOMS
406
+ ... 2
407
+ ... ITEM: BOX BOUNDS pp pp pp
408
+ ... 0.0 10.0
409
+ ... 0.0 10.0
410
+ ... 0.0 10.0
411
+ ... ITEM: ATOMS id type xs ys zs
412
+ ... 2 1 0.2 0.5 0.5
413
+ ... 1 1 0.1 0.5 0.5
414
+ ... '''
415
+ >>> with tempfile.TemporaryDirectory() as tmp:
416
+ ... path = Path(tmp) / "chain.lammpstrj"
417
+ ... _ = path.write_text(text)
418
+ ... frames = list(iter_dump(path))
419
+ >>> [(frame.timestep, frame.n_atoms) for frame in frames]
420
+ [(0, 2)]
421
+ >>> frames[0].column("id")
422
+ array([1, 2])
423
+ >>> frames[0].positions()
424
+ array([[1., 5., 5.],
425
+ [2., 5., 5.]])
426
+ """
427
+ path = Path(path)
428
+ with _open_text(path) as stream:
429
+ lines = _Lines(stream, path)
430
+ units: str | None = None
431
+ previous: int | None = None
432
+ index = 0
433
+ while True:
434
+ frame = _read_frame(lines, index, units)
435
+ if frame is None:
436
+ return
437
+ units = frame.units
438
+ if previous is not None and frame.timestep <= previous:
439
+ logger.warning(
440
+ "%s: frame %d has timestep %d, not after the previous "
441
+ "frame's %d", path, index, frame.timestep, previous,
442
+ )
443
+ previous = frame.timestep
444
+ if sort_by_id and "id" in frame.columns:
445
+ order = np.argsort(frame.column("id"), kind="stable")
446
+ frame = DumpFrame(
447
+ timestep=frame.timestep, box=frame.box,
448
+ columns=frame.columns, data=frame.data[order],
449
+ time=frame.time, units=frame.units,
450
+ )
451
+ yield frame
452
+ index += 1
453
+
454
+
455
+ def read_dump_frame(
456
+ path: str | Path, index: int = 0, *, sort_by_id: bool = True
457
+ ) -> DumpFrame:
458
+ """Read one frame of a LAMMPS dump file.
459
+
460
+ .. versionadded:: 0.6.0
461
+
462
+ Parameters
463
+ ----------
464
+ path : str or pathlib.Path
465
+ As for :func:`iter_dump`.
466
+ index : int
467
+ Position of the frame in the file, from 0.
468
+ sort_by_id : bool
469
+ As for :func:`iter_dump`.
470
+
471
+ Returns
472
+ -------
473
+ DumpFrame
474
+
475
+ Raises
476
+ ------
477
+ IndexError
478
+ If `index` is negative or the file has no frame `index`.
479
+ BrokenDumpError
480
+ If a frame up to and including frame `index` is broken.
481
+
482
+ Notes
483
+ -----
484
+ Every frame before `index` is parsed: time is
485
+ :math:`O((index + 1) n_a n_c)`, memory that of one frame.
486
+
487
+ Examples
488
+ --------
489
+ >>> import tempfile
490
+ >>> frame = '''ITEM: TIMESTEP
491
+ ... {step}
492
+ ... ITEM: NUMBER OF ATOMS
493
+ ... 1
494
+ ... ITEM: BOX BOUNDS pp pp pp
495
+ ... 0 10
496
+ ... 0 10
497
+ ... 0 10
498
+ ... ITEM: ATOMS id type x y z
499
+ ... 1 1 {x} 5.0 5.0
500
+ ... '''
501
+ >>> text = frame.format(step=0, x=1.0) + frame.format(step=500, x=1.5)
502
+ >>> with tempfile.TemporaryDirectory() as tmp:
503
+ ... path = Path(tmp) / "bead.lammpstrj"
504
+ ... _ = path.write_text(text)
505
+ ... second = read_dump_frame(path, 1)
506
+ >>> second.timestep, second.positions()
507
+ (500, array([[1.5, 5. , 5. ]]))
508
+ """
509
+ if index < 0:
510
+ raise IndexError(f"'index' must be 0 or more, got {index}")
511
+ count = 0
512
+ with closing(iter_dump(path, sort_by_id=sort_by_id)) as frames:
513
+ for count, frame in enumerate(frames, start=1):
514
+ if count - 1 == index:
515
+ return frame
516
+ raise IndexError(f"{path} has {count} frames; there is no frame {index}")
517
+
518
+
519
+ class _Lines:
520
+ """The lines of an open dump, with the number of the last line read."""
521
+
522
+ def __init__(self, stream: IO[str], path: Path) -> None:
523
+ self._lines = iter(stream)
524
+ self.path = path
525
+ self.number = 0
526
+
527
+ def read(self) -> str | None:
528
+ """Return the next line, or None at the end of the file."""
529
+ line = next(self._lines, None)
530
+ if line is not None:
531
+ self.number += 1
532
+ return line
533
+
534
+ def take(self, count: int) -> list[str]:
535
+ """Return up to `count` lines; fewer only at the end of the file."""
536
+ taken = list(islice(self._lines, count))
537
+ self.number += len(taken)
538
+ return taken
539
+
540
+
541
+ def _broken(
542
+ lines: _Lines, index: int, timestep: int | None, message: str
543
+ ) -> BrokenDumpError:
544
+ """Return an error naming the file, frame, timestep, and line."""
545
+ step = "" if timestep is None else f" (timestep {timestep})"
546
+ return BrokenDumpError(
547
+ f"{lines.path}: frame {index}{step}, line {lines.number}: {message}"
548
+ )
549
+
550
+
551
+ def _read_frame(
552
+ lines: _Lines, index: int, units: str | None
553
+ ) -> DumpFrame | None:
554
+ """Read one frame, or return None at the end of the file."""
555
+ line = lines.read()
556
+ while line is not None and not line.strip():
557
+ line = lines.read()
558
+ if line is None:
559
+ return None
560
+ time: float | None = None
561
+ while True:
562
+ item = _item(lines, index, line)
563
+ if item == "TIMESTEP":
564
+ break
565
+ if item == "UNITS":
566
+ units = _first_field(lines, index, None, "UNITS")
567
+ elif item == "TIME":
568
+ time = _read_float(lines, index, None, "TIME")
569
+ else:
570
+ raise _broken(lines, index, None,
571
+ f"expected ITEM: TIMESTEP, got {line.strip()!r}")
572
+ line = _next_line(lines, index, None)
573
+ timestep = _read_int(lines, index, None, "TIMESTEP")
574
+ _expect(lines, index, timestep, "NUMBER OF ATOMS")
575
+ n_atoms = _read_int(lines, index, timestep, "NUMBER OF ATOMS")
576
+ if n_atoms < 0:
577
+ raise _broken(lines, index, timestep,
578
+ f"negative number of atoms {n_atoms}")
579
+ header = _expect(lines, index, timestep, "BOX BOUNDS")
580
+ box = _read_box(lines, index, timestep, header)
581
+ columns = tuple(_expect(lines, index, timestep, "ATOMS").split()[2:])
582
+ if not columns or len(set(columns)) != len(columns):
583
+ raise _broken(lines, index, timestep,
584
+ f"ITEM: ATOMS names no columns or repeats one: "
585
+ f"{columns}")
586
+ data = _read_atoms(lines, index, timestep, n_atoms, len(columns))
587
+ return DumpFrame(timestep=timestep, box=box, columns=columns, data=data,
588
+ time=time, units=units)
589
+
590
+
591
+ def _next_line(lines: _Lines, index: int, timestep: int | None) -> str:
592
+ """Return the next line; a frame that ends here is cut off."""
593
+ line = lines.read()
594
+ if line is None:
595
+ raise _broken(lines, index, timestep,
596
+ "the file ends inside the frame header; "
597
+ "the dump may be cut off")
598
+ return line
599
+
600
+
601
+ def _item(lines: _Lines, index: int, line: str) -> str:
602
+ """Return the name of an ``ITEM:`` line, such as ``'BOX BOUNDS'``."""
603
+ if not line.startswith(_ITEM):
604
+ raise _broken(lines, index, None,
605
+ f"expected an ITEM: line, got {line.strip()!r}")
606
+ words = line[len(_ITEM):].split()
607
+ for name in ("NUMBER OF ATOMS", "BOX BOUNDS"):
608
+ if words[:len(name.split())] == name.split():
609
+ return name
610
+ return words[0] if words else ""
611
+
612
+
613
+ def _expect(
614
+ lines: _Lines, index: int, timestep: int | None, name: str
615
+ ) -> str:
616
+ """Read the ``ITEM: <name>`` line and return it."""
617
+ line = _next_line(lines, index, timestep)
618
+ if not line.startswith(_ITEM) or _item(lines, index, line) != name:
619
+ raise _broken(lines, index, timestep,
620
+ f"expected ITEM: {name}, got {line.strip()!r}")
621
+ return line
622
+
623
+
624
+ def _first_field(
625
+ lines: _Lines, index: int, timestep: int | None, name: str
626
+ ) -> str:
627
+ """Return the first field of the value line of ``ITEM: <name>``."""
628
+ words = _next_line(lines, index, timestep).split()
629
+ if not words:
630
+ raise _broken(lines, index, timestep,
631
+ f"ITEM: {name} is not followed by a value")
632
+ return words[0]
633
+
634
+
635
+ def _read_int(
636
+ lines: _Lines, index: int, timestep: int | None, name: str
637
+ ) -> int:
638
+ """Read the integer value of ``ITEM: <name>``."""
639
+ field = _first_field(lines, index, timestep, name)
640
+ try:
641
+ return int(field)
642
+ except ValueError:
643
+ raise _broken(lines, index, timestep,
644
+ f"ITEM: {name} is not followed by an integer, got "
645
+ f"{field!r}") from None
646
+
647
+
648
+ def _read_float(
649
+ lines: _Lines, index: int, timestep: int | None, name: str
650
+ ) -> float:
651
+ """Read the floating-point value of ``ITEM: <name>``."""
652
+ field = _first_field(lines, index, timestep, name)
653
+ try:
654
+ return float(field)
655
+ except ValueError:
656
+ raise _broken(lines, index, timestep,
657
+ f"ITEM: {name} is not followed by a number, got "
658
+ f"{field!r}") from None
659
+
660
+
661
+ def _read_box(
662
+ lines: _Lines, index: int, timestep: int, header: str
663
+ ) -> Box:
664
+ """Read the three bound lines of ``ITEM: BOX BOUNDS``.
665
+
666
+ For a restricted triclinic box, the dump stores the bounds of the
667
+ orthogonal box that encloses it; the box's own bounds are
668
+ :math:`x_{lo} = x_{lo}^{bound} - \\min(0, xy, xz, xy + xz)`,
669
+ :math:`x_{hi} = x_{hi}^{bound} - \\max(0, xy, xz, xy + xz)`,
670
+ :math:`y_{lo} = y_{lo}^{bound} - \\min(0, yz)`,
671
+ :math:`y_{hi} = y_{hi}^{bound} - \\max(0, yz)`, and the z bounds
672
+ (LAMMPS ``Howto_triclinic``).
673
+ """
674
+ words = header.split()[3:]
675
+ if words[:2] == ["abc", "origin"]:
676
+ raise _broken(lines, index, timestep,
677
+ "general triclinic boxes (ITEM: BOX BOUNDS abc origin, "
678
+ "dump_modify triclinic/general) are not supported")
679
+ triclinic = words[:3] == ["xy", "xz", "yz"]
680
+ flags = words[3:] if triclinic else words
681
+ if len(flags) != 3:
682
+ raise _broken(lines, index, timestep,
683
+ f"ITEM: BOX BOUNDS needs three boundary flags, got "
684
+ f"{header.strip()!r}")
685
+ width = 3 if triclinic else 2
686
+ rows = []
687
+ for _ in range(3):
688
+ fields = _next_line(lines, index, timestep).split()
689
+ try:
690
+ row = [float(field) for field in fields]
691
+ except ValueError:
692
+ row = []
693
+ if len(row) != width:
694
+ raise _broken(lines, index, timestep,
695
+ f"a box bound line needs {width} numbers, got "
696
+ f"{' '.join(fields)!r}")
697
+ rows.append(row)
698
+ bounds = np.array(rows, dtype=np.float64)
699
+ tilt = bounds[:, 2].copy() if triclinic else np.zeros(3)
700
+ lo = bounds[:, 0].copy()
701
+ hi = bounds[:, 1].copy()
702
+ if triclinic:
703
+ xy, xz, yz = tilt
704
+ lo -= [min(0.0, xy, xz, xy + xz), min(0.0, yz), 0.0]
705
+ hi -= [max(0.0, xy, xz, xy + xz), max(0.0, yz), 0.0]
706
+ try:
707
+ return Box(lo=lo, hi=hi, tilt=tilt,
708
+ boundary=(flags[0], flags[1], flags[2]),
709
+ triclinic=triclinic)
710
+ except ValueError as err:
711
+ raise _broken(lines, index, timestep, str(err)) from None
712
+
713
+
714
+ def _read_atoms(
715
+ lines: _Lines, index: int, timestep: int, n_atoms: int, n_columns: int
716
+ ) -> FloatArray:
717
+ """Read a frame's atom lines into an ``(n_atoms, n_columns)`` array."""
718
+ first = lines.number + 1
719
+ rows = lines.take(n_atoms)
720
+ if len(rows) < n_atoms:
721
+ raise _broken(lines, index, timestep,
722
+ f"{len(rows)} of {n_atoms} atom lines; "
723
+ "the dump may be cut off")
724
+ # Each line's own count: a short line offset by a long one would keep
725
+ # the frame's total right and shift every later value.
726
+ split = [row.split() for row in rows]
727
+ try:
728
+ values = np.asarray([field for fields in split for field in fields],
729
+ dtype=np.float64)
730
+ except ValueError:
731
+ values = np.empty(0)
732
+ if values.size != n_atoms * n_columns or any(
733
+ len(fields) != n_columns for fields in split
734
+ ):
735
+ for offset, row in enumerate(rows):
736
+ if not _is_atom_line(row, n_columns):
737
+ lines.number = first + offset
738
+ break
739
+ raise _broken(lines, index, timestep,
740
+ f"an atom line needs {n_columns} numbers, got "
741
+ f"{rows[lines.number - first].strip()!r}")
742
+ return values.reshape(n_atoms, n_columns)
743
+
744
+
745
+ def _is_atom_line(row: str, n_columns: int) -> bool:
746
+ """Return True if `row` holds `n_columns` numbers."""
747
+ fields = row.split()
748
+ try:
749
+ [float(field) for field in fields]
750
+ except ValueError:
751
+ return False
752
+ return len(fields) == n_columns