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.
- {npc_sync-0.2.6/src/npc_sync.egg-info → npc_sync-0.2.7}/PKG-INFO +2 -2
- {npc_sync-0.2.6 → npc_sync-0.2.7}/pyproject.toml +2 -2
- {npc_sync-0.2.6 → npc_sync-0.2.7}/src/npc_sync/sync.py +142 -1
- {npc_sync-0.2.6 → npc_sync-0.2.7/src/npc_sync.egg-info}/PKG-INFO +2 -2
- {npc_sync-0.2.6 → npc_sync-0.2.7}/src/npc_sync.egg-info/SOURCES.txt +2 -1
- {npc_sync-0.2.6 → npc_sync-0.2.7}/src/npc_sync.egg-info/requires.txt +1 -1
- npc_sync-0.2.7/tests/test_replay.py +96 -0
- {npc_sync-0.2.6 → npc_sync-0.2.7}/LICENSE +0 -0
- {npc_sync-0.2.6 → npc_sync-0.2.7}/README.md +0 -0
- {npc_sync-0.2.6 → npc_sync-0.2.7}/setup.cfg +0 -0
- {npc_sync-0.2.6 → npc_sync-0.2.7}/src/npc_sync/__init__.py +0 -0
- {npc_sync-0.2.6 → npc_sync-0.2.7}/src/npc_sync/py.typed +0 -0
- {npc_sync-0.2.6 → npc_sync-0.2.7}/src/npc_sync.egg-info/dependency_links.txt +0 -0
- {npc_sync-0.2.6 → npc_sync-0.2.7}/src/npc_sync.egg-info/entry_points.txt +0 -0
- {npc_sync-0.2.6 → npc_sync-0.2.7}/src/npc_sync.egg-info/top_level.txt +0 -0
- {npc_sync-0.2.6 → npc_sync-0.2.7}/tests/test_core.py +0 -0
- {npc_sync-0.2.6 → npc_sync-0.2.7}/tests/test_known_files.py +0 -0
- {npc_sync-0.2.6 → npc_sync-0.2.7}/tests/test_plots.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: npc-sync
|
|
3
|
-
Version: 0.2.
|
|
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.
|
|
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.
|
|
17
|
+
"npc-stim>=0.1.23",
|
|
18
18
|
]
|
|
19
|
-
version = "0.2.
|
|
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.
|
|
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.
|
|
27
|
+
Requires-Dist: npc-stim>=0.1.23
|
|
28
28
|
Dynamic: license-file
|
|
29
29
|
|
|
30
30
|
# npc_sync
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|