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.
Files changed (22) hide show
  1. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PKG-INFO +1 -1
  2. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/__init__.py +1 -2
  3. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/analysis.py +44 -14
  4. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/dataset.py +2 -24
  5. zaksphysicslibrary-1.4.0/PhysicsLibrary/processing_TDT.py +387 -0
  6. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/ZaksPhysicsLibrary.egg-info/PKG-INFO +1 -1
  7. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/pyproject.toml +1 -1
  8. zaksphysicslibrary-1.2.2/PhysicsLibrary/processing_TDT.py +0 -273
  9. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/LICENSE +0 -0
  10. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/file_parser.py +0 -0
  11. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/file_parser_generic.py +0 -0
  12. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/loaders/__init__.py +0 -0
  13. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/loaders/oxysoft_loader.py +0 -0
  14. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/loaders/pt2_loader.py +0 -0
  15. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/loaders/tdt_loader.py +0 -0
  16. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/PhysicsLibrary/models.py +0 -0
  17. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/README.md +0 -0
  18. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/ZaksPhysicsLibrary.egg-info/SOURCES.txt +0 -0
  19. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/ZaksPhysicsLibrary.egg-info/dependency_links.txt +0 -0
  20. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/ZaksPhysicsLibrary.egg-info/requires.txt +0 -0
  21. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/ZaksPhysicsLibrary.egg-info/top_level.txt +0 -0
  22. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ZaksPhysicsLibrary
3
- Version: 1.2.2
3
+ Version: 1.4.0
4
4
  Summary: Data processing and analysis library for TDT, Oxysoft NIRS, and Terranova EFNMR lab data
5
5
  Author: zakgm2
6
6
  License-Expression: MIT
@@ -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("PhysicsLibrary")
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=30):
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
- 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)
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
- baseline_end = len(seg_y) // 2
45
- baseline_period = seg_y[:baseline_end]
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=30):
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
- 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)
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 and folder selection. No parsing logic lives here — that's in
6
- loaders/.
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ZaksPhysicsLibrary
3
- Version: 1.2.2
3
+ Version: 1.4.0
4
4
  Summary: Data processing and analysis library for TDT, Oxysoft NIRS, and Terranova EFNMR lab data
5
5
  Author: zakgm2
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ZaksPhysicsLibrary"
7
- version = "1.2.2"
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)