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