MoleditPy 2.2.0a1__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/main_window_ui_manager.py +21 -2
- moleditpy/modules/plugin_interface.py +1 -10
- moleditpy/modules/plugin_manager.py +0 -3
- {moleditpy-2.2.0a1.dist-info → moleditpy-2.2.0a3.dist-info}/METADATA +1 -1
- {moleditpy-2.2.0a1.dist-info → moleditpy-2.2.0a3.dist-info}/RECORD +11 -28
- 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 -547
- moleditpy/plugins/Utility/console.py +0 -163
- moleditpy/plugins/Utility/pubchem_ressolver.py +0 -244
- moleditpy/plugins/Utility/vdw_radii_overlay.py +0 -303
- {moleditpy-2.2.0a1.dist-info → moleditpy-2.2.0a3.dist-info}/WHEEL +0 -0
- {moleditpy-2.2.0a1.dist-info → moleditpy-2.2.0a3.dist-info}/entry_points.txt +0 -0
- {moleditpy-2.2.0a1.dist-info → moleditpy-2.2.0a3.dist-info}/licenses/LICENSE +0 -0
- {moleditpy-2.2.0a1.dist-info → moleditpy-2.2.0a3.dist-info}/top_level.txt +0 -0
|
@@ -1,919 +0,0 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
import sys
|
|
3
|
-
import math
|
|
4
|
-
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QWidget, QMessageBox, QPushButton, QFileDialog, QCheckBox, QDoubleSpinBox
|
|
5
|
-
from PyQt6.QtGui import QPainter, QPen, QBrush, QColor, QFont, QPalette, QLinearGradient, QGradient
|
|
6
|
-
from PyQt6.QtCore import Qt, QRectF, QPointF
|
|
7
|
-
|
|
8
|
-
try:
|
|
9
|
-
from rdkit import Chem
|
|
10
|
-
from rdkit.Chem import Descriptors
|
|
11
|
-
except ImportError:
|
|
12
|
-
Chem = None
|
|
13
|
-
|
|
14
|
-
__version__="2025.12.18"
|
|
15
|
-
__author__="HiroYokoyama"
|
|
16
|
-
|
|
17
|
-
PLUGIN_NAME = "MS Spectrum Simulation Neo"
|
|
18
|
-
|
|
19
|
-
class MSSpectrumDialog(QDialog):
|
|
20
|
-
def __init__(self, mol, parent=None):
|
|
21
|
-
super().__init__(parent)
|
|
22
|
-
self.setWindowTitle("MS Spectrum Simulation Neo")
|
|
23
|
-
self.resize(500, 550)
|
|
24
|
-
self.mol = mol
|
|
25
|
-
|
|
26
|
-
# Clean white look
|
|
27
|
-
self.setStyleSheet("""
|
|
28
|
-
QDialog {
|
|
29
|
-
background-color: #ffffff;
|
|
30
|
-
color: #000000;
|
|
31
|
-
}
|
|
32
|
-
QLabel {
|
|
33
|
-
color: #333333;
|
|
34
|
-
font-size: 14px;
|
|
35
|
-
font-family: 'Segoe UI', sans-serif;
|
|
36
|
-
}
|
|
37
|
-
QGroupBox {
|
|
38
|
-
border: 1px solid #dddddd;
|
|
39
|
-
border-radius: 4px;
|
|
40
|
-
margin-top: 20px;
|
|
41
|
-
padding-top: 10px;
|
|
42
|
-
}
|
|
43
|
-
QGroupBox::title {
|
|
44
|
-
subcontrol-origin: margin;
|
|
45
|
-
subcontrol-position: top center;
|
|
46
|
-
padding: 0 5px;
|
|
47
|
-
color: #555555;
|
|
48
|
-
}
|
|
49
|
-
""")
|
|
50
|
-
|
|
51
|
-
layout = QVBoxLayout(self)
|
|
52
|
-
layout.setContentsMargins(10, 10, 10, 10)
|
|
53
|
-
|
|
54
|
-
# --- Settings Panel ---
|
|
55
|
-
from PyQt6.QtWidgets import QGroupBox, QFormLayout, QLineEdit, QComboBox, QSpinBox
|
|
56
|
-
|
|
57
|
-
settings_group = QGroupBox("Configuration")
|
|
58
|
-
settings_layout = QFormLayout(settings_group)
|
|
59
|
-
|
|
60
|
-
# 1. Formula Input
|
|
61
|
-
self.formula_input = QLineEdit()
|
|
62
|
-
if self.mol:
|
|
63
|
-
self.formula_input.setText(Chem.rdMolDescriptors.CalcMolFormula(self.mol))
|
|
64
|
-
settings_layout.addRow("Formula:", self.formula_input)
|
|
65
|
-
|
|
66
|
-
# 2. Charge (Signed)
|
|
67
|
-
self.charge_spin = QSpinBox()
|
|
68
|
-
self.charge_spin.setRange(-10, 10)
|
|
69
|
-
self.charge_spin.setValue(1) # Default +1
|
|
70
|
-
# Prevent 0
|
|
71
|
-
self.charge_spin.valueChanged.connect(self.on_charge_changed)
|
|
72
|
-
settings_layout.addRow("Charge (z):", self.charge_spin)
|
|
73
|
-
|
|
74
|
-
# 3. Adduct Species
|
|
75
|
-
self.adduct_combo = QComboBox()
|
|
76
|
-
self.update_adduct_options() # Populate initial
|
|
77
|
-
settings_layout.addRow("Adduct:", self.adduct_combo)
|
|
78
|
-
|
|
79
|
-
layout.addWidget(settings_group)
|
|
80
|
-
|
|
81
|
-
# Gaussian Options
|
|
82
|
-
self.gauss_check = QCheckBox("Gaussian Broadening")
|
|
83
|
-
self.gauss_check.setChecked(False)
|
|
84
|
-
self.gauss_check.stateChanged.connect(self.recalc_peaks)
|
|
85
|
-
|
|
86
|
-
self.width_spin = QDoubleSpinBox()
|
|
87
|
-
self.width_spin.setRange(0.001, 5.0)
|
|
88
|
-
self.width_spin.setSingleStep(0.01)
|
|
89
|
-
self.width_spin.setValue(0.04)
|
|
90
|
-
self.width_spin.setSuffix(" Da")
|
|
91
|
-
self.width_spin.setToolTip("Peak width (Sigma)")
|
|
92
|
-
self.width_spin.valueChanged.connect(self.recalc_peaks)
|
|
93
|
-
|
|
94
|
-
# Layout for Gaussian
|
|
95
|
-
gauss_layout = QHBoxLayout()
|
|
96
|
-
gauss_layout.addWidget(self.gauss_check)
|
|
97
|
-
gauss_layout.addWidget(QLabel("Width:"))
|
|
98
|
-
gauss_layout.addWidget(self.width_spin)
|
|
99
|
-
gauss_layout.addStretch()
|
|
100
|
-
layout.addLayout(gauss_layout)
|
|
101
|
-
|
|
102
|
-
# --- Info Labels ---
|
|
103
|
-
info_layout = QHBoxLayout()
|
|
104
|
-
self.lbl_mw = QLabel("Neutral Avg Mass: -")
|
|
105
|
-
self.lbl_em = QLabel("Neutral Exact Mass: -")
|
|
106
|
-
self.lbl_ion = QLabel("Monoisotopic m/z: -")
|
|
107
|
-
info_layout.addWidget(self.lbl_mw)
|
|
108
|
-
info_layout.addWidget(self.lbl_em)
|
|
109
|
-
info_layout.addWidget(self.lbl_ion)
|
|
110
|
-
layout.addLayout(info_layout)
|
|
111
|
-
|
|
112
|
-
# --- Plot ---
|
|
113
|
-
plot_container = QWidget()
|
|
114
|
-
plot_container.setStyleSheet("background-color: #ffffff; border: 1px solid #cccccc; border-radius: 4px;")
|
|
115
|
-
plot_layout = QVBoxLayout(plot_container)
|
|
116
|
-
plot_layout.setContentsMargins(1, 1, 1, 1)
|
|
117
|
-
|
|
118
|
-
self.plot_widget = HistogramWidget([]) # Init empty
|
|
119
|
-
plot_layout.addWidget(self.plot_widget)
|
|
120
|
-
layout.addWidget(plot_container, 1)
|
|
121
|
-
|
|
122
|
-
# Export Button
|
|
123
|
-
btn_layout = QHBoxLayout()
|
|
124
|
-
btn_layout.addStretch()
|
|
125
|
-
self.export_btn = QPushButton("Export to Image")
|
|
126
|
-
self.export_btn.clicked.connect(self.export_image)
|
|
127
|
-
btn_layout.addWidget(self.export_btn)
|
|
128
|
-
|
|
129
|
-
self.btn_export_csv = QPushButton("Export CSV")
|
|
130
|
-
self.btn_export_csv.clicked.connect(self.export_csv)
|
|
131
|
-
btn_layout.addWidget(self.btn_export_csv)
|
|
132
|
-
|
|
133
|
-
layout.addLayout(btn_layout)
|
|
134
|
-
|
|
135
|
-
# Signals
|
|
136
|
-
# Signals
|
|
137
|
-
self.formula_input.textChanged.connect(lambda: self.recalc_peaks(reset=True))
|
|
138
|
-
self.adduct_combo.currentIndexChanged.connect(lambda: self.recalc_peaks(reset=True))
|
|
139
|
-
self.charge_spin.valueChanged.connect(lambda: self.recalc_peaks(reset=True))
|
|
140
|
-
|
|
141
|
-
# Gaussian changes should NOT reset view (keep zoom)
|
|
142
|
-
self.gauss_check.stateChanged.connect(lambda: self.recalc_peaks(reset=False))
|
|
143
|
-
self.width_spin.valueChanged.connect(lambda: self.recalc_peaks(reset=False))
|
|
144
|
-
|
|
145
|
-
# Initial Calc
|
|
146
|
-
self.recalc_peaks(reset=True)
|
|
147
|
-
|
|
148
|
-
def export_image(self):
|
|
149
|
-
filename, _ = QFileDialog.getSaveFileName(self, "Save Spectrum Image", "spectrum.png", "Images (*.png *.jpg)")
|
|
150
|
-
if filename:
|
|
151
|
-
pixmap = self.plot_widget.grab()
|
|
152
|
-
if pixmap.save(filename):
|
|
153
|
-
QMessageBox.information(self, "Success", f"Image saved to {filename}")
|
|
154
|
-
else:
|
|
155
|
-
QMessageBox.critical(self, "Error", "Failed to save image.")
|
|
156
|
-
|
|
157
|
-
def on_charge_changed(self, val):
|
|
158
|
-
# Allow 0
|
|
159
|
-
|
|
160
|
-
# Always update options to reflect new charge in strings
|
|
161
|
-
# Always update options to reflect new charge in strings
|
|
162
|
-
self.update_adduct_options()
|
|
163
|
-
# Recalc is triggered by signals if values change, but charge spin change
|
|
164
|
-
# manually connected? No, above we connected charge_spin.valueChanged.
|
|
165
|
-
# But wait, update_adduct_options clears combo?
|
|
166
|
-
# Let's rely on signals.
|
|
167
|
-
# self.recalc_peaks() -> Will be called by signal.
|
|
168
|
-
|
|
169
|
-
def to_superscript(self, txt):
|
|
170
|
-
mapping = {
|
|
171
|
-
'0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴',
|
|
172
|
-
'5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹',
|
|
173
|
-
'+': '⁺', '-': '⁻'
|
|
174
|
-
}
|
|
175
|
-
return "".join(mapping.get(c, c) for c in str(txt))
|
|
176
|
-
|
|
177
|
-
def to_subscript(self, txt):
|
|
178
|
-
mapping = {
|
|
179
|
-
'0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄',
|
|
180
|
-
'5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉'
|
|
181
|
-
}
|
|
182
|
-
return "".join(mapping.get(c, c) for c in str(txt))
|
|
183
|
-
|
|
184
|
-
def update_adduct_options(self):
|
|
185
|
-
# Save current choice
|
|
186
|
-
current_idx = self.adduct_combo.currentIndex()
|
|
187
|
-
if current_idx < 0: current_idx = 0
|
|
188
|
-
|
|
189
|
-
self.adduct_combo.blockSignals(True)
|
|
190
|
-
self.adduct_combo.clear()
|
|
191
|
-
|
|
192
|
-
charge_val = self.charge_spin.value()
|
|
193
|
-
z = abs(charge_val)
|
|
194
|
-
|
|
195
|
-
if charge_val == 0:
|
|
196
|
-
items = [
|
|
197
|
-
"M (Neutral) [M]",
|
|
198
|
-
"None"
|
|
199
|
-
]
|
|
200
|
-
self.adduct_combo.addItems(items)
|
|
201
|
-
self.adduct_combo.blockSignals(False)
|
|
202
|
-
return
|
|
203
|
-
|
|
204
|
-
sign = "⁺" if charge_val > 0 else "⁻"
|
|
205
|
-
|
|
206
|
-
# Format Charge Superscript (e.g. ²⁺ or ⁺)
|
|
207
|
-
z_super = self.to_superscript(z) if z > 1 else ""
|
|
208
|
-
charge_str = f"{z_super}{sign}"
|
|
209
|
-
|
|
210
|
-
# Format Count (coefficient) (e.g. 2 or "")
|
|
211
|
-
n_str = str(z) if z > 1 else ""
|
|
212
|
-
|
|
213
|
-
if charge_val > 0: # Positive
|
|
214
|
-
items = [
|
|
215
|
-
f"M [M]{charge_str}",
|
|
216
|
-
f"H (Proton) [M+{n_str}H]{charge_str}",
|
|
217
|
-
f"Na (Sodium) [M+{n_str}Na]{charge_str}",
|
|
218
|
-
f"K (Potassium) [M+{n_str}K]{charge_str}",
|
|
219
|
-
f"NH4 (Ammonium) [M+{n_str}NH{self.to_subscript(4)}]{charge_str}",
|
|
220
|
-
f"CH3CN+H (Acetonitrile) [M+{n_str}H+{n_str}CH{self.to_subscript(3)}CN]{charge_str}"
|
|
221
|
-
]
|
|
222
|
-
else: # Negative
|
|
223
|
-
items = [
|
|
224
|
-
f"M [M]{charge_str}",
|
|
225
|
-
f"H (Deprotonation) [M-{n_str}H]{charge_str}",
|
|
226
|
-
f"Cl (Chloride) [M+{n_str}Cl]{charge_str}",
|
|
227
|
-
f"HCOO (Formate) [M+{n_str}HCOO]{charge_str}",
|
|
228
|
-
f"CH3COO (Acetate) [M+{n_str}CH{self.to_subscript(3)}COO]{charge_str}"
|
|
229
|
-
]
|
|
230
|
-
|
|
231
|
-
self.adduct_combo.addItems(items)
|
|
232
|
-
|
|
233
|
-
# Restore index if safe, else 0
|
|
234
|
-
if current_idx < self.adduct_combo.count():
|
|
235
|
-
self.adduct_combo.setCurrentIndex(current_idx)
|
|
236
|
-
else:
|
|
237
|
-
self.adduct_combo.setCurrentIndex(0)
|
|
238
|
-
|
|
239
|
-
self.adduct_combo.blockSignals(False)
|
|
240
|
-
|
|
241
|
-
def parse_formula_str(self, formula):
|
|
242
|
-
import re
|
|
243
|
-
# Tokenize: Elements (e.g., "Na", "C"), Numbers, Parentheses
|
|
244
|
-
tokens = re.findall(r"([A-Z][a-z]*|\d+|\(|\))", formula)
|
|
245
|
-
|
|
246
|
-
stack = [{}]
|
|
247
|
-
|
|
248
|
-
i = 0
|
|
249
|
-
while i < len(tokens):
|
|
250
|
-
token = tokens[i]
|
|
251
|
-
|
|
252
|
-
if token == '(':
|
|
253
|
-
stack.append({})
|
|
254
|
-
i += 1
|
|
255
|
-
elif token == ')':
|
|
256
|
-
# Check for multiplier after parenthesis
|
|
257
|
-
multiplier = 1
|
|
258
|
-
if i + 1 < len(tokens) and tokens[i+1].isdigit():
|
|
259
|
-
multiplier = int(tokens[i+1])
|
|
260
|
-
i += 1 # Consume number
|
|
261
|
-
|
|
262
|
-
# Pop and merge
|
|
263
|
-
if len(stack) > 1:
|
|
264
|
-
top = stack.pop()
|
|
265
|
-
for el, count in top.items():
|
|
266
|
-
stack[-1][el] = stack[-1].get(el, 0) + count * multiplier
|
|
267
|
-
i += 1
|
|
268
|
-
elif token.isdigit():
|
|
269
|
-
# Should have been handled, but ignore if standalone
|
|
270
|
-
i += 1
|
|
271
|
-
elif token[0].isalpha(): # Element
|
|
272
|
-
element = token
|
|
273
|
-
count = 1
|
|
274
|
-
if i + 1 < len(tokens) and tokens[i+1].isdigit():
|
|
275
|
-
count = int(tokens[i+1])
|
|
276
|
-
i += 1 # Consume number
|
|
277
|
-
|
|
278
|
-
stack[-1][element] = stack[-1].get(element, 0) + count
|
|
279
|
-
i += 1
|
|
280
|
-
|
|
281
|
-
# Merge any remaining stack items
|
|
282
|
-
while len(stack) > 1:
|
|
283
|
-
top = stack.pop()
|
|
284
|
-
for el, count in top.items():
|
|
285
|
-
stack[-1][el] = stack[-1].get(el, 0) + count
|
|
286
|
-
|
|
287
|
-
return stack[0]
|
|
288
|
-
|
|
289
|
-
def get_adduct_delta(self, species_idx, mode, charge):
|
|
290
|
-
# Map index to species logic
|
|
291
|
-
# Positive: 0:M, 1:H, 2:Na, 3:K, 4:NH4, 5:CH3CN+H
|
|
292
|
-
# Negative: 0:M, 1:H(-), 2:Cl, 3:HCOO, 4:CH3COO
|
|
293
|
-
|
|
294
|
-
delta = {}
|
|
295
|
-
|
|
296
|
-
# NOTE: Indices shifted because M is now 0
|
|
297
|
-
if mode == "Positive":
|
|
298
|
-
if species_idx == 0: # M
|
|
299
|
-
pass
|
|
300
|
-
elif species_idx == 1: # H
|
|
301
|
-
delta = {'H': 1 * charge}
|
|
302
|
-
elif species_idx == 2: # Na
|
|
303
|
-
delta = {'Na': 1 * charge}
|
|
304
|
-
elif species_idx == 3: # K
|
|
305
|
-
delta = {'K': 1 * charge}
|
|
306
|
-
elif species_idx == 4: # NH4
|
|
307
|
-
delta = {'N': 1 * charge, 'H': 4 * charge}
|
|
308
|
-
elif species_idx == 5: # CH3CN+H
|
|
309
|
-
delta = {'C': 2 * charge, 'H': 4 * charge, 'N': 1 * charge}
|
|
310
|
-
|
|
311
|
-
else: # Negative
|
|
312
|
-
if species_idx == 0: # M
|
|
313
|
-
pass
|
|
314
|
-
elif species_idx == 1: # H (Deprotonation -H)
|
|
315
|
-
delta = {'H': -1 * charge}
|
|
316
|
-
elif species_idx == 2: # Cl
|
|
317
|
-
delta = {'Cl': 1 * charge}
|
|
318
|
-
elif species_idx == 3: # HCOO
|
|
319
|
-
delta = {'C': 1 * charge, 'H': 1 * charge, 'O': 2 * charge}
|
|
320
|
-
elif species_idx == 4: # CH3COO
|
|
321
|
-
delta = {'C': 2 * charge, 'H': 3 * charge, 'O': 2 * charge}
|
|
322
|
-
|
|
323
|
-
return delta
|
|
324
|
-
|
|
325
|
-
def export_csv(self):
|
|
326
|
-
filename, _ = QFileDialog.getSaveFileName(self, "Save Spectrum CSV", "spectrum.csv", "CSV Files (*.csv)")
|
|
327
|
-
if not filename: return
|
|
328
|
-
|
|
329
|
-
try:
|
|
330
|
-
with open(filename, 'w') as f:
|
|
331
|
-
f.write("m/z,Intensity\n")
|
|
332
|
-
data = self.plot_widget.peaks
|
|
333
|
-
for m, i in data:
|
|
334
|
-
f.write(f"{m:.5f},{i:.5f}\n")
|
|
335
|
-
QMessageBox.information(self, "Success", f"Saved to {filename}")
|
|
336
|
-
except Exception as e:
|
|
337
|
-
QMessageBox.critical(self, "Error", f"Failed to save CSV: {e}")
|
|
338
|
-
|
|
339
|
-
def recalc_peaks(self, reset=True):
|
|
340
|
-
peaks, final_mw, neutral_exact_mw, ion_mz = self._calculate_peaks()
|
|
341
|
-
|
|
342
|
-
# Apply Gaussian if checked
|
|
343
|
-
if self.gauss_check.isChecked() and peaks:
|
|
344
|
-
sigma = self.width_spin.value()
|
|
345
|
-
display_peaks = self.apply_gaussian_broadening(peaks, sigma)
|
|
346
|
-
self.plot_widget.draw_mode = "profile"
|
|
347
|
-
else:
|
|
348
|
-
display_peaks = peaks
|
|
349
|
-
self.plot_widget.draw_mode = "stick"
|
|
350
|
-
|
|
351
|
-
self.peaks = display_peaks
|
|
352
|
-
self.plot_widget.peaks = display_peaks
|
|
353
|
-
self.plot_widget.stick_peaks = peaks # Save theoretical peaks for labels
|
|
354
|
-
|
|
355
|
-
if reset:
|
|
356
|
-
self.plot_widget.reset_view()
|
|
357
|
-
else:
|
|
358
|
-
self.plot_widget.update()
|
|
359
|
-
|
|
360
|
-
# Prepare Info Text for Graph
|
|
361
|
-
formula_raw = self.formula_input.text().strip()
|
|
362
|
-
|
|
363
|
-
# Format formula (C6H6 -> C₆H₆)
|
|
364
|
-
sub_map = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
|
|
365
|
-
formula_formatted = formula_raw.translate(sub_map)
|
|
366
|
-
|
|
367
|
-
full_adduct_str = self.adduct_combo.currentText()
|
|
368
|
-
import re
|
|
369
|
-
match = re.search(r"\[.*?\][⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻˙]*", full_adduct_str)
|
|
370
|
-
if match:
|
|
371
|
-
adduct_display = match.group(0)
|
|
372
|
-
else:
|
|
373
|
-
adduct_display = full_adduct_str
|
|
374
|
-
|
|
375
|
-
adduct_display = adduct_display.replace("M", formula_formatted)
|
|
376
|
-
|
|
377
|
-
if self.charge_spin.value() == 0:
|
|
378
|
-
adduct_display = "Neutral"
|
|
379
|
-
self.plot_widget.info_text = f"{formula_formatted}\n{adduct_display}"
|
|
380
|
-
else:
|
|
381
|
-
self.plot_widget.info_text = f"{adduct_display}"
|
|
382
|
-
|
|
383
|
-
self.plot_widget.update()
|
|
384
|
-
|
|
385
|
-
# Update Info
|
|
386
|
-
try:
|
|
387
|
-
self.lbl_ion.setText(f"<b>Monoisotopic m/z:</b> {ion_mz:.4f}")
|
|
388
|
-
self.lbl_mw.setText(f"<b>Neutral Avg Mass:</b> {final_mw:.4f}")
|
|
389
|
-
self.lbl_em.setText(f"<b>Neutral Exact Mass:</b> {neutral_exact_mw:.4f}")
|
|
390
|
-
except:
|
|
391
|
-
pass
|
|
392
|
-
|
|
393
|
-
def apply_gaussian_broadening(self, peaks, sigma):
|
|
394
|
-
import math
|
|
395
|
-
if not peaks: return []
|
|
396
|
-
|
|
397
|
-
# Dynamic margin to avoid clipping wide peaks
|
|
398
|
-
margin = max(2.0, 6.0 * sigma)
|
|
399
|
-
min_mz = min(p[0] for p in peaks) - margin
|
|
400
|
-
max_mz = max(p[0] for p in peaks) + margin
|
|
401
|
-
|
|
402
|
-
# Grid - Finer resolution to minimize peak shift artifacts
|
|
403
|
-
import numpy as np
|
|
404
|
-
# 10 points per sigma is better for appearance, 20 is good for peak top approx
|
|
405
|
-
step = min(0.005, sigma / 10.0)
|
|
406
|
-
x_vals = np.arange(min_mz, max_mz, step)
|
|
407
|
-
y_vals = np.zeros_like(x_vals)
|
|
408
|
-
|
|
409
|
-
# Sum Gaussians
|
|
410
|
-
for center, intensity in peaks:
|
|
411
|
-
indices = np.where((x_vals >= center - 5*sigma) & (x_vals <= center + 5*sigma))
|
|
412
|
-
if len(indices[0]) == 0: continue
|
|
413
|
-
|
|
414
|
-
xs = x_vals[indices]
|
|
415
|
-
ys = intensity * np.exp( - (xs - center)**2 / (2 * sigma**2) )
|
|
416
|
-
y_vals[indices] += ys
|
|
417
|
-
|
|
418
|
-
if np.max(y_vals) > 0:
|
|
419
|
-
y_vals = (y_vals / np.max(y_vals)) * 100.0
|
|
420
|
-
|
|
421
|
-
return list(zip(x_vals, y_vals))
|
|
422
|
-
|
|
423
|
-
def _calculate_peaks(self):
|
|
424
|
-
formula_str = self.formula_input.text().strip()
|
|
425
|
-
if not formula_str:
|
|
426
|
-
return [], 0.0, 0.0, 0.0
|
|
427
|
-
|
|
428
|
-
# 1. Base composition
|
|
429
|
-
base_counts = self.parse_formula_str(formula_str)
|
|
430
|
-
if not base_counts:
|
|
431
|
-
return [], 0.0, 0.0, 0.0
|
|
432
|
-
|
|
433
|
-
# 2. Adduct adjustments
|
|
434
|
-
charge_val = self.charge_spin.value() # Signed
|
|
435
|
-
charge = abs(charge_val)
|
|
436
|
-
mode = "Positive" if charge_val > 0 else "Negative"
|
|
437
|
-
species_idx = self.adduct_combo.currentIndex()
|
|
438
|
-
|
|
439
|
-
delta = self.get_adduct_delta(species_idx, mode, charge)
|
|
440
|
-
|
|
441
|
-
# Merge counts
|
|
442
|
-
final_counts = base_counts.copy()
|
|
443
|
-
for el, count in delta.items():
|
|
444
|
-
final_counts[el] = final_counts.get(el, 0) + count
|
|
445
|
-
|
|
446
|
-
# Remove elements with <= 0 count
|
|
447
|
-
final_counts = {k: v for k, v in final_counts.items() if v > 0}
|
|
448
|
-
|
|
449
|
-
if not final_counts:
|
|
450
|
-
return [], 0.0, 0.0, 0.0
|
|
451
|
-
|
|
452
|
-
# 3. Dynamic Isotope Calc
|
|
453
|
-
pt = Chem.GetPeriodicTable()
|
|
454
|
-
current_dist = [(0.0, 1.0)]
|
|
455
|
-
electron_mass = 0.00054858
|
|
456
|
-
|
|
457
|
-
total_mw = 0.0 # Neutral average mass
|
|
458
|
-
|
|
459
|
-
for sym, count in final_counts.items():
|
|
460
|
-
# Special handling for Deuterium
|
|
461
|
-
if sym == "D":
|
|
462
|
-
mass_d = 2.0141017781
|
|
463
|
-
total_mw += mass_d * count
|
|
464
|
-
atom_iso_dist = [(mass_d, 1.0)]
|
|
465
|
-
else:
|
|
466
|
-
try:
|
|
467
|
-
base_mass = pt.GetAtomicWeight(sym) # Average weight
|
|
468
|
-
total_mw += base_mass * count
|
|
469
|
-
|
|
470
|
-
atomic_num = pt.GetAtomicNumber(sym)
|
|
471
|
-
atom_iso_dist = []
|
|
472
|
-
center_mass = pt.GetMostCommonIsotope(atomic_num)
|
|
473
|
-
# Scan a range
|
|
474
|
-
for m in range(max(1, center_mass - 5), center_mass + 10):
|
|
475
|
-
try:
|
|
476
|
-
abundance = pt.GetAbundanceForIsotope(atomic_num, m)
|
|
477
|
-
if abundance > 0.00001:
|
|
478
|
-
exact_mass = pt.GetMassForIsotope(atomic_num, m)
|
|
479
|
-
atom_iso_dist.append((exact_mass, abundance))
|
|
480
|
-
except RuntimeError: pass
|
|
481
|
-
|
|
482
|
-
# Normalize
|
|
483
|
-
total_p = sum(p for m, p in atom_iso_dist)
|
|
484
|
-
if total_p == 0: continue
|
|
485
|
-
atom_iso_dist = [(m, p / total_p) for m, p in atom_iso_dist]
|
|
486
|
-
except:
|
|
487
|
-
# Ignore unknown elements
|
|
488
|
-
continue
|
|
489
|
-
|
|
490
|
-
# Convolve 'count' times
|
|
491
|
-
for _ in range(count):
|
|
492
|
-
new_peaks = {}
|
|
493
|
-
for m1, p1 in current_dist:
|
|
494
|
-
if p1 < 1e-5: continue
|
|
495
|
-
for m2, p2 in atom_iso_dist:
|
|
496
|
-
m = m1 + m2
|
|
497
|
-
p = p1 * p2
|
|
498
|
-
m_bin = round(m, 6)
|
|
499
|
-
new_peaks[m_bin] = new_peaks.get(m_bin, 0) + p
|
|
500
|
-
|
|
501
|
-
sorted_p = sorted(new_peaks.items())
|
|
502
|
-
|
|
503
|
-
merged = []
|
|
504
|
-
if sorted_p:
|
|
505
|
-
curr_m, curr_p = sorted_p[0]
|
|
506
|
-
for m, p in sorted_p[1:]:
|
|
507
|
-
if m - curr_m < 0.005:
|
|
508
|
-
total = curr_p + p
|
|
509
|
-
curr_m = (curr_m * curr_p + m * p) / total
|
|
510
|
-
curr_p = total
|
|
511
|
-
else:
|
|
512
|
-
merged.append((curr_m, curr_p))
|
|
513
|
-
curr_m, curr_p = m, p
|
|
514
|
-
merged.append((curr_m, curr_p))
|
|
515
|
-
|
|
516
|
-
max_p = max(p for m, p in merged) if merged else 1
|
|
517
|
-
current_dist = [x for x in merged if x[1] > max_p * 0.001]
|
|
518
|
-
if len(current_dist) > 200:
|
|
519
|
-
current_dist.sort(key=lambda x: x[1], reverse=True)
|
|
520
|
-
current_dist = current_dist[:200]
|
|
521
|
-
|
|
522
|
-
# Calculate EXACT NEUTRAL MASS (Always needed)
|
|
523
|
-
exact_mass_sum = 0.0
|
|
524
|
-
for sym, count in final_counts.items():
|
|
525
|
-
if sym == "D":
|
|
526
|
-
exact_mass_sum += 2.0141017781 * count
|
|
527
|
-
else:
|
|
528
|
-
try:
|
|
529
|
-
anum = pt.GetAtomicNumber(sym)
|
|
530
|
-
m_iso = pt.GetMostCommonIsotope(anum)
|
|
531
|
-
mass_iso = pt.GetMassForIsotope(anum, m_iso)
|
|
532
|
-
exact_mass_sum += mass_iso * count
|
|
533
|
-
except: pass
|
|
534
|
-
|
|
535
|
-
neutral_exact_mass = exact_mass_sum
|
|
536
|
-
|
|
537
|
-
# 4. Adjust for Charge (m/z)
|
|
538
|
-
if charge_val == 0:
|
|
539
|
-
# Neutral Mode: Hide Spectrum
|
|
540
|
-
# Return empty peaks, but valid neutral masses
|
|
541
|
-
return [], total_mw, neutral_exact_mass, 0.0
|
|
542
|
-
|
|
543
|
-
# Apply electron mass correction
|
|
544
|
-
total_ion_mass_dist = []
|
|
545
|
-
|
|
546
|
-
e_params = 0
|
|
547
|
-
if mode == "Positive":
|
|
548
|
-
e_params = -1 * charge * electron_mass
|
|
549
|
-
else:
|
|
550
|
-
e_params = +1 * charge * electron_mass
|
|
551
|
-
|
|
552
|
-
for m, p in current_dist:
|
|
553
|
-
ion_mass = m + e_params
|
|
554
|
-
if ion_mass <= 0: continue
|
|
555
|
-
mz = ion_mass / charge
|
|
556
|
-
total_ion_mass_dist.append((mz, p))
|
|
557
|
-
|
|
558
|
-
if not total_ion_mass_dist:
|
|
559
|
-
return [], total_mw, neutral_exact_mass, 0.0
|
|
560
|
-
|
|
561
|
-
max_intensity = max(p for m, p in total_ion_mass_dist)
|
|
562
|
-
final_peaks = [(m, (p / max_intensity) * 100.0) for m, p in total_ion_mass_dist]
|
|
563
|
-
final_peaks.sort(key=lambda x: x[0])
|
|
564
|
-
|
|
565
|
-
if mode == "Positive":
|
|
566
|
-
exact_mass_sum += (-1 * charge * electron_mass)
|
|
567
|
-
else:
|
|
568
|
-
exact_mass_sum += (+1 * charge * electron_mass)
|
|
569
|
-
|
|
570
|
-
exact_mz = exact_mass_sum / charge if charge != 0 else 0
|
|
571
|
-
|
|
572
|
-
return [p for p in final_peaks if p[1] > 0.05], total_mw, neutral_exact_mass, exact_mz
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
class HistogramWidget(QWidget):
|
|
576
|
-
def __init__(self, peaks):
|
|
577
|
-
super().__init__()
|
|
578
|
-
self.peaks = peaks # list of (mass, intensity) - used for drawing (stick or profile)
|
|
579
|
-
self.stick_peaks = [] # Always holds the theoretical stick peaks for labeling
|
|
580
|
-
self.info_text = ""
|
|
581
|
-
self.draw_mode = "stick" # "stick" or "profile"
|
|
582
|
-
self.setBackgroundRole(QPalette.ColorRole.Base)
|
|
583
|
-
self.setAutoFillBackground(False)
|
|
584
|
-
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
|
|
585
|
-
|
|
586
|
-
# View State
|
|
587
|
-
self.view_min = None
|
|
588
|
-
self.view_max = None
|
|
589
|
-
self.last_mouse_x = None
|
|
590
|
-
|
|
591
|
-
def reset_view(self):
|
|
592
|
-
# Auto-scale based on stick peaks (stable) or actual peaks
|
|
593
|
-
if self.stick_peaks:
|
|
594
|
-
masses = [p[0] for p in self.stick_peaks]
|
|
595
|
-
elif self.peaks:
|
|
596
|
-
masses = [p[0] for p in self.peaks]
|
|
597
|
-
else:
|
|
598
|
-
masses = []
|
|
599
|
-
|
|
600
|
-
if masses:
|
|
601
|
-
min_m, max_m = min(masses), max(masses)
|
|
602
|
-
if max_m == min_m:
|
|
603
|
-
min_m -= 5.0
|
|
604
|
-
max_m += 5.0
|
|
605
|
-
else:
|
|
606
|
-
padding = 5.0
|
|
607
|
-
min_m -= padding
|
|
608
|
-
max_m += padding
|
|
609
|
-
self.view_min = min_m
|
|
610
|
-
self.view_max = max_m
|
|
611
|
-
else:
|
|
612
|
-
self.view_min = 0.0
|
|
613
|
-
self.view_max = 100.0
|
|
614
|
-
|
|
615
|
-
self.update()
|
|
616
|
-
|
|
617
|
-
def wheelEvent(self, event):
|
|
618
|
-
if self.view_min is None or self.view_max is None: return
|
|
619
|
-
|
|
620
|
-
# Current Range
|
|
621
|
-
current_range = self.view_max - self.view_min
|
|
622
|
-
if current_range <= 0: return
|
|
623
|
-
|
|
624
|
-
# Zoom Factor
|
|
625
|
-
angle = event.angleDelta().y()
|
|
626
|
-
factor = 0.9 if angle > 0 else 1.1
|
|
627
|
-
|
|
628
|
-
# Mouse Position Ratio (0.0 to 1.0) relative to plot area
|
|
629
|
-
w = self.width()
|
|
630
|
-
ml, mr = 60, 40
|
|
631
|
-
plot_w = w - ml - mr
|
|
632
|
-
if plot_w <= 0: return
|
|
633
|
-
|
|
634
|
-
mouse_x = event.position().x()
|
|
635
|
-
ratio = (mouse_x - ml) / plot_w
|
|
636
|
-
# Clamp ratio
|
|
637
|
-
ratio = max(0.0, min(1.0, ratio))
|
|
638
|
-
|
|
639
|
-
# Calculate new range
|
|
640
|
-
new_range = current_range * factor
|
|
641
|
-
|
|
642
|
-
# Limit zoom (e.g. max range 2000, min range 1.0)
|
|
643
|
-
if new_range > 5000: new_range = 5000
|
|
644
|
-
if new_range < 1.0: new_range = 1.0
|
|
645
|
-
|
|
646
|
-
# Adjust min/max keeping ratio point fixed
|
|
647
|
-
change = new_range - current_range
|
|
648
|
-
|
|
649
|
-
self.view_min -= change * ratio
|
|
650
|
-
self.view_max += change * (1.0 - ratio)
|
|
651
|
-
|
|
652
|
-
self.update()
|
|
653
|
-
|
|
654
|
-
def mousePressEvent(self, event):
|
|
655
|
-
if event.button() == Qt.MouseButton.LeftButton:
|
|
656
|
-
self.last_mouse_x = event.position().x()
|
|
657
|
-
|
|
658
|
-
def mouseMoveEvent(self, event):
|
|
659
|
-
if self.last_mouse_x is not None and self.view_min is not None:
|
|
660
|
-
dx = event.position().x() - self.last_mouse_x
|
|
661
|
-
|
|
662
|
-
w = self.width()
|
|
663
|
-
ml, mr = 60, 40
|
|
664
|
-
plot_w = w - ml - mr
|
|
665
|
-
|
|
666
|
-
if plot_w > 0:
|
|
667
|
-
current_range = self.view_max - self.view_min
|
|
668
|
-
# Pixels to Mass Units
|
|
669
|
-
mass_shift = (dx / plot_w) * current_range
|
|
670
|
-
|
|
671
|
-
self.view_min -= mass_shift
|
|
672
|
-
self.view_max -= mass_shift
|
|
673
|
-
self.update()
|
|
674
|
-
|
|
675
|
-
self.last_mouse_x = event.position().x()
|
|
676
|
-
|
|
677
|
-
def mouseReleaseEvent(self, event):
|
|
678
|
-
if event.button() == Qt.MouseButton.LeftButton:
|
|
679
|
-
self.last_mouse_x = None
|
|
680
|
-
|
|
681
|
-
def mouseDoubleClickEvent(self, event):
|
|
682
|
-
if event.button() == Qt.MouseButton.LeftButton:
|
|
683
|
-
self.reset_view()
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
def paintEvent(self, event):
|
|
688
|
-
painter = QPainter(self)
|
|
689
|
-
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
690
|
-
|
|
691
|
-
w = self.width()
|
|
692
|
-
h = self.height()
|
|
693
|
-
|
|
694
|
-
# 1. Background White
|
|
695
|
-
painter.fillRect(0, 0, w, h, QColor("#ffffff"))
|
|
696
|
-
|
|
697
|
-
# Margins (Increased Top Margin for Labels)
|
|
698
|
-
ml, mt, mr, mb = 60, 60, 40, 50
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
if not self.peaks:
|
|
703
|
-
painter.setPen(QColor("#999999"))
|
|
704
|
-
font = painter.font()
|
|
705
|
-
font.setPointSize(12)
|
|
706
|
-
font.setBold(False)
|
|
707
|
-
painter.setFont(font)
|
|
708
|
-
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "No Data")
|
|
709
|
-
return
|
|
710
|
-
|
|
711
|
-
plot_w = w - ml - mr
|
|
712
|
-
plot_h = h - mt - mb
|
|
713
|
-
|
|
714
|
-
if self.view_min is None or self.view_max is None:
|
|
715
|
-
self.reset_view()
|
|
716
|
-
|
|
717
|
-
min_mass = self.view_min
|
|
718
|
-
max_mass = self.view_max
|
|
719
|
-
|
|
720
|
-
mass_range = max_mass - min_mass
|
|
721
|
-
max_intensity = 110.0
|
|
722
|
-
|
|
723
|
-
# 2. Draw Grid & Y-Axis Labels
|
|
724
|
-
painter.setPen(QPen(QColor("#eeeeee"), 1, Qt.PenStyle.SolidLine))
|
|
725
|
-
font = painter.font()
|
|
726
|
-
font.setPixelSize(10)
|
|
727
|
-
font.setBold(False)
|
|
728
|
-
painter.setFont(font)
|
|
729
|
-
|
|
730
|
-
# Grid lines and Y Labels
|
|
731
|
-
for i in range(0, 6): # 0 to 5 (0, 20, 40, 60, 80, 100)
|
|
732
|
-
val = i * 20
|
|
733
|
-
ratio = val / max_intensity
|
|
734
|
-
y = (h - mb) - (ratio * plot_h)
|
|
735
|
-
|
|
736
|
-
# Grid Line
|
|
737
|
-
if i > 0: # Don't draw over X axis
|
|
738
|
-
painter.setPen(QPen(QColor("#eeeeee"), 1, Qt.PenStyle.SolidLine))
|
|
739
|
-
painter.drawLine(ml, int(y), w - mr, int(y))
|
|
740
|
-
|
|
741
|
-
# Label
|
|
742
|
-
painter.setPen(QColor("#000000"))
|
|
743
|
-
painter.drawText(QRectF(0, y - 10, ml - 5, 20), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, str(val))
|
|
744
|
-
|
|
745
|
-
# 3. Draw Axes
|
|
746
|
-
axis_pen = QPen(QColor("#000000"), 2)
|
|
747
|
-
painter.setPen(axis_pen)
|
|
748
|
-
# Y axis
|
|
749
|
-
painter.drawLine(ml, mt, ml, h - mb)
|
|
750
|
-
# X axis
|
|
751
|
-
painter.drawLine(ml, h - mb, w - mr, h - mb)
|
|
752
|
-
|
|
753
|
-
# X-Axis Ticks
|
|
754
|
-
n_ticks = 5
|
|
755
|
-
tick_step = mass_range / n_ticks
|
|
756
|
-
import math
|
|
757
|
-
mag = 10**math.floor(math.log10(tick_step)) if tick_step > 0 else 1
|
|
758
|
-
nice_step = round(tick_step / mag) * mag
|
|
759
|
-
if nice_step == 0: nice_step = tick_step
|
|
760
|
-
|
|
761
|
-
start_tick = math.ceil(min_mass / nice_step) * nice_step
|
|
762
|
-
|
|
763
|
-
painter.setPen(QColor("#000000"))
|
|
764
|
-
curr_tick = start_tick
|
|
765
|
-
while curr_tick <= max_mass:
|
|
766
|
-
x_ratio = (curr_tick - min_mass) / mass_range if mass_range > 0 else 0.5
|
|
767
|
-
x = ml + x_ratio * plot_w
|
|
768
|
-
|
|
769
|
-
if ml <= x <= w - mr:
|
|
770
|
-
painter.drawLine(int(x), h - mb, int(x), h - mb + 5)
|
|
771
|
-
painter.drawText(QRectF(x - 30, h - mb + 5, 60, 20), Qt.AlignmentFlag.AlignCenter, f"{curr_tick:.1f}")
|
|
772
|
-
|
|
773
|
-
curr_tick += nice_step
|
|
774
|
-
|
|
775
|
-
# 4. Draw Peaks
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
font.setPixelSize(11)
|
|
779
|
-
painter.setFont(font)
|
|
780
|
-
|
|
781
|
-
base_y = h - mb
|
|
782
|
-
|
|
783
|
-
if self.draw_mode == "profile":
|
|
784
|
-
# 1. Draw Profile Curve (CLIPPED)
|
|
785
|
-
painter.save()
|
|
786
|
-
painter.setClipRect(ml, mt, plot_w, plot_h)
|
|
787
|
-
|
|
788
|
-
path_points = []
|
|
789
|
-
first_mass = self.peaks[0][0]
|
|
790
|
-
first_x_ratio = (first_mass - min_mass) / mass_range if mass_range > 0 else 0.5
|
|
791
|
-
first_x = ml + first_x_ratio * plot_w
|
|
792
|
-
path_points.append(QPointF(first_x, base_y))
|
|
793
|
-
|
|
794
|
-
# Use self.peaks (profile data) for Curve
|
|
795
|
-
for mass, intensity in self.peaks:
|
|
796
|
-
x_ratio = (mass - min_mass) / mass_range if mass_range > 0 else 0.5
|
|
797
|
-
y_ratio = intensity / max_intensity
|
|
798
|
-
|
|
799
|
-
x_pos = ml + x_ratio * plot_w
|
|
800
|
-
y_pos = (h - mb) - (y_ratio * plot_h)
|
|
801
|
-
path_points.append(QPointF(x_pos, y_pos))
|
|
802
|
-
|
|
803
|
-
last_mass = self.peaks[-1][0]
|
|
804
|
-
last_x_ratio = (last_mass - min_mass) / mass_range if mass_range > 0 else 0.5
|
|
805
|
-
last_x = ml + last_x_ratio * plot_w
|
|
806
|
-
path_points.append(QPointF(last_x, base_y))
|
|
807
|
-
|
|
808
|
-
profile_pen = QPen(QColor("#007bff"), 2)
|
|
809
|
-
painter.setPen(profile_pen)
|
|
810
|
-
painter.drawPolyline(path_points)
|
|
811
|
-
|
|
812
|
-
painter.restore() # Unclip for labels
|
|
813
|
-
|
|
814
|
-
# 2. Peak Labels (Theoretical Stick Peaks) - UNCLIPPED
|
|
815
|
-
painter.setPen(QPen(QColor("#007bff")))
|
|
816
|
-
|
|
817
|
-
if self.stick_peaks:
|
|
818
|
-
for s_mass, s_int in self.stick_peaks:
|
|
819
|
-
# Filter very low intensity theoretical peaks
|
|
820
|
-
if s_int < 0.05: continue
|
|
821
|
-
|
|
822
|
-
if s_mass < min_mass or s_mass > max_mass: continue
|
|
823
|
-
|
|
824
|
-
x_ratio = (s_mass - min_mass) / mass_range if mass_range > 0 else 0.5
|
|
825
|
-
y_ratio = s_int / max_intensity
|
|
826
|
-
|
|
827
|
-
x_pos = ml + x_ratio * plot_w
|
|
828
|
-
y_pos = (h - mb) - (y_ratio * plot_h)
|
|
829
|
-
|
|
830
|
-
if not (ml <= x_pos <= w - mr): continue
|
|
831
|
-
|
|
832
|
-
# Mass Label - Higher Position over stick peak
|
|
833
|
-
# NOTE: We draw label at STICK height, not PROFILE height
|
|
834
|
-
label_rect = QRectF(x_pos - 40, y_pos - 25, 80, 20)
|
|
835
|
-
painter.setPen(QColor("#000000"))
|
|
836
|
-
painter.drawText(label_rect, Qt.AlignmentFlag.AlignCenter, f"{s_mass:.4f}")
|
|
837
|
-
|
|
838
|
-
else:
|
|
839
|
-
# STICK MODE
|
|
840
|
-
|
|
841
|
-
# 1. Draw Sticks (CLIPPED)
|
|
842
|
-
painter.save()
|
|
843
|
-
painter.setClipRect(ml, mt, plot_w, plot_h)
|
|
844
|
-
|
|
845
|
-
stick_pen = QPen(QColor("#007bff"), 3)
|
|
846
|
-
stick_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
847
|
-
painter.setPen(stick_pen)
|
|
848
|
-
|
|
849
|
-
for mass, intensity in self.peaks:
|
|
850
|
-
x_ratio = (mass - min_mass) / mass_range if mass_range > 0 else 0.5
|
|
851
|
-
y_ratio = intensity / max_intensity
|
|
852
|
-
|
|
853
|
-
x_pos = ml + x_ratio * plot_w
|
|
854
|
-
y_pos = (h - mb) - (y_ratio * plot_h)
|
|
855
|
-
|
|
856
|
-
painter.drawLine(QPointF(x_pos, base_y), QPointF(x_pos, y_pos))
|
|
857
|
-
|
|
858
|
-
painter.restore() # Unclip for labels
|
|
859
|
-
|
|
860
|
-
# 2. Draw Labels (UNCLIPPED)
|
|
861
|
-
for mass, intensity in self.peaks:
|
|
862
|
-
x_ratio = (mass - min_mass) / mass_range if mass_range > 0 else 0.5
|
|
863
|
-
y_ratio = intensity / max_intensity
|
|
864
|
-
|
|
865
|
-
x_pos = ml + x_ratio * plot_w
|
|
866
|
-
y_pos = (h - mb) - (y_ratio * plot_h)
|
|
867
|
-
|
|
868
|
-
if x_pos < ml or x_pos > w - mr: continue # Simple visibility check
|
|
869
|
-
|
|
870
|
-
painter.setPen(QPen(QColor("#000000")))
|
|
871
|
-
|
|
872
|
-
# Mass Label (Line 1)
|
|
873
|
-
label_rect = QRectF(x_pos - 40, y_pos - 35, 80, 15)
|
|
874
|
-
painter.drawText(label_rect, Qt.AlignmentFlag.AlignCenter, f"{mass:.4f}")
|
|
875
|
-
|
|
876
|
-
# Intensity Label (Line 2)
|
|
877
|
-
painter.setPen(QPen(QColor("#007bff")))
|
|
878
|
-
int_rect = QRectF(x_pos - 40, y_pos - 20, 80, 15)
|
|
879
|
-
painter.drawText(int_rect, Qt.AlignmentFlag.AlignCenter, f"{int(intensity)}%")
|
|
880
|
-
|
|
881
|
-
# Axis Labels
|
|
882
|
-
painter.setPen(QColor("#000000"))
|
|
883
|
-
font.setBold(True)
|
|
884
|
-
painter.setFont(font)
|
|
885
|
-
painter.drawText(QRectF(0, h - 30, w, 20), Qt.AlignmentFlag.AlignCenter, "m/z")
|
|
886
|
-
|
|
887
|
-
# Draw Info Text (Top Right) - Drawn Last to be on top of Grid
|
|
888
|
-
if self.info_text:
|
|
889
|
-
painter.setPen(QColor("#333333"))
|
|
890
|
-
font = painter.font()
|
|
891
|
-
font.setPointSize(14)
|
|
892
|
-
font.setBold(True)
|
|
893
|
-
painter.setFont(font)
|
|
894
|
-
|
|
895
|
-
rect = QRectF(w - 250 - mr, mt, 250, 60)
|
|
896
|
-
# Optional: White background for text to strictly hide grid?
|
|
897
|
-
# User just said "change drawing order", implying text on top is enough.
|
|
898
|
-
# If grid is black and text is black, it might collide.
|
|
899
|
-
# But usually text on top is standard.
|
|
900
|
-
# Let's add a slight semi-transparent white bg if needed?
|
|
901
|
-
# No, user said "Revert", so let's go back to original simple text but drawn last.
|
|
902
|
-
painter.drawText(rect, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop, self.info_text)
|
|
903
|
-
|
|
904
|
-
save_state = painter.save()
|
|
905
|
-
painter.translate(20, h / 2)
|
|
906
|
-
painter.rotate(-90)
|
|
907
|
-
painter.drawText(QRectF(-100, -100, 200, 20), Qt.AlignmentFlag.AlignCenter, "Relative Intensity (%)")
|
|
908
|
-
painter.restore()
|
|
909
|
-
|
|
910
|
-
def run(mw):
|
|
911
|
-
if Chem is None:
|
|
912
|
-
QMessageBox.critical(mw, "MS Plugin", "RDKit is not available.")
|
|
913
|
-
return
|
|
914
|
-
|
|
915
|
-
# Allow launching without a molecule (start empty)
|
|
916
|
-
dialog = MSSpectrumDialog(mw.current_mol, mw)
|
|
917
|
-
dialog.exec()
|
|
918
|
-
|
|
919
|
-
# initialize removed as it only registered the analysis tool
|