Skip to content

Commit a27df7a

Browse files
committed
draft
1 parent ab821e7 commit a27df7a

File tree

1 file changed

+103
-59
lines changed

1 file changed

+103
-59
lines changed

neo/rawio/spikeglxrawio.py

Lines changed: 103 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ def _source_name(self):
126126

127127
def _parse_header(self):
128128
self.signals_info_list = scan_files(self.dirname)
129+
_add_segment_order(self.signals_info_list)
129130

130131
# sort stream_name by higher sampling rate first
131132
srates = {info["stream_name"]: info["sampling_rate"] for info in self.signals_info_list}
@@ -165,8 +166,9 @@ def _parse_header(self):
165166
sync_stream_id_to_buffer_id = {}
166167

167168
for stream_name in stream_names:
168-
# take first segment
169-
info = self.signals_info_dict[0, stream_name]
169+
# take first segment to extract the signal info
170+
segment_index = 0
171+
info = self.signals_info_dict[segment_index, stream_name]
170172

171173
buffer_id = stream_name
172174
buffer_name = stream_name
@@ -175,44 +177,42 @@ def _parse_header(self):
175177
stream_id = stream_name
176178

177179
signal_streams.append((stream_name, stream_id, buffer_id))
178-
180+
signal_channel_names = info["analog_channels"] if info["device_kind"] in ["obx", "nidq"] else info["channel_names"]
179181
# add channels to global list
180-
for local_chan in range(info["num_chan"]):
181-
chan_name = info["channel_names"][local_chan]
182-
chan_id = f"{stream_name}#{chan_name}"
183-
184-
# Sync channel
185-
if (
186-
"nidq" not in stream_name
187-
and "SY0" in chan_name
188-
and not self.load_sync_channel
189-
and local_chan == info["num_chan"] - 1
190-
):
182+
for local_channel_index, channel_name in enumerate(signal_channel_names):
183+
# This should be unique across all streams
184+
chan_id = f"{stream_name}#{channel_name}"
185+
186+
# Separate sync channel in its own stream
187+
is_sync_channel = "SY0" in channel_name and not self.load_sync_channel
188+
if is_sync_channel :
191189
# This is a sync channel and should be added as its own stream
192190
sync_stream_id = f"{stream_name}-SYNC"
193191
sync_stream_id_to_buffer_id[sync_stream_id] = buffer_id
194192
stream_id_for_chan = sync_stream_id
193+
195194
else:
196195
stream_id_for_chan = stream_id
197196

198197
signal_channels.append(
199198
(
200-
chan_name,
199+
channel_name,
201200
chan_id,
202201
info["sampling_rate"],
203202
"int16",
204203
info["units"],
205-
info["channel_gains"][local_chan],
206-
info["channel_offsets"][local_chan],
204+
info["channel_gains"][local_channel_index],
205+
info["channel_offsets"][local_channel_index],
207206
stream_id_for_chan,
208207
buffer_id,
209208
)
210209
)
211210

212-
# all channel by default unless load_sync_channel=False
211+
# None here means that the all the channels in the buffer will be included
213212
self._stream_buffer_slice[stream_id] = None
214213

215-
# check sync channel validity
214+
# Then we modify this if sync channel is present to slice the last channel
215+
# out of the stream buffer
216216
if "nidq" not in stream_name:
217217
if not self.load_sync_channel and info["has_sync_trace"]:
218218
# the last channel is removed from the stream but not from the buffer
@@ -227,7 +227,7 @@ def _parse_header(self):
227227

228228
signal_buffers = np.array(signal_buffers, dtype=_signal_buffer_dtype)
229229

230-
# Add sync channels as their own streams
230+
# Add sync channels as their own streams. We do it here to keep the order of the streams
231231
for sync_stream_id, buffer_id in sync_stream_id_to_buffer_id.items():
232232
signal_streams.append((sync_stream_id, sync_stream_id, buffer_id))
233233

@@ -259,7 +259,6 @@ def _parse_header(self):
259259
spike_channels = np.array(spike_channels, dtype=_spike_channel_dtype)
260260

261261
# deal with nb_segment and t_start/t_stop per segment
262-
263262
self._t_starts = {stream_name: {} for stream_name in stream_names}
264263
self._t_stops = {seg_index: 0.0 for seg_index in range(nb_segment)}
265264

@@ -378,12 +377,7 @@ def _get_analogsignal_buffer_description(self, block_index, seg_index, buffer_id
378377

379378
def scan_files(dirname):
380379
"""
381-
Scan for pairs of `.bin` and `.meta` files and return information about it.
382-
383-
After exploring the folder, the segment index (`seg_index`) is construct as follow:
384-
* if only one `gate_num=0` then `trigger_num` = `seg_index`
385-
* if only one `trigger_num=0` then `gate_num` = `seg_index`
386-
* if both are increasing then seg_index increased by gate_num, trigger_num order.
380+
Scan for pairs of `.bin` and `.meta` files and parse the metadata file to extract signal information.
387381
"""
388382
info_list = []
389383

@@ -392,7 +386,15 @@ def scan_files(dirname):
392386
if not file.endswith(".meta"):
393387
continue
394388
meta_filename = Path(root) / file
395-
bin_filename = meta_filename.with_suffix(".bin")
389+
390+
# Handle both regular .bin files and OneBox .obx.bin files
391+
if file.endswith(".obx.meta"):
392+
# OneBox case: .obx.meta -> .obx.bin
393+
bin_filename = meta_filename.with_suffix(".bin")
394+
else:
395+
# Regular case: .meta -> .bin
396+
bin_filename = meta_filename.with_suffix(".bin")
397+
396398
if meta_filename.exists() and bin_filename.exists():
397399
meta = read_meta_file(meta_filename)
398400
info = extract_stream_info(meta_filename, meta)
@@ -404,9 +406,21 @@ def scan_files(dirname):
404406
if len(info_list) == 0:
405407
raise FileNotFoundError(f"No appropriate combination of .meta and .bin files were detected in {dirname}")
406408

409+
return info_list
410+
411+
def _add_segment_order(info_list):
412+
"""
413+
Uses gate and trigger numbers to construct a segment index (`seg_index`) for each signal in `info_list`.
414+
415+
After exploring the folder, the segment index (`seg_index`) is construct as follow:
416+
* if only one `gate_num=0` then `trigger_num` = `seg_index`
417+
* if only one `trigger_num=0` then `gate_num` = `seg_index`
418+
* if both are increasing then seg_index increased by gate_num, trigger_num order.
419+
420+
"""
407421
# This sets non-integers values before integers
408422
normalize = lambda x: x if isinstance(x, int) else -1
409-
423+
410424
# Segment index is determined by the gate_num and trigger_num in that order
411425
def get_segment_tuple(info):
412426
# Create a key from the normalized gate_num and trigger_num
@@ -423,25 +437,6 @@ def get_segment_tuple(info):
423437
for info in info_list:
424438
info["seg_index"] = segment_tuple_to_segment_index[get_segment_tuple(info)]
425439

426-
for info in info_list:
427-
# device_kind is imec, nidq
428-
if info.get("device_kind") == "imec":
429-
info["device_index"] = info["device"].split("imec")[-1]
430-
else:
431-
info["device_index"] = "" # TODO: Handle multi nidq case, maybe use meta["typeNiEnabled"]
432-
433-
# Define stream base on device_kind [imec|nidq], device_index and stream_kind [ap|lf] for imec
434-
# Stream format is "{device_kind}{device_index}.{stream_kind}"
435-
for info in info_list:
436-
device_kind = info["device_kind"]
437-
device_index = info["device_index"]
438-
stream_kind = f".{info['stream_kind']}" if info["stream_kind"] else ""
439-
stream_name = f"{device_kind}{device_index}{stream_kind}"
440-
441-
info["stream_name"] = stream_name
442-
443-
return info_list
444-
445440

446441
def parse_spikeglx_fname(fname):
447442
"""
@@ -451,13 +446,13 @@ def parse_spikeglx_fname(fname):
451446
https://github.com/billkarsh/SpikeGLX/blob/15ec8898e17829f9f08c226bf04f46281f106e5f/Markdown/UserManual.md#gates-and-triggers
452447
453448
Example file name structure:
454-
Consider the filenames: `Noise4Sam_g0_t0.nidq.bin` or `Noise4Sam_g0_t0.imec0.lf.bin`
449+
Consider the filenames: `Noise4Sam_g0_t0.nidq.bin`, `Noise4Sam_g0_t0.imec0.lf.bin`, or `myRun_g0_t0.obx0.obx.bin`
455450
The filenames consist of 3 or 4 parts separated by `.`
456451
1. "Noise4Sam_g0_t0" will be the `name` variable. This choosen by the user at recording time.
457452
2. "g0" is the "gate_num"
458453
3. "t0" is the "trigger_num"
459-
4. "nidq" or "imec0" will give the `device`
460-
5. "lf" or "ap" will be the `stream_kind`
454+
4. "nidq", "imec0", or "obx0" will give the `device`
455+
5. "lf", "ap", or "obx" will be the `stream_kind`
461456
`stream_name` variable is the concatenation of `device.stream_kind`
462457
463458
If CatGT is used, then the trigger numbers in the file names ("t0"/"t1"/etc.)
@@ -472,7 +467,7 @@ def parse_spikeglx_fname(fname):
472467
Parameters
473468
---------
474469
fname: str
475-
The filename to parse without the extension, e.g. "my-run-name_g0_t1.imec2.lf"
470+
The filename to parse without the extension, e.g. "my-run-name_g0_t1.imec2.lf" or "my-run-name_g0_t0.obx0.obx"
476471
477472
Returns
478473
-------
@@ -483,27 +478,33 @@ def parse_spikeglx_fname(fname):
483478
trigger_num: int | str or None
484479
The trigger identifier, e.g. 1. If CatGT is used, then the trigger_num will be set to "cat".
485480
device: str
486-
The probe identifier, e.g. "imec2"
481+
The probe identifier, e.g. "imec2" or "obx0"
487482
stream_kind: str or None
488-
The data type identifier, "lf" or "ap" or None
483+
The data type identifier, "lf", "ap", "obx", or None
489484
"""
490485
re_standard = re.findall(r"(\S*)_g(\d*)_t(\d*)\.(\S*).(ap|lf)", fname)
491486
re_tcat = re.findall(r"(\S*)_g(\d*)_tcat.(\S*).(ap|lf)", fname)
492487
re_nidq = re.findall(r"(\S*)_g(\d*)_t(\d*)\.(\S*)", fname)
488+
re_obx = re.findall(r"(\S*)_g(\d*)_t(\d*)\.(\S*)\.obx", fname)
489+
493490
if len(re_standard) == 1:
494491
# standard case with probe
495492
run_name, gate_num, trigger_num, device, stream_kind = re_standard[0]
496493
elif len(re_tcat) == 1:
497494
# tcat case
498495
run_name, gate_num, device, stream_kind = re_tcat[0]
499496
trigger_num = "cat"
497+
elif len(re_obx) == 1:
498+
# OneBox case
499+
run_name, gate_num, trigger_num, device = re_obx[0]
500+
stream_kind = "obx"
500501
elif len(re_nidq) == 1:
501502
# case for nidaq
502503
run_name, gate_num, trigger_num, device = re_nidq[0]
503504
stream_kind = None
504505
else:
505506
# the naming do not correspond lets try something more easy
506-
# example: sglx_xxx.imec0.ap
507+
# example: sglx_xxx.imec0.ap or sglx_xxx.obx0.obx
507508
re_else = re.findall(r"(\S*)\.(\S*).(ap|lf)", fname)
508509
re_else_nidq = re.findall(r"(\S*)\.(\S*)", fname)
509510
if len(re_else) == 1:
@@ -554,13 +555,17 @@ def extract_stream_info(meta_file, meta):
554555
# AP and LF meta have this field
555556
ap, lf, sy = [int(s) for s in meta["snsApLfSy"].split(",")]
556557
has_sync_trace = sy == 1
558+
elif "snsXaDwSy" in meta:
559+
# OneBox case
560+
xa, dw, sy = [int(s) for s in meta["snsXaDwSy"].split(",")]
561+
has_sync_trace = sy == 1
557562
else:
558563
# NIDQ case
559564
has_sync_trace = False
560565

561566
# This is the original name that the file had. It might not match the current name if the user changed it
562-
bin_file_path = meta["fileName"]
563-
fname = Path(bin_file_path).stem
567+
original_bin_file_path = meta["fileName"]
568+
fname = Path(original_bin_file_path).stem
564569

565570
run_name, gate_num, trigger_num, device, stream_kind = parse_spikeglx_fname(fname)
566571

@@ -603,6 +608,18 @@ def extract_stream_info(meta_file, meta):
603608
channel_gains = gain_factor * per_channel_gain * 1e6
604609
else:
605610
raise NotImplementedError("This meta file version of spikeglx" " is not implemented")
611+
elif meta.get("typeThis") == "obx":
612+
# OneBox case
613+
device = fname.split(".")[-2] if "." in fname else device
614+
stream_kind = ""
615+
units = "V"
616+
617+
# OneBox gain calculation
618+
# V = i * Vmax / Imax where Imax = obMaxInt, Vmax = obAiRangeMax
619+
# See: https://billkarsh.github.io/SpikeGLX/Sgl_help/Metadata_30.html
620+
max_int = int(meta["obMaxInt"])
621+
gain_factor = float(meta["obAiRangeMax"]) / max_int
622+
channel_gains = np.ones(num_chan) * gain_factor
606623
else:
607624
device = fname.split(".")[-1]
608625
stream_kind = ""
@@ -624,11 +641,14 @@ def extract_stream_info(meta_file, meta):
624641
probe_slot = meta.get("imDatPrb_slot", None)
625642
probe_port = meta.get("imDatPrb_port", None)
626643
probe_dock = meta.get("imDatPrb_dock", None)
644+
645+
# OneBox specific metadata
646+
obx_slot = meta.get("imDatObx_slot", None)
627647

628648
info = {}
629649
info["fname"] = fname
630650
info["meta"] = meta
631-
for k in ("niSampRate", "imSampRate"):
651+
for k in ("niSampRate", "imSampRate", "obSampRate"):
632652
if k in meta:
633653
info["sampling_rate"] = float(meta[k])
634654
info["num_chan"] = num_chan
@@ -648,8 +668,26 @@ def extract_stream_info(meta_file, meta):
648668
info["probe_slot"] = int(probe_slot) if probe_slot else None
649669
info["probe_port"] = int(probe_port) if probe_port else None
650670
info["probe_dock"] = int(probe_dock) if probe_dock else None
671+
info["obx_slot"] = int(obx_slot) if obx_slot else None
672+
673+
# Add device index
674+
if info.get("device_kind") == "imec":
675+
info["device_index"] = info["device"].split("imec")[-1]
676+
elif info.get("device_kind") == "obx":
677+
info["device_index"] = info["device"].split("obx")[-1]
678+
else:
679+
info["device_index"] = "" # TODO: Handle multi nidq case, maybe use meta["typeNiEnabled"]
651680

652-
if "nidq" in device:
681+
# Define stream base on device_kind [imec|nidq|obx], device_index and stream_kind [ap|lf] for imec
682+
# Stream format is "{device_kind}{device_index}.{stream_kind}"
683+
device_kind = info["device_kind"]
684+
device_index = info["device_index"]
685+
stream_kind = f".{info['stream_kind']}" if info["stream_kind"] else ""
686+
info["stream_name"] = f"{device_kind}{device_index}{stream_kind}"
687+
688+
# Separate analog and digital channels
689+
if device_kind == "nidq":
690+
# Note that we only handling the non-multiplexed channels here
653691
info["digital_channels"] = []
654692
info["analog_channels"] = [channel for channel in info["channel_names"] if not channel.startswith("XD")]
655693
# Digital/event channels are encoded within the digital word, so that will need more handling
@@ -661,4 +699,10 @@ def extract_stream_info(meta_file, meta):
661699
info["digital_channels"].extend([f"XD{i}" for i in range(start, end + 1)])
662700
else:
663701
info["digital_channels"].append(f"XD{int(item)}")
702+
elif device_kind == "obx":
703+
# OneBox channel handling - focus on analog channels only as requested
704+
info["digital_channels"] = [channel for channel in info["channel_names"] if channel.startswith("XD")]
705+
info["analog_channels"] = [channel for channel in info["channel_names"] if channel.startswith("XA")]
706+
707+
664708
return info

0 commit comments

Comments
 (0)