pyTRACTnmr 0.1.1b1__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.
- pyTRACTnmr/__init__.py +1 -0
- pyTRACTnmr/main.py +15 -0
- pyTRACTnmr/processing.py +243 -0
- pyTRACTnmr/widgets.py +86 -0
- pyTRACTnmr/window.py +628 -0
- pytractnmr-0.1.1b1.dist-info/METADATA +15 -0
- pytractnmr-0.1.1b1.dist-info/RECORD +9 -0
- pytractnmr-0.1.1b1.dist-info/WHEEL +4 -0
- pytractnmr-0.1.1b1.dist-info/entry_points.txt +2 -0
pyTRACTnmr/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Init tract_gui package
|
pyTRACTnmr/main.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from PySide6.QtWidgets import QApplication
|
|
3
|
+
try:
|
|
4
|
+
from .window import TractApp
|
|
5
|
+
except ImportError:
|
|
6
|
+
from window import TractApp
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
app = QApplication(sys.argv)
|
|
10
|
+
window = TractApp()
|
|
11
|
+
window.show()
|
|
12
|
+
sys.exit(app.exec())
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
main()
|
pyTRACTnmr/processing.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import numpy as np
|
|
3
|
+
import nmrglue as ng # type: ignore
|
|
4
|
+
from scipy.optimize import curve_fit
|
|
5
|
+
from typing import Optional, Tuple, List, Dict
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
# Configure logging
|
|
9
|
+
logging.basicConfig(level=logging.INFO)
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TractBruker:
|
|
14
|
+
"""
|
|
15
|
+
Process Bruker TRACT NMR data for 15N relaxation analysis.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# Physical constants (CODATA 2018 values)
|
|
19
|
+
PLANCK = 6.62607015e-34
|
|
20
|
+
VACUUM_PERMEABILITY = 1.25663706212e-6
|
|
21
|
+
GAMMA_1H = 267.52218744e6
|
|
22
|
+
GAMMA_15N = -27.126e6
|
|
23
|
+
NH_BOND_LENGTH = 1.02e-10
|
|
24
|
+
CSA_15N = 160e-6
|
|
25
|
+
CSA_BOND_ANGLE = 17 * np.pi / 180
|
|
26
|
+
|
|
27
|
+
def __init__(self, exp_folder: str, delay_list: Optional[str] = None) -> None:
|
|
28
|
+
logger.info(f"Initializing TractBruker with folder: {exp_folder}")
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
self.attributes, self.fids = ng.bruker.read(exp_folder)
|
|
32
|
+
try:
|
|
33
|
+
self.phc0 = self.attributes["procs"]["PHC0"]
|
|
34
|
+
self.phc1 = self.attributes["procs"]["PHC1"]
|
|
35
|
+
except KeyError:
|
|
36
|
+
self.phc0 = 0.0
|
|
37
|
+
self.phc1 = 0.0
|
|
38
|
+
except Exception as e:
|
|
39
|
+
raise ValueError(f"Could not load Bruker data: {e}")
|
|
40
|
+
|
|
41
|
+
# Handle delays
|
|
42
|
+
if delay_list and os.path.exists(delay_list):
|
|
43
|
+
self.delays = self._read_delays(delay_list)
|
|
44
|
+
else:
|
|
45
|
+
# Try standard 'vdlist' in folder
|
|
46
|
+
vdlist_path = os.path.join(exp_folder, "vdlist")
|
|
47
|
+
if os.path.exists(vdlist_path):
|
|
48
|
+
self.delays = self._read_delays(vdlist_path)
|
|
49
|
+
else:
|
|
50
|
+
logger.warning("No delay list found. Using dummy delays.")
|
|
51
|
+
# Assuming interleaved alpha/beta, so 2 FIDs per delay point
|
|
52
|
+
n_delays = self.fids.shape[1] // 2
|
|
53
|
+
self.delays = np.linspace(0.01, 1.0, n_delays)
|
|
54
|
+
|
|
55
|
+
self.alpha_spectra: List[np.ndarray] = []
|
|
56
|
+
self.beta_spectra: List[np.ndarray] = []
|
|
57
|
+
# self.alpha_integrals: np.ndarray | None = None
|
|
58
|
+
# self.beta_integrals: np.ndarray | None = None
|
|
59
|
+
self.unit_converter = None
|
|
60
|
+
|
|
61
|
+
def _read_delays(self, file: str) -> np.ndarray:
|
|
62
|
+
with open(file, "r") as list_file:
|
|
63
|
+
delays = list_file.read()
|
|
64
|
+
delays = delays.replace("u", "e-6").replace("m", "e-3")
|
|
65
|
+
return np.array([float(x) for x in delays.splitlines() if x.strip()])
|
|
66
|
+
|
|
67
|
+
def process_first_trace(
|
|
68
|
+
self,
|
|
69
|
+
p0: float,
|
|
70
|
+
p1: float,
|
|
71
|
+
points: int = 2048,
|
|
72
|
+
off: float = 0.35,
|
|
73
|
+
end: float = 0.98,
|
|
74
|
+
pow: float = 2.0,
|
|
75
|
+
) -> np.ndarray:
|
|
76
|
+
"""Process first FID for interactive phase correction."""
|
|
77
|
+
fid = self.fids[0, 0]
|
|
78
|
+
# Apply apodization
|
|
79
|
+
data = ng.proc_base.sp(fid, off=off, end=end, pow=pow)
|
|
80
|
+
# Zero filling
|
|
81
|
+
data = ng.proc_base.zf_size(data, points)
|
|
82
|
+
# Fourier transform
|
|
83
|
+
data = ng.proc_base.fft(data)
|
|
84
|
+
# Remove digital filter
|
|
85
|
+
data = ng.bruker.remove_digital_filter(self.attributes, data, post_proc=True)
|
|
86
|
+
# Apply phase correction
|
|
87
|
+
data = ng.proc_base.ps(data, p0=p0, p1=p1)
|
|
88
|
+
# Discard imaginary part
|
|
89
|
+
data = ng.proc_base.di(data)
|
|
90
|
+
# Reverse spectrum
|
|
91
|
+
data = ng.proc_base.rev(data)
|
|
92
|
+
|
|
93
|
+
# Set up unit converter
|
|
94
|
+
udic = ng.bruker.guess_udic(self.attributes, data)
|
|
95
|
+
self.unit_converter = ng.fileiobase.uc_from_udic(udic)
|
|
96
|
+
return data
|
|
97
|
+
|
|
98
|
+
def split_process(
|
|
99
|
+
self,
|
|
100
|
+
p0: float,
|
|
101
|
+
p1: float,
|
|
102
|
+
points: int = 2048,
|
|
103
|
+
off: float = 0.35,
|
|
104
|
+
end: float = 0.98,
|
|
105
|
+
pow: float = 2.0,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Process all FIDs and split into alpha/beta."""
|
|
108
|
+
self.phc0 = p0
|
|
109
|
+
self.phc1 = p1
|
|
110
|
+
self.alpha_spectra = []
|
|
111
|
+
self.beta_spectra = []
|
|
112
|
+
|
|
113
|
+
for i in range(self.fids.shape[0]):
|
|
114
|
+
for j in range(self.fids[i].shape[0]):
|
|
115
|
+
data = self.fids[i][j]
|
|
116
|
+
data = ng.proc_base.sp(data, off=off, end=end, pow=pow)
|
|
117
|
+
data = ng.proc_base.zf_size(data, points)
|
|
118
|
+
data = ng.proc_base.fft(data)
|
|
119
|
+
data = ng.bruker.remove_digital_filter(
|
|
120
|
+
self.attributes, data, post_proc=True
|
|
121
|
+
)
|
|
122
|
+
data = ng.proc_base.ps(data, p0=p0, p1=p1)
|
|
123
|
+
data = ng.proc_base.di(data)
|
|
124
|
+
data = ng.proc_bl.baseline_corrector(data)
|
|
125
|
+
data = ng.proc_base.rev(data)
|
|
126
|
+
|
|
127
|
+
if j % 2 == 0:
|
|
128
|
+
self.beta_spectra.append(data)
|
|
129
|
+
else:
|
|
130
|
+
self.alpha_spectra.append(data)
|
|
131
|
+
|
|
132
|
+
# Unit converter from first spectrum
|
|
133
|
+
if self.beta_spectra:
|
|
134
|
+
udic = ng.bruker.guess_udic(self.attributes, self.beta_spectra[0])
|
|
135
|
+
self.unit_converter = ng.fileiobase.uc_from_udic(udic)
|
|
136
|
+
|
|
137
|
+
def integrate_indices(self, start_idx: int, end_idx: int) -> None:
|
|
138
|
+
"""Integrate using point indices."""
|
|
139
|
+
if not self.alpha_spectra or not self.beta_spectra:
|
|
140
|
+
raise RuntimeError("No spectra available. Run split_process() first.")
|
|
141
|
+
|
|
142
|
+
self.alpha_integrals: np.ndarray = np.array(
|
|
143
|
+
[s[start_idx:end_idx].sum() for s in self.alpha_spectra]
|
|
144
|
+
)
|
|
145
|
+
self.beta_integrals: np.ndarray = np.array(
|
|
146
|
+
[s[start_idx:end_idx].sum() for s in self.beta_spectra]
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def integrate_ppm(self, start_ppm: float, end_ppm: float) -> None:
|
|
150
|
+
"""Integrate using ppm range."""
|
|
151
|
+
if self.unit_converter is None:
|
|
152
|
+
raise RuntimeError("Unit converter not initialized.")
|
|
153
|
+
|
|
154
|
+
idx1 = self.unit_converter(start_ppm, "ppm")
|
|
155
|
+
idx2 = self.unit_converter(end_ppm, "ppm")
|
|
156
|
+
|
|
157
|
+
start = int(min(idx1, idx2))
|
|
158
|
+
end = int(max(idx1, idx2))
|
|
159
|
+
self.integrate_indices(start, end)
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _relax(x, a, r):
|
|
163
|
+
return a * np.exp(-r * x)
|
|
164
|
+
|
|
165
|
+
def calc_relaxation(self) -> None:
|
|
166
|
+
if self.alpha_integrals is None or self.beta_integrals is None:
|
|
167
|
+
raise RuntimeError("Must call integrate() before calc_relaxation()")
|
|
168
|
+
|
|
169
|
+
# Truncate delays if mismatch
|
|
170
|
+
n_pts = min(len(self.alpha_integrals), len(self.delays))
|
|
171
|
+
delays: np.ndarray = self.delays[:n_pts]
|
|
172
|
+
alpha_ints = self.alpha_integrals[:n_pts]
|
|
173
|
+
beta_ints = self.beta_integrals[:n_pts]
|
|
174
|
+
|
|
175
|
+
# Normalize
|
|
176
|
+
alpha_norm = alpha_ints / alpha_ints[0]
|
|
177
|
+
beta_norm = beta_ints / beta_ints[0]
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
self.popt_alpha, self.pcov_alpha = curve_fit(
|
|
181
|
+
self._relax, delays, alpha_norm, p0=[1.0, 5.0], maxfev=5000
|
|
182
|
+
)
|
|
183
|
+
self.popt_beta, self.pcov_beta = curve_fit(
|
|
184
|
+
self._relax, delays, beta_norm, p0=[1.0, 5.0], maxfev=5000
|
|
185
|
+
)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
raise RuntimeError(f"Fitting failed: {e}")
|
|
188
|
+
|
|
189
|
+
self.Ra: float = self.popt_alpha[1]
|
|
190
|
+
self.Rb: float = self.popt_beta[1]
|
|
191
|
+
self.err_Ra: float = np.sqrt(np.diag(self.pcov_alpha))[1]
|
|
192
|
+
self.err_Rb: float = np.sqrt(np.diag(self.pcov_beta))[1]
|
|
193
|
+
|
|
194
|
+
def _tc_equation(self, w_N: float, c: float, S2: float = 1.0) -> float:
|
|
195
|
+
t1 = (5 * c) / (24 * S2)
|
|
196
|
+
A = 336 * (S2**2) * (w_N**2)
|
|
197
|
+
B = 25 * (c**2) * (w_N**4)
|
|
198
|
+
C = 125 * (c**3) * (w_N**6)
|
|
199
|
+
D = 625 * (S2**2) * (c**4) * (w_N**10)
|
|
200
|
+
E = 3025 * (S2**4) * (c**2) * (w_N**8)
|
|
201
|
+
F = 21952 * (S2**6) * (w_N**6)
|
|
202
|
+
G = 1800 * c * (w_N**4)
|
|
203
|
+
term_sqrt = np.sqrt(D - E + F)
|
|
204
|
+
term_cbrt = (C + 24 * np.sqrt(3) * term_sqrt + G * S2**2) ** (1 / 3)
|
|
205
|
+
t2 = (A - B) / (24 * (w_N**2) * S2 * term_cbrt)
|
|
206
|
+
t3 = term_cbrt / (24 * S2 * w_N**2)
|
|
207
|
+
return t1 - t2 + t3
|
|
208
|
+
|
|
209
|
+
def calc_tc(
|
|
210
|
+
self, B0: Optional[float] = None, S2: float = 1.0, n_bootstrap: int = 1000
|
|
211
|
+
) -> None:
|
|
212
|
+
if not hasattr(self, "Ra"):
|
|
213
|
+
self.calc_relaxation()
|
|
214
|
+
if B0 is None:
|
|
215
|
+
B0 = self.attributes["acqus"]["SFO1"]
|
|
216
|
+
B_0 = B0 * 1e6 * 2 * np.pi / self.GAMMA_1H
|
|
217
|
+
p = (
|
|
218
|
+
self.VACUUM_PERMEABILITY * self.GAMMA_1H * self.GAMMA_15N * self.PLANCK
|
|
219
|
+
) / (16 * np.pi**2 * np.sqrt(2) * self.NH_BOND_LENGTH**3)
|
|
220
|
+
dN = self.GAMMA_15N * B_0 * self.CSA_15N / (3 * np.sqrt(2))
|
|
221
|
+
w_N = B_0 * self.GAMMA_15N
|
|
222
|
+
Ra_samples: np.ndarray = np.random.normal(self.Ra, self.err_Ra, n_bootstrap)
|
|
223
|
+
Rb_samples: np.ndarray = np.random.normal(self.Rb, self.err_Rb, n_bootstrap)
|
|
224
|
+
c_samples = (Rb_samples - Ra_samples) / (
|
|
225
|
+
2 * dN * p * (3 * np.cos(self.CSA_BOND_ANGLE) ** 2 - 1)
|
|
226
|
+
)
|
|
227
|
+
tau_samples: np.ndarray = (
|
|
228
|
+
np.array(
|
|
229
|
+
[self._tc_equation(w_N, c, S2) for c in c_samples if not np.isnan(c)]
|
|
230
|
+
)
|
|
231
|
+
* 1e9
|
|
232
|
+
)
|
|
233
|
+
self.tau_c = np.mean(tau_samples)
|
|
234
|
+
self.err_tau_c = np.std(tau_samples)
|
|
235
|
+
|
|
236
|
+
def get_fit_data(
|
|
237
|
+
self,
|
|
238
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
239
|
+
n_pts = min(len(self.alpha_integrals), len(self.delays))
|
|
240
|
+
x = self.delays[:n_pts]
|
|
241
|
+
y_a = self.alpha_integrals[:n_pts] / self.alpha_integrals[0]
|
|
242
|
+
y_b = self.beta_integrals[:n_pts] / self.beta_integrals[0]
|
|
243
|
+
return x, y_a, y_b, self.popt_alpha, self.popt_beta
|
pyTRACTnmr/widgets.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from PySide6.QtWidgets import (
|
|
2
|
+
QFontDialog,
|
|
3
|
+
QInputDialog,
|
|
4
|
+
QFileDialog,
|
|
5
|
+
QMessageBox,
|
|
6
|
+
QWidget,
|
|
7
|
+
)
|
|
8
|
+
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
|
|
9
|
+
import matplotlib.pyplot as plt
|
|
10
|
+
from matplotlib.figure import Figure
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CustomNavigationToolbar(NavigationToolbar2QT):
|
|
15
|
+
def __init__(
|
|
16
|
+
self, canvas: FigureCanvasQTAgg, parent: QWidget, coordinates: bool = True
|
|
17
|
+
) -> None:
|
|
18
|
+
super().__init__(canvas, parent, coordinates)
|
|
19
|
+
self.addSeparator()
|
|
20
|
+
self.addAction("Font", self.change_font)
|
|
21
|
+
self.addAction("Export", self.export_figure)
|
|
22
|
+
|
|
23
|
+
def export_figure(self) -> None:
|
|
24
|
+
dpi, ok = QInputDialog.getInt(
|
|
25
|
+
self, "Export Settings", "DPI:", value=300, minValue=72, maxValue=1200
|
|
26
|
+
)
|
|
27
|
+
if not ok:
|
|
28
|
+
return
|
|
29
|
+
fname, _ = QFileDialog.getSaveFileName(
|
|
30
|
+
self, "Save Figure", "", "PNG (*.png);;PDF (*.pdf);;SVG (*.svg)"
|
|
31
|
+
)
|
|
32
|
+
if fname:
|
|
33
|
+
try:
|
|
34
|
+
self.canvas.figure.savefig(fname, dpi=dpi, bbox_inches="tight")
|
|
35
|
+
except Exception as e:
|
|
36
|
+
QMessageBox.critical(self, "Error", f"Could not save figure: {e}")
|
|
37
|
+
|
|
38
|
+
def change_font(self) -> None:
|
|
39
|
+
ok, font = QFontDialog.getFont(self)
|
|
40
|
+
if ok:
|
|
41
|
+
size = font.pointSize()
|
|
42
|
+
family = font.family()
|
|
43
|
+
|
|
44
|
+
# Update rcParams for future plots
|
|
45
|
+
plt.rcParams.update(
|
|
46
|
+
{
|
|
47
|
+
"font.size": size,
|
|
48
|
+
"font.family": family,
|
|
49
|
+
"axes.labelsize": size,
|
|
50
|
+
"axes.titlesize": size + 2,
|
|
51
|
+
"xtick.labelsize": size,
|
|
52
|
+
"ytick.labelsize": size,
|
|
53
|
+
"legend.fontsize": size,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Update current figure elements
|
|
58
|
+
for ax in self.canvas.figure.axes:
|
|
59
|
+
for item in (
|
|
60
|
+
[ax.title, ax.xaxis.label, ax.yaxis.label]
|
|
61
|
+
+ ax.get_xticklabels()
|
|
62
|
+
+ ax.get_yticklabels()
|
|
63
|
+
):
|
|
64
|
+
item.set_fontsize(size)
|
|
65
|
+
item.set_fontfamily(family)
|
|
66
|
+
|
|
67
|
+
legend = ax.get_legend()
|
|
68
|
+
if legend:
|
|
69
|
+
for text in legend.get_texts():
|
|
70
|
+
text.set_fontsize(size)
|
|
71
|
+
text.set_fontfamily(family)
|
|
72
|
+
|
|
73
|
+
self.canvas.draw()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class MplCanvas(FigureCanvasQTAgg):
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
parent: Optional[QWidget] = None,
|
|
80
|
+
width: float = 5,
|
|
81
|
+
height: float = 4,
|
|
82
|
+
dpi: int = 100,
|
|
83
|
+
) -> None:
|
|
84
|
+
self.fig = Figure(figsize=(width, height), dpi=dpi)
|
|
85
|
+
self.axes = self.fig.add_subplot(111)
|
|
86
|
+
super().__init__(self.fig)
|
pyTRACTnmr/window.py
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import csv
|
|
3
|
+
import numpy as np
|
|
4
|
+
from typing import List, Dict, Any, Optional
|
|
5
|
+
from PySide6.QtWidgets import (
|
|
6
|
+
QMainWindow,
|
|
7
|
+
QWidget,
|
|
8
|
+
QVBoxLayout,
|
|
9
|
+
QHBoxLayout,
|
|
10
|
+
QPushButton,
|
|
11
|
+
QLabel,
|
|
12
|
+
QLineEdit,
|
|
13
|
+
QFileDialog,
|
|
14
|
+
QSplitter,
|
|
15
|
+
QTabWidget,
|
|
16
|
+
QFormLayout,
|
|
17
|
+
QGroupBox,
|
|
18
|
+
QMessageBox,
|
|
19
|
+
QTableWidget,
|
|
20
|
+
QTableWidgetItem,
|
|
21
|
+
QHeaderView,
|
|
22
|
+
QMenu,
|
|
23
|
+
QSlider,
|
|
24
|
+
)
|
|
25
|
+
from PySide6.QtGui import QAction
|
|
26
|
+
from PySide6.QtCore import Qt, QPoint
|
|
27
|
+
|
|
28
|
+
from matplotlib.widgets import SpanSelector
|
|
29
|
+
try:
|
|
30
|
+
from .widgets import MplCanvas, CustomNavigationToolbar
|
|
31
|
+
from . import processing
|
|
32
|
+
except ImportError:
|
|
33
|
+
from widgets import MplCanvas, CustomNavigationToolbar
|
|
34
|
+
import processing
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TractApp(QMainWindow):
|
|
38
|
+
def __init__(self):
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.setWindowTitle("TRACT Analysis GUI")
|
|
41
|
+
self.resize(1200, 800)
|
|
42
|
+
|
|
43
|
+
# Data State
|
|
44
|
+
self.dic = None
|
|
45
|
+
self.data = None
|
|
46
|
+
self.proc_data = None
|
|
47
|
+
self.time_points = None
|
|
48
|
+
self.datasets: List[Dict[str, Any]] = []
|
|
49
|
+
self.current_idx: int = -1
|
|
50
|
+
self.selector: Optional[SpanSelector] = None
|
|
51
|
+
|
|
52
|
+
self.init_ui()
|
|
53
|
+
|
|
54
|
+
def init_ui(self) -> None:
|
|
55
|
+
main_widget = QWidget()
|
|
56
|
+
self.setCentralWidget(main_widget)
|
|
57
|
+
main_layout = QHBoxLayout(main_widget)
|
|
58
|
+
|
|
59
|
+
# --- Panel 1: Data Loading ---
|
|
60
|
+
panel1 = QGroupBox("Experiment Info")
|
|
61
|
+
layout1 = QVBoxLayout()
|
|
62
|
+
|
|
63
|
+
self.btn_load = QPushButton("Load Bruker Directory")
|
|
64
|
+
self.btn_load.clicked.connect(self.load_data)
|
|
65
|
+
|
|
66
|
+
self.current_experiment = QLineEdit()
|
|
67
|
+
self.current_experiment.setPlaceholderText("Current Experiment")
|
|
68
|
+
self.current_experiment.setReadOnly(True)
|
|
69
|
+
|
|
70
|
+
self.table_data = QTableWidget()
|
|
71
|
+
self.table_data.setColumnCount(9)
|
|
72
|
+
self.table_data.setHorizontalHeaderLabels(
|
|
73
|
+
["Experiment", "Temperature (K)", "Delays", "Ra (Hz)", "Rb (Hz)", "Tau_C (ns)", "Err Ra", "Err Rb", "Err Tau_C"]
|
|
74
|
+
)
|
|
75
|
+
self.table_data.horizontalHeader().setSectionResizeMode(
|
|
76
|
+
QHeaderView.ResizeMode.Stretch
|
|
77
|
+
)
|
|
78
|
+
self.table_data.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
79
|
+
self.table_data.setEditTriggers(
|
|
80
|
+
QTableWidget.EditTrigger.DoubleClicked
|
|
81
|
+
| QTableWidget.EditTrigger.EditKeyPressed
|
|
82
|
+
)
|
|
83
|
+
self.table_data.cellDoubleClicked.connect(self.on_table_double_click)
|
|
84
|
+
self.table_data.itemChanged.connect(self.on_table_item_changed)
|
|
85
|
+
self.table_data.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
86
|
+
self.table_data.customContextMenuRequested.connect(self.show_context_menu)
|
|
87
|
+
|
|
88
|
+
layout1.addWidget(QLabel("Load Data:"))
|
|
89
|
+
layout1.addWidget(self.btn_load)
|
|
90
|
+
layout1.addSpacing(10)
|
|
91
|
+
layout1.addWidget(QLabel("Current Experiment:"))
|
|
92
|
+
layout1.addWidget(self.current_experiment)
|
|
93
|
+
layout1.addSpacing(10)
|
|
94
|
+
layout1.addWidget(self.table_data)
|
|
95
|
+
layout1.addStretch()
|
|
96
|
+
panel1.setLayout(layout1)
|
|
97
|
+
|
|
98
|
+
# --- Panel 2: Visualization ---
|
|
99
|
+
splitter_center = QSplitter(Qt.Orientation.Vertical)
|
|
100
|
+
|
|
101
|
+
# Top: Spectrum
|
|
102
|
+
self.canvas_spec = MplCanvas(self)
|
|
103
|
+
self.toolbar_spec = CustomNavigationToolbar(self.canvas_spec, self)
|
|
104
|
+
widget_spec = QWidget()
|
|
105
|
+
layout_spec = QVBoxLayout()
|
|
106
|
+
lbl_spec = QLabel("<b>Processed Spectrum (Phase Check)</b>")
|
|
107
|
+
lbl_spec.setFixedHeight(30)
|
|
108
|
+
layout_spec.addWidget(lbl_spec)
|
|
109
|
+
layout_spec.addWidget(self.toolbar_spec)
|
|
110
|
+
layout_spec.addWidget(self.canvas_spec)
|
|
111
|
+
widget_spec.setLayout(layout_spec)
|
|
112
|
+
|
|
113
|
+
# Bottom: Fits
|
|
114
|
+
self.canvas_fit = MplCanvas(self)
|
|
115
|
+
self.toolbar_fit = CustomNavigationToolbar(self.canvas_fit, self)
|
|
116
|
+
widget_fit = QWidget()
|
|
117
|
+
layout_fit = QVBoxLayout()
|
|
118
|
+
lbl_fit = QLabel("<b>Relaxation Fits</b>")
|
|
119
|
+
lbl_fit.setFixedHeight(30)
|
|
120
|
+
layout_fit.addWidget(lbl_fit)
|
|
121
|
+
layout_fit.addWidget(self.toolbar_fit)
|
|
122
|
+
layout_fit.addWidget(self.canvas_fit)
|
|
123
|
+
widget_fit.setLayout(layout_fit)
|
|
124
|
+
|
|
125
|
+
splitter_center.addWidget(widget_spec)
|
|
126
|
+
splitter_center.addWidget(widget_fit)
|
|
127
|
+
|
|
128
|
+
# --- Panel 3: Controls ---
|
|
129
|
+
panel3 = QTabWidget()
|
|
130
|
+
|
|
131
|
+
# Tab 1: Processing
|
|
132
|
+
tab1 = QWidget()
|
|
133
|
+
layout_t1 = QFormLayout()
|
|
134
|
+
|
|
135
|
+
self.slider_p0_coarse = QSlider(Qt.Orientation.Horizontal)
|
|
136
|
+
self.slider_p0_coarse.setRange(-180, 180)
|
|
137
|
+
self.slider_p0_coarse.setValue(0)
|
|
138
|
+
self.slider_p0_coarse.valueChanged.connect(self.process_data)
|
|
139
|
+
|
|
140
|
+
self.slider_p0_fine = QSlider(Qt.Orientation.Horizontal)
|
|
141
|
+
self.slider_p0_fine.setRange(-50, 50)
|
|
142
|
+
self.slider_p0_fine.setValue(0)
|
|
143
|
+
self.slider_p0_fine.valueChanged.connect(self.process_data)
|
|
144
|
+
self.input_p0 = QLineEdit("0.0")
|
|
145
|
+
self.input_p0.setFixedWidth(50)
|
|
146
|
+
self.input_p0.editingFinished.connect(self.update_phase_from_text)
|
|
147
|
+
|
|
148
|
+
self.slider_p1_coarse = QSlider(Qt.Orientation.Horizontal)
|
|
149
|
+
self.slider_p1_coarse.setRange(-360, 360)
|
|
150
|
+
self.slider_p1_coarse.setValue(0)
|
|
151
|
+
self.slider_p1_coarse.valueChanged.connect(self.process_data)
|
|
152
|
+
|
|
153
|
+
self.slider_p1_fine = QSlider(Qt.Orientation.Horizontal)
|
|
154
|
+
self.slider_p1_fine.setRange(-50, 50)
|
|
155
|
+
self.slider_p1_fine.setValue(0)
|
|
156
|
+
self.slider_p1_fine.valueChanged.connect(self.process_data)
|
|
157
|
+
self.input_p1 = QLineEdit("0.0")
|
|
158
|
+
self.input_p1.setFixedWidth(50)
|
|
159
|
+
self.input_p1.editingFinished.connect(self.update_phase_from_text)
|
|
160
|
+
|
|
161
|
+
self.input_points = QLineEdit("2048")
|
|
162
|
+
self.input_points.editingFinished.connect(self.process_data)
|
|
163
|
+
|
|
164
|
+
self.input_off = QLineEdit("0.35")
|
|
165
|
+
self.input_off.editingFinished.connect(self.process_data)
|
|
166
|
+
|
|
167
|
+
self.input_end = QLineEdit("0.98")
|
|
168
|
+
self.input_end.editingFinished.connect(self.process_data)
|
|
169
|
+
|
|
170
|
+
self.input_pow = QLineEdit("2.0")
|
|
171
|
+
self.input_pow.editingFinished.connect(self.process_data)
|
|
172
|
+
|
|
173
|
+
self.input_int_start = QLineEdit("9.5")
|
|
174
|
+
self.input_int_start.editingFinished.connect(self.process_data)
|
|
175
|
+
self.input_int_end = QLineEdit("7.5")
|
|
176
|
+
self.input_int_end.editingFinished.connect(self.process_data)
|
|
177
|
+
|
|
178
|
+
layout_t1.addRow("P0 Coarse:", self.slider_p0_coarse)
|
|
179
|
+
layout_t1.addRow(
|
|
180
|
+
"P0 Fine (+/- 5):",
|
|
181
|
+
self.create_slider_layout(self.slider_p0_fine, self.input_p0),
|
|
182
|
+
)
|
|
183
|
+
layout_t1.addRow("P1 Coarse:", self.slider_p1_coarse)
|
|
184
|
+
layout_t1.addRow(
|
|
185
|
+
"P1 Fine (+/- 5):",
|
|
186
|
+
self.create_slider_layout(self.slider_p1_fine, self.input_p1),
|
|
187
|
+
)
|
|
188
|
+
layout_t1.addRow(QLabel("<b>Apodization & ZF</b>"))
|
|
189
|
+
layout_t1.addRow("Points (ZF):", self.input_points)
|
|
190
|
+
layout_t1.addRow("Sine Offset:", self.input_off)
|
|
191
|
+
layout_t1.addRow("Sine End:", self.input_end)
|
|
192
|
+
layout_t1.addRow("Sine Power:", self.input_pow)
|
|
193
|
+
layout_t1.addRow(QLabel("<b>Integration Range</b>"))
|
|
194
|
+
layout_t1.addRow("Start (ppm):", self.input_int_start)
|
|
195
|
+
layout_t1.addRow("End (ppm):", self.input_int_end)
|
|
196
|
+
tab1.setLayout(layout_t1)
|
|
197
|
+
|
|
198
|
+
# Tab 2: Fitting
|
|
199
|
+
tab2 = QWidget()
|
|
200
|
+
layout_t2 = QFormLayout()
|
|
201
|
+
self.input_field = QLineEdit("600")
|
|
202
|
+
self.input_csa = QLineEdit("160")
|
|
203
|
+
self.input_angle = QLineEdit("17")
|
|
204
|
+
self.input_s2 = QLineEdit("1.0")
|
|
205
|
+
self.input_bootstraps = QLineEdit("1000")
|
|
206
|
+
self.btn_fit = QPushButton("Calculate Tau_c")
|
|
207
|
+
self.btn_fit.clicked.connect(self.run_fitting)
|
|
208
|
+
self.lbl_results = QLabel("Results will appear here.")
|
|
209
|
+
self.lbl_results.setWordWrap(True)
|
|
210
|
+
|
|
211
|
+
layout_t2.addRow("Field Strength (MHz):", self.input_field)
|
|
212
|
+
layout_t2.addRow("CSA (ppm):", self.input_csa)
|
|
213
|
+
layout_t2.addRow("CSA Angle (deg):", self.input_angle)
|
|
214
|
+
layout_t2.addRow("Order Parameter (S2):", self.input_s2)
|
|
215
|
+
layout_t2.addRow("Bootstraps:", self.input_bootstraps)
|
|
216
|
+
layout_t2.addRow(self.btn_fit)
|
|
217
|
+
layout_t2.addRow(self.lbl_results)
|
|
218
|
+
tab2.setLayout(layout_t2)
|
|
219
|
+
|
|
220
|
+
panel3.addTab(tab1, "Processing")
|
|
221
|
+
panel3.addTab(tab2, "Fitting")
|
|
222
|
+
|
|
223
|
+
main_splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
224
|
+
main_splitter.addWidget(panel1)
|
|
225
|
+
main_splitter.addWidget(splitter_center)
|
|
226
|
+
main_splitter.addWidget(panel3)
|
|
227
|
+
main_splitter.setSizes([400, 500, 300])
|
|
228
|
+
main_layout.addWidget(main_splitter)
|
|
229
|
+
|
|
230
|
+
def create_slider_layout(self, slider: QSlider, label: QWidget) -> QWidget:
|
|
231
|
+
widget = QWidget()
|
|
232
|
+
layout = QHBoxLayout(widget)
|
|
233
|
+
layout.addWidget(slider)
|
|
234
|
+
layout.addWidget(label)
|
|
235
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
236
|
+
return widget
|
|
237
|
+
|
|
238
|
+
def update_phase_from_text(self) -> None:
|
|
239
|
+
try:
|
|
240
|
+
p0 = float(self.input_p0.text())
|
|
241
|
+
p1 = float(self.input_p1.text())
|
|
242
|
+
|
|
243
|
+
self.slider_p0_coarse.blockSignals(True)
|
|
244
|
+
self.slider_p0_fine.blockSignals(True)
|
|
245
|
+
self.slider_p1_coarse.blockSignals(True)
|
|
246
|
+
self.slider_p1_fine.blockSignals(True)
|
|
247
|
+
|
|
248
|
+
self.slider_p0_coarse.setValue(int(p0))
|
|
249
|
+
self.slider_p0_fine.setValue(int(round((p0 - int(p0)) * 10)))
|
|
250
|
+
|
|
251
|
+
self.slider_p1_coarse.setValue(int(p1))
|
|
252
|
+
self.slider_p1_fine.setValue(int(round((p1 - int(p1)) * 10)))
|
|
253
|
+
|
|
254
|
+
self.slider_p0_coarse.blockSignals(False)
|
|
255
|
+
self.slider_p0_fine.blockSignals(False)
|
|
256
|
+
self.slider_p1_coarse.blockSignals(False)
|
|
257
|
+
self.slider_p1_fine.blockSignals(False)
|
|
258
|
+
|
|
259
|
+
self.process_data()
|
|
260
|
+
except ValueError:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
def load_data(self) -> None:
|
|
264
|
+
folder = QFileDialog.getExistingDirectory(self, "Select Bruker Directory")
|
|
265
|
+
if folder:
|
|
266
|
+
try:
|
|
267
|
+
delay_list = None
|
|
268
|
+
if not os.path.exists(os.path.join(folder, "vdlist")):
|
|
269
|
+
delay_list, _ = QFileDialog.getOpenFileName(
|
|
270
|
+
self, "vdlist not found. Select delay list file:", folder
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
tb = processing.TractBruker(folder, delay_list=delay_list)
|
|
274
|
+
name = os.path.basename(folder)
|
|
275
|
+
dataset = {
|
|
276
|
+
"name": name,
|
|
277
|
+
"path": folder,
|
|
278
|
+
"handler": tb,
|
|
279
|
+
"p0": tb.phc0,
|
|
280
|
+
"p1": tb.phc1,
|
|
281
|
+
}
|
|
282
|
+
self.datasets.append(dataset)
|
|
283
|
+
self.update_table()
|
|
284
|
+
self.switch_dataset(len(self.datasets) - 1)
|
|
285
|
+
except Exception as e:
|
|
286
|
+
QMessageBox.critical(self, "Error", f"Failed to load data: {str(e)}")
|
|
287
|
+
|
|
288
|
+
def process_data(self) -> None:
|
|
289
|
+
if self.current_idx < 0:
|
|
290
|
+
return
|
|
291
|
+
try:
|
|
292
|
+
p0 = self.slider_p0_coarse.value() + (self.slider_p0_fine.value() / 10.0)
|
|
293
|
+
p1 = self.slider_p1_coarse.value() + (self.slider_p1_fine.value() / 10.0)
|
|
294
|
+
|
|
295
|
+
self.input_p0.setText(f"{p0:.1f}")
|
|
296
|
+
self.input_p1.setText(f"{p1:.1f}")
|
|
297
|
+
|
|
298
|
+
points = int(self.input_points.text())
|
|
299
|
+
off = float(self.input_off.text())
|
|
300
|
+
end = float(self.input_end.text())
|
|
301
|
+
pow_val = float(self.input_pow.text())
|
|
302
|
+
|
|
303
|
+
if self.current_idx >= 0:
|
|
304
|
+
self.datasets[self.current_idx]["p0"] = p0
|
|
305
|
+
self.datasets[self.current_idx]["p1"] = p1
|
|
306
|
+
|
|
307
|
+
tb = self.datasets[self.current_idx]["handler"]
|
|
308
|
+
trace = tb.process_first_trace(
|
|
309
|
+
p0, p1, points=points, off=off, end=end, pow=pow_val
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
self.canvas_spec.axes.clear()
|
|
313
|
+
if tb.unit_converter:
|
|
314
|
+
ppm_scale = tb.unit_converter.ppm_scale()
|
|
315
|
+
self.canvas_spec.axes.plot(ppm_scale, trace, label="First Plane")
|
|
316
|
+
self.canvas_spec.axes.invert_xaxis()
|
|
317
|
+
self.canvas_spec.axes.set_xlabel(r"$^{1}H (ppm)$")
|
|
318
|
+
self.canvas_spec.axes.set_ylabel("Intensity")
|
|
319
|
+
else:
|
|
320
|
+
self.canvas_spec.axes.plot(trace, label="First Plane")
|
|
321
|
+
self.canvas_spec.axes.legend()
|
|
322
|
+
|
|
323
|
+
self.selector = SpanSelector(
|
|
324
|
+
self.canvas_spec.axes,
|
|
325
|
+
self.on_span_select,
|
|
326
|
+
"horizontal",
|
|
327
|
+
useblit=True,
|
|
328
|
+
props=dict(alpha=0.2, facecolor="green"),
|
|
329
|
+
interactive=True,
|
|
330
|
+
drag_from_anywhere=True,
|
|
331
|
+
)
|
|
332
|
+
try:
|
|
333
|
+
s = float(self.input_int_start.text())
|
|
334
|
+
e = float(self.input_int_end.text())
|
|
335
|
+
self.selector.extents = (min(s, e), max(s, e))
|
|
336
|
+
except ValueError:
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
self.canvas_spec.draw()
|
|
340
|
+
except Exception as e:
|
|
341
|
+
QMessageBox.critical(self, "Processing Error", str(e))
|
|
342
|
+
|
|
343
|
+
def run_fitting(self) -> None:
|
|
344
|
+
if self.current_idx < 0:
|
|
345
|
+
return
|
|
346
|
+
try:
|
|
347
|
+
tb = self.datasets[self.current_idx]["handler"]
|
|
348
|
+
p0 = self.slider_p0_coarse.value() + (self.slider_p0_fine.value() / 10.0)
|
|
349
|
+
p1 = self.slider_p1_coarse.value() + (self.slider_p1_fine.value() / 10.0)
|
|
350
|
+
|
|
351
|
+
points = int(self.input_points.text())
|
|
352
|
+
off = float(self.input_off.text())
|
|
353
|
+
end_param = float(self.input_end.text())
|
|
354
|
+
pow_val = float(self.input_pow.text())
|
|
355
|
+
start_ppm = float(self.input_int_start.text())
|
|
356
|
+
end_ppm = float(self.input_int_end.text())
|
|
357
|
+
|
|
358
|
+
# Update physics constants
|
|
359
|
+
tb.CSA_15N = float(self.input_csa.text()) * 1e-6
|
|
360
|
+
tb.CSA_BOND_ANGLE = float(self.input_angle.text()) * np.pi / 180
|
|
361
|
+
s2_val = float(self.input_s2.text())
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
n_boot = int(self.input_bootstraps.text())
|
|
365
|
+
if n_boot < 10:
|
|
366
|
+
n_boot = 10
|
|
367
|
+
self.input_bootstraps.setText("10")
|
|
368
|
+
except ValueError:
|
|
369
|
+
n_boot = 1000
|
|
370
|
+
self.input_bootstraps.setText("1000")
|
|
371
|
+
|
|
372
|
+
tb.split_process(p0, p1, points=points, off=off, end=end_param, pow=pow_val)
|
|
373
|
+
tb.integrate_ppm(start_ppm, end_ppm)
|
|
374
|
+
tb.calc_relaxation()
|
|
375
|
+
|
|
376
|
+
b0 = float(self.input_field.text()) if self.input_field.text() else None
|
|
377
|
+
tb.calc_tc(B0=b0, S2=s2_val, n_bootstrap=n_boot)
|
|
378
|
+
|
|
379
|
+
x, y_a, y_b, popt_a, popt_b = tb.get_fit_data()
|
|
380
|
+
|
|
381
|
+
self.canvas_fit.axes.clear()
|
|
382
|
+
self.canvas_fit.axes.plot(x, y_a, "bo", label=r"$\alpha -spin\ state$")
|
|
383
|
+
self.canvas_fit.axes.plot(x, y_b, "ro", label=r"$\beta -spin\ state$")
|
|
384
|
+
self.canvas_fit.axes.plot(
|
|
385
|
+
x, processing.TractBruker._relax(x, *popt_a), "b-"
|
|
386
|
+
)
|
|
387
|
+
self.canvas_fit.axes.plot(
|
|
388
|
+
x, processing.TractBruker._relax(x, *popt_b), "r-"
|
|
389
|
+
)
|
|
390
|
+
self.canvas_fit.axes.set_xlabel("Delay (s)")
|
|
391
|
+
self.canvas_fit.axes.set_ylabel(r"$I/I_0$")
|
|
392
|
+
|
|
393
|
+
res_text = (
|
|
394
|
+
f"Ra: {tb.Ra:.2f} +/- {tb.err_Ra:.2f} Hz\n"
|
|
395
|
+
f"Rb: {tb.Rb:.2f} +/- {tb.err_Rb:.2f} Hz\n"
|
|
396
|
+
f"Tau_c: {tb.tau_c:.2f} +/- {tb.err_tau_c:.2f} ns"
|
|
397
|
+
)
|
|
398
|
+
self.lbl_results.setText(res_text)
|
|
399
|
+
|
|
400
|
+
self.canvas_fit.axes.legend()
|
|
401
|
+
self.canvas_fit.draw()
|
|
402
|
+
|
|
403
|
+
self.update_table()
|
|
404
|
+
except Exception as e:
|
|
405
|
+
QMessageBox.critical(self, "Fit Error", str(e))
|
|
406
|
+
|
|
407
|
+
def export_table_to_csv(self) -> None:
|
|
408
|
+
path, _ = QFileDialog.getSaveFileName(self, "Save CSV", "", "CSV Files (*.csv)")
|
|
409
|
+
if not path:
|
|
410
|
+
return
|
|
411
|
+
try:
|
|
412
|
+
with open(path, 'w', newline='') as f:
|
|
413
|
+
writer = csv.writer(f)
|
|
414
|
+
headers = []
|
|
415
|
+
for col in range(self.table_data.columnCount()):
|
|
416
|
+
item = self.table_data.horizontalHeaderItem(col)
|
|
417
|
+
headers.append(item.text() if item else "")
|
|
418
|
+
writer.writerow(headers)
|
|
419
|
+
for row in range(self.table_data.rowCount()):
|
|
420
|
+
row_data = [self.table_data.item(row, col).text() if self.table_data.item(row, col) else "" for col in range(self.table_data.columnCount())]
|
|
421
|
+
writer.writerow(row_data)
|
|
422
|
+
except Exception as e:
|
|
423
|
+
QMessageBox.critical(self, "Export Error", str(e))
|
|
424
|
+
|
|
425
|
+
def update_table(self) -> None:
|
|
426
|
+
self.table_data.blockSignals(True)
|
|
427
|
+
self.table_data.setRowCount(len(self.datasets))
|
|
428
|
+
for i, ds in enumerate(self.datasets):
|
|
429
|
+
# Experiment Name (Editable)
|
|
430
|
+
item_name = QTableWidgetItem(ds["name"])
|
|
431
|
+
item_name.setFlags(item_name.flags() | Qt.ItemFlag.ItemIsEditable)
|
|
432
|
+
self.table_data.setItem(i, 0, item_name)
|
|
433
|
+
|
|
434
|
+
# Temperature
|
|
435
|
+
try:
|
|
436
|
+
temp = ds["handler"].attributes["acqus"]["TE"]
|
|
437
|
+
except (KeyError, TypeError):
|
|
438
|
+
temp = "N/A"
|
|
439
|
+
item_temp = QTableWidgetItem(str(temp))
|
|
440
|
+
item_temp.setFlags(item_temp.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
441
|
+
self.table_data.setItem(i, 1, item_temp)
|
|
442
|
+
|
|
443
|
+
# Delays
|
|
444
|
+
n_delays = (
|
|
445
|
+
len(ds["handler"].delays) if ds["handler"].delays is not None else 0
|
|
446
|
+
)
|
|
447
|
+
item_delays = QTableWidgetItem(str(n_delays))
|
|
448
|
+
item_delays.setFlags(item_delays.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
449
|
+
self.table_data.setItem(i, 2, item_delays)
|
|
450
|
+
|
|
451
|
+
# Helper for values
|
|
452
|
+
def get_val(attr):
|
|
453
|
+
if hasattr(ds["handler"], attr):
|
|
454
|
+
return f"{getattr(ds['handler'], attr):.2f}"
|
|
455
|
+
return "N/A"
|
|
456
|
+
|
|
457
|
+
# Ra
|
|
458
|
+
item_ra = QTableWidgetItem(get_val("Ra"))
|
|
459
|
+
item_ra.setFlags(item_ra.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
460
|
+
self.table_data.setItem(i, 3, item_ra)
|
|
461
|
+
|
|
462
|
+
# Rb
|
|
463
|
+
item_rb = QTableWidgetItem(get_val("Rb"))
|
|
464
|
+
item_rb.setFlags(item_rb.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
465
|
+
self.table_data.setItem(i, 4, item_rb)
|
|
466
|
+
|
|
467
|
+
# Tau_C
|
|
468
|
+
item_tau = QTableWidgetItem(get_val("tau_c"))
|
|
469
|
+
item_tau.setFlags(item_tau.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
470
|
+
self.table_data.setItem(i, 5, item_tau)
|
|
471
|
+
|
|
472
|
+
# Errors
|
|
473
|
+
for col, attr in enumerate(["err_Ra", "err_Rb", "err_tau_c"], start=6):
|
|
474
|
+
item_err = QTableWidgetItem(get_val(attr))
|
|
475
|
+
item_err.setFlags(item_err.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
476
|
+
self.table_data.setItem(i, col, item_err)
|
|
477
|
+
|
|
478
|
+
self.table_data.blockSignals(False)
|
|
479
|
+
|
|
480
|
+
def on_table_double_click(self, row: int, col: int) -> None:
|
|
481
|
+
if col == 0:
|
|
482
|
+
return
|
|
483
|
+
self.switch_dataset(row)
|
|
484
|
+
|
|
485
|
+
def on_table_item_changed(self, item: QTableWidgetItem) -> None:
|
|
486
|
+
if item.column() == 0:
|
|
487
|
+
row = item.row()
|
|
488
|
+
new_name = item.text()
|
|
489
|
+
if row < len(self.datasets):
|
|
490
|
+
self.datasets[row]["name"] = new_name
|
|
491
|
+
if row == self.current_idx:
|
|
492
|
+
self.current_experiment.setText(new_name)
|
|
493
|
+
|
|
494
|
+
def switch_dataset(self, index: int) -> None:
|
|
495
|
+
if index < 0 or index >= len(self.datasets):
|
|
496
|
+
return
|
|
497
|
+
self.current_idx = index
|
|
498
|
+
ds = self.datasets[index]
|
|
499
|
+
tb = ds["handler"]
|
|
500
|
+
|
|
501
|
+
self.current_experiment.setText(ds["name"])
|
|
502
|
+
|
|
503
|
+
# Update Field Strength from parameters
|
|
504
|
+
try:
|
|
505
|
+
self.input_field.setText("{:.2f}".format(tb.attributes["acqus"]["SFO1"]))
|
|
506
|
+
except (KeyError, AttributeError):
|
|
507
|
+
pass
|
|
508
|
+
|
|
509
|
+
self.slider_p0_coarse.blockSignals(True)
|
|
510
|
+
self.slider_p0_fine.blockSignals(True)
|
|
511
|
+
self.slider_p1_coarse.blockSignals(True)
|
|
512
|
+
self.slider_p1_fine.blockSignals(True)
|
|
513
|
+
|
|
514
|
+
p0 = ds["p0"]
|
|
515
|
+
self.slider_p0_coarse.setValue(int(p0))
|
|
516
|
+
self.slider_p0_fine.setValue(round((p0 - int(p0)) * 10))
|
|
517
|
+
self.input_p0.setText(f"{p0:.1f}")
|
|
518
|
+
|
|
519
|
+
p1 = ds["p1"]
|
|
520
|
+
self.slider_p1_coarse.setValue(int(p1))
|
|
521
|
+
self.slider_p1_fine.setValue(round((p1 - int(p1)) * 10))
|
|
522
|
+
self.input_p1.setText(f"{p1:.1f}")
|
|
523
|
+
|
|
524
|
+
self.slider_p0_coarse.blockSignals(False)
|
|
525
|
+
self.slider_p0_fine.blockSignals(False)
|
|
526
|
+
self.slider_p1_coarse.blockSignals(False)
|
|
527
|
+
self.slider_p1_fine.blockSignals(False)
|
|
528
|
+
|
|
529
|
+
self.process_data()
|
|
530
|
+
|
|
531
|
+
# Update fit display
|
|
532
|
+
self.canvas_fit.axes.clear()
|
|
533
|
+
self.lbl_results.setText("Results will appear here.")
|
|
534
|
+
|
|
535
|
+
if hasattr(tb, "Ra") and hasattr(tb, "popt_alpha"):
|
|
536
|
+
try:
|
|
537
|
+
x, y_a, y_b, popt_a, popt_b = tb.get_fit_data()
|
|
538
|
+
self.canvas_fit.axes.plot(x, y_a, "bo", label="Alpha (Anti-TROSY)")
|
|
539
|
+
self.canvas_fit.axes.plot(x, y_b, "ro", label="Beta (TROSY)")
|
|
540
|
+
self.canvas_fit.axes.plot(
|
|
541
|
+
x, processing.TractBruker._relax(x, *popt_a), "b-"
|
|
542
|
+
)
|
|
543
|
+
self.canvas_fit.axes.plot(
|
|
544
|
+
x, processing.TractBruker._relax(x, *popt_b), "r-"
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
tau_c_val = getattr(tb, "tau_c", 0.0)
|
|
548
|
+
err_tau_c_val = getattr(tb, "err_tau_c", 0.0)
|
|
549
|
+
res_text = (
|
|
550
|
+
f"Ra: {tb.Ra:.2f} +/- {tb.err_Ra:.2f} Hz\n"
|
|
551
|
+
f"Rb: {tb.Rb:.2f} +/- {tb.err_Rb:.2f} Hz\n"
|
|
552
|
+
f"Tau_c: {tau_c_val:.2f} +/- {err_tau_c_val:.2f} ns"
|
|
553
|
+
)
|
|
554
|
+
self.lbl_results.setText(res_text)
|
|
555
|
+
self.canvas_fit.axes.legend()
|
|
556
|
+
except Exception:
|
|
557
|
+
pass
|
|
558
|
+
|
|
559
|
+
self.canvas_fit.draw()
|
|
560
|
+
|
|
561
|
+
def update_sample_name(self) -> None:
|
|
562
|
+
if self.current_idx >= 0:
|
|
563
|
+
name = self.current_experiment.text()
|
|
564
|
+
self.datasets[self.current_idx]["name"] = name
|
|
565
|
+
self.update_table()
|
|
566
|
+
|
|
567
|
+
def show_context_menu(self, pos: QPoint) -> None:
|
|
568
|
+
menu = QMenu()
|
|
569
|
+
action_change = QAction("Change Experiment", self)
|
|
570
|
+
action_delete = QAction("Delete Experiment", self)
|
|
571
|
+
action_export = QAction("Export Table to CSV", self)
|
|
572
|
+
|
|
573
|
+
action_change.triggered.connect(self.change_experiment)
|
|
574
|
+
action_delete.triggered.connect(self.delete_experiment)
|
|
575
|
+
action_export.triggered.connect(self.export_table_to_csv)
|
|
576
|
+
|
|
577
|
+
menu.addAction(action_change)
|
|
578
|
+
menu.addAction(action_delete)
|
|
579
|
+
menu.addSeparator()
|
|
580
|
+
menu.addAction(action_export)
|
|
581
|
+
menu.exec(self.table_data.mapToGlobal(pos))
|
|
582
|
+
|
|
583
|
+
def change_experiment(self) -> None:
|
|
584
|
+
row = self.table_data.currentRow()
|
|
585
|
+
if row < 0:
|
|
586
|
+
return
|
|
587
|
+
folder = QFileDialog.getExistingDirectory(self, "Select Bruker Directory")
|
|
588
|
+
if folder:
|
|
589
|
+
try:
|
|
590
|
+
delay_list = None
|
|
591
|
+
if not os.path.exists(os.path.join(folder, "vdlist")):
|
|
592
|
+
delay_list, _ = QFileDialog.getOpenFileName(
|
|
593
|
+
self, "vdlist not found. Select delay list file:", folder
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
tb = processing.TractBruker(folder, delay_list=delay_list)
|
|
597
|
+
self.datasets[row]["handler"] = tb
|
|
598
|
+
self.datasets[row]["path"] = folder
|
|
599
|
+
self.datasets[row]["name"] = os.path.basename(folder)
|
|
600
|
+
self.datasets[row]["p0"] = tb.phc0
|
|
601
|
+
self.datasets[row]["p1"] = tb.phc1
|
|
602
|
+
self.update_table()
|
|
603
|
+
if row == self.current_idx:
|
|
604
|
+
self.switch_dataset(row)
|
|
605
|
+
except Exception as e:
|
|
606
|
+
QMessageBox.critical(self, "Error", f"Failed to change data: {str(e)}")
|
|
607
|
+
|
|
608
|
+
def delete_experiment(self) -> None:
|
|
609
|
+
row = self.table_data.currentRow()
|
|
610
|
+
if row < 0:
|
|
611
|
+
return
|
|
612
|
+
del self.datasets[row]
|
|
613
|
+
self.update_table()
|
|
614
|
+
if len(self.datasets) == 0:
|
|
615
|
+
self.current_idx = -1
|
|
616
|
+
self.canvas_spec.axes.clear()
|
|
617
|
+
self.canvas_spec.draw()
|
|
618
|
+
self.canvas_fit.axes.clear()
|
|
619
|
+
self.canvas_fit.draw()
|
|
620
|
+
self.current_experiment.clear()
|
|
621
|
+
elif row == self.current_idx:
|
|
622
|
+
self.switch_dataset(max(0, row - 1))
|
|
623
|
+
elif row < self.current_idx:
|
|
624
|
+
self.current_idx -= 1
|
|
625
|
+
|
|
626
|
+
def on_span_select(self, vmin: float, vmax: float) -> None:
|
|
627
|
+
self.input_int_start.setText(f"{vmax:.3f}")
|
|
628
|
+
self.input_int_end.setText(f"{vmin:.3f}")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyTRACTnmr
|
|
3
|
+
Version: 0.1.1b1
|
|
4
|
+
Summary: A simple gui based application to process and analyse TRACT data from NMR spectroscopy.
|
|
5
|
+
Requires-Python: >=3.14
|
|
6
|
+
Requires-Dist: matplotlib>=3.10.8
|
|
7
|
+
Requires-Dist: nmrglue>=0.11
|
|
8
|
+
Requires-Dist: numpy>=2.4.2
|
|
9
|
+
Requires-Dist: pyside6-stubs>=6.7.3.0
|
|
10
|
+
Requires-Dist: pyside6>=6.10.2
|
|
11
|
+
Requires-Dist: scipy-stubs>=1.17.0.2
|
|
12
|
+
Requires-Dist: scipy>=1.17.0
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
This is a simple GUI application for processing and Analysing TRACT data. Currently this only supports collected with Bruker spectrometers with pulseprogram `tractf3gpphwg`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pyTRACTnmr/__init__.py,sha256=5rxO0E3s6JQ3X_bBwWIR3tOVEZrS4ZIC-_LZPemSnmQ,24
|
|
2
|
+
pyTRACTnmr/main.py,sha256=o1UZv2D9Kay36K4hoKR0iu_xRw3QuFxofzehycXoSSw,296
|
|
3
|
+
pyTRACTnmr/processing.py,sha256=8Afvi66NDRBXWs7l260uIYOSxr4X-GpDZf8zWL3SRck,9204
|
|
4
|
+
pyTRACTnmr/widgets.py,sha256=DlSSWUZ_soVfeXvkm5s26zPKoxREUW-4x5__vvXtpj4,2818
|
|
5
|
+
pyTRACTnmr/window.py,sha256=geP_yvoXslK14yyRNBVYH2XmWl4cogAUbMn6FXYO94I,24399
|
|
6
|
+
pytractnmr-0.1.1b1.dist-info/METADATA,sha256=tNeUqf02m2xHpx1Lp7zo5gB9Ptc8Kv112ZEGBjYA5bc,617
|
|
7
|
+
pytractnmr-0.1.1b1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
8
|
+
pytractnmr-0.1.1b1.dist-info/entry_points.txt,sha256=wAW1nWzvGBezl-fd7fcJLk-2iMC4ow11xTi-uSfcUC0,52
|
|
9
|
+
pytractnmr-0.1.1b1.dist-info/RECORD,,
|