chemparseplot 1.9.8__tar.gz → 1.9.9__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.
Files changed (102) hide show
  1. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/PKG-INFO +8 -1
  2. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/_version.py +2 -2
  3. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/api.py +52 -0
  4. chemparseplot-1.9.9/chemparseplot/parse/__init__.py +20 -0
  5. chemparseplot-1.9.9/chemparseplot/parse/grammar/__init__.py +48 -0
  6. chemparseplot-1.9.9/chemparseplot/parse/grammar/_deps.py +68 -0
  7. chemparseplot-1.9.9/chemparseplot/parse/grammar/orca_text.py +235 -0
  8. chemparseplot-1.9.9/chemparseplot/parse/grammar/xyz.py +181 -0
  9. chemparseplot-1.9.9/chemparseplot/parse/orca/__init__.py +32 -0
  10. chemparseplot-1.9.9/chemparseplot/parse/orca/_opi.py +67 -0
  11. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/orca/neb/__init__.py +8 -4
  12. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/orca/neb/opi_parser.py +79 -30
  13. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/plot/neb.py +118 -24
  14. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/plot/optimization.py +16 -0
  15. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/pyproject.toml +16 -1
  16. chemparseplot-1.9.9/tests/fixtures/h2mol.xyz +4 -0
  17. chemparseplot-1.9.9/tests/fixtures/orca_energy_coords_snippet.out +14 -0
  18. {chemparseplot-1.9.8/chemparseplot/parse/orca → chemparseplot-1.9.9/tests/parse/grammar}/__init__.py +0 -4
  19. chemparseplot-1.9.9/tests/parse/grammar/test_grammar_deps.py +38 -0
  20. chemparseplot-1.9.9/tests/parse/grammar/test_orca_text_grammar.py +57 -0
  21. chemparseplot-1.9.9/tests/parse/grammar/test_xyz_grammar.py +61 -0
  22. chemparseplot-1.9.9/tests/parse/test_orca_public_api.py +168 -0
  23. chemparseplot-1.9.9/tests/plot/test_surface_auto_thin.py +114 -0
  24. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/test_coverage_batch.py +12 -0
  25. chemparseplot-1.9.8/chemparseplot/parse/__init__.py +0 -11
  26. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/.gitignore +0 -0
  27. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/LICENSE +0 -0
  28. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/__init__.py +0 -0
  29. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/chemgp_hdf5.py +0 -0
  30. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/chemgp_jsonl.py +0 -0
  31. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/converter.py +0 -0
  32. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/eon/__init__.py +0 -0
  33. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/eon/_trajectory_common.py +0 -0
  34. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/eon/con_io.py +0 -0
  35. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/eon/dimer_trajectory.py +0 -0
  36. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/eon/gprd.py +0 -0
  37. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/eon/min_trajectory.py +0 -0
  38. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/eon/minimization.py +0 -0
  39. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/eon/neb.py +0 -0
  40. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/eon/saddle_search.py +0 -0
  41. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/eon/stitch.py +0 -0
  42. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/file_.py +0 -0
  43. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/neb_utils.py +0 -0
  44. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/orca/geomscan.py +0 -0
  45. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/orca/neb/interp.py +0 -0
  46. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/patterns.py +0 -0
  47. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/plumed.py +0 -0
  48. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/projection.py +0 -0
  49. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/sella/saddle_search.py +0 -0
  50. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/trajectory/__init__.py +0 -0
  51. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/trajectory/hdf5.py +0 -0
  52. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/trajectory/neb.py +0 -0
  53. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/parse/types.py +0 -0
  54. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/plot/__init__.py +0 -0
  55. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/plot/chemgp.py +0 -0
  56. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/plot/geomscan.py +0 -0
  57. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/plot/plumed.py +0 -0
  58. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/plot/structs.py +0 -0
  59. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/plot/theme.py +0 -0
  60. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/units.py +0 -0
  61. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/chemparseplot/util.py +0 -0
  62. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/readme.md +0 -0
  63. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/__init__.py +0 -0
  64. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/_optional_imports.py +0 -0
  65. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/conftest.py +0 -0
  66. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/fixtures_geomscan_snippet.txt +0 -0
  67. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/__init__.py +0 -0
  68. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/_con_fixtures.py +0 -0
  69. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/orca/test_geomscan.py +0 -0
  70. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/orca/test_interp.py +0 -0
  71. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_chemgp_hdf5.py +0 -0
  72. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_chemgp_jsonl_types.py +0 -0
  73. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_con_io.py +0 -0
  74. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_converter.py +0 -0
  75. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_dimer_trajectory.py +0 -0
  76. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_eon_neb_types.py +0 -0
  77. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_eon_trajectory_common.py +0 -0
  78. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_min_trajectory.py +0 -0
  79. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_minimization_result.py +0 -0
  80. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_neb_stitch.py +0 -0
  81. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_neb_utils.py +0 -0
  82. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_parser_types.py +0 -0
  83. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_patterns.py +0 -0
  84. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_plumed.py +0 -0
  85. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_projection.py +0 -0
  86. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_readcon_boundary.py +0 -0
  87. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_saddle_results.py +0 -0
  88. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_sella_saddle.py +0 -0
  89. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_trajectory_hdf5.py +0 -0
  90. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/parse/test_trajectory_neb.py +0 -0
  91. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/plot/__init__.py +0 -0
  92. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/plot/test_chemgp_utils.py +0 -0
  93. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/plot/test_neb_helpers.py +0 -0
  94. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/plot/test_neb_renderers.py +0 -0
  95. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/plot/test_optimization.py +0 -0
  96. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/plot/test_orca_neb_payload.py +0 -0
  97. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/plot/test_projection_refactor.py +0 -0
  98. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/plot/test_strip_rendering.py +0 -0
  99. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/plot/test_structs_units.py +0 -0
  100. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/test_full_coverage.py +0 -0
  101. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/tests/test_public_api.py +0 -0
  102. {chemparseplot-1.9.8 → chemparseplot-1.9.9}/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.8
3
+ Version: 1.9.9
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.8'
22
- __version_tuple__ = version_tuple = (1, 9, 8)
21
+ __version__ = version = '1.9.9'
22
+ __version_tuple__ = version_tuple = (1, 9, 9)
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"