MoleditPy 2.2.0__py3-none-any.whl → 2.2.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. moleditpy/modules/constants.py +1 -1
  2. moleditpy/modules/main_window_main_init.py +13 -31
  3. moleditpy/modules/main_window_ui_manager.py +2 -21
  4. moleditpy/modules/plugin_interface.py +10 -1
  5. moleditpy/modules/plugin_manager.py +3 -0
  6. moleditpy/plugins/Analysis/ms_spectrum_neo.py +919 -0
  7. moleditpy/plugins/File/animated_xyz_giffer.py +583 -0
  8. moleditpy/plugins/File/cube_viewer.py +689 -0
  9. moleditpy/plugins/File/gaussian_fchk_freq_analyzer.py +1148 -0
  10. moleditpy/plugins/File/mapped_cube_viewer.py +552 -0
  11. moleditpy/plugins/File/orca_out_freq_analyzer.py +1226 -0
  12. moleditpy/plugins/File/paste_xyz.py +336 -0
  13. moleditpy/plugins/Input Generator/gaussian_input_generator_neo.py +930 -0
  14. moleditpy/plugins/Input Generator/orca_input_generator_neo.py +1028 -0
  15. moleditpy/plugins/Input Generator/orca_xyz2inp_gui.py +286 -0
  16. moleditpy/plugins/Optimization/all-trans_optimizer.py +65 -0
  17. moleditpy/plugins/Optimization/complex_molecule_untangler.py +268 -0
  18. moleditpy/plugins/Optimization/conf_search.py +224 -0
  19. moleditpy/plugins/Utility/atom_colorizer.py +547 -0
  20. moleditpy/plugins/Utility/console.py +163 -0
  21. moleditpy/plugins/Utility/pubchem_ressolver.py +244 -0
  22. moleditpy/plugins/Utility/vdw_radii_overlay.py +303 -0
  23. {moleditpy-2.2.0.dist-info → moleditpy-2.2.0a1.dist-info}/METADATA +1 -1
  24. {moleditpy-2.2.0.dist-info → moleditpy-2.2.0a1.dist-info}/RECORD +28 -11
  25. {moleditpy-2.2.0.dist-info → moleditpy-2.2.0a1.dist-info}/WHEEL +0 -0
  26. {moleditpy-2.2.0.dist-info → moleditpy-2.2.0a1.dist-info}/entry_points.txt +0 -0
  27. {moleditpy-2.2.0.dist-info → moleditpy-2.2.0a1.dist-info}/licenses/LICENSE +0 -0
  28. {moleditpy-2.2.0.dist-info → moleditpy-2.2.0a1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,919 @@
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