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.
Files changed (73) hide show
  1. eagleplot/Home.py +362 -0
  2. eagleplot/__init__.py +19 -0
  3. eagleplot/annotations.py +215 -0
  4. eagleplot/assets/logo.ico +0 -0
  5. eagleplot/assets/logo.png +0 -0
  6. eagleplot/assets/nccu-horiz-logo.png +0 -0
  7. eagleplot/assets/nccu-wings.png +0 -0
  8. eagleplot/batch.py +446 -0
  9. eagleplot/cli.py +719 -0
  10. eagleplot/data_tools.py +849 -0
  11. eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
  12. eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
  13. eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
  14. eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
  15. eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
  16. eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
  17. eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
  18. eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
  19. eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
  20. eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
  21. eagleplot/gallery/box_plot.png +0 -0
  22. eagleplot/gallery/contour_plot.png +0 -0
  23. eagleplot/gallery/distribution_plot.png +0 -0
  24. eagleplot/gallery/heatmap.png +0 -0
  25. eagleplot/gallery/histogram.png +0 -0
  26. eagleplot/gallery/interactive_3d_surface.png +0 -0
  27. eagleplot/gallery/interactive_heatmap.png +0 -0
  28. eagleplot/gallery/interactive_histogram.png +0 -0
  29. eagleplot/gallery/interactive_line.png +0 -0
  30. eagleplot/gallery/interactive_scatter.png +0 -0
  31. eagleplot/gallery/line_plot.png +0 -0
  32. eagleplot/gallery/manifest.json +196 -0
  33. eagleplot/gallery/regression_plot.png +0 -0
  34. eagleplot/gallery/scatter_plot.png +0 -0
  35. eagleplot/io.py +1008 -0
  36. eagleplot/math.py +1210 -0
  37. eagleplot/molecular/__init__.py +21 -0
  38. eagleplot/molecular/atom_data.py +287 -0
  39. eagleplot/molecular/parsers.py +747 -0
  40. eagleplot/nist.py +241 -0
  41. eagleplot/pages/10_Molecular_Viz.py +225 -0
  42. eagleplot/pages/11_Batch_Analysis.py +325 -0
  43. eagleplot/pages/12_Help.py +199 -0
  44. eagleplot/pages/13_Settings.py +388 -0
  45. eagleplot/pages/1_Data_Upload.py +521 -0
  46. eagleplot/pages/2_Quick_Plot.py +1693 -0
  47. eagleplot/pages/3_Analysis_Tools.py +2510 -0
  48. eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
  49. eagleplot/pages/5_Advanced_Plotting.py +691 -0
  50. eagleplot/pages/6_Export_Results.py +442 -0
  51. eagleplot/pages/7_Gallery.py +201 -0
  52. eagleplot/pages/8_Data_Tools.py +434 -0
  53. eagleplot/pages/9_Spectroscopy.py +821 -0
  54. eagleplot/pages/__init__.py +8 -0
  55. eagleplot/peaks.py +513 -0
  56. eagleplot/plotting.py +2630 -0
  57. eagleplot/plugin_loader.py +208 -0
  58. eagleplot/report.py +448 -0
  59. eagleplot/signal.py +494 -0
  60. eagleplot/spectroscopy.py +830 -0
  61. eagleplot/utils/__init__.py +76 -0
  62. eagleplot/utils/access.py +77 -0
  63. eagleplot/utils/data_preview.py +352 -0
  64. eagleplot/utils/plot_config.py +2721 -0
  65. eagleplot/utils/session_state.py +535 -0
  66. eagleplot/utils/theming.py +312 -0
  67. eagleplot/utils/user_settings.py +294 -0
  68. eagleplot-3.0.0.dist-info/METADATA +304 -0
  69. eagleplot-3.0.0.dist-info/RECORD +73 -0
  70. eagleplot-3.0.0.dist-info/WHEEL +5 -0
  71. eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
  72. eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
  73. 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