Skip to content

Commit edd0fd0

Browse files
h-mayorquinzm711
andauthored
Add gap_tolerance_ms API to Neuralynx reader (#1822)
* Add gap segmentation API to neuralynx * zach review * zach review --------- Co-authored-by: Zach McKenzie <92116279+zm711@users.noreply.github.com>
1 parent 42fb21d commit edd0fd0

8 files changed

Lines changed: 388 additions & 74 deletions

File tree

‎neo/io/neuralynxio.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ def __init__(
3636
include_filenames=None,
3737
exclude_filenames=None,
3838
keep_original_times=False,
39+
gap_tolerance_ms=None,
40+
strict_gap_mode=None,
3941
filename=None,
4042
exclude_filename=None,
4143
):
@@ -67,6 +69,13 @@ def __init__(
6769
Preserve original time stamps as in data files. By default datasets are
6870
shifted to begin at t_start = 0*pq.second.
6971
Default: False
72+
gap_tolerance_ms : float | None, default: None
73+
Controls how timestamp gaps in NCS files are handled.
74+
If None (default), a ValueError is raised when gaps are detected, with a
75+
detailed gap report. If a float value is provided, gaps smaller than this
76+
threshold (in milliseconds) are ignored, and gaps larger create new segments.
77+
strict_gap_mode : bool | None, default: None
78+
Deprecated and will be removed in version 0.16.0. Use gap_tolerance_ms instead.
7079
"""
7180

7281
if filename is not None:
@@ -83,6 +92,8 @@ def __init__(
8392
include_filenames=include_filenames,
8493
exclude_filenames=exclude_filenames,
8594
keep_original_times=keep_original_times,
95+
gap_tolerance_ms=gap_tolerance_ms,
96+
strict_gap_mode=strict_gap_mode,
8697
use_cache=use_cache,
8798
cache_path=cache_path,
8899
)

‎neo/rawio/neuralynxrawio/ncssections.py‎

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"""
3636

3737
import math
38+
import warnings
3839
import numpy as np
3940

4041
from enum import IntEnum, auto
@@ -67,6 +68,7 @@ def __init__(self):
6768
self.sects = []
6869
self.sampFreqUsed = 0 # actual sampling frequency of samples
6970
self.microsPerSampUsed = 0 # microseconds per sample
71+
self.detected_gaps = [] # list of (record_index, gap_size_us) for all detected gaps
7072

7173
def __eq__(self, other):
7274
samp_eq = self.sampFreqUsed == other.sampFreqUsed
@@ -216,13 +218,25 @@ def _buildNcsSections(ncsMemMap, sampFreq, gapTolerance=0):
216218
n_samples=n_samples,
217219
)
218220
ncsSects.sects.append(section0)
221+
# No gaps detected in fast path
222+
ncsSects.detected_gaps = []
219223

220224
else:
221225
# need to parse all data block to detect gaps
222226
# check when the predicted timestamp is outside the tolerance
223227
delta = (ncsMemMap["timestamp"][1:] - ncsMemMap["timestamp"][:-1]).astype(np.int64)
224228
delta_prediction = ((ncsMemMap["nb_valid"][:-1] / sampFreq) * 1e6).astype(np.int64)
225229

230+
# Always detect all gaps using the strict threshold for reporting
231+
strict_tolerance = round(NcsSectionsFactory._maxGapSampFrac * 1e6 / sampFreq)
232+
all_gap_inds = np.flatnonzero(np.abs(delta - delta_prediction) > strict_tolerance)
233+
gap_sizes_us = (delta - delta_prediction)[all_gap_inds]
234+
ncsSects.detected_gaps = [
235+
(int(record_index + 1), int(gap_size))
236+
for record_index, gap_size in zip(all_gap_inds, gap_sizes_us)
237+
]
238+
239+
# Use user-provided tolerance for actual segmentation
226240
gap_inds = np.flatnonzero(np.abs(delta - delta_prediction) > gapTolerance)
227241
gap_inds += 1
228242

@@ -245,7 +259,7 @@ def _buildNcsSections(ncsMemMap, sampFreq, gapTolerance=0):
245259
return ncsSects
246260

247261
@staticmethod
248-
def build_for_ncs_file(ncsMemMap, nlxHdr, gapTolerance=None, strict_gap_mode=True):
262+
def build_for_ncs_file(ncsMemMap, nlxHdr, gap_tolerance_us=None, **kwargs):
249263
"""
250264
Build an NcsSections object for an NcsFile, given as a memmap and NlxHeader,
251265
handling gap detection appropriately given the file type as specified by the header.
@@ -256,27 +270,60 @@ def build_for_ncs_file(ncsMemMap, nlxHdr, gapTolerance=None, strict_gap_mode=Tru
256270
memory map of file
257271
nlxHdr:
258272
NlxHeader from corresponding file.
273+
gap_tolerance_us : float | None, default: None
274+
Gap tolerance in microseconds for segmentation.
259275
260276
Returns
261277
-------
262278
An NcsSections corresponding to the provided ncsMemMap and nlxHdr
263279
"""
280+
# Handle deprecated parameters
281+
gapTolerance = kwargs.pop("gapTolerance", None)
282+
strict_gap_mode = kwargs.pop("strict_gap_mode", None)
283+
if kwargs:
284+
raise TypeError(f"Unexpected keyword arguments: {list(kwargs.keys())}")
285+
286+
if gapTolerance is not None:
287+
warnings.warn(
288+
"The `gapTolerance` parameter is deprecated and will be removed in version 0.16.0. "
289+
"Use `gap_tolerance_us` instead.",
290+
DeprecationWarning,
291+
stacklevel=2,
292+
)
293+
if gap_tolerance_us is None:
294+
gap_tolerance_us = gapTolerance
295+
296+
if strict_gap_mode is not None:
297+
warnings.warn(
298+
"The `strict_gap_mode` parameter is deprecated and will be removed in version 0.16.0. "
299+
"Use `gap_tolerance_us` instead.",
300+
DeprecationWarning,
301+
stacklevel=2,
302+
)
303+
304+
if gap_tolerance_us is not None and gap_tolerance_us < 0:
305+
raise ValueError(f"`gap_tolerance_us` must be non-negative, got {gap_tolerance_us}")
306+
264307
acqType = nlxHdr.type_of_recording()
265308
freq = nlxHdr["sampling_rate"]
266309

310+
# Deprecation shim for strict_gap_mode (the boolean predecessor of gap_tolerance_us).
311+
# strict_gap_mode True/None already match the modern per-type defaults below; only
312+
# strict_gap_mode=False differed, tolerating a quarter-packet gap (PRE4 was 0 either way).
313+
# Translating that single case here keeps the per-type branches on gap_tolerance_us only.
314+
# Remove this block when strict_gap_mode is dropped in v0.16.0.
315+
if gap_tolerance_us is None and strict_gap_mode is not None and not strict_gap_mode and acqType != AcqType.PRE4:
316+
gap_tolerance_us = round(0.25 * NcsSection._RECORD_SIZE * 1e6 / freq)
317+
267318
if acqType == AcqType.PRE4:
268319
# Old Neuralynx style with truncated whole microseconds for actual sampling. This
269320
# restriction arose from the sampling being based on a master 1 MHz clock.
270321
microsPerSampUsed = math.floor(NcsSectionsFactory.get_micros_per_samp_for_freq(freq))
271322
sampFreqUsed = NcsSectionsFactory.get_freq_for_micros_per_samp(microsPerSampUsed)
272-
if gapTolerance is None:
273-
if strict_gap_mode:
274-
# this is the old behavior, maybe we could put 0.9 sample interval no ?
275-
gapTolerance = 0
276-
else:
277-
gapTolerance = 0
278-
279-
ncsSects = NcsSectionsFactory._buildNcsSections(ncsMemMap, sampFreqUsed, gapTolerance=gapTolerance)
323+
if gap_tolerance_us is None:
324+
gap_tolerance_us = 0
325+
326+
ncsSects = NcsSectionsFactory._buildNcsSections(ncsMemMap, sampFreqUsed, gapTolerance=gap_tolerance_us)
280327
ncsSects.sampFreqUsed = sampFreqUsed
281328
ncsSects.microsPerSampUsed = microsPerSampUsed
282329

@@ -288,17 +335,12 @@ def build_for_ncs_file(ncsMemMap, nlxHdr, gapTolerance=None, strict_gap_mode=Tru
288335
AcqType.RAWDATAFILE,
289336
]:
290337
# digital lynx style with fractional frequency and micros per samp determined from block times
291-
if gapTolerance is None:
292-
if strict_gap_mode:
293-
# this is the old behavior
294-
gapTolerance = round(NcsSectionsFactory._maxGapSampFrac * 1e6 / freq)
295-
else:
296-
# quarter of paquet size is tolerate
297-
gapTolerance = round(0.25 * NcsSection._RECORD_SIZE * 1e6 / freq)
298-
ncsSects = NcsSectionsFactory._buildNcsSections(ncsMemMap, freq, gapTolerance=gapTolerance)
299-
300-
# take longer data block to compute reaal sampling rate
301-
# ind_max = np.argmax([section.n_samples for section in ncsSects.sects])
338+
if gap_tolerance_us is None:
339+
# default: strict detection (0.2 of a sample interval)
340+
gap_tolerance_us = round(NcsSectionsFactory._maxGapSampFrac * 1e6 / freq)
341+
ncsSects = NcsSectionsFactory._buildNcsSections(ncsMemMap, freq, gapTolerance=gap_tolerance_us)
342+
343+
# take longer data block to compute real sampling rate
302344
ind_max = np.argmax([section.endRec - section.startRec for section in ncsSects.sects])
303345
section = ncsSects.sects[ind_max]
304346
if section.endRec != section.startRec:
@@ -315,13 +357,9 @@ def build_for_ncs_file(ncsMemMap, nlxHdr, gapTolerance=None, strict_gap_mode=Tru
315357

316358
elif acqType == AcqType.BML or acqType == AcqType.ATLAS:
317359
# BML & ATLAS style with fractional frequency and micros per samp
318-
if strict_gap_mode:
319-
# this is the old behavior, maybe we could put 0.9 sample interval no ?
320-
gapTolerance = 0
321-
else:
322-
# quarter of paquet size is tolerate
323-
gapTolerance = round(0.25 * NcsSection._RECORD_SIZE * 1e6 / freq)
324-
ncsSects = NcsSectionsFactory._buildNcsSections(ncsMemMap, freq, gapTolerance=gapTolerance)
360+
if gap_tolerance_us is None:
361+
gap_tolerance_us = 0
362+
ncsSects = NcsSectionsFactory._buildNcsSections(ncsMemMap, freq, gapTolerance=gap_tolerance_us)
325363
ncsSects.sampFreqUsed = freq
326364
ncsSects.microsPerSampUsed = NcsSectionsFactory.get_micros_per_samp_for_freq(freq)
327365

0 commit comments

Comments
 (0)