ZaksPhysicsLibrary 1.2.2__tar.gz → 1.4.0__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 → zaksphysicslibrary-1.4.0}/PKG-INFO +1 -1
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/__init__.py +1 -2
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/analysis.py +44 -14
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/dataset.py +2 -24
- zaksphysicslibrary-1.4.0/PhysicsLibrary/processing_TDT.py +387 -0
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/ZaksPhysicsLibrary.egg-info/PKG-INFO +1 -1
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/pyproject.toml +1 -1
- zaksphysicslibrary-1.2.2/PhysicsLibrary/processing_TDT.py +0 -273
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/LICENSE +0 -0
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/file_parser.py +0 -0
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/file_parser_generic.py +0 -0
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/loaders/__init__.py +0 -0
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/loaders/oxysoft_loader.py +0 -0
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/loaders/pt2_loader.py +0 -0
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/loaders/tdt_loader.py +0 -0
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/models.py +0 -0
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/README.md +0 -0
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/ZaksPhysicsLibrary.egg-info/SOURCES.txt +0 -0
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/ZaksPhysicsLibrary.egg-info/dependency_links.txt +0 -0
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/ZaksPhysicsLibrary.egg-info/requires.txt +0 -0
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/ZaksPhysicsLibrary.egg-info/top_level.txt +0 -0
- {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/setup.cfg +0 -0
|
@@ -7,14 +7,13 @@ Data processing and analysis library for Physics Analysis GUI.
|
|
|
7
7
|
from importlib.metadata import version as _version, PackageNotFoundError
|
|
8
8
|
|
|
9
9
|
try:
|
|
10
|
-
__version__ = _version("
|
|
10
|
+
__version__ = _version("ZaksPhysicsLibrary")
|
|
11
11
|
except PackageNotFoundError:
|
|
12
12
|
__version__ = "unknown"
|
|
13
13
|
|
|
14
14
|
from .file_parser_generic import load_any_file
|
|
15
15
|
|
|
16
16
|
from .dataset import (
|
|
17
|
-
choose_file,
|
|
18
17
|
detect_format,
|
|
19
18
|
detect_format_file,
|
|
20
19
|
DataFormat,
|
|
@@ -14,26 +14,40 @@ from scipy.signal import detrend, find_peaks
|
|
|
14
14
|
from scipy.optimize import curve_fit
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
def get_zscore_slice(time_array, signal, center_t, window=
|
|
17
|
+
def get_zscore_slice(time_array, signal, center_t, window=None, pre=None, post=None):
|
|
18
18
|
"""
|
|
19
19
|
Extract and z-score a time window around an event.
|
|
20
20
|
|
|
21
|
+
Supports either a symmetric `window` (split evenly before/after
|
|
22
|
+
center_t, legacy behaviour) or an asymmetric `pre`/`post` pair — e.g.
|
|
23
|
+
10s before the event and 20s after. If pre/post are given they take
|
|
24
|
+
precedence over window.
|
|
25
|
+
|
|
21
26
|
Parameters
|
|
22
27
|
----------
|
|
23
28
|
time_array : array
|
|
24
29
|
signal : array
|
|
25
30
|
center_t : float
|
|
26
31
|
Event time in seconds
|
|
27
|
-
window : float
|
|
28
|
-
Total window size in seconds
|
|
32
|
+
window : float or None
|
|
33
|
+
Total window size in seconds, split evenly before/after center_t.
|
|
34
|
+
Ignored if pre/post are given.
|
|
35
|
+
pre : float or None
|
|
36
|
+
Seconds before center_t to include. Defaults to window/2.
|
|
37
|
+
post : float or None
|
|
38
|
+
Seconds after center_t to include. Defaults to window/2.
|
|
29
39
|
|
|
30
40
|
Returns
|
|
31
41
|
-------
|
|
32
42
|
(time segment, z-scored signal)
|
|
33
43
|
"""
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
if pre is None or post is None:
|
|
45
|
+
half_win = (window if window is not None else 30) / 2
|
|
46
|
+
pre = pre if pre is not None else half_win
|
|
47
|
+
post = post if post is not None else half_win
|
|
48
|
+
|
|
49
|
+
start_idx = np.searchsorted(time_array, center_t - pre)
|
|
50
|
+
end_idx = np.searchsorted(time_array, center_t + post)
|
|
37
51
|
|
|
38
52
|
seg_y = signal[start_idx:end_idx]
|
|
39
53
|
seg_x = time_array[start_idx:end_idx]
|
|
@@ -41,8 +55,11 @@ def get_zscore_slice(time_array, signal, center_t, window=30):
|
|
|
41
55
|
# Clip extreme artefacts before z-scoring so outliers don't dominate the baseline std.
|
|
42
56
|
seg_y = np.clip(seg_y, -5, 5)
|
|
43
57
|
|
|
44
|
-
|
|
45
|
-
|
|
58
|
+
# Baseline is the pre-event portion — the part of the window that
|
|
59
|
+
# actually precedes the event, not just "the first half of the segment"
|
|
60
|
+
# (those differ once pre != post).
|
|
61
|
+
baseline_mask = seg_x < center_t
|
|
62
|
+
baseline_period = seg_y[baseline_mask] if baseline_mask.any() else seg_y
|
|
46
63
|
mu = np.mean(baseline_period)
|
|
47
64
|
std = np.std(baseline_period)
|
|
48
65
|
|
|
@@ -95,7 +112,7 @@ def bin_for_heatmap(z_seg, num_bins=300):
|
|
|
95
112
|
return np.array([np.mean(z_seg[bin_edges[i]:bin_edges[i+1]]) for i in range(num_bins)])
|
|
96
113
|
|
|
97
114
|
|
|
98
|
-
def compute_fft_slice(time_array, signal, center_t, fs, window=
|
|
115
|
+
def compute_fft_slice(time_array, signal, center_t, fs, window=None, pre=None, post=None):
|
|
99
116
|
"""
|
|
100
117
|
Extract a time window around center_t and compute its FFT.
|
|
101
118
|
|
|
@@ -103,6 +120,10 @@ def compute_fft_slice(time_array, signal, center_t, fs, window=30):
|
|
|
103
120
|
the DC spike and slow drift, making physiological frequencies
|
|
104
121
|
(breathing ~0.3 Hz, heart rate ~1 Hz) visible.
|
|
105
122
|
|
|
123
|
+
Supports either a symmetric `window` (split evenly before/after
|
|
124
|
+
center_t, legacy behaviour) or an asymmetric `pre`/`post` pair. If
|
|
125
|
+
pre/post are given they take precedence over window.
|
|
126
|
+
|
|
106
127
|
Parameters
|
|
107
128
|
----------
|
|
108
129
|
time_array : array
|
|
@@ -111,8 +132,13 @@ def compute_fft_slice(time_array, signal, center_t, fs, window=30):
|
|
|
111
132
|
Center time in seconds
|
|
112
133
|
fs : float
|
|
113
134
|
Sampling frequency in Hz
|
|
114
|
-
window : float
|
|
115
|
-
Total window size in seconds
|
|
135
|
+
window : float or None
|
|
136
|
+
Total window size in seconds, split evenly before/after center_t.
|
|
137
|
+
Ignored if pre/post are given.
|
|
138
|
+
pre : float or None
|
|
139
|
+
Seconds before center_t to include. Defaults to window/2.
|
|
140
|
+
post : float or None
|
|
141
|
+
Seconds after center_t to include. Defaults to window/2.
|
|
116
142
|
|
|
117
143
|
Returns
|
|
118
144
|
-------
|
|
@@ -121,9 +147,13 @@ def compute_fft_slice(time_array, signal, center_t, fs, window=30):
|
|
|
121
147
|
seg_x : array
|
|
122
148
|
seg_y : array
|
|
123
149
|
"""
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
150
|
+
if pre is None or post is None:
|
|
151
|
+
half_win = (window if window is not None else 30) / 2
|
|
152
|
+
pre = pre if pre is not None else half_win
|
|
153
|
+
post = post if post is not None else half_win
|
|
154
|
+
|
|
155
|
+
start_idx = np.searchsorted(time_array, center_t - pre)
|
|
156
|
+
end_idx = np.searchsorted(time_array, center_t + post)
|
|
127
157
|
|
|
128
158
|
seg_y = signal[start_idx:end_idx]
|
|
129
159
|
seg_x = time_array[start_idx:end_idx]
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
dataset.py
|
|
3
3
|
----------
|
|
4
4
|
The universal Dataset container returned by every loader, plus format
|
|
5
|
-
detection
|
|
6
|
-
|
|
5
|
+
detection. No parsing logic lives here — that's in loaders/. No GUI
|
|
6
|
+
concerns either — folder/file selection is a caller responsibility.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
@@ -11,33 +11,11 @@ from __future__ import annotations
|
|
|
11
11
|
import os
|
|
12
12
|
from dataclasses import dataclass, field
|
|
13
13
|
from enum import Enum, auto
|
|
14
|
-
from tkinter import filedialog
|
|
15
14
|
from typing import Optional
|
|
16
15
|
|
|
17
16
|
import numpy as np
|
|
18
17
|
|
|
19
18
|
|
|
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
19
|
# ---------------------------------------------------------------------------
|
|
42
20
|
# Universal output struct
|
|
43
21
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""
|
|
2
|
+
processing_TDT.py
|
|
3
|
+
-----------------
|
|
4
|
+
Signal processing pipeline for TDT (Tucker-Davis Technologies) fibre photometry data.
|
|
5
|
+
|
|
6
|
+
Handles loading, bleaching correction, denoising, and event marker extraction.
|
|
7
|
+
Depends on the `tdt` Python SDK for reading tank files.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from scipy.optimize import curve_fit
|
|
14
|
+
from scipy.signal import butter, filtfilt
|
|
15
|
+
import tdt
|
|
16
|
+
|
|
17
|
+
from .models import double_exponential_model as double_exponential
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_tdt_folder(path):
|
|
21
|
+
"""
|
|
22
|
+
Validate whether a directory contains a TDT recording block.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
path : str
|
|
27
|
+
Path to the TDT data folder.
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
tuple
|
|
32
|
+
(bool, str)
|
|
33
|
+
- True + folder name if valid
|
|
34
|
+
- False + error message if invalid
|
|
35
|
+
"""
|
|
36
|
+
if not path:
|
|
37
|
+
return False, "No folder selected."
|
|
38
|
+
|
|
39
|
+
has_tbk = any(fname.endswith('.Tbk') for fname in os.listdir(path))
|
|
40
|
+
|
|
41
|
+
if has_tbk:
|
|
42
|
+
return True, os.path.basename(path)
|
|
43
|
+
else:
|
|
44
|
+
return False, "Invalid Folder: No TDT block files (.Tbk) found."
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def process_tdt_folder(folder_path):
|
|
48
|
+
"""
|
|
49
|
+
Full photometry processing pipeline for a TDT recording.
|
|
50
|
+
|
|
51
|
+
Steps:
|
|
52
|
+
1. Load TDT block
|
|
53
|
+
2. Extract 465 nm (signal) and optional 415 nm (reference)
|
|
54
|
+
3. Perform regression-based motion correction
|
|
55
|
+
4. Correct photobleaching trend
|
|
56
|
+
5. Compute ΔF/F
|
|
57
|
+
6. Denoise signal
|
|
58
|
+
7. Extract event markers
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
folder_path : str
|
|
63
|
+
Path to TDT recording folder.
|
|
64
|
+
|
|
65
|
+
Returns
|
|
66
|
+
-------
|
|
67
|
+
dict
|
|
68
|
+
Processed signals and metadata:
|
|
69
|
+
- x: time vector
|
|
70
|
+
- raw: corrected fluorescence signal
|
|
71
|
+
- corr: final ΔF/F (denoised)
|
|
72
|
+
- dff: same as corr (alias)
|
|
73
|
+
- f0: bleaching baseline
|
|
74
|
+
- fs: sampling frequency
|
|
75
|
+
- store: signal label
|
|
76
|
+
- markers: behavioral event markers
|
|
77
|
+
"""
|
|
78
|
+
data_struct = get_tdt_struct(folder_path)
|
|
79
|
+
streams = data_struct.streams.keys()
|
|
80
|
+
|
|
81
|
+
name_465 = next((s for s in streams if '465' in s), None)
|
|
82
|
+
name_415 = next((s for s in streams if '415' in s), None)
|
|
83
|
+
|
|
84
|
+
if not name_465:
|
|
85
|
+
raise ValueError("No 465 signal found")
|
|
86
|
+
|
|
87
|
+
x, y_465, fs = get_plot_data(data_struct, name_465)
|
|
88
|
+
|
|
89
|
+
if name_415:
|
|
90
|
+
_, y_415, _ = get_plot_data(data_struct, name_415)
|
|
91
|
+
p = np.polyfit(y_415, y_465, 1)
|
|
92
|
+
y_fit = np.polyval(p, y_415)
|
|
93
|
+
y_final = y_465 - y_fit
|
|
94
|
+
display_name = f"Corrected {name_465} (via {name_415})"
|
|
95
|
+
else:
|
|
96
|
+
y_final = y_465
|
|
97
|
+
display_name = f"{name_465} (Uncorrected)"
|
|
98
|
+
|
|
99
|
+
_, trend = correct_bleaching(y_final, fs)
|
|
100
|
+
|
|
101
|
+
f0 = np.maximum(trend, 1e-6)
|
|
102
|
+
dff = (y_final - f0) / f0
|
|
103
|
+
dff = denoise_signal(dff, fs, cutoff=5)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
"x": x,
|
|
107
|
+
"raw": y_final,
|
|
108
|
+
"corr": dff,
|
|
109
|
+
"dff": dff,
|
|
110
|
+
"f0": f0,
|
|
111
|
+
"fs": fs,
|
|
112
|
+
"store": display_name,
|
|
113
|
+
"markers": get_event_markers(data_struct),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_tdt_struct(path):
|
|
118
|
+
"""
|
|
119
|
+
Load a Tucker-Davis Technologies (TDT) recording block.
|
|
120
|
+
|
|
121
|
+
Streams and scalars are read in a single call (unaffected by the issue
|
|
122
|
+
below). Epoc stores are auto-discovered from the block's own headers
|
|
123
|
+
and then read ONE STORE AT A TIME, each with its own fresh
|
|
124
|
+
tdt.read_block() call:
|
|
125
|
+
|
|
126
|
+
- The `tdt` SDK has a bug where reading multiple epoc stores together
|
|
127
|
+
breaks if any store has a mismatched onset/offset count (e.g. a
|
|
128
|
+
strobe/epoch that was still active when the recording was stopped —
|
|
129
|
+
a completely normal way for a session to end). Reading one store per
|
|
130
|
+
call sidesteps it; a store that still fails to a genuine data issue
|
|
131
|
+
is skipped with a warning rather than crashing the entire load.
|
|
132
|
+
- Store names must come from each store's own `.name` attribute, not
|
|
133
|
+
the header dict's key — TDT sanitizes store codes containing `/`
|
|
134
|
+
into a trailing-underscore Python attribute name (e.g. dict key
|
|
135
|
+
"EE1_" but the real store code passed to `store=` is "EE1/").
|
|
136
|
+
- Epoc reads must NOT reuse the cached `headers=` object from the
|
|
137
|
+
initial header scan — doing so carries over the same mismatched
|
|
138
|
+
onset/offset state that causes the crash in the first place. Only
|
|
139
|
+
the streams/scalars read benefits from header reuse.
|
|
140
|
+
|
|
141
|
+
This entirely auto-discovers whatever stores a given recording
|
|
142
|
+
contains — nothing about store names/types is hardcoded, so it works
|
|
143
|
+
for any TDT block regardless of how it was configured in Synapse.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
path : str
|
|
148
|
+
Folder containing TDT data.
|
|
149
|
+
|
|
150
|
+
Returns
|
|
151
|
+
-------
|
|
152
|
+
object
|
|
153
|
+
Parsed TDT data structure (data.streams, data.epocs, data.scalars).
|
|
154
|
+
"""
|
|
155
|
+
import warnings
|
|
156
|
+
|
|
157
|
+
heads = tdt.read_block(path, headers=1)
|
|
158
|
+
if heads is None:
|
|
159
|
+
raise Exception("TDT returned an empty object.")
|
|
160
|
+
|
|
161
|
+
data = tdt.read_block(path, headers=heads, evtype=['streams', 'scalars'], verbose=0)
|
|
162
|
+
if data is None:
|
|
163
|
+
raise Exception("TDT returned an empty object.")
|
|
164
|
+
|
|
165
|
+
epoc_stores = [(key, s.name) for key, s in heads.stores.items()
|
|
166
|
+
if getattr(s, 'type_str', None) == 'epocs']
|
|
167
|
+
|
|
168
|
+
for key, real_name in epoc_stores:
|
|
169
|
+
try:
|
|
170
|
+
# No headers= reuse here — see docstring.
|
|
171
|
+
ep = tdt.read_block(path, store=real_name, evtype=['epocs'], verbose=0)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
warnings.warn(
|
|
174
|
+
f"Skipping epoc store '{key}' ({real_name}): {e}",
|
|
175
|
+
RuntimeWarning, stacklevel=2,
|
|
176
|
+
)
|
|
177
|
+
continue
|
|
178
|
+
if not ep.epocs:
|
|
179
|
+
continue
|
|
180
|
+
store_key = next(iter(ep.epocs.keys()))
|
|
181
|
+
setattr(data.epocs, key, ep.epocs[store_key])
|
|
182
|
+
|
|
183
|
+
return data
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_plot_data(data, store_name, channel=0, max_points=None):
|
|
187
|
+
"""
|
|
188
|
+
Extract time-series data from a TDT stream.
|
|
189
|
+
|
|
190
|
+
Parameters
|
|
191
|
+
----------
|
|
192
|
+
data : object
|
|
193
|
+
TDT data structure
|
|
194
|
+
store_name : str
|
|
195
|
+
Stream name (e.g., 'x465A')
|
|
196
|
+
channel : int
|
|
197
|
+
Channel index
|
|
198
|
+
max_points : int or None
|
|
199
|
+
Optional downsampling limit
|
|
200
|
+
|
|
201
|
+
Returns
|
|
202
|
+
-------
|
|
203
|
+
tuple
|
|
204
|
+
(time array, signal array, sampling frequency)
|
|
205
|
+
"""
|
|
206
|
+
stream = data.streams[store_name]
|
|
207
|
+
fs = stream.fs
|
|
208
|
+
|
|
209
|
+
data_2d = np.atleast_2d(stream.data)
|
|
210
|
+
if channel >= data_2d.shape[0]:
|
|
211
|
+
channel = 0
|
|
212
|
+
|
|
213
|
+
y_full = data_2d[channel, :]
|
|
214
|
+
|
|
215
|
+
if max_points:
|
|
216
|
+
ds_factor = max(1, len(y_full) // max_points)
|
|
217
|
+
y = y_full[::ds_factor]
|
|
218
|
+
x = np.arange(len(y)) * (ds_factor / fs)
|
|
219
|
+
else:
|
|
220
|
+
y = y_full
|
|
221
|
+
x = np.arange(len(y)) / fs
|
|
222
|
+
|
|
223
|
+
return x, y, fs
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def correct_bleaching(y, fs):
|
|
227
|
+
"""
|
|
228
|
+
Estimate and correct photobleaching using masked curve fitting.
|
|
229
|
+
|
|
230
|
+
Returns
|
|
231
|
+
-------
|
|
232
|
+
corrected : array
|
|
233
|
+
Bleaching-corrected signal
|
|
234
|
+
trend : array
|
|
235
|
+
Estimated baseline trend
|
|
236
|
+
"""
|
|
237
|
+
x = np.arange(len(y)) / fs
|
|
238
|
+
|
|
239
|
+
threshold = np.median(y)
|
|
240
|
+
mask = y > threshold
|
|
241
|
+
x_fit, y_fit = x[mask], y[mask]
|
|
242
|
+
|
|
243
|
+
if len(y_fit) < 100:
|
|
244
|
+
return y, np.zeros_like(y)
|
|
245
|
+
|
|
246
|
+
k_guess = np.percentile(y_fit, 10)
|
|
247
|
+
total_amp = np.max(y_fit) - k_guess
|
|
248
|
+
p0 = [total_amp * 0.6, 0.05, total_amp * 0.4, 0.0001, k_guess]
|
|
249
|
+
lower = [0, 0, 0, 0, k_guess * 0.8]
|
|
250
|
+
upper = [np.inf, 1, np.inf, 0.1, np.max(y_fit)]
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
popt, _ = curve_fit(double_exponential, x_fit, y_fit, p0=p0,
|
|
254
|
+
bounds=(lower, upper), maxfev=10000)
|
|
255
|
+
trend = double_exponential(x, *popt)
|
|
256
|
+
except Exception:
|
|
257
|
+
# curve_fit failed — fall back to log-linear fit as a rough trend estimate
|
|
258
|
+
import warnings
|
|
259
|
+
warnings.warn(
|
|
260
|
+
"correct_bleaching: double-exponential fit failed; falling back to log-linear trend.",
|
|
261
|
+
RuntimeWarning, stacklevel=2,
|
|
262
|
+
)
|
|
263
|
+
coeffs = np.polyfit(x_fit, np.log(np.maximum(y_fit, 1e-6)), 1)
|
|
264
|
+
trend = np.exp(np.polyval(coeffs, x))
|
|
265
|
+
|
|
266
|
+
corrected = y - trend + trend[0]
|
|
267
|
+
return corrected, trend
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_event_markers(data):
|
|
271
|
+
"""
|
|
272
|
+
Extract behavioral event markers from every populated TDT epoc store.
|
|
273
|
+
|
|
274
|
+
A recording can easily have a dozen+ epoc stores (I/O strobes, Epoch
|
|
275
|
+
Event Storage, free-text notes, a 1-second Tick reference, ...) —
|
|
276
|
+
every one of them becomes its own marker group here, tagged by store
|
|
277
|
+
name via the 'store' key, so a caller (e.g. a GUI) can let the user
|
|
278
|
+
toggle each store's markers independently instead of dumping every
|
|
279
|
+
store onto the plot at once (which overlaps into an unreadable mess).
|
|
280
|
+
|
|
281
|
+
Every epoc is a state that goes high (onset) and later low (offset) —
|
|
282
|
+
for a lever press that's press/release, for a pump or light that's
|
|
283
|
+
on/off. Both are emitted here, each tagged with a 'phase' key ('high'
|
|
284
|
+
for onset, 'low' for offset), so a caller can decide per store whether
|
|
285
|
+
it cares about both edges (e.g. how long the pump ran) or only one
|
|
286
|
+
(e.g. just the press, not the release) instead of that decision being
|
|
287
|
+
baked into the library.
|
|
288
|
+
|
|
289
|
+
The 'Note' store keeps its original behaviour: each onset becomes a
|
|
290
|
+
marker labeled with the actual note text (from Notes.txt), and has no
|
|
291
|
+
offset/'low' marker — free-text annotations are instantaneous, not a
|
|
292
|
+
state with a duration. Every other epoc store gets one 'high' marker
|
|
293
|
+
per onset and one 'low' marker per offset, both labeled with the store
|
|
294
|
+
name.
|
|
295
|
+
|
|
296
|
+
Nothing about which stores exist is hardcoded — this walks whatever
|
|
297
|
+
data.epocs actually contains, so it works for any TDT block.
|
|
298
|
+
|
|
299
|
+
Returns
|
|
300
|
+
-------
|
|
301
|
+
list of dict
|
|
302
|
+
Each dict contains:
|
|
303
|
+
- time : float
|
|
304
|
+
- label : str
|
|
305
|
+
- color : str
|
|
306
|
+
- store : str (source epoc store name, for grouping/toggling)
|
|
307
|
+
- phase : str ('high'=onset, 'low'=offset; omitted for Note markers)
|
|
308
|
+
"""
|
|
309
|
+
if not hasattr(data, 'epocs'):
|
|
310
|
+
return []
|
|
311
|
+
|
|
312
|
+
# Experiment-specific label→colour mapping for Note text; every other
|
|
313
|
+
# store cycles through a fixed palette so each store gets a distinct,
|
|
314
|
+
# consistent colour across redraws.
|
|
315
|
+
note_color_map = {'Clap': 'red', 'Sucrose': 'green', 'Stop': 'blue'}
|
|
316
|
+
palette = ['black', 'purple', 'orange', 'saddlebrown', 'teal', 'magenta',
|
|
317
|
+
'darkgreen', 'navy', 'gray', 'olive']
|
|
318
|
+
|
|
319
|
+
markers = []
|
|
320
|
+
store_keys = sorted(k for k in data.epocs.keys())
|
|
321
|
+
|
|
322
|
+
for i, store_key in enumerate(store_keys):
|
|
323
|
+
epoc = data.epocs[store_key]
|
|
324
|
+
onsets = getattr(epoc, 'onset', [])
|
|
325
|
+
if len(onsets) == 0:
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
display_name = store_key.rstrip('_') # cosmetic: TDT pads slash-containing codes with a trailing "_"
|
|
329
|
+
|
|
330
|
+
if store_key == 'Note':
|
|
331
|
+
notes = getattr(epoc, 'notes', [])
|
|
332
|
+
for n, t in zip(notes, onsets):
|
|
333
|
+
note_str = n.decode() if isinstance(n, bytes) else str(n)
|
|
334
|
+
note_str = note_str.strip()
|
|
335
|
+
markers.append({
|
|
336
|
+
'time': float(t),
|
|
337
|
+
'label': note_str,
|
|
338
|
+
'color': note_color_map.get(note_str, 'black'),
|
|
339
|
+
'store': display_name,
|
|
340
|
+
})
|
|
341
|
+
else:
|
|
342
|
+
color = palette[i % len(palette)]
|
|
343
|
+
for t in onsets:
|
|
344
|
+
markers.append({
|
|
345
|
+
'time': float(t),
|
|
346
|
+
'label': display_name,
|
|
347
|
+
'color': color,
|
|
348
|
+
'store': display_name,
|
|
349
|
+
'phase': 'high',
|
|
350
|
+
})
|
|
351
|
+
offsets = getattr(epoc, 'offset', [])
|
|
352
|
+
for t in offsets:
|
|
353
|
+
# TDT represents a strobe still active at recording end with
|
|
354
|
+
# offset=inf — not a real timestamp, nothing to plot.
|
|
355
|
+
if not np.isfinite(t):
|
|
356
|
+
continue
|
|
357
|
+
markers.append({
|
|
358
|
+
'time': float(t),
|
|
359
|
+
'label': display_name,
|
|
360
|
+
'color': color,
|
|
361
|
+
'store': display_name,
|
|
362
|
+
'phase': 'low',
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
return markers
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def denoise_signal(signal, fs, cutoff=5, order=2):
|
|
369
|
+
"""
|
|
370
|
+
Low-pass Butterworth filter for ΔF/F signals.
|
|
371
|
+
|
|
372
|
+
Parameters
|
|
373
|
+
----------
|
|
374
|
+
cutoff : float
|
|
375
|
+
Cutoff frequency in Hz
|
|
376
|
+
order : int
|
|
377
|
+
Filter order
|
|
378
|
+
|
|
379
|
+
Returns
|
|
380
|
+
-------
|
|
381
|
+
array
|
|
382
|
+
Filtered signal
|
|
383
|
+
"""
|
|
384
|
+
nyquist = fs / 2
|
|
385
|
+
normal_cutoff = cutoff / nyquist
|
|
386
|
+
b, a = butter(order, normal_cutoff, btype='low', analog=False)
|
|
387
|
+
return filtfilt(b, a, signal)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ZaksPhysicsLibrary"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.4.0"
|
|
8
8
|
description = "Data processing and analysis library for TDT, Oxysoft NIRS, and Terranova EFNMR lab data"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
readme = "README.md"
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
processing_TDT.py
|
|
3
|
-
-----------------
|
|
4
|
-
Signal processing pipeline for TDT (Tucker-Davis Technologies) fibre photometry data.
|
|
5
|
-
|
|
6
|
-
Handles loading, bleaching correction, denoising, and event marker extraction.
|
|
7
|
-
Depends on the `tdt` Python SDK for reading tank files.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import os
|
|
11
|
-
|
|
12
|
-
import numpy as np
|
|
13
|
-
from scipy.optimize import curve_fit
|
|
14
|
-
from scipy.signal import butter, filtfilt
|
|
15
|
-
import tdt
|
|
16
|
-
|
|
17
|
-
from .models import double_exponential_model as double_exponential
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def validate_tdt_folder(path):
|
|
21
|
-
"""
|
|
22
|
-
Validate whether a directory contains a TDT recording block.
|
|
23
|
-
|
|
24
|
-
Parameters
|
|
25
|
-
----------
|
|
26
|
-
path : str
|
|
27
|
-
Path to the TDT data folder.
|
|
28
|
-
|
|
29
|
-
Returns
|
|
30
|
-
-------
|
|
31
|
-
tuple
|
|
32
|
-
(bool, str)
|
|
33
|
-
- True + folder name if valid
|
|
34
|
-
- False + error message if invalid
|
|
35
|
-
"""
|
|
36
|
-
if not path:
|
|
37
|
-
return False, "No folder selected."
|
|
38
|
-
|
|
39
|
-
has_tbk = any(fname.endswith('.Tbk') for fname in os.listdir(path))
|
|
40
|
-
|
|
41
|
-
if has_tbk:
|
|
42
|
-
return True, os.path.basename(path)
|
|
43
|
-
else:
|
|
44
|
-
return False, "Invalid Folder: No TDT block files (.Tbk) found."
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def process_tdt_folder(folder_path):
|
|
48
|
-
"""
|
|
49
|
-
Full photometry processing pipeline for a TDT recording.
|
|
50
|
-
|
|
51
|
-
Steps:
|
|
52
|
-
1. Load TDT block
|
|
53
|
-
2. Extract 465 nm (signal) and optional 415 nm (reference)
|
|
54
|
-
3. Perform regression-based motion correction
|
|
55
|
-
4. Correct photobleaching trend
|
|
56
|
-
5. Compute ΔF/F
|
|
57
|
-
6. Denoise signal
|
|
58
|
-
7. Extract event markers
|
|
59
|
-
|
|
60
|
-
Parameters
|
|
61
|
-
----------
|
|
62
|
-
folder_path : str
|
|
63
|
-
Path to TDT recording folder.
|
|
64
|
-
|
|
65
|
-
Returns
|
|
66
|
-
-------
|
|
67
|
-
dict
|
|
68
|
-
Processed signals and metadata:
|
|
69
|
-
- x: time vector
|
|
70
|
-
- raw: corrected fluorescence signal
|
|
71
|
-
- corr: final ΔF/F (denoised)
|
|
72
|
-
- dff: same as corr (alias)
|
|
73
|
-
- f0: bleaching baseline
|
|
74
|
-
- fs: sampling frequency
|
|
75
|
-
- store: signal label
|
|
76
|
-
- markers: behavioral event markers
|
|
77
|
-
"""
|
|
78
|
-
data_struct = get_tdt_struct(folder_path)
|
|
79
|
-
streams = data_struct.streams.keys()
|
|
80
|
-
|
|
81
|
-
name_465 = next((s for s in streams if '465' in s), None)
|
|
82
|
-
name_415 = next((s for s in streams if '415' in s), None)
|
|
83
|
-
|
|
84
|
-
if not name_465:
|
|
85
|
-
raise ValueError("No 465 signal found")
|
|
86
|
-
|
|
87
|
-
x, y_465, fs = get_plot_data(data_struct, name_465)
|
|
88
|
-
|
|
89
|
-
if name_415:
|
|
90
|
-
_, y_415, _ = get_plot_data(data_struct, name_415)
|
|
91
|
-
p = np.polyfit(y_415, y_465, 1)
|
|
92
|
-
y_fit = np.polyval(p, y_415)
|
|
93
|
-
y_final = y_465 - y_fit
|
|
94
|
-
display_name = f"Corrected {name_465} (via {name_415})"
|
|
95
|
-
else:
|
|
96
|
-
y_final = y_465
|
|
97
|
-
display_name = f"{name_465} (Uncorrected)"
|
|
98
|
-
|
|
99
|
-
_, trend = correct_bleaching(y_final, fs)
|
|
100
|
-
|
|
101
|
-
f0 = np.maximum(trend, 1e-6)
|
|
102
|
-
dff = (y_final - f0) / f0
|
|
103
|
-
dff = denoise_signal(dff, fs, cutoff=5)
|
|
104
|
-
|
|
105
|
-
return {
|
|
106
|
-
"x": x,
|
|
107
|
-
"raw": y_final,
|
|
108
|
-
"corr": dff,
|
|
109
|
-
"dff": dff,
|
|
110
|
-
"f0": f0,
|
|
111
|
-
"fs": fs,
|
|
112
|
-
"store": display_name,
|
|
113
|
-
"markers": get_event_markers(data_struct),
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def get_tdt_struct(path):
|
|
118
|
-
"""
|
|
119
|
-
Load a Tucker-Davis Technologies (TDT) recording block.
|
|
120
|
-
|
|
121
|
-
Parameters
|
|
122
|
-
----------
|
|
123
|
-
path : str
|
|
124
|
-
Folder containing TDT data.
|
|
125
|
-
|
|
126
|
-
Returns
|
|
127
|
-
-------
|
|
128
|
-
object
|
|
129
|
-
Parsed TDT data structure.
|
|
130
|
-
"""
|
|
131
|
-
data = tdt.read_block(path)
|
|
132
|
-
if data is None:
|
|
133
|
-
raise Exception("TDT returned an empty object.")
|
|
134
|
-
return data
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def get_plot_data(data, store_name, channel=0, max_points=None):
|
|
138
|
-
"""
|
|
139
|
-
Extract time-series data from a TDT stream.
|
|
140
|
-
|
|
141
|
-
Parameters
|
|
142
|
-
----------
|
|
143
|
-
data : object
|
|
144
|
-
TDT data structure
|
|
145
|
-
store_name : str
|
|
146
|
-
Stream name (e.g., 'x465A')
|
|
147
|
-
channel : int
|
|
148
|
-
Channel index
|
|
149
|
-
max_points : int or None
|
|
150
|
-
Optional downsampling limit
|
|
151
|
-
|
|
152
|
-
Returns
|
|
153
|
-
-------
|
|
154
|
-
tuple
|
|
155
|
-
(time array, signal array, sampling frequency)
|
|
156
|
-
"""
|
|
157
|
-
stream = data.streams[store_name]
|
|
158
|
-
fs = stream.fs
|
|
159
|
-
|
|
160
|
-
data_2d = np.atleast_2d(stream.data)
|
|
161
|
-
if channel >= data_2d.shape[0]:
|
|
162
|
-
channel = 0
|
|
163
|
-
|
|
164
|
-
y_full = data_2d[channel, :]
|
|
165
|
-
|
|
166
|
-
if max_points:
|
|
167
|
-
ds_factor = max(1, len(y_full) // max_points)
|
|
168
|
-
y = y_full[::ds_factor]
|
|
169
|
-
x = np.arange(len(y)) * (ds_factor / fs)
|
|
170
|
-
else:
|
|
171
|
-
y = y_full
|
|
172
|
-
x = np.arange(len(y)) / fs
|
|
173
|
-
|
|
174
|
-
return x, y, fs
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def correct_bleaching(y, fs):
|
|
178
|
-
"""
|
|
179
|
-
Estimate and correct photobleaching using masked curve fitting.
|
|
180
|
-
|
|
181
|
-
Returns
|
|
182
|
-
-------
|
|
183
|
-
corrected : array
|
|
184
|
-
Bleaching-corrected signal
|
|
185
|
-
trend : array
|
|
186
|
-
Estimated baseline trend
|
|
187
|
-
"""
|
|
188
|
-
x = np.arange(len(y)) / fs
|
|
189
|
-
|
|
190
|
-
threshold = np.median(y)
|
|
191
|
-
mask = y > threshold
|
|
192
|
-
x_fit, y_fit = x[mask], y[mask]
|
|
193
|
-
|
|
194
|
-
if len(y_fit) < 100:
|
|
195
|
-
return y, np.zeros_like(y)
|
|
196
|
-
|
|
197
|
-
k_guess = np.percentile(y_fit, 10)
|
|
198
|
-
total_amp = np.max(y_fit) - k_guess
|
|
199
|
-
p0 = [total_amp * 0.6, 0.05, total_amp * 0.4, 0.0001, k_guess]
|
|
200
|
-
lower = [0, 0, 0, 0, k_guess * 0.8]
|
|
201
|
-
upper = [np.inf, 1, np.inf, 0.1, np.max(y_fit)]
|
|
202
|
-
|
|
203
|
-
try:
|
|
204
|
-
popt, _ = curve_fit(double_exponential, x_fit, y_fit, p0=p0,
|
|
205
|
-
bounds=(lower, upper), maxfev=10000)
|
|
206
|
-
trend = double_exponential(x, *popt)
|
|
207
|
-
except Exception:
|
|
208
|
-
# curve_fit failed — fall back to log-linear fit as a rough trend estimate
|
|
209
|
-
import warnings
|
|
210
|
-
warnings.warn(
|
|
211
|
-
"correct_bleaching: double-exponential fit failed; falling back to log-linear trend.",
|
|
212
|
-
RuntimeWarning, stacklevel=2,
|
|
213
|
-
)
|
|
214
|
-
coeffs = np.polyfit(x_fit, np.log(np.maximum(y_fit, 1e-6)), 1)
|
|
215
|
-
trend = np.exp(np.polyval(coeffs, x))
|
|
216
|
-
|
|
217
|
-
corrected = y - trend + trend[0]
|
|
218
|
-
return corrected, trend
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def get_event_markers(data):
|
|
222
|
-
"""
|
|
223
|
-
Extract behavioral event markers from TDT epoc data.
|
|
224
|
-
|
|
225
|
-
Returns
|
|
226
|
-
-------
|
|
227
|
-
list of dict
|
|
228
|
-
Each dict contains:
|
|
229
|
-
- time
|
|
230
|
-
- label
|
|
231
|
-
- color
|
|
232
|
-
"""
|
|
233
|
-
if not hasattr(data.epocs, 'Note'):
|
|
234
|
-
return []
|
|
235
|
-
|
|
236
|
-
notes = data.epocs.Note.notes
|
|
237
|
-
onsets = data.epocs.Note.onset
|
|
238
|
-
|
|
239
|
-
# Experiment-specific label→colour mapping; unknown labels default to black.
|
|
240
|
-
color_map = {'Clap': 'red', 'Sucrose': 'green', 'Stop': 'blue'}
|
|
241
|
-
markers = []
|
|
242
|
-
|
|
243
|
-
for n, t in zip(notes, onsets):
|
|
244
|
-
note_str = n.decode() if isinstance(n, bytes) else str(n)
|
|
245
|
-
note_str = note_str.strip()
|
|
246
|
-
markers.append({
|
|
247
|
-
'time': t,
|
|
248
|
-
'label': note_str,
|
|
249
|
-
'color': color_map.get(note_str, 'black'),
|
|
250
|
-
})
|
|
251
|
-
return markers
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def denoise_signal(signal, fs, cutoff=5, order=2):
|
|
255
|
-
"""
|
|
256
|
-
Low-pass Butterworth filter for ΔF/F signals.
|
|
257
|
-
|
|
258
|
-
Parameters
|
|
259
|
-
----------
|
|
260
|
-
cutoff : float
|
|
261
|
-
Cutoff frequency in Hz
|
|
262
|
-
order : int
|
|
263
|
-
Filter order
|
|
264
|
-
|
|
265
|
-
Returns
|
|
266
|
-
-------
|
|
267
|
-
array
|
|
268
|
-
Filtered signal
|
|
269
|
-
"""
|
|
270
|
-
nyquist = fs / 2
|
|
271
|
-
normal_cutoff = cutoff / nyquist
|
|
272
|
-
b, a = butter(order, normal_cutoff, btype='low', analog=False)
|
|
273
|
-
return filtfilt(b, a, signal)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/loaders/oxysoft_loader.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/ZaksPhysicsLibrary.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/ZaksPhysicsLibrary.egg-info/requires.txt
RENAMED
|
File without changes
|
{zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/ZaksPhysicsLibrary.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|