eagleplot 3.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.
- eagleplot/Home.py +362 -0
- eagleplot/__init__.py +19 -0
- eagleplot/annotations.py +215 -0
- eagleplot/assets/logo.ico +0 -0
- eagleplot/assets/logo.png +0 -0
- eagleplot/assets/nccu-horiz-logo.png +0 -0
- eagleplot/assets/nccu-wings.png +0 -0
- eagleplot/batch.py +446 -0
- eagleplot/cli.py +719 -0
- eagleplot/data_tools.py +849 -0
- eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
- eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
- eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
- eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
- eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
- eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
- eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
- eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
- eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
- eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
- eagleplot/gallery/box_plot.png +0 -0
- eagleplot/gallery/contour_plot.png +0 -0
- eagleplot/gallery/distribution_plot.png +0 -0
- eagleplot/gallery/heatmap.png +0 -0
- eagleplot/gallery/histogram.png +0 -0
- eagleplot/gallery/interactive_3d_surface.png +0 -0
- eagleplot/gallery/interactive_heatmap.png +0 -0
- eagleplot/gallery/interactive_histogram.png +0 -0
- eagleplot/gallery/interactive_line.png +0 -0
- eagleplot/gallery/interactive_scatter.png +0 -0
- eagleplot/gallery/line_plot.png +0 -0
- eagleplot/gallery/manifest.json +196 -0
- eagleplot/gallery/regression_plot.png +0 -0
- eagleplot/gallery/scatter_plot.png +0 -0
- eagleplot/io.py +1008 -0
- eagleplot/math.py +1210 -0
- eagleplot/molecular/__init__.py +21 -0
- eagleplot/molecular/atom_data.py +287 -0
- eagleplot/molecular/parsers.py +747 -0
- eagleplot/nist.py +241 -0
- eagleplot/pages/10_Molecular_Viz.py +225 -0
- eagleplot/pages/11_Batch_Analysis.py +325 -0
- eagleplot/pages/12_Help.py +199 -0
- eagleplot/pages/13_Settings.py +388 -0
- eagleplot/pages/1_Data_Upload.py +521 -0
- eagleplot/pages/2_Quick_Plot.py +1693 -0
- eagleplot/pages/3_Analysis_Tools.py +2510 -0
- eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
- eagleplot/pages/5_Advanced_Plotting.py +691 -0
- eagleplot/pages/6_Export_Results.py +442 -0
- eagleplot/pages/7_Gallery.py +201 -0
- eagleplot/pages/8_Data_Tools.py +434 -0
- eagleplot/pages/9_Spectroscopy.py +821 -0
- eagleplot/pages/__init__.py +8 -0
- eagleplot/peaks.py +513 -0
- eagleplot/plotting.py +2630 -0
- eagleplot/plugin_loader.py +208 -0
- eagleplot/report.py +448 -0
- eagleplot/signal.py +494 -0
- eagleplot/spectroscopy.py +830 -0
- eagleplot/utils/__init__.py +76 -0
- eagleplot/utils/access.py +77 -0
- eagleplot/utils/data_preview.py +352 -0
- eagleplot/utils/plot_config.py +2721 -0
- eagleplot/utils/session_state.py +535 -0
- eagleplot/utils/theming.py +312 -0
- eagleplot/utils/user_settings.py +294 -0
- eagleplot-3.0.0.dist-info/METADATA +304 -0
- eagleplot-3.0.0.dist-info/RECORD +73 -0
- eagleplot-3.0.0.dist-info/WHEEL +5 -0
- eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
- eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
- eagleplot-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
"""Molecular vibration parsers and visualization helpers.
|
|
2
|
+
|
|
3
|
+
Provides functionality for parsing vibrational frequency data from quantum
|
|
4
|
+
chemistry output files (Gaussian, ORCA, Molden) and building Plotly figures
|
|
5
|
+
for molecular visualization.
|
|
6
|
+
|
|
7
|
+
Classes:
|
|
8
|
+
VibrationalMode: Data structure for a single vibrational mode.
|
|
9
|
+
VibrationalData: Container for a complete vibrational analysis.
|
|
10
|
+
|
|
11
|
+
Functions:
|
|
12
|
+
parse_gaussian_vibrations: Parse Gaussian .log/.out files.
|
|
13
|
+
parse_orca_vibrations: Parse ORCA .out files.
|
|
14
|
+
parse_molden_vibrations: Parse Molden .molden files.
|
|
15
|
+
parse_vibrations: Auto-detect format and parse.
|
|
16
|
+
create_displacement_arrows: Generate Plotly Cone arrow traces.
|
|
17
|
+
build_molecule_figure: Build a self-contained Plotly 3-D figure.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import TYPE_CHECKING, Optional
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
import plotly.graph_objects as go
|
|
28
|
+
|
|
29
|
+
from .atom_data import (
|
|
30
|
+
BOHR_TO_ANGSTROM,
|
|
31
|
+
get_atom_color,
|
|
32
|
+
get_atom_symbol,
|
|
33
|
+
get_vdw_radius,
|
|
34
|
+
symbol_to_number,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Data structures
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class VibrationalMode:
|
|
48
|
+
"""Single vibrational mode data.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
mode_number: Mode index (1-based, following QM convention).
|
|
52
|
+
frequency: Frequency in cm⁻¹ (negative for imaginary modes).
|
|
53
|
+
ir_intensity: IR intensity in km/mol, or ``None`` if not available.
|
|
54
|
+
displacement_vectors: Array of shape ``(n_atoms, 3)`` with Cartesian
|
|
55
|
+
displacements.
|
|
56
|
+
is_imaginary: ``True`` when the frequency is negative.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
mode_number: int
|
|
60
|
+
frequency: float
|
|
61
|
+
ir_intensity: Optional[float]
|
|
62
|
+
displacement_vectors: np.ndarray # shape: (n_atoms, 3)
|
|
63
|
+
is_imaginary: bool
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class VibrationalData:
|
|
68
|
+
"""Complete vibrational analysis data.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
coordinates: Atomic coordinates of shape ``(n_atoms, 3)`` in Angstroms.
|
|
72
|
+
atomic_numbers: List of atomic numbers (length ``n_atoms``).
|
|
73
|
+
modes: List of :class:`VibrationalMode` objects.
|
|
74
|
+
source_file: Original filename for reference.
|
|
75
|
+
program: Source program identifier — ``"gaussian"``, ``"orca"``, or
|
|
76
|
+
``"molden"``.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
coordinates: np.ndarray # shape: (n_atoms, 3)
|
|
80
|
+
atomic_numbers: list[int]
|
|
81
|
+
modes: list[VibrationalMode]
|
|
82
|
+
source_file: str
|
|
83
|
+
program: str
|
|
84
|
+
|
|
85
|
+
def get_mode(self, mode_number: int) -> Optional[VibrationalMode]:
|
|
86
|
+
"""Retrieve a mode by its 1-based mode number.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
mode_number: Mode number to retrieve.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
:class:`VibrationalMode` if found, ``None`` otherwise.
|
|
93
|
+
"""
|
|
94
|
+
for mode in self.modes:
|
|
95
|
+
if mode.mode_number == mode_number:
|
|
96
|
+
return mode
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
def get_displacement_magnitudes(self, mode_number: int) -> np.ndarray:
|
|
100
|
+
"""Calculate per-atom displacement magnitude for a given mode.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
mode_number: Mode number to analyse (1-based).
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
1-D array of displacement magnitudes (one value per atom).
|
|
107
|
+
An empty array is returned when the mode is not found.
|
|
108
|
+
"""
|
|
109
|
+
mode = self.get_mode(mode_number)
|
|
110
|
+
if mode is None:
|
|
111
|
+
return np.array([])
|
|
112
|
+
return np.linalg.norm(mode.displacement_vectors, axis=1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Gaussian parser
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_gaussian_vibrations(filepath: str) -> VibrationalData:
|
|
121
|
+
"""Parse vibrational data from a Gaussian .log or .out file.
|
|
122
|
+
|
|
123
|
+
Strategy:
|
|
124
|
+
|
|
125
|
+
1. Find the last ``Standard orientation`` section for equilibrium
|
|
126
|
+
coordinates.
|
|
127
|
+
2. Locate the ``Harmonic frequencies`` section.
|
|
128
|
+
3. Parse frequency blocks (printed in groups of up to 5 modes).
|
|
129
|
+
4. Extract frequencies, IR intensities, and displacement vectors.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
filepath: Path to the Gaussian output file.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
:class:`VibrationalData` populated from the file.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
FileNotFoundError: If *filepath* does not exist.
|
|
139
|
+
ValueError: If no vibrational data or coordinates are found.
|
|
140
|
+
"""
|
|
141
|
+
with open(filepath) as f:
|
|
142
|
+
content = f.read()
|
|
143
|
+
|
|
144
|
+
# 1. Extract coordinates from the last "Standard orientation" section.
|
|
145
|
+
coord_pattern = r"Standard orientation:.*?-{50,}.*?-{50,}\s*(.*?)\s*-{50,}"
|
|
146
|
+
coord_matches = list(re.finditer(coord_pattern, content, re.DOTALL))
|
|
147
|
+
if not coord_matches:
|
|
148
|
+
raise ValueError("No coordinates found in Gaussian file")
|
|
149
|
+
|
|
150
|
+
last_coords_block = coord_matches[-1].group(1)
|
|
151
|
+
coords: list[list[float]] = []
|
|
152
|
+
atomic_numbers: list[int] = []
|
|
153
|
+
|
|
154
|
+
coord_line_pattern = r"\s*\d+\s+(\d+)\s+\d+\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)"
|
|
155
|
+
for match in re.finditer(coord_line_pattern, last_coords_block):
|
|
156
|
+
atomic_numbers.append(int(match.group(1)))
|
|
157
|
+
coords.append([float(match.group(2)), float(match.group(3)), float(match.group(4))])
|
|
158
|
+
|
|
159
|
+
coords_arr = np.array(coords)
|
|
160
|
+
n_atoms = len(coords_arr)
|
|
161
|
+
|
|
162
|
+
# 2. Find the vibrational section.
|
|
163
|
+
vib_pattern = r"Harmonic frequencies \(cm\*\*-1\), IR intensities.*?(?=\n\s*-{3,}|\Z)"
|
|
164
|
+
vib_match = re.search(vib_pattern, content, re.DOTALL)
|
|
165
|
+
if not vib_match:
|
|
166
|
+
raise ValueError("No vibrational data found in Gaussian file")
|
|
167
|
+
|
|
168
|
+
vib_section = vib_match.group(0)
|
|
169
|
+
|
|
170
|
+
# 3. Parse frequency blocks.
|
|
171
|
+
modes: list[VibrationalMode] = []
|
|
172
|
+
|
|
173
|
+
mode_header_pattern = (
|
|
174
|
+
r"^\s+(\d+)\s{5,}(\d+)(?:\s{5,}(\d+))?(?:\s{5,}(\d+))?(?:\s{5,}(\d+))?\s*$"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
mode_blocks = []
|
|
178
|
+
for match in re.finditer(mode_header_pattern, vib_section, re.MULTILINE):
|
|
179
|
+
mode_nums = [int(x) for x in match.groups() if x is not None]
|
|
180
|
+
next_match = re.search(mode_header_pattern, vib_section[match.end() :], re.MULTILINE)
|
|
181
|
+
end_pos = match.end() + next_match.start() if next_match else len(vib_section)
|
|
182
|
+
block_content = vib_section[match.end() : end_pos]
|
|
183
|
+
mode_blocks.append((mode_nums, block_content))
|
|
184
|
+
|
|
185
|
+
for mode_nums, block in mode_blocks:
|
|
186
|
+
freq_match = re.search(
|
|
187
|
+
r"Frequencies --\s+([-\d.]+)\s+([-\d.]+)"
|
|
188
|
+
r"\s*(?:([-\d.]+))?\s*(?:([-\d.]+))?\s*(?:([-\d.]+))?",
|
|
189
|
+
block,
|
|
190
|
+
)
|
|
191
|
+
if not freq_match:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
frequencies = [float(x) for x in freq_match.groups() if x is not None]
|
|
195
|
+
|
|
196
|
+
ir_match = re.search(
|
|
197
|
+
r"IR Inten\s+--\s+([-\d.]+)\s+([-\d.]+)"
|
|
198
|
+
r"\s*(?:([-\d.]+))?\s*(?:([-\d.]+))?\s*(?:([-\d.]+))?",
|
|
199
|
+
block,
|
|
200
|
+
)
|
|
201
|
+
ir_intensities: list[Optional[float]] = (
|
|
202
|
+
[float(x) for x in ir_match.groups() if x is not None]
|
|
203
|
+
if ir_match
|
|
204
|
+
else [None] * len(frequencies)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
disp_start = block.find("Atom AN")
|
|
208
|
+
if disp_start == -1:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
disp_section = block[disp_start:]
|
|
212
|
+
disp_lines = disp_section.split("\n")[1:] # skip header
|
|
213
|
+
|
|
214
|
+
n_modes_in_block = len(frequencies)
|
|
215
|
+
displacements = [np.zeros((n_atoms, 3)) for _ in range(n_modes_in_block)]
|
|
216
|
+
|
|
217
|
+
atom_idx = 0
|
|
218
|
+
line_idx = 0
|
|
219
|
+
while atom_idx < n_atoms and line_idx < len(disp_lines):
|
|
220
|
+
line = disp_lines[line_idx]
|
|
221
|
+
if not line.strip():
|
|
222
|
+
line_idx += 1
|
|
223
|
+
continue
|
|
224
|
+
parts = line.split()
|
|
225
|
+
if len(parts) < 5:
|
|
226
|
+
line_idx += 1
|
|
227
|
+
continue
|
|
228
|
+
displacement_values = parts[2:]
|
|
229
|
+
for mode_idx in range(n_modes_in_block):
|
|
230
|
+
start_idx = mode_idx * 3
|
|
231
|
+
if start_idx + 2 < len(displacement_values):
|
|
232
|
+
displacements[mode_idx][atom_idx, 0] = float(displacement_values[start_idx])
|
|
233
|
+
displacements[mode_idx][atom_idx, 1] = float(displacement_values[start_idx + 1])
|
|
234
|
+
displacements[mode_idx][atom_idx, 2] = float(displacement_values[start_idx + 2])
|
|
235
|
+
atom_idx += 1
|
|
236
|
+
line_idx += 1
|
|
237
|
+
|
|
238
|
+
for mode_num, freq, ir_int, disp in zip(
|
|
239
|
+
mode_nums[: len(frequencies)],
|
|
240
|
+
frequencies,
|
|
241
|
+
ir_intensities,
|
|
242
|
+
displacements,
|
|
243
|
+
):
|
|
244
|
+
modes.append(
|
|
245
|
+
VibrationalMode(
|
|
246
|
+
mode_number=mode_num,
|
|
247
|
+
frequency=freq,
|
|
248
|
+
ir_intensity=ir_int,
|
|
249
|
+
displacement_vectors=disp,
|
|
250
|
+
is_imaginary=(freq < 0),
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return VibrationalData(
|
|
255
|
+
coordinates=coords_arr,
|
|
256
|
+
atomic_numbers=atomic_numbers,
|
|
257
|
+
modes=modes,
|
|
258
|
+
source_file=filepath,
|
|
259
|
+
program="gaussian",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# ORCA parser
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def parse_orca_vibrations(filepath: str) -> VibrationalData:
|
|
269
|
+
"""Parse vibrational data from an ORCA .out file.
|
|
270
|
+
|
|
271
|
+
Strategy:
|
|
272
|
+
|
|
273
|
+
1. Find ``CARTESIAN COORDINATES (ANGSTROEM)`` for the geometry.
|
|
274
|
+
2. Locate the ``VIBRATIONAL FREQUENCIES`` section.
|
|
275
|
+
3. Skip the first 6 translations/rotations (near-zero frequencies).
|
|
276
|
+
4. Extract displacement vectors from the ``NORMAL MODES`` section
|
|
277
|
+
(ORCA prints modes in columns, 6 per block).
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
filepath: Path to the ORCA output file.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
:class:`VibrationalData` populated from the file.
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
FileNotFoundError: If *filepath* does not exist.
|
|
287
|
+
ValueError: If required sections are absent or malformed.
|
|
288
|
+
"""
|
|
289
|
+
with open(filepath) as f:
|
|
290
|
+
content = f.read()
|
|
291
|
+
|
|
292
|
+
# 1. Coordinates.
|
|
293
|
+
coord_pattern = r"CARTESIAN COORDINATES \(ANGSTROEM\)\s*-+\s*(.*?)(?:\n\s*\n|\Z)"
|
|
294
|
+
coord_match = re.search(coord_pattern, content, re.DOTALL)
|
|
295
|
+
if not coord_match:
|
|
296
|
+
raise ValueError("No coordinates found in ORCA file")
|
|
297
|
+
|
|
298
|
+
coord_section = coord_match.group(1)
|
|
299
|
+
coords: list[list[float]] = []
|
|
300
|
+
atomic_numbers: list[int] = []
|
|
301
|
+
|
|
302
|
+
coord_line_pattern = r"\s*([A-Z][a-z]?)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)"
|
|
303
|
+
for match in re.finditer(coord_line_pattern, coord_section):
|
|
304
|
+
sym = match.group(1)
|
|
305
|
+
x, y, z = float(match.group(2)), float(match.group(3)), float(match.group(4))
|
|
306
|
+
atomic_num = symbol_to_number.get(sym)
|
|
307
|
+
if atomic_num is None:
|
|
308
|
+
raise ValueError(f"Unknown element symbol: {sym}")
|
|
309
|
+
atomic_numbers.append(atomic_num)
|
|
310
|
+
coords.append([x, y, z])
|
|
311
|
+
|
|
312
|
+
coords_arr = np.array(coords)
|
|
313
|
+
n_atoms = len(coords_arr)
|
|
314
|
+
|
|
315
|
+
# 2. Vibrational frequencies.
|
|
316
|
+
freq_pattern = r"VIBRATIONAL FREQUENCIES\s*-+.*?Scaling factor.*?\n(.*?)(?:\n\s*-+|\Z)"
|
|
317
|
+
freq_match = re.search(freq_pattern, content, re.DOTALL)
|
|
318
|
+
if not freq_match:
|
|
319
|
+
raise ValueError("No vibrational frequencies found in ORCA file")
|
|
320
|
+
|
|
321
|
+
freq_section = freq_match.group(1)
|
|
322
|
+
frequencies: list[tuple[int, float]] = []
|
|
323
|
+
freq_line_pattern = r"\s*(\d+):\s+([-\d.]+)\s+cm"
|
|
324
|
+
for match in re.finditer(freq_line_pattern, freq_section):
|
|
325
|
+
mode_idx = int(match.group(1))
|
|
326
|
+
freq = float(match.group(2))
|
|
327
|
+
frequencies.append((mode_idx, freq))
|
|
328
|
+
|
|
329
|
+
# Keep only non-zero modes (skip translations/rotations).
|
|
330
|
+
vibrational_freqs = [(idx, freq) for idx, freq in frequencies if abs(freq) > 1.0]
|
|
331
|
+
|
|
332
|
+
# 3. Normal modes.
|
|
333
|
+
modes_pattern = r"NORMAL MODES\s*-+(.*?)(?:\n-{3,}|\Z)"
|
|
334
|
+
modes_match = re.search(modes_pattern, content, re.DOTALL)
|
|
335
|
+
if not modes_match:
|
|
336
|
+
raise ValueError("No normal modes found in ORCA file")
|
|
337
|
+
|
|
338
|
+
modes_section = modes_match.group(1)
|
|
339
|
+
|
|
340
|
+
mode_header_pattern = (
|
|
341
|
+
r"^\s+(\d+)(?:\s+(\d+))?(?:\s+(\d+))?(?:\s+(\d+))?(?:\s+(\d+))?(?:\s+(\d+))?\s*$"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
mode_blocks: list[tuple[list[int], list[list[float]]]] = []
|
|
345
|
+
lines = modes_section.split("\n")
|
|
346
|
+
i = 0
|
|
347
|
+
while i < len(lines):
|
|
348
|
+
line = lines[i]
|
|
349
|
+
header_match = re.match(mode_header_pattern, line)
|
|
350
|
+
if header_match:
|
|
351
|
+
mode_nums = [int(x) for x in header_match.groups() if x is not None]
|
|
352
|
+
displacement_data: list[list[float]] = []
|
|
353
|
+
i += 1
|
|
354
|
+
for _ in range(n_atoms * 3):
|
|
355
|
+
if i >= len(lines):
|
|
356
|
+
break
|
|
357
|
+
data_line = lines[i]
|
|
358
|
+
parts = data_line.split()
|
|
359
|
+
if len(parts) >= 2:
|
|
360
|
+
values = [float(x) for x in parts[1:]]
|
|
361
|
+
displacement_data.append(values)
|
|
362
|
+
i += 1
|
|
363
|
+
if len(displacement_data) == n_atoms * 3:
|
|
364
|
+
mode_blocks.append((mode_nums, displacement_data))
|
|
365
|
+
else:
|
|
366
|
+
i += 1
|
|
367
|
+
|
|
368
|
+
# 4. Build VibrationalMode objects.
|
|
369
|
+
all_mode_displacements: dict[int, list[float]] = {}
|
|
370
|
+
for mode_nums, disp_data in mode_blocks:
|
|
371
|
+
for mode_idx, mode_num in enumerate(mode_nums):
|
|
372
|
+
if mode_num not in all_mode_displacements:
|
|
373
|
+
all_mode_displacements[mode_num] = []
|
|
374
|
+
for row in disp_data:
|
|
375
|
+
if mode_idx < len(row):
|
|
376
|
+
all_mode_displacements[mode_num].append(row[mode_idx])
|
|
377
|
+
|
|
378
|
+
modes: list[VibrationalMode] = []
|
|
379
|
+
for mode_idx, freq in vibrational_freqs:
|
|
380
|
+
if mode_idx in all_mode_displacements:
|
|
381
|
+
disp_vector = all_mode_displacements[mode_idx]
|
|
382
|
+
if len(disp_vector) == n_atoms * 3:
|
|
383
|
+
disp_array = np.array(disp_vector).reshape((n_atoms, 3))
|
|
384
|
+
mode_number = len(modes) + 1
|
|
385
|
+
modes.append(
|
|
386
|
+
VibrationalMode(
|
|
387
|
+
mode_number=mode_number,
|
|
388
|
+
frequency=freq,
|
|
389
|
+
ir_intensity=None,
|
|
390
|
+
displacement_vectors=disp_array,
|
|
391
|
+
is_imaginary=(freq < 0),
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
return VibrationalData(
|
|
396
|
+
coordinates=coords_arr,
|
|
397
|
+
atomic_numbers=atomic_numbers,
|
|
398
|
+
modes=modes,
|
|
399
|
+
source_file=filepath,
|
|
400
|
+
program="orca",
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ---------------------------------------------------------------------------
|
|
405
|
+
# Molden parser
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def parse_molden_vibrations(filepath: str) -> VibrationalData:
|
|
410
|
+
"""Parse vibrational data from a Molden format file.
|
|
411
|
+
|
|
412
|
+
Strategy:
|
|
413
|
+
|
|
414
|
+
1. Parse ``[Atoms]`` section for coordinates and atomic numbers.
|
|
415
|
+
Coordinates may be in Angstroms (``Angs``) or Bohr (``AU``).
|
|
416
|
+
2. Parse ``[FREQ]`` section for frequencies.
|
|
417
|
+
3. Parse ``[INT]`` section for IR intensities (optional).
|
|
418
|
+
4. Parse ``[FR-NORM-COORD]`` section for displacement vectors.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
filepath: Path to the Molden file.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
:class:`VibrationalData` populated from the file.
|
|
425
|
+
|
|
426
|
+
Raises:
|
|
427
|
+
FileNotFoundError: If *filepath* does not exist.
|
|
428
|
+
ValueError: If required sections are missing or malformed.
|
|
429
|
+
"""
|
|
430
|
+
with open(filepath) as f:
|
|
431
|
+
content = f.read()
|
|
432
|
+
|
|
433
|
+
# 1. [Atoms] section.
|
|
434
|
+
atoms_pattern = r"\[Atoms\]\s+(Angs|AU)\s*\n(.*?)(?:\n\[|\Z)"
|
|
435
|
+
atoms_match = re.search(atoms_pattern, content, re.DOTALL | re.IGNORECASE)
|
|
436
|
+
if not atoms_match:
|
|
437
|
+
raise ValueError("No [Atoms] section found in Molden file")
|
|
438
|
+
|
|
439
|
+
unit = atoms_match.group(1).upper()
|
|
440
|
+
atoms_section = atoms_match.group(2)
|
|
441
|
+
|
|
442
|
+
coords: list[list[float]] = []
|
|
443
|
+
atomic_numbers: list[int] = []
|
|
444
|
+
|
|
445
|
+
atom_line_pattern = r"\s*([A-Z][a-z]?)\s+\d+\s+(\d+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)"
|
|
446
|
+
for match in re.finditer(atom_line_pattern, atoms_section):
|
|
447
|
+
atomic_num = int(match.group(2))
|
|
448
|
+
x, y, z = float(match.group(3)), float(match.group(4)), float(match.group(5))
|
|
449
|
+
if unit == "AU":
|
|
450
|
+
x *= BOHR_TO_ANGSTROM
|
|
451
|
+
y *= BOHR_TO_ANGSTROM
|
|
452
|
+
z *= BOHR_TO_ANGSTROM
|
|
453
|
+
atomic_numbers.append(atomic_num)
|
|
454
|
+
coords.append([x, y, z])
|
|
455
|
+
|
|
456
|
+
coords_arr = np.array(coords)
|
|
457
|
+
n_atoms = len(coords_arr)
|
|
458
|
+
|
|
459
|
+
# 2. [FREQ] section.
|
|
460
|
+
freq_pattern = r"\[FREQ\]\s*\n(.*?)(?:\n\[|\Z)"
|
|
461
|
+
freq_match = re.search(freq_pattern, content, re.DOTALL)
|
|
462
|
+
if not freq_match:
|
|
463
|
+
raise ValueError("No [FREQ] section found in Molden file")
|
|
464
|
+
|
|
465
|
+
frequencies: list[float] = []
|
|
466
|
+
for line in freq_match.group(1).strip().split("\n"):
|
|
467
|
+
line = line.strip()
|
|
468
|
+
if line:
|
|
469
|
+
frequencies.append(float(line))
|
|
470
|
+
|
|
471
|
+
# 3. [INT] section (optional).
|
|
472
|
+
int_pattern = r"\[INT\]\s*\n(.*?)(?:\n\[|\Z)"
|
|
473
|
+
int_match = re.search(int_pattern, content, re.DOTALL)
|
|
474
|
+
|
|
475
|
+
ir_intensities: list[Optional[float]] = []
|
|
476
|
+
if int_match:
|
|
477
|
+
for line in int_match.group(1).strip().split("\n"):
|
|
478
|
+
line = line.strip()
|
|
479
|
+
if line:
|
|
480
|
+
ir_intensities.append(float(line))
|
|
481
|
+
|
|
482
|
+
if not ir_intensities:
|
|
483
|
+
ir_intensities = [None] * len(frequencies)
|
|
484
|
+
elif len(ir_intensities) < len(frequencies):
|
|
485
|
+
ir_intensities.extend([None] * (len(frequencies) - len(ir_intensities)))
|
|
486
|
+
|
|
487
|
+
# 4. [FR-NORM-COORD] section.
|
|
488
|
+
norm_coord_pattern = r"\[FR-NORM-COORD\]\s*\n(.*?)(?:\n\[|\Z)"
|
|
489
|
+
norm_coord_match = re.search(norm_coord_pattern, content, re.DOTALL)
|
|
490
|
+
if not norm_coord_match:
|
|
491
|
+
raise ValueError("No [FR-NORM-COORD] section found in Molden file")
|
|
492
|
+
|
|
493
|
+
norm_coord_section = norm_coord_match.group(1)
|
|
494
|
+
modes: list[VibrationalMode] = []
|
|
495
|
+
|
|
496
|
+
vibration_blocks = re.split(r"vibration\s+(\d+)", norm_coord_section)
|
|
497
|
+
# vibration_blocks = ['', '1', '\n...', '2', '\n...', ...]
|
|
498
|
+
for i in range(1, len(vibration_blocks), 2):
|
|
499
|
+
if i + 1 >= len(vibration_blocks):
|
|
500
|
+
break
|
|
501
|
+
mode_number = int(vibration_blocks[i])
|
|
502
|
+
block_content = vibration_blocks[i + 1].strip()
|
|
503
|
+
|
|
504
|
+
displacement_vectors: list[list[float]] = []
|
|
505
|
+
for line in block_content.split("\n"):
|
|
506
|
+
line = line.strip()
|
|
507
|
+
if line:
|
|
508
|
+
values = list(map(float, line.split()))
|
|
509
|
+
if len(values) == 3:
|
|
510
|
+
displacement_vectors.append(values)
|
|
511
|
+
|
|
512
|
+
if len(displacement_vectors) == n_atoms:
|
|
513
|
+
disp_array = np.array(displacement_vectors)
|
|
514
|
+
freq_idx = mode_number - 1
|
|
515
|
+
if freq_idx < len(frequencies):
|
|
516
|
+
freq = frequencies[freq_idx]
|
|
517
|
+
ir_int = ir_intensities[freq_idx] if freq_idx < len(ir_intensities) else None
|
|
518
|
+
modes.append(
|
|
519
|
+
VibrationalMode(
|
|
520
|
+
mode_number=mode_number,
|
|
521
|
+
frequency=freq,
|
|
522
|
+
ir_intensity=ir_int,
|
|
523
|
+
displacement_vectors=disp_array,
|
|
524
|
+
is_imaginary=(freq < 0),
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
return VibrationalData(
|
|
529
|
+
coordinates=coords_arr,
|
|
530
|
+
atomic_numbers=atomic_numbers,
|
|
531
|
+
modes=modes,
|
|
532
|
+
source_file=filepath,
|
|
533
|
+
program="molden",
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
# ---------------------------------------------------------------------------
|
|
538
|
+
# Auto-detect dispatcher
|
|
539
|
+
# ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def parse_vibrations(filepath: str) -> VibrationalData:
|
|
543
|
+
"""Auto-detect the file format and parse vibrational data.
|
|
544
|
+
|
|
545
|
+
Detection strategy:
|
|
546
|
+
|
|
547
|
+
* ``.molden`` extension **or** ``[Molden Format]`` in the first 1 kB →
|
|
548
|
+
:func:`parse_molden_vibrations`.
|
|
549
|
+
* ``Gaussian`` in the first 500 bytes → :func:`parse_gaussian_vibrations`.
|
|
550
|
+
* ``O R C A`` in the first 500 bytes → :func:`parse_orca_vibrations`.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
filepath: Path to the vibration file.
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
:class:`VibrationalData` object.
|
|
557
|
+
|
|
558
|
+
Raises:
|
|
559
|
+
FileNotFoundError: If *filepath* does not exist.
|
|
560
|
+
ValueError: If the file format cannot be detected.
|
|
561
|
+
"""
|
|
562
|
+
import os
|
|
563
|
+
|
|
564
|
+
ext = os.path.splitext(filepath)[1].lower()
|
|
565
|
+
|
|
566
|
+
with open(filepath) as f:
|
|
567
|
+
first_kb = f.read(1024)
|
|
568
|
+
|
|
569
|
+
if ext == ".molden" or "[Molden Format]" in first_kb:
|
|
570
|
+
return parse_molden_vibrations(filepath)
|
|
571
|
+
elif "Gaussian" in first_kb[:500]:
|
|
572
|
+
return parse_gaussian_vibrations(filepath)
|
|
573
|
+
elif "O R C A" in first_kb[:500]:
|
|
574
|
+
return parse_orca_vibrations(filepath)
|
|
575
|
+
else:
|
|
576
|
+
raise ValueError(f"Cannot detect vibration file format: {filepath}")
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# ---------------------------------------------------------------------------
|
|
580
|
+
# Visualization helpers
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def create_displacement_arrows(
|
|
585
|
+
vib_data: VibrationalData,
|
|
586
|
+
mode_number: int,
|
|
587
|
+
amplitude: float = 1.0,
|
|
588
|
+
arrow_scale: float = 1.0,
|
|
589
|
+
color: str = "red",
|
|
590
|
+
show_small_displacements: bool = False,
|
|
591
|
+
displacement_threshold: float = 0.01,
|
|
592
|
+
) -> list[go.Cone]:
|
|
593
|
+
"""Create 3-D arrow (Cone) traces for vibrational displacements.
|
|
594
|
+
|
|
595
|
+
Arrows are auto-scaled so that the largest displacement is ~15 % of the
|
|
596
|
+
molecular extent, keeping them visible but not overwhelming.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
vib_data: :class:`VibrationalData` object.
|
|
600
|
+
mode_number: Which mode to visualise (1-based).
|
|
601
|
+
amplitude: Displacement amplitude multiplier.
|
|
602
|
+
arrow_scale: Visual scale factor applied to cone ``sizeref``.
|
|
603
|
+
color: Arrow color (any Plotly-accepted string).
|
|
604
|
+
show_small_displacements: When ``False``, hide arrows whose magnitude
|
|
605
|
+
is below *displacement_threshold*.
|
|
606
|
+
displacement_threshold: Minimum displacement magnitude to display.
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
List containing a single :class:`plotly.graph_objects.Cone` trace
|
|
610
|
+
(empty list if no atoms pass the threshold).
|
|
611
|
+
|
|
612
|
+
Raises:
|
|
613
|
+
ValueError: If the requested mode is not found in *vib_data*.
|
|
614
|
+
"""
|
|
615
|
+
mode = vib_data.get_mode(mode_number)
|
|
616
|
+
if mode is None:
|
|
617
|
+
raise ValueError(f"Mode {mode_number} not found")
|
|
618
|
+
|
|
619
|
+
coords = vib_data.coordinates
|
|
620
|
+
|
|
621
|
+
# Molecular size as mean coordinate range (replaces removed np.ptp).
|
|
622
|
+
coord_ranges = np.max(coords, axis=0) - np.min(coords, axis=0)
|
|
623
|
+
molecular_size = float(np.mean(coord_ranges))
|
|
624
|
+
|
|
625
|
+
displacements = mode.displacement_vectors * amplitude
|
|
626
|
+
magnitudes = np.linalg.norm(displacements, axis=1)
|
|
627
|
+
|
|
628
|
+
max_magnitude = float(np.max(magnitudes)) if len(magnitudes) > 0 else 1.0
|
|
629
|
+
if max_magnitude > 0:
|
|
630
|
+
auto_scale = (0.15 * molecular_size) / max_magnitude
|
|
631
|
+
displacements = displacements * auto_scale
|
|
632
|
+
|
|
633
|
+
if not show_small_displacements:
|
|
634
|
+
mask = magnitudes > displacement_threshold
|
|
635
|
+
coords_filtered = coords[mask]
|
|
636
|
+
displacements_filtered = displacements[mask]
|
|
637
|
+
else:
|
|
638
|
+
coords_filtered = coords
|
|
639
|
+
displacements_filtered = displacements
|
|
640
|
+
|
|
641
|
+
if len(coords_filtered) == 0:
|
|
642
|
+
return []
|
|
643
|
+
|
|
644
|
+
trace = go.Cone(
|
|
645
|
+
x=coords_filtered[:, 0],
|
|
646
|
+
y=coords_filtered[:, 1],
|
|
647
|
+
z=coords_filtered[:, 2],
|
|
648
|
+
u=displacements_filtered[:, 0],
|
|
649
|
+
v=displacements_filtered[:, 1],
|
|
650
|
+
w=displacements_filtered[:, 2],
|
|
651
|
+
colorscale=[[0, color], [1, color]],
|
|
652
|
+
sizemode="scaled",
|
|
653
|
+
sizeref=arrow_scale * 0.3,
|
|
654
|
+
showscale=False,
|
|
655
|
+
name=f"Mode {mode_number} ({mode.frequency:.1f} cm\u207b\u00b9)",
|
|
656
|
+
hovertemplate=(
|
|
657
|
+
f"<b>Mode {mode_number}</b><br>"
|
|
658
|
+
f"Frequency: {mode.frequency:.2f} cm\u207b\u00b9<br>"
|
|
659
|
+
"Displacement: %{u:.3f}, %{v:.3f}, %{w:.3f}<br>"
|
|
660
|
+
"<extra></extra>"
|
|
661
|
+
),
|
|
662
|
+
)
|
|
663
|
+
return [trace]
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def build_molecule_figure(
|
|
667
|
+
vib_data: VibrationalData,
|
|
668
|
+
mode_number: Optional[int] = None,
|
|
669
|
+
arrow_color: str = "red",
|
|
670
|
+
arrow_scale: float = 1.0,
|
|
671
|
+
amplitude: float = 1.0,
|
|
672
|
+
) -> go.Figure:
|
|
673
|
+
"""Build a Plotly figure showing atoms as a 3-D scatter plot.
|
|
674
|
+
|
|
675
|
+
Atoms are represented as markers coloured and sized by element type
|
|
676
|
+
(CPK colours, sizes proportional to van der Waals radii). No RDKit
|
|
677
|
+
dependency is required.
|
|
678
|
+
|
|
679
|
+
If *mode_number* is provided, displacement arrow cones are added on
|
|
680
|
+
top of the atom scatter trace.
|
|
681
|
+
|
|
682
|
+
Args:
|
|
683
|
+
vib_data: :class:`VibrationalData` to visualise.
|
|
684
|
+
mode_number: If given, add displacement arrows for this mode
|
|
685
|
+
(1-based).
|
|
686
|
+
arrow_color: Color for the displacement arrows.
|
|
687
|
+
arrow_scale: Visual scale factor for arrow cones.
|
|
688
|
+
amplitude: Displacement amplitude multiplier for arrows.
|
|
689
|
+
|
|
690
|
+
Returns:
|
|
691
|
+
:class:`plotly.graph_objects.Figure` ready for display.
|
|
692
|
+
"""
|
|
693
|
+
coords = vib_data.coordinates
|
|
694
|
+
atomic_nums = vib_data.atomic_numbers
|
|
695
|
+
|
|
696
|
+
# Build per-atom visual properties.
|
|
697
|
+
colors = [get_atom_color(z) for z in atomic_nums]
|
|
698
|
+
# Scale vdW radius to a reasonable marker pixel size (multiply by ~15).
|
|
699
|
+
sizes = [get_vdw_radius(z) * 15 for z in atomic_nums]
|
|
700
|
+
labels = [get_atom_symbol(z) for z in atomic_nums]
|
|
701
|
+
|
|
702
|
+
atom_trace = go.Scatter3d(
|
|
703
|
+
x=coords[:, 0],
|
|
704
|
+
y=coords[:, 1],
|
|
705
|
+
z=coords[:, 2],
|
|
706
|
+
mode="markers+text",
|
|
707
|
+
marker=dict(
|
|
708
|
+
color=colors,
|
|
709
|
+
size=sizes,
|
|
710
|
+
opacity=0.85,
|
|
711
|
+
line=dict(width=0),
|
|
712
|
+
),
|
|
713
|
+
text=labels,
|
|
714
|
+
textposition="top center",
|
|
715
|
+
name="Atoms",
|
|
716
|
+
hovertemplate=(
|
|
717
|
+
"<b>%{text}</b><br>x: %{x:.3f} Å<br>y: %{y:.3f} Å<br>z: %{z:.3f} Å<br><extra></extra>"
|
|
718
|
+
),
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
fig = go.Figure(data=[atom_trace])
|
|
722
|
+
|
|
723
|
+
# Optionally overlay displacement arrows.
|
|
724
|
+
if mode_number is not None:
|
|
725
|
+
arrow_traces = create_displacement_arrows(
|
|
726
|
+
vib_data=vib_data,
|
|
727
|
+
mode_number=mode_number,
|
|
728
|
+
amplitude=amplitude,
|
|
729
|
+
arrow_scale=arrow_scale,
|
|
730
|
+
color=arrow_color,
|
|
731
|
+
)
|
|
732
|
+
for trace in arrow_traces:
|
|
733
|
+
fig.add_trace(trace)
|
|
734
|
+
|
|
735
|
+
# Clean 3-D layout with hidden axes.
|
|
736
|
+
fig.update_layout(
|
|
737
|
+
scene=dict(
|
|
738
|
+
xaxis=dict(visible=False),
|
|
739
|
+
yaxis=dict(visible=False),
|
|
740
|
+
zaxis=dict(visible=False),
|
|
741
|
+
aspectmode="data",
|
|
742
|
+
),
|
|
743
|
+
showlegend=False,
|
|
744
|
+
margin=dict(l=0, r=0, t=30, b=0),
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
return fig
|