diff --git a/+ndr/+format/+neuropixelsGLX/probeGeometry.m b/+ndr/+format/+neuropixelsGLX/probeGeometry.m
new file mode 100644
index 0000000..3c6f090
--- /dev/null
+++ b/+ndr/+format/+neuropixelsGLX/probeGeometry.m
@@ -0,0 +1,238 @@
+function pg = probeGeometry(metafilename)
+%PROBEGEOMETRY Extract probe geometry from a SpikeGLX .meta file.
+%
+% PG = ndr.format.neuropixelsGLX.probeGeometry(METAFILENAME)
+%
+% Reads a SpikeGLX .meta file and returns a structure whose fields match
+% the NDI probe_geometry document type. Electrode positions are taken
+% from the ~snsGeomMap field if present (SpikeGLX >= 20230202), or
+% computed from the ~imroTbl field and probe type for older files.
+%
+% Inputs:
+% METAFILENAME - Full path to a SpikeGLX .meta file (char row vector).
+%
+% Outputs:
+% PG - A scalar structure with the following fields:
+% site_locations_leftright : [N x 1] left-right positions in um
+% site_locations_frontback : [N x 1] front-back positions (zeros)
+% site_locations_depth : [N x 1] depth positions in um
+% probe_model : Probe model name (char)
+% manufacturer : 'IMEC' (char)
+% shank_id : [N x 1] shank IDs (1-based integer)
+% contact_shape : 'square' (char)
+% contact_shape_width : [N x 1] contact widths in um
+% contact_shape_height : [N x 1] contact heights in um
+% contact_shape_radius : [N x 1] zeros (not circular)
+% ndim : 2 (integer)
+% unit : 'um' (char)
+% has_planar_contour : 0 (integer)
+% contour_x : [] (empty)
+% contour_y : [] (empty)
+%
+% where N is the number of neural channels (excluding sync).
+%
+% Example:
+% pg = ndr.format.neuropixelsGLX.probeGeometry('/data/run_g0_t0.imec0.ap.meta');
+%
+% See also: ndr.format.neuropixelsGLX.header, ndr.format.neuropixelsGLX.readmeta
+
+ arguments
+ metafilename (1,:) char {mustBeFile}
+ end
+
+ meta = ndr.format.neuropixelsGLX.readmeta(metafilename);
+ info = ndr.format.neuropixelsGLX.header(metafilename);
+ n_neural = info.n_neural_chans;
+
+ % Try snsGeomMap first (available in newer SpikeGLX)
+ geom_str = find_meta_field(meta, 'snsGeomMap');
+
+ if ~isempty(geom_str)
+ [x, y, shank] = parse_snsGeomMap(geom_str, n_neural);
+ else
+ % Fall back to computing geometry from imroTbl + probe type
+ imro_str = find_meta_field(meta, 'imroTbl');
+ if isempty(imro_str)
+ error('ndr:format:neuropixelsGLX:probeGeometry:NoGeometry', ...
+ 'Meta file has neither snsGeomMap nor imroTbl.');
+ end
+ probe_type = str2double(info.probe_type);
+ [x, y, shank] = compute_geometry_from_imro(imro_str, probe_type);
+ end
+
+ % Get probe model name and contact size
+ probe_type_num = str2double(info.probe_type);
+ [model_name, contact_size] = probe_type_info(probe_type_num);
+
+ % Build output structure
+ pg = struct();
+ pg.site_locations_leftright = x(:);
+ pg.site_locations_frontback = zeros(n_neural, 1);
+ pg.site_locations_depth = y(:);
+ pg.probe_model = model_name;
+ pg.manufacturer = 'IMEC';
+ pg.shank_id = shank(:);
+ pg.contact_shape = 'square';
+ pg.contact_shape_width = repmat(contact_size, n_neural, 1);
+ pg.contact_shape_height = repmat(contact_size, n_neural, 1);
+ pg.contact_shape_radius = zeros(n_neural, 1);
+ pg.ndim = 2;
+ pg.unit = 'um';
+ pg.has_planar_contour = 0;
+ pg.contour_x = [];
+ pg.contour_y = [];
+
+end
+
+
+function value = find_meta_field(meta, target)
+%FIND_META_FIELD Find a field in the meta struct by partial name match.
+% Handles the fact that readmeta transforms '~' prefixed field names
+% (e.g. ~snsGeomMap becomes x_snsGeomMap via makeValidName).
+
+ value = '';
+ fields = fieldnames(meta);
+ for i = 1:numel(fields)
+ if contains(fields{i}, target, 'IgnoreCase', true)
+ value = meta.(fields{i});
+ return;
+ end
+ end
+
+end
+
+
+function [x, y, shank] = parse_snsGeomMap(geommap_str, n_neural)
+%PARSE_SNSGEOMMAP Parse the snsGeomMap string for electrode positions.
+%
+% Format: (header)(shank:x:y:used)(shank:x:y:used)...
+% Header: (probePN,nShanks,shankSep,shankWidth) — skipped.
+% Each channel entry has colon-separated integers: shank:x:y:used.
+
+ tokens = regexp(geommap_str, '\(([^)]+)\)', 'tokens');
+
+ if numel(tokens) < 1 + n_neural
+ error('ndr:format:neuropixelsGLX:probeGeometry:BadGeomMap', ...
+ 'snsGeomMap has %d entries but expected at least %d (header + %d neural).', ...
+ numel(tokens), 1 + n_neural, n_neural);
+ end
+
+ x = zeros(n_neural, 1);
+ y = zeros(n_neural, 1);
+ shank = zeros(n_neural, 1);
+
+ % Skip the first token (header), parse channel entries
+ for i = 1:n_neural
+ parts = sscanf(tokens{i + 1}{1}, '%d:%d:%d:%d');
+ if numel(parts) < 4
+ error('ndr:format:neuropixelsGLX:probeGeometry:BadGeomMapEntry', ...
+ 'Could not parse snsGeomMap entry %d: "%s".', i, tokens{i+1}{1});
+ end
+ shank(i) = parts(1) + 1; % Convert 0-based to 1-based
+ x(i) = parts(2);
+ y(i) = parts(3);
+ end
+
+end
+
+
+function [x, y, shank] = compute_geometry_from_imro(imroTbl_str, probe_type)
+%COMPUTE_GEOMETRY_FROM_IMRO Compute electrode positions from imroTbl.
+%
+% Supported probe types:
+% 0 — Neuropixels 1.0 (staggered 2-column, 20 um pitch)
+% 21, 2003,
+% 2004 — Neuropixels 2.0 single shank (2-column, 15 um pitch)
+% 24, 2013,
+% 2014 — Neuropixels 2.0 four shank (2-column, 15 um pitch)
+
+ tokens = regexp(imroTbl_str, '\(([^)]+)\)', 'tokens');
+ if numel(tokens) < 2
+ error('ndr:format:neuropixelsGLX:probeGeometry:BadImroTbl', ...
+ 'Could not parse imroTbl: too few parenthesized groups.');
+ end
+
+ n_chans = numel(tokens) - 1; % First token is the header
+ x = zeros(n_chans, 1);
+ y = zeros(n_chans, 1);
+ shank = ones(n_chans, 1);
+
+ switch probe_type
+ case 0
+ % Neuropixels 1.0
+ % Entry format: (chanIdx bank refIdx apGain lfGain apHiPass)
+ % Electrode ID = chanIdx + bank * 384
+ % Staggered 2-column layout, 20 um vertical pitch
+ for i = 1:n_chans
+ vals = sscanf(tokens{i + 1}{1}, '%d %d %d %d %d %d');
+ elec_id = vals(1) + vals(2) * 384;
+ row = floor(elec_id / 2);
+ col = mod(elec_id, 2);
+ y(i) = row * 20;
+ if mod(row, 2) == 0
+ x(i) = 27 + col * 32;
+ else
+ x(i) = 11 + col * 32;
+ end
+ end
+
+ case {21, 2003, 2004}
+ % Neuropixels 2.0 single shank
+ % Entry format: (chanIdx bankMask refIdx elecInd)
+ % 2-column layout, 15 um vertical pitch
+ for i = 1:n_chans
+ vals = sscanf(tokens{i + 1}{1}, '%d %d %d %d');
+ elec_ind = vals(4);
+ row = floor(elec_ind / 2);
+ col = mod(elec_ind, 2);
+ y(i) = row * 15;
+ x(i) = 27 + col * 32;
+ end
+
+ case {24, 2013, 2014}
+ % Neuropixels 2.0 four shank
+ % Entry format: (chanIdx shankIdx bankMask refIdx elecInd)
+ % 2-column layout per shank, 15 um pitch, 250 um shank spacing
+ for i = 1:n_chans
+ vals = sscanf(tokens{i + 1}{1}, '%d %d %d %d %d');
+ shank_idx = vals(2);
+ elec_ind = vals(5);
+ row = floor(elec_ind / 2);
+ col = mod(elec_ind, 2);
+ y(i) = row * 15;
+ x(i) = shank_idx * 250 + 27 + col * 32;
+ shank(i) = shank_idx + 1;
+ end
+
+ otherwise
+ error('ndr:format:neuropixelsGLX:probeGeometry:UnsupportedProbe', ...
+ ['Probe type %d is not supported for geometry computation from ' ...
+ 'imroTbl. Use a SpikeGLX version that writes ~snsGeomMap ' ...
+ 'to the .meta file.'], probe_type);
+ end
+
+end
+
+
+function [model_name, contact_size] = probe_type_info(probe_type)
+%PROBE_TYPE_INFO Return probe model name and contact size for a probe type.
+
+ switch probe_type
+ case 0
+ model_name = 'Neuropixels 1.0';
+ contact_size = 12;
+ case {21, 2003, 2004}
+ model_name = 'Neuropixels 2.0 Single Shank';
+ contact_size = 12;
+ case {24, 2013, 2014}
+ model_name = 'Neuropixels 2.0 Four Shank';
+ contact_size = 12;
+ case {1100, 1110}
+ model_name = 'Neuropixels Ultra';
+ contact_size = 6;
+ otherwise
+ model_name = sprintf('Neuropixels (type %d)', probe_type);
+ contact_size = 12;
+ end
+
+end
diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg
index 4416129..7dfcaa8 100644
--- a/.github/badges/tests.svg
+++ b/.github/badges/tests.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/tools/tests/+ndr/+unittest/+format/+neuropixelsGLX/TestProbeGeometry.m b/tools/tests/+ndr/+unittest/+format/+neuropixelsGLX/TestProbeGeometry.m
new file mode 100644
index 0000000..e4f3c32
--- /dev/null
+++ b/tools/tests/+ndr/+unittest/+format/+neuropixelsGLX/TestProbeGeometry.m
@@ -0,0 +1,412 @@
+classdef TestProbeGeometry < matlab.unittest.TestCase
+%TESTPROBEGEOMETRY Unit tests for ndr.format.neuropixelsGLX.probeGeometry.
+%
+% Tests probe geometry extraction from synthetic SpikeGLX .meta files
+% covering all supported code paths:
+% - snsGeomMap parsing (preferred path for newer SpikeGLX)
+% - imroTbl-based computation for NP 1.0 (type 0)
+% - imroTbl-based computation for NP 2.0 single shank (type 21)
+% - imroTbl-based computation for NP 2.0 four shank (type 24)
+%
+% Example:
+% results = runtests('ndr.unittest.format.neuropixelsGLX.TestProbeGeometry');
+
+ properties (SetAccess=protected)
+ TempDir char = ''
+ end
+
+ methods (TestMethodSetup)
+ function createTempDir(testCase)
+ testCase.TempDir = fullfile(tempdir, ...
+ ['ndr_pg_test_' char(java.util.UUID.randomUUID)]);
+ mkdir(testCase.TempDir);
+ testCase.addTeardown(@() rmdir(testCase.TempDir, 's'));
+ end
+ end
+
+ % --- Helper Methods ---
+
+ methods (Access=private)
+
+ function metafile = writeMetaFile(testCase, filename, fields)
+ %WRITEMETAFILE Write a minimal .meta file from a cell array of
+ % {'key','value'; ...} pairs.
+ metafile = fullfile(testCase.TempDir, filename);
+ fid = fopen(metafile, 'w');
+ testCase.assertNotEqual(fid, -1, 'Could not create meta file.');
+ for i = 1:size(fields, 1)
+ fprintf(fid, '%s=%s\n', fields{i,1}, fields{i,2});
+ end
+ fclose(fid);
+ end
+
+ function metafile = writeNP10Meta(testCase, n_chans, bank_values)
+ %WRITENP10META Write a NP 1.0 .meta file with imroTbl (no snsGeomMap).
+ % bank_values is [n_chans x 1] array of bank indices (0,1,2).
+ if nargin < 3
+ bank_values = zeros(n_chans, 1);
+ end
+ imro = sprintf('(0,%d)', n_chans);
+ for c = 0:(n_chans-1)
+ imro = [imro, sprintf('(%d %d 0 500 250 1)', c, bank_values(c+1))]; %#ok
+ end
+ fields = {
+ 'imSampRate', '30000';
+ 'nSavedChans', num2str(n_chans + 1);
+ 'snsApLfSy', sprintf('%d,0,1', n_chans);
+ 'snsSaveChanSubset', 'all';
+ 'fileSizeBytes', '0';
+ 'fileTimeSecs', '0';
+ 'imAiRangeMax', '0.6';
+ 'imAiRangeMin', '-0.6';
+ 'imMaxInt', '512';
+ 'imDatPrb_type', '0';
+ 'imDatPrb_sn', '0000000000';
+ 'imroTbl', imro;
+ };
+ metafile = testCase.writeMetaFile('test.imec0.ap.meta', fields);
+ end
+
+ function metafile = writeNP20SSMeta(testCase, n_chans, elec_inds)
+ %WRITENP20SSMETA Write a NP 2.0 single-shank .meta file.
+ % elec_inds is [n_chans x 1] array of electrode indices.
+ imro = sprintf('(21,%d)', n_chans);
+ for c = 0:(n_chans-1)
+ imro = [imro, sprintf('(%d 0 0 %d)', c, elec_inds(c+1))]; %#ok
+ end
+ fields = {
+ 'imSampRate', '30000';
+ 'nSavedChans', num2str(n_chans + 1);
+ 'snsApLfSy', sprintf('%d,0,1', n_chans);
+ 'snsSaveChanSubset', 'all';
+ 'fileSizeBytes', '0';
+ 'fileTimeSecs', '0';
+ 'imAiRangeMax', '0.6';
+ 'imAiRangeMin', '-0.6';
+ 'imMaxInt', '8192';
+ 'imDatPrb_type', '21';
+ 'imDatPrb_sn', '0000000000';
+ 'imroTbl', imro;
+ };
+ metafile = testCase.writeMetaFile('test.imec0.ap.meta', fields);
+ end
+
+ function metafile = writeNP20_4SMeta(testCase, n_chans, shank_ids, elec_inds)
+ %WRITENP20_4SMETA Write a NP 2.0 four-shank .meta file.
+ imro = sprintf('(24,%d)', n_chans);
+ for c = 0:(n_chans-1)
+ imro = [imro, sprintf('(%d %d 0 0 %d)', c, shank_ids(c+1), elec_inds(c+1))]; %#ok
+ end
+ fields = {
+ 'imSampRate', '30000';
+ 'nSavedChans', num2str(n_chans + 1);
+ 'snsApLfSy', sprintf('%d,0,1', n_chans);
+ 'snsSaveChanSubset', 'all';
+ 'fileSizeBytes', '0';
+ 'fileTimeSecs', '0';
+ 'imAiRangeMax', '0.6';
+ 'imAiRangeMin', '-0.6';
+ 'imMaxInt', '8192';
+ 'imDatPrb_type', '24';
+ 'imDatPrb_sn', '0000000000';
+ 'imroTbl', imro;
+ };
+ metafile = testCase.writeMetaFile('test.imec0.ap.meta', fields);
+ end
+
+ function metafile = writeGeomMapMeta(testCase, n_chans, geommap_str)
+ %WRITEGEOMMAPMETA Write a .meta file with ~snsGeomMap.
+ imro = sprintf('(0,%d)', n_chans);
+ for c = 0:(n_chans-1)
+ imro = [imro, sprintf('(%d 0 0 500 250 1)', c)]; %#ok
+ end
+ fields = {
+ 'imSampRate', '30000';
+ 'nSavedChans', num2str(n_chans + 1);
+ 'snsApLfSy', sprintf('%d,0,1', n_chans);
+ 'snsSaveChanSubset', 'all';
+ 'fileSizeBytes', '0';
+ 'fileTimeSecs', '0';
+ 'imAiRangeMax', '0.6';
+ 'imAiRangeMin', '-0.6';
+ 'imMaxInt', '512';
+ 'imDatPrb_type', '0';
+ 'imDatPrb_sn', '0000000000';
+ 'imroTbl', imro;
+ '~snsGeomMap', geommap_str;
+ };
+ metafile = testCase.writeMetaFile('test.imec0.ap.meta', fields);
+ end
+
+ end
+
+ % --- Test Methods ---
+
+ methods (Test)
+
+ % ---- Output structure validation ----
+
+ function testOutputFieldsExist(testCase)
+ %TESTOUTPUTFIELDSEXIST Verify all expected fields are present.
+ metafile = testCase.writeNP10Meta(8);
+ pg = ndr.format.neuropixelsGLX.probeGeometry(metafile);
+
+ expected_fields = {'site_locations_leftright', ...
+ 'site_locations_frontback', 'site_locations_depth', ...
+ 'probe_model', 'manufacturer', 'shank_id', ...
+ 'contact_shape', 'contact_shape_width', ...
+ 'contact_shape_height', 'contact_shape_radius', ...
+ 'ndim', 'unit', 'has_planar_contour', ...
+ 'contour_x', 'contour_y'};
+
+ for i = 1:numel(expected_fields)
+ testCase.verifyTrue(isfield(pg, expected_fields{i}), ...
+ ['Missing field: ' expected_fields{i}]);
+ end
+ end
+
+ function testOutputVectorSizes(testCase)
+ %TESTOUTPUTVECTORSIZES Verify all per-channel fields are N x 1.
+ n = 16;
+ metafile = testCase.writeNP10Meta(n);
+ pg = ndr.format.neuropixelsGLX.probeGeometry(metafile);
+
+ testCase.verifySize(pg.site_locations_leftright, [n 1]);
+ testCase.verifySize(pg.site_locations_frontback, [n 1]);
+ testCase.verifySize(pg.site_locations_depth, [n 1]);
+ testCase.verifySize(pg.shank_id, [n 1]);
+ testCase.verifySize(pg.contact_shape_width, [n 1]);
+ testCase.verifySize(pg.contact_shape_height, [n 1]);
+ testCase.verifySize(pg.contact_shape_radius, [n 1]);
+ end
+
+ function testScalarFieldValues(testCase)
+ %TESTSCALARFIELDVALUES Verify constant/scalar fields.
+ metafile = testCase.writeNP10Meta(8);
+ pg = ndr.format.neuropixelsGLX.probeGeometry(metafile);
+
+ testCase.verifyEqual(pg.manufacturer, 'IMEC');
+ testCase.verifyEqual(pg.contact_shape, 'square');
+ testCase.verifyEqual(pg.ndim, 2);
+ testCase.verifyEqual(pg.unit, 'um');
+ testCase.verifyEqual(pg.has_planar_contour, 0);
+ testCase.verifyEmpty(pg.contour_x);
+ testCase.verifyEmpty(pg.contour_y);
+ testCase.verifyEqual(pg.site_locations_frontback, zeros(8, 1));
+ testCase.verifyEqual(pg.contact_shape_radius, zeros(8, 1));
+ end
+
+ % ---- NP 1.0 imroTbl path ----
+
+ function testNP10BankZeroPositions(testCase)
+ %TESTNP10BANKZEROPOSITIONS Verify NP 1.0 geometry for bank 0.
+ % Channels 0-7, bank 0 → electrodes 0-7.
+ % e0: row=0,col=0 → x=27, y=0 (even row)
+ % e1: row=0,col=1 → x=59, y=0 (even row)
+ % e2: row=1,col=0 → x=11, y=20 (odd row)
+ % e3: row=1,col=1 → x=43, y=20 (odd row)
+ % e4: row=2,col=0 → x=27, y=40 (even row)
+ % e5: row=2,col=1 → x=59, y=40 (even row)
+ % e6: row=3,col=0 → x=11, y=60 (odd row)
+ % e7: row=3,col=1 → x=43, y=60 (odd row)
+ metafile = testCase.writeNP10Meta(8);
+ pg = ndr.format.neuropixelsGLX.probeGeometry(metafile);
+
+ expected_x = [27; 59; 11; 43; 27; 59; 11; 43];
+ expected_y = [0; 0; 20; 20; 40; 40; 60; 60];
+
+ testCase.verifyEqual(pg.site_locations_leftright, expected_x);
+ testCase.verifyEqual(pg.site_locations_depth, expected_y);
+ end
+
+ function testNP10BankOnePositions(testCase)
+ %TESTNP10BANKONEPOSITIONS Verify NP 1.0 with bank=1.
+ % Channel 0, bank 1 → electrode 384.
+ % e384: row=192,col=0 → row is even → x=27, y=192*20=3840
+ banks = [1; 1; 0; 0];
+ metafile = testCase.writeNP10Meta(4, banks);
+ pg = ndr.format.neuropixelsGLX.probeGeometry(metafile);
+
+ % chan 0 bank 1 → elec 384: row=192(even), col=0 → x=27, y=3840
+ % chan 1 bank 1 → elec 385: row=192(even), col=1 → x=59, y=3840
+ % chan 2 bank 0 → elec 2: row=1(odd), col=0 → x=11, y=20
+ % chan 3 bank 0 → elec 3: row=1(odd), col=1 → x=43, y=20
+ testCase.verifyEqual(pg.site_locations_leftright, [27; 59; 11; 43]);
+ testCase.verifyEqual(pg.site_locations_depth, [3840; 3840; 20; 20]);
+ end
+
+ function testNP10ContactSize(testCase)
+ %TESTNP10CONTACTSIZE Verify NP 1.0 has 12 um square contacts.
+ metafile = testCase.writeNP10Meta(4);
+ pg = ndr.format.neuropixelsGLX.probeGeometry(metafile);
+
+ testCase.verifyEqual(pg.contact_shape_width, repmat(12, 4, 1));
+ testCase.verifyEqual(pg.contact_shape_height, repmat(12, 4, 1));
+ testCase.verifyEqual(pg.probe_model, 'Neuropixels 1.0');
+ end
+
+ function testNP10SingleShank(testCase)
+ %TESTNP10SINGLESHANK Verify NP 1.0 is single shank (all 1).
+ metafile = testCase.writeNP10Meta(8);
+ pg = ndr.format.neuropixelsGLX.probeGeometry(metafile);
+
+ testCase.verifyEqual(pg.shank_id, ones(8, 1));
+ end
+
+ % ---- NP 2.0 single shank imroTbl path ----
+
+ function testNP20SSPositions(testCase)
+ %TESTNP20SSPOSITIONS Verify NP 2.0 single-shank geometry.
+ % elecInd 0: row=0,col=0 → x=27, y=0
+ % elecInd 1: row=0,col=1 → x=59, y=0
+ % elecInd 2: row=1,col=0 → x=27, y=15
+ % elecInd 3: row=1,col=1 → x=59, y=15
+ elec_inds = [0; 1; 2; 3; 10; 11];
+ metafile = testCase.writeNP20SSMeta(6, elec_inds);
+ pg = ndr.format.neuropixelsGLX.probeGeometry(metafile);
+
+ expected_x = [27; 59; 27; 59; 27; 59];
+ expected_y = [0; 0; 15; 15; 75; 75];
+
+ testCase.verifyEqual(pg.site_locations_leftright, expected_x);
+ testCase.verifyEqual(pg.site_locations_depth, expected_y);
+ testCase.verifyEqual(pg.probe_model, 'Neuropixels 2.0 Single Shank');
+ testCase.verifyEqual(pg.shank_id, ones(6, 1));
+ end
+
+ % ---- NP 2.0 four shank imroTbl path ----
+
+ function testNP20FourShankPositions(testCase)
+ %TESTNP20FOURSHANKPOSITIONS Verify NP 2.0 four-shank geometry.
+ shank_ids = [0; 0; 1; 1; 2; 3];
+ elec_inds = [0; 1; 0; 1; 4; 5];
+ metafile = testCase.writeNP20_4SMeta(6, shank_ids, elec_inds);
+ pg = ndr.format.neuropixelsGLX.probeGeometry(metafile);
+
+ % shank 0, elec 0: x = 0*250 + 27 = 27, y = 0
+ % shank 0, elec 1: x = 0*250 + 59 = 59, y = 0
+ % shank 1, elec 0: x = 1*250 + 27 = 277, y = 0
+ % shank 1, elec 1: x = 1*250 + 59 = 309, y = 0
+ % shank 2, elec 4: row=2,col=0 → x = 2*250 + 27 = 527, y = 30
+ % shank 3, elec 5: row=2,col=1 → x = 3*250 + 59 = 809, y = 30
+ expected_x = [27; 59; 277; 309; 527; 809];
+ expected_y = [0; 0; 0; 0; 30; 30];
+ expected_shank = [1; 1; 2; 2; 3; 4];
+
+ testCase.verifyEqual(pg.site_locations_leftright, expected_x);
+ testCase.verifyEqual(pg.site_locations_depth, expected_y);
+ testCase.verifyEqual(pg.shank_id, expected_shank);
+ testCase.verifyEqual(pg.probe_model, 'Neuropixels 2.0 Four Shank');
+ end
+
+ % ---- snsGeomMap path ----
+
+ function testGeomMapPreferredOverImroTbl(testCase)
+ %TESTGEOMMAPREFERREDOVERIMROTBL Verify snsGeomMap takes precedence.
+ % Write a meta file with both imroTbl and ~snsGeomMap. The
+ % snsGeomMap positions should be used, not computed from imroTbl.
+ geommap = '(NP1010,1,0,70)(0:100:200:1)(0:300:400:1)(0:0:0:0)';
+ metafile = testCase.writeGeomMapMeta(2, geommap);
+ pg = ndr.format.neuropixelsGLX.probeGeometry(metafile);
+
+ % Should use snsGeomMap values, not imroTbl computation
+ testCase.verifyEqual(pg.site_locations_leftright, [100; 300]);
+ testCase.verifyEqual(pg.site_locations_depth, [200; 400]);
+ end
+
+ function testGeomMapShankIds(testCase)
+ %TESTGEOMMAPSHANKIDS Verify snsGeomMap shank IDs are 1-based.
+ geommap = '(NP2014,4,250,70)(0:27:0:1)(1:27:0:1)(2:59:15:1)(3:59:30:1)(0:0:0:0)';
+ metafile = testCase.writeGeomMapMeta(4, geommap);
+ pg = ndr.format.neuropixelsGLX.probeGeometry(metafile);
+
+ % 0-based shank IDs in snsGeomMap → 1-based in output
+ testCase.verifyEqual(pg.shank_id, [1; 2; 3; 4]);
+ end
+
+ function testGeomMapExcludesSyncChannel(testCase)
+ %TESTGEOMMAPEXCLUDESSYNCCHANNEL Verify sync channel is excluded.
+ % 4 neural channels + 1 sync (used=0)
+ geommap = '(NP1010,1,0,70)(0:10:20:1)(0:30:40:1)(0:50:60:1)(0:70:80:1)(0:0:0:0)';
+ metafile = testCase.writeGeomMapMeta(4, geommap);
+ pg = ndr.format.neuropixelsGLX.probeGeometry(metafile);
+
+ % Should have exactly 4 entries (not 5)
+ testCase.verifySize(pg.site_locations_leftright, [4 1]);
+ testCase.verifyEqual(pg.site_locations_leftright, [10; 30; 50; 70]);
+ testCase.verifyEqual(pg.site_locations_depth, [20; 40; 60; 80]);
+ end
+
+ % ---- Error cases ----
+
+ function testErrorNoGeometryInfo(testCase)
+ %TESTERRORNOGEOMETRYINFO Verify error when neither field is present.
+ fields = {
+ 'imSampRate', '30000';
+ 'nSavedChans', '5';
+ 'snsApLfSy', '4,0,1';
+ 'snsSaveChanSubset', 'all';
+ 'fileSizeBytes', '0';
+ 'fileTimeSecs', '0';
+ 'imAiRangeMax', '0.6';
+ 'imAiRangeMin', '-0.6';
+ 'imMaxInt', '512';
+ 'imDatPrb_type', '0';
+ };
+ metafile = testCase.writeMetaFile('test.imec0.ap.meta', fields);
+
+ testCase.verifyError(...
+ @() ndr.format.neuropixelsGLX.probeGeometry(metafile), ...
+ 'ndr:format:neuropixelsGLX:probeGeometry:NoGeometry');
+ end
+
+ function testErrorUnsupportedProbeType(testCase)
+ %TESTERRORUNSUPPORTEDPROBETYPE Verify error for unknown probe type.
+ imro = '(9999,2)(0 0 0 500 250 1)(1 0 0 500 250 1)';
+ fields = {
+ 'imSampRate', '30000';
+ 'nSavedChans', '3';
+ 'snsApLfSy', '2,0,1';
+ 'snsSaveChanSubset', 'all';
+ 'fileSizeBytes', '0';
+ 'fileTimeSecs', '0';
+ 'imAiRangeMax', '0.6';
+ 'imAiRangeMin', '-0.6';
+ 'imMaxInt', '512';
+ 'imDatPrb_type', '9999';
+ 'imroTbl', imro;
+ };
+ metafile = testCase.writeMetaFile('test.imec0.ap.meta', fields);
+
+ testCase.verifyError(...
+ @() ndr.format.neuropixelsGLX.probeGeometry(metafile), ...
+ 'ndr:format:neuropixelsGLX:probeGeometry:UnsupportedProbe');
+ end
+
+ % ---- NP 1.0 stagger pattern verification ----
+
+ function testNP10StaggerPattern(testCase)
+ %TESTNP10STAGGERPATTERN Verify all 4 x-positions appear correctly.
+ % NP 1.0 has 4 distinct x-positions: 11, 27, 43, 59.
+ metafile = testCase.writeNP10Meta(8);
+ pg = ndr.format.neuropixelsGLX.probeGeometry(metafile);
+
+ unique_x = sort(unique(pg.site_locations_leftright));
+ testCase.verifyEqual(unique_x, [11; 27; 43; 59], ...
+ 'NP 1.0 should have 4 distinct x-positions.');
+ end
+
+ function testNP20NoStagger(testCase)
+ %TESTNP20NOSTAGGER Verify NP 2.0 has only 2 x-positions (no stagger).
+ elec_inds = (0:7)';
+ metafile = testCase.writeNP20SSMeta(8, elec_inds);
+ pg = ndr.format.neuropixelsGLX.probeGeometry(metafile);
+
+ unique_x = sort(unique(pg.site_locations_leftright));
+ testCase.verifyEqual(unique_x, [27; 59], ...
+ 'NP 2.0 should have 2 distinct x-positions (no stagger).');
+ end
+
+ end % methods (Test)
+
+end % classdef