ZaksPhysicsLibrary 1.2.2__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.
@@ -0,0 +1,59 @@
1
+ """
2
+ PhysicsLibrary
3
+ --------------
4
+ Data processing and analysis library for Physics Analysis GUI.
5
+ """
6
+
7
+ from importlib.metadata import version as _version, PackageNotFoundError
8
+
9
+ try:
10
+ __version__ = _version("PhysicsLibrary")
11
+ except PackageNotFoundError:
12
+ __version__ = "unknown"
13
+
14
+ from .file_parser_generic import load_any_file
15
+
16
+ from .dataset import (
17
+ choose_file,
18
+ detect_format,
19
+ detect_format_file,
20
+ DataFormat,
21
+ Dataset,
22
+ )
23
+
24
+ from .file_parser import (
25
+ load_dataset,
26
+ load_dataset_file,
27
+ )
28
+
29
+ from .loaders.pt2_loader import load_pt2
30
+
31
+ from .processing_TDT import (
32
+ process_tdt_folder,
33
+ validate_tdt_folder,
34
+ get_tdt_struct,
35
+ get_plot_data,
36
+ correct_bleaching,
37
+ denoise_signal,
38
+ get_event_markers,
39
+ )
40
+
41
+ from .analysis import (
42
+ get_zscore_slice,
43
+ smooth_signal,
44
+ bin_for_heatmap,
45
+ compute_fft_slice,
46
+ annotate_fft_peaks,
47
+ compute_slope_segment,
48
+ fit_model_to_segment,
49
+ )
50
+
51
+ from .models import (
52
+ double_exponential_model,
53
+ visibility_model,
54
+ linear_model,
55
+ single_exponential_model,
56
+ exponential_rise_model,
57
+ gaussian_model,
58
+ sinusoidal_model,
59
+ )
@@ -0,0 +1,251 @@
1
+ """
2
+ analysis.py
3
+ -----------
4
+ Format-agnostic analysis routines for Physics Analysis GUI.
5
+
6
+ Currently:
7
+ - Z-Score PETH (get_zscore_slice, smooth_signal, bin_for_heatmap)
8
+ - FFT (compute_fft_slice, annotate_fft_peaks)
9
+ - Curve fitting (compute_slope_segment, fit_model_to_segment)
10
+ """
11
+
12
+ import numpy as np
13
+ from scipy.signal import detrend, find_peaks
14
+ from scipy.optimize import curve_fit
15
+
16
+
17
+ def get_zscore_slice(time_array, signal, center_t, window=30):
18
+ """
19
+ Extract and z-score a time window around an event.
20
+
21
+ Parameters
22
+ ----------
23
+ time_array : array
24
+ signal : array
25
+ center_t : float
26
+ Event time in seconds
27
+ window : float
28
+ Total window size in seconds
29
+
30
+ Returns
31
+ -------
32
+ (time segment, z-scored signal)
33
+ """
34
+ half_win = window / 2
35
+ start_idx = np.searchsorted(time_array, center_t - half_win)
36
+ end_idx = np.searchsorted(time_array, center_t + half_win)
37
+
38
+ seg_y = signal[start_idx:end_idx]
39
+ seg_x = time_array[start_idx:end_idx]
40
+
41
+ # Clip extreme artefacts before z-scoring so outliers don't dominate the baseline std.
42
+ seg_y = np.clip(seg_y, -5, 5)
43
+
44
+ baseline_end = len(seg_y) // 2
45
+ baseline_period = seg_y[:baseline_end]
46
+ mu = np.mean(baseline_period)
47
+ std = np.std(baseline_period)
48
+
49
+ if std < 1e-6:
50
+ return seg_x, np.zeros_like(seg_y)
51
+
52
+ return seg_x, (seg_y - mu) / std
53
+
54
+
55
+ def smooth_signal(data, fs, window_sec=0.5):
56
+ """
57
+ Moving average smoothing filter.
58
+
59
+ Parameters
60
+ ----------
61
+ data : array
62
+ fs : float
63
+ Sampling frequency in Hz
64
+ window_sec : float
65
+ Smoothing window in seconds
66
+
67
+ Returns
68
+ -------
69
+ array
70
+ Smoothed signal
71
+ """
72
+ window_size = int(fs * window_sec)
73
+ if window_size % 2 == 0:
74
+ window_size += 1
75
+ return np.convolve(data, np.ones(window_size) / window_size, mode='same')
76
+
77
+
78
+ def bin_for_heatmap(z_seg, num_bins=300):
79
+ """
80
+ Bin a signal into equal segments for heatmap plotting.
81
+
82
+ Parameters
83
+ ----------
84
+ z_seg : array
85
+ num_bins : int
86
+
87
+ Returns
88
+ -------
89
+ array
90
+ Binned signal
91
+ """
92
+ if z_seg is None or len(z_seg) == 0:
93
+ return np.zeros(num_bins)
94
+ bin_edges = np.linspace(0, len(z_seg), num_bins + 1).astype(int)
95
+ return np.array([np.mean(z_seg[bin_edges[i]:bin_edges[i+1]]) for i in range(num_bins)])
96
+
97
+
98
+ def compute_fft_slice(time_array, signal, center_t, fs, window=30):
99
+ """
100
+ Extract a time window around center_t and compute its FFT.
101
+
102
+ Applies mean removal and linear detrending before FFT to eliminate
103
+ the DC spike and slow drift, making physiological frequencies
104
+ (breathing ~0.3 Hz, heart rate ~1 Hz) visible.
105
+
106
+ Parameters
107
+ ----------
108
+ time_array : array
109
+ signal : array
110
+ center_t : float
111
+ Center time in seconds
112
+ fs : float
113
+ Sampling frequency in Hz
114
+ window : float
115
+ Total window size in seconds
116
+
117
+ Returns
118
+ -------
119
+ freqs : array
120
+ power : array
121
+ seg_x : array
122
+ seg_y : array
123
+ """
124
+ half_win = window / 2
125
+ start_idx = np.searchsorted(time_array, center_t - half_win)
126
+ end_idx = np.searchsorted(time_array, center_t + half_win)
127
+
128
+ seg_y = signal[start_idx:end_idx]
129
+ seg_x = time_array[start_idx:end_idx]
130
+
131
+ if len(seg_y) < 4:
132
+ return np.array([]), np.array([]), seg_x, seg_y
133
+
134
+ seg_y = detrend(seg_y, type='linear') # removes mean and linear trend
135
+ windowed = seg_y * np.hanning(len(seg_y))
136
+
137
+ n = len(windowed)
138
+ fft_y = np.fft.rfft(windowed)
139
+ freqs = np.fft.rfftfreq(n, d=1.0 / fs)
140
+ power = (np.abs(fft_y) ** 2) / n
141
+
142
+ return freqs, power, seg_x, seg_y
143
+
144
+
145
+ def annotate_fft_peaks(ax_f, freqs, power, color, n_peaks=3):
146
+ """
147
+ Find top N peaks in a power spectrum and annotate them with
148
+ frequency and BPM labels directly on the axes.
149
+
150
+ Parameters
151
+ ----------
152
+ ax_f : matplotlib Axes
153
+ freqs : array
154
+ power : array
155
+ color : str
156
+ n_peaks: int
157
+ """
158
+ mask = freqs >= 0.05
159
+ f_m = freqs[mask]
160
+ p_m = power[mask]
161
+ if len(p_m) < 3:
162
+ return
163
+ min_prom = 0.05 * p_m.max()
164
+ peaks, _ = find_peaks(p_m, prominence=min_prom)
165
+ if len(peaks) == 0:
166
+ return
167
+ top = sorted(peaks, key=lambda i: p_m[i], reverse=True)[:n_peaks]
168
+ for idx in top:
169
+ freq = f_m[idx]
170
+ pwr = p_m[idx]
171
+ bpm = freq * 60
172
+ ax_f.annotate(
173
+ f"{freq:.2f} Hz\n({bpm:.0f} bpm)",
174
+ xy=(freq, pwr),
175
+ xytext=(freq + 0.05, pwr * 0.92),
176
+ fontsize=7, color=color, fontweight='bold',
177
+ arrowprops=dict(arrowstyle='->', color=color, lw=0.8),
178
+ )
179
+ ax_f.axvline(freq, color=color, lw=0.7, linestyle=':', alpha=0.5)
180
+
181
+
182
+ def compute_slope_segment(x_data, y_data, p1_idx, p2_idx, padding_pct=0.05):
183
+ """
184
+ Least-squares linear regression slope between two index boundaries.
185
+
186
+ Parameters
187
+ ----------
188
+ x_data : array
189
+ y_data : array
190
+ p1_idx : int
191
+ p2_idx : int
192
+ padding_pct : float visual context padding
193
+
194
+ Returns
195
+ -------
196
+ dict with slope, intercept, crop_x, crop_y, x1, y1, x2, y2
197
+ """
198
+ idx1, idx2 = sorted([p1_idx, p2_idx])
199
+
200
+ fit_x = x_data[idx1:idx2 + 1]
201
+ fit_y = y_data[idx1:idx2 + 1]
202
+
203
+ if len(fit_x) < 2:
204
+ slope, intercept = 0.0, 0.0
205
+ else:
206
+ slope, intercept = np.polyfit(fit_x, fit_y, 1)
207
+
208
+ x1, y1 = fit_x[0], fit_y[0]
209
+ x2, y2 = fit_x[-1], fit_y[-1]
210
+
211
+ pad = max(5, int(len(x_data) * padding_pct))
212
+ start_idx = max(0, idx1 - pad)
213
+ end_idx = min(len(x_data) - 1, idx2 + pad)
214
+
215
+ return {
216
+ 'slope': slope,
217
+ 'intercept': intercept,
218
+ 'crop_x': x_data[start_idx:end_idx + 1],
219
+ 'crop_y': y_data[start_idx:end_idx + 1],
220
+ 'x1': x1, 'y1': y1,
221
+ 'x2': x2, 'y2': y2,
222
+ }
223
+
224
+
225
+ def fit_model_to_segment(x_seg, y_seg, model_fn, p0_fn):
226
+ """
227
+ Fit a model function to a data segment using scipy curve_fit.
228
+
229
+ Parameters
230
+ ----------
231
+ x_seg : array
232
+ y_seg : array
233
+ model_fn : callable f(x, *params) -> y
234
+ p0_fn : callable f(x_seg, y_seg) -> list of initial guesses
235
+
236
+ Returns
237
+ -------
238
+ dict with popt, y_fit, r2, success, error
239
+ """
240
+ try:
241
+ p0 = p0_fn(x_seg, y_seg)
242
+ popt, _ = curve_fit(model_fn, x_seg, y_seg, p0=p0, maxfev=10000)
243
+ y_fit = model_fn(x_seg, *popt)
244
+ ss_res = np.sum((y_seg - y_fit) ** 2)
245
+ ss_tot = np.sum((y_seg - y_seg.mean()) ** 2)
246
+ r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 0.0
247
+ return {"popt": popt, "y_fit": y_fit, "r2": r2,
248
+ "success": True, "error": None}
249
+ except Exception as e:
250
+ return {"popt": None, "y_fit": np.zeros_like(y_seg),
251
+ "r2": 0.0, "success": False, "error": str(e)}
@@ -0,0 +1,125 @@
1
+ """
2
+ dataset.py
3
+ ----------
4
+ The universal Dataset container returned by every loader, plus format
5
+ detection and folder selection. No parsing logic lives here — that's in
6
+ loaders/.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum, auto
14
+ from tkinter import filedialog
15
+ from typing import Optional
16
+
17
+ import numpy as np
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Folder selection
22
+ # ---------------------------------------------------------------------------
23
+
24
+ def choose_file(parent_window=None) -> tuple[Optional[str], Optional[str]]:
25
+ """
26
+ Opens a native folder selection dialog.
27
+
28
+ Returns
29
+ -------
30
+ (folder_path, folder_name) or (None, None) if cancelled.
31
+ """
32
+ folder_path = filedialog.askdirectory(
33
+ parent=parent_window,
34
+ title="Open Lab Data Folder",
35
+ )
36
+ if not folder_path:
37
+ return None, None
38
+ return folder_path, os.path.basename(folder_path)
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Universal output struct
43
+ # ---------------------------------------------------------------------------
44
+
45
+ @dataclass
46
+ class Dataset:
47
+ """Universal container returned by every loader."""
48
+
49
+ source_format: str # 'TDT' | 'Oxysoft'
50
+ folder_path: str
51
+ folder_name: str
52
+
53
+ # timing
54
+ sample_rate: float = 0.0 # Hz
55
+ num_samples: int = 0
56
+ duration_s: float = 0.0 # seconds
57
+
58
+ # signals – shape (num_channels, num_samples)
59
+ signals: Optional[np.ndarray] = None
60
+
61
+ # channel metadata
62
+ channel_names: list[str] = field(default_factory=list)
63
+ num_channels: int = 0
64
+
65
+ # events – list of dicts with at least {'label': str, 'sample': int}
66
+ events: list[dict] = field(default_factory=list)
67
+
68
+ # raw header / metadata blob (format-specific)
69
+ metadata: dict = field(default_factory=dict)
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Format detection
74
+ # ---------------------------------------------------------------------------
75
+
76
+ class DataFormat(Enum):
77
+ TDT = auto()
78
+ OXYSOFT = auto()
79
+ UNKNOWN = auto()
80
+
81
+
82
+ _TDT_EXTENSIONS = {'.tbk', '.tdx', '.tev', '.tsq', '.sev'}
83
+ _OXYSOFT_HEADER_MARKER = 'Datafile sample rate'
84
+
85
+
86
+ def detect_format(folder_path: str) -> DataFormat:
87
+ """
88
+ Inspect the contents of *folder_path* and return the matching DataFormat.
89
+ Priority: TDT first (proprietary extensions), then Oxysoft (.txt marker).
90
+ """
91
+ entries = os.listdir(folder_path)
92
+ extensions = {os.path.splitext(e)[1].lower() for e in entries}
93
+
94
+ if extensions & _TDT_EXTENSIONS:
95
+ return DataFormat.TDT
96
+
97
+ txt_files = [e for e in entries if e.lower().endswith('.txt')]
98
+ for fname in txt_files:
99
+ fpath = os.path.join(folder_path, fname)
100
+ try:
101
+ with open(fpath, 'r', encoding='utf-8', errors='replace') as fh:
102
+ for _ in range(30):
103
+ if _OXYSOFT_HEADER_MARKER in fh.readline():
104
+ return DataFormat.OXYSOFT
105
+ except OSError:
106
+ continue
107
+
108
+ return DataFormat.UNKNOWN
109
+
110
+
111
+ def detect_format_file(file_path: str) -> DataFormat:
112
+ """
113
+ Detect the format of a single file (as opposed to a folder).
114
+ Currently supports Oxysoft .txt exports.
115
+ """
116
+ if not file_path.lower().endswith('.txt'):
117
+ return DataFormat.UNKNOWN
118
+ try:
119
+ with open(file_path, 'r', encoding='utf-8', errors='replace') as fh:
120
+ for _ in range(30):
121
+ if _OXYSOFT_HEADER_MARKER in fh.readline():
122
+ return DataFormat.OXYSOFT
123
+ except OSError:
124
+ pass
125
+ return DataFormat.UNKNOWN
@@ -0,0 +1,59 @@
1
+ """
2
+ file_parser.py
3
+ --------------
4
+ Top-level dispatcher: detects the recording format inside a selected
5
+ folder/file and routes to the matching loader, returning a universal
6
+ Dataset struct.
7
+
8
+ - TDT (Tucker-Davis Technologies) – loaders/tdt_loader.py
9
+ - Oxysoft / Artinis (Oxymon, OctaMon, PortaMon …) – loaders/oxysoft_loader.py
10
+
11
+ Format detection, the Dataset struct, and folder selection live in
12
+ dataset.py. The .pt2 EFNMR/MRI image format is a standalone parser in
13
+ loaders/pt2_loader.py (it returns a raw image array, not a Dataset).
14
+
15
+ Usage
16
+ -----
17
+ from file_parser import detect_format, load_dataset, DataFormat
18
+
19
+ fmt = detect_format(folder_path)
20
+ dataset = load_dataset(folder_path, fmt)
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ from typing import Optional
27
+
28
+ from .dataset import Dataset, DataFormat, detect_format, detect_format_file
29
+ from .loaders.tdt_loader import load_tdt
30
+ from .loaders.oxysoft_loader import load_oxysoft, load_oxysoft_file
31
+
32
+
33
+ def load_dataset_file(file_path: str) -> Dataset:
34
+ """Load a single file and return a Dataset."""
35
+ fmt = detect_format_file(file_path)
36
+ if fmt == DataFormat.OXYSOFT:
37
+ return load_oxysoft_file(file_path)
38
+ raise ValueError(f"Unrecognised file format: {file_path}")
39
+
40
+
41
+ def load_dataset(folder_path: str, fmt: Optional[DataFormat] = None) -> Dataset:
42
+ """
43
+ Load a recording folder and return a Dataset.
44
+ If *fmt* is None, detect_format() is called automatically.
45
+ """
46
+ folder_name = os.path.basename(folder_path.rstrip('/\\'))
47
+
48
+ if fmt is None:
49
+ fmt = detect_format(folder_path)
50
+
51
+ if fmt is DataFormat.TDT:
52
+ return load_tdt(folder_path, folder_name)
53
+ elif fmt is DataFormat.OXYSOFT:
54
+ return load_oxysoft(folder_path, folder_name)
55
+ else:
56
+ raise ValueError(
57
+ f"Could not identify a supported data format in: {folder_path}\n"
58
+ "Expected TDT proprietary files (.Tbk/.tev/…) or an Oxysoft .txt export."
59
+ )