Skip to content

Commit f894e9a

Browse files
authored
Merge branch 'master' into spikeglx_sync_separate
2 parents 2106198 + 6696426 commit f894e9a

File tree

7 files changed

+197
-36
lines changed

7 files changed

+197
-36
lines changed

.github/workflows/core-test.yml

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,13 @@ jobs:
2626
matrix:
2727
os: ["ubuntu-latest", "windows-latest", "macos-latest"]
2828
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
29-
numpy-version: ['1.22.4', '1.23.5', '1.24.4', '1.25.1', '1.26.4', '2.0.2','2.1']
30-
# numpy 1.22: 3.10, 1.23: 3.11, 1.24: 3.11, 1.25: 3.11, 1.26: 3.12
29+
numpy-version: ['1.24.4', '1.25.1', '1.26.4', '2.0.2','2.1.3', '2.2.4']
30+
# 1.24: 3.11, 1.25: 3.11, 1.26: 3.12
3131
exclude:
3232
- python-version: '3.9'
33-
numpy-version: '2.1'
34-
- python-version: '3.11'
35-
numpy-version: '1.22.4'
36-
- python-version: '3.12'
37-
numpy-version: '1.22.4'
38-
- python-version: '3.12'
39-
numpy-version: '1.23.5'
33+
numpy-version: '2.1.3'
34+
- python-version: '3.9'
35+
numpy-version: '2.2.4'
4036
- python-version: '3.12'
4137
numpy-version: '1.24.4'
4238
- python-version: '3.12'

neo/io/biocamio.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ class BiocamIO(BiocamRawIO, BaseFromRaw):
66
__doc__ = BiocamRawIO.__doc__
77
mode = "file"
88

9-
def __init__(self, filename):
10-
BiocamRawIO.__init__(self, filename=filename)
9+
def __init__(self, filename, fill_gaps_strategy=None):
10+
BiocamRawIO.__init__(self, filename=filename,
11+
fill_gaps_strategy=fill_gaps_strategy)
1112
BaseFromRaw.__init__(self, filename)

neo/rawio/biocamrawio.py

Lines changed: 165 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
_spike_channel_dtype,
1919
_event_channel_dtype,
2020
)
21+
22+
import numpy as np
23+
import json
24+
import warnings
2125
from neo.core import NeoReadWriteError
2226

2327

@@ -29,6 +33,14 @@ class BiocamRawIO(BaseRawIO):
2933
----------
3034
filename: str, default: ''
3135
The *.h5 file to be read
36+
fill_gaps_strategy: "zeros" | "synthetic_noise" | None, default: None
37+
The strategy to fill the gaps in the data when using event-based
38+
compression. If None and the file is event-based compressed,
39+
you need to specify a fill gaps strategy:
40+
41+
* "zeros": the gaps are filled with unsigned 0s (2048). This value is the "0" of the unsigned 12 bits
42+
representation of the data.
43+
* "synthetic_noise": the gaps are filled with synthetic noise.
3244
3345
Examples
3446
--------
@@ -49,9 +61,10 @@ class BiocamRawIO(BaseRawIO):
4961
extensions = ["h5", "brw"]
5062
rawmode = "one-file"
5163

52-
def __init__(self, filename=""):
64+
def __init__(self, filename="", fill_gaps_strategy="zeros"):
5365
BaseRawIO.__init__(self)
5466
self.filename = filename
67+
self._fill_gaps_strategy = fill_gaps_strategy
5568

5669
def _source_name(self):
5770
return self.filename
@@ -130,7 +143,24 @@ def _get_analogsignal_chunk(self, block_index, seg_index, i_start, i_stop, strea
130143
i_stop = self._num_frames
131144

132145
# read functions are different based on the version of biocam
133-
data = self._read_function(self._filehandle, i_start, i_stop, self._num_channels)
146+
if self._read_function is readHDF5t_brw4_sparse:
147+
if self._fill_gaps_strategy is None:
148+
raise ValueError(
149+
"Please set `fill_gaps_strategy` to 'zeros' or 'synthetic_noise'."
150+
)
151+
if self._fill_gaps_strategy == "synthetic_noise":
152+
warnings.warn("Event-based compression : gaps will be filled with synthetic noise. "
153+
"Set `fill_gaps_strategy` to 'zeros' to fill gaps with 0s.")
154+
use_synthetic_noise = True
155+
elif self._fill_gaps_strategy == "zeros":
156+
use_synthetic_noise = False
157+
else:
158+
raise ValueError("`fill_gaps_strategy` must be 'zeros' or 'synthetic_noise'")
159+
160+
data = self._read_function(self._filehandle, i_start, i_stop, self._num_channels,
161+
use_synthetic_noise=use_synthetic_noise)
162+
else:
163+
data = self._read_function(self._filehandle, i_start, i_stop, self._num_channels)
134164

135165
# older style data returns array of (n_samples, n_channels), should be a view
136166
# but if memory issues come up we should doublecheck out how the file is being stored
@@ -243,15 +273,21 @@ def open_biocam_file_header(filename) -> dict:
243273
min_digital = experiment_settings["ValueConverter"]["MinDigitalValue"]
244274
scale_factor = experiment_settings["ValueConverter"]["ScaleFactor"]
245275
sampling_rate = experiment_settings["TimeConverter"]["FrameRate"]
276+
num_frames = rf['TOC'][-1,-1]
246277

247278
num_channels = None
248-
for key in rf:
249-
if key[:5] == "Well_":
250-
num_channels = len(rf[key]["StoredChIdxs"])
251-
if len(rf[key]["Raw"]) % num_channels:
252-
raise NeoReadWriteError(f"Length of raw data array is not multiple of channel number in {key}")
253-
num_frames = len(rf[key]["Raw"]) // num_channels
254-
break
279+
well_ID = None
280+
for well_ID in rf:
281+
if well_ID.startswith("Well_"):
282+
num_channels = len(rf[well_ID]["StoredChIdxs"])
283+
if "Raw" in rf[well_ID]:
284+
if len(rf[well_ID]["Raw"]) % num_channels:
285+
raise NeoReadWriteError(f"Length of raw data array is not multiple of channel number in {well_ID}")
286+
num_frames = len(rf[well_ID]["Raw"]) // num_channels
287+
break
288+
elif "EventsBasedSparseRaw" in rf[well_ID]:
289+
# Not sure how to check for this with sparse data
290+
pass
255291

256292
if num_channels is not None:
257293
num_channels_x = num_channels_y = int(np.sqrt(num_channels))
@@ -264,7 +300,10 @@ def open_biocam_file_header(filename) -> dict:
264300

265301
gain = scale_factor * (max_uv - min_uv) / (max_digital - min_digital)
266302
offset = min_uv
267-
read_function = readHDF5t_brw4
303+
if "Raw" in rf[well_ID]:
304+
read_function = readHDF5t_brw4
305+
elif "EventsBasedSparseRaw" in rf[well_ID]:
306+
read_function = readHDF5t_brw4_sparse
268307

269308
return dict(
270309
file_handle=rf,
@@ -302,5 +341,120 @@ def readHDF5t_101_i(rf, t0, t1, nch):
302341

303342
def readHDF5t_brw4(rf, t0, t1, nch):
304343
for key in rf:
305-
if key[:5] == "Well_":
344+
if key.startswith("Well_"):
306345
return rf[key]["Raw"][nch * t0 : nch * t1]
346+
347+
348+
def readHDF5t_brw4_sparse(rf, t0, t1, nch, use_synthetic_noise=False):
349+
350+
# noise_std = None
351+
start_frame = t0
352+
num_frames = t1 - t0
353+
for well_ID in rf:
354+
if well_ID.startswith("Well_"):
355+
break
356+
# initialize an empty (fill with zeros) data collection
357+
data = np.zeros((nch, num_frames), dtype=np.uint16)
358+
if not use_synthetic_noise:
359+
# Will read as 0s after 12 bits signed conversion
360+
data.fill(2048)
361+
else:
362+
# fill the data collection with Gaussian noise if requested
363+
data = generate_synthetic_noise(rf, data, well_ID, start_frame, num_frames) #, std=noise_std)
364+
# fill the data collection with the decoded event based sparse raw data
365+
data = decode_event_based_raw_data(rf, data, well_ID, start_frame, num_frames)
366+
367+
return data.T
368+
369+
370+
def decode_event_based_raw_data(rf, data, well_ID, start_frame, num_frames):
371+
# Source: Documentation by 3Brain
372+
# https://gin.g-node.org/NeuralEnsemble/ephy_testing_data/src/master/biocam/documentation_brw_4.x_bxr_3.x_bcmp_1.x_in_brainwave_5.x_v1.1.3.pdf
373+
# collect the TOCs
374+
toc = np.array(rf["TOC"])
375+
events_toc = np.array(rf[well_ID]["EventsBasedSparseRawTOC"])
376+
# from the given start position and duration in frames, localize the corresponding event positions
377+
# using the TOC
378+
toc_start_idx = np.searchsorted(toc[:, 1], start_frame)
379+
toc_end_idx = min(
380+
np.searchsorted(toc[:, 1], start_frame + num_frames, side="right") + 1,
381+
len(toc) - 1)
382+
events_start_pos = events_toc[toc_start_idx]
383+
events_end_pos = events_toc[toc_end_idx]
384+
# decode all data for the given well ID and time interval
385+
binary_data = rf[well_ID]["EventsBasedSparseRaw"][events_start_pos:events_end_pos]
386+
binary_data_length = len(binary_data)
387+
pos = 0
388+
while pos < binary_data_length:
389+
ch_idx = int.from_bytes(binary_data[pos:pos + 4], byteorder="little")
390+
pos += 4
391+
ch_data_length = int.from_bytes(binary_data[pos:pos + 4], byteorder="little")
392+
pos += 4
393+
ch_data_pos = pos
394+
while pos < ch_data_pos + ch_data_length:
395+
from_inclusive = int.from_bytes(binary_data[pos:pos + 8], byteorder="little")
396+
pos += 8
397+
to_exclusive = int.from_bytes(binary_data[pos:pos + 8], byteorder="little")
398+
pos += 8
399+
range_data_pos = pos
400+
for j in range(from_inclusive, to_exclusive):
401+
if j >= start_frame + num_frames:
402+
break
403+
if j >= start_frame:
404+
data[ch_idx][j - start_frame] = int.from_bytes(
405+
binary_data[range_data_pos:range_data_pos + 2], byteorder="little")
406+
range_data_pos += 2
407+
pos += (to_exclusive - from_inclusive) * 2
408+
409+
return data
410+
411+
def generate_synthetic_noise(rf, data, well_ID, start_frame, num_frames):
412+
# Source: Documentation by 3Brain
413+
# https://gin.g-node.org/NeuralEnsemble/ephy_testing_data/src/master/biocam/documentation_brw_4.x_bxr_3.x_bcmp_1.x_in_brainwave_5.x_v1.1.3.pdf
414+
# collect the TOCs
415+
toc = np.array(rf["TOC"])
416+
noise_toc = np.array(rf[well_ID]["NoiseTOC"])
417+
# from the given start position in frames, localize the corresponding noise positions
418+
# using the TOC
419+
toc_start_idx = np.searchsorted(toc[:, 1], start_frame)
420+
noise_start_pos = noise_toc[toc_start_idx]
421+
noise_end_pos = noise_start_pos
422+
for i in range(toc_start_idx + 1, len(noise_toc)):
423+
next_pos = noise_toc[i]
424+
if next_pos > noise_start_pos:
425+
noise_end_pos = next_pos
426+
break
427+
if noise_end_pos == noise_start_pos:
428+
for i in range(toc_start_idx - 1, 0, -1):
429+
previous_pos = noise_toc[i]
430+
if previous_pos < noise_start_pos:
431+
noise_end_pos = noise_start_pos
432+
noise_start_pos = previous_pos
433+
break
434+
# obtain the noise info at the start position
435+
noise_ch_idx = rf[well_ID]["NoiseChIdxs"][noise_start_pos:noise_end_pos]
436+
noise_mean = rf[well_ID]["NoiseMean"][noise_start_pos:noise_end_pos]
437+
noise_std = rf[well_ID]["NoiseStdDev"][noise_start_pos:noise_end_pos]
438+
439+
noise_length = noise_end_pos - noise_start_pos
440+
noise_info = {}
441+
mean_collection = []
442+
std_collection = []
443+
for i in range(1, noise_length):
444+
noise_info[noise_ch_idx[i]] = [noise_mean[i], noise_std[i]]
445+
mean_collection.append(noise_mean[i])
446+
std_collection.append(noise_std[i])
447+
# calculate the median mean and standard deviation of all channels to be used for
448+
# invalid channels
449+
median_mean = np.median(mean_collection)
450+
median_std = np.median(std_collection)
451+
# fill with Gaussian noise
452+
for ch_idx in range(len(data)):
453+
if ch_idx in noise_info:
454+
data[ch_idx] = np.array(np.random.normal(noise_info[ch_idx][0], noise_info[ch_idx][1],
455+
num_frames), dtype=np.uint16)
456+
else:
457+
data[ch_idx] = np.array(np.random.normal(median_mean, median_std, num_frames),
458+
dtype=np.uint16)
459+
460+
return data

neo/rawio/blackrockrawio.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ class BlackrockRawIO(BaseRawIO):
130130
extensions.extend(["nev", "sif", "ccf"]) # 'sif', 'ccf' not yet supported
131131
rawmode = "multi-file"
132132

133+
# We need to document the origin of this value
134+
main_sampling_rate = 30000.0
135+
136+
133137
def __init__(
134138
self, filename=None, nsx_override=None, nev_override=None, nsx_to_load=None, load_nev=True, verbose=False
135139
):
@@ -250,7 +254,6 @@ def __init__(
250254

251255
def _parse_header(self):
252256

253-
main_sampling_rate = 30000.0
254257

255258
event_channels = []
256259
spike_channels = []
@@ -298,7 +301,7 @@ def _parse_header(self):
298301
# TODO: Double check if this is the correct assumption (10 samples)
299302
# default value: threshold crossing after 10 samples of waveform
300303
wf_left_sweep = 10
301-
wf_sampling_rate = main_sampling_rate
304+
wf_sampling_rate = self.main_sampling_rate
302305
spike_channels.append((name, _id, wf_units, wf_gain, wf_offset, wf_left_sweep, wf_sampling_rate))
303306

304307
# scan events
@@ -392,7 +395,7 @@ def _parse_header(self):
392395
_data_reader_fun = self.__nsx_data_reader[spec]
393396
self.nsx_datas[nsx_nb] = _data_reader_fun(nsx_nb)
394397

395-
sr = float(main_sampling_rate / self.__nsx_basic_header[nsx_nb]["period"])
398+
sr = float(self.main_sampling_rate / self.__nsx_basic_header[nsx_nb]["period"])
396399
self.sig_sampling_rates[nsx_nb] = sr
397400

398401
if spec in ["2.2", "2.3", "3.0"]:

neo/rawio/neuralynxrawio/nlxheader.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ def _to_bool(txt):
9898
),
9999
# Cheetah version 5.6.0, some range of versions in between
100100
"v5.6.0": dict(
101-
datetime1_regex=r"## Time Opened: \(m/d/y\): (?P<date>\S+)" r" At Time: (?P<time>\S+)",
101+
datetime1_regex=r"## Time Opened \(m/d/y\): (?P<date>\S+)" r" \(h:m:s.ms\) (?P<time>\S+)",
102+
datetime2_regex=r"## Time Closed \(m/d/y\): (?P<date>\S+)" r" \(h:m:s.ms\) (?P<time>\S+)",
102103
filename_regex=r"## File Name: (?P<filename>\S+)",
103104
datetimeformat="%m/%d/%Y %H:%M:%S.%f",
104105
),

neo/rawio/openephysbinaryrawio.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def _parse_header(self):
130130
# create signals channel map: several channel per stream
131131
signal_channels = []
132132
sync_stream_id_to_buffer_id = {}
133+
normal_stream_id_to_sync_stream_id = {}
133134
for stream_index, stream_name in enumerate(sig_stream_names):
134135
# stream_index is the index in vector stream names
135136
stream_id = str(stream_index)
@@ -140,6 +141,7 @@ def _parse_header(self):
140141
chan_id = chan_info["channel_name"]
141142

142143
units = chan_info["units"]
144+
channel_stream_id = stream_id
143145
if units == "":
144146
# When units are not provided they are microvolts for neural channels and volts for ADC channels
145147
# See https://open-ephys.github.io/gui-docs/User-Manual/Recording-data/Binary-format.html#continuous
@@ -148,14 +150,19 @@ def _parse_header(self):
148150
# Special cases for stream
149151
if "SYNC" in chan_id and not self.load_sync_channel:
150152
# Every stream sync channel is added as its own stream
151-
stream_id = f"{chan_id}-{str(stream_index)}"
152-
sync_stream_id_to_buffer_id[stream_id] = buffer_id
153+
sync_stream_id = f"{stream_name}SYNC"
154+
sync_stream_id_to_buffer_id[sync_stream_id] = buffer_id
155+
156+
# We save this mapping for the buffer description protocol
157+
normal_stream_id_to_sync_stream_id[stream_id] = sync_stream_id
158+
# We then set the stream_id to the sync stream id
159+
channel_stream_id = sync_stream_id
153160

154161
if "ADC" in chan_id:
155162
# These are non-neural channels and their stream should be separated
156163
# We defined their stream_id as the stream_index of neural data plus the number of neural streams
157164
# This is to not break backwards compatbility with the stream_id numbering
158-
stream_id = str(stream_index + len(sig_stream_names))
165+
channel_stream_id = str(stream_index + len(sig_stream_names))
159166

160167
gain = float(chan_info["bit_volts"])
161168
sampling_rate = float(info["sample_rate"])
@@ -169,7 +176,7 @@ def _parse_header(self):
169176
units,
170177
gain,
171178
offset,
172-
stream_id,
179+
channel_stream_id,
173180
buffer_id,
174181
)
175182
)
@@ -274,9 +281,8 @@ def _parse_header(self):
274281
self._stream_buffer_slice[stream_id] = slice(None, -1)
275282

276283
# Add a buffer slice for the sync channel
277-
sync_channel_name = info["channels"][-1]["channel_name"]
278-
stream_name = f"{sync_channel_name}-{str(stream_id)}"
279-
self._stream_buffer_slice[stream_name] = slice(-1, None)
284+
sync_stream_id = normal_stream_id_to_sync_stream_id[stream_id]
285+
self._stream_buffer_slice[sync_stream_id] = slice(-1, None)
280286
else:
281287
self._stream_buffer_slice[stream_id] = None
282288
else:
@@ -290,8 +296,8 @@ def _parse_header(self):
290296
self._stream_buffer_slice[stream_id_non_neural] = slice(num_neural_channels, -1)
291297

292298
# Add a buffer slice for the sync channel
293-
sync_channel_name = info["channels"][-1]["channel_name"]
294-
self._stream_buffer_slice[sync_channel_name] = slice(-1, None)
299+
sync_stream_id = normal_stream_id_to_sync_stream_id[stream_id]
300+
self._stream_buffer_slice[sync_stream_id] = slice(-1, None)
295301
else:
296302
self._stream_buffer_slice[stream_id_non_neural] = slice(num_neural_channels, None)
297303

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ classifiers = [
2424

2525
dependencies = [
2626
"packaging",
27-
"numpy>=1.22.4",
27+
"numpy>=1.24.4",
2828
"quantities>=0.16.1"
2929
]
3030

0 commit comments

Comments
 (0)