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 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()
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pytractnmr = pyTRACTnmr.main:main