npc-sync 0.2.6__tar.gz → 0.2.8__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.8
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.8"
20
20
  classifiers = [
21
21
  "Development Status :: 3 - Alpha",
22
22
  "Programming Language :: Python :: 3",
@@ -63,6 +63,16 @@ 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
+
71
+ REPLAY_MAX_BUFFER_SAMPLES = 50_000
72
+ """Largest acquisition buffer (0.5 s at 100 kHz) considered when looking for replayed
73
+ buffers. Identical slots separated by more than this are coincidental matches (e.g. quiet
74
+ or periodic stretches of real data) and are ignored."""
75
+
66
76
  SUSPICIOUS_INTERVAL_THRESHOLD = 0.3 / FRAME_RATE
67
77
  DEFINITE_SHORT_INTERVAL_THRESHOLD = 0.05 / FRAME_RATE
68
78
  # at 60 fps we should never have diode-flip intervals this small: consider them anomalous
@@ -154,6 +164,113 @@ def get_bit(uint_array: npt.NDArray, bit: int) -> npt.NDArray[np.uint8]:
154
164
  return np.bitwise_and(uint_array, 2**bit).astype(bool).astype(np.uint8)
155
165
 
156
166
 
167
+ def _splitmix64(x: npt.NDArray[np.uint64]) -> npt.NDArray[np.uint64]:
168
+ """Vectorized splitmix64 finalizer: scrambles 64-bit integers."""
169
+ x = x.astype(np.uint64, copy=True)
170
+ with np.errstate(over="ignore"):
171
+ x ^= x >> np.uint64(30)
172
+ x *= np.uint64(0xBF58476D1CE4E5B9)
173
+ x ^= x >> np.uint64(27)
174
+ x *= np.uint64(0x94D049BB133111EB)
175
+ x ^= x >> np.uint64(31)
176
+ return x
177
+
178
+
179
+ def find_replayed_buffers(
180
+ times: npt.ArrayLike,
181
+ bits: npt.ArrayLike,
182
+ slot_samples: int = REPLAY_SLOT_SAMPLES,
183
+ max_buffer_samples: int = REPLAY_MAX_BUFFER_SAMPLES,
184
+ ) -> tuple[tuple[tuple[int, int], ...], int | None]:
185
+ """Find acquisition buffers in a sync event list that are replayed copies of a later
186
+ buffer.
187
+
188
+ Some sync files contain runs of consecutive buffers with identical event content on
189
+ every line (same sample times relative to the buffer start, same bit states). The
190
+ copies overwrite real data; the last buffer of each run is assumed to be genuine.
191
+
192
+ Method: split events into slots of `slot_samples`, fingerprint each non-empty slot
193
+ by summing a hash of each event's (time relative to slot start, bit state), then find
194
+ slots with identical fingerprints. The most common slot separation between identical
195
+ slots gives the buffer size, so any buffer size that is a multiple of `slot_samples`
196
+ is found. Buffers are assumed to be aligned to sample 0.
197
+
198
+ Parameters
199
+ ----------
200
+ times : array of int
201
+ Sample index of each event.
202
+ bits : array of int
203
+ Bit state (all lines) of each event.
204
+ slot_samples : int
205
+ Fingerprint slot width in samples. Must divide the buffer size.
206
+ max_buffer_samples : int
207
+ Identical slots further apart than this are not treated as replays.
208
+
209
+ Returns
210
+ -------
211
+ intervals : tuple of (start, stop) sample indices
212
+ Half-open intervals of replayed copies. `stop` is the start of the genuine buffer.
213
+ buffer_size : int or None
214
+ Inferred buffer size in samples, or None if no replay was found.
215
+
216
+ Examples
217
+ --------
218
+ >>> rng = np.random.default_rng(0)
219
+ >>> buffer, n_buffers = 50_000, 20
220
+ >>> rel = [np.sort(rng.choice(buffer, 100, replace=False)) for _ in range(n_buffers)]
221
+ >>> state = [rng.integers(0, 2**32, 100) for _ in range(n_buffers)]
222
+ >>> for i in (5, 6, 7): # buffers 5-7 are copies of buffer 8
223
+ ... rel[i], state[i] = rel[8], state[8]
224
+ >>> times = np.concatenate([r + i * buffer for i, r in enumerate(rel)])
225
+ >>> find_replayed_buffers(times, np.concatenate(state))
226
+ (((250000, 400000),), 50000)
227
+ >>> find_replayed_buffers(times[times < 250_000], np.concatenate(state)[times < 250_000])
228
+ ((), None)
229
+ """
230
+ times = np.asarray(times).astype(np.int64)
231
+ bits = np.asarray(bits).astype(np.uint64)
232
+ if times.size == 0:
233
+ return (), None
234
+ slot = times // slot_samples
235
+ starts = np.flatnonzero(np.r_[True, slot[1:] != slot[:-1]]) # non-empty slots only
236
+ tokens = ((times - slot * slot_samples).astype(np.uint64) << np.uint64(32)) | (
237
+ bits & np.uint64(0xFFFFFFFF)
238
+ )
239
+ with np.errstate(over="ignore"):
240
+ fingerprints = np.add.reduceat(_splitmix64(tokens), starts)
241
+ slots = slot[starts]
242
+ order = np.lexsort((slots, fingerprints)) # by fingerprint, then slot
243
+ fingerprints, slots = fingerprints[order], slots[order]
244
+ same = fingerprints[1:] == fingerprints[:-1]
245
+ if not same.any():
246
+ return (), None
247
+ earlier = slots[:-1][same]
248
+ lags = slots[1:][same] - earlier
249
+ plausible = lags <= max_buffer_samples // slot_samples
250
+ earlier, lags = earlier[plausible], lags[plausible]
251
+ if lags.size == 0:
252
+ return (), None
253
+ values, counts = np.unique(lags, return_counts=True)
254
+ lag = int(values[np.argmax(counts)])
255
+ if len(values) > 1:
256
+ logger.warning(
257
+ "Identical sync slots found at more than one separation "
258
+ f"(in units of {slot_samples} samples): "
259
+ f"{dict(zip(values.tolist(), counts.tolist()))}. Using the most common ({lag})."
260
+ )
261
+ buffer_size = lag * slot_samples
262
+ copies = np.unique(
263
+ earlier[lags == lag] // lag
264
+ ) # buffer index of each replayed copy
265
+ breaks = np.flatnonzero(np.diff(copies) != 1)
266
+ first = copies[np.r_[0, breaks + 1]]
267
+ last = copies[np.r_[breaks, len(copies) - 1]]
268
+ intervals = tuple(
269
+ (int(a * buffer_size), int((z + 1) * buffer_size)) for a, z in zip(first, last)
270
+ )
271
+ return intervals, buffer_size
272
+
273
+
157
274
  def get_sync_line_for_stim_onset(
158
275
  waveform_type: (
159
276
  str | Literal["sound", "audio", "opto", "galvo", "laser_488", "laser_633"]
@@ -1006,6 +1123,32 @@ class SyncDataset:
1006
1123
 
1007
1124
  return tuple(blocks)
1008
1125
 
1126
+ @npc_io.cached_property
1127
+ def invalid_intervals(self) -> tuple[tuple[float, float], ...]:
1128
+ """(start, stop) times in seconds of each interval of sync data that is a replayed
1129
+ copy of a later acquisition buffer. Empty if none are found.
1130
+
1131
+ Events in [start, stop) are not real on any line. The last buffer of each run of
1132
+ identical buffers is assumed to be genuine and is not included. See
1133
+ `find_replayed_buffers`.
1134
+ """
1135
+ events = self.get_all_events()
1136
+ if self.meta_data["ni_daq"]["counter_bits"] == 32:
1137
+ times = events[:, 0]
1138
+ else:
1139
+ times = self.times
1140
+ intervals, buffer_size = find_replayed_buffers(times, events[:, -1])
1141
+ result = tuple(
1142
+ (a / self.sample_freq, z / self.sample_freq) for a, z in intervals
1143
+ )
1144
+ if result:
1145
+ logger.warning(
1146
+ f"{self}: {len(result)} interval(s) of replayed sync data with "
1147
+ f"{buffer_size}-sample buffers (all lines invalid): "
1148
+ f"{[(round(a, 3), round(z, 3)) for a, z in result]}"
1149
+ )
1150
+ return result
1151
+
1009
1152
  @property
1010
1153
  def total_seconds(self) -> float:
1011
1154
  return self.meta_data["total_samples"] / self.sample_freq
@@ -1131,7 +1274,17 @@ class SyncDataset:
1131
1274
 
1132
1275
  @npc_io.cached_property
1133
1276
  def frame_display_time_blocks(self) -> tuple[npt.NDArray[np.floating], ...]:
1134
- """Blocks of adjusted diode times: one block per stimulus."""
1277
+ """Blocks of adjusted diode times: one block per stimulus.
1278
+
1279
+ Raises `AssertionError` if the sync file contains replayed data (see
1280
+ `invalid_intervals`), since vsync and diode edges can't be trusted there.
1281
+ """
1282
+ if self.invalid_intervals:
1283
+ raise AssertionError(
1284
+ f"{self} contains replayed sync data (all lines invalid) at "
1285
+ + ", ".join(f"{a:.3f}-{z:.3f} s" for a, z in self.invalid_intervals)
1286
+ + ": frame display times can't be computed reliably"
1287
+ )
1135
1288
  vsync_times_in_blocks = self.vsync_times_in_blocks
1136
1289
 
1137
1290
  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.8
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,106 @@
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]
97
+
98
+
99
+ def test_ignores_identical_slots_beyond_max_buffer_size() -> None:
100
+ rng = np.random.default_rng(1)
101
+ rel = np.sort(rng.choice(10_000, 50, replace=False))
102
+ state = rng.integers(0, 2**32, 50)
103
+ # same slot content 31 slots (310,000 samples) apart: not a plausible buffer
104
+ times = np.concatenate([rel, rel + 310_000])
105
+ bits = np.concatenate([state, state])
106
+ assert npc_sync.sync.find_replayed_buffers(times, bits) == ((), None)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes