From 034f210497ef1f39c8c17f2b8d152b67d2d01069 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 12:11:00 +0000 Subject: [PATCH 1/5] Support Intan RHD multi-file recordings as one continuous stream Intan's acquisition software splits long recordings into multiple .rhd files that share a common prefix and a YYMMDD_HHMMSS timestamp. Add a new getRHD2000FileList helper plus a 'fileMode' option on the header and data-file readers that, when set to 'multiFile', expose the entire set as a single recording: aggregated block counts, total samples and time, and reads that transparently span file boundaries. The reader class auto-detects multi-file epochs when more than one .rhd is in the epochstreams. --- +ndr/+format/+intan/Intan_RHD2000_blockinfo.m | 46 +++++++++---- +ndr/+format/+intan/getRHD2000FileList.m | 64 +++++++++++++++++++ .../+intan/read_Intan_RHD2000_datafile.m | 49 +++++++++++++- .../+intan/read_Intan_RHD2000_header.m | 59 +++++++++++++++-- +ndr/+reader/intan_rhd.m | 53 +++++++++------ .../+ndr/+unittest/+reader/TestIntanRhd.m | 64 +++++++++++++++++++ 6 files changed, 294 insertions(+), 41 deletions(-) create mode 100644 +ndr/+format/+intan/getRHD2000FileList.m diff --git a/+ndr/+format/+intan/Intan_RHD2000_blockinfo.m b/+ndr/+format/+intan/Intan_RHD2000_blockinfo.m index 89f87908..67d277cd 100644 --- a/+ndr/+format/+intan/Intan_RHD2000_blockinfo.m +++ b/+ndr/+format/+intan/Intan_RHD2000_blockinfo.m @@ -1,7 +1,7 @@ -function [blockinfo, bytes_per_block, bytes_present, num_data_blocks] = Intan_RHD2000_blockinfo(filename, header) +function [blockinfo, bytes_per_block, bytes_present, num_data_blocks, file_blocks] = Intan_RHD2000_blockinfo(filename, header) % INTAN_RHD2000_BLOCKINFO - Block information for an Intan RHD2000 file % -% [BLOCK_INFO, BYTES_PER_BLOCK, BYTES_PRESENT, NUMDATABLOCKS] = ... +% [BLOCK_INFO, BYTES_PER_BLOCK, BYTES_PRESENT, NUMDATABLOCKS, FILE_BLOCKS] = ... % INTAN_RHD2000_BLOCKINFO(FILENAME [, HEADER]) % % Computes the parameters of each data block of an Intan_RHD_2000 file. @@ -15,13 +15,17 @@ % '.rhd'). HEADER should be the header information structure that is returned % by READ_INTAN_RHD2000_HEADER; if it is left blank, it will be read from the % file. -% +% % BLOCK_INFO is a structure describing the parameters of each block. % BYTES_PER_BLOCK is the number of bytes per data block -% BYTES_PRESENT is the number of non-header bytes in the file. -% NUMDATABLOCKS is the number of data blocks in the file. +% BYTES_PRESENT is the number of non-header bytes in the file (or the sum +% across all files when HEADER describes a multi-file recording). +% NUMDATABLOCKS is the number of data blocks in the file (or the sum across +% all files when HEADER describes a multi-file recording). +% FILE_BLOCKS is a vector with the per-file data block counts (length 1 in +% single-file mode, length N in multi-file mode). % -% See also: READ_INTAN_RHD2000_HEADER, READ_INTAN_RHD2000_DATAFILE, CAT_INTAN_RHD2000_FILES +% See also: READ_INTAN_RHD2000_HEADER, READ_INTAN_RHD2000_DATAFILE, CAT_INTAN_RHD2000_FILES, GETRHD2000FILELIST if nargin<2, header = ndr.format.intan.read_Intan_RHD2000_header(filename); @@ -97,10 +101,28 @@ end; bytes_per_block = block_offset; -% How many data blocks are in this file? -bytes_present = header.fileinfo.filesize - header.fileinfo.headersize; -num_data_blocks_float = bytes_present / bytes_per_block; -num_data_blocks = floor(num_data_blocks_float); -if num_data_blocks~=num_data_blocks_float, - warning(['File ' filename ' may be truncated or corrupted. Proceeding with ' int2str(num_data_blocks) ' of ' num2str(num_data_blocks_float) ' data blocks.']); +% How many data blocks are in this file (or across all files in multi-file mode)? +if isfield(header.fileinfo,'multifile') && strcmp(header.fileinfo.multifile.fileMode,'multiFile'), + mf = header.fileinfo.multifile; + file_blocks = zeros(1, numel(mf.files)); + bytes_present = 0; + for i = 1:numel(mf.files), + bytes_present_i = mf.file_sizes(i) - mf.headersize; + num_blocks_float_i = bytes_present_i / bytes_per_block; + num_blocks_i = floor(num_blocks_float_i); + if num_blocks_i ~= num_blocks_float_i, + warning(['File ' mf.files{i} ' may be truncated or corrupted. Proceeding with ' int2str(num_blocks_i) ' of ' num2str(num_blocks_float_i) ' data blocks.']); + end; + file_blocks(i) = num_blocks_i; + bytes_present = bytes_present + bytes_present_i; + end; + num_data_blocks = sum(file_blocks); +else, + bytes_present = header.fileinfo.filesize - header.fileinfo.headersize; + num_data_blocks_float = bytes_present / bytes_per_block; + num_data_blocks = floor(num_data_blocks_float); + if num_data_blocks~=num_data_blocks_float, + warning(['File ' filename ' may be truncated or corrupted. Proceeding with ' int2str(num_data_blocks) ' of ' num2str(num_data_blocks_float) ' data blocks.']); + end; + file_blocks = num_data_blocks; end; diff --git a/+ndr/+format/+intan/getRHD2000FileList.m b/+ndr/+format/+intan/getRHD2000FileList.m new file mode 100644 index 00000000..26b8970d --- /dev/null +++ b/+ndr/+format/+intan/getRHD2000FileList.m @@ -0,0 +1,64 @@ +function files = getRHD2000FileList(filename, fileMode) +% GETRHD2000FILELIST - Get the list of RHD2000 files comprising a recording +% +% FILES = GETRHD2000FILELIST(FILENAME, FILEMODE) +% +% Given a single .rhd FILENAME, return a cell array of full-path file names +% that together make up the recording. +% +% FILEMODE may be: +% 'singleFile' (default) - The recording is a single .rhd file. Returns a +% cell array containing just FILENAME. +% 'multiFile' - The recording is spread across many .rhd files saved by +% the Intan acquisition software with the same base prefix and a +% _ timestamp before the extension. Returns the sorted +% (chronological) list of all files in the same directory matching +% '__.rhd', where is parsed from +% FILENAME. +% +% See also: READ_INTAN_RHD2000_HEADER, READ_INTAN_RHD2000_DATAFILE + +if nargin < 2 || isempty(fileMode), + fileMode = 'singleFile'; +end; + +switch fileMode, + case 'singleFile', + files = {filename}; + case 'multiFile', + [dirname, fname, ext] = fileparts(filename); + if isempty(dirname), + dirname = pwd; + end; + % parse _YYMMDD_HHMMSS + tok = regexp(fname, '^(.*)_(\d{6})_(\d{6})$', 'tokens', 'once'); + if isempty(tok), + error(['Filename ' filename ' does not match the Intan multi-file pattern _YYMMDD_HHMMSS' ext '.']); + end; + prefix = tok{1}; + d = dir(fullfile(dirname, [prefix '_*_*' ext])); + keep = false(1, numel(d)); + timestamps = cell(1, numel(d)); + prefix_pattern = ['^' regexptranslate('escape', prefix) '_(\d{6})_(\d{6})$']; + for i = 1:numel(d), + [~, name_i, ~] = fileparts(d(i).name); + t_i = regexp(name_i, prefix_pattern, 'tokens', 'once'); + if ~isempty(t_i), + keep(i) = true; + timestamps{i} = [t_i{1} t_i{2}]; + end; + end; + d = d(keep); + timestamps = timestamps(keep); + if isempty(d), + error(['No RHD multi-file set found for prefix ' prefix ' in ' dirname '.']); + end; + [~, order] = sort(timestamps); + d = d(order); + files = cell(1, numel(d)); + for i = 1:numel(d), + files{i} = fullfile(dirname, d(i).name); + end; + otherwise, + error(['Unknown fileMode: ' fileMode '. Use ''singleFile'' or ''multiFile''.']); +end; diff --git a/+ndr/+format/+intan/read_Intan_RHD2000_datafile.m b/+ndr/+format/+intan/read_Intan_RHD2000_datafile.m index c8231862..fbd6df48 100644 --- a/+ndr/+format/+intan/read_Intan_RHD2000_datafile.m +++ b/+ndr/+format/+intan/read_Intan_RHD2000_datafile.m @@ -4,6 +4,19 @@ % [DATA,TOTAL_SAMPLES,TOTAL_TIME,BLOCKINFO] = READ_INTAN_RHD2000_DATAFILE(FILENAME, % HEADER, CHANNEL_TYPE, CHANNEL_NUMBERS, T0, T1); % +% Optional name/value pairs: +% 'fileMode' - 'singleFile' (default) or 'multiFile'. +% In 'multiFile' mode, FILENAME is treated +% as one member of a contiguous set of +% Intan-saved files sharing a common +% prefix. The reader transparently spans +% the entire set, so T0 and T1 refer to +% the time axis of the concatenated +% recording. +% 'force_single_channel_read' - Force the per-channel read path even +% when multiple consecutive channels are +% requested. +% % Inputs: % Reads data from the Intan Technologies .rhd 2000 file FILENAME. % The file HEADER information can be provided in HEADER. If HEADER @@ -52,13 +65,14 @@ % force_single_channel_read = 0; +fileMode = 'singleFile'; assign(varargin{:}); if isempty(header), - header = ndr.format.intan.read_Intan_RHD2000_header(filename); + header = ndr.format.intan.read_Intan_RHD2000_header(filename, 'fileMode', fileMode); end; -[blockinfo, bytes_per_block, bytes_present, num_data_blocks] = ndr.format.intan.Intan_RHD2000_blockinfo(filename, header); +[blockinfo, bytes_per_block, bytes_present, num_data_blocks, file_blocks] = ndr.format.intan.Intan_RHD2000_blockinfo(filename, header); total_samples = header.fileinfo.num_samples_per_data_block * num_data_blocks; total_time = total_samples / header.frequency_parameters.amplifier_sample_rate; % in seconds @@ -89,6 +103,37 @@ ch(channel_type) = 1; c = find(ch); +% Multi-file mode: dispatch the request to the constituent files and +% concatenate the results, treating the recording as one continuous file. +if isfield(header.fileinfo,'multifile') && strcmp(header.fileinfo.multifile.fileMode,'multiFile'), + files = header.fileinfo.multifile.files; + spb = blockinfo(c).samples_per_block; + sr = blockinfo(c).sample_rate; + file_samples = file_blocks * spb; + cum_end = cumsum(file_samples); + cum_start = [0, cum_end(1:end-1)] + 1; + + g_s0 = 1 + round(t0 * sr); + g_s1 = 1 + round(t1 * sr); + + data = []; + for i = 1:numel(files), + f_lo = cum_start(i); + f_hi = cum_end(i); + if f_hi < g_s0 || f_lo > g_s1, + continue; + end; + local_lo = max(f_lo, g_s0) - f_lo + 1; + local_hi = min(f_hi, g_s1) - f_lo + 1; + local_t0 = (local_lo - 1) / sr; + local_t1 = (local_hi - 1) / sr; + d_i = ndr.format.intan.read_Intan_RHD2000_datafile(files{i}, [], channel_type, channel_numbers, local_t0, local_t1, ... + 'fileMode', 'singleFile', 'force_single_channel_read', force_single_channel_read); + data = [data; d_i]; + end; + return; +end; + % now compute starting and ending samples to read s0 = 1+round(t0 * blockinfo(c).sample_rate); s1 = 1+round(t1 * blockinfo(c).sample_rate); diff --git a/+ndr/+format/+intan/read_Intan_RHD2000_header.m b/+ndr/+format/+intan/read_Intan_RHD2000_header.m index 62e30de8..e0aeb7c1 100644 --- a/+ndr/+format/+intan/read_Intan_RHD2000_header.m +++ b/+ndr/+format/+intan/read_Intan_RHD2000_header.m @@ -1,15 +1,27 @@ -function [header] = read_Intan_RHD2000_header(filename); +function [header] = read_Intan_RHD2000_header(filename, varargin); % READ_INTAN_RHD2000_HEADER - Read header information from an Intan data file % % HEADER = READ_INTAN_RHD2000_HEADER(FILENAME) +% HEADER = READ_INTAN_RHD2000_HEADER(FILENAME, 'fileMode', FILEMODE) % % Returns a structure HEADER with all of the information fields that % are stored in the Intan RHD2000 file FILENAME. % +% Optional name/value pairs: +% 'fileMode' - 'singleFile' (default) reads the header from a single +% .rhd file. 'multiFile' indicates that FILENAME is one +% member of a set of .rhd files saved by the Intan +% acquisition software with a common prefix and a +% _ timestamp before the extension that +% together represent a continuous recording. The header +% is parsed from the first (chronologically earliest) +% file and the resulting HEADER exposes the recording as +% if it were one large file. +% % HEADER contains several substructures: % -------------------------------------------------------------------- % fileinfo | Information about the file and its version -% frequency_parameters | Information about sampling frequency +% frequency_parameters | Information about sampling frequency % spike_triggers | Information about spike triggers for each amplifier channel % amplifier_channels | Information about amplifier channels % aux_input_channels | Information about auxillary input channels @@ -19,18 +31,32 @@ % board_dig_out_channels | Digital output channels % num_temp_sensor_channels| Number of temperature sensor channels % -% See also: READ_INTAN_RDH2000_DATAFILE +% In multi-file mode, HEADER.fileinfo.multifile contains the list of files, +% their sizes, and the (assumed identical) header size. In single-file mode +% it contains the single file equivalents. +% +% See also: READ_INTAN_RHD2000_DATAFILE, GETRHD2000FILELIST % -fid = fopen(filename,'r'); +fileMode = 'singleFile'; +assign(varargin{:}); + +files = ndr.format.intan.getRHD2000FileList(filename, fileMode); + +% Parse the header from the first file. Acquisition parameters are +% identical across files in a multi-file recording, so a single parse is +% sufficient to characterize the layout. +primary_filename = files{1}; + +fid = fopen(primary_filename,'r'); if fileid_value(fid)<0, - error(['Could not open filename ' filename_value(filename) ' for reading (check path, spelling, permissions).']); + error(['Could not open filename ' filename_value(primary_filename) ' for reading (check path, spelling, permissions).']); end; -[dirname,fname,ext]=fileparts(filename); +[dirname,fname,ext]=fileparts(primary_filename); s = dir(fullfile(dirname,[fname ext])); if isempty(s), - error(['Could not find a file ' filename_value(filename) '; check spelling, permissions, extension']); + error(['Could not find a file ' filename_value(primary_filename) '; check spelling, permissions, extension']); end; filesize = s.bytes; % Check 'magic number' at beginning of file to make sure this is an Intan @@ -235,3 +261,22 @@ header.fileinfo.headersize = ftell(fid); % get the location of the next byte to be read fclose(fid); + +% Attach multi-file information so downstream block/data readers can treat +% the recording as one continuous file. Per-file headersize is assumed +% identical across files because the acquisition parameters match; only +% the file sizes need to be measured for each constituent file. +file_sizes = zeros(1, numel(files)); +for i = 1:numel(files), + s_i = dir(files{i}); + if isempty(s_i), + error(['Could not stat file ' files{i} '.']); + end; + file_sizes(i) = s_i.bytes; +end; + +header.fileinfo.multifile = struct(... + 'fileMode', fileMode, ... + 'files', {files}, ... + 'file_sizes', file_sizes, ... + 'headersize', header.fileinfo.headersize); diff --git a/+ndr/+reader/intan_rhd.m b/+ndr/+reader/intan_rhd.m index 5aa74826..7637834e 100755 --- a/+ndr/+reader/intan_rhd.m +++ b/+ndr/+reader/intan_rhd.m @@ -89,9 +89,9 @@ % channelstruct = vlt.data.emptystruct('internal_type','internal_number',... 'internal_channelname','ndr_type','samplerate'); - - filename = intan_rhd_obj.filenamefromepochfiles(epochstreams); - header = ndr.format.intan.read_Intan_RHD2000_header(filename); + + [filename, ~, ~, fileMode] = intan_rhd_obj.filenamefromepochfiles(epochstreams); + header = ndr.format.intan.read_Intan_RHD2000_header(filename, 'fileMode', fileMode); % make sure that the fields are in the correct order channelstruct_here.internal_type = []; @@ -139,9 +139,9 @@ % % See also: ndr.time.clocktype, EPOCHCLOCK % - [filename,parentdir,isdirectory] = intan_rhd_obj.filenamefromepochfiles(epochstreams); - header = ndr.format.intan.read_Intan_RHD2000_header(filename); - + [filename,parentdir,isdirectory,fileMode] = intan_rhd_obj.filenamefromepochfiles(epochstreams); + header = ndr.format.intan.read_Intan_RHD2000_header(filename, 'fileMode', fileMode); + if ~isdirectory, [blockinfo, bytes_per_block, bytes_present, num_data_blocks] = ndr.format.intan.Intan_RHD2000_blockinfo(filename, header); total_samples = 60 * num_data_blocks; @@ -188,8 +188,8 @@ % for any new channel that hasn't been identified before, % add it to the list - filename = intan_rhd_obj.filenamefromepochfiles(epochstreams); - header = ndr.format.intan.read_Intan_RHD2000_header(filename); + [filename, ~, ~, fileMode] = intan_rhd_obj.filenamefromepochfiles(epochstreams); + header = ndr.format.intan.read_Intan_RHD2000_header(filename, 'fileMode', fileMode); channels = vlt.data.emptystruct('name','type','time_channel'); @@ -284,7 +284,7 @@ % % DATA will have one column per channel. % - [filename,parentdir,isdirectory] = intan_rhd_obj.filenamefromepochfiles(epochstreams); + [filename,parentdir,isdirectory,fileMode] = intan_rhd_obj.filenamefromepochfiles(epochstreams); intanchanneltype = intan_rhd_obj.mfdaqchanneltype2intanchanneltype(channeltype); @@ -304,7 +304,7 @@ end; if ~isdirectory, - data = ndr.format.intan.read_Intan_RHD2000_datafile(filename,'',intanchanneltype,channel,t0,t1); + data = ndr.format.intan.read_Intan_RHD2000_datafile(filename,[],intanchanneltype,channel,t0,t1,'fileMode',fileMode); else, data = ndr.format.intan.read_Intan_RHD2000_directory(parentdir,'',intanchanneltype,channel,t0,t1); end; @@ -326,9 +326,9 @@ error(['Intan RHD files have 1 epoch per file.']); end; sr = []; - filename = intan_rhd_obj.filenamefromepochfiles(epochstreams); - - head = ndr.format.intan.read_Intan_RHD2000_header(filename); + [filename, ~, ~, fileMode] = intan_rhd_obj.filenamefromepochfiles(epochstreams); + + head = ndr.format.intan.read_Intan_RHD2000_header(filename, 'fileMode', fileMode); for i=1:numel(channel), channeltype_here = vlt.data.celloritem(channeltype,i); freq_fieldname = intan_rhd_obj.mfdaqchanneltype2intanfreqheader(channeltype_here); @@ -336,25 +336,38 @@ end end % ndr.reader.intan_rhd.samplerate - function [filename, parentdir, isdirectory] = filenamefromepochfiles(intan_rhd_obj, filename_array) + function [filename, parentdir, isdirectory, fileMode] = filenamefromepochfiles(intan_rhd_obj, filename_array) % FILENAMEFROMEPOCHFILES - Return the file name that corresponds to the .RHD file, or directory in case of directory % - % [FILENAME, PARENTDIR, ISDIRECTORY] = FILENAMEFROMEPOCHFILES(NDR_NDRREADER_INTANREADER_OBJ, FILENAME_ARRAY) + % [FILENAME, PARENTDIR, ISDIRECTORY, FILEMODE] = FILENAMEFROMEPOCHFILES(NDR_NDRREADER_INTANREADER_OBJ, FILENAME_ARRAY) % % Examines the list of filenames in FILENAME_ARRAY (cell array of full path file strings) and determines which % one is an .RHD data file. If the 1-file-per-channel mode is used, then PARENTDIR is the name of the directory % that holds the data files and ISDIRECTORY is 1. + % + % If more than one .rhd file is provided in FILENAME_ARRAY, the + % recording is treated as a single contiguous multi-file + % recording (Intan saves a new file each time its file-length + % threshold is reached). In that case FILEMODE is 'multiFile' + % and FILENAME is the chronologically earliest of the supplied + % files. Otherwise FILEMODE is 'singleFile'. % s1 = ['.*\.rhd\>']; % equivalent of *.ext on the command line [tf, matchstring, substring] = vlt.string.strcmp_substitution(s1,filename_array,'UseSubstituteString',0); parentdir = ''; isdirectory = 0; - + fileMode = 'singleFile'; + index = find(tf); - if numel(index)>1, - error(['Need only 1 .rhd file per epoch.']); - elseif numel(index)==0, - error(['Need 1 .rhd file per epoch.']); + if numel(index)==0, + error(['Need at least 1 .rhd file per epoch.']); + elseif numel(index)>1, + rhd_files = filename_array(index); + [~, order] = sort(rhd_files); + rhd_files = rhd_files(order); + filename = rhd_files{1}; + [parentdir, ~, ~] = fileparts(filename); + fileMode = 'multiFile'; else, filename = filename_array{index}; [parentdir, fname, ext] = fileparts(filename); diff --git a/tools/tests/+ndr/+unittest/+reader/TestIntanRhd.m b/tools/tests/+ndr/+unittest/+reader/TestIntanRhd.m index 3f767828..cd9bce8b 100644 --- a/tools/tests/+ndr/+unittest/+reader/TestIntanRhd.m +++ b/tools/tests/+ndr/+unittest/+reader/TestIntanRhd.m @@ -125,6 +125,70 @@ function testChannelStructParsing(testCase) testCase.verifyEqual(result, 'ai1', 'Should work without chip_channel field'); end + function testMultiFileMode(testCase) + % Verify that two copies of the example file, named per the + % Intan multi-file convention, are exposed as a single + % continuous recording. + ndr_path = ndr.fun.ndrpath(); + rhd_file = fullfile(ndr_path, 'example_data', 'example.rhd'); + + tmpdir = tempname(); + mkdir(tmpdir); + cleanup = onCleanup(@() rmdir(tmpdir, 's')); + + f1 = fullfile(tmpdir, 'recording_240101_120000.rhd'); + f2 = fullfile(tmpdir, 'recording_240101_120100.rhd'); + copyfile(rhd_file, f1); + copyfile(rhd_file, f2); + + % helper: file list discovery and ordering + files = ndr.format.intan.getRHD2000FileList(f2, 'multiFile'); + testCase.verifyEqual(numel(files), 2); + testCase.verifyEqual(files{1}, f1); + testCase.verifyEqual(files{2}, f2); + + % singleFile default returns just the requested file + files_single = ndr.format.intan.getRHD2000FileList(f1); + testCase.verifyEqual(files_single, {f1}); + + % header in multi-file mode reports both files + header_multi = ndr.format.intan.read_Intan_RHD2000_header(f1, 'fileMode', 'multiFile'); + testCase.verifyEqual(header_multi.fileinfo.multifile.fileMode, 'multiFile'); + testCase.verifyEqual(numel(header_multi.fileinfo.multifile.files), 2); + + % blockinfo aggregates blocks across files + header_single = ndr.format.intan.read_Intan_RHD2000_header(f1); + [~, ~, ~, num_blocks_single] = ndr.format.intan.Intan_RHD2000_blockinfo(f1, header_single); + [~, ~, ~, num_blocks_multi, file_blocks] = ndr.format.intan.Intan_RHD2000_blockinfo(f1, header_multi); + testCase.verifyEqual(num_blocks_multi, 2 * num_blocks_single); + testCase.verifyEqual(file_blocks, [num_blocks_single num_blocks_single]); + + % reader autodetects multi-file when more than one .rhd is in the epochstreams + reader = ndr.reader.intan_rhd(); + t0t1_single = reader.t0_t1({f1}, 1); + t0t1_multi = reader.t0_t1({f1, f2}, 1); + sr = reader.samplerate({f1}, 1, 'ai', 1); + duration_single = t0t1_single{1}(2) - t0t1_single{1}(1); + duration_multi = t0t1_multi{1}(2) - t0t1_multi{1}(1); + testCase.verifyEqual(duration_multi, duration_single + duration_single + 1/sr, 'AbsTol', 1e-9); + + % reading the first N samples in multi-file mode equals reading + % them in single-file mode + n = 50; + data_single = reader.readchannels_epochsamples('ai', 1, {f1}, 1, 1, n); + data_multi = reader.readchannels_epochsamples('ai', 1, {f1, f2}, 1, 1, n); + testCase.verifyEqual(data_multi, data_single); + + % a read that spans the boundary between the two files returns + % the concatenation of (tail of file 1) + (head of file 2) + single_total_samples = num_blocks_single * header_single.fileinfo.num_samples_per_data_block; + span = 20; + tail = reader.readchannels_epochsamples('ai', 1, {f1}, 1, single_total_samples - span + 1, single_total_samples); + head = reader.readchannels_epochsamples('ai', 1, {f1}, 1, 1, span); + spanning = reader.readchannels_epochsamples('ai', 1, {f1, f2}, 1, single_total_samples - span + 1, single_total_samples + span); + testCase.verifyEqual(spanning, [tail; head]); + end + function testAuxSampleRate(testCase) % Test that samplerate works for auxiliary_in reader = ndr.reader.intan_rhd(); From d3dad408cfd766fa460cf6864e8786bab332a8de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 12:43:06 +0000 Subject: [PATCH 2/5] Update GitHub badges [skip ci] --- .github/badges/code_issues.svg | 2 +- .github/badges/tests.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/code_issues.svg b/.github/badges/code_issues.svg index 7a8387c6..22bd7700 100644 --- a/.github/badges/code_issues.svg +++ b/.github/badges/code_issues.svg @@ -1 +1 @@ -code issuescode issues12861286 \ No newline at end of file +code issuescode issues13221322 \ No newline at end of file diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg index c431d40a..35a36223 100644 --- a/.github/badges/tests.svg +++ b/.github/badges/tests.svg @@ -1 +1 @@ -teststests126 passed126 passed \ No newline at end of file +teststests127 passed127 passed \ No newline at end of file From a938988203f8fcb9032a2bc2a41fcb820825a9fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 15:53:25 +0000 Subject: [PATCH 3/5] Default to auto-detecting Intan RHD multi-file recordings Add a detectRHD2000FileMode helper that returns 'multiFile' when an .rhd file has at least one sibling matching the __.rhd pattern in the same directory, and 'singleFile' otherwise. Wire it into getRHD2000FileList, read_Intan_RHD2000_header, and read_Intan_RHD2000_datafile as a new 'detect' fileMode and make 'detect' the default. The reader class also defaults to 'detect' for single-file epochstreams so multi-file recordings are picked up without callers passing fileMode. --- +ndr/+format/+intan/Intan_RHD2000_blockinfo.m | 3 +- +ndr/+format/+intan/detectRHD2000FileMode.m | 39 +++++++++ +ndr/+format/+intan/getRHD2000FileList.m | 15 +++- .../+intan/read_Intan_RHD2000_datafile.m | 14 ++- .../+intan/read_Intan_RHD2000_header.m | 26 +++--- +ndr/+reader/intan_rhd.m | 6 +- .../+ndr/+unittest/+reader/TestIntanRhd.m | 86 +++++++++++++++---- 7 files changed, 153 insertions(+), 36 deletions(-) create mode 100644 +ndr/+format/+intan/detectRHD2000FileMode.m diff --git a/+ndr/+format/+intan/Intan_RHD2000_blockinfo.m b/+ndr/+format/+intan/Intan_RHD2000_blockinfo.m index 67d277cd..8c3b8596 100644 --- a/+ndr/+format/+intan/Intan_RHD2000_blockinfo.m +++ b/+ndr/+format/+intan/Intan_RHD2000_blockinfo.m @@ -14,7 +14,8 @@ % FILENAME should be the name of an RHD2000 file (normally with extension % '.rhd'). HEADER should be the header information structure that is returned % by READ_INTAN_RHD2000_HEADER; if it is left blank, it will be read from the -% file. +% file using the default 'detect' fileMode, which automatically aggregates +% across an Intan multi-file recording when sibling files are present. % % BLOCK_INFO is a structure describing the parameters of each block. % BYTES_PER_BLOCK is the number of bytes per data block diff --git a/+ndr/+format/+intan/detectRHD2000FileMode.m b/+ndr/+format/+intan/detectRHD2000FileMode.m new file mode 100644 index 00000000..828e1610 --- /dev/null +++ b/+ndr/+format/+intan/detectRHD2000FileMode.m @@ -0,0 +1,39 @@ +function fileMode = detectRHD2000FileMode(filename) +% DETECTRHD2000FILEMODE - Detect whether an RHD file is part of a multi-file recording +% +% FILEMODE = DETECTRHD2000FILEMODE(FILENAME) +% +% Returns 'multiFile' when FILENAME follows the Intan +% '__.rhd' naming convention and at least one +% additional sibling file in the same directory shares that prefix and +% matches the same timestamp pattern. Returns 'singleFile' otherwise. +% +% See also: GETRHD2000FILELIST, READ_INTAN_RHD2000_HEADER, READ_INTAN_RHD2000_DATAFILE + +[dirname, fname, ext] = fileparts(filename); +if isempty(dirname), + dirname = pwd; +end; + +tok = regexp(fname, '^(.*)_(\d{6})_(\d{6})$', 'tokens', 'once'); +if isempty(tok), + fileMode = 'singleFile'; + return; +end; + +prefix = tok{1}; +prefix_pattern = ['^' regexptranslate('escape', prefix) '_(\d{6})_(\d{6})$']; +d = dir(fullfile(dirname, [prefix '_*_*' ext])); + +count = 0; +for i = 1:numel(d), + [~, name_i, ~] = fileparts(d(i).name); + if ~isempty(regexp(name_i, prefix_pattern, 'once')), + count = count + 1; + if count > 1, + fileMode = 'multiFile'; + return; + end; + end; +end; +fileMode = 'singleFile'; diff --git a/+ndr/+format/+intan/getRHD2000FileList.m b/+ndr/+format/+intan/getRHD2000FileList.m index 26b8970d..c45347cb 100644 --- a/+ndr/+format/+intan/getRHD2000FileList.m +++ b/+ndr/+format/+intan/getRHD2000FileList.m @@ -7,8 +7,11 @@ % that together make up the recording. % % FILEMODE may be: -% 'singleFile' (default) - The recording is a single .rhd file. Returns a -% cell array containing just FILENAME. +% 'detect' (default) - Auto-detect: resolves to 'multiFile' if more than +% one file matching the prefix+timestamp pattern is found in the same +% directory, otherwise 'singleFile'. See DETECTRHD2000FILEMODE. +% 'singleFile' - The recording is a single .rhd file. Returns a cell +% array containing just FILENAME. % 'multiFile' - The recording is spread across many .rhd files saved by % the Intan acquisition software with the same base prefix and a % _ timestamp before the extension. Returns the sorted @@ -16,10 +19,14 @@ % '__.rhd', where is parsed from % FILENAME. % -% See also: READ_INTAN_RHD2000_HEADER, READ_INTAN_RHD2000_DATAFILE +% See also: READ_INTAN_RHD2000_HEADER, READ_INTAN_RHD2000_DATAFILE, DETECTRHD2000FILEMODE if nargin < 2 || isempty(fileMode), - fileMode = 'singleFile'; + fileMode = 'detect'; +end; + +if strcmp(fileMode, 'detect'), + fileMode = ndr.format.intan.detectRHD2000FileMode(filename); end; switch fileMode, diff --git a/+ndr/+format/+intan/read_Intan_RHD2000_datafile.m b/+ndr/+format/+intan/read_Intan_RHD2000_datafile.m index fbd6df48..e86bf930 100644 --- a/+ndr/+format/+intan/read_Intan_RHD2000_datafile.m +++ b/+ndr/+format/+intan/read_Intan_RHD2000_datafile.m @@ -5,11 +5,15 @@ % HEADER, CHANNEL_TYPE, CHANNEL_NUMBERS, T0, T1); % % Optional name/value pairs: -% 'fileMode' - 'singleFile' (default) or 'multiFile'. +% 'fileMode' - 'detect' (default), 'singleFile', or +% 'multiFile'. In 'detect' mode the reader +% checks for Intan-style sibling files in +% the same directory and automatically +% resolves to 'multiFile' or 'singleFile'. % In 'multiFile' mode, FILENAME is treated % as one member of a contiguous set of % Intan-saved files sharing a common -% prefix. The reader transparently spans +% prefix; the reader transparently spans % the entire set, so T0 and T1 refer to % the time axis of the concatenated % recording. @@ -65,9 +69,13 @@ % force_single_channel_read = 0; -fileMode = 'singleFile'; +fileMode = 'detect'; assign(varargin{:}); +if strcmp(fileMode, 'detect'), + fileMode = ndr.format.intan.detectRHD2000FileMode(filename); +end; + if isempty(header), header = ndr.format.intan.read_Intan_RHD2000_header(filename, 'fileMode', fileMode); end; diff --git a/+ndr/+format/+intan/read_Intan_RHD2000_header.m b/+ndr/+format/+intan/read_Intan_RHD2000_header.m index e0aeb7c1..18c1d241 100644 --- a/+ndr/+format/+intan/read_Intan_RHD2000_header.m +++ b/+ndr/+format/+intan/read_Intan_RHD2000_header.m @@ -8,15 +8,17 @@ % are stored in the Intan RHD2000 file FILENAME. % % Optional name/value pairs: -% 'fileMode' - 'singleFile' (default) reads the header from a single -% .rhd file. 'multiFile' indicates that FILENAME is one -% member of a set of .rhd files saved by the Intan -% acquisition software with a common prefix and a -% _ timestamp before the extension that -% together represent a continuous recording. The header -% is parsed from the first (chronologically earliest) -% file and the resulting HEADER exposes the recording as -% if it were one large file. +% 'fileMode' - 'detect' (default) checks whether FILENAME has Intan +% sibling files in the same directory matching +% '__.rhd' and resolves to either +% 'multiFile' or 'singleFile' (see DETECTRHD2000FILEMODE). +% Pass 'singleFile' to force reading the header from a +% single .rhd file, or 'multiFile' to force treating +% FILENAME as one member of a set that together represents +% a continuous recording. The header is parsed from the +% first (chronologically earliest) file and the resulting +% HEADER exposes the recording as if it were one large +% file. % % HEADER contains several substructures: % -------------------------------------------------------------------- @@ -38,9 +40,13 @@ % See also: READ_INTAN_RHD2000_DATAFILE, GETRHD2000FILELIST % -fileMode = 'singleFile'; +fileMode = 'detect'; assign(varargin{:}); +if strcmp(fileMode, 'detect'), + fileMode = ndr.format.intan.detectRHD2000FileMode(filename); +end; + files = ndr.format.intan.getRHD2000FileList(filename, fileMode); % Parse the header from the first file. Acquisition parameters are diff --git a/+ndr/+reader/intan_rhd.m b/+ndr/+reader/intan_rhd.m index 7637834e..1276b204 100755 --- a/+ndr/+reader/intan_rhd.m +++ b/+ndr/+reader/intan_rhd.m @@ -350,13 +350,15 @@ % recording (Intan saves a new file each time its file-length % threshold is reached). In that case FILEMODE is 'multiFile' % and FILENAME is the chronologically earliest of the supplied - % files. Otherwise FILEMODE is 'singleFile'. + % files. Otherwise FILEMODE is 'detect', which lets the format + % layer auto-detect multi-file recordings by looking for Intan + % sibling files in the same directory. % s1 = ['.*\.rhd\>']; % equivalent of *.ext on the command line [tf, matchstring, substring] = vlt.string.strcmp_substitution(s1,filename_array,'UseSubstituteString',0); parentdir = ''; isdirectory = 0; - fileMode = 'singleFile'; + fileMode = 'detect'; index = find(tf); if numel(index)==0, diff --git a/tools/tests/+ndr/+unittest/+reader/TestIntanRhd.m b/tools/tests/+ndr/+unittest/+reader/TestIntanRhd.m index cd9bce8b..1dfab1bd 100644 --- a/tools/tests/+ndr/+unittest/+reader/TestIntanRhd.m +++ b/tools/tests/+ndr/+unittest/+reader/TestIntanRhd.m @@ -125,10 +125,9 @@ function testChannelStructParsing(testCase) testCase.verifyEqual(result, 'ai1', 'Should work without chip_channel field'); end - function testMultiFileMode(testCase) - % Verify that two copies of the example file, named per the - % Intan multi-file convention, are exposed as a single - % continuous recording. + function testDetectFileMode(testCase) + % Verify detectRHD2000FileMode returns 'multiFile' iff there + % are >=2 sibling files matching the Intan timestamp pattern. ndr_path = ndr.fun.ndrpath(); rhd_file = fullfile(ndr_path, 'example_data', 'example.rhd'); @@ -136,46 +135,97 @@ function testMultiFileMode(testCase) mkdir(tmpdir); cleanup = onCleanup(@() rmdir(tmpdir, 's')); + % File without timestamp suffix -> singleFile + plain = fullfile(tmpdir, 'plain.rhd'); + copyfile(rhd_file, plain); + testCase.verifyEqual(ndr.format.intan.detectRHD2000FileMode(plain), 'singleFile'); + + % Single timestamped file (no siblings) -> singleFile f1 = fullfile(tmpdir, 'recording_240101_120000.rhd'); + copyfile(rhd_file, f1); + testCase.verifyEqual(ndr.format.intan.detectRHD2000FileMode(f1), 'singleFile'); + + % Add a sibling -> multiFile f2 = fullfile(tmpdir, 'recording_240101_120100.rhd'); + copyfile(rhd_file, f2); + testCase.verifyEqual(ndr.format.intan.detectRHD2000FileMode(f1), 'multiFile'); + testCase.verifyEqual(ndr.format.intan.detectRHD2000FileMode(f2), 'multiFile'); + + % Default fileMode in the format layer is 'detect': calling + % the header reader with no fileMode should aggregate when + % siblings exist. + header_default = ndr.format.intan.read_Intan_RHD2000_header(f1); + testCase.verifyEqual(header_default.fileinfo.multifile.fileMode, 'multiFile'); + testCase.verifyEqual(numel(header_default.fileinfo.multifile.files), 2); + + % And a single-file directory yields singleFile by default. + header_plain = ndr.format.intan.read_Intan_RHD2000_header(plain); + testCase.verifyEqual(header_plain.fileinfo.multifile.fileMode, 'singleFile'); + end + + function testMultiFileMode(testCase) + % Verify that two copies of the example file, named per the + % Intan multi-file convention, are exposed as a single + % continuous recording. + ndr_path = ndr.fun.ndrpath(); + rhd_file = fullfile(ndr_path, 'example_data', 'example.rhd'); + + % Multi-file directory: two timestamped siblings + multi_dir = tempname(); + mkdir(multi_dir); + cleanup_multi = onCleanup(@() rmdir(multi_dir, 's')); + f1 = fullfile(multi_dir, 'recording_240101_120000.rhd'); + f2 = fullfile(multi_dir, 'recording_240101_120100.rhd'); copyfile(rhd_file, f1); copyfile(rhd_file, f2); - % helper: file list discovery and ordering + % Single-file directory: a separate copy on its own so the + % default 'detect' fileMode resolves to 'singleFile' + single_dir = tempname(); + mkdir(single_dir); + cleanup_single = onCleanup(@() rmdir(single_dir, 's')); + solo = fullfile(single_dir, 'recording_240101_120000.rhd'); + copyfile(rhd_file, solo); + + % helper: file list discovery and ordering in explicit multiFile files = ndr.format.intan.getRHD2000FileList(f2, 'multiFile'); testCase.verifyEqual(numel(files), 2); testCase.verifyEqual(files{1}, f1); testCase.verifyEqual(files{2}, f2); - % singleFile default returns just the requested file - files_single = ndr.format.intan.getRHD2000FileList(f1); + % singleFile mode returns just the requested file even when siblings exist + files_single = ndr.format.intan.getRHD2000FileList(f1, 'singleFile'); testCase.verifyEqual(files_single, {f1}); + % default 'detect' mode resolves correctly in each directory + testCase.verifyEqual(numel(ndr.format.intan.getRHD2000FileList(f1)), 2); + testCase.verifyEqual(ndr.format.intan.getRHD2000FileList(solo), {solo}); + % header in multi-file mode reports both files header_multi = ndr.format.intan.read_Intan_RHD2000_header(f1, 'fileMode', 'multiFile'); testCase.verifyEqual(header_multi.fileinfo.multifile.fileMode, 'multiFile'); testCase.verifyEqual(numel(header_multi.fileinfo.multifile.files), 2); % blockinfo aggregates blocks across files - header_single = ndr.format.intan.read_Intan_RHD2000_header(f1); - [~, ~, ~, num_blocks_single] = ndr.format.intan.Intan_RHD2000_blockinfo(f1, header_single); + header_single = ndr.format.intan.read_Intan_RHD2000_header(solo); + [~, ~, ~, num_blocks_single] = ndr.format.intan.Intan_RHD2000_blockinfo(solo, header_single); [~, ~, ~, num_blocks_multi, file_blocks] = ndr.format.intan.Intan_RHD2000_blockinfo(f1, header_multi); testCase.verifyEqual(num_blocks_multi, 2 * num_blocks_single); testCase.verifyEqual(file_blocks, [num_blocks_single num_blocks_single]); - % reader autodetects multi-file when more than one .rhd is in the epochstreams + % reader autodetects multi-file when sibling files exist on disk reader = ndr.reader.intan_rhd(); - t0t1_single = reader.t0_t1({f1}, 1); + t0t1_single = reader.t0_t1({solo}, 1); t0t1_multi = reader.t0_t1({f1, f2}, 1); - sr = reader.samplerate({f1}, 1, 'ai', 1); + sr = reader.samplerate({solo}, 1, 'ai', 1); duration_single = t0t1_single{1}(2) - t0t1_single{1}(1); duration_multi = t0t1_multi{1}(2) - t0t1_multi{1}(1); testCase.verifyEqual(duration_multi, duration_single + duration_single + 1/sr, 'AbsTol', 1e-9); % reading the first N samples in multi-file mode equals reading - % them in single-file mode + % them from the lone single file n = 50; - data_single = reader.readchannels_epochsamples('ai', 1, {f1}, 1, 1, n); + data_single = reader.readchannels_epochsamples('ai', 1, {solo}, 1, 1, n); data_multi = reader.readchannels_epochsamples('ai', 1, {f1, f2}, 1, 1, n); testCase.verifyEqual(data_multi, data_single); @@ -183,10 +233,14 @@ function testMultiFileMode(testCase) % the concatenation of (tail of file 1) + (head of file 2) single_total_samples = num_blocks_single * header_single.fileinfo.num_samples_per_data_block; span = 20; - tail = reader.readchannels_epochsamples('ai', 1, {f1}, 1, single_total_samples - span + 1, single_total_samples); - head = reader.readchannels_epochsamples('ai', 1, {f1}, 1, 1, span); + tail = reader.readchannels_epochsamples('ai', 1, {solo}, 1, single_total_samples - span + 1, single_total_samples); + head = reader.readchannels_epochsamples('ai', 1, {solo}, 1, 1, span); spanning = reader.readchannels_epochsamples('ai', 1, {f1, f2}, 1, single_total_samples - span + 1, single_total_samples + span); testCase.verifyEqual(spanning, [tail; head]); + + % single-file epoch with no siblings on disk reads as singleFile + data_single_default = reader.readchannels_epochsamples('ai', 1, {solo}, 1, 1, n); + testCase.verifyEqual(data_single_default, data_single); end function testAuxSampleRate(testCase) From fd544b4cce497ad6d04bca294f5bc43bf79d998e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 16:22:00 +0000 Subject: [PATCH 4/5] Refresh PR head From 2e2df628f13097380ae0154c7cfebc5dcdd06ea9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 16:24:53 +0000 Subject: [PATCH 5/5] Update GitHub badges [skip ci] --- .github/badges/code_issues.svg | 2 +- .github/badges/tests.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/code_issues.svg b/.github/badges/code_issues.svg index 22bd7700..805f59f2 100644 --- a/.github/badges/code_issues.svg +++ b/.github/badges/code_issues.svg @@ -1 +1 @@ -code issuescode issues13221322 \ No newline at end of file +code issuescode issues13381338 \ No newline at end of file diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg index 35a36223..60cd83fa 100644 --- a/.github/badges/tests.svg +++ b/.github/badges/tests.svg @@ -1 +1 @@ -teststests127 passed127 passed \ No newline at end of file +teststests128 passed128 passed \ No newline at end of file