npc-sync 0.2.6__tar.gz → 0.2.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: npc-sync
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: Repackaging of AllenSDK.SyncDataset with extra functionality, compatible with data in the cloud.
5
5
  Author-email: Ben Hardcastle <ben.hardcastle@alleninstitue.org>
6
6
  License: MIT
@@ -24,7 +24,7 @@ Requires-Dist: typing-extensions>=4.9.0
24
24
  Requires-Dist: npc-io>=0.1.26
25
25
  Requires-Dist: npc-session>=0.1.33
26
26
  Requires-Dist: scipy>=1.13.1
27
- Requires-Dist: npc-stim>=0.1.14
27
+ Requires-Dist: npc-stim>=0.1.23
28
28
  Dynamic: license-file
29
29
 
30
30
  # npc_sync
@@ -14,9 +14,9 @@ dependencies = [
14
14
  "npc-io>=0.1.26",
15
15
  "npc-session>=0.1.33",
16
16
  "scipy>=1.13.1",
17
- "npc-stim>=0.1.14",
17
+ "npc-stim>=0.1.23",
18
18
  ]
19
- version = "0.2.6"
19
+ version = "0.2.7"
20
20
  classifiers = [
21
21
  "Development Status :: 3 - Alpha",
22
22
  "Programming Language :: Python :: 3",
@@ -63,6 +63,11 @@ MIN_VSYNC_DIODE_FLIP_SEPARATION_SEC = 0.015 # 0.022 is typical
63
63
  FRAME_RATE = 59.95
64
64
  FRAME_INTERVAL = 1 / FRAME_RATE
65
65
  # we've seen apparently real flip intervals of 0.29 * 59.95, and the line between anomalous and real isn't clear-cut
66
+ REPLAY_SLOT_SAMPLES = 10_000
67
+ """Slot width, in samples, used to fingerprint sync events when looking for replayed
68
+ acquisition buffers. Buffer sizes seen so far (50,000 and 10,000 samples) are multiples
69
+ of this. Buffer size is not recorded in the sync file metadata."""
70
+
66
71
  SUSPICIOUS_INTERVAL_THRESHOLD = 0.3 / FRAME_RATE
67
72
  DEFINITE_SHORT_INTERVAL_THRESHOLD = 0.05 / FRAME_RATE
68
73
  # at 60 fps we should never have diode-flip intervals this small: consider them anomalous
@@ -154,6 +159,106 @@ def get_bit(uint_array: npt.NDArray, bit: int) -> npt.NDArray[np.uint8]:
154
159
  return np.bitwise_and(uint_array, 2**bit).astype(bool).astype(np.uint8)
155
160
 
156
161
 
162
+ def _splitmix64(x: npt.NDArray[np.uint64]) -> npt.NDArray[np.uint64]:
163
+ """Vectorized splitmix64 finalizer: scrambles 64-bit integers."""
164
+ x = x.astype(np.uint64, copy=True)
165
+ with np.errstate(over="ignore"):
166
+ x ^= x >> np.uint64(30)
167
+ x *= np.uint64(0xBF58476D1CE4E5B9)
168
+ x ^= x >> np.uint64(27)
169
+ x *= np.uint64(0x94D049BB133111EB)
170
+ x ^= x >> np.uint64(31)
171
+ return x
172
+
173
+
174
+ def find_replayed_buffers(
175
+ times: npt.ArrayLike,
176
+ bits: npt.ArrayLike,
177
+ slot_samples: int = REPLAY_SLOT_SAMPLES,
178
+ ) -> tuple[tuple[tuple[int, int], ...], int | None]:
179
+ """Find acquisition buffers in a sync event list that are replayed copies of a later
180
+ buffer.
181
+
182
+ Some sync files contain runs of consecutive buffers with identical event content on
183
+ every line (same sample times relative to the buffer start, same bit states). The
184
+ copies overwrite real data; the last buffer of each run is assumed to be genuine.
185
+
186
+ Method: split events into slots of `slot_samples`, fingerprint each non-empty slot
187
+ by summing a hash of each event's (time relative to slot start, bit state), then find
188
+ slots with identical fingerprints. The most common slot separation between identical
189
+ slots gives the buffer size, so any buffer size that is a multiple of `slot_samples`
190
+ is found. Buffers are assumed to be aligned to sample 0.
191
+
192
+ Parameters
193
+ ----------
194
+ times : array of int
195
+ Sample index of each event.
196
+ bits : array of int
197
+ Bit state (all lines) of each event.
198
+ slot_samples : int
199
+ Fingerprint slot width in samples. Must divide the buffer size.
200
+
201
+ Returns
202
+ -------
203
+ intervals : tuple of (start, stop) sample indices
204
+ Half-open intervals of replayed copies. `stop` is the start of the genuine buffer.
205
+ buffer_size : int or None
206
+ Inferred buffer size in samples, or None if no replay was found.
207
+
208
+ Examples
209
+ --------
210
+ >>> rng = np.random.default_rng(0)
211
+ >>> buffer, n_buffers = 50_000, 20
212
+ >>> rel = [np.sort(rng.choice(buffer, 100, replace=False)) for _ in range(n_buffers)]
213
+ >>> state = [rng.integers(0, 2**32, 100) for _ in range(n_buffers)]
214
+ >>> for i in (5, 6, 7): # buffers 5-7 are copies of buffer 8
215
+ ... rel[i], state[i] = rel[8], state[8]
216
+ >>> times = np.concatenate([r + i * buffer for i, r in enumerate(rel)])
217
+ >>> find_replayed_buffers(times, np.concatenate(state))
218
+ (((250000, 400000),), 50000)
219
+ >>> find_replayed_buffers(times[times < 250_000], np.concatenate(state)[times < 250_000])
220
+ ((), None)
221
+ """
222
+ times = np.asarray(times).astype(np.int64)
223
+ bits = np.asarray(bits).astype(np.uint64)
224
+ if times.size == 0:
225
+ return (), None
226
+ slot = times // slot_samples
227
+ starts = np.flatnonzero(np.r_[True, slot[1:] != slot[:-1]]) # non-empty slots only
228
+ tokens = ((times - slot * slot_samples).astype(np.uint64) << np.uint64(32)) | (
229
+ bits & np.uint64(0xFFFFFFFF)
230
+ )
231
+ with np.errstate(over="ignore"):
232
+ fingerprints = np.add.reduceat(_splitmix64(tokens), starts)
233
+ slots = slot[starts]
234
+ order = np.lexsort((slots, fingerprints)) # by fingerprint, then slot
235
+ fingerprints, slots = fingerprints[order], slots[order]
236
+ same = fingerprints[1:] == fingerprints[:-1]
237
+ if not same.any():
238
+ return (), None
239
+ earlier = slots[:-1][same]
240
+ lags = slots[1:][same] - earlier
241
+ values, counts = np.unique(lags, return_counts=True)
242
+ lag = int(values[np.argmax(counts)])
243
+ if len(values) > 1:
244
+ logger.warning(
245
+ "Identical sync slots found at more than one separation "
246
+ f"(in units of {slot_samples} samples): "
247
+ f"{dict(zip(values.tolist(), counts.tolist()))}. Using the most common ({lag})."
248
+ )
249
+ buffer_size = lag * slot_samples
250
+ copies = np.unique(
251
+ earlier[lags == lag] // lag
252
+ ) # buffer index of each replayed copy
253
+ breaks = np.flatnonzero(np.diff(copies) != 1)
254
+ first = copies[np.r_[0, breaks + 1]]
255
+ last = copies[np.r_[breaks, len(copies) - 1]]
256
+ intervals = tuple(
257
+ (int(a * buffer_size), int((z + 1) * buffer_size)) for a, z in zip(first, last)
258
+ )
259
+ return intervals, buffer_size
260
+
261
+
157
262
  def get_sync_line_for_stim_onset(
158
263
  waveform_type: (
159
264
  str | Literal["sound", "audio", "opto", "galvo", "laser_488", "laser_633"]
@@ -1006,6 +1111,32 @@ class SyncDataset:
1006
1111
 
1007
1112
  return tuple(blocks)
1008
1113
 
1114
+ @npc_io.cached_property
1115
+ def invalid_intervals(self) -> tuple[tuple[float, float], ...]:
1116
+ """(start, stop) times in seconds of each interval of sync data that is a replayed
1117
+ copy of a later acquisition buffer. Empty if none are found.
1118
+
1119
+ Events in [start, stop) are not real on any line. The last buffer of each run of
1120
+ identical buffers is assumed to be genuine and is not included. See
1121
+ `find_replayed_buffers`.
1122
+ """
1123
+ events = self.get_all_events()
1124
+ if self.meta_data["ni_daq"]["counter_bits"] == 32:
1125
+ times = events[:, 0]
1126
+ else:
1127
+ times = self.times
1128
+ intervals, buffer_size = find_replayed_buffers(times, events[:, -1])
1129
+ result = tuple(
1130
+ (a / self.sample_freq, z / self.sample_freq) for a, z in intervals
1131
+ )
1132
+ if result:
1133
+ logger.warning(
1134
+ f"{self}: {len(result)} interval(s) of replayed sync data with "
1135
+ f"{buffer_size}-sample buffers (all lines invalid): "
1136
+ f"{[(round(a, 3), round(z, 3)) for a, z in result]}"
1137
+ )
1138
+ return result
1139
+
1009
1140
  @property
1010
1141
  def total_seconds(self) -> float:
1011
1142
  return self.meta_data["total_samples"] / self.sample_freq
@@ -1131,7 +1262,17 @@ class SyncDataset:
1131
1262
 
1132
1263
  @npc_io.cached_property
1133
1264
  def frame_display_time_blocks(self) -> tuple[npt.NDArray[np.floating], ...]:
1134
- """Blocks of adjusted diode times: one block per stimulus."""
1265
+ """Blocks of adjusted diode times: one block per stimulus.
1266
+
1267
+ Raises `AssertionError` if the sync file contains replayed data (see
1268
+ `invalid_intervals`), since vsync and diode edges can't be trusted there.
1269
+ """
1270
+ if self.invalid_intervals:
1271
+ raise AssertionError(
1272
+ f"{self} contains replayed sync data (all lines invalid) at "
1273
+ + ", ".join(f"{a:.3f}-{z:.3f} s" for a, z in self.invalid_intervals)
1274
+ + ": frame display times can't be computed reliably"
1275
+ )
1135
1276
  vsync_times_in_blocks = self.vsync_times_in_blocks
1136
1277
 
1137
1278
  diode_rising_edges = self.get_rising_edges("stim_photodiode", units="seconds")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: npc-sync
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: Repackaging of AllenSDK.SyncDataset with extra functionality, compatible with data in the cloud.
5
5
  Author-email: Ben Hardcastle <ben.hardcastle@alleninstitue.org>
6
6
  License: MIT
@@ -24,7 +24,7 @@ Requires-Dist: typing-extensions>=4.9.0
24
24
  Requires-Dist: npc-io>=0.1.26
25
25
  Requires-Dist: npc-session>=0.1.33
26
26
  Requires-Dist: scipy>=1.13.1
27
- Requires-Dist: npc-stim>=0.1.14
27
+ Requires-Dist: npc-stim>=0.1.23
28
28
  Dynamic: license-file
29
29
 
30
30
  # npc_sync
@@ -12,4 +12,5 @@ src/npc_sync.egg-info/requires.txt
12
12
  src/npc_sync.egg-info/top_level.txt
13
13
  tests/test_core.py
14
14
  tests/test_known_files.py
15
- tests/test_plots.py
15
+ tests/test_plots.py
16
+ tests/test_replay.py
@@ -5,4 +5,4 @@ typing-extensions>=4.9.0
5
5
  npc-io>=0.1.26
6
6
  npc-session>=0.1.33
7
7
  scipy>=1.13.1
8
- npc-stim>=0.1.14
8
+ npc-stim>=0.1.23
@@ -0,0 +1,96 @@
1
+ """Detection of replayed sync acquisition buffers.
2
+
3
+ See outputs/vsync-frame-count-discrepancies.md.
4
+ """
5
+
6
+ import pathlib
7
+
8
+ import numpy as np
9
+ import pytest
10
+
11
+ import npc_sync
12
+
13
+
14
+ def _make_events(
15
+ buffer_size: int,
16
+ n_buffers: int,
17
+ copies: dict[int, int],
18
+ events_per_buffer: int = 200,
19
+ seed: int = 0,
20
+ ) -> tuple[np.ndarray, np.ndarray]:
21
+ """Random events in each buffer; `copies` maps copy buffer index -> source buffer index."""
22
+ rng = np.random.default_rng(seed)
23
+ rel = [
24
+ np.sort(rng.choice(buffer_size, events_per_buffer, replace=False))
25
+ for _ in range(n_buffers)
26
+ ]
27
+ state = [rng.integers(0, 2**32, events_per_buffer) for _ in range(n_buffers)]
28
+ for copy, source in copies.items():
29
+ rel[copy], state[copy] = rel[source].copy(), state[source].copy()
30
+ times = np.concatenate([r + i * buffer_size for i, r in enumerate(rel)])
31
+ return times, np.concatenate(state)
32
+
33
+
34
+ @pytest.mark.parametrize("buffer_size", [10_000, 50_000, 30_000])
35
+ def test_buffer_size_inferred(buffer_size: int) -> None:
36
+ times, bits = _make_events(buffer_size, 40, {10: 13, 11: 13, 12: 13})
37
+ intervals, size = npc_sync.find_replayed_buffers(times, bits)
38
+ assert size == buffer_size
39
+ assert intervals == ((10 * buffer_size, 13 * buffer_size),)
40
+
41
+
42
+ def test_multiple_runs() -> None:
43
+ times, bits = _make_events(50_000, 40, {5: 6, 20: 22, 21: 22})
44
+ intervals, size = npc_sync.find_replayed_buffers(times, bits)
45
+ assert size == 50_000
46
+ assert intervals == ((250_000, 300_000), (1_000_000, 1_100_000))
47
+
48
+
49
+ def test_no_replay() -> None:
50
+ times, bits = _make_events(50_000, 40, {})
51
+ assert npc_sync.find_replayed_buffers(times, bits) == ((), None)
52
+ assert npc_sync.find_replayed_buffers([], []) == ((), None)
53
+
54
+
55
+ def test_trailing_event_difference() -> None:
56
+ """The genuine buffer can have an extra trailing event that the copies lack
57
+ (DRpilot_847958_20260413): its last slot doesn't match, but the copy buffers are
58
+ still reported in full."""
59
+ buffer_size = 50_000
60
+ times, bits = _make_events(buffer_size, 40, {10: 12, 11: 12})
61
+ genuine = (times >= 12 * buffer_size) & (times < 13 * buffer_size)
62
+ extra_time = times[genuine].max() + 1
63
+ i = np.searchsorted(times, extra_time)
64
+ times = np.insert(times, i, extra_time)
65
+ bits = np.insert(bits, i, 12345)
66
+ intervals, size = npc_sync.find_replayed_buffers(times, bits)
67
+ assert size == buffer_size
68
+ assert intervals == ((10 * buffer_size, 12 * buffer_size),)
69
+
70
+
71
+ def test_frame_display_time_blocks_fails_fast_on_replay() -> None:
72
+ sync = object.__new__(npc_sync.SyncDataset)
73
+ sync.path = pathlib.Path("replayed.h5")
74
+ sync.invalid_intervals = ((5247.5, 5257.0),)
75
+ with pytest.raises(AssertionError, match="5247.500-5257.000 s"):
76
+ _ = sync.frame_display_time_blocks
77
+
78
+
79
+ DATA = pathlib.Path(__file__).parents[1] / "experiments" / "vsync-frame-count" / "data"
80
+ KNOWN = {
81
+ "DRpilot_836629_20260430": ((5247.5, 5257.0),),
82
+ "DRpilot_839146_20260518": ((5622.0, 5629.0),),
83
+ "DRpilot_856847_20260602": ((801.5, 805.0),),
84
+ "DRpilot_856847_20260604": ((2743.0, 2744.5),),
85
+ "DRpilot_862025_20260805": ((705.0, 706.5),),
86
+ "DRpilot_847958_20260413": ((5706.0, 5707.5),),
87
+ "DRpilot_840160_20260428": (), # timebase drop, not a replay
88
+ }
89
+
90
+
91
+ @pytest.mark.parametrize("session", sorted(KNOWN))
92
+ def test_known_sessions(session: str) -> None:
93
+ paths = sorted((DATA / session).glob("*.h5"))
94
+ if not paths:
95
+ pytest.skip(f"local sync file not available for {session}")
96
+ assert npc_sync.SyncDataset(paths[0]).invalid_intervals == KNOWN[session]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes