chemparseplot 1.9.8__tar.gz → 1.9.10__tar.gz
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.
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/PKG-INFO +8 -1
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/_version.py +2 -2
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/api.py +52 -0
- chemparseplot-1.9.10/chemparseplot/parse/__init__.py +20 -0
- chemparseplot-1.9.10/chemparseplot/parse/grammar/__init__.py +48 -0
- chemparseplot-1.9.10/chemparseplot/parse/grammar/_deps.py +68 -0
- chemparseplot-1.9.10/chemparseplot/parse/grammar/orca_text.py +235 -0
- chemparseplot-1.9.10/chemparseplot/parse/grammar/xyz.py +181 -0
- chemparseplot-1.9.10/chemparseplot/parse/orca/__init__.py +32 -0
- chemparseplot-1.9.10/chemparseplot/parse/orca/_opi.py +67 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/orca/neb/__init__.py +8 -4
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/orca/neb/opi_parser.py +79 -30
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/plot/neb.py +164 -24
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/plot/optimization.py +26 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/pyproject.toml +16 -1
- chemparseplot-1.9.10/tests/fixtures/h2mol.xyz +4 -0
- chemparseplot-1.9.10/tests/fixtures/orca_energy_coords_snippet.out +14 -0
- {chemparseplot-1.9.8/chemparseplot/parse/orca → chemparseplot-1.9.10/tests/parse/grammar}/__init__.py +0 -4
- chemparseplot-1.9.10/tests/parse/grammar/test_grammar_deps.py +38 -0
- chemparseplot-1.9.10/tests/parse/grammar/test_orca_text_grammar.py +57 -0
- chemparseplot-1.9.10/tests/parse/grammar/test_xyz_grammar.py +61 -0
- chemparseplot-1.9.10/tests/parse/test_orca_public_api.py +168 -0
- chemparseplot-1.9.10/tests/plot/test_surface_auto_thin.py +187 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/test_coverage_batch.py +12 -0
- chemparseplot-1.9.8/chemparseplot/parse/__init__.py +0 -11
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/.gitignore +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/LICENSE +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/__init__.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/chemgp_hdf5.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/chemgp_jsonl.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/converter.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/eon/__init__.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/eon/_trajectory_common.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/eon/con_io.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/eon/dimer_trajectory.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/eon/gprd.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/eon/min_trajectory.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/eon/minimization.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/eon/neb.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/eon/saddle_search.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/eon/stitch.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/file_.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/neb_utils.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/orca/geomscan.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/orca/neb/interp.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/patterns.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/plumed.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/projection.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/sella/saddle_search.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/trajectory/__init__.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/trajectory/hdf5.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/trajectory/neb.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/parse/types.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/plot/__init__.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/plot/chemgp.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/plot/geomscan.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/plot/plumed.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/plot/structs.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/plot/theme.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/units.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/chemparseplot/util.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/readme.md +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/__init__.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/_optional_imports.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/conftest.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/fixtures_geomscan_snippet.txt +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/__init__.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/_con_fixtures.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/orca/test_geomscan.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/orca/test_interp.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_chemgp_hdf5.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_chemgp_jsonl_types.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_con_io.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_converter.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_dimer_trajectory.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_eon_neb_types.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_eon_trajectory_common.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_min_trajectory.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_minimization_result.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_neb_stitch.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_neb_utils.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_parser_types.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_patterns.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_plumed.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_projection.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_readcon_boundary.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_saddle_results.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_sella_saddle.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_trajectory_hdf5.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/parse/test_trajectory_neb.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/plot/__init__.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/plot/test_chemgp_utils.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/plot/test_neb_helpers.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/plot/test_neb_renderers.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/plot/test_optimization.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/plot/test_orca_neb_payload.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/plot/test_projection_refactor.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/plot/test_strip_rendering.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/plot/test_structs_units.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/test_full_coverage.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/test_public_api.py +0 -0
- {chemparseplot-1.9.8 → chemparseplot-1.9.10}/tests/tutorials/test_chemparseplot.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chemparseplot
|
|
3
|
-
Version: 1.9.
|
|
3
|
+
Version: 1.9.10
|
|
4
4
|
Summary: Parsers and plotting tools for computational chemistry
|
|
5
5
|
Project-URL: Documentation, https://chemparseplot.rgoswami.me
|
|
6
6
|
Project-URL: Issues, https://github.com/HaoZeke/chemparseplot/issues
|
|
@@ -25,6 +25,8 @@ Requires-Dist: ase>=3.22; extra == 'all'
|
|
|
25
25
|
Requires-Dist: cmcrameri>=1.7; extra == 'all'
|
|
26
26
|
Requires-Dist: h5py>=3.0; extra == 'all'
|
|
27
27
|
Requires-Dist: matplotlib>=3.8.2; extra == 'all'
|
|
28
|
+
Requires-Dist: orca-pi>=0.1.0; extra == 'all'
|
|
29
|
+
Requires-Dist: parsimonious>=0.10.0; extra == 'all'
|
|
28
30
|
Requires-Dist: polars>=0.20; extra == 'all'
|
|
29
31
|
Requires-Dist: readcon>=0.13.1; extra == 'all'
|
|
30
32
|
Requires-Dist: xyzrender>=0.1.2; extra == 'all'
|
|
@@ -39,6 +41,8 @@ Requires-Dist: sphinx-sitemap>=2.5.1; extra == 'doc'
|
|
|
39
41
|
Requires-Dist: sphinx-togglebutton>=0.3.2; extra == 'doc'
|
|
40
42
|
Requires-Dist: sphinx>=7.2.6; extra == 'doc'
|
|
41
43
|
Requires-Dist: sphinxcontrib-apidoc>=0.4; extra == 'doc'
|
|
44
|
+
Provides-Extra: grammar
|
|
45
|
+
Requires-Dist: parsimonious>=0.10.0; extra == 'grammar'
|
|
42
46
|
Provides-Extra: lint
|
|
43
47
|
Requires-Dist: ruff>=0.1.6; extra == 'lint'
|
|
44
48
|
Provides-Extra: neb
|
|
@@ -46,6 +50,8 @@ Requires-Dist: ase>=3.22; extra == 'neb'
|
|
|
46
50
|
Requires-Dist: h5py>=3.0; extra == 'neb'
|
|
47
51
|
Requires-Dist: polars>=0.20; extra == 'neb'
|
|
48
52
|
Requires-Dist: readcon>=0.13.1; extra == 'neb'
|
|
53
|
+
Provides-Extra: opi
|
|
54
|
+
Requires-Dist: orca-pi>=0.1.0; extra == 'opi'
|
|
49
55
|
Provides-Extra: plot
|
|
50
56
|
Requires-Dist: cmcrameri>=1.7; extra == 'plot'
|
|
51
57
|
Requires-Dist: matplotlib>=3.8.2; extra == 'plot'
|
|
@@ -58,6 +64,7 @@ Provides-Extra: test
|
|
|
58
64
|
Requires-Dist: ase>=3.22; extra == 'test'
|
|
59
65
|
Requires-Dist: h5py>=3.0; extra == 'test'
|
|
60
66
|
Requires-Dist: matplotlib>=3.8.2; extra == 'test'
|
|
67
|
+
Requires-Dist: parsimonious>=0.10.0; extra == 'test'
|
|
61
68
|
Requires-Dist: polars>=0.20; extra == 'test'
|
|
62
69
|
Requires-Dist: pytest-cov>=4.1.0; extra == 'test'
|
|
63
70
|
Requires-Dist: pytest>=7.4.3; extra == 'test'
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.9.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 9,
|
|
21
|
+
__version__ = version = '1.9.10'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 9, 10)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -45,11 +45,63 @@ __all__ = [
|
|
|
45
45
|
"convert_energy_magnitude",
|
|
46
46
|
"energy_quantity",
|
|
47
47
|
"extract_orca_geomscan_energy",
|
|
48
|
+
"grammar_available",
|
|
48
49
|
"normalize_energy_unit",
|
|
50
|
+
"parse_orca_final_energy",
|
|
51
|
+
"parse_xyz",
|
|
49
52
|
"suite_pins",
|
|
50
53
|
]
|
|
51
54
|
|
|
52
55
|
|
|
56
|
+
def grammar_available() -> bool:
|
|
57
|
+
"""True if the optional parsimonious grammar track is importable."""
|
|
58
|
+
from chemparseplot.parse.grammar import grammar_available as _ga
|
|
59
|
+
|
|
60
|
+
return _ga()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _looks_like_path(s: str) -> bool:
|
|
64
|
+
"""True for short path-like strings (avoid Path on multi-line blobs)."""
|
|
65
|
+
if "\n" in s or len(s) > 4096:
|
|
66
|
+
return False
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
return Path(s).is_file()
|
|
71
|
+
except OSError:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_xyz(path_or_text: str):
|
|
76
|
+
"""Parse XYZ via grammar track (file path if exists, else text).
|
|
77
|
+
|
|
78
|
+
Requires ``chemparseplot[grammar]``.
|
|
79
|
+
"""
|
|
80
|
+
from chemparseplot.parse.grammar.xyz import parse_xyz_file, parse_xyz_text
|
|
81
|
+
|
|
82
|
+
if _looks_like_path(path_or_text):
|
|
83
|
+
return parse_xyz_file(path_or_text)
|
|
84
|
+
return parse_xyz_text(path_or_text)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_orca_final_energy(path_or_text: str):
|
|
88
|
+
"""Last ``FINAL SINGLE POINT ENERGY`` as a hartree Quantity.
|
|
89
|
+
|
|
90
|
+
Grammar track; requires ``chemparseplot[grammar]``.
|
|
91
|
+
"""
|
|
92
|
+
from chemparseplot.parse.grammar.orca_text import (
|
|
93
|
+
parse_orca_text_file,
|
|
94
|
+
parse_orca_text_summary,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if _looks_like_path(path_or_text):
|
|
98
|
+
summary = parse_orca_text_file(path_or_text)
|
|
99
|
+
else:
|
|
100
|
+
summary = parse_orca_text_summary(path_or_text)
|
|
101
|
+
return summary.final_energy
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
53
105
|
def extract_orca_geomscan_energy(
|
|
54
106
|
data: str, energy_type: str = "Actual Energy"
|
|
55
107
|
) -> tuple[Q_, Q_]:
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from chemparseplot.parse import eon, orca, patterns
|
|
6
|
+
|
|
7
|
+
__all__ = ["eon", "grammar", "orca", "patterns"]
|
|
8
|
+
|
|
9
|
+
# Lazy imports for modules with optional heavy deps (h5py, pandas, parsimonious)
|
|
10
|
+
# Import directly: from chemparseplot.parse.chemgp_hdf5 import read_h5_table
|
|
11
|
+
# Or: from chemparseplot.parse import plumed, projection
|
|
12
|
+
# Grammar track: from chemparseplot.parse.grammar import parse_xyz_text
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def __getattr__(name: str):
|
|
16
|
+
if name == "grammar":
|
|
17
|
+
from chemparseplot.parse import grammar as _grammar
|
|
18
|
+
|
|
19
|
+
return _grammar
|
|
20
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""Grammar / AST track for text-heavy formats (parsimonious).
|
|
5
|
+
|
|
6
|
+
Optional extra: ``pip install 'chemparseplot[grammar]'``.
|
|
7
|
+
|
|
8
|
+
Prefer structured APIs (OPI) for ORCA products that expose them; use this
|
|
9
|
+
package for XYZ and text-only ORCA sections (energy lines, Cartesian blocks).
|
|
10
|
+
|
|
11
|
+
```{versionadded} 1.9.9
|
|
12
|
+
```
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from chemparseplot.parse.grammar._deps import (
|
|
18
|
+
grammar_available,
|
|
19
|
+
reset_grammar_cache,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"grammar_available",
|
|
24
|
+
"parse_orca_text_file",
|
|
25
|
+
"parse_orca_text_summary",
|
|
26
|
+
"parse_xyz_file",
|
|
27
|
+
"parse_xyz_text",
|
|
28
|
+
"reset_grammar_cache",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def __getattr__(name: str):
|
|
33
|
+
if name in ("parse_xyz_text", "parse_xyz_file", "XyzFrame", "XyzAtom", "write_xyz_text"):
|
|
34
|
+
from chemparseplot.parse.grammar import xyz as _xyz
|
|
35
|
+
|
|
36
|
+
return getattr(_xyz, name)
|
|
37
|
+
if name in (
|
|
38
|
+
"parse_orca_text_summary",
|
|
39
|
+
"parse_orca_text_file",
|
|
40
|
+
"OrcaTextSummary",
|
|
41
|
+
"OrcaAtomCoord",
|
|
42
|
+
"extract_final_energies_hartree",
|
|
43
|
+
"extract_cartesian_angstrom_blocks",
|
|
44
|
+
):
|
|
45
|
+
from chemparseplot.parse.grammar import orca_text as _ot
|
|
46
|
+
|
|
47
|
+
return getattr(_ot, name)
|
|
48
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""Lazy loading for optional ``parsimonious`` (grammar track).
|
|
5
|
+
|
|
6
|
+
```{versionadded} 1.9.9
|
|
7
|
+
```
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
import importlib.util
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
_grammar_mod: Any | None = None
|
|
17
|
+
_nodes_mod: Any | None = None
|
|
18
|
+
_probe: bool | None = None
|
|
19
|
+
|
|
20
|
+
_INSTALL_HINT = (
|
|
21
|
+
"parsimonious is not installed. "
|
|
22
|
+
"Install with: pip install 'chemparseplot[grammar]' "
|
|
23
|
+
"for grammar/AST text parsers (XYZ, text-heavy ORCA sections)."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def grammar_available() -> bool:
|
|
28
|
+
"""Return True if ``parsimonious`` can be imported."""
|
|
29
|
+
global _probe
|
|
30
|
+
if _probe is not None:
|
|
31
|
+
return _probe
|
|
32
|
+
try:
|
|
33
|
+
_probe = importlib.util.find_spec("parsimonious") is not None
|
|
34
|
+
except (ImportError, ModuleNotFoundError, ValueError):
|
|
35
|
+
_probe = False
|
|
36
|
+
return _probe
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_grammar_class() -> Any:
|
|
40
|
+
"""Return ``parsimonious.grammar.Grammar``, importing lazily."""
|
|
41
|
+
global _grammar_mod
|
|
42
|
+
if _grammar_mod is not None:
|
|
43
|
+
return _grammar_mod.Grammar
|
|
44
|
+
try:
|
|
45
|
+
_grammar_mod = importlib.import_module("parsimonious.grammar")
|
|
46
|
+
except ImportError as exc:
|
|
47
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
48
|
+
return _grammar_mod.Grammar
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_node_visitor_class() -> Any:
|
|
52
|
+
"""Return ``parsimonious.nodes.NodeVisitor``, importing lazily."""
|
|
53
|
+
global _nodes_mod
|
|
54
|
+
if _nodes_mod is not None:
|
|
55
|
+
return _nodes_mod.NodeVisitor
|
|
56
|
+
try:
|
|
57
|
+
_nodes_mod = importlib.import_module("parsimonious.nodes")
|
|
58
|
+
except ImportError as exc:
|
|
59
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
60
|
+
return _nodes_mod.NodeVisitor
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def reset_grammar_cache() -> None:
|
|
64
|
+
"""Clear cached parsimonious import state (tests only)."""
|
|
65
|
+
global _grammar_mod, _nodes_mod, _probe
|
|
66
|
+
_grammar_mod = None
|
|
67
|
+
_nodes_mod = None
|
|
68
|
+
_probe = None
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""Grammar-oriented extractors for text-heavy ORCA output sections.
|
|
5
|
+
|
|
6
|
+
Prefer structured backends (OPI) when available. This module covers sections
|
|
7
|
+
that remain text dumps: final single-point energies and Cartesian geometry
|
|
8
|
+
blocks. Foundation for VPT2/IR/populations ports from wailord.
|
|
9
|
+
|
|
10
|
+
```{versionadded} 1.9.9
|
|
11
|
+
```
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from chemparseplot.parse.grammar._deps import (
|
|
22
|
+
get_grammar_class,
|
|
23
|
+
get_node_visitor_class,
|
|
24
|
+
)
|
|
25
|
+
from chemparseplot.units import Q_
|
|
26
|
+
|
|
27
|
+
# Named rules are real expressions (not aliases) so visitors fire reliably.
|
|
28
|
+
_ENERGY_LINE_GRAMMAR_SRC = r"""
|
|
29
|
+
energy_line = label ws value ws?
|
|
30
|
+
label = "FINAL SINGLE POINT ENERGY"
|
|
31
|
+
value = ~r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?"
|
|
32
|
+
ws = ~r"[ \t]+"
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_CART_BLOCK_GRAMMAR_SRC = r"""
|
|
36
|
+
cart_block = header newline dashes newline (atom_line newline)+
|
|
37
|
+
header = "CARTESIAN COORDINATES (ANGSTROEM)"
|
|
38
|
+
dashes = ~r"-{3,}"
|
|
39
|
+
atom_line = ws? atype ws float_ ws float_ ws float_ ws?
|
|
40
|
+
atype = ~r"[A-Za-z][a-zA-Z0-9]{0,2}"
|
|
41
|
+
float_ = ~r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?"
|
|
42
|
+
newline = ~r"\n"
|
|
43
|
+
ws = ~r"[ \t]+"
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
_energy_grammar: Any | None = None
|
|
47
|
+
_cart_grammar: Any | None = None
|
|
48
|
+
|
|
49
|
+
_ENERGY_LINE_RE = re.compile(
|
|
50
|
+
r"^.*FINAL SINGLE POINT ENERGY[ \t]+"
|
|
51
|
+
r"([+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?).*$",
|
|
52
|
+
re.MULTILINE,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
_CART_BLOCK_RE = re.compile(
|
|
56
|
+
r"CARTESIAN COORDINATES \(ANGSTROEM\)\n"
|
|
57
|
+
r"-{3,}\n"
|
|
58
|
+
r"((?:[ \t]*[A-Za-z][a-zA-Z0-9]{0,2}[ \t]+"
|
|
59
|
+
r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?[ \t]+"
|
|
60
|
+
r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?[ \t]+"
|
|
61
|
+
r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?[ \t]*\n)+)",
|
|
62
|
+
re.MULTILINE,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_N_ATOMS_RE = re.compile(
|
|
66
|
+
r"Number of atoms\s*(?:\.{3,}|\.\.\.)\s*(\d+)",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _energy_g() -> Any:
|
|
71
|
+
global _energy_grammar
|
|
72
|
+
if _energy_grammar is None:
|
|
73
|
+
Grammar = get_grammar_class()
|
|
74
|
+
_energy_grammar = Grammar(_ENERGY_LINE_GRAMMAR_SRC)
|
|
75
|
+
return _energy_grammar
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cart_g() -> Any:
|
|
79
|
+
global _cart_grammar
|
|
80
|
+
if _cart_grammar is None:
|
|
81
|
+
Grammar = get_grammar_class()
|
|
82
|
+
_cart_grammar = Grammar(_CART_BLOCK_GRAMMAR_SRC)
|
|
83
|
+
return _cart_grammar
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _flatten(xs: Any) -> list[Any]:
|
|
87
|
+
out: list[Any] = []
|
|
88
|
+
if isinstance(xs, list):
|
|
89
|
+
for x in xs:
|
|
90
|
+
out.extend(_flatten(x))
|
|
91
|
+
elif xs is not None and xs != "":
|
|
92
|
+
out.append(xs)
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class OrcaAtomCoord:
|
|
98
|
+
"""One atom in an ORCA Cartesian (Å) block."""
|
|
99
|
+
|
|
100
|
+
symbol: str
|
|
101
|
+
x: float
|
|
102
|
+
y: float
|
|
103
|
+
z: float
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class OrcaTextSummary:
|
|
108
|
+
"""Structured slice of text ORCA output."""
|
|
109
|
+
|
|
110
|
+
energies_hartree: tuple[float, ...]
|
|
111
|
+
last_geometry: tuple[OrcaAtomCoord, ...]
|
|
112
|
+
n_atoms: int | None
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def final_energy_hartree(self) -> float | None:
|
|
116
|
+
if not self.energies_hartree:
|
|
117
|
+
return None
|
|
118
|
+
return self.energies_hartree[-1]
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def final_energy(self) -> Any:
|
|
122
|
+
"""Last final single-point energy as a pint Quantity (hartree)."""
|
|
123
|
+
e = self.final_energy_hartree
|
|
124
|
+
if e is None:
|
|
125
|
+
return Q_([], "hartree")
|
|
126
|
+
return Q_(e, "hartree")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def extract_final_energies_hartree(text: str) -> list[float]:
|
|
130
|
+
"""All ``FINAL SINGLE POINT ENERGY`` values in document order.
|
|
131
|
+
|
|
132
|
+
Candidate lines are selected with a line scan; each is validated and
|
|
133
|
+
extracted with a parsimonious line grammar.
|
|
134
|
+
"""
|
|
135
|
+
g = _energy_g()
|
|
136
|
+
NodeVisitor = get_node_visitor_class()
|
|
137
|
+
|
|
138
|
+
class EnergyVisitor(NodeVisitor):
|
|
139
|
+
def __init__(self) -> None:
|
|
140
|
+
self.value: float | None = None
|
|
141
|
+
|
|
142
|
+
def visit_value(self, node: Any, visited_children: Any) -> float:
|
|
143
|
+
self.value = float(node.text)
|
|
144
|
+
return self.value
|
|
145
|
+
|
|
146
|
+
def generic_visit(self, node: Any, visited_children: Any) -> Any:
|
|
147
|
+
return visited_children or node.text
|
|
148
|
+
|
|
149
|
+
out: list[float] = []
|
|
150
|
+
for m in _ENERGY_LINE_RE.finditer(text):
|
|
151
|
+
line = m.group(0).strip()
|
|
152
|
+
try:
|
|
153
|
+
tree = g.parse(line)
|
|
154
|
+
except Exception: # noqa: BLE001 — skip non-conforming noise
|
|
155
|
+
continue
|
|
156
|
+
v = EnergyVisitor()
|
|
157
|
+
v.visit(tree)
|
|
158
|
+
if v.value is not None:
|
|
159
|
+
out.append(v.value)
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def extract_cartesian_angstrom_blocks(text: str) -> list[list[OrcaAtomCoord]]:
|
|
164
|
+
"""Parse every ``CARTESIAN COORDINATES (ANGSTROEM)`` block via grammar."""
|
|
165
|
+
g = _cart_g()
|
|
166
|
+
NodeVisitor = get_node_visitor_class()
|
|
167
|
+
|
|
168
|
+
class CartVisitor(NodeVisitor):
|
|
169
|
+
def __init__(self) -> None:
|
|
170
|
+
self.atoms: list[OrcaAtomCoord] = []
|
|
171
|
+
|
|
172
|
+
def visit_atom_line(self, node: Any, visited_children: Any) -> OrcaAtomCoord:
|
|
173
|
+
flat = _flatten(visited_children)
|
|
174
|
+
symbol = next(
|
|
175
|
+
x for x in flat if isinstance(x, str) and x.strip() and x[0].isalpha()
|
|
176
|
+
)
|
|
177
|
+
nums = [x for x in flat if isinstance(x, float)]
|
|
178
|
+
atom = OrcaAtomCoord(symbol=symbol, x=nums[0], y=nums[1], z=nums[2])
|
|
179
|
+
self.atoms.append(atom)
|
|
180
|
+
return atom
|
|
181
|
+
|
|
182
|
+
def visit_atype(self, node: Any, visited_children: Any) -> str:
|
|
183
|
+
return node.text
|
|
184
|
+
|
|
185
|
+
def visit_float_(self, node: Any, visited_children: Any) -> float:
|
|
186
|
+
return float(node.text)
|
|
187
|
+
|
|
188
|
+
def generic_visit(self, node: Any, visited_children: Any) -> Any:
|
|
189
|
+
return visited_children or node.text
|
|
190
|
+
|
|
191
|
+
blocks: list[list[OrcaAtomCoord]] = []
|
|
192
|
+
for m in _CART_BLOCK_RE.finditer(text):
|
|
193
|
+
chunk = (
|
|
194
|
+
"CARTESIAN COORDINATES (ANGSTROEM)\n"
|
|
195
|
+
"---------------------------------\n"
|
|
196
|
+
+ m.group(1)
|
|
197
|
+
)
|
|
198
|
+
if not chunk.endswith("\n"):
|
|
199
|
+
chunk += "\n"
|
|
200
|
+
try:
|
|
201
|
+
tree = g.parse(chunk)
|
|
202
|
+
except Exception: # noqa: BLE001
|
|
203
|
+
continue
|
|
204
|
+
v = CartVisitor()
|
|
205
|
+
v.visit(tree)
|
|
206
|
+
if v.atoms:
|
|
207
|
+
blocks.append(v.atoms)
|
|
208
|
+
return blocks
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def extract_n_atoms(text: str) -> int | None:
|
|
212
|
+
"""Best-effort ``Number of atoms`` from ORCA text (last occurrence)."""
|
|
213
|
+
matches = _N_ATOMS_RE.findall(text)
|
|
214
|
+
if not matches:
|
|
215
|
+
return None
|
|
216
|
+
return int(matches[-1])
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def parse_orca_text_summary(text: str) -> OrcaTextSummary:
|
|
220
|
+
"""Summarize final energies and last Cartesian geometry from ORCA text."""
|
|
221
|
+
energies = extract_final_energies_hartree(text)
|
|
222
|
+
blocks = extract_cartesian_angstrom_blocks(text)
|
|
223
|
+
last_geom: tuple[OrcaAtomCoord, ...] = ()
|
|
224
|
+
if blocks:
|
|
225
|
+
last_geom = tuple(blocks[-1])
|
|
226
|
+
return OrcaTextSummary(
|
|
227
|
+
energies_hartree=tuple(energies),
|
|
228
|
+
last_geometry=last_geom,
|
|
229
|
+
n_atoms=extract_n_atoms(text),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def parse_orca_text_file(path: str | Path) -> OrcaTextSummary:
|
|
234
|
+
"""Read an ORCA ``.out`` and return :class:`OrcaTextSummary`."""
|
|
235
|
+
return parse_orca_text_summary(Path(path).read_text())
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""XYZ grammar (parsimonious) for multi-atom frames.
|
|
5
|
+
|
|
6
|
+
Port of the wailord paper thesis: structured grammar parsing for XYZ rather
|
|
7
|
+
than ad-hoc line splits. Multi-character element symbols and scientific
|
|
8
|
+
floats are supported.
|
|
9
|
+
|
|
10
|
+
```{versionadded} 1.9.9
|
|
11
|
+
```
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from chemparseplot.parse.grammar._deps import (
|
|
22
|
+
get_grammar_class,
|
|
23
|
+
get_node_visitor_class,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Each rule that needs a visitor must be a real expression (not an alias);
|
|
27
|
+
# parsimonious collapses pure aliases so visit_natoms would never fire.
|
|
28
|
+
_XYZ_GRAMMAR_SRC = r"""
|
|
29
|
+
meta = natoms newline comment_line newline coord_block ws?
|
|
30
|
+
natoms = ~r"\d+"
|
|
31
|
+
comment_line = ~r"[^\n]*"
|
|
32
|
+
coord_block = (aline newline?)+
|
|
33
|
+
aline = ws? atype ws float_ ws float_ ws float_ ws?
|
|
34
|
+
atype = ~r"[A-Za-z][a-zA-Z0-9]{0,2}"
|
|
35
|
+
float_ = ~r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?"
|
|
36
|
+
newline = ~r"\n"
|
|
37
|
+
ws = ~r"[ \t]+"
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_grammar_cache: Any | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def xyz_grammar() -> Any:
|
|
44
|
+
"""Compiled XYZ grammar (cached)."""
|
|
45
|
+
global _grammar_cache
|
|
46
|
+
if _grammar_cache is None:
|
|
47
|
+
Grammar = get_grammar_class()
|
|
48
|
+
_grammar_cache = Grammar(_XYZ_GRAMMAR_SRC)
|
|
49
|
+
return _grammar_cache
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class XyzAtom:
|
|
54
|
+
"""One atom line: element symbol and Cartesian coordinates (Å)."""
|
|
55
|
+
|
|
56
|
+
symbol: str
|
|
57
|
+
x: float
|
|
58
|
+
y: float
|
|
59
|
+
z: float
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class XyzFrame:
|
|
64
|
+
"""Parsed single-frame XYZ."""
|
|
65
|
+
|
|
66
|
+
natoms: int
|
|
67
|
+
comment: str
|
|
68
|
+
atoms: tuple[XyzAtom, ...]
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def symbols(self) -> list[str]:
|
|
72
|
+
return [a.symbol for a in self.atoms]
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def counts(self) -> Counter:
|
|
76
|
+
return Counter(self.symbols)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def system(self) -> str:
|
|
80
|
+
"""Composition slug as ``ElementCount`` joined (e.g. ``H2``, ``C1O2``)."""
|
|
81
|
+
flat: list[str] = []
|
|
82
|
+
for elem, n in self.counts.items():
|
|
83
|
+
flat.append(elem)
|
|
84
|
+
flat.append(str(n))
|
|
85
|
+
return "".join(flat)
|
|
86
|
+
|
|
87
|
+
def coordinates(self) -> list[tuple[float, float, float]]:
|
|
88
|
+
return [(a.x, a.y, a.z) for a in self.atoms]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _flatten(xs: Any) -> list[Any]:
|
|
92
|
+
out: list[Any] = []
|
|
93
|
+
if isinstance(xs, list):
|
|
94
|
+
for x in xs:
|
|
95
|
+
out.extend(_flatten(x))
|
|
96
|
+
elif xs is not None and xs != "":
|
|
97
|
+
out.append(xs)
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _make_visitor() -> Any:
|
|
102
|
+
NodeVisitor = get_node_visitor_class()
|
|
103
|
+
|
|
104
|
+
class XyzVisitor(NodeVisitor):
|
|
105
|
+
def __init__(self) -> None:
|
|
106
|
+
self.natoms: int | None = None
|
|
107
|
+
self.comment: str = ""
|
|
108
|
+
self.atoms: list[XyzAtom] = []
|
|
109
|
+
|
|
110
|
+
def visit_natoms(self, node: Any, visited_children: Any) -> int:
|
|
111
|
+
self.natoms = int(node.text)
|
|
112
|
+
return self.natoms
|
|
113
|
+
|
|
114
|
+
def visit_comment_line(self, node: Any, visited_children: Any) -> str:
|
|
115
|
+
self.comment = node.text
|
|
116
|
+
return self.comment
|
|
117
|
+
|
|
118
|
+
def visit_aline(self, node: Any, visited_children: Any) -> XyzAtom:
|
|
119
|
+
flat = _flatten(visited_children)
|
|
120
|
+
symbol = next(x for x in flat if isinstance(x, str) and x.strip() and x[0].isalpha())
|
|
121
|
+
nums = [x for x in flat if isinstance(x, float)]
|
|
122
|
+
atom = XyzAtom(symbol=symbol, x=nums[0], y=nums[1], z=nums[2])
|
|
123
|
+
self.atoms.append(atom)
|
|
124
|
+
return atom
|
|
125
|
+
|
|
126
|
+
def visit_atype(self, node: Any, visited_children: Any) -> str:
|
|
127
|
+
return node.text
|
|
128
|
+
|
|
129
|
+
def visit_float_(self, node: Any, visited_children: Any) -> float:
|
|
130
|
+
return float(node.text)
|
|
131
|
+
|
|
132
|
+
def generic_visit(self, node: Any, visited_children: Any) -> Any:
|
|
133
|
+
return visited_children or node.text
|
|
134
|
+
|
|
135
|
+
return XyzVisitor()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_xyz_text(text: str) -> XyzFrame:
|
|
139
|
+
"""Parse a single-frame XYZ string with the grammar visitor.
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
text:
|
|
144
|
+
Full XYZ content including atom count and comment line.
|
|
145
|
+
|
|
146
|
+
Returns
|
|
147
|
+
-------
|
|
148
|
+
XyzFrame
|
|
149
|
+
Structured frame (natoms, comment, atoms).
|
|
150
|
+
"""
|
|
151
|
+
body = text if text.endswith("\n") else text + "\n"
|
|
152
|
+
tree = xyz_grammar().parse(body)
|
|
153
|
+
visitor = _make_visitor()
|
|
154
|
+
visitor.visit(tree)
|
|
155
|
+
natoms = visitor.natoms if visitor.natoms is not None else len(visitor.atoms)
|
|
156
|
+
if natoms != len(visitor.atoms):
|
|
157
|
+
msg = f"XYZ natoms={natoms} but parsed {len(visitor.atoms)} atom lines"
|
|
158
|
+
raise ValueError(msg)
|
|
159
|
+
return XyzFrame(
|
|
160
|
+
natoms=natoms,
|
|
161
|
+
comment=visitor.comment,
|
|
162
|
+
atoms=tuple(visitor.atoms),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def parse_xyz_file(path: str | Path) -> XyzFrame:
|
|
167
|
+
"""Read and parse an XYZ file."""
|
|
168
|
+
return parse_xyz_text(Path(path).read_text())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def write_xyz_text(
|
|
172
|
+
frame: XyzFrame,
|
|
173
|
+
*,
|
|
174
|
+
comment: str | None = None,
|
|
175
|
+
) -> str:
|
|
176
|
+
"""Serialize a frame back to XYZ text (Å)."""
|
|
177
|
+
c = frame.comment if comment is None else comment
|
|
178
|
+
lines = [str(frame.natoms), c.rstrip("\n")]
|
|
179
|
+
for a in frame.atoms:
|
|
180
|
+
lines.append(f"{a.symbol:3s} {a.x:12.5f} {a.y:12.5f} {a.z:12.5f}")
|
|
181
|
+
return "\n".join(lines) + "\n"
|