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