Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions +ndr/+format/+neuropixelsGLX/header.m
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,15 @@
info.n_neural_chans = counts(1);
end
elseif isfield(meta, 'snsMnMaXaDw')
% NI-DAQ stream
% NI-DAQ stream: MN,MA,XA,DW
info.stream_type = 'nidq';
counts = sscanf(meta.snsMnMaXaDw, '%d,%d,%d,%d');
info.n_neural_chans = counts(1); % MN channels
info.n_sync_chans = 0;
info.n_mn_chans = counts(1); % multiplexed neural
info.n_ma_chans = counts(2); % multiplexed analog
info.n_xa_chans = counts(3); % non-multiplexed analog
info.n_dw_chans = counts(4); % digital words
info.n_neural_chans = counts(1) + counts(2) + counts(3);
info.n_sync_chans = counts(4);
else
% Fallback
info.stream_type = 'unknown';
Expand All @@ -107,17 +111,31 @@
vmax = str2double(meta.imAiRangeMax);
vmin = str2double(meta.imAiRangeMin);
info.voltage_range = [vmin vmax];
elseif isfield(meta, 'niAiRangeMax')
vmax = str2double(meta.niAiRangeMax);
vmin = str2double(meta.niAiRangeMin);
info.voltage_range = [vmin vmax];
else
info.voltage_range = [-0.6 0.6]; % Neuropixels 1.0 default
end

% Max integer value
if isfield(meta, 'imMaxInt')
info.max_int = str2double(meta.imMaxInt);
elseif isfield(meta, 'niMaxInt')
info.max_int = str2double(meta.niMaxInt);
else
info.max_int = 512; % Neuropixels 1.0 default
end

% NI-DAQ gains
if isfield(meta, 'niMNGain')
info.ni_mn_gain = str2double(meta.niMNGain);
end
if isfield(meta, 'niMAGain')
info.ni_ma_gain = str2double(meta.niMAGain);
end

% Bits per sample
info.bits_per_sample = 16;

Expand Down
48 changes: 33 additions & 15 deletions +ndr/+format/+neuropixelsGLX/read.m
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@
% SR - Sampling rate in Hz (positive double, default: from .meta).
% channels - 1-based vector of channel indices to read (default: []
% meaning all channels). Must be within [1, numChans].
% scale - If true (default), convert raw int16 data to volts
% using ndr.format.neuropixelsGLX.samples2volts. Requires
% a companion .meta file. If false, return raw int16.
%
% Outputs:
% DATA - N x C int16 matrix, where N is the number of time samples
% and C is the number of channels read.
% DATA - N x C matrix of samples. Double volts if scale is true,
% int16 raw values if scale is false.
% T - N x 1 double vector of time points in seconds.
% T0_T1 - 1x2 double vector [startTime endTime] for the full file.
%
Expand All @@ -47,21 +50,27 @@
options.numChans (1,1) {mustBeInteger, mustBeNonnegative} = 0
options.SR (1,1) {mustBeNumeric, mustBeNonnegative} = 0
options.channels (1,:) {mustBeNumeric, mustBeInteger, mustBePositive} = []
options.scale (1,1) logical = true
end

% If numChans or SR not provided, read from companion .meta file
if options.numChans == 0 || options.SR == 0
metafilename = [binfilename(1:end-3) 'meta'];
% Read companion .meta file if needed for numChans/SR or scaling
metafilename = [binfilename(1:end-3) 'meta'];
info = [];
if options.numChans == 0 || options.SR == 0 || options.scale
if ~isfile(metafilename)
error('ndr:format:neuropixelsGLX:read:NoMetaFile', ...
'No .meta file found at %s and numChans/SR not specified.', metafilename);
end
info = ndr.format.neuropixelsGLX.header(metafilename);
if options.numChans == 0
options.numChans = info.n_saved_chans;
end
if options.SR == 0
options.SR = info.sample_rate;
if options.numChans == 0 || options.SR == 0
error('ndr:format:neuropixelsGLX:read:NoMetaFile', ...
'No .meta file found at %s and numChans/SR not specified.', metafilename);
end
% scale requested but no meta file; will skip scaling below
else
info = ndr.format.neuropixelsGLX.header(metafilename);
if options.numChans == 0
options.numChans = info.n_saved_chans;
end
if options.SR == 0
options.SR = info.sample_rate;
end
end
end

Expand Down Expand Up @@ -124,12 +133,21 @@
'byteOrder', 'ieee-le', ...
'headerSkip', uint64(0));

% Scale to volts if requested and header info is available
if options.scale && ~isempty(info) && ~isempty(data)
data = ndr.format.neuropixelsGLX.samples2volts(data, info, double(channelsToRead));
end

% Generate time vector
if ~isempty(data)
t = ndr.time.fun.samples2times((s0_actual:s1_actual)', t0_t1, SR);
else
t = [];
data = int16([]);
if ~options.scale
data = int16([]);
else
data = double([]);
end
end

end
87 changes: 73 additions & 14 deletions +ndr/+format/+neuropixelsGLX/samples2volts.m
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
function volts = samples2volts(data, info)
function volts = samples2volts(data, info, channels)
%SAMPLES2VOLTS Convert raw int16 samples to voltage in volts.
%
% VOLTS = ndr.format.neuropixelsGLX.samples2volts(DATA, INFO)
% VOLTS = ndr.format.neuropixelsGLX.samples2volts(DATA, INFO, CHANNELS)
%
% Converts raw int16 Neuropixels data to voltage using the gain and range
% parameters from the meta file header.
Expand All @@ -16,40 +17,63 @@
% Per-channel gains are automatically parsed from the imroTbl field.
% If imroTbl is absent, default gains are used (500 for AP, 250 for LF).
%
% For NI-DAQ streams, gains are determined from niMNGain and niMAGain
% fields in the meta file.
%
% Inputs:
% DATA - N x C int16 matrix of raw samples.
% INFO - Header structure from ndr.format.neuropixelsGLX.header.
% DATA - N x C int16 matrix of raw samples.
% INFO - Header structure from ndr.format.neuropixelsGLX.header.
% CHANNELS - Optional 1-based channel indices corresponding to the
% columns of DATA. If omitted, columns are assumed to be
% channels 1:C in order.
%
% Outputs:
% VOLTS - N x C double matrix of voltages in volts.
%
% Example:
% info = ndr.format.neuropixelsGLX.header('run_g0_t0.imec0.ap.meta');
% [data, ~] = ndr.format.neuropixelsGLX.read('run_g0_t0.imec0.ap.bin', 0, 1);
% [data, ~] = ndr.format.neuropixelsGLX.read('run_g0_t0.imec0.ap.bin', 0, 1, 'scale', false);
% volts = ndr.format.neuropixelsGLX.samples2volts(data(:,1:info.n_neural_chans), info);
%
% See also: ndr.format.neuropixelsGLX.header, ndr.format.neuropixelsGLX.read

arguments
data {mustBeNumeric}
info (1,1) struct
channels (1,:) {mustBeNumeric, mustBeInteger, mustBePositive} = []
end

vmax = info.voltage_range(2); % imAiRangeMax
vmax = info.voltage_range(2);
n_chans = size(data, 2);

% Parse per-channel gains from imroTbl if available
if isfield(info.meta, 'imroTbl')
gains = parse_imro_gains(info.meta.imroTbl, info.stream_type);
n_chans = size(data, 2);
if numel(gains) >= n_chans
gains = gains(1:n_chans);
if strcmpi(info.stream_type, 'nidq')
% NI-DAQ stream: build gain vector for all channels, then select
all_gains = build_nidq_gains(info, info.n_saved_chans);
if ~isempty(channels)
gains = all_gains(channels);
else
gains = all_gains(1:n_chans);
end
volts = double(data) .* (vmax ./ (info.max_int .* gains));
elseif isfield(info.meta, 'imroTbl')
% Imec stream with per-channel gains from imroTbl
all_gains = parse_imro_gains(info.meta.imroTbl, info.stream_type);
if ~isempty(channels)
% Pad if needed, then index
if numel(all_gains) < max(channels)
all_gains = [all_gains, repmat(all_gains(end), 1, max(channels) - numel(all_gains))];
end
gains = all_gains(channels);
else
gains = [gains, repmat(gains(end), 1, n_chans - numel(gains))];
if numel(all_gains) >= n_chans
gains = all_gains(1:n_chans);
else
gains = [all_gains, repmat(all_gains(end), 1, n_chans - numel(all_gains))];
end
end
% Official formula: V = int16 * imAiRangeMax / imMaxInt / gain
volts = double(data) .* (vmax ./ (info.max_int .* gains));
else
% Default gain for Neuropixels 1.0 AP band
% Default gain for Neuropixels 1.0
if strcmpi(info.stream_type, 'ap')
default_gain = 500;
else
Expand All @@ -61,6 +85,41 @@
end


function gains = build_nidq_gains(info, n_chans)
%BUILD_NIDQ_GAINS Build per-channel gain vector for NI-DAQ streams.
%
% NI-DAQ channels are ordered: MN (neural), MA (auxiliary analog),
% XA (non-multiplexed analog), DW (digital words).
% MN channels use niMNGain, MA channels use niMAGain, XA channels
% have gain=1 (already in volts), DW channels have gain=1.

mn_gain = 1;
ma_gain = 1;
if isfield(info, 'ni_mn_gain')
mn_gain = info.ni_mn_gain;
end
if isfield(info, 'ni_ma_gain')
ma_gain = info.ni_ma_gain;
end

n_mn = 0; n_ma = 0; n_xa = 0;
if isfield(info, 'n_mn_chans'), n_mn = info.n_mn_chans; end
if isfield(info, 'n_ma_chans'), n_ma = info.n_ma_chans; end
if isfield(info, 'n_xa_chans'), n_xa = info.n_xa_chans; end

gains = [repmat(mn_gain, 1, n_mn), ...
repmat(ma_gain, 1, n_ma), ...
ones(1, n_xa)];

% Pad or trim to match the number of data columns
if numel(gains) >= n_chans
gains = gains(1:n_chans);
else
gains = [gains, ones(1, n_chans - numel(gains))];
end
end


function gains = parse_imro_gains(imroTbl_str, stream_type)
%PARSE_IMRO_GAINS Extract per-channel gains from imroTbl string.
%
Expand Down
2 changes: 1 addition & 1 deletion .github/badges/tests.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 38 additions & 2 deletions tools/tests/+ndr/+unittest/+reader/TestNeuropixelsGLX.m
Original file line number Diff line number Diff line change
Expand Up @@ -351,14 +351,50 @@ function testFormatReadFunction(testCase)
testCase.BinFilename, 0, 0.001, ...
'numChans', testCase.NumTotalChans, ...
'SR', testCase.SR, ...
'channels', 1:2);
'channels', 1:2, ...
'scale', false);

testCase.verifyClass(data, 'int16', 'Format read should return int16.');
testCase.verifyClass(data, 'int16', 'Format read should return int16 when scale is false.');
testCase.verifySize(data, [size(data,1), 2], 'Should have 2 columns.');
testCase.verifyGreaterThan(numel(t), 0, 'Time vector should not be empty.');
testCase.verifyEqual(t0_t1_range(1), 0, 'AbsTol', 1e-9, 'File should start at t=0.');
end

function testFormatReadScaled(testCase)
%TESTFORMATREADSCALED Verify read returns volts when scale is true.
[data_scaled, ~, ~] = ndr.format.neuropixelsGLX.read(...
testCase.BinFilename, 0, 0.001, ...
'numChans', testCase.NumTotalChans, ...
'SR', testCase.SR, ...
'channels', 1:2, ...
'scale', true);

testCase.verifyClass(data_scaled, 'double', 'Scaled data should be double.');

% Compare against manual conversion
[data_raw, ~, ~] = ndr.format.neuropixelsGLX.read(...
testCase.BinFilename, 0, 0.001, ...
'numChans', testCase.NumTotalChans, ...
'SR', testCase.SR, ...
'channels', 1:2, ...
'scale', false);

info = ndr.format.neuropixelsGLX.header(testCase.MetaFilename);
expected = ndr.format.neuropixelsGLX.samples2volts(data_raw, info, [1 2]);
testCase.verifyEqual(data_scaled, expected, 'AbsTol', 1e-15, ...
'Scaled read should match manual samples2volts conversion.');
end

function testFormatReadScaleDefault(testCase)
%TESTFORMATREADSCALEDEFAULT Verify read defaults to scaled output.
[data, ~, ~] = ndr.format.neuropixelsGLX.read(...
testCase.BinFilename, 0, 0.001, ...
'channels', 1:2);

testCase.verifyClass(data, 'double', ...
'Default read should return double (scaled).');
end

function testChannelSubsetParsing(testCase)
%TESTCHANNELSUBSETPARSING Verify channel subset field in header.
info = ndr.format.neuropixelsGLX.header(testCase.MetaFilename);
Expand Down
Loading