ZaksPhysicsLibrary 1.2.2__tar.gz → 1.3.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 (21) hide show
  1. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/PKG-INFO +1 -1
  2. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/PhysicsLibrary/__init__.py +1 -2
  3. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/PhysicsLibrary/analysis.py +44 -14
  4. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/PhysicsLibrary/dataset.py +2 -24
  5. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/PhysicsLibrary/processing_TDT.py +112 -22
  6. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/ZaksPhysicsLibrary.egg-info/PKG-INFO +1 -1
  7. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/pyproject.toml +1 -1
  8. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/LICENSE +0 -0
  9. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/PhysicsLibrary/file_parser.py +0 -0
  10. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/PhysicsLibrary/file_parser_generic.py +0 -0
  11. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/PhysicsLibrary/loaders/__init__.py +0 -0
  12. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/PhysicsLibrary/loaders/oxysoft_loader.py +0 -0
  13. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/PhysicsLibrary/loaders/pt2_loader.py +0 -0
  14. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/PhysicsLibrary/loaders/tdt_loader.py +0 -0
  15. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/PhysicsLibrary/models.py +0 -0
  16. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/README.md +0 -0
  17. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/ZaksPhysicsLibrary.egg-info/SOURCES.txt +0 -0
  18. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/ZaksPhysicsLibrary.egg-info/dependency_links.txt +0 -0
  19. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/ZaksPhysicsLibrary.egg-info/requires.txt +0 -0
  20. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.0}/ZaksPhysicsLibrary.egg-info/top_level.txt +0 -0
  21. {zaksphysicslibrary-1.2.2 → zaksphysicslibrary-1.3.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.3.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
  # ---------------------------------------------------------------------------
@@ -118,6 +118,30 @@ def get_tdt_struct(path):
118
118
  """
119
119
  Load a Tucker-Davis Technologies (TDT) recording block.
120
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
+
121
145
  Parameters
122
146
  ----------
123
147
  path : str
@@ -126,11 +150,36 @@ def get_tdt_struct(path):
126
150
  Returns
127
151
  -------
128
152
  object
129
- Parsed TDT data structure.
153
+ Parsed TDT data structure (data.streams, data.epocs, data.scalars).
130
154
  """
131
- data = tdt.read_block(path)
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)
132
162
  if data is None:
133
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
+
134
183
  return data
135
184
 
136
185
 
@@ -220,34 +269,75 @@ def correct_bleaching(y, fs):
220
269
 
221
270
  def get_event_markers(data):
222
271
  """
223
- Extract behavioral event markers from TDT epoc data.
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
+ The 'Note' store keeps its original behaviour: each onset becomes a
282
+ marker labeled with the actual note text (from Notes.txt), since
283
+ that's the one store meant for free-text annotations. Every other
284
+ epoc store gets one marker per onset event, labeled with the store
285
+ name.
286
+
287
+ Nothing about which stores exist is hardcoded — this walks whatever
288
+ data.epocs actually contains, so it works for any TDT block.
224
289
 
225
290
  Returns
226
291
  -------
227
292
  list of dict
228
293
  Each dict contains:
229
- - time
230
- - label
231
- - color
294
+ - time : float
295
+ - label : str
296
+ - color : str
297
+ - store : str (source epoc store name, for grouping/toggling)
232
298
  """
233
- if not hasattr(data.epocs, 'Note'):
299
+ if not hasattr(data, 'epocs'):
234
300
  return []
235
301
 
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
- })
302
+ # Experiment-specific label→colour mapping for Note text; every other
303
+ # store cycles through a fixed palette so each store gets a distinct,
304
+ # consistent colour across redraws.
305
+ note_color_map = {'Clap': 'red', 'Sucrose': 'green', 'Stop': 'blue'}
306
+ palette = ['black', 'purple', 'orange', 'saddlebrown', 'teal', 'magenta',
307
+ 'darkgreen', 'navy', 'gray', 'olive']
308
+
309
+ markers = []
310
+ store_keys = sorted(k for k in data.epocs.keys())
311
+
312
+ for i, store_key in enumerate(store_keys):
313
+ epoc = data.epocs[store_key]
314
+ onsets = getattr(epoc, 'onset', [])
315
+ if len(onsets) == 0:
316
+ continue
317
+
318
+ display_name = store_key.rstrip('_') # cosmetic: TDT pads slash-containing codes with a trailing "_"
319
+
320
+ if store_key == 'Note':
321
+ notes = getattr(epoc, 'notes', [])
322
+ for n, t in zip(notes, onsets):
323
+ note_str = n.decode() if isinstance(n, bytes) else str(n)
324
+ note_str = note_str.strip()
325
+ markers.append({
326
+ 'time': float(t),
327
+ 'label': note_str,
328
+ 'color': note_color_map.get(note_str, 'black'),
329
+ 'store': display_name,
330
+ })
331
+ else:
332
+ color = palette[i % len(palette)]
333
+ for t in onsets:
334
+ markers.append({
335
+ 'time': float(t),
336
+ 'label': display_name,
337
+ 'color': color,
338
+ 'store': display_name,
339
+ })
340
+
251
341
  return markers
252
342
 
253
343
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ZaksPhysicsLibrary
3
- Version: 1.2.2
3
+ Version: 1.3.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.3.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"