ZaksPhysicsLibrary 1.5.0__tar.gz → 1.7.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 (24) hide show
  1. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/PKG-INFO +2 -2
  2. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/PhysicsLibrary/__init__.py +8 -0
  3. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/PhysicsLibrary/analysis.py +193 -0
  4. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/PhysicsLibrary/loaders/oxysoft_loader.py +7 -0
  5. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/PhysicsLibrary/processing_TDT.py +10 -0
  6. zaksphysicslibrary-1.7.0/PhysicsLibrary/splice.py +113 -0
  7. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/ZaksPhysicsLibrary.egg-info/PKG-INFO +2 -2
  8. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/ZaksPhysicsLibrary.egg-info/SOURCES.txt +1 -0
  9. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/pyproject.toml +2 -2
  10. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/LICENSE +0 -0
  11. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/PhysicsLibrary/dataset.py +0 -0
  12. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/PhysicsLibrary/field_study_validation.py +0 -0
  13. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/PhysicsLibrary/file_parser.py +0 -0
  14. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/PhysicsLibrary/file_parser_generic.py +0 -0
  15. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/PhysicsLibrary/loaders/__init__.py +0 -0
  16. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/PhysicsLibrary/loaders/pt2_loader.py +0 -0
  17. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/PhysicsLibrary/loaders/tdt_loader.py +0 -0
  18. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/PhysicsLibrary/models.py +0 -0
  19. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/PhysicsLibrary/text_field_study.py +0 -0
  20. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/README.md +0 -0
  21. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/ZaksPhysicsLibrary.egg-info/dependency_links.txt +0 -0
  22. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/ZaksPhysicsLibrary.egg-info/requires.txt +0 -0
  23. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/ZaksPhysicsLibrary.egg-info/top_level.txt +0 -0
  24. {zaksphysicslibrary-1.5.0 → zaksphysicslibrary-1.7.0}/setup.cfg +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ZaksPhysicsLibrary
3
- Version: 1.5.0
4
- Summary: Data processing and analysis library for TDT, Oxysoft NIRS, Terranova EFNMR lab data, and grouped-text-field studies
3
+ Version: 1.7.0
4
+ Summary: Data processing and analysis library for TDT, Oxysoft NIRS, Terranova EFNMR lab data, and text-field studies
5
5
  Author: zakgm2
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/zakgm2/PhysicsLibrary
@@ -45,6 +45,14 @@ from .analysis import (
45
45
  annotate_fft_peaks,
46
46
  compute_slope_segment,
47
47
  fit_model_to_segment,
48
+ compute_event_zscore_peth,
49
+ find_significant_peaks,
50
+ find_peak_near_events,
51
+ )
52
+
53
+ from .splice import (
54
+ splice_keep_inside,
55
+ splice_cut_out,
48
56
  )
49
57
 
50
58
  from .models import (
@@ -69,6 +69,199 @@ def get_zscore_slice(time_array, signal, center_t, window=None, pre=None, post=N
69
69
  return seg_x, (seg_y - mu) / std
70
70
 
71
71
 
72
+ def compute_event_zscore_peth(time_array, signal, event_times, pre, post, num_bins=300):
73
+ """
74
+ Z-score and align every occurrence of one event type into a
75
+ trial x time matrix, for a stacked-heatmap + trial-average PETH view
76
+ (GuPPy-style) rather than a single click-triggered PETH.
77
+
78
+ Each trial is z-scored independently against its own pre-event
79
+ baseline (see get_zscore_slice) — that's what makes a trial's
80
+ response comparable regardless of the signal's absolute level at
81
+ that point in the recording. Trials are then resampled onto one
82
+ shared relative-time axis (num_bins points spanning -pre..+post) so
83
+ they can be stacked into a single matrix despite each trial's raw
84
+ segment having a slightly different sample count from indexing
85
+ rounding.
86
+
87
+ Parameters
88
+ ----------
89
+ time_array : array
90
+ signal : array
91
+ event_times : list of float
92
+ Timestamps (same units as time_array) for every occurrence of
93
+ the event being analyzed.
94
+ pre, post : float
95
+ Seconds before/after each event to include.
96
+ num_bins : int
97
+ Number of points each trial is resampled to.
98
+
99
+ Returns
100
+ -------
101
+ dict with:
102
+ time_axis : array, shape (num_bins,) — relative time, -pre..+post
103
+ trial_matrix : array, shape (n_valid_trials, num_bins)
104
+ trial_event_times : list of the event_times that produced a
105
+ usable trial (too-short/edge-of-recording events are skipped)
106
+ mean_trace : array, shape (num_bins,)
107
+ sem_trace : array, shape (num_bins,) — standard error of the mean
108
+ across trials, zero if fewer than 2 trials
109
+ """
110
+ time_axis = np.linspace(-pre, post, num_bins)
111
+ rows = []
112
+ valid_times = []
113
+ for t in event_times:
114
+ seg_x, seg_z = get_zscore_slice(time_array, signal, t, pre=pre, post=post)
115
+ if seg_x is None or len(seg_x) < 2:
116
+ continue
117
+ rel_x = seg_x - t
118
+ rows.append(np.interp(time_axis, rel_x, seg_z))
119
+ valid_times.append(t)
120
+
121
+ if not rows:
122
+ empty = np.zeros(num_bins)
123
+ return {
124
+ "time_axis": time_axis, "trial_matrix": np.zeros((0, num_bins)),
125
+ "trial_event_times": [], "mean_trace": empty, "sem_trace": empty,
126
+ }
127
+
128
+ trial_matrix = np.array(rows)
129
+ mean_trace = trial_matrix.mean(axis=0)
130
+ if trial_matrix.shape[0] > 1:
131
+ sem_trace = trial_matrix.std(axis=0, ddof=1) / np.sqrt(trial_matrix.shape[0])
132
+ else:
133
+ sem_trace = np.zeros(num_bins)
134
+
135
+ return {
136
+ "time_axis": time_axis, "trial_matrix": trial_matrix,
137
+ "trial_event_times": valid_times, "mean_trace": mean_trace, "sem_trace": sem_trace,
138
+ }
139
+
140
+
141
+ def find_significant_peaks(time_array, signal, z_threshold=2.5, min_distance_sec=1.0,
142
+ include_troughs=False):
143
+ """
144
+ Auto-detect statistically significant transients directly from the
145
+ signal, rather than relying on externally-supplied event markers
146
+ (TDT epocs, manual markers, ...) that may not actually line up with
147
+ where the neural signal itself is doing something.
148
+
149
+ The whole recording is z-scored against its own global mean/std
150
+ (not a local baseline — this is a single-pass "how unusual is this
151
+ point relative to the entire recording" measure, not per-event), and
152
+ scipy.signal.find_peaks picks local maxima at or above z_threshold,
153
+ at least min_distance_sec apart so a single transient's rising edge
154
+ doesn't get counted as several peaks.
155
+
156
+ Parameters
157
+ ----------
158
+ time_array : array
159
+ signal : array
160
+ Already-processed signal (e.g. bleach-corrected + smoothed) —
161
+ this function does no filtering of its own.
162
+ z_threshold : float
163
+ Minimum z-score (standard deviations above the recording's own
164
+ mean) for a peak to count as "statistically significant".
165
+ min_distance_sec : float
166
+ Minimum spacing between detected peaks, in seconds.
167
+ include_troughs : bool
168
+ Also detect significant negative-going deflections (z <=
169
+ -z_threshold) — off by default since most fibre-photometry
170
+ analyses care about excitatory transients specifically.
171
+
172
+ Returns
173
+ -------
174
+ list of dict, each {"time": float, "z_score": float, "kind": "peak"|"trough"},
175
+ sorted by time.
176
+ """
177
+ fs = 1.0 / np.median(np.diff(time_array))
178
+ distance = max(1, int(min_distance_sec * fs))
179
+
180
+ mu, std = np.mean(signal), np.std(signal)
181
+ if std < 1e-9:
182
+ return []
183
+ z = (signal - mu) / std
184
+
185
+ results = []
186
+ peak_idx, _ = find_peaks(z, height=z_threshold, distance=distance)
187
+ for i in peak_idx:
188
+ results.append({"time": float(time_array[i]), "z_score": float(z[i]), "kind": "peak"})
189
+
190
+ if include_troughs:
191
+ trough_idx, _ = find_peaks(-z, height=z_threshold, distance=distance)
192
+ for i in trough_idx:
193
+ results.append({"time": float(time_array[i]), "z_score": float(z[i]), "kind": "trough"})
194
+
195
+ results.sort(key=lambda r: r["time"])
196
+ return results
197
+
198
+
199
+ def find_peak_near_events(time_array, signal, event_times, pre, post,
200
+ z_threshold=2.5, include_troughs=False):
201
+ """
202
+ Check whether a statistically significant peak actually shows up near
203
+ each given event time, rather than assuming the event marker itself
204
+ marks where the neural signal responds. Works for a single event
205
+ (event_times of length 1) or many occurrences of the same event type
206
+ (checking consistency across all of them).
207
+
208
+ Each event's window is baselined the same way as get_zscore_slice
209
+ (pre-event portion), so "significant" means relative to that event's
210
+ own local baseline, not the whole recording's.
211
+
212
+ Parameters
213
+ ----------
214
+ time_array : array
215
+ signal : array
216
+ event_times : list of float
217
+ pre, post : float
218
+ Seconds before/after each event to search within.
219
+ z_threshold : float
220
+ Minimum |z-score| within the window for a peak to count as found.
221
+ include_troughs : bool
222
+ Also consider negative-going deflections as candidate "peaks",
223
+ keeping whichever (peak or trough) is more extreme.
224
+
225
+ Returns
226
+ -------
227
+ list of dict, one per event_time (same order), each:
228
+ {"event_time": float, "found": bool, "peak_time": float or None,
229
+ "latency": float or None (peak_time - event_time),
230
+ "z_score": float or None, "kind": "peak"|"trough"|None}
231
+ "found" is False when the window was unusable (too close to the
232
+ recording's edges) or nothing in it reached z_threshold.
233
+ """
234
+ results = []
235
+ for t in event_times:
236
+ seg_x, seg_z = get_zscore_slice(time_array, signal, t, pre=pre, post=post)
237
+ if seg_x is None or len(seg_x) == 0:
238
+ results.append({"event_time": t, "found": False, "peak_time": None,
239
+ "latency": None, "z_score": None, "kind": None})
240
+ continue
241
+
242
+ idx_max = int(np.argmax(seg_z))
243
+ if include_troughs:
244
+ idx_min = int(np.argmin(seg_z))
245
+ if abs(seg_z[idx_min]) > seg_z[idx_max]:
246
+ best_idx, kind = idx_min, "trough"
247
+ else:
248
+ best_idx, kind = idx_max, "peak"
249
+ else:
250
+ best_idx, kind = idx_max, "peak"
251
+
252
+ best_z = float(seg_z[best_idx])
253
+ found = abs(best_z) >= z_threshold
254
+ peak_time = float(seg_x[best_idx]) if found else None
255
+ results.append({
256
+ "event_time": t, "found": found,
257
+ "peak_time": peak_time,
258
+ "latency": (peak_time - t) if found else None,
259
+ "z_score": best_z if found else None,
260
+ "kind": kind if found else None,
261
+ })
262
+ return results
263
+
264
+
72
265
  def smooth_signal(data, fs, window_sec=0.5):
73
266
  """
74
267
  Moving average smoothing filter.
@@ -88,6 +88,13 @@ def load_oxysoft_file(file_path: str) -> Dataset:
88
88
  """Parse a single Oxysoft .txt export into a Dataset."""
89
89
  folder_name = os.path.splitext(os.path.basename(file_path))[0]
90
90
  o2hb, hhb, events, metadata, ch_labels, sample_rate = _parse_oxysoft_txt(file_path)
91
+ if o2hb.ndim < 2 or o2hb.shape[0] == 0:
92
+ raise ValueError(
93
+ f"No O2Hb channels recognized in the Legend block of {os.path.basename(file_path)} — "
94
+ "this file's column labels don't match what this parser expects "
95
+ "(looks for 'O2Hb' in each Legend row's description). The file may use a "
96
+ "different Oxysoft export format/version than this parser was built against."
97
+ )
91
98
  n_ch, n_samp = o2hb.shape
92
99
  signals = np.concatenate([o2hb, hhb], axis=0)
93
100
  return Dataset(
@@ -341,6 +341,16 @@ def get_event_markers(data):
341
341
  else:
342
342
  color = palette[i % len(palette)]
343
343
  for t in onsets:
344
+ # A level-type epoc store (buffered, tracks a logic
345
+ # signal's on/off state — common for some Synapse Gizmo
346
+ # outputs) has no history before recording starts; if its
347
+ # logic already happened to be high the instant recording
348
+ # began, TDT inserts a synthetic onset at exactly t=0 to
349
+ # represent that pre-existing state, not a real event.
350
+ # Mirrors the offset=inf guard below for the same store
351
+ # type's opposite edge case (still active at the end).
352
+ if t == 0.0:
353
+ continue
344
354
  markers.append({
345
355
  'time': float(t),
346
356
  'label': display_name,
@@ -0,0 +1,113 @@
1
+ """
2
+ splice.py
3
+ ---------
4
+ Non-destructive time-range edits of a signal: trim to a range, or cut
5
+ one out and stitch the remainder back together. Pure array/dict
6
+ operations — no knowledge of any GUI's cache/context shape, just plain
7
+ x/raw/corr arrays and marker dicts (each with at least a 'time' key).
8
+
9
+ Two operations:
10
+ - keep_inside: the usual "trim to a window" splice.
11
+ - cut_out: removes a range from the middle and stitches the two
12
+ remaining pieces together. Everything after the cut is shifted
13
+ backward by the cut's duration so the timeline stays contiguous —
14
+ a gap in x would break every downstream analysis that assumes
15
+ uniform sampling (PETH windows, FFT, ...) right at the cut boundary.
16
+ Markers inside the removed range are dropped (that moment no longer
17
+ exists); markers after it are shifted by the same amount so they
18
+ stay aligned with the signal.
19
+ """
20
+
21
+ import numpy as np
22
+
23
+
24
+ def slice_markers_keep_inside(markers, start, end):
25
+ """Keeps original absolute timestamps (not re-zeroed to the splice
26
+ start) so every downstream analysis that reads times straight from
27
+ the marker dicts keeps working with no special-casing."""
28
+ return [dict(m) for m in markers if start <= m['time'] <= end]
29
+
30
+
31
+ def slice_markers_cut_out(markers, start, end, shift):
32
+ """Drops markers inside the removed range; shifts the rest that fall
33
+ after it by `shift` (the cut's duration) to stay aligned with the
34
+ signal's new, contiguous timeline."""
35
+ out = []
36
+ for m in markers:
37
+ t = m['time']
38
+ if start < t < end:
39
+ continue
40
+ m = dict(m)
41
+ if t >= end:
42
+ m['time'] = t - shift
43
+ out.append(m)
44
+ return out
45
+
46
+
47
+ def splice_keep_inside(x, raw, corr, markers, detected_markers, start, end):
48
+ """
49
+ Trims x/raw/corr to [start, end] and filters both marker lists to
50
+ the same range.
51
+
52
+ Parameters
53
+ ----------
54
+ x, raw, corr : array
55
+ markers, detected_markers : list of dict, each with a 'time' key
56
+ start, end : float
57
+
58
+ Returns
59
+ -------
60
+ dict with x, raw, corr (copies, not views), markers,
61
+ detected_markers, n_samples — or None if the range doesn't contain
62
+ at least 2 samples.
63
+ """
64
+ i0 = int(np.searchsorted(x, start, side='left'))
65
+ i1 = int(np.searchsorted(x, end, side='right'))
66
+ if i1 - i0 < 2:
67
+ return None
68
+
69
+ return {
70
+ "x": x[i0:i1].copy(),
71
+ "raw": raw[i0:i1].copy(),
72
+ "corr": corr[i0:i1].copy(),
73
+ "markers": slice_markers_keep_inside(markers, start, end),
74
+ "detected_markers": slice_markers_keep_inside(detected_markers, start, end),
75
+ "n_samples": i1 - i0,
76
+ }
77
+
78
+
79
+ def splice_cut_out(x, raw, corr, markers, detected_markers, start, end):
80
+ """
81
+ Removes [start, end] from x/raw/corr and stitches the remainder
82
+ together, shifting everything after the cut backward by the cut's
83
+ duration so the timeline stays contiguous. Markers inside the cut
84
+ are dropped; markers after it are shifted by the same amount.
85
+
86
+ Parameters
87
+ ----------
88
+ x, raw, corr : array
89
+ markers, detected_markers : list of dict, each with a 'time' key
90
+ start, end : float
91
+
92
+ Returns
93
+ -------
94
+ dict with x, raw, corr, markers, detected_markers, n_samples — or
95
+ None if there isn't usable signal on both sides of the cut to
96
+ stitch together.
97
+ """
98
+ i0 = int(np.searchsorted(x, start, side='left'))
99
+ i1 = int(np.searchsorted(x, end, side='right'))
100
+ if i0 < 1 or i1 >= len(x) - 1 or i1 <= i0:
101
+ return None
102
+
103
+ shift = float(x[i1] - x[i0]) # cut duration, used to reconnect the timeline
104
+ new_x = np.concatenate([x[:i0], x[i1:] - shift])
105
+
106
+ return {
107
+ "x": new_x,
108
+ "raw": np.concatenate([raw[:i0], raw[i1:]]),
109
+ "corr": np.concatenate([corr[:i0], corr[i1:]]),
110
+ "markers": slice_markers_cut_out(markers, start, end, shift),
111
+ "detected_markers": slice_markers_cut_out(detected_markers, start, end, shift),
112
+ "n_samples": len(new_x),
113
+ }
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ZaksPhysicsLibrary
3
- Version: 1.5.0
4
- Summary: Data processing and analysis library for TDT, Oxysoft NIRS, Terranova EFNMR lab data, and grouped-text-field studies
3
+ Version: 1.7.0
4
+ Summary: Data processing and analysis library for TDT, Oxysoft NIRS, Terranova EFNMR lab data, and text-field studies
5
5
  Author: zakgm2
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/zakgm2/PhysicsLibrary
@@ -9,6 +9,7 @@ PhysicsLibrary/file_parser.py
9
9
  PhysicsLibrary/file_parser_generic.py
10
10
  PhysicsLibrary/models.py
11
11
  PhysicsLibrary/processing_TDT.py
12
+ PhysicsLibrary/splice.py
12
13
  PhysicsLibrary/text_field_study.py
13
14
  PhysicsLibrary/loaders/__init__.py
14
15
  PhysicsLibrary/loaders/oxysoft_loader.py
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ZaksPhysicsLibrary"
7
- version = "1.5.0"
8
- description = "Data processing and analysis library for TDT, Oxysoft NIRS, Terranova EFNMR lab data, and grouped-text-field studies"
7
+ version = "1.7.0"
8
+ description = "Data processing and analysis library for TDT, Oxysoft NIRS, Terranova EFNMR lab data, and text-field studies"
9
9
  requires-python = ">=3.10"
10
10
  readme = "README.md"
11
11
  license = "MIT"