MoleditPy 2.2.0a2__py3-none-any.whl → 2.2.0a3__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 +31 -13
- moleditpy/modules/plugin_interface.py +1 -10
- moleditpy/modules/plugin_manager.py +0 -3
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.0a3.dist-info}/METADATA +1 -1
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.0a3.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.0a3.dist-info}/WHEEL +0 -0
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.0a3.dist-info}/entry_points.txt +0 -0
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.0a3.dist-info}/licenses/LICENSE +0 -0
- {moleditpy-2.2.0a2.dist-info → moleditpy-2.2.0a3.dist-info}/top_level.txt +0 -0
|
@@ -1,1148 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import re
|
|
3
|
-
import numpy as np
|
|
4
|
-
import traceback
|
|
5
|
-
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
|
|
6
|
-
QListWidget, QSlider, QCheckBox, QFileDialog, QMessageBox,
|
|
7
|
-
QDockWidget, QWidget, QSplitter, QApplication, QTreeWidget, QTreeWidgetItem, QHeaderView)
|
|
8
|
-
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
|
|
9
|
-
|
|
10
|
-
# Try to import RDKit
|
|
11
|
-
try:
|
|
12
|
-
from rdkit import Chem
|
|
13
|
-
from rdkit.Geometry import Point3D
|
|
14
|
-
except ImportError:
|
|
15
|
-
Chem = None
|
|
16
|
-
|
|
17
|
-
PLUGIN_NAME = "Gaussian Freq Analyzer"
|
|
18
|
-
__version__="2025.12.25"
|
|
19
|
-
__author__="HiroYokoyama"
|
|
20
|
-
|
|
21
|
-
class FCHKParser:
|
|
22
|
-
def __init__(self):
|
|
23
|
-
self.filename = ""
|
|
24
|
-
self.atoms = [] # List of atomic numbers
|
|
25
|
-
self.coords = [] # List of (x, y, z) in Angstrom
|
|
26
|
-
self.frequencies = [] # List of frequencies in cm^-1
|
|
27
|
-
self.vib_modes = [] # List of displacement vectors (natoms, 3)
|
|
28
|
-
self.charge = 0
|
|
29
|
-
self.multiplicity = 1
|
|
30
|
-
|
|
31
|
-
def parse(self, filename):
|
|
32
|
-
self.filename = filename
|
|
33
|
-
self.atoms = []
|
|
34
|
-
self.coords = []
|
|
35
|
-
self.frequencies = []
|
|
36
|
-
self.intensities = [] # IR Intensities
|
|
37
|
-
self.vib_modes = [] # list of list of (dx, dy, dz)
|
|
38
|
-
|
|
39
|
-
with open(filename, 'r') as f:
|
|
40
|
-
lines = f.readlines()
|
|
41
|
-
|
|
42
|
-
data = {}
|
|
43
|
-
current_section = None
|
|
44
|
-
current_values = []
|
|
45
|
-
|
|
46
|
-
# Helper to process accumulated values
|
|
47
|
-
def process_section(section, values):
|
|
48
|
-
if section == "Atomic numbers":
|
|
49
|
-
# Integers
|
|
50
|
-
return [int(v) for v in values]
|
|
51
|
-
elif section == "Current cartesian coordinates":
|
|
52
|
-
# Floats, Bohr -> Angstrom? No, FCHK usually stores in Bohr.
|
|
53
|
-
# Use standard conversion 0.529177
|
|
54
|
-
return [float(v) for v in values]
|
|
55
|
-
elif section == "Vib-E2":
|
|
56
|
-
return [float(v) for v in values]
|
|
57
|
-
elif section == "Vib-Modes":
|
|
58
|
-
return [float(v) for v in values]
|
|
59
|
-
elif section == "Dipole Derivatives":
|
|
60
|
-
return [float(v) for v in values]
|
|
61
|
-
elif section == "IR Inten":
|
|
62
|
-
return [float(v) for v in values]
|
|
63
|
-
elif section == "Charge":
|
|
64
|
-
return [int(v) for v in values]
|
|
65
|
-
elif section == "Multiplicity":
|
|
66
|
-
return [int(v) for v in values]
|
|
67
|
-
return values
|
|
68
|
-
|
|
69
|
-
# Basic FCHK parsing
|
|
70
|
-
i = 0
|
|
71
|
-
while i < len(lines):
|
|
72
|
-
line = lines[i].strip()
|
|
73
|
-
if not line:
|
|
74
|
-
i += 1
|
|
75
|
-
continue
|
|
76
|
-
|
|
77
|
-
# Section header?
|
|
78
|
-
# Pattern: Name (starts upper) ... Type (I/R) ... N= (optional) ... Value/Count
|
|
79
|
-
# Example: "Vib-Modes R N= 27"
|
|
80
|
-
# Or: "Charge I 0"
|
|
81
|
-
# We look for Capital Start, then some space, then I or R, then optional N=
|
|
82
|
-
|
|
83
|
-
# Simple check: line starts with upper char
|
|
84
|
-
if line[0].isupper():
|
|
85
|
-
# Check for Type and N= signature more loosely but reliably
|
|
86
|
-
# If " I " or " R " is present (at least 3 spaces before/after or N=)
|
|
87
|
-
# Let's try Regex for robustness
|
|
88
|
-
# Matches: Start with Word, spaces, I or R, spaces, (N= xxxxx)?
|
|
89
|
-
match = re.search(r'^([A-Za-z0-9\-\s]+?)\s+([IR])\s+(?:N=\s+(\d+)|([0-9\-]+))', line)
|
|
90
|
-
|
|
91
|
-
# Actually, simpler: check for " I " or " R " or " I " at specific columns?
|
|
92
|
-
# FCHK is fixed format mostly.
|
|
93
|
-
# Name: 0-40. Type: 43.
|
|
94
|
-
# But let's trust " I" or " R" presence for now if regex is too complex to get right blindly.
|
|
95
|
-
# However, the previous "R N=" failed. Maybe it was "R N=".
|
|
96
|
-
# Let's use a regex that handles variable whitespace.
|
|
97
|
-
if re.search(r'\s+[IR]\s+(N=)?\s+', line):
|
|
98
|
-
# Store previous
|
|
99
|
-
if current_section:
|
|
100
|
-
data[current_section] = process_section(current_section, current_values)
|
|
101
|
-
|
|
102
|
-
# Extract section name
|
|
103
|
-
parts = line.split()
|
|
104
|
-
# Name is usually first 1-N tokens until I/R
|
|
105
|
-
# But Name can have spaces "Atomic numbers".
|
|
106
|
-
# split by " I" or " R" is safest if exists.
|
|
107
|
-
|
|
108
|
-
if " I" in line:
|
|
109
|
-
current_section = line.split(" I")[0].strip()
|
|
110
|
-
elif " R" in line:
|
|
111
|
-
current_section = line.split(" R")[0].strip()
|
|
112
|
-
else:
|
|
113
|
-
# Fallback: scan tokens for I or R
|
|
114
|
-
label_parts = []
|
|
115
|
-
for p in parts:
|
|
116
|
-
if p == 'I' or p == 'R':
|
|
117
|
-
break
|
|
118
|
-
label_parts.append(p)
|
|
119
|
-
current_section = " ".join(label_parts)
|
|
120
|
-
|
|
121
|
-
current_values = []
|
|
122
|
-
i += 1
|
|
123
|
-
continue
|
|
124
|
-
|
|
125
|
-
# Data line (if not header)
|
|
126
|
-
|
|
127
|
-
# Data line
|
|
128
|
-
# Accumulated values
|
|
129
|
-
# FCHK data lines are space separated
|
|
130
|
-
tokens = line.split()
|
|
131
|
-
current_values.extend(tokens)
|
|
132
|
-
i += 1
|
|
133
|
-
|
|
134
|
-
# Last section
|
|
135
|
-
if current_section:
|
|
136
|
-
data[current_section] = process_section(current_section, current_values)
|
|
137
|
-
|
|
138
|
-
# Extract specific data
|
|
139
|
-
if "Atomic numbers" in data:
|
|
140
|
-
self.atoms = data["Atomic numbers"]
|
|
141
|
-
|
|
142
|
-
BOHR_TO_ANG = 0.529177210903
|
|
143
|
-
|
|
144
|
-
if "Current cartesian coordinates" in data:
|
|
145
|
-
raw_coords = data["Current cartesian coordinates"]
|
|
146
|
-
# Convert to list of tuples (x,y,z)
|
|
147
|
-
# FCHK coords are X1, Y1, Z1, X2... in Bohr
|
|
148
|
-
coords_ang = []
|
|
149
|
-
for j in range(0, len(raw_coords), 3):
|
|
150
|
-
if j+2 < len(raw_coords):
|
|
151
|
-
x = raw_coords[j] * BOHR_TO_ANG
|
|
152
|
-
y = raw_coords[j+1] * BOHR_TO_ANG
|
|
153
|
-
z = raw_coords[j+2] * BOHR_TO_ANG
|
|
154
|
-
coords_ang.append((x, y, z))
|
|
155
|
-
self.coords = coords_ang
|
|
156
|
-
|
|
157
|
-
# Parse Vib-E2 section properly
|
|
158
|
-
# Vib-E2 contains blocks: [Frequencies, Reduced Masses, Force Constants, IR Intensities, ...]
|
|
159
|
-
# Each block has n_modes values
|
|
160
|
-
if "Vib-E2" in data:
|
|
161
|
-
raw_e2 = data["Vib-E2"]
|
|
162
|
-
|
|
163
|
-
# Determine n_modes from Vib-Modes section (safest approach)
|
|
164
|
-
if "Vib-Modes" in data:
|
|
165
|
-
n_atoms = len(self.atoms) if self.atoms else 0
|
|
166
|
-
if n_atoms > 0:
|
|
167
|
-
# Vib-Modes size = 3 * n_atoms * n_modes
|
|
168
|
-
n_modes = len(data["Vib-Modes"]) // (3 * n_atoms)
|
|
169
|
-
else:
|
|
170
|
-
n_modes = 0
|
|
171
|
-
else:
|
|
172
|
-
# Fallback: estimate from 3N-6 (or 3N-5 for linear)
|
|
173
|
-
n_atoms = len(self.atoms)
|
|
174
|
-
n_modes = max(1, 3 * n_atoms - 6) if n_atoms > 2 else 1
|
|
175
|
-
|
|
176
|
-
if n_modes > 0 and len(raw_e2) >= n_modes:
|
|
177
|
-
# Block 1: Frequencies (0 to n_modes)
|
|
178
|
-
self.frequencies = raw_e2[0:n_modes]
|
|
179
|
-
|
|
180
|
-
# Block 4: IR Intensities (3*n_modes to 4*n_modes)
|
|
181
|
-
# Already in km/mol units - NO conversion needed!
|
|
182
|
-
if len(raw_e2) >= 4 * n_modes:
|
|
183
|
-
ir_start = 3 * n_modes
|
|
184
|
-
ir_end = 4 * n_modes
|
|
185
|
-
self.intensities = raw_e2[ir_start:ir_end]
|
|
186
|
-
|
|
187
|
-
# Get actual masses used by Gaussian
|
|
188
|
-
self.masses = []
|
|
189
|
-
if "Real atomic weights" in data:
|
|
190
|
-
self.masses = [float(m) for m in data["Real atomic weights"]]
|
|
191
|
-
elif "Atomic numbers" in data:
|
|
192
|
-
# Fallback: RDKit average atomic weight (slight difference)
|
|
193
|
-
if Chem:
|
|
194
|
-
from rdkit.Chem import GetPeriodicTable
|
|
195
|
-
pt = GetPeriodicTable()
|
|
196
|
-
self.masses = [pt.GetAtomicWeight(int(z)) for z in data["Atomic numbers"]]
|
|
197
|
-
|
|
198
|
-
# Fallback: Check for separate IR Inten section (uncommon but possible)
|
|
199
|
-
# Gaussian's conversion factor: Atomic Units -> km/mol
|
|
200
|
-
CONV_FACTOR = 974.868
|
|
201
|
-
|
|
202
|
-
# Only look for separate IR section if we didn't get it from Vib-E2
|
|
203
|
-
if not self.intensities or len(self.intensities) == 0:
|
|
204
|
-
ir_key = None
|
|
205
|
-
for key in data.keys():
|
|
206
|
-
if key.strip().lower() in ["ir inten", "ir intensities"]:
|
|
207
|
-
ir_key = key
|
|
208
|
-
break
|
|
209
|
-
|
|
210
|
-
if ir_key:
|
|
211
|
-
raw_vals = [float(val) for val in data[ir_key]]
|
|
212
|
-
# Always convert from a.u. to km/mol using Gaussian's factor
|
|
213
|
-
self.intensities = [v * CONV_FACTOR for v in raw_vals]
|
|
214
|
-
|
|
215
|
-
if "Dipole Derivatives" in data:
|
|
216
|
-
self.dipole_derivs = data["Dipole Derivatives"]
|
|
217
|
-
|
|
218
|
-
if "Vib-Modes" in data:
|
|
219
|
-
# Modes are stored as X1, Y1, Z1... for mode 1, then mode 2...
|
|
220
|
-
# Size should be 3*N_atoms * N_freqs
|
|
221
|
-
raw_modes = data["Vib-Modes"]
|
|
222
|
-
n_atoms = len(self.atoms)
|
|
223
|
-
n_modes = len(self.frequencies)
|
|
224
|
-
mode_len = n_atoms * 3
|
|
225
|
-
|
|
226
|
-
parsed_modes = []
|
|
227
|
-
for m in range(n_modes):
|
|
228
|
-
start = m * mode_len
|
|
229
|
-
end = start + mode_len
|
|
230
|
-
if end <= len(raw_modes):
|
|
231
|
-
mode_vec = raw_modes[start:end]
|
|
232
|
-
# Structure as list of (dx, dy, dz)
|
|
233
|
-
vecs = []
|
|
234
|
-
for k in range(0, len(mode_vec), 3):
|
|
235
|
-
dx = mode_vec[k]
|
|
236
|
-
dy = mode_vec[k+1]
|
|
237
|
-
dz = mode_vec[k+2]
|
|
238
|
-
vecs.append((dx, dy, dz))
|
|
239
|
-
parsed_modes.append(vecs)
|
|
240
|
-
self.vib_modes = parsed_modes
|
|
241
|
-
|
|
242
|
-
# Consistency Fix:
|
|
243
|
-
# Sync frequencies, modes, and intensities to avoid mismatches
|
|
244
|
-
if len(self.frequencies) != len(self.vib_modes):
|
|
245
|
-
n_valid = min(len(self.frequencies), len(self.vib_modes))
|
|
246
|
-
self.frequencies = self.frequencies[:n_valid]
|
|
247
|
-
self.vib_modes = self.vib_modes[:n_valid]
|
|
248
|
-
if self.intensities and len(self.intensities) > n_valid:
|
|
249
|
-
self.intensities = self.intensities[:n_valid]
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if "Charge" in data and len(data["Charge"]) > 0:
|
|
254
|
-
self.charge = data["Charge"][0]
|
|
255
|
-
|
|
256
|
-
if "Multiplicity" in data and len(data["Multiplicity"]) > 0:
|
|
257
|
-
self.multiplicity = data["Multiplicity"][0]
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
|
|
261
|
-
QListWidget, QSlider, QCheckBox, QFileDialog, QMessageBox,
|
|
262
|
-
QDockWidget, QWidget, QSplitter, QFormLayout, QDialogButtonBox, QSpinBox, QDoubleSpinBox)
|
|
263
|
-
from PyQt6.QtGui import QImage, QPainter, QPen, QColor, QFont, QPaintEvent
|
|
264
|
-
try:
|
|
265
|
-
from PIL import Image
|
|
266
|
-
HAS_PIL = True
|
|
267
|
-
except ImportError:
|
|
268
|
-
HAS_PIL = False
|
|
269
|
-
|
|
270
|
-
# ... (Previous imports safely handled by original file or above)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
class GaussianFCHKFreqAnalyzer(QWidget):
|
|
274
|
-
def __init__(self, main_window, dock_widget=None):
|
|
275
|
-
super().__init__(main_window)
|
|
276
|
-
self.mw = main_window
|
|
277
|
-
self.dock = dock_widget
|
|
278
|
-
self.setAcceptDrops(True)
|
|
279
|
-
|
|
280
|
-
self.parser = None
|
|
281
|
-
self.base_mol = None
|
|
282
|
-
self.timer = QTimer()
|
|
283
|
-
self.timer.timeout.connect(self.animate_frame)
|
|
284
|
-
self.animation_step = 0
|
|
285
|
-
self.is_playing = False
|
|
286
|
-
self.vector_actor = None
|
|
287
|
-
|
|
288
|
-
self.init_ui()
|
|
289
|
-
|
|
290
|
-
def init_ui(self):
|
|
291
|
-
layout = QVBoxLayout(self)
|
|
292
|
-
|
|
293
|
-
# Info Label
|
|
294
|
-
self.lbl_info = QLabel("Drop .fchk file here or click Open")
|
|
295
|
-
self.lbl_info.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
296
|
-
self.lbl_info.setStyleSheet("border: 2px dashed #AAA; padding: 20px; color: #555;")
|
|
297
|
-
layout.addWidget(self.lbl_info)
|
|
298
|
-
|
|
299
|
-
# Open Button
|
|
300
|
-
btn_open = QPushButton("Open FCHK File")
|
|
301
|
-
btn_open.clicked.connect(self.open_file_dialog)
|
|
302
|
-
layout.addWidget(btn_open)
|
|
303
|
-
|
|
304
|
-
# Frequency List
|
|
305
|
-
# Frequency List
|
|
306
|
-
layout.addWidget(QLabel("Vibrational Frequencies:"))
|
|
307
|
-
self.list_freq = QTreeWidget()
|
|
308
|
-
self.list_freq.setColumnCount(3)
|
|
309
|
-
self.list_freq.setHeaderLabels(["No.", "Frequency (cm⁻¹)", "Intensity (km/mol)"])
|
|
310
|
-
self.list_freq.header().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
|
311
|
-
self.list_freq.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
|
312
|
-
self.list_freq.headerItem().setTextAlignment(0, Qt.AlignmentFlag.AlignCenter) # Center No. header
|
|
313
|
-
self.list_freq.headerItem().setTextAlignment(1, Qt.AlignmentFlag.AlignCenter) # Center Frequency header
|
|
314
|
-
self.list_freq.headerItem().setTextAlignment(2, Qt.AlignmentFlag.AlignCenter) # Center Intensity header
|
|
315
|
-
self.list_freq.currentItemChanged.connect(self.on_freq_selected)
|
|
316
|
-
layout.addWidget(self.list_freq)
|
|
317
|
-
|
|
318
|
-
# Spectrum Button
|
|
319
|
-
btn_spectrum = QPushButton("Show Spectrum")
|
|
320
|
-
btn_spectrum.clicked.connect(self.show_spectrum)
|
|
321
|
-
layout.addWidget(btn_spectrum)
|
|
322
|
-
|
|
323
|
-
# Animation Controls
|
|
324
|
-
anim_layout = QVBoxLayout()
|
|
325
|
-
|
|
326
|
-
# Vector Controls
|
|
327
|
-
vec_layout = QHBoxLayout()
|
|
328
|
-
self.chk_vectors = QCheckBox("Show Vectors")
|
|
329
|
-
self.chk_vectors.setChecked(False)
|
|
330
|
-
self.chk_vectors.stateChanged.connect(lambda state: self.update_vectors())
|
|
331
|
-
vec_layout.addWidget(self.chk_vectors)
|
|
332
|
-
|
|
333
|
-
vec_layout.addWidget(QLabel("Scale:"))
|
|
334
|
-
self.spin_vec_scale = QDoubleSpinBox()
|
|
335
|
-
self.spin_vec_scale.setRange(0.1, 200.0)
|
|
336
|
-
self.spin_vec_scale.setSingleStep(1.0)
|
|
337
|
-
self.spin_vec_scale.setValue(2.0)
|
|
338
|
-
self.spin_vec_scale.valueChanged.connect(lambda val: self.update_vectors())
|
|
339
|
-
vec_layout.addWidget(self.spin_vec_scale)
|
|
340
|
-
vec_layout.addStretch()
|
|
341
|
-
|
|
342
|
-
anim_layout.addLayout(vec_layout)
|
|
343
|
-
|
|
344
|
-
# Amplitude
|
|
345
|
-
amp_layout = QHBoxLayout()
|
|
346
|
-
amp_layout.addWidget(QLabel("Amplitude:"))
|
|
347
|
-
self.slider_amp = QSlider(Qt.Orientation.Horizontal)
|
|
348
|
-
self.slider_amp.setRange(1, 20)
|
|
349
|
-
self.slider_amp.setValue(5)
|
|
350
|
-
|
|
351
|
-
self.lbl_amp_val = QLabel("5")
|
|
352
|
-
self.slider_amp.valueChanged.connect(lambda v: self.lbl_amp_val.setText(str(v)))
|
|
353
|
-
|
|
354
|
-
amp_layout.addWidget(self.slider_amp)
|
|
355
|
-
amp_layout.addWidget(self.lbl_amp_val)
|
|
356
|
-
anim_layout.addLayout(amp_layout)
|
|
357
|
-
|
|
358
|
-
# FPS (Speed)
|
|
359
|
-
speed_layout = QHBoxLayout()
|
|
360
|
-
speed_layout.addWidget(QLabel("FPS:"))
|
|
361
|
-
self.slider_speed = QSlider(Qt.Orientation.Horizontal)
|
|
362
|
-
self.slider_speed.setRange(1, 60)
|
|
363
|
-
self.slider_speed.setValue(20)
|
|
364
|
-
|
|
365
|
-
self.lbl_speed_val = QLabel("20")
|
|
366
|
-
self.slider_speed.valueChanged.connect(lambda v: self.lbl_speed_val.setText(str(v)))
|
|
367
|
-
|
|
368
|
-
self.slider_speed.valueChanged.connect(self.update_timer_interval)
|
|
369
|
-
speed_layout.addWidget(self.slider_speed)
|
|
370
|
-
speed_layout.addWidget(self.lbl_speed_val)
|
|
371
|
-
anim_layout.addLayout(speed_layout)
|
|
372
|
-
|
|
373
|
-
# Buttons
|
|
374
|
-
btn_layout = QHBoxLayout()
|
|
375
|
-
self.btn_play = QPushButton("Play")
|
|
376
|
-
self.btn_play.clicked.connect(self.toggle_play)
|
|
377
|
-
self.btn_stop = QPushButton("Stop")
|
|
378
|
-
self.btn_stop.clicked.connect(self.stop_play)
|
|
379
|
-
|
|
380
|
-
btn_layout.addWidget(self.btn_play)
|
|
381
|
-
btn_layout.addWidget(self.btn_stop)
|
|
382
|
-
anim_layout.addLayout(btn_layout)
|
|
383
|
-
|
|
384
|
-
layout.addLayout(anim_layout)
|
|
385
|
-
|
|
386
|
-
# Export GIF Button
|
|
387
|
-
self.btn_gif = QPushButton("Export GIF")
|
|
388
|
-
self.btn_gif.clicked.connect(self.save_as_gif)
|
|
389
|
-
self.btn_gif.setEnabled(HAS_PIL)
|
|
390
|
-
layout.addWidget(self.btn_gif)
|
|
391
|
-
|
|
392
|
-
# Metadata Info
|
|
393
|
-
self.lbl_meta = QLabel("")
|
|
394
|
-
layout.addWidget(self.lbl_meta)
|
|
395
|
-
|
|
396
|
-
layout.addStretch()
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
# Close Button
|
|
400
|
-
btn_close = QPushButton("Close")
|
|
401
|
-
def close_action():
|
|
402
|
-
self.stop_play()
|
|
403
|
-
self.reset_geometry()
|
|
404
|
-
self.remove_vectors()
|
|
405
|
-
if self.dock:
|
|
406
|
-
self.dock.close()
|
|
407
|
-
else:
|
|
408
|
-
self.close()
|
|
409
|
-
btn_close.clicked.connect(close_action)
|
|
410
|
-
layout.addWidget(btn_close)
|
|
411
|
-
|
|
412
|
-
self.setLayout(layout)
|
|
413
|
-
|
|
414
|
-
def dragEnterEvent(self, event):
|
|
415
|
-
if event.mimeData().hasUrls():
|
|
416
|
-
for url in event.mimeData().urls():
|
|
417
|
-
fname = url.toLocalFile().lower()
|
|
418
|
-
if fname.endswith(".fchk") or fname.endswith(".fck"):
|
|
419
|
-
event.acceptProposedAction()
|
|
420
|
-
return
|
|
421
|
-
event.ignore()
|
|
422
|
-
|
|
423
|
-
def dropEvent(self, event):
|
|
424
|
-
for url in event.mimeData().urls():
|
|
425
|
-
file_path = url.toLocalFile()
|
|
426
|
-
if file_path.lower().endswith((".fchk", ".fck")):
|
|
427
|
-
self.load_file(file_path)
|
|
428
|
-
event.acceptProposedAction()
|
|
429
|
-
break
|
|
430
|
-
|
|
431
|
-
def open_file_dialog(self):
|
|
432
|
-
fname, _ = QFileDialog.getOpenFileName(self, "Open Gaussian FCHK", "", "FCHK Files (*.fchk *.fck)")
|
|
433
|
-
if fname:
|
|
434
|
-
self.load_file(fname)
|
|
435
|
-
|
|
436
|
-
def load_file(self, filename):
|
|
437
|
-
self.parser = FCHKParser()
|
|
438
|
-
try:
|
|
439
|
-
self.parser.parse(filename)
|
|
440
|
-
self.update_ui_after_load()
|
|
441
|
-
self.lbl_info.setText(os.path.basename(filename))
|
|
442
|
-
self.lbl_info.setStyleSheet("border: 2px solid #4CAF50; padding: 10px; color: #4CAF50;")
|
|
443
|
-
|
|
444
|
-
# Update Main Window Context
|
|
445
|
-
if hasattr(self.mw, 'current_file_path'):
|
|
446
|
-
self.mw.current_file_path = filename
|
|
447
|
-
if hasattr(self.mw, 'update_window_title'):
|
|
448
|
-
self.mw.update_window_title()
|
|
449
|
-
else:
|
|
450
|
-
self.mw.setWindowTitle(f"{os.path.basename(filename)} - MoleditPy")
|
|
451
|
-
|
|
452
|
-
except Exception as e:
|
|
453
|
-
QMessageBox.critical(self, "Error", f"Failed to parse FCHK:\n{e}")
|
|
454
|
-
traceback.print_exc()
|
|
455
|
-
|
|
456
|
-
def update_ui_after_load(self):
|
|
457
|
-
self.list_freq.clear()
|
|
458
|
-
# Ensure freqs match modes logic handled in parser consistency check
|
|
459
|
-
if hasattr(self.parser, 'frequencies'):
|
|
460
|
-
for i, freq in enumerate(self.parser.frequencies):
|
|
461
|
-
item = QTreeWidgetItem()
|
|
462
|
-
item.setText(0, str(i + 1)) # Mode number
|
|
463
|
-
item.setText(1, f"{freq:.2f}")
|
|
464
|
-
|
|
465
|
-
if hasattr(self.parser, 'intensities') and i < len(self.parser.intensities):
|
|
466
|
-
inten = self.parser.intensities[i]
|
|
467
|
-
# Higher precision to see small values and compare with LOG
|
|
468
|
-
item.setText(2, f"{inten:.4f}")
|
|
469
|
-
else:
|
|
470
|
-
item.setText(2, "-")
|
|
471
|
-
item.setTextAlignment(0, Qt.AlignmentFlag.AlignCenter) # Center mode number
|
|
472
|
-
item.setTextAlignment(1, Qt.AlignmentFlag.AlignCenter) # Center frequency
|
|
473
|
-
item.setTextAlignment(2, Qt.AlignmentFlag.AlignCenter) # Center intensity
|
|
474
|
-
self.list_freq.addTopLevelItem(item)
|
|
475
|
-
|
|
476
|
-
self.lbl_meta.setText(f"Charge: {self.parser.charge}, Multiplicity: {self.parser.multiplicity}\nAtoms: {len(self.parser.atoms)}")
|
|
477
|
-
|
|
478
|
-
# Load molecule into main window
|
|
479
|
-
if len(self.parser.atoms) > 0 and Chem:
|
|
480
|
-
self.create_base_molecule()
|
|
481
|
-
|
|
482
|
-
def create_base_molecule(self):
|
|
483
|
-
if not self.parser: return
|
|
484
|
-
|
|
485
|
-
mol = Chem.RWMol()
|
|
486
|
-
|
|
487
|
-
# Mapping atomic number to symbol?
|
|
488
|
-
# Needed: FCHK gives Atomic Numbers
|
|
489
|
-
pt = Chem.GetPeriodicTable()
|
|
490
|
-
|
|
491
|
-
for ans in self.parser.atoms:
|
|
492
|
-
sym = pt.GetElementSymbol(int(ans))
|
|
493
|
-
atom = Chem.Atom(sym)
|
|
494
|
-
mol.AddAtom(atom)
|
|
495
|
-
|
|
496
|
-
conf = Chem.Conformer(len(self.parser.atoms))
|
|
497
|
-
for idx, (x, y, z) in enumerate(self.parser.coords):
|
|
498
|
-
conf.SetAtomPosition(idx, Point3D(x, y, z))
|
|
499
|
-
mol.AddConformer(conf)
|
|
500
|
-
|
|
501
|
-
if hasattr(self.mw, 'estimate_bonds_from_distances'):
|
|
502
|
-
self.mw.estimate_bonds_from_distances(mol)
|
|
503
|
-
|
|
504
|
-
self.base_mol = mol.GetMol()
|
|
505
|
-
self.mw.current_mol = self.base_mol
|
|
506
|
-
|
|
507
|
-
if hasattr(self.mw, '_enter_3d_viewer_ui_mode'):
|
|
508
|
-
self.mw._enter_3d_viewer_ui_mode()
|
|
509
|
-
|
|
510
|
-
self.mw.draw_molecule_3d(self.base_mol)
|
|
511
|
-
if hasattr(self.mw, 'plotter'):
|
|
512
|
-
self.mw.plotter.reset_camera()
|
|
513
|
-
|
|
514
|
-
def on_freq_selected(self, current, previous):
|
|
515
|
-
if self.is_playing:
|
|
516
|
-
# Transition smoothly to new selected mode
|
|
517
|
-
pass
|
|
518
|
-
else:
|
|
519
|
-
self.update_vectors()
|
|
520
|
-
|
|
521
|
-
def toggle_play(self):
|
|
522
|
-
curr = self.list_freq.currentItem()
|
|
523
|
-
if not curr:
|
|
524
|
-
return
|
|
525
|
-
# Row index logic
|
|
526
|
-
row = self.list_freq.indexOfTopLevelItem(curr)
|
|
527
|
-
if row < 0: return
|
|
528
|
-
|
|
529
|
-
if self.is_playing:
|
|
530
|
-
# Pause logic
|
|
531
|
-
self.is_playing = False
|
|
532
|
-
self.timer.stop()
|
|
533
|
-
self.btn_play.setText("Play")
|
|
534
|
-
# Do NOT reset geometry
|
|
535
|
-
return
|
|
536
|
-
|
|
537
|
-
self.is_playing = True
|
|
538
|
-
self.btn_play.setText("Pause")
|
|
539
|
-
self.timer.start(50)
|
|
540
|
-
self.update_timer_interval()
|
|
541
|
-
|
|
542
|
-
def stop_play(self):
|
|
543
|
-
self.is_playing = False
|
|
544
|
-
self.timer.stop()
|
|
545
|
-
self.btn_play.setText("Play")
|
|
546
|
-
|
|
547
|
-
self.reset_geometry()
|
|
548
|
-
QApplication.processEvents()
|
|
549
|
-
|
|
550
|
-
def update_timer_interval(self):
|
|
551
|
-
fps = self.slider_speed.value()
|
|
552
|
-
if fps <= 0: fps = 1
|
|
553
|
-
interval = 1000 / fps
|
|
554
|
-
self.timer.setInterval(int(interval))
|
|
555
|
-
|
|
556
|
-
def reset_geometry(self):
|
|
557
|
-
if not self.base_mol or not self.parser: return
|
|
558
|
-
conf = self.base_mol.GetConformer()
|
|
559
|
-
for idx, (x, y, z) in enumerate(self.parser.coords):
|
|
560
|
-
conf.SetAtomPosition(idx, Point3D(x, y, z))
|
|
561
|
-
self.mw.draw_molecule_3d(self.base_mol)
|
|
562
|
-
|
|
563
|
-
self.update_vectors()
|
|
564
|
-
|
|
565
|
-
if hasattr(self.mw, 'plotter'):
|
|
566
|
-
self.mw.plotter.render()
|
|
567
|
-
|
|
568
|
-
def animate_frame(self):
|
|
569
|
-
if not self.parser or not self.base_mol:
|
|
570
|
-
self.stop_play()
|
|
571
|
-
return
|
|
572
|
-
|
|
573
|
-
curr = self.list_freq.currentItem()
|
|
574
|
-
if not curr: return
|
|
575
|
-
row = self.list_freq.indexOfTopLevelItem(curr)
|
|
576
|
-
if row < 0 or row >= len(self.parser.vib_modes):
|
|
577
|
-
return
|
|
578
|
-
|
|
579
|
-
mode_vecs = self.parser.vib_modes[row]
|
|
580
|
-
|
|
581
|
-
self.animation_step += 1
|
|
582
|
-
# Use 20 steps per cycle
|
|
583
|
-
cycle_pos = (self.animation_step % 20) / 20.0
|
|
584
|
-
phase = cycle_pos * 2 * np.pi
|
|
585
|
-
|
|
586
|
-
scale = self.slider_amp.value() / 20.0
|
|
587
|
-
factor = np.sin(phase) * scale
|
|
588
|
-
|
|
589
|
-
self.apply_displacement(mode_vecs, factor)
|
|
590
|
-
self.mw.draw_molecule_3d(self.base_mol)
|
|
591
|
-
self.update_vectors(mode_vecs=mode_vecs, scale_factor=factor)
|
|
592
|
-
|
|
593
|
-
def apply_displacement(self, mode_vecs, factor):
|
|
594
|
-
conf = self.base_mol.GetConformer()
|
|
595
|
-
base_coords = self.parser.coords
|
|
596
|
-
for idx, (bx, by, bz) in enumerate(base_coords):
|
|
597
|
-
if idx < len(mode_vecs):
|
|
598
|
-
dx, dy, dz = mode_vecs[idx]
|
|
599
|
-
nx = bx + dx * factor
|
|
600
|
-
ny = by + dy * factor
|
|
601
|
-
nz = bz + dz * factor
|
|
602
|
-
conf.SetAtomPosition(idx, Point3D(nx, ny, nz))
|
|
603
|
-
|
|
604
|
-
def remove_vectors(self):
|
|
605
|
-
if self.vector_actor and hasattr(self.mw, 'plotter'):
|
|
606
|
-
try:
|
|
607
|
-
self.mw.plotter.remove_actor(self.vector_actor)
|
|
608
|
-
except: pass
|
|
609
|
-
self.vector_actor = None
|
|
610
|
-
|
|
611
|
-
def update_vectors(self, mode_vecs=None, scale_factor=0.0):
|
|
612
|
-
# Clean up existing vectors
|
|
613
|
-
self.remove_vectors()
|
|
614
|
-
|
|
615
|
-
if not self.chk_vectors.isChecked():
|
|
616
|
-
return
|
|
617
|
-
|
|
618
|
-
if not self.parser or not self.base_mol or not hasattr(self.mw, 'plotter'):
|
|
619
|
-
return
|
|
620
|
-
|
|
621
|
-
# Get current frequency
|
|
622
|
-
curr = self.list_freq.currentItem()
|
|
623
|
-
if not curr: return
|
|
624
|
-
row = self.list_freq.indexOfTopLevelItem(curr)
|
|
625
|
-
if row < 0 or row >= len(self.parser.vib_modes): return
|
|
626
|
-
|
|
627
|
-
# Get vectors if not provided
|
|
628
|
-
if mode_vecs is None:
|
|
629
|
-
mode_vecs = self.parser.vib_modes[row]
|
|
630
|
-
|
|
631
|
-
# Current coords from molecule conformer
|
|
632
|
-
conf = self.base_mol.GetConformer()
|
|
633
|
-
coords = []
|
|
634
|
-
vectors = []
|
|
635
|
-
|
|
636
|
-
# Amplitude for vector length scaling
|
|
637
|
-
# Now decoupled from animation amplitude
|
|
638
|
-
vis_scale = self.spin_vec_scale.value()
|
|
639
|
-
|
|
640
|
-
for idx in range(len(mode_vecs)):
|
|
641
|
-
pos = conf.GetAtomPosition(idx)
|
|
642
|
-
coords.append([pos.x, pos.y, pos.z])
|
|
643
|
-
|
|
644
|
-
dx, dy, dz = mode_vecs[idx]
|
|
645
|
-
vectors.append([dx, dy, dz])
|
|
646
|
-
|
|
647
|
-
if not coords: return
|
|
648
|
-
|
|
649
|
-
coords = np.array(coords)
|
|
650
|
-
vectors = np.array(vectors)
|
|
651
|
-
|
|
652
|
-
try:
|
|
653
|
-
self.vector_actor = self.mw.plotter.add_arrows(coords, vectors, mag=vis_scale, color='lightgreen', show_scalar_bar=False)
|
|
654
|
-
except Exception as e:
|
|
655
|
-
print(f"Error adding arrows: {e}")
|
|
656
|
-
|
|
657
|
-
def save_as_gif(self):
|
|
658
|
-
if not self.parser or not self.base_mol: return
|
|
659
|
-
|
|
660
|
-
# Pause to configure
|
|
661
|
-
was_playing = self.is_playing
|
|
662
|
-
if self.is_playing:
|
|
663
|
-
self.toggle_play() # Pause
|
|
664
|
-
|
|
665
|
-
curr = self.list_freq.currentItem()
|
|
666
|
-
if not curr:
|
|
667
|
-
QMessageBox.warning(self, "Select Frequency", "Please select a frequency to export.")
|
|
668
|
-
return
|
|
669
|
-
row = self.list_freq.indexOfTopLevelItem(curr)
|
|
670
|
-
|
|
671
|
-
dialog = QDialog(self)
|
|
672
|
-
dialog.setWindowTitle("Export GIF Settings")
|
|
673
|
-
form = QFormLayout(dialog)
|
|
674
|
-
|
|
675
|
-
# Calculate current FPS
|
|
676
|
-
# Slider value is now FPS directly
|
|
677
|
-
current_fps = self.slider_speed.value()
|
|
678
|
-
|
|
679
|
-
spin_fps = QSpinBox()
|
|
680
|
-
spin_fps.setRange(1, 60)
|
|
681
|
-
spin_fps.setValue(current_fps)
|
|
682
|
-
|
|
683
|
-
chk_transparent = QCheckBox()
|
|
684
|
-
chk_transparent.setChecked(True)
|
|
685
|
-
|
|
686
|
-
chk_hq = QCheckBox()
|
|
687
|
-
chk_hq.setChecked(True)
|
|
688
|
-
|
|
689
|
-
form.addRow("FPS:", spin_fps)
|
|
690
|
-
form.addRow("Transparent Background:", chk_transparent)
|
|
691
|
-
form.addRow("High Quality (Adaptive):", chk_hq)
|
|
692
|
-
|
|
693
|
-
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
694
|
-
btns.accepted.connect(dialog.accept)
|
|
695
|
-
btns.rejected.connect(dialog.reject)
|
|
696
|
-
form.addRow(btns)
|
|
697
|
-
|
|
698
|
-
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
699
|
-
if was_playing: self.toggle_play()
|
|
700
|
-
return # Cancel
|
|
701
|
-
|
|
702
|
-
target_fps = spin_fps.value()
|
|
703
|
-
use_transparent = chk_transparent.isChecked()
|
|
704
|
-
use_hq = chk_hq.isChecked()
|
|
705
|
-
|
|
706
|
-
file_path, _ = QFileDialog.getSaveFileName(self, "Save GIF", "", "GIF Files (*.gif)")
|
|
707
|
-
if not file_path:
|
|
708
|
-
if was_playing: self.toggle_play()
|
|
709
|
-
return
|
|
710
|
-
|
|
711
|
-
if not file_path.lower().endswith('.gif'):
|
|
712
|
-
file_path += '.gif'
|
|
713
|
-
|
|
714
|
-
# Generate Frames
|
|
715
|
-
# 1 Cycle = 20 steps
|
|
716
|
-
images = []
|
|
717
|
-
mode_vecs = self.parser.vib_modes[row]
|
|
718
|
-
|
|
719
|
-
import copy
|
|
720
|
-
# Store current geometry to restore later
|
|
721
|
-
self.reset_geometry() # align to base
|
|
722
|
-
|
|
723
|
-
try:
|
|
724
|
-
for i in range(20):
|
|
725
|
-
cycle_pos = i / 20.0
|
|
726
|
-
phase = cycle_pos * 2 * np.pi
|
|
727
|
-
scale = self.slider_amp.value() / 20.0
|
|
728
|
-
factor = np.sin(phase) * scale # Calculate factor here
|
|
729
|
-
self.apply_displacement(mode_vecs, factor)
|
|
730
|
-
self.mw.draw_molecule_3d(self.base_mol)
|
|
731
|
-
self.update_vectors(mode_vecs, factor)
|
|
732
|
-
self.mw.plotter.render()
|
|
733
|
-
|
|
734
|
-
img_array = self.mw.plotter.screenshot(transparent_background=use_transparent, return_img=True)
|
|
735
|
-
if img_array is not None:
|
|
736
|
-
img = Image.fromarray(img_array)
|
|
737
|
-
images.append(img)
|
|
738
|
-
|
|
739
|
-
if images:
|
|
740
|
-
duration_ms = int(1000 / target_fps)
|
|
741
|
-
|
|
742
|
-
processed_images = []
|
|
743
|
-
for img in images:
|
|
744
|
-
if use_hq:
|
|
745
|
-
if use_transparent:
|
|
746
|
-
# Alpha preservation with adaptive palette wrapper
|
|
747
|
-
alpha = img.split()[3]
|
|
748
|
-
img_rgb = img.convert("RGB")
|
|
749
|
-
# Quantize to 255 colors to leave room for transparency
|
|
750
|
-
img_p = img_rgb.convert('P', palette=Image.Palette.ADAPTIVE, colors=255)
|
|
751
|
-
# Set simple transparency
|
|
752
|
-
mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0)
|
|
753
|
-
img_p.paste(255, mask)
|
|
754
|
-
img_p.info['transparency'] = 255
|
|
755
|
-
processed_images.append(img_p)
|
|
756
|
-
else:
|
|
757
|
-
processed_images.append(img.convert("P", palette=Image.Palette.ADAPTIVE, colors=256))
|
|
758
|
-
else:
|
|
759
|
-
if use_transparent:
|
|
760
|
-
img = img.convert("RGBA")
|
|
761
|
-
processed_images.append(img)
|
|
762
|
-
else:
|
|
763
|
-
processed_images.append(img.convert("RGB"))
|
|
764
|
-
|
|
765
|
-
processed_images[0].save(file_path, save_all=True, append_images=processed_images[1:], duration=duration_ms, loop=0, disposal=2)
|
|
766
|
-
QMessageBox.information(self, "Success", f"Saved GIF to:\n{file_path}")
|
|
767
|
-
|
|
768
|
-
except Exception as e:
|
|
769
|
-
QMessageBox.critical(self, "Error", f"Failed to save GIF: {e}")
|
|
770
|
-
traceback.print_exc()
|
|
771
|
-
finally:
|
|
772
|
-
self.reset_geometry()
|
|
773
|
-
# Restore play state if needed, or leave paused
|
|
774
|
-
# User might want to inspect
|
|
775
|
-
if was_playing:
|
|
776
|
-
self.toggle_play()
|
|
777
|
-
|
|
778
|
-
def on_dock_visibility_changed(self, visible):
|
|
779
|
-
if not visible and self.is_playing:
|
|
780
|
-
self.stop_play()
|
|
781
|
-
|
|
782
|
-
def close_plugin(self):
|
|
783
|
-
self.stop_play()
|
|
784
|
-
self.remove_vectors()
|
|
785
|
-
self.animation_step = 0
|
|
786
|
-
def show_spectrum(self):
|
|
787
|
-
if not self.parser or not self.parser.frequencies:
|
|
788
|
-
return
|
|
789
|
-
|
|
790
|
-
# Filter out low frequencies (translations/rotations)
|
|
791
|
-
freqs = []
|
|
792
|
-
intensities = []
|
|
793
|
-
|
|
794
|
-
parser_intensities = self.parser.intensities if hasattr(self.parser, 'intensities') and self.parser.intensities else [1.0]*len(self.parser.frequencies)
|
|
795
|
-
|
|
796
|
-
for i, freq in enumerate(self.parser.frequencies):
|
|
797
|
-
# Use abs() to preserve imaginary frequencies (negative values)
|
|
798
|
-
# Only exclude low-frequency modes (translations/rotations)
|
|
799
|
-
if abs(freq) > 10.0:
|
|
800
|
-
freqs.append(freq)
|
|
801
|
-
if i < len(parser_intensities):
|
|
802
|
-
intensities.append(parser_intensities[i])
|
|
803
|
-
else:
|
|
804
|
-
intensities.append(1.0)
|
|
805
|
-
|
|
806
|
-
dlg = SpectrumDialog(freqs, intensities, self)
|
|
807
|
-
dlg.exec()
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
class SpectrumDialog(QDialog):
|
|
811
|
-
def __init__(self, freqs, intensities, parent=None):
|
|
812
|
-
super().__init__(parent)
|
|
813
|
-
self.setWindowTitle("IR Spectrum")
|
|
814
|
-
self.resize(800, 600)
|
|
815
|
-
|
|
816
|
-
self.freqs = np.array(freqs)
|
|
817
|
-
self.intensities = np.array(intensities)
|
|
818
|
-
self.scaling_factor = 1.0
|
|
819
|
-
|
|
820
|
-
# Layout
|
|
821
|
-
layout = QVBoxLayout(self)
|
|
822
|
-
|
|
823
|
-
# Plot Area
|
|
824
|
-
self.plot_widget = SpectrumPlotWidget(self.freqs, self.intensities)
|
|
825
|
-
layout.addWidget(self.plot_widget)
|
|
826
|
-
|
|
827
|
-
# Controls
|
|
828
|
-
controls = QHBoxLayout()
|
|
829
|
-
|
|
830
|
-
# Scaling Factor
|
|
831
|
-
controls.addWidget(QLabel("Scaling Factor:"))
|
|
832
|
-
from PyQt6.QtWidgets import QDoubleSpinBox
|
|
833
|
-
self.spin_scale = QDoubleSpinBox()
|
|
834
|
-
self.spin_scale.setRange(0.5, 1.5)
|
|
835
|
-
self.spin_scale.setValue(1.0)
|
|
836
|
-
self.spin_scale.setSingleStep(0.01)
|
|
837
|
-
self.spin_scale.setDecimals(3)
|
|
838
|
-
self.spin_scale.valueChanged.connect(self.on_scaling_changed)
|
|
839
|
-
controls.addWidget(self.spin_scale)
|
|
840
|
-
|
|
841
|
-
controls.addWidget(QLabel("FWHM (cm⁻¹):"))
|
|
842
|
-
self.spin_fwhm = QSpinBox()
|
|
843
|
-
self.spin_fwhm.setRange(1, 500)
|
|
844
|
-
self.spin_fwhm.setValue(50)
|
|
845
|
-
self.spin_fwhm.valueChanged.connect(self.on_fwhm_changed)
|
|
846
|
-
controls.addWidget(self.spin_fwhm)
|
|
847
|
-
|
|
848
|
-
# Axis Range
|
|
849
|
-
controls.addWidget(QLabel("Min WN:"))
|
|
850
|
-
self.spin_min = QSpinBox()
|
|
851
|
-
self.spin_min.setRange(0, 5000)
|
|
852
|
-
self.spin_min.setValue(0)
|
|
853
|
-
self.spin_min.setSingleStep(100)
|
|
854
|
-
self.spin_min.valueChanged.connect(self.on_range_changed)
|
|
855
|
-
controls.addWidget(self.spin_min)
|
|
856
|
-
|
|
857
|
-
controls.addWidget(QLabel("Max WN:"))
|
|
858
|
-
self.spin_max = QSpinBox()
|
|
859
|
-
self.spin_max.setRange(0, 5000)
|
|
860
|
-
self.spin_max.setValue(4000)
|
|
861
|
-
self.spin_max.setSingleStep(100)
|
|
862
|
-
self.spin_max.valueChanged.connect(self.on_range_changed)
|
|
863
|
-
controls.addWidget(self.spin_max)
|
|
864
|
-
|
|
865
|
-
btn_csv = QPushButton("Export CSV")
|
|
866
|
-
btn_csv.clicked.connect(self.export_csv)
|
|
867
|
-
controls.addWidget(btn_csv)
|
|
868
|
-
|
|
869
|
-
btn_png = QPushButton("Export Image")
|
|
870
|
-
btn_png.clicked.connect(self.export_png)
|
|
871
|
-
controls.addWidget(btn_png)
|
|
872
|
-
|
|
873
|
-
btn_close = QPushButton("Close")
|
|
874
|
-
btn_close.clicked.connect(self.accept)
|
|
875
|
-
controls.addWidget(btn_close)
|
|
876
|
-
|
|
877
|
-
layout.addLayout(controls)
|
|
878
|
-
self.setLayout(layout)
|
|
879
|
-
|
|
880
|
-
# Initial Plot
|
|
881
|
-
self.on_range_changed()
|
|
882
|
-
|
|
883
|
-
def on_scaling_changed(self, val):
|
|
884
|
-
self.scaling_factor = val
|
|
885
|
-
scaled_freqs = self.freqs * self.scaling_factor
|
|
886
|
-
self.plot_widget.set_frequencies(scaled_freqs)
|
|
887
|
-
|
|
888
|
-
def on_fwhm_changed(self, val):
|
|
889
|
-
self.plot_widget.set_fwhm(val)
|
|
890
|
-
|
|
891
|
-
def on_range_changed(self):
|
|
892
|
-
mn = self.spin_min.value()
|
|
893
|
-
mx = self.spin_max.value()
|
|
894
|
-
if mx > mn:
|
|
895
|
-
self.plot_widget.set_range(mn, mx)
|
|
896
|
-
|
|
897
|
-
def export_csv(self):
|
|
898
|
-
fname, _ = QFileDialog.getSaveFileName(self, "Save Spectrum Data", "", "CSV Files (*.csv)")
|
|
899
|
-
if fname:
|
|
900
|
-
if not fname.lower().endswith('.csv'): fname += '.csv'
|
|
901
|
-
try:
|
|
902
|
-
x, y = self.plot_widget.get_curve_data()
|
|
903
|
-
with open(fname, 'w') as f:
|
|
904
|
-
f.write("Frequency,Intensity\n")
|
|
905
|
-
for xi, yi in zip(x, y):
|
|
906
|
-
f.write(f"{xi:.2f},{yi:.4f}\n")
|
|
907
|
-
QMessageBox.information(self, "Success", "Saved CSV.")
|
|
908
|
-
except Exception as e:
|
|
909
|
-
QMessageBox.critical(self, "Error", str(e))
|
|
910
|
-
|
|
911
|
-
def export_png(self):
|
|
912
|
-
fname, _ = QFileDialog.getSaveFileName(self, "Save Spectrum Image", "", "PNG Files (*.png)")
|
|
913
|
-
if fname:
|
|
914
|
-
if not fname.lower().endswith('.png'): fname += '.png'
|
|
915
|
-
try:
|
|
916
|
-
# Capture the widget
|
|
917
|
-
pixmap = self.plot_widget.grab()
|
|
918
|
-
pixmap.save(fname)
|
|
919
|
-
QMessageBox.information(self, "Success", "Saved Image.")
|
|
920
|
-
except Exception as e:
|
|
921
|
-
QMessageBox.critical(self, "Error", str(e))
|
|
922
|
-
|
|
923
|
-
class SpectrumPlotWidget(QWidget):
|
|
924
|
-
def __init__(self, freqs, intensities, parent=None):
|
|
925
|
-
super().__init__(parent)
|
|
926
|
-
self.freqs = freqs
|
|
927
|
-
self.intensities = intensities
|
|
928
|
-
self.fwhm = 80.0
|
|
929
|
-
self.curve_x = []
|
|
930
|
-
self.curve_y = []
|
|
931
|
-
|
|
932
|
-
self.setAutoFillBackground(True)
|
|
933
|
-
self.setStyleSheet("background-color: white;")
|
|
934
|
-
|
|
935
|
-
self.min_x = 0.0
|
|
936
|
-
self.max_x = 4000.0
|
|
937
|
-
|
|
938
|
-
def set_fwhm(self, val):
|
|
939
|
-
self.fwhm = val
|
|
940
|
-
self.recalc_curve()
|
|
941
|
-
self.update()
|
|
942
|
-
|
|
943
|
-
def set_frequencies(self, freqs):
|
|
944
|
-
self.freqs = freqs
|
|
945
|
-
self.recalc_curve()
|
|
946
|
-
self.update()
|
|
947
|
-
|
|
948
|
-
def set_range(self, mn, mx):
|
|
949
|
-
self.min_x = float(mn)
|
|
950
|
-
self.max_x = float(mx)
|
|
951
|
-
self.recalc_curve()
|
|
952
|
-
self.update()
|
|
953
|
-
|
|
954
|
-
def get_curve_data(self):
|
|
955
|
-
return self.curve_x, self.curve_y
|
|
956
|
-
|
|
957
|
-
def recalc_curve(self):
|
|
958
|
-
# Determine range
|
|
959
|
-
if len(self.freqs) == 0: return
|
|
960
|
-
|
|
961
|
-
# X resolution based on custom range
|
|
962
|
-
self.curve_x = np.linspace(self.min_x, self.max_x, 1000)
|
|
963
|
-
self.curve_y = np.zeros_like(self.curve_x)
|
|
964
|
-
|
|
965
|
-
# Sum Gaussians weighted by intensity
|
|
966
|
-
sigma = self.fwhm / 2.35482
|
|
967
|
-
|
|
968
|
-
for f, i in zip(self.freqs, self.intensities):
|
|
969
|
-
self.curve_y += i * np.exp(-(self.curve_x - f)**2 / (2 * sigma**2))
|
|
970
|
-
|
|
971
|
-
# Do NOT normalize - preserve actual intensity values
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
def paintEvent(self, event):
|
|
975
|
-
painter = QPainter(self)
|
|
976
|
-
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
977
|
-
|
|
978
|
-
w = self.width()
|
|
979
|
-
h = self.height()
|
|
980
|
-
|
|
981
|
-
# Margins
|
|
982
|
-
margin_l = 50
|
|
983
|
-
margin_r = 20
|
|
984
|
-
margin_t = 20
|
|
985
|
-
margin_b = 60 # Increased from 40 for better spacing
|
|
986
|
-
|
|
987
|
-
plot_w = w - margin_l - margin_r
|
|
988
|
-
plot_h = h - margin_t - margin_b
|
|
989
|
-
|
|
990
|
-
if len(self.curve_x) == 0:
|
|
991
|
-
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "No Data")
|
|
992
|
-
return
|
|
993
|
-
|
|
994
|
-
# Calculate max_y from both curve AND raw intensities to prevent stick normalization
|
|
995
|
-
# Add 10% padding for better visibility
|
|
996
|
-
max_curve = np.max(self.curve_y) if len(self.curve_y) > 0 else 1.0
|
|
997
|
-
max_intensity = np.max(self.intensities) if len(self.intensities) > 0 else 1.0
|
|
998
|
-
max_y = max(max_curve, max_intensity) * 1.1 # 1.1x for padding
|
|
999
|
-
if max_y == 0: max_y = 1.0
|
|
1000
|
-
min_x = np.min(self.curve_x)
|
|
1001
|
-
max_x = np.max(self.curve_x)
|
|
1002
|
-
range_x = max_x - min_x
|
|
1003
|
-
if range_x == 0: range_x = 100
|
|
1004
|
-
|
|
1005
|
-
# Helper to transform
|
|
1006
|
-
# Inverted X: Max (left) -> Min (right)
|
|
1007
|
-
# Inverted Y: 0 (top) -> Max (bottom) - peaks point down
|
|
1008
|
-
|
|
1009
|
-
def to_screen(x, y):
|
|
1010
|
-
# X: margin_l corresponds to max_x, w - margin_r corresponds to min_x
|
|
1011
|
-
sx = margin_l + (max_x - x) / range_x * plot_w
|
|
1012
|
-
|
|
1013
|
-
# Y: margin_t corresponds to 0, h - margin_b corresponds to max_y
|
|
1014
|
-
sy = margin_t + (y / max_y) * plot_h
|
|
1015
|
-
return sx, sy
|
|
1016
|
-
|
|
1017
|
-
# Draw Axes
|
|
1018
|
-
painter.setPen(QPen(Qt.GlobalColor.black, 2))
|
|
1019
|
-
painter.drawLine(margin_l, margin_t, w - margin_r, margin_t) # X-axis at top (Baseline)
|
|
1020
|
-
painter.drawLine(margin_l, h - margin_b, w - margin_r, h - margin_b) # X-axis at bottom
|
|
1021
|
-
|
|
1022
|
-
painter.drawLine(margin_l, h - margin_b, margin_l, margin_t) # Y (Left)
|
|
1023
|
-
painter.drawLine(w - margin_r, h - margin_b, w - margin_r, margin_t) # Y (Right border)
|
|
1024
|
-
|
|
1025
|
-
# Draw Ticks / Labels (Simplified)
|
|
1026
|
-
font = painter.font()
|
|
1027
|
-
font.setPointSize(12) # Increased from 8
|
|
1028
|
-
painter.setFont(font)
|
|
1029
|
-
|
|
1030
|
-
# X Ticks (approx 5)
|
|
1031
|
-
# Inverted: Left is Max, Right is Min
|
|
1032
|
-
n_ticks = 5
|
|
1033
|
-
for i in range(n_ticks + 1):
|
|
1034
|
-
# val goes from max_x to min_x
|
|
1035
|
-
val = max_x - (range_x * i / n_ticks)
|
|
1036
|
-
px, py = to_screen(val, 0)
|
|
1037
|
-
# Label at bottom
|
|
1038
|
-
painter.drawText(int(px)-20, h - margin_b + 5, 40, 20, Qt.AlignmentFlag.AlignCenter, f"{int(val)}")
|
|
1039
|
-
painter.drawLine(int(px), h - margin_b, int(px), h - margin_b + 5)
|
|
1040
|
-
|
|
1041
|
-
# Labels
|
|
1042
|
-
font.setPointSize(14) # Increased from 10
|
|
1043
|
-
font.setBold(True)
|
|
1044
|
-
painter.setFont(font)
|
|
1045
|
-
painter.drawText(0, h-25, w, 20, Qt.AlignmentFlag.AlignCenter, "Wavenumber (cm⁻¹)")
|
|
1046
|
-
|
|
1047
|
-
# Draw baseline at y=0
|
|
1048
|
-
baseline_x_start, baseline_y = to_screen(max_x, 0)
|
|
1049
|
-
baseline_x_end, _ = to_screen(min_x, 0)
|
|
1050
|
-
painter.setPen(QPen(QColor(150, 150, 150), 1, Qt.PenStyle.DashLine))
|
|
1051
|
-
painter.drawLine(int(baseline_x_start), int(baseline_y), int(baseline_x_end), int(baseline_y))
|
|
1052
|
-
|
|
1053
|
-
# Draw Curve
|
|
1054
|
-
painter.setPen(QPen(Qt.GlobalColor.blue, 2))
|
|
1055
|
-
path_points = []
|
|
1056
|
-
for x, y in zip(self.curve_x, self.curve_y):
|
|
1057
|
-
sx, sy = to_screen(x, y)
|
|
1058
|
-
path_points.append( (sx, sy) )
|
|
1059
|
-
|
|
1060
|
-
if len(path_points) > 1:
|
|
1061
|
-
from PyQt6.QtCore import QPointF
|
|
1062
|
-
qpoints = [QPointF(x, y) for x, y in path_points]
|
|
1063
|
-
painter.drawPolyline(qpoints)
|
|
1064
|
-
|
|
1065
|
-
# Draw Sticks (Bars) for original frequencies
|
|
1066
|
-
painter.setPen(QPen(QColor(255, 0, 0, 100), 1))
|
|
1067
|
-
for f, i in zip(self.freqs, self.intensities):
|
|
1068
|
-
sx, sy = to_screen(f, i)
|
|
1069
|
-
px_base, py_base = to_screen(f, 0)
|
|
1070
|
-
painter.drawLine(int(sx), int(py_base), int(sx), int(sy))
|
|
1071
|
-
# If we need to remove self from layout or close dock?
|
|
1072
|
-
# Done by caller usually.
|
|
1073
|
-
|
|
1074
|
-
# def closeEvent(self, event):
|
|
1075
|
-
# self.close_plugin()
|
|
1076
|
-
# super().closeEvent(event)
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
def on_dock_visibility_changed(self, visible):
|
|
1081
|
-
if not visible:
|
|
1082
|
-
self.stop_play()
|
|
1083
|
-
self.animation_step = 0
|
|
1084
|
-
if self.base_mol:
|
|
1085
|
-
self.reset_geometry()
|
|
1086
|
-
# Force redraw
|
|
1087
|
-
if hasattr(self.mw, 'plotter'):
|
|
1088
|
-
self.mw.plotter.render()
|
|
1089
|
-
|
|
1090
|
-
def load_from_file(main_window, fname):
|
|
1091
|
-
# Check for existing dock
|
|
1092
|
-
dock = None
|
|
1093
|
-
analyzer = None
|
|
1094
|
-
|
|
1095
|
-
# Check existing docks
|
|
1096
|
-
for d in main_window.findChildren(QDockWidget):
|
|
1097
|
-
if d.windowTitle() == "Gaussian Freq Analyzer":
|
|
1098
|
-
dock = d
|
|
1099
|
-
analyzer = d.widget()
|
|
1100
|
-
break
|
|
1101
|
-
|
|
1102
|
-
if not dock:
|
|
1103
|
-
dock = QDockWidget("Gaussian Freq Analyzer", main_window)
|
|
1104
|
-
dock.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
|
|
1105
|
-
analyzer = GaussianFCHKFreqAnalyzer(main_window, dock)
|
|
1106
|
-
dock.setWidget(analyzer)
|
|
1107
|
-
main_window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock)
|
|
1108
|
-
|
|
1109
|
-
# Connect visibility change
|
|
1110
|
-
dock.visibilityChanged.connect(analyzer.on_dock_visibility_changed)
|
|
1111
|
-
|
|
1112
|
-
dock.show()
|
|
1113
|
-
dock.raise_()
|
|
1114
|
-
|
|
1115
|
-
if analyzer:
|
|
1116
|
-
analyzer.load_file(fname)
|
|
1117
|
-
|
|
1118
|
-
def run(mw):
|
|
1119
|
-
# Smart Open Logic
|
|
1120
|
-
if hasattr(mw, 'current_file_path') and mw.current_file_path:
|
|
1121
|
-
fpath = mw.current_file_path.lower()
|
|
1122
|
-
if fpath.endswith((".fchk", ".fck")):
|
|
1123
|
-
load_from_file(mw, mw.current_file_path)
|
|
1124
|
-
return
|
|
1125
|
-
|
|
1126
|
-
fname, _ = QFileDialog.getOpenFileName(mw, "Open Gaussian FCHK", "", "Gaussian FCHK (*.fchk *.fck);;All Files (*)")
|
|
1127
|
-
if fname:
|
|
1128
|
-
load_from_file(mw, fname)
|
|
1129
|
-
|
|
1130
|
-
def initialize(context):
|
|
1131
|
-
mw = context.get_main_window()
|
|
1132
|
-
|
|
1133
|
-
def load_wrapper(fname):
|
|
1134
|
-
load_from_file(mw, fname)
|
|
1135
|
-
|
|
1136
|
-
# 1. Register File Openers
|
|
1137
|
-
context.register_file_opener('.fchk', load_wrapper)
|
|
1138
|
-
context.register_file_opener('.fck', load_wrapper)
|
|
1139
|
-
|
|
1140
|
-
# 2. Register Drop Handler
|
|
1141
|
-
def drop_handler(file_path):
|
|
1142
|
-
if file_path.lower().endswith(('.fchk', '.fck')):
|
|
1143
|
-
load_from_file(mw, file_path)
|
|
1144
|
-
return True
|
|
1145
|
-
return False
|
|
1146
|
-
|
|
1147
|
-
if hasattr(context, 'register_drop_handler'):
|
|
1148
|
-
context.register_drop_handler(drop_handler, priority=10)
|