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
polyphys/simulate/log.py
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
"""\
|
|
2
|
+
=====================================================================
|
|
3
|
+
LAMMPS log files --- :mod:`polyphys.simulate.log`
|
|
4
|
+
=====================================================================
|
|
5
|
+
|
|
6
|
+
The :mod:`~polyphys.simulate.log` module reads a LAMMPS log file into
|
|
7
|
+
pandas DataFrames: the thermodynamic output of every run, the summary LAMMPS
|
|
8
|
+
prints at the end of each run, and the total wall time.
|
|
9
|
+
|
|
10
|
+
A log holds one *run* per ``run`` or ``minimize`` command. Each run starts
|
|
11
|
+
with the line ``Per MPI rank memory allocation ...`` (``Memory usage per
|
|
12
|
+
processor ...`` in old versions), continues with the thermo table, and ends
|
|
13
|
+
with the line ``Loop time of ...``, after which comes the run summary. The
|
|
14
|
+
log ends with ``Total wall time: H:MM:SS``. Every marker is matched at the
|
|
15
|
+
start of a line only, because the log also echoes the input script, whose
|
|
16
|
+
comments may contain the same words.
|
|
17
|
+
|
|
18
|
+
.. versionadded:: 0.6.0
|
|
19
|
+
|
|
20
|
+
Contents
|
|
21
|
+
========
|
|
22
|
+
|
|
23
|
+
:func:`read_log` reads a file and :func:`parse_log` reads text; both return
|
|
24
|
+
a :class:`LogFile`. :class:`BrokenLogError`, a
|
|
25
|
+
:class:`~polyphys.simulate.core.LammpsFormatError`, reports a log that is cut
|
|
26
|
+
off or does not follow the format.
|
|
27
|
+
|
|
28
|
+
Thermo styles
|
|
29
|
+
=============
|
|
30
|
+
|
|
31
|
+
- ``one`` and ``custom``: a header line of keywords, then one row of values
|
|
32
|
+
per output step, separated by spaces.
|
|
33
|
+
- ``multi``: one section per output step, opened by a line
|
|
34
|
+
``------------ Step N ----- CPU = t (sec) -------------`` and followed by
|
|
35
|
+
``Keyword = value`` pairs, three per line.
|
|
36
|
+
- ``yaml``: ``---``, ``keywords: [...]``, ``data:``, one `` - [...]`` line
|
|
37
|
+
per output step, and ``...``.
|
|
38
|
+
|
|
39
|
+
``WARNING`` lines and the ``Fix halt condition ...`` message may appear
|
|
40
|
+
inside a table, and recent versions follow each with a ``For more
|
|
41
|
+
information see ...`` line; all three are skipped, and the first two are
|
|
42
|
+
logged. Blank lines are skipped. Any other line inside a table that is not
|
|
43
|
+
data raises :class:`BrokenLogError`.
|
|
44
|
+
|
|
45
|
+
Complexity
|
|
46
|
+
==========
|
|
47
|
+
|
|
48
|
+
Time and memory are :math:`O(L)` in the length :math:`L` of the log: the
|
|
49
|
+
file is read once and each line is visited a bounded number of times.
|
|
50
|
+
|
|
51
|
+
References
|
|
52
|
+
==========
|
|
53
|
+
|
|
54
|
+
The formats follow the LAMMPS documentation of ``thermo_style``,
|
|
55
|
+
``thermo_modify``, and ``run``, and these output statements in the LAMMPS
|
|
56
|
+
source, release ``stable_30Sep2026``:
|
|
57
|
+
|
|
58
|
+
- Run start: `output.cpp, line 1214
|
|
59
|
+
<https://github.com/lammps/lammps/blob/8de817dd79bfe4525d5d39246a212d833e6dee07/src/output.cpp#L1214>`_.
|
|
60
|
+
- Thermo header and rows: `thermo.cpp, lines 91, 256 to 325, and
|
|
61
|
+
387 to 419
|
|
62
|
+
<https://github.com/lammps/lammps/blob/8de817dd79bfe4525d5d39246a212d833e6dee07/src/thermo.cpp#L387-L419>`_.
|
|
63
|
+
- Run summary: `finish.cpp, lines 128 to 185, 321 to 356, and 543 to 544
|
|
64
|
+
<https://github.com/lammps/lammps/blob/8de817dd79bfe4525d5d39246a212d833e6dee07/src/finish.cpp#L128-L185>`_.
|
|
65
|
+
- Total wall time: `lammps.cpp, line 796
|
|
66
|
+
<https://github.com/lammps/lammps/blob/8de817dd79bfe4525d5d39246a212d833e6dee07/src/lammps.cpp#L796>`_.
|
|
67
|
+
- Fix halt message: `fix_halt.cpp, lines 313 to 320
|
|
68
|
+
<https://github.com/lammps/lammps/blob/8de817dd79bfe4525d5d39246a212d833e6dee07/src/fix_halt.cpp#L313-L320>`_.
|
|
69
|
+
|
|
70
|
+
Dependencies
|
|
71
|
+
============
|
|
72
|
+
- Python standard library: `dataclasses`, `enum`, `logging`, `pathlib`,
|
|
73
|
+
`re`
|
|
74
|
+
- Third-party: `pandas`
|
|
75
|
+
- PolyPhys: :mod:`polyphys.simulate.core`
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
import logging
|
|
79
|
+
import math
|
|
80
|
+
import re
|
|
81
|
+
from dataclasses import dataclass
|
|
82
|
+
from enum import Enum
|
|
83
|
+
from pathlib import Path
|
|
84
|
+
from typing import Iterator, Sequence
|
|
85
|
+
|
|
86
|
+
import pandas as pd
|
|
87
|
+
|
|
88
|
+
from polyphys.simulate.core import LammpsFormatError, _open_text
|
|
89
|
+
|
|
90
|
+
__all__ = ["BrokenLogError", "LogFile", "parse_log", "read_log"]
|
|
91
|
+
|
|
92
|
+
logger = logging.getLogger(__name__)
|
|
93
|
+
|
|
94
|
+
# Line-start markers, from the LAMMPS source lines cited in the module
|
|
95
|
+
# docstring.
|
|
96
|
+
_RUN_START = ("Per MPI rank memory allocation", "Memory usage per processor")
|
|
97
|
+
_RUN_END = "Loop time of"
|
|
98
|
+
_WALL_TIME = "Total wall time:"
|
|
99
|
+
_ERROR = "ERROR"
|
|
100
|
+
_SKIPPED_MESSAGES = ("WARNING", "Fix halt condition")
|
|
101
|
+
_MORE_INFO = "For more information see"
|
|
102
|
+
_TIMING_TABLE = "MPI task timing breakdown:"
|
|
103
|
+
|
|
104
|
+
_RUN_STATS_LEADING = (
|
|
105
|
+
"run", "loop_time", "n_cores", "loop_timesteps", "n_atoms",
|
|
106
|
+
"perf_per_day", "perf_unit", "timesteps_per_s",
|
|
107
|
+
"cpu_use", "mpi_task", "openmp_threads",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
_VERSION = re.compile(r"LAMMPS \((?P<version>.+)\)\s*")
|
|
111
|
+
_MULTI_HEADER = re.compile(
|
|
112
|
+
r"-+ Step\s+(?P<step>\S+) -+ CPU =\s+(?P<cpu>\S+) \(sec\) -+\s*"
|
|
113
|
+
)
|
|
114
|
+
_MULTI_PAIRS = re.compile(r"\s*(?:\S+\s+=\s+\S+\s*)+")
|
|
115
|
+
_MULTI_PAIR = re.compile(r"(?P<key>\S+)\s+=\s+(?P<value>\S+)")
|
|
116
|
+
_LOOP = re.compile(
|
|
117
|
+
r"Loop time of (?P<time>\S+) on (?P<procs>\d+) procs for "
|
|
118
|
+
r"(?P<steps>\d+) steps with (?P<atoms>\d+) atoms"
|
|
119
|
+
)
|
|
120
|
+
_PERFORMANCE = re.compile(
|
|
121
|
+
r"Performance: (?P<per_day>\S+) (?P<unit>\S+/day), (?:.*?, )?"
|
|
122
|
+
r"(?P<steps_per_s>\S+) timesteps/s"
|
|
123
|
+
)
|
|
124
|
+
_CPU = re.compile(
|
|
125
|
+
r"(?P<cpu>\S+)% CPU use with (?P<tasks>\d+) MPI tasks x "
|
|
126
|
+
r"(?P<threads>\d+|no) OpenMP threads"
|
|
127
|
+
)
|
|
128
|
+
_DANGEROUS = re.compile(r"Dangerous builds = (?P<count>\d+)")
|
|
129
|
+
_WALL = re.compile(
|
|
130
|
+
r"Total wall time: (?P<hours>\d+):(?P<minutes>\d{2}):(?P<seconds>\d{2})"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class BrokenLogError(LammpsFormatError):
|
|
135
|
+
"""A LAMMPS log is cut off or does not follow the log format.
|
|
136
|
+
|
|
137
|
+
Examples
|
|
138
|
+
--------
|
|
139
|
+
A log killed during its first run has no ``Loop time of`` line:
|
|
140
|
+
|
|
141
|
+
>>> text = '''LAMMPS (22 Jul 2025 - Update 4)
|
|
142
|
+
... Per MPI rank memory allocation (min/avg/max) = 4.9 | 4.9 | 4.9 Mbytes
|
|
143
|
+
... Step Temp
|
|
144
|
+
... 0 1
|
|
145
|
+
... 100 0.5
|
|
146
|
+
... '''
|
|
147
|
+
>>> try:
|
|
148
|
+
... parse_log(text)
|
|
149
|
+
... except BrokenLogError as err:
|
|
150
|
+
... print(err)
|
|
151
|
+
Run 0 (line 2) has no 'Loop time of' line; the log may be cut off.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class _ThermoStyle(Enum):
|
|
156
|
+
"""Layout of the thermo table of one run."""
|
|
157
|
+
|
|
158
|
+
ONE = "one" # also custom: a header line, then one row per output step
|
|
159
|
+
MULTI = "multi"
|
|
160
|
+
YAML = "yaml"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class _RunBlock:
|
|
165
|
+
"""The lines of one run.
|
|
166
|
+
|
|
167
|
+
Attributes
|
|
168
|
+
----------
|
|
169
|
+
index : int
|
|
170
|
+
Position of the run in the log, from 0.
|
|
171
|
+
first_line : int
|
|
172
|
+
Line number, from 1, of the run-start line in the log; error
|
|
173
|
+
messages give line numbers counted from it.
|
|
174
|
+
thermo : tuple of str
|
|
175
|
+
The lines after the run-start line, up to the ``Loop time of`` line.
|
|
176
|
+
summary : tuple of str
|
|
177
|
+
The lines from the ``Loop time of`` line up to the next run-start
|
|
178
|
+
line or the end of the log.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
index: int
|
|
182
|
+
first_line: int
|
|
183
|
+
thermo: tuple[str, ...]
|
|
184
|
+
summary: tuple[str, ...]
|
|
185
|
+
|
|
186
|
+
def numbered_thermo(self) -> Iterator[tuple[int, str]]:
|
|
187
|
+
"""Yield each thermo line with its line number in the log.
|
|
188
|
+
|
|
189
|
+
Skipped messages and blank lines are left out; ``WARNING`` and
|
|
190
|
+
``Fix halt condition`` lines are logged at level INFO.
|
|
191
|
+
"""
|
|
192
|
+
for offset, line in enumerate(self.thermo, start=1):
|
|
193
|
+
if not line.strip() or line.startswith(_MORE_INFO):
|
|
194
|
+
continue
|
|
195
|
+
if line.startswith(_SKIPPED_MESSAGES):
|
|
196
|
+
logger.info(
|
|
197
|
+
"Run %d, line %d: skipped %r",
|
|
198
|
+
self.index, self.first_line + offset, line,
|
|
199
|
+
)
|
|
200
|
+
continue
|
|
201
|
+
yield self.first_line + offset, line
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(frozen=True)
|
|
205
|
+
class LogFile:
|
|
206
|
+
"""The parsed contents of one LAMMPS log.
|
|
207
|
+
|
|
208
|
+
The DataFrames can still be changed in place; copy one before editing it.
|
|
209
|
+
|
|
210
|
+
Attributes
|
|
211
|
+
----------
|
|
212
|
+
path : pathlib.Path or None
|
|
213
|
+
The log file, or None when the log was parsed from text.
|
|
214
|
+
lammps_version : str or None
|
|
215
|
+
The version on the first line of the log, such as
|
|
216
|
+
``'22 Jul 2025 - Update 4'``, or None if the first line is not
|
|
217
|
+
``LAMMPS (<version>)``.
|
|
218
|
+
thermo : pandas.DataFrame
|
|
219
|
+
One row per thermo output step of every run, in log order. Column
|
|
220
|
+
``run`` (int64) is the position of the run in the log, from 0; the
|
|
221
|
+
other columns are the thermo keywords exactly as LAMMPS prints them,
|
|
222
|
+
such as ``Step``, ``Temp``, or ``c_gyr[1]``. ``Step`` is int64 and
|
|
223
|
+
every other keyword float64. A keyword absent from a run is NaN in
|
|
224
|
+
that run's rows. The first row of each run repeats the step at which
|
|
225
|
+
it starts, so a run that follows another without ``reset_timestep``
|
|
226
|
+
repeats the previous run's last step.
|
|
227
|
+
run_stats : pandas.DataFrame
|
|
228
|
+
One row per run, from its summary:
|
|
229
|
+
|
|
230
|
+
- ``run``: position of the run in the log, from 0.
|
|
231
|
+
- ``loop_time``: wall time of the run, in seconds.
|
|
232
|
+
- ``n_cores``: processes times OpenMP threads.
|
|
233
|
+
- ``loop_timesteps``: number of timesteps of the run.
|
|
234
|
+
- ``n_atoms``: number of atoms at the end of the run.
|
|
235
|
+
- ``perf_per_day``, ``perf_unit``, ``timesteps_per_s``: simulated
|
|
236
|
+
time per day of wall time, its unit (``'tau/day'``, ``'fs/day'``,
|
|
237
|
+
or ``'ns/day'``), and timesteps per second of wall time.
|
|
238
|
+
- ``cpu_use``: CPU use, in percent.
|
|
239
|
+
- ``mpi_task``: number of MPI tasks.
|
|
240
|
+
- ``openmp_threads``: OpenMP threads per task, 0 for a build without
|
|
241
|
+
OpenMP.
|
|
242
|
+
- ``<section>_avg_time`` and ``<section>_total_pct``: average time,
|
|
243
|
+
in seconds, and percent of the loop time spent in each section of
|
|
244
|
+
the MPI task timing breakdown, such as ``Pair_avg_time``.
|
|
245
|
+
- ``dangerous_builds``: dangerous neighbor-list builds.
|
|
246
|
+
|
|
247
|
+
A field the run does not report is NaN: LAMMPS prints no
|
|
248
|
+
performance line after ``minimize`` or in ``cgs`` and ``si`` units,
|
|
249
|
+
and no count of dangerous builds when neighbor checks are off.
|
|
250
|
+
wall_time : pandas.DataFrame
|
|
251
|
+
One row: ``wall_time_s`` (int64, seconds) and ``wall_time_hr``
|
|
252
|
+
(float64, hours) from the ``Total wall time`` line, and ``n_cores``
|
|
253
|
+
and ``n_atoms`` from the summary of the last run.
|
|
254
|
+
|
|
255
|
+
Examples
|
|
256
|
+
--------
|
|
257
|
+
>>> text = '''LAMMPS (22 Jul 2025 - Update 4)
|
|
258
|
+
... Per MPI rank memory allocation (min/avg/max) = 4.9 | 4.9 | 4.9 Mbytes
|
|
259
|
+
... Step Temp
|
|
260
|
+
... 0 1
|
|
261
|
+
... 100 0.5
|
|
262
|
+
... Loop time of 0.01 on 1 procs for 100 steps with 20 atoms
|
|
263
|
+
...
|
|
264
|
+
... Per MPI rank memory allocation (min/avg/max) = 4.9 | 4.9 | 4.9 Mbytes
|
|
265
|
+
... Step Temp
|
|
266
|
+
... 100 0.5
|
|
267
|
+
... 200 0.25
|
|
268
|
+
... Loop time of 0.02 on 1 procs for 100 steps with 20 atoms
|
|
269
|
+
...
|
|
270
|
+
... Total wall time: 0:00:01
|
|
271
|
+
... '''
|
|
272
|
+
>>> log = parse_log(text)
|
|
273
|
+
>>> log.thermo
|
|
274
|
+
run Step Temp
|
|
275
|
+
0 0 0 1.00
|
|
276
|
+
1 0 100 0.50
|
|
277
|
+
2 1 100 0.50
|
|
278
|
+
3 1 200 0.25
|
|
279
|
+
|
|
280
|
+
Keep the second run only:
|
|
281
|
+
|
|
282
|
+
>>> log.thermo[log.thermo["run"] == 1]["Temp"].tolist()
|
|
283
|
+
[0.5, 0.25]
|
|
284
|
+
>>> log.run_stats[["run", "loop_time", "loop_timesteps"]]
|
|
285
|
+
run loop_time loop_timesteps
|
|
286
|
+
0 0 0.01 100
|
|
287
|
+
1 1 0.02 100
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
path: Path | None
|
|
291
|
+
lammps_version: str | None
|
|
292
|
+
thermo: pd.DataFrame
|
|
293
|
+
run_stats: pd.DataFrame
|
|
294
|
+
wall_time: pd.DataFrame
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def read_log(path: str | Path) -> LogFile:
|
|
298
|
+
"""Read a LAMMPS log file.
|
|
299
|
+
|
|
300
|
+
Parameters
|
|
301
|
+
----------
|
|
302
|
+
path : str or pathlib.Path
|
|
303
|
+
The log file, read as UTF-8; an undecodable byte becomes U+FFFD. A
|
|
304
|
+
name ending in ``.gz`` is read through gzip.
|
|
305
|
+
|
|
306
|
+
Returns
|
|
307
|
+
-------
|
|
308
|
+
LogFile
|
|
309
|
+
The parsed log, with ``path`` set to `path` as a
|
|
310
|
+
:class:`pathlib.Path`.
|
|
311
|
+
|
|
312
|
+
Raises
|
|
313
|
+
------
|
|
314
|
+
BrokenLogError
|
|
315
|
+
If the log is cut off or does not follow the format; see
|
|
316
|
+
:func:`parse_log`.
|
|
317
|
+
|
|
318
|
+
Notes
|
|
319
|
+
-----
|
|
320
|
+
Time and memory are :math:`O(L)` in the length :math:`L` of the file.
|
|
321
|
+
|
|
322
|
+
Examples
|
|
323
|
+
--------
|
|
324
|
+
>>> import tempfile
|
|
325
|
+
>>> text = '''LAMMPS (22 Jul 2025 - Update 4)
|
|
326
|
+
... Per MPI rank memory allocation (min/avg/max) = 4.9 | 4.9 | 4.9 Mbytes
|
|
327
|
+
... Step Temp
|
|
328
|
+
... 0 1
|
|
329
|
+
... 100 0.5
|
|
330
|
+
... Loop time of 0.01 on 1 procs for 100 steps with 20 atoms
|
|
331
|
+
...
|
|
332
|
+
... Total wall time: 0:01:05
|
|
333
|
+
... '''
|
|
334
|
+
>>> with tempfile.TemporaryDirectory() as tmp:
|
|
335
|
+
... path = Path(tmp) / "log.lammps"
|
|
336
|
+
... _ = path.write_text(text)
|
|
337
|
+
... log = read_log(path)
|
|
338
|
+
>>> log.path.name
|
|
339
|
+
'log.lammps'
|
|
340
|
+
>>> log.wall_time[["wall_time_s", "n_atoms"]]
|
|
341
|
+
wall_time_s n_atoms
|
|
342
|
+
0 65 20
|
|
343
|
+
"""
|
|
344
|
+
path = Path(path)
|
|
345
|
+
with _open_text(path, errors="replace") as stream:
|
|
346
|
+
text = stream.read()
|
|
347
|
+
return parse_log(text, path)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def parse_log(text: str, path: Path | None = None) -> LogFile:
|
|
351
|
+
"""Parse the text of a LAMMPS log.
|
|
352
|
+
|
|
353
|
+
Parameters
|
|
354
|
+
----------
|
|
355
|
+
text : str
|
|
356
|
+
The whole log.
|
|
357
|
+
path : pathlib.Path or None, default None
|
|
358
|
+
The file the text came from, stored in the result.
|
|
359
|
+
|
|
360
|
+
Returns
|
|
361
|
+
-------
|
|
362
|
+
LogFile
|
|
363
|
+
The parsed log.
|
|
364
|
+
|
|
365
|
+
Raises
|
|
366
|
+
------
|
|
367
|
+
BrokenLogError
|
|
368
|
+
If the log has no run; a run has no ``Loop time of`` line, as after a
|
|
369
|
+
walltime kill, or one of another form than ``Loop time of <t> on <n>
|
|
370
|
+
procs for <steps> steps with <atoms> atoms``; an ``ERROR`` line
|
|
371
|
+
appears; a thermo header repeats a keyword; a thermo table holds a
|
|
372
|
+
line that is neither data nor a skipped message; or the log has no
|
|
373
|
+
``Total wall time`` line.
|
|
374
|
+
|
|
375
|
+
Notes
|
|
376
|
+
-----
|
|
377
|
+
Time and memory are :math:`O(L)` in the length :math:`L` of `text`.
|
|
378
|
+
|
|
379
|
+
Examples
|
|
380
|
+
--------
|
|
381
|
+
>>> text = '''LAMMPS (22 Jul 2025 - Update 4)
|
|
382
|
+
... Per MPI rank memory allocation (min/avg/max) = 4.9 | 4.9 | 4.9 Mbytes
|
|
383
|
+
... Step Temp c_rg[1]
|
|
384
|
+
... 0 1 nan
|
|
385
|
+
... WARNING: Lost atoms: original 20 current 19 (src/thermo.cpp:532)
|
|
386
|
+
... For more information see https://docs.lammps.org/err0008
|
|
387
|
+
... 100 0.5 2.5
|
|
388
|
+
... Loop time of 0.01 on 1 procs for 100 steps with 19 atoms
|
|
389
|
+
...
|
|
390
|
+
... Total wall time: 0:00:01
|
|
391
|
+
... '''
|
|
392
|
+
>>> log = parse_log(text)
|
|
393
|
+
>>> log.lammps_version
|
|
394
|
+
'22 Jul 2025 - Update 4'
|
|
395
|
+
>>> log.thermo
|
|
396
|
+
run Step Temp c_rg[1]
|
|
397
|
+
0 0 0 1.0 NaN
|
|
398
|
+
1 0 100 0.5 2.5
|
|
399
|
+
"""
|
|
400
|
+
lines = text.splitlines()
|
|
401
|
+
blocks = _split_runs(lines)
|
|
402
|
+
wall_time_s = _parse_wall_time(lines)
|
|
403
|
+
thermo_tables = []
|
|
404
|
+
stats_rows: list[dict[str, float | str]] = []
|
|
405
|
+
for block in blocks:
|
|
406
|
+
table = _parse_thermo(block)
|
|
407
|
+
table.insert(0, "run", block.index)
|
|
408
|
+
thermo_tables.append(table)
|
|
409
|
+
stats_rows.append({"run": block.index, **_parse_run_summary(block)})
|
|
410
|
+
run_stats = pd.DataFrame(stats_rows)
|
|
411
|
+
# Fixed columns first, whichever run reported them first; then the
|
|
412
|
+
# timing sections in log order; then dangerous_builds.
|
|
413
|
+
leading = [col for col in _RUN_STATS_LEADING if col in run_stats]
|
|
414
|
+
trailing = [col for col in ("dangerous_builds",) if col in run_stats]
|
|
415
|
+
sections = [col for col in run_stats
|
|
416
|
+
if col not in leading and col not in trailing]
|
|
417
|
+
run_stats = run_stats[leading + sections + trailing]
|
|
418
|
+
last_run = stats_rows[-1]
|
|
419
|
+
wall_time = pd.DataFrame(
|
|
420
|
+
{
|
|
421
|
+
"wall_time_s": [wall_time_s],
|
|
422
|
+
"wall_time_hr": [wall_time_s / 3600],
|
|
423
|
+
"n_cores": [last_run["n_cores"]],
|
|
424
|
+
"n_atoms": [last_run["n_atoms"]],
|
|
425
|
+
}
|
|
426
|
+
)
|
|
427
|
+
return LogFile(
|
|
428
|
+
path=path,
|
|
429
|
+
lammps_version=_lammps_version(lines),
|
|
430
|
+
thermo=pd.concat(thermo_tables, ignore_index=True),
|
|
431
|
+
run_stats=run_stats,
|
|
432
|
+
wall_time=wall_time,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _lammps_version(lines: Sequence[str]) -> str | None:
|
|
437
|
+
"""Return the version from the first line, ``LAMMPS (<version>)``.
|
|
438
|
+
|
|
439
|
+
Parameters
|
|
440
|
+
----------
|
|
441
|
+
lines : sequence of str
|
|
442
|
+
The lines of the log.
|
|
443
|
+
|
|
444
|
+
Returns
|
|
445
|
+
-------
|
|
446
|
+
str or None
|
|
447
|
+
The text between the parentheses, or None if the log is empty or
|
|
448
|
+
its first line has another form, as in a log opened by the ``log``
|
|
449
|
+
command.
|
|
450
|
+
"""
|
|
451
|
+
if not lines:
|
|
452
|
+
return None
|
|
453
|
+
match = _VERSION.fullmatch(lines[0])
|
|
454
|
+
return match["version"] if match else None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _split_runs(lines: Sequence[str]) -> list[_RunBlock]:
|
|
458
|
+
"""Split the lines of a log into runs.
|
|
459
|
+
|
|
460
|
+
Parameters
|
|
461
|
+
----------
|
|
462
|
+
lines : sequence of str
|
|
463
|
+
The lines of the log, without line endings.
|
|
464
|
+
|
|
465
|
+
Returns
|
|
466
|
+
-------
|
|
467
|
+
list of _RunBlock
|
|
468
|
+
The runs in log order.
|
|
469
|
+
|
|
470
|
+
Raises
|
|
471
|
+
------
|
|
472
|
+
BrokenLogError
|
|
473
|
+
If there is no run-start line, a run has no ``Loop time of`` line,
|
|
474
|
+
a run's ``Loop time of`` line has another form than the one LAMMPS
|
|
475
|
+
writes, or a line starts with ``ERROR``.
|
|
476
|
+
|
|
477
|
+
Notes
|
|
478
|
+
-----
|
|
479
|
+
Time is :math:`O(L)` in the number :math:`L` of lines.
|
|
480
|
+
"""
|
|
481
|
+
starts = []
|
|
482
|
+
for number, line in enumerate(lines):
|
|
483
|
+
if line.startswith(_ERROR):
|
|
484
|
+
raise BrokenLogError(f"Line {number + 1} reports an error: {line}")
|
|
485
|
+
if line.startswith(_RUN_START):
|
|
486
|
+
starts.append(number)
|
|
487
|
+
if not starts:
|
|
488
|
+
raise BrokenLogError(
|
|
489
|
+
"The log has no run: no line starts with "
|
|
490
|
+
f"{' or '.join(map(repr, _RUN_START))}."
|
|
491
|
+
)
|
|
492
|
+
blocks = []
|
|
493
|
+
bounds = starts + [len(lines)]
|
|
494
|
+
for index, (start, stop) in enumerate(zip(bounds[:-1], bounds[1:])):
|
|
495
|
+
end = next(
|
|
496
|
+
(n for n in range(start + 1, stop)
|
|
497
|
+
if lines[n].startswith(_RUN_END)),
|
|
498
|
+
None,
|
|
499
|
+
)
|
|
500
|
+
if end is None:
|
|
501
|
+
raise BrokenLogError(
|
|
502
|
+
f"Run {index} (line {start + 1}) has no {_RUN_END!r} line; "
|
|
503
|
+
"the log may be cut off."
|
|
504
|
+
)
|
|
505
|
+
if not _LOOP.match(lines[end]):
|
|
506
|
+
raise BrokenLogError(
|
|
507
|
+
f"Line {end + 1} starts with {_RUN_END!r} but is not the "
|
|
508
|
+
f"line 'Loop time of <t> on <n> procs for <steps> steps with "
|
|
509
|
+
f"<atoms> atoms' that LAMMPS writes: {lines[end]!r}"
|
|
510
|
+
)
|
|
511
|
+
blocks.append(
|
|
512
|
+
_RunBlock(
|
|
513
|
+
index=index,
|
|
514
|
+
first_line=start + 1,
|
|
515
|
+
thermo=tuple(lines[start + 1:end]),
|
|
516
|
+
summary=tuple(lines[end:stop]),
|
|
517
|
+
)
|
|
518
|
+
)
|
|
519
|
+
return blocks
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _detect_style(lines: Sequence[str]) -> _ThermoStyle:
|
|
523
|
+
"""Return the thermo style of a run's thermo lines.
|
|
524
|
+
|
|
525
|
+
Parameters
|
|
526
|
+
----------
|
|
527
|
+
lines : sequence of str
|
|
528
|
+
The thermo lines of one run, as in :attr:`_RunBlock.thermo`.
|
|
529
|
+
|
|
530
|
+
Returns
|
|
531
|
+
-------
|
|
532
|
+
_ThermoStyle
|
|
533
|
+
``MULTI`` if the first line that is neither blank nor a skipped
|
|
534
|
+
message is a ``------------ Step`` line, ``YAML`` if it is ``---``,
|
|
535
|
+
and ``ONE`` otherwise, including when there is no such line.
|
|
536
|
+
"""
|
|
537
|
+
for line in lines:
|
|
538
|
+
if (not line.strip() or line.startswith(_SKIPPED_MESSAGES)
|
|
539
|
+
or line.startswith(_MORE_INFO)):
|
|
540
|
+
continue
|
|
541
|
+
if _MULTI_HEADER.fullmatch(line):
|
|
542
|
+
return _ThermoStyle.MULTI
|
|
543
|
+
if line.strip() == "---":
|
|
544
|
+
return _ThermoStyle.YAML
|
|
545
|
+
return _ThermoStyle.ONE
|
|
546
|
+
return _ThermoStyle.ONE
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _parse_thermo(block: _RunBlock) -> pd.DataFrame:
|
|
550
|
+
"""Parse the thermo table of one run.
|
|
551
|
+
|
|
552
|
+
Parameters
|
|
553
|
+
----------
|
|
554
|
+
block : _RunBlock
|
|
555
|
+
One run.
|
|
556
|
+
|
|
557
|
+
Returns
|
|
558
|
+
-------
|
|
559
|
+
pandas.DataFrame
|
|
560
|
+
One row per output step and one column per keyword, named as LAMMPS
|
|
561
|
+
prints it; ``Step`` is int64 and the other columns float64. ``nan``
|
|
562
|
+
and ``inf`` values are read as such. No ``run`` column.
|
|
563
|
+
|
|
564
|
+
Raises
|
|
565
|
+
------
|
|
566
|
+
BrokenLogError
|
|
567
|
+
If the table has no header, its header repeats a keyword, or a line
|
|
568
|
+
is neither data, blank, nor a skipped message; the message names its
|
|
569
|
+
line number in the log.
|
|
570
|
+
|
|
571
|
+
Notes
|
|
572
|
+
-----
|
|
573
|
+
Time is :math:`O(n)` in the number :math:`n` of thermo lines.
|
|
574
|
+
"""
|
|
575
|
+
style = _detect_style(block.thermo)
|
|
576
|
+
if style is _ThermoStyle.MULTI:
|
|
577
|
+
table = _parse_multi(block)
|
|
578
|
+
elif style is _ThermoStyle.YAML:
|
|
579
|
+
table = _parse_yaml(block)
|
|
580
|
+
else:
|
|
581
|
+
table = _parse_one(block)
|
|
582
|
+
if "Step" in table.columns:
|
|
583
|
+
table["Step"] = table["Step"].astype("int64")
|
|
584
|
+
return table
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _parse_one(block: _RunBlock) -> pd.DataFrame:
|
|
588
|
+
"""Parse a thermo table in the ``one`` or ``custom`` style."""
|
|
589
|
+
numbered = block.numbered_thermo()
|
|
590
|
+
header = next(numbered, None)
|
|
591
|
+
if header is None:
|
|
592
|
+
raise BrokenLogError(
|
|
593
|
+
f"Run {block.index} (line {block.first_line}) has no thermo "
|
|
594
|
+
"header."
|
|
595
|
+
)
|
|
596
|
+
keywords = header[1].split()
|
|
597
|
+
_check_unique(header[0], keywords)
|
|
598
|
+
rows = [_floats(number, line.split(), len(keywords), line)
|
|
599
|
+
for number, line in numbered]
|
|
600
|
+
return pd.DataFrame(rows, columns=keywords, dtype="float64")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _parse_multi(block: _RunBlock) -> pd.DataFrame:
|
|
604
|
+
"""Parse a thermo table in the ``multi`` style."""
|
|
605
|
+
records: list[dict[str, float]] = []
|
|
606
|
+
for number, line in block.numbered_thermo():
|
|
607
|
+
header = _MULTI_HEADER.fullmatch(line)
|
|
608
|
+
if header:
|
|
609
|
+
records.append({
|
|
610
|
+
"Step": _float(number, header["step"], line),
|
|
611
|
+
"CPU": _float(number, header["cpu"], line),
|
|
612
|
+
})
|
|
613
|
+
elif records and _MULTI_PAIRS.fullmatch(line):
|
|
614
|
+
for pair in _MULTI_PAIR.finditer(line):
|
|
615
|
+
records[-1][pair["key"]] = _float(number, pair["value"], line)
|
|
616
|
+
else:
|
|
617
|
+
raise _not_data(number, line)
|
|
618
|
+
return pd.DataFrame.from_records(records).astype("float64")
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _parse_yaml(block: _RunBlock) -> pd.DataFrame:
|
|
622
|
+
"""Parse a thermo table in the ``yaml`` style."""
|
|
623
|
+
keywords: list[str] | None = None
|
|
624
|
+
rows: list[list[float]] = []
|
|
625
|
+
for number, line in block.numbered_thermo():
|
|
626
|
+
stripped = line.strip()
|
|
627
|
+
if stripped in ("---", "data:", "..."):
|
|
628
|
+
continue
|
|
629
|
+
if stripped.startswith("keywords: [") and stripped.endswith("]"):
|
|
630
|
+
keywords = [word.strip().strip("'\"")
|
|
631
|
+
for word in _yaml_items(stripped[len("keywords:"):])]
|
|
632
|
+
_check_unique(number, keywords)
|
|
633
|
+
elif (keywords is not None and stripped.startswith("- [")
|
|
634
|
+
and stripped.endswith("]")):
|
|
635
|
+
rows.append(_floats(number, _yaml_items(stripped[1:]),
|
|
636
|
+
len(keywords), line))
|
|
637
|
+
else:
|
|
638
|
+
raise _not_data(number, line)
|
|
639
|
+
if keywords is None:
|
|
640
|
+
raise BrokenLogError(
|
|
641
|
+
f"Run {block.index} (line {block.first_line}) has no "
|
|
642
|
+
"'keywords:' line."
|
|
643
|
+
)
|
|
644
|
+
return pd.DataFrame(rows, columns=keywords, dtype="float64")
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _check_unique(number: int, keywords: Sequence[str]) -> None:
|
|
648
|
+
"""Raise BrokenLogError if a thermo header repeats a keyword."""
|
|
649
|
+
seen: set[str] = set()
|
|
650
|
+
for keyword in keywords:
|
|
651
|
+
if keyword in seen:
|
|
652
|
+
raise BrokenLogError(
|
|
653
|
+
f"Line {number} repeats the thermo keyword {keyword!r}; "
|
|
654
|
+
"each keyword must name one column."
|
|
655
|
+
)
|
|
656
|
+
seen.add(keyword)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _yaml_items(flow_list: str) -> list[str]:
|
|
660
|
+
"""Return the items of a YAML flow list such as ``[0, 1.5, ]``."""
|
|
661
|
+
inner = flow_list.strip()[1:-1]
|
|
662
|
+
return [item.strip() for item in inner.split(",") if item.strip()]
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _floats(number: int, fields: Sequence[str], expected: int,
|
|
666
|
+
line: str) -> list[float]:
|
|
667
|
+
"""Convert the fields of one data row, checking their count."""
|
|
668
|
+
if len(fields) != expected:
|
|
669
|
+
raise _not_data(number, line)
|
|
670
|
+
return [_float(number, field, line) for field in fields]
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _float(number: int, field: str, line: str) -> float:
|
|
674
|
+
"""Convert one field, reporting its line on failure."""
|
|
675
|
+
try:
|
|
676
|
+
return float(field)
|
|
677
|
+
except ValueError:
|
|
678
|
+
raise _not_data(number, line) from None
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _not_data(number: int, line: str) -> BrokenLogError:
|
|
682
|
+
"""Return the error for a line of a thermo table that is not data."""
|
|
683
|
+
return BrokenLogError(
|
|
684
|
+
f"Line {number} is inside a thermo table but is not data: {line!r}"
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _parse_run_summary(block: _RunBlock) -> dict[str, float | str]:
|
|
689
|
+
"""Parse the summary that ends one run.
|
|
690
|
+
|
|
691
|
+
Parameters
|
|
692
|
+
----------
|
|
693
|
+
block : _RunBlock
|
|
694
|
+
One run.
|
|
695
|
+
|
|
696
|
+
Returns
|
|
697
|
+
-------
|
|
698
|
+
dict of str to float or str
|
|
699
|
+
The fields of one row of :attr:`LogFile.run_stats`, without
|
|
700
|
+
``run``; a field the summary lacks is absent from the dict. When a
|
|
701
|
+
line occurs more than once, the first occurrence counts.
|
|
702
|
+
|
|
703
|
+
Notes
|
|
704
|
+
-----
|
|
705
|
+
Time is :math:`O(n)` in the number :math:`n` of summary lines.
|
|
706
|
+
"""
|
|
707
|
+
fields: dict[str, float | str] = {}
|
|
708
|
+
lines = block.summary
|
|
709
|
+
number = 0
|
|
710
|
+
while number < len(lines):
|
|
711
|
+
line = lines[number]
|
|
712
|
+
number += 1
|
|
713
|
+
if (match := _LOOP.match(line)) and "loop_time" not in fields:
|
|
714
|
+
fields.update(
|
|
715
|
+
loop_time=float(match["time"]),
|
|
716
|
+
n_cores=int(match["procs"]),
|
|
717
|
+
loop_timesteps=int(match["steps"]),
|
|
718
|
+
n_atoms=int(match["atoms"]),
|
|
719
|
+
)
|
|
720
|
+
elif (match := _PERFORMANCE.match(line)) and \
|
|
721
|
+
"perf_per_day" not in fields:
|
|
722
|
+
fields.update(
|
|
723
|
+
perf_per_day=float(match["per_day"]),
|
|
724
|
+
perf_unit=match["unit"],
|
|
725
|
+
timesteps_per_s=float(match["steps_per_s"]),
|
|
726
|
+
)
|
|
727
|
+
elif (match := _CPU.match(line)) and "cpu_use" not in fields:
|
|
728
|
+
threads = match["threads"]
|
|
729
|
+
fields.update(
|
|
730
|
+
cpu_use=float(match["cpu"]),
|
|
731
|
+
mpi_task=int(match["tasks"]),
|
|
732
|
+
openmp_threads=0 if threads == "no" else int(threads),
|
|
733
|
+
)
|
|
734
|
+
elif (match := _DANGEROUS.match(line)) and \
|
|
735
|
+
"dangerous_builds" not in fields:
|
|
736
|
+
fields["dangerous_builds"] = int(match["count"])
|
|
737
|
+
elif line.startswith(_TIMING_TABLE) and number + 2 <= len(lines):
|
|
738
|
+
# The column header, a line of dashes, then one row per section
|
|
739
|
+
# until a blank line.
|
|
740
|
+
columns = [cell.strip() for cell in lines[number].split("|")]
|
|
741
|
+
number += 2
|
|
742
|
+
while number < len(lines) and lines[number].strip():
|
|
743
|
+
cells = [cell.strip() for cell in lines[number].split("|")]
|
|
744
|
+
number += 1
|
|
745
|
+
if len(cells) != len(columns):
|
|
746
|
+
continue
|
|
747
|
+
row = dict(zip(columns, cells))
|
|
748
|
+
section = row.get("Section")
|
|
749
|
+
if not section:
|
|
750
|
+
continue
|
|
751
|
+
fields.setdefault(f"{section}_avg_time",
|
|
752
|
+
_float_or_nan(row.get("avg time", "")))
|
|
753
|
+
fields.setdefault(f"{section}_total_pct",
|
|
754
|
+
_float_or_nan(row.get("%total", "")))
|
|
755
|
+
return fields
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _float_or_nan(cell: str) -> float:
|
|
759
|
+
"""Return a timing-table cell as a float, NaN if blank or not numeric."""
|
|
760
|
+
try:
|
|
761
|
+
return float(cell)
|
|
762
|
+
except ValueError:
|
|
763
|
+
return math.nan
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _parse_wall_time(lines: Sequence[str]) -> int:
|
|
767
|
+
"""Return the total wall time, in seconds.
|
|
768
|
+
|
|
769
|
+
Parameters
|
|
770
|
+
----------
|
|
771
|
+
lines : sequence of str
|
|
772
|
+
The lines of the log.
|
|
773
|
+
|
|
774
|
+
Returns
|
|
775
|
+
-------
|
|
776
|
+
int
|
|
777
|
+
Seconds, from the line ``Total wall time: H:MM:SS``.
|
|
778
|
+
|
|
779
|
+
Raises
|
|
780
|
+
------
|
|
781
|
+
BrokenLogError
|
|
782
|
+
If no line starts with ``Total wall time:``.
|
|
783
|
+
"""
|
|
784
|
+
for line in reversed(lines):
|
|
785
|
+
if line.startswith(_WALL_TIME) and (match := _WALL.match(line)):
|
|
786
|
+
return (int(match["hours"]) * 3600 + int(match["minutes"]) * 60
|
|
787
|
+
+ int(match["seconds"]))
|
|
788
|
+
raise BrokenLogError(f"The log has no {_WALL_TIME!r} line.")
|