Skip to content

Commit 02da7aa

Browse files
h-mayorquinzm711
andauthored
Add stim demultiplex rhs intan + Improve handling of one-file-per-channel (#1660)
* add stim demultiplex rhs intan * fix tests * docstring and demultiplex to decode name change * Update neo/rawio/intanrawio.py Co-authored-by: Zach McKenzie <[email protected]> * Update neo/rawio/intanrawio.py Co-authored-by: Zach McKenzie <[email protected]> * Update neo/rawio/intanrawio.py Co-authored-by: Zach McKenzie <[email protected]> * fix * refactor and comments * add test * add comments to test * add gin comment * update header parsing for stim + update docstring * add stim to channel check * wip * oops * fix for header attached * fix for one-file-per-signal * add one new test file --------- Co-authored-by: Zach McKenzie <[email protected]>
1 parent b26be18 commit 02da7aa

File tree

3 files changed

+218
-40
lines changed

3 files changed

+218
-40
lines changed

neo/rawio/intanrawio.py

Lines changed: 162 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,12 @@ class IntanRawIO(BaseRawIO):
6363
'one-file-per-channel' which have a header file called 'info.rhd' or 'info.rhs' and a series
6464
of binary files with the '.dat' suffix
6565
66-
* The reader can handle three file formats 'header-attached', 'one-file-per-signal' and
67-
'one-file-per-channel'.
68-
69-
* Intan files contain amplifier channels labeled 'A', 'B' 'C' or 'D'
70-
depending on the port in which they were recorded along with the following
66+
* Intan files contain amplifier channels labeled 'A', 'B' 'C' or 'D' for the 512 recorder
67+
or 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H' for the 1024 recorder system
68+
depending on the port in which they were recorded along (stored in stream_id '0') with the following
7169
additional streams.
7270
73-
0: 'RHD2000' amplifier channel
71+
0: 'RHD2000 amplifier channel'
7472
1: 'RHD2000 auxiliary input channel',
7573
2: 'RHD2000 supply voltage channel',
7674
3: 'USB board ADC input channel',
@@ -87,9 +85,11 @@ class IntanRawIO(BaseRawIO):
8785
10: 'DC Amplifier channel',
8886
11: 'Stim channel',
8987
90-
* For the "header-attached" and "one-file-per-signal" formats, the structure of the digital input and output channels is
91-
one long vector, which must be post-processed to extract individual digital channel information.
92-
See the intantech website for more information on performing this post-processing.
88+
* We currently implement digital data demultiplexing so that if digital streams are requested they are
89+
returned as arrays of 1s and 0s.
90+
91+
* We also do stim data decoding which returns the stim data as an int16 of appropriate magnitude. Please
92+
use `rescale_signal_raw_to_float` to obtain stim data in amperes.
9393
9494
9595
Examples
@@ -442,6 +442,7 @@ def _get_analogsignal_chunk_header_attached(self, i_start, i_stop, stream_index,
442442

443443
stream_name = self.header["signal_streams"][stream_index]["name"][:]
444444
stream_is_digital = stream_name in digital_stream_names
445+
stream_is_stim = stream_name == "Stim channel"
445446

446447
field_name = stream_name if stream_is_digital else channel_ids[0]
447448

@@ -460,17 +461,22 @@ def _get_analogsignal_chunk_header_attached(self, i_start, i_stop, stream_index,
460461
sl0 = i_start % block_size
461462
sl1 = sl0 + (i_stop - i_start)
462463

463-
# For all streams raw_data is a structured memmap with a field for each channel_id
464464
if not stream_is_digital:
465+
# For all streams raw_data is a structured memmap with a field for each channel_id
465466
sigs_chunk = np.zeros((i_stop - i_start, len(channel_ids)), dtype=dtype)
466-
467467
for chunk_index, channel_id in enumerate(channel_ids):
468468
data_chan = self._raw_data[channel_id]
469+
469470
if multiple_samples_per_block:
470471
sigs_chunk[:, chunk_index] = data_chan[block_start:block_stop].flatten()[sl0:sl1]
471472
else:
472473
sigs_chunk[:, chunk_index] = data_chan[i_start:i_stop]
473-
else: # For digital data the channels come interleaved in a single field so we need to demultiplex
474+
475+
if stream_is_stim:
476+
sigs_chunk = self._decode_current_from_stim_data(sigs_chunk, 0, sigs_chunk.shape[0])
477+
478+
else:
479+
# For digital data the channels come interleaved in a single field so we need to demultiplex
474480
digital_raw_data = self._raw_data[field_name].flatten()
475481
sigs_chunk = self._demultiplex_digital_data(digital_raw_data, channel_ids, i_start, i_stop)
476482
return sigs_chunk
@@ -479,6 +485,8 @@ def _get_analogsignal_chunk_one_file_per_channel(self, i_start, i_stop, stream_i
479485

480486
stream_name = self.header["signal_streams"][stream_index]["name"][:]
481487
signal_data_memmap_list = self._raw_data[stream_name]
488+
stream_is_stim = stream_name == "Stim channel"
489+
482490
channel_indexes_are_slice = isinstance(channel_indexes, slice)
483491
if channel_indexes_are_slice:
484492
num_channels = len(signal_data_memmap_list)
@@ -496,6 +504,10 @@ def _get_analogsignal_chunk_one_file_per_channel(self, i_start, i_stop, stream_i
496504
channel_memmap = signal_data_memmap_list[channel_index]
497505
sigs_chunk[:, chunk_index] = channel_memmap[i_start:i_stop]
498506

507+
# If this is stim data, we need to decode the current values
508+
if stream_is_stim:
509+
sigs_chunk = self._decode_current_from_stim_data(sigs_chunk, 0, sigs_chunk.shape[0])
510+
499511
return sigs_chunk
500512

501513
def _get_analogsignal_chunk_one_file_per_signal(self, i_start, i_stop, stream_index, channel_indexes):
@@ -504,21 +516,57 @@ def _get_analogsignal_chunk_one_file_per_signal(self, i_start, i_stop, stream_in
504516
raw_data = self._raw_data[stream_name]
505517

506518
stream_is_digital = stream_name in digital_stream_names
519+
stream_is_stim = stream_name == "Stim channel"
520+
507521
if stream_is_digital:
508522
stream_id = self.header["signal_streams"][stream_index]["id"]
509523
mask = self.header["signal_channels"]["stream_id"] == stream_id
510524
signal_channels = self.header["signal_channels"][mask]
511525
channel_ids = signal_channels["id"][channel_indexes]
512526

513527
output = self._demultiplex_digital_data(raw_data, channel_ids, i_start, i_stop)
514-
528+
elif stream_is_stim:
529+
output = self._decode_current_from_stim_data(raw_data, i_start, i_stop)
530+
output = output[:, channel_indexes]
515531
else:
516532
output = raw_data[i_start:i_stop, channel_indexes]
517533

518534
return output
519535

520536
def _demultiplex_digital_data(self, raw_digital_data, channel_ids, i_start, i_stop):
537+
"""
538+
Demultiplex digital data by extracting individual channel values from packed 16-bit format.
539+
540+
According to the Intan format, digital input/output data is stored with all 16 channels
541+
encoded bit-by-bit in each 16-bit word. This method extracts the specified digital channels
542+
from the packed format into separate uint16 arrays of 0 and 1.
543+
544+
Parameters
545+
----------
546+
raw_digital_data : ndarray
547+
Raw digital data in packed 16-bit format where each bit represents a different channel.
548+
channel_ids : list or array
549+
List of channel identifiers to extract. Each channel_id must correspond to a digital
550+
input or output channel.
551+
i_start : int
552+
Starting sample index for demultiplexing.
553+
i_stop : int
554+
Ending sample index for demultiplexing (exclusive).
555+
556+
Returns
557+
-------
558+
ndarray
559+
Demultiplexed digital data with shape (i_stop-i_start, len(channel_ids)),
560+
containing 0 or 1 values for each requested channel.
521561
562+
Notes
563+
-----
564+
In the Intan format, digital channels are packed into 16-bit words where each bit position
565+
corresponds to a specific channel number. For example, with digital inputs 0, 4, and 5
566+
set high and the rest low, the 16-bit word would be 2^0 + 2^4 + 2^5 = 1 + 16 + 32 = 49.
567+
568+
The native_order property for each channel corresponds to its bit position in the packed word.
569+
"""
522570
dtype = np.uint16 # We fix this to match the memmap dtype
523571
output = np.zeros((i_stop - i_start, len(channel_ids)), dtype=dtype)
524572

@@ -530,6 +578,65 @@ def _demultiplex_digital_data(self, raw_digital_data, channel_ids, i_start, i_st
530578

531579
return output
532580

581+
def _decode_current_from_stim_data(self, raw_stim_data, i_start, i_stop):
582+
"""
583+
Demultiplex stimulation data by extracting current values from packed 16-bit format.
584+
585+
According to the Intan RHS data format, stimulation current is stored in the lower 9 bits
586+
of each 16-bit word: 8 bits for magnitude and 1 bit for sign. The upper bits contain
587+
flags for compliance limit, charge recovery, and amplifier settle.
588+
589+
Parameters
590+
----------
591+
raw_stim_data : ndarray
592+
Raw stimulation data in packed 16-bit format.
593+
i_start : int
594+
Starting sample index for demultiplexing.
595+
i_stop : int
596+
Ending sample index for demultiplexing (exclusive).
597+
598+
Returns
599+
-------
600+
ndarray
601+
Demultiplexed stimulation current values in amperes, preserving the original
602+
array dimensions. The output values need to be multiplied by the stim_step_size
603+
parameter (from header) to obtain the actual current in amperes.
604+
605+
Notes
606+
-----
607+
Bit structure of each 16-bit stimulation word:
608+
- Bits 0-7: Current magnitude
609+
- Bit 8: Sign bit (1 = negative current)
610+
- Bits 9-13: Unused (always zero)
611+
- Bit 14: Amplifier settle flag (1 = activated)
612+
- Bit 15: Charge recovery flag (1 = activated)
613+
- Bit 16 (MSB): Compliance limit flag (1 = limit reached)
614+
615+
The actual current value in amperes is obtained by multiplying the
616+
output by the 'stim_step_size' parameter from the file header. These scaled values can be
617+
obtained with the `rescale_signal_raw_to_float` function.
618+
"""
619+
# Get the relevant portion of the data
620+
data = raw_stim_data[i_start:i_stop]
621+
622+
# Extract current value (bits 0-8)
623+
magnitude = np.bitwise_and(data, 0xFF)
624+
sign_bit = np.bitwise_and(np.right_shift(data, 8), 0x01) # Shift right by 8 bits to get the sign bit
625+
626+
# Apply sign to current values
627+
# We need to cast to int16 to handle negative values correctly
628+
# The max value of 8 bits is 255 so the casting is safe as there are non-negative values
629+
magnitude = magnitude.astype(np.int16)
630+
current = np.where(sign_bit == 1, -magnitude, magnitude)
631+
632+
# Note: If needed, other flag bits could be extracted as follows:
633+
# compliance_flag = np.bitwise_and(np.right_shift(data, 15), 0x01).astype(bool) # Bit 16 (MSB)
634+
# charge_recovery_flag = np.bitwise_and(np.right_shift(data, 14), 0x01).astype(bool) # Bit 15
635+
# amp_settle_flag = np.bitwise_and(np.right_shift(data, 13), 0x01).astype(bool) # Bit 14
636+
# These could be returned as a structured array or dictionary if needed
637+
638+
return current
639+
533640
def get_intan_timestamps(self, i_start=None, i_stop=None):
534641
"""
535642
Retrieves the sample indices from the Intan raw data within a specified range.
@@ -793,8 +900,18 @@ def read_rhs(filename, file_format: str):
793900
channel_number_dict = {name: len(stream_name_to_channel_info_list[name]) for name in names_to_count}
794901

795902
# Both DC Amplifier and Stim streams have the same number of channels as the amplifier stream
903+
# if using the `header-attached` or `one-file-per-signal` formats
904+
# the amplifier data is stored in the same place in the header so no matter what the DC amp
905+
# uses the same info as the RHS amp.
796906
channel_number_dict["DC Amplifier channel"] = channel_number_dict["RHS2000 amplifier channel"]
797-
channel_number_dict["Stim channel"] = channel_number_dict["RHS2000 amplifier channel"]
907+
if file_format != "one-file-per-channel":
908+
channel_number_dict["Stim channel"] = channel_number_dict["RHS2000 amplifier channel"]
909+
raw_file_paths_dict = create_one_file_per_signal_dict_rhs(dirname=filename.parent)
910+
else:
911+
raw_file_paths_dict = create_one_file_per_channel_dict_rhs(dirname=filename.parent)
912+
channel_number_dict["Stim channel"] = len(raw_file_paths_dict["Stim channel"])
913+
# but the user can shut off the normal amplifier and only save dc amplifier
914+
channel_number_dict["RHS2000 amplifier channel"] = len(raw_file_paths_dict["RHS2000 amplifier channel"])
798915

799916
header_size = f.tell()
800917

@@ -808,24 +925,25 @@ def read_rhs(filename, file_format: str):
808925
memmap_data_dtype["timestamp"] = "int32"
809926
channel_number_dict["timestamp"] = 1
810927

811-
for chan_info in stream_name_to_channel_info_list["RHS2000 amplifier channel"]:
812-
chan_info["sampling_rate"] = sr
813-
chan_info["units"] = "uV"
814-
chan_info["gain"] = 0.195
815-
if file_format == "header-attached":
816-
chan_info["offset"] = -32768 * 0.195
817-
else:
818-
chan_info["offset"] = 0.0
819-
if file_format == "header-attached":
820-
chan_info["dtype"] = "uint16"
821-
else:
822-
chan_info["dtype"] = "int16"
823-
ordered_channel_info.append(chan_info)
824-
if file_format == "header-attached":
825-
name = chan_info["native_channel_name"]
826-
memmap_data_dtype += [(name, "uint16", BLOCK_SIZE)]
827-
else:
828-
memmap_data_dtype["RHS2000 amplifier channel"] = "int16"
928+
if file_format != "one-file-per-channel" or channel_number_dict["RHS2000 amplifier channel"] > 0:
929+
for chan_info in stream_name_to_channel_info_list["RHS2000 amplifier channel"]:
930+
chan_info["sampling_rate"] = sr
931+
chan_info["units"] = "uV"
932+
chan_info["gain"] = 0.195
933+
if file_format == "header-attached":
934+
chan_info["offset"] = -32768 * 0.195
935+
else:
936+
chan_info["offset"] = 0.0
937+
if file_format == "header-attached":
938+
chan_info["dtype"] = "uint16"
939+
else:
940+
chan_info["dtype"] = "int16"
941+
ordered_channel_info.append(chan_info)
942+
if file_format == "header-attached":
943+
name = chan_info["native_channel_name"]
944+
memmap_data_dtype += [(name, "uint16", BLOCK_SIZE)]
945+
else:
946+
memmap_data_dtype["RHS2000 amplifier channel"] = "int16"
829947

830948
if bool(global_info["dc_amplifier_data_saved"]):
831949
# if we have dc amp we need to grab the correct number of channels
@@ -847,27 +965,31 @@ def read_rhs(filename, file_format: str):
847965
memmap_data_dtype["DC Amplifier channel"] = "uint16"
848966

849967
# I can't seem to get stim files to generate for one-file-per-channel
850-
# so let's skip for now and can be given on request
851-
if file_format != "one-file-per-channel":
852-
for chan_info in stream_name_to_channel_info_list["RHS2000 amplifier channel"]:
968+
# so ideally at some point we need test data to confirm this is true
969+
# based on what Heberto and I read in the docs
970+
for chan_info in stream_name_to_channel_info_list["RHS2000 amplifier channel"]:
971+
# we see which stim were activated
972+
if file_format != "one-file-per-channel" or any(
973+
[chan_info["native_channel_name"] in stim_file.stem for stim_file in raw_file_paths_dict["Stim channel"]]
974+
):
853975
chan_info_stim = dict(chan_info)
854976
name = chan_info["native_channel_name"]
855977
chan_info_stim["native_channel_name"] = name + "_STIM"
856978
chan_info_stim["sampling_rate"] = sr
857979
# stim channel are complicated because they are coded
858980
# with bits, they do not fit the gain/offset rawio strategy
859-
chan_info_stim["units"] = ""
860-
chan_info_stim["gain"] = 1.0
981+
chan_info_stim["units"] = "A" # Amps
982+
chan_info_stim["gain"] = global_info["stim_step_size"]
861983
chan_info_stim["offset"] = 0.0
862984
chan_info_stim["signal_type"] = 11 # put it in another group
863-
chan_info_stim["dtype"] = "uint16"
985+
chan_info_stim["dtype"] = "int16" # this change is due to bit decoding see note below
864986
ordered_channel_info.append(chan_info_stim)
987+
# Note that the data on disk is uint16 but the data is
988+
# then decoded as int16 so the chan_info is int16
865989
if file_format == "header-attached":
866990
memmap_data_dtype += [(name + "_STIM", "uint16", BLOCK_SIZE)]
867991
else:
868992
memmap_data_dtype["Stim channel"] = "uint16"
869-
else:
870-
warnings.warn("Stim not implemented for `one-file-per-channel` due to lack of test files")
871993

872994
# No supply or aux for rhs files (ie no stream_id 1 and 2)
873995
# We have an error above that requests test files to help if the spec is changed

neo/test/iotest/test_intanio.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ class TestIntanIO(
2323
"intan/intan_fpc_rhs_test_240329_091637/info.rhs", # Format one-file-per-channel
2424
"intan/intan_fps_rhs_test_240329_091536/info.rhs", # Format one-file-per-signal
2525
"intan/rhd_fpc_multistim_240514_082044/info.rhd", # Multiple digital channels one-file-per-channel rhd
26+
"intan/rhs_stim_data_single_file_format/intanTestFile.rhs", # header-attached rhs data with stimulus current
27+
"intan/test_fcs_dc_250327_154333/info.rhs", # this is an example of only having dc amp rather than amp files
28+
#"intan/test_fpc_stim_250327_151617/info.rhs", # wrong files Heberto will fix
2629
]
2730

2831

neo/test/rawiotest/test_intanrawio.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class TestIntanRawIO(
2121
"intan/intan_fpc_rhs_test_240329_091637/info.rhs", # Format one-file-per-channel
2222
"intan/intan_fps_rhs_test_240329_091536/info.rhs", # Format one-file-per-signal
2323
"intan/rhd_fpc_multistim_240514_082044/info.rhd", # Multiple digital channels one-file-per-channel rhd
24+
"intan/rhs_stim_data_single_file_format/intanTestFile.rhs", # header-attached rhs data with stimulus current
25+
"intan/test_fcs_dc_250327_154333/info.rhs", # this is an example of only having dc amp rather than amp files
26+
#"intan/test_fpc_stim_250327_151617/info.rhs", # wrong files Heberto will fix
2427
]
2528

2629
def test_annotations(self):
@@ -87,5 +90,55 @@ def test_correct_reading_one_file_per_channel(self):
8790
np.testing.assert_allclose(data_raw, data_from_neo)
8891

8992

93+
def test_correct_decoding_of_stimulus_current(self):
94+
# See https://github.com/NeuralEnsemble/python-neo/pull/1660 for discussion
95+
# See https://gin.g-node.org/NeuralEnsemble/ephy_testing_data/src/master/intan/README.md#rhs_stim_data_single_file_format
96+
# For a description of the data
97+
98+
file_path = Path(self.get_local_path("intan/rhs_stim_data_single_file_format/intanTestFile.rhs"))
99+
intan_reader = IntanRawIO(filename=file_path)
100+
intan_reader.parse_header()
101+
102+
signal_streams = intan_reader.header['signal_streams']
103+
stream_ids = signal_streams['id'].tolist()
104+
stream_index = stream_ids.index('11')
105+
sampling_rate = intan_reader.get_signal_sampling_rate(stream_index=stream_index)
106+
sig_chunk = intan_reader.get_analogsignal_chunk(stream_index=stream_index, channel_ids=["D-016_STIM"])
107+
final_stim = intan_reader.rescale_signal_raw_to_float(sig_chunk, stream_index=stream_index, channel_ids=["D-016_STIM"])
108+
109+
# This contains only the first pulse and I got this by visual inspection
110+
data_to_test = final_stim[200:250]
111+
112+
positive_pulse_size = np.max(data_to_test).item()
113+
negative_pulse_size = np.min(data_to_test).item()
114+
115+
expected_value = 60 * 1e-6# 60 microamperes
116+
117+
# Assert is close float
118+
assert np.isclose(positive_pulse_size, expected_value)
119+
assert np.isclose(negative_pulse_size, -expected_value)
120+
121+
# Check that negative pulse is leading
122+
argmin = np.argmin(data_to_test)
123+
argmax = np.argmax(data_to_test)
124+
assert argmin < argmax
125+
126+
# Check that the negative pulse is 200 us long
127+
negative_pulse_frames = np.where(data_to_test > 0)[0]
128+
number_of_negative_frames = negative_pulse_frames.size
129+
duration_of_negative_pulse = number_of_negative_frames / sampling_rate
130+
131+
expected_duration = 200 * 1e-6 # 400 microseconds / 2
132+
assert np.isclose(duration_of_negative_pulse, expected_duration)
133+
134+
# Check that the positive pulse is 200 us long
135+
positive_pulse_frames = np.where(data_to_test > 0)[0]
136+
number_of_positive_frames = positive_pulse_frames.size
137+
duration_of_positive_pulse = number_of_positive_frames / sampling_rate
138+
expected_duration = 200 * 1e-6 # 400 microseconds / 2
139+
140+
assert np.isclose(duration_of_positive_pulse, expected_duration)
141+
142+
90143
if __name__ == "__main__":
91144
unittest.main()

0 commit comments

Comments
 (0)