chemparseplot 1.9.13__tar.gz → 1.9.15__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 (103) hide show
  1. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/PKG-INFO +1 -1
  2. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/_version.py +2 -2
  3. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/eon/__init__.py +2 -2
  4. chemparseplot-1.9.15/chemparseplot/parse/eon/frame_series.py +191 -0
  5. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/plot/neb.py +43 -30
  6. chemparseplot-1.9.15/tests/parse/test_frame_series.py +121 -0
  7. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/plot/test_neb_renderers.py +19 -12
  8. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/.gitignore +0 -0
  9. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/LICENSE +0 -0
  10. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/__init__.py +0 -0
  11. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/api.py +0 -0
  12. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/__init__.py +0 -0
  13. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/chemgp_hdf5.py +0 -0
  14. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/chemgp_jsonl.py +0 -0
  15. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/converter.py +0 -0
  16. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/eon/_trajectory_common.py +0 -0
  17. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/eon/con_io.py +0 -0
  18. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/eon/dimer_trajectory.py +0 -0
  19. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/eon/gprd.py +0 -0
  20. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/eon/min_trajectory.py +0 -0
  21. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/eon/minimization.py +0 -0
  22. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/eon/neb.py +0 -0
  23. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/eon/saddle_search.py +0 -0
  24. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/eon/stitch.py +0 -0
  25. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/file_.py +0 -0
  26. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/grammar/__init__.py +0 -0
  27. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/grammar/_deps.py +0 -0
  28. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/grammar/orca_text.py +0 -0
  29. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/grammar/xyz.py +0 -0
  30. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/neb_utils.py +0 -0
  31. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/orca/__init__.py +0 -0
  32. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/orca/_opi.py +0 -0
  33. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/orca/geomscan.py +0 -0
  34. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/orca/neb/__init__.py +0 -0
  35. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/orca/neb/interp.py +0 -0
  36. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/orca/neb/opi_parser.py +0 -0
  37. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/patterns.py +0 -0
  38. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/plumed.py +0 -0
  39. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/projection.py +0 -0
  40. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/sella/saddle_search.py +0 -0
  41. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/trajectory/__init__.py +0 -0
  42. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/trajectory/hdf5.py +0 -0
  43. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/trajectory/neb.py +0 -0
  44. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/parse/types.py +0 -0
  45. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/plot/__init__.py +0 -0
  46. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/plot/chemgp.py +0 -0
  47. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/plot/geomscan.py +0 -0
  48. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/plot/optimization.py +0 -0
  49. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/plot/plumed.py +0 -0
  50. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/plot/structs.py +0 -0
  51. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/plot/theme.py +0 -0
  52. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/units.py +0 -0
  53. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/chemparseplot/util.py +0 -0
  54. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/pyproject.toml +0 -0
  55. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/readme.md +0 -0
  56. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/__init__.py +0 -0
  57. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/_optional_imports.py +0 -0
  58. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/conftest.py +0 -0
  59. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/fixtures/h2mol.xyz +0 -0
  60. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/fixtures/orca_energy_coords_snippet.out +0 -0
  61. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/fixtures_geomscan_snippet.txt +0 -0
  62. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/__init__.py +0 -0
  63. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/_con_fixtures.py +0 -0
  64. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/grammar/__init__.py +0 -0
  65. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/grammar/test_grammar_deps.py +0 -0
  66. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/grammar/test_orca_text_grammar.py +0 -0
  67. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/grammar/test_xyz_grammar.py +0 -0
  68. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/orca/test_geomscan.py +0 -0
  69. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/orca/test_interp.py +0 -0
  70. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_chemgp_hdf5.py +0 -0
  71. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_chemgp_jsonl_types.py +0 -0
  72. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_con_io.py +0 -0
  73. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_converter.py +0 -0
  74. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_dimer_trajectory.py +0 -0
  75. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_eon_neb_types.py +0 -0
  76. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_eon_trajectory_common.py +0 -0
  77. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_min_trajectory.py +0 -0
  78. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_minimization_result.py +0 -0
  79. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_neb_stitch.py +0 -0
  80. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_neb_utils.py +0 -0
  81. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_orca_public_api.py +0 -0
  82. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_parser_types.py +0 -0
  83. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_patterns.py +0 -0
  84. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_plumed.py +0 -0
  85. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_projection.py +0 -0
  86. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_readcon_boundary.py +0 -0
  87. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_saddle_results.py +0 -0
  88. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_sella_saddle.py +0 -0
  89. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_trajectory_hdf5.py +0 -0
  90. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/parse/test_trajectory_neb.py +0 -0
  91. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/plot/__init__.py +0 -0
  92. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/plot/test_chemgp_utils.py +0 -0
  93. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/plot/test_neb_helpers.py +0 -0
  94. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/plot/test_optimization.py +0 -0
  95. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/plot/test_orca_neb_payload.py +0 -0
  96. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/plot/test_projection_refactor.py +0 -0
  97. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/plot/test_strip_rendering.py +0 -0
  98. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/plot/test_structs_units.py +0 -0
  99. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/plot/test_surface_auto_thin.py +0 -0
  100. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/test_coverage_batch.py +0 -0
  101. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/test_full_coverage.py +0 -0
  102. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/tests/test_public_api.py +0 -0
  103. {chemparseplot-1.9.13 → chemparseplot-1.9.15}/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.13
3
+ Version: 1.9.15
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
@@ -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.13'
22
- __version_tuple__ = version_tuple = (1, 9, 13)
21
+ __version__ = version = '1.9.15'
22
+ __version_tuple__ = version_tuple = (1, 9, 15)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -3,6 +3,6 @@
3
3
  # SPDX-License-Identifier: MIT
4
4
  """eOn trajectory parsers."""
5
5
 
6
- from chemparseplot.parse.eon import con_io
6
+ from chemparseplot.parse.eon import con_io, frame_series
7
7
 
8
- __all__ = ["con_io"]
8
+ __all__ = ["con_io", "frame_series"]
@@ -0,0 +1,191 @@
1
+ # SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """ConFrame sequence → series / trajectory DTOs for eOn plots.
5
+
6
+ The plot packet is an ordered sequence of ``readcon.ConFrame`` (or duck-typed
7
+ frames with ``energy``, ``metadata``, ``to_ase`` / geometry). ASE and Matter
8
+ are adapters into this wire format; this module does not invent a second schema.
9
+
10
+ ```{versionadded} 1.9.14
11
+ ```
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Sequence
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+ import polars as pl
21
+ from ase import Atoms
22
+
23
+ from chemparseplot.parse.eon._trajectory_common import (
24
+ frame_rows_to_table,
25
+ metadata_value,
26
+ )
27
+ from chemparseplot.parse.eon.dimer_trajectory import (
28
+ DIMER_METADATA_COLUMNS,
29
+ DimerTrajectoryData,
30
+ table_from_dimer_metadata,
31
+ )
32
+ from chemparseplot.parse.eon.min_trajectory import (
33
+ MIN_METADATA_COLUMNS,
34
+ MinTrajectoryData,
35
+ table_from_min_metadata,
36
+ )
37
+
38
+ __all__ = [
39
+ "atoms_list_from_frames",
40
+ "dimer_trajectory_from_frames",
41
+ "energies_from_frames",
42
+ "min_trajectory_from_frames",
43
+ "neb_path_arrays",
44
+ "series_from_frames",
45
+ ]
46
+
47
+
48
+ def energies_from_frames(frames: Sequence[Any]) -> np.ndarray:
49
+ """Total energy (eV) per frame; raises if any frame lacks energy."""
50
+ if not frames:
51
+ msg = "energies_from_frames: empty frame sequence"
52
+ raise ValueError(msg)
53
+ out = np.empty(len(frames), dtype=float)
54
+ for i, frame in enumerate(frames):
55
+ e = metadata_value(frame, "energy")
56
+ if e is None:
57
+ msg = f"frame {i} has no energy stamp"
58
+ raise ValueError(msg)
59
+ out[i] = float(e)
60
+ return out
61
+
62
+
63
+ def atoms_list_from_frames(frames: Sequence[Any]) -> list[Atoms]:
64
+ """Convert frames to ASE Atoms with energy stored in ``atoms.info``."""
65
+ atoms_list: list[Atoms] = []
66
+ for i, frame in enumerate(frames):
67
+ to_ase = getattr(frame, "to_ase", None)
68
+ if not callable(to_ase):
69
+ msg = f"frame {i} has no to_ase(); need readcon.ConFrame or equivalent"
70
+ raise TypeError(msg)
71
+ atoms = to_ase()
72
+ e = metadata_value(frame, "energy")
73
+ if e is not None:
74
+ atoms.info["energy"] = float(e)
75
+ atoms_list.append(atoms)
76
+ return atoms_list
77
+
78
+
79
+ def series_from_frames(
80
+ frames: Sequence[Any],
81
+ columns: Sequence[str],
82
+ *,
83
+ allow_leading_incomplete: bool = False,
84
+ ) -> pl.DataFrame:
85
+ """Build a polars table of metadata columns from frames (empty if incomplete)."""
86
+ return frame_rows_to_table(
87
+ frames,
88
+ columns,
89
+ allow_leading_incomplete=allow_leading_incomplete,
90
+ )
91
+
92
+
93
+ def neb_path_arrays(frames: Sequence[Any]) -> dict[str, Any]:
94
+ """Extract NEB path series from stamped frames (writePathCon / path_frames).
95
+
96
+ Returns
97
+ -------
98
+ dict
99
+ ``atoms_list``, ``energies``, optional ``reaction_coordinate``,
100
+ ``relative_energy``, ``parallel_force``, ``neb_bead``, ``frame_index``.
101
+ """
102
+ if not frames:
103
+ msg = "neb_path_arrays: empty frame sequence"
104
+ raise ValueError(msg)
105
+ atoms_list = atoms_list_from_frames(frames)
106
+ energies = energies_from_frames(frames)
107
+ result: dict[str, Any] = {
108
+ "atoms_list": atoms_list,
109
+ "energies": energies,
110
+ "n_frames": len(frames),
111
+ }
112
+ for key in (
113
+ "reaction_coordinate",
114
+ "relative_energy",
115
+ "parallel_force",
116
+ "neb_bead",
117
+ "frame_index",
118
+ "lowest_eigenvalue",
119
+ ):
120
+ vals = []
121
+ ok = True
122
+ for frame in frames:
123
+ v = metadata_value(frame, key)
124
+ if v is None and key in ("reaction_coordinate", "relative_energy", "parallel_force"):
125
+ # optional NEB stamps
126
+ ok = False
127
+ break
128
+ vals.append(v)
129
+ if ok and any(v is not None for v in vals):
130
+ result[key] = np.array(
131
+ [np.nan if v is None else float(v) for v in vals],
132
+ dtype=float,
133
+ )
134
+ return result
135
+
136
+
137
+ def min_trajectory_from_frames(frames: Sequence[Any]) -> MinTrajectoryData:
138
+ """Build :class:`MinTrajectoryData` from stamped minimization ConFrames."""
139
+ if not frames:
140
+ msg = "min_trajectory_from_frames: empty frame sequence"
141
+ raise ValueError(msg)
142
+ atoms_list = atoms_list_from_frames(frames)
143
+ dat_df = table_from_min_metadata(list(frames))
144
+ if dat_df.is_empty():
145
+ # Minimal table from energy + sequential iteration
146
+ energies = energies_from_frames(frames)
147
+ dat_df = pl.DataFrame(
148
+ {
149
+ "iteration": list(range(len(frames))),
150
+ "step_size": [0.0] * len(frames),
151
+ "convergence": [0.0] * len(frames),
152
+ "energy": energies.tolist(),
153
+ }
154
+ )
155
+ return MinTrajectoryData(
156
+ atoms_list=atoms_list,
157
+ dat_df=dat_df,
158
+ initial_atoms=atoms_list[0],
159
+ final_atoms=atoms_list[-1],
160
+ )
161
+
162
+
163
+ def dimer_trajectory_from_frames(frames: Sequence[Any]) -> DimerTrajectoryData:
164
+ """Build :class:`DimerTrajectoryData` from stamped climb ConFrames."""
165
+ if not frames:
166
+ msg = "dimer_trajectory_from_frames: empty frame sequence"
167
+ raise ValueError(msg)
168
+ atoms_list = atoms_list_from_frames(frames)
169
+ dat_df = table_from_dimer_metadata(list(frames))
170
+ if dat_df.is_empty():
171
+ energies = energies_from_frames(frames)
172
+ e0 = float(energies[0])
173
+ dat_df = pl.DataFrame(
174
+ {
175
+ "iteration": list(range(len(frames))),
176
+ "step_size": [0.0] * len(frames),
177
+ "delta_e": (energies - e0).tolist(),
178
+ "convergence": [0.0] * len(frames),
179
+ "eigenvalue": [0.0] * len(frames),
180
+ "torque": [0.0] * len(frames),
181
+ "angle": [0.0] * len(frames),
182
+ "rotations": [0] * len(frames),
183
+ }
184
+ )
185
+ return DimerTrajectoryData(
186
+ atoms_list=atoms_list,
187
+ dat_df=dat_df,
188
+ initial_atoms=atoms_list[0],
189
+ saddle_atoms=atoms_list[-1],
190
+ mode_vector=None,
191
+ )
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import io
4
4
  import logging
5
5
  import shutil
6
- import subprocess
7
6
  import tempfile
8
7
  from dataclasses import dataclass
9
8
  from pathlib import Path
@@ -198,20 +197,40 @@ def render_structure_to_image(atoms, zoom, rotation): # noqa: ARG001
198
197
  return img_data
199
198
 
200
199
 
200
+ def _import_xyzrender():
201
+ """Import xyzrender via ensure_import (AUTO_DEPS) or a normal import.
202
+
203
+ Prefer the Python API over a PATH binary. Matches the suite lazy-dep model
204
+ (rgpycrumbs ``ensure_import`` / ``RGPYCRUMBS_AUTO_DEPS``).
205
+ """
206
+ try:
207
+ from rgpycrumbs._aux import enable_library_auto_deps, ensure_import
208
+
209
+ enable_library_auto_deps()
210
+ return ensure_import("xyzrender")
211
+ except ImportError:
212
+ pass
213
+ try:
214
+ import xyzrender
215
+ except ImportError as exc:
216
+ msg = (
217
+ "xyzrender is required for strip_renderer='xyzrender'. "
218
+ "Install with: pip install 'xyzrender>=0.1.3' "
219
+ "(or set RGPYCRUMBS_AUTO_DEPS=1 so ensure_import can stage it)"
220
+ )
221
+ raise RuntimeError(msg) from exc
222
+ return xyzrender
223
+
224
+
201
225
  def _check_xyzrender():
202
- """Verify that the ``xyzrender`` binary is on PATH.
226
+ """Verify xyzrender is importable (package or ensure_import cache).
203
227
 
204
228
  Raises
205
229
  ------
206
230
  RuntimeError
207
- If xyzrender is not found, with install instructions.
231
+ If xyzrender cannot be imported, with install instructions.
208
232
  """
209
- if shutil.which("xyzrender") is None:
210
- msg = (
211
- "xyzrender binary not found on PATH. "
212
- "Install with: pip install 'xyzrender>=0.1.3'"
213
- )
214
- raise RuntimeError(msg)
233
+ _import_xyzrender()
215
234
 
216
235
 
217
236
  def _apply_perspective_tilt(atoms, tilt_deg=8.0):
@@ -267,9 +286,8 @@ def _parse_rotation_angles(rotation_str):
267
286
  def _render_xyzrender(atoms, rotation="auto", canvas_size=400, config="paton"):
268
287
  """Render an ASE Atoms object to a numpy RGBA array via xyzrender.
269
288
 
270
- Uses the specified config preset (default: ``paton`` for ball-and-stick).
271
- Other useful presets: ``bubble`` (space-filling, good for surfaces),
272
- ``flat``, ``tube``, ``wire``, ``skeletal``.
289
+ Uses the **Python API** (``xyzrender.render``), staged through
290
+ ``ensure_import`` when ``RGPYCRUMBS_AUTO_DEPS=1`` — not a PATH binary.
273
291
 
274
292
  Parameters
275
293
  ----------
@@ -280,15 +298,18 @@ def _render_xyzrender(atoms, rotation="auto", canvas_size=400, config="paton"):
280
298
  Any ASE-style string (e.g. ``"0x,90y,0z"``) disables auto-orient
281
299
  and pre-rotates the atoms.
282
300
  canvas_size : int
283
- Output image width/height in pixels (passed as ``-S``).
301
+ Output image width/height in pixels.
284
302
 
285
303
  Returns
286
304
  -------
287
305
  numpy.ndarray
288
306
  RGBA image array with shape ``(H, W, 4)`` and float dtype.
289
307
  """
308
+ import os
309
+
290
310
  from ase.io import write as _ase_write
291
311
 
312
+ xr = _import_xyzrender()
292
313
  with tempfile.NamedTemporaryFile(suffix=".xyz", delete=False) as xyz_fh:
293
314
  xyz_path = xyz_fh.name
294
315
  png_path = xyz_path.rsplit(".", 1)[0] + ".png"
@@ -299,31 +320,23 @@ def _render_xyzrender(atoms, rotation="auto", canvas_size=400, config="paton"):
299
320
 
300
321
  if has_custom_rotation:
301
322
  rx, ry, rz = _parse_rotation_angles(rotation)
302
- # Pre-rotate and disable auto-orient
303
323
  atoms.rotate(rx, "x", center="COP")
304
324
  atoms.rotate(ry, "y", center="COP")
305
325
  atoms.rotate(rz, "z", center="COP")
306
326
 
307
327
  _ase_write(xyz_path, atoms, format="xyz")
308
- cmd = [
309
- "xyzrender",
328
+ # Python API: same knobs as CLI --hy -t -S --config
329
+ xr.render(
310
330
  xyz_path,
311
- "-o",
312
- png_path,
313
- "-S",
314
- str(canvas_size),
315
- "--config",
316
- config,
317
- "--hy",
318
- "-t",
319
- ]
320
- if has_custom_rotation:
321
- cmd.append("--no-orient")
322
- subprocess.run(cmd, check=True, capture_output=True) # noqa: S603
331
+ config=config,
332
+ canvas_size=canvas_size,
333
+ hy=True,
334
+ transparent=True,
335
+ orient=not has_custom_rotation,
336
+ output=png_path,
337
+ )
323
338
  img_data = plt.imread(png_path)
324
339
  finally:
325
- import os
326
-
327
340
  for p in (xyz_path, png_path):
328
341
  try:
329
342
  os.unlink(p)
@@ -0,0 +1,121 @@
1
+ # SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """ConFrame sequence → series used by eOn plot paths."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+ import pytest
10
+
11
+ pytest.importorskip("readcon")
12
+ pytest.importorskip("ase")
13
+ pytest.importorskip("polars")
14
+
15
+ from ase import Atoms
16
+
17
+ from chemparseplot.parse.eon.frame_series import (
18
+ atoms_list_from_frames,
19
+ dimer_trajectory_from_frames,
20
+ energies_from_frames,
21
+ min_trajectory_from_frames,
22
+ neb_path_arrays,
23
+ )
24
+
25
+
26
+ def _h_frame(energy: float, z: float, *, frame_index: int, **scalars):
27
+ import readcon
28
+
29
+ atoms = Atoms("H", positions=[[0.0, 0.0, z]], cell=[10, 10, 10], pbc=False)
30
+ frame = readcon.ConFrame.from_ase(atoms)
31
+ frame.set_energy(float(energy))
32
+ frame.set_frame_index(int(frame_index))
33
+ for key, value in scalars.items():
34
+ frame.set_scalar_metadata(key, float(value) if isinstance(value, float) else value)
35
+ return frame
36
+
37
+
38
+ def test_energies_from_frames_match_stamps():
39
+ frames = [_h_frame(1.0 + 0.1 * i, float(i), frame_index=i) for i in range(4)]
40
+ e = energies_from_frames(frames)
41
+ assert e.shape == (4,)
42
+ assert np.allclose(e, [1.0, 1.1, 1.2, 1.3])
43
+
44
+
45
+ def test_energies_from_frames_missing_raises():
46
+ import readcon
47
+ from ase import Atoms
48
+
49
+ bare = readcon.ConFrame.from_ase(Atoms("H", positions=[[0, 0, 0]]))
50
+ with pytest.raises(ValueError, match="no energy"):
51
+ energies_from_frames([bare])
52
+
53
+
54
+ def test_neb_path_arrays_length_and_stamps():
55
+ frames = []
56
+ for i in range(5):
57
+ fr = _h_frame(
58
+ -10.0 + i,
59
+ 0.1 * i,
60
+ frame_index=i,
61
+ reaction_coordinate=0.2 * i,
62
+ relative_energy=float(i),
63
+ parallel_force=-0.01 * i,
64
+ )
65
+ fr.set_neb_bead(i)
66
+ frames.append(fr)
67
+ path = neb_path_arrays(frames)
68
+ assert path["n_frames"] == 5
69
+ assert len(path["atoms_list"]) == 5
70
+ assert np.allclose(path["energies"], [-10, -9, -8, -7, -6])
71
+ assert np.allclose(path["reaction_coordinate"], [0.0, 0.2, 0.4, 0.6, 0.8])
72
+ assert np.allclose(path["relative_energy"], [0, 1, 2, 3, 4])
73
+ assert path["atoms_list"][0].info["energy"] == pytest.approx(-10.0)
74
+
75
+
76
+ def test_min_trajectory_from_frames_metrics():
77
+ frames = [
78
+ _h_frame(
79
+ -5.0 + 0.5 * i,
80
+ 0.0,
81
+ frame_index=i,
82
+ step_size=0.1 * (i + 1),
83
+ convergence=1.0 / (i + 1),
84
+ )
85
+ for i in range(3)
86
+ ]
87
+ traj = min_trajectory_from_frames(frames)
88
+ assert len(traj.atoms_list) == 3
89
+ assert traj.dat_df.height == 3
90
+ assert "energy" in traj.dat_df.columns
91
+ assert traj.dat_df["energy"].to_list() == pytest.approx([-5.0, -4.5, -4.0])
92
+ assert traj.final_atoms is traj.atoms_list[-1]
93
+
94
+
95
+ def test_dimer_trajectory_from_frames_metrics():
96
+ frames = [
97
+ _h_frame(
98
+ 0.0 + i,
99
+ 0.0,
100
+ frame_index=i,
101
+ step_size=0.05,
102
+ delta_e=float(i),
103
+ convergence=0.1,
104
+ eigenvalue=-1.0,
105
+ torque=0.0,
106
+ angle=0.0,
107
+ rotations=0,
108
+ )
109
+ for i in range(3)
110
+ ]
111
+ # rotations may need int via set_scalar_metadata
112
+ traj = dimer_trajectory_from_frames(frames)
113
+ assert len(traj.atoms_list) == 3
114
+ assert traj.dat_df.height == 3
115
+ assert "delta_e" in traj.dat_df.columns or "energy" in traj.dat_df.columns
116
+
117
+
118
+ def test_atoms_list_from_frames_preserves_energy_info():
119
+ frames = [_h_frame(2.5, 0.0, frame_index=0)]
120
+ atoms = atoms_list_from_frames(frames)
121
+ assert atoms[0].info["energy"] == pytest.approx(2.5)
@@ -44,26 +44,33 @@ class TestRenderStructureToImage:
44
44
 
45
45
 
46
46
  class TestCheckXyzrender:
47
- """Binary availability check."""
47
+ """Package importability (ensure_import / site-packages), not PATH binary."""
48
48
 
49
- def test_missing_binary_raises(self):
50
- with patch.object(shutil, "which", return_value=None):
51
- with pytest.raises(RuntimeError, match="xyzrender binary not found"):
49
+ def test_missing_package_raises(self):
50
+ import builtins
51
+ real_import = builtins.__import__
52
+
53
+ def fake_import(name, *a, **k):
54
+ if name == "xyzrender" or name.startswith("xyzrender."):
55
+ raise ImportError("blocked")
56
+ if name == "rgpycrumbs" or name.startswith("rgpycrumbs."):
57
+ raise ImportError("blocked")
58
+ return real_import(name, *a, **k)
59
+
60
+ with patch.object(builtins, "__import__", side_effect=fake_import):
61
+ with pytest.raises(RuntimeError, match="xyzrender is required"):
52
62
  _check_xyzrender()
53
63
 
54
- def test_present_binary_passes(self):
55
- with patch.object(shutil, "which", return_value="/usr/bin/xyzrender"):
56
- _check_xyzrender()
64
+ def test_present_package_passes(self):
65
+ pytest.importorskip("xyzrender")
66
+ _check_xyzrender()
57
67
 
58
68
 
59
69
  class TestRenderXyzrender:
60
- """xyzrender subprocess renderer."""
70
+ """xyzrender Python API renderer."""
61
71
 
62
- @pytest.mark.skipif(
63
- shutil.which("xyzrender") is None,
64
- reason="xyzrender not installed",
65
- )
66
72
  def test_produces_image(self, water):
73
+ pytest.importorskip("xyzrender")
67
74
  img = _render_xyzrender(water, canvas_size=200)
68
75
  assert isinstance(img, np.ndarray)
69
76
  assert img.ndim == 3
File without changes
File without changes