ZaksPhysicsLibrary 1.2.2__tar.gz
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.
- zaksphysicslibrary-1.2.2/LICENSE +21 -0
- zaksphysicslibrary-1.2.2/PKG-INFO +142 -0
- zaksphysicslibrary-1.2.2/PhysicsLibrary/__init__.py +59 -0
- zaksphysicslibrary-1.2.2/PhysicsLibrary/analysis.py +251 -0
- zaksphysicslibrary-1.2.2/PhysicsLibrary/dataset.py +125 -0
- zaksphysicslibrary-1.2.2/PhysicsLibrary/file_parser.py +59 -0
- zaksphysicslibrary-1.2.2/PhysicsLibrary/file_parser_generic.py +287 -0
- zaksphysicslibrary-1.2.2/PhysicsLibrary/loaders/__init__.py +0 -0
- zaksphysicslibrary-1.2.2/PhysicsLibrary/loaders/oxysoft_loader.py +235 -0
- zaksphysicslibrary-1.2.2/PhysicsLibrary/loaders/pt2_loader.py +43 -0
- zaksphysicslibrary-1.2.2/PhysicsLibrary/loaders/tdt_loader.py +49 -0
- zaksphysicslibrary-1.2.2/PhysicsLibrary/models.py +62 -0
- zaksphysicslibrary-1.2.2/PhysicsLibrary/processing_TDT.py +273 -0
- zaksphysicslibrary-1.2.2/README.md +117 -0
- zaksphysicslibrary-1.2.2/ZaksPhysicsLibrary.egg-info/PKG-INFO +142 -0
- zaksphysicslibrary-1.2.2/ZaksPhysicsLibrary.egg-info/SOURCES.txt +19 -0
- zaksphysicslibrary-1.2.2/ZaksPhysicsLibrary.egg-info/dependency_links.txt +1 -0
- zaksphysicslibrary-1.2.2/ZaksPhysicsLibrary.egg-info/requires.txt +3 -0
- zaksphysicslibrary-1.2.2/ZaksPhysicsLibrary.egg-info/top_level.txt +1 -0
- zaksphysicslibrary-1.2.2/pyproject.toml +39 -0
- zaksphysicslibrary-1.2.2/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zakgm2
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ZaksPhysicsLibrary
|
|
3
|
+
Version: 1.2.2
|
|
4
|
+
Summary: Data processing and analysis library for TDT, Oxysoft NIRS, and Terranova EFNMR lab data
|
|
5
|
+
Author: zakgm2
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/zakgm2/PhysicsLibrary
|
|
8
|
+
Project-URL: Repository, https://github.com/zakgm2/PhysicsLibrary
|
|
9
|
+
Project-URL: Changelog, https://github.com/zakgm2/PhysicsLibrary/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: physics,neuroscience,fibre-photometry,NIRS,TDT,EFNMR,signal-processing
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Intended Audience :: Science/Research
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: numpy
|
|
22
|
+
Requires-Dist: scipy
|
|
23
|
+
Requires-Dist: tdt
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# PhysicsLibrary
|
|
27
|
+
|
|
28
|
+
Data processing and analysis library backing [PhysicsAnalysis](https://github.com/zakgm2/PhysicsAnalysis) — file parsing, signal processing, and curve-fitting logic, with no GUI code of its own. Any interface (tkinter, PyQt6, a script, a notebook) can sit on top of it.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## What it does
|
|
33
|
+
|
|
34
|
+
- **Loads lab data** from three instrument formats plus generic tabular files:
|
|
35
|
+
- **TDT** (Tucker-Davis Technologies) fibre photometry tanks
|
|
36
|
+
- **Oxysoft / Artinis** (Oxymon, OctaMon, PortaMon …) NIRS `.txt` exports
|
|
37
|
+
- **Terranova Prospa** `.pt2` EFNMR/MRI 2D images
|
|
38
|
+
- Generic **Excel / CSV / TSV / plain text**, with automatic sub-table detection for side-by-side data layouts on one sheet
|
|
39
|
+
- **Processes signals** — bleach correction, denoising, Z-score PETH slicing, FFT with peak annotation, slope/segment analysis
|
|
40
|
+
- **Fits curves** — linear, single/double exponential, exponential rise, Gaussian, sinusoidal, and a photon-entanglement visibility model, all via `scipy.optimize.curve_fit`
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Structure
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
PhysicsLibrary/
|
|
48
|
+
__init__.py Public API — see below
|
|
49
|
+
dataset.py Dataset struct, DataFormat enum, format detection, folder picker
|
|
50
|
+
file_parser.py Top-level dispatcher: load_dataset(), load_dataset_file()
|
|
51
|
+
file_parser_generic.py Generic Excel/CSV/TSV/text parser with sub-table detection
|
|
52
|
+
processing_TDT.py TDT tank reading, bleach correction, denoising, event markers
|
|
53
|
+
analysis.py PETH/Z-score, FFT, slope segments, curve-fit runner
|
|
54
|
+
models.py Parametric model functions for curve fitting
|
|
55
|
+
loaders/
|
|
56
|
+
tdt_loader.py Wraps processing_TDT into a Dataset
|
|
57
|
+
oxysoft_loader.py Oxysoft .txt parsing (folder + single-file) into a Dataset
|
|
58
|
+
pt2_loader.py .pt2 EFNMR/MRI image parser
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Each loader/parser is single-purpose and has no knowledge of the others — `file_parser.py` is the only place that ties format detection to the right loader.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install git+https://github.com/zakgm2/PhysicsLibrary.git
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or as a dependency in another project's `requirements.txt`:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
git+https://github.com/zakgm2/PhysicsLibrary.git
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Requirements
|
|
78
|
+
|
|
79
|
+
- Python 3.10+
|
|
80
|
+
- `numpy`, `scipy`, `tdt` (installed automatically)
|
|
81
|
+
- `openpyxl` — only needed for `.xlsx`/`.xls` files; imported lazily with a clear error if missing when you actually try to load Excel
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Usage
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import PhysicsLibrary as pl
|
|
89
|
+
|
|
90
|
+
# Detect + load a TDT tank or Oxysoft export folder
|
|
91
|
+
fmt = pl.detect_format(folder_path)
|
|
92
|
+
dataset = pl.load_dataset(folder_path, fmt)
|
|
93
|
+
|
|
94
|
+
# Or load a single Oxysoft .txt file directly
|
|
95
|
+
dataset = pl.load_dataset_file(file_path)
|
|
96
|
+
|
|
97
|
+
# Every loader returns the same universal Dataset struct
|
|
98
|
+
dataset.source_format # "TDT" | "Oxysoft"
|
|
99
|
+
dataset.sample_rate # Hz
|
|
100
|
+
dataset.signals # (num_channels, num_samples)
|
|
101
|
+
dataset.channel_names # list[str]
|
|
102
|
+
dataset.events # [{'label': str, 'sample': int}, ...]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
# Generic tabular data (Excel/CSV/TSV/text) — returns one GenericTable per
|
|
107
|
+
# detected sub-table, since a single sheet can contain several side-by-side
|
|
108
|
+
tables = pl.load_any_file(path)
|
|
109
|
+
table = tables[0]
|
|
110
|
+
table.headers # list[str]
|
|
111
|
+
table.data # (n_rows, n_cols) float64, NaN for missing
|
|
112
|
+
|
|
113
|
+
# Terranova .pt2 EFNMR/MRI image — returns a raw 2D array, not a Dataset
|
|
114
|
+
img = pl.load_pt2(path) # (n, n) float32
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# Analysis
|
|
119
|
+
x_seg, z = pl.get_zscore_slice(time_array, signal, center_t, window=30)
|
|
120
|
+
freqs, power, seg_x, seg_y = pl.compute_fft_slice(time_array, signal, center_t, fs)
|
|
121
|
+
pl.annotate_fft_peaks(ax, freqs, power, color='blue') # matplotlib peak labels
|
|
122
|
+
|
|
123
|
+
# Curve fitting
|
|
124
|
+
result = pl.fit_model_to_segment(x_seg, y_seg, pl.single_exponential_model, p0_fn)
|
|
125
|
+
result["popt"], result["r2"], result["y_fit"]
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
See [CHANGELOG.md](CHANGELOG.md) for the version history.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Public API
|
|
133
|
+
|
|
134
|
+
Everything importable from `PhysicsLibrary` directly:
|
|
135
|
+
|
|
136
|
+
| Category | Names |
|
|
137
|
+
|----------|-------|
|
|
138
|
+
| Format detection | `choose_file`, `detect_format`, `detect_format_file`, `DataFormat`, `Dataset` |
|
|
139
|
+
| Loading | `load_dataset`, `load_dataset_file`, `load_any_file`, `load_pt2` |
|
|
140
|
+
| TDT processing | `process_tdt_folder`, `validate_tdt_folder`, `get_tdt_struct`, `get_plot_data`, `correct_bleaching`, `denoise_signal`, `get_event_markers` |
|
|
141
|
+
| Analysis | `get_zscore_slice`, `smooth_signal`, `bin_for_heatmap`, `compute_fft_slice`, `annotate_fft_peaks`, `compute_slope_segment`, `fit_model_to_segment` |
|
|
142
|
+
| Curve fit models | `linear_model`, `single_exponential_model`, `exponential_rise_model`, `double_exponential_model`, `gaussian_model`, `sinusoidal_model`, `visibility_model` |
|
|
@@ -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
|
+
)
|