npc-sync 0.2.5__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.5
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.5"
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
@@ -1104,9 +1235,44 @@ class SyncDataset:
1104
1235
  result[block_idx] = npc_stim.get_total_stim_frames(stim_data)
1105
1236
  return result
1106
1237
 
1238
+ def _get_diode_box_frame_interval(self, block_idx: int) -> int:
1239
+ """Return the number of frames between diode-box flips for a block.
1240
+
1241
+ Stim files created before ``diodeBoxFrameInterval`` was added always
1242
+ flipped the diode box every frame, so missing metadata defaults to 1.
1243
+ """
1244
+ path = self.block_index_to_stim_path[block_idx]
1245
+ if path is None:
1246
+ return 1
1247
+ stim_data = None
1248
+ try:
1249
+ stim_data = npc_stim.get_stim_data(path)
1250
+ value = stim_data.get("diodeBoxFrameInterval", 1)
1251
+ if isinstance(value, h5py.Dataset):
1252
+ value = value[()]
1253
+ return int(value)
1254
+ except Exception as exc:
1255
+ logger.warning(
1256
+ f"Failed to load diodeBoxFrameInterval for block {block_idx} at {path}: {exc}"
1257
+ )
1258
+ return 1
1259
+ finally:
1260
+ if isinstance(stim_data, h5py.File):
1261
+ stim_data.close()
1262
+
1107
1263
  @npc_io.cached_property
1108
1264
  def frame_display_time_blocks(self) -> tuple[npt.NDArray[np.floating], ...]:
1109
- """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
+ )
1110
1276
  vsync_times_in_blocks = self.vsync_times_in_blocks
1111
1277
 
1112
1278
  diode_rising_edges = self.get_rising_edges("stim_photodiode", units="seconds")
@@ -1252,12 +1418,7 @@ class SyncDataset:
1252
1418
  """Concatenate the rising and falling edges into a single array."""
1253
1419
  return np.sort(np.concatenate((rising, falling)))
1254
1420
 
1255
- # July 2025 we updated the photodiode interval from every frame to every 3 frames
1256
- vsyncs_per_diode_flip: int = (
1257
- 1
1258
- if self.start_time < datetime.datetime(2025, 7, 15)
1259
- else round(np.mean(np.diff(concat_flips(rising, falling))) * FRAME_RATE)
1260
- )
1421
+ vsyncs_per_diode_flip = self._get_diode_box_frame_interval(block_idx)
1261
1422
 
1262
1423
  if (
1263
1424
  vsyncs_per_diode_flip == 1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: npc-sync
3
- Version: 0.2.5
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
@@ -1,3 +1,7 @@
1
+ import pathlib
2
+
3
+ import h5py
4
+
1
5
  import npc_sync
2
6
 
3
7
 
@@ -28,3 +32,26 @@ def test_init_from_instance_and_meta_data() -> None:
28
32
  meta = s2.meta_data
29
33
  assert isinstance(meta, dict)
30
34
  assert "line_labels" in meta
35
+
36
+
37
+ def test_diode_box_frame_interval_is_read_from_stim_file(
38
+ tmp_path: pathlib.Path,
39
+ ) -> None:
40
+ current_stim = tmp_path / "current.hdf5"
41
+ with h5py.File(current_stim, "w") as stim_data:
42
+ stim_data["diodeBoxFrameInterval"] = 3
43
+
44
+ old_stim = tmp_path / "old.hdf5"
45
+ with h5py.File(old_stim, "w"):
46
+ pass
47
+
48
+ sync = object.__new__(npc_sync.SyncDataset)
49
+ sync.block_index_to_stim_path = {
50
+ 0: current_stim,
51
+ 1: old_stim,
52
+ 2: None,
53
+ }
54
+
55
+ assert sync._get_diode_box_frame_interval(0) == 3
56
+ assert sync._get_diode_box_frame_interval(1) == 1
57
+ assert sync._get_diode_box_frame_interval(2) == 1
@@ -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