MoleditPy 2.2.0a2__py3-none-any.whl → 2.2.1__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.
- moleditpy/modules/constants.py +1 -1
- moleditpy/modules/main_window_main_init.py +32 -14
- moleditpy/modules/plugin_interface.py +1 -10
- moleditpy/modules/plugin_manager.py +0 -3
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.1.dist-info}/METADATA +1 -1
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.1.dist-info}/RECORD +10 -27
- moleditpy/plugins/Analysis/ms_spectrum_neo.py +0 -919
- moleditpy/plugins/File/animated_xyz_giffer.py +0 -583
- moleditpy/plugins/File/cube_viewer.py +0 -689
- moleditpy/plugins/File/gaussian_fchk_freq_analyzer.py +0 -1148
- moleditpy/plugins/File/mapped_cube_viewer.py +0 -552
- moleditpy/plugins/File/orca_out_freq_analyzer.py +0 -1226
- moleditpy/plugins/File/paste_xyz.py +0 -336
- moleditpy/plugins/Input Generator/gaussian_input_generator_neo.py +0 -930
- moleditpy/plugins/Input Generator/orca_input_generator_neo.py +0 -1028
- moleditpy/plugins/Input Generator/orca_xyz2inp_gui.py +0 -286
- moleditpy/plugins/Optimization/all-trans_optimizer.py +0 -65
- moleditpy/plugins/Optimization/complex_molecule_untangler.py +0 -268
- moleditpy/plugins/Optimization/conf_search.py +0 -224
- moleditpy/plugins/Utility/atom_colorizer.py +0 -262
- moleditpy/plugins/Utility/console.py +0 -163
- moleditpy/plugins/Utility/pubchem_ressolver.py +0 -244
- moleditpy/plugins/Utility/vdw_radii_overlay.py +0 -432
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.1.dist-info}/WHEEL +0 -0
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.1.dist-info}/entry_points.txt +0 -0
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.1.dist-info}/licenses/LICENSE +0 -0
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.1.dist-info}/top_level.txt +0 -0
|
@@ -1,1226 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import numpy as np
|
|
3
|
-
import traceback
|
|
4
|
-
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
|
|
5
|
-
QListWidget, QSlider, QCheckBox, QFileDialog, QMessageBox,
|
|
6
|
-
QDockWidget, QWidget, QFormLayout, QDialogButtonBox, QSpinBox, QApplication, QTreeWidget, QTreeWidgetItem, QHeaderView, QDoubleSpinBox)
|
|
7
|
-
from PyQt6.QtGui import QImage, QPainter, QPen, QColor, QFont, QPaintEvent
|
|
8
|
-
try:
|
|
9
|
-
from PIL import Image
|
|
10
|
-
HAS_PIL = True
|
|
11
|
-
except ImportError:
|
|
12
|
-
HAS_PIL = False
|
|
13
|
-
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
|
|
14
|
-
|
|
15
|
-
# Try to import RDKit
|
|
16
|
-
try:
|
|
17
|
-
from rdkit import Chem
|
|
18
|
-
from rdkit.Geometry import Point3D
|
|
19
|
-
except ImportError:
|
|
20
|
-
Chem = None
|
|
21
|
-
|
|
22
|
-
PLUGIN_NAME = "ORCA Freq Analyzer"
|
|
23
|
-
__version__="2025.12.25"
|
|
24
|
-
__author__="HiroYokoyama"
|
|
25
|
-
|
|
26
|
-
class OrcaParser:
|
|
27
|
-
def __init__(self):
|
|
28
|
-
self.filename = ""
|
|
29
|
-
self.atoms = []
|
|
30
|
-
self.coords = []
|
|
31
|
-
self.frequencies = []
|
|
32
|
-
self.intensities = [] # IR Intensities
|
|
33
|
-
self.vib_modes = [] # list of list of (dx, dy, dz)
|
|
34
|
-
self.charge = 0
|
|
35
|
-
self.multiplicity = 1
|
|
36
|
-
|
|
37
|
-
def parse(self, filename):
|
|
38
|
-
self.filename = filename
|
|
39
|
-
self.atoms = []
|
|
40
|
-
self.coords = []
|
|
41
|
-
self.frequencies = []
|
|
42
|
-
self.vib_modes = []
|
|
43
|
-
self.charge = 0
|
|
44
|
-
self.multiplicity = 1
|
|
45
|
-
|
|
46
|
-
with open(filename, 'r', encoding='utf-8', errors='replace') as f:
|
|
47
|
-
lines = f.readlines()
|
|
48
|
-
|
|
49
|
-
# 1. basic properties: charge, mult
|
|
50
|
-
# Look for "Total Charge Charge .... 0"
|
|
51
|
-
# OR In input block: "* xyz 0 1"
|
|
52
|
-
# scan lines
|
|
53
|
-
|
|
54
|
-
# 2. Geometry: Look for "CARTESIAN COORDINATES (ANGSTROEM)" or similar.
|
|
55
|
-
# Use the LAST occurrence to get optimized geometry.
|
|
56
|
-
|
|
57
|
-
coord_start_lines = []
|
|
58
|
-
freq_start_line = -1
|
|
59
|
-
modes_start_line = -1
|
|
60
|
-
|
|
61
|
-
for i, line in enumerate(lines):
|
|
62
|
-
line_s = line.strip()
|
|
63
|
-
if "CARTESIAN COORDINATES (ANGSTROEM)" in line:
|
|
64
|
-
coord_start_lines.append(i)
|
|
65
|
-
elif "CARTESIAN COORDINATES (A.U.)" in line:
|
|
66
|
-
pass # ignore AU for now unless needed
|
|
67
|
-
elif "VIBRATIONAL FREQUENCIES" in line:
|
|
68
|
-
freq_start_line = i
|
|
69
|
-
elif "NORMAL MODES" in line:
|
|
70
|
-
modes_start_line = i
|
|
71
|
-
elif "Total Charge" in line and "Charge" in line:
|
|
72
|
-
# e.g. "Total Charge Charge .... 0"
|
|
73
|
-
parts = line.split()
|
|
74
|
-
try:
|
|
75
|
-
self.charge = int(parts[-1])
|
|
76
|
-
except: pass
|
|
77
|
-
elif "Multiplicity" in line and "Mult" in line:
|
|
78
|
-
parts = line.split()
|
|
79
|
-
try:
|
|
80
|
-
self.multiplicity = int(parts[-1])
|
|
81
|
-
except: pass
|
|
82
|
-
|
|
83
|
-
# Parse Geometry (Last one)
|
|
84
|
-
if coord_start_lines:
|
|
85
|
-
start = coord_start_lines[-1]
|
|
86
|
-
try:
|
|
87
|
-
# format:
|
|
88
|
-
# CARTESIAN COORDINATES (ANGSTROEM)
|
|
89
|
-
# ---------------------------------
|
|
90
|
-
# O 0.000000 0.000000 0.000000
|
|
91
|
-
# H 0.000000 0.759337 0.596043
|
|
92
|
-
|
|
93
|
-
# Check where data starts. Usually start+2
|
|
94
|
-
curr = start + 2
|
|
95
|
-
while curr < len(lines):
|
|
96
|
-
l = lines[curr].strip()
|
|
97
|
-
if not l:
|
|
98
|
-
curr += 1
|
|
99
|
-
# If multiple empty lines, maybe end
|
|
100
|
-
if curr < len(lines) and not lines[curr].strip():
|
|
101
|
-
break
|
|
102
|
-
continue
|
|
103
|
-
|
|
104
|
-
parts = l.split()
|
|
105
|
-
if len(parts) >= 4:
|
|
106
|
-
sym = parts[0]
|
|
107
|
-
# Check if sym is element
|
|
108
|
-
if not sym[0].isalpha():
|
|
109
|
-
break # End of block
|
|
110
|
-
|
|
111
|
-
try:
|
|
112
|
-
x = float(parts[1])
|
|
113
|
-
y = float(parts[2])
|
|
114
|
-
z = float(parts[3])
|
|
115
|
-
|
|
116
|
-
# Valid atom
|
|
117
|
-
# Convert Sym to atomic num? RDKit needs num or Valid symbol
|
|
118
|
-
# We can keep symbol or convert
|
|
119
|
-
# Let's keep symbol for now, convert to num for uniformity if needed
|
|
120
|
-
# RDKit Atom(symbol) works
|
|
121
|
-
self.atoms.append(sym)
|
|
122
|
-
self.coords.append((x, y, z))
|
|
123
|
-
except ValueError:
|
|
124
|
-
pass
|
|
125
|
-
else:
|
|
126
|
-
break
|
|
127
|
-
curr += 1
|
|
128
|
-
except Exception as e:
|
|
129
|
-
print(f"Error parsing coords: {e}")
|
|
130
|
-
|
|
131
|
-
# Parse Frequencies
|
|
132
|
-
# -----------------------
|
|
133
|
-
# VIBRATIONAL FREQUENCIES
|
|
134
|
-
# -----------------------
|
|
135
|
-
# ...
|
|
136
|
-
# 0: 0.00 cm**-1
|
|
137
|
-
# 6: 1709.03 cm**-1
|
|
138
|
-
|
|
139
|
-
if freq_start_line > 0:
|
|
140
|
-
curr = freq_start_line + 4 # skip header
|
|
141
|
-
while curr < len(lines):
|
|
142
|
-
l = lines[curr].strip()
|
|
143
|
-
if not l:
|
|
144
|
-
curr += 1
|
|
145
|
-
continue
|
|
146
|
-
if "NORMAL MODES" in l: # safe guard
|
|
147
|
-
break
|
|
148
|
-
# Format: " 6: 1709.03 cm**-1"
|
|
149
|
-
if ":" in l and "cm**-1" in l:
|
|
150
|
-
parts = l.split()
|
|
151
|
-
# parts example: ['6:', '1709.03', 'cm**-1']
|
|
152
|
-
# sometimes: '0:', '0.00', 'cm**-1'
|
|
153
|
-
try:
|
|
154
|
-
val_str = parts[1]
|
|
155
|
-
val = float(val_str)
|
|
156
|
-
if val != 0.0: # Only non-zero? FCHK has all. User wants vibrations.
|
|
157
|
-
# But let's verify if user wants 0 modes (translation/rotation). Usually not.
|
|
158
|
-
# But keeping index alignment with Normal Modes is crucial.
|
|
159
|
-
# ORCA Normal Modes output block usually includes all 3*N modes.
|
|
160
|
-
pass
|
|
161
|
-
self.frequencies.append(val)
|
|
162
|
-
except: pass
|
|
163
|
-
elif "NORMAL MODES" in l or "-----" in l:
|
|
164
|
-
if len(self.frequencies) > 0: # If we parsed some, stop
|
|
165
|
-
break
|
|
166
|
-
curr += 1
|
|
167
|
-
|
|
168
|
-
# Parse IR Spectrum for Intensities
|
|
169
|
-
# Store as a dictionary: mode_id -> intensity
|
|
170
|
-
intensity_map = {} # {mode_idx: intensity}
|
|
171
|
-
|
|
172
|
-
ir_start = -1
|
|
173
|
-
for i, line in enumerate(lines):
|
|
174
|
-
if "IR SPECTRUM" in line:
|
|
175
|
-
ir_start = i # Keep updating to get the LAST occurrence
|
|
176
|
-
# Don't break - continue to find last one
|
|
177
|
-
|
|
178
|
-
if ir_start > 0:
|
|
179
|
-
curr = ir_start + 6 # Skip headers and dashed line
|
|
180
|
-
# Expected format:
|
|
181
|
-
# Mode freq eps Int T**2 TX TY TZ
|
|
182
|
-
# cm**-1 L/(mol*cm) km/mol a.u.
|
|
183
|
-
# ----------------------------------------------------------------------------
|
|
184
|
-
# 6: 1709.03 0.015725 79.47 0.002871 (-0.001018 -0.053574 -0.000000)
|
|
185
|
-
|
|
186
|
-
while curr < len(lines):
|
|
187
|
-
l = lines[curr].strip()
|
|
188
|
-
if not l:
|
|
189
|
-
curr += 1
|
|
190
|
-
continue
|
|
191
|
-
if "-----" in l or "The first frequency" in l or "*" in l:
|
|
192
|
-
break
|
|
193
|
-
|
|
194
|
-
# line: " 6: 1709.03 0.015725 79.47 0.002871 ..."
|
|
195
|
-
if ":" in l:
|
|
196
|
-
parts = l.split()
|
|
197
|
-
# parts[0] -> "6:"
|
|
198
|
-
# parts[1] -> Freq
|
|
199
|
-
# parts[2] -> eps
|
|
200
|
-
# parts[3] -> Int (km/mol)
|
|
201
|
-
# parts[4] -> T**2
|
|
202
|
-
try:
|
|
203
|
-
mode_id_str = parts[0].rstrip(':')
|
|
204
|
-
mode_id = int(mode_id_str)
|
|
205
|
-
if len(parts) >= 4:
|
|
206
|
-
inten = float(parts[3])
|
|
207
|
-
intensity_map[mode_id] = inten
|
|
208
|
-
except:
|
|
209
|
-
pass
|
|
210
|
-
curr += 1
|
|
211
|
-
|
|
212
|
-
# Parse Normal Modes
|
|
213
|
-
# ------------
|
|
214
|
-
# NORMAL MODES
|
|
215
|
-
# ------------
|
|
216
|
-
# ...
|
|
217
|
-
# 0 1 2 3 4 5
|
|
218
|
-
# 0 0.000000 0.000000 ...
|
|
219
|
-
# ...
|
|
220
|
-
# 6 7 8
|
|
221
|
-
# 0 0.001345 -0.000914 0.069964
|
|
222
|
-
|
|
223
|
-
if modes_start_line > 0 and len(self.atoms) > 0:
|
|
224
|
-
# We need to reconstruct full vectors for each mode.
|
|
225
|
-
# ORCA output is blocked by columns (modes).
|
|
226
|
-
# Rows are atoms * 3 (X, Y, Z coordinates).
|
|
227
|
-
|
|
228
|
-
n_atoms = len(self.atoms)
|
|
229
|
-
n_coords = n_atoms * 3
|
|
230
|
-
# Initialize empty modes
|
|
231
|
-
# We don't know exactly how many modes total yet, but freq list gives a hint.
|
|
232
|
-
# Let's verify number of frequencies to initialize
|
|
233
|
-
|
|
234
|
-
# Since parsing columnar data is tricky line-by-line without knowing total columns,
|
|
235
|
-
# we will create a dictionary mode_index -> [vector]
|
|
236
|
-
|
|
237
|
-
mode_data = {} # {mode_idx: [val0, val1, ...]}
|
|
238
|
-
|
|
239
|
-
curr = modes_start_line + 7 # skip headers (approx)
|
|
240
|
-
# Find first line of numbers
|
|
241
|
-
while curr < len(lines) and not lines[curr].strip():
|
|
242
|
-
curr += 1
|
|
243
|
-
|
|
244
|
-
# Now we are at column headers: " 0 1 2 ..."
|
|
245
|
-
while curr < len(lines):
|
|
246
|
-
header = lines[curr].strip()
|
|
247
|
-
if not header:
|
|
248
|
-
curr += 1
|
|
249
|
-
continue
|
|
250
|
-
if "IR SPECTRUM" in header or "--------" in header:
|
|
251
|
-
break
|
|
252
|
-
|
|
253
|
-
# Parse column indices
|
|
254
|
-
try:
|
|
255
|
-
cols = [int(c) for c in header.split()]
|
|
256
|
-
except ValueError:
|
|
257
|
-
# Maybe not a header line, skip
|
|
258
|
-
curr += 1
|
|
259
|
-
continue
|
|
260
|
-
|
|
261
|
-
# Check next line: " 0 0.000000 0.000000 ..."
|
|
262
|
-
# This corresponds to coordinate index 0 (Atom 0, X)
|
|
263
|
-
|
|
264
|
-
start_data = curr + 1
|
|
265
|
-
# Read 3*N lines for coordinates
|
|
266
|
-
for row_idx in range(n_coords):
|
|
267
|
-
if start_data + row_idx >= len(lines): break
|
|
268
|
-
row_line = lines[start_data + row_idx].strip()
|
|
269
|
-
row_parts = row_line.split()
|
|
270
|
-
|
|
271
|
-
# First part is coordinate index (0, 1, 2...)
|
|
272
|
-
# Remaining parts are values for the columns
|
|
273
|
-
values = row_parts[1:]
|
|
274
|
-
|
|
275
|
-
if len(values) != len(cols):
|
|
276
|
-
# Mismatched data?
|
|
277
|
-
continue
|
|
278
|
-
|
|
279
|
-
for c_idx, val_str in enumerate(values):
|
|
280
|
-
mode_idx = cols[c_idx]
|
|
281
|
-
val = float(val_str)
|
|
282
|
-
if mode_idx not in mode_data:
|
|
283
|
-
mode_data[mode_idx] = []
|
|
284
|
-
mode_data[mode_idx].append(val)
|
|
285
|
-
|
|
286
|
-
curr = start_data + n_coords
|
|
287
|
-
|
|
288
|
-
# Convert mode_data to self.vib_modes aligned with self.frequencies?
|
|
289
|
-
# Frequencies list has indices 0...N.
|
|
290
|
-
# We only care about modes that match frequencies we found.
|
|
291
|
-
# Note ORCA freq list usually filters out 0.00 translations if "VIBRATIONAL FREQUENCIES" is used,
|
|
292
|
-
# BUT the list index in freq block (" 6: ...") matches the mode index.
|
|
293
|
-
|
|
294
|
-
# Let's align them.
|
|
295
|
-
# `self.frequencies` is just a flat list of values found. We need pairs (index, freq).
|
|
296
|
-
# The freq parsing above was simplistic. Let's re-parse freq to get IDs.
|
|
297
|
-
|
|
298
|
-
# Re-scanning frequencies for ID mapping:
|
|
299
|
-
freq_map = {} # id -> freq
|
|
300
|
-
if freq_start_line > 0:
|
|
301
|
-
curr = freq_start_line + 4
|
|
302
|
-
while curr < len(lines):
|
|
303
|
-
l = lines[curr].strip()
|
|
304
|
-
if ":" in l and "cm**-1" in l:
|
|
305
|
-
parts = l.split(':')
|
|
306
|
-
try:
|
|
307
|
-
mid = int(parts[0].strip())
|
|
308
|
-
freq_val = float(parts[1].split()[0])
|
|
309
|
-
freq_map[mid] = freq_val
|
|
310
|
-
except: pass
|
|
311
|
-
if "NORMAL MODES" in l: break
|
|
312
|
-
curr += 1
|
|
313
|
-
|
|
314
|
-
# Now build final list
|
|
315
|
-
sorted_mids = sorted(mode_data.keys())
|
|
316
|
-
|
|
317
|
-
# Only keep modes that have frequencies (or all?)
|
|
318
|
-
# Usually we only want non-zero modes (vibrations).
|
|
319
|
-
# ORCA often prints 0-5 as translations/rotations.
|
|
320
|
-
|
|
321
|
-
# Let's store pairs: (freq, vector)
|
|
322
|
-
# Filter: only if freq > 10.0 cm-1 (avoid translations)
|
|
323
|
-
|
|
324
|
-
self.final_modes = [] # item: {'freq': f, 'intensity': I, 'vector': [(x,y,z),...]}
|
|
325
|
-
|
|
326
|
-
for mid in sorted_mids:
|
|
327
|
-
freq = freq_map.get(mid, 0.0)
|
|
328
|
-
# Use abs() to preserve imaginary frequencies (negative values)
|
|
329
|
-
# Only exclude low-frequency modes (translations/rotations)
|
|
330
|
-
if abs(freq) < 10.0: continue
|
|
331
|
-
|
|
332
|
-
raw_vec = mode_data[mid]
|
|
333
|
-
if len(raw_vec) != n_coords: continue
|
|
334
|
-
|
|
335
|
-
# format to list of (dx,dy,dz)
|
|
336
|
-
vec_formatted = []
|
|
337
|
-
for k in range(0, len(raw_vec), 3):
|
|
338
|
-
vec_formatted.append((raw_vec[k], raw_vec[k+1], raw_vec[k+2]))
|
|
339
|
-
|
|
340
|
-
# Get intensity for this mode from intensity_map
|
|
341
|
-
intensity = intensity_map.get(mid, None)
|
|
342
|
-
|
|
343
|
-
self.final_modes.append({'freq': freq, 'intensity': intensity, 'vector': vec_formatted})
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
class OrcaOutFreqAnalyzer(QWidget):
|
|
347
|
-
def __init__(self, main_window, dock_widget=None):
|
|
348
|
-
super().__init__(main_window)
|
|
349
|
-
self.mw = main_window
|
|
350
|
-
self.dock = dock_widget # Store reference
|
|
351
|
-
self.setAcceptDrops(True)
|
|
352
|
-
|
|
353
|
-
self.parser = None
|
|
354
|
-
self.base_mol = None
|
|
355
|
-
self.timer = QTimer()
|
|
356
|
-
self.timer.timeout.connect(self.animate_frame)
|
|
357
|
-
self.animation_step = 0
|
|
358
|
-
self.is_playing = False
|
|
359
|
-
self.vector_actor = None
|
|
360
|
-
|
|
361
|
-
self.init_ui()
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
def init_ui(self):
|
|
365
|
-
layout = QVBoxLayout(self)
|
|
366
|
-
|
|
367
|
-
# Info Label
|
|
368
|
-
self.lbl_info = QLabel("Drop .out file here or click Open")
|
|
369
|
-
self.lbl_info.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
370
|
-
self.lbl_info.setStyleSheet("border: 2px dashed #AAA; padding: 20px; color: #555;")
|
|
371
|
-
layout.addWidget(self.lbl_info)
|
|
372
|
-
|
|
373
|
-
# Open Button
|
|
374
|
-
btn_open = QPushButton("Open ORCA Output")
|
|
375
|
-
btn_open.clicked.connect(self.open_file_dialog)
|
|
376
|
-
layout.addWidget(btn_open)
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
# Frequency List
|
|
380
|
-
# Frequency List
|
|
381
|
-
layout.addWidget(QLabel("Vibrational Frequencies:"))
|
|
382
|
-
self.list_freq = QTreeWidget()
|
|
383
|
-
self.list_freq.setColumnCount(3)
|
|
384
|
-
self.list_freq.setHeaderLabels(["No.", "Frequency (cm⁻¹)", "Intensity (km/mol)"])
|
|
385
|
-
self.list_freq.header().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
|
386
|
-
self.list_freq.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
|
387
|
-
self.list_freq.headerItem().setTextAlignment(0, Qt.AlignmentFlag.AlignCenter) # Center No. header
|
|
388
|
-
self.list_freq.headerItem().setTextAlignment(1, Qt.AlignmentFlag.AlignCenter) # Center Frequency header
|
|
389
|
-
self.list_freq.headerItem().setTextAlignment(2, Qt.AlignmentFlag.AlignCenter) # Center Intensity header
|
|
390
|
-
self.list_freq.currentItemChanged.connect(self.on_freq_selected)
|
|
391
|
-
layout.addWidget(self.list_freq)
|
|
392
|
-
|
|
393
|
-
# Spectrum Button
|
|
394
|
-
btn_spectrum = QPushButton("Show Spectrum")
|
|
395
|
-
btn_spectrum.clicked.connect(self.show_spectrum)
|
|
396
|
-
layout.addWidget(btn_spectrum)
|
|
397
|
-
|
|
398
|
-
# Animation Controls
|
|
399
|
-
anim_layout = QVBoxLayout()
|
|
400
|
-
|
|
401
|
-
# Vector Controls
|
|
402
|
-
vec_layout = QHBoxLayout()
|
|
403
|
-
self.chk_vectors = QCheckBox("Show Vectors")
|
|
404
|
-
self.chk_vectors.setChecked(False)
|
|
405
|
-
self.chk_vectors.stateChanged.connect(lambda state: self.update_vectors())
|
|
406
|
-
vec_layout.addWidget(self.chk_vectors)
|
|
407
|
-
|
|
408
|
-
vec_layout.addWidget(QLabel("Scale:"))
|
|
409
|
-
self.spin_vec_scale = QDoubleSpinBox()
|
|
410
|
-
self.spin_vec_scale.setRange(0.1, 200.0)
|
|
411
|
-
self.spin_vec_scale.setSingleStep(1.0)
|
|
412
|
-
self.spin_vec_scale.setValue(2.0)
|
|
413
|
-
self.spin_vec_scale.valueChanged.connect(lambda val: self.update_vectors())
|
|
414
|
-
vec_layout.addWidget(self.spin_vec_scale)
|
|
415
|
-
vec_layout.addStretch()
|
|
416
|
-
|
|
417
|
-
anim_layout.addLayout(vec_layout)
|
|
418
|
-
|
|
419
|
-
# Amplitude
|
|
420
|
-
amp_layout = QHBoxLayout()
|
|
421
|
-
amp_layout.addWidget(QLabel("Amplitude:"))
|
|
422
|
-
self.slider_amp = QSlider(Qt.Orientation.Horizontal)
|
|
423
|
-
self.slider_amp.setRange(1, 20)
|
|
424
|
-
self.slider_amp.setValue(5)
|
|
425
|
-
|
|
426
|
-
self.lbl_amp_val = QLabel("5")
|
|
427
|
-
self.slider_amp.valueChanged.connect(lambda v: self.lbl_amp_val.setText(str(v)))
|
|
428
|
-
|
|
429
|
-
amp_layout.addWidget(self.slider_amp)
|
|
430
|
-
amp_layout.addWidget(self.lbl_amp_val)
|
|
431
|
-
anim_layout.addLayout(amp_layout)
|
|
432
|
-
|
|
433
|
-
# FPS (Speed)
|
|
434
|
-
speed_layout = QHBoxLayout()
|
|
435
|
-
speed_layout.addWidget(QLabel("FPS:"))
|
|
436
|
-
self.slider_speed = QSlider(Qt.Orientation.Horizontal)
|
|
437
|
-
self.slider_speed.setRange(1, 60)
|
|
438
|
-
self.slider_speed.setValue(20)
|
|
439
|
-
|
|
440
|
-
self.lbl_speed_val = QLabel("20")
|
|
441
|
-
self.slider_speed.valueChanged.connect(lambda v: self.lbl_speed_val.setText(str(v)))
|
|
442
|
-
|
|
443
|
-
self.slider_speed.valueChanged.connect(self.update_timer_interval)
|
|
444
|
-
speed_layout.addWidget(self.slider_speed)
|
|
445
|
-
speed_layout.addWidget(self.lbl_speed_val)
|
|
446
|
-
anim_layout.addLayout(speed_layout)
|
|
447
|
-
|
|
448
|
-
# Buttons
|
|
449
|
-
btn_layout = QHBoxLayout()
|
|
450
|
-
self.btn_play = QPushButton("Play")
|
|
451
|
-
self.btn_play.clicked.connect(self.toggle_play)
|
|
452
|
-
self.btn_stop = QPushButton("Stop")
|
|
453
|
-
self.btn_stop.clicked.connect(self.stop_play)
|
|
454
|
-
|
|
455
|
-
btn_layout.addWidget(self.btn_play)
|
|
456
|
-
btn_layout.addWidget(self.btn_stop)
|
|
457
|
-
anim_layout.addLayout(btn_layout)
|
|
458
|
-
|
|
459
|
-
layout.addLayout(anim_layout)
|
|
460
|
-
|
|
461
|
-
# Export GIF Button
|
|
462
|
-
self.btn_gif = QPushButton("Export GIF")
|
|
463
|
-
self.btn_gif.clicked.connect(self.save_as_gif)
|
|
464
|
-
self.btn_gif.setEnabled(HAS_PIL)
|
|
465
|
-
layout.addWidget(self.btn_gif)
|
|
466
|
-
|
|
467
|
-
# Metadata Info
|
|
468
|
-
self.lbl_meta = QLabel("")
|
|
469
|
-
layout.addWidget(self.lbl_meta)
|
|
470
|
-
|
|
471
|
-
layout.addStretch()
|
|
472
|
-
|
|
473
|
-
# Close Button
|
|
474
|
-
btn_close = QPushButton("Close")
|
|
475
|
-
def close_action():
|
|
476
|
-
self.stop_play()
|
|
477
|
-
self.reset_geometry()
|
|
478
|
-
self.remove_vectors()
|
|
479
|
-
if self.dock:
|
|
480
|
-
self.dock.close()
|
|
481
|
-
else:
|
|
482
|
-
self.close()
|
|
483
|
-
btn_close.clicked.connect(close_action)
|
|
484
|
-
layout.addWidget(btn_close)
|
|
485
|
-
|
|
486
|
-
self.setLayout(layout)
|
|
487
|
-
|
|
488
|
-
def dragEnterEvent(self, event):
|
|
489
|
-
if event.mimeData().hasUrls():
|
|
490
|
-
for url in event.mimeData().urls():
|
|
491
|
-
fname = url.toLocalFile().lower()
|
|
492
|
-
if fname.endswith(".out") or fname.endswith(".log"):
|
|
493
|
-
event.acceptProposedAction()
|
|
494
|
-
return
|
|
495
|
-
event.ignore()
|
|
496
|
-
|
|
497
|
-
def dropEvent(self, event):
|
|
498
|
-
for url in event.mimeData().urls():
|
|
499
|
-
file_path = url.toLocalFile()
|
|
500
|
-
if file_path.lower().endswith((".out", ".log")):
|
|
501
|
-
self.load_file(file_path)
|
|
502
|
-
event.acceptProposedAction()
|
|
503
|
-
break
|
|
504
|
-
|
|
505
|
-
def open_file_dialog(self):
|
|
506
|
-
fname, _ = QFileDialog.getOpenFileName(self, "Open ORCA Out", "", "Output Files (*.out *.log)")
|
|
507
|
-
if fname:
|
|
508
|
-
self.load_file(fname)
|
|
509
|
-
|
|
510
|
-
def load_file(self, filename):
|
|
511
|
-
# Validation before parsing
|
|
512
|
-
if not is_valid_orca_file(filename):
|
|
513
|
-
QMessageBox.critical(self, "Invalid File", "The selected file does not appear to be a valid ORCA output file.\n(Missing 'ORCA' header)")
|
|
514
|
-
return
|
|
515
|
-
|
|
516
|
-
self.parser = OrcaParser()
|
|
517
|
-
try:
|
|
518
|
-
self.parser.parse(filename)
|
|
519
|
-
self.update_ui_after_load()
|
|
520
|
-
self.lbl_info.setText(os.path.basename(filename))
|
|
521
|
-
self.lbl_info.setStyleSheet("border: 2px solid #4CAF50; padding: 10px; color: #4CAF50;")
|
|
522
|
-
|
|
523
|
-
# Update Main Window Context
|
|
524
|
-
if hasattr(self.mw, 'current_file_path'):
|
|
525
|
-
self.mw.current_file_path = filename
|
|
526
|
-
if hasattr(self.mw, 'update_window_title'):
|
|
527
|
-
self.mw.update_window_title()
|
|
528
|
-
else:
|
|
529
|
-
self.mw.setWindowTitle(f"{os.path.basename(filename)} - MoleditPy")
|
|
530
|
-
|
|
531
|
-
except Exception as e:
|
|
532
|
-
QMessageBox.critical(self, "Error", f"Failed to parse Output:\n{e}")
|
|
533
|
-
traceback.print_exc()
|
|
534
|
-
|
|
535
|
-
def update_ui_after_load(self):
|
|
536
|
-
self.list_freq.clear()
|
|
537
|
-
if hasattr(self.parser, 'final_modes'):
|
|
538
|
-
for i, mode in enumerate(self.parser.final_modes):
|
|
539
|
-
freq = mode['freq']
|
|
540
|
-
item = QTreeWidgetItem()
|
|
541
|
-
item.setText(0, str(i + 1)) # Mode number
|
|
542
|
-
item.setText(1, f"{freq:.2f}")
|
|
543
|
-
|
|
544
|
-
# Get intensity from the mode dictionary
|
|
545
|
-
inten = mode.get('intensity')
|
|
546
|
-
if inten is not None:
|
|
547
|
-
item.setText(2, f"{inten:.2f}")
|
|
548
|
-
else:
|
|
549
|
-
item.setText(2, "-")
|
|
550
|
-
item.setTextAlignment(0, Qt.AlignmentFlag.AlignCenter) # Center mode number
|
|
551
|
-
item.setTextAlignment(1, Qt.AlignmentFlag.AlignCenter) # Center frequency
|
|
552
|
-
item.setTextAlignment(2, Qt.AlignmentFlag.AlignCenter) # Center intensity
|
|
553
|
-
self.list_freq.addTopLevelItem(item)
|
|
554
|
-
|
|
555
|
-
self.lbl_meta.setText(f"Charge: {self.parser.charge}, Multiplicity: {self.parser.multiplicity}\nAtoms: {len(self.parser.atoms)}")
|
|
556
|
-
|
|
557
|
-
# Load molecule into main window
|
|
558
|
-
if len(self.parser.atoms) > 0 and Chem:
|
|
559
|
-
self.create_base_molecule()
|
|
560
|
-
|
|
561
|
-
def create_base_molecule(self):
|
|
562
|
-
if not self.parser: return
|
|
563
|
-
|
|
564
|
-
mol = Chem.RWMol()
|
|
565
|
-
|
|
566
|
-
for sym in self.parser.atoms:
|
|
567
|
-
atom = Chem.Atom(sym)
|
|
568
|
-
mol.AddAtom(atom)
|
|
569
|
-
|
|
570
|
-
conf = Chem.Conformer(len(self.parser.atoms))
|
|
571
|
-
for idx, (x, y, z) in enumerate(self.parser.coords):
|
|
572
|
-
conf.SetAtomPosition(idx, Point3D(x, y, z))
|
|
573
|
-
mol.AddConformer(conf)
|
|
574
|
-
|
|
575
|
-
if hasattr(self.mw, 'estimate_bonds_from_distances'):
|
|
576
|
-
self.mw.estimate_bonds_from_distances(mol)
|
|
577
|
-
|
|
578
|
-
self.base_mol = mol.GetMol()
|
|
579
|
-
self.mw.current_mol = self.base_mol
|
|
580
|
-
|
|
581
|
-
if hasattr(self.mw, '_enter_3d_viewer_ui_mode'):
|
|
582
|
-
self.mw._enter_3d_viewer_ui_mode()
|
|
583
|
-
|
|
584
|
-
self.mw.draw_molecule_3d(self.base_mol)
|
|
585
|
-
if hasattr(self.mw, 'plotter'):
|
|
586
|
-
self.mw.plotter.reset_camera()
|
|
587
|
-
|
|
588
|
-
def on_freq_selected(self, current, previous):
|
|
589
|
-
if self.is_playing:
|
|
590
|
-
# Transition smoothly: Reset geometry base for next frame calculation?
|
|
591
|
-
# animate_frame uses base coords + displacement, so it handles switch naturally.
|
|
592
|
-
# Just ensure vectors update if needed (animate_frame updates them too)
|
|
593
|
-
pass
|
|
594
|
-
else:
|
|
595
|
-
self.reset_geometry()
|
|
596
|
-
self.update_vectors()
|
|
597
|
-
|
|
598
|
-
def toggle_play(self):
|
|
599
|
-
curr = self.list_freq.currentItem()
|
|
600
|
-
if not curr:
|
|
601
|
-
return
|
|
602
|
-
|
|
603
|
-
row = self.list_freq.indexOfTopLevelItem(curr)
|
|
604
|
-
if row < 0: return
|
|
605
|
-
|
|
606
|
-
if self.is_playing:
|
|
607
|
-
# Pause logic
|
|
608
|
-
self.is_playing = False
|
|
609
|
-
self.timer.stop()
|
|
610
|
-
self.btn_play.setText("Play")
|
|
611
|
-
# Do NOT reset geometry
|
|
612
|
-
return
|
|
613
|
-
|
|
614
|
-
self.is_playing = True
|
|
615
|
-
self.btn_play.setText("Pause")
|
|
616
|
-
self.timer.start(50)
|
|
617
|
-
self.update_timer_interval()
|
|
618
|
-
|
|
619
|
-
def stop_play(self):
|
|
620
|
-
self.is_playing = False
|
|
621
|
-
self.timer.stop()
|
|
622
|
-
self.btn_play.setText("Play")
|
|
623
|
-
|
|
624
|
-
self.reset_geometry()
|
|
625
|
-
QApplication.processEvents()
|
|
626
|
-
|
|
627
|
-
def update_timer_interval(self):
|
|
628
|
-
fps = self.slider_speed.value()
|
|
629
|
-
if fps <= 0: fps = 1
|
|
630
|
-
interval = 1000 / fps
|
|
631
|
-
self.timer.setInterval(int(interval))
|
|
632
|
-
|
|
633
|
-
def reset_geometry(self):
|
|
634
|
-
if not self.base_mol or not self.parser: return
|
|
635
|
-
conf = self.base_mol.GetConformer()
|
|
636
|
-
for idx, (x, y, z) in enumerate(self.parser.coords):
|
|
637
|
-
conf.SetAtomPosition(idx, Point3D(x, y, z))
|
|
638
|
-
self.mw.draw_molecule_3d(self.base_mol)
|
|
639
|
-
self.mw.draw_molecule_3d(self.base_mol)
|
|
640
|
-
|
|
641
|
-
self.update_vectors()
|
|
642
|
-
|
|
643
|
-
if hasattr(self.mw, 'plotter'):
|
|
644
|
-
self.mw.plotter.render()
|
|
645
|
-
|
|
646
|
-
def animate_frame(self):
|
|
647
|
-
if not self.parser or not self.base_mol:
|
|
648
|
-
self.stop_play()
|
|
649
|
-
return
|
|
650
|
-
|
|
651
|
-
curr = self.list_freq.currentItem()
|
|
652
|
-
if not curr:
|
|
653
|
-
self.stop_play()
|
|
654
|
-
return
|
|
655
|
-
row = self.list_freq.indexOfTopLevelItem(curr)
|
|
656
|
-
if row < 0 or row >= len(self.parser.final_modes):
|
|
657
|
-
return
|
|
658
|
-
|
|
659
|
-
mode_data = self.parser.final_modes[row]
|
|
660
|
-
mode_vecs = mode_data['vector']
|
|
661
|
-
|
|
662
|
-
self.animation_step += 1
|
|
663
|
-
cycle_pos = (self.animation_step % 20) / 20.0
|
|
664
|
-
phase = cycle_pos * 2 * np.pi
|
|
665
|
-
|
|
666
|
-
scale = self.slider_amp.value() / 20.0
|
|
667
|
-
factor = np.sin(phase) * scale
|
|
668
|
-
|
|
669
|
-
self.apply_displacement(mode_vecs, factor)
|
|
670
|
-
self.mw.draw_molecule_3d(self.base_mol)
|
|
671
|
-
self.update_vectors(mode_vecs=mode_vecs, scale_factor=factor)
|
|
672
|
-
|
|
673
|
-
def apply_displacement(self, mode_vecs, factor):
|
|
674
|
-
conf = self.base_mol.GetConformer()
|
|
675
|
-
base_coords = self.parser.coords
|
|
676
|
-
|
|
677
|
-
for idx, (bx, by, bz) in enumerate(base_coords):
|
|
678
|
-
if idx < len(mode_vecs):
|
|
679
|
-
dx, dy, dz = mode_vecs[idx]
|
|
680
|
-
nx = bx + dx * factor
|
|
681
|
-
ny = by + dy * factor
|
|
682
|
-
nz = bz + dz * factor
|
|
683
|
-
conf.SetAtomPosition(idx, Point3D(nx, ny, nz))
|
|
684
|
-
|
|
685
|
-
def remove_vectors(self):
|
|
686
|
-
if self.vector_actor and hasattr(self.mw, 'plotter'):
|
|
687
|
-
try:
|
|
688
|
-
self.mw.plotter.remove_actor(self.vector_actor)
|
|
689
|
-
except: pass
|
|
690
|
-
self.vector_actor = None
|
|
691
|
-
|
|
692
|
-
def update_vectors(self, mode_vecs=None, scale_factor=0.0):
|
|
693
|
-
# Clean up existing vectors
|
|
694
|
-
self.remove_vectors()
|
|
695
|
-
|
|
696
|
-
if not self.chk_vectors.isChecked():
|
|
697
|
-
return
|
|
698
|
-
|
|
699
|
-
if not self.parser or not self.base_mol or not hasattr(self.mw, 'plotter'):
|
|
700
|
-
return
|
|
701
|
-
|
|
702
|
-
# Get current frequency
|
|
703
|
-
curr = self.list_freq.currentItem()
|
|
704
|
-
if not curr: return
|
|
705
|
-
row = self.list_freq.indexOfTopLevelItem(curr)
|
|
706
|
-
if row < 0 or row >= len(self.parser.final_modes): return
|
|
707
|
-
|
|
708
|
-
# Get vectors if not provided
|
|
709
|
-
if mode_vecs is None:
|
|
710
|
-
mode_data = self.parser.final_modes[row]
|
|
711
|
-
mode_vecs = mode_data['vector']
|
|
712
|
-
|
|
713
|
-
# Current coords from molecule conformer
|
|
714
|
-
conf = self.base_mol.GetConformer()
|
|
715
|
-
coords = []
|
|
716
|
-
vectors = []
|
|
717
|
-
|
|
718
|
-
# Amplitude for vector length scaling
|
|
719
|
-
# Now decoupled from animation amplitude
|
|
720
|
-
vis_scale = self.spin_vec_scale.value()
|
|
721
|
-
|
|
722
|
-
for idx in range(len(mode_vecs)):
|
|
723
|
-
pos = conf.GetAtomPosition(idx)
|
|
724
|
-
coords.append([pos.x, pos.y, pos.z])
|
|
725
|
-
|
|
726
|
-
dx, dy, dz = mode_vecs[idx]
|
|
727
|
-
vectors.append([dx, dy, dz])
|
|
728
|
-
|
|
729
|
-
if not coords: return
|
|
730
|
-
|
|
731
|
-
coords = np.array(coords)
|
|
732
|
-
vectors = np.array(vectors)
|
|
733
|
-
|
|
734
|
-
try:
|
|
735
|
-
self.vector_actor = self.mw.plotter.add_arrows(coords, vectors, mag=vis_scale, color='lightgreen', show_scalar_bar=False)
|
|
736
|
-
except Exception as e:
|
|
737
|
-
print(f"Error adding arrows: {e}")
|
|
738
|
-
|
|
739
|
-
def save_as_gif(self):
|
|
740
|
-
if not self.parser or not self.base_mol: return
|
|
741
|
-
|
|
742
|
-
was_playing = self.is_playing
|
|
743
|
-
if self.is_playing:
|
|
744
|
-
self.toggle_play()
|
|
745
|
-
|
|
746
|
-
curr = self.list_freq.currentItem()
|
|
747
|
-
if not curr:
|
|
748
|
-
QMessageBox.warning(self, "Select Frequency", "Please select a frequency to export.")
|
|
749
|
-
return
|
|
750
|
-
row = self.list_freq.indexOfTopLevelItem(curr)
|
|
751
|
-
|
|
752
|
-
dialog = QDialog(self)
|
|
753
|
-
dialog.setWindowTitle("Export GIF Settings")
|
|
754
|
-
form = QFormLayout(dialog)
|
|
755
|
-
|
|
756
|
-
# Calculate current FPS
|
|
757
|
-
# Slider value is now FPS directly
|
|
758
|
-
current_fps = self.slider_speed.value()
|
|
759
|
-
|
|
760
|
-
spin_fps = QSpinBox()
|
|
761
|
-
spin_fps.setRange(1, 60)
|
|
762
|
-
spin_fps.setValue(current_fps)
|
|
763
|
-
|
|
764
|
-
chk_transparent = QCheckBox()
|
|
765
|
-
chk_transparent.setChecked(True)
|
|
766
|
-
|
|
767
|
-
chk_hq = QCheckBox()
|
|
768
|
-
chk_hq.setChecked(True)
|
|
769
|
-
|
|
770
|
-
form.addRow("FPS:", spin_fps)
|
|
771
|
-
form.addRow("Transparent Background:", chk_transparent)
|
|
772
|
-
form.addRow("High Quality (Adaptive):", chk_hq)
|
|
773
|
-
|
|
774
|
-
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
775
|
-
btns.accepted.connect(dialog.accept)
|
|
776
|
-
btns.rejected.connect(dialog.reject)
|
|
777
|
-
form.addRow(btns)
|
|
778
|
-
|
|
779
|
-
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
780
|
-
if was_playing: self.toggle_play()
|
|
781
|
-
return
|
|
782
|
-
|
|
783
|
-
target_fps = spin_fps.value()
|
|
784
|
-
use_transparent = chk_transparent.isChecked()
|
|
785
|
-
use_hq = chk_hq.isChecked()
|
|
786
|
-
|
|
787
|
-
file_path, _ = QFileDialog.getSaveFileName(self, "Save GIF", "", "GIF Files (*.gif)")
|
|
788
|
-
if not file_path:
|
|
789
|
-
if was_playing: self.toggle_play()
|
|
790
|
-
return
|
|
791
|
-
|
|
792
|
-
if not file_path.lower().endswith('.gif'):
|
|
793
|
-
file_path += '.gif'
|
|
794
|
-
|
|
795
|
-
# Generate Frames
|
|
796
|
-
images = []
|
|
797
|
-
mode_data = self.parser.final_modes[row]
|
|
798
|
-
mode_vecs = mode_data['vector']
|
|
799
|
-
|
|
800
|
-
self.reset_geometry()
|
|
801
|
-
|
|
802
|
-
try:
|
|
803
|
-
for i in range(20):
|
|
804
|
-
cycle_pos = i / 20.0
|
|
805
|
-
phase = cycle_pos * 2 * np.pi
|
|
806
|
-
scale = self.slider_amp.value() / 20.0
|
|
807
|
-
factor = np.sin(phase) * scale
|
|
808
|
-
|
|
809
|
-
self.apply_displacement(mode_vecs, factor)
|
|
810
|
-
self.mw.draw_molecule_3d(self.base_mol)
|
|
811
|
-
self.update_vectors(mode_vecs, factor)
|
|
812
|
-
self.mw.plotter.render()
|
|
813
|
-
|
|
814
|
-
img_array = self.mw.plotter.screenshot(transparent_background=use_transparent, return_img=True)
|
|
815
|
-
if img_array is not None:
|
|
816
|
-
img = Image.fromarray(img_array)
|
|
817
|
-
images.append(img)
|
|
818
|
-
|
|
819
|
-
if images:
|
|
820
|
-
duration_ms = int(1000 / target_fps)
|
|
821
|
-
processed_images = []
|
|
822
|
-
for img in images:
|
|
823
|
-
if use_hq:
|
|
824
|
-
if use_transparent:
|
|
825
|
-
# Alpha preservation with adaptive palette wrapper
|
|
826
|
-
alpha = img.split()[3]
|
|
827
|
-
img_rgb = img.convert("RGB")
|
|
828
|
-
# Quantize to 255 colors to leave room for transparency
|
|
829
|
-
img_p = img_rgb.convert('P', palette=Image.Palette.ADAPTIVE, colors=255)
|
|
830
|
-
# Set simple transparency
|
|
831
|
-
mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0)
|
|
832
|
-
img_p.paste(255, mask)
|
|
833
|
-
img_p.info['transparency'] = 255
|
|
834
|
-
processed_images.append(img_p)
|
|
835
|
-
else:
|
|
836
|
-
processed_images.append(img.convert("P", palette=Image.Palette.ADAPTIVE, colors=256))
|
|
837
|
-
else:
|
|
838
|
-
if use_transparent:
|
|
839
|
-
processed_images.append(img.convert("RGBA"))
|
|
840
|
-
else:
|
|
841
|
-
processed_images.append(img.convert("RGB"))
|
|
842
|
-
|
|
843
|
-
processed_images[0].save(file_path, save_all=True, append_images=processed_images[1:], duration=duration_ms, loop=0, disposal=2)
|
|
844
|
-
QMessageBox.information(self, "Success", f"Saved GIF to:\n{file_path}")
|
|
845
|
-
|
|
846
|
-
except Exception as e:
|
|
847
|
-
QMessageBox.critical(self, "Error", f"Failed to save GIF: {e}")
|
|
848
|
-
traceback.print_exc()
|
|
849
|
-
finally:
|
|
850
|
-
self.reset_geometry()
|
|
851
|
-
if was_playing:
|
|
852
|
-
self.toggle_play()
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
def on_dock_visibility_changed(self, visible):
|
|
856
|
-
if not visible and self.is_playing:
|
|
857
|
-
self.stop_play()
|
|
858
|
-
|
|
859
|
-
def close_plugin(self):
|
|
860
|
-
self.stop_play()
|
|
861
|
-
self.remove_vectors()
|
|
862
|
-
self.animation_step = 0
|
|
863
|
-
def show_spectrum(self):
|
|
864
|
-
# Use final_modes which has proper freq-intensity alignment
|
|
865
|
-
# and already filters out low frequencies (translations/rotations)
|
|
866
|
-
|
|
867
|
-
if not hasattr(self.parser, 'final_modes') or not self.parser.final_modes:
|
|
868
|
-
QMessageBox.warning(self, "No Data", "No vibrational frequencies available.")
|
|
869
|
-
return
|
|
870
|
-
|
|
871
|
-
freqs = []
|
|
872
|
-
intensities = []
|
|
873
|
-
|
|
874
|
-
for mode in self.parser.final_modes:
|
|
875
|
-
freqs.append(mode['freq'])
|
|
876
|
-
intensities.append(mode.get('intensity', 0.0))
|
|
877
|
-
|
|
878
|
-
dlg = SpectrumDialog(freqs, intensities, self)
|
|
879
|
-
dlg.exec()
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
class SpectrumDialog(QDialog):
|
|
883
|
-
def __init__(self, freqs, intensities, parent=None):
|
|
884
|
-
super().__init__(parent)
|
|
885
|
-
self.setWindowTitle("IR Spectrum")
|
|
886
|
-
self.resize(800, 600)
|
|
887
|
-
|
|
888
|
-
self.freqs = np.array(freqs)
|
|
889
|
-
self.intensities = np.array(intensities)
|
|
890
|
-
|
|
891
|
-
# Layout
|
|
892
|
-
layout = QVBoxLayout(self)
|
|
893
|
-
|
|
894
|
-
# Plot Area
|
|
895
|
-
self.plot_widget = SpectrumPlotWidget(self.freqs, self.intensities)
|
|
896
|
-
layout.addWidget(self.plot_widget)
|
|
897
|
-
|
|
898
|
-
# Controls
|
|
899
|
-
controls = QHBoxLayout()
|
|
900
|
-
|
|
901
|
-
controls.addWidget(QLabel("Gaussian Broadening (FWHM, cm⁻¹):"))
|
|
902
|
-
self.spin_fwhm = QSpinBox()
|
|
903
|
-
self.spin_fwhm.setRange(1, 500)
|
|
904
|
-
self.spin_fwhm.setValue(50)
|
|
905
|
-
self.spin_fwhm.valueChanged.connect(self.on_fwhm_changed)
|
|
906
|
-
controls.addWidget(self.spin_fwhm)
|
|
907
|
-
|
|
908
|
-
controls.addWidget(self.spin_fwhm)
|
|
909
|
-
|
|
910
|
-
# Axis Range
|
|
911
|
-
controls.addWidget(QLabel("Min WN:"))
|
|
912
|
-
self.spin_min = QSpinBox()
|
|
913
|
-
self.spin_min.setRange(0, 5000)
|
|
914
|
-
self.spin_min.setValue(0)
|
|
915
|
-
self.spin_min.setSingleStep(100)
|
|
916
|
-
self.spin_min.valueChanged.connect(self.on_range_changed)
|
|
917
|
-
controls.addWidget(self.spin_min)
|
|
918
|
-
|
|
919
|
-
controls.addWidget(QLabel("Max WN:"))
|
|
920
|
-
self.spin_max = QSpinBox()
|
|
921
|
-
self.spin_max.setRange(0, 5000)
|
|
922
|
-
self.spin_max.setValue(4000)
|
|
923
|
-
self.spin_max.setSingleStep(100)
|
|
924
|
-
self.spin_max.valueChanged.connect(self.on_range_changed)
|
|
925
|
-
controls.addWidget(self.spin_max)
|
|
926
|
-
|
|
927
|
-
btn_csv = QPushButton("Export CSV")
|
|
928
|
-
btn_csv.clicked.connect(self.export_csv)
|
|
929
|
-
controls.addWidget(btn_csv)
|
|
930
|
-
|
|
931
|
-
btn_png = QPushButton("Export Image")
|
|
932
|
-
btn_png.clicked.connect(self.export_png)
|
|
933
|
-
controls.addWidget(btn_png)
|
|
934
|
-
|
|
935
|
-
btn_close = QPushButton("Close")
|
|
936
|
-
btn_close.clicked.connect(self.accept)
|
|
937
|
-
controls.addWidget(btn_close)
|
|
938
|
-
|
|
939
|
-
layout.addLayout(controls)
|
|
940
|
-
self.setLayout(layout)
|
|
941
|
-
|
|
942
|
-
# Initial Plot
|
|
943
|
-
self.on_range_changed()
|
|
944
|
-
|
|
945
|
-
def on_fwhm_changed(self, val):
|
|
946
|
-
self.plot_widget.set_fwhm(val)
|
|
947
|
-
|
|
948
|
-
def on_range_changed(self):
|
|
949
|
-
mn = self.spin_min.value()
|
|
950
|
-
mx = self.spin_max.value()
|
|
951
|
-
if mx > mn:
|
|
952
|
-
self.plot_widget.set_range(mn, mx)
|
|
953
|
-
|
|
954
|
-
def export_csv(self):
|
|
955
|
-
fname, _ = QFileDialog.getSaveFileName(self, "Save Spectrum Data", "", "CSV Files (*.csv)")
|
|
956
|
-
if fname:
|
|
957
|
-
if not fname.lower().endswith('.csv'): fname += '.csv'
|
|
958
|
-
try:
|
|
959
|
-
x, y = self.plot_widget.get_curve_data()
|
|
960
|
-
with open(fname, 'w') as f:
|
|
961
|
-
f.write("Frequency,Intensity\n")
|
|
962
|
-
for xi, yi in zip(x, y):
|
|
963
|
-
f.write(f"{xi:.2f},{yi:.4f}\n")
|
|
964
|
-
QMessageBox.information(self, "Success", "Saved CSV.")
|
|
965
|
-
except Exception as e:
|
|
966
|
-
QMessageBox.critical(self, "Error", str(e))
|
|
967
|
-
|
|
968
|
-
def export_png(self):
|
|
969
|
-
fname, _ = QFileDialog.getSaveFileName(self, "Save Spectrum Image", "", "PNG Files (*.png)")
|
|
970
|
-
if fname:
|
|
971
|
-
if not fname.lower().endswith('.png'): fname += '.png'
|
|
972
|
-
try:
|
|
973
|
-
# Capture the widget
|
|
974
|
-
pixmap = self.plot_widget.grab()
|
|
975
|
-
pixmap.save(fname)
|
|
976
|
-
QMessageBox.information(self, "Success", "Saved Image.")
|
|
977
|
-
except Exception as e:
|
|
978
|
-
QMessageBox.critical(self, "Error", str(e))
|
|
979
|
-
|
|
980
|
-
class SpectrumPlotWidget(QWidget):
|
|
981
|
-
def __init__(self, freqs, intensities, parent=None):
|
|
982
|
-
super().__init__(parent)
|
|
983
|
-
self.freqs = freqs
|
|
984
|
-
self.intensities = intensities
|
|
985
|
-
self.fwhm = 80.0
|
|
986
|
-
self.curve_x = []
|
|
987
|
-
self.curve_y = []
|
|
988
|
-
|
|
989
|
-
self.setAutoFillBackground(True)
|
|
990
|
-
self.setStyleSheet("background-color: white;")
|
|
991
|
-
|
|
992
|
-
self.min_x = 0.0
|
|
993
|
-
self.max_x = 4000.0
|
|
994
|
-
|
|
995
|
-
def set_fwhm(self, val):
|
|
996
|
-
self.fwhm = val
|
|
997
|
-
self.recalc_curve()
|
|
998
|
-
self.update()
|
|
999
|
-
|
|
1000
|
-
def set_range(self, mn, mx):
|
|
1001
|
-
self.min_x = float(mn)
|
|
1002
|
-
self.max_x = float(mx)
|
|
1003
|
-
self.recalc_curve()
|
|
1004
|
-
self.update()
|
|
1005
|
-
|
|
1006
|
-
def get_curve_data(self):
|
|
1007
|
-
return self.curve_x, self.curve_y
|
|
1008
|
-
|
|
1009
|
-
def recalc_curve(self):
|
|
1010
|
-
if len(self.freqs) == 0: return
|
|
1011
|
-
|
|
1012
|
-
# X resolution based on custom range
|
|
1013
|
-
self.curve_x = np.linspace(self.min_x, self.max_x, 1000)
|
|
1014
|
-
self.curve_y = np.zeros_like(self.curve_x)
|
|
1015
|
-
|
|
1016
|
-
# Sum Gaussians
|
|
1017
|
-
sigma = self.fwhm / 2.35482
|
|
1018
|
-
|
|
1019
|
-
for f, i in zip(self.freqs, self.intensities):
|
|
1020
|
-
self.curve_y += i * np.exp(-(self.curve_x - f)**2 / (2 * sigma**2))
|
|
1021
|
-
|
|
1022
|
-
# Do NOT normalize - preserve actual intensity values
|
|
1023
|
-
|
|
1024
|
-
def paintEvent(self, event):
|
|
1025
|
-
painter = QPainter(self)
|
|
1026
|
-
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
1027
|
-
|
|
1028
|
-
w = self.width()
|
|
1029
|
-
h = self.height()
|
|
1030
|
-
|
|
1031
|
-
# Margins
|
|
1032
|
-
margin_l = 50
|
|
1033
|
-
margin_r = 20
|
|
1034
|
-
margin_t = 20
|
|
1035
|
-
margin_b = 60 # Increased from 40 for better spacing
|
|
1036
|
-
|
|
1037
|
-
plot_w = w - margin_l - margin_r
|
|
1038
|
-
plot_h = h - margin_t - margin_b
|
|
1039
|
-
|
|
1040
|
-
if len(self.curve_x) == 0:
|
|
1041
|
-
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "No Data")
|
|
1042
|
-
return
|
|
1043
|
-
|
|
1044
|
-
# Calculate max_y from both curve AND raw intensities to prevent stick normalization
|
|
1045
|
-
# Add 10% padding for better visibility
|
|
1046
|
-
max_curve = np.max(self.curve_y) if len(self.curve_y) > 0 else 1.0
|
|
1047
|
-
max_intensity = np.max(self.intensities) if len(self.intensities) > 0 else 1.0
|
|
1048
|
-
max_y = max(max_curve, max_intensity) * 1.1 # 1.1x for padding
|
|
1049
|
-
if max_y == 0: max_y = 1.0
|
|
1050
|
-
min_x = np.min(self.curve_x)
|
|
1051
|
-
max_x = np.max(self.curve_x)
|
|
1052
|
-
range_x = max_x - min_x
|
|
1053
|
-
if range_x == 0: range_x = 100
|
|
1054
|
-
|
|
1055
|
-
# Helper to transform
|
|
1056
|
-
# Inverted X: Max (left) -> Min (right)
|
|
1057
|
-
# Inverted Y: 0 (top) -> Max (bottom)
|
|
1058
|
-
|
|
1059
|
-
def to_screen(x, y):
|
|
1060
|
-
# X: margin_l corresponds to max_x, w - margin_r corresponds to min_x
|
|
1061
|
-
# formula: x_ratio = (max_x - x) / range_x
|
|
1062
|
-
# sx = margin_l + x_ratio * plot_w
|
|
1063
|
-
sx = margin_l + (max_x - x) / range_x * plot_w
|
|
1064
|
-
|
|
1065
|
-
# Y: margin_t corresponds to 0, h - margin_b corresponds to max_y
|
|
1066
|
-
# formula: y_ratio = y / max_y
|
|
1067
|
-
# sy = margin_t + y_ratio * plot_h
|
|
1068
|
-
sy = margin_t + (y / max_y) * plot_h
|
|
1069
|
-
return sx, sy
|
|
1070
|
-
|
|
1071
|
-
# Draw Axes
|
|
1072
|
-
painter.setPen(QPen(Qt.GlobalColor.black, 2))
|
|
1073
|
-
painter.drawLine(margin_l, margin_t, w - margin_r, margin_t) # X-axis at top (Baseline)
|
|
1074
|
-
painter.drawLine(margin_l, h - margin_b, w - margin_r, h - margin_b) # X-axis at bottom
|
|
1075
|
-
|
|
1076
|
-
painter.drawLine(margin_l, h - margin_b, margin_l, margin_t) # Y (Left)
|
|
1077
|
-
painter.drawLine(w - margin_r, h - margin_b, w - margin_r, margin_t) # Y (Right border)
|
|
1078
|
-
|
|
1079
|
-
# Draw Ticks / Labels (Simplified)
|
|
1080
|
-
font = painter.font()
|
|
1081
|
-
font.setPointSize(12) # Increased from 8
|
|
1082
|
-
painter.setFont(font)
|
|
1083
|
-
|
|
1084
|
-
# X Ticks (approx 5)
|
|
1085
|
-
# Inverted: Left is Max, Right is Min
|
|
1086
|
-
n_ticks = 5
|
|
1087
|
-
for i in range(n_ticks + 1):
|
|
1088
|
-
# val goes from max_x to min_x
|
|
1089
|
-
val = max_x - (range_x * i / n_ticks)
|
|
1090
|
-
px, py = to_screen(val, 0)
|
|
1091
|
-
# Label at bottom
|
|
1092
|
-
painter.drawText(int(px)-20, h - margin_b + 5, 40, 20, Qt.AlignmentFlag.AlignCenter, f"{int(val)}")
|
|
1093
|
-
painter.drawLine(int(px), h - margin_b, int(px), h - margin_b + 5)
|
|
1094
|
-
|
|
1095
|
-
# Labels
|
|
1096
|
-
font.setPointSize(14) # Increased from 10
|
|
1097
|
-
font.setBold(True)
|
|
1098
|
-
painter.setFont(font)
|
|
1099
|
-
painter.drawText(0, h-25, w, 20, Qt.AlignmentFlag.AlignCenter, "Wavenumber (cm⁻¹)")
|
|
1100
|
-
|
|
1101
|
-
# Draw baseline at y=0
|
|
1102
|
-
baseline_x_start, baseline_y = to_screen(max_x, 0)
|
|
1103
|
-
baseline_x_end, _ = to_screen(min_x, 0)
|
|
1104
|
-
painter.setPen(QPen(QColor(150, 150, 150), 1, Qt.PenStyle.DashLine))
|
|
1105
|
-
painter.drawLine(int(baseline_x_start), int(baseline_y), int(baseline_x_end), int(baseline_y))
|
|
1106
|
-
|
|
1107
|
-
# Draw Curve
|
|
1108
|
-
painter.setPen(QPen(Qt.GlobalColor.blue, 2))
|
|
1109
|
-
path_points = []
|
|
1110
|
-
for x, y in zip(self.curve_x, self.curve_y):
|
|
1111
|
-
sx, sy = to_screen(x, y)
|
|
1112
|
-
path_points.append( (sx, sy) )
|
|
1113
|
-
|
|
1114
|
-
if len(path_points) > 1:
|
|
1115
|
-
from PyQt6.QtCore import QPointF
|
|
1116
|
-
qpoints = [QPointF(x, y) for x, y in path_points]
|
|
1117
|
-
painter.drawPolyline(qpoints)
|
|
1118
|
-
|
|
1119
|
-
# Draw Sticks (Bars) for original frequencies
|
|
1120
|
-
painter.setPen(QPen(QColor(255, 0, 0, 100), 1))
|
|
1121
|
-
for f, i in zip(self.freqs, self.intensities):
|
|
1122
|
-
sx, sy = to_screen(f, i)
|
|
1123
|
-
px_base, py_base = to_screen(f, 0)
|
|
1124
|
-
painter.drawLine(int(sx), int(py_base), int(sx), int(sy))
|
|
1125
|
-
|
|
1126
|
-
# def closeEvent(self, event):
|
|
1127
|
-
# self.close_plugin()
|
|
1128
|
-
# super().closeEvent(event)
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
def on_dock_visibility_changed(self, visible):
|
|
1133
|
-
if not visible:
|
|
1134
|
-
self.stop_play()
|
|
1135
|
-
self.animation_step = 0
|
|
1136
|
-
if self.base_mol:
|
|
1137
|
-
self.reset_geometry()
|
|
1138
|
-
# Force redraw
|
|
1139
|
-
if hasattr(self.mw, 'plotter'):
|
|
1140
|
-
self.mw.plotter.render()
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
def load_from_file(main_window, fname):
|
|
1144
|
-
dock = None
|
|
1145
|
-
analyzer = None
|
|
1146
|
-
|
|
1147
|
-
# Check existing docks
|
|
1148
|
-
for d in main_window.findChildren(QDockWidget):
|
|
1149
|
-
if d.windowTitle() == "ORCA Output Freq Analyzer":
|
|
1150
|
-
dock = d
|
|
1151
|
-
analyzer = d.widget()
|
|
1152
|
-
break
|
|
1153
|
-
|
|
1154
|
-
if not dock:
|
|
1155
|
-
dock = QDockWidget("ORCA Output Freq Analyzer", main_window)
|
|
1156
|
-
dock.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
|
|
1157
|
-
analyzer = OrcaOutFreqAnalyzer(main_window, dock)
|
|
1158
|
-
dock.setWidget(analyzer)
|
|
1159
|
-
main_window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock)
|
|
1160
|
-
|
|
1161
|
-
# Connect visibility change
|
|
1162
|
-
dock.visibilityChanged.connect(analyzer.on_dock_visibility_changed)
|
|
1163
|
-
|
|
1164
|
-
dock.show()
|
|
1165
|
-
dock.raise_()
|
|
1166
|
-
|
|
1167
|
-
if analyzer:
|
|
1168
|
-
analyzer.load_file(fname)
|
|
1169
|
-
|
|
1170
|
-
def is_valid_orca_file(filepath):
|
|
1171
|
-
try:
|
|
1172
|
-
with open(filepath, 'r', errors='ignore') as f:
|
|
1173
|
-
# Check first 500 lines for "ORCA" keyword to be safe
|
|
1174
|
-
for _ in range(500):
|
|
1175
|
-
line = f.readline()
|
|
1176
|
-
if not line: break
|
|
1177
|
-
if "ORCA" in line or "O R C A" in line:
|
|
1178
|
-
return True
|
|
1179
|
-
return False
|
|
1180
|
-
except:
|
|
1181
|
-
return False
|
|
1182
|
-
|
|
1183
|
-
def run(mw):
|
|
1184
|
-
# Smart Open Logic
|
|
1185
|
-
if hasattr(mw, 'current_file_path') and mw.current_file_path:
|
|
1186
|
-
fpath = mw.current_file_path.lower()
|
|
1187
|
-
if fpath.endswith((".out", ".log")):
|
|
1188
|
-
if is_valid_orca_file(mw.current_file_path):
|
|
1189
|
-
load_from_file(mw, mw.current_file_path)
|
|
1190
|
-
return
|
|
1191
|
-
|
|
1192
|
-
fname, _ = QFileDialog.getOpenFileName(mw, "Open ORCA Output", "", "ORCA Output (*.out *.log);;All Files (*)")
|
|
1193
|
-
if not fname: return
|
|
1194
|
-
|
|
1195
|
-
if is_valid_orca_file(fname):
|
|
1196
|
-
load_from_file(mw, fname)
|
|
1197
|
-
else:
|
|
1198
|
-
QMessageBox.warning(mw, "Invalid File", "This does not appear to be a valid ORCA output file.")
|
|
1199
|
-
|
|
1200
|
-
def initialize(context):
|
|
1201
|
-
mw = context.get_main_window()
|
|
1202
|
-
|
|
1203
|
-
def load_wrapper(fname):
|
|
1204
|
-
# Validate ORCA file before loading
|
|
1205
|
-
if is_valid_orca_file(fname):
|
|
1206
|
-
load_from_file(mw, fname)
|
|
1207
|
-
else:
|
|
1208
|
-
print(f"Skipping invalid ORCA file: {fname}")
|
|
1209
|
-
|
|
1210
|
-
# 1. Register File Openers
|
|
1211
|
-
# Note: .log can be generic, but our valid check helps
|
|
1212
|
-
context.register_file_opener('.out', load_wrapper)
|
|
1213
|
-
context.register_file_opener('.log', load_wrapper)
|
|
1214
|
-
|
|
1215
|
-
# 2. Register Drop Handler
|
|
1216
|
-
def drop_handler(file_path):
|
|
1217
|
-
fpath = file_path.lower()
|
|
1218
|
-
if fpath.endswith(('.out', '.log')):
|
|
1219
|
-
if is_valid_orca_file(file_path):
|
|
1220
|
-
load_from_file(mw, file_path)
|
|
1221
|
-
return True
|
|
1222
|
-
return False
|
|
1223
|
-
|
|
1224
|
-
if hasattr(context, 'register_drop_handler'):
|
|
1225
|
-
context.register_drop_handler(drop_handler, priority=10)
|
|
1226
|
-
|