diff --git a/general functions/plotRawWaveforms.m b/general functions/plotRawWaveforms.m new file mode 100644 index 0000000..1c129f0 --- /dev/null +++ b/general functions/plotRawWaveforms.m @@ -0,0 +1,315 @@ +function [fig1, fig2] = plotRawWaveforms(obj, unitIDs, params) +% plotRawWaveforms - Plot raw spike waveforms from KS4 output, Phy-style +% Each unit is shown in its own tile at true probe positions. +% Optionally plots ACGs for all units in a single tiled figure. +% +% INPUTS: +% obj - Visual stimulation object +% unitIDs - scalar or vector of cluster IDs to plot e.g. 42 or [3 7 42] +% +% OPTIONAL NAME-VALUE PARAMS: +% nWaveforms - number of random waveforms to plot (default: 100) +% nChanAround - nearest channels around max amp channel (default: 10) +% nPre - samples before spike peak (default: 20) +% nPost - samples after spike peak (default: 61) +% showCorr - plot auto-correlogram figure (default: false) +% corrWin - correlogram half-window in ms (default: 100) +% corrBin - correlogram bin size in ms (default: 1) +% +% EXAMPLES: +% plotRawWaveforms(obj, 42) +% plotRawWaveforms(obj, [3 7 42], nWaveforms=200, nChanAround=6) +% plotRawWaveforms(obj, [3 7 42], showCorr=true, corrWin=50, corrBin=0.5) + +arguments (Input) + obj + unitIDs (1,:) double + params.nWaveforms (1,1) double = 100 + params.nChanAround (1,1) double = 8 + params.nPre (1,1) double = 20 + params.nPost (1,1) double = 61 + params.showCorr (1,1) logical = false + params.corrWin (1,1) double = 100 + params.corrBin (1,1) double = 1 +end + +nUnits = numel(unitIDs); + +%% Paths +ksDir = obj.spikeSortingFolder; +recordingDir = obj.dataObj.recordingDir; + +%% Settings from obj +n_channels = str2double(obj.dataObj.nSavedChansImec); +sample_rate = obj.dataObj.samplingFrequency; +uV_per_bit = unique(obj.dataObj.MicrovoltsPerAD); +chPos = obj.dataObj.chLayoutPositions; % [2 x nAllCh]: row1=x, row2=y + +fprintf('Settings — nCh: %d | Fs: %d Hz | uV/bit: %.4f\n', ... + n_channels, sample_rate, uV_per_bit); + +%% Find binary file +binFiles = dir(fullfile(recordingDir, '*.bin')); +if isempty(binFiles), binFiles = dir(fullfile(recordingDir, '*.dat')); end +if isempty(binFiles), error('No .bin or .dat file found in: %s', recordingDir); end +binPath = fullfile(recordingDir, binFiles(1).name); +fprintf('Using binary file: %s\n', binPath); + +%% Load KS4 output (once, shared across all units) +spike_times = readNPY(fullfile(ksDir, 'spike_times.npy')); +spike_clusters = readNPY(fullfile(ksDir, 'spike_clusters.npy')); +templates = readNPY(fullfile(ksDir, 'templates.npy')); % [nUnits x T x nCh] +chan_map = readNPY(fullfile(ksDir, 'channel_map.npy')); % [nCh x 1], 0-indexed +chan_pos = readNPY(fullfile(ksDir, 'channel_positions.npy')); % [nCh x 2] + +unit_ids_ks = (0 : size(templates, 1) - 1)'; + +%% Probe pitch (shared across all units) +x_unique = unique(chPos(1,:)); +y_unique = unique(chPos(2,:)); +x_spacing = min(diff(sort(x_unique))); +y_spacing = min(diff(sort(y_unique))); +if isempty(x_spacing) || numel(x_unique) == 1, x_spacing = 32; end +if isempty(y_spacing) || numel(y_unique) == 1, y_spacing = 20; end + +t_ms = (-params.nPre : params.nPost) / sample_rate * 1000; + +%% Colours +col_default = [0.25 0.45 0.75]; % blue +col_best = [0.85 0.20 0.15]; % red + +%% ---- Extract data for each unit ---- +finfo = dir(binPath); +n_samp_total = finfo.bytes / (n_channels * 2); +fid = fopen(binPath, 'rb'); + +unitData = struct(); % will hold per-unit results + +for ui = 1:nUnits + unitID = unitIDs(ui); + + % Template index + tmpl_idx = find(unit_ids_ks == unitID); + if isempty(tmpl_idx) + warning('Unit %d not found in templates.npy, skipping.', unitID); + unitData(ui).valid = false; + continue + end + + % Best channel by p2p on template + unit_template = squeeze(templates(tmpl_idx, :, :)); % [T x nCh] + p2p = max(unit_template) - min(unit_template); + [~, best_tmpl_chan] = max(p2p); + + % nChanAround nearest channels by Euclidean distance on probe + best_xy = chan_pos(best_tmpl_chan, :); + dists = sqrt(sum((chan_pos - best_xy).^2, 2)); + [~, sorted_idx] = sort(dists, 'ascend'); + chan_indices = sorted_idx(1 : min(params.nChanAround + 1, numel(dists)))'; + n_chans_plot = numel(chan_indices); + best_local_idx = find(chan_indices == best_tmpl_chan); + + bin_chans = chan_map(chan_indices) + 1; % 1-indexed + best_bin_chan = bin_chans(best_local_idx); + + % Spike times for this unit + st = double(spike_times(spike_clusters == unitID)); + if numel(st) < 2 + warning('Unit %d has fewer than 2 spikes, skipping.', unitID); + unitData(ui).valid = false; + continue + end + + % Random subsample + idx = randperm(numel(st), min(params.nWaveforms, numel(st))); + st_sub = st(idx); + fprintf('Unit %d: %d total spikes, extracting %d waveforms\n', ... + unitID, numel(st), numel(st_sub)); + + % Extract waveforms + waveform_len = params.nPre + params.nPost + 1; + waveforms = NaN(n_chans_plot, waveform_len, numel(st_sub)); + + for si = 1:numel(st_sub) + s0 = st_sub(si) - params.nPre; + s1 = st_sub(si) + params.nPost; + if s0 < 1 || s1 > n_samp_total, continue; end + fseek(fid, (s0 - 1) * n_channels * 2, 'bof'); + raw = fread(fid, [n_channels, waveform_len], '*int16'); + if size(raw, 2) < waveform_len, continue; end + waveforms(:, :, si) = double(raw(bin_chans, :)) * uV_per_bit; + end + + % Baseline subtract + baseline = mean(waveforms(:, 1:params.nPre, :), 2, 'omitnan'); + waveforms = waveforms - baseline; + + % Store + unitData(ui).valid = true; + unitData(ui).unitID = unitID; + unitData(ui).waveforms = waveforms; + % Exclude outlier waveforms based on peak-to-peak MAD + % Compute p2p amplitude for each waveform (max across channels and time) + wf_p2p = squeeze(max(max(waveforms,[],1),[],2) - ... + min(min(waveforms,[],1),[],2)); % [1 x nWaveforms] + wf_median = median(wf_p2p, 'omitnan'); + wf_mad = median(abs(wf_p2p - wf_median), 'omitnan'); + inlier_mask = abs(wf_p2p - wf_median) < 5 * wf_mad; % 5-MAD threshold + fprintf('Unit %d: %d/%d waveforms kept for envelope (outlier rejection)\n', ... + unitID, sum(inlier_mask), numel(inlier_mask)); + + unitData(ui).mean_wf = mean(waveforms(:,:,inlier_mask), 3, 'omitnan'); + unitData(ui).std_wf = std(waveforms(:,:,inlier_mask), 0, 3, 'omitnan'); + unitData(ui).bin_chans = bin_chans; + unitData(ui).best_bin_chan = best_bin_chan; + unitData(ui).best_local_idx= best_local_idx; + unitData(ui).n_chans_plot = n_chans_plot; + unitData(ui).ch_x = chPos(1, bin_chans); + unitData(ui).ch_y = chPos(2, bin_chans); + unitData(ui).st = st; + unitData(ui).n_wf = numel(st_sub); + + % ACG + if params.showCorr + [unitData(ui).ccg_counts, unitData(ui).ccg_bins] = ... + computeACG(st, sample_rate, params.corrWin, params.corrBin); + end +end +fclose(fid); + +%% ---- Waveform figure: one tile per unit ---- +% Determine tiled layout dimensions +nCols = min(nUnits, 4); +nRows = ceil(nUnits / nCols); + +fig1 = figure('Color', 'w', 'Name', 'Waveforms'); +wf_tl = tiledlayout(fig1, nRows, nCols, 'TileSpacing', 'compact', 'Padding', 'compact'); +title(wf_tl, 'Raw Waveforms', 'FontSize', 13, 'FontWeight', 'bold'); + +for ui = 1:nUnits + if ~unitData(ui).valid, continue; end + + d = unitData(ui); + mean_wf = d.mean_wf; + std_wf = d.std_wf; + ch_x = d.ch_x; + ch_y = d.ch_y; + bin_chans = d.bin_chans; + best_local_idx = d.best_local_idx; + n_chans_plot = d.n_chans_plot; + + % Per-unit amplitude scale: use mean±std envelope to prevent overlap + % on noisy units (large std compresses the scale automatically) + upper_env = max(mean_wf + std_wf, [], 2); % [nCh x 1] + lower_env = min(mean_wf - std_wf, [], 2); + max_extent = max(upper_env - lower_env); + if max_extent == 0, max_extent = 1; end + amp_scale = 0.8 * y_spacing / max_extent; + t_scale = 0.8 * x_spacing / (t_ms(end) - t_ms(1)); + + % Scale bar µV: round max amplitude to nearest 50 µV + sb_uv = max(50, round(max_extent / 50) * 50); + + ax = nexttile(wf_tl); + hold(ax, 'on'); + + for ci = 1:n_chans_plot + cx = ch_x(ci); + cy = ch_y(ci); + col = col_default; + if ci == best_local_idx, col = col_best; end + + x_wf = cx + t_ms * t_scale; + + % Individual waveforms + wf_ci = squeeze(d.waveforms(ci, :, :)); + plot(ax, x_wf, cy + wf_ci * amp_scale, ... + 'Color', [col, 0.12], 'LineWidth', 0.5); + + % Std shading + upper = cy + (mean_wf(ci,:) + std_wf(ci,:)) * amp_scale; + lower = cy + (mean_wf(ci,:) - std_wf(ci,:)) * amp_scale; + fill(ax, [x_wf, fliplr(x_wf)], [upper, fliplr(lower)], ... + col, 'FaceAlpha', 0.2, 'EdgeColor', 'none'); + + % Mean waveform (black), with coloured std shading + plot(ax, x_wf, cy + mean_wf(ci,:) * amp_scale, ... + 'Color', 'k', 'LineWidth', 2); + + % Channel label (two rows, left of waveform start) + text(ax, x_wf(1) - 2, cy, ... + sprintf('ch%d\n(%g,%g)', bin_chans(ci), cx, cy), ... + 'FontSize', 6, 'HorizontalAlignment', 'right', ... + 'VerticalAlignment', 'middle', 'Color', col); + end + + % L-scale bar: bottom-right channel of this unit + sb_ms = 1; % sb_uv already set above + sb_xlen = sb_ms * t_scale; + sb_ylen = sb_uv * amp_scale; + + [~, br_ci] = min(ch_y - ch_x * 1e-6); + sb_ox = ch_x(br_ci) + t_ms(end) * t_scale + 0.2 * x_spacing; + sb_oy = ch_y(br_ci); + + plot(ax, [sb_ox, sb_ox], [sb_oy, sb_oy - sb_ylen], 'k', 'LineWidth', 2); + plot(ax, [sb_ox, sb_ox + sb_xlen], [sb_oy, sb_oy], 'k', 'LineWidth', 2); + text(ax, sb_ox - 2, sb_oy - sb_ylen/2, sprintf('%d µV', sb_uv), ... + 'FontSize', 7, 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'middle', 'Rotation', 90); + text(ax, sb_ox + sb_xlen/2, sb_oy + 2, sprintf('%d ms', sb_ms), ... + 'FontSize', 7, 'HorizontalAlignment', 'center', 'VerticalAlignment', 'top'); + + title(ax, sprintf('Unit %d | ch%d | n=%d', ... + d.unitID, d.best_bin_chan, d.n_wf), 'FontSize', 9); + axis(ax, 'tight'); + axis(ax, 'off'); +end + +%% ---- ACG figure: one tile per unit ---- +if params.showCorr + fig2 = figure('Color', 'w', 'Name', 'ACGs'); + acg_tl = tiledlayout(fig2, nRows, nCols, 'TileSpacing', 'compact', 'Padding', 'compact'); + title(acg_tl, sprintf('ACG | RP 2 ms | bin %.1f ms | win ±%d ms', ... + params.corrBin, params.corrWin), 'FontSize', 12, 'FontWeight', 'bold'); + xlabel(acg_tl, 'Lag (ms)'); + ylabel(acg_tl, 'Spike count'); + + for ui = 1:nUnits + if ~unitData(ui).valid, continue; end + d = unitData(ui); + + ax_c = nexttile(acg_tl); + bar(ax_c, d.ccg_bins, d.ccg_counts, 1, ... + 'FaceColor', [0.3 0.5 0.8], 'EdgeColor', 'none'); + hold(ax_c, 'on'); + xline(ax_c, 0, '--k', 'Alpha', 0.4); + + ylims = ylim(ax_c); + patch(ax_c, [-2 2 2 -2], [0 0 ylims(2) ylims(2)], ... + 'r', 'FaceAlpha', 0.1, 'EdgeColor', 'none'); + + xlim(ax_c, [-params.corrWin params.corrWin]); + title(ax_c, sprintf('Unit %d', d.unitID), 'FontSize', 9); + box(ax_c, 'off'); + end +else + fig2 = []; +end + +end % main function + + +%% ========================================================================= +function [counts, bin_centers] = computeACG(spike_times_samples, fs, win_ms, bin_ms) +st_ms = spike_times_samples / fs * 1000; +edges = -win_ms : bin_ms : win_ms; +bin_centers = edges(1:end-1) + bin_ms / 2; +counts = zeros(1, numel(bin_centers)); +for i = 1:numel(st_ms) + diffs = st_ms - st_ms(i); + diffs(i) = NaN; + diffs = diffs(diffs > -win_ms & diffs < win_ms); + counts = counts + histcounts(diffs, edges); +end +end \ No newline at end of file diff --git a/general functions/plotSwarmBootstrapWithComparisons.asv b/general functions/plotSwarmBootstrapWithComparisons.asv new file mode 100644 index 0000000..7ff4c80 --- /dev/null +++ b/general functions/plotSwarmBootstrapWithComparisons.asv @@ -0,0 +1,1205 @@ +function [fig, randiColors, figAllDiffs] = plotSwarmBootstrapWithComparisons(tbl, pairs, pValues, valueField, params) +% PLOTSWARMBOOTSTRAPWITHCOMPARISONS Per-stimulus swarm plot with hierarchical +% bootstrap central tendency, uncertainty bar, and pairwise significance brackets. +% +% [fig, randiColors, figAllDiffs] = plotSwarmBootstrapWithComparisons(tbl, ... +% pairs, pValues, valueField, params) +% +% tbl - One row per observation. Required columns: stimulus, animal, +% insertion (all categorical). Optional: NeurID (numeric, used +% in diff mode for neuron-level data), zScore (numeric). +% Two granularities are auto-detected: +% * neuron-level : multiple rows per (insertion,stimulus) +% * insertion-level: one row per (insertion,stimulus) +% pairs - Nx2 cell array of stimulus name pairs to compare/test. +% pValues - N-element vector of pre-computed p-values for those pairs. +% valueField - 1-cell {field} for raw value, 2-cell {num,den} for ratio. +% +% Selected params (see arguments block for full list): +% nBoot - bootstrap replicates (default 10000) +% fraction - true => valueField{1} ./ valueField{2} +% diff - plot per-neuron stimA-stimB difference instead of raw +% showBothAndDiff- two-tile layout: raw on left, difference on right +% ciMethod - 'sem' or 'percentile' (95% bootstrap CI, default) +% bootGroupVars - cell of column names defining the bootstrap hierarchy. +% Default auto-fills from {'animal','insertion'} based on +% what exists in the table. Pass {} explicitly to force a +% flat bootstrap. +% rngSeed - bootstrap RNG seed for reproducibility (default 0) +% +% Returns the figure handle, random dot-draw permutation, and (when >1 pair) +% a second figure showing every pairwise difference in separate tiles. +% +% Bootstrap details: uses hierBoot (Saravanan et al. 2020) when grouping +% levels are present, resampling each level with replacement in turn. + +% ------------------------------------------------------------------------- +% Argument validation block +% ------------------------------------------------------------------------- +arguments + tbl table % observation table + pairs cell = {} % stim pairs to test + pValues double = [] % p-value per pair + valueField cell = {} % field name(s) of value column + params.nBoot (1,1) double = 10000 % bootstrap replicates + params.fraction logical = false % ratio mode (num/den) + params.yLegend char = 'value' % y-axis label + params.diff logical = false % plot per-pair difference only + params.Xjitter = 'density' % swarm jitter scheme + params.dotSize = 5 % marker size (pts^2) + params.yMaxVis = 1 % visible y-axis cap + params.filled logical = true % filled vs open markers + params.Alpha = 0.2 % marker face/edge alpha + params.plotMeanSem logical = true % overlay mean +/- uncertainty + params.colorByZScore logical = false % color dots by zScore (else by animal) + params.showBothAndDiff logical = true % two-tile raw + diff layout + params.drawLines logical = false % connect paired observations + params.rngSeed (1,1) double = 0 % bootstrap reproducibility + params.ciMethod char = 'percentile'% 'sem' | 'percentile' + params.bootGroupVars cell = {'__auto__'}% hierarchical bootstrap levels + params.markUnits cell = {} % N×4 {NeurID,stimulus,animal,insertion}; row1->X, row2->+ + params.nearSigThresh (1,1) double = 0.1 % print exact p when 0.05 <= p < this; nothing above it +end + +% ------------------------------------------------------------------------- +% Up-front input validation +% ------------------------------------------------------------------------- + +% Either raw mode (1 field) or fraction mode (2 fields) +if params.fraction + assert(numel(valueField) == 2, 'Fraction mode requires two valueField entries.'); +else + assert(~isempty(valueField), 'valueField must contain at least one column name.'); +end + +% colorByZScore requires the column to exist; downgrade with warning if absent +if params.colorByZScore && ~ismember('zScore', tbl.Properties.VariableNames) + warning('colorByZScore=true but tbl has no zScore column; falling back to animal coloring.'); + params.colorByZScore = false; +end + +% showBothAndDiff places diff in its own tile; it overrides params.diff +if params.showBothAndDiff && params.diff + warning('showBothAndDiff=true overrides params.diff; diff appears in the right tile only.'); + params.diff = false; +end + +% Seed RNG once so bootstraps and dot-draw orders are deterministic +rng(params.rngSeed); + +% ------------------------------------------------------------------------- +% Resolve bootstrap grouping variables +% ------------------------------------------------------------------------- +if isscalar(params.bootGroupVars) && strcmp(params.bootGroupVars{1}, '__auto__') + cands = {'animal','insertion'}; % candidate hierarchy columns + params.bootGroupVars = cands(ismember(cands, tbl.Properties.VariableNames)); +else + missing = ~ismember(params.bootGroupVars, tbl.Properties.VariableNames); + assert(~any(missing), 'bootGroupVars contains missing columns: %s', ... + strjoin(params.bootGroupVars(missing), ', ')); +end + +% ------------------------------------------------------------------------- +% Detect data granularity +% ------------------------------------------------------------------------- +% If every (insertion, stimulus) pair appears at most once, the table is +% insertion-level; otherwise neuron-level. +isInsertionLevel = height(tbl) == height(unique(tbl(:, {'insertion','stimulus'}))); + +% ------------------------------------------------------------------------- +% Padding / spacing constants derived from the y-axis cap +% ------------------------------------------------------------------------- +yMaxVis = params.yMaxVis; +bracketPad = yMaxVis * 0.05; % gap between data and first bracket +stackPad = yMaxVis * 0.05; % vertical stacking between brackets +textPad = yMaxVis * 0.01; % gap between bracket and star text +semAlpha = 0.6; % alpha for bootstrap error bars + +% ------------------------------------------------------------------------- +% Pre-process tbl: rename legacy labels, reorder categories, compute value +% ------------------------------------------------------------------------- +tbl = renameStimulusLabels(tbl); % RG->SB, SDGs->SG, SDGm->MG +pairs = renamePairLabels(pairs); % apply same rename to pair labels +tbl = reorderStimulusByLevel(tbl); % sort categories by trailing number + +% Normalise the marker specs and apply the SAME label renaming as the table, +% so a user-supplied 'RG' lands in the renamed 'SB' column. +params.markUnitsResolved = resolveMarkUnits(params.markUnits); + +if params.fraction + tbl.value = tbl.(valueField{1}) ./ tbl.(valueField{2}); % element-wise ratio +else + tbl.value = tbl.(valueField{1}); % raw value +end + +% Drop unused categorical levels so colormaps and counts are accurate +tbl.stimulus = removecats(tbl.stimulus); +tbl.animal = removecats(tbl.animal); +tbl.insertion = removecats(tbl.insertion); + +% ------------------------------------------------------------------------- +% Build figure: single axes or 1x2 tiledlayout +% ------------------------------------------------------------------------- +fig = figure; +set(fig, 'Color', 'w'); % white background for publication + +if params.showBothAndDiff + % Left tile: every stimulus shown raw; right tile: most-significant diff + tl = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact'); + axRaw = nexttile(tl, 1); + axDiff = nexttile(tl, 2); + + randiColors = plotRawSwarm(axRaw, tbl, pairs, pValues, params, ... + yMaxVis, bracketPad, stackPad, textPad, semAlpha, isInsertionLevel); + + % Pick the most significant pair for the diff tile + if ~isempty(pValues) + [~, sigIdx] = min(pValues); + else + sigIdx = 1; + end + pairForDiff = pairs(sigIdx, :); + pValForDiff = pValues(sigIdx); + + tblDiff = buildDiffTable(tbl, pairForDiff, params, isInsertionLevel); + plotDiffSwarm(axDiff, tblDiff, pairForDiff, pValForDiff, params, ... + yMaxVis, bracketPad, textPad); +else + % Single-axes mode: either the raw swarm or the difference + ax = axes(fig); + hold(ax, 'on'); + set(ax, 'Clipping', 'off'); + + if params.diff + tblDiff = buildDiffTable(tbl, pairs, params, isInsertionLevel); + randiColors = plotDiffSwarm(ax, tblDiff, pairs, pValues, params, ... + yMaxVis, bracketPad, textPad); + else + randiColors = plotRawSwarm(ax, tbl, pairs, pValues, params, ... + yMaxVis, bracketPad, stackPad, textPad, semAlpha, isInsertionLevel); + end +end + +% ------------------------------------------------------------------------- +% Additional figure: one tile per pairwise difference (only when >1 pair) +% ------------------------------------------------------------------------- +if size(pairs, 1) > 1 + figAllDiffs = plotAllPairDiffs(tbl, pairs, pValues, params, ... + isInsertionLevel, yMaxVis, bracketPad, textPad); +else + figAllDiffs = []; +end + +end % main function + + + + +% ========================================================================= +% LOCAL FUNCTION: plotRawSwarm +% ========================================================================= +function randiColors = plotRawSwarm(ax, tbl, pairs, pValues, params, ... + yMaxVis, bracketPad, stackPad, textPad, semAlpha, isInsertionLevel) + +hold(ax, 'on'); +set(ax, 'Clipping', 'off'); % brackets may sit above yMaxVis + +stimuli = categories(tbl.stimulus); % ordered category list +tblPlot = tbl; + +% Random permutation for dot draw order (seeded in main) +randiColors = randperm(height(tblPlot)); + +% Choose dot color source +if params.colorByZScore + colorData = tblPlot.zScore(randiColors); +else + colorData = tblPlot.animal(randiColors); +end + +% Draw swarm +if params.filled + s = swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... + params.dotSize, colorData, 'filled', ... + 'MarkerFaceAlpha', params.Alpha); +else + s = swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... + params.dotSize, colorData, ... + 'MarkerEdgeAlpha', params.Alpha, 'LineWidth', 1, 'SizeData', 30); +end +s.XJitter = params.Xjitter; + +% Build short tick labels from encoded category names +Str = string(stimuli); +out = buildTickLabels(Str); + +% Apply custom tick labels only if categories contain embedded numbers +numStr = regexp(Str, '-?\d+\.?\d*', 'match', 'once'); +hold(ax, 'on'); +if any(~cellfun(@isempty, numStr)) + xticklabels(ax, out); +end + +% Configure colormap +if params.colorByZScore + colormap(ax, buildRdBuColormap(256)); + maxZ = max(abs(tblPlot.zScore), [], 'omitnan'); + if isempty(maxZ) || maxZ == 0, maxZ = 1; end + clim(ax, [-maxZ maxZ]); + cb = colorbar(ax); + cb.Label.String = 'Z-score'; +else + colormap(ax, lines(numel(categories(tblPlot.animal)))); +end + +% ------------------------------------------------------------------------- +% Optional connecting lines between paired observations +% ------------------------------------------------------------------------- +if params.drawLines && numel(stimuli) <= 2 + if isInsertionLevel + unitIDvar = 'insertion'; % insertion IS the unit + elseif ismember('NeurID', tblPlot.Properties.VariableNames) + unitIDvar = 'NeurID'; % neuron is the unit + else + unitIDvar = ''; + warning(['drawLines=true on neuron-level data without NeurID; ', ... + 'skipping connecting lines.']); + end + + if ~isempty(unitIDvar) + cats = categories(tblPlot.stimulus); + xMap = containers.Map(cats, 1:numel(cats)); % stimulus -> x-position + xNum = arrayfun(@(i) xMap(char(tblPlot.stimulus(i))), 1:height(tblPlot)); + + unitIDs = unique(tblPlot.(unitIDvar)); + for u = 1:numel(unitIDs) + idx = tblPlot.(unitIDvar) == unitIDs(u); + if nnz(idx) < 2, continue; end + line(ax, xNum(idx), tblPlot.value(idx), ... + 'Color', [0 0 0 0.1], 'LineWidth', 0.1); + end + end +end + +ylabel(ax, params.yLegend); +ax.Box = 'off'; +ax.Layer = 'top'; + +% Hierarchical bootstrap mean +/- SE (or 95% CI) +if params.plotMeanSem + plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha); +end + +% Significance brackets. When >5 pairs, only bracket adjacent groups to +% avoid visual clutter; the full set is in figAllDiffs. +if ~isempty(pairs) && numel(pValues) == size(pairs, 1) + adjacentOnly = size(pairs, 1) > 6; + plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... + yMaxVis, bracketPad, stackPad, textPad, adjacentOnly,params.nearSigThresh); +end + +% Overlay user-specified example-unit markers (X for spec 1, + for spec 2) +overlayMarkedUnits(ax, tblPlot, params, true); % true: also match on stimulus column + +% Cap visible y-range (brackets use Clipping=off so they remain visible) +ylim(ax, [ax.YLim(1) yMaxVis]); + +end % plotRawSwarm + + +% ========================================================================= +% LOCAL FUNCTION: plotDiffSwarm +% Draws a swarm plot of pairwise differences for one condition pair, +% overlays a bootstrap mean ± SE bar, a zero reference line, and +% (if significant) a star annotation. +% ========================================================================= +function randiColors = plotDiffSwarm(ax, tblDiff, pairs, pValues, params, ... + yMaxVis, bracketPad, textPad) + +% Hold axes so subsequent drawing commands add to the same panel +hold(ax, 'on'); + +% Allow markers and text to render outside the strict axes box; +% required so significance stars above yMaxVis remain visible +set(ax, 'Clipping', 'off'); + +% ---- Guard: empty diff table -------------------------------------------- +% This should already be caught by the caller, but defend here too +if height(tblDiff) == 0 + randiColors = []; + text(ax, 0.5, 0.5, 'No paired data', ... + 'Units', 'normalized', ... + 'HorizontalAlignment','center', ... + 'FontSize', 8, ... + 'Color', [0.6 0.6 0.6]); + return +end + +% ---- Reproducible draw order -------------------------------------------- +% Fix the RNG seed immediately before randperm so that the colour-to-unit +% mapping is identical on every re-render (required for publication). +% Use a deterministic seed derived from the pair names so different panels +% get different (but stable) permutations. +rng(sum(double(char(strjoin({pairs{1,1}, pairs{1,2}}, ''))))); +randiColors = randperm(height(tblDiff)); % shuffled row index for plotting order + +% ---- Colour source ------------------------------------------------------ +% Choose per-dot colour based on z-score (diverging) or animal identity +if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) + % Numeric z-score maps onto a diverging colormap set below + colorData = tblDiff.zScore(randiColors); +else + % Animal identity is categorical; convert to numeric so swarmchart + % accepts it as a valid colour index vector + colorData = double(tblDiff.animal(randiColors)); +end + +% ---- Swarm chart --------------------------------------------------------- +if params.filled + % Filled circles; face transparency controlled by params.Alpha + s = swarmchart(ax, ... + tblDiff.stimulus(randiColors), ... % x: condition label (categorical) + tblDiff.value(randiColors), ... % y: difference value + params.dotSize, ... % marker area (pt²) + colorData, ... % colour data (numeric) + 'filled', ... + 'MarkerFaceAlpha', params.Alpha); +else + % Open circles; use params.dotSize for size consistency with filled branch + s = swarmchart(ax, ... + tblDiff.stimulus(randiColors), ... + tblDiff.value(randiColors), ... + params.dotSize, ... % fixed: was hardcoded 30, ignoring params + colorData, ... + 'MarkerEdgeAlpha', params.Alpha, ... + 'LineWidth', 1); +end + +% Horizontal jitter style for the swarm (e.g. 'rand', 'none') +s.XJitter = params.Xjitter; + +% ---- X tick label -------------------------------------------------------- +% Combine the two condition names into a single readable label, +% e.g. "MB 1.57 - MG 90" +pairLabels = buildTickLabels(string({pairs{1,1}, pairs{1,2}})); +xticklabels(ax, join(pairLabels, " - ")); + +% ---- Colormap & colour bar ----------------------------------------------- +if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) + % Symmetric diverging colormap centred on zero + colormap(ax, buildRdBuColormap(256)); + maxZ = max(abs(tblDiff.zScore), [], 'omitnan'); % symmetric colour limit + if isempty(maxZ) || maxZ == 0, maxZ = 1; end % avoid degenerate clim + clim(ax, [-maxZ maxZ]); + cb = colorbar(ax); + cb.Label.String = 'Z-score'; +else + % One colour per unique animal; categories() requires a categorical column + colormap(ax, lines(numel(categories(tblDiff.animal)))); +end + +% ---- Reference line at zero --------------------------------------------- +% Makes it immediately clear which differences are positive vs negative +yline(ax, 0, 'LineWidth', 1, 'Alpha', 0.7); + +% Y-axis label (e.g. 'Δ firing rate (spk/s)') +ylabel(ax, params.yLegend); + +% Clean axes appearance for publication +ax.Box = 'off'; +ax.Layer = 'top'; % draw axes on top of data so tick marks are not obscured + +% ---- Bootstrap mean ± SE bars ------------------------------------------- +if params.plotMeanSem + stimuli = categories(tblDiff.stimulus); % ordered condition list + plotMeanSemBars(ax, tblDiff, stimuli, params, 0.6); +end + +% ---- Significance annotation -------------------------------------------- +% Capture the current auto-scaled Y limits AFTER all data have been drawn +% so the lower bound reflects the true data range +ylims = ylim(ax); + +if ~isempty(pValues) && numel(pValues) >= 1 + pVal = pValues(1); % scalar p-value for this difference panel + + % Compute the highest data point that falls within the visible window. + % This anchors the star text just above the top of the visible data, + % not above data that has been clipped by yMaxVis. + % Equivalent to min(max(vals), yMaxVis) but written for clarity. + vals = tblDiff.value; + maxVisible = min(max(vals(:), [], 'omitnan'), yMaxVis); % fixed: was max(min(...)) with no 'omitnan' + + % Fallback if vals was empty or all-NaN (isempty catches []; isnan catches NaN) + if isempty(maxVisible) || isnan(maxVisible) + maxVisible = yMaxVis; + end + + % Text sits bracketPad above the highest visible data point + yText = maxVisible + bracketPad; + + % Significant -> stars; near-significant (0.05 <= p < nearSigThresh) -> exact + % p; clearly non-significant or NaN -> nothing. One text call, one placement. + txt = ''; % default: draw nothing + if ~isnan(pVal) + if pVal < 0.001, txt = '***'; + elseif pVal < 0.01, txt = '**'; + elseif pVal < 0.05, txt = '*'; + elseif pVal < params.nearSigThresh, txt = sprintf('%.3f', pVal); % trend + end + end + if ~isempty(txt) + text(ax, 1, yText, txt, ... + 'HorizontalAlignment', 'center', ... + 'FontSize', 7, ... + 'Clipping', 'off'); + end +end + +% Overlay example-unit markers on the difference dots (identity match only) +overlayMarkedUnits(ax, tblDiff, params, false); % false: stimulus is irrelevant in a diff + +ylim(ax, [ylims(1), yMaxVis]); +% Fix the upper Y limit to yMaxVis so all panels in plotAllPairDiffs +% start from the same ceiling before linkaxes applies the shared lower bound. +% The lower bound is left as auto-scaled so it reflects each panel's data range; +% plotAllPairDiffs will then extend it to the global minimum via linkaxes. +ylim(ax, [ylims(1), yMaxVis]); + +end % plotDiffSwarm + + +% ========================================================================= +% LOCAL FUNCTION: buildDiffTable +% Per-unit (stimA - stimB) within each insertion. +% Pairing strategy depends on data granularity: +% insertion-level: direct subtraction (one row per insertion) +% neuron-level + NeurID: match by NeurID (intersect) +% neuron-level without NeurID: row-order fallback with warning +% ========================================================================= +function tblDiff = buildDiffTable(tbl, pairs, params, isInsertionLevel) + +assert(~isempty(pairs) && size(pairs, 1) >= 1, ... + 'diff mode requires at least one stimulus pair.'); + +stimA = strtrim(pairs{1,1}); % trim whitespace for safety +stimB = strtrim(pairs{1,2}); + +hasNeurID = ismember('NeurID', tbl.Properties.VariableNames); + +if ~hasNeurID && ~isInsertionLevel + warning(['buildDiffTable: NeurID column absent for neuron-level data. ', ... + 'Pairing by row order — fragile if rows are reordered.']); +end + +ins = categories(tbl.insertion); % unique insertion labels +diffVals = []; % accumulator: paired differences +animals = categorical.empty(0, 1); % accumulator: animal per diff row +insers = categorical.empty(0, 1); % accumulator: insertion per diff row +zScores = []; % accumulator: zScore (if colorByZScore) +neurIDs = []; % accumulator: NeurID per diff row (NaN where unavailable) +hasRealIns = ismember('realInsertion', tbl.Properties.VariableNames); +realIns = categorical.empty(0, 1); % accumulator: real insertion per diff row +useZ = params.colorByZScore && ismember('zScore', tbl.Properties.VariableNames); + +for i = 1:numel(ins) + idxA = tbl.insertion == ins{i} & tbl.stimulus == stimA; + idxB = tbl.insertion == ins{i} & tbl.stimulus == stimB; + + % Skip insertions where either stimulus is absent + if ~any(idxA) || ~any(idxB) + continue + end + + if isInsertionLevel + % One row per side; direct subtraction + vA = tbl.value(idxA); + vB = tbl.value(idxB); + an = tbl.animal(idxA); + insCat = tbl.insertion(idxA); % preserve original categorical + nidPiece = NaN; % insertion-level: no neuron identity + if useZ + zPair = (tbl.zScore(idxA) + tbl.zScore(idxB)) / 2; + end + + elseif hasNeurID + % Neuron-level with explicit IDs: safest matching via intersect + tA = tbl(idxA, :); + tB = tbl(idxB, :); + [~, iA, iB] = intersect(tA.NeurID, tB.NeurID, 'stable'); + if isempty(iA) + continue + end + + vA = tA.value(iA); + vB = tB.value(iB); + an = tA.animal(iA); + insCat = tA.insertion(iA); + nidPiece = tA.NeurID(iA); % keep the IDs of the matched neurons + if useZ + zPair = (tA.zScore(iA) + tB.zScore(iB)) / 2; + end + + else + % Row-order fallback (fragile) + vA = tbl.value(idxA); + vB = tbl.value(idxB); + if numel(vA) ~= numel(vB) + warning('Insertion %s: %d stimA rows vs %d stimB rows; skipping.', ... + ins{i}, numel(vA), numel(vB)); + continue + end + an = repmat(tbl.animal(find(idxA, 1)), numel(vA), 1); + insCat = repmat(tbl.insertion(find(idxA, 1)), numel(vA), 1); + nidPiece = NaN(numel(vA), 1); % row-order fallback: IDs not reliable + if useZ + zPair = (tbl.zScore(idxA) + tbl.zScore(idxB)) / 2; + end + end + + diffVals = [diffVals; vA - vB]; %#ok + animals = [animals; an]; %#ok + insers = [insers; insCat]; %#ok + neurIDs = [neurIDs; nidPiece(:)]; + if hasRealIns + riThis = tbl.realInsertion(find(idxA, 1)); % constant within this insertion + realIns = [realIns; repmat(riThis, numel(vA), 1)]; %#ok + end + if useZ + zScores = [zScores; zPair]; %#ok + end +end + +% Drop NaN differences (e.g. from zero-denominator fractions) +valid = ~isnan(diffVals); +stimName = sprintf('%s-%s', stimA, stimB); + +tblDiff = table(); +tblDiff.insertion = insers(valid); % categorical insertion labels +tblDiff.stimulus = categorical(repmat({stimName}, sum(valid), 1)); +tblDiff.animal = animals(valid); +tblDiff.value = diffVals(valid); +tblDiff.NeurID = neurIDs(valid); +if hasRealIns + tblDiff.realInsertion = realIns(valid); +end + +if useZ + tblDiff.zScore = zScores(valid); +end + +end % buildDiffTable + + +% ========================================================================= +% LOCAL FUNCTION: plotMeanSemBars +% Hierarchical-bootstrap central tendency and uncertainty per stimulus. +% Uses hierBoot (Saravanan et al. 2020) for hierarchical resampling. +% ========================================================================= +function plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha) + +for i = 1:numel(stimuli) + idx = tblPlot.stimulus == stimuli{i}; + if ~any(idx), continue; end + + % Pull values and drop NaNs + vals = tblPlot.value(idx); + keep = ~isnan(vals); + vals = vals(keep); + + n = numel(vals); + if n < 3 + fprintf('Stimulus %s: n=%d < 3; skipping mean/SE bar.\n', char(stimuli{i}), n); + continue + end + + % Pull each grouping column aligned with NaN drop + groupVars = params.bootGroupVars; + groupVals = cell(1, numel(groupVars)); + for g = 1:numel(groupVars) + col = tblPlot.(groupVars{g})(idx); + col = col(keep); + if iscategorical(col) + col = double(col); % hierBoot requires numeric + end + groupVals{g} = col; + end + + % Make the level-2 (insertion) key unique *within* animal. Insertion + % numbering is reset per animal, so double(insertion) gives the same index + % to "insertion 1" of every animal; findgroups over (animal, insertion) + % yields one ID per distinct pair, so two animals' same-labelled insertions + % can never be merged when the bootstrap builds its animal->insertion + % nesting. No-op if the resampler already enumerates insertions within each + % animal; a silent-bug fix if it keys insertion globally. + if numel(groupVars) == 2 && all(strcmp(groupVars(:)', {'animal','insertion'})) + groupVals{2} = findgroups(groupVals{1}, groupVals{2}); % composite within-animal insertion ID + end + + + % Hierarchical (animal -> insertion) or flat bootstrap of the mean. + % hierBootMatchFreq is hard-coded for exactly two grouping levels and + % expects them coarsest-first (lvl1 = animal, lvl2 = insertion). The + % default bootGroupVars ordering {'animal','insertion'} satisfies this. + if isempty(groupVars) + bootMean = bootstrp(params.nBoot, @mean, vals); % no hierarchy: ordinary bootstrap + else + assert(numel(groupVars) == 2, ... % enforce the 2-level contract + ['plotMeanSemBars: hierBootMatchFreq needs exactly two grouping ', ... + 'levels (animal, insertion); got %d. Adjust bootGroupVars, or ', ... + 'pass {} for a flat bootstrap.'], numel(groupVars)); + bootMean = hierBootMatchFreq(vals, params.nBoot, ... % lvl1 = animal (coarsest) + groupVals{1}, groupVals{2}); % lvl2 = insertion + end + + % Point estimate: mean of the bootstrap distribution + % (weights animals/insertions equally, matching the mixed model) + mu = mean(bootMean); + + % Uncertainty bar + switch lower(params.ciMethod) + case 'sem' + se = std(bootMean); + yLo = mu - se; + yHi = mu + se; + case 'percentile' + yLo = prctile(bootMean, 2.5); + yHi = prctile(bootMean, 97.5); + otherwise + error('Unknown ciMethod: %s. Use ''sem'' or ''percentile''.', params.ciMethod); + end + + % Vertical uncertainty line + line(ax, [i i], [yLo yHi], ... + 'Color', [0 0 0 semAlpha], 'LineWidth', 2); + % End caps + capW = 0.1; + line(ax, [i-capW i+capW], [yHi yHi], ... + 'Color', [0 0 0 semAlpha], 'LineWidth', 2); + line(ax, [i-capW i+capW], [yLo yLo], ... + 'Color', [0 0 0 semAlpha], 'LineWidth', 2); + % Mean line + dx = 0.15; + plot(ax, [i-dx i+dx], [mu mu], 'k-', 'LineWidth', 1.2); +end + +end % plotMeanSemBars + + +% ========================================================================= +% LOCAL FUNCTION: plotBrackets +% Pairwise significance brackets. When adjacentOnly is true, only brackets +% between groups at positions i and i+1 are drawn (prevents visual clutter +% with many comparisons). All pairs are always reported to plotAllPairDiffs. +% ========================================================================= +function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... + yMaxVis, bracketPad, stackPad, textPad, adjacentOnly,nearSigThresh) + +% Track y-positions of placed brackets to prevent overlap +usedHeights = zeros(size(pairs, 1), 1); + +for k = 1:size(pairs, 1) + + % Skip NaN and clearly non-significant pairs. Pairs with + % 0.05 <= p < nearSigThresh are kept and get the exact p above the bracket. + if isnan(pValues(k)) || pValues(k) >= nearSigThresh + continue + end + + % Find x-positions for both stimuli in this pair + x1 = find(strcmp(stimuli, pairs{k,1})); + x2 = find(strcmp(stimuli, pairs{k,2})); + if isempty(x1) || isempty(x2), continue; end + + % --- ADJACENT-ONLY FILTER --- + % When adjacentOnly is true, skip any pair where the two groups are + % not next to each other on the x-axis. This prevents 22+ overlapping + % brackets when there are many significant comparisons. The full set + % of pairwise differences is shown in the separate figAllDiffs figure. + if adjacentOnly && abs(x1 - x2) > 1 + continue + end + + % Cap individual values at yMaxVis so the bracket sits at the visible edge + vals1 = tblPlot.value(tblPlot.stimulus == pairs{k,1}); + vals2 = tblPlot.value(tblPlot.stimulus == pairs{k,2}); + maxVisible = max(min([vals1; vals2], yMaxVis)); + yBase = maxVisible + bracketPad; + + % Vertical stacking: nudge up if a previous bracket is too close + y = yBase; + while any(abs(usedHeights(1:k-1) - y) < stackPad) + y = y + stackPad; + end + usedHeights(k) = y; + + % Horizontal bracket + two short vertical ticks + line(ax, [x1 x2], [y y], 'Color', 'k', 'LineWidth', 1.2, 'Clipping', 'off'); + line(ax, [x1 x1], [y - yMaxVis*0.01, y], 'Color', 'k', 'LineWidth', 1.2, 'Clipping', 'off'); + line(ax, [x2 x2], [y - yMaxVis*0.01, y], 'Color', 'k', 'LineWidth', 1.2, 'Clipping', 'off'); + + % Significant -> stars; near-significant -> exact p above the bracket. + if pValues(k) < 0.001, txt = '***'; + elseif pValues(k) < 0.01, txt = '**'; + elseif pValues(k) < 0.05, txt = '*'; + else, txt = sprintf('%.3f', pValues(k)); % 0.05 <= p < nearSigThresh + end + text(ax, mean([x1 x2]), y + textPad, txt, ... + 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); +end + +end % plotBrackets + + +% ========================================================================= +% LOCAL FUNCTION: plotAllPairDiffs +% Stand-alone figure with one tile per pairwise difference. +% ========================================================================= +function figAll = plotAllPairDiffs(tbl, pairs, pValues, params, ... + isInsertionLevel, yMaxVis, bracketPad, textPad) + +% Total number of pairwise comparisons to tile +nPairs = size(pairs, 1); + +% Guard: nothing to plot +if nPairs == 0 + figAll = figure; + return +end + +% Create figure with white background for publication +figAll = figure; +set(figAll, 'Color', 'w'); + +% Determine grid dimensions: cap columns at 7 to keep tiles legible; +% add as many rows as needed to accommodate all pairs +nCols = min(nPairs, 7); +nRows = ceil(nPairs / nCols); + +% Build the tiled layout with tight spacing for a compact multi-panel figure +tl = tiledlayout(figAll, nRows, nCols, ... + 'TileSpacing', 'compact', 'Padding', 'compact'); + +% Overall figure title +title(tl, 'All pairwise differences'); + +% Pre-allocate axes handle array so we can link Y axes after the loop. +% Initialise with gobjects so empty-data tiles can be excluded cleanly. +axHandles = gobjects(nPairs, 1); + +for k = 1:nPairs + + % Open the next tile in reading order + ax = nexttile(tl); + + % Extract the condition pair (1×2 cell) and its omnibus p-value for tile k + pairK = pairs(k, :); + pValK = pValues(k); + + % Build the difference table for this pair (one row per unit/insertion) + tblDiff = buildDiffTable(tbl, pairK, params, isInsertionLevel); + + % --- Empty-data path --------------------------------------------------- + if height(tblDiff) == 0 + % Suppress all axes decorations so the blank tile is invisible in print + axis(ax, 'off'); + + % Build a human-readable label from the two condition names + pairLabels = buildTickLabels(string({pairK{1}, pairK{2}})); + + % Place a low-contrast annotation so the missing pair is still legible + % when inspecting the figure interactively + text(ax, 0.5, 0.5, join(pairLabels, " - ") + " (no data)", ... + 'Units', 'normalized', ... + 'HorizontalAlignment','center', ... + 'FontSize', 6, ... + 'Color', [0.6 0.6 0.6]); + + % Do NOT store this handle — we exclude empty tiles from Y-axis linking + continue + end + % ----------------------------------------------------------------------- + + % Draw the swarm + significance bracket into ax + plotDiffSwarm(ax, tblDiff, pairK, pValK, params, ... + yMaxVis, bracketPad, textPad); + + % Record the handle for this successfully plotted tile + axHandles(k) = ax; + +end % for k + +% --- Shared Y axis --------------------------------------------------------- +% Retain only the axes handles that were actually plotted (non-null gobjects) +validAx = axHandles(isvalid(axHandles)); + +if numel(validAx) > 1 + % Couple all valid tile axes so interactive zoom/pan stays synchronised + linkaxes(validAx, 'y'); + + % Compute the union of all individual Y ranges so no data are clipped. + % We do this explicitly rather than relying on linkaxes auto-scaling, + % because plotDiffSwarm may have expanded ylim for brackets/text and + % we want the *tightest* common range that honours those expansions. + allYLims = cell2mat(arrayfun(@(a) ylim(a), validAx, ... + 'UniformOutput', false)); % numel(validAx) × 2 matrix + sharedYLim = [min(allYLims(:,1)), max(allYLims(:,2))]; + + % Apply the shared limits; linkaxes propagates this to every coupled axis + ylim(validAx(1), sharedYLim); +end +% --------------------------------------------------------------------------- + +end % plotAllPairDiffs + + +% ========================================================================= +% LOCAL FUNCTION: renameStimulusLabels +% ========================================================================= +function tbl = renameStimulusLabels(tbl) +s = string(tbl.stimulus); +s = replace(s, "RG", "SB"); +s = replace(s, "SDGs", "SG"); +s = replace(s, "SDGm", "MG"); +tbl.stimulus = categorical(s); +end + + +% ========================================================================= +% LOCAL FUNCTION: renamePairLabels +% ========================================================================= +function pairs = renamePairLabels(pairs) +if isempty(pairs), return; end +for i = 1:numel(pairs) + p = string(pairs{i}); + p = replace(p, "RG", "SB"); + p = replace(p, "SDGs", "SG"); + p = replace(p, "SDGm", "MG"); + pairs{i} = char(p); +end +end + + +% ========================================================================= +% LOCAL FUNCTION: buildTickLabels +% Decode category names into short human-readable tick labels. +% e.g. 'MB_dir_1p57' -> 'MB 1.57', 'MG_ang_90' -> 'MG 90' +% ========================================================================= +% ========================================================================= +function out = buildTickLabels(Str) +% buildTickLabels Convert coded condition strings (e.g. "MB0p05") into +% reader-friendly tick labels (e.g. "MB 0.05"), stripping redundant +% prefixes when only one prefix exists in the set, and silently +% discarding any entries whose numeric payload is NaN. +% +% Input: Str – string array of coded condition names +% Output: out – string array of formatted labels (same size as Str, +% with NaN entries replaced by "") + +% ---- Step 1: extract the leading uppercase prefix from each entry ---- +% Matches one or more capital letters at the start of each string. +% Returns for entries without an uppercase prefix. +prefixes = strings(size(Str)); % pre-allocate output +for i = 1:numel(Str) + prefixes(i) = regexp(Str(i), '^[A-Z]+', 'match', 'once'); +end + +% ---- Step 2: decide whether the prefix carries information ---- +% If every entry shares the same prefix (e.g. all start with "TF"), +% the prefix is redundant and can be dropped to declutter the axis. +uniquePrefixes = unique(prefixes(~ismissing(prefixes))); % ignore when counting +removePrefix = numel(uniquePrefixes) <= 1; % true → prefixes are uninformative + +% ---- Step 3: format each entry ---- +out = strings(size(Str)); % pre-allocate output + +for i = 1:numel(Str) + s = Str(i); % current coded string + prefix = prefixes(i); % its uppercase prefix (or ) + + % -- Extract the numeric part of the string -- + % Matches an optional minus sign, one or more digits, and an optional + % decimal part delimited by 'p' or '.'. (Inside a character class + % the dot is already literal, so no backslash is needed.) + numStr = regexp(s, '-?\d+(?:[p.]\d+)?', 'match', 'once'); + + % -- Guard: skip entries with no numeric payload -- + if ismissing(numStr) + out(i) = s; % preserve non-numeric labels as-is + continue + end + + % -- Convert the 'p' decimal convention to a real decimal point -- + numStr = replace(numStr, 'p', '.'); + + % -- Convert to a numeric value for formatting decisions -- + numVal = str2double(numStr); + + % ================================================================ + % BUG FIX: upstream code occasionally produces NaN-valued entries + % (e.g. "MB NaN"). The digit regex above will not match "NaN", + % but it *will* match if the string is e.g. "MBNaN" with stray + % digits nearby. Either way, any NaN that survives to this point + % must be caught or it will appear as "MB NaN" on the axis. + % ================================================================ + if isnan(numVal) + out(i) = ""; % blank label; caller should + continue % remove or handle these + end + + % -- Choose a readable numeric format -- + % Use scientific notation for very small non-zero magnitudes; + % otherwise use fixed-point with two decimal places. + if abs(numVal) < 0.01 && numVal ~= 0 + numFormatted = compose("%.2e", numVal); % e.g. "1.50e-03" + else + numFormatted = compose("%.2f", numVal); % e.g. "0.25" + end + + % -- Strip unnecessary trailing zeros from the mantissa -- + % For scientific notation the zeros sit before the 'e', so we + % first handle the mantissa, then reassemble with the exponent. + if contains(numFormatted, 'e') + parts = split(numFormatted, 'e'); % {"1.50", "-03"} + parts(1) = regexprep(parts(1), '\.?0+$', ''); % "1.50" → "1.5" + numFormatted = join(parts, 'e'); % "1.5e-03" + else + numFormatted = regexprep(numFormatted, '\.?0+$', ''); % "0.250" → "0.25" + end + + % ---- Reassemble: prefix + number (or number alone) ---- + if ~ismissing(prefix) && ~removePrefix + out(i) = prefix + " " + numFormatted; % e.g. "MB 0.25" + else + out(i) = numFormatted; % e.g. "0.25" + end +end +end + +% ========================================================================= +% LOCAL FUNCTION: reorderStimulusByLevel +% Reorder tbl.stimulus categories ascending by the trailing numeric token +% of each label (e.g. 'MB_dir_0' < 'MB_dir_30'). Reverses the encoding +% used by AllExpAnalysis: 'p' -> '.', 'neg' -> '-'. +% No-op if fewer than 2 labels have a numeric trailing token. +% ========================================================================= +function tbl = reorderStimulusByLevel(tbl) + +cats = categories(tbl.stimulus); +nums = nan(numel(cats), 1); + +for i = 1:numel(cats) + parts = strsplit(cats{i}, '_'); + if numel(parts) < 2, continue; end % no underscore => no level token + + last = parts{end}; % trailing token + last = strrep(last, 'p', '.'); % decode decimal + last = strrep(last, 'neg', '-'); % decode negative + + v = str2double(last); + if ~isnan(v), nums(i) = v; end +end + +% Need at least 2 numeric tokens to reorder; otherwise leave alphabetical +if sum(~isnan(nums)) < 2 + stimOrder = unique(string(tbl.stimulus), 'stable'); + tbl.stimulus = reordercats(tbl.stimulus, cellstr(stimOrder)); + return +end + +% Two-step stable sort: primary numeric ascending, secondary alphabetical +[catsAlpha, idxAlpha] = sort(cats); +numsAlpha = nums(idxAlpha); +[~, idxNum] = sort(numsAlpha, 'ascend', 'MissingPlacement', 'last'); +catsFinal = catsAlpha(idxNum); + +tbl.stimulus = reordercats(tbl.stimulus, catsFinal); + +end % reorderStimulusByLevel + + +% ========================================================================= +% LOCAL FUNCTION: buildRdBuColormap +% ========================================================================= +function cmap = buildRdBuColormap(n) +half = floor(n/2); +blueToWhite = [linspace(0.02, 1, half)', ... + linspace(0.44, 1, half)', ... + linspace(0.69, 1, half)']; +whiteToRed = [linspace(1, 0.70, half)', ... + linspace(1, 0.09, half)', ... + linspace(1, 0.09, half)']; +cmap = [blueToWhite; whiteToRed]; +end + +% ========================================================================= +% LOCAL FUNCTION: resolveMarkUnits +% Normalise the markUnits cell into a struct array, applying the same stimulus +% renaming the table receives, and assigning a marker symbol per row. +% ========================================================================= +function specs = resolveMarkUnits(markUnits) + +% Marker per row: 1st unit -> X, 2nd unit -> + (cross), spares for >2 specs +markerSymbols = {'x', '+', 'o', '*', 'square'}; + +% Empty / unset input: return a 0x0 struct with the right fields (overlay no-ops) +if isempty(markUnits) + specs = struct('id', {}, 'stim', {}, 'animal', {}, 'insertion', {}, 'symbol', {}); + return +end + +% Shape check: must be an N×4 cell {NeurID, stimulus, animal, insertion} +assert(iscell(markUnits) && size(markUnits, 2) == 4, ... + 'markUnits must be an N×4 cell: {NeurID, stimulus, animal, insertion}.'); + +n = size(markUnits, 1); % number of requested units + +% Guard against more specs than we have symbols for +if n > numel(markerSymbols) + warning('markUnits has %d rows but only %d symbols; using the first %d.', ... + n, numel(markerSymbols), numel(markerSymbols)); + n = numel(markerSymbols); % truncate to available symbols +end + +% Pre-allocate the output struct array +specs = struct('id', cell(1, n), 'stim', cell(1, n), ... + 'animal', cell(1, n), 'insertion', cell(1, n), 'symbol', cell(1, n)); + +for i = 1:n + % NeurID -> double, tolerating numeric or char/categorical input + rawID = markUnits{i, 1}; % raw NeurID entry + if isnumeric(rawID) + specs(i).id = double(rawID); % already numeric + else + specs(i).id = str2double(string(rawID)); % char/categorical -> numeric (not double()) + end + + % Stimulus: apply the SAME replacements as renameStimulusLabels, same order + stimStr = string(markUnits{i, 2}); % raw stimulus label + stimStr = replace(stimStr, "RG", "SB"); % moving/static ball grid rename + stimStr = replace(stimStr, "SDGs", "SG"); % static grating rename + stimStr = replace(stimStr, "SDGm", "MG"); % moving grating rename + specs(i).stim = char(stimStr); % store as char for equality tests + + % Animal / insertion: store as char so string() comparison is unambiguous + specs(i).animal = char(string(markUnits{i, 3})); % animal label (matched as string) + specs(i).insertion = str2double(string(markUnits{i, 4})); % REAL insertion number (recordingName trailing token) + + % Symbol assigned strictly by row order + specs(i).symbol = markerSymbols{i}; % 'x' for row 1, '+' for row 2, ... +end + +end % resolveMarkUnits + + +% ========================================================================= +% LOCAL FUNCTION: overlayMarkedUnits +% Draw the example-unit symbols onto an already-rendered swarm. A neuron is +% identified only by NeurID x animal x insertion (NeurID repeats across both), +% plus stimulus in the raw panels. Markers sit at the category centre and the +% unit's exact value; the jittered x is not retrievable on a categorical ruler. +% ========================================================================= +function overlayMarkedUnits(ax, tblPlot, params, matchByStimulus) + +% Pull the resolved specs; nothing to do if none were given +specs = params.markUnitsResolved; % struct array (possibly empty) +if isempty(specs) + return +end + +% Marking is keyed on NeurID, so the column must exist +if ~ismember('NeurID', tblPlot.Properties.VariableNames) + warning('overlayMarkedUnits: tbl has no NeurID column; cannot mark units.'); + return +end + +if ~ismember('realInsertion', tblPlot.Properties.VariableNames) + warning('overlayMarkedUnits: tbl has no realInsertion column; cannot mark units.'); + return +end + +% Numeric NeurID vector (categorical loaded from .mat needs str2double(string())) +if iscategorical(tblPlot.NeurID) + nid = str2double(string(tblPlot.NeurID)); % category labels -> numeric +else + nid = double(tblPlot.NeurID); % already numeric +end + +% String views of the grouping columns for robust equality +animalStr = string(tblPlot.animal); % per-row animal +stimStr = string(tblPlot.stimulus); % per-row stimulus (renamed already) +realInsTbl = str2double(string(tblPlot.realInsertion));% per-row REAL insertion number + +% Loop over the requested example units +for m = 1:numel(specs) + spec = specs(m); % current spec + + % Rows for the requested animal + isAnimal = animalStr == spec.animal; + if ~any(isAnimal) + warning('overlayMarkedUnits: no rows for animal %s; marker skipped.', spec.animal); + continue + end + + % Rows for the requested animal + isAnimal = animalStr == spec.animal; + if ~any(isAnimal) + warning('overlayMarkedUnits: no rows for animal %s; marker skipped.', spec.animal); + continue + end + + % Match by the REAL insertion number. The number you type IS the recording's + % own insertion label, so this is a direct value match — no offset, gap-safe, + % and unaffected by a dropped first insertion. + if ~any(realInsTbl(isAnimal) == spec.insertion) + warning(['overlayMarkedUnits: insertion %g is not present in animal %s ', ... + '(likely no somatic/responsive units there); marker skipped.'], ... + spec.insertion, spec.animal); + continue + end + + % Identity mask: animal + real insertion number + neuron + mask = isAnimal ... + & realInsTbl == spec.insertion ... % real insertion, by value + & nid == spec.id; + if matchByStimulus + mask = mask & stimStr == spec.stim; % raw panel: restrict to the stimulus column + end + + rows = find(mask); + + % Insertion exists but this neuron/stimulus combination doesn't + if isempty(rows) + if matchByStimulus + warning(['overlayMarkedUnits: insertion %g of animal %s exists, but no ', ... + 'NeurID %g under stimulus %s; marker skipped.'], ... + spec.insertion, spec.animal, spec.id, spec.stim); + else + fprintf(['overlayMarkedUnits: NeurID %g (animal %s, insertion %g) ', ... + 'absent from this difference panel; skipped.\n'], ... + spec.id, spec.animal, spec.insertion); + end + continue + end + + if numel(rows) > 1 + warning(['overlayMarkedUnits: %d rows match NeurID %g (animal %s, ', ... + 'insertion %g); marking all of them.'], ... + numel(rows), spec.id, spec.animal, spec.insertion); + end + + % Draw each matched unit (normally exactly one) + for r = rows' + xMark = tblPlot.stimulus(r); % categorical category centre (axis-safe) + yMark = tblPlot.value(r); % the unit's exact value + + % White halo first, then symbol on top; both small and at alpha 0.6 so + % the underlying swarm dot stays visible through the mark. + plot(ax, xMark, yMark, spec.symbol, ... + 'Color', [1 1 1 0.6], 'MarkerSize', 8, 'LineWidth', 2.0, 'Clipping', 'off'); % halo + plot(ax, xMark, yMark, spec.symbol, ... + 'Color', [0 0 0 0.6], 'MarkerSize', 6, 'LineWidth', 1.2, 'Clipping', 'off'); % symbol + end +end + +end % overlayMarkedUnits \ No newline at end of file diff --git a/general functions/plotSwarmBootstrapWithComparisons.m b/general functions/plotSwarmBootstrapWithComparisons.m index 6ae4703..6eb3df1 100644 --- a/general functions/plotSwarmBootstrapWithComparisons.m +++ b/general functions/plotSwarmBootstrapWithComparisons.m @@ -1,385 +1,1220 @@ -function [fig,randiColors] = plotSwarmBootstrapWithComparisons(tbl, pairs, pValues, valueField, params) - +function [fig, randiColors, figAllDiffs] = plotSwarmBootstrapWithComparisons(tbl, pairs, pValues, valueField, params) +% PLOTSWARMBOOTSTRAPWITHCOMPARISONS Per-stimulus swarm plot with hierarchical +% bootstrap central tendency, uncertainty bar, and pairwise significance brackets. +% +% [fig, randiColors, figAllDiffs] = plotSwarmBootstrapWithComparisons(tbl, ... +% pairs, pValues, valueField, params) +% +% tbl - One row per observation. Required columns: stimulus, animal, +% insertion (all categorical). Optional: NeurID (numeric, used +% in diff mode for neuron-level data), zScore (numeric). +% Two granularities are auto-detected: +% * neuron-level : multiple rows per (insertion,stimulus) +% * insertion-level: one row per (insertion,stimulus) +% pairs - Nx2 cell array of stimulus name pairs to compare/test. +% pValues - N-element vector of pre-computed p-values for those pairs. +% valueField - 1-cell {field} for raw value, 2-cell {num,den} for ratio. +% +% Selected params (see arguments block for full list): +% nBoot - bootstrap replicates (default 10000) +% fraction - true => valueField{1} ./ valueField{2} +% diff - plot per-neuron stimA-stimB difference instead of raw +% showBothAndDiff- two-tile layout: raw on left, difference on right +% ciMethod - 'sem' or 'percentile' (95% bootstrap CI, default) +% bootGroupVars - cell of column names defining the bootstrap hierarchy. +% Default auto-fills from {'animal','insertion'} based on +% what exists in the table. Pass {} explicitly to force a +% flat bootstrap. +% rngSeed - bootstrap RNG seed for reproducibility (default 0) +% +% Returns the figure handle, random dot-draw permutation, and (when >1 pair) +% a second figure showing every pairwise difference in separate tiles. +% +% Bootstrap details: uses hierBoot (Saravanan et al. 2020) when grouping +% levels are present, resampling each level with replacement in turn. + +% ------------------------------------------------------------------------- +% Argument validation block +% ------------------------------------------------------------------------- arguments - tbl table - pairs cell = {} - pValues double = [] - valueField cell = {} - params.nBoot (1,1) double = 10000 - params.fraction logical = false - params.yLegend char = 'value' - params.diff logical = false % compute difference between first pair - params.Xjitter = 'density' - params.dotSize = 7 - params.yMaxVis = 1 - params.filled = true - params.Alpha = 0.2 - params.plotMeanSem = true -end - -%% ----------------- PARAMETERS ----------------- -yMaxVis = params.yMaxVis; -bracketPad = yMaxVis * 0.05; -stackPad = yMaxVis * 0.05; -textPad = yMaxVis * 0.01; -semAlpha = 0.6; - -%% ----------------- DIFF MODE ----------------- -if params.diff - - assert(~isempty(pairs) && size(pairs,1) >= 1, ... - 'params.diff=true requires at least one stimulus pair.'); - - stimA = pairs{1,1}; - stimB = pairs{1,2}; - - if params.fraction - assert(numel(valueField) == 2, ... - 'Fraction mode requires two valueField entries.'); - end - - - ins = categories(tbl.insertion); - - diffVals = []; - animals = []; - insers = []; - - for i = 1:numel(ins) - idxA = tbl.insertion == ins{i} & tbl.stimulus == stimA; - idxB = tbl.insertion == ins{i} & tbl.stimulus == stimB; - - if any(idxA) && any(idxB) - - if params.fraction - vA = tbl.(valueField{1})(idxA) ./ tbl.(valueField{2})(idxA); - vB = tbl.(valueField{1})(idxB) ./ tbl.(valueField{2})(idxB); - else - vA = tbl.(valueField{1})(idxA); - vB = tbl.(valueField{1})(idxB); - end - - diffVals = [diffVals; vA - vB]; - animals = [animals; repmat(tbl.animal(find(idxA,1)),length(vA),1)]; - insers = [insers; repmat(i,length(vA),1)]; - end - end + tbl table % observation table + pairs cell = {} % stim pairs to test + pValues double = [] % p-value per pair + valueField cell = {} % field name(s) of value column + params.nBoot (1,1) double = 10000 % bootstrap replicates + params.fraction logical = false % ratio mode (num/den) + params.yLegend char = 'value' % y-axis label + params.diff logical = false % plot per-pair difference only + params.Xjitter = 'density' % swarm jitter scheme + params.dotSize = 5 % marker size (pts^2) + params.yMaxVis = 1 % visible y-axis cap + params.filled logical = true % filled vs open markers + params.Alpha = 0.2 % marker face/edge alpha + params.plotMeanSem logical = true % overlay mean +/- uncertainty + params.colorByZScore logical = false % color dots by zScore (else by animal) + params.showBothAndDiff logical = true % two-tile raw + diff layout + params.drawLines logical = false % connect paired observations + params.rngSeed (1,1) double = 0 % bootstrap reproducibility + params.ciMethod char = 'percentile'% 'sem' | 'percentile' + params.bootGroupVars cell = {'__auto__'}% hierarchical bootstrap levels + params.markUnits cell = {} % N×4 {NeurID,stimulus,animal,insertion}; row1->X, row2->+ + params.nearSigThresh (1,1) double = 0.1 % print exact p when 0.05 <= p < this; nothing above it +end + +% ------------------------------------------------------------------------- +% Up-front input validation +% ------------------------------------------------------------------------- + +% Either raw mode (1 field) or fraction mode (2 fields) +if params.fraction + assert(numel(valueField) == 2, 'Fraction mode requires two valueField entries.'); +else + assert(~isempty(valueField), 'valueField must contain at least one column name.'); +end - valid = ~isnan(diffVals); +% colorByZScore requires the column to exist; downgrade with warning if absent +if params.colorByZScore && ~ismember('zScore', tbl.Properties.VariableNames) + warning('colorByZScore=true but tbl has no zScore column; falling back to animal coloring.'); + params.colorByZScore = false; +end - stimName = sprintf('%s-%s', stimA, stimB); +% showBothAndDiff places diff in its own tile; it overrides params.diff +if params.showBothAndDiff && params.diff + warning('showBothAndDiff=true overrides params.diff; diff appears in the right tile only.'); + params.diff = false; +end - tblPlot = table(); - tblPlot.insertion = categorical(insers(valid)); - tblPlot.stimulus = categorical(repmat({stimName}, sum(valid), 1)); - tblPlot.animal = animals(valid); - tblPlot.value = diffVals(valid); +% Seed RNG once so bootstraps and dot-draw orders are deterministic +rng(params.rngSeed); - plotLinesBetween = false; +% ------------------------------------------------------------------------- +% Resolve bootstrap grouping variables +% ------------------------------------------------------------------------- +if isscalar(params.bootGroupVars) && strcmp(params.bootGroupVars{1}, '__auto__') + cands = {'animal','insertion'}; % candidate hierarchy columns + params.bootGroupVars = cands(ismember(cands, tbl.Properties.VariableNames)); +else + missing = ~ismember(params.bootGroupVars, tbl.Properties.VariableNames); + assert(~any(missing), 'bootGroupVars contains missing columns: %s', ... + strjoin(params.bootGroupVars(missing), ', ')); +end +% ------------------------------------------------------------------------- +% Detect data granularity +% ------------------------------------------------------------------------- +% If every (insertion, stimulus) pair appears at most once, the table is +% insertion-level; otherwise neuron-level. +isInsertionLevel = height(tbl) == height(unique(tbl(:, {'insertion','stimulus'}))); + +% ------------------------------------------------------------------------- +% Padding / spacing constants derived from the y-axis cap +% ------------------------------------------------------------------------- +yMaxVis = params.yMaxVis; +bracketPad = yMaxVis * 0.05; % gap between data and first bracket +stackPad = yMaxVis * 0.05; % vertical stacking between brackets +textPad = yMaxVis * 0.01; % gap between bracket and star text +semAlpha = 1; % alpha for bootstrap error bars + +% ------------------------------------------------------------------------- +% Pre-process tbl: rename legacy labels, reorder categories, compute value +% ------------------------------------------------------------------------- +tbl = renameStimulusLabels(tbl); % RG->SB, SDGs->SG, SDGm->MG +pairs = renamePairLabels(pairs); % apply same rename to pair labels +tbl = reorderStimulusByLevel(tbl); % sort categories by trailing number + +% Normalise the marker specs and apply the SAME label renaming as the table, +% so a user-supplied 'RG' lands in the renamed 'SB' column. +params.markUnitsResolved = resolveMarkUnits(params.markUnits); + +if params.fraction + tbl.value = tbl.(valueField{1}) ./ tbl.(valueField{2}); % element-wise ratio else - %% ----------------- RAW MODE ----------------- - if params.fraction - assert(numel(valueField) == 2, ... - 'Fraction mode requires two valueField entries.'); - tbl.value = tbl.(valueField{1}) ./ tbl.(valueField{2}); + tbl.value = tbl.(valueField{1}); % raw value +end + +% Drop unused categorical levels so colormaps and counts are accurate +tbl.stimulus = removecats(tbl.stimulus); +tbl.animal = removecats(tbl.animal); +tbl.insertion = removecats(tbl.insertion); + +% ------------------------------------------------------------------------- +% Build figure: single axes or 1x2 tiledlayout +% ------------------------------------------------------------------------- +fig = figure; +set(fig, 'Color', 'w'); % white background for publication + +if params.showBothAndDiff + % Left tile: every stimulus shown raw; right tile: most-significant diff + tl = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact'); + axRaw = nexttile(tl, 1); + axDiff = nexttile(tl, 2); + + randiColors = plotRawSwarm(axRaw, tbl, pairs, pValues, params, ... + yMaxVis, bracketPad, stackPad, textPad, semAlpha, isInsertionLevel); + + % Pick the most significant pair for the diff tile + if ~isempty(pValues) + [~, sigIdx] = min(pValues); else - tbl.value = tbl.(valueField{1}); + sigIdx = 1; end + pairForDiff = pairs(sigIdx, :); + pValForDiff = pValues(sigIdx); - tblPlot = tbl; - plotLinesBetween = true; + tblDiff = buildDiffTable(tbl, pairForDiff, params, isInsertionLevel); + plotDiffSwarm(axDiff, tblDiff, pairForDiff, pValForDiff, params, ... + yMaxVis, bracketPad, textPad); +else + % Single-axes mode: either the raw swarm or the difference + ax = axes(fig); + hold(ax, 'on'); + set(ax, 'Clipping', 'off'); + + if params.diff + tblDiff = buildDiffTable(tbl, pairs, params, isInsertionLevel); + randiColors = plotDiffSwarm(ax, tblDiff, pairs, pValues, params, ... + yMaxVis, bracketPad, textPad); + else + randiColors = plotRawSwarm(ax, tbl, pairs, pValues, params, ... + yMaxVis, bracketPad, stackPad, textPad, semAlpha, isInsertionLevel); + end end -%% ----------------- CHANGE STIM NAMES ----------------- -stimuli = unique(tblPlot.stimulus); +% ------------------------------------------------------------------------- +% Additional figure: one tile per pairwise difference (only when >1 pair) +% ------------------------------------------------------------------------- +if size(pairs, 1) > 1 + figAllDiffs = plotAllPairDiffs(tbl, pairs, pValues, params, ... + isInsertionLevel, yMaxVis, bracketPad, textPad); +else + figAllDiffs = []; +end -% tbl.stimulus = removecats(tbl.stimulus); -% tbl.animal = removecats(tbl.animal); -% tbl.insertion = removecats(tbl.insertion); -%Replace 'RG' with 'SB' +end % main function -% Convert to string -s = string(tblPlot.stimulus); -% Replace substring wherever it appears -s = replace(s, "RG", "SB"); -s = replace(s, "SDGs", "SG"); -s = replace(s, "SDGm", "MG"); -% Convert back to categorical -tblPlot.stimulus = categorical(s); +% ========================================================================= +% LOCAL FUNCTION: plotRawSwarm +% ========================================================================= +function randiColors = plotRawSwarm(ax, tbl, pairs, pValues, params, ... + yMaxVis, bracketPad, stackPad, textPad, semAlpha, isInsertionLevel) + +hold(ax, 'on'); +set(ax, 'Clipping', 'off'); % brackets may sit above yMaxVis + +stimuli = categories(tbl.stimulus); % ordered category list +tblPlot = tbl; + +% Random permutation for dot draw order (seeded in main) +randiColors = randperm(height(tblPlot)); + +% Choose dot color source +if params.colorByZScore + colorData = tblPlot.zScore(randiColors); +else + colorData = tblPlot.animal(randiColors); +end + +% Draw swarm +if params.filled + s = swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... + params.dotSize, colorData, 'filled', ... + 'MarkerFaceAlpha', params.Alpha); +else + s = swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... + params.dotSize, colorData, ... + 'MarkerEdgeAlpha', params.Alpha, 'LineWidth', 1, 'SizeData', 30); +end +s.XJitter = params.Xjitter; +% Build short tick labels from encoded category names +Str = string(stimuli); +out = buildTickLabels(Str); -%% ----------------- RENAME PAIRS TO MATCH ----------------- -if ~isempty(pairs) - for i = 1:numel(pairs) - if strcmp(pairs{i}, 'RG') - pairs{i} = 'SB'; - elseif strcmp(pairs{i}, 'SDGm') - pairs{i} = 'MG'; - elseif strcmp(pairs{i}, 'SDGs') - pairs{i} = 'SG'; +% Apply custom tick labels only if categories contain embedded numbers +numStr = regexp(Str, '-?\d+\.?\d*', 'match', 'once'); +hold(ax, 'on'); +if any(~cellfun(@isempty, numStr)) + xticklabels(ax, out); +end + +% Configure colormap +if params.colorByZScore + colormap(ax, buildRdBuColormap(256)); + maxZ = max(abs(tblPlot.zScore), [], 'omitnan'); + if isempty(maxZ) || maxZ == 0, maxZ = 1; end + clim(ax, [-maxZ maxZ]); + cb = colorbar(ax); + cb.Label.String = 'Z-score'; +else + colormap(ax, lines(numel(categories(tblPlot.animal)))); +end + +% ------------------------------------------------------------------------- +% Optional connecting lines between paired observations +% ------------------------------------------------------------------------- +if params.drawLines && numel(stimuli) <= 2 + if isInsertionLevel + unitIDvar = 'insertion'; % insertion IS the unit + elseif ismember('NeurID', tblPlot.Properties.VariableNames) + unitIDvar = 'NeurID'; % neuron is the unit + else + unitIDvar = ''; + warning(['drawLines=true on neuron-level data without NeurID; ', ... + 'skipping connecting lines.']); + end + + if ~isempty(unitIDvar) + cats = categories(tblPlot.stimulus); + xMap = containers.Map(cats, 1:numel(cats)); % stimulus -> x-position + xNum = arrayfun(@(i) xMap(char(tblPlot.stimulus(i))), 1:height(tblPlot)); + + unitIDs = unique(tblPlot.(unitIDvar)); + for u = 1:numel(unitIDs) + idx = tblPlot.(unitIDvar) == unitIDs(u); + if nnz(idx) < 2, continue; end + line(ax, xNum(idx), tblPlot.value(idx), ... + 'Color', [0 0 0 0.1], 'LineWidth', 0.1); end end end +ylabel(ax, params.yLegend); +ax.Box = 'off'; +ax.Layer = 'top'; + +% Hierarchical bootstrap mean +/- SE (or 95% CI) +if params.plotMeanSem + plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha); +end -%% ----------------- CLEAN CATEGORIES ----------------- -tblPlot.stimulus = removecats(tblPlot.stimulus); -tblPlot.animal = removecats(tblPlot.animal); -tblPlot.insertion = removecats(tblPlot.insertion); +% Significance brackets. When >5 pairs, only bracket adjacent groups to +% avoid visual clutter; the full set is in figAllDiffs. +maxBracketY = yMaxVis; % default top when no brackets are drawn +if ~isempty(pairs) && numel(pValues) == size(pairs, 1) + adjacentOnly = size(pairs, 1) > 6; + maxBracketY = plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... + yMaxVis, bracketPad, stackPad, textPad, adjacentOnly, params.nearSigThresh); +end -stimuli = categories(tblPlot.stimulus); -insertions = categories(tblPlot.insertion); +% Overlay user-specified example-unit markers (X for spec 1, + for spec 2) +overlayMarkedUnits(ax, tblPlot, params, true); -%% ----------------- RANDOMIZED COLORS ----------------- -randiColors = randperm(size(tblPlot,1)); +% Cap the visible y-range, but extend the top to fit lifted bracket labels so +% they aren't clipped (text near the top of a tiled layout can be cut even +% though the brackets themselves use Clipping=off). +ylim(ax, [ax.YLim(1), max(yMaxVis, maxBracketY)]); -%% ----------------- FIGURE ----------------- -fig = figure; -ax = axes; +end % plotRawSwarm + + +% ========================================================================= +% LOCAL FUNCTION: plotDiffSwarm +% Draws a swarm plot of pairwise differences for one condition pair, +% overlays a bootstrap mean ± SE bar, a zero reference line, and +% (if significant) a star annotation. +% ========================================================================= +function randiColors = plotDiffSwarm(ax, tblDiff, pairs, pValues, params, ... + yMaxVis, bracketPad, textPad) + +% Hold axes so subsequent drawing commands add to the same panel hold(ax, 'on'); -set(ax, 'Clipping', 'off'); % <-- ADD THIS LINE -% 1) Swarm first +% Allow markers and text to render outside the strict axes box; +% required so significance stars above yMaxVis remain visible +set(ax, 'Clipping', 'off'); + +% ---- Guard: empty diff table -------------------------------------------- +% This should already be caught by the caller, but defend here too +if height(tblDiff) == 0 + randiColors = []; + text(ax, 0.5, 0.5, 'No paired data', ... + 'Units', 'normalized', ... + 'HorizontalAlignment','center', ... + 'FontSize', 8, ... + 'Color', [0.6 0.6 0.6]); + return +end + +% ---- Reproducible draw order -------------------------------------------- +% Fix the RNG seed immediately before randperm so that the colour-to-unit +% mapping is identical on every re-render (required for publication). +% Use a deterministic seed derived from the pair names so different panels +% get different (but stable) permutations. +rng(sum(double(char(strjoin({pairs{1,1}, pairs{1,2}}, ''))))); +randiColors = randperm(height(tblDiff)); % shuffled row index for plotting order + +% ---- Colour source ------------------------------------------------------ +% Choose per-dot colour based on z-score (diverging) or animal identity +if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) + % Numeric z-score maps onto a diverging colormap set below + colorData = tblDiff.zScore(randiColors); +else + % Animal identity is categorical; convert to numeric so swarmchart + % accepts it as a valid colour index vector + colorData = double(tblDiff.animal(randiColors)); +end + +% ---- Swarm chart --------------------------------------------------------- if params.filled - s=swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... - params.dotSize, tblPlot.animal(randiColors), 'filled', ... + % Filled circles; face transparency controlled by params.Alpha + s = swarmchart(ax, ... + tblDiff.stimulus(randiColors), ... % x: condition label (categorical) + tblDiff.value(randiColors), ... % y: difference value + params.dotSize, ... % marker area (pt²) + colorData, ... % colour data (numeric) + 'filled', ... 'MarkerFaceAlpha', params.Alpha); else - s=swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... - params.dotSize, tblPlot.animal(randiColors), ... - 'MarkerEdgeAlpha',params.Alpha,'LineWidth',1,'SizeData',30); + % Open circles; use params.dotSize for size consistency with filled branch + s = swarmchart(ax, ... + tblDiff.stimulus(randiColors), ... + tblDiff.value(randiColors), ... + params.dotSize, ... % fixed: was hardcoded 30, ignoring params + colorData, ... + 'MarkerEdgeAlpha', params.Alpha, ... + 'LineWidth', 1); end +% Horizontal jitter style for the swarm (e.g. 'rand', 'none') s.XJitter = params.Xjitter; -%s.XJitterWidth = 0.1; -if plotLinesBetween - % 2) Get numeric x positions of categories - cats = categories(tblPlot.stimulus); - xMap = containers.Map(cats, 1:numel(cats)); +% ---- X tick label -------------------------------------------------------- +% Combine the two condition names into a single readable label, +% e.g. "MB 1.57 - MG 90" +pairLabels = buildTickLabels(string({pairs{1,1}, pairs{1,2}})); +xticklabels(ax, join(pairLabels, " - ")); + +% ---- Colormap & colour bar ----------------------------------------------- +if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) + % Symmetric diverging colormap centred on zero + colormap(ax, buildRdBuColormap(256)); + maxZ = max(abs(tblDiff.zScore), [], 'omitnan'); % symmetric colour limit + if isempty(maxZ) || maxZ == 0, maxZ = 1; end % avoid degenerate clim + clim(ax, [-maxZ maxZ]); + cb = colorbar(ax); + cb.Label.String = 'Z-score'; +else + % One colour per unique animal; categories() requires a categorical column + colormap(ax, lines(numel(categories(tblDiff.animal)))); +end + +% ---- Reference line at zero --------------------------------------------- +% Makes it immediately clear which differences are positive vs negative +yline(ax, 0, 'LineWidth', 1, 'Alpha', 0.7); - xNum = arrayfun(@(i) xMap(char(tblPlot.stimulus(i))), ... - 1:height(tblPlot)); +% Y-axis label (e.g. 'Δ firing rate (spk/s)') +ylabel(ax, params.yLegend); +% Clean axes appearance for publication +ax.Box = 'off'; +ax.Layer = 'top'; % draw axes on top of data so tick marks are not obscured + +% ---- Bootstrap mean ± SE bars ------------------------------------------- +if params.plotMeanSem + stimuli = categories(tblDiff.stimulus); % ordered condition list + plotMeanSemBars(ax, tblDiff, stimuli, params, 0.6); +end - try - tblPlot.NeurID; - UI = 'NeurID'; - catch - UI = 'insertion'; +% ---- Significance annotation -------------------------------------------- +% Capture the current auto-scaled Y limits AFTER all data have been drawn +% so the lower bound reflects the true data range +ylims = ylim(ax); +topNeeded = yMaxVis; % how high the axis must reach to fit the label (default: the cap) + +if ~isempty(pValues) && numel(pValues) >= 1 + pVal = pValues(1); % scalar p-value for this difference panel + + % Highest data point within the visible window (anchors the label just + % above the top of the visible data, not above data clipped by yMaxVis). + vals = tblDiff.value; + maxVisible = min(max(vals(:), [], 'omitnan'), yMaxVis); + if isempty(maxVisible) || isnan(maxVisible) + maxVisible = yMaxVis; end - % 3) Plot lines AFTER swarm - for i = 1:numel(unique(tblPlot.(UI))) - idx = double(tblPlot.(UI)) == i; - if sum(idx) < 2 - continue + % Label sits bracketPad above the highest visible point + yText = maxVisible + bracketPad; + + % Significant -> stars; near-significant (0.05 <= p < nearSigThresh) -> exact + % p; clearly non-significant or NaN -> nothing. + txt = ''; % default: draw nothing + if ~isnan(pVal) + if pVal < 0.001, txt = '***'; + elseif pVal < 0.01, txt = '**'; + elseif pVal < 0.05, txt = '*'; + elseif pVal < params.nearSigThresh, txt = sprintf('%.3f', pVal); % trend end + end - line(ax, ... - xNum(idx), tblPlot.value(idx), ... - 'Color', [0 0 0 0.1], ... - 'LineWidth', 0.1),'lin'; + if ~isempty(txt) + % Bottom-anchored so the label grows upward, clear of the data + text(ax, 1, yText, txt, ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'bottom', ... + 'FontSize', 7, ... + 'Clipping', 'off'); + topNeeded = yText + bracketPad; % headroom above the label end -else - yline(0,LineWidth=1,Alpha=0.7) end -coloranimal = {lines(numel(categories(tblPlot.animal))),categories(tblPlot.animal)}; -colormap(lines(numel(categories(tblPlot.animal)))) -ylabel(params.yLegend) +% Overlay example-unit markers on the difference dots (identity match only) +overlayMarkedUnits(ax, tblDiff, params, false); % false: stimulus is irrelevant in a diff -ax = gca; -ax.Box = 'off'; -ax.Layer = 'top'; +% Cap the upper Y limit, extending it to fit the significance label so it isn't +% clipped. The lower bound stays auto-scaled (per-panel data range); for the +% all-pairs figure, plotAllPairDiffs takes the union across panels via linkaxes. +ylim(ax, [ylims(1), max(yMaxVis, topNeeded)]); -%% ----------------- BOOTSTRAP MEAN + SEM ----------------- -if params.plotMeanSem +end % plotDiffSwarm + + +% ========================================================================= +% LOCAL FUNCTION: buildDiffTable +% Per-unit (stimA - stimB) within each insertion. +% Pairing strategy depends on data granularity: +% insertion-level: direct subtraction (one row per insertion) +% neuron-level + NeurID: match by NeurID (intersect) +% neuron-level without NeurID: row-order fallback with warning +% ========================================================================= +function tblDiff = buildDiffTable(tbl, pairs, params, isInsertionLevel) + +assert(~isempty(pairs) && size(pairs, 1) >= 1, ... + 'diff mode requires at least one stimulus pair.'); - for i = 1:numel(stimuli) +stimA = strtrim(pairs{1,1}); % trim whitespace for safety +stimB = strtrim(pairs{1,2}); - idx = tblPlot.stimulus == stimuli{i}; +hasNeurID = ismember('NeurID', tbl.Properties.VariableNames); + +if ~hasNeurID && ~isInsertionLevel + warning(['buildDiffTable: NeurID column absent for neuron-level data. ', ... + 'Pairing by row order — fragile if rows are reordered.']); +end + +ins = categories(tbl.insertion); % unique insertion labels +diffVals = []; % accumulator: paired differences +animals = categorical.empty(0, 1); % accumulator: animal per diff row +insers = categorical.empty(0, 1); % accumulator: insertion per diff row +zScores = []; % accumulator: zScore (if colorByZScore) +neurIDs = []; % accumulator: NeurID per diff row (NaN where unavailable) +hasRealIns = ismember('realInsertion', tbl.Properties.VariableNames); +realIns = categorical.empty(0, 1); % accumulator: real insertion per diff row +useZ = params.colorByZScore && ismember('zScore', tbl.Properties.VariableNames); + +for i = 1:numel(ins) + idxA = tbl.insertion == ins{i} & tbl.stimulus == stimA; + idxB = tbl.insertion == ins{i} & tbl.stimulus == stimB; + + % Skip insertions where either stimulus is absent + if ~any(idxA) || ~any(idxB) + continue + end - if any(idx) && params.fraction - vals = tblPlot.value(idx); - insers = tblPlot.insertion(idx); - animals = tblPlot.animal(idx); - elseif any(idx) - vals = tblPlot.value(idx); - insers = tblPlot.insertion(idx); - animals = tblPlot.animal(idx); + if isInsertionLevel + % One row per side; direct subtraction + vA = tbl.value(idxA); + vB = tbl.value(idxB); + an = tbl.animal(idxA); + insCat = tbl.insertion(idxA); % preserve original categorical + nidPiece = NaN; % insertion-level: no neuron identity + if useZ + zPair = (tbl.zScore(idxA) + tbl.zScore(idxB)) / 2; end - if numel(vals) < 3 - fprintf('Number of values to bootstrap is less than 3\n') + elseif hasNeurID + % Neuron-level with explicit IDs: safest matching via intersect + tA = tbl(idxA, :); + tB = tbl(idxB, :); + [~, iA, iB] = intersect(tA.NeurID, tB.NeurID, 'stable'); + if isempty(iA) continue end - if size(tblPlot,1) < 500 - bootMean = bootstrp(params.nBoot, @mean, vals); - mu = mean(bootMean); - sem = std(bootMean); - else + vA = tA.value(iA); + vB = tB.value(iB); + an = tA.animal(iA); + insCat = tA.insertion(iA); + nidPiece = tA.NeurID(iA); % keep the IDs of the matched neurons + if useZ + zPair = (tA.zScore(iA) + tB.zScore(iB)) / 2; + end - mu = mean(vals); - sem = std(vals,'omitnan') / sqrt(numel(vals)); + else + % Row-order fallback (fragile) + vA = tbl.value(idxA); + vB = tbl.value(idxB); + if numel(vA) ~= numel(vB) + warning('Insertion %s: %d stimA rows vs %d stimB rows; skipping.', ... + ins{i}, numel(vA), numel(vB)); + continue end + an = repmat(tbl.animal(find(idxA, 1)), numel(vA), 1); + insCat = repmat(tbl.insertion(find(idxA, 1)), numel(vA), 1); + nidPiece = NaN(numel(vA), 1); % row-order fallback: IDs not reliable + if useZ + zPair = (tbl.zScore(idxA) + tbl.zScore(idxB)) / 2; + end + end - % SEM - line([i i], mu + [-1 1]*sem, ... - 'Color', [0 0 0 semAlpha], 'LineWidth', 2); + diffVals = [diffVals; vA - vB]; %#ok + animals = [animals; an]; %#ok + insers = [insers; insCat]; %#ok + neurIDs = [neurIDs; nidPiece(:)]; + if hasRealIns + riThis = tbl.realInsertion(find(idxA, 1)); % constant within this insertion + realIns = [realIns; repmat(riThis, numel(vA), 1)]; %#ok + end + if useZ + zScores = [zScores; zPair]; %#ok + end +end + +% Drop NaN differences (e.g. from zero-denominator fractions) +valid = ~isnan(diffVals); +stimName = sprintf('%s-%s', stimA, stimB); + +tblDiff = table(); +tblDiff.insertion = insers(valid); % categorical insertion labels +tblDiff.stimulus = categorical(repmat({stimName}, sum(valid), 1)); +tblDiff.animal = animals(valid); +tblDiff.value = diffVals(valid); +tblDiff.NeurID = neurIDs(valid); +if hasRealIns + tblDiff.realInsertion = realIns(valid); +end + +if useZ + tblDiff.zScore = zScores(valid); +end + +end % buildDiffTable + + +% ========================================================================= +% LOCAL FUNCTION: plotMeanSemBars +% Hierarchical-bootstrap central tendency and uncertainty per stimulus. +% Uses hierBoot (Saravanan et al. 2020) for hierarchical resampling. +% ========================================================================= +function plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha) + +for i = 1:numel(stimuli) + idx = tblPlot.stimulus == stimuli{i}; + if ~any(idx), continue; end + + % Pull values and drop NaNs + vals = tblPlot.value(idx); + keep = ~isnan(vals); + vals = vals(keep); + + n = numel(vals); + if n < 3 + fprintf('Stimulus %s: n=%d < 3; skipping mean/SE bar.\n', char(stimuli{i}), n); + continue + end + + % Pull each grouping column aligned with NaN drop + groupVars = params.bootGroupVars; + groupVals = cell(1, numel(groupVars)); + for g = 1:numel(groupVars) + col = tblPlot.(groupVars{g})(idx); + col = col(keep); + if iscategorical(col) + col = double(col); % hierBoot requires numeric + end + groupVals{g} = col; + end + + % Make the level-2 (insertion) key unique *within* animal. Insertion + % numbering is reset per animal, so double(insertion) gives the same index + % to "insertion 1" of every animal; findgroups over (animal, insertion) + % yields one ID per distinct pair, so two animals' same-labelled insertions + % can never be merged when the bootstrap builds its animal->insertion + % nesting. No-op if the resampler already enumerates insertions within each + % animal; a silent-bug fix if it keys insertion globally. + if numel(groupVars) == 2 && all(strcmp(groupVars(:)', {'animal','insertion'})) + groupVals{2} = findgroups(groupVals{1}, groupVals{2}); % composite within-animal insertion ID + end - capW = 0.1; - line([i-capW i+capW], [mu+sem mu+sem], ... - 'Color', [0 0 0 semAlpha], 'LineWidth', 2); - line([i-capW i+capW], [mu-sem mu-sem], ... - 'Color', [0 0 0 semAlpha], 'LineWidth', 2); + % Hierarchical (animal -> insertion) or flat bootstrap of the mean. + % hierBootMatchFreq is hard-coded for exactly two grouping levels and + % expects them coarsest-first (lvl1 = animal, lvl2 = insertion). The + % default bootGroupVars ordering {'animal','insertion'} satisfies this. + if isempty(groupVars) + bootMean = bootstrp(params.nBoot, @mean, vals); % no hierarchy: ordinary bootstrap + else + assert(numel(groupVars) == 2, ... % enforce the 2-level contract + ['plotMeanSemBars: hierBootMatchFreq needs exactly two grouping ', ... + 'levels (animal, insertion); got %d. Adjust bootGroupVars, or ', ... + 'pass {} for a flat bootstrap.'], numel(groupVars)); + bootMean = hierBootMatchFreq(vals, params.nBoot, ... % lvl1 = animal (coarsest) + groupVals{1}, groupVals{2}); % lvl2 = insertion + end - % mean - dx = 0.15; - plot([i-dx i+dx], [mu mu], 'k-', 'LineWidth', 1.2); + % Point estimate: mean of the bootstrap distribution + % (weights animals/insertions equally, matching the mixed model) + mu = mean(bootMean); + + % Uncertainty bar + switch lower(params.ciMethod) + case 'sem' + se = std(bootMean); + yLo = mu - se; + yHi = mu + se; + case 'percentile' + yLo = prctile(bootMean, 2.5); + yHi = prctile(bootMean, 97.5); + otherwise + error('Unknown ciMethod: %s. Use ''sem'' or ''percentile''.', params.ciMethod); end + + xb = i + 0.30; % place the bar just right of the swarm column + + % Vertical uncertainty line + line(ax, [xb xb], [yLo yHi], 'Color', [0 0 0 semAlpha], 'LineWidth', 2); + % End caps + capW = 0.1; + line(ax, [xb-capW xb+capW], [yHi yHi], 'Color', [0 0 0 semAlpha], 'LineWidth', 2); + line(ax, [xb-capW xb+capW], [yLo yLo], 'Color', [0 0 0 semAlpha], 'LineWidth', 2); + % Mean line + dx = 0.15; + plot(ax, [xb-dx xb+dx], [mu mu], 'k-', 'LineWidth', 1.2); end +end % plotMeanSemBars -%% ----------------- PAIRWISE COMPARISONS ----------------- -if ~params.diff && ~isempty(pairs) && numel(pValues) == size(pairs,1) - fprintf('=== DEBUGGING BRACKETS ===\n'); - fprintf('Number of pairs: %d\n', size(pairs,1)); - fprintf('Number of pValues: %d\n', numel(pValues)); - fprintf('Stimuli in plot: %s\n', strjoin(cellstr(stimuli), ', ')); - - usedHeights = zeros(size(pairs,1),1); - - for k = 1:size(pairs,1) - - fprintf('\n--- Pair %d: %s vs %s ---\n', k, pairs{k,1}, pairs{k,2}); - - x1 = find(strcmp(stimuli, pairs{k,1})); - x2 = find(strcmp(stimuli, pairs{k,2})); - - fprintf('x1 index: %d, x2 index: %d\n', x1, x2); - - if isempty(x1) || isempty(x2) - fprintf('SKIPPING: One or both stimuli not found!\n'); - continue - end +% ========================================================================= +% LOCAL FUNCTION: plotBrackets +% Pairwise significance brackets. When adjacentOnly is true, only brackets +% between groups at positions i and i+1 are drawn (prevents visual clutter +% with many comparisons). All pairs are always reported to plotAllPairDiffs. +% ========================================================================= +function maxY = plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... + yMaxVis, bracketPad, stackPad, textPad, adjacentOnly,nearSigThresh) - vals1 = tblPlot.value(tblPlot.stimulus == pairs{k,1}); - vals2 = tblPlot.value(tblPlot.stimulus == pairs{k,2}); +% Track y-positions of placed brackets to prevent overlap +usedHeights = zeros(size(pairs, 1), 1); +maxY = yMaxVis; % highest point drawn, so the caller can extend ylim to fit labels +for k = 1:size(pairs, 1) - fprintf('vals1 count: %d, vals2 count: %d\n', numel(vals1), numel(vals2)); + % Skip NaN and clearly non-significant pairs. Pairs with + % 0.05 <= p < nearSigThresh are kept and get the exact p above the bracket. + if isnan(pValues(k)) || pValues(k) >= nearSigThresh + continue + end - maxVisible = max(min([vals1; vals2], yMaxVis)); - yBase = maxVisible + bracketPad; + % Find x-positions for both stimuli in this pair + x1 = find(strcmp(stimuli, pairs{k,1})); + x2 = find(strcmp(stimuli, pairs{k,2})); + if isempty(x1) || isempty(x2), continue; end + + % --- ADJACENT-ONLY FILTER --- + % When adjacentOnly is true, skip any pair where the two groups are + % not next to each other on the x-axis. This prevents 22+ overlapping + % brackets when there are many significant comparisons. The full set + % of pairwise differences is shown in the separate figAllDiffs figure. + if adjacentOnly && abs(x1 - x2) > 1 + continue + end - y = yBase; - while any(abs(usedHeights(1:k-1) - y) < stackPad) - y = y + stackPad; - end - usedHeights(k) = y; - - fprintf('Bracket y position: %.3f\n', y); - fprintf('p-value: %.4e\n', pValues(k)); - fprintf('Will draw bracket: YES\n'); - - % bracket - line([x1 x2], [y y], 'Color','k', 'LineWidth',1.2, 'Clipping', 'off'); - line([x1 x1], [y-yMaxVis*0.01 y], 'Color','k', 'LineWidth',1.2, 'Clipping', 'off'); - line([x2 x2], [y-yMaxVis*0.01 y], 'Color','k', 'LineWidth',1.2, 'Clipping', 'off'); - - % significance - if pValues(k) < 1e-3 - txt = '***'; - if pValues(k) == 0 - txt = '****'; - end - fprintf('Drawing text: %s\n', txt); - text(mean([x1 x2]), y + textPad, txt, ... - 'HorizontalAlignment','center', ... - 'FontSize', 7, 'Clipping', 'off'); - else - fprintf('p-value not significant enough (>= 1e-3)\n'); - end + % Cap individual values at yMaxVis so the bracket sits at the visible edge + vals1 = tblPlot.value(tblPlot.stimulus == pairs{k,1}); + vals2 = tblPlot.value(tblPlot.stimulus == pairs{k,2}); + maxVisible = max(min([vals1; vals2], yMaxVis)); + yBase = maxVisible + bracketPad; + + % Vertical stacking: nudge up if a previous bracket is too close + y = yBase; + while any(abs(usedHeights(1:k-1) - y) < stackPad) + y = y + stackPad; end - + usedHeights(k) = y; + + % Horizontal bracket + two short vertical ticks + line(ax, [x1 x2], [y y], 'Color', 'k', 'LineWidth', 1.2, 'Clipping', 'off'); + line(ax, [x1 x1], [y - yMaxVis*0.01, y], 'Color', 'k', 'LineWidth', 1.2, 'Clipping', 'off'); + line(ax, [x2 x2], [y - yMaxVis*0.01, y], 'Color', 'k', 'LineWidth', 1.2, 'Clipping', 'off'); + + % Significant -> stars; near-significant -> exact p above the bracket. + if pValues(k) < 0.001, txt = '***'; + elseif pValues(k) < 0.01, txt = '**'; + elseif pValues(k) < 0.05, txt = '*'; + else, txt = sprintf('%.3f', pValues(k)); % 0.05 <= p < nearSigThresh + end + + % Numeric labels are taller and sit closer to the line than stars, so lift + % them further and anchor by their bottom edge so they grow upward, away + % from the bracket, instead of overlapping it. + if ~startsWith(txt, '*') + yTxt = y + bracketPad; % extra lift for numbers + else + yTxt = y + textPad; % stars sit just above + end + text(ax, mean([x1 x2]), yTxt, txt, ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'bottom', ... % text grows up, off the bracket + 'FontSize', 8, 'Clipping', 'off'); + + maxY = max(maxY, yTxt + bracketPad); % headroom above the lifted label end -%% ----------------- SIGNIFICANCE FOR DIFF MODE ----------------- -ylims = ylim; +end % plotBrackets -if params.diff && ~isempty(pValues) && numel(pValues) >= 1 - - fprintf('=== DIFF MODE SIGNIFICANCE ===\n'); - fprintf('p-value: %.4e\n', pValues(1)); - - % There's only one "stimulus" (the difference) - x1 = 1; % Position of the single difference bar - - % Get all values for this difference - vals = tblPlot.value; - - % Find the maximum visible value - maxVisible = max(min(vals, yMaxVis)); - yText = maxVisible + bracketPad; - - fprintf('Text y position: %.3f\n', yText); - - % Draw significance stars - if pValues(1) < 1e-3 - txt = '***'; - if pValues(1) == 0 - txt = '****'; - end - fprintf('Drawing significance: %s\n', txt); - - % Draw the asterisks - text(x1, yText, txt, ... - 'HorizontalAlignment', 'center', ... - 'FontSize', 7, ... - 'Clipping', 'off'); - - % Draw the comparison text above the asterisks - stimA = pairs{1,1}; - stimB = pairs{1,2}; - compText = sprintf('%s > %s', stimA, stimB); - - yCompText = yText + textPad*10; - - text(x1, yCompText, compText, ... - 'HorizontalAlignment', 'center', ... - 'FontSize', 10, ... - 'Clipping', 'off'); - - fprintf('Drawing comparison text: %s\n', compText); - - - - % Adjust ylim if needed to fit both texts - requiredHeight = yCompText + textPad*10; % Extra padding above comparison text - if requiredHeight > yMaxVis - ylim([ylims(1) requiredHeight]); - fprintf('Adjusted ylim to [0 %.3f] to fit text\n', requiredHeight); - else - ylim([ylims(1) yMaxVis]); - end +% ========================================================================= +% LOCAL FUNCTION: plotAllPairDiffs +% Stand-alone figure with one tile per pairwise difference. +% ========================================================================= +function figAll = plotAllPairDiffs(tbl, pairs, pValues, params, ... + isInsertionLevel, yMaxVis, bracketPad, textPad) + +% Total number of pairwise comparisons to tile +nPairs = size(pairs, 1); + +% Guard: nothing to plot +if nPairs == 0 + figAll = figure; + return +end + +% Create figure with white background for publication +figAll = figure; +set(figAll, 'Color', 'w'); + +% Determine grid dimensions: cap columns at 7 to keep tiles legible; +% add as many rows as needed to accommodate all pairs +nCols = min(nPairs, 7); +nRows = ceil(nPairs / nCols); + +% Build the tiled layout with tight spacing for a compact multi-panel figure +tl = tiledlayout(figAll, nRows, nCols, ... + 'TileSpacing', 'compact', 'Padding', 'compact'); + +% Overall figure title +title(tl, 'All pairwise differences'); + +% Pre-allocate axes handle array so we can link Y axes after the loop. +% Initialise with gobjects so empty-data tiles can be excluded cleanly. +axHandles = gobjects(nPairs, 1); + +for k = 1:nPairs + + % Open the next tile in reading order + ax = nexttile(tl); + + % Extract the condition pair (1×2 cell) and its omnibus p-value for tile k + pairK = pairs(k, :); + pValK = pValues(k); + + % Build the difference table for this pair (one row per unit/insertion) + tblDiff = buildDiffTable(tbl, pairK, params, isInsertionLevel); + + % --- Empty-data path --------------------------------------------------- + if height(tblDiff) == 0 + % Suppress all axes decorations so the blank tile is invisible in print + axis(ax, 'off'); + + % Build a human-readable label from the two condition names + pairLabels = buildTickLabels(string({pairK{1}, pairK{2}})); + + % Place a low-contrast annotation so the missing pair is still legible + % when inspecting the figure interactively + text(ax, 0.5, 0.5, join(pairLabels, " - ") + " (no data)", ... + 'Units', 'normalized', ... + 'HorizontalAlignment','center', ... + 'FontSize', 6, ... + 'Color', [0.6 0.6 0.6]); + + % Do NOT store this handle — we exclude empty tiles from Y-axis linking + continue + end + % ----------------------------------------------------------------------- + + % Draw the swarm + significance bracket into ax + plotDiffSwarm(ax, tblDiff, pairK, pValK, params, ... + yMaxVis, bracketPad, textPad); + + % Record the handle for this successfully plotted tile + axHandles(k) = ax; + +end % for k + +% --- Shared Y axis --------------------------------------------------------- +% Retain only the axes handles that were actually plotted (non-null gobjects) +validAx = axHandles(isvalid(axHandles)); + +if numel(validAx) > 1 + % Couple all valid tile axes so interactive zoom/pan stays synchronised + linkaxes(validAx, 'y'); + + % Compute the union of all individual Y ranges so no data are clipped. + % We do this explicitly rather than relying on linkaxes auto-scaling, + % because plotDiffSwarm may have expanded ylim for brackets/text and + % we want the *tightest* common range that honours those expansions. + allYLims = cell2mat(arrayfun(@(a) ylim(a), validAx, ... + 'UniformOutput', false)); % numel(validAx) × 2 matrix + sharedYLim = [min(allYLims(:,1)), max(allYLims(:,2))]; + + % Apply the shared limits; linkaxes propagates this to every coupled axis + ylim(validAx(1), sharedYLim); +end +% --------------------------------------------------------------------------- + +end % plotAllPairDiffs + + +% ========================================================================= +% LOCAL FUNCTION: renameStimulusLabels +% ========================================================================= +function tbl = renameStimulusLabels(tbl) +s = string(tbl.stimulus); +s = replace(s, "RG", "SB"); +s = replace(s, "SDGs", "SG"); +s = replace(s, "SDGm", "MG"); +tbl.stimulus = categorical(s); +end + + +% ========================================================================= +% LOCAL FUNCTION: renamePairLabels +% ========================================================================= +function pairs = renamePairLabels(pairs) +if isempty(pairs), return; end +for i = 1:numel(pairs) + p = string(pairs{i}); + p = replace(p, "RG", "SB"); + p = replace(p, "SDGs", "SG"); + p = replace(p, "SDGm", "MG"); + pairs{i} = char(p); +end +end + + +% ========================================================================= +% LOCAL FUNCTION: buildTickLabels +% Decode category names into short human-readable tick labels. +% e.g. 'MB_dir_1p57' -> 'MB 1.57', 'MG_ang_90' -> 'MG 90' +% ========================================================================= +% ========================================================================= +function out = buildTickLabels(Str) +% buildTickLabels Convert coded condition strings (e.g. "MB0p05") into +% reader-friendly tick labels (e.g. "MB 0.05"), stripping redundant +% prefixes when only one prefix exists in the set, and silently +% discarding any entries whose numeric payload is NaN. +% +% Input: Str – string array of coded condition names +% Output: out – string array of formatted labels (same size as Str, +% with NaN entries replaced by "") + +% ---- Step 1: extract the leading uppercase prefix from each entry ---- +% Matches one or more capital letters at the start of each string. +% Returns for entries without an uppercase prefix. +prefixes = strings(size(Str)); % pre-allocate output +for i = 1:numel(Str) + prefixes(i) = regexp(Str(i), '^[A-Z]+', 'match', 'once'); +end + +% ---- Step 2: decide whether the prefix carries information ---- +% If every entry shares the same prefix (e.g. all start with "TF"), +% the prefix is redundant and can be dropped to declutter the axis. +uniquePrefixes = unique(prefixes(~ismissing(prefixes))); % ignore when counting +removePrefix = numel(uniquePrefixes) <= 1; % true → prefixes are uninformative + +% ---- Step 3: format each entry ---- +out = strings(size(Str)); % pre-allocate output + +for i = 1:numel(Str) + s = Str(i); % current coded string + prefix = prefixes(i); % its uppercase prefix (or ) + + % -- Extract the numeric part of the string -- + % Matches an optional minus sign, one or more digits, and an optional + % decimal part delimited by 'p' or '.'. (Inside a character class + % the dot is already literal, so no backslash is needed.) + numStr = regexp(s, '-?\d+(?:[p.]\d+)?', 'match', 'once'); + + % -- Guard: skip entries with no numeric payload -- + if ismissing(numStr) + out(i) = s; % preserve non-numeric labels as-is + continue + end + + % -- Convert the 'p' decimal convention to a real decimal point -- + numStr = replace(numStr, 'p', '.'); + + % -- Convert to a numeric value for formatting decisions -- + numVal = str2double(numStr); + + % ================================================================ + % BUG FIX: upstream code occasionally produces NaN-valued entries + % (e.g. "MB NaN"). The digit regex above will not match "NaN", + % but it *will* match if the string is e.g. "MBNaN" with stray + % digits nearby. Either way, any NaN that survives to this point + % must be caught or it will appear as "MB NaN" on the axis. + % ================================================================ + if isnan(numVal) + out(i) = ""; % blank label; caller should + continue % remove or handle these + end + + % -- Choose a readable numeric format -- + % Use scientific notation for very small non-zero magnitudes; + % otherwise use fixed-point with two decimal places. + if abs(numVal) < 0.01 && numVal ~= 0 + numFormatted = compose("%.2e", numVal); % e.g. "1.50e-03" else - fprintf('p-value not significant enough (>= 1e-3)\n'); - ylim([ylims(1) yMaxVis]); + numFormatted = compose("%.2f", numVal); % e.g. "0.25" end + + % -- Strip unnecessary trailing zeros from the mantissa -- + % For scientific notation the zeros sit before the 'e', so we + % first handle the mantissa, then reassemble with the exponent. + if contains(numFormatted, 'e') + parts = split(numFormatted, 'e'); % {"1.50", "-03"} + parts(1) = regexprep(parts(1), '\.?0+$', ''); % "1.50" → "1.5" + numFormatted = join(parts, 'e'); % "1.5e-03" + else + numFormatted = regexprep(numFormatted, '\.?0+$', ''); % "0.250" → "0.25" + end + + % ---- Reassemble: prefix + number (or number alone) ---- + if ~ismissing(prefix) && ~removePrefix + out(i) = prefix + " " + numFormatted; % e.g. "MB 0.25" + else + out(i) = numFormatted; % e.g. "0.25" + end +end +end + +% ========================================================================= +% LOCAL FUNCTION: reorderStimulusByLevel +% Reorder tbl.stimulus categories ascending by the trailing numeric token +% of each label (e.g. 'MB_dir_0' < 'MB_dir_30'). Reverses the encoding +% used by AllExpAnalysis: 'p' -> '.', 'neg' -> '-'. +% No-op if fewer than 2 labels have a numeric trailing token. +% ========================================================================= +function tbl = reorderStimulusByLevel(tbl) + +cats = categories(tbl.stimulus); +nums = nan(numel(cats), 1); + +for i = 1:numel(cats) + parts = strsplit(cats{i}, '_'); + if numel(parts) < 2, continue; end % no underscore => no level token + + last = parts{end}; % trailing token + last = strrep(last, 'p', '.'); % decode decimal + last = strrep(last, 'neg', '-'); % decode negative + + v = str2double(last); + if ~isnan(v), nums(i) = v; end +end + +% Need at least 2 numeric tokens to reorder; otherwise leave alphabetical +if sum(~isnan(nums)) < 2 + stimOrder = unique(string(tbl.stimulus), 'stable'); + tbl.stimulus = reordercats(tbl.stimulus, cellstr(stimOrder)); + return +end + +% Two-step stable sort: primary numeric ascending, secondary alphabetical +[catsAlpha, idxAlpha] = sort(cats); +numsAlpha = nums(idxAlpha); +[~, idxNum] = sort(numsAlpha, 'ascend', 'MissingPlacement', 'last'); +catsFinal = catsAlpha(idxNum); + +tbl.stimulus = reordercats(tbl.stimulus, catsFinal); + +end % reorderStimulusByLevel + + +% ========================================================================= +% LOCAL FUNCTION: buildRdBuColormap +% ========================================================================= +function cmap = buildRdBuColormap(n) +half = floor(n/2); +blueToWhite = [linspace(0.02, 1, half)', ... + linspace(0.44, 1, half)', ... + linspace(0.69, 1, half)']; +whiteToRed = [linspace(1, 0.70, half)', ... + linspace(1, 0.09, half)', ... + linspace(1, 0.09, half)']; +cmap = [blueToWhite; whiteToRed]; +end + +% ========================================================================= +% LOCAL FUNCTION: resolveMarkUnits +% Normalise the markUnits cell into a struct array, applying the same stimulus +% renaming the table receives, and assigning a marker symbol per row. +% ========================================================================= +function specs = resolveMarkUnits(markUnits) + +% Marker per row: 1st unit -> X, 2nd unit -> + (cross), spares for >2 specs +markerSymbols = {'x', '+', 'o', '*', 'square'}; + +% Empty / unset input: return a 0x0 struct with the right fields (overlay no-ops) +if isempty(markUnits) + specs = struct('id', {}, 'stim', {}, 'animal', {}, 'insertion', {}, 'symbol', {}); + return +end + +% Shape check: must be an N×4 cell {NeurID, stimulus, animal, insertion} +assert(iscell(markUnits) && size(markUnits, 2) == 4, ... + 'markUnits must be an N×4 cell: {NeurID, stimulus, animal, insertion}.'); + +n = size(markUnits, 1); % number of requested units + +% Guard against more specs than we have symbols for +if n > numel(markerSymbols) + warning('markUnits has %d rows but only %d symbols; using the first %d.', ... + n, numel(markerSymbols), numel(markerSymbols)); + n = numel(markerSymbols); % truncate to available symbols +end + +% Pre-allocate the output struct array +specs = struct('id', cell(1, n), 'stim', cell(1, n), ... + 'animal', cell(1, n), 'insertion', cell(1, n), 'symbol', cell(1, n)); + +for i = 1:n + % NeurID -> double, tolerating numeric or char/categorical input + rawID = markUnits{i, 1}; % raw NeurID entry + if isnumeric(rawID) + specs(i).id = double(rawID); % already numeric + else + specs(i).id = str2double(string(rawID)); % char/categorical -> numeric (not double()) + end + + % Stimulus: apply the SAME replacements as renameStimulusLabels, same order + stimStr = string(markUnits{i, 2}); % raw stimulus label + stimStr = replace(stimStr, "RG", "SB"); % moving/static ball grid rename + stimStr = replace(stimStr, "SDGs", "SG"); % static grating rename + stimStr = replace(stimStr, "SDGm", "MG"); % moving grating rename + specs(i).stim = char(stimStr); % store as char for equality tests - fprintf('=== END DIFF MODE SIGNIFICANCE ===\n'); + % Animal / insertion: store as char so string() comparison is unambiguous + specs(i).animal = char(string(markUnits{i, 3})); % animal label (matched as string) + specs(i).insertion = str2double(string(markUnits{i, 4})); % REAL insertion number (recordingName trailing token) + + % Symbol assigned strictly by row order + specs(i).symbol = markerSymbols{i}; % 'x' for row 1, '+' for row 2, ... +end + +end % resolveMarkUnits + + +% ========================================================================= +% LOCAL FUNCTION: overlayMarkedUnits +% Draw the example-unit symbols onto an already-rendered swarm. A neuron is +% identified only by NeurID x animal x insertion (NeurID repeats across both), +% plus stimulus in the raw panels. Markers sit at the category centre and the +% unit's exact value; the jittered x is not retrievable on a categorical ruler. +% ========================================================================= +function overlayMarkedUnits(ax, tblPlot, params, matchByStimulus) + +% Pull the resolved specs; nothing to do if none were given +specs = params.markUnitsResolved; % struct array (possibly empty) +if isempty(specs) + return +end + +% Marking is keyed on NeurID, so the column must exist +if ~ismember('NeurID', tblPlot.Properties.VariableNames) + warning('overlayMarkedUnits: tbl has no NeurID column; cannot mark units.'); + return +end + +if ~ismember('realInsertion', tblPlot.Properties.VariableNames) + warning('overlayMarkedUnits: tbl has no realInsertion column; cannot mark units.'); + return +end + +% Numeric NeurID vector (categorical loaded from .mat needs str2double(string())) +if iscategorical(tblPlot.NeurID) + nid = str2double(string(tblPlot.NeurID)); % category labels -> numeric else - ylim([ylims(1) yMaxVis]); + nid = double(tblPlot.NeurID); % already numeric end +% String views of the grouping columns for robust equality +animalStr = string(tblPlot.animal); % per-row animal +stimStr = string(tblPlot.stimulus); % per-row stimulus (renamed already) +realInsTbl = str2double(string(tblPlot.realInsertion));% per-row REAL insertion number + +% Loop over the requested example units +for m = 1:numel(specs) + spec = specs(m); % current spec + + % Rows for the requested animal + isAnimal = animalStr == spec.animal; + if ~any(isAnimal) + warning('overlayMarkedUnits: no rows for animal %s; marker skipped.', spec.animal); + continue + end + + % Rows for the requested animal + isAnimal = animalStr == spec.animal; + if ~any(isAnimal) + warning('overlayMarkedUnits: no rows for animal %s; marker skipped.', spec.animal); + continue + end + + % Match by the REAL insertion number. The number you type IS the recording's + % own insertion label, so this is a direct value match — no offset, gap-safe, + % and unaffected by a dropped first insertion. + if ~any(realInsTbl(isAnimal) == spec.insertion) + warning(['overlayMarkedUnits: insertion %g is not present in animal %s ', ... + '(likely no somatic/responsive units there); marker skipped.'], ... + spec.insertion, spec.animal); + continue + end + + % Identity mask: animal + real insertion number + neuron + mask = isAnimal ... + & realInsTbl == spec.insertion ... % real insertion, by value + & nid == spec.id; + if matchByStimulus + mask = mask & stimStr == spec.stim; % raw panel: restrict to the stimulus column + end + + rows = find(mask); + + % Insertion exists but this neuron/stimulus combination doesn't + if isempty(rows) + if matchByStimulus + warning(['overlayMarkedUnits: insertion %g of animal %s exists, but no ', ... + 'NeurID %g under stimulus %s; marker skipped.'], ... + spec.insertion, spec.animal, spec.id, spec.stim); + else + fprintf(['overlayMarkedUnits: NeurID %g (animal %s, insertion %g) ', ... + 'absent from this difference panel; skipped.\n'], ... + spec.id, spec.animal, spec.insertion); + end + continue + end + + if numel(rows) > 1 + warning(['overlayMarkedUnits: %d rows match NeurID %g (animal %s, ', ... + 'insertion %g); marking all of them.'], ... + numel(rows), spec.id, spec.animal, spec.insertion); + end + + % Draw each matched unit (normally exactly one) + for r = rows' + xMark = tblPlot.stimulus(r); % categorical category centre (axis-safe) + yMark = tblPlot.value(r); % the unit's exact value + + % White halo first, then symbol on top; both small and at alpha 0.6 so + % the underlying swarm dot stays visible through the mark. + plot(ax, xMark, yMark, spec.symbol, ... + 'Color', [1 1 1 0.6], 'MarkerSize', 8, 'LineWidth', 2.0, 'Clipping', 'off'); % halo + plot(ax, xMark, yMark, spec.symbol, ... + 'Color', [0 0 0 0.6], 'MarkerSize', 6, 'LineWidth', 1.2, 'Clipping', 'off'); % symbol + end +end -end \ No newline at end of file +end % overlayMarkedUnits \ No newline at end of file diff --git a/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/StaticDriftingGratingAnalysis.m b/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/StaticDriftingGratingAnalysis.m index f8e777b..ad74e09 100644 --- a/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/StaticDriftingGratingAnalysis.m +++ b/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/StaticDriftingGratingAnalysis.m @@ -210,10 +210,10 @@ NeuronRespProfile(k,3) = max_position_Trial(k,2); %4% real spike rate of response posTr*trialDivision-trialDivision+1:posTr*trialDivision - NeuronRespProfile(k,4) = mean(Mr(max_position_Trial(k,1)*trialDivision-trialDivision+1:max_position_Trial(k,1)*trialDivision-1,u,... + NeuronRespProfile(k,4) = mean(Mr(max_position_Trial(k,1)*trialDivision-trialDivision+1:max_position_Trial(k,1)*trialDivision,u,... max_position_Trial(k,2):max_position_Trial(k,2)+window_size(2)-1),'all');%max_position_Trial(k,2); - BaseResp = mean(Mbd(max_position_TrialB(k,1)*trialDivision-trialDivision+1:max_position_TrialB(k,1)*trialDivision-1,u,... + BaseResp = mean(Mbd(max_position_TrialB(k,1)*trialDivision-trialDivision+1:max_position_TrialB(k,1)*trialDivision,u,... max_position_TrialB(k,2):max_position_TrialB(k,2)+window_size(2)-1),'all'); %4%. Resp - Baseline @@ -239,7 +239,7 @@ end - colNames = {'Resp','MaxWinTrial','MaxWinBin','RespSubsBaseline','Directions','Offsets','Sizes','Speeds','Luminosities'}; + colNames = {'Resp','MaxWinTrial','MaxWinBin','RespSubsBaseline','angles','tempFrequency','spatFrequency'}; S.C = C; S.Coff = Coff; diff --git a/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/plotRaster.m b/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/plotRaster.m new file mode 100644 index 0000000..80d1c2d --- /dev/null +++ b/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/plotRaster.m @@ -0,0 +1,611 @@ +function plotRaster(obj, params) +% plotRaster Combined static + drifting raster, PSTH, and raw trace. +% +% Plots both grating phases in a single raster, divided only by angle. +% TF/SF sub-divisions are intentionally omitted because the stimulus design +% does not guarantee equal representation of all (TF, SF) combinations across +% angles (see diagnostic output printed at startup). +% +% DESIGN IMBALANCE — OPTIONS FOR THE PAPER: +% A) Common-subset: restrict quantitative analyses (tuning curves, DSI, OSI) +% to the (TF, SF) combinations that appear in every angle. The raster +% can still show all trials. +% B) Velocity grouping: TF/SF (deg/s) may have been the intended constant. +% Group by velocity rather than TF and SF independently. +% C) Per-condition tuning: compute one orientation tuning curve per (TF,SF) +% combination that has sufficient trials across all angles. +% +% Trial timing (from ResponseWindow): +% |-- preBase --|-- staticDur --|-- movingDur --|-- preBase --| +% ^ ^ ^ +% stim on phase change stim off +% +% ResponseWindow fields: +% Onsets(:,1) = static onset per trial (ms, absolute) +% Onsets(:,2) = moving onset per trial (ms, absolute) +% Offsets(:,2) = moving offset per trial (ms, absolute) +% C columns = [stimOnTime, angle, TF, SF] + +% -------------------------------------------------------------------------- +% BUG FIXES (all carried forward from earlier versions): +% 1. best_row uninitialized when SelectedWindow=false -> default = 1. +% 2. [nT,nN,nB]=size(Mr2) on a 2-D matrix -> removed. +% 3. Floating-point filter comparisons -> abs(a-b) 0.5. +% 5. ur/u dual-index confusion -> documented. +% 6. Overlapping dividing lines at angle boundaries -> loop from row 2. +% 7. Wrong y-tick formula from moving-ball code -> midpoint formula. +% 8. Angle-block boundaries now robust to imbalanced designs. +% 9. Diagnostic condition-balance table printed at startup. +% +% FIXED IN THIS VERSION: +% 10. Red patch x-offset bug: 'start' is already in full-window coordinates +% (0 = first bin of the preBase buffer), so adding another preBase +% shifted the patch preBase/params.bin bins to the right of the actual +% best window. Fixed: patch drawn at [start, start+window]/params.bin. +% 11. Raw-trace xlines: the inherited 'xline(-start/1000)' marked a position +% before the visible window. Replaced with correctly computed event +% markers (stim on, phase transition, stim off) that are only drawn when +% they fall inside the 500 ms trace window. +% -------------------------------------------------------------------------- + +arguments (Input) + obj + params.overwrite logical = false % Overwrite saved figures + params.analysisTime = datetime('now') % Provenance timestamp + params.inputParams logical = false % Print params and exit + params.preBase = 200 % Pre/post-stimulus baseline (ms) + params.bin = 30 % Raster bin size (ms/bin) + params.exNeurons = [] % Neuron index into good-unit list + params.exNeuronsPhyID double = [] % Override: select by phy cluster ID + params.AllSomaticNeurons logical = false % Plot all good units + params.AllResponsiveNeurons logical = true % Plot units with min(pS,pM) < 0.05 + params.SelectedWindow logical = true % Auto-detect best response window + params.MergeNtrials = 1 % Trials averaged per raster row + params.GaussianLength = 10 % Gaussian smoothing kernel (bins) + params.Gaussian logical = false % Apply Gaussian smoothing + params.MaxVal_1 logical = true % Clamp raster colormap to [0 1] + params.OneAngle string = "all" % Restrict to one angle, e.g. "90" + params.OneTF string = "all" % Restrict to one TF (Hz) + params.OneSF string = "all" % Restrict to one SF (c/deg) + params.PaperFig logical = false % High-quality figure export + params.statType string = "maxPermuteTest" % "maxPermuteTest"|"BootstrapPerNeuron" + params.plotRaw logical = true +end + +if params.inputParams, disp(params); return; end + +% ========================================================================== +% 1. LOAD PRE-COMPUTED RESULTS +% ========================================================================== + +NeuronResp = obj.ResponseWindow; + +if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; +else + Stats = obj.StatisticsPerNeuron; +end + +pvalsS = Stats.Static.pvalsResponse; +pvalsM = Stats.Moving.pvalsResponse; +pvalsMin = min(pvalsS, pvalsM); + +% ========================================================================== +% 2. SPIKE SORTING AND STIMULUS TIMING +% ========================================================================== + +p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); +phy_IDg = p.phy_ID(string(p.label') == 'good'); +label = string(p.label'); +goodU = p.ic(:, label == 'good'); + +staticDur = round(mean(NeuronResp.Onsets(:,2) - NeuronResp.Onsets(:,1))); % (ms) +totalStimDur = round(mean(NeuronResp.Offsets(:,2) - NeuronResp.Onsets(:,1))); % (ms) +movingDur = totalStimDur - staticDur; % (ms) +stimInter = NeuronResp.stimInter; +preBase = round(stimInter - stimInter / 4); % 75% of ITI (ms) + +% ========================================================================== +% 3. CONDITION MATRIX C AND OPTIONAL FILTERING +% ========================================================================== + +C = NeuronResp.C; % columns: [stimOnTime, angle, TF, SF] + +if params.OneAngle ~= "all" + angleVal = str2double(params.OneAngle); + C = C(abs(C(:,2) - angleVal) < 1e-3, :); + if isempty(C), error('No trials found for OneAngle = "%s"', params.OneAngle); end +end +if params.OneTF ~= "all" + tfVal = str2double(params.OneTF); + C = C(abs(C(:,3) - tfVal) < 1e-4, :); + if isempty(C), error('No trials found for OneTF = "%s"', params.OneTF); end +end +if params.OneSF ~= "all" + sfVal = str2double(params.OneSF); + C = C(abs(C(:,4) - sfVal) < 1e-4, :); + if isempty(C), error('No trials found for OneSF = "%s"', params.OneSF); end +end + +% Sort angle primary, TF secondary, SF tertiary so that similar conditions +% remain adjacent within each angle block even without TF/SF boundary lines +[C, ~] = sortrows(C, [2 3 4]); + +% Static-onset time for each sorted trial — the single reference time for +% all BuildBurstMatrix calls (both phases are captured relative to this) +directimesSorted = C(:,1)'; + +% ========================================================================== +% 4. CONDITION COUNTS AND DIAGNOSTIC TABLE +% ========================================================================== + +uAngle = unique(C(:,2)); +uTF = unique(C(:,3)); +uSF = unique(C(:,4)); +angleN = numel(uAngle); +tfN = numel(uTF); +sfN = numel(uSF); +nT = size(C, 1); + +fprintf('\n=== Condition balance check ===\n'); +fprintf('%-8s %-8s %-8s %-8s %s\n', 'Angle', 'TF [Hz]', 'SF [c/d]', 'Vel[d/s]', 'nTrials'); +fprintf('%s\n', repmat('-', 1, 52)); +for a = 1:angleN + for t = 1:tfN + for s = 1:sfN + mask = abs(C(:,2)-uAngle(a))<1e-3 & ... + abs(C(:,3)-uTF(t))<1e-4 & ... + abs(C(:,4)-uSF(s))<1e-4; + if sum(mask) > 0 + fprintf('%-8.0f %-8.2f %-8.4f %-8.1f %d\n', ... + uAngle(a), uTF(t), uSF(s), uTF(t)/uSF(s), sum(mask)); + end + end + end +end +fprintf('%s\n\n', repmat('=', 1, 52)); + +% --- Angle-block boundaries (robust to imbalanced designs) --- +% Scanning sorted C for angle transitions avoids the assumption that every +% angle has the same number of trials. +% +% angleChangeIdx: (angleN+1)-vector where: +% angleChangeIdx(a) = first row of angle block a (1-based into C) +% angleChangeIdx(a+1) = first row of angle block a+1, or nT+1 for the last +angleChangeIdx = [find([true; diff(C(:,2)) ~= 0]); nT + 1]; +nTrialsPerAngle = diff(angleChangeIdx); % [angleN x 1] +angleMidpoints = angleChangeIdx(1:end-1) + (nTrialsPerAngle-1)/2; % midpoint rows + +% ========================================================================== +% 5. NEURON SELECTION +% ========================================================================== + +if ~isempty(params.exNeuronsPhyID) + [found, neuronIdx] = ismember(params.exNeuronsPhyID, phy_IDg); + if any(~found) + warning('Phy IDs not found in good units (skipped): %s', ... + num2str(params.exNeuronsPhyID(~found))); + end + params.exNeurons = neuronIdx(found); + fprintf(' Converted phy IDs [%s] -> unit indices [%s]\n', ... + num2str(params.exNeuronsPhyID(found)), num2str(params.exNeurons)); +end + +if params.AllSomaticNeurons + eNeuron = 1:size(goodU, 2); +elseif params.AllResponsiveNeurons + eNeuron = find(pvalsMin < 0.05); + if isempty(eNeuron) + fprintf('No responsive neurons (min p < 0.05 across both phases).\n'); return + end +else + eNeuron = params.exNeurons; +end + +% ========================================================================== +% 6. BUILD RASTER MATRIX +% ========================================================================== + +% Mr: [nTrials x nSelectedNeurons x nBins] +% Window = preBase + staticDur + movingDur + preBase, in params.bin ms bins. +% Column 1 of Mr = preBase ms before static onset (= beginning of window). +% Column preBase/params.bin of Mr = static onset. +% Column (preBase+staticDur)/params.bin of Mr = moving onset. +Mr = BuildBurstMatrix( ... + goodU(:, eNeuron), ... + round(p.t / params.bin), ... + round((directimesSorted - preBase) / params.bin), ... + round((totalStimDur + preBase*2) / params.bin)); + +if params.Gaussian + Mr = ConvBurstMatrix(Mr, fspecial('gaussian', [1 params.GaussianLength], 3), 'same'); +end + +channels = goodU(1, eNeuron); +[~, ~, nBins] = size(Mr); + +% ========================================================================== +% 7. PER-NEURON FIGURE LOOP +% ========================================================================== +% +% INDEXING: +% u = absolute index into goodU (goodU(:,u), phy_IDg(u), pvalsS(u)) +% ur = relative index into eNeuron (Mr(:,ur,:), channels(ur)) +% ur must be incremented in every code path. + +ur = 1; + +for u = eNeuron + + fig = figure; + + % ------------------------------------------------------------------ + % 7a. 2-D merged raster [nTrials x nBins] + % ------------------------------------------------------------------ + + mergeTrials = params.MergeNtrials; + Mr2 = zeros(nT, nBins); + + if mergeTrials > 1 + for i = 1:mergeTrials:nT + meanb = mean(squeeze(Mr(i:min(i+mergeTrials-1, end), ur, :)), 1); + Mr2(i:i+mergeTrials-1, :) = repmat(meanb, [mergeTrials 1]); + end + else + Mr2 = squeeze(Mr(:, ur, :)); % [nTrials x nBins] + end + + if sum(Mr2, 'all') == 0 + close(fig); ur = ur + 1; continue + end + + % ================================================================== + % PANEL 2 (rows 6-16): Combined raster + % ================================================================== + + ax_raster = subplot(18, 1, [6 14]); + + imagesc(Mr2 .* (1000 / params.bin)); % Display in spk/s + colormap(flipud(gray(64))); + hold on; + + % --- Vertical time markers --- + % x = 0 corresponds to the start of the preBase buffer. + % x = preBase/params.bin corresponds to the static onset. + xMax = round(totalStimDur + preBase*2) / params.bin; + xline(preBase / params.bin, 'k', 'LineWidth', 1.5); % Stim on + xline((preBase + staticDur) / params.bin, '--k', 'LineWidth', 1.2); % Phase transition + xline((preBase + totalStimDur) / params.bin, 'k', 'LineWidth', 1.5); % Stim off + + if params.MaxVal_1, caxis([0 1]); end + + % --- Angle-boundary horizontal lines --- + for a = 2:angleN + yline(angleChangeIdx(a) - 0.5, 'k', 'LineWidth', 2); + end + + % Phase labels above the raster + yAbove = nT + nT * 0.07; + text(preBase/params.bin + (staticDur/params.bin)/2, yAbove, 'Static', ... + 'HorizontalAlignment', 'center', 'FontSize', 7, ... + 'FontName', 'helvetica', 'Clipping', 'off'); + text((preBase+staticDur)/params.bin + (movingDur/params.bin)/2, yAbove, 'Moving', ... + 'HorizontalAlignment', 'center', 'FontSize', 7, ... + 'FontName', 'helvetica', 'Clipping', 'off'); + + xlim([0, xMax]); + xticks([0, preBase/params.bin : 600/params.bin : xMax, ... + round((totalStimDur+preBase*2)/100)*100 / params.bin]); + xticklabels([]); + + % Y-ticks: one per angle block, at block midpoint, labeled with trial count + if numel(uAngle) ~=1 + yticks(angleMidpoints); + yticklabels(arrayfun(@num2str, nTrialsPerAngle, 'UniformOutput', false)); + end + + ax_raster.YAxis.FontSize = 8; + ax_raster.YAxis.FontName = 'helvetica'; + ylabel('Trials', 'FontSize', 10, 'FontName', 'helvetica'); + + % ================================================================== + % RIGHT-SIDE ANGLE LABELS + % ================================================================== + + colGap = xMax * 0.06; + tickLen = colGap * 0.40; + xCol = xMax + colGap * 0.5; + + text(xCol + tickLen, 0, 'Angle', ... + 'FontSize', 5.5, 'FontWeight', 'bold', 'FontName', 'helvetica', ... + 'HorizontalAlignment', 'center', 'VerticalAlignment', 'bottom', 'Clipping', 'off'); + + for a = 1:angleN + rowStart = angleChangeIdx(a); + rowEnd = angleChangeIdx(a+1) - 1; + yMid = angleMidpoints(a); + yT = rowStart - 0.5; + yB = rowEnd + 0.5; + + line([xCol, xCol + tickLen], [yT, yT], ... + 'Color', [0.4 0.4 0.4], 'LineWidth', 0.5, 'Clipping', 'off'); + line([xCol, xCol], [yT, yB], ... + 'Color', [0.4 0.4 0.4], 'LineWidth', 0.5, 'Clipping', 'off'); + line([xCol, xCol + tickLen], [yB, yB], ... + 'Color', [0.4 0.4 0.4], 'LineWidth', 0.5, 'Clipping', 'off'); + + text(xCol + tickLen * 1.5, yMid, sprintf('%.0f°', uAngle(a)), ... + 'FontSize', 7, 'FontWeight', 'bold', 'FontName', 'helvetica', ... + 'HorizontalAlignment', 'left', 'VerticalAlignment', 'middle', 'Clipping', 'off'); + end + + % ================================================================== + % 7b. Identify best response window + % ================================================================== + + if params.SelectedWindow + + % Find the angle block with the highest mean firing rate + meanMr = zeros(1, angleN); + for a = 1:angleN + meanMr(a) = mean(Mr2(angleChangeIdx(a):angleChangeIdx(a+1)-1, :), 'all'); + end + [~, bestAngle_a] = max(meanMr); + + % All trial indices for the best angle block + trials = angleChangeIdx(bestAngle_a) : angleChangeIdx(bestAngle_a+1) - 1; + + window = 500; % Sliding-window width (ms) + + % Mr2 rows for the best angle block [nTrialsInBlock x nBins] + X = Mr2(trials, :); + X(X > 1) = 1; % Clip outliers before the search + + [n_rows, n_cols] = size(X); + nWinPos = n_cols - round(window / params.bin) + 1; + + % Per-trial mean inside every sliding window [n_rows x nWinPos] + window_means = zeros(n_rows, nWinPos); + for col = 1:nWinPos + window_means(:, col) = mean(X(:, col : col+round(window/params.bin)-1), 2); + end + + % Best (trial, window-start) pair + [~, linear_idx] = max(window_means(:)); + [best_row, best_col] = ind2sub(size(window_means), linear_idx); + + % 'start': window start position in FULL-WINDOW coordinates. + % This is in the same coordinate system as Mr2 columns: + % start = 0 -> very beginning of the preBase buffer + % start = preBase -> static onset + % start = preBase+staticDur -> moving onset + % (i.e., preBase is already included in 'start') + start = best_col * params.bin; % (ms, from beginning of recording window) + + bestPhase = 'Static'; + if start >= preBase + staticDur + bestPhase = 'Moving'; + elseif start >= preBase + bestPhase = 'Static (stim)'; + end + + else + [~, bestPhaseIdx] = max([ ... + max(NeuronResp.Static.NeuronVals(u, :, 4)), ... + max(NeuronResp.Moving.NeuronVals(u, :, 4))]); + phaseNames = ["Static", "Moving"]; + bestPhase = phaseNames(bestPhaseIdx); + [~, maxRespIn] = max(NeuronResp.(bestPhase).NeuronVals(u, :, 4)); + start = NeuronResp.(bestPhase).NeuronVals(u, maxRespIn, 3) * ... + NeuronResp.params.binRaster - 20; + window = 500; + bestAngleVal = NeuronResp.(bestPhase).NeuronVals(u, maxRespIn, 6); + bestAngle_a = find(abs(uAngle - bestAngleVal) < 1e-3, 1); + if isempty(bestAngle_a), bestAngle_a = 1; end + trials = angleChangeIdx(bestAngle_a) : angleChangeIdx(bestAngle_a+1) - 1; + best_row = 1; % FIX Bug 1: conservative default + end + + RasterTrials = trials(best_row); % Absolute trial index of the best single trial + + % Grey band: full time extent of the best angle block + if numel(uAngle) ~=1 + yBandTop = angleChangeIdx(bestAngle_a) - 0.5; + yBandBot = angleChangeIdx(bestAngle_a+1) - 0.5; + patch([0, xMax, xMax, 0], [yBandTop, yBandTop, yBandBot, yBandBot], ... + 'k', 'FaceAlpha', 0.1, 'EdgeColor', 'none'); + end + + % --- Red patch: best trial x best response window --- + % + % FIX (Bug 10): 'start' is already in full-window coordinates (column + % index of Mr2 * params.bin), so the raster x-positions are simply + % start/params.bin and (start+window)/params.bin. + % + % The previous code used (preBase+start)/params.bin, which added an + % extra preBase offset and shifted the patch to the right by + % preBase/params.bin bins — misaligning it with the raw trace below. + + patch([start/params.bin, (start+window)/params.bin, ... + (start+window)/params.bin, start/params.bin], ... + [RasterTrials-0.5, RasterTrials-0.5, RasterTrials+0.5, RasterTrials+0.5], ... + 'r', 'FaceAlpha', 0.3, 'EdgeColor', 'none'); + + % ================================================================== + % PANEL 3 (rows 17-18): PSTH for the best angle block + % ================================================================== + + ax_psth = subplot(18, 1, [16 18]); + + MRhist = BuildBurstMatrix(goodU(:, u), round(p.t), ... + round(directimesSorted - preBase), round(totalStimDur + preBase*2)); + MRhist = squeeze(MRhist(trials, :, :)); % [nTrialsInBestAngle x windowDur_ms] + + [nT2, nB2] = size(MRhist); + spikeTimes = repmat(1:nB2, nT2, 1); + spikeTimes = spikeTimes(logical(MRhist)); + + binWidth = 125; + if nBins > 300, binWidth = 250; end + + edges = 1:binWidth:round(totalStimDur + preBase*2); + psthCounts = histcounts(spikeTimes, edges); + psthRate = (psthCounts / (binWidth * nT2)) * 1000; % [spk/s] + + b = bar(edges(1:end-1), psthRate, 'histc'); + b.FaceColor = 'k'; b.FaceAlpha = 0.3; b.MarkerEdgeColor = 'none'; + + xlim([0, round((totalStimDur + preBase*2) / 100) * 100]); + + try + ylim([0, max(psthRate) + std(psthRate)]); + catch + close(fig); ur = ur + 1; continue + end + + xticks([0, preBase:600:(totalStimDur+preBase*2), ... + round((totalStimDur+preBase*2)/100)*100]); + xline(preBase, 'LineWidth', 1.5); + xline(preBase + staticDur, '--', 'LineWidth', 1.2); + xline(preBase + totalStimDur, 'LineWidth', 1.5); + xticklabels([-(preBase), 0:600:round((totalStimDur/100))*100, ... + round((totalStimDur/100))*100 + 2*preBase] ./ 1000); + + ax_psth.XAxis.FontSize = 8; ax_psth.XAxis.FontName = 'helvetica'; + ax_psth.YAxis.FontSize = 8; ax_psth.YAxis.FontName = 'helvetica'; + ylabel('[spk/s]', 'FontSize', 10, 'FontName', 'helvetica'); + xlabel('Time [s]', 'FontSize', 10, 'FontName', 'helvetica'); + + ylims = ylim; + yticks([round(ylims(2)/10)*5, ceil(ylims(2)/10)*10]); + + % ================================================================== + % PANEL 1 (rows 1-3): Raw AP/LFP trace for the best single trial + % ================================================================== + + if params.plotRaw + chan = goodU(1, u); + + subplot(18, 1, [1 3]); + + % --- Raw trace start time --- + % + % 'start' is in full-window ms (0 = beginning of preBase buffer, i.e. + % preBase ms before static onset). + % + % Absolute time of static onset for the best trial: + % staticOnset_best = directimesSorted(RasterTrials) + % + % The window starts at: + % staticOnset_best - preBase (the recording window start) + % + % The best response window starts 'start' ms into that recording window: + % startTimes = (staticOnset_best - preBase) + start + % = staticOnset_best + start - preBase + % + % This is the same absolute time the red patch begins at on the raster, + % so the raw trace and the patch are now guaranteed to be aligned. + + startTimes = directimesSorted(RasterTrials) + start - preBase; % (ms, absolute) + + spikes = squeeze(BuildBurstMatrix(goodU(:,u), round(p.t), round(startTimes), round(window))); + + [fig, ~, ~] = PlotRawDataNP(obj, fig=fig, chan=chan, ... + startTimes=startTimes, window=window, spikeTimes=spikes); + + ax_raw = gca; + ax_raw.YAxis.FontSize = 8; ax_raw.YAxis.FontName = 'helvetica'; + ax_raw.XAxis.FontSize = 8; ax_raw.XAxis.FontName = 'helvetica'; + ax_raw.XRuler.TickDirection = 'out'; + ax_raw.XAxisLocation = 'bottom'; + + xlims = xlim; + xticks(0:(xlims(2)/5):xlims(2)); + xticklabels(0:100:window); + + % --- Stimulus-event markers in the raw trace (FIX Bug 11) --- + % + % The raw trace runs from absolute time 'startTimes' to 'startTimes+window'. + % The x-axis is mapped linearly to [0, window] ms by the tick labels above. + % Scale factor converts ms offsets to x-axis units. + % + % Event offsets from the trace start (ms): + % static onset : directimesSorted(RasterTrials) - startTimes + % = directimesSorted(RasterTrials) - (directimesSorted(RasterTrials) + start - preBase) + % = preBase - start + % phase transition : preBase - start + staticDur + % stim offset : preBase - start + totalStimDur + % + % Only draw events that fall inside [0, window] ms. + % + % (The original 'xline(-start/1000)' divided ms by 1000 suggesting a + % seconds axis but the tick labels are in ms — it reliably produced a + % marker at ~0 ms regardless of 'start', which was incorrect.) + + scale = xlims(2) / window; % x-axis units per ms (handles both ms and s axes) + + evOffsets = [preBase - start, ... % static onset + preBase - start + staticDur, ... % static->moving transition + preBase - start + totalStimDur]; % stim offset + evStyles = {'k', '--k', 'k' }; + evWidths = [1.5, 1.2, 1.5 ]; + + for ev = 1:3 + xEv = evOffsets(ev) * scale; + if xEv >= xlims(1) && xEv <= xlims(2) + xline(xEv, evStyles{ev}, 'LineWidth', evWidths(ev)); + end + end + + xlabel('Time [ms]', 'FontName', 'helvetica', 'FontSize', 10); + ylabel('[\muV]', 'FontSize', 10, 'FontName', 'helvetica'); + + end + + % Title: identity + best angle + per-phase p-values + bestAngleVal = C(RasterTrials, 2); + bestTF = C(RasterTrials, 3); + bestSF = C(RasterTrials, 4); + + title({ ... + sprintf('U.%d Chan-%d Phy-%d | pS=%.4f pM=%.4f', ... + u, channels(ur), phy_IDg(u), pvalsS(u), pvalsM(u)), ... + sprintf('Best angle: %.0f° (TF=%.1f Hz, SF=%.3f c/d at best trial) [%s]', ... + bestAngleVal, bestTF, bestSF, bestPhase) ... + }); + + % ================================================================== + % AXES POSITION ADJUSTMENT + % ================================================================== + % Shrink raster and PSTH by the same factor to keep their time axes + % aligned while making room for the angle-label column on the right. + + shrinkFactor = 0.85; + pos = ax_raster.Position; + ax_raster.Position = [pos(1), pos(2), pos(3)*shrinkFactor, pos(4)]; + pos = ax_psth.Position; + ax_psth.Position = [pos(1), pos(2), pos(3)*shrinkFactor, pos(4)]; + + % ================================================================== + % 7c. Figure layout and export + % ================================================================== + + set(fig, 'Units', 'centimeters'); + set(fig, 'Position', [20 20 9 12]); + + if params.PaperFig + obj.printFig(fig, sprintf('%s-Grating-CombinedRaster-Unit%d', ... + obj.dataObj.recordingName, u), 'PaperFig', params.PaperFig); + elseif params.overwrite + obj.printFig(fig, sprintf('%s-Grating-CombinedRaster-Unit%d', ... + obj.dataObj.recordingName, u)); + end + + %if ur ~= length(eNeuron), close(fig); end + + ur = ur + 1; % MUST be reached in every code path above + +end % end neuron loop + +end % end plotRaster \ No newline at end of file diff --git a/visualStimulationAnalysis/@VStimAnalysis/BootstrapPerNeuron.m b/visualStimulationAnalysis/@VStimAnalysis/BootstrapPerNeuron.m index 7c5c987..e1b97d1 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/BootstrapPerNeuron.m +++ b/visualStimulationAnalysis/@VStimAnalysis/BootstrapPerNeuron.m @@ -3,8 +3,8 @@ arguments (Input) obj params.nBoot = 10000 - params.EmptyTrialPerc = 0.6 - params.FilterEmptyResponses = false + params.EmptyTrialPerc = 0.7 %If empty trials per category are higher than EmptyTrialPerc then filter + params.FilterEmptyResponses = true params.overwrite = false end % Computes per-neuron z-scores of stimulus responses vs baseline using bootstrap @@ -24,10 +24,61 @@ p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); label = string(p.label'); goodU = p.ic(:,label == 'good'); %somatic neurons +responseParams = obj.ResponseWindow; + if isempty(goodU) warning('%s has No somatic Neurons, skipping experiment/n',obj.dataObj.recordingName) + results = []; + fprintf('Saving results to file.\n'); + if isequal(obj.stimName, 'linearlyMovingBall') + % S.(fieldName).BootResponse = respBoot; + % S.(fieldName).BootBaseline = baseBoot; + S.Speed1.BootDiff = []; + S.Speed1.pvalsResponse = []; + S.Speed1.ZScoreU = []; + S.Speed1.ObsDiff = []; + S.Speed1.ObsReponse = []; + S.Speed1.ObsBaseline = []; + + if isfield(responseParams, "Speed2") + S.Speed2.BootDiff = []; + S.Speed2.pvalsResponse = []; + S.Speed2.ZScoreU = []; + S.Speed2.ObsDiff = []; + S.Speed2.ObsReponse = []; + S.Speed2.ObsBaseline = []; + end + elseif isequal(obj.stimName,'StaticDriftingGrating') + % S.(fieldName).BootResponse = respBoot; + % S.(fieldName).BootBaseline = baseBoot; + S.Moving.BootDiff = []; + S.Moving.pvalsResponse = []; + S.Moving.ZScoreU = []; + S.Moving.ObsDiff = []; + S.Moving.ObsReponse = []; + S.Moving.ObsBaseline = []; + + S.Static.BootDiff = []; + S.Static.pvalsResponse = []; + S.Static.ZScoreU = []; + S.Static.ObsDiff = []; + S.Static.ObsReponse = []; + S.Static.ObsBaseline = []; + else + % S.BootResponse = respBoot; + % S.BootBaseline = baseBoot; + S.BootDiff = []; + S.pvalsResponse = []; + S.ZScoreU = []; + S.ObsDiff = []; + S.ObsReponse = []; + S.ObsBaseline = []; + end + + S.params = params; + save(obj.getAnalysisFileName,'-struct', 'S'); return end @@ -40,7 +91,6 @@ end -responseParams = obj.ResponseWindow; %%If it is a moving stimulus with speed cathegories if isfield(responseParams, "Speed1") @@ -90,6 +140,11 @@ directimesSorted = Times.(fieldName); stimDur = Durations.(fieldName); + end + + if isequal(obj.stimName, 'linearlyMovingBall') + + end %Mr = BuildBurstMatrix(goodU,round(p.t),round(directimesSorted),round(stimDur+ responseParams.params.durationWindow)); %response matrix @@ -108,31 +163,30 @@ for i=1:trialsCat:size(Mr,1) for u = 1:size(goodU,2) - tempM = responses(i:i+trialsCat-1,u); - emptyRows = all(tempM == 0, 2); - perc = sum(emptyRows) / size(tempM,1); + emptyRows = all(responses(i:i+trialsCat-1, u) == 0, 2); + perc = sum(emptyRows) / trialsCat; if perc >= params.EmptyTrialPerc - responses(i:i+trialsCat-1, u) = zeros(1,trialsCat); - baselines(i:i+trialsCat-1, u) = zeros(1,trialsCat);% Store z-scores for neurons with sufficient trials + rowsToRemove = [rowsToRemove; (i:i+trialsCat-1)']; % collect indices end end end end - Diff = responses - baselines; - bootDiff = bootstrp(params.nBoot,@mean,Diff); + Diff(rowsToRemove, :) = []; % remove before permutation test - pVal = mean(bootDiff <= 0); - %Test the proportion of times the difference is greater or equal than 0 - bootBase = bootstrp(params.nBoot,@mean,baselines); - stdDiff = std(bootDiff); - - stdBase = std(bootBase); + % Generate all sign matrices at once: [nTrials × nBoot] + signs = 2 * randi(2, size(Diff,1), params.nBoot) - 3; + + % Matrix multiply to get all null means at once: [nBoot × nNeurons] + nullDist = (signs' * Diff) / size(Diff, 1); + + pVal = mean(nullDist >= ObsMeanDiff); - z = mean(bootDiff,1) ./ stdDiff; + % True z-score: normalize by baseline variability + z = mean(Diff, 1) ./ std(baselines, 1); if isfield(responseParams, "Speed1") @@ -178,3 +232,4 @@ % p_sh = mean(D_sh <= 0); end +%% diff --git a/visualStimulationAnalysis/@VStimAnalysis/DirectionTuning.m b/visualStimulationAnalysis/@VStimAnalysis/DirectionTuning.m new file mode 100644 index 0000000..eb91a54 --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/DirectionTuning.m @@ -0,0 +1,483 @@ +function results = DirectionTuning(vsObj, params) +% DirectionTuning Compute orientation & direction selectivity indices for +% a single experiment from the ResponseWindow of any stimulus object that +% contains a 'direction' category in its condition matrix. +% +% OSI is computed via the circular-variance method (1 − CV at 2θ): +% OSI = |Σ R(θ) · e^(i·2θ)| / Σ R(θ) +% equivalent to equation (4) in Ringach et al. (2002) J Neurosci 22:5639 +% and equation (2) in Mazurek et al. (2014) Front Neural Circuits 8:130. +% +% DSI is computed analogously at 1θ: +% DSI = |Σ R(θ) · e^(i·θ)| / Σ R(θ) +% which is the standard direction selectivity metric (Mazurek et al. 2014). +% +% Preferred direction is the angle of the resultant vector at 1θ. +% Preferred orientation is the angle of the resultant vector at 2θ, +% divided by 2 to map back to orientation space. +% +% INPUTS +% vsObj – any stimulus analysis object (e.g. linearlyMovingBallAnalysis, +% StaticDriftingGratingAnalysis) that has a ResponseWindow method. +% params – name-value pairs (see arguments block). +% +% OUTPUTS +% results – struct with fields: +% .tuningCurve [nN × nDir] mean spike rate per direction +% .tuningCurveSEM [nN × nDir] SEM across trials per direction +% .OSI [nN × 1] orientation selectivity index +% .DSI [nN × 1] direction selectivity index +% .prefDirRad [nN × 1] preferred direction (radians) +% .prefDirDeg [nN × 1] preferred direction (degrees) +% .prefOriRad [nN × 1] preferred orientation (radians) +% .prefOriDeg [nN × 1] preferred orientation (degrees) +% .uDirRad [1 × nDir] unique directions in radians (sorted) +% .uDirDeg [1 × nDir] unique directions in degrees (sorted) +% .nTrialsPerDir [nN × nDir] trial count per neuron × direction +% .goodU [2 × nGood] ic columns of good somatic units +% .phyID [nGood × 1] phy cluster IDs of good units +% .respMask [nN × 1] logical: neuron is responsive (p < threshold) +% .pvals [nN × 1] p-values from StatisticsPerNeuron +% .params struct copy of params used for this run +% +% EXAMPLE +% NP = loadNPclassFromTable(49); +% obj = StaticDriftingGratingAnalysis(NP); +% res = DirectionTuning(obj, 'fieldName', 'Moving'); +% +% REFERENCES +% Ringach DL et al. (2002) J Neurosci 22:5639-5651 +% Mazurek M et al. (2014) Front Neural Circuits 8:130 +% +% See also: AllExpDirectionTuning, hierBoot, plotSwarmBootstrapWithComparisons + +% ========================================================================= +% ARGUMENTS BLOCK +% ========================================================================= +arguments + vsObj % stimulus analysis object (handle class) + + % --- Which subfield of ResponseWindow to use --- + params.fieldName string = "auto" % e.g. 'Speed1', 'Moving'. "auto" attempts detection. + + % --- Bin size for BuildBurstMatrix --- + params.bin double = 1 % bin size in ms. Default 1 ms gives spike + % counts at 1 ms resolution; increase (e.g. + % 10 ms) to reduce memory and compute time. + + % --- Responsiveness filter --- + params.threshold double = 0.05 % p-value threshold for responsive neurons + + % --- Statistics --- + params.overwriteRW logical = false % force recomputation of ResponseWindow + params.overwriteStats logical = false % force recomputation of StatisticsPerNeuron + + % --- Saving --- + params.save logical = true % save results to disk + params.overwrite logical = false % overwrite existing saved results +end + +% ========================================================================= +% 1. LOAD RESPONSE WINDOW AND SPIKE SORTING +% ========================================================================= + +% Compute or load the ResponseWindow for this stimulus object +vsObj.ResponseWindow('overwrite', params.overwriteRW); % ensures cached result exists +rw = vsObj.ResponseWindow; % retrieve the cached struct + +% Compute or load per-neuron statistics (p-values for responsiveness) +vsObj.StatisticsPerNeuron('overwrite', params.overwriteStats); +Stats = vsObj.StatisticsPerNeuron; % retrieve cached statistics + +% ========================================================================= +% 2. RESOLVE FIELD NAME (Speed1 / Speed2 / Moving / Static / flat) +% ========================================================================= + +% Determine which subfield of the ResponseWindow struct contains NeuronVals +fieldName = resolveFieldName(rw, vsObj, params.fieldName); + +% ========================================================================= +% 3. EXTRACT SPIKE SORTING AND GOOD UNITS +% (done here, before the burst matrix, since goodU is needed for it) +% ========================================================================= + +% Convert phy spike sorting to spike train matrix and unit metadata +p = vsObj.dataObj.convertPhySorting2tIc(vsObj.spikeSortingFolder); +label = string(p.label'); % quality label per unit +goodU = p.ic(:, label == 'good'); % [2 × nGood] spike train index matrix +phyID = p.phy_ID(label == 'good'); % [nGood × 1] phy cluster IDs +nN = size(goodU, 2); % number of good somatic units + +% ========================================================================= +% 4. EXTRACT C MATRIX AND IDENTIFY DIRECTION COLUMN +% ========================================================================= + +% The C matrix holds one row per trial with columns: +% C(:,1) = trial onset times (ms) +% C(:,2:end) = condition parameter values (matching colNames) +% +% colNames = rw.colNames{1}(5:end) are the condition parameter names, +% skipping the first 4 internal NeuronVals features (nSpks, duration, +% spikeRate, baseline). The offset between colNames and C is 1 because +% C(:,1) holds onset times, so colNames{k} → C(:, k+1). + +% Access C from the resolved subfield +if isempty(fieldName) + C = rw.C; % flat struct (no subfield) +else + C = rw.(fieldName).C; % subfield struct (SDGm, MB, etc.) +end + +colNames = rw.colNames{1}(5:end); % condition parameter names (cell array) + +% Find the direction column: search colNames for 'direction' or 'angles' +catIdx = find(strcmpi(colNames, 'directions') | strcmpi(colNames, 'angles'), 1); +if isempty(catIdx) + error('DirectionTuning:noDirCol', ... + 'Could not find "directions" or "angles" in colNames: {%s}.\n', ... + strjoin(colNames, ', ')); +end +catColIdx = catIdx + 1; % +1 because C(:,1) is onset times + +fprintf('Direction column: colNames{%d} = "%s" → C(:,%d)\n', ... + catIdx, colNames{catIdx}, catColIdx); + +% ========================================================================= +% 5. NORMALISE DIRECTION VALUES AND BUILD TUNING CURVE +% ========================================================================= + +dirRaw = C(:, catColIdx); % raw direction values per trial [nTrials × 1] + +% Normalise direction values to radians in [0, 2π). +% +% SDGm / SDGs require a remapping step before normalisation because gratings +% store directions in a different degree convention than MB: +% SDG 0° → π/2 rad (MB "up") +% SDG 90° → 0 rad (MB "right") +% SDG 180° → 3π/2 rad (MB "down") +% SDG 270° → π rad (MB "left") +% Without this, OSI/DSI would be numerically correct for each stimulus +% independently but the preferred-direction angles would not be comparable +% across SDG and MB in cross-stimulus analyses. +if contains(class(vsObj), 'StaticDrifting', 'IgnoreCase', true) + % Lookup table: [SDG_degree → MB_radian] + sdgMap = [ 0, pi/2; ... % 0° → π/2 + 90, 0; ... % 90° → 0 + 180, 3*pi/2; ... % 180° → 3π/2 + 270, pi]; % 270° → π + + dirRemapped = nan(size(dirRaw)); % pre-fill with NaN to catch unmapped values + for k = 1:size(sdgMap, 1) + mask = abs(dirRaw - sdgMap(k, 1)) < 1e-6; % trials at this SDG degree value + dirRemapped(mask) = sdgMap(k, 2); % assign the corresponding MB radian + end + + % Warn if any trial has a direction value not covered by the map + if any(isnan(dirRemapped)) + warning('DirectionTuning:unmappedSDGdir', ... + 'Some SDGm/SDGs direction values could not be remapped: [%s] deg. ' + ... + 'Extend sdgMap if the stimulus uses additional directions.', ... + num2str(unique(dirRaw(isnan(dirRemapped)))', '%.4g ')); + end + + dirNorm = mod(dirRemapped, 2*pi); % normalise to [0, 2π); values already radians + fprintf('SDGm/SDGs: directions remapped to MB radian convention.\n'); + +else + % All other stimuli: detect whether values are in degrees or radians. + % If any absolute value exceeds 2π + a small tolerance, assume degrees. + if max(abs(dirRaw)) > 2*pi + 0.1 + dirNorm = mod(deg2rad(dirRaw), 2*pi); % convert degrees → radians, normalise + fprintf('Directions detected as degrees → converted to radians.\n'); + else + dirNorm = mod(dirRaw, 2*pi); % already radians, normalise to [0, 2π) + end +end + +% Unique directions after normalisation. +% uniquetol groups values within tol of each other — handles the case where +% 0 and 2π (or 0 and 360°) are stored as distinct but equivalent values. +dirTol = 1e-4; % tolerance in radians (~0.006°) +uDirRad = uniquetol(dirNorm, dirTol)'; % [1 × nDir] sorted unique directions +nDir = numel(uDirRad); % number of unique directions + +assert(nDir >= 3, 'DirectionTuning:tooFewDirs', ... + 'Only %d unique directions found — need >= 3 for OSI/DSI.', nDir); + +fprintf('Found %d unique directions: [%s] deg\n', ... + nDir, num2str(rad2deg(uDirRad), '%.1f ')); + +% Get stimulus duration and inter-stimulus interval from ResponseWindow +stimDur = rw.(fieldName).stimDur; % stimulus duration (ms) +stimInter = rw.stimInter; % inter-stimulus interval (ms) + +% Build the burst matrix from raw spike trains. +% Arguments (all in bins = ms when params.bin == 1): +% goodU : spike train index matrix +% round(p.t/bin) : total recording length in bins +% round(C(:,1)/bin) : trial onset times in bins +% round(stimDur/bin) : window size in bins (response window = stimulus duration) +% +% NOTE: No pre-stimulus offset is added here — window starts at trial onset. +% If a baseline period is needed, extend the window and offset trial times. +% For OSI/DSI we only need the response during the stimulus. +% +% Mr dimensions: [nTrials × nNeurons × nBins] +bin = params.bin; % ms per bin (local alias for readability) +Mr = BuildBurstMatrix(goodU, round(p.t / bin), ... + round(C(:,1)' / bin), ... % trial onset times in bins + round(stimDur / bin)); % window = stimulus duration in bins + +% Pre-allocate tuning curve arrays +tuningCurve = zeros(nN, nDir); % [nN × nDir] mean spike rate (spks/s) +tuningCurveSEM = zeros(nN, nDir); % [nN × nDir] SEM across trials +nTrialsPerDir = zeros(1, nDir); % [1 × nDir] trial count per direction + +for d = 1:nDir + % Logical mask: trials belonging to this direction (within tolerance) + trialMask = abs(dirNorm - uDirRad(d)) < dirTol; % [nTrials × 1] logical + nTr = sum(trialMask); % number of trials for this direction + + % Sum spike counts across all bins within the stimulus window. + % Mr(trialMask, :, :) is [nTr × nN × nBins]; sum over dim 3 → [nTr × nN]. + spikeCounts = sum(Mr(trialMask, :, :), 3); % [nTr × nN] total spikes per trial + + % Convert spike counts to firing rate (spikes/s) + spikeRates_d = spikeCounts / (stimDur / 1000); % [nTr × nN] divide by duration in seconds + + % Mean spike rate across trials for each neuron + tuningCurve(:, d) = mean(spikeRates_d, 1)'; % [nN × 1] + + % SEM across trials (undefined for n=1, set to 0) + if nTr > 1 + tuningCurveSEM(:, d) = std(spikeRates_d, 0, 1)' / sqrt(nTr); % [nN × 1] + end + + nTrialsPerDir(d) = nTr; % store trial count for diagnostics +end + +% ========================================================================= +% 6. RECTIFY NEGATIVE RATES (GUARD FOR BASELINE-SUBTRACTED DATA) +% ========================================================================= + +% If rates were baseline-subtracted, some may be negative, which would make +% OSI/DSI uninterpretable (denominator could be zero or negative). +% Clamp to zero with a warning. +hasNegative = any(tuningCurve(:) < 0); % check for any negative rates +if hasNegative + warning('DirectionTuning:negativeRates', ... + 'Tuning curve contains negative rates (likely baseline-subtracted). ' + ... + 'Clamping to zero. Consider using raw (non-subtracted) spike rates.'); + tuningCurve = max(tuningCurve, 0); % clamp negatives to zero +end + +% ========================================================================= +% 7. COMPUTE OSI (CIRCULAR VARIANCE AT 2θ) +% ========================================================================= + +% OSI = |Σ_d R(θ_d) · e^(i·2·θ_d)| / Σ_d R(θ_d) +% +% This is 1 minus the circular variance of the response distribution at +% double the angle (so that 0° and 180° map to the same orientation). +% Range: [0, 1] where 0 = untuned, 1 = perfectly orientation-selective. + +% Sum of responses across all directions per neuron (denominator) +sumR = sum(tuningCurve, 2); % [nN × 1] + +% Numerator components: project responses onto sin(2θ) and cos(2θ) +sinComponent2 = sum(tuningCurve .* sin(2 * uDirRad), 2); % [nN × 1] +cosComponent2 = sum(tuningCurve .* cos(2 * uDirRad), 2); % [nN × 1] + +% OSI = magnitude of resultant vector / sum of responses +% Guard against division by zero: if sumR == 0, neuron is unresponsive → NaN +OSI = sqrt(sinComponent2.^2 + cosComponent2.^2) ./ sumR; % [nN × 1] + +% ========================================================================= +% 8. COMPUTE DSI (CIRCULAR VARIANCE AT 1θ) +% ========================================================================= + +% DSI = |Σ_d R(θ_d) · e^(i·θ_d)| / Σ_d R(θ_d) +% +% Same formula as OSI but without angle-doubling, so opposite directions +% do NOT collapse. Range: [0, 1] where 0 = no direction preference, +% 1 = responds to only one direction. + +% Numerator components: project responses onto sin(θ) and cos(θ) +sinComponent1 = sum(tuningCurve .* sin(uDirRad), 2); % [nN × 1] +cosComponent1 = sum(tuningCurve .* cos(uDirRad), 2); % [nN × 1] + +% DSI = magnitude of resultant vector at 1θ / sum of responses +DSI = sqrt(sinComponent1.^2 + cosComponent1.^2) ./ sumR; % [nN × 1] + +% ========================================================================= +% 9. COMPUTE PREFERRED DIRECTION AND ORIENTATION +% ========================================================================= + +% Preferred direction: angle of the resultant vector at 1θ. +% atan2 returns values in [−π, +π]; wrap to [0, 2π) for convenience. +prefDirRad = atan2(sinComponent1, cosComponent1); % [nN × 1] in (−π, π] +prefDirRad = mod(prefDirRad, 2*pi); % [nN × 1] in [0, 2π) +prefDirDeg = rad2deg(prefDirRad); % [nN × 1] in [0, 360) + +% Preferred orientation: angle of the resultant vector at 2θ, halved. +% Since the angle was doubled, dividing by 2 maps back to orientation space. +% Result is in [0°, 180°). +prefOriRad = atan2(sinComponent2, cosComponent2); % [nN × 1] resultant at 2θ +prefOriRad = mod(prefOriRad, 2*pi) / 2; % [nN × 1] in [0, π) +prefOriDeg = rad2deg(prefOriRad); % [nN × 1] in [0, 180) + +% ========================================================================= +% 10. EXTRACT P-VALUES AND RESPONSIVENESS MASK +% ========================================================================= + +% Get p-values for each neuron from StatisticsPerNeuron. +% The subfield structure depends on stimulus type (e.g., Stats.Moving, +% Stats.Speed1, or flat Stats.pvalsResponse). +pvals = extractPvals(Stats, fieldName); % [nN × 1] + +% Logical mask: which neurons are responsive at the given threshold +respMask = pvals < params.threshold; % [nN × 1] logical + +fprintf('Responsive neurons: %d / %d (p < %.3f)\n', ... + sum(respMask), nN, params.threshold); + +% ========================================================================= +% 11. GOOD-UNIT IDENTIFIERS +% (goodU and phyID were extracted in section 3 alongside p_sort) +% ========================================================================= +% goodU and phyID are already defined above. + +% ========================================================================= +% 12. ASSEMBLE OUTPUT STRUCT +% ========================================================================= + +results.tuningCurve = tuningCurve; % [nN × nDir] +results.tuningCurveSEM = tuningCurveSEM; % [nN × nDir] +results.OSI = OSI; % [nN × 1] +results.DSI = DSI; % [nN × 1] +results.prefDirRad = prefDirRad; % [nN × 1] +results.prefDirDeg = prefDirDeg; % [nN × 1] +results.prefOriRad = prefOriRad; % [nN × 1] +results.prefOriDeg = prefOriDeg; % [nN × 1] +results.uDirRad = uDirRad; % [1 × nDir] +results.uDirDeg = rad2deg(uDirRad); % [1 × nDir] +results.nTrialsPerDir = nTrialsPerDir; % [nN × nDir] +results.goodU = goodU; % [2 × nGood] +results.phyID = phyID; % [nGood × 1] +results.respMask = respMask; % [nN × 1] logical +results.pvals = pvals; % [nN × 1] +results.params = params; % copy of params used + +% ========================================================================= +% 13. SAVE TO DISK +% ========================================================================= + +if params.save + saveDir = fileparts(vsObj.getAnalysisFileName); % experiment analysis folder + saveName = sprintf('DirectionTuning_%s.mat', fieldName);% e.g. DirectionTuning_Moving.mat + savePath = fullfile(saveDir, saveName); % full path + + if ~exist(savePath, 'file') || params.overwrite + save(savePath, '-struct', 'results'); % save all fields as top-level vars + fprintf('Saved: %s\n', savePath); + else + fprintf('File exists (use overwrite=true to replace): %s\n', savePath); + end +end + +end % ===== END OF MAIN FUNCTION ===== + + +% ========================================================================= +% LOCAL HELPER FUNCTIONS +% ========================================================================= + + +function fieldName = resolveFieldName(rw, vsObj, requested) +% resolveFieldName Determine the correct subfield of ResponseWindow. +% +% "auto" logic: +% - StaticDriftingGratingAnalysis → 'Moving' (drifting phase) +% - linearlyMovingBall/Bar → highest Speed field (e.g. Speed2) +% - everything else → '' (flat struct, no subfield) +% +% If the user explicitly provides a field name, use it directly. + + if requested ~= "auto" + % User provided an explicit field name — trust it + fieldName = char(requested); % convert string to char + assert(isfield(rw, fieldName), ... + 'DirectionTuning:badField', ... + 'Field "%s" not found in ResponseWindow struct.', fieldName); + return + end + + % Auto-detect based on stimulus class name + className = class(vsObj); % e.g. 'StaticDriftingGratingAnalysis' + + if contains(className, 'StaticDrifting', 'IgnoreCase', true) + % For SDG, use the Moving (drifting) phase for direction tuning + fieldName = 'Moving'; + + elseif contains(className, {'linearlyMovingBall', 'linearlyMovingBar'}, 'IgnoreCase', true) + % For moving ball/bar, use the highest speed field available + fn = fieldnames(rw); % all field names + speedFields = fn(startsWith(fn, 'Speed')); % e.g. {'Speed1','Speed2'} + if isempty(speedFields) + error('DirectionTuning:noSpeed', ... + 'No Speed fields found in ResponseWindow for %s.', className); + end + fieldName = speedFields{end}; % highest speed (sorted alphabetically) + + else + % Generic stimulus — assume flat struct with NeuronVals at top level. + % Check common field names. + if isfield(rw, 'NeuronVals') + fieldName = ''; % will be handled downstream + else + % Try first available subfield that contains NeuronVals + fn = fieldnames(rw); + found = false; + for i = 1:numel(fn) + if isstruct(rw.(fn{i})) && isfield(rw.(fn{i}), 'NeuronVals') + fieldName = fn{i}; + found = true; + break + end + end + if ~found + error('DirectionTuning:noNeuronVals', ... + 'Cannot locate NeuronVals in ResponseWindow for %s.', className); + end + end + end + + fprintf('Auto-detected fieldName: "%s" (class: %s)\n', fieldName, className); +end + + +% (findDirectionColumn removed: direction column detection is now inline +% in section 4 using the C matrix and colNames directly.) + + +function pvals = extractPvals(Stats, fieldName) +% extractPvals Pull per-neuron p-values from the Statistics struct. +% +% Handles subfield structures: +% Stats.Moving.pvalsResponse (SDG) +% Stats.Speed1.pvalsResponse (MB) +% Stats.pvalsResponse (flat, e.g. RG/NI) + + if ~isempty(fieldName) && isfield(Stats, fieldName) && ... + isfield(Stats.(fieldName), 'pvalsResponse') + pvals = Stats.(fieldName).pvalsResponse; % subfield path + elseif isfield(Stats, 'pvalsResponse') + pvals = Stats.pvalsResponse; % flat path + else + warning('DirectionTuning:noPvals', ... + 'Cannot find pvalsResponse in Stats. Setting all p = 1 (no filter).'); + pvals = ones(size(Stats.ZScoreU)); % conservative: mark all as non-responsive + end + + pvals = pvals(:); % ensure column vector +end \ No newline at end of file diff --git a/visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.asv b/visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.asv deleted file mode 100644 index f5e3708..0000000 --- a/visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.asv +++ /dev/null @@ -1,1506 +0,0 @@ -function fig = PlotZScoreComparison(expList, Stims2Comp,params) - -arguments - expList (1,:) double %%Number of experiment from excel list - Stims2Comp cell %% Comparison order {'MB','RG','MBR'} would select neurons responsive to moving ball and - % compare this neurons responses to other stimuli. - params.threshold = 0.05 - params.diffResp = false - params.overwrite = false - params.StimsPresent = {'MB','RG'} %assumes that at least moving ball is present - params.StimsNotPresent = {} - params.StimsToCompare = {} %Select 2 stims to compare scatter plots (default: 1st and 2nd stim are compared from the Stims2Comp cell array) - params.overwriteResponse = false - params.overwriteStats = false - params.overwriteGroupStats = false - params.RespDurationWin = 100; %same as default - params.shuffles = 2000; %same as default - params.StatMethod = 'ObsWindow' - params.ignoreNonSignif = false %when comparing first stim, ignore neurons non responsive to other stim - params.EachStimSignif = false %resposnive neurons for each stim are selected (default: responsive neurons of first stime are selected) - params.ComparePairs = {}; %Compare only pairs, recommended - params.PaperFig logical = false -end - -% Compare z-scores and p-values between moving ball and rect grid analyses - -animal = 0; -insertion =0; -animalVector = cell(1,numel(expList)); -insertionVector = cell(1,numel(expList)); -zScoresMB = cell(1,numel(expList)); -zScoresRG = cell(1,numel(expList)); -spKrMB = cell(1,numel(expList)); -spKrRG = cell(1,numel(expList)); -diffSpkMB = cell(1,numel(expList)); -diffSpkRG = cell(1,numel(expList)); - -zScoresSDGm = cell(1,numel(expList)); -zScoresMBR = cell(1,numel(expList)); -zScoresFFF = cell(1,numel(expList)); -spKrMBR = cell(1,numel(expList)); -spKrFFF = cell(1,numel(expList)); -spKrSDGm = cell(1,numel(expList)); -diffSpkMBR = cell(1,numel(expList)); -diffSpkFFF = cell(1,numel(expList)); -diffSpkSDGm = cell(1,numel(expList)); - -zScoresNI = cell(1,numel(expList)); -% zScoresNV = cell(1,numel(expList)); -spKrNI = cell(1,numel(expList)); -spKrNV = cell(1,numel(expList)); -diffSpkNI = cell(1,numel(expList)); -diffSpkNV = cell(1,numel(expList)); - -j = 1; -AnimalI = ""; -InsertionI = 0; - -NP = loadNPclassFromTable(expList(1)); %73 81 -vs = linearlyMovingBallAnalysis(NP); - -%%% Asumes all experiments were analyzed using the same window -vs.ResponseWindow; -MBvs = vs.ResponseWindow; -%%% - -nameOfFile = sprintf('\\Ex_%d-%d_Combined_Neural_responses_%s_filtered.mat',expList(1),expList(end),Stims2Comp{1}); -p = extractBefore(vs.getAnalysisFileName,'lizards'); -p = [p 'lizards']; - -if ~exist([p '\Combined_lizard_analysis'],'dir') - cd(p) - mkdir Combined_lizard_analysis -end -saveDir = [p '\Combined_lizard_analysis']; - -if exist([saveDir nameOfFile],'file') == 2 && ~params.overwrite - - S = load([saveDir nameOfFile]); - - expList2 = S.expList; - - if isequal(expList2,expList) - - forloop = false; - else - forloop = true; - end -else - forloop = true; -end - -longTablePairComp = table( ... - categorical.empty(0,1), ... - categorical.empty(0,1), ... - categorical.empty(0,1), ... - categorical.empty(0,1),... - double.empty(0,1), ... - double.empty(0,1), ... - 'VariableNames', {'animal','insertion','stimulus','NeurID','Z-score','SpkR'} ); - -longTable= table( ... - categorical.empty(0,1), ... - categorical.empty(0,1), ... - categorical.empty(0,1), ... - double.empty(0,1), ... - double.empty(0,1), ... - 'VariableNames', {'animal','insertion','stimulus','respNeur','totalSomaticN'} ); - -if forloop - for ex = expList - - fprintf('Processing recording: %s .\n',NP.recordingName) - NP = loadNPclassFromTable(ex); %73 81 - vs = linearlyMovingBallAnalysis(NP); - vsR = rectGridAnalysis(NP); - - %Assumes that RG and MB are present in all insertions - Animal = string(regexp( vs.getAnalysisFileName, 'PV\d+', 'match', 'once')); - longTable(end+1,:) = {categorical(Animal),categorical(j), categorical("MB"), 0,0}; - longTable(end+1,:) = {categorical(Animal),categorical(j), categorical("RG"), 0,0}; - - try - vsBr = linearlyMovingBarAnalysis(NP); - params.StimsPresent{3} = 'MBR'; - - if isempty(vsBr.VST) - error('Moving Bar stimulus not found.\n') - else - longTable(end+1,:) = {categorical(Animal),categorical(j), categorical("MBR"), 0,0}; - end - catch - params.StimsPresent{3} = ''; - fprintf('Moving Bar stimulus not found.\n') - vsBr = linearlyMovingBallAnalysis(NP); %use rectGrid here to avoid puting lots of ifs. - end - try - vsG = StaticDriftingGratingAnalysis(NP); - params.StimsPresent{4} = 'SDG'; - - if isempty(vsG.VST) - error('Gratings stimulus not found.\n') - else - longTable(end+1,:) = {categorical(Animal),categorical(j), categorical("SDGm"), 0,0}; - longTable(end+1,:) = {categorical(Animal),categorical(j), categorical("SDGs"), 0,0}; - end - catch - params.StimsPresent{4} = ''; - fprintf('Gratings stimulus not found.\n') - vsG = rectGridAnalysis(NP); %use rectGrid here to avoid puting lots of ifs. - end - try - vsNI = imageAnalysis(NP); - params.StimsPresent{5} = 'NI'; - - if isempty(vsNI.VST) - error('Gratings stimulus not found.\n') - else - longTable(end+1,:) = {categorical(Animal),categorical(j), categorical("NI"), 0,0}; - end - catch - params.StimsPresent{5} = ''; - fprintf('Natural images stimulus not found.\n') - vsNI = rectGridAnalysis(NP); %use rectGrid here to avoid puting lots of ifs. - end - try - vsNV = movieAnalysis(NP); - params.StimsPresent{6} = 'NV'; - - if isempty(vsNV.VST) - error('Gratings stimulus not found.\n') - else - longTable(end+1,:) = {categorical(Animal),categorical(j), categorical("NV"), 0,0}; - end - catch - params.StimsPresent{6} = ''; - fprintf('Natural video stimulus not found.\n') - vsNV = rectGridAnalysis(NP); %use rectGrid here to avoid puting lots of ifs. - end - - try - vsFFF = fullFieldFlashAnalysis(NP); - params.StimsPresent{7} = 'FFF'; - - if isempty(vsFFF.VST) - error('FFF stimulus not found.\n') - else - longTable(end+1,:) = {categorical(Animal),categorical(j), categorical("FFF"), 0,0}; - end - catch - params.StimsPresent{7} = ''; - fprintf('FFF stimulus not found.\n') - vsFFF = rectGridAnalysis(NP); %use moving ball here to avoid puting lots of ifs. - end - - - %%Load pvals and zscore from rect grid and moving ball - if isequal(params.StimsPresent{1},'') || ~ismember(params.StimsPresent{1}, Stims2Comp) - vs.ResponseWindow; - else - vs.ResponseWindow('overwrite',params.overwriteResponse,'durationWindow',params.RespDurationWin); - if isequal(params.StatMethod,'ObsWindow') - vs.ShufflingAnalysis('overwrite',params.overwriteStats,"N_bootstrap", params.shuffles); - else - vs.BootstrapPerNeuron('overwrite',params.overwriteStats); - end - end - - if isequal(params.StimsPresent{2},'') || ~ismember(params.StimsPresent{2}, Stims2Comp) - vsR.ResponseWindow; - else - vsR.ResponseWindow('overwrite',params.overwriteResponse,'durationWindow',params.RespDurationWin); - if isequal(params.StatMethod,'ObsWindow') - vsR.ShufflingAnalysis('overwrite',params.overwriteStats,"N_bootstrap", params.shuffles); - else - vsR.BootstrapPerNeuron('overwrite',params.overwriteStats); - end - end - if isequal(params.StimsPresent{3},'') || ~ismember(params.StimsPresent{3}, Stims2Comp) - vsBr.ResponseWindow; - else - vsBr.ResponseWindow('overwrite',params.overwriteResponse,'durationWindow',params.RespDurationWin); - if isequal(params.StatMethod,'ObsWindow') - vsBr.ShufflingAnalysis('overwrite',params.overwriteStats,"N_bootstrap", params.shuffles); - else - vsBr.BootstrapPerNeuron('overwrite',params.overwriteStats); - end - end - if isequal(params.StimsPresent{4},'') || ~ismember(params.StimsPresent{4}, Stims2Comp) - vsG.ResponseWindow; - else - vsG.ResponseWindow('overwrite',params.overwriteResponse,'durationWindow',params.RespDurationWin); - if isequal(params.StatMethod,'ObsWindow') - vsG.ShufflingAnalysis('overwrite',params.overwriteStats,"N_bootstrap", params.shuffles); - else - vsG.BootstrapPerNeuron('overwrite',params.overwriteStats); - end - end - if isequal(params.StimsPresent{5},'') || ~ismember(params.StimsPresent{5}, Stims2Comp) - vsNI.ResponseWindow; - else - vsNI.ResponseWindow('overwrite',params.overwriteResponse,'durationWindow',params.RespDurationWin); - if isequal(params.StatMethod,'ObsWindow') - vsNI.ShufflingAnalysis('overwrite',params.overwriteStats,"N_bootstrap", params.shuffles); - else - vsNI.BootstrapPerNeuron('overwrite',params.overwriteStats); - end - end - if isequal(params.StimsPresent{6},'') || ~ismember(params.StimsPresent{6}, Stims2Comp) - vsNV.ResponseWindow; - else - vsNV.ResponseWindow('overwrite',params.overwriteResponse,'durationWindow',params.RespDurationWin); - if isequal(params.StatMethod,'ObsWindow') - vsNV.ShufflingAnalysis('overwrite',params.overwriteStats,"N_bootstrap", params.shuffles); - else - vsNV.BootstrapPerNeuron('overwrite',params.overwriteStats); - end - end - if isequal(params.StimsPresent{7},'') || ~ismember(params.StimsPresent{7}, Stims2Comp) - vsFFF.ResponseWindow; - else - vsFFF.ResponseWindow('overwrite',params.overwriteResponse,'durationWindow',params.RespDurationWin); - if isequal(params.StatMethod,'ObsWindow') - vsFFF.ShufflingAnalysis('overwrite',params.overwriteStats,"N_bootstrap", params.shuffles); - else - vsFFF.BootstrapPerNeuron('overwrite',params.overwriteStats); - end - end - - if isequal(params.StatMethod,'ObsWindow') - statsMB = vs.ShufflingAnalysis; - statsRG = vsR.ShufflingAnalysis; - statsMBR = vsBr.ShufflingAnalysis; - statsSDG = vsG.ShufflingAnalysis; - statsFFF = vsFFF.ShufflingAnalysis; - statsNI = vsNI.ShufflingAnalysis; - statsNV = vsNV.ShufflingAnalysis; - else - statsMB = vs.BootstrapPerNeuron; - statsRG = vsR.BootstrapPerNeuron; - statsMBR = vsBr.BootstrapPerNeuron; - statsSDG = vsG.BootstrapPerNeuron; - statsFFF = vsFFF.BootstrapPerNeuron; - statsNI = vsNI.BootstrapPerNeuron; - statsNV = vsNV.BootstrapPerNeuron; - end - - rwRG = vsR.ResponseWindow; - rwMB = vs.ResponseWindow; - rwMBR = vsBr.ResponseWindow; - rwFFF = vsFFF.ResponseWindow; - rwSDG = vsG.ResponseWindow; - rwNI = vsNI.ResponseWindow; - rwNV = vsNV.ResponseWindow; - - %Load stats of Moving Ball, select fastest speed if there are several - zScores_MB = statsMB.Speed1.ZScoreU; - pValuesMB = statsMB.Speed1.pvalsResponse; - spkR_MB = max(rwMB.Speed1.NeuronVals(:,:,4),[],2); - spkDiff_MB = max(rwMB.Speed1.NeuronVals(:,:,5),[],2); - - if isfield(statsMB, 'Speed2') %If - zScores_MB = statsMB.Speed2.ZScoreU; - pValuesMB = statsMB.Speed2.pvalsResponse; - spkR_MB = max(rwMB.Speed2.NeuronVals(:,:,4),[],2); - spkDiff_MB = max(rwMB.Speed2.NeuronVals(:,:,5),[],2); - end - - totalU{j} = numel(zScores_MB); - %Load stats of Rect Grid. - zScores_RG = statsRG.ZScoreU; - pValuesRG = statsRG.pvalsResponse; - spkR_RG = max(rwRG.NeuronVals(:,:,4),[],2); - spkDiff_RG = max(rwRG.NeuronVals(:,:,5),[],2); - - %Load stats of Moving bar. - zScores_MBR = statsMBR.Speed1.ZScoreU; - pValuesMBR = statsMBR.Speed1.pvalsResponse; - spkR_MBR = max(rwMBR.Speed1.NeuronVals(:,:,4),[],2); - spkDiff_MBR = max(rwMBR.Speed1.NeuronVals(:,:,5),[],2); - - %Load stats of FFF - zScores_FFF = statsFFF.ZScoreU; - pValuesFFF = statsFFF.pvalsResponse; - spkR_FFF = max(rwFFF.NeuronVals(:,:,4),[],2); - spkDiff_FFF = max(rwFFF.NeuronVals(:,:,5),[],2); - - %Load stats of SDG moving - - if isequal(params.StimsPresent{4},'') - - zScores_SDGm = statsSDG.ZScoreU; - pValuesSDGm = statsSDG.pvalsResponse; - spkR_SDGm = max(rwSDG.NeuronVals(:,:,4),[],2); - spkDiff_SDGm = max(rwSDG.NeuronVals(:,:,5),[],2); - - %Load stats of SDG static - zScores_SDGs = statsSDG.ZScoreU; - pValuesSDGs = statsSDG.pvalsResponse; - spkR_SDGs = max(rwSDG.NeuronVals(:,:,4),[],2); - spkDiff_SDGs = max(rwSDG.NeuronVals(:,:,5),[],2); - - else - zScores_SDGm = statsSDG.Moving.ZScoreU; - pValuesSDGm = statsSDG.Moving.pvalsResponse; - spkR_SDGm = max(rwSDG.Moving.NeuronVals(:,:,4),[],2); - spkDiff_SDGm = max(rwSDG.Moving.NeuronVals(:,:,5),[],2); - - %Load stats of SDG static - zScores_SDGs = statsSDG.Static.ZScoreU; - pValuesSDGs = statsSDG.Static.pvalsResponse; - spkR_SDGs = max(rwSDG.Static.NeuronVals(:,:,4),[],2); - spkDiff_SDGs = max(rwSDG.Static.NeuronVals(:,:,5),[],2); - end - - %Load stats of Natural images - zScores_NI = statsNI.ZScoreU; - pValuesNI = statsNI.pvalsResponse; - spkR_NI = max(rwNI.NeuronVals(:,:,4),[],2); - spkDiff_NI = max(rwNI.NeuronVals(:,:,5),[],2); - - %Load stats of video - zScores_NV = statsNV.ZScoreU; - pValuesNV = statsNV.pvalsResponse; - spkR_NV = max(rwNV.NeuronVals(:,:,4),[],2); - spkDiff_NV = max(rwNV.NeuronVals(:,:,5),[],2); - - if ~isequal(params.StatMethod,'ObsWindow') - - spkR_NV = mean(statsNV.ObsReponse,1); - spkR_NI = mean(statsNI.ObsReponse,1); - - try - spkR_SDGs = mean(statsSDG.Static.ObsReponse,1); - spkR_SDGm = mean(statsSDG.Moving.ObsReponse,1); - - catch - spkR_SDGs = mean(statsSDG.ObsReponse,1); - spkR_SDGm = mean(statsSDG.ObsReponse,1); - end - - spkR_FFF = mean(statsFFF.ObsReponse,1); - - try - spkR_MBR = mean(statsMBR.Speed1.ObsReponse,1); - catch - spkR_MBR = mean(statsMBR.ObsReponse,1); - end - - spkR_RG = mean(statsRG.ObsReponse,1); - - if isfield(statsMB, 'Speed2') - spkR_MB = mean(statsMB.Speed2.ObsReponse); - else - spkR_MB = mean(statsMB.Speed1.ObsReponse); - end - - end - - if params.ignoreNonSignif - - zScores_NV(pValuesNV>params.threshold) = -1000; - zScores_NI(pValuesNI>params.threshold) = -1000; - zScores_SDGs(pValuesSDGs>params.threshold) = -1000; - zScores_SDGm(pValuesSDGm>params.threshold) = -1000; - zScores_FFF(pValuesFFF>params.threshold) = -1000; - zScores_MBR(pValuesMBR>params.threshold) = -1000; - zScores_RG(pValuesRG>params.threshold) = -1000; - zScores_MB(pValuesMB>params.threshold) = -1000; - - end - - pvals = {'pValuesMB','pValuesRG','pValuesMBR','pValuesFFF','pValuesSDGm','pValuesSDGs','pValuesNI','pValuesNV'... - ;pValuesMB,pValuesRG,pValuesMBR,pValuesFFF,pValuesSDGm,pValuesSDGs,pValuesNI,pValuesNV}; - - [row, col] = find(cellfun(@(x) ischar(x) && endsWith(x, Stims2Comp{1}), pvals)); - - for i=1:numel(params.ComparePairs) - - [row, col] = find(cellfun(@(x) ischar(x) && endsWith(x, params.ComparePairs{i}), pvals)); - - pvalsC{i}= pvals{2,col}; - - end - - vars = who; - - zscoresC1 = vars(contains(vars,sprintf('zScores_%s',params.ComparePairs{1}))); - zscoresC1 = eval(zscoresC1{1}); - unitIDs = 1:numel(zscoresC1); - zscoresC1 = zscoresC1(pvalsC{1}=BootFirst); - j = j+1; - end - - %%Calculate probabilities - - S.groupStats.Bayes_ZscoreCompare = probs; - S.groupStatsP_ZscoreCompare = ps; - - save([saveDir nameOfFile],'-struct', 'S'); - - end - - - %%%Scatter plot comparison for 2 stimuli Z-score (first and second input) - nexttile - %stims to compare - % boxplot(y2,'Labels',Stims2Comp) - - if isempty(params.StimsToCompare) - ind1 = 1; - ind2 = 2; - else - - ind1 = find(strcmp(Stims2Comp2, params.StimsToCompare{1})); - ind2 = find(strcmp(Stims2Comp2, params.StimsToCompare{2})); - - end - - ValsToCompare = {StimZS{ind1},StimZS{ind2}}; - - if numel(ValsToCompare{1}) == numel(ValsToCompare{2}) - - - scatter(ValsToCompare{1},ValsToCompare{2},10,AnIndex,"filled","MarkerFaceAlpha",0.5) - colormap(colormapUsed) - hold on - axis equal - - lims =[min(y(y>-inf)) max(y)]; - plot(lims, lims, 'k--', 'LineWidth', 1.5) - lims = [-5 40]; - ylim(lims) - xlim(lims) - xlabel(Stims2Comp(ind1)) - ylabel(Stims2Comp(ind2)) - - end - - %%%%%% SPIKE RATE ANALYSIS %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - - - y = cell2mat(stimRSP); - %y = cell2mat(StimZS); - - - % ---- Swarmchart (Larger Left Subplot) ---- - nexttile % Takes most of the space - if ~params.EachStimSignif - swarmchart(x, y, 5, [colormapUsed(allColorIndices,:)], 'filled','MarkerFaceAlpha',0.7); % Marker size 50 - else - swarmchart(x, y, 5, 'filled','MarkerFaceAlpha',0.7); % Marker size 50 - end - - xticks(1:8); - xticklabels(Stims2Comp2); - ylabel('Spike Rate'); - set(fig,'Color','w') - - %%HIERARCHICAL BOOTSTRAPPING SpikeRate hierBoot - if params.overwriteGroupStats || ~isfield(S, 'groupStats') - FirstStim = y(x==1); - - BootFirst = hierBoot(FirstStim(~isnan(FirstStim)),10000,InsIndex(~isnan(FirstStim)),AnIndex(~isnan(FirstStim))); - j=1; - for i = 2:numel(Stims2Comp2) - secondaryStim = y(x==i); - secondaryStim(isnan(secondaryStim)) =0; - secondaryStim = secondaryStim(secondaryStim~=-inf); - BootSec= hierBoot(secondaryStim,10000,InsIndex(secondaryStim~=-inf),AnIndex(secondaryStim~=-inf)); - probs{j} = get_direct_prob(BootFirst,BootSec); % - ps{j} = mean(BootSec>=BootFirst); - j = j+1; - end - - S.groupStats.Bayes_SpikeRateCompare = probs; - S.groupStats.P_SpikeRateCompare = ps; - end - - %%%Scatter plot comparison for 2 stimuli Z-score (first and second input) - nexttile - ValsToCompare = {stimRSP{ind1},stimRSP{ind2}}; - - if numel(ValsToCompare{1}) == numel(ValsToCompare{2}) - - - scatter(ValsToCompare{1},ValsToCompare{2},10,AnIndex,"filled","MarkerFaceAlpha",0.5) - colormap(colormapUsed) - hold on - axis equal - lims = [0 max(xlim)]; - plot(lims, lims, 'k--', 'LineWidth', 1.5) - ylim(lims) - xlim(lims) - xlabel(Stims2Comp(ind1)) - ylabel(Stims2Comp(ind2)) - end - - -end %% end of analysis comparing multiple pairs - -%% %% ANALYSIS OF QUANTITIES OF RESPONSIVE NEURONS %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%Run until here, check insertion list to create bootstrapping of neuronal -%quantities that are responsive to each stim -% AllNeur =0; -% fn = fieldnames(S.stimValsSignif); -% for i = 1:numel(Stims2Comp2) -% -% ending = [Stims2Comp2{i} 'g']; -% pattern = ['^zS.*' ending '$']; -% matches = fn(~cellfun('isempty', regexp(fn, pattern))); -% -% if isequal(Stims2Comp2{i},'SDGm') -% matches2 = fn(~cellfun('isempty', regexp(fn, ['^sumNeur.*' 'SDGm' '$']))); -% elseif isequal(Stims2Comp2{i},'SDGs') -% matches2 = fn(~cellfun('isempty', regexp(fn, ['^sumNeur.*' 'SDGm' '$']))); -% else -% matches2 = fn(~cellfun('isempty', regexp(fn, ['^sumNeur.*' Stims2Comp2{i} '$']))); -% end -% -% matTemp = cell2mat(S.stimValsSignif.(matches{1})); -% matTemp = matTemp(matTemp>-inf); -% RespNeurCountFraction{i} = numel(matTemp)/(sum(cell2mat(S.stimValsSignif.(matches2{1})))); -% RespNeurCount{i} = numel(matTemp); -% AllNeur = AllNeur+sum(cell2mat(S.stimValsSignif.(matches2{1}))); -% -% end - - -%Stimuli pairs to compare - -if isempty(params.ComparePairs) - pairs = {Stims2Comp{1},Stims2Comp{2}}; -else - pairs = params.ComparePairs; -end - - - -[G, insID] = findgroups(S.TableRespNeurs.insertion); -hasAll = splitapply(@(s) all(ismember(unique(categorical(pairs)), s)), S.TableRespNeurs.stimulus, G); - -tempTable = S.TableRespNeurs(hasAll(G) & ismember(S.TableRespNeurs.stimulus, unique(categorical(pairs))),:); - - -%pairs = {"SDGm","SDGs";"MB","MBR";"MB","RG";"NV","NI"}; -nBoot = 10000; -j=1; - - - -%%% BOOTSRAPPING - -ps = zeros(1,size(pairs,1)); - -for i = 1:size(pairs,1) - - diffs = []; - for ins = unique(S.TableRespNeurs.insertion)' - - idx1 = S.TableRespNeurs.insertion == categorical(ins) & S.TableRespNeurs.stimulus == pairs{j,1}; - idx2 = S.TableRespNeurs.insertion == categorical(ins) & S.TableRespNeurs.stimulus == pairs{j,2}; - - if any(idx1) && any(idx2) - diffs(end+1,1) = S.TableRespNeurs.respNeur(idx1)/ S.TableRespNeurs.totalSomaticN(idx1) - S.TableRespNeurs.respNeur(idx2)/S.TableRespNeurs.totalSomaticN(idx1); - end - end - - bootDiff = bootstrp(nBoot, @mean, diffs); - ps(j) = mean(bootDiff<=0); - j = j+1; -end - -[G,expID] = findgroups(tempTable.insertion); -totals = splitapply(@sum, tempTable.respNeur, G); - -tempTable.TotalRespNeur = totals(G); - -%%% PLOTTING - - -fig = plotSwarmBootstrapWithComparisons(tempTable,pairs,ps,{'respNeur','totalSomaticN'},fraction = true, yLegend='Responsive/total units',diff=false, filled = false, Xjitter = 'none',Alpha=0.9); - - ax = gca; - ax.YAxis.FontSize = 8; - ax.YAxis.FontName = 'helvetica'; - - ax = gca; - ax.XAxis.FontSize = 8; - ax.XAxis.FontName = 'helvetica'; - - set(fig, 'Units', 'centimeters'); - set(fig, 'Position', [20 20 4 6]); - -end \ No newline at end of file diff --git a/visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.m b/visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.m index 6ffed20..2c7e05d 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.m +++ b/visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.m @@ -201,8 +201,10 @@ vs.ResponseWindow('overwrite',params.overwriteResponse,'durationWindow',params.RespDurationWin); if isequal(params.StatMethod,'ObsWindow') vs.ShufflingAnalysis('overwrite',params.overwriteStats,"N_bootstrap", params.shuffles); - else + elseif isequal(params.StatMethod,'bootsrapRespBase') vs.BootstrapPerNeuron('overwrite',params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vs.StatisticsPerNeuron('overwrite',params.overwriteStats); end end @@ -212,8 +214,10 @@ vsR.ResponseWindow('overwrite',params.overwriteResponse,'durationWindow',params.RespDurationWin); if isequal(params.StatMethod,'ObsWindow') vsR.ShufflingAnalysis('overwrite',params.overwriteStats,"N_bootstrap", params.shuffles); - else + elseif isequal(params.StatMethod,'bootsrapRespBase') vsR.BootstrapPerNeuron('overwrite',params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vsR.StatisticsPerNeuron('overwrite',params.overwriteStats); end end if isequal(params.StimsPresent{3},'') || ~ismember(params.StimsPresent{3}, Stims2Comp) @@ -222,8 +226,10 @@ vsBr.ResponseWindow('overwrite',params.overwriteResponse,'durationWindow',params.RespDurationWin); if isequal(params.StatMethod,'ObsWindow') vsBr.ShufflingAnalysis('overwrite',params.overwriteStats,"N_bootstrap", params.shuffles); - else + elseif isequal(params.StatMethod,'bootsrapRespBase') vsBr.BootstrapPerNeuron('overwrite',params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vsBr.StatisticsPerNeuron('overwrite',params.overwriteStats); end end if isequal(params.StimsPresent{4},'') || ~ismember(params.StimsPresent{4}, Stims2Comp) @@ -232,8 +238,10 @@ vsG.ResponseWindow('overwrite',params.overwriteResponse,'durationWindow',params.RespDurationWin); if isequal(params.StatMethod,'ObsWindow') vsG.ShufflingAnalysis('overwrite',params.overwriteStats,"N_bootstrap", params.shuffles); - else + elseif isequal(params.StatMethod,'bootsrapRespBase') vsG.BootstrapPerNeuron('overwrite',params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vsG.StatisticsPerNeuron('overwrite',params.overwriteStats); end end if isequal(params.StimsPresent{5},'') || ~ismember(params.StimsPresent{5}, Stims2Comp) @@ -242,8 +250,10 @@ vsNI.ResponseWindow('overwrite',params.overwriteResponse,'durationWindow',params.RespDurationWin); if isequal(params.StatMethod,'ObsWindow') vsNI.ShufflingAnalysis('overwrite',params.overwriteStats,"N_bootstrap", params.shuffles); - else + elseif isequal(params.StatMethod,'bootsrapRespBase') vsNI.BootstrapPerNeuron('overwrite',params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vsNI.StatisticsPerNeuron('overwrite',params.overwriteStats); end end if isequal(params.StimsPresent{6},'') || ~ismember(params.StimsPresent{6}, Stims2Comp) @@ -252,8 +262,10 @@ vsNV.ResponseWindow('overwrite',params.overwriteResponse,'durationWindow',params.RespDurationWin); if isequal(params.StatMethod,'ObsWindow') vsNV.ShufflingAnalysis('overwrite',params.overwriteStats,"N_bootstrap", params.shuffles); - else + elseif isequal(params.StatMethod,'bootsrapRespBase') vsNV.BootstrapPerNeuron('overwrite',params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vsNV.StatisticsPerNeuron('overwrite',params.overwriteStats); end end if isequal(params.StimsPresent{7},'') || ~ismember(params.StimsPresent{7}, Stims2Comp) @@ -262,8 +274,10 @@ vsFFF.ResponseWindow('overwrite',params.overwriteResponse,'durationWindow',params.RespDurationWin); if isequal(params.StatMethod,'ObsWindow') vsFFF.ShufflingAnalysis('overwrite',params.overwriteStats,"N_bootstrap", params.shuffles); - else + elseif isequal(params.StatMethod,'bootsrapRespBase') vsFFF.BootstrapPerNeuron('overwrite',params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vsFFF.StatisticsPerNeuron('overwrite',params.overwriteStats); end end @@ -275,7 +289,7 @@ statsFFF = vsFFF.ShufflingAnalysis; statsNI = vsNI.ShufflingAnalysis; statsNV = vsNV.ShufflingAnalysis; - else + elseif isequal(params.StatMethod,'bootsrapRespBase') statsMB = vs.BootstrapPerNeuron; statsRG = vsR.BootstrapPerNeuron; statsMBR = vsBr.BootstrapPerNeuron; @@ -283,6 +297,14 @@ statsFFF = vsFFF.BootstrapPerNeuron; statsNI = vsNI.BootstrapPerNeuron; statsNV = vsNV.BootstrapPerNeuron; + else + statsMB = vs.StatisticsPerNeuron; + statsRG = vsR.StatisticsPerNeuron; + statsMBR = vsBr.StatisticsPerNeuron; + statsSDG = vsG.StatisticsPerNeuron; + statsFFF = vsFFF.StatisticsPerNeuron; + statsNI = vsNI.StatisticsPerNeuron; + statsNV = vsNV.StatisticsPerNeuron; end rwRG = vsR.ResponseWindow; @@ -367,32 +389,32 @@ if ~isequal(params.StatMethod,'ObsWindow') - spkR_NV = mean(statsNV.ObsReponse,1); - spkR_NI = mean(statsNI.ObsReponse,1); + spkR_NV = mean(statsNV.ObsResponse,1); + spkR_NI = mean(statsNI.ObsResponse,1); try - spkR_SDGs = mean(statsSDG.Static.ObsReponse,1); - spkR_SDGm = mean(statsSDG.Moving.ObsReponse,1); + spkR_SDGs = mean(statsSDG.Static.ObsResponse,1); + spkR_SDGm = mean(statsSDG.Moving.ObsResponse,1); catch - spkR_SDGs = mean(statsSDG.ObsReponse,1); - spkR_SDGm = mean(statsSDG.ObsReponse,1); + spkR_SDGs = mean(statsSDG.ObsResponse,1); + spkR_SDGm = mean(statsSDG.ObsResponse,1); end - spkR_FFF = mean(statsFFF.ObsReponse,1); + spkR_FFF = mean(statsFFF.ObsResponse,1); try - spkR_MBR = mean(statsMBR.Speed1.ObsReponse,1); + spkR_MBR = mean(statsMBR.Speed1.ObsResponse,1); catch - spkR_MBR = mean(statsMBR.ObsReponse,1); + spkR_MBR = mean(statsMBR.ObsResponse,1); end - spkR_RG = mean(statsRG.ObsReponse,1); + spkR_RG = mean(statsRG.ObsResponse,1); if isfield(statsMB, 'Speed2') - spkR_MB = mean(statsMB.Speed2.ObsReponse); + spkR_MB = mean(statsMB.Speed2.ObsResponse); else - spkR_MB = mean(statsMB.Speed1.ObsReponse); + spkR_MB = mean(statsMB.Speed1.ObsResponse); end end diff --git a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m new file mode 100644 index 0000000..b5c3df3 --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m @@ -0,0 +1,806 @@ +function results = StatisticsPerNeuron(obj, params) +% StatisticsPerNeuron - Computes per-neuron response statistics vs baseline. +% +% For each neuron this function outputs: +% pvalsResponse : p-value from a max-statistic sign-flip permutation test. +% Tests H0: no stimulus category drives a response above baseline. +% The max-statistic controls family-wise error rate across categories +% without requiring Bonferroni correction. +% +% ZScoreU : Data-driven z-score of neuronal response normalised by pooled +% baseline SD. Three modes controlled by MovingWindow and UseLOO: +% - MovingWindow=true : peak 300ms sliding window at preferred +% category (argmax of MW), baseline corrected. +% - MovingWindow=false, UseLOO=true : LOO cross-validated mean +% Diff at preferred category — unbiased across stimuli. +% - MovingWindow=false, UseLOO=false : direct mean Diff at +% preferred category — faster but subject to winner's curse. +% +% ZScorePermutation : Permutation z-score — observed max-statistic normalised +% by the mean and SD of its own null distribution. +% Quantifies how many SDs above the null the observed response is. +% More comparable across stimuli than ZScoreU when stimulus +% durations or category counts differ substantially. +% Note: still partially affected by nCats and duration since +% nullSD scales with both. Use alongside ZScoreU, not instead. +% +% prefCat : Consensus preferred category index [1 × nNeurons]. +% +% validCats : [nCats × nNeurons] logical mask. False where a category has +% >= EmptyTrialPerc fraction of zero-spike trials. +% +% pValTTest : p-value from one-sample t-test against zero, pooled across +% all valid categories per neuron. +% +% tStat : t-statistic corresponding to pValTTest [1 × nNeurons]. +% +% Usage: +% results = obj.StatisticsPerNeuron() +% results = obj.StatisticsPerNeuron(nBoot=5000, UseLOO=false, overwrite=true) +% +% Reference for sign-flip permutation test: +% Nichols & Holmes (2002) Human Brain Mapping 15:1-25 + +arguments (Input) + obj + params.nBoot = 10000 % number of permutation iterations for null distribution + params.EmptyTrialPerc = 0.7 % exclude category if fraction of zero-spike trials >= this threshold + params.FilterEmptyResponses = false % whether to apply empty-trial category filtering + params.overwrite = false % if true, recompute even if a saved file already exists + params.randomSeed = 42 % fixed seed for reproducible permutation results (required for publication) + params.MovingWindowPVal = false % if true: use per-trial sliding window max for + % permutation test. If false: use segmented approach + % for moving ball (nSegments equal epochs) or full + % duration mean for all other stimuli. + params.durationWindow = 100 % Length of moving window + params.nSegments = 5 % number of equal non-overlapping segments to divide + % the moving ball stimulus into when MovingWindowPVal=false. + % Each segment is stimDur/nSegments ms long. + % Max-statistic is taken across both categories and + % segments simultaneously, controlling FWER across both. + % Only applies to stimuli with Speed field (moving ball). + % Ignored for all other stimulus types. + params.UseLOO = false % if true: LOO cross-validated z-score (recommended) + % if false: direct z-score at preferred category (faster, inflated) + % ignored when MovingWindow=true (prefCat from argmax of MW) + params.CapStimDuration = false % if true: cap stimulus duration at MaxStimDuration ms + % before building response matrix. Ensures comparable + % analysis windows across stimuli with different durations. + params.MaxStimDuration = 500 % maximum stimulus duration in ms when CapStimDuration=true. + % Should be set to the duration of the shortest stimulus + % (e.g. 500ms for rectGrid) for cross-stimulus comparability. + params.ApplyFDR = false % Benjamini-Hochberg FDR correction reduces the number of false positives. + params.PermutationZScoreBio = true %It uses the observed stat in the perumutation and the baseline std to calculate biological z-score + %SDs above THE UNIT'S BASELINE NOISE + params.PermutationZScoreStat = false %It uses the observed stat in the perumutation and the baseline std to calculate statistical z-score + % SDs above the null PERMUTED distribution + params.SpatialGridMode = false % if true: use StatisticsPerNeuronSpatialGrid + % only applies to linearlyMovingBall + % ignored for other stimuli + params.BaseRespWindow = 300 %Fixed window for baseline and response + params.useSegments = false %Use segmented approach + params.maxCategory = true %Use the max category to calculate the observed statistic and the null distribution across bootstrap iterations + +end + +% ------------------------------------------------------------------------- +% Load cached results if available +% ------------------------------------------------------------------------- +if isfile(obj.getAnalysisFileName) && ~params.overwrite + if nargout == 1 + fprintf('Loading saved results from file.\n'); + results = load(obj.getAnalysisFileName); % return previously computed results + else + fprintf('Analysis already exists (use overwrite option to recalculate).\n'); + end + return +end + + +% ------------------------------------------------------------------------- +% Route to spatial grid analysis for moving ball when enabled +% SpatialGridMode only applies to linearlyMovingBall — other stimuli ignore it +% ------------------------------------------------------------------------- +if params.SpatialGridMode && isequal(obj.stimName, 'linearlyMovingBall') + fprintf('Routing to StatisticsPerNeuronSpatialGrid for moving ball analysis.\n'); + results = StatisticsPerNeuronSpatialGrid(obj, ... + nBoot = params.nBoot, ... + randomSeed = params.randomSeed, ... + GridSize = 9, ... + GridAnalysisWindow = 300, ... + MinTrialsPerCell = 3, ... + ApplyFDR = params.ApplyFDR, ... + overwrite = params.overwrite); + return +end + + +% ------------------------------------------------------------------------- +% Fix random seed for reproducibility +% Required for published code so permutation results are identical across runs +% ------------------------------------------------------------------------- +rng(params.randomSeed); + +% ------------------------------------------------------------------------- +% Load spike-sorted units +% ------------------------------------------------------------------------- +p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); % load kilosort/phy output +label = string(p.label'); % unit quality labels as strings +goodU = p.ic(:, label == 'good'); % keep only somatic ('good') units +responseParams = obj.ResponseWindow; % stimulus timing and category structure + +% ------------------------------------------------------------------------- +% Handle case with no somatic neurons — save empty struct and return +% ------------------------------------------------------------------------- +if isempty(goodU) + warning('%s has no somatic neurons, skipping experiment.\n', obj.dataObj.recordingName); + S = buildEmptyStruct(obj, responseParams); % consistent empty output struct + S.params = params; + save(obj.getAnalysisFileName, '-struct', 'S'); + results = S; + return +end + +% ------------------------------------------------------------------------- +% Sync diode triggers for stimulus alignment +% Wrapped in try/catch because trigger files may need to be regenerated +% on first run or after recording issues +% ------------------------------------------------------------------------- +try + obj.getSyncedDiodeTriggers; +catch + obj.getSessionTime("overwrite", true); % regenerate session time file + obj.getDiodeTriggers("extractionMethod", 'digitalTriggerDiode', 'overwrite', true); % re-extract diode triggers + obj.getSyncedDiodeTriggers; % retry sync +end + +% ------------------------------------------------------------------------- +% Parse stimulus timing per condition +% Stimulus type determines loop structure: +% linearlyMovingBall/Bar → one or two speed conditions (Speed1, Speed2) +% StaticDriftingGrating → Static and Moving phases +% all others (rectGrid) → single condition +% ------------------------------------------------------------------------- +if isfield(responseParams, "Speed1") + % BUG FIX: original code used length(obj.VST.speed) which returns total + % number of trials — corrected to numel(unique(...)) for distinct speeds + nSpeeds = numel(unique(obj.VST.speed)); + + Times.Speed1 = responseParams.Speed1.C(:,1)'; + Durations.Speed1 = responseParams.Speed1.stimDur; + trialsCats.Speed1 = round(numel(Times.Speed1) / size(responseParams.Speed1.NeuronVals, 2)); + MWs.Speed1 = responseParams.Speed1.NeuronVals(:,:,4)'; % [nCats × nNeurons] + + if nSpeeds > 1 + Times.Speed2 = responseParams.Speed2.C(:,1)'; + Durations.Speed2 = responseParams.Speed2.stimDur; + trialsCats.Speed2 = round(numel(Times.Speed2) / size(responseParams.Speed2.NeuronVals, 2)); + MWs.Speed2 = responseParams.Speed2.NeuronVals(:,:,4)'; + end + + x = nSpeeds; + +elseif isequal(obj.stimName, 'StaticDriftingGrating') + Times.Moving = responseParams.C(:,1)' + obj.VST.static_time * 1000; + Durations.Moving = responseParams.Moving.stimDur; + trialsCats.Moving = round(numel(Times.Moving) / size(responseParams.Moving.NeuronVals, 2)); + MWs.Moving = responseParams.Moving.NeuronVals(:,:,4)'; + + Times.Static = responseParams.C(:,1)'; + Durations.Static = responseParams.Static.stimDur; + trialsCats.Static = round(numel(Times.Static) / size(responseParams.Static.NeuronVals, 2)); + MWs.Static = responseParams.Static.NeuronVals(:,:,4)'; + + FieldNames = {'Static', 'Moving'}; + x = 2; + +elseif isequal(obj.stimName, 'movie') + stimDur = responseParams.stimDur; + MW = responseParams.NeuronVals(:,:,4)'; % [nCats × nNeurons] + x = 1; + directimesSorted = responseParams.C(:,1)'; %% Get center of movement + trialsCat = round(numel(directimesSorted) / size(responseParams.NeuronVals, 2)); + + +elseif isequal(obj.stimName, 'image') + directimesSorted = responseParams.C(:,1)'; + stimDur = responseParams.stimDur; + trialsCat = round(numel(directimesSorted) / size(responseParams.NeuronVals, 2)); + MW = responseParams.NeuronVals(:,:,4)'; % [nCats × nNeurons] + %Select only lizards + directimesSorted = directimesSorted([1:15 61:75]); + x = 1; + +else + directimesSorted = responseParams.C(:,1)'; + stimDur = responseParams.stimDur; + trialsCat = round(numel(directimesSorted) / size(responseParams.NeuronVals, 2)); + MW = responseParams.NeuronVals(:,:,4)'; % [nCats × nNeurons] + x = 1; +end + +% ========================================================================= +% Main loop over stimulus conditions +% ========================================================================= +for s = 1:x + + + + % --- Assign condition-specific variables --- + if isfield(responseParams, "Speed1") + fieldName = sprintf('Speed%d', s); + directimesSorted = Times.(fieldName); + stimDur = Durations.(fieldName); + trialsCat = trialsCats.(fieldName); + MW = MWs.(fieldName); + end + + if isequal(obj.stimName, 'StaticDriftingGrating') + fieldName = FieldNames{s}; + directimesSorted = Times.(fieldName); + stimDur = Durations.(fieldName); + trialsCat = trialsCats.(fieldName); + MW = MWs.(fieldName); + end + + % ------------------------------------------------------------------------- + % Cap stimulus duration if requested + % Ensures the response matrix covers the same time span across stimuli, + % preventing winner's curse inflation in moving window analyses caused by + % longer stimuli providing more windows to search over. + % For moving ball (2.3s) vs rectGrid (0.5s), capping at 500ms makes the + % number of sliding window positions comparable. + % Warning is issued when capping occurs so the user is aware. + % ------------------------------------------------------------------------- + if params.CapStimDuration && stimDur > params.MaxStimDuration + fprintf(['Warning: stimulus duration (%.0f ms) exceeds MaxStimDuration ' ... + '(%.0f ms) — capping response window for %s.\n'], ... + stimDur, params.MaxStimDuration, obj.stimName); + effectiveStimDur = params.MaxStimDuration; % capped duration used for Mr on1. ly + elseif params.MovingWindowPVal + effectiveStimDur = stimDur; % full duration — no capping needed + else + effectiveStimDur = params.BaseRespWindow; + + end + + % --- Build spike count matrices --- + % Mr: response window — capped at effectiveStimDur if CapStimDuration=true + % Capping takes first MaxStimDuration ms of each trial, starting at stimulus onset + Mr = BuildBurstMatrix(goodU, ... + round(p.t), ... + round(directimesSorted), ... + round(effectiveStimDur)); % capped or full duration + + if isequal(obj.stimName, 'StaticDriftingGrating') + if isequal(fieldName,'moving') + + Mb = BuildBurstMatrix(goodU, ... + round(p.t), ... + round(directimesSorted - obj.VST.static_time- params.BaseRespWindow), ... + round(params.BaseRespWindow)); + %Baseline before : 0.75 * obj.VST.interTrialDelay * 1000 + else + % Mb: baseline window — always uses 75% of inter-trial interval + % Duration is independent of stimulus duration so no capping needed + Mb = BuildBurstMatrix(goodU, ... + round(p.t), ... + round(directimesSorted - params.BaseRespWindow), ... + round(params.BaseRespWindow)); + end + else + Mb = BuildBurstMatrix(goodU, ... + round(p.t), ... + round(directimesSorted - min([params.BaseRespWindow responseParams.stimInter-100])), ... + round(params.BaseRespWindow)); + end + + % ------------------------------------------------------------------------- + % Always compute full-duration means for z-score and empty-trial filtering + % ------------------------------------------------------------------------- + responses = mean(Mr, 3); % mean spikes/ms over capped response window: [nTrials × nNeurons] + baselines = mean(Mb, 3); % mean spikes/ms over baseline window: [nTrials × nNeurons] + Diff = responses - baselines; % full-duration Diff — always used for z-score + + % ------------------------------------------------------------------------- + % Compute DiffPVal — used only for permutation test + % + % Three cases: + % MovingWindowPVal=true : per-trial sliding window max (all stimuli) + % MovingWindowPVal=false, moving ball : nSegments equal epochs of stimDur/nSegments ms + % max-statistic taken across cats AND segments + % MovingWindowPVal=false, other stimuli: full duration mean (same as Diff) + % ------------------------------------------------------------------------- + + % Flag: use segmented approach for moving ball when sliding window disabled + %useSegments = ~params.MovingWindowPVal && isfield(responseParams, "Speed1"); + + if params.MovingWindowPVal + % --- Sliding window approach --- + winSize = params.durationWindow; % sliding window size in ms/bins + + assert(size(Mr,3) >= winSize, ... + 'Response window (%d ms) shorter than durationWindow (%d ms).', ... + size(Mr,3), winSize); + assert(size(Mb,3) >= winSize, ... + 'Baseline window (%d ms) shorter than durationWindow (%d ms).', ... + size(Mb,3), winSize); + + mrMov = movmean(Mr, winSize, 3, 'Endpoints', 'discard'); % [nTrials × nNeurons × nStepsR] + mbMov = movmean(Mb, winSize, 3, 'Endpoints', 'discard'); % [nTrials × nNeurons × nStepsB] + responsesMW = max(mrMov, [], 3); % [nTrials × nNeurons] per-trial max window response + baselinesMW = max(mbMov, [], 3); % [nTrials × nNeurons] per-trial max window baseline + DiffPVal = responsesMW - baselinesMW; % [nTrials × nNeurons] + + elseif params.useSegments + % --- Segmented approach for moving ball --- + % Divide full stimulus duration (before capping) into nSegments equal epochs. + % Each segment is stimDur/nSegments ms — e.g. 2300/5 = 460ms. + % Response matrix for each segment built independently from BuildBurstMatrix. + % Baseline is shared across all segments (same pre-trial window per trial). + % Max-statistic permutation test will take max across both categories and + % segments simultaneously, controlling FWER across both dimensions. + segDur = stimDur / params.nSegments; % duration of each segment in ms + nSegs = params.nSegments; % number of segments (e.g. 5) + + fprintf('Using %d segments of %.1f ms for %s permutation test.\n', ... + nSegs, segDur, obj.stimName); + + % Pre-allocate: mean response per trial per segment [nTrials × nNeurons × nSegs] + MrSegs = zeros(size(Mr,1), size(Mr,2), nSegs); + + for seg = 1:nSegs + % Onset of this segment: shift trial onsets by (seg-1)*segDur ms + segOnsets = round(directimesSorted + (seg-1) * segDur); % [1 × nTrials] + MrSeg = BuildBurstMatrix(goodU, round(p.t), segOnsets, round(segDur)); + MrSegs(:,:,seg) = mean(MrSeg, 3); % mean over time bins: [nTrials × nNeurons] + end + + % DiffSeg: response minus baseline per segment [nTrials × nNeurons × nSegs] + % baselines is [nTrials × nNeurons] — broadcast across segment dimension + DiffSeg = MrSegs - baselines; % [nTrials × nNeurons × nSegs] + DiffPVal = []; % not used as flat matrix — handled separately in permutation block + + else + % --- Full duration mean (non-moving-ball stimuli) --- + DiffPVal = Diff; % same as z-score Diff — no special treatment needed + end + + nNeurons = size(goodU, 2); + nCats = round(size(Diff,1) / trialsCat); + DiffReshaped = reshape(Diff, trialsCat, nCats, nNeurons); + + assert(size(Diff,1) == nCats * trialsCat, ... + 'Trial count (%d) not evenly divisible by trialsCat (%d).', ... + size(Diff,1), trialsCat); + + % ------------------------------------------------------------------------- + % Category-level empty-trial filtering + % Always based on full-duration responses — unaffected by permutation mode + % ------------------------------------------------------------------------- + validCats = true(nCats, nNeurons); + + if params.FilterEmptyResponses + responsesReshaped = reshape(responses, trialsCat, nCats, nNeurons); + for c = 1:nCats + for u = 1:nNeurons + emptyTrials = responsesReshaped(:, c, u) == 0; + perc = sum(emptyTrials) / trialsCat; + if perc >= params.EmptyTrialPerc + validCats(c, u) = false; + end + end + end + end + + noValidCat = all(~validCats, 1); % [1 × nNeurons] + + % ------------------------------------------------------------------------- + % Observed max-statistic and permutation test + % + % Segmented case: max taken across both categories AND segments simultaneously + % controls FWER across both dimensions in one test + % All other cases: max taken across categories only (as before) + % ------------------------------------------------------------------------- + + % Generate sign vectors: [nTrials × nBoot], values ±1 + % Same signs used regardless of permutation mode — trial-level pairing preserved + signs = 2 * randi(2, size(Diff,1), params.nBoot) - 3; + signsR = reshape(signs, trialsCat, nCats, params.nBoot); % [trialsCat × nCats × nBoot] + + if params.useSegments + % --- Segmented permutation test --- + % ObsStat: max mean DiffSeg across valid categories AND segments [1 × nNeurons] + % DiffSeg: [nTrials × nNeurons × nSegs] + + % Category means per segment: [nCats × nNeurons × nSegs] + DiffSegReshaped = reshape(DiffSeg, trialsCat, nCats, nNeurons, nSegs); % [trialsCat × nCats × nNeurons × nSegs] + catSegMeans = reshape(mean(DiffSegReshaped, 1), nCats, nNeurons, nSegs); + + % Mask invalid categories across all segments + validCatsSeg = repmat(validCats, 1, 1, nSegs); % [nCats × nNeurons × nSegs] + catSegMeans(~validCatsSeg) = -Inf; + + % Max across both categories and segments: [1 × nNeurons] + ObsStat = max(reshape(catSegMeans, nCats*nSegs, nNeurons), [], 1); + + % Null distribution: loop over segments, accumulate running max + % Each segment uses pagemtimes for efficient vectorisation over nBoot. + % Loop runs nSegs=5 times — negligible cost relative to nBoot iterations. + nullMax = -Inf(params.nBoot, nNeurons); % initialise at -Inf for running max + + for seg = 1:nSegs + % Diff for this segment: [nTrials × nNeurons] + DiffSegS = DiffSeg(:,:,seg); + + % Reshape into category structure: [trialsCat × nCats × nNeurons] + DiffSegSR = reshape(DiffSegS, trialsCat, nCats, nNeurons); + + % Permute for pagemtimes + DiffRp = permute(DiffSegSR, [3 1 2]); % [nNeurons × trialsCat × nCats] + signsRp = permute(signsR, [1 3 2]); % [trialsCat × nBoot × nCats] + + % Batched category means under H0: [nNeurons × nBoot × nCats] + catMeansPermSeg = pagemtimes(DiffRp, signsRp) / trialsCat; + + % Permute to [nCats × nNeurons × nBoot], mask invalid categories + catMeansPermSeg = permute(catMeansPermSeg, [3 1 2]); + validCats3D = repmat(validCats, 1, 1, params.nBoot); + catMeansPermSeg(~validCats3D) = -Inf; + + % Max across categories for this segment: [nBoot × nNeurons] + nullMaxSeg = reshape(max(catMeansPermSeg, [], 1), params.nBoot, nNeurons); + + % Running max across segments — equivalent to max across cats AND segs + nullMax = max(nullMax, nullMaxSeg); + end + + else + % --- Standard permutation test (sliding window or full duration) --- + DiffPValReshaped = reshape(DiffPVal, trialsCat, nCats, nNeurons); + catMeans = reshape(mean(DiffPValReshaped, 1), nCats, nNeurons); + + catMeansMasked = catMeans; + catMeansMasked(~validCats) = -Inf; + [ObsStat, prefCat ] = max(catMeansMasked, [], 1); % [1 × nNeurons] + + DiffRp = permute(DiffPValReshaped, [3 1 2]); + signsRp = permute(signsR, [1 3 2]); + + catMeansAll = pagemtimes(DiffRp, signsRp) / trialsCat; + catMeansAll = permute(catMeansAll, [3 1 2]); + + validCats3D = repmat(validCats, 1, 1, params.nBoot); + catMeansAll(~validCats3D) = -Inf; + + nullMax = reshape(max(catMeansAll, [], 1), params.nBoot, nNeurons); + + if ~params.maxCategory + + ObsStat = mean(catMeansMasked, 1); + nullMax = reshape(mean(catMeansAll, 1), params.nBoot, nNeurons); + + end + + end + + + + % p-value and permutation z-score — identical for both cases + pVal = mean(nullMax >= ObsStat, 1); % [1 × nNeurons] + pVal(noValidCat) = NaN; + + if params.ApplyFDR + [pVal, ~, ~,~] = fdr_BH(pVal, 0.05); + + end + + % ------------------------------------------------------------------------- + % Permutation z-score + % Observed stat normalised by the mean and SD of its own null distribution. + % Answers: "how many SDs above the null is this neuron's observed response?" + % Saved as a separate field from ZScoreU — the two metrics complement each + % other and are appropriate for different comparisons. + % Note: nullSD still partially scales with nCats and stimulus duration, + % so this metric is not perfectly comparable across stimuli — see methods. + % ------------------------------------------------------------------------- + nullMean = mean(nullMax, 1); % [1 × nNeurons] expected max under H0 + nullSD = std(nullMax, 1); % [1 × nNeurons] variability of null max + zPerm = (ObsStat - nullMean) ./ nullSD;% [1 × nNeurons] permutation z-score + zPerm(nullSD==0) = 0; % degenerate null — set to 0 + zPerm(noValidCat) = NaN; % undefined for fully invalid neurons + + sdBase = std(baselines, 0, 1); % [1 × nNeurons] pooled baseline SD across all trials + + if params.PermutationZScoreBio + + z_mean = ObsStat; + z = (ObsStat - nullMean) ./ sdBase; + z(sdBase == 0) = 0; + z(noValidCat) = NaN; + + elseif params.PermutationZScoreStat + + z_mean = ObsStat; + z = (ObsStat -nullMean) ./ std(nullMax, 0, 1); + z(sdBase == 0) = 0; + z(noValidCat) = NaN; + + else + + % ------------------------------------------------------------------------- + % Data z-score (ZScoreU) + % Three modes depending on MovingWindow and UseLOO flags: + % + % MovingWindow=true: + % prefCat = argmax(MW) — MW is [nCats × nNeurons] peak firing rate + % per category from sliding window already computed in ResponseWindow. + % z_mean = MW at prefCat minus mean baseline (both in spikes/ms). + % UseLOO is ignored in this mode. + % + % MovingWindow=false, UseLOO=true (recommended): + % LOO cross-validated mean Diff at preferred category. + % Preferred category identified on n-1 trials per fold. + % Prevents winner's curse inflation that scales with nCats. + % + % MovingWindow=false, UseLOO=false: + % Direct mean Diff at preferred category from all trials. + % Faster but inflated when nCats is large — exploration only. + % + % All modes normalised by pooled baseline SD across all trials, + % more stable than per-category SD with few trials per category. + % ------------------------------------------------------------------------- + + + if params.useSegments + + if params.UseLOO + % ------------------------------------------------------------------------- + % Segmented LOO z-score — only when useSegments=true (moving ball, + % MovingWindowPVal=false). Preferred category AND segment identified + % jointly by LOO, capturing the trajectory epoch where the ball crosses + % the RF. Winner's curse controlled across the joint cat×seg search space. + % ------------------------------------------------------------------------- + + % Pre-compute per-category per-segment trial sums for efficient LOO + % totalSum: [nCats × nNeurons × nSegs] + totalSum = zeros(nCats, nNeurons, nSegs); + for seg = 1:nSegs + for c = 1:nCats + rows = (c-1)*trialsCat + 1 : c*trialsCat; % trial rows for category c + totalSum(c,:,seg) = sum(DiffSeg(rows,:,seg), 1); % sum over trials + end + end + + z_loo_acc = zeros(1, nNeurons); % accumulates held-out diff at preferred cat×seg + prefCatCount = zeros(nCats*nSegs, nNeurons); % tallies preferred cat×seg selections per fold + + for k = 1:trialsCat + % LOO mean across all categories and segments: [nCats × nNeurons × nSegs] + looMean = zeros(nCats, nNeurons, nSegs); + for seg = 1:nSegs + kthRow = (0:nCats-1)*trialsCat + k; % kth trial row of each category + looMean(:,:,seg) = (totalSum(:,:,seg) - DiffSeg(kthRow,:,seg)) / (trialsCat-1); + end + + % Flatten to [nCats*nSegs × nNeurons] for joint max across cats and segs + looMeanFlat = reshape(looMean, nCats*nSegs, nNeurons); + validCatsSegs = repmat(validCats, nSegs, 1); % [nCats*nSegs × nNeurons] + looMeanFlat(~validCatsSegs) = -Inf; % exclude invalid categories + + % Preferred cat×seg for this fold: [1 × nNeurons] + [~, prefIdxLOO] = max(looMeanFlat, [], 1); + + % Tally preferred cat×seg selection across folds + idx = prefIdxLOO + (0:nNeurons-1) * nCats*nSegs; + prefCatCount(idx) = prefCatCount(idx) + 1; + + % Held-out trial at preferred cat×seg + % Build flat [nCats*nSegs × nNeurons] matrix of kth trial per cat×seg + testValsFlat = zeros(nCats*nSegs, nNeurons); + for seg = 1:nSegs + kthRow = (0:nCats-1)*trialsCat + k; % kth trial of each category + segVals = DiffSeg(kthRow,:,seg); % [nCats × nNeurons] + testValsFlat((seg-1)*nCats+1:seg*nCats,:) = segVals; % insert into flat matrix + end + + z_loo_acc = z_loo_acc + testValsFlat(idx); % accumulate held-out diff at preferred cat×seg + end + + z_mean = z_loo_acc / trialsCat; % mean held-out diff [1 × nNeurons] + [~, prefIdx] = max(prefCatCount, [], 1); % consensus preferred cat×seg index + + % Convert flat index back to category and segment + prefSeg = ceil(prefIdx / nCats); % preferred segment [1 × nNeurons] + prefCat = prefIdx - (prefSeg-1) * nCats; % preferred category [1 × nNeurons] + + else + % --- Direct segmented z-score (no LOO) --- + % Select best category×segment combination from all trials. + % Subject to winner's curse across nCats×nSegs combinations. + % Use for exploration only — LOO recommended for publication. + + % Category means per segment: [nCats*nSegs × nNeurons] + catSegMeansFlat = reshape(catSegMeans, nCats*nSegs, nNeurons); + validCatsSegs = repmat(validCats, nSegs, 1); % [nCats*nSegs × nNeurons] + catSegMeansFlat(~validCatsSegs) = -Inf; + + % Best cat×seg combination per neuron + [bestVal, prefIdx] = max(catSegMeansFlat, [], 1); % [1 × nNeurons] + + % Convert flat index to category and segment + prefSeg = ceil(prefIdx / nCats); % preferred segment [1 × nNeurons] + prefCat = prefIdx - (prefSeg-1) * nCats; % preferred category [1 × nNeurons] + + z_mean = bestVal - mean(nullMax, 1); % [1 × nNeurons] mean Diff at preferred cat×seg + end + else + % ------------------------------------------------------------------------- + % Standard z-score — full duration capped Diff, LOO or direct + % Used for all non-segmented cases: + % - moving ball with MovingWindowPVal=true (sliding window p-value) + % - all other stimuli (rectGrid, gratings) regardless of flags + % ------------------------------------------------------------------------- + if nCats == 1 + % Single category — preferred is trivially category 1 + prefCat = ones(1, nNeurons); + z_mean = mean(Diff, 1); % mean over all trials [1 × nNeurons] + + elseif params.UseLOO + % LOO cross-validated z-score at preferred category + totalSum = zeros(nCats, nNeurons); + for c = 1:nCats + rows = (c-1)*trialsCat + 1 : c*trialsCat; + totalSum(c,:) = sum(Diff(rows,:), 1); + end + + z_loo_acc = zeros(1, nNeurons); + prefCatCount = zeros(nCats, nNeurons); + + for k = 1:trialsCat + looMean = (totalSum - Diff((0:nCats-1)*trialsCat + k,:)) / (trialsCat-1); + looMeanMasked = looMean; + looMeanMasked(~validCats) = -Inf; + + [~, prefCatLOO] = max(looMeanMasked, [], 1); % [1 × nNeurons] + idx = prefCatLOO + (0:nNeurons-1) * nCats; + prefCatCount(idx) = prefCatCount(idx) + 1; + + testVals = Diff((0:nCats-1)*trialsCat + k, :); % [nCats × nNeurons] + z_loo_acc = z_loo_acc + testVals(idx); + end + + z_mean = z_loo_acc / trialsCat; + [~, prefCat] = max(prefCatCount, [], 1); + + else + % Direct z-score — subject to winner's curse, exploration only + catMeansDir = reshape(mean(DiffReshaped, 1), nCats, nNeurons); + catMeansDir(~validCats) = -Inf; + [~, prefCat] = max(catMeansDir, [], 1); + idx = prefCat + (0:nNeurons-1) * nCats; + z_mean = catMeansDir(idx)- mean(nullMax, 1); + end + prefSeg = []; % not applicable outside segmented mode — set to empty + end + + % ------------------------------------------------------------------------- + % Normalise by pooled baseline SD — applies to both segmented and standard + % ------------------------------------------------------------------------- + z = z_mean ./ sdBase; + z(sdBase == 0) = 0; + z(noValidCat) = NaN; + + end + + % ------------------------------------------------------------------------- + % One-sample t-test pooled across all valid categories + % H0: mean(Diff) = 0 across all valid trials. + % Pooling maximises df and avoids cherry-picking the preferred category. + % Permutation test is the primary criterion; t-test is a secondary check. + % ------------------------------------------------------------------------- + pValTTest = zeros(1, nNeurons); + tStat = zeros(1, nNeurons); + + for u = 1:nNeurons + if noValidCat(u) + pValTTest(u) = NaN; + tStat(u) = NaN; + continue + end + + % Logical row mask: all trials belonging to valid categories for neuron u + validRows = false(size(Diff, 1), 1); + for c = 1:nCats + if validCats(c, u) + rows = (c-1)*trialsCat + 1 : c*trialsCat; + validRows(rows) = true; + end + end + + DiffValid = Diff(validRows, u); % valid trials for neuron u + [~, pValTTest(u), ~, stats] = ttest(DiffValid); % one-sample t-test vs zero + tStat(u) = stats.tstat; + end + + pValTTest(noValidCat) = NaN; + tStat(noValidCat) = NaN; + + % ------------------------------------------------------------------------- + % Store results for this condition + % ------------------------------------------------------------------------- + if isfield(responseParams, "Speed1") || isequal(obj.stimName, 'StaticDriftingGrating') + S.(fieldName).pvalsResponse = pVal; % [1 × nNeurons] permutation p-values + S.(fieldName).ZScoreU = z; % [1 × nNeurons] data z-score (LOO/direct/MW) + S.(fieldName).ZScorePermutation = zPerm; % [1 × nNeurons] permutation z-score + S.(fieldName).ObsDiff = Diff; % [nTrials × nNeurons] response minus baseline + S.(fieldName).ObsResponse = responses; % [nTrials × nNeurons] full-duration response + S.(fieldName).ObsBaseline = baselines; % [nTrials × nNeurons] baseline spike counts + S.(fieldName).prefCat = prefCat; % [1 × nNeurons] preferred category index + S.(fieldName).validCats = validCats; % [nCats × nNeurons] category validity mask + S.(fieldName).MaxMovWinResponse = max(MW,[],1); % [1 × nNeurons] peak MW response across cats + S.(fieldName).pValTTest = pValTTest; % [1 × nNeurons] t-test p-values + S.(fieldName).tStat = tStat; % [1 × nNeurons] t-statistics + %S.(fieldName).prefSeg = prefSeg; % [1 × nNeurons] preferred segment (empty if not segmented) + S.(fieldName).z_mean = z_mean.*1000; % [1 × nNeurons] mean spikes/sec difference (resp-base) of preferred segment (empty if not segmented) + else + S.pvalsResponse = pVal; + S.ZScoreU = z; + S.ZScorePermutation = zPerm; + S.ObsDiff = Diff; + S.ObsResponse = responses; + S.ObsBaseline = baselines; + S.prefCat = prefCat; + S.validCats = validCats; + S.MaxMovWinResponse = max(MW,[],1); + S.pValTTest = pValTTest; + S.tStat = tStat; + S.z_mean = z_mean.*1000; + end + + S.params = params; % store parameters alongside results for reproducibility + +end % end condition loop + +% --- Save and return --- +fprintf('Saving results to file.\n'); +save(obj.getAnalysisFileName, '-struct', 'S'); +results = S; + +end % end main function + + +% ========================================================================= +%% Local helper: build empty output struct when no neurons are found +% ========================================================================= +function S = buildEmptyStruct(obj, responseParams) +% buildEmptyStruct - Returns empty results struct with correct field names. +% Ensures downstream code receives a consistent struct regardless of neuron count. + +emptyFields = {'pvalsResponse','ZScoreU','ZScorePermutation','ObsDiff', ... + 'ObsResponse','ObsBaseline','prefCat','prefSeg','validCats', ... + 'MaxMovWinResponse','pValTTest','tStat'}; + +if isequal(obj.stimName, 'linearlyMovingBall') || isequal(obj.stimName, 'linearlyMovingBar') + for f = emptyFields + S.Speed1.(f{1}) = []; + end + if isfield(responseParams, "Speed2") + for f = emptyFields + S.Speed2.(f{1}) = []; + end + end + +elseif isequal(obj.stimName, 'StaticDriftingGrating') + for cond = {'Static', 'Moving'} + for f = emptyFields + S.(cond{1}).(f{1}) = []; + end + end + +else + for f = emptyFields + S.(f{1}) = []; + end +end +end \ No newline at end of file diff --git a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.asv b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.asv new file mode 100644 index 0000000..faecef3 --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.asv @@ -0,0 +1,527 @@ +function results = StatisticsPerNeuronPerCategory(obj, params) +% StatisticsPerNeuronPerCategory - Per-category statistical analysis of +% neuronal responses. +% +% For a specified stimulus category (e.g. 'size', 'direction', 'speed', +% 'luminosity'), this function: +% +% 1. Tests responsiveness separately for each category level using a +% sign-flip permutation test (H0: mean response = baseline). +% +% 2. Tests whether responses differ ACROSS levels using a permutation-based +% one-way ANOVA F-test (omnibus test). +% +% 3. Performs pairwise comparisons between all level pairs using a +% two-sample permutation test, with FDR correction across all pairs +% and neurons. +% +% Output is saved to a separate file: analysisFileName_categoryname.mat +% +% Category names are matched case-insensitively to responseParams.colNames. +% For linearlyMovingBall, comparing 'speed' receives special handling since +% Speed1 and Speed2 are stored in separate struct fields. +% +% Usage: +% results = obj.StatisticsPerNeuronPerCategory('compareCategory', 'size') +% results = obj.StatisticsPerNeuronPerCategory('compareCategory', 'direction', ... +% 'nBoot', 5000, 'overwrite', true) +% +% Reference for permutation tests: +% Nichols & Holmes (2002) Human Brain Mapping 15:1-25 + +arguments (Input) + obj + params.compareCategory = '' % category name to compare (case-insensitive) + params.nBoot = 10000 % permutation iterations + params.BaseRespWindow = 1000 % ms response window from stimulus onset + params.BaselineBuffer = 200 % ms buffer before stimulus onset for baseline + % avoids contamination from off-responses of + % preceding stimulus or anticipatory activity + params.overwrite = false % recompute even if saved file exists + params.randomSeed = 42 % fixed seed for reproducibility + params.ApplyFDR = false % Benjamini-Hochberg FDR correction for pairwise + params. = 200 % ms sliding window for moving ball per-trial peak response + % Applied to full stimulus duration, response only (not baseline) + % Only used when stimulus is linearlyMovingBall + params.GratingType = "moving" %If the stimulus is grating, select it's mode. +end + +% ------------------------------------------------------------------------- +% Validate input +% ------------------------------------------------------------------------- +if isempty(strtrim(params.compareCategory)) + error('params.compareCategory must be specified (e.g. ''size'', ''direction'').'); +end + +% ------------------------------------------------------------------------- +% Output file: append category name to base analysis filename +% ------------------------------------------------------------------------- +outputFile = strrep(obj.getAnalysisFileName, '.mat', ... + ['_' lower(strtrim(params.compareCategory)) '.mat']); + +if isfile(outputFile) && ~params.overwrite + if nargout == 1 + fprintf('Loading saved results from file.\n'); + results = load(outputFile); + else + fprintf('Analysis already exists (use overwrite option to recalculate).\n'); + end + return +end + +% ------------------------------------------------------------------------- +% Fix random seed for reproducibility +% ------------------------------------------------------------------------- +rng(params.randomSeed); + +% ------------------------------------------------------------------------- +% Load spike-sorted somatic units +% ------------------------------------------------------------------------- +p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); +label = string(p.label'); +goodU = p.ic(:, label == 'good'); +nNeurons = size(goodU, 2); + +if isempty(goodU) + warning('%s has no somatic neurons.', obj.dataObj.recordingName); + results = []; + return +end + +responseParams = obj.ResponseWindow; + +% ------------------------------------------------------------------------- +% Sync diode triggers for stimulus alignment +% ------------------------------------------------------------------------- +try + obj.getSyncedDiodeTriggers; +catch + obj.getSessionTime("overwrite", true); + obj.getDiodeTriggers("extractionMethod", 'digitalTriggerDiode', 'overwrite', true); + obj.getSyncedDiodeTriggers; +end + +% ------------------------------------------------------------------------- +% Identify stimulus type and set flags +% ------------------------------------------------------------------------- +isMovingBall = isequal(obj.stimName, 'linearlyMovingBall') || ... + isequal(obj.stimName, 'linearlyMovingBar'); +isGratingMov = isequal(obj.stimName, 'StaticDriftingGrating') && params.GratingType == "moving"; +isGratingStat = isequal(obj.stimName, 'StaticDriftingGrating') && params.GratingType == "static"; +isSpeedComp = isMovingBall && strcmpi(strtrim(params.compareCategory), 'speed'); + +% ------------------------------------------------------------------------- +% Get C matrix, trial times, stimulus duration and category column names +% ------------------------------------------------------------------------- +if isMovingBall + nSpeeds = numel(unique(obj.VST.speed)); + + if isSpeedComp + % Speed comparison: each speed is a level, handled separately below + + + if nSpeeds < 2 + fprintf(['Only one speed condition found in %s. ' ... + 'Cannot compare speeds.\n'], obj.stimName); + results = []; + return + end + + nLevels = nSpeeds; + levels = (1:nSpeeds)'; + fprintf('Comparing %d speed conditions for %s.\n', nSpeeds, obj.stimName); + else + % colNames are the same regardless of speed — just need them for category matching + colNames = responseParams.colNames{1}(5:end); + % C will be overwritten with pooled version inside the response matrix block + C = responseParams.Speed1.C; % temporary — used only for catIdx/cCol detection + end + +elseif isGratingMov + % Use Moving phase for grating + C = responseParams.C; + C(:,1) = C(:,1) +obj.VST.static_time*1000; + colNames = responseParams.colNames{1}(5:end); + +else + % All other stimuli (rectGrid, etc.) + C = responseParams.C; + colNames = responseParams.colNames{1}(5:end); +end + +% ------------------------------------------------------------------------- +% Find category column in C and get unique levels +% colNames{k} corresponds to C(:, k+1) since C(:,1) is stimulus onset time +% ------------------------------------------------------------------------- +if ~isSpeedComp + catIdx = find(strcmpi(colNames, strtrim(params.compareCategory))); + + if isempty(catIdx) + fprintf(['Category "%s" not found in this stimulus.\n' ... + 'Available categories: %s\n'], ... + params.compareCategory, strjoin(colNames, ', ')); + results = []; + return + end + + cCol = catIdx + 1; % column index in C (C(:,2) = first category) + levels = unique(C(:, cCol)); % unique level values [nLevels × 1] + nLevels = numel(levels); + + if nLevels < 2 + fprintf(['Only one level found for category "%s" in %s. ' ... + 'Nothing to compare.\n'], params.compareCategory, obj.stimName); + results = []; + return + end + + fprintf('Comparing %d levels of "%s": [%s]\n', nLevels, params.compareCategory, ... + num2str(levels', '%.4g ')); +end + +% ========================================================================= +% Build response and baseline matrices, compute per-level Diff +% ========================================================================= + +if isSpeedComp + allDiff = cell(nSpeeds, 1); + allBaselines = cell(nSpeeds, 1); + + for s = 1:nSpeeds + fName_s = sprintf('Speed%d', s); + trialTimes_s = responseParams.(fName_s).C(:,1)'; + stimDur_s = responseParams.(fName_s).stimDur; + + % Response: sliding window across full stimulus duration (moving ball always) + Mr_s = BuildBurstMatrix(goodU, round(p.t), ... + round(trialTimes_s), round(stimDur_s)); + + assert(size(Mr_s,3) >= params.MovingWindowDuration, ... + 'Speed%d stimulus duration (%d ms) shorter than MovingWindowDuration (%d ms).', ... + s, size(Mr_s,3), params.MovingWindowDuration); + + mrMov_s = movmean(Mr_s, params.MovingWindowDuration, 3, ... + 'Endpoints', 'discard'); + responses_s = max(mrMov_s, [], 3); % [nTrials_s × nNeurons] peak window + + % Baseline: fixed window — no moving window on baseline + Mb_s = BuildBurstMatrix(goodU, round(p.t), ... + round(trialTimes_s - min([obj.VST.interTrialDelay*1000 - params.BaselineBuffer, ... + params.BaseRespWindow])), ... + params.BaseRespWindow); + + baselines_s = mean(Mb_s, 3); + allDiff{s} = responses_s - baselines_s; + allBaselines{s} = baselines_s; + + fprintf('Speed%d: using %d ms sliding window over %d ms stimulus.\n', ... + s, params.MovingWindowDuration, round(stimDur_s)); + end + + % Pooled baseline SD across all trials and speeds + sdBase = std(vertcat(allBaselines{:}), 0, 1); % [1 × nNeurons] + +else + % ------------------------------------------------------------------------- + % Standard comparison: build full matrices once, split by category level + % For moving ball: sliding window across full stimulus duration to capture + % peak per-trial response regardless of when ball crosses the RF. + % Baseline remains fixed — no moving window on baseline to avoid false + % negative bias (supervisor recommendation). + % For all other stimuli: fixed window from stimulus onset. + % ------------------------------------------------------------------------- + + if isMovingBall + % Pool trials across ALL speeds instead of using max speed only + % Each speed has different stimDur so build matrices per speed, + % apply moving window to each, then concatenate + nSpeeds = numel(unique(obj.VST.speed)); + + % Concatenate C matrices from all speeds to get pooled category info + C_all = []; + for s = 1:nSpeeds + fName_s = sprintf('Speed%d', s); + C_all = [C_all; responseParams.(fName_s).C]; + end + C = C_all; % overwrite C with pooled version + cCol = catIdx + 1; % recalculate in case C structure changed + + % Rebuild levels from pooled C (should be same but ensures consistency) + levels = unique(C(:, cCol)); + nLevels = numel(levels); + + % Build response and baseline per speed, apply moving window, concatenate + responsesList = cell(nSpeeds, 1); + baselinesList = cell(nSpeeds, 1); + + for s = 1:nSpeeds + fName_s = sprintf('Speed%d', s); + trialTimes_s = responseParams.(fName_s).C(:,1)'; + stimDur_s = responseParams.(fName_s).stimDur; + + % Response: full stimulus duration with sliding window + Mr_s = BuildBurstMatrix(goodU, round(p.t), ... + round(trialTimes_s), round(stimDur_s)); + + assert(size(Mr_s,3) >= params.MovingWindowDuration, ... + 'Speed%d: stimulus (%d ms) shorter than MovingWindowDuration (%d ms).', ... + s, size(Mr_s,3), params.MovingWindowDuration); + + mrMov_s = movmean(Mr_s, params.MovingWindowDuration, 3, ... + 'Endpoints', 'discard'); + responsesList{s} = max(mrMov_s, [], 3); % [nTrials_s × nNeurons] + + % Baseline: fixed window — same approach for all speeds + Mb_s = BuildBurstMatrix(goodU, round(p.t), ... + round(trialTimes_s - min([obj.VST.interTrialDelay*1000 - params.BaselineBuffer, ... + params.BaseRespWindow])), ... + params.BaseRespWindow); + baselinesList{s} = mean(Mb_s, 3); % [nTrials_s × nNeurons] + + fprintf('Speed%d: %d ms sliding window over %d ms, %d trials pooled.\n', ... + s, params.MovingWindowDuration, round(stimDur_s), size(Mr_s,1)); + end + + % Concatenate across speeds: [nTotalTrials × nNeurons] + responsesFull = vertcat(responsesList{:}); + baselinesFull = vertcat(baselinesList{:}); + DiffFull = responsesFull - baselinesFull; + + % Pooled baseline SD across all trials and speeds + sdBase = std(baselinesFull, 0, 1); % [1 × nNeurons] + + % Split Diff by category level using pooled C + allDiff = cell(nLevels, 1); + for k = 1:nLevels + mask = C(:, cCol) == levels(k); + allDiff{k} = DiffFull(mask, :); + fprintf('Level %g: %d trials (pooled across speeds)\n', levels(k), sum(mask)); + end + + else + + trialTimes = C(:,1)'; + + % Fixed window for all other stimuli + MrFull = BuildBurstMatrix(goodU, round(p.t), ... + round(trialTimes), params.BaseRespWindow); + responsesFull = mean(MrFull, 3); % [nTrials × nNeurons] + + + % Baseline: fixed window ending BaselineBuffer ms before onset + % Same for all stimuli — no moving window on baseline + MbFull = BuildBurstMatrix(goodU, round(p.t), ... + round(trialTimes - min([obj.VST.interTrialDelay*1000 - params.BaselineBuffer, ... + params.BaseRespWindow])), ... + params.BaseRespWindow); + baselinesFull = mean(MbFull, 3); % [nTrials × nNeurons] + + DiffFull = responsesFull - baselinesFull; % [nTrials × nNeurons] + + % Pooled baseline SD across all trials + sdBase = std(baselinesFull, 0, 1); % [1 × nNeurons] + + % Split Diff by category level — pool all other non-specified categories + allDiff = cell(nLevels, 1); + for k = 1:nLevels + mask = C(:, cCol) == levels(k); + allDiff{k} = DiffFull(mask, :); + fprintf('Level %g: %d trials\n', levels(k), sum(mask)); + end + + end +end + +% ========================================================================= +% Per-level responsiveness test +% Sign-flip permutation test: H0: mean(Diff) = 0 for this level +% Trials are pooled across all non-specified categories within each level +% One-tailed (excitatory): p = proportion of null >= observed mean +% Z-score: bias-corrected by subtracting null mean, normalised by sdBase +% ========================================================================= +pValPerLevel = nan(nLevels, nNeurons); % [nLevels × nNeurons] p-values +zPerLevel = nan(nLevels, nNeurons); % [nLevels × nNeurons] z-scores +obsStatLevels = nan(nLevels, nNeurons); % [nLevels × nNeurons] observed mean Diff + +for k = 1:nLevels + Diff_k = allDiff{k}; % [nTrials_k × nNeurons] + nTrials_k = size(Diff_k, 1); + + ObsStat_k = mean(Diff_k, 1); % [1 × nNeurons] + obsStatLevels(k,:) = ObsStat_k; + + % Vectorised sign-flip null distribution: [nBoot × nNeurons] + signs_k = 2 * randi(2, nTrials_k, params.nBoot) - 3; % [nTrials_k × nBoot] + nullDist_k = (signs_k' * Diff_k) / nTrials_k; % [nBoot × nNeurons] + + % One-tailed p-value: proportion of null >= observed + pValPerLevel(k,:) = mean(nullDist_k >= ObsStat_k, 1); + + % Bias-corrected z-score: (observed - null mean) / pooled baseline SD + nullMean_k = mean(nullDist_k, 1); % [1 × nNeurons] + z_k = (ObsStat_k - nullMean_k) ./ sdBase; % [1 × nNeurons] + z_k(sdBase == 0) = 0; + zPerLevel(k,:) = z_k; +end + +% ========================================================================= +% Omnibus test: permutation one-way ANOVA F-test +% H0: mean response is equal across all levels +% Pool all trials, permute level labels nBoot times +% More powerful than pairwise-only approach since it uses all data +% ========================================================================= +DiffPooled = vertcat(allDiff{:}); % [nTotalTrials × nNeurons] +nTotalTrials = size(DiffPooled, 1); + +% Level label vector: k repeated nTrials_k times per level +levelLabelsVec = cell2mat(arrayfun(@(k) ... + k * ones(size(allDiff{k},1), 1), ... + (1:nLevels)', 'UniformOutput', false)); % [nTotalTrials × 1] + +% Observed F-statistic +F_obs = computeFstat(DiffPooled, levelLabelsVec, levels); % [1 × nNeurons] + +% Null distribution: permute level labels +nullF = zeros(params.nBoot, nNeurons); +for b = 1:params.nBoot + permLabels = levelLabelsVec(randperm(nTotalTrials)); + nullF(b,:) = computeFstat(DiffPooled, permLabels, levels); +end + +pValOmnibus = mean(nullF >= F_obs, 1); % [1 × nNeurons] + +% ========================================================================= +% Pairwise comparisons: two-sample permutation test for each level pair +% Observed: mean(Diff_i) - mean(Diff_j) +% Null: randomly reassign trials between groups, recompute mean difference +% Two-tailed: |observed| >= |null| +% FDR correction across all pairs × neurons +% ========================================================================= +pairIdx = nchoosek(1:nLevels, 2); % [nPairs × 2] +nPairs = size(pairIdx, 1); + +pValPairwise = nan(nPairs, nNeurons); % raw p-values +obsStatPairwise = nan(nPairs, nNeurons); % observed mean differences + +for pr = 1:nPairs + i = pairIdx(pr,1); + j = pairIdx(pr,2); + Diff_i = allDiff{i}; % [nTrials_i × nNeurons] + Diff_j = allDiff{j}; % [nTrials_j × nNeurons] + nI = size(Diff_i, 1); + nJ = size(Diff_j, 1); + + ObsDiff_ij = mean(Diff_i,1) - mean(Diff_j,1); % [1 × nNeurons] + obsStatPairwise(pr,:) = ObsDiff_ij; + + % Pool trials and permute group assignment + DiffPair = [Diff_i; Diff_j]; % [nI+nJ × nNeurons] + nTotal_pr = nI + nJ; + + % Vectorised: weight matrix W [nTotal_pr × nBoot] + % For each boot: first nI rows get weight +1/nI, rest get -1/nJ + nullPair = zeros(params.nBoot, nNeurons); + for b = 1:params.nBoot + perm = randperm(nTotal_pr); + nullPair(b,:) = mean(DiffPair(perm(1:nI),:), 1) - ... + mean(DiffPair(perm(nI+1:end),:), 1); + end + + % Two-tailed p-value + pValPairwise(pr,:) = mean(abs(nullPair) >= abs(ObsDiff_ij), 1); +end + +% FDR correction across all pairs × neurons simultaneously +if params.ApplyFDR && nPairs > 1 + pFlat = pValPairwise(:); % [nPairs*nNeurons × 1] + validMask = ~isnan(pFlat); + pAdj = nan(size(pFlat)); + if any(validMask) + [pAdj(validMask), ~, ~, ~] = fdr_BH(pFlat(validMask), 0.05, false); + end + pValPairwiseAdj = reshape(pAdj, nPairs, nNeurons); % [nPairs × nNeurons] +else + pValPairwiseAdj = pValPairwise; +end + +% ========================================================================= +% Store results +% ========================================================================= +S.categoryName = params.compareCategory; % name of compared category +S.categoryLevels = levels; % actual level values +S.params = params; + +% Per-level results — field named by category and level value +for k = 1:nLevels + % Build valid MATLAB field name from category + level value + fName_k = sprintf('%s_%g', lower(strtrim(params.compareCategory)), levels(k)); + fName_k = strrep(fName_k, '.', 'p'); % replace decimal for valid field name + fName_k = strrep(fName_k, '-', 'neg'); % replace negative sign + + S.(fName_k).pvalsResponse = pValPerLevel(k,:); % [1 × nNeurons] p-values vs baseline + S.(fName_k).ZScoreU = zPerLevel(k,:); % [1 × nNeurons] bias-corrected z-score + S.(fName_k).ObsStat = obsStatLevels(k,:); % [1 × nNeurons] mean Diff (spikes/ms) + S.(fName_k).nTrials = size(allDiff{k}, 1); % number of trials for this level +end + +% Omnibus test +S.omnibus.pVal = pValOmnibus; % [1 × nNeurons] any difference across levels? +S.omnibus.F_obs = F_obs; % [1 × nNeurons] observed F-statistic + +% Pairwise results +for pr = 1:nPairs + i = pairIdx(pr,1); + j = pairIdx(pr,2); + pairName = sprintf('%s_%g_vs_%g', ... + lower(strtrim(params.compareCategory)), levels(i), levels(j)); + pairName = strrep(pairName, '.', 'p'); + pairName = strrep(pairName, '-', 'neg'); + + S.pairwise.(pairName).pVal = pValPairwise(pr,:); % [1 × nNeurons] raw + S.pairwise.(pairName).pValAdj = pValPairwiseAdj(pr,:); % [1 × nNeurons] FDR corrected + S.pairwise.(pairName).obsDiff = obsStatPairwise(pr,:); % [1 × nNeurons] level_i minus level_j +end + +fprintf('Saving results to %s\n', outputFile); +save(outputFile, '-struct', 'S'); +results = S; + +end % end main function + + +% ========================================================================= +%% Local helper: permutation-compatible one-way ANOVA F-statistic +% ========================================================================= +function F = computeFstat(Diff, levelLabels, levels) +% computeFstat - One-way ANOVA F-statistic for permutation testing. +% +% Inputs: +% Diff : [nTrials × nNeurons] response-minus-baseline values +% levelLabels : [nTrials × 1] integer group membership per trial +% levels : unique level values [nLevels × 1] +% +% Output: +% F : [1 × nNeurons] F-statistic per neuron + + nLevels = numel(levels); + nTotal = size(Diff, 1); + nNeurons = size(Diff, 2); + + grandMean = mean(Diff, 1); % [1 × nNeurons] + SS_between = zeros(1, nNeurons); + SS_within = zeros(1, nNeurons); + + for k = 1:nLevels + mask = levelLabels == levels(k); + nk = sum(mask); + groupMean = mean(Diff(mask,:), 1); % [1 × nNeurons] + SS_between = SS_between + nk * (groupMean - grandMean).^2; % weighted group deviation + SS_within = SS_within + sum((Diff(mask,:) - groupMean).^2, 1); % within-group variance + end + + df_between = nLevels - 1; + df_within = nTotal - nLevels; + + F = (SS_between / df_between) ./ (SS_within / df_within); + F(SS_within==0) = 0; % degenerate: all trials identical within groups +end \ No newline at end of file diff --git a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.m b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.m new file mode 100644 index 0000000..6795c0e --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.m @@ -0,0 +1,728 @@ +function results = StatisticsPerNeuronPerCategory(obj, params) +% StatisticsPerNeuronPerCategory - Per-category statistical analysis of +% neuronal responses. +% +% For a specified stimulus category (e.g. 'size', 'direction', 'speed', +% 'luminosity'), this function: +% +% 1. Tests responsiveness separately for each category level using a +% sign-flip permutation test (H0: mean response = baseline). +% +% 2. Tests whether responses differ ACROSS levels using a permutation-based +% one-way ANOVA F-test (omnibus test). +% +% 3. Performs pairwise comparisons between all level pairs using a +% two-sample permutation test, with FDR correction across all pairs +% and neurons. +% +% When `CategoryMaximized` is supplied, for each neuron and each level of +% the compared category the trials are restricted to the level of the +% maximizing category that produces the largest mean response. The +% resulting `Diff_k` matrices then have a different number of valid trials +% per neuron and are NaN-padded; ALL three statistical tests below have +% been rewritten to be NaN-aware on a per-neuron basis. +% +% Note on the maximizing option: this is intended for between-level +% effect-size comparisons within a single neuron (e.g. "is size 3 more +% effective than size 1 at the best luminosity?"), NOT for unbiased +% claims about overall responsiveness, since the same trials are used +% to select bestLevel and to test against zero. +% +% Output is saved to a separate file: analysisFileName_categoryname.mat +% +% Category names are matched case-insensitively to responseParams.colNames. +% For linearlyMovingBall, comparing 'speed' receives special handling since +% Speed1 and Speed2 are stored in separate struct fields. +% +% Usage: +% results = obj.StatisticsPerNeuronPerCategory('compareCategory', 'size') +% results = obj.StatisticsPerNeuronPerCategory('compareCategory', 'size', ... +% 'CategoryMaximized', 'luminosity', 'overwrite', true) +% +% Reference for permutation tests: +% Nichols & Holmes (2002) Human Brain Mapping 15:1-25 +% +% Changelog (current revision): +% * Fixed NaN propagation in the sign-flip null (matrix multiplication +% was making the null distribution all-NaN whenever a column had any +% NaN padding, which silently broke the maximized branch). +% * Fixed normalization mismatch (observed used `omitmissing`, null +% divided by full padded n). +% * Rewrote computeFstat to compute df_between / df_within and group +% means PER NEURON (previously `nk` was a scalar that collapsed +% across neurons, driving F to ~0 in the maximized branch). +% * Fixed baseline window so it always ends `BaselineBuffer` ms before +% stimulus onset (previously it could overlap the stimulus when the +% inter-trial interval was short). +% * Removed dead variable `idxCompare`. +% * Added a warning when `CategoryMaximized` is set in a speed-compare +% run (where it is currently not honoured). + +% ------------------------------------------------------------------------- +% Argument block. arguments (Input) is the modern MATLAB way to declare a +% Name=Value interface with defaults and (optionally) per-argument +% validators. All fields end up under the struct `params`. +% ------------------------------------------------------------------------- +arguments (Input) + obj % parent stimulus-analysis object (provides data accessors) + params.compareCategory = '' % category name to compare (case-insensitive) + params.nBoot = 10000 % permutation iterations + params.BaseRespWindow = 1000 % ms response window from stimulus onset (also used as max baseline window) + params.BaselineBuffer = 200 % ms buffer before stimulus onset for baseline — avoids contamination from off-responses of the preceding stimulus or anticipatory activity + params.overwrite = false % recompute even if a saved file exists + params.randomSeed = 42 % fixed seed for reproducibility + params.ApplyFDR = false % Benjamini-Hochberg FDR correction across pairs × neurons + params.MovingWindowDuration = 200 % ms sliding window for moving-ball per-trial peak response (response only; never applied to baseline). Only used when stimulus is linearlyMovingBall. + params.GratingType = "moving" % grating phase to analyse when stimulus is StaticDriftingGrating + params.CategoryMaximized = '' % for each neuron, pick the level of this category that maximizes mean response before running per-level statistics +end + +% ------------------------------------------------------------------------- +% Validate input. compareCategory is mandatory. +% ------------------------------------------------------------------------- +if isempty(strtrim(params.compareCategory)) % treat empty / whitespace as "not specified" + error('params.compareCategory must be specified (e.g. ''size'', ''direction'').'); +end + +% ------------------------------------------------------------------------- +% Output file: append the (lowercased, trimmed) category name to the base +% analysis filename so each category gets its own .mat. +% ------------------------------------------------------------------------- +outputFile = strrep(obj.getAnalysisFileName, '.mat', ... + ['_' lower(strtrim(params.compareCategory)) '.mat']); % e.g. ..._size.mat + +% Short-circuit if a saved result exists and the caller did not request +% overwriting. If they captured an output we still load and return it. +if isfile(outputFile) && ~params.overwrite + if nargout == 1 + fprintf('Loading saved results from file.\n'); + results = load(outputFile); + else + fprintf('Analysis already exists (use overwrite option to recalculate).\n'); + end + return +end + +% ------------------------------------------------------------------------- +% Optional CategoryMaximized validation. +% ------------------------------------------------------------------------- +useMaxCategory = ~isempty(strtrim(params.CategoryMaximized)); % flag: true if maximizing is requested + +if useMaxCategory && strcmpi(strtrim(params.CategoryMaximized), ... + strtrim(params.compareCategory)) + % Maximizing OVER the category being compared would collapse it away. + error('CategoryMaximized must be different from compareCategory.'); +end + +% ------------------------------------------------------------------------- +% Reproducibility. +% ------------------------------------------------------------------------- +rng(params.randomSeed); % seed MATLAB's RNG before any randi/randperm calls + +% ------------------------------------------------------------------------- +% Load spike-sorted somatic units. `convertPhySorting2tIc` returns +% Phy/Kilosort output reshaped into the `tIc` ("times-by-cluster") layout +% used by BuildBurstMatrix. +% ------------------------------------------------------------------------- +p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); % p.t = spike times, p.ic = [4 × nUnits] index array, p.label = Phy quality label +label = string(p.label'); % convert label cell -> string column for easy comparison +goodU = p.ic(:, label == 'good'); % keep only manually curated "good" (somatic) units +nNeurons = size(goodU, 2); % number of neurons that pass the quality filter + +% Defensive: if no good units survived curation, bail out gracefully. +if isempty(goodU) + warning('%s has no somatic neurons.', obj.dataObj.recordingName); + results = []; + return +end + +responseParams = obj.ResponseWindow; % pre-computed per-stimulus metadata (trial timing matrix C, stim duration, column names) + +% ------------------------------------------------------------------------- +% Make sure diode triggers (the photodiode-based stimulus onset times) are +% synced to the ephys clock. If the sync object is missing we (re)build the +% intermediate session-time and diode-trigger files and try again. +% ------------------------------------------------------------------------- +try + obj.getSyncedDiodeTriggers; +catch + obj.getSessionTime("overwrite", true); + obj.getDiodeTriggers("extractionMethod", 'digitalTriggerDiode', 'overwrite', true); + obj.getSyncedDiodeTriggers; +end + +% ------------------------------------------------------------------------- +% Identify stimulus type and set per-stimulus flags used below. +% ------------------------------------------------------------------------- +isMovingBall = isequal(obj.stimName, 'linearlyMovingBall') || ... % moving-ball and moving-bar share the same processing path + isequal(obj.stimName, 'linearlyMovingBar'); +isGratingMov = isequal(obj.stimName, 'StaticDriftingGrating') && params.GratingType == "moving"; % drifting (moving) grating phase +isGratingStat= isequal(obj.stimName, 'StaticDriftingGrating') && params.GratingType == "static"; % static grating phase (declared for symmetry; not used downstream) +isSpeedComp = isMovingBall && strcmpi(strtrim(params.compareCategory), 'speed'); % special case: comparing speeds in moving-ball + +% ------------------------------------------------------------------------- +% Warn (but do not error) if the caller asked for maximization on a speed +% comparison — the speed-compare branch ignores CategoryMaximized. +% ------------------------------------------------------------------------- +if useMaxCategory && isSpeedComp + warning(['CategoryMaximized="%s" is currently ignored when ' ... + 'compareCategory="speed". Proceeding without maximization.'], ... + params.CategoryMaximized); + useMaxCategory = false; +end + +% ------------------------------------------------------------------------- +% Get C matrix, trial times, stimulus duration and category column names. +% C is [nTrials × nCols]; C(:,1) is stimulus onset time, C(:,2:end) are the +% category labels, in the order given by colNames{1}(5:end). +% ------------------------------------------------------------------------- +if isMovingBall + nSpeeds = numel(unique(obj.VST.speed)); % how many speed conditions were presented + + if isSpeedComp + % Speed comparison: each speed is itself a level, processed below + % in its own dedicated block. + if nSpeeds < 2 + fprintf(['Only one speed condition found in %s. ' ... + 'Cannot compare speeds.\n'], obj.stimName); + results = []; + return + end + + nLevels = nSpeeds; + levels = (1:nSpeeds)'; % integer levels 1..nSpeeds (Speed1, Speed2, ...) + fprintf('Comparing %d speed conditions for %s.\n', nSpeeds, obj.stimName); + else + % colNames are identical across speed sub-structs; pull from Speed1. + % `(5:end)` drops the first 4 columns of the colNames vector (the + % bookkeeping columns: trial idx, onset, offset, etc.) leaving only + % the actual stimulus parameter names. + colNames = responseParams.colNames{1}(5:end); + % C is overwritten with a speed-pooled version inside the response + % matrix block. Use Speed1 here only so catIdx / cCol can be found. + C = responseParams.Speed1.C; + end + +elseif isGratingMov + % Moving (drifting) phase of grating: shift trial onsets by the static + % pre-period so timing aligns with the drift onset. + C = responseParams.C; + C(:,1) = C(:,1) + obj.VST.static_time*1000; % static_time is in seconds; convert to ms + colNames = responseParams.colNames{1}(5:end); + +else + % All other stimuli (rectGrid, static grating phase, etc.) + C = responseParams.C; + colNames = responseParams.colNames{1}(5:end); +end + +% ------------------------------------------------------------------------- +% Find the column of `compareCategory` inside C (and of CategoryMaximized +% if requested). Note: colNames{k} corresponds to C(:, k+1), because C(:,1) +% is stimulus onset. +% ------------------------------------------------------------------------- +if ~isSpeedComp + catIdx = find(strcmpi(colNames, strtrim(params.compareCategory))); % case-insensitive lookup + + if isempty(catIdx) + fprintf(['Category "%s" not found in this stimulus.\n' ... + 'Available categories: %s\n'], ... + params.compareCategory, strjoin(colNames, ', ')); + results = []; + return + end + + % Optional maximizing category. + if useMaxCategory + maxIdx = find(strcmpi(colNames, strtrim(params.CategoryMaximized))); + + if isempty(maxIdx) + fprintf(['CategoryMaximized "%s" not found in this stimulus.\n' ... + 'Proceeding without maximizing.\n'], ... + params.CategoryMaximized); + useMaxCategory = false; % silently disable + else + maxCol = maxIdx + 1; % +1 because C(:,1) is onset time + maxLevels = unique(C(:, maxCol)); % candidate levels of the maximizing category + + fprintf('Maximizing across "%s" with %d levels.\n', ... + params.CategoryMaximized, numel(maxLevels)); + end + end + + cCol = catIdx + 1; % column index of compareCategory in C + levels = unique(C(:, cCol)); % unique level values [nLevels × 1] + nLevels = numel(levels); + + if nLevels < 2 + fprintf(['Only one level found for category "%s" in %s. ' ... + 'Nothing to compare.\n'], params.compareCategory, obj.stimName); + results = []; + return + end + + fprintf('Comparing %d levels of "%s": [%s]\n', nLevels, params.compareCategory, ... + num2str(levels', '%.4g ')); +end + +% ========================================================================= +% Build response and baseline matrices, compute per-level Diff +% ========================================================================= +% +% Sub-function: compute baseline window start offset and duration once so +% the same logic is reused everywhere. The baseline window always ENDS +% `BaselineBuffer` ms before stimulus onset and is at most `BaseRespWindow` +% ms long; if the inter-trial gap is too short, it shrinks accordingly. +% +% onset - offsetMs ---------- onset - BaselineBuffer ---------- onset +% |<--------- duration --------->|<------ buffer ------>| +% +preStim = obj.VST.interTrialDelay*1000 - params.BaselineBuffer; % ms of pre-stim time available for the baseline window itself +baseDur = min(preStim, params.BaseRespWindow); % actual baseline duration in ms +baseStart = baseDur + params.BaselineBuffer; % offset before onset where the baseline window STARTS +if baseDur <= 0 + error(['Negative or zero baseline duration: interTrialDelay=%.3f s, ' ... + 'BaselineBuffer=%d ms. Reduce BaselineBuffer or check timing.'], ... + obj.VST.interTrialDelay, params.BaselineBuffer); +end + +if isSpeedComp + % --------------------------------------------------------------------- + % Speed comparison branch: each speed is a level. Build per-level Diff + % for each speed in turn. + % --------------------------------------------------------------------- + allDiff = cell(nSpeeds, 1); % cell{k} -> [nTrials_k × nNeurons] Diff for level k + allBaselines = cell(nSpeeds, 1); % cell{k} -> [nTrials_k × nNeurons] baseline for level k + + for s = 1:nSpeeds + fName_s = sprintf('Speed%d', s); % e.g. 'Speed1' + trialTimes_s = responseParams.(fName_s).C(:,1)'; % row vector of trial onset times for this speed + stimDur_s = responseParams.(fName_s).stimDur; % stimulus duration for this speed (ms) + + % Response: sliding window over the full stimulus duration (peak in + % time per trial), because the ball can cross the RF at different + % times for different speeds and positions. + Mr_s = BuildBurstMatrix(goodU, round(p.t), ... + round(trialTimes_s), round(stimDur_s)); % [nTrials × nNeurons × stimDur_s] binned spike matrix + + if ~isempty(params.MovingWindowDuration) + assert(size(Mr_s,3) >= params.MovingWindowDuration, ... + 'Speed%d stimulus duration (%d ms) shorter than MovingWindowDuration (%d ms).', ... + s, size(Mr_s,3), params.MovingWindowDuration); + + mrMov_s = movmean(Mr_s, params.MovingWindowDuration, 3, ... + 'Endpoints', 'discard'); % moving mean along time, discard edge-truncated windows + responses_s = max(mrMov_s, [], 3); % [nTrials_s × nNeurons] peak window rate (spikes/ms) + else + responses_s = mean(Mr_s, 3); % fallback: simple mean across full stimulus duration + end + + % Baseline: fixed window ending `BaselineBuffer` ms before onset. + Mb_s = BuildBurstMatrix(goodU, round(p.t), ... + round(trialTimes_s - baseStart), baseDur); % start `baseStart` ms before onset, span `baseDur` ms + + baselines_s = mean(Mb_s, 3); % [nTrials_s × nNeurons] + allDiff{s} = responses_s - baselines_s; % response-minus-baseline per trial + allBaselines{s} = baselines_s; + + fprintf('Speed%d: using %d ms sliding window over %d ms stimulus.\n', ... + s, params.MovingWindowDuration, round(stimDur_s)); + end + + % Pooled baseline SD across ALL trials and speeds (one number per neuron). + sdBase = std(vertcat(allBaselines{:}), 0, 1); % [1 × nNeurons] + +else + % --------------------------------------------------------------------- + % Standard comparison: build full Diff once, split by category level. + % --------------------------------------------------------------------- + + if isMovingBall + % Moving ball at a non-speed category: pool trials across ALL + % speeds (each speed has its own stimDur, so we still process per + % speed and then concatenate the per-speed response/baseline blocks). + nSpeeds = numel(unique(obj.VST.speed)); + + % Concatenate C matrices from every speed so the pooled trial + % ordering matches the pooled response matrix. + C_all = []; + for s = 1:nSpeeds + fName_s = sprintf('Speed%d', s); + C_all = [C_all; responseParams.(fName_s).C]; %#ok ok-for-grow: nSpeeds is small + end + C = C_all; % overwrite C with pooled version + cCol = catIdx + 1; % column index recomputed defensively (same value) + + % Rebuild unique levels from the pooled C. + levels = unique(C(:, cCol)); + nLevels = numel(levels); + + responsesList = cell(nSpeeds, 1); + baselinesList = cell(nSpeeds, 1); + + for s = 1:nSpeeds + fName_s = sprintf('Speed%d', s); + trialTimes_s = responseParams.(fName_s).C(:,1)'; + stimDur_s = responseParams.(fName_s).stimDur; + + % Response: full stimulus duration with sliding window. + Mr_s = BuildBurstMatrix(goodU, round(p.t), ... + round(trialTimes_s), round(stimDur_s)); + + if ~isempty(params.MovingWindowDuration) + assert(size(Mr_s,3) >= params.MovingWindowDuration, ... + 'Speed%d: stimulus (%d ms) shorter than MovingWindowDuration (%d ms).', ... + s, size(Mr_s,3), params.MovingWindowDuration); + + mrMov_s = movmean(Mr_s, params.MovingWindowDuration, 3, ... + 'Endpoints', 'discard'); + responsesList{s} = max(mrMov_s, [], 3); % [nTrials_s × nNeurons] + else + responsesList{s} = mean(Mr_s, 3); + end + + % Baseline: fixed window — same logic for all speeds. + Mb_s = BuildBurstMatrix(goodU, round(p.t), ... + round(trialTimes_s - baseStart), baseDur); + baselinesList{s} = mean(Mb_s, 3); + + fprintf('Speed%d: %d ms sliding window over %d ms, %d trials pooled.\n', ... + s, params.MovingWindowDuration, round(stimDur_s), size(Mr_s,1)); + end + + % Concatenate across speeds: [nTotalTrials × nNeurons]. + responsesFull = vertcat(responsesList{:}); + baselinesFull = vertcat(baselinesList{:}); + DiffFull = responsesFull - baselinesFull; + + % Pooled baseline SD across all trials and speeds. + sdBase = std(baselinesFull, 0, 1); % [1 × nNeurons] + + else + % Fixed-window response for everything that isn't a moving ball. + trialTimes = C(:,1)'; + + MrFull = BuildBurstMatrix(goodU, round(p.t), ... + round(trialTimes), params.BaseRespWindow); + responsesFull = mean(MrFull, 3); % [nTrials × nNeurons] + + % Baseline: window ending `BaselineBuffer` ms before onset. + MbFull = BuildBurstMatrix(goodU, round(p.t), ... + round(trialTimes - baseStart), baseDur); + baselinesFull = mean(MbFull, 3); % [nTrials × nNeurons] + + DiffFull = responsesFull - baselinesFull; % [nTrials × nNeurons] + + % Pooled baseline SD across all trials. + sdBase = std(baselinesFull, 0, 1); % [1 × nNeurons] + end + + % --------------------------------------------------------------------- + % Split DiffFull by category level (shared code path for moving-ball + % non-speed and all other stimuli). When CategoryMaximized is set, the + % per-neuron best level of the maximizing category is chosen and the + % resulting Diff_k is NaN-padded so downstream code sees a regular + % rectangular array; ALL stats below are NaN-aware. + % --------------------------------------------------------------------- + allDiff = cell(nLevels, 1); + + for k = 1:nLevels + maskCompare = C(:, cCol) == levels(k); % logical mask: trials at this level of compareCategory + + if ~useMaxCategory + % Plain version: keep every trial at this level. + allDiff{k} = DiffFull(maskCompare, :); + fprintf('Level %g: %d trials\n', levels(k), sum(maskCompare)); + + else + % Maximizing version: per neuron, pick the maxCategory level + % with the largest mean response, then keep only those trials. + Diff_k = nan(sum(maskCompare), nNeurons); % allocate NaN-padded matrix + + for n = 1:nNeurons + bestResp = -inf; % best mean Diff seen so far for this neuron + bestLevel = nan; % which level of maxCategory produced it + + for m = 1:numel(maxLevels) + maskTmp = maskCompare & (C(:, maxCol) == maxLevels(m)); % trials at this (compare, max) combination + if ~any(maskTmp) + continue % no trials in this cell + end + muTmp = mean(DiffFull(maskTmp, n), 1); % mean Diff for neuron n in this cell + if muTmp > bestResp + bestResp = muTmp; + bestLevel = maxLevels(m); + end + end + + finalMask = maskCompare & (C(:, maxCol) == bestLevel); % select trials at the best maxCategory level + tmp = DiffFull(finalMask, n); % Diff values for this neuron at its best cell + Diff_k(1:numel(tmp), n) = tmp; % top-fill column n; remaining rows stay NaN + end + + allDiff{k} = Diff_k; + fprintf('Level %g: maximizing "%s"\n', levels(k), params.CategoryMaximized); + end + end +end + +% ========================================================================= +% Per-level responsiveness test +% Sign-flip permutation: H0: mean(Diff) = 0 for this level. +% NaN-aware so the maximized branch works correctly. +% One-tailed (excitatory): p = proportion of null >= observed. +% Z-score: bias-corrected by subtracting null mean, normalised by sdBase. +% ========================================================================= +pValPerLevel = nan(nLevels, nNeurons); % [nLevels × nNeurons] p-values per level vs baseline +zPerLevel = nan(nLevels, nNeurons); % [nLevels × nNeurons] z-scores +obsStatLevels = nan(nLevels, nNeurons); % [nLevels × nNeurons] observed mean Diff + +for k = 1:nLevels + Diff_k = allDiff{k}; % [nTrials_k × nNeurons], possibly NaN-padded + nTrials_k = size(Diff_k, 1); % padded row count (same across neurons) + + % Per-neuron valid trial counts; zero-fill NaNs so they contribute 0 + % to both the observed sum and the sign-flipped null sum. Sign × 0 = 0, + % so randomly flipping the sign of a padded position doesn't affect the + % null statistic. + isValid = ~isnan(Diff_k); % [nTrials_k × nNeurons] + nValid_k = sum(isValid, 1); % [1 × nNeurons] + Diff_k0 = Diff_k; + Diff_k0(~isValid) = 0; % zero-filled copy used for matrix arithmetic + + % Observed mean per neuron (each neuron divided by its own valid n). + ObsStat_k = sum(Diff_k0, 1) ./ max(nValid_k, 1); % [1 × nNeurons] + ObsStat_k(nValid_k == 0) = NaN; % neurons with no valid trials -> NaN (not 0) + obsStatLevels(k,:) = ObsStat_k; + + % Sign-flip null. signs_k is ±1 with each column an independent draw. + signs_k = 2*randi(2, nTrials_k, params.nBoot) - 3; % [nTrials_k × nBoot], values in {-1, +1} + nullSum_k = signs_k' * Diff_k0; % [nBoot × nNeurons], NaN-free because of zero-fill + nullDist_k = nullSum_k ./ max(nValid_k, 1); % broadcast: divide each column by its own valid n + nullDist_k(:, nValid_k == 0) = NaN; % keep empty-neuron columns as NaN + + % One-tailed p-value: proportion of null >= observed. + pValPerLevel(k,:) = mean(nullDist_k >= ObsStat_k, 1, 'omitnan'); + + % Bias-corrected z-score. + nullMean_k = mean(nullDist_k, 1, 'omitnan'); % [1 × nNeurons] + z_k = (ObsStat_k - nullMean_k) ./ sdBase; % [1 × nNeurons] + z_k(sdBase == 0) = 0; % avoid 0/0 for silent neurons + z_k(nValid_k == 0) = NaN; % preserve missingness + zPerLevel(k,:) = z_k; +end + +% ========================================================================= +% Omnibus test: permutation one-way ANOVA F-test +% H0: mean response is equal across all levels. +% Permutes level labels nBoot times. computeFstat (helper at bottom) +% computes the F-statistic per neuron with full NaN-awareness. +% ========================================================================= +DiffPooled = vertcat(allDiff{:}); % [nTotalTrials × nNeurons] +nTotalTrials = size(DiffPooled, 1); + +% Level label vector: integer k repeated nTrials_k times for each level. +levelLabelsVec = cell2mat(arrayfun(@(k) ... + k * ones(size(allDiff{k},1), 1), ... + (1:nLevels)', 'UniformOutput', false)); % [nTotalTrials × 1] + +% Observed F per neuron. +F_obs = computeFstat(DiffPooled, levelLabelsVec, levels); % [1 × nNeurons] + +% Null distribution: permute labels and recompute F. +nullF = zeros(params.nBoot, nNeurons); +for b = 1:params.nBoot + permLabels = levelLabelsVec(randperm(nTotalTrials)); % shuffle labels across trials + nullF(b,:) = computeFstat(DiffPooled, permLabels, levels); +end + +pValOmnibus = mean(nullF >= F_obs, 1, 'omitnan'); % [1 × nNeurons] + +% ========================================================================= +% Pairwise comparisons: two-sample permutation test for each level pair. +% Observed: mean(Diff_i) - mean(Diff_j). +% Null: randomly reassign trials between the two groups, recompute mean +% difference. Two-tailed: |observed| >= |null|. +% NaN-aware: each group's mean is computed per neuron using its own valid n. +% FDR correction across all pairs × neurons. +% ========================================================================= +pairIdx = nchoosek(1:nLevels, 2); % [nPairs × 2] +nPairs = size(pairIdx, 1); + +pValPairwise = nan(nPairs, nNeurons); % raw p-values +obsStatPairwise = nan(nPairs, nNeurons); % observed mean differences + +for pr = 1:nPairs + i = pairIdx(pr,1); + j = pairIdx(pr,2); + Diff_i = allDiff{i}; % [nTrials_i × nNeurons], possibly NaN-padded + Diff_j = allDiff{j}; + nI = size(Diff_i, 1); + nJ = size(Diff_j, 1); + + % Observed mean difference per neuron (NaN-aware). + ObsDiff_ij = mean(Diff_i, 1, 'omitnan') - mean(Diff_j, 1, 'omitnan'); + obsStatPairwise(pr,:) = ObsDiff_ij; + + % Pool trials and prepare zero-filled copy + per-row validity mask. + DiffPair = [Diff_i; Diff_j]; % [nI+nJ × nNeurons] + isValidPair = ~isnan(DiffPair); % [nI+nJ × nNeurons] + DiffPair0 = DiffPair; + DiffPair0(~isValidPair) = 0; + nTotal_pr = nI + nJ; + + nullPair = zeros(params.nBoot, nNeurons); + for b = 1:params.nBoot + perm = randperm(nTotal_pr); + idx1 = perm(1:nI); + idx2 = perm(nI+1:end); + + % Per-neuron group sums and valid counts. + s1 = sum(DiffPair0(idx1,:), 1); % [1 × nNeurons] + s2 = sum(DiffPair0(idx2,:), 1); + n1 = sum(isValidPair(idx1,:), 1); + n2 = sum(isValidPair(idx2,:), 1); + + m1 = s1 ./ max(n1, 1); + m2 = s2 ./ max(n2, 1); + m1(n1 == 0) = NaN; + m2(n2 == 0) = NaN; + + nullPair(b,:) = m1 - m2; + end + + % Two-tailed p-value. + pValPairwise(pr,:) = mean(abs(nullPair) >= abs(ObsDiff_ij), 1, 'omitnan'); +end + +% FDR correction across all (pair × neuron) cells simultaneously. +if params.ApplyFDR && nPairs > 1 + pFlat = pValPairwise(:); % [nPairs*nNeurons × 1] + validMask = ~isnan(pFlat); + pAdj = nan(size(pFlat)); + if any(validMask) + [pAdj(validMask), ~, ~, ~] = fdr_BH(pFlat(validMask), 0.05, false); % Benjamini-Hochberg, two-stage off + end + pValPairwiseAdj = reshape(pAdj, nPairs, nNeurons); % [nPairs × nNeurons] +else + pValPairwiseAdj = pValPairwise; +end + +% ========================================================================= +% Store results in struct S and save. +% ========================================================================= +S.categoryName = params.compareCategory; % name of compared category +S.categoryLevels = levels; % actual level values +S.params = params; +S.CategoryMaximized = params.CategoryMaximized; + +% Per-level results — field named by category and level value. +for k = 1:nLevels + % Build a valid MATLAB struct field name from category name + level value. + fName_k = sprintf('%s_%g', lower(strtrim(params.compareCategory)), levels(k)); + fName_k = strrep(fName_k, '.', 'p'); % '0.5' -> '0p5' (valid field) + fName_k = strrep(fName_k, '-', 'neg'); % '-1' -> 'neg1' + + S.(fName_k).pvalsResponse = pValPerLevel(k,:); % [1 × nNeurons] p-values vs baseline + S.(fName_k).ZScoreU = zPerLevel(k,:); % [1 × nNeurons] bias-corrected z-score + S.(fName_k).ObsStat = obsStatLevels(k,:); % [1 × nNeurons] mean Diff (spikes/ms) + S.(fName_k).nTrials = size(allDiff{k}, 1); % padded row count for this level + S.(fName_k).nValid = sum(~isnan(allDiff{k}), 1); % [1 × nNeurons] valid-trial count per neuron +end + +% Omnibus test. +S.omnibus.pVal = pValOmnibus; % [1 × nNeurons] any difference across levels? +S.omnibus.F_obs = F_obs; % [1 × nNeurons] observed F-statistic + +% Pairwise results. +for pr = 1:nPairs + i = pairIdx(pr,1); + j = pairIdx(pr,2); + pairName = sprintf('%s_%g_vs_%g', ... + lower(strtrim(params.compareCategory)), levels(i), levels(j)); + pairName = strrep(pairName, '.', 'p'); + pairName = strrep(pairName, '-', 'neg'); + + S.pairwise.(pairName).pVal = pValPairwise(pr,:); % [1 × nNeurons] raw + S.pairwise.(pairName).pValAdj = pValPairwiseAdj(pr,:); % [1 × nNeurons] FDR-corrected + S.pairwise.(pairName).obsDiff = obsStatPairwise(pr,:); % [1 × nNeurons] level_i minus level_j +end + +fprintf('Saving results to %s\n', outputFile); +save(outputFile, '-struct', 'S'); +results = S; + +end % end main function + + +% ========================================================================= +%% Local helper: permutation-compatible one-way ANOVA F-statistic +% ========================================================================= +function F = computeFstat(Diff, levelLabels, levels) +% computeFstat - One-way ANOVA F-statistic for permutation testing. +% +% Inputs: +% Diff : [nTrials × nNeurons] response-minus-baseline values +% (may contain NaN; treated as missing per neuron) +% levelLabels : [nTrials × 1] integer group membership per trial +% levels : unique level values [nLevels × 1] (only its length is used) +% +% Output: +% F : [1 × nNeurons] F-statistic per neuron, NaN-safe +% +% The previous version pooled `nk` across neurons (`all(~isnan(...),2)`) +% which collapsed to zero whenever different neurons had different NaN +% patterns (the maximized branch). This rewrite computes nk, group means, +% the grand mean, df_between, and df_within per neuron. + + nLevels = numel(levels); + nNeurons = size(Diff, 2); + + % Validity mask and zero-filled copy: zero-filled values contribute + % nothing to sums (so sums-of-squares stay correct as long as we use + % the SAME zero-filled copy and the per-neuron group means). + isValid = ~isnan(Diff); % [nTotal × nNeurons] + nValidTotal = sum(isValid, 1); % [1 × nNeurons] + Diff0 = Diff; + Diff0(~isValid) = 0; + + % Grand mean per neuron (using each neuron's own valid n). + grandMean = sum(Diff0, 1) ./ max(nValidTotal, 1); % [1 × nNeurons] + + SS_between = zeros(1, nNeurons); + SS_within = zeros(1, nNeurons); + nGroupsUsed = zeros(1, nNeurons); % count of non-empty groups per neuron (for df_between) + + for k = 1:nLevels + mask = levelLabels == k; % trials assigned to group k under the current label vector + Dg0 = Diff0(mask, :); % zero-filled group block + isVg = isValid(mask, :); % per-neuron validity within this group + nk_per_n = sum(isVg, 1); % [1 × nNeurons] valid trials in group k per neuron + + % Per-neuron group mean (use 1 in the denominator when nk=0 to + % avoid division by zero; that group simply contributes nothing + % because nk_per_n is 0 in the SS_between term). + groupMean = sum(Dg0, 1) ./ max(nk_per_n, 1); % [1 × nNeurons] + + % SS_between = sum_k nk * (groupMean_k - grandMean)^2, per neuron. + SS_between = SS_between + nk_per_n .* (groupMean - grandMean).^2; + + % SS_within = sum_k sum_i (x_ik - groupMean_k)^2, per neuron. + % Subtract groupMean within the group, then zero out the contribution + % of invalid (NaN-padded) rows so they don't add (groupMean)^2 noise. + dev = Dg0 - groupMean; % broadcasts to [nk × nNeurons] + dev(~isVg) = 0; % invalid rows contribute 0 + SS_within = SS_within + sum(dev.^2, 1); + + % Count this group for df purposes only if it contributed any + % valid trials for that neuron. + nGroupsUsed = nGroupsUsed + (nk_per_n > 0); + end + + df_between = max(nGroupsUsed - 1, 1); % per neuron; floor at 1 to avoid 0/0 + df_within = max(nValidTotal - nGroupsUsed, 1); % per neuron + + F = (SS_between ./ df_between) ./ (SS_within ./ df_within); + F(SS_within == 0) = 0; % degenerate: all trials identical within groups + F(nValidTotal == 0) = NaN; % truly empty neurons -> NaN +end \ No newline at end of file diff --git a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m new file mode 100644 index 0000000..b57fb40 --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m @@ -0,0 +1,382 @@ +function results = StatisticsPerNeuronSpatialGrid(obj, params) +% StatisticsPerNeuronSpatialGrid - Spatial grid analysis of moving ball responses. +% +% Divides the screen into a GridSize × GridSize grid and analyses the response +% at each grid cell as the ball centre crosses it. Responses are compared across +% directions (main factor) and further split by other stimulus factors (offset, +% size, speed, luminosity) for downstream analysis. +% +% Pipeline: +% 1. Detect ball crossings per trial per grid cell (computeBallGridCrossings) +% 2. Extract GridAnalysisWindow ms response at each crossing +% 3. Per-direction permutation test with pooled null across directions +% 4. Best-direction observed stat, bias-corrected z-score +% 5. Per-cell z-scores split by direction × each non-direction factor +% +% Only applies to linearlyMovingBall. Other stimuli use the standard function. +% +% Reference: +% Nichols & Holmes (2002) Human Brain Mapping 15:1-25 + +arguments (Input) + obj + params.nBoot = 10000 % number of permutation iterations + params.randomSeed = 42 % fixed seed for reproducibility + params.GridSize = 9 % 9×9 = 81 grid cells + params.GridAnalysisWindow = 200 % ms analysis window starting at ball crossing + params.MinTrialsPerCell = 3 % minimum trials per cell×direction×factor level + params.ApplyFDR = false % Benjamini-Hochberg FDR correction reduces the number of false positives. + params.overwrite = false % recompute even if cached results exist +end + +% ------------------------------------------------------------------------- +% Load cached results if available +% ------------------------------------------------------------------------- +if isfile(obj.getAnalysisFileName) && ~params.overwrite + if nargout == 1 + fprintf('Loading saved results from file.\n'); + results = load(obj.getAnalysisFileName); + else + fprintf('Analysis already exists (use overwrite option to recalculate).\n'); + end + return +end + +% ------------------------------------------------------------------------- +% Fix random seed for reproducible permutation results +% ------------------------------------------------------------------------- +rng(params.randomSeed); + +% ------------------------------------------------------------------------- +% Load spike-sorted somatic units +% ------------------------------------------------------------------------- +p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder,0,1); % kilosort/phy output +label = string(p.label'); % unit quality labels +goodU = p.ic(:, label == 'good'); % somatic units only + +if isempty(goodU) + warning('%s has no somatic neurons.', obj.dataObj.recordingName); + results = []; + return +end + +responseParams = obj.ResponseWindow; +nSpeeds = numel(unique(obj.VST.speed)); % number of distinct speed conditions +winSize = params.GridAnalysisWindow; % analysis window in ms + +% ========================================================================= +% Main loop over speed conditions (Speed1, Speed2) +% ========================================================================= +for s = 1:nSpeeds + + fieldName = sprintf('Speed%d', s); + C = responseParams.(fieldName).C; % stimulus category matrix + trialTimes = C(:,1)'; % trial onset times in ms + stimDur = responseParams.(fieldName).stimDur; % full stimulus duration in ms + + % ------------------------------------------------------------------------- + % Frame-to-time conversion per trial + % obj.VST.nFrames is [nSpeeds × nOffsets × nDirections] — frame count differs + % across trials because speed changes trajectory duration. + % For this speed condition (indexed by s), look up per-(offset, direction) frame + % count, then map each trial's (offset, direction) to its correct frame count. + % ------------------------------------------------------------------------- + nFramesFull = obj.VST.nFrames; + + + % Build per-trial frame count using C(:,2)=direction and C(:,3)=offset + uDirsAll = unique(C(:,2)); + uOffsetsAll = unique(C(:,3)); + nTrials = size(C,1); + nFramesPerTrial = zeros(nTrials, 1); + + if ndims(nFramesFull) == 3 + nFramesThisSpeed = reshape(nFramesFull(s, :, :),size(nFramesFull,2),size(nFramesFull,3)); % [nOffsets × nDirections] + else + nFramesThisSpeed = nFramesFull; % single-speed fallback + end + + + for t = 1:nTrials + dIdx = find(uDirsAll == C(t, 2)); % direction index + oIdx = find(uOffsetsAll == C(t, 3)); % offset index + nFramesPerTrial(t) = nFramesThisSpeed(oIdx, dIdx); + end + + % Per-trial ms-per-frame conversion: [nTrials × 1] + msPerFramePerTrial = stimDur ./ nFramesPerTrial; + + % ------------------------------------------------------------------------- + % Detect ball crossings per trial per grid cell + % Returns: crossingFrame [nTrials × nCells], dwellFrames [nTrials × nCells], + % validGridPerDir [nCells × nDirs], nTrialsPerCellDir [nCells × nDirs] + % ------------------------------------------------------------------------- + [crossingFrame, dwellFrames, validGridPerDir, nTrialsPerCellDir] = ... + computeBallGridCrossings(obj, s, params); + + % Dwell time in ms per trial per cell (per-trial frame rate) + dwellTimeMs = dwellFrames .* msPerFramePerTrial; % broadcast [nTrials × 1] across cells + + % Warn for cells with low mean dwell time + meanDwellPerCell = mean(dwellTimeMs, 1, 'omitnan'); % [1 × nCells] + lowDwellCells = find(meanDwellPerCell < winSize/2 & meanDwellPerCell > 0); + if ~isempty(lowDwellCells) + fprintf(['Warning: %d grid cells have mean dwell time < %.0fms ' ... + '(half of analysis window). Interpret results at these ' ... + 'cells cautiously.\n'], numel(lowDwellCells), winSize/2); + end + + % ------------------------------------------------------------------------- + % Set up dimensions + % ------------------------------------------------------------------------- + directions = C(:,2); % direction label per trial + uDirs = unique(directions); % unique direction values + nDirs = numel(uDirs); % number of directions + nNeurons = size(goodU, 2); % number of somatic units + nCells = params.GridSize^2; % total grid cells (e.g. 81) + + % ------------------------------------------------------------------------- + % Build full-duration response burst matrix ONCE for all trials + % MrFull: [nTrials × nNeurons × stimDur] spike counts per ms bin + % Then index into this matrix per grid cell using crossing times + % ------------------------------------------------------------------------- + MrFull = BuildBurstMatrix(goodU, round(p.t), round(trialTimes), round(stimDur)); + + % ------------------------------------------------------------------------- + % Baseline burst matrix: winSize ms from start of ITI preceding each trial + % Mb: [nTrials × nNeurons × winSize] + % baselines: [nTrials × nNeurons] — mean spikes/ms per trial per neuron + % One baseline per trial, shared across all grid cells crossed in that trial + % ------------------------------------------------------------------------- + MbMat = BuildBurstMatrix(goodU, round(p.t), ... + round(trialTimes - winSize), ... + winSize); + baselines = mean(MbMat, 3); % [nTrials × nNeurons] + + % Pooled baseline SD across all trials — stable z-score normalisation + sdBase = std(baselines, 0, 1); % [1 × nNeurons] + + % ------------------------------------------------------------------------- + % Extract per-cell response by indexing into MrFull + % gridResponse: [nTrials × nCells × nNeurons] + % spikes/ms averaged over winSize starting at crossing time + % NaN where ball did not cross that cell on that trial + % ------------------------------------------------------------------------- + gridResponse = nan(nTrials, nCells, nNeurons); + crossingMsInTrial = (crossingFrame - 1) .* msPerFramePerTrial; % [nTrials × nCells] + nTruncated = 0; + + for t = 1:nTrials + for c = 1:nCells + if isnan(crossingFrame(t,c)) + continue % ball did not cross this cell on this trial + end + + startBin = round(crossingMsInTrial(t,c)) + 1; % 1-indexed start bin in MrFull + endBin = startBin + winSize - 1; % inclusive end bin + + if endBin > size(MrFull, 3) + endBin = size(MrFull, 3); % truncate at stimulus end + nTruncated = nTruncated + 1; % count for diagnostic + end + + % Mean spikes/ms over the window for all neurons in this trial + gridResponse(t, c, :) = mean(MrFull(t, :, startBin:endBin), 3); + end + end + + % Report truncation diagnostic + if nTruncated > 0 + totalCrossings = sum(~isnan(crossingFrame(:))); + fprintf(['Info: %d of %d trial-cell crossings (%.1f%%) had truncated ' ... + 'analysis windows due to reaching stimulus end.\n'], ... + nTruncated, totalCrossings, 100*nTruncated/totalCrossings); + end + + % ------------------------------------------------------------------------- + % Per-trial per-cell Diff: response minus trial's baseline + % Broadcasting: baselines [nTrials × nNeurons] → [nTrials × 1 × nNeurons] + % gridDiff: [nTrials × nCells × nNeurons], NaN where ball did not cross + % ------------------------------------------------------------------------- + gridDiff = gridResponse - reshape(baselines, nTrials, 1, nNeurons); + + % ========================================================================= + % Per-direction observed stat and null distribution + % + % For each direction: + % Per-cell mean Diff across trials of that direction (ignoring NaN) + % Mask invalid cells (validGridPerDir) + % Max across valid cells = observed stat for this direction + % Sign-flip trials within direction → null distribution for this direction + % + % Pooled null = concatenation of per-direction null distributions + % Observed overall stat = max across directions per neuron + % ========================================================================= + obsStatPerDir = zeros(nDirs, nNeurons); % [nDirs × nNeurons] + % Joint null over directions: ONE draw per bootstrap = max over cells AND + % directions. Initialised at -Inf and updated by a running max across the + % direction loop, so the null search space matches the observed statistic + % (also max over cells AND directions) and FWER is controlled over both. + nullMax = -Inf(params.nBoot, nNeurons); % [nBoot × nNeurons] + + for d = 1:nDirs + trialsD = find(directions == uDirs(d)); % trial indices for this direction + nTd = numel(trialsD); + + % Extract Diff for this direction: [nTd × nCells × nNeurons] + DiffD = gridDiff(trialsD, :, :); + + % Per-cell mean across trials for this direction (omits NaN from uncrossed cells) + meanDiffD = squeeze(mean(DiffD, 1, 'omitnan')); % [nCells × nNeurons] + if nNeurons == 1 + meanDiffD = reshape(meanDiffD, nCells, 1); % guard singleton collapse + end + + % Mask cells invalid for this direction + meanDiffDMasked = meanDiffD; + meanDiffDMasked(~validGridPerDir(:,d), :) = -Inf; + + % Observed max per neuron for this direction + obsStatPerDir(d, :) = max(meanDiffDMasked, [], 1); + + % Sign-flip permutations within this direction + % signs: [nTd × nBoot] — each column is one permutation + signs = 2 * randi(2, nTd, params.nBoot) - 3; + + for b = 1:params.nBoot + % Apply signs: broadcast [nTd × 1 × 1] across [nTd × nCells × nNeurons] + DiffPerm = DiffD .* reshape(signs(:,b), nTd, 1, 1); + + % Per-cell mean under H0 + meanPerm = squeeze(mean(DiffPerm, 1, 'omitnan')); % [nCells × nNeurons] + if nNeurons == 1 + meanPerm = reshape(meanPerm, nCells, 1); + end + + meanPerm(~validGridPerDir(:,d), :) = -Inf; + + % Join this direction's permuted cell-max into the running max across + % directions for bootstrap b. After the direction loop completes, + % nullMax(b,:) is the max over cells AND directions — the correct + % max-statistic null draw matching the observed obsStat. + nullMax(b, :) = max(nullMax(b, :), max(meanPerm, [], 1)); + end + end + + % ------------------------------------------------------------------------- + % Overall observed stat and p-value + % obsStat: max across cells AND directions per neuron + % pVal: proportion of the joint (cells × directions) max null >= observed + [obsStat, prefDirection] = max(obsStatPerDir, [], 1); % [1 × nNeurons] + pVal = mean(nullMax >= obsStat, 1); % [1 × nNeurons] + + + if params.ApplyFDR + [pVal, ~, ~,~] = fdr_BH(pVal, 0.05); + + end + + % ------------------------------------------------------------------------- + % Bias-corrected z-score + % z = (observed - expected null max) / pooled baseline SD + % Subtracting mean(nullStatsAll) removes winner's curse inflation + % ------------------------------------------------------------------------- + nullMean = mean(nullMax, 1); % [1 × nNeurons] + z = (obsStat - nullMean) ./ sdBase; % [1 × nNeurons] + z_mean = obsStat; + z(sdBase == 0) = 0; % silent baseline — set to 0 + + % ------------------------------------------------------------------------- + % Preferred grid cell per neuron at preferred direction + % Identified from observed mean Diff across trials of preferred direction + % ------------------------------------------------------------------------- + prefGridCell = zeros(1, nNeurons); + for u = 1:nNeurons + d = prefDirection(u); + trialsD = find(directions == uDirs(d)); + meanDiffD = squeeze(mean(gridDiff(trialsD, :, u), 1, 'omitnan')); % [nCells × 1] + meanDiffD(~validGridPerDir(:,d)) = -Inf; + [~, prefGridCell(u)] = max(meanDiffD); + end + + % ========================================================================= + % Per-cell z-scores split by direction × each non-direction factor + % C columns: 1=stimOn, 2=direction, 3=offset, 4=size, 5=speed, 6=luminosity + % Output struct: one field per factor, each [nCells × nDirs × nLevels × nNeurons] + % Direction is already a dimension of each array — not a separate field + % ========================================================================= + factorCols = 3:size(C,2); % non-direction factor columns + factorNames = {'offset', 'size', 'speed', 'luminosity'}; + factorNames = factorNames(1:numel(factorCols)); % trim to available columns + + ZScorePerGrid = struct(); + + for fIdx = 1:numel(factorCols) + col = factorCols(fIdx); + uLevels = unique(C(:, col)); % unique values for this factor + nLevels = numel(uLevels); + fName = factorNames{fIdx}; + + % Pre-allocate [nCells × nDirs × nLevels × nNeurons], NaN default + zArr = nan(nCells, nDirs, nLevels, nNeurons); + + for d = 1:nDirs + for lev = 1:nLevels + % Trials matching this direction AND this factor level + mask = (directions == uDirs(d)) & (C(:,col) == uLevels(lev)); + trialsDL = find(mask); + + if numel(trialsDL) < params.MinTrialsPerCell + continue % too few trials — leave NaN + end + + DiffDL = gridDiff(trialsDL, :, :); % [nTdl × nCells × nNeurons] + nTrialsPerCellDL = sum(~isnan(DiffDL(:,:,1)), 1); % [1 × nCells] + + meanDL = squeeze(mean(DiffDL, 1, 'omitnan')); % [nCells × nNeurons] + if nNeurons == 1 + meanDL = reshape(meanDL, nCells, 1); + end + + % Normalise by pooled baseline SD — same for all cells + zDL = meanDL ./ reshape(sdBase, 1, nNeurons); % [nCells × nNeurons] + + % Mask cells with too few crossings for this (direction, factor level) + zDL(nTrialsPerCellDL < params.MinTrialsPerCell, :) = NaN; + + % Store in 4D array + zArr(:, d, lev, :) = reshape(zDL, nCells, 1, 1, nNeurons); + end + end + + ZScorePerGrid.(fName) = zArr; % [nCells × nDirs × nLevels × nNeurons] + end + + % ========================================================================= + % Store results for this speed condition + % ========================================================================= + S.(fieldName).pvalsResponse = pVal; % [1 × nNeurons] + S.(fieldName).ZScoreU = z; % [1 × nNeurons] bias-corrected z + S.(fieldName).prefDirection = prefDirection; % [1 × nNeurons] + S.(fieldName).prefGridCell = prefGridCell; % [1 × nNeurons] + S.(fieldName).ObsResponse = gridResponse; % [nTrials × nCells × nNeurons] + S.(fieldName).ObsBaseline = baselines; % [nTrials × nNeurons] + S.(fieldName).ObsDiff = gridDiff; % [nTrials × nCells × nNeurons] + S.(fieldName).validGrid = validGridPerDir; % [nCells × nDirs] + S.(fieldName).dwellTimeMs = dwellTimeMs; % [nTrials × nCells] + S.(fieldName).meanDwellPerCell = meanDwellPerCell; % [1 × nCells] + S.(fieldName).nTrialsPerCellDir = nTrialsPerCellDir; % [nCells × nDirs] + S.(fieldName).ZScorePerGrid = ZScorePerGrid; % struct of 4D arrays + S.(fieldName).gridSize = params.GridSize; % scalar, grid dimensions + S.(fieldName).z_mean = z_mean*1000; + + S.params = params; % store parameters for reproducibility + +end % end speed loop + +% --- Save and return --- +fprintf('Saving results to file.\n'); +save([fileparts(obj.getAnalysisFileName) filesep 'StatisticsPerNeuron.mat'], '-struct', 'S'); +results = S; + +end % end main function \ No newline at end of file diff --git a/visualStimulationAnalysis/@VStimAnalysis/VStimAnalysis.m b/visualStimulationAnalysis/@VStimAnalysis/VStimAnalysis.m index 928f295..4242523 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/VStimAnalysis.m +++ b/visualStimulationAnalysis/@VStimAnalysis/VStimAnalysis.m @@ -40,6 +40,7 @@ else obj.dataObj=dataObj; end + end end @@ -261,6 +262,8 @@ function plotCorrSpikePattern(obj,params) params.overwrite logical = false %if true overwrites results params.analysisTime = datetime('now') %extract the time at which analysis was performed params.inputParams = false %if true - prints out the iput parameters so that it is clear what can be manipulated in the method + params.fallbackToDigitalTriggers logical = true + params.mismatchThreshold = 0.1 end if params.inputParams,disp(params),return,end @@ -289,11 +292,11 @@ function plotCorrSpikePattern(obj,params) elseif isfield(obj.VST,'flip') allFlips=obj.VST.flip'; elseif isfield(obj.VST,'flipOnsetTimeStamp') - if ~params.analyzeOnlyOnFlips + if ~params.analyzeOnlyOnFlips allFlips=[obj.VST.flipOnsetTimeStamp;obj.VST.flipOffsetTimeStamp]; else allFlips=[obj.VST.flipOnsetTimeStamp]; - end + end end @@ -318,9 +321,38 @@ function plotCorrSpikePattern(obj,params) expectedFlips=numel(allFlips); fprintf('%d flips expected, %d found (diff=%d). Linking existing flip times with stimuli...\n',expectedFlips,measuredFlips,expectedFlips-measuredFlips); - if (expectedFlips-measuredFlips)>0.1*expectedFlips - fprintf('There are more than 10 percent mismatch in the number of diode and vStim expected flips. Cant continue!!! Please check diode extraction!\n'); - return; + mismatchFrac = (expectedFlips - measuredFlips) / expectedFlips; + if mismatchFrac > params.mismatchThreshold + if params.fallbackToDigitalTriggers + fprintf('Diode/vStim mismatch %.1f%% exceeds %.0f%% threshold. Re-extracting via digitalTriggerDiode...\n', ... + mismatchFrac*100, params.mismatchThreshold*100); + try + % digitalTriggerDiode windows each trial with the TTL triggers (t{3}/t{4}) and + % interpolates frame times where the photodiode failed. + % overwrite=true REQUIRED: getDiodeTriggers caches per method-name only and does + % not validate params.extractionMethod on reload, so without it the cached analog + % result is returned. Side effect: the cache now holds the digital extraction. + diode = obj.getDiodeTriggers('extractionMethod','digitalTriggerDiode','overwrite',true); + catch ME + fprintf('digitalTriggerDiode extraction failed (%s). Cant continue!!!\n', ME.message); + return; + end + allDiodeFlips = sort([diode.diodeUpCross,diode.diodeDownCross]); + allDiodeFlips(1+find(diff(allDiodeFlips) params.mismatchThreshold + fprintf('Mismatch still exceeds %.0f%% after digital fallback. Cant continue!!!\n', ... + params.mismatchThreshold*100); + return; + end + else + fprintf('There are more than %.0f%% mismatch in the number of diode and vStim expected flips. Cant continue!!! Please check diode extraction!\n', ... + params.mismatchThreshold*100); + return; + end end switch obj.trialType case 'videoTrials' @@ -329,16 +361,62 @@ function plotCorrSpikePattern(obj,params) stimOnFlipTimes=allDiodeFlips(pTrialStarts); stimOffFlipTimes=allDiodeFlips(pTrialEnds); - if isequal(obj.stimName,'StaticDriftingGrating') %Change because static time could be equal to intertrial delat - - % Compute differences - dv_prev = [NaN diff(allDiodeFlips)]; % difference from previous element - dv_next = [diff(allDiodeFlips) NaN]; % difference from next element - pTrialStarts = [1 find(abs(dv_prev) >= obj.VST.static_time*0.7*1000 & abs(dv_next) >= obj.VST.interTrialDelay*0.7*1000)]; - pTrialEnds = [find(abs(dv_prev) <= (1/obj.VST.fps)*10*1000 & abs(dv_next) >= obj.VST.interTrialDelay*0.7*1000) numel(allDiodeFlips)]; + if isequal(obj.stimName,'StaticDriftingGrating') + + % --- Compute the minimum gap expected between the last drifting + % flip of trial N and the first drifting flip of trial N+1. + % This gap spans: interTrialDelay + static_time (both in seconds + % in obj.VST, so convert to ms). We use 70 % of the sum as a + % conservative threshold to absorb timing jitter. + combinedGapMs = (obj.VST.interTrialDelay + obj.VST.static_time) * 1000; % expected total gap [ms] + gapThresh = combinedGapMs * 0.7; % 70 % detection threshold [ms] + + % --- Compute inter-flip intervals -------------------------------- + dv = diff(allDiodeFlips); % interval between consecutive diode flips [ms] + + % --- Identify large gaps that mark trial boundaries --------------- + % A gap >= gapThresh means "end of one trial's drifting phase + % → start of the next trial's drifting phase (static flip is + % missing)." + largeGapIdx = find(dv >= gapThresh); % indices into allDiodeFlips where large gaps START + % There should be exactly nTotTrials-1 inter-trial gaps; + % discard any trailing gaps beyond that count. + if numel(largeGapIdx) > obj.VST.nTotTrials - 1 + largeGapIdx = largeGapIdx(1:obj.VST.nTotTrials - 1); + end + % --- Derive trial-end and trial-start indices --------------------- + % Trial N ends at the flip just BEFORE the large gap. + % The first drifting flip of trial N+1 is just AFTER the gap. + pTrialEnds = [largeGapIdx, numel(allDiodeFlips)]; % last flip index of each trial (last trial ends at final flip) + driftStarts = [1, largeGapIdx + 1]; % first *drifting* flip index of each trial (trial 1 starts at flip 1) + + % --- Sanity-check: did we recover the expected number of trials? -- + nDetected = numel(driftStarts); + if nDetected ~= obj.VST.nTotTrials + warning('SDG fix: detected %d trial boundaries but expected %d. Check gap threshold (%.1f ms vs combinedGap %.1f ms).', ... + nDetected, obj.VST.nTotTrials, gapThresh, combinedGapMs); + end + + % --- Build stimOnFlipTimes by back-dating static_time ------------- + % The diode did not fire at static onset, so we synthesise that + % time: static_onset = first_drift_flip − static_time. + % This is the trial-start time the rest of the pipeline expects. + staticDurMs = obj.VST.static_time * 1000; % static-phase duration [ms] + stimOnFlipTimes = allDiodeFlips(driftStarts) - staticDurMs; % synthetic static-onset times [ms] + + % --- stimOffFlipTimes = last detected drifting flip of each trial -- + stimOffFlipTimes = allDiodeFlips(pTrialEnds); + + % --- Overwrite pTrialStarts so the per-trial loop below can use + % the *drifting* flip indices (the synthetic static onsets are + % NOT in allDiodeFlips, so we must NOT index into it with them). + pTrialStarts = driftStarts; + + % --- Diagnostic printout ----------------------------------------- + fprintf(['SDG trial detection: %d trials found | gap threshold = %.1f ms\n' ... + ' static_time = %.3f s | interTrialDelay = %.3f s\n'], ... + nDetected, gapThresh, obj.VST.static_time, obj.VST.interTrialDelay); - stimOnFlipTimes=allDiodeFlips(pTrialStarts); - stimOffFlipTimes=allDiodeFlips(pTrialEnds); end if numel(pTrialEnds)~=obj.VST.nTotTrials || numel(pTrialStarts)~=obj.VST.nTotTrials @@ -366,7 +444,11 @@ function plotCorrSpikePattern(obj,params) [in,p]=ismembertol(currentPCFlipTimes-currentPCFlipTimes(1),currentDiodeFlipTimes-currentDiodeFlipTimes(1),obj.VST.ifi*1000/2,'DataScale',1); elseif (frameMatch+sum(pDelayed))==0 && ~isequal(obj.stimName,'StaticDriftingGrating')%at least some of the frames were delayed in presentation and not just missed %look for a delay in diode flips which may explain a consistent delay - tmpDiode=currentDiodeFlipTimes+cumsum([zeros(1,-frameMatch) pDelayed])*(-obj.VST.ifi*1000); + % tmpDiode=currentDiodeFlipTimes+cumsum([zeros(1,-frameMatch) pDelayed])*(-obj.VST.ifi*1000); + % [in,p]=ismembertol(currentPCFlipTimes-currentPCFlipTimes(1),tmpDiode-tmpDiode(1),obj.VST.ifi*1000/2,'DataScale',1); + compensation = [zeros(1,-frameMatch) cumsum(pDelayed)]; + compensation = compensation(1:numel(currentDiodeFlipTimes)); % length is n+sum(pDelayed)-1; truncate to n + tmpDiode = currentDiodeFlipTimes + compensation*(-obj.VST.ifi*1000); [in,p]=ismembertol(currentPCFlipTimes-currentPCFlipTimes(1),tmpDiode-tmpDiode(1),obj.VST.ifi*1000/2,'DataScale',1); elseif frameMatch<0 && (numel(currentPCFlipTimes)-numel(currentDiodeFlipTimes))>=0 %identifies irregularities in timing in=ones(1,numel(currentDiodeFlipTimes)); @@ -389,6 +471,29 @@ function plotCorrSpikePattern(obj,params) save(obj.getAnalysisFileName,'params','diodeFrameFlipTimes','stimOnFlipTimes','stimOffFlipTimes'); results = load(obj.getAnalysisFileName); case 'imageTrials' + % Image-trial sync needs an exact match between expected flips and detected + % crossings (it pairs them off 1-on/1-off). A small over- or under-count from + % the analog photodiode is fatal here, so fall back to digitalTriggerDiode, + % which forces exactly one on + one off crossing per trial. + if expectedFlips ~= measuredFlips && params.fallbackToDigitalTriggers + fprintf('Image-trial trigger mismatch (%d expected vs %d found). Re-extracting via digitalTriggerDiode...\n', ... + expectedFlips, measuredFlips); + try + % Two-step per the framework convention: an overwrite=true call recomputes and + % saves but returns NO output; a second call (no overwrite) loads the result. + obj.getDiodeTriggers('extractionMethod','digitalTriggerDiode','overwrite',true); % recompute + overwrite cache + diode = obj.getDiodeTriggers('extractionMethod','digitalTriggerDiode'); % load the cached digital result + catch ME + fprintf('digitalTriggerDiode extraction failed (%s). Cant continue!!!\n', ME.message); + return; + end + allDiodeFlips = sort([diode.diodeUpCross,diode.diodeDownCross]); + allDiodeFlips(1+find(diff(allDiodeFlips) squeeze(). +% 2. Row indices into Mr2 used raw trialsPerCath when merged +% -> rowsPerCath = trialsPerCath/mergeTrials. +% 3. maxRespIn 0-based shift applied before trial arithmetic -> clarified. +% 4. 30x10 subplot grid on a 5 cm-tall figure collapsed raster and PSTH +% onto each other -> replaced with explicit ax.Position overrides +% in normalised figure units. +% -------------------------------------------------------------------------- + +arguments (Input) obj - params.overwrite logical = false - params.analysisTime = datetime('now') - params.inputParams = false - params.preBase = 500 - params.bin = 10 - params.exNeurons = 1 - params.AllSomaticNeurons = false - params.AllResponsiveNeurons = false - params.fixedWindow = true - params.MergeNtrials =1 - params.GaussianLength = 3 - params.oneTrial = false - params.imageDir = 'W:\Large_scale_mapping_NP\NormalAndRandImages' + params.overwrite logical = false + params.analysisTime = datetime('now') + params.inputParams logical = false + params.preBase = 500 % Pre/post-stimulus baseline (ms) + params.bin = 20 % Raster bin size (ms/bin) + params.exNeurons = 1 % Neuron index into good-unit list + params.AllSomaticNeurons logical = false + params.AllResponsiveNeurons logical = false + params.fixedWindow logical = true % true: fixed window; false: NeuronVals + params.MergeNtrials = 1 % Trials averaged per raster row + params.GaussianLength = 3 % Gaussian kernel length (bins) + params.oneTrial logical = false % Raw: only trial with most spikes + params.imageDir = 'W:\Large_scale_mapping_NP\NormalAndRandImages' + params.windowDur = 500 % Sliding-window width (ms) + params.psthBinWidth = 100 % PSTH bin width (ms) + params.MaxVal_1 = true + params.plotRawData = false + params.selectCats = [] + params.PaperFig = false + params.bottomMargin = 0.18 % xlabel space (norm. units) + params.midGap = 0.04 % Gap raster<->PSTH (norm. units) + params.psthHeightFrac = 0.18 % PSTH height (norm. units) end +if params.inputParams, disp(params); return; end -NeuronResp = obj.ResponseWindow; -Stats = obj.ShufflingAnalysis; -directimesSorted = NeuronResp.C(:,1)'; +% ========================================================================== +% 1. LOAD PRE-COMPUTED RESULTS +% ========================================================================== -goodU = NeuronResp.goodU; -p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); -p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); -phy_IDg = p.phy_ID(string(p.label') == 'good'); -pvals = Stats.pvalsResponse; +NeuronResp = obj.ResponseWindow; +Stats = obj.StatisticsPerNeuron; -stimDur = NeuronResp.stimDur; -stimInter = NeuronResp.stimInter; +C = NeuronResp.C; +nImages = numel(unique(C(:,2))); +nState = numel(unique(C(:,3))); +directimesSorted = C(:,1)'; -%Organize images asuming that shuffled images are have an even index in cell -%array containing names -imagesNames = cell(1,numel(obj.VST.imgNames)); -imagesNames(1:numel(imagesNames)/2) = obj.VST.imgNames(1:2:numel(imagesNames)); -imagesNames(numel(imagesNames)/2+1:numel(imagesNames)) = obj.VST.imgNames(2:2:numel(imagesNames)); -cd(params.imageDir) +trialsPerCath = size(C,1) / nImages; % Unmerged trials per image category +% ========================================================================== +% 2. IMAGE METADATA AND LOADING +% ========================================================================== -% Load and combine vertically -imgs = cellfun(@imread, imagesNames, 'UniformOutput', false); +imagesNames = cell(1, numel(obj.VST.imgNames)); +imagesNames(1:numel(imagesNames)/2) = obj.VST.imgNames(1:2:end); +imagesNames(numel(imagesNames)/2+1:end) = obj.VST.imgNames(2:2:end); +cd(params.imageDir); +imgs = cellfun(@imread, imagesNames, 'UniformOutput', false); combinedImg = cat(1, imgs{:}); +if ~isempty(params.selectCats) + indexes = []; + for i = 1:numel(params.selectCats) + indexes = [indexes, ... + params.selectCats(i)*trialsPerCath - (trialsPerCath-1) : params.selectCats(i)*trialsPerCath]; + end + C = C(indexes, :); + nImages = numel(unique(C(:,2))); + nState = numel(unique(C(:,3))); + imagesNames = imagesNames(params.selectCats); + imgs = cellfun(@imread, imagesNames, 'UniformOutput', false); + combinedImg = cat(1, imgs{:}); + directimesSorted = C(:,1)'; +end +% ========================================================================== +% 3. SPIKE SORTING METADATA +% ========================================================================== + +p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); +phy_IDg = p.phy_ID(string(p.label') == 'good'); +pvals = Stats.pvalsResponse; +label = string(p.label'); +goodU = p.ic(:, label == 'good'); -% trialDivision = numel(directimesSorted)/numel(unique(NeuronResp.C(:,2)))/numel(unique(NeuronResp.C(:,3)))/... -% numel(unique(NeuronResp.C(:,4))); -nImages = numel(unique(NeuronResp.C(:,2))); -nState = numel(unique(NeuronResp.C(:,3))); +% ========================================================================== +% 4. STIMULUS TIMING +% ========================================================================== +stimDur = NeuronResp.stimDur; preBase = params.preBase; +bin = params.bin; +win = stimDur + preBase*2; + +% ========================================================================== +% 5. NEURON SELECTION +% ========================================================================== if params.AllSomaticNeurons - eNeuron = 1:size(goodU,2); - pvals = [eNeuron;pvals(eNeuron)]; + eNeuron = 1:size(goodU, 2); + pvals = [eNeuron; pvals(eNeuron)]; elseif params.AllResponsiveNeurons - eNeuron = find(pvals<0.05); - pvals = [eNeuron;pvals(eNeuron)];% Select all good neurons if not specified + eNeuron = find(pvals < 0.05); + pvals = [eNeuron; pvals(eNeuron)]; if isempty(eNeuron) - fprintf('No responsive neurons.\n') - return + fprintf('No responsive neurons.\n'); return end else eNeuron = params.exNeurons; - pvals = [eNeuron;pvals(eNeuron)]; + pvals = [eNeuron; pvals(eNeuron)]; end -bin=params.bin; -win=stimDur+preBase*2; -%preBase = round(stimInter/20)*10; +% ========================================================================== +% 6. BUILD RASTER MATRIX +% ========================================================================== -[Mr]=BuildBurstMatrix(goodU,round(p.t/bin),round((directimesSorted-preBase)/bin),round(win/bin)); +Mr = BuildBurstMatrix(goodU, round(p.t/bin), ... + round((directimesSorted - preBase)/bin), round(win/bin)); +Mr = ConvBurstMatrix(Mr, fspecial('gaussian', [1 params.GaussianLength], 3), 'same'); -Mr = ConvBurstMatrix(Mr,fspecial('gaussian',[1 3],3),'same'); +[nT, ~, nB] = size(Mr); -[nT,nN,nB] = size(Mr); -%indRG --> sorted infexes +% ========================================================================== +% 7. PER-NEURON FIGURE LOOP +% ========================================================================== -trialsPerCath = length(directimesSorted)/(nImages); +ur = 1; -ur =1; for u = eNeuron - if params.MergeNtrials >1 - j=1; - mergeTrials = params.MergeNtrials; + % ------------------------------------------------------------------ + % 7a. 2-D raster Mr2 (BUG FIX 1: squeeze) + % ------------------------------------------------------------------ - Mr2 = zeros(nT/mergeTrials,nB); + mergeTrials = params.MergeNtrials; - for i = 1:mergeTrials:nT + if mergeTrials > 1 + nRows = floor(nT / mergeTrials); + Mr2 = zeros(nRows, nB); + for i = 1:nRows + src = (i-1)*mergeTrials + 1 : min(i*mergeTrials, nT); + Mr2(i,:) = mean(squeeze(Mr(src, u, :)), 1); + end + else + Mr2 = squeeze(Mr(:, u, :)); + nRows = nT; + end - meanb = mean(squeeze(Mr(i:min(i+mergeTrials-1, end),u,:)),1); + rowsPerCath = trialsPerCath / mergeTrials; % BUG FIX 2 - Mr2(j,:) = meanb; + % ------------------------------------------------------------------ + % 7b. Best image category + % ------------------------------------------------------------------ - j = j+1; + meanMr = zeros(1, nImages); + for i = 1:nImages + r1 = (i-1)*rowsPerCath + 1; + r2 = i *rowsPerCath; + meanMr(i) = mean(Mr2(r1:r2, :), 'all'); + end - end - else - Mr2=Mr(:,u,:); - mergeTrials =1; + [~, bestCat] = max(meanMr); + bestCatRowStart = (bestCat-1)*rowsPerCath + 1; + bestCatRowEnd = bestCat *rowsPerCath; + trialsAbsolute = (bestCat-1)*trialsPerCath + 1 : bestCat*trialsPerCath; + + % ------------------------------------------------------------------ + % 7c. Sliding-window search + % ------------------------------------------------------------------ + + window = params.windowDur; + nWinBins = round(window / bin); + X = min(Mr2(bestCatRowStart:bestCatRowEnd, :), 1); + nWinPos = nB - nWinBins + 1; + + window_means = zeros(rowsPerCath, nWinPos); + for col = 1:nWinPos + window_means(:, col) = mean(X(:, col:col+nWinBins-1), 2); end - if params.fixedWindow %%Select highest window stim type - j =1; - meanMr = zeros(1,nT/trialsPerCath); - for i = 1:trialsPerCath:nT - meanMr(j) = mean(Mr2(i:i+trialsPerCath-1,:),'all'); - j = j+1; - end + [~, linear_idx] = max(window_means(:)); + [best_row, best_col] = ind2sub(size(window_means), linear_idx); + + bestDisplayRow = bestCatRowStart + best_row - 1; + bestTrialIdx = trialsAbsolute((best_row-1)*mergeTrials + 1); + + % ========================================================================== + % 8. FIGURE — explicit axes positions in normalised figure units + % + % BUG FIX 4: the 30x10 subplot grid put the PSTH at tile rows 21-30 and + % the raster at rows 1-18. On a 5 cm-tall figure each tile is <2 mm high + % and MATLAB's automatic inset margins around each subplot are larger + % than the tile itself — so the raster and PSTH visually overlap. + % + % Fix: compute three panel rectangles directly in normalised figure units + % and create axes with axes('Position', [left bottom width height]). + % This guarantees the PSTH sits below the raster with a clean gap + % (midGap) and leaves an explicit bottom strip (bottomMargin) for + % xlabel and xticklabels to render without being clipped. + % ========================================================================== - [maxResp,maxRespIn]= max(meanMr); - %Figure paper - start = -50; - window = stimDur+100; + fig = figure; + set(fig, 'Units', 'centimeters'); + set(fig, 'Position', [20 20 9 4]); + + % Horizontal layout + leftMargin = 0.12; + rightMargin = 0.04; + thumbWidth = 0.18; + gapColumn = 0.02; + + % Vertical layout + topMargin = 0.04; + bottomMargin = params.bottomMargin; + midGap = params.midGap; + psthHeight = params.psthHeightFrac; + + psthBottom = bottomMargin; + psthTop = bottomMargin + psthHeight; + rasterBottom = psthTop + midGap; + rasterTop = 1 - topMargin; + rasterHeight = rasterTop - rasterBottom; + + rasterWidth = 1 - leftMargin - rightMargin - thumbWidth - gapColumn; + rasterLeft = leftMargin; + thumbLeft = rasterLeft + rasterWidth + gapColumn; + + ax_raster = axes('Position', [rasterLeft, rasterBottom, rasterWidth, rasterHeight]); + ax_thumb = axes('Position', [thumbLeft, rasterBottom, thumbWidth, rasterHeight]); + ax_psth = axes('Position', [rasterLeft, psthBottom, rasterWidth, psthHeight]); + + % ========================================================================== + % 9. RASTER PANEL + % ========================================================================== + + axes(ax_raster); + + M = Mr2 .* (1000 / bin); + imagesc(1:nB, 1:nRows, M); + colormap(ax_raster, flipud(gray(64))); + hold on; + + xline(preBase/bin, 'k', 'LineWidth', 1.5); + xline((stimDur+preBase)/bin, 'k', 'LineWidth', 1.5); + ticks = trialsPerCath:trialsPerCath:nT; + yticks(ticks(1:end-1)) + + xticks([]); % Time axis is labeled on the PSTH below + + yline((rowsPerCath:rowsPerCath:nRows-1) + 0.5, 'LineWidth', 1); + yline((nRows/nState:nRows/nState:nRows-1) + 0.5, 'LineWidth', 3, 'Color', 'k'); + + % Grey patch: best image category + patch([1, nB, nB, 1], ... + [bestCatRowStart-0.5, bestCatRowStart-0.5, ... + bestCatRowEnd+0.5, bestCatRowEnd+0.5], ... + 'k', 'FaceAlpha', 0.12, 'EdgeColor', 'none'); + + % Red patch: best trial x best window + patch([best_col, best_col+nWinBins, best_col+nWinBins, best_col], ... + [bestDisplayRow-0.5, bestDisplayRow-0.5, ... + bestDisplayRow+0.5, bestDisplayRow+0.5], ... + 'r', 'FaceAlpha', 0.35, 'EdgeColor', 'none'); + + if params.MaxVal_1 + clim([0 1]); else - [maxResp,maxRespIn]= max(NeuronResp.NeuronVals(u,:,1)); - start = NeuronResp.NeuronVals(u,maxRespIn,3)*NeuronResp.params.binRaster -20; - window = 500; - end + colorbar; + end - [T,B] = size(Mr2); + ylabel(sprintf('%d trials', nRows * mergeTrials), ... + 'FontSize', 10, 'FontName', 'helvetica'); + ax_raster.FontSize = 8; + ax_raster.FontName = 'helvetica'; - - fig = figure; - subplot(1,10,1:8) - %Build raster - M = Mr2.*(1000/bin); - [nTrials,nTimes]=size(M); - imagesc((1:nTimes),1:nTrials,squeeze(M));colormap(flipud(gray(64))); - xline(preBase/bin, LineWidth=1.5, Color="#77AC30"); - xline((stimDur+preBase)/bin, LineWidth=1.5, Color="#0072BD"); - xticks([preBase/bin (round(stimDur/100)*100+preBase)/bin]); - xticklabels(xticks*bin) - - yline([trialsPerCath/mergeTrials:trialsPerCath/mergeTrials:T/mergeTrials-1]+0.5,LineWidth=1) - - yline([T/mergeTrials/nState:T/mergeTrials/nState:T/mergeTrials-1]+0.5,LineWidth=3,Color='r') - - set(fig, 'Color', 'w'); - % Set the color of the figure and axes to black - colorbar; - %caxis([0 1]); - title(sprintf('NaturalImage-raster-U%d-PhyU-%dpval-%s',u,phy_IDg(u),num2str(pvals(2,ur),'%.4f'))) - ylabel(sprintf('%d trials',nTrials*mergeTrials)) - xlabel('Time (ms)') - - subplot(1,10,9:10) + % ========================================================================== + % 10. THUMBNAIL PANEL + % ========================================================================== + + axes(ax_thumb); imagesc(combinedImg); axis image off; - fig.Position = [147 270 662 446];%[147 58 994 658]; + % ========================================================================== + % 11. PSTH PANEL + % ========================================================================== - %%Plot raw data + axes(ax_psth); - maxRespIn = maxRespIn-1; - trials = maxRespIn*trialsPerCath+1:maxRespIn*trialsPerCath + trialsPerCath; + MRhist = BuildBurstMatrix(goodU(:, u), round(p.t), ... + round(directimesSorted(trialsAbsolute) - preBase), round(win)); + MRhist = squeeze(MRhist); - chan = goodU(1,u); + [nT2, nB2] = size(MRhist); + spikeTimes = repmat(1:nB2, nT2, 1); + spikeTimes = spikeTimes(logical(MRhist)); - startTimes = directimesSorted(trials)+start; + psthBin = params.psthBinWidth; + edges = 1:psthBin:round(win); + psthCounts = histcounts(spikeTimes, edges); + psthRate = (psthCounts / (psthBin * nT2)) * 1000; - freq = "AP"; %or "LFP" + b = bar(edges(1:end-1), psthRate, 'histc'); + b.FaceColor = 'k'; b.FaceAlpha = 0.3; b.MarkerEdgeColor = 'none'; + hold on; - typeData = "line"; %or heatmap + xline(preBase, 'k', 'LineWidth', 1.5); + xline(stimDur + preBase, 'k', 'LineWidth', 1.5); - fig2 = figure; - - spikes = squeeze(BuildBurstMatrix(goodU(:,u),round(p.t),round(startTimes),round((window)))); - - if params.oneTrial - [mx ind] = max(sum(spikes,2)); %select trial with most spikes - else - ind = 1:size(spikes,1); + xlim([0, win]); + + try + ylim([0, max(psthRate) + std(psthRate)]); + catch end - [fig2, mx, mn] = PlotRawDataNP(obj,fig = fig2,chan = chan, startTimes = startTimes(ind),... - window = window,spikeTimes = spikes(ind,:),multFactor =1.5, stdMult = 3); + ylims = ylim; + yticks([round(ylims(2)/2), round(ylims(2))]); + + xticks([0, preBase:preBase:preBase+ceil(stimDur/1000)*1000, win]); + + xticklabels(arrayfun(@(x) sprintf('%.1f', x), ... + [-preBase, 0:preBase:ceil(stimDur/1000)*1000, stimDur+preBase] / 1000, ... + 'UniformOutput', false)); + + xlabel('Time [s]', 'FontSize', 10, 'FontName', 'helvetica'); + ylabel('[spk/s]', 'FontSize', 10, 'FontName', 'helvetica'); + ax_psth.FontSize = 8; + ax_psth.FontName = 'helvetica'; + + % ========================================================================== + % 12. RAW DATA FIGURE + % ========================================================================== + + if params.plotRawData + if params.fixedWindow + rawStart = -50; + rawWindow = stimDur + 100; + else + [~, maxRespIn] = max(NeuronResp.NeuronVals(u, :, 1)); + rawStart = NeuronResp.NeuronVals(u, maxRespIn, 3) * NeuronResp.params.binRaster - 20; + rawWindow = 500; + maxRespIn_0 = maxRespIn - 1; + trialsAbsolute = maxRespIn_0*trialsPerCath + 1 : maxRespIn_0*trialsPerCath + trialsPerCath; + bestTrialIdx = trialsAbsolute(1); + end + + startTimes = directimesSorted(trialsAbsolute) + rawStart; + spikes = squeeze(BuildBurstMatrix(goodU(:,u), round(p.t), ... + round(startTimes), round(rawWindow))); + + if params.oneTrial + [~, ind] = max(sum(spikes, 2)); + else + ind = 1:size(spikes, 1); + end - xline(-start/1000,'LineWidth',1.5,Color="#77AC30") - xline((stimDur+abs(start))/1000,'LineWidth',1.5,Color="#0072BD") - xticks([0,abs(start)/1000:abs(start)/1000:obj.VST.stimDuration+abs(start*2)/1000+1]) - xticklabels([start,0:abs(start):obj.VST.stimDuration*1000+abs(start*2)]) - xlabel('Miliseconds') - yticks([]) - ylabel('uV') - title(sprintf('U.%d-Unit-phy-%d-p-%d',u,phy_IDg(u),pvals(2,ur))); - fig2.Position = [147 270 662 446]; + fig2 = figure; + fig2.Position = [147 270 662 446]; + + [fig2, ~, ~] = PlotRawDataNP(obj, fig=fig2, chan=goodU(1,u), ... + startTimes=startTimes(ind), window=rawWindow, ... + spikeTimes=spikes(ind,:), multFactor=1.5, stdMult=3); + + xline(-rawStart/1000, 'LineWidth', 1.5, 'Color', '#77AC30'); + xline((stimDur+abs(rawStart))/1000, 'LineWidth', 1.5, 'Color', '#0072BD'); + + xticks([0, abs(rawStart)/1000 : abs(rawStart)/1000 : ... + obj.VST.stimDuration + abs(rawStart*2)/1000 + 1]); + xticklabels([rawStart, 0:abs(rawStart):obj.VST.stimDuration*1000+abs(rawStart*2)]); + xlabel('Milliseconds'); + yticks([]); + ylabel('uV'); + title(sprintf('U.%d Phy-%d p=%.4f', u, phy_IDg(u), pvals(2,ur))); + + if params.PaperFig + obj.printFig(fig2, sprintf('%s-NatImg-rawData-eNeuron-%d', ... + obj.dataObj.recordingName, u), "PaperFig", true); + elseif params.overwrite + obj.printFig(fig2, sprintf('%s-NatImg-rawData-eNeuron-%d', ... + obj.dataObj.recordingName, u)); + end + end - if params.overwrite,obj.printFig(fig2,sprintf('%s-rect-GRid-rawData-raster-eNeuron-%d',obj.dataObj.recordingName,u)),close(fig2),end + % ========================================================================== + % 13. EXPORT RASTER FIGURE + % ========================================================================== + + if params.PaperFig + obj.printFig(fig, sprintf('%s-NatImg-raster-eNeuron-%d', ... + obj.dataObj.recordingName, u), "PaperFig", true); + elseif params.overwrite + obj.printFig(fig, sprintf('%s-NatImg-raster-eNeuron-%d', ... + obj.dataObj.recordingName, u)); + end - if params.overwrite,obj.printFig(fig,sprintf('%s-rect-GRid-raster-eNeuron-%d',obj.dataObj.recordingName,u)),close(fig),end - %prettify_plot - - ur = ur+1; + ur = ur + 1; -end %end eNeuron for loop +end % end neuron loop -end %end plotRaster \ No newline at end of file +end % end plotRaster \ No newline at end of file diff --git a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/CalculateReceptiveFields.m b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/CalculateReceptiveFields.m index fd97c8f..8759bb4 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/CalculateReceptiveFields.m +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/CalculateReceptiveFields.m @@ -16,6 +16,8 @@ params.nShuffle = 2 %Number of shuffles to generate shuffled receptive fields. params.testConvolution = false params.reduceFactor = 20 %reduce factor for screen resolution + params.statType string = "maxPermutationTest" + params.nGrid = 9 end if params.inputParams,disp(params),return,end @@ -37,10 +39,18 @@ end NeuronResp = obj.ResponseWindow; -Stats = obj.ShufflingAnalysis; -goodU = NeuronResp.goodU; + +% Stats struct for p-values +if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; +else + Stats = obj.StatisticsPerNeuron; +end + p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); phy_IDg = p.phy_ID(string(p.label') == 'good'); +label = string(p.label'); +goodU = p.ic(:, label == 'good'); fieldName = sprintf('Speed%d', params.speed); pvals = Stats.(fieldName).pvalsResponse; @@ -53,8 +63,11 @@ fprintf('No responsive neurons.\n') return end +else + respU = 1:size(goodU,2); end + if params.exNeurons >0 respU = params.exNeurons; end @@ -101,6 +114,7 @@ trialDivisionVid = size(C,1)/numel(unique(C(:,2)))/numel(unique(C(:,3)))/numel(unique(C(:,4)))... /numel(unique(C(:,5))); + %%%Create a matrix with trials that have unique positions ChangePosX = zeros(sizeX(1)*sizeX(2)*sizeX(3)*sizeN*trialDivisionVid,sizeX(4)); ChangePosY = zeros(sizeX(1)*sizeX(2)*sizeX(3)*sizeN*trialDivisionVid,sizeX(4)); @@ -308,7 +322,7 @@ if params.testConvolution %%%Test convolution spikeSumArt = zeros(size(spikeSum)); -spikeSumArt([1:10 91:100 181:190 271:280],1,end-20:end)=1;%Spiking at the end of first offset +spikeSumArt([1:10 91:100 161:180],1,end-20:end)=1;%Spiking at the end of first offset %spikeSumArt(nT/4+1:nT/4+15,7,end-20:end) =1;% spikeSumsDiv{1}{1} = spikeSumArt; figure;imagesc(squeeze( spikeSumsDiv{1}{1}(:,:,:)));colormap(flipud(gray(64))); @@ -358,19 +372,37 @@ % Generate video trials (this part stays the same) videoTrials = zeros(nT/trialDivisionVid,redCoorY,redCoorY,sizeX(4),'single'); h =1; +pad = ceil(max(sizesU)/(2*reduceFactor)) + 1; +[x_pad, y_pad] = meshgrid(1:redCoorY + 2*pad, 1:redCoorY + 2*pad); +x_pad = fliplr(x_pad); +cropOffsetX = (redCoorX - redCoorY)/2; + for i = 1:trialDivisionVid:nT + % for j = 1:sizeX(4) + % xyScreen = zeros(redCoorY,redCoorX,"single"); + % centerX = ChangePosX(i,j)/reduceFactor; + % centerY = ChangePosY(i,j)/reduceFactor; + % radius = sizeV(i)/2; + % distances = sqrt((x - centerX).^2 + (y - centerY).^2); + % xyScreen(distances <= radius/reduceFactor+0.5) = 1; + % videoTrials(h,:,:,j) = xyScreen(:,(redCoorX-redCoorY)/2+1:(redCoorX-redCoorY)/2+redCoorY); + % end + for j = 1:sizeX(4) - xyScreen = zeros(redCoorY,redCoorX,"single"); - centerX = ChangePosX(i,j)/reduceFactor; - centerY = ChangePosY(i,j)/reduceFactor; + xyScreen = zeros(redCoorY + 2*pad, redCoorY + 2*pad, "single"); + centerX = ChangePosX(i,j)/reduceFactor - cropOffsetX + pad; + centerY = ChangePosY(i,j)/reduceFactor + pad; radius = sizeV(i)/2; - distances = sqrt((x - centerX).^2 + (y - centerY).^2); - xyScreen(distances <= radius/reduceFactor+0.5) = 1; - videoTrials(h,:,:,j) = xyScreen(:,(redCoorX-redCoorY)/2+1:(redCoorX-redCoorY)/2+redCoorY); + distances = sqrt((x_pad - centerX).^2 + (y_pad - centerY).^2); + xyScreen(distances <= radius/reduceFactor + 0.5) = 1; + % Crop back to original square size, removing padding + videoTrials(h,:,:,j) = xyScreen(pad+1:pad+redCoorY, pad+1:pad+redCoorY); end h = h+1; end +%implay(squeeze(videoTrials(9,:,:,:))); + for t = 1:numel(IndexDiv) for q = 1:numel(IndexQ) @@ -406,7 +438,11 @@ % videoTrials(:,:,j) = xyScreen(:,(redCoorX-redCoorY)/2+1:(redCoorX-redCoorY)/2+redCoorY); % end - videoTrialsi = squeeze(videoTrials(ceil(p/2),:,:,:)); + if trialDivision*2 == trialDivisionVid %two luminosities are used, so trial division for videos are twicethe trialdivision + videoTrialsi = squeeze(videoTrials(ceil(p/2),:,:,:)); + else + videoTrialsi = squeeze(videoTrials(p,:,:,:)); + end % OPTIMIZATION 3: Vectorized spike mean calculation spikeMean = mean(spikeSum(i:i+trialDivision-1,:,:), 'omitnan'); spikeMeanShuff = mean(shuffledData(i:i+trialDivision-1,:,:,:), 'omitnan'); @@ -456,6 +492,58 @@ %implay(squeeze(RFu(:,:,:,1))); %implay(videoTrials) + %%%%%%%%%% Spike rate grid map + nGrid = params.nGrid; + cropOffsetX = (redCoorX - redCoorY)/2; + + xMin = cropOffsetX * reduceFactor; + xMax = (cropOffsetX + redCoorY) * reduceFactor; + yMin = 0; + yMax = redCoorY * reduceFactor; + + xEdges = linspace(xMin, xMax, nGrid+1); + yEdges = linspace(yMin, yMax, nGrid+1); + + gridSpikeRate = zeros(nGrid, nGrid, nNeurons, length(Usize), length(Ulum)); + gridSpikeRateShuff = zeros(nGrid, nGrid, nNeurons, nShuffle, length(Usize), length(Ulum)); + trialCount = zeros(nGrid, nGrid, length(Usize), length(Ulum)); + + for i = 1:nT + xPos = mean(ChangePosX(i,:)); + yPos = mean(ChangePosY(i,:)); + + xBin = discretize(xPos, xEdges); + yBin = discretize(yPos, yEdges); + + if isnan(xBin) || isnan(yBin) + continue + end + + sizeIdx = find(Usize == C(i,5)); + lumIdx = find(Ulum == C(i,6)); + + trialCount(yBin, xBin, sizeIdx, lumIdx) = trialCount(yBin, xBin, sizeIdx, lumIdx) + 1; + + gridSpikeRate(yBin, xBin, :, sizeIdx, lumIdx) = gridSpikeRate(yBin, xBin, :, sizeIdx, lumIdx) + ... + reshape(mean(spikeSum(i,:,:), 3), [1 1 nNeurons]); + + for s = 1:nShuffle + gridSpikeRateShuff(yBin, xBin, :, s, sizeIdx, lumIdx) = gridSpikeRateShuff(yBin, xBin, :, s, sizeIdx, lumIdx) + ... + reshape(mean(shuffledData(i,:,:,s), 3), [1 1 nNeurons]); + end + end + + % Normalize by trial count + for si = 1:length(Usize) + for li = 1:length(Ulum) + tc = max(trialCount(:,:,si,li), 1); + gridSpikeRate(:,:,:,si,li) = gridSpikeRate(:,:,:,si,li) ./ tc; + for s = 1:nShuffle + gridSpikeRateShuff(:,:,:,s,si,li) = gridSpikeRateShuff(:,:,:,s,si,li) ./ tc; + end + end + end + %%%%%%%%%% Normalization parameters L = size(spikeSum,3); time_zero_index = ceil(L / 2); @@ -501,6 +589,8 @@ names = {'X','Y'}; + %figure;imagesc(squeeze(RFuDirSizeLumFilt(1,:,:,:,:))); + if params.noEyeMoves save(sprintf('NEM-RFuSTDirSizeLumFilt-Q%d-Div-%s-%s',q,names{t},NP.recordingName),'RFuDirSizeLumFilt','-v7.3') save(sprintf('NEM-RFuSTDirSize-Q%d-Div-%s-%s',q,names{t},NP.recordingName),'RFuSTDirSize','-v7.3') @@ -528,6 +618,8 @@ S.RFuSTDirSizeLum = RFuSTDirSizeLum; S.RFuST = RFuST; S.RFuShuffST = RFuShuffST; + S.gridSpikeRate = gridSpikeRate; + S.gridSpikeRateShuff = gridSpikeRateShuff; save(sprintf('%s-Speed-%d.mat',filename,params.speed),'-struct','S'); results = S; end diff --git a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/PlotReceptiveFields.m b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/PlotReceptiveFields.m index da2169a..7a36418 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/PlotReceptiveFields.m +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/PlotReceptiveFields.m @@ -1,331 +1,427 @@ -function [colorbarLims] = PlotReceptiveFields(obj,params) - +function [colorbarLims] = PlotReceptiveFields(obj, params) +% PlotReceptiveFields Plots the spatial receptive fields of neurons recorded +% during a moving-ball stimulus, optionally filtered by +% direction, size, and luminance. +% +% OUTPUT +% colorbarLims – [cMin cMax] limits of the last colorbar drawn. + +% ── Input argument block ───────────────────────────────────────────────────── arguments (Input) - obj - params.overwrite logical = false - params.analysisTime = datetime('now') - params.inputParams = false - params.exNeurons = nan; - params.AllSomaticNeurons = false; - params.AllResponsiveNeurons = true; - params.fixedWindow = false; - params.speed = 1; %min =1, max = 2; - params.noEyeMoves = false - params.reduceFactor = 20 - params.allCombined = false - params.eye_to_monitor_distance = 21.5 % Distance from eye to monitor in cm - params.pixel_size = 33 - params.resolution = 1080 - params.meanAllNeurons = false %get mean of receptive fields - params.PaperFig logical = false - params.OneDirection string = "all" - params.OneLuminosity string = "all" - params.OneSize string = "all" - params.colorbarLims = [] - + obj % Parent analysis object + params.overwrite logical = false % Overwrite existing figure files + params.analysisTime = datetime('now') % Timestamp for provenance + params.inputParams = false % If true, print params and exit + params.exNeurons = nan; % Explicit neuron indices to plot + params.AllSomaticNeurons = false % Plot every somatic unit + params.AllResponsiveNeurons = true % Plot only statistically responsive units + params.fixedWindow = false % Use a fixed time window + params.speed = 1; % Stimulus speed index (1 = slow, 2 = fast) + params.noEyeMoves = false % Use no-eye-movement recording mode + params.reduceFactor = 20 % Spatial downsampling factor for the RF map + params.allCombined = false % Also plot the direction-summed RF + params.eye_to_monitor_distance = 21.5 % Eye-to-monitor distance in cm + params.pixel_size = 33 % Physical size of one pixel in µm + params.resolution = 1080 % Monitor vertical resolution in pixels + params.meanAllNeurons = false % Average RF across all neurons before plotting + params.PaperFig logical = false % Apply paper-quality rendering settings + params.OneDirection string = "all" % Restrict plot to one direction ("up","left","down","right","all") + params.OneLuminosity string = "all" % Restrict plot to one luminance ("black","white","all") + params.OneSize string = "all" % Restrict plot to one size ("small","middle","big","all") + params.colorbarLims = [] % Optional manual colorbar limits [cMin cMax] + params.tickNum = 3 % Number of ticks per axis end -if params.inputParams,disp(params),return,end +% ── Debug helper: print parameter struct and exit ──────────────────────────── +if params.inputParams, disp(params), return, end + +% ── Load pre-computed statistics and receptive fields ─────────────────────── +Stats = obj.ShufflingAnalysis; % Shuffling-based significance statistics +RFs = obj.CalculateReceptiveFields('speed', params.speed); % Receptive-field maps at the chosen speed -Stats = obj.ShufflingAnalysis; -RFs = obj.CalculateReceptiveFields('speed',params.speed); -%Parameters -%check receptive field neurons first +% Build the field name that indexes speed-specific substructs (e.g., "Speed1") fieldName = sprintf('Speed%d', params.speed); + +% Extract per-unit response p-values for the chosen speed pvals = Stats.(fieldName).pvalsResponse; +% Load the response window data to recover the stimulus condition table responses = obj.ResponseWindow; -uDir = unique(responses.(fieldName).C(:,2)); -uSize = unique(responses.(fieldName).C(:,4)); -uLum = unique(responses.(fieldName).C(:,6)); -%%% switch cases to plot one or all cases of one parameter +% Extract the unique values of each stimulus dimension from the condition matrix +% Columns of C are assumed to encode: [?, direction, ?, size, ?, luminance] +uDir = unique(responses.(fieldName).C(:, 2)); % Unique motion directions (radians) +uSize = unique(responses.(fieldName).C(:, 4)); % Unique stimulus sizes +uLum = unique(responses.(fieldName).C(:, 6)); % Unique luminance levels + +% ── Resolve which direction(s) to include ──────────────────────────────────── if params.OneDirection ~= "all" switch params.OneDirection case "up" - dirIDX = find(uDir==0); + dirIDX = find(round(uDir,2) == 0); % 0 rad = upward motion case "left" - dirIDX = find(uDir==1.57); + dirIDX = find(round(uDir,2) == 1.57); % π/2 rad ≈ leftward motion case "down" - dirIDX = find(uDir==3.14); + dirIDX = find(round(uDir,2) == 3.14); % π rad ≈ downward motion case "right" - dirIDX = find(uDir==1.57); + % BUG FIX: was find(uDir==1.57) which is identical to "left". + % Right corresponds to 3π/2 ≈ 4.71 rad. + dirIDX = find(round(uDir,2) == 4.71); otherwise - error("Unknown inputPa value: %s", params.OneDirection) + error("Unknown OneDirection value: %s", params.OneDirection) end - DirectionSelected = uDir(dirIDX); + DirectionSelected = uDir(dirIDX); % Scalar: the single selected direction value else - DirectionSelected = uDir; + dirIDX = 1:numel(uDir); % All direction indices + DirectionSelected = uDir; % All direction values end +% ── Resolve which luminance(s) to include ──────────────────────────────────── if params.OneLuminosity ~= "all" switch params.OneLuminosity case "black" - lumIDX = find(uLum==1); + lumIDX = find(uLum == 1); % Luminance value 1 = black stimulus case "white" - lumIDX = find(uLum==255); + lumIDX = find(uLum == 255); % Luminance value 255 = white stimulus otherwise - error("Unknown inputPa value: %s", params.OneLuminosity) + error("Unknown OneLuminosity value: %s", params.OneLuminosity) end - LuminositySelected = uLum(lumIDX); + LuminositySelected = uLum(lumIDX); % Scalar: the single selected luminance value else - LuminositySelected = uLum; + lumIDX = 1:numel(uLum); % All luminance indices + LuminositySelected = uLum; % All luminance values end +% ── Resolve which size(s) to include ───────────────────────────────────────── if params.OneSize ~= "all" switch params.OneSize case "small" - sizeIDX = 1; + sizeIDX = 1; % First (smallest) size case "middle" - sizeIDX = 2; + sizeIDX = 2; % Middle size case "big" - sizeIDX = 3; + sizeIDX = 3; % Last (largest) size otherwise - error("Unknown inputPa value: %s", params.OneLuminosity) + % BUG FIX: error message incorrectly referenced params.OneLuminosity + error("Unknown OneSize value: %s", params.OneSize) end - SizeSelected = uSize(sizeIDX); + % BUG FIX: variable was named SizeSelected (no 's') but referenced later + % as SizesSelected, causing "Undefined variable" at runtime. + SizesSelected = uSize(sizeIDX); % Scalar: the single selected size value else - SizesSelected = uSize; + sizeIDX = 1:numel(uSize); % All size indices + SizesSelected = uSize; % All size values end - +% ── Select which neurons to process ────────────────────────────────────────── if isnan(params.exNeurons) if params.AllSomaticNeurons + % Use every unit regardless of responsiveness eNeuron = 1:numel(pvals); - pvals = [eNeuron;pvals(eNeuron)]; + pvals = [eNeuron; pvals(eNeuron)]; % Row 1: indices, Row 2: p-values elseif params.AllResponsiveNeurons - eNeuron = find(pvals<0.05); - pvals = [eNeuron;pvals(eNeuron)];% Select all good neurons if not specified + % Keep only units whose response p-value is below α = 0.05 + eNeuron = find(pvals < 0.05); + pvals = [eNeuron; pvals(eNeuron)]; if isempty(eNeuron) fprintf('No responsive neurons.\n') return end end else + % Use the explicitly provided unit indices eNeuron = params.exNeurons; - pvals = [eNeuron;pvals(eNeuron)]; + pvals = [eNeuron; pvals(eNeuron)]; end - -coorRect = obj.VST.rect'; -reduceFactor = min([params.reduceFactor min(obj.VST.ballSizes)]); %has to be bigger than the smallest ball size -redCoorX = round(coorRect(3)/reduceFactor); -redCoorY = round(coorRect(4)/reduceFactor); - -pixel_size = params.pixel_size/(params.resolution/reduceFactor); % Size of one pixel in cm (e.g., 25 micrometers) -monitor_resolution = [redCoorX, redCoorY]; % Width and height in pixels -[theta_x,theta_y] = pixels2eyeDegrees(params.eye_to_monitor_distance,pixel_size,monitor_resolution); - -theta_x = theta_x(:,1+(redCoorX-redCoorY)/2:(redCoorX-redCoorY)/2+redCoorY); - -if params.noEyeMoves %%%mode quadrant - RFu = squeeze(load(sprintf('NEM-RFuST-Q1-Div-X-%s',NP.recordingName)).RFuST); +% ── Build the reduced-resolution coordinate grid ───────────────────────────── +coorRect = obj.VST.rect'; % Screen rectangle [x y w h] (pixels), transposed to column +% SUGGESTION: consider using obj.VST.rect directly with named fields to +% avoid silent breakage if the rectangle layout changes. + +% Downsampling factor must not exceed the smallest ball radius +reduceFactor = min([params.reduceFactor, min(obj.VST.ballSizes)]); + +% Reduced-resolution grid dimensions +redCoorX = round(coorRect(3) / reduceFactor); % Width in reduced pixels +redCoorY = round(coorRect(4) / reduceFactor); % Height in reduced pixels + +% Convert pixel size to cm at the reduced resolution +pixel_size_cm = params.pixel_size / (params.resolution / reduceFactor); + +% Build the visual-angle (degrees) coordinate arrays for the reduced grid +monitor_resolution = [redCoorX, redCoorY]; % [width height] in reduced pixels +[theta_x, theta_y] = pixels2eyeDegrees(params.eye_to_monitor_distance, ... + pixel_size_cm, monitor_resolution); + +% Crop theta_x horizontally so it is square (same spatial extent as theta_y) +% The crop removes the equal-width margins on left and right +theta_x = theta_x(:, 1 + (redCoorX - redCoorY)/2 : (redCoorX - redCoorY)/2 + redCoorY); + +% ── Pre-compute symmetric, degree-based tick positions (shared across tiles) ─ +% SUGGESTION: Computing ticks once here and reusing inside the loop avoids +% repeated interp1 calls and guarantees all subplots share identical ticks. + +% X-axis: find the largest absolute visual angle present in the cropped grid +maxDeg_x = max(abs(theta_x(1, :))); +% Define 5 tick positions in degrees, symmetric about 0, stopping 1° inside the edge +tickDeg_x = linspace(-(maxDeg_x - 5), maxDeg_x - 5, params.tickNum); +% Map degree values back to reduced-pixel column indices via linear interpolation +tickPix_x = interp1(theta_x(1, :), 1:size(theta_x, 2), tickDeg_x, 'linear', 'extrap'); + +% Y-axis: same procedure on the first column of theta_y +maxDeg_y = max(abs(theta_y(:, 1))); +tickDeg_y = linspace(-(maxDeg_y - 5), maxDeg_y - 5, params.tickNum); +tickPix_y = interp1(theta_y(:, 1), 1:size(theta_y, 1), tickDeg_y, 'linear', 'extrap'); + +% Pre-round degree labels so they are computed only once +tickLbl_x = round(tickDeg_x); % Rounded x-axis degree labels for display +tickLbl_y = round(tickDeg_y); % Rounded y-axis degree labels for display + +% ── Load the appropriate RF array ──────────────────────────────────────────── +if params.noEyeMoves + % Load the no-eye-movement RF (previously saved per-recording) + RFu = squeeze(load(sprintf('NEM-RFuST-Q1-Div-X-%s', NP.recordingName)).RFuST); else - RFu = RFs.RFuST; %Sum of RFUs - - RFuDirSizeLum = RFs.RFuDirSizeLumFilt; %Size and dir and lum - - + RFu = RFs.RFuST; % Direction-summed RF map [y × x × unit] + RFuDirSizeLum = RFs.RFuDirSizeLumFilt; % RF array split by [dir × size × lum × y × x × unit] end -offsetN = numel(unique(obj.VST.offsets)); -TwoDGaussian = fspecial('gaussian',floor(size(RFu,2)/(offsetN/2)),redCoorY/offsetN); %increase size of gaussian by 100%. - +% Build a 2-D Gaussian smoothing kernel scaled to the RF map size +% Kernel size is proportional to the number of spatial offsets used +offsetN = numel(unique(obj.VST.offsets)); % Number of unique stimulus offsets +TwoDGaussian = fspecial('gaussian', floor(size(RFu, 2) / (offsetN / 2)), ... % Kernel window (px) + redCoorY / offsetN); % Kernel sigma (px) +% SUGGESTION: fspecial is from the Image Processing Toolbox. +% imgaussfilt or a manually constructed kernel can be used as a fallback. + +% ═══════════════════════════════════════════════════════════════════════════════ +% Main loop: one iteration per selected neuron +% ═══════════════════════════════════════════════════════════════════════════════ for u = eNeuron - - ru = find(eNeuron == u); - + %ru = find(eNeuron == u); % Index of neuron u within the eNeuron vector + ru =u; + % ── Optional: plot the direction-summed (combined) RF ────────────────── if params.allCombined - % %%%Filter with gaussian: + figRF = figure; % Open a new figure window + % Display the Gaussian-smoothed combined RF as a colour image + imagesc(squeeze(conv2(RFu(:, :, ru), TwoDGaussian, 'same'))); - figRF=figure; - imagesc((squeeze(conv2(RFu(:,:,ru),TwoDGaussian,'same')))); + c = colorbar; % Attach a colourbar + title(c, 'spk/s') % Label colourbar units - c = colorbar; - title(c,'spk/s') + colormap('turbo') % Apply perceptually-uniform colour map + title(sprintf('u-%d', u)) % Title with unit number - colormap('turbo') - title(sprintf('u-%d',u)) + % Apply symmetric x ticks (degrees) + xticks(tickPix_x); + xticklabels(tickLbl_x); - xt = xticks; - xt = xt((1:2:numel(xt))); - xticks(xt); - xticklabels(round(theta_x(1,xt))) + % Apply symmetric y ticks (degrees) + yticks(tickPix_y); + yticklabels(tickLbl_y); - yt = yticks; - yt = yt(1:2:numel(yt)); - yticks(yt); - yticklabels(round(theta_y(yt,1))) + axis image % Equal aspect ratio, no white space - axis equal tight + figRF.Position = [680 577 156 139]; % Set figure size (pixels) - figRF.Position = [ 680 577 156 139]; - if params.noEyeMoves - %print(figRF, sprintf('%s-NEM-MovBall-ReceptiveField-eNeuron-%d.pdf',NP.recordingName,u), '-dpdf', '-r300', '-vector'); - if params.PaperFig - if params.overwrite,obj.printFig(figRF,sprintf('%s-NEM-MovBall-ReceptiveField-eNeuron-%d.pdf',obj.dataObj.recordingName,u), PaperFig = params.PaperFig),end - else - if params.overwrite,obj.printFig(figRF,sprintf('%s-NEM-MovBall-ReceptiveField-eNeuron-%d.pdf',obj.dataObj.recordingName,u)),end + % Save figure to disk if overwrite flag is set + if params.noEyeMoves + if params.overwrite + if params.PaperFig + obj.printFig(figRF, sprintf('%s-NEM-MovBall-ReceptiveField-eNeuron-%d.pdf', ... + obj.dataObj.recordingName, u), PaperFig = params.PaperFig); + else + obj.printFig(figRF, sprintf('%s-NEM-MovBall-ReceptiveField-eNeuron-%d.pdf', ... + obj.dataObj.recordingName, u)); + end end - else - if params.PaperFig - if params.overwrite,obj.printFig(figRF,sprintf('%s-MovBall-ReceptiveField-eNeuron-%d',obj.dataObj.recordingName,u), PaperFig = params.PaperFig),end - else - if params.overwrite,obj.printFig(figRF,sprintf('%s-MovBall-ReceptiveField-eNeuron-%d',obj.dataObj.recordingName,u)),end + if params.overwrite + if params.PaperFig + obj.printFig(figRF, sprintf('%s-MovBall-ReceptiveField-eNeuron-%d', ... + obj.dataObj.recordingName, u), PaperFig = params.PaperFig); + else + obj.printFig(figRF, sprintf('%s-MovBall-ReceptiveField-eNeuron-%d', ... + obj.dataObj.recordingName, u)); + end end end - - end - %%%% Plot receptive field per direction - %%%% find max and min of colorbar limits + end % allCombined + % ── Extract this neuron's RF slice and apply any dimension filters ────── if params.meanAllNeurons - RFuRed =reshape(mean(RFuDirSizeLum,6),[size(RFuDirSizeLum,1),size(RFuDirSizeLum,2),... - size(RFuDirSizeLum,3),size(RFuDirSizeLum,4)... - ,size(RFuDirSizeLum,5)]); %%Takes mean across all neurons - - for i = 1:numel(hasNotString) %Take mean of elements that are not going to be compared (like luminosities, or directions, etc) - RFuRed = mean(RFuRed,hasNotString(i)); - size(RFuRed) + % Average the RF across all neurons (dimension 6) and collapse it + RFuRed = reshape(mean(RFuDirSizeLum, 6), ... + [size(RFuDirSizeLum, 1), size(RFuDirSizeLum, 2), ... + size(RFuDirSizeLum, 3), size(RFuDirSizeLum, 4), ... + size(RFuDirSizeLum, 5)]); + % BUG: hasNotString is never defined; the intent seems to be to + % additionally average over the non-compared dimensions (e.g., if + % only direction is compared, average over size and luminance). + % Define hasNotString before this block, e.g.: + % hasNotString = []; + % if params.OneSize ~= "all", hasNotString(end+1) = 2; end + % if params.OneLuminosity ~= "all", hasNotString(end+1) = 3; end + % if params.OneDirection ~= "all", hasNotString(end+1) = 1; end + % Then the loop below is correct: + for i = 1:numel(hasNotString) % Average over dimensions not being compared + RFuRed = mean(RFuRed, hasNotString(i)); end else - RFuRed =reshape(RFuDirSizeLum(:,:,:,:,:,ru),[size(RFuDirSizeLum,1),size(RFuDirSizeLum,2),size(RFuDirSizeLum,3),size(RFuDirSizeLum,4)... - ,size(RFuDirSizeLum,5)]); + % Extract the RF for neuron ru; keep all 5 condition dimensions explicit + RFuRed = reshape(RFuDirSizeLum(:, :, :, :, :, ru), ... + [size(RFuDirSizeLum, 1), size(RFuDirSizeLum, 2), ... + size(RFuDirSizeLum, 3), size(RFuDirSizeLum, 4), ... + size(RFuDirSizeLum, 5)]); % [dir × size × lum × y × x] + % Apply size filter if requested (select single size slice) if params.OneSize ~= "all" - RFuRed = RFuRed(:,sizeIDX,:,:,:); + RFuRed = RFuRed(:, sizeIDX, :, :, :); end - if params.OneLuminosity ~= "all" - RFuRed = RFuRed(:,:,lumIDX,:,:); + % Apply luminance filter if requested + if params.OneLuminosity ~= "all" + RFuRed = RFuRed(:, :, lumIDX, :, :); end - - if params.OneDirection ~= "all" - RFuRed = RFuRed(dirIDX,:,:,:,:); + + % Apply direction filter if requested + if params.OneDirection ~= "all" + RFuRed = RFuRed(dirIDX, :, :, :, :); end end - - cMax = max(RFuRed,[],'all'); - cMin = min(RFuRed,[],'all'); - tilesSize = prod(size(RFuRed,[1 2 3])); + % ── Determine colour-axis limits from this neuron's data ──────────────── + cMax = max(RFuRed, [], 'all'); % Global maximum firing rate across all conditions + cMin = min(RFuRed, [], 'all'); % Global minimum firing rate across all conditions - if numel(tilesSize) ==1 %%Create tile grid for RF ploting - if tilesSize<4 - tilesSize = [1 tilesSize]; - else - tilesSize = [floor(tilesSize/2) ceil(tilesSize/2)]; - end - end + % ── Compute tile-grid layout for the tiled figure ─────────────────────── + % BUG FIX: was prod(size(RFuRed,[1 2 3])) which always produces a scalar, + % making the numel==3 branch unreachable. Corrected to size(). + tilesSize = size(RFuRed, [1 2 3]); % [nDir, nSize, nLum] – counts of condition tiles - if numel(tilesSize) ==3 %%Create tile grid for RF ploting - tilesSize = [tilesSize(1) tilesSize(2)*tilesSize(3)]; - end + % Flatten to a [rows × cols] layout: rows = directions, cols = size × lum + tilesSize = [tilesSize(1), tilesSize(2) * tilesSize(3)]; + % ── Create the tiled figure ────────────────────────────────────────────── + figRF = figure('Units', 'normalized', 'OuterPosition', [0 0 1 1]); % Full-screen window + NeuronLayout = tiledlayout(tilesSize(1), tilesSize(2), ... + "TileSpacing", "tight", "Padding", "compact"); - %%%%%%%%%%%%%%% Create tiled plot showcasing different luminosities and - %%%%%%%%%%%%%%% directions - figRF = figure('Units', 'normalized', 'OuterPosition', [0 0 1 1]); % Full screen figure; - NeuronLayout = tiledlayout(tilesSize(1),tilesSize(2),"TileSpacing","tight","Padding","compact"); + j = 0; % Running tile counter (used to attach colorbar to the last tile) - j=0; + % ── Inner loops: one tile per (direction × size × luminance) combination ─ + for d = 1:size(RFuRed, 1) % Iterate over direction slices + for s = 1:size(RFuRed, 2) % Iterate over size slices + for l = 1:size(RFuRed, 3) % Iterate over luminance slices - for d = 1:size(RFuRed,1) - for s = 1:size(RFuRed,2) - for l = 1:size(RFuRed,3) + ax = nexttile; % Advance to the next tile in the layout - ax = nexttile; - imagesc((squeeze(RFuRed(d,s,l,:,:)))); + % Display the RF heat map for condition (d, s, l) + imagesc(squeeze(RFuRed(d, s, l, :, :))); - caxis([cMin cMax]); + % Lock colour axis to the per-neuron global range for comparability + % SUGGESTION: clim() is the modern replacement for the deprecated caxis() + clim([cMin, cMax]); + % Style y-axis tick labels axi = gca; axi.YAxis.FontSize = 8; axi.YAxis.FontName = 'helvetica'; - xlabel('Degrees','FontSize',10,'FontName','helvetica') - ylabel('Degrees','FontSize',10,'FontName','helvetica') + % Axis labels (degrees of visual angle) + xlabel('Degrees', 'FontSize', 10, 'FontName', 'helvetica') + ylabel('Degrees', 'FontSize', 10, 'FontName', 'helvetica') - axi = gca; + % Style x-axis tick labels axi.XAxis.FontSize = 8; axi.XAxis.FontName = 'helvetica'; - colormap('turbo') - title(sprintf('Dir-%s-Size-%s-Lum-%s',string(uDir(d)),string(uSize(s)),string(uLum(l))),'FontSize',4) + colormap('turbo') % Perceptually-uniform colour map + + % BUG FIX: was uDir(d), uSize(s), uLum(l) – these index the + % FULL unfiltered arrays, so the label is wrong whenever a + % filter is active. Use the Selected vectors instead. + title(sprintf('Dir-%.2f-Size-%.0f-Lum-%.0f', ... + DirectionSelected(d), SizesSelected(s), LuminositySelected(l)), ... + 'FontSize', 4) - %xlim([(redCoorX-redCoorY)/2 (redCoorX-redCoorY)/2+redCoorY]) - xt = xticks;%(linspace((redCoorX-redCoorY)/2,(redCoorX-redCoorY)/2+redCoorY,offsetN*2)); - xt = xt((1:2:numel(xt))); - xticks(xt); - xticklabels(round(theta_x(1,xt))) + % ── Apply symmetric degree-based ticks ────────────────────── + xticks(tickPix_x); % Set x tick positions (reduced-pixel units) + xticklabels(tickLbl_x); % Label with rounded degree values + yticks(tickPix_y); % Set y tick positions (reduced-pixel units) + yticklabels(tickLbl_y); % Label with rounded degree values - yt = yticks; - yt = yt(1:2:numel(yt)); - yticks(yt); - yticklabels(round(theta_y(yt,1))) + j = j + 1; % Increment tile counter - j = j+1; - - if j ==size(RFuRed,1)*size(RFuRed,2)*size(RFuRed,3) + % Attach colourbar only to the final tile so it doesn't clutter the layout + if j == size(RFuRed, 1) * size(RFuRed, 2) * size(RFuRed, 3) c = colorbar; - title(c,'spk/s','FontSize',8,'FontName','helvetica') + title(c, 'spk/s', 'FontSize', 8, 'FontName', 'helvetica') + % Override colour limits if the caller supplied explicit bounds if ~isempty(params.colorbarLims) - clim([params.colorbarLims]); + clim(params.colorbarLims); end - [colorbarLims] = c.Limits; + + colorbarLims = c.Limits; % Return the final colourbar limits end - - axis(ax, 'equal'); - pbaspect(ax, [1 1 1]); - end - end - end + axis(ax, 'image'); % Equal aspect ratio so the RF map is not distorted + % SUGGESTION: axis equal already enforces equal scaling; + % pbaspect([1 1 1]) is therefore redundant here and can be removed. - - %title(NeuronLayout, sprintf('Unit-%d',u),'FontSize',4); - %figRF.Position = [ 0.2328125 0.315 0.23515625 0.38125]; + end % luminance + end % size + end % direction - Sdir= strjoin(string(DirectionSelected),"-"); - Ssize= strjoin(string(SizesSelected),"-"); - Slum= strjoin(string(LuminositySelected),"-"); - + % ── Build filename strings from the selected condition labels ──────────── + Sdir = strjoin(string(DirectionSelected), "-"); % e.g. "0-1.57-3.14-4.71" + Ssize = strjoin(string(SizesSelected), "-"); % e.g. "5-10-20" + Slum = strjoin(string(LuminositySelected), "-"); % e.g. "1-255" + + % ── Handle the mean-all-neurons special case ───────────────────────────── if params.meanAllNeurons - title(NeuronLayout,'MeanAllUnits'); - if params.overwrite,obj.printFig(figRF,sprintf('%s-%s-MovBall-RF-sep-%s-Mean',... - obj.dataObj.recordingName,fieldName, sprintf('Dir-%s-Size-%s-Lum-%s',Sdir,Ssize,Slum))),end - return + title(NeuronLayout, 'MeanAllUnits'); % Label the whole layout + if params.overwrite + obj.printFig(figRF, sprintf('%s-%s-MovBall-RF-sep-%s-Mean', ... + obj.dataObj.recordingName, fieldName, ... + sprintf('Dir-%s-Size-%s-Lum-%s', Sdir, Ssize, Slum))); + end + return % Only one figure is produced; no per-neuron loop needed end + % ── Resize figure for single-row layouts ──────────────────────────────── if tilesSize(1) == 1 set(figRF, 'Units', 'centimeters'); - set(figRF, 'Position', [2 2 4 4]); + set(figRF, 'Position', [2 2 4 4]); % Compact format for one-row grids end - - if params.noEyeMoves - - else - if params.PaperFig - if params.overwrite,obj.printFig(figRF,sprintf('%s-%s-MovBall-RF-sep-%s-eNeuron-%d',... - obj.dataObj.recordingName,fieldName, sprintf('Dir-%s-Size-%s-Lum-%s',Sdir,Ssize,Slum),u),PaperFig = params.PaperFig),end - else - if params.overwrite,obj.printFig(figRF,sprintf('%s-%s-MovBall-RF-sep-%s-eNeuron-%d',... - obj.dataObj.recordingName,fieldName, sprintf('Dir-%s-Size-%s-Lum-%s',Sdir,Ssize,Slum),u)),end + % ── Save figure ────────────────────────────────────────────────────────── + if ~params.noEyeMoves % Standard (eye-movement) recording mode + if params.overwrite + if params.PaperFig + obj.printFig(figRF, sprintf('%s-%s-MovBall-RF-sep-%s-eNeuron-%d', ... + obj.dataObj.recordingName, fieldName, ... + sprintf('Dir-%s-Size-%s-Lum-%s', Sdir, Ssize, Slum), u), ... + PaperFig = params.PaperFig); + else + obj.printFig(figRF, sprintf('%s-%s-MovBall-RF-sep-%s-eNeuron-%d', ... + obj.dataObj.recordingName, fieldName, ... + sprintf('Dir-%s-Size-%s-Lum-%s', Sdir, Ssize, Slum), u)); + end end end + % Close the figure unless this is the last neuron (keep last open for inspection) if u ~= eNeuron(end) close end +end % neuron loop -end %%%End onDir - -end +end % function \ No newline at end of file diff --git a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/linearlyMovingBallAnalysis.m b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/linearlyMovingBallAnalysis.m index f083f52..63200ae 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/linearlyMovingBallAnalysis.m +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/linearlyMovingBallAnalysis.m @@ -13,14 +13,50 @@ function [obj] = linearlyMovingBallAnalysis(dataObj,params) arguments (Input) %ResponseWindow.mat dataObj - params.Session = 1; + params.Session double = 1 + params.MultipleOffsets logical = false + params.Multiplesizes logical = false end if nargin==0 dataObj=[]; end + % Call superclass constructor obj@VStimAnalysis(dataObj,'Session',params.Session); obj.Session = params.Session; + + if ~isempty(obj.VST) + + if length(unique(obj.VST.offsets)) < 2 && params.MultipleOffsets + originalSession = params.Session; + params.Session = 1 + floor(1/params.Session); %converts 1 into 2 and 2 into 1. Only a maximum of two sessions per insertion. + + warning('linearlyMovingBallAnalysis:insufficientOffsets', ... + 'Session %d has fewer than 2 unique offsets. Switching to session %d.', ... + originalSession, params.Session); + + % Reconstruct the object with the fallback session - overwrites the first construction + obj = linearlyMovingBallAnalysis(dataObj, 'Session', params.Session, 'MultipleOffsets', false); + obj.Session = params.Session; + end + + + if length(unique(obj.VST.ballSizes)) < 2 && params.Multiplesizes + originalSession = params.Session; + params.Session = 1 + floor(1/params.Session); %converts 1 into 2 and 2 into 1. Only a maximum of two sessions per insertion. + + warning('linearlyMovingBallAnalysis:insufficientSizes', ... + 'Session %d has fewer than 2 unique sizes. Switching to session %d.', ... + originalSession, params.Session); + + % Reconstruct the object with the fallback session - overwrites the first construction + obj = linearlyMovingBallAnalysis(dataObj, 'Session', params.Session, 'Multiplesizes', false); + obj.Session = params.Session; + end + + end + + end end @@ -56,6 +92,7 @@ catch obj.getSessionTime("overwrite",true); obj.getDiodeTriggers("extractionMethod",'digitalTriggerDiode','overwrite',true); + obj.getSyncedDiodeTriggers('overwrite',true); DiodeCrossings = obj.getSyncedDiodeTriggers; end diff --git a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m index 67ea352..1cc9929 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m @@ -6,8 +6,9 @@ function plotRaster(obj,params) params.analysisTime = datetime('now') params.inputParams = false params.preBase = 200 - params.bin = 15 + params.bin = 30 params.exNeurons = 1 + params.exNeuronsPhyID double = [] % alternative to exNeurons: specify neurons by phy cluster ID params.AllSomaticNeurons = false params.AllResponsiveNeurons = false params.SelectedWindow = true @@ -15,16 +16,27 @@ function plotRaster(obj,params) params.MergeNtrials =1 params.oneTrial = false params.GaussianLength = 10 + params.Gaussian logical = false params.MaxVal_1 =true params.useNormTrialWindow = false params.OneDirection string = "all" params.OneLuminosity string = "all" params.PaperFig logical = false + params.statType string = "maxPermuteTest" + params.sortingOrder string = ["direction","luminosity","offset","size","speed"] + end NeuronResp = obj.ResponseWindow; -Stats = obj.ShufflingAnalysis; + +if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; +else + Stats = obj.StatisticsPerNeuron; +end + + if params.speed ~= "max" fieldName = sprintf('Speed%d', str2double(params.speed)); @@ -46,12 +58,26 @@ function plotRaster(obj,params) end -goodU = NeuronResp.goodU; p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); phy_IDg = p.phy_ID(string(p.label') == 'good'); pvals = Stats.(fieldName).pvalsResponse; stimDur = NeuronResp.(fieldName).stimDur; stimInter = NeuronResp.stimInter; +label = string(p.label'); +goodU = p.ic(:,label == 'good'); %somatic neurons + +% Convert phy IDs to unit indices if exNeuronsPhyID is provided. +% This overrides exNeurons if both are set — phy ID is more explicit. +if ~isempty(params.exNeuronsPhyID) + [found, neuronIdx] = ismember(params.exNeuronsPhyID, phy_IDg); + if any(~found) + warning('The following phy IDs were not found in good units and will be skipped: %s', ... + num2str(params.exNeuronsPhyID(~found))); + end + params.exNeurons = neuronIdx(found); % convert to regular indices + fprintf(' Converted phy IDs [%s] -> unit indices [%s]\n', ... + num2str(params.exNeuronsPhyID(found)), num2str(params.exNeurons)); +end C = NeuronResp.(fieldName).C; @@ -73,16 +99,30 @@ function plotRaster(obj,params) if params.OneLuminosity ~= "all" switch params.OneLuminosity case "black" - C = NeuronResp.(fieldName).C(round(C(:,6), 2)==1,:); + C = C(round(C(:,6), 2)==1,:); case "white" - C = NeuronResp.(fieldName).C(round(C(:,6), 2)==255,:); + C = C(round(C(:,6), 2)==255,:); otherwise error("Unknown inputPa value: %s", params.OneLuminosity) end end -[C indexS] = sortrows(C,[2 6 3 4 5]); +sortMap = struct( ... + 'direction', 2, ... + 'offset', 3, ... + 'size', 4,... + 'speed', 5, ... + 'luminosity',6); + +sortCols = zeros(1,numel(params.sortingOrder)); + +for k = 1:numel(params.sortingOrder) + sortCols(k) = sortMap.(params.sortingOrder(k)); +end + +[C, sortIdx] = sortrows(C, sortCols); + directimesSorted = C(:,1)'; %Unique parmeters of the different categories @@ -92,7 +132,6 @@ function plotRaster(obj,params) uSpeed = unique(C(:,5)); uLums= unique(C(:,6)); - %Number of unique parameters per category offsetN = length(uOffset); direcN = length(uDir); @@ -104,6 +143,132 @@ function plotRaster(obj,params) preBase = round(stimInter-stimInter/4); +%Calculate size of ball in degrees: +%Standard measurements for last set of experiments: +eye_to_monitor_distance = 21.5000; +pixel_size = 33; +resolution = 1080; +pixel_size = pixel_size/resolution; +monitor_resolution = [1920 1080]; +[theta_x,theta_y] = pixels2eyeDegrees(eye_to_monitor_distance,pixel_size,monitor_resolution); + +for i = 1:sizeN + sizeBall(i) = round(abs(abs(theta_x(1,uSize(i)))-abs(theta_x(1,1))),2); +end + +sizesString = strjoin(string(sizeBall), "_"); + +% ========================================================== +% OUTER GROUP INFO +% ========================================================== + +outerGroup = params.sortingOrder(1); +outerCol = sortMap.(outerGroup); + +outerVals = C(:,outerCol); + +uOuter = unique(outerVals,'stable'); + +groupStarts = zeros(numel(uOuter),1); +groupEnds = zeros(numel(uOuter),1); + +for i = 1:numel(uOuter) + + idx = find(outerVals == uOuter(i)); + + groupStarts(i) = idx(1); + groupEnds(i) = idx(end); + +end + +groupCenters = (groupStarts + groupEnds)/2; + +% ========================================================== +% BUILD GROUP LABELS +% ========================================================== + +groupLabels = strings(size(uOuter)); + +switch outerGroup + + % ====================================================== + case "direction" + + for i = 1:numel(uOuter) + + val = round(uOuter(i),2); + + switch val + + case 0 + groupLabels(i) = "U"; + + case 1.57 + groupLabels(i) = "L"; + + case 3.14 + groupLabels(i) = "D"; + + case 4.71 + groupLabels(i) = "R"; + + otherwise + groupLabels(i) = string(val); + + end + end + + % ====================================================== + case "size" + + groupLabels = strings(size(uOuter)); + + for i = 1:numel(uOuter) + + idxSize = find(uSize == uOuter(i)); + + if ~isempty(idxSize) + groupLabels(i) = sprintf('%.1f°', sizeBall(idxSize)); + else + groupLabels(i) = string(uOuter(i)); + end + + end + + % ====================================================== + case "luminosity" + + for i = 1:numel(uOuter) + + if uOuter(i) == 1 + groupLabels(i) = "B"; + elseif uOuter(i) == 255 + groupLabels(i) = "W"; + else + groupLabels(i) = string(uOuter(i)); + end + + end + + % ====================================================== + case "offset" + + for i = 1:numel(uOuter) + groupLabels(i) = sprintf('O%d',uOuter(i)); + end + + % ====================================================== + case "speed" + + for i = 1:numel(uOuter) + groupLabels(i) = sprintf('S%d',uOuter(i)); + end + +end + + + + if params.AllSomaticNeurons eNeuron = 1:size(goodU,2); pvals = [eNeuron;pvals(eNeuron)]; @@ -119,9 +284,12 @@ function plotRaster(obj,params) pvals = [eNeuron;pvals(eNeuron)]; end + [Mr] = BuildBurstMatrix(goodU(:,eNeuron),round(p.t/params.bin),round((directimesSorted-preBase)/params.bin),round((stimDur+preBase*2)/params.bin)); -[Mr]=ConvBurstMatrix(Mr,fspecial('gaussian',[1 params.GaussianLength],3),'same'); +if params.Gaussian + [Mr]=ConvBurstMatrix(Mr,fspecial('gaussian',[1 params.GaussianLength],3),'same'); +end channels = goodU(1,eNeuron); @@ -131,20 +299,6 @@ function plotRaster(obj,params) ur = 1; -%Calculate size of ball in degrees: -%Standard measurements for last set of experiments: -eye_to_monitor_distance = 21.5000; -pixel_size = 33; -resolution = 1080; -pixel_size = pixel_size/resolution; -monitor_resolution = [1920 1080]; -[theta_x,theta_y] = pixels2eyeDegrees(eye_to_monitor_distance,pixel_size,monitor_resolution); - -for i = 1:sizeN - sizeBall(i) = round(abs(abs(theta_x(1,uSize(i)))-abs(theta_x(1,1))),2); -end - -sizesString = strjoin(string(sizeBall), "_"); for u = eNeuron @@ -187,6 +341,7 @@ function plotRaster(obj,params) subplot(18,1,[6 16]); imagesc(squeeze(Mr2).*(1000/params.bin));colormap(flipud(gray(64))); + set(gca,'Clipping','off') %Plot stim start: xline(preBase/params.bin,'k', LineWidth=1.5) %Plot stim end: @@ -196,30 +351,75 @@ function plotRaster(obj,params) if params.MaxVal_1 caxis([0 1]) end - dirStart = C(1,2); - offStart = C(1,3); - lumStart = C(1,6); - sizeStart = C(1,4); - for t = 1:nT - if dirStart ~= C(t,2) - yline(t-0.5,'k',LineWidth=2); - dirStart = C(t,2); - end - if offStart ~= C(t,3) - yline(t-0.5,'k',LineWidth=0.5); - offStart = C(t,3); - end - if lumStart ~= C(t,6) - yline(t-0.5,'--b',LineWidth=1); - lumStart = C(t,6); - end - if sizeStart ~= C(t,4) - yline(t-0.5,'--r',LineWidth=0.05); - sizeStart = C(t,4); - end + % ========================================================== + % DYNAMIC GROUP LINES + % ========================================================== + + hold on + + % --- outer group (thick lines) --- + for i = 2:numel(groupStarts) + + yline(groupStarts(i)-0.5, ... + 'k', ... + 'LineWidth',2); end + % ========================================================== + % INNER GROUPS + % ========================================================== + + innerOrders = params.sortingOrder(2:end); + + lineStyles = {'-','--',':'}; + lineWidths = [0.8 0.8 0.8]; + + for s = 1:numel(innerOrders) + + thisGroup = innerOrders(s); + + thisCol = sortMap.(thisGroup); + + vals = C(:,thisCol); + + prevVal = vals(1); + + for t = 2:nT + + if vals(t) ~= prevVal + + yline(t-0.5, ... + 'Color',[0.4 0.4 0.4], ... + 'LineStyle',lineStyles{min(s,numel(lineStyles))}, ... + 'LineWidth',lineWidths(min(s,numel(lineWidths)))); + + prevVal = vals(t); + + end + + end + end + + % ========================================================== + % GROUP LABELS + % ========================================================== + + xText = -8; + + for i = 1:numel(groupCenters) + + text(xText, ... + groupCenters(i), ... + groupLabels(i), ... + 'HorizontalAlignment','right', ... + 'VerticalAlignment','middle', ... + 'FontWeight','bold', ... + 'FontSize',8, ... + 'FontName','helvetica', ... + 'Clipping','off'); + + end hold on xticklabels([]) @@ -257,15 +457,50 @@ function plotRaster(obj,params) maxRespIn = maxRespIn-1; X = squeeze(Mr2(maxRespIn*trialDivision+1:maxRespIn*trialDivision + trialDivision,:,:)); window = 500; %in ms - % Moving mean across 2nd dimension - mm = movmean(X, round(window/params.bin), 2, 'Endpoints', 'discard'); - % Average across rows to get kernel score - score = mean(mm, 1); - % Find max kernel location - [maxVal, idx] = max(score); + + + % % Moving mean across 2nd dimension + % mm = movmean(X, round(window/params.bin), 2, 'Endpoints', 'discard'); + % % Average across rows to get kernel score + % score = mean(mm, 1); + % % Find max kernel location + % [maxVal, idx] = max(score); + + X(X>1) = 1; + [n_rows, n_cols] = size(X); + n_windows = n_cols - round(window/params.bin) + 1; + + % Compute mean for every sliding window in every row + % Result: 20 x n_windows matrix + window_means = zeros(n_rows, n_windows); + for col = 1:n_windows + window_means(:, col) = mean(X(:, col:col+round(window/params.bin)-1), 2); + end + + % Find the overall maximum mean across all rows and windows + [~, linear_idx] = max(window_means(:)); + + % Convert linear index to (row, col) — col = start of window + [best_row, best_col] = ind2sub(size(window_means), linear_idx); % Kernel column range - start = idx; + start = best_col*params.bin; + + + % % --- Plot --- + % figure; + % imagesc(X); + % colorbar; + % axis tight; + % hold on; + % + % % Highlight the full best row (horizontal span) + % rectangle('Position', [0.5, best_row - 0.5, n_cols, 1], ... + % 'EdgeColor', 'r', 'LineWidth', 1.5, 'LineStyle', '--'); + % + % % Highlight the selected window (column span within best row) + % rectangle('Position', [best_col - 0.5, best_row - 0.5, round(window/params.bin), 1], ... + % 'EdgeColor', 'y', 'LineWidth', 2.5); else if params.useNormTrialWindow @@ -292,18 +527,23 @@ function plotRaster(obj,params) 'k','FaceAlpha',0.1,'EdgeColor','none') - TrialM = squeeze(Mr2(trials,:,round((preBase+start)/params.bin):round((preBase+start+window)/params.bin)))'; + % TrialM = squeeze(Mr2(trials,round((preBase+start)/params.bin):round((preBase+start+window)/params.bin)))'; + % + % [mxTrial TrialNumber] = max(sum(TrialM)); - [mxTrial TrialNumber] = max(sum(TrialM)); + RasterTrials = trials(best_row); - RasterTrials = trials(TrialNumber); + % patch([(preBase+start)/params.bin (preBase+start+window)/params.bin (preBase+start+window)/params.bin (preBase+start)/params.bin],... + % [RasterTrials-0.5 RasterTrials-0.5 RasterTrials+0.5 RasterTrials+0.5],... + % 'r','FaceAlpha',0.3,'EdgeColor','none') - patch([(preBase+start)/params.bin (preBase+start+window)/params.bin (preBase+start+window)/params.bin (preBase+start)/params.bin],... + patch([(start)/params.bin (start+window)/params.bin (start+window)/params.bin (start)/params.bin],... [RasterTrials-0.5 RasterTrials-0.5 RasterTrials+0.5 RasterTrials+0.5],... 'r','FaceAlpha',0.3,'EdgeColor','none') + %%%%%% Plot PSTH %%%%%% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% @@ -362,7 +602,7 @@ function plotRaster(obj,params) xlabel('Time [s]','FontSize',10,'FontName','helvetica'); ylims = ylim; - yticks([round(ylims(2)/10)*5 round(ylims(2)/10)*10]) + yticks([round(ylims(2)/10)*5 ceil(ylims(2)/10)*10]) %%%%PLot raw data several trials one @@ -370,19 +610,21 @@ function plotRaster(obj,params) %Mark selected trial - bin3 = 2; + bin3 = 1; trialM = BuildBurstMatrix(goodU(:,u),round(p.t/bin3),round((directimesSorted+start)/bin3),round((window)/bin3)); TrialM = squeeze(trialM(trials,:,:))'; - [mxTrial TrialNumber] = max(sum(TrialM)); + [mxTrial TrialNumber] = max(mean(TrialM)); + + %RasterTrials = trials(TrialNumber); - RasterTrials = trials(TrialNumber); + RasterTrials = trials(best_row); chan = goodU(1,u); subplot(18,1,[1 3]) - startTimes = directimesSorted(RasterTrials)+start; + startTimes = directimesSorted(RasterTrials)+start-preBase; freq = "AP"; %or "LFP" @@ -411,7 +653,7 @@ function plotRaster(obj,params) ax.XRuler.TickDirection = 'out'; % ticks only outward (bottom) ax.XAxisLocation = 'bottom'; ylabel('[\muV]','FontSize',10,'FontName','helvetica') - title({sprintf('U.%d-Chan-%d-U.phy-%d-p-%d',u,channels(ur),phy_IDg(u),pvals(2,ur)),... + title({sprintf('U.%d-Chan-%d-U.phy-%d-p=%.4f',u,channels(ur),phy_IDg(u),pvals(2,ur)),... sprintf('Ball-sizes-deg-%s',sizesString)}); %%%%%%%%%%% Plot raster of selected trials diff --git a/visualStimulationAnalysis/@linearlyMovingBarAnalysis/linearlyMovingBarAnalysis.m b/visualStimulationAnalysis/@linearlyMovingBarAnalysis/linearlyMovingBarAnalysis.m index 2812a89..fd29c85 100644 --- a/visualStimulationAnalysis/@linearlyMovingBarAnalysis/linearlyMovingBarAnalysis.m +++ b/visualStimulationAnalysis/@linearlyMovingBarAnalysis/linearlyMovingBarAnalysis.m @@ -229,10 +229,10 @@ %4% real spike rate of response posTr*trialDivision-trialDivision+1:posTr*trialDivision - NeuronRespProfile(k,4) = mean(Mr(max_position_Trial(k,1)*trialDivision-trialDivision+1:max_position_Trial(k,1)*trialDivision-1,u,... + NeuronRespProfile(k,4) = mean(Mr(max_position_Trial(k,1)*trialDivision-trialDivision+1:max_position_Trial(k,1)*trialDivision,u,... max_position_Trial(k,2):max_position_Trial(k,2)+window_size(2)-1),'all');%max_position_Trial(k,2); - BaseResp = mean(Mbd(max_position_TrialB(k,1)*trialDivision-trialDivision+1:max_position_TrialB(k,1)*trialDivision-1,u,... + BaseResp = mean(Mbd(max_position_TrialB(k,1)*trialDivision-trialDivision+1:max_position_TrialB(k,1)*trialDivision,u,... max_position_TrialB(k,2):max_position_TrialB(k,2)+window_size(2)-1),'all'); %4%. Resp - Baseline diff --git a/visualStimulationAnalysis/@movieAnalysis/movieAnalysis.m b/visualStimulationAnalysis/@movieAnalysis/movieAnalysis.m index 826069d..4f16f1e 100644 --- a/visualStimulationAnalysis/@movieAnalysis/movieAnalysis.m +++ b/visualStimulationAnalysis/@movieAnalysis/movieAnalysis.m @@ -187,10 +187,10 @@ %4% real spike rate of response posTr*trialDivision-trialDivision+1:posTr*trialDivision - NeuronRespProfile(k,4) = mean(Mr(max_position_Trial(k,1)*trialDivision-trialDivision+1:max_position_Trial(k,1)*trialDivision-1,u,... + NeuronRespProfile(k,4) = mean(Mr(max_position_Trial(k,1)*trialDivision-trialDivision+1:max_position_Trial(k,1)*trialDivision,u,... max_position_Trial(k,2):max_position_Trial(k,2)+window_size(2)-1),'all');%max_position_Trial(k,2); - BaseResp = mean(Mbd(max_position_TrialB(k,1)*trialDivision-trialDivision+1:max_position_TrialB(k,1)*trialDivision-1,u,... + BaseResp = mean(Mbd(max_position_TrialB(k,1)*trialDivision-trialDivision+1:max_position_TrialB(k,1)*trialDivision,u,... max_position_TrialB(k,2):max_position_TrialB(k,2)+window_size(2)-1),'all'); %4%. Resp - Baseline @@ -207,7 +207,7 @@ NeuronVals(u,:,:) = NeuronRespProfile; end - colNames = {'Resp','MaxWinTrial','MaxWinBin','RespSubsBaseline','Position','Size','Luminosities'}; + colNames = {''}; S.params = params; S.colNames = {colNames}; diff --git a/visualStimulationAnalysis/@movieAnalysis/plotRaster.m b/visualStimulationAnalysis/@movieAnalysis/plotRaster.m index 619cd3e..b574848 100644 --- a/visualStimulationAnalysis/@movieAnalysis/plotRaster.m +++ b/visualStimulationAnalysis/@movieAnalysis/plotRaster.m @@ -5,8 +5,8 @@ function plotRaster(obj,params) params.overwrite logical = false params.analysisTime = datetime('now') params.inputParams = false - params.preBase = 500 - params.bin = 30 + params.preBase = 750 + params.bin = 60 params.exNeurons = 1 params.AllSomaticNeurons = false params.AllResponsiveNeurons = false @@ -20,13 +20,14 @@ function plotRaster(obj,params) NeuronResp = obj.ResponseWindow; -Stats = obj.ShufflingAnalysis; +Stats = obj.StatisticsPerNeuron; directimesSorted = NeuronResp.C(:,1)'; -goodU = NeuronResp.goodU; p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); phy_IDg = p.phy_ID(string(p.label') == 'good'); pvals = Stats.pvalsResponse; +label = string(p.label'); +goodU = p.ic(:, label == 'good'); stimDur = NeuronResp.stimDur; stimInter = NeuronResp.stimInter; diff --git a/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m b/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m index 9aae3eb..a624b0f 100644 --- a/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m +++ b/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m @@ -12,295 +12,386 @@ params.fixedWindow = false params.noEyeMoves = false params.delay = 250 - params.nShuffle = 20 %Number of shuffles to generate shuffled receptive fields. + params.nShuffle = 20 % Number of shuffles to generate shuffled receptive fields params.testConvolution = false params.reduceFactor = 20; - params.duration = 300; %response window - params.durationOff = 3000; - params.offsetR = 50; %Response after onset of stim - params.TakeAllStimDur = true %calculate the receptive fields taking into account the whole window + params.duration = 300; % Response window (ms) + params.durationOff = 3000; % Off-response window (ms) + params.offsetR = 50; % Response after onset of stim (ms) + params.TakeAllStimDur = true % Use whole stim window for RF calculation + params.statType string = "maxPermutationTest" + params.nGrid = 9 end - -if params.inputParams,disp(params),return,end +if params.inputParams, disp(params), return, end filename = obj.getAnalysisFileName; - +% ------------------------------------------------------------------------- +% Load from file if it exists and overwrite is false +% ------------------------------------------------------------------------- if isfile(obj.getAnalysisFileName) && ~params.overwrite - if nargout==1 + if nargout == 1 fprintf('Loading saved results from file.\n'); - results=load(filename); + results = load(filename); else fprintf('Analysis already exists (use overwrite option to recalculate).\n'); end - return end NeuronResp = obj.ResponseWindow; -Stats = obj.ShufflingAnalysis; -goodU = NeuronResp.goodU; -p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); -phy_IDg = p.phy_ID(string(p.label') == 'good'); -pvals = Stats.pvalsResponse; -C = NeuronResp.C; -stimDur = NeuronResp.stimDur; +% Select statistics struct for p-values based on statType parameter +if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; +else + Stats = obj.StatisticsPerNeuron; +end + +% Extract spike-sorted unit data: phy IDs, labels, and spike train matrix +p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); +phy_IDg = p.phy_ID(string(p.label') == 'good'); % phy IDs of good units +label = string(p.label'); +goodU = p.ic(:, label == 'good'); % spike train matrix for good units +% Get p-values and trial metadata from response window +pvals = Stats.pvalsResponse; +C = NeuronResp.C; % trial condition matrix: [stimOn, pos, size, lum, ...] +stimDur = NeuronResp.stimDur; % stimulus duration (ms) + +% Select all statistically responsive neurons (p < 0.05) if params.AllResponsiveNeurons - respU = find(pvals<0.05); + respU = find(pvals < 0.05); if isempty(respU) fprintf('No responsive neurons.\n') return end +else + respU = 1:size(goodU,2); end -if params.exNeurons >0 +% Override with manually specified neuron indices if provided +if params.exNeurons > 0 respU = params.exNeurons; end +% Extract stimulus layout: positions, sizes, luminosities seqMatrix = obj.VST.pos; -sizes = obj.VST.tilingRatios; -uSize = unique(sizes); -nSize = length(uSize); -uLums = unique(obj.VST.rectLuminosity(obj.VST.luminosities)); -nLums = length(uLums); %%mAKE IT TO BE ABLE TO COMPARE TWO LUMINOSITIES. -trialDiv = length(seqMatrix)/length(unique(seqMatrix))/nSize/nLums; -directimesSorted = C(:,1)'; +sizes = obj.VST.tilingRatios; +uSize = unique(sizes); +nSize = length(uSize); +uLums = unique(obj.VST.rectLuminosity(obj.VST.luminosities)); +nLums = length(uLums); +% Number of trial repetitions per unique condition (pos x size x lum) +trialDiv = length(seqMatrix) / length(unique(seqMatrix)) / nSize / nLums; + +% Sorted stimulus onset times +directimesSorted = C(:, 1)'; + +% Use full stimulus duration as response window if TakeAllStimDur is set if params.TakeAllStimDur - params.offsetR=0; - params.duration = stimDur; + params.offsetR = 0; + params.duration = stimDur; params.durationOff = NeuronResp.stimInter; end -[Mr] = BuildBurstMatrix(goodU,round(p.t/params.bin),round((directimesSorted+params.offsetR)/params.bin),round(params.duration/params.bin)); -[Mro] = BuildBurstMatrix(goodU,round(p.t/params.bin),round((directimesSorted+stimDur)/params.bin),round(params.durationOff/params.bin)); - -% Mr = Mr.*(1000/params.bin); %convert to seconds -% Mro = Mro.*(1000/params.bin); %convert to seconds - -[nT,nN,NB] = size(Mr); - - -%%%%%%%%%%%%%%%%%%%% Shuffle raster before point multiplication in order -%%%%%%%%%%%%%%%%%%%% to calculate tuning index -%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%%%%%% - -nShuffle =params.nShuffle; - -Raster = BuildBurstMatrix(goodU,round(p.t/params.bin),round((directimesSorted)/params.bin),round((stimDur)/params.bin)); -Raster = Raster.*(1000/params.bin); - -shuffledData = zeros(size(Raster,1), size(Raster,2), size(Raster,3), nShuffle); - -for i =1:nShuffle - - % Shuffle along the first dimension - idx1 = randperm(size(Raster,1)); - - % Shuffle along the third dimension - idx3 = randperm(size(Raster,3)); - - shuffledData(:,:,:,i) = Raster(idx1, :, idx3); - +% Use the shorter of on/off windows to keep matrix sizes consistent +durationMin = min([params.duration params.durationOff]); + +% Build spike count matrices for on-response (Mr) and off-response (Mro) +[Mr] = BuildBurstMatrix(goodU, round(p.t / params.bin), ... + round((directimesSorted + params.offsetR) / params.bin), ... + round(durationMin / params.bin)); +[Mro] = BuildBurstMatrix(goodU, round(p.t / params.bin), ... + round((directimesSorted + stimDur) / params.bin), ... + round(durationMin / params.bin)); + +[nT, nN, NB] = size(Mr); % nT=trials, nN=neurons, NB=time bins + +% ------------------------------------------------------------------------- +% Build shuffle distributions for both on and off responses +% Each shuffle randomly permutes trial order (dim 1) and time bin order +% (dim 3) independently, breaking stimulus-response associations +% ------------------------------------------------------------------------- +nShuffle = params.nShuffle; + +% On-response raster in spike/s, used as base for on-shuffles +RasterOn = Mr .* (1000 / params.bin); % [nT, nN, NB] +% Off-response raster in spike/s, used as base for off-shuffles +RasterOff = Mro .* (1000 / params.bin); % [nT, nN, NB] + +% Pre-allocate shuffle arrays: [nT, nN, NB, nShuffle] +shuffledDataOn = zeros(nT, nN, NB, nShuffle); +shuffledDataOff = zeros(nT, nN, NB, nShuffle); + +for i = 1:nShuffle + idx1 = randperm(nT); % shuffle trial order + idx3 = randperm(NB); % shuffle time bin order within trial + + shuffledDataOn( :,:,:,i) = RasterOn( idx1, :, idx3); + shuffledDataOff(:,:,:,i) = RasterOff(idx1, :, idx3); end -if params.noEyeMoves - % EyePositionAnalysis - % Create spike Sums with NaNs when the eye is not present. - % - % - % file = dir (NP.recordingDir); - % filenames = {file.name}; - % files= filenames(contains(filenames,"timeSnipsNoMov-31")); - % cd(NP.recordingDir) - % %Run eyePosition Analysis to find no movement timeSnips - % timeSnips = load(files{1}).timeSnips; - % timeSnipsMode = timeSnips(:,timeSnips(3,:) == mode(timeSnips(3,:))); - % - % selecInd = []; - % for i = 1:size(timeSnipsMode,2) - % - % %Find stimOns and offs that are between each timeSnip - % selecInd = [selecInd find(directimesSorted>=timeSnipsMode(1,i) & directimesSorted<(timeSnipsMode(2,i)-stimDur))]; - % end - % - % %MrC = nan(round(nT/trialDiv),nN, NB+NBo); - % - % MrC = nan(round(nT/trialDiv),nN, NB); - % - % - % %%Create summary of identical trials - % - % MrMean = nan(round(nT/trialDiv),nN); - % - % for u = 1:length(goodU) - % j=1; - % - % for i = 1:trialDiv:nT - % - % indexVal = selecInd(selecInd>=i & selecInd<=i+trialDiv-1); - % - % if ~isempty(indexVal) - % - % - % meanRon = reshape(mean(Mr(indexVal,u,:),1),[1,size(Mr,3)]); - % - % meanRoff = reshape(mean(Mro(indexVal,u,:),1),[1,size(Mro,3)]); - % - % %meanBase = reshape(mean(Mb1(indexVal,u,:),1),[1,size(Mb1,3)]); - % - % %MrC(j,u,:) = [meanRon-meanBase meanRoff-meanBase]; %Combine on and off response and substract to each the mean baseline - % - % MrC(j,u,:) = [meanRon]; - % MrMean(j,u) = mean(MrC(j,u,:),3);%-Nbase; - % - % else - % 2+2 - % end - % - % - % j = j+1; - % - % end - % end - % - % 2+2 +% ------------------------------------------------------------------------- +% NOTE: Original code used shuffledData (on only). We now use +% shuffledDataOn / shuffledDataOff to keep on and off shuffles separate +% and symmetric. gridSpikeRateShuff still uses shuffledDataOn for +% backward compatibility (on response only). +% ------------------------------------------------------------------------- +if params.noEyeMoves + % Eye movement exclusion path — not implemented (see commented code above) else - MrC = zeros(2,nLums,nSize,round(nT/trialDiv),nN, NB); - - MRtotal = zeros(2,size(Mr,1),size(Mr,2),size(Mr,3)); %includes on and off response - - MRtotal(1,:,:,:) = Mr; + % Build per-condition mean spike rate: [2, nLums, nSize, nPos, nN, NB] + % dim 1: on(1) / off(2) response + % NOTE: dim order here is [onOff, nLums, nSize, ...] — not [onOff, nSize, nLums] + % (matches MrC indexing: MrC(o, uLums==..., uSize==..., j, u, :)) + MrC = zeros(2, nLums, nSize, round(nT / trialDiv), nN, NB); - MRtotal(2,:,:,:) = Mro; - - %%Create summary of identical trials - - for u = 1:length(goodU) - + % Stack on and off into a single 4D array for unified indexing + MRtotal = zeros(2, size(Mr,1), size(Mr,2), size(Mr,3)); + MRtotal(1,:,:,:) = Mr; % on-response + MRtotal(2,:,:,:) = Mro; % off-response + % Average over trialDiv repetitions for each unique condition + for u = 1:size(goodU, 2) for o = 1:2 - j=1; + j = 1; for i = 1:trialDiv:nT - - meanR = mean(squeeze(MRtotal(o,i:i+trialDiv-1,u,:))).*(1000/params.bin); %convert to spikes per second - - MrC(o,uLums == C(i,4),uSize == C(i,3),j,u,:) =meanR; - - j = j+1; + % Mean over trialDiv reps, convert to spikes/s + meanR = mean(squeeze(MRtotal(o, i:i+trialDiv-1, u, :))) .* (1000 / params.bin); + MrC(o, uLums == C(i,4), uSize == C(i,3), j, u, :) = meanR; + j = j + 1; end end end - MrMean = mean(MrC,6);%-Nbase; + % Average over time bins to get mean rate per condition: [2, nLums, nSize, nPos, nN] + MrMean = mean(MrC, 6); end +% ------------------------------------------------------------------------- +% Build position-indexed video frames: circle mask for each stimulus position +% ------------------------------------------------------------------------- +screenSide = obj.VST.rect; +screenRed = screenSide(4) / params.reduceFactor; % reduced screen resolution +[x, y] = meshgrid(1:screenRed, 1:screenRed); +pxyScreen = zeros(screenRed, screenRed); % cumulative position coverage +VideoScreen = zeros(screenRed, screenRed, size(C,1) / trialDiv); % per-position stimulus mask -screenSide = obj.VST.rect; %Same as moving ball +rectData = obj.VST.rectData; -screenRed = screenSide(4)/params.reduceFactor; -[x, y] = meshgrid(1:screenRed, 1:screenRed); +% Store reduced-coordinate centres for each unique position (for grid binning) +XcStore = zeros(1, size(C,1) / trialDiv); +YcStore = zeros(1, size(C,1) / trialDiv); -pxyScreen = zeros(screenRed,screenRed); +j = 1; +for i = 1:trialDiv:length(C) + xyScreen = zeros(screenRed, screenRed)'; -VideoScreen = zeros(screenRed,screenRed,size(C,1)/trialDiv); + % Compute circle centre in reduced pixel coordinates + Xc = round((rectData.X2{1,C(i,3)}(C(i,2)) - rectData.X1{1,C(i,3)}(C(i,2))) / 2) + ... + rectData.X1{1,C(i,3)}(C(i,2)); + Xc = Xc / params.reduceFactor; -rectData = obj.VST.rectData; + Yc = round((rectData.Y4{1,C(i,3)}(C(i,2)) - rectData.Y1{1,C(i,3)}(C(i,2))) / 2) + ... + rectData.Y1{1,C(i,3)}(C(i,2)); + Yc = Yc / params.reduceFactor; -j=1; + XcStore(j) = Xc; + YcStore(j) = Yc; -for i = 1:trialDiv:length(C) + % Circle radius in reduced pixels + r = round((rectData.X2{1,C(i,3)}(C(i,2)) - rectData.X1{1,C(i,3)}(C(i,2))) / 2) / params.reduceFactor; - xyScreen = zeros(screenRed,screenRed)'; %%Make calculations if sizes>1 and if experiment is new and the shape is a circle. + % Binary circle mask: 1 inside stimulus, 0 outside + distances = sqrt((x - Xc).^2 + (y - Yc).^2); + xyScreen(distances <= r) = 1; - % string(obj.VST.shape) == "circle" %%%Asumes that shape is circle + VideoScreen(:,:,j) = xyScreen'; + pxyScreen = pxyScreen + xyScreen; + j = j + 1; +end - Xc = round((rectData.X2{1,C(i,3)}(C(i,2))-rectData.X1{1,C(i,3)}(C(i,2)))/2)+rectData.X1{1,C(i,3)}(C(i,2));%... - Xc = Xc/params.reduceFactor; +% ------------------------------------------------------------------------- +% Spike rate grid map: bin trials into nGrid x nGrid spatial grid +% ------------------------------------------------------------------------- +nGrid = params.nGrid; - Yc = round((rectData.Y4{1,C(i,3)}(C(i,2))-rectData.Y1{1,C(i,3)}(C(i,2)))/2)+rectData.Y1{1,C(i,3)}(C(i,2));%... - Yc = Yc/params.reduceFactor; +% Grid edges in reduced pixel coordinates +xEdges = linspace(0, screenSide(3) / params.reduceFactor, nGrid + 1); +yEdges = linspace(0, screenSide(4) / params.reduceFactor, nGrid + 1); - r = round((rectData.X2{1,C(i,3)}(C(i,2))-rectData.X1{1,C(i,3)}(C(i,2)))/2); - r= r/params.reduceFactor; +% [nGrid, nGrid, nN, 2(on/off), nSize, nLums] +gridSpikeRate = zeros(nGrid, nGrid, nN, 2, nSize, nLums); +% [nGrid, nGrid, nN, nShuffle, 2(on/off), nSize, nLums] +gridSpikeRateShuff = zeros(nGrid, nGrid, nN, nShuffle, 2, nSize, nLums); +trialCount = zeros(nGrid, nGrid, nSize, nLums); - % Calculate the distance of each point from the center - distances = sqrt((x - Xc).^2 + (y - Yc).^2); +jj = 1; +for i = 1:trialDiv:nT - % Set the values inside the circle to 1 (or any other value you prefer) - xyScreen(distances <= r) = 1; + % Bin stimulus centre into grid cell + xBin = discretize(XcStore(jj), xEdges); + yBin = discretize(YcStore(jj), yEdges); - % - % hold on; plot(rect.VSMetaData.allPropVal{21,1}.X1{1,C(i,3)}(C(i,2)),rect.VSMetaData.allPropVal{21,1}.Y1{1,C(i,3)}(C(i,2)),points{C(i,3)},MarkerSize=10)%,... - % hold on; plot(rect.VSMetaData.allPropVal{21,1}.X2{1,C(i,3)}(C(i,2)),rect.VSMetaData.allPropVal{21,1}.Y2{1,C(i,3)}(C(i,2)),points{C(i,3)},MarkerSize=10) - % hold on; plot(rect.VSMetaData.allPropVal{21,1}.X3{1,C(i,3)}(C(i,2)),rect.VSMetaData.allPropVal{21,1}.Y3{1,C(i,3)}(C(i,2)),points{C(i,3)},MarkerSize=10)%,... - % hold on; plot(rect.VSMetaData.allPropVal{21,1}.X4{1,C(i,3)}(C(i,2)),rect.VSMetaData.allPropVal{21,1}.Y4{1,C(i,3)}(C(i,2)),points{C(i,3)},MarkerSize=10)%,... - % hold on; plot(Xc,Yc,points{C(i,3)},MarkerSize=10); + if isnan(xBin) || isnan(yBin) + jj = jj + 1; + continue + end - %figure;imagesc(xyScreen') + sizeIdx = find(uSize == C(i,3)); + lumIdx = find(uLums == C(i,4)); - VideoScreen(:,:,j) = xyScreen'; + trialCount(yBin, xBin, sizeIdx, lumIdx) = trialCount(yBin, xBin, sizeIdx, lumIdx) + 1; - pxyScreen = pxyScreen+xyScreen; + % Mean on/off rate over trialDiv reps, convert to spikes/s: [1,1,nN] + onRate = reshape(mean(mean(Mr( i:i+trialDiv-1,:,:), 1), 3) .* (1000/params.bin), [1 1 nN]); + offRate = reshape(mean(mean(Mro(i:i+trialDiv-1,:,:), 1), 3) .* (1000/params.bin), [1 1 nN]); - j = j+1; + gridSpikeRate(yBin, xBin, :, 1, sizeIdx, lumIdx) = gridSpikeRate(yBin, xBin, :, 1, sizeIdx, lumIdx) + onRate; + gridSpikeRate(yBin, xBin, :, 2, sizeIdx, lumIdx) = gridSpikeRate(yBin, xBin, :, 2, sizeIdx, lumIdx) + offRate; -end + % Accumulate shuffle spike rates (on response only, for grid) + for s = 1:nShuffle + shuffRate = reshape(mean(mean(shuffledDataOn(i:i+trialDiv-1,:,:,s), 1), 3), [1 1 nN]); + gridSpikeRateShuff(yBin, xBin, :, s, 1, sizeIdx, lumIdx) = ... + gridSpikeRateShuff(yBin, xBin, :, s, 1, sizeIdx, lumIdx) + shuffRate; + end -% M = MrMean(:,u)'./Nbase(u); + jj = jj + 1; +end -VD = reshape(VideoScreen,[1 1 1 size(VideoScreen,1) size(VideoScreen,1) size(VideoScreen,3)]); -VD = repmat(VD,[1,1,1,1,1,1,nN]); +% Normalize grid maps by trial count per cell +for si = 1:nSize + for li = 1:nLums + tc = max(trialCount(:,:,si,li), 1); % [nGrid, nGrid] — avoid divide-by-zero + for s = 1:nShuffle + for oi = 1:2 + gridSpikeRateShuff(:,:,:,s,oi,si,li) = gridSpikeRateShuff(:,:,:,s,oi,si,li) ./ tc; + end + end + for oi = 1:2 + gridSpikeRate(:,:,:,oi,si,li) = gridSpikeRate(:,:,:,oi,si,li) ./ tc; + end + end +end +% ------------------------------------------------------------------------- +% RF via point multiplication: weight VideoScreen frames by mean spike rate +% VD: [2, nLums, nSize, screenRed, screenRed, nPos, nN] (after repmat) +% Res: [2, nLums, nSize, 1, 1, nPos, nN] +% ------------------------------------------------------------------------- +nPos = size(VideoScreen, 3); % number of unique stimulus positions -NanPos = isnan(MrMean); +% Reshape VideoScreen to broadcast across onOff/lum/size/neuron dims +VD = reshape(VideoScreen, [1, 1, 1, screenRed, screenRed, nPos]); +VD = repmat(VD, [1, 1, 1, 1, 1, 1, nN]); % [1, 1, 1, screenRed, screenRed, nPos, nN] +NanPos = isnan(MrMean); MrMean(NanPos) = 0; -Res = reshape(MrMean,[size(MrMean,1),size(MrMean,2),size(MrMean,3),1,1,size(MrMean,4),nN]).*1000; +% Reshape MrMean to align position and neuron dims with VD +Res = reshape(MrMean, [2, nLums, nSize, 1, 1, nPos, nN]); -%Take mean -RFu = reshape(mean(VD.*Res,6),[size(MrMean,1),size(MrMean,2),size(MrMean,3),size(VD,4),size(VD,4),nN]); +% Weighted average across positions: [2, nLums, nSize, screenRed, screenRed, nN] +RFu = reshape(mean(VD .* Res, 6), [2, nLums, nSize, screenRed, screenRed, nN]); -offsetN = sqrt(max(seqMatrix)); +% ------------------------------------------------------------------------- +% Shuffle RF: same computation as RFu but using shuffled spike rates +% Result: RFuShuffMean [2, nLums, nSize, screenRed, screenRed, nN] +% averaged across nShuffle, directly comparable to RFu +% ------------------------------------------------------------------------- -TwoDGaussian = fspecial('gaussian',floor(size(RFu,4)/(offsetN/2)),screenRed/offsetN); +% Pre-compute mean shuffle rate per trial by averaging over time bins +% [nT, nN, nShuffle] — avoids recomputing inside the shuffle loop -RFuFilt = zeros(size(RFu)); +shuffMeanRateOn = reshape(mean(shuffledDataOn, 3), [nT, nN, nShuffle]); % [nT, nN, nShuffle] +shuffMeanRateOff = reshape(mean(shuffledDataOff, 3), [nT, nN, nShuffle]); % [nT, nN, nShuffle] +% Accumulate shuffle RFs across all nShuffle — [2, nLums, nSize, screenRed, screenRed, nN, nShuffle] +RFuShuffAll = zeros(2, nLums, nSize, screenRed, screenRed, nN, nShuffle); -for d = 1:size(RFu,1) %On off response - for s = 1:size(RFu,2) %Lums - for l = 1:size(RFu,3) %size - for ui =1:size(RFu,6) %units - slice = squeeze(RFu(d,s,l,:,:,ui)); +for sh = 1:nShuffle - slicek = conv2(slice,TwoDGaussian,'same'); + % Build per-condition mean shuffle rate: [2, nLums, nSize, nPos, nN] + MrMeanShuff_sh = zeros(2, nLums, nSize, nPos, nN); + j = 1; + for i = 1:trialDiv:nT + li = find(uLums == C(i,4)); + si = find(uSize == C(i,3)); - RFuFilt(d,s,l,:,:,ui) =slicek; - end - end + % Average over trialDiv reps for this position + MrMeanShuff_sh(1, li, si, j, :) = mean(shuffMeanRateOn( i:i+trialDiv-1, :, sh), 1); % on + MrMeanShuff_sh(2, li, si, j, :) = mean(shuffMeanRateOff(i:i+trialDiv-1, :, sh), 1); % off + j = j + 1; end + + % Set NaNs to zero before multiplication (same as real RF path) + MrMeanShuff_sh(isnan(MrMeanShuff_sh)) = 0; + + % Reshape to align with VD: [2, nLums, nSize, 1, 1, nPos, nN] + ResSh = reshape(MrMeanShuff_sh, [2, nLums, nSize, 1, 1, nPos, nN]); + + % Weighted average across positions: [2, nLums, nSize, screenRed, screenRed, nN] + RFuShuffAll(:,:,:,:,:,:,sh) = reshape(mean(VD .* ResSh, 6), ... + [2, nLums, nSize, screenRed, screenRed, nN]); + end -% figure;imagesc(squeeze(RFu(2,:,:,:,:,83))); -S.RFu = RFu; +% Average shuffle RFs across shuffles: [2, nLums, nSize, screenRed, screenRed, nN] +% This is the shuffle baseline, directly comparable to RFu +RFuShuffMean = mean(RFuShuffAll, 7); -S.RFuFilt = RFuFilt; +% ------------------------------------------------------------------------- +% Apply 2D Gaussian smoothing to both RFu and RFuShuffMean +% Sigma and kernel size scale with screen resolution and position layout +% ------------------------------------------------------------------------- +offsetN = sqrt(max(seqMatrix)); % number of positions along one screen axis -S.shuffledData = shuffledData; +TwoDGaussian = fspecial('gaussian', ... + floor(size(RFu, 4) / (offsetN / 2)), ... + screenRed / offsetN); -S.params = params; +RFuFilt = zeros(size(RFu)); % smoothed RF +RFuShuffMeanFilt = zeros(size(RFu)); % smoothed shuffle RF baseline -save(filename,'-struct','S'); +for d = 1:size(RFu, 1) % on/off + for s = 1:size(RFu, 2) % lums + for l = 1:size(RFu, 3) % sizes + for ui = 1:size(RFu, 6) % neurons + slice = squeeze(RFu(d,s,l,:,:,ui)); + RFuFilt(d,s,l,:,:,ui) = conv2(slice, TwoDGaussian, 'same'); + sliceSh = squeeze(RFuShuffMean(d,s,l,:,:,ui)); + RFuShuffMeanFilt(d,s,l,:,:,ui) = conv2(sliceSh, TwoDGaussian, 'same'); + end + end + end end +% ------------------------------------------------------------------------- +% Save results +% ------------------------------------------------------------------------- +S.RFu = RFu; % [2, nLums, nSize, screenRed, screenRed, nN] — NOTE: dim2=lums, dim3=size +S.RFuFilt = RFuFilt; % Gaussian-smoothed version of RFu +S.RFuShuffMean = RFuShuffMean; % Shuffle baseline RF [same dims as RFu] +S.RFuShuffMeanFilt = RFuShuffMeanFilt; % Gaussian-smoothed shuffle baseline +S.shuffledData = shuffledDataOn; % Kept for backward compatibility (on-response shuffles) +S.shuffledDataOff = shuffledDataOff; +S.gridSpikeRate = gridSpikeRate; % [nGrid, nGrid, nN, 2, nSize, nLums] +S.gridSpikeRateShuff = gridSpikeRateShuff; % [nGrid, nGrid, nN, nShuffle, 2, nSize, nLums] +S.params = params; + +save(filename, '-struct', 'S'); +fprintf('Saved results to %s\n', filename); + +results = S; + +end \ No newline at end of file diff --git a/visualStimulationAnalysis/@rectGridAnalysis/PlotReceptiveFields.m b/visualStimulationAnalysis/@rectGridAnalysis/PlotReceptiveFields.m index 443fd2e..bfe32e9 100644 --- a/visualStimulationAnalysis/@rectGridAnalysis/PlotReceptiveFields.m +++ b/visualStimulationAnalysis/@rectGridAnalysis/PlotReceptiveFields.m @@ -1,284 +1,378 @@ -function [colorbarLims] = PlotReceptiveFields(obj,params) - +function [colorbarLims] = PlotReceptiveFields(obj, params) +% PlotReceptiveFields Plots spatial receptive fields from a rectangular-grid +% (flash) stimulus, split by luminance, size, and +% On/Off response polarity. +% +% OUTPUT +% colorbarLims – [cMin cMax] limits of the last colorbar drawn. + +% ── Input argument block ────────────────────────────────────────────────────── arguments (Input) obj - params.overwrite logical = false - params.analysisTime = datetime('now') - params.inputParams = false - params.exNeurons = 1; - params.AllSomaticNeurons = false; - params.AllResponsiveNeurons = false; - params.noEyeMoves = false - params.reduceFactor = 20 - params.allStimParamsCombined = false - params.RFsDivision = {'On-Off','',''}; %On-Off, luminosities, sizes - params.eye_to_monitor_distance = 21.5 % Distance from eye to monitor in cm - params.pixel_size = 33 - params.resolution = 1080 - params.meanAllNeurons = false %get mean of receptive fields - params.TypeOfResponse = "on" - params.PaperFig = false - params.colorbarLims = [] + params.overwrite logical = false % Overwrite existing saved figures + params.analysisTime = datetime('now') % Timestamp for provenance + params.inputParams = false % If true, print params and exit + params.exNeurons = 1; % Explicit neuron index/indices to plot + params.AllSomaticNeurons = false % Plot every somatic unit + params.AllResponsiveNeurons = false % Plot only statistically responsive units + params.noEyeMoves = false % Use no-eye-movement recording mode + params.reduceFactor = 20 % Spatial downsampling factor (fallback) + params.allStimParamsCombined = false % Plot the fully-combined (summed) RF + params.RFsDivision = {'On-Off','',''} % Which dims to show separately vs. average: {response, lum, size} + params.eye_to_monitor_distance = 21.5 % Eye-to-monitor distance in cm + params.pixel_size = 33 % Physical pixel size in µm + params.resolution = 1080 % Monitor vertical resolution in pixels + params.meanAllNeurons = false % Average RF across all neurons before plotting + params.TypeOfResponse string = "on" % Which polarity to plot: "on", "off", or "both" + params.PaperFig = false % Apply paper-quality rendering settings + params.colorbarLims = [] % Optional manual colorbar limits [cMin cMax] + params.tickNum = 3 % Number of ticks per axis end -if params.inputParams,disp(params),return,end +% ── Debug helper: print parameter struct and exit ───────────────────────────── +if params.inputParams, disp(params), return, end + +% ── Load pre-computed statistics and receptive fields ──────────────────────── +Stats = obj.ShufflingAnalysis; % Shuffling-based significance statistics +RFs = obj.CalculateReceptiveFields; % Receptive-field maps (no speed argument for this stimulus) -Stats = obj.ShufflingAnalysis; -RFs = obj.CalculateReceptiveFields; -%Parameters -%check receptive field neurons first +% Extract per-unit response p-values (no speed sub-struct for this stimulus) pvals = Stats.pvalsResponse; +% Load the response window data to recover the stimulus condition table responses = obj.ResponseWindow; -uSize = unique(responses.C(:,3)); -uLum = unique(responses.C(:,4)); +% Extract unique stimulus sizes and luminance levels from the condition matrix +uSize = unique(responses.C(:, 3)); % Unique stimulus sizes +uLum = unique(responses.C(:, 4)); % Unique luminance levels +% ── Select which neurons to process ────────────────────────────────────────── if params.AllSomaticNeurons + % Use every unit regardless of responsiveness eNeuron = 1:numel(pvals); - pvals = [eNeuron;pvals(eNeuron)]; + pvals = [eNeuron; pvals(eNeuron)]; % Row 1: indices, Row 2: p-values elseif params.AllResponsiveNeurons - eNeuron = find(pvals<0.05); - pvals = [eNeuron;pvals(eNeuron)];% Select all good neurons if not specified + % Keep only units whose response p-value is below α = 0.05 + eNeuron = find(pvals < 0.05); + pvals = [eNeuron; pvals(eNeuron)]; if isempty(eNeuron) fprintf('No responsive neurons.\n') return end else + % Use the explicitly provided unit index/indices eNeuron = params.exNeurons; - pvals = [eNeuron;pvals(eNeuron)]; + pvals = [eNeuron; pvals(eNeuron)]; end +% ── Build the reduced-resolution coordinate grid ────────────────────────────── +coorRect = obj.VST.rect'; % Screen rectangle [x y w h] in pixels, transposed to column vector -coorRect = obj.VST.rect'; -% reduceFactor = min([params.reduceFactor min(obj.VST.ballSizes)]); %has to be bigger than the smallest ball size -redCoorX = round(coorRect(3)/RFs.params.reduceFactor); -redCoorY = round(coorRect(4)/RFs.params.reduceFactor); +% Use the reduceFactor stored inside the RF struct (more reliable than params) +redCoorX = round(coorRect(3) / RFs.params.reduceFactor); % Grid width in reduced pixels +redCoorY = round(coorRect(4) / RFs.params.reduceFactor); % Grid height in reduced pixels -pixel_size = params.pixel_size/(params.resolution/RFs.params.reduceFactor); % Size of one pixel in cm (e.g., 25 micrometers) -monitor_resolution = [redCoorX, redCoorY]; % Width and height in pixels -[theta_x,theta_y] = pixels2eyeDegrees(params.eye_to_monitor_distance,pixel_size,monitor_resolution); +% Convert pixel size to cm at the reduced resolution +pixel_size_cm = params.pixel_size / (params.resolution / RFs.params.reduceFactor); -theta_x = theta_x(:,1+(redCoorX-redCoorY)/2:(redCoorX-redCoorY)/2+redCoorY); +% Build the visual-angle (degrees) coordinate arrays for the reduced grid +monitor_resolution = [redCoorX, redCoorY]; % [width height] in reduced pixels +[theta_x, theta_y] = pixels2eyeDegrees(params.eye_to_monitor_distance, ... + pixel_size_cm, monitor_resolution); -if params.noEyeMoves %%%mode quadrant - %; -else - RFu = RFs.RFu; %Sum of RFUs +% Crop theta_x horizontally to be square (removes equal margins left and right) +theta_x = theta_x(:, 1 + (redCoorX - redCoorY)/2 : (redCoorX - redCoorY)/2 + redCoorY); + +% ── Pre-compute symmetric, degree-based tick positions (shared across tiles) ─ +% Computing ticks once here and reusing inside the loop avoids +% repeated interp1 calls and guarantees all subplots share identical ticks. + +% X-axis: find the largest absolute visual angle present in the cropped grid +maxDeg_x = max(abs(theta_x(1, :))); +% Define params.tickNum tick positions in degrees, symmetric about 0, stopping 5° inside the edge +tickDeg_x = linspace(-(maxDeg_x - 5), maxDeg_x - 5, params.tickNum); +% Map degree values back to reduced-pixel column indices via linear interpolation +tickPix_x = interp1(theta_x(1, :), 1:size(theta_x, 2), tickDeg_x, 'linear', 'extrap'); + +% Y-axis: same procedure on the first column of theta_y +maxDeg_y = max(abs(theta_y(:, 1))); +tickDeg_y = linspace(-(maxDeg_y - 5), maxDeg_y - 5, params.tickNum); +tickPix_y = interp1(theta_y(:, 1), 1:size(theta_y, 1), tickDeg_y, 'linear', 'extrap'); - RFuFilt = RFs.RFuFilt; %Size and dir and lum +% Pre-round degree labels so they are computed only once +tickLbl_x = round(tickDeg_x); % Rounded x-axis degree labels for display +tickLbl_y = round(tickDeg_y); % Rounded y-axis degree labels for display +% Warn if requested tick range exceeds the actual RF map extent +if maxDeg_x < 5 || maxDeg_y < 5 + warning('PlotReceptiveFields: tick margin of 5° exceeds RF map extent (%.1f°, %.1f°)', ... + maxDeg_x, maxDeg_y); end +% ── Load the appropriate RF arrays ──────────────────────────────────────────── +if params.noEyeMoves + % No-eye-movement mode: loading not yet implemented (placeholder) + % BUG: this branch leaves RFu and RFuFilt undefined; implement or error out. + error('PlotReceptiveFields: noEyeMoves mode is not yet implemented for this stimulus.'); +else + RFu = RFs.RFu; % Fully-combined RF map [on/off × lum × size × y × x × unit] + RFuFilt = RFs.RFuFilt; % RF array before dimension averaging [on/off × lum × size × y × x × unit] +end + +% Compute the number of spatial offset positions (used to scale the Gaussian kernel) +% SUGGESTION: sqrt(max(obj.VST.pos)) is a fragile way to recover the grid side length. +% If VST.pos contains XY positions, consider numel(unique(obj.VST.pos(:,1))) instead. offsetN = sqrt(max(obj.VST.pos)); -TwoDGaussian = fspecial('gaussian',floor(size(RFu,4)/(offsetN/2)),redCoorY/offsetN); %increase size of gaussian by 100%. -hasNotString = find(~cellfun(@isempty, params.RFsDivision)==0); %gives you dimensions (dirs, sizes, or lums) that are to be combined. +% Build a 2-D Gaussian smoothing kernel scaled to the RF map size +% SUGGESTION: fspecial requires the Image Processing Toolbox; imgaussfilt is more portable. +TwoDGaussian = fspecial('gaussian', ... + floor(size(RFu, 4) / (offsetN / 2)), ... % Kernel window (px) + redCoorY / offsetN); % Kernel sigma (px) + +% Identify which RFsDivision dimensions are EMPTY (to be averaged/combined) +% Empty cell = "combine this dimension"; non-empty = "show this dimension separately" +hasNotString = find(~cellfun(@isempty, params.RFsDivision) == 0); + +% Identify which RFsDivision dimensions are NON-EMPTY (to be shown separately) +% BUG: hasString is computed but never used downstream – likely dead code or a missing feature. +hasString = find(~cellfun(@isempty, params.RFsDivision) == 1); + +% ── Build response-type labels consistent with TypeOfResponse filter ────────── +% BUG FIX (original): TypeOfResponse filter was applied twice (once before +% tilesSize computation, once after creating the figure). The second pass +% would crash when TypeOfResponse is "off" because dim 1 was already size 1. +% Fixed by applying the filter only once, just before the tile loop. +% Additionally, the title used rspT{r} which always returned 'on' for "off" +% mode. Fixed by building rspLabels from the actual selected polarity. +switch params.TypeOfResponse + case "on" + rspLabels = {'on'}; % Single label for On-only display + case "off" + rspLabels = {'off'}; % Single label for Off-only display + case "both" + rspLabels = {'on','off'}; % Two labels when showing both polarities + otherwise + error('params.TypeOfResponse is not valid; options are "on", "off", "both".') +end + +% ── Mean-across-neurons preprocessing ──────────────────────────────────────── if params.meanAllNeurons - RFuRed =reshape(mean(RFuFilt,6),[size(RFuFilt,1),size(RFuFilt,2),... - size(RFuFilt,3),size(RFuFilt,4)... - ,size(RFuFilt,5)]); - for i = 1:numel(hasNotString) %Take mean of elements that are not going to be compared (like luminosities, or directions, etc) - RFuRed = mean(RFuRed,hasNotString(i)); - size(RFuRed) + % Average RFuFilt across all neurons (dimension 6) + RFuRed = reshape(mean(RFuFilt, 6), ... + [size(RFuFilt,1), size(RFuFilt,2), ... + size(RFuFilt,3), size(RFuFilt,4), size(RFuFilt,5)]); + + % Additionally average over dimensions marked as "combine" in RFsDivision + for i = 1:numel(hasNotString) + RFuRed = mean(RFuRed, hasNotString(i)); % Collapse dimension i by averaging end - RFu = mean(sum(RFu,[1,2,3]),6); - - eNeuron =1; + % Also collapse the summed RF used in allStimParamsCombined + RFu = mean(sum(RFu, [1,2,3]), 6); % Sum over condition dims, then average over neurons + eNeuron = 1; % Treat mean as a single "virtual" neuron end +% ═══════════════════════════════════════════════════════════════════════════════ +% Main loop: one iteration per selected neuron +% ═══════════════════════════════════════════════════════════════════════════════ for u = eNeuron + % ── Optional: plot the fully-combined (summed across all conditions) RF ── if params.allStimParamsCombined - % %%%Filter with gaussian: + % BUG FIX: RFu was being summed inside the loop, permanently modifying + % it on every iteration. Use a local variable to avoid corrupting RFu + % for subsequent neurons. + RFu_combined = sum(RFu, [1,2,3]); % Sum over response-type, lum, and size dimensions - RFu = sum(RFu,[1,2,3]); + figRF = figure; % Open a new figure window - figRF=figure; if params.meanAllNeurons - imagesc((squeeze(conv2(squeeze(RFu),TwoDGaussian,'same')))); + % Display the pre-averaged, Gaussian-smoothed RF + imagesc(squeeze(conv2(squeeze(RFu_combined), TwoDGaussian, 'same'))); else - imagesc((squeeze(conv2(squeeze(RFu(:,:,:,:,:,u)),TwoDGaussian,'same')))); + % Display the Gaussian-smoothed combined RF for neuron u + imagesc(squeeze(conv2(squeeze(RFu_combined(:,:,:,:,:,u)), TwoDGaussian, 'same'))); end - c = colorbar; - title(c,'spk/s') + c = colorbar; % Attach a colourbar + title(c, 'spk/s') % Label colourbar units + colormap('turbo') % Perceptually-uniform colour map + title(sprintf('u-%d', u)) % Title with unit number - colormap('turbo') - title(sprintf('u-%d',u)) + % Apply symmetric degree-based ticks + xticks(tickPix_x); xticklabels(tickLbl_x); + yticks(tickPix_y); yticklabels(tickLbl_y); - xt = xticks; - xt = xt((1:2:numel(xt))); - xticks(xt); - xticklabels(round(theta_x(1,xt))) + axis image % Equal aspect ratio, axis box fitted tightly to image (no whitespace) - yt = yticks; - yt = yt(1:2:numel(yt)); - yticks(yt); - yticklabels(round(theta_y(yt,1))) + figRF.Position = [680 577 156 139]; % Set figure size in pixels - axis equal tight - - figRF.Position = [ 680 577 156 139]; - if params.noEyeMoves - %print(figRF, sprintf('%s-NEM-MovBall-ReceptiveField-eNeuron-%d.pdf',NP.recordingName,u), '-dpdf', '-r300', '-vector'); - if params.overwrite,obj.printFig(figRF,sprintf('%s-NEM-rectGrid-ReceptiveField-eNeuron-%d.pdf',obj.dataObj.recordingName,u)),end + % Save figure to disk if overwrite flag is set + if params.noEyeMoves + if params.overwrite + obj.printFig(figRF, sprintf('%s-NEM-rectGrid-ReceptiveField-eNeuron-%d.pdf', ... + obj.dataObj.recordingName, u)); + end else - %print(figRF, sprintf('%s-MovBall-ReceptiveField-eNeuron-%d.pdf',NP.recordingName,u), '-dpdf', '-r300', '-vector'); - if params.overwrite,obj.printFig(figRF,sprintf('%s-rectGrid-ReceptiveField-eNeuron-%d',obj.dataObj.recordingName,u)),end + if params.overwrite + obj.printFig(figRF, sprintf('%s-rectGrid-ReceptiveField-eNeuron-%d', ... + obj.dataObj.recordingName, u)); + end end - - - end - - %%%% Plot receptive field per direction - %%%% find max and min of colorbar limits - - cMax = -inf; - cMin = inf; + end % allStimParamsCombined + % ── Extract this neuron's RF slice and average over "combine" dimensions ─ if ~params.meanAllNeurons - RFuRed =reshape(RFuFilt(:,:,:,:,:,u),[size(RFuFilt,1),size(RFuFilt,2),size(RFuFilt,3),size(RFuFilt,4)... - ,size(RFuFilt,5)]); - for i = 1:numel(hasNotString) %Take mean of elements that are not going to be compared (like luminosities, or directions, etc) - RFuRed = mean(RFuRed,hasNotString(i)); - size(RFuRed) + % Extract neuron u's RF; preserve all 5 condition dimensions explicitly + RFuRed = reshape(RFuFilt(:,:,:,:,:,u), ... + [size(RFuFilt,1), size(RFuFilt,2), ... + size(RFuFilt,3), size(RFuFilt,4), size(RFuFilt,5)]); + % [on/off × lum × size × y × x] + + % Average over dimensions that are marked "combine" in RFsDivision + for i = 1:numel(hasNotString) + RFuRed = mean(RFuRed, hasNotString(i)); % Collapse dimension i by averaging end end - - cMax = max(RFuRed,[],'all'); - cMin = min(RFuRed,[],'all'); - - if params.TypeOfResponse == "on" - RFuRed = RFuRed(1,:,:,:,:); - elseif params.TypeOfResponse == "off" - RFuRed = RFuRed(2,:,:,:,:); - elseif params.TypeOfResponse ~= "both" - error(fprintf('params.TypeOfResponse is not valid, options are "on","off","both".\n')) + % ── Determine colour-axis limits BEFORE applying the polarity filter ────── + % Computing cMax/cMin here (on the full On+Off data) ensures the colorbar + % range is symmetric across polarities when TypeOfResponse is "both". + cMax = max(RFuRed, [], 'all'); % Global maximum firing rate + cMin = min(RFuRed, [], 'all'); % Global minimum firing rate + + % ── Apply TypeOfResponse polarity filter ───────────────────────────────── + % BUG FIX: filter applied only once here (was applied twice in original, + % crashing on the second pass). + switch params.TypeOfResponse + case "on" + RFuRed = RFuRed(1, :, :, :, :); % Select On-response slice (dim 1 = 1) + case "off" + RFuRed = RFuRed(2, :, :, :, :); % Select Off-response slice (dim 1 = 2) + % "both": keep RFuRed unchanged; the for-r loop handles both slices end - hasString = find(~cellfun(@isempty, params.RFsDivision)==1); %gives you dimensions (dirs, sizes, or lums) that are to be combined. + % ── Compute tile-grid layout ────────────────────────────────────────────── + % BUG FIX: was prod(size(RFuRed,[1 2 3])) which always produces a scalar, + % making the numel==3 branch unreachable. Corrected to size(). + tilesSize = size(RFuRed, [1 2 3]); % [nResponseType, nLum, nSize] + tilesSize = [tilesSize(1), tilesSize(2) * tilesSize(3)]; % rows = polarity, cols = lum × size - tilesSize = prod(size(RFuRed,[1 2 3])); + % ── Create the tiled figure ─────────────────────────────────────────────── + figRF = figure('Units', 'normalized', 'OuterPosition', [0 0 1 1]); % Full-screen window + NeuronLayout = tiledlayout(tilesSize(1), tilesSize(2), ... + "TileSpacing", "tight", "Padding", "tight"); - if numel(tilesSize) ==1 %%Create tile grid for RF ploting - if tilesSize<4 - tilesSize = [1 tilesSize]; - else - tilesSize = [floor(tilesSize/2) ceil(tilesSize/2)]; - end - end + j = 0; % Running tile counter (used to attach colorbar to the last tile only) - if numel(tilesSize) ==3 %%Create tile grid for RF ploting - tilesSize = [tilesSize(1) tilesSize(2)*tilesSize(3)]; - end - - - %Create plot - figRF = figure('Units', 'normalized', 'OuterPosition', [0 0 1 1]); % Full screen figure; - NeuronLayout = tiledlayout(tilesSize(1),tilesSize(2),"TileSpacing","tight","Padding","tight"); + % ── Inner loops: one tile per (response-type × luminance × size) ───────── + for r = 1:size(RFuRed, 1) % Iterate over response-type slices (1 or 2) + for l = 1:size(RFuRed, 2) % Iterate over luminance slices + for s = 1:size(RFuRed, 3) % Iterate over size slices - j=0; - - rspT={'on','off'}; - - if params.TypeOfResponse == "on" - RFuRed = RFuRed(1,:,:,:,:); - elseif params.TypeOfResponse == "off" - RFuRed = RFuRed(2,:,:,:,:); - elseif params.TypeOfResponse ~= "both" - error(fprintf('params.TypeOfResponse is not valid, options are "on","off","both".\n')) - end + ax = nexttile; % Advance to the next tile in the layout - for r = 1:size(RFuRed,1) - for l = 1:size(RFuRed,2) - for s = 1:size(RFuRed,3) + % Display the RF heat map for condition (r, l, s) + imagesc(squeeze(RFuRed(r, l, s, :, :))); - ax = nexttile; - imagesc((squeeze(RFuRed(r,l,s,:,:)))); + % BUG FIX: original code assigned 'xi = gca' (unused) then + % immediately used 'axi' which was not yet defined, causing + % "Undefined variable 'axi'" error. Fixed: use axi throughout. + axi = gca; - xi = gca; + % Style axis tick labels axi.YAxis.FontSize = 8; axi.YAxis.FontName = 'helvetica'; - - xlabel('Degrees','FontSize',10,'FontName','helvetica') - ylabel('Degrees','FontSize',10,'FontName','helvetica') - - axi = gca; axi.XAxis.FontSize = 8; axi.XAxis.FontName = 'helvetica'; - caxis([cMin cMax]); + % Axis labels (degrees of visual angle) + xlabel('Degrees', 'FontSize', 10, 'FontName', 'helvetica') + ylabel('Degrees', 'FontSize', 10, 'FontName', 'helvetica') + + % Lock colour axis to the per-neuron global range for comparability + % SUGGESTION: clim() is the modern replacement for deprecated caxis() + clim([cMin, cMax]); - colormap('turbo') - title(sprintf('respType-%s-Lum-%s-Size-%s',string(rspT{r}),string(uLum(l)),string(uSize(s))),'FontSize',4) + colormap('turbo') % Perceptually-uniform colour map - %xlim([(redCoorX-redCoorY)/2 (redCoorX-redCoorY)/2+redCoorY]) - xt = xticks;%(linspace((redCoorX-redCoorY)/2,(redCoorX-redCoorY)/2+redCoorY,offsetN*2)); - xt = xt((1:2:numel(xt))); - xticks(xt); - xticklabels(round(theta_x(1,xt))) + % BUG FIX: original used rspT{r} which always returned 'on' + % when TypeOfResponse=="off" (because dim 1 is already size 1 + % after filtering). Fixed: use rspLabels built from TypeOfResponse. + title(sprintf('respType-%s-Lum-%s-Size-%s', ... + rspLabels{r}, string(uLum(l)), string(uSize(s))), ... + 'FontSize', 4) - yt = yticks; - yt = yt(1:2:numel(yt)); - yticks(yt); - yticklabels(round(theta_y(yt,1))) + % Apply symmetric degree-based ticks + xticks(tickPix_x); xticklabels(tickLbl_x); % x ticks in degrees + yticks(tickPix_y); yticklabels(tickLbl_y); % y ticks in degrees - j = j+1; - + j = j + 1; % Increment tile counter - if j ==size(RFuRed,1)*size(RFuRed,2)*size(RFuRed,3) + % Attach colourbar only to the final tile + if j == size(RFuRed,1) * size(RFuRed,2) * size(RFuRed,3) c = colorbar; - title(c,'spk/s','FontSize',8,'FontName','helvetica') + title(c, 'spk/s', 'FontSize', 8, 'FontName', 'helvetica') + % Override colour limits if the caller supplied explicit bounds if ~isempty(params.colorbarLims) - clim([params.colorbarLims]); + clim(params.colorbarLims); end - [colorbarLims] = c.Limits; + + colorbarLims = c.Limits; % Return the final colourbar limits end - axis(ax, 'equal'); - pbaspect(ax, [1 1 1]); - - end - end - end + % Equal aspect ratio, axis box fitted tightly to image (no whitespace) + % SUGGESTION: axis image supersedes both axis equal and pbaspect([1 1 1]) + axis(ax, 'image'); - %title(NeuronLayout, sprintf('Unit-%d',u)); + end % size + end % luminance + end % response type + % ── Resize figure and build filename strings ────────────────────────────── set(figRF, 'Units', 'centimeters'); - set(figRF, 'Position', [2 2 4 4]); - Slum= strjoin(string(uLum),"-"); + set(figRF, 'Position', [2 2 4 4]); % Compact size for single-tile or small layouts + + Slum = strjoin(string(uLum), "-"); % Luminance values joined for filename, e.g. "1-255" + % ── Handle the mean-all-neurons special case ────────────────────────────── if params.meanAllNeurons - title(NeuronLayout,'MeanAllUnits'); - if params.overwrite,obj.printFig(figRF,sprintf('%s-%s-RectGrid-RF-sep-%s-Mean',... - obj.dataObj.recordingName,fieldName, strjoin(params.RFsDivision, '&'))),end - return + title(NeuronLayout, 'MeanAllUnits'); % Label the whole layout + if params.overwrite + % BUG: fieldName is never defined in this function. + % Removed fieldName from the format string below. + obj.printFig(figRF, sprintf('%s-RectGrid-RF-sep-%s-Mean', ... + obj.dataObj.recordingName, strjoin(params.RFsDivision, '&'))); + end + return % Only one figure produced; exit after the mean neuron end - if params.noEyeMoves - - else - if params.PaperFig - - if params.overwrite,obj.printFig(figRF,sprintf('%s-RectGrid-RF-lum-%s-eNeuron-%d',... - obj.dataObj.recordingName, Slum,u),"PaperFig",true),end - else - if params.overwrite,obj.printFig(figRF,sprintf('%s-%s-RectGrid-RF-sep-%s-eNeuron-%d',... - obj.dataObj.recordingName,fieldName, strjoin(params.RFsDivision, '&'),u)),end + % ── Save figure ─────────────────────────────────────────────────────────── + if ~params.noEyeMoves + if params.overwrite + if params.PaperFig + obj.printFig(figRF, sprintf('%s-RectGrid-RF-lum-%s-eNeuron-%d', ... + obj.dataObj.recordingName, Slum, u), "PaperFig", true); + else + % BUG FIX: fieldName was used here but is never defined in this function. + % Replaced with params.TypeOfResponse for a meaningful filename component. + obj.printFig(figRF, sprintf('%s-RectGrid-RF-sep-%s-%s-eNeuron-%d', ... + obj.dataObj.recordingName, params.TypeOfResponse, ... + strjoin(params.RFsDivision, '&'), u)); + end end end + % Close the figure unless this is the last neuron (keep last open for inspection) if u ~= eNeuron(end) close end -end %%%End onDir +end % neuron loop -end +end % function \ No newline at end of file diff --git a/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m b/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m index a7c1f4e..187f0c6 100644 --- a/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m +++ b/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m @@ -6,24 +6,32 @@ function plotRaster(obj,params) params.analysisTime = datetime('now') params.inputParams = false params.preBase = 200 - params.bin = 40 - params.exNeurons = [] + params.bin =15 + params.exNeurons double = [] + params.exNeuronsPhyID double = [] % alternative to exNeurons: specify neurons by phy cluster ID params.AllSomaticNeurons = false - params.AllResponsiveNeurons = true + params.AllResponsiveNeurons = false params.fixedWindow = true params.MergeNtrials =1 params.GaussianLength = 50 - params.oneTrial = false + params.oneTrial = false %Highlight one trial params.selectedLum = [] params.plotPatch logical = true params.PaperFig logical = false params.stim2show = 300 + params.statType string = "BootstrapPerNeuron" end NeuronResp = obj.ResponseWindow; -Stats = obj.ShufflingAnalysis; + +if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; +else + Stats = obj.StatisticsPerNeuron; +end + directimesSorted = NeuronResp.C(:,1)'; nSize = numel(unique(NeuronResp.C(:,3))); @@ -38,11 +46,24 @@ function plotRaster(obj,params) proportionTrials = 1/(numel(NeuronResp.C(:,1))/numel(directimesSorted)); -goodU = NeuronResp.goodU; p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); phy_IDg = p.phy_ID(string(p.label') == 'good'); pvals = Stats.pvalsResponse; - +label = string(p.label'); +goodU = p.ic(:,label == 'good'); %somatic neurons + +% Convert phy IDs to unit indices if exNeuronsPhyID is provided. +% This overrides exNeurons if both are set — phy ID is more explicit. +if ~isempty(params.exNeuronsPhyID) + [found, neuronIdx] = ismember(params.exNeuronsPhyID, phy_IDg); + if any(~found) + warning('The following phy IDs were not found in good units and will be skipped: %s', ... + num2str(params.exNeuronsPhyID(~found))); + end + params.exNeurons = neuronIdx(found); % convert to regular indices + fprintf(' Converted phy IDs [%s] -> unit indices [%s]\n', ... + num2str(params.exNeuronsPhyID(found)), num2str(params.exNeurons)); +end stimDur = NeuronResp.stimDur; stimInter = NeuronResp.stimInter; @@ -80,6 +101,8 @@ function plotRaster(obj,params) %Mr = ConvBurstMatrix(Mr,fspecial('gaussian',[1 params.GaussianLength],3),'same'); +%%%%Sort + ur =1; @@ -288,18 +311,21 @@ function plotRaster(obj,params) % pos1 = cb.Position(1); % cb.Position(1) = pos1 + 0.03; + figName = sprintf('%s-rect-GRid-raster-eNeuron-%d-Lum-%d',obj.dataObj.recordingName,u,params.selectedLum); + if params.PaperFig - obj.printFig(fig,sprintf('%s-rect-GRid-raster-eNeuron-%d',obj.dataObj.recordingName,u),PaperFig = params.PaperFig) + obj.printFig(fig,figName,PaperFig = params.PaperFig) elseif params.overwrite - obj.printFig(fig,sprintf('%s-rect-GRid-raster-eNeuron-%d',obj.dataObj.recordingName,u)) + obj.printFig(fig,figName) end %%Plot raw data - maxRespIn = maxRespIn-1; + maxRespIn = maxRespIn-1; + % trialsPerCath = length(directimesSorted)/(length(unique(seqMatrix))); trials = maxRespIn*trialsPerCath+1:maxRespIn*trialsPerCath + trialsPerCath; @@ -311,7 +337,7 @@ function plotRaster(obj,params) typeData = "line"; %or heatmap - spikes = squeeze(BuildBurstMatrix(goodU(:,u),round(p.t),round(startTimes),round((window)))); + spikes = squeeze(BuildBurstMatrix(goodU(:,u),round(p.t),round(startTimes),round(window))); if params.oneTrial [mx ind] = max(sum(spikes,2)); %select trial with most spikes @@ -320,8 +346,8 @@ function plotRaster(obj,params) end fig2 = figure; - [fig2, mx, mn] = PlotRawDataNP(obj,fig = fig2,c = chan, startTimes = startTimes(ind),... - window = window,spikeTimes = spikes(ind,:)); + [fig2, mx, mn] = PlotRawDataNP(obj,fig = fig2,chan = chan, startTimes = startTimes(ind),... + window = window,spikeTimes = spikes(ind,:)); % % % xline(-start/1000,'r','LineWidth',1.5) % xline((stimDur+abs(start))/1000,'r','LineWidth',1.5) @@ -345,10 +371,11 @@ function plotRaster(obj,params) tr = numel(ind); end + figName = sprintf('%s-rect-GRid-rawData-%d-Trials-raster-eNeuron-%d-Lum%d',obj.dataObj.recordingName,tr,u,params.selectedLum); if params.PaperFig - obj.printFig(fig2,sprintf('%s-rect-GRid-rawData-%d-Trials-raster-eNeuron-%d',obj.dataObj.recordingName,tr,u),PaperFig = params.PaperFig) + obj.printFig(fig2,figName,PaperFig = params.PaperFig) elseif params.overwrite - obj.printFig(fig2,sprintf('%s-rect-GRid-rawData-%d-Trials-raster-eNeuron-%d',obj.dataObj.recordingName,u)) + obj.printFig(fig2,figName) end %prettify_plot diff --git a/visualStimulationAnalysis/@rectGridAnalysis/rectGridAnalysis.m b/visualStimulationAnalysis/@rectGridAnalysis/rectGridAnalysis.m index 9a230bf..d894190 100644 --- a/visualStimulationAnalysis/@rectGridAnalysis/rectGridAnalysis.m +++ b/visualStimulationAnalysis/@rectGridAnalysis/rectGridAnalysis.m @@ -80,6 +80,10 @@ stimDur = mean(-stimOn+stimOff); + if ~isfield(obj.VST,'tilingRatios') + obj.VST.tilingRatios = ones(1,length(stimOn)); + end + A = [stimOn' obj.VST.pos' obj.VST.tilingRatios' obj.VST.rectLuminosity(obj.VST.luminosities)']; [C indRG]= sortrows(A,[2 3 4]); @@ -94,11 +98,12 @@ label = string(p.label'); goodU = p.ic(:,label == 'good'); - if isempty(goodU) || size(goodU,2) < 3 - warning('%s Has less than 3 somatic Neurons, skipping experiment%n',obj.dataObj.recordingName) + if isempty(goodU) + warning('%s has No somatic Neurons, skipping experiment/n',obj.dataObj.recordingName) return end + %%Create window to scan rasters and get the maximum response duration = params.durationWindow; %window in ms, same as in MB Mr = BuildBurstMatrix(goodU,round(p.t/bin),round((directimesSorted)/bin),round((stimDur)/bin)); %response matrix @@ -131,7 +136,12 @@ mergeTrials = trialDivision; %Baseline = size window - preBase = params.preBase; + + if params.preBase > window_size(2) + preBase = params.preBase; + else + preBase = window_size(2)+50; + end [Mbd] = BuildBurstMatrix(goodU,round(p.t/bin),round((directimesSorted-preBase)/bin),round(preBase/bin)); %Baseline matrix plus @@ -210,11 +220,15 @@ %4% real spike rate of response posTr*trialDivision-trialDivision+1:posTr*trialDivision - NeuronRespProfile(k,4) = mean(Mr(max_position_Trial(k,1)*trialDivision-trialDivision+1:max_position_Trial(k,1)*trialDivision-1,u,... + NeuronRespProfile(k,4) = mean(Mr(max_position_Trial(k,1)*trialDivision-trialDivision+1:max_position_Trial(k,1)*trialDivision,u,... max_position_Trial(k,2):max_position_Trial(k,2)+window_size(2)-1),'all');%max_position_Trial(k,2); - BaseResp = mean(Mbd(max_position_TrialB(k,1)*trialDivision-trialDivision+1:max_position_TrialB(k,1)*trialDivision-1,u,... + try + BaseResp = mean(Mbd(max_position_TrialB(k,1)*trialDivision-trialDivision+1:max_position_TrialB(k,1)*trialDivision,u,... max_position_TrialB(k,2):max_position_TrialB(k,2)+window_size(2)-1),'all'); + catch + 2+2 + end %4%. Resp - Baseline % NeuronRespProfile(i,4) = (max_mean_value_Trial(i) - (spkRateBM(u)+max_mean_value_Trial(i))/2)/denom(u); %Zscore diff --git a/visualStimulationAnalysis/AllExpAnalysis.asv b/visualStimulationAnalysis/AllExpAnalysis.asv new file mode 100644 index 0000000..1a30c5a --- /dev/null +++ b/visualStimulationAnalysis/AllExpAnalysis.asv @@ -0,0 +1,1626 @@ +function [tempTable] = AllExpAnalysis(expList, params) +% AllExpAnalysis Pool neural responses across Neuropixels recordings, +% run pairwise statistical comparisons via hierarchical bootstrapping, +% and generate publication-ready swarm and scatter plots. +% +% Supports three modes: +% +% MODE 1 — ACROSS-STIMULUS: +% ComparePairs = {'SDGm','SDGs'} compares z-scores/spike rates between +% different stimulus types. Neurons significant for ANY stimulus in the +% set are included (OR union). +% +% MODE 2 — WITHIN-STIMULUS CATEGORY (all levels): +% ComparePairs = {'MB'}, CompareCategory = "size" compares all levels of +% a category within a single stimulus. Uses StatisticsPerNeuronPerCategory +% for per-level statistics. Recordings without >=2 levels are skipped +% (tries Session=1 then Session=2). +% +% MODE 3 — SPECIFIC-LEVEL ACROSS-STIMULUS: +% ComparePairs = {'MB','SDGm'} +% CompareCategory = {'direction','direction'} +% CompareLevels = {[0],[0]} +% Compares specific level(s) of (possibly different) categories across +% different stimuli. Each stimulus has its own category and level list. +% For a given stimulus, all requested levels must exist in a single +% session of that stimulus, otherwise the experiment is skipped. +% +% INPUTS +% expList (1,:) double Row vector of experiment IDs from master Excel. +% params Name-value See arguments block below. +% +% OUTPUTS +% tempTable table Fraction-responsive table (one row per insertion x +% item), filtered to insertions containing all +% compared items. +% +% EXAMPLES +% % Mode 1 +% t = AllExpAnalysis([49:54 64:66], ComparePairs = {'SDGm','SDGs'}); +% +% % Mode 2 +% t = AllExpAnalysis([49:54 64:66], ... +% ComparePairs = {'MB'}, CompareCategory = "size"); +% +% % Mode 3 +% t = AllExpAnalysis([49:54 64:66], ... +% ComparePairs = {'MB','SDGm'}, ... +% CompareCategory = {'direction','direction'}, ... +% CompareLevels = {[0],[0]}); +% +% See also: hierBoot, , +% StatisticsPerNeuronPerCategory + +% ========================================================================= +% ARGUMENTS BLOCK +% ========================================================================= +arguments + expList (1,:) double % Row vector of experiment IDs from master Excel + + % --- Mode selection --- + params.ComparePairs cell % Stimuli to compare (cell of char/string) + params.CompareCategory = "" % "" -> mode 1; string -> mode 2; cell -> mode 3 + params.CompareLevels cell = {} % Cell of numeric vectors (non-empty -> mode 3) + + % --- Responsiveness filter --- + params.useGeneralFilter logical = false % Use general per-neuron p-values instead of per-level p + params.threshold double = 0.05 % p-value cutoff for the responsiveness OR-mask + + % --- ResponseWindow parameters --- + params.RespDurationWin double = 100 % Duration window (ms) for ResponseWindow computation + params.overwriteResponse logical = false % Force recomputation of ResponseWindow + + % --- StatisticsPerNeuron parameters (maxPermuteTest) --- + params.BaseRespWindow double = 100 % Base response window (ms) for statistics computation + params.SpatialGridMode logical = false % When true, forces BaseRespWindow = 200 ms + params.maxCategory logical = false % Use max-category mode in StatisticsPerNeuron + params.applyFDR logical = false % Apply FDR correction inside the statistics functions + params.overwriteStats logical = false % Force recomputation of statistics + params.CategoryMaximized = '' %Category to be maximized along levels of category to comapre + % --- Bootstrap parameters --- + params.nBoot double = 10000 % Iterations for pairwise hierarchical bootstrap + params.nBootCategory double = 10000 % Iterations for per-category bootstrap + + % --- Data extraction --- + params.useZmean logical = true % true = use z_mean field; false = peak spike rate + + % --- Post-hoc correction (applied AFTER extraction, distinct from applyFDR) --- + params.useFDR logical = false % Benjamini-Hochberg FDR on extracted p-values + + params.markUnits cell = {} % N×4 {NeurID,stimulus,animal,insertion}; row1->X, row2->+ + + % --- Output --- + params.overwrite logical = false % Force rerun of the entire per-experiment loop + params.PaperFig logical = false % Save publication-quality figures via printFig +end + +% ========================================================================= +% SECTION 1 — DETECT MODE, VALIDATE, AND APPLY GLOBAL OVERRIDES +% ========================================================================= + +% --- SpatialGridMode override --- +% In SpatialGridMode the analysis window is fixed at 200 ms. Apply the +% override here so that every downstream call sees the corrected value. +if params.SpatialGridMode + params.BaseRespWindow = 200; % override user-supplied value +end + +% --- Detect operating mode from parameter combinations --- +if ~isempty(params.CompareLevels) || iscell(params.CompareCategory) + % MODE 3: specific category levels compared across stimuli + mode = 3; + + % CompareCategory must be a cell or string array with one entry per stimulus + assert(iscell(params.CompareCategory) || isstring(params.CompareCategory), ... + 'Mode 3: CompareCategory must be a cell or string array, one per stimulus.'); + assert(numel(params.CompareCategory) == numel(params.ComparePairs), ... + 'Mode 3: CompareCategory must have same length as ComparePairs.'); + % Empty CompareLevels → auto-discover all levels per stimulus + if isempty(params.CompareLevels) + params.CompareLevels = repmat({[]}, 1, numel(params.ComparePairs)); + end + assert(numel(params.CompareLevels) == numel(params.ComparePairs), ... + 'Mode 3: CompareLevels must have same length as ComparePairs.'); + + + + % Normalise CompareCategory to a cell of char arrays for uniform handling + catList = cell(1, numel(params.CompareCategory)); + for i = 1:numel(params.CompareCategory) + if iscell(params.CompareCategory) + catList{i} = char(strtrim(params.CompareCategory{i})); % cell input + else + catList{i} = char(strtrim(params.CompareCategory(i))); % string array input + end + end + fprintf('=== Mode 3: specific-level cross-stimulus comparison ===\n'); + +elseif (ischar(params.CompareCategory) || isstring(params.CompareCategory)) && ... + strtrim(string(params.CompareCategory)) ~= "" + % MODE 2: all levels of one category within a single stimulus + mode = 2; + + assert(numel(params.ComparePairs) == 1, ... + 'Mode 2: requires exactly one stimulus in ComparePairs.'); + + stimName = params.ComparePairs{1}; % the single stimulus being decomposed + catName = char(strtrim(string(params.CompareCategory))); % category column name + fprintf('=== Mode 2: within-stimulus category "%s" in %s ===\n', catName, stimName); + +else + % MODE 1: direct across-stimulus comparison + mode = 1; + + assert(numel(params.ComparePairs) >= 2, ... + 'Mode 1: requires >=2 stimuli in ComparePairs.'); +end + +% Boolean shortcuts used throughout the function +isCategoryMode = (mode == 2); % within-stimulus category comparison +isSpecificLevelMode = (mode == 3); % specific (stim, cat, level) tuples + +% Unique stimulus names that need to be loaded (deduplicated, preserving order) +stimsNeeded = unique(params.ComparePairs, 'stable'); + +% ========================================================================= +% SECTION 2 — DIRECTORY SETUP AND CACHE MANAGEMENT +% ========================================================================= + +% Load the first experiment to extract directory paths for saving +NP0 = loadNPclassFromTable(expList(end)); +vs0 = linearlyMovingBallAnalysis(NP0); + +% Build the root path and combined-analysis save directory +rootPath = extractBefore(vs0.dataObj.recordingDir, 'lizards'); % path up to 'lizards' +rootPath = [rootPath 'lizards']; % include 'lizards' folder +saveDir = fullfile(rootPath, 'Combined_lizard_analysis'); % pooled data directory +if ~exist(saveDir, 'dir') + mkdir(saveDir); % create if it does not exist +end + +% Construct a descriptive filename for the cached pooled data. +% Encoding all comparison parameters in the filename prevents accidental +% cache collisions when the same experiment list is analysed differently. +switch mode + case 1 + % Mode 1: stimulus names joined by hyphens + nameOfFile = sprintf('Ex_%d-%d_Combined_%s.mat', ... + expList(1), expList(end), strjoin(stimsNeeded, '-')); + case 2 + % Mode 2: stimulus + category name + nameOfFile = sprintf('Ex_%d-%d_Combined_%s_%s.mat', ... + expList(1), expList(end), stimName, lower(catName)); + case 3 + % Mode 3: each (stim, cat, levels) tuple encoded + parts = cell(1, numel(stimsNeeded)); + for si = 1:numel(stimsNeeded) + if isempty(catList{si}) + parts{si} = stimsNeeded{si}; + elseif isempty(params.CompareLevels{si}) + parts{si} = sprintf('%s-%s-all', stimsNeeded{si}, catList{si}); + else + lvStr = strjoin(arrayfun(@(v) sprintf('%g', v), ... + params.CompareLevels{si}, 'UniformOutput', false), '_'); + parts{si} = sprintf('%s-%s-%s', stimsNeeded{si}, catList{si}, lvStr); + end + end + nameOfFile = sprintf('Ex_%d-%d_SpecLvl_%s.mat', ... + expList(1), expList(end), strjoin(parts, '__')); +end +savePath = fullfile(saveDir, nameOfFile); % full path for the cache .mat + +% Decide whether the per-experiment loop needs to run. +% Skip if the cache exists, matches the experiment list, and overwrite is off. +runLoop = true; +if exist(savePath, 'file') == 2 && ~params.overwrite + S = load(savePath); % load cached struct + if isfield(S, 'expList') && isequal(S.expList, expList) + runLoop = false; % cache is valid — skip the loop + end +end + +% ========================================================================= +% SECTION 3 — INITIALISE LONG-FORMAT TABLES +% ========================================================================= + +% TableStimComp: one row per (neuron x stimulus). Holds z-scores and spike +% rates for every neuron that passes the responsiveness filter. +TableStimComp = table( ... + categorical.empty(0,1), categorical.empty(0,1), categorical.empty(0,1), ... + categorical.empty(0,1), categorical.empty(0,1), double.empty(0,1), double.empty(0,1), ... + 'VariableNames', {'animal','insertion','realInsertion','stimulus','NeurID','Z-score','SpkR'}); + +% TableRespNeurs: one row per (insertion x stimulus). Counts of responsive +% neurons and total somatic neurons for fraction-responsive analysis. +TableRespNeurs = table( ... + categorical.empty(0,1), categorical.empty(0,1), categorical.empty(0,1), ... + double.empty(0,1), double.empty(0,1), ... + 'VariableNames', {'animal','insertion','stimulus','respNeur','totalSomaticN'}); + +% In mode 2, level labels are determined from the first valid recording. +% In mode 3, comparison labels are fixed by parameters from the start. +levelLabels = {}; % mode 2: populated on first valid recording +fixedCompLabels = {}; % mode 3: canonical labels built from parameters + +if isSpecificLevelMode + allSpecified = all(cellfun(@(x) ~isempty(x), params.CompareLevels)) && ... + all(cellfun(@(x) ~isempty(x), catList)); + if allSpecified + [fixedCompLabels, ~] = buildMode3Items(stimsNeeded, catList, params.CompareLevels); + end +end + +% ========================================================================= +% SECTION 4 — PER-EXPERIMENT LOOP +% ========================================================================= + +if runLoop + + % --- BUG FIX (was Bug #4): Map-based counters --- + % Using containers.Map guarantees the same animal/insertion always + % receives the same numeric index, regardless of expList ordering. + % The previous sequential-counter approach silently assigned different + % IDs if the same animal appeared non-contiguously in expList. + animalMap = containers.Map('KeyType','char','ValueType','double'); + insertionMap = containers.Map('KeyType','char','ValueType','double'); + nextAnimalIdx = 0; % running counter for unique animals + nextInsertionIdx = 0; % running counter for unique (animal, insertion) pairs + + for ex = expList + + % ---- 4a: Load recording and check stimulus availability ---- + + NP = loadNPclassFromTable(ex); % load Neuropixels recording object + fprintf('Processing recording: %s\n', NP.recordingName); + + % Load one analysis object per unique stimulus class + [vsObjs, present] = loadStimulusObjects(NP, stimsNeeded); + + % Check that every required stimulus is present in this recording + allPresent = true; + for si = 1:numel(stimsNeeded) + if ~present(stimsNeeded{si}) + allPresent = false; break % at least one missing — skip + end + end + if ~allPresent + fprintf(' -> Skipping: stimulus not present.\n'); + continue + end + + % ---- 4b: Mode-specific session selection ---- + + if isCategoryMode + % Mode 2: find session of stimName with >=2 levels of catName + key = getObjKey(stimName); % shared object key (e.g. 'SDG') + vsObj = vsObjs(key); % retrieve the analysis object + + % Search sessions for >=2 category levels + [levels, ~, vsObj] = findCategoryLevels(vsObj, NP, stimName, catName, params); + + if numel(levels) < 2 + fprintf(' -> Skipping: <2 levels for "%s".\n', catName); + continue % not enough levels for comparison + end + + vsObjs(key) = vsObj; % store back (may have changed session) + + % Convert numeric levels to field-name labels (e.g. 'size_5') + currentLabels = arrayfun(@(v) levelToFieldName(catName, v), ... + levels, 'UniformOutput', false); + + % Lock the level set on the first valid recording; skip any + % subsequent recordings with a different level set. + if isempty(levelLabels) + levelLabels = currentLabels; + fprintf(' Category levels locked: %s\n', strjoin(levelLabels, ', ')); + else + if ~isequal(sort(currentLabels), sort(levelLabels)) + fprintf(' -> Skipping: level mismatch (expected %s, got %s).\n', ... + strjoin(levelLabels,','), strjoin(currentLabels,',')); + continue + end + end + + elseif isSpecificLevelMode + sessionFound = true; + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + cat = catList{si}; + lvls = params.CompareLevels{si}; + key = getObjKey(sn); + + if isempty(cat) + % No category — general stats will be used in 4d + continue + + elseif isempty(lvls) + % Category specified but no levels → auto-discover all + vsObj = vsObjs(key); + [discoveredLvls, ~, vsObj] = findCategoryLevels( ... + vsObj, NP, sn, cat, params); + if numel(discoveredLvls) < 2 + fprintf(' -> Skipping: <2 levels for "%s" in %s.\n', cat, sn); + sessionFound = false; break + end + params.CompareLevels{si} = discoveredLvls(:)'; + vsObjs(key) = vsObj; + + else + % Specific levels → find session containing all of them + [vsObj, allFound] = findSessionWithLevels( ... + NP, sn, cat, lvls, params); + if ~allFound + fprintf(' -> Skipping: %s levels [%s] of "%s" not found.\n', ... + sn, num2str(lvls(:)','%g '), cat); + sessionFound = false; break + end + vsObjs(key) = vsObj; + end + end + if ~sessionFound, continue; end + + % Build labels from (possibly auto-discovered) levels + [currentLabels, ~] = buildMode3Items(stimsNeeded, catList, params.CompareLevels); + if isempty(fixedCompLabels) + fixedCompLabels = currentLabels; + fprintf(' Labels locked: %s\n', strjoin(fixedCompLabels, ', ')); + end + end + + % ---- 4c: Parse metadata and assign animal/insertion indices ---- + % + % BUG FIX (was Bug #4): Uses map-based lookup instead of sequential + % counters. Safe for any ordering of expList. + + % Extract animal ID from recording name (e.g. 'PV123' or 'SA45') + animalID = string(regexp(NP.recordingName, 'PV\d+', 'match', 'once')); + if animalID == "" + animalID = string(regexp(NP.recordingName, 'SA\d+', 'match', 'once')); + end + + % Extract insertion number from the directory path + insStr = regexp(NP.recordingDir, 'Insertion\d+', 'match', 'once'); + insNum = str2double(regexp(insStr, '\d+', 'match')); + + % Real (recording) insertion number = trailing token of the recording name, + % e.g. 'PV103_Experiment_12_6_23_1' -> 1. This is the human-facing insertion + % number used downstream to mark example units; robust to dropped insertions + % because it is the actual recording label, not a sequential/offset index. + recName = NP.recordingName; % == vsObj.dataObj.recordingName + nameTok = strsplit(recName, '_'); % {'PV103','Experiment','12','6','23','1'} + realInsertionID = str2double(nameTok{end}); % trailing token is the insertion number + assert(~isnan(realInsertionID), ... + 'recordingName does not end in a numeric insertion token: %s', recName); + + % Register animal in the map (assigns a new index only on first encounter) + animalKey = char(animalID); + if ~animalMap.isKey(animalKey) + nextAnimalIdx = nextAnimalIdx + 1; + animalMap(animalKey) = nextAnimalIdx; + end + + % Register the (animal, insertion) pair in the map + insKey = sprintf('%s__Ins%d', animalKey, insNum); + if ~insertionMap.isKey(insKey) + nextInsertionIdx = nextInsertionIdx + 1; + insertionMap(insKey) = nextInsertionIdx; + end + insertionCount = insertionMap(insKey); % stable numeric index for this insertion + + % ---- 4d: Run statistics and extract per-item data ---- + + stimData = struct(); % per-item z-scores, p-values, spike rates + nUnits = []; % total somatic neuron count (set once) + compLabels = {}; % labels for items being compared + generalPbyStim = struct(); % general per-neuron p-values (for useGeneralFilter) + + if isCategoryMode + % ---- Mode 2: single stimulus, all levels of one category ---- + + key = getObjKey(stimName); + vsObj = vsObjs(key); + + % Run general per-neuron statistics (StatisticsPerNeuron) + runStimStats(vsObj, params); + vsObjs(key) = vsObj; % store back (handle class — redundant but safe) + + % Extract general p-values (for optional useGeneralFilter) + [~, generalP, ~, ~] = extractStimData(vsObj, stimName, params.useZmean); + generalPbyStim.(stimName) = generalP; + + % Auto-detect GratingType from stimulus abbreviation + gratingType = detectGratingType(stimName); + + % Build name-value arguments for StatisticsPerNeuronPerCategory + catStatsArgs = { ... + 'compareCategory', catName, ... + 'nBoot', params.nBootCategory, ... + 'overwrite', params.overwriteStats, ... + 'BaseRespWindow', params.BaseRespWindow, ... + 'applyFDR', params.applyFDR,... + 'CategoryMaximized' params.CategoryMaximized}; + if ~isempty(gratingType) + % Only pass GratingType for grating stimuli (SDGm/SDGs) + catStatsArgs = [catStatsArgs, {'GratingType', gratingType}]; + end + + % Run per-category statistics + catStats = vsObj.StatisticsPerNeuronPerCategory(catStatsArgs{:}); + + % Extract per-level data from catStats + for li = 1:numel(levelLabels) + fName = levelLabels{li}; % field name in catStats + stimData.(fName).z = catStats.(fName).ZScoreU(:); % z-score + stimData.(fName).p = catStats.(fName).pvalsResponse(:); % p-value + stimData.(fName).spkR = catStats.(fName).ObsStat(:); % spike rate / z_mean + if isempty(nUnits), nUnits = numel(stimData.(fName).z); end + end + compLabels = levelLabels; % items to compare = level labels + + elseif isSpecificLevelMode + % ---- Mode 3: each stimulus contributes one or more (stim, cat, level) items ---- + + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; % stimulus name + cat = catList{si}; % category for this stimulus + lvls = params.CompareLevels{si}; % requested levels + key = getObjKey(sn); + vsObj = vsObjs(key); + + % Run general per-neuron statistics + runStimStats(vsObj, params); + + if isempty(cat) + runStimStats(vsObj, params); + vsObjs(key) = vsObj; + [z, p, spkR, ~] = extractStimData(vsObj, sn, params.useZmean); + generalPbyStim.(sn) = p; + stimData.(sn).z = z(:); + stimData.(sn).p = p(:); + stimData.(sn).spkR = spkR(:); + compLabels{end+1} = sn; %#ok + if isempty(nUnits), nUnits = numel(z); end + continue + end + + vsObjs(key) = vsObj; + + % Extract general p-values (for optional useGeneralFilter) + [~, generalP, ~, ~] = extractStimData(vsObj, sn, params.useZmean); + generalPbyStim.(sn) = generalP; + + % Auto-detect GratingType from stimulus abbreviation + gratingType = detectGratingType(sn); + + % Build name-value arguments for StatisticsPerNeuronPerCategory + catStatsArgs = { ... + 'compareCategory', cat, ... + 'nBoot', params.nBootCategory, ... + 'overwrite', params.overwriteStats, ... + 'BaseRespWindow', params.BaseRespWindow, ... + 'applyFDR', params.applyFDR}; + if ~isempty(gratingType) + catStatsArgs = [catStatsArgs, {'GratingType', gratingType}]; + end + + % Run per-category statistics for this stimulus + catStats = vsObj.StatisticsPerNeuronPerCategory(catStatsArgs{:}); + + % Fields corresponding to levels + allFields = fieldnames(catStats); + + % Keep only fields starting with category name + levelFields = allFields(startsWith(lower(allFields), lower(cat) + "_")); + + % Numeric levels stored in struct + storedLvls = catStats.categoryLevels(:); + + % Extract data for each requested level + for lvi = 1:numel(lvls) + lv = lvls(lvi); % numeric level value + % Find closest matching stored level + [~, idx] = min(abs(storedLvls - lv)); + + % Corresponding field name + fName = levelFields{idx}; + cLabel = makeCompLabel(sn, cat, lv); % short composite label + + stimData.(cLabel).z = catStats.(fName).ZScoreU(:); + stimData.(cLabel).p = catStats.(fName).pvalsResponse(:); + stimData.(cLabel).spkR = catStats.(fName).ObsStat(:); + + compLabels{end+1} = cLabel; %#ok + if isempty(nUnits), nUnits = numel(stimData.(cLabel).z); end + end + end + + % Verify labels match (skip check if labels were just locked above) + if ~isempty(fixedCompLabels) && ~isequal(sort(compLabels(:)), sort(fixedCompLabels(:))) + fprintf(' -> Skipping: comparison label mismatch.\n'); + continue + end + + else + % ---- Mode 1: across-stimulus (one item per stimulus) ---- + + % Run general statistics for every loaded stimulus object + objKeys = keys(vsObjs); + for k = 1:numel(objKeys) + key = objKeys{k}; + vsObj = vsObjs(key); + runStimStats(vsObj, params); + vsObjs(key) = vsObj; + end + + % Extract z-scores, p-values, spike rates for each stimulus + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + key = getObjKey(sn); + [z, p, spkR, ~] = extractStimData(vsObjs(key), sn, params.useZmean); + stimData.(sn).z = z(:); + stimData.(sn).p = p(:); + stimData.(sn).spkR = spkR(:); + generalPbyStim.(sn) = p(:); % general p = per-stimulus p in mode 1 + if isempty(nUnits), nUnits = numel(z); end + end + compLabels = stimsNeeded; % items to compare = stimulus names + end + + % ---- 4e: Optional post-hoc FDR correction ---- + % NOTE: This is the AllExpAnalysis-level FDR (Benjamini-Hochberg on + % the extracted p-values). It is DISTINCT from params.applyFDR, + % which is applied inside the statistics functions themselves. + if params.useFDR + for ci = 1:numel(compLabels) + cl = compLabels{ci}; + stimData.(cl).p = bhFDR(stimData.(cl).p); % correct per-item p-values + end + end + + % ---- 4f: Significance mask ---- + % Build a logical OR mask: a neuron passes if it is significant for + % at least one comparison item. + + if params.useGeneralFilter && (isCategoryMode || isSpecificLevelMode) + % Use general per-stimulus p-values (StatisticsPerNeuron), OR'd + % across stimuli. This avoids filtering on the same per-level + % p-values used for comparison. + orMask = false(nUnits, 1); + stimNames_ = fieldnames(generalPbyStim); + for si = 1:numel(stimNames_) + gp = generalPbyStim.(stimNames_{si}); + if params.useFDR, gp = bhFDR(gp); end % apply FDR if requested + orMask = orMask | (gp < params.threshold); + end + else + % Default: OR across all per-item p-values + orMask = false(nUnits, 1); + for ci = 1:numel(compLabels) + cl = compLabels{ci}; + orMask = orMask | (stimData.(cl).p < params.threshold); + end + end + + unitIDs = find(orMask); % indices of neurons passing the filter + nSig = numel(unitIDs); % count of significant neurons + + % ---- 4g: Append to TableStimComp ---- + % Add one block of rows per comparison item (only significant neurons). + if nSig > 0 + for ci = 1:numel(compLabels) + cl = compLabels{ci}; + newRows = table( ... + repmat(categorical(cellstr(animalID)), nSig, 1), ... % animal column + repmat(categorical(insertionCount), nSig, 1), ... % insertion column + repmat(categorical(realInsertionID), nSig, 1), ... %Real Insertion ID + repmat(categorical(cellstr(cl)), nSig, 1), ... % stimulus/item column + categorical(unitIDs), ... % neuron ID column + stimData.(cl).z(orMask), ... % z-score column + stimData.(cl).spkR(orMask), ... % spike rate column + 'VariableNames', {'animal','insertion','realInsertion','stimulus','NeurID','Z-score','SpkR'}); + TableStimComp = [TableStimComp; newRows]; %#ok + end + end + + % ---- 4h: Append to TableRespNeurs ---- + % One summary row per (insertion x comparison item): responsive + % neuron count and total somatic neuron count. + for ci = 1:numel(compLabels) + cl = compLabels{ci}; + nResp = sum(stimData.(cl).p < params.threshold); % neurons below threshold + newRow = table( ... + categorical(cellstr(animalID)), ... % animal + categorical(insertionCount), ... % insertion + categorical(cellstr(cl)), ... % stimulus/item + nResp, nUnits, ... % responsive count, total count + 'VariableNames', TableRespNeurs.Properties.VariableNames); + TableRespNeurs = [TableRespNeurs; newRow]; %#ok + end + + fprintf(' -> %d / %d units pass filter.\n', nSig, nUnits); + + end % end for ex + + % ========================================================================= + % SECTION 5 — SAVE POOLED DATA + % ========================================================================= + + % Describe the analysis mode in plain text (for figure annotation) + switch mode + case 1, modeDesc = 'across-stimulus'; + case 2, modeDesc = 'within-stimulus-category'; + case 3, modeDesc = 'specific-level-cross-stim'; + end + + % Build a metadata sub-struct capturing every parameter needed to + % reproduce the analysis. This travels with the pooled data AND with + % saved figures, so any figure can be traced to its configuration. + analysisMetadata = struct( ... + 'mode', mode, ... % numeric mode (1/2/3) + 'modeDescription', modeDesc, ... % human-readable label + 'ComparePairs', {params.ComparePairs}, ... % stimuli being compared + 'RespDurationWin', params.RespDurationWin, ... % ResponseWindow duration (ms) + 'BaseRespWindow', params.BaseRespWindow, ... % statistics base window (ms) + 'SpatialGridMode', params.SpatialGridMode, ... % whether grid mode is active + 'maxCategory', params.maxCategory, ... % max-category flag for StatisticsPerNeuron + 'applyFDR', params.applyFDR, ... % FDR inside statistics functions + 'useFDR', params.useFDR, ... % post-hoc BH FDR in AllExpAnalysis + 'threshold', params.threshold, ... % responsiveness p-value cutoff + 'useZmean', params.useZmean, ... % z_mean vs peak spike rate + 'useGeneralFilter', params.useGeneralFilter, ...% general vs per-item filter + 'nBoot', params.nBoot, ... % bootstrap iterations (pairwise) + 'nBootCategory', params.nBootCategory); % bootstrap iterations (category) + + % Add mode-specific fields + if isCategoryMode + analysisMetadata.stimName = stimName; % the decomposed stimulus + analysisMetadata.catName = catName; % category column name + analysisMetadata.levelLabels = levelLabels; % resolved level labels + end + if isSpecificLevelMode + analysisMetadata.catList = catList; + analysisMetadata.CompareLevels = params.CompareLevels; + analysisMetadata.fixedCompLabels = fixedCompLabels; + end + + % Pack into save struct + S.expList = expList; + S.TableStimComp = TableStimComp; + S.TableRespNeurs = TableRespNeurs; + S.params = params; % full params (superset of metadata) + S.mode = mode; + S.analysisMetadata = analysisMetadata; % curated subset for figure annotation + if isCategoryMode, S.levelLabels = levelLabels; end + if isSpecificLevelMode, S.fixedCompLabels = fixedCompLabels; end + + % Write to disk + save(savePath, '-struct', 'S'); + fprintf('Saved pooled data to %s\n', savePath); +end + +% ========================================================================= +% SECTION 5b — RESTORE VARIABLES FROM CACHE +% ========================================================================= +% BUG FIX (was Bug #6): When runLoop is false, S was loaded from disk but +% mode-dependent local variables were never set, causing downstream errors. + +if ~runLoop + mode = S.mode; % restore numeric mode + isCategoryMode = (mode == 2); + isSpecificLevelMode = (mode == 3); + + if isCategoryMode && isfield(S, 'levelLabels') + levelLabels = S.levelLabels; % restore level labels + end + if isSpecificLevelMode && isfield(S, 'fixedCompLabels') + fixedCompLabels = S.fixedCompLabels; % restore comparison labels + end + + fprintf('Loaded pooled data from cache: %s\n', savePath); +end + +% ========================================================================= +% SECTION 6 — GUARD: ABORT IF NO DATA +% ========================================================================= + +if isempty(S.TableStimComp) || height(S.TableStimComp) == 0 + warning('AllExpAnalysis:noUnits', 'No significant units found. Returning empty.'); + tempTable = table(); + return +end + +% Replace NaN z-scores and spike rates with zero (prevents plotting issues) +S.TableStimComp.('Z-score')(isnan(S.TableStimComp.('Z-score'))) = 0; +S.TableStimComp.SpkR(isnan(S.TableStimComp.SpkR)) = 0; + +% Defensive: ensure animal/insertion/stimulus are string-based categoricals. +% Prevents numeric-named categoricals from being silently converted to +% double inside plotSwarmBootstrapWithComparisons. +S.TableStimComp.animal = categorical(cellstr(string(S.TableStimComp.animal))); +S.TableStimComp.insertion = categorical(cellstr(string(S.TableStimComp.insertion))); +S.TableStimComp.stimulus = categorical(cellstr(string(S.TableStimComp.stimulus))); + +% ========================================================================= +% SECTION 7 — SHARED PLOTTING SETUP +% ========================================================================= + +% Create a fresh analysis object for the first experiment (used for printFig) +NP = loadNPclassFromTable(expList(end)); +vs = linearlyMovingBallAnalysis(NP, 'MultipleOffsets', false, 'Multiplesizes', false); + +[~, figTag, ~] = fileparts(nameOfFile); % e.g. 'Ex_49-97_SpecLvl_SDGm-angles-0_1p57...' +% Build a consistent colour map: one colour per animal, shared across all plots +animalOrder = categories(S.TableStimComp.animal); % sorted unique animal names +nAnimals = numel(animalOrder); % number of animals +sharedCmap = lines(nAnimals); % Nx3 colour matrix +animalIdxAll = double(S.TableStimComp.animal); % per-row animal index (for scatter colouring) + +if (mode == 3 || mode == 2) && ... + (any(contains(string(S.TableStimComp.stimulus), "SDG")) && any(contains(string(S.TableStimComp.stimulus), "ang"))) + + stimStr = string(S.TableStimComp.stimulus); + + % Rows containing "SDG" + idxSDG = contains(stimStr, "SDG"); + + % Replace angle values only in SDG rows + stimStr(idxSDG) = replace(stimStr(idxSDG), ... + ["0", "90", "180", "270"], ... + ["1.57","0", "4.71","3.14"]); + + % Convert back to categorical + S.TableStimComp.stimulus = categorical(stimStr); + +end + + +% All pairwise combinations of comparison items +compLabels = cellstr(categories(S.TableStimComp.stimulus)); % unique sorted item labels +pairsAll = nchoosek(compLabels, 2); % Kx2 cell of pairs + +% Display-name substitutions for axis labels +labelMap = {'RG','SB'; 'SDGs','SG'; 'SDGm','MG'}; + +% ========================================================================= +% SECTION 8 — Z-SCORE PAIRWISE COMPARISON +% ========================================================================= + +% Compute a hierarchical-bootstrap p-value for each pair (z-score metric) +pValsZ = zeros(1, size(pairsAll, 1)); +for pi = 1:size(pairsAll, 1) + pValsZ(pi) = bootstrapPairDifference( ... + S.TableStimComp, pairsAll(pi,:), params.nBoot, 'Z-score'); +end + +% Upper y-limit: ceiling of max z-score plus headroom for significance brackets +ZscoreYlim = ceil(max(S.TableStimComp.('Z-score'))) +0.1*ceil(max(S.TableStimComp.('Z-score'))); + +% Generate swarm + bootstrap plot for z-scores +[fig,~,figAllDiffs] = plotSwarmBootstrapWithComparisons( ... + S.TableStimComp, pairsAll, pValsZ, {'Z-score'}, ... + yLegend = 'Z-score', yMaxVis = ZscoreYlim, ... + diff = true, plotMeanSem = true, Alpha = 0.7, markUnits = params.markUnits) ; % N×4 {NeurID,stimulus,animal,insertion}; row1->X, row2->+); + +% Apply consistent font formatting to all axes in the figure +ax = findobj(fig, 'Type', 'axes'); +for i = 1:numel(ax) + formatAxes(ax(i), 8, 'helvetica'); +end + +% Set figure size and colour map +figure(fig); +set(fig, 'Units', 'centimeters', 'Position', [20 20 10 6]); +colormap(fig, sharedCmap); + +%set(gca,'Clipping','off') +pause(2) +% Save figure if PaperFig mode is active +if params.PaperFig + vs.printFig(fig, sprintf('Zscore-Swarm-%s', figTag), ... + PaperFig = params.PaperFig); +end + +ax = findobj(figAllDiffs, 'Type', 'axes'); +for i = 1:numel(ax) + formatAxes(ax(i), 8, 'helvetica'); +end + +if ~isempty(figAllDiffs) + figure(figAllDiffs); + pause(2) + % Save figure if PaperFig mode is active + if params.PaperFig + vs.printFig(figAllDiffs, sprintf('AllDiff-Zscore-Swarm-%s', figTag), ... + PaperFig = params.PaperFig); + end +end + +groupcounts(S.TableStimComp, 'stimulus') % pooled rows per stimulus: are MB and MBR equal? +g = groupcounts(S.TableStimComp, {'insertion','stimulus'}); % rows per (insertion, stimulus) +unstack(g, 'GroupCount', 'stimulus') % one row per insertion; spot where MB ~= MBR +% For exactly two items, also produce a paired scatter plot +if numel(compLabels) == 2 + fig = plotPairScatter(S.TableStimComp, compLabels, ... + 'Z-score', sharedCmap, animalIdxAll, labelMap); + title('Z-score'); + if params.PaperFig + vs.printFig(fig, sprintf('Zscore-Scatter-%s', figTag), ... + PaperFig = params.PaperFig); + end +end + +% ========================================================================= +% SECTION 9 — SPIKE-RATE PAIRWISE COMPARISON +% ========================================================================= + +% Compute a hierarchical-bootstrap p-value for each pair (spike rate metric) +pValsSpk = zeros(1, size(pairsAll, 1)); +for pi = 1:size(pairsAll, 1) + pValsSpk(pi) = bootstrapPairDifference( ... + S.TableStimComp, pairsAll(pi,:), params.nBoot, 'SpkR'); +end + +% Upper y-limit for spike rate +spkMax = max(S.TableStimComp.SpkR) +0.1*max(S.TableStimComp.SpkR); + +% Generate swarm + bootstrap plot for spike rates +[fig,~,figAllDiffs] = plotSwarmBootstrapWithComparisons( ... + S.TableStimComp, pairsAll, pValsSpk, {'SpkR'}, ... + yLegend = 'SpkR', yMaxVis = spkMax, ... + diff = true, plotMeanSem = true, Alpha = 0.7, markUnits = params.markUnits); + +% Apply consistent font formatting +ax = findobj(fig, 'Type', 'axes'); +for i = 1:numel(ax) + formatAxes(ax(i), 8, 'helvetica'); +end + +% Set figure size and colour map +figure(fig); +set(fig, 'Units', 'centimeters', 'Position', [20 20 10 6]); +colormap(fig, sharedCmap); +pause(2) +% Save figure if PaperFig mode is active +if params.PaperFig + vs.printFig(fig, sprintf('SpkRate-Swarm-%s', figTag), ... + PaperFig = params.PaperFig); +end + +ax = findobj(figAllDiffs, 'Type', 'axes'); +for i = 1:numel(ax) + formatAxes(ax(i), 8, 'helvetica'); +end + +if ~isempty(figAllDiffs) + figure(figAllDiffs); + pause(2) + + % Save figure if PaperFig mode is active + if params.PaperFig + vs.printFig(figAllDiffs, sprintf('AllDiff-SpkRate-Swarm-%s', figTag), ... + PaperFig = params.PaperFig); + end +end + +% For exactly two items, produce a paired scatter plot +if numel(compLabels) == 2 + fig = plotPairScatter(S.TableStimComp, compLabels, ... + 'SpkR', sharedCmap, animalIdxAll, labelMap); + title('Spk. rate'); + if params.PaperFig + vs.printFig(fig, sprintf('SpkRate-Scatter-%s', figTag), ... + PaperFig = params.PaperFig); + end +end + +% ========================================================================= +% SECTION 10 — FRACTION-RESPONSIVE ANALYSIS +% ========================================================================= + +compLabels = cellstr(categories(S.TableRespNeurs.stimulus)); % unique sorted item labels + +% Find groups by insertion, then check which insertions contain ALL items +[G, ~] = findgroups(S.TableRespNeurs.insertion); +hasAll = splitapply( ... + @(s) all(ismember(categorical(compLabels), s)), ... + S.TableRespNeurs.stimulus, G); + +% Filter to only insertions that have data for every comparison item +tempTable = S.TableRespNeurs( ... + hasAll(G) & ismember(S.TableRespNeurs.stimulus, categorical(compLabels)), :); + +% --- BUG FIX (was Bug #3): Hierarchical bootstrap for fraction-responsive --- +% The previous flat bootstrp(@mean, diffs) ignored the nesting of insertions +% within animals. Using hierBoot is consistent with the mixed model. + +pairsAll = nchoosek(compLabels, 2); + +pValsFrac = zeros(1, size(pairsAll, 1)); +for pi = 1:size(pairsAll, 1) + + diffs = []; % per-insertion fraction differences + insLabels = []; % insertion indices for hierBoot (level 1) + animLabels = []; % animal indices for hierBoot (level 2) + + for ins = unique(S.TableRespNeurs.insertion)' + + % Find rows for this insertion and each stimulus in the pair + idx1 = S.TableRespNeurs.insertion == ins & ... + S.TableRespNeurs.stimulus == pairsAll{pi,1}; + idx2 = S.TableRespNeurs.insertion == ins & ... + S.TableRespNeurs.stimulus == pairsAll{pi,2}; + + % Both stimuli must be present for a valid paired comparison + if any(idx1) && any(idx2) + total = S.TableRespNeurs.totalSomaticN(idx1); % total neurons + f1 = S.TableRespNeurs.respNeur(idx1) / total; % fraction, stim 1 + f2 = S.TableRespNeurs.respNeur(idx2) / total; % fraction, stim 2 + d = f1 - f2; % paired difference + + animal = S.TableRespNeurs.animal(idx1); % animal for this insertion + + diffs = [diffs; d]; + insLabels = [insLabels; double(ins)]; + animLabels = [animLabels; double(animal)]; + end + end + + % Hierarchical bootstrap: resample animals -> insertions -> fractions + bootDiff = hierBootMatchFreq(diffs, params.nBoot, animLabels,insLabels); + + % Two-tailed p-value + pLeft = mean(bootDiff <= 0); + pRight = mean(bootDiff >= 0); + pValsFrac(pi) = min(2 * min(pLeft, pRight), 1); +end + +% Compute total responsive neurons per insertion (across stimuli) +[G, ~] = findgroups(tempTable.insertion); +totals = splitapply(@sum, tempTable.respNeur, G); +tempTable.TotalRespNeur = totals(G); + +% Generate swarm plot for fraction responsive +[fig,~,figAllDiffs] = plotSwarmBootstrapWithComparisons( ... + tempTable, pairsAll, pValsFrac, ... + {'respNeur','totalSomaticN'}, ... + fraction = true, showBothAndDiff = false, ... + yLegend = 'Responsive/total units', ... + diff = false, filled = false, Xjitter = 'none', ... + Alpha = 0.6, drawLines = true); + +set(gca, 'Clipping', 'off') + +% Count total unique responsive neurons across all (animal, insertion) pairs +totalResp = 0; +animals = unique(S.TableStimComp.animal); +for a = 1:numel(animals) + inser = unique(S.TableStimComp.insertion(S.TableStimComp.animal == animals(a))); + for in = 1:numel(inser) + totalResp = totalResp + length(unique( ... + S.TableStimComp.NeurID( ... + S.TableStimComp.insertion == inser(in) & ... + S.TableStimComp.animal == animals(a)))); + end +end + + +% --- Compute total somatic neurons across all unique insertions --- +totalSomatic = 0; % initialize accumulator +allInsertions = unique(S.TableRespNeurs.insertion); % unique insertion IDs across all animals +for in = 1:numel(allInsertions) % iterate over every insertion (BUG FIX: was "= numel(inser)", only hit last index) + insRows = S.TableRespNeurs.totalSomaticN( ... % extract totalSomaticN for all rows matching this insertion + S.TableRespNeurs.insertion == allInsertions(in)); + totalSomatic = totalSomatic + insRows(1); % take first value (identical across stimulus rows within an insertion) +end + +% Build annotation string with per-item responsive neuron counts +perItemN = arrayfun(@(s) sum(tempTable.respNeur(tempTable.stimulus == s)), ... + categorical(compLabels)); +annotParts = arrayfun(@(i) sprintf('%s = %d', compLabels{i}, perItemN(i)), ... + 1:numel(compLabels), 'UniformOutput', false); +annotStr = ['TS=' num2str(totalSomatic) '-' 'TR=' num2str(totalResp) '-' strjoin(annotParts, '-')]; + +% Apply consistent font formatting +ax = findobj(fig, 'Type', 'axes'); +for i = 1:numel(ax) + formatAxes(ax(i), 8, 'helvetica'); +end + +% Set figure size and labels +figure(fig); +set(fig, 'Units', 'centimeters', 'Position', [20 20 5 6]); +ylabel('Responsive / Total responsive'); +title(''); + +% % Shift axes up slightly to make room for the annotation +% pos = get(gca, 'Position'); +% pos(2) = pos(2) + 2; +% set(gca, 'Position', pos); + +% Add bottom annotation with neuron counts +annotation('textbox', [0.1, 0.01, 0.8, 0.04], ... + 'String', annotStr, 'EdgeColor', 'none', ... + 'FontSize', 5, 'FontWeight', 'bold', ... + 'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle', ... + 'FitBoxToText', false); + +% Save figure if PaperFig mode is active +if params.PaperFig + vs.printFig(fig, sprintf('ResponsiveUnits-%s', figTag), ... + PaperFig = params.PaperFig); +end + +if ~isempty(figAllDiffs) + figure(figAllDiffs); + % Save figure if PaperFig mode is active + if params.PaperFig + vs.printFig(figAllDiffs, sprintf('AllDiff-RespUnits-%s', figTag), ... + PaperFig = params.PaperFig); + end +end + +% ========================================================================= +% SECTION 11 — SAVE ANALYSIS STRUCT TO FIGURE DIRECTORY +% ========================================================================= +% When PaperFig is true, save a companion .mat alongside the figures so +% that every figure folder is self-contained: figures + the exact analysis +% configuration that produced them. + +if params.PaperFig + % Retrieve the figure save directory from the analysis object + % NOTE: adjust this path if vs.printFig uses a different convention + rootPath = extractBefore(vs0.dataObj.recordingDir, 'lizards'); % 'W:\Large_scale_mapping_NP\' + + figSaveDir = [rootPath 'Paper_figs']; + % Use the same base name as the analysis cache, with a suffix + [~, cacheName, ~] = fileparts(nameOfFile); + figStructPath = fullfile(figSaveDir, [cacheName '_analysisStruct.mat']); + + % Save the full struct (duplicates ~tens of KB; guarantees self-containment) + save(figStructPath, '-struct', 'S'); + fprintf('Saved analysis struct to figure directory: %s\n', figStructPath); +end + +end % end function AllExpAnalysis + + +% ######################################################################### +% LOCAL HELPER FUNCTIONS +% ######################################################################### + + +function gt = detectGratingType(stimName) +% detectGratingType Auto-detect the GratingType parameter from stimulus +% abbreviation. Returns 'moving' for SDGm, 'static' for SDGs, or '' +% for non-grating stimuli (in which case GratingType is not passed). + switch stimName + case 'SDGm', gt = 'moving'; % moving grating + case 'SDGs', gt = 'static'; % static grating + otherwise, gt = ''; % not a grating stimulus + end +end + + +function [labels, items] = buildMode3Items(stimsNeeded, catList, levelsCell) +% buildMode3Items Build canonical comparison labels and (stim, cat, lvl) tuples. +% labels{k} = 'MB_dir_0' etc. +% items(k) = struct('stim','MB', 'cat','direction', 'lv',0). + +labels = {}; +items = struct('stim', {}, 'cat', {}, 'lv', {}); + +for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + cat = catList{si}; + lvls = levelsCell{si}; + + if isempty(cat) + % No category → single item labeled with plain stimulus name + labels{end+1} = sn; %#ok + items(end+1) = struct('stim', sn, 'cat', '', 'lv', NaN); %#ok + continue + end + + for lvi = 1:numel(lvls) + lv = lvls(lvi); % single level value + labels{end+1} = makeCompLabel(sn, cat, lv); %#ok + items(end+1) = struct('stim', sn, 'cat', cat, 'lv', lv); %#ok + end +end +end + + +function lbl = makeCompLabel(stimName, catName, levelValue) +% makeCompLabel Short composite label: '__'. +% Category truncated to 3 chars; decimals -> 'p'; negative -> 'neg'. + + catAbbr = lower(catName); % lowercase category + if strlength(catAbbr) > 3 + catAbbr = extractBetween(catAbbr, 1, 3); % truncate to 3 characters + catAbbr = char(catAbbr); + end + lbl = sprintf('%s_%s_%g', stimName, catAbbr, levelValue); % e.g. 'MB_dir_0' + lbl = strrep(lbl, '.', 'p'); % 0.3 -> 0p3 + lbl = strrep(lbl, '-', 'neg'); % -1 -> neg1 +end + + +function [vsObj, allFound] = findSessionWithLevels(NP, stimName, catName, requestedLevels, params) +% findSessionWithLevels Find a session of stimName whose category column +% contains ALL requested levels. Tries Session=1 then Session=2. +% +% ResponseWindow is recomputed with params.overwriteResponse before +% reading colNames/C, to ensure stale cached column names are refreshed. + + vsObj = []; + allFound = false; + + for session = [1, 2] + candidate = createStimulusObject(NP, stimName, session); % try this session + if isempty(candidate) || isempty(candidate.VST) + continue % session not available + end + + % Recompute ResponseWindow (fixes stale column names if overwrite on) + candidate.ResponseWindow( ... + 'overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + rw = candidate.ResponseWindow; + + % Extract the condition matrix and its column names + [C, colNames] = getCmatrix(rw, stimName); + if isempty(C) || isempty(colNames) + continue + end + + % Find the requested category column (case-insensitive) + catIdx = find(strcmpi(colNames, catName)); + if isempty(catIdx) + continue % category not in this stimulus + end + + catColIdx = catIdx + 1; % +1 because colNames excludes first 4 cols + availLevels = uniquetol(C(~isnan(C(:, catColIdx)), catColIdx), 1e-6); + + % Check that every requested level is present + ok = true; + for lv = requestedLevels(:)' + if ~any(abs(availLevels - lv) < 1e-2) + ok = false; break + end + end + + if ok + vsObj = candidate; + allFound = true; + return + end + end +end + + +function [levels, catColIdx, vsObj] = findCategoryLevels(vsObj, NP, stimName, catName, params) +% findCategoryLevels Find unique category levels in a recording (mode 2). +% Tries Session=1, then Session=2. + + levels = []; + catColIdx = 0; + + for session = [1, 2] + fprintf(' Trying Session=%d...\n', session); + vsObj = createStimulusObject(NP, stimName, session); + if isempty(vsObj) || isempty(vsObj.VST) + continue + end + + % Recompute ResponseWindow + vsObj.ResponseWindow( ... + 'overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + rw = vsObj.ResponseWindow; + + % Extract condition matrix + [C, colNames] = getCmatrix(rw, stimName); + if isempty(C) || isempty(colNames) + continue + end + + % Look for the category column (case-insensitive) + catIdx = find(strcmpi(colNames, catName)); + if isempty(catIdx) + fprintf(' Category "%s" not found. Available: %s\n', ... + catName, strjoin(colNames, ', ')); + return + end + + catColIdx = catIdx + 1; % offset for first 4 metadata cols + rawCol = C(:, catColIdx); % raw values + rawCol = rawCol(~isnan(rawCol)); % remove NaNs + levels = uniquetol(rawCol, 1e-6); % unique levels with tolerance + + if numel(levels) >= 2 + fprintf(' Found %d levels of "%s" (session %d): [%s]\n', ... + numel(levels), catName, session, num2str(levels', '%.4g ')); + return % success — exit early + else + fprintf(' Only %d level of "%s" in session %d.\n', ... + numel(levels), catName, session); + end + end +end + + +function [C, colNames] = getCmatrix(rw, stimName) +% getCmatrix Extract the condition matrix C and column names from a +% ResponseWindow struct. Returns empty if not available. + + C = []; + colNames = {}; + + switch stimName + case {'MB', 'MBR'} + % MB/MBR store per-speed sub-structs; use the last (fastest) speed + speedFields = fieldnames(rw); + speedFields = speedFields(startsWith(speedFields, 'Speed')); + if isempty(speedFields), return; end + maxField = speedFields{end}; + C = rw.(maxField).C; + colNames = rw.colNames{1}(5:end); % skip first 4 metadata columns + + case 'SDGm' + if isfield(rw, 'C') + C = rw.C; + colNames = rw.colNames{1}(5:end); + end + + case 'SDGs' + if isfield(rw, 'C') + C = rw.C; + colNames = rw.colNames{1}(5:end); + end + + otherwise + % Generic fallback + if isfield(rw, 'C') + C = rw.C; + colNames = rw.colNames{1}(5:end); + end + end +end + + +function vsObj = createStimulusObject(NP, stimName, session) +% createStimulusObject Create an analysis object, optionally with Session. +% Returns [] on failure. + + vsObj = []; + try + key = getObjKey(stimName); % shared key (e.g. 'SDG' for both SDGm/SDGs) + if session == 0 + % No session specified — use default + switch key + case 'MB', vsObj = linearlyMovingBallAnalysis(NP); + case 'RG', vsObj = rectGridAnalysis(NP); + case 'MBR', vsObj = linearlyMovingBarAnalysis(NP); + case 'SDG', vsObj = StaticDriftingGratingAnalysis(NP); + case 'NI', vsObj = imageAnalysis(NP); + case 'NV', vsObj = movieAnalysis(NP); + case 'FFF', vsObj = fullFieldFlashAnalysis(NP); + end + else + % Explicit session number + switch key + case 'MB', vsObj = linearlyMovingBallAnalysis(NP, 'Session', session); + case 'RG', vsObj = rectGridAnalysis(NP, 'Session', session); + case 'MBR', vsObj = linearlyMovingBarAnalysis(NP, 'Session', session); + case 'SDG', vsObj = StaticDriftingGratingAnalysis(NP, 'Session', session); + case 'NI', vsObj = imageAnalysis(NP, 'Session', session); + case 'NV', vsObj = movieAnalysis(NP, 'Session', session); + case 'FFF', vsObj = fullFieldFlashAnalysis(NP, 'Session', session); + end + end + catch ME + fprintf(' Could not create %s Session=%d: %s\n', stimName, session, ME.message); + vsObj = []; + end +end + + +function [vsObjs, present] = loadStimulusObjects(NP, stimsNeeded) +% loadStimulusObjects Load one analysis object per unique stimulus class. +% Returns a containers.Map of objects and a Map of presence flags. + + vsObjs = containers.Map(); % key -> analysis object + present = containers.Map(); % stimName -> true/false + + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; % stimulus name + key = getObjKey(sn); % shared object key + + if ~vsObjs.isKey(key) + % First encounter of this key — create the object + obj = createStimulusObject(NP, sn, 0); + + if isempty(obj) || isempty(obj.VST) + fprintf(' %s: stimulus not found.\n', key); + present(sn) = false; + else + present(sn) = true; + end + + if ~isempty(obj) + vsObjs(key) = obj; + end + else + % Object already loaded for this key — check presence + if ~present.isKey(sn) + present(sn) = vsObjs.isKey(key) && ~isempty(vsObjs(key).VST); + end + end + end +end + + +function key = getObjKey(stimName) +% getObjKey Map stimulus abbreviation to shared analysis-object key. +% SDGm and SDGs share a single StaticDriftingGratingAnalysis object. + switch stimName + case {'SDGm','SDGs'}, key = 'SDG'; + otherwise, key = stimName; + end +end + + +function fName = levelToFieldName(catName, value) +% levelToFieldName Build a valid MATLAB field name matching +% StatisticsPerNeuronPerCategory's convention. +% e.g. ('size', 5) -> 'size_5', ('speed', 0.3) -> 'speed_0p3'. + + fName = sprintf('%s_%g', lower(strtrim(catName)), value); + fName = strrep(fName, '.', 'p'); % decimal point -> 'p' + fName = strrep(fName, '-', 'neg'); % minus sign -> 'neg' +end + + +function runStimStats(vsObj, params) +% runStimStats Run ResponseWindow + StatisticsPerNeuron. +% NOTE: This function assumes vsObj is a handle class. If it is a value +% class, the caller must capture the return value (which this function +% does not currently provide). Assert handle-class identity here to +% catch this early. + + assert(isa(vsObj, 'handle'), ... + 'runStimStats:notHandle', ... + 'Analysis object must be a handle class for in-place mutation.'); + + % Step 1: Compute ResponseWindow (response traces, condition matrix) + vsObj.ResponseWindow( ... + 'overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + + % Step 2: Run StatisticsPerNeuron (max-permutation test) + vsObj.StatisticsPerNeuron( ... + 'overwrite', params.overwriteStats, ... + 'BaseRespWindow', params.BaseRespWindow, ... + 'SpatialGridMode', params.SpatialGridMode, ... + 'maxCategory', params.maxCategory, ... + 'applyFDR', params.applyFDR); +end + + +function [z, p, spkR, spkDiff] = extractStimData(vsObj, stimName, useZmean) +% extractStimData Pull z-scores, p-values, and spike rate from the +% StatisticsPerNeuron results. +% +% INPUTS +% vsObj Analysis object (with computed StatisticsPerNeuron) +% stimName char Stimulus abbreviation ('MB','SDGm','SDGs', etc.) +% useZmean logical true = use z_mean; false = use peak spike rate +% +% OUTPUTS +% z (N,1) double Z-scores per neuron +% p (N,1) double P-values per neuron +% spkR (N,1) double Spike rate (or z_mean) per neuron +% spkDiff (N,1) double Response minus baseline difference + + % Always read from StatisticsPerNeuron (only stat method retained) + stats = vsObj.StatisticsPerNeuron; + rw = vsObj.ResponseWindow; + + switch stimName + + case 'MB' + % MB has multiple speeds — find best speed per neuron (lowest p) + speedFields = fieldnames(stats); + speedFields = speedFields(contains(speedFields, 'Speed')); + nSpeeds = numel(speedFields); + + allP = []; % (nNeurons x nSpeeds) p-value matrix + allZ = []; % (nNeurons x nSpeeds) z-score matrix + allR = []; % (nNeurons x nSpeeds) spike rate matrix + allDf = []; % (nNeurons x nSpeeds) difference matrix + + for iS = 1:nSpeeds + sName = speedFields{iS}; % e.g. 'Speed1', 'Speed2' + subTmp = stats.(sName); % statistics sub-struct + rwTmp = rw.(sName); % response window sub-struct + + allP(:,iS) = subTmp.pvalsResponse(:); %#ok + allZ(:,iS) = subTmp.ZScoreU(:); %#ok + + if useZmean && isfield(subTmp, 'z_mean') + allR(:,iS) = subTmp.z_mean(:); %#ok + else + allR(:,iS) = max(rwTmp.NeuronVals(:,:,4), [], 2); %#ok + end + allDf(:,iS) = max(rwTmp.NeuronVals(:,:,5), [], 2); %#ok + end + + % Select the speed with the lowest p-value for each neuron + [p, bestIdx] = min(allP, [], 2); + nNeurons = size(allP, 1); + linIdx = sub2ind(size(allP), (1:nNeurons)', bestIdx); + z = allZ(linIdx); + spkR = allR(linIdx); + spkDiff = allDf(linIdx); + + return + + case 'MBR' + sub = stats.Speed1; rwSub = rw.Speed1; % bar: single speed + case 'SDGm' + sub = stats.Moving; rwSub = rw.Moving; % moving grating sub-struct + case 'SDGs' + sub = stats.Static; rwSub = rw.Static; % static grating sub-struct + otherwise + sub = stats; rwSub = rw; % generic: top-level struct + end + + % Extract per-neuron values from the selected sub-struct + z = sub.ZScoreU(:); % z-score + p = sub.pvalsResponse(:); % p-value + + + if useZmean && isfield(sub, 'z_mean') + spkR = sub.z_mean(:); % z-scored mean response + else + spkR = max(rwSub.NeuronVals(:,:,4), [], 2); % peak spike rate + end + + spkDiff = max(rwSub.NeuronVals(:,:,5), [], 2); % peak response - baseline +end + + +function pVal = bootstrapPairDifference(tbl, pair, nBoot, metric) +% bootstrapPairDifference Hierarchical bootstrap test for a single pair. +% +% BUG FIX (was Bug #2): Pairs neurons explicitly by NeurID within each +% insertion using innerjoin, instead of relying on row order. The +% previous row-order approach silently produced wrong differences if the +% table was ever sorted or filtered asymmetrically. +% +% INPUTS +% tbl table Long-format with columns: insertion, stimulus, NeurID, +% animal, and the metric column. +% pair {1x2} Cell pair of stimulus labels. +% nBoot double Number of bootstrap iterations. +% metric char Column name to test ('Z-score' or 'SpkR'). +% +% OUTPUT +% pVal double Two-tailed p-value from hierarchical bootstrap. + + diffs = []; % per-neuron paired differences + insers = []; % insertion label for each difference (hierBoot level 1) + animals = []; % animal label for each difference (hierBoot level 2) + + for ins = unique(tbl.insertion)' + + % Logical masks: rows for this insertion x each stimulus + mask1 = tbl.insertion == ins & tbl.stimulus == pair{1}; + mask2 = tbl.insertion == ins & tbl.stimulus == pair{2}; + + if ~any(mask1) || ~any(mask2), continue; end % skip if either is absent + + % Extract sub-tables with the metric, NeurID, and animal columns + sub1 = tbl(mask1, {metric, 'NeurID', 'animal'}); + sub2 = tbl(mask2, {metric, 'NeurID'}); + + % Inner-join on NeurID ensures correct neuron-to-neuron pairing + merged = innerjoin(sub1, sub2, 'Keys', 'NeurID', ... + 'LeftVariables', {metric, 'NeurID', 'animal'}, ... + 'RightVariables', {metric}); + + % MATLAB auto-suffixes duplicated variable names after innerjoin; + % find both metric columns by prefix matching + mergedVarNames = merged.Properties.VariableNames; + metricCols = mergedVarNames(startsWith(mergedVarNames, metric)); + + % Paired difference: stimulus 1 minus stimulus 2 + d = merged.(metricCols{1}) - merged.(metricCols{2}); + + % Animal identity (constant within an insertion) + animal = merged.animal(1); + + % Append to accumulators + nPaired = numel(d); + diffs = [diffs; d]; %#ok + insers = [insers; double(repmat(ins, nPaired, 1))]; %#ok + animals = [animals; double(repmat(animal, nPaired, 1))]; %#ok + end + + % Hierarchical bootstrap: resample animals -> insertions -> neurons + %bootMeans = hierBoot(diffs, nBoot, insers, animals); + bootMeans = hierBootMatchFreq(diffs, nBoot, animals ,insers); + + + % Two-tailed p-value: probability that |bootstrap mean| >= |observed| + pLeft = mean(bootMeans <= 0); % fraction of bootstrap means <= 0 + pRight = mean(bootMeans >= 0); % fraction of bootstrap means >= 0 + pVal = 2 * min(pLeft, pRight); % double the smaller tail + pVal = min(pVal, 1); % cap at 1 +end + + +function fig = plotPairScatter(tbl, compLabels, metric, cmap, animalIdx, labelMap) +% plotPairScatter Scatter plot: first comparison item (x) vs second (y). +% Points coloured by animal identity. + + fig = figure; + + % Separate data for each stimulus + mask1 = tbl.stimulus == compLabels{1}; + mask2 = tbl.stimulus == compLabels{2}; + v1 = tbl.(metric)(mask1); % x-axis values + v2 = tbl.(metric)(mask2); % y-axis values + cIdx = animalIdx(mask1); % colour index per point + + % Scatter with animal-coloured markers + scatter(v1, v2, 7, cmap(cIdx,:), 'filled', 'MarkerFaceAlpha', 0.3); + hold on; axis equal; + + % Unity line (equal-response reference) + lims = [min(tbl.(metric)), max(tbl.(metric))]; + plot(lims, lims, 'k--', 'LineWidth', 1.5); + xlim(lims); ylim(lims); + + % Apply display-name substitutions for axis labels + xLab = compLabels{1}; yLab = compLabels{2}; + for li = 1:size(labelMap, 1) + xLab = strrep(xLab, labelMap{li,1}, labelMap{li,2}); + yLab = strrep(yLab, labelMap{li,1}, labelMap{li,2}); + end + xlabel(xLab); ylabel(yLab); + colormap(fig, cmap); + + % Consistent font formatting and figure size + formatAxes(gca, 8, 'helvetica'); + set(fig, 'Units', 'centimeters', 'Position', [20 20 5 5]); +end + + +function formatAxes(ax, fontSize, fontName) +% formatAxes Apply consistent font styling to an axes object. + ax.YAxis.FontSize = fontSize; ax.YAxis.FontName = fontName; + ax.XAxis.FontSize = fontSize; ax.XAxis.FontName = fontName; +end + + +function pAdj = bhFDR(pVals) +% bhFDR Benjamini-Hochberg FDR correction. +% Adjusts a vector of p-values to control the false discovery rate. + + n = numel(pVals); % number of tests + [pSorted, sortIdx] = sort(pVals(:)); % sort ascending + ranks = (1:n)'; % rank of each sorted p-value + + pAdj = pSorted .* n ./ ranks; % BH adjustment: p * n / rank + pAdj = min(pAdj, 1); % cap at 1 + + % Enforce monotonicity: each adjusted p must be <= the one above it + for k = n-1:-1:1 + pAdj(k) = min(pAdj(k), pAdj(k+1)); + end + + pAdj(sortIdx) = pAdj; % restore original order +end \ No newline at end of file diff --git a/visualStimulationAnalysis/AllExpAnalysis.m b/visualStimulationAnalysis/AllExpAnalysis.m new file mode 100644 index 0000000..1a30c5a --- /dev/null +++ b/visualStimulationAnalysis/AllExpAnalysis.m @@ -0,0 +1,1626 @@ +function [tempTable] = AllExpAnalysis(expList, params) +% AllExpAnalysis Pool neural responses across Neuropixels recordings, +% run pairwise statistical comparisons via hierarchical bootstrapping, +% and generate publication-ready swarm and scatter plots. +% +% Supports three modes: +% +% MODE 1 — ACROSS-STIMULUS: +% ComparePairs = {'SDGm','SDGs'} compares z-scores/spike rates between +% different stimulus types. Neurons significant for ANY stimulus in the +% set are included (OR union). +% +% MODE 2 — WITHIN-STIMULUS CATEGORY (all levels): +% ComparePairs = {'MB'}, CompareCategory = "size" compares all levels of +% a category within a single stimulus. Uses StatisticsPerNeuronPerCategory +% for per-level statistics. Recordings without >=2 levels are skipped +% (tries Session=1 then Session=2). +% +% MODE 3 — SPECIFIC-LEVEL ACROSS-STIMULUS: +% ComparePairs = {'MB','SDGm'} +% CompareCategory = {'direction','direction'} +% CompareLevels = {[0],[0]} +% Compares specific level(s) of (possibly different) categories across +% different stimuli. Each stimulus has its own category and level list. +% For a given stimulus, all requested levels must exist in a single +% session of that stimulus, otherwise the experiment is skipped. +% +% INPUTS +% expList (1,:) double Row vector of experiment IDs from master Excel. +% params Name-value See arguments block below. +% +% OUTPUTS +% tempTable table Fraction-responsive table (one row per insertion x +% item), filtered to insertions containing all +% compared items. +% +% EXAMPLES +% % Mode 1 +% t = AllExpAnalysis([49:54 64:66], ComparePairs = {'SDGm','SDGs'}); +% +% % Mode 2 +% t = AllExpAnalysis([49:54 64:66], ... +% ComparePairs = {'MB'}, CompareCategory = "size"); +% +% % Mode 3 +% t = AllExpAnalysis([49:54 64:66], ... +% ComparePairs = {'MB','SDGm'}, ... +% CompareCategory = {'direction','direction'}, ... +% CompareLevels = {[0],[0]}); +% +% See also: hierBoot, , +% StatisticsPerNeuronPerCategory + +% ========================================================================= +% ARGUMENTS BLOCK +% ========================================================================= +arguments + expList (1,:) double % Row vector of experiment IDs from master Excel + + % --- Mode selection --- + params.ComparePairs cell % Stimuli to compare (cell of char/string) + params.CompareCategory = "" % "" -> mode 1; string -> mode 2; cell -> mode 3 + params.CompareLevels cell = {} % Cell of numeric vectors (non-empty -> mode 3) + + % --- Responsiveness filter --- + params.useGeneralFilter logical = false % Use general per-neuron p-values instead of per-level p + params.threshold double = 0.05 % p-value cutoff for the responsiveness OR-mask + + % --- ResponseWindow parameters --- + params.RespDurationWin double = 100 % Duration window (ms) for ResponseWindow computation + params.overwriteResponse logical = false % Force recomputation of ResponseWindow + + % --- StatisticsPerNeuron parameters (maxPermuteTest) --- + params.BaseRespWindow double = 100 % Base response window (ms) for statistics computation + params.SpatialGridMode logical = false % When true, forces BaseRespWindow = 200 ms + params.maxCategory logical = false % Use max-category mode in StatisticsPerNeuron + params.applyFDR logical = false % Apply FDR correction inside the statistics functions + params.overwriteStats logical = false % Force recomputation of statistics + params.CategoryMaximized = '' %Category to be maximized along levels of category to comapre + % --- Bootstrap parameters --- + params.nBoot double = 10000 % Iterations for pairwise hierarchical bootstrap + params.nBootCategory double = 10000 % Iterations for per-category bootstrap + + % --- Data extraction --- + params.useZmean logical = true % true = use z_mean field; false = peak spike rate + + % --- Post-hoc correction (applied AFTER extraction, distinct from applyFDR) --- + params.useFDR logical = false % Benjamini-Hochberg FDR on extracted p-values + + params.markUnits cell = {} % N×4 {NeurID,stimulus,animal,insertion}; row1->X, row2->+ + + % --- Output --- + params.overwrite logical = false % Force rerun of the entire per-experiment loop + params.PaperFig logical = false % Save publication-quality figures via printFig +end + +% ========================================================================= +% SECTION 1 — DETECT MODE, VALIDATE, AND APPLY GLOBAL OVERRIDES +% ========================================================================= + +% --- SpatialGridMode override --- +% In SpatialGridMode the analysis window is fixed at 200 ms. Apply the +% override here so that every downstream call sees the corrected value. +if params.SpatialGridMode + params.BaseRespWindow = 200; % override user-supplied value +end + +% --- Detect operating mode from parameter combinations --- +if ~isempty(params.CompareLevels) || iscell(params.CompareCategory) + % MODE 3: specific category levels compared across stimuli + mode = 3; + + % CompareCategory must be a cell or string array with one entry per stimulus + assert(iscell(params.CompareCategory) || isstring(params.CompareCategory), ... + 'Mode 3: CompareCategory must be a cell or string array, one per stimulus.'); + assert(numel(params.CompareCategory) == numel(params.ComparePairs), ... + 'Mode 3: CompareCategory must have same length as ComparePairs.'); + % Empty CompareLevels → auto-discover all levels per stimulus + if isempty(params.CompareLevels) + params.CompareLevels = repmat({[]}, 1, numel(params.ComparePairs)); + end + assert(numel(params.CompareLevels) == numel(params.ComparePairs), ... + 'Mode 3: CompareLevels must have same length as ComparePairs.'); + + + + % Normalise CompareCategory to a cell of char arrays for uniform handling + catList = cell(1, numel(params.CompareCategory)); + for i = 1:numel(params.CompareCategory) + if iscell(params.CompareCategory) + catList{i} = char(strtrim(params.CompareCategory{i})); % cell input + else + catList{i} = char(strtrim(params.CompareCategory(i))); % string array input + end + end + fprintf('=== Mode 3: specific-level cross-stimulus comparison ===\n'); + +elseif (ischar(params.CompareCategory) || isstring(params.CompareCategory)) && ... + strtrim(string(params.CompareCategory)) ~= "" + % MODE 2: all levels of one category within a single stimulus + mode = 2; + + assert(numel(params.ComparePairs) == 1, ... + 'Mode 2: requires exactly one stimulus in ComparePairs.'); + + stimName = params.ComparePairs{1}; % the single stimulus being decomposed + catName = char(strtrim(string(params.CompareCategory))); % category column name + fprintf('=== Mode 2: within-stimulus category "%s" in %s ===\n', catName, stimName); + +else + % MODE 1: direct across-stimulus comparison + mode = 1; + + assert(numel(params.ComparePairs) >= 2, ... + 'Mode 1: requires >=2 stimuli in ComparePairs.'); +end + +% Boolean shortcuts used throughout the function +isCategoryMode = (mode == 2); % within-stimulus category comparison +isSpecificLevelMode = (mode == 3); % specific (stim, cat, level) tuples + +% Unique stimulus names that need to be loaded (deduplicated, preserving order) +stimsNeeded = unique(params.ComparePairs, 'stable'); + +% ========================================================================= +% SECTION 2 — DIRECTORY SETUP AND CACHE MANAGEMENT +% ========================================================================= + +% Load the first experiment to extract directory paths for saving +NP0 = loadNPclassFromTable(expList(end)); +vs0 = linearlyMovingBallAnalysis(NP0); + +% Build the root path and combined-analysis save directory +rootPath = extractBefore(vs0.dataObj.recordingDir, 'lizards'); % path up to 'lizards' +rootPath = [rootPath 'lizards']; % include 'lizards' folder +saveDir = fullfile(rootPath, 'Combined_lizard_analysis'); % pooled data directory +if ~exist(saveDir, 'dir') + mkdir(saveDir); % create if it does not exist +end + +% Construct a descriptive filename for the cached pooled data. +% Encoding all comparison parameters in the filename prevents accidental +% cache collisions when the same experiment list is analysed differently. +switch mode + case 1 + % Mode 1: stimulus names joined by hyphens + nameOfFile = sprintf('Ex_%d-%d_Combined_%s.mat', ... + expList(1), expList(end), strjoin(stimsNeeded, '-')); + case 2 + % Mode 2: stimulus + category name + nameOfFile = sprintf('Ex_%d-%d_Combined_%s_%s.mat', ... + expList(1), expList(end), stimName, lower(catName)); + case 3 + % Mode 3: each (stim, cat, levels) tuple encoded + parts = cell(1, numel(stimsNeeded)); + for si = 1:numel(stimsNeeded) + if isempty(catList{si}) + parts{si} = stimsNeeded{si}; + elseif isempty(params.CompareLevels{si}) + parts{si} = sprintf('%s-%s-all', stimsNeeded{si}, catList{si}); + else + lvStr = strjoin(arrayfun(@(v) sprintf('%g', v), ... + params.CompareLevels{si}, 'UniformOutput', false), '_'); + parts{si} = sprintf('%s-%s-%s', stimsNeeded{si}, catList{si}, lvStr); + end + end + nameOfFile = sprintf('Ex_%d-%d_SpecLvl_%s.mat', ... + expList(1), expList(end), strjoin(parts, '__')); +end +savePath = fullfile(saveDir, nameOfFile); % full path for the cache .mat + +% Decide whether the per-experiment loop needs to run. +% Skip if the cache exists, matches the experiment list, and overwrite is off. +runLoop = true; +if exist(savePath, 'file') == 2 && ~params.overwrite + S = load(savePath); % load cached struct + if isfield(S, 'expList') && isequal(S.expList, expList) + runLoop = false; % cache is valid — skip the loop + end +end + +% ========================================================================= +% SECTION 3 — INITIALISE LONG-FORMAT TABLES +% ========================================================================= + +% TableStimComp: one row per (neuron x stimulus). Holds z-scores and spike +% rates for every neuron that passes the responsiveness filter. +TableStimComp = table( ... + categorical.empty(0,1), categorical.empty(0,1), categorical.empty(0,1), ... + categorical.empty(0,1), categorical.empty(0,1), double.empty(0,1), double.empty(0,1), ... + 'VariableNames', {'animal','insertion','realInsertion','stimulus','NeurID','Z-score','SpkR'}); + +% TableRespNeurs: one row per (insertion x stimulus). Counts of responsive +% neurons and total somatic neurons for fraction-responsive analysis. +TableRespNeurs = table( ... + categorical.empty(0,1), categorical.empty(0,1), categorical.empty(0,1), ... + double.empty(0,1), double.empty(0,1), ... + 'VariableNames', {'animal','insertion','stimulus','respNeur','totalSomaticN'}); + +% In mode 2, level labels are determined from the first valid recording. +% In mode 3, comparison labels are fixed by parameters from the start. +levelLabels = {}; % mode 2: populated on first valid recording +fixedCompLabels = {}; % mode 3: canonical labels built from parameters + +if isSpecificLevelMode + allSpecified = all(cellfun(@(x) ~isempty(x), params.CompareLevels)) && ... + all(cellfun(@(x) ~isempty(x), catList)); + if allSpecified + [fixedCompLabels, ~] = buildMode3Items(stimsNeeded, catList, params.CompareLevels); + end +end + +% ========================================================================= +% SECTION 4 — PER-EXPERIMENT LOOP +% ========================================================================= + +if runLoop + + % --- BUG FIX (was Bug #4): Map-based counters --- + % Using containers.Map guarantees the same animal/insertion always + % receives the same numeric index, regardless of expList ordering. + % The previous sequential-counter approach silently assigned different + % IDs if the same animal appeared non-contiguously in expList. + animalMap = containers.Map('KeyType','char','ValueType','double'); + insertionMap = containers.Map('KeyType','char','ValueType','double'); + nextAnimalIdx = 0; % running counter for unique animals + nextInsertionIdx = 0; % running counter for unique (animal, insertion) pairs + + for ex = expList + + % ---- 4a: Load recording and check stimulus availability ---- + + NP = loadNPclassFromTable(ex); % load Neuropixels recording object + fprintf('Processing recording: %s\n', NP.recordingName); + + % Load one analysis object per unique stimulus class + [vsObjs, present] = loadStimulusObjects(NP, stimsNeeded); + + % Check that every required stimulus is present in this recording + allPresent = true; + for si = 1:numel(stimsNeeded) + if ~present(stimsNeeded{si}) + allPresent = false; break % at least one missing — skip + end + end + if ~allPresent + fprintf(' -> Skipping: stimulus not present.\n'); + continue + end + + % ---- 4b: Mode-specific session selection ---- + + if isCategoryMode + % Mode 2: find session of stimName with >=2 levels of catName + key = getObjKey(stimName); % shared object key (e.g. 'SDG') + vsObj = vsObjs(key); % retrieve the analysis object + + % Search sessions for >=2 category levels + [levels, ~, vsObj] = findCategoryLevels(vsObj, NP, stimName, catName, params); + + if numel(levels) < 2 + fprintf(' -> Skipping: <2 levels for "%s".\n', catName); + continue % not enough levels for comparison + end + + vsObjs(key) = vsObj; % store back (may have changed session) + + % Convert numeric levels to field-name labels (e.g. 'size_5') + currentLabels = arrayfun(@(v) levelToFieldName(catName, v), ... + levels, 'UniformOutput', false); + + % Lock the level set on the first valid recording; skip any + % subsequent recordings with a different level set. + if isempty(levelLabels) + levelLabels = currentLabels; + fprintf(' Category levels locked: %s\n', strjoin(levelLabels, ', ')); + else + if ~isequal(sort(currentLabels), sort(levelLabels)) + fprintf(' -> Skipping: level mismatch (expected %s, got %s).\n', ... + strjoin(levelLabels,','), strjoin(currentLabels,',')); + continue + end + end + + elseif isSpecificLevelMode + sessionFound = true; + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + cat = catList{si}; + lvls = params.CompareLevels{si}; + key = getObjKey(sn); + + if isempty(cat) + % No category — general stats will be used in 4d + continue + + elseif isempty(lvls) + % Category specified but no levels → auto-discover all + vsObj = vsObjs(key); + [discoveredLvls, ~, vsObj] = findCategoryLevels( ... + vsObj, NP, sn, cat, params); + if numel(discoveredLvls) < 2 + fprintf(' -> Skipping: <2 levels for "%s" in %s.\n', cat, sn); + sessionFound = false; break + end + params.CompareLevels{si} = discoveredLvls(:)'; + vsObjs(key) = vsObj; + + else + % Specific levels → find session containing all of them + [vsObj, allFound] = findSessionWithLevels( ... + NP, sn, cat, lvls, params); + if ~allFound + fprintf(' -> Skipping: %s levels [%s] of "%s" not found.\n', ... + sn, num2str(lvls(:)','%g '), cat); + sessionFound = false; break + end + vsObjs(key) = vsObj; + end + end + if ~sessionFound, continue; end + + % Build labels from (possibly auto-discovered) levels + [currentLabels, ~] = buildMode3Items(stimsNeeded, catList, params.CompareLevels); + if isempty(fixedCompLabels) + fixedCompLabels = currentLabels; + fprintf(' Labels locked: %s\n', strjoin(fixedCompLabels, ', ')); + end + end + + % ---- 4c: Parse metadata and assign animal/insertion indices ---- + % + % BUG FIX (was Bug #4): Uses map-based lookup instead of sequential + % counters. Safe for any ordering of expList. + + % Extract animal ID from recording name (e.g. 'PV123' or 'SA45') + animalID = string(regexp(NP.recordingName, 'PV\d+', 'match', 'once')); + if animalID == "" + animalID = string(regexp(NP.recordingName, 'SA\d+', 'match', 'once')); + end + + % Extract insertion number from the directory path + insStr = regexp(NP.recordingDir, 'Insertion\d+', 'match', 'once'); + insNum = str2double(regexp(insStr, '\d+', 'match')); + + % Real (recording) insertion number = trailing token of the recording name, + % e.g. 'PV103_Experiment_12_6_23_1' -> 1. This is the human-facing insertion + % number used downstream to mark example units; robust to dropped insertions + % because it is the actual recording label, not a sequential/offset index. + recName = NP.recordingName; % == vsObj.dataObj.recordingName + nameTok = strsplit(recName, '_'); % {'PV103','Experiment','12','6','23','1'} + realInsertionID = str2double(nameTok{end}); % trailing token is the insertion number + assert(~isnan(realInsertionID), ... + 'recordingName does not end in a numeric insertion token: %s', recName); + + % Register animal in the map (assigns a new index only on first encounter) + animalKey = char(animalID); + if ~animalMap.isKey(animalKey) + nextAnimalIdx = nextAnimalIdx + 1; + animalMap(animalKey) = nextAnimalIdx; + end + + % Register the (animal, insertion) pair in the map + insKey = sprintf('%s__Ins%d', animalKey, insNum); + if ~insertionMap.isKey(insKey) + nextInsertionIdx = nextInsertionIdx + 1; + insertionMap(insKey) = nextInsertionIdx; + end + insertionCount = insertionMap(insKey); % stable numeric index for this insertion + + % ---- 4d: Run statistics and extract per-item data ---- + + stimData = struct(); % per-item z-scores, p-values, spike rates + nUnits = []; % total somatic neuron count (set once) + compLabels = {}; % labels for items being compared + generalPbyStim = struct(); % general per-neuron p-values (for useGeneralFilter) + + if isCategoryMode + % ---- Mode 2: single stimulus, all levels of one category ---- + + key = getObjKey(stimName); + vsObj = vsObjs(key); + + % Run general per-neuron statistics (StatisticsPerNeuron) + runStimStats(vsObj, params); + vsObjs(key) = vsObj; % store back (handle class — redundant but safe) + + % Extract general p-values (for optional useGeneralFilter) + [~, generalP, ~, ~] = extractStimData(vsObj, stimName, params.useZmean); + generalPbyStim.(stimName) = generalP; + + % Auto-detect GratingType from stimulus abbreviation + gratingType = detectGratingType(stimName); + + % Build name-value arguments for StatisticsPerNeuronPerCategory + catStatsArgs = { ... + 'compareCategory', catName, ... + 'nBoot', params.nBootCategory, ... + 'overwrite', params.overwriteStats, ... + 'BaseRespWindow', params.BaseRespWindow, ... + 'applyFDR', params.applyFDR,... + 'CategoryMaximized' params.CategoryMaximized}; + if ~isempty(gratingType) + % Only pass GratingType for grating stimuli (SDGm/SDGs) + catStatsArgs = [catStatsArgs, {'GratingType', gratingType}]; + end + + % Run per-category statistics + catStats = vsObj.StatisticsPerNeuronPerCategory(catStatsArgs{:}); + + % Extract per-level data from catStats + for li = 1:numel(levelLabels) + fName = levelLabels{li}; % field name in catStats + stimData.(fName).z = catStats.(fName).ZScoreU(:); % z-score + stimData.(fName).p = catStats.(fName).pvalsResponse(:); % p-value + stimData.(fName).spkR = catStats.(fName).ObsStat(:); % spike rate / z_mean + if isempty(nUnits), nUnits = numel(stimData.(fName).z); end + end + compLabels = levelLabels; % items to compare = level labels + + elseif isSpecificLevelMode + % ---- Mode 3: each stimulus contributes one or more (stim, cat, level) items ---- + + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; % stimulus name + cat = catList{si}; % category for this stimulus + lvls = params.CompareLevels{si}; % requested levels + key = getObjKey(sn); + vsObj = vsObjs(key); + + % Run general per-neuron statistics + runStimStats(vsObj, params); + + if isempty(cat) + runStimStats(vsObj, params); + vsObjs(key) = vsObj; + [z, p, spkR, ~] = extractStimData(vsObj, sn, params.useZmean); + generalPbyStim.(sn) = p; + stimData.(sn).z = z(:); + stimData.(sn).p = p(:); + stimData.(sn).spkR = spkR(:); + compLabels{end+1} = sn; %#ok + if isempty(nUnits), nUnits = numel(z); end + continue + end + + vsObjs(key) = vsObj; + + % Extract general p-values (for optional useGeneralFilter) + [~, generalP, ~, ~] = extractStimData(vsObj, sn, params.useZmean); + generalPbyStim.(sn) = generalP; + + % Auto-detect GratingType from stimulus abbreviation + gratingType = detectGratingType(sn); + + % Build name-value arguments for StatisticsPerNeuronPerCategory + catStatsArgs = { ... + 'compareCategory', cat, ... + 'nBoot', params.nBootCategory, ... + 'overwrite', params.overwriteStats, ... + 'BaseRespWindow', params.BaseRespWindow, ... + 'applyFDR', params.applyFDR}; + if ~isempty(gratingType) + catStatsArgs = [catStatsArgs, {'GratingType', gratingType}]; + end + + % Run per-category statistics for this stimulus + catStats = vsObj.StatisticsPerNeuronPerCategory(catStatsArgs{:}); + + % Fields corresponding to levels + allFields = fieldnames(catStats); + + % Keep only fields starting with category name + levelFields = allFields(startsWith(lower(allFields), lower(cat) + "_")); + + % Numeric levels stored in struct + storedLvls = catStats.categoryLevels(:); + + % Extract data for each requested level + for lvi = 1:numel(lvls) + lv = lvls(lvi); % numeric level value + % Find closest matching stored level + [~, idx] = min(abs(storedLvls - lv)); + + % Corresponding field name + fName = levelFields{idx}; + cLabel = makeCompLabel(sn, cat, lv); % short composite label + + stimData.(cLabel).z = catStats.(fName).ZScoreU(:); + stimData.(cLabel).p = catStats.(fName).pvalsResponse(:); + stimData.(cLabel).spkR = catStats.(fName).ObsStat(:); + + compLabels{end+1} = cLabel; %#ok + if isempty(nUnits), nUnits = numel(stimData.(cLabel).z); end + end + end + + % Verify labels match (skip check if labels were just locked above) + if ~isempty(fixedCompLabels) && ~isequal(sort(compLabels(:)), sort(fixedCompLabels(:))) + fprintf(' -> Skipping: comparison label mismatch.\n'); + continue + end + + else + % ---- Mode 1: across-stimulus (one item per stimulus) ---- + + % Run general statistics for every loaded stimulus object + objKeys = keys(vsObjs); + for k = 1:numel(objKeys) + key = objKeys{k}; + vsObj = vsObjs(key); + runStimStats(vsObj, params); + vsObjs(key) = vsObj; + end + + % Extract z-scores, p-values, spike rates for each stimulus + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + key = getObjKey(sn); + [z, p, spkR, ~] = extractStimData(vsObjs(key), sn, params.useZmean); + stimData.(sn).z = z(:); + stimData.(sn).p = p(:); + stimData.(sn).spkR = spkR(:); + generalPbyStim.(sn) = p(:); % general p = per-stimulus p in mode 1 + if isempty(nUnits), nUnits = numel(z); end + end + compLabels = stimsNeeded; % items to compare = stimulus names + end + + % ---- 4e: Optional post-hoc FDR correction ---- + % NOTE: This is the AllExpAnalysis-level FDR (Benjamini-Hochberg on + % the extracted p-values). It is DISTINCT from params.applyFDR, + % which is applied inside the statistics functions themselves. + if params.useFDR + for ci = 1:numel(compLabels) + cl = compLabels{ci}; + stimData.(cl).p = bhFDR(stimData.(cl).p); % correct per-item p-values + end + end + + % ---- 4f: Significance mask ---- + % Build a logical OR mask: a neuron passes if it is significant for + % at least one comparison item. + + if params.useGeneralFilter && (isCategoryMode || isSpecificLevelMode) + % Use general per-stimulus p-values (StatisticsPerNeuron), OR'd + % across stimuli. This avoids filtering on the same per-level + % p-values used for comparison. + orMask = false(nUnits, 1); + stimNames_ = fieldnames(generalPbyStim); + for si = 1:numel(stimNames_) + gp = generalPbyStim.(stimNames_{si}); + if params.useFDR, gp = bhFDR(gp); end % apply FDR if requested + orMask = orMask | (gp < params.threshold); + end + else + % Default: OR across all per-item p-values + orMask = false(nUnits, 1); + for ci = 1:numel(compLabels) + cl = compLabels{ci}; + orMask = orMask | (stimData.(cl).p < params.threshold); + end + end + + unitIDs = find(orMask); % indices of neurons passing the filter + nSig = numel(unitIDs); % count of significant neurons + + % ---- 4g: Append to TableStimComp ---- + % Add one block of rows per comparison item (only significant neurons). + if nSig > 0 + for ci = 1:numel(compLabels) + cl = compLabels{ci}; + newRows = table( ... + repmat(categorical(cellstr(animalID)), nSig, 1), ... % animal column + repmat(categorical(insertionCount), nSig, 1), ... % insertion column + repmat(categorical(realInsertionID), nSig, 1), ... %Real Insertion ID + repmat(categorical(cellstr(cl)), nSig, 1), ... % stimulus/item column + categorical(unitIDs), ... % neuron ID column + stimData.(cl).z(orMask), ... % z-score column + stimData.(cl).spkR(orMask), ... % spike rate column + 'VariableNames', {'animal','insertion','realInsertion','stimulus','NeurID','Z-score','SpkR'}); + TableStimComp = [TableStimComp; newRows]; %#ok + end + end + + % ---- 4h: Append to TableRespNeurs ---- + % One summary row per (insertion x comparison item): responsive + % neuron count and total somatic neuron count. + for ci = 1:numel(compLabels) + cl = compLabels{ci}; + nResp = sum(stimData.(cl).p < params.threshold); % neurons below threshold + newRow = table( ... + categorical(cellstr(animalID)), ... % animal + categorical(insertionCount), ... % insertion + categorical(cellstr(cl)), ... % stimulus/item + nResp, nUnits, ... % responsive count, total count + 'VariableNames', TableRespNeurs.Properties.VariableNames); + TableRespNeurs = [TableRespNeurs; newRow]; %#ok + end + + fprintf(' -> %d / %d units pass filter.\n', nSig, nUnits); + + end % end for ex + + % ========================================================================= + % SECTION 5 — SAVE POOLED DATA + % ========================================================================= + + % Describe the analysis mode in plain text (for figure annotation) + switch mode + case 1, modeDesc = 'across-stimulus'; + case 2, modeDesc = 'within-stimulus-category'; + case 3, modeDesc = 'specific-level-cross-stim'; + end + + % Build a metadata sub-struct capturing every parameter needed to + % reproduce the analysis. This travels with the pooled data AND with + % saved figures, so any figure can be traced to its configuration. + analysisMetadata = struct( ... + 'mode', mode, ... % numeric mode (1/2/3) + 'modeDescription', modeDesc, ... % human-readable label + 'ComparePairs', {params.ComparePairs}, ... % stimuli being compared + 'RespDurationWin', params.RespDurationWin, ... % ResponseWindow duration (ms) + 'BaseRespWindow', params.BaseRespWindow, ... % statistics base window (ms) + 'SpatialGridMode', params.SpatialGridMode, ... % whether grid mode is active + 'maxCategory', params.maxCategory, ... % max-category flag for StatisticsPerNeuron + 'applyFDR', params.applyFDR, ... % FDR inside statistics functions + 'useFDR', params.useFDR, ... % post-hoc BH FDR in AllExpAnalysis + 'threshold', params.threshold, ... % responsiveness p-value cutoff + 'useZmean', params.useZmean, ... % z_mean vs peak spike rate + 'useGeneralFilter', params.useGeneralFilter, ...% general vs per-item filter + 'nBoot', params.nBoot, ... % bootstrap iterations (pairwise) + 'nBootCategory', params.nBootCategory); % bootstrap iterations (category) + + % Add mode-specific fields + if isCategoryMode + analysisMetadata.stimName = stimName; % the decomposed stimulus + analysisMetadata.catName = catName; % category column name + analysisMetadata.levelLabels = levelLabels; % resolved level labels + end + if isSpecificLevelMode + analysisMetadata.catList = catList; + analysisMetadata.CompareLevels = params.CompareLevels; + analysisMetadata.fixedCompLabels = fixedCompLabels; + end + + % Pack into save struct + S.expList = expList; + S.TableStimComp = TableStimComp; + S.TableRespNeurs = TableRespNeurs; + S.params = params; % full params (superset of metadata) + S.mode = mode; + S.analysisMetadata = analysisMetadata; % curated subset for figure annotation + if isCategoryMode, S.levelLabels = levelLabels; end + if isSpecificLevelMode, S.fixedCompLabels = fixedCompLabels; end + + % Write to disk + save(savePath, '-struct', 'S'); + fprintf('Saved pooled data to %s\n', savePath); +end + +% ========================================================================= +% SECTION 5b — RESTORE VARIABLES FROM CACHE +% ========================================================================= +% BUG FIX (was Bug #6): When runLoop is false, S was loaded from disk but +% mode-dependent local variables were never set, causing downstream errors. + +if ~runLoop + mode = S.mode; % restore numeric mode + isCategoryMode = (mode == 2); + isSpecificLevelMode = (mode == 3); + + if isCategoryMode && isfield(S, 'levelLabels') + levelLabels = S.levelLabels; % restore level labels + end + if isSpecificLevelMode && isfield(S, 'fixedCompLabels') + fixedCompLabels = S.fixedCompLabels; % restore comparison labels + end + + fprintf('Loaded pooled data from cache: %s\n', savePath); +end + +% ========================================================================= +% SECTION 6 — GUARD: ABORT IF NO DATA +% ========================================================================= + +if isempty(S.TableStimComp) || height(S.TableStimComp) == 0 + warning('AllExpAnalysis:noUnits', 'No significant units found. Returning empty.'); + tempTable = table(); + return +end + +% Replace NaN z-scores and spike rates with zero (prevents plotting issues) +S.TableStimComp.('Z-score')(isnan(S.TableStimComp.('Z-score'))) = 0; +S.TableStimComp.SpkR(isnan(S.TableStimComp.SpkR)) = 0; + +% Defensive: ensure animal/insertion/stimulus are string-based categoricals. +% Prevents numeric-named categoricals from being silently converted to +% double inside plotSwarmBootstrapWithComparisons. +S.TableStimComp.animal = categorical(cellstr(string(S.TableStimComp.animal))); +S.TableStimComp.insertion = categorical(cellstr(string(S.TableStimComp.insertion))); +S.TableStimComp.stimulus = categorical(cellstr(string(S.TableStimComp.stimulus))); + +% ========================================================================= +% SECTION 7 — SHARED PLOTTING SETUP +% ========================================================================= + +% Create a fresh analysis object for the first experiment (used for printFig) +NP = loadNPclassFromTable(expList(end)); +vs = linearlyMovingBallAnalysis(NP, 'MultipleOffsets', false, 'Multiplesizes', false); + +[~, figTag, ~] = fileparts(nameOfFile); % e.g. 'Ex_49-97_SpecLvl_SDGm-angles-0_1p57...' +% Build a consistent colour map: one colour per animal, shared across all plots +animalOrder = categories(S.TableStimComp.animal); % sorted unique animal names +nAnimals = numel(animalOrder); % number of animals +sharedCmap = lines(nAnimals); % Nx3 colour matrix +animalIdxAll = double(S.TableStimComp.animal); % per-row animal index (for scatter colouring) + +if (mode == 3 || mode == 2) && ... + (any(contains(string(S.TableStimComp.stimulus), "SDG")) && any(contains(string(S.TableStimComp.stimulus), "ang"))) + + stimStr = string(S.TableStimComp.stimulus); + + % Rows containing "SDG" + idxSDG = contains(stimStr, "SDG"); + + % Replace angle values only in SDG rows + stimStr(idxSDG) = replace(stimStr(idxSDG), ... + ["0", "90", "180", "270"], ... + ["1.57","0", "4.71","3.14"]); + + % Convert back to categorical + S.TableStimComp.stimulus = categorical(stimStr); + +end + + +% All pairwise combinations of comparison items +compLabels = cellstr(categories(S.TableStimComp.stimulus)); % unique sorted item labels +pairsAll = nchoosek(compLabels, 2); % Kx2 cell of pairs + +% Display-name substitutions for axis labels +labelMap = {'RG','SB'; 'SDGs','SG'; 'SDGm','MG'}; + +% ========================================================================= +% SECTION 8 — Z-SCORE PAIRWISE COMPARISON +% ========================================================================= + +% Compute a hierarchical-bootstrap p-value for each pair (z-score metric) +pValsZ = zeros(1, size(pairsAll, 1)); +for pi = 1:size(pairsAll, 1) + pValsZ(pi) = bootstrapPairDifference( ... + S.TableStimComp, pairsAll(pi,:), params.nBoot, 'Z-score'); +end + +% Upper y-limit: ceiling of max z-score plus headroom for significance brackets +ZscoreYlim = ceil(max(S.TableStimComp.('Z-score'))) +0.1*ceil(max(S.TableStimComp.('Z-score'))); + +% Generate swarm + bootstrap plot for z-scores +[fig,~,figAllDiffs] = plotSwarmBootstrapWithComparisons( ... + S.TableStimComp, pairsAll, pValsZ, {'Z-score'}, ... + yLegend = 'Z-score', yMaxVis = ZscoreYlim, ... + diff = true, plotMeanSem = true, Alpha = 0.7, markUnits = params.markUnits) ; % N×4 {NeurID,stimulus,animal,insertion}; row1->X, row2->+); + +% Apply consistent font formatting to all axes in the figure +ax = findobj(fig, 'Type', 'axes'); +for i = 1:numel(ax) + formatAxes(ax(i), 8, 'helvetica'); +end + +% Set figure size and colour map +figure(fig); +set(fig, 'Units', 'centimeters', 'Position', [20 20 10 6]); +colormap(fig, sharedCmap); + +%set(gca,'Clipping','off') +pause(2) +% Save figure if PaperFig mode is active +if params.PaperFig + vs.printFig(fig, sprintf('Zscore-Swarm-%s', figTag), ... + PaperFig = params.PaperFig); +end + +ax = findobj(figAllDiffs, 'Type', 'axes'); +for i = 1:numel(ax) + formatAxes(ax(i), 8, 'helvetica'); +end + +if ~isempty(figAllDiffs) + figure(figAllDiffs); + pause(2) + % Save figure if PaperFig mode is active + if params.PaperFig + vs.printFig(figAllDiffs, sprintf('AllDiff-Zscore-Swarm-%s', figTag), ... + PaperFig = params.PaperFig); + end +end + +groupcounts(S.TableStimComp, 'stimulus') % pooled rows per stimulus: are MB and MBR equal? +g = groupcounts(S.TableStimComp, {'insertion','stimulus'}); % rows per (insertion, stimulus) +unstack(g, 'GroupCount', 'stimulus') % one row per insertion; spot where MB ~= MBR +% For exactly two items, also produce a paired scatter plot +if numel(compLabels) == 2 + fig = plotPairScatter(S.TableStimComp, compLabels, ... + 'Z-score', sharedCmap, animalIdxAll, labelMap); + title('Z-score'); + if params.PaperFig + vs.printFig(fig, sprintf('Zscore-Scatter-%s', figTag), ... + PaperFig = params.PaperFig); + end +end + +% ========================================================================= +% SECTION 9 — SPIKE-RATE PAIRWISE COMPARISON +% ========================================================================= + +% Compute a hierarchical-bootstrap p-value for each pair (spike rate metric) +pValsSpk = zeros(1, size(pairsAll, 1)); +for pi = 1:size(pairsAll, 1) + pValsSpk(pi) = bootstrapPairDifference( ... + S.TableStimComp, pairsAll(pi,:), params.nBoot, 'SpkR'); +end + +% Upper y-limit for spike rate +spkMax = max(S.TableStimComp.SpkR) +0.1*max(S.TableStimComp.SpkR); + +% Generate swarm + bootstrap plot for spike rates +[fig,~,figAllDiffs] = plotSwarmBootstrapWithComparisons( ... + S.TableStimComp, pairsAll, pValsSpk, {'SpkR'}, ... + yLegend = 'SpkR', yMaxVis = spkMax, ... + diff = true, plotMeanSem = true, Alpha = 0.7, markUnits = params.markUnits); + +% Apply consistent font formatting +ax = findobj(fig, 'Type', 'axes'); +for i = 1:numel(ax) + formatAxes(ax(i), 8, 'helvetica'); +end + +% Set figure size and colour map +figure(fig); +set(fig, 'Units', 'centimeters', 'Position', [20 20 10 6]); +colormap(fig, sharedCmap); +pause(2) +% Save figure if PaperFig mode is active +if params.PaperFig + vs.printFig(fig, sprintf('SpkRate-Swarm-%s', figTag), ... + PaperFig = params.PaperFig); +end + +ax = findobj(figAllDiffs, 'Type', 'axes'); +for i = 1:numel(ax) + formatAxes(ax(i), 8, 'helvetica'); +end + +if ~isempty(figAllDiffs) + figure(figAllDiffs); + pause(2) + + % Save figure if PaperFig mode is active + if params.PaperFig + vs.printFig(figAllDiffs, sprintf('AllDiff-SpkRate-Swarm-%s', figTag), ... + PaperFig = params.PaperFig); + end +end + +% For exactly two items, produce a paired scatter plot +if numel(compLabels) == 2 + fig = plotPairScatter(S.TableStimComp, compLabels, ... + 'SpkR', sharedCmap, animalIdxAll, labelMap); + title('Spk. rate'); + if params.PaperFig + vs.printFig(fig, sprintf('SpkRate-Scatter-%s', figTag), ... + PaperFig = params.PaperFig); + end +end + +% ========================================================================= +% SECTION 10 — FRACTION-RESPONSIVE ANALYSIS +% ========================================================================= + +compLabels = cellstr(categories(S.TableRespNeurs.stimulus)); % unique sorted item labels + +% Find groups by insertion, then check which insertions contain ALL items +[G, ~] = findgroups(S.TableRespNeurs.insertion); +hasAll = splitapply( ... + @(s) all(ismember(categorical(compLabels), s)), ... + S.TableRespNeurs.stimulus, G); + +% Filter to only insertions that have data for every comparison item +tempTable = S.TableRespNeurs( ... + hasAll(G) & ismember(S.TableRespNeurs.stimulus, categorical(compLabels)), :); + +% --- BUG FIX (was Bug #3): Hierarchical bootstrap for fraction-responsive --- +% The previous flat bootstrp(@mean, diffs) ignored the nesting of insertions +% within animals. Using hierBoot is consistent with the mixed model. + +pairsAll = nchoosek(compLabels, 2); + +pValsFrac = zeros(1, size(pairsAll, 1)); +for pi = 1:size(pairsAll, 1) + + diffs = []; % per-insertion fraction differences + insLabels = []; % insertion indices for hierBoot (level 1) + animLabels = []; % animal indices for hierBoot (level 2) + + for ins = unique(S.TableRespNeurs.insertion)' + + % Find rows for this insertion and each stimulus in the pair + idx1 = S.TableRespNeurs.insertion == ins & ... + S.TableRespNeurs.stimulus == pairsAll{pi,1}; + idx2 = S.TableRespNeurs.insertion == ins & ... + S.TableRespNeurs.stimulus == pairsAll{pi,2}; + + % Both stimuli must be present for a valid paired comparison + if any(idx1) && any(idx2) + total = S.TableRespNeurs.totalSomaticN(idx1); % total neurons + f1 = S.TableRespNeurs.respNeur(idx1) / total; % fraction, stim 1 + f2 = S.TableRespNeurs.respNeur(idx2) / total; % fraction, stim 2 + d = f1 - f2; % paired difference + + animal = S.TableRespNeurs.animal(idx1); % animal for this insertion + + diffs = [diffs; d]; + insLabels = [insLabels; double(ins)]; + animLabels = [animLabels; double(animal)]; + end + end + + % Hierarchical bootstrap: resample animals -> insertions -> fractions + bootDiff = hierBootMatchFreq(diffs, params.nBoot, animLabels,insLabels); + + % Two-tailed p-value + pLeft = mean(bootDiff <= 0); + pRight = mean(bootDiff >= 0); + pValsFrac(pi) = min(2 * min(pLeft, pRight), 1); +end + +% Compute total responsive neurons per insertion (across stimuli) +[G, ~] = findgroups(tempTable.insertion); +totals = splitapply(@sum, tempTable.respNeur, G); +tempTable.TotalRespNeur = totals(G); + +% Generate swarm plot for fraction responsive +[fig,~,figAllDiffs] = plotSwarmBootstrapWithComparisons( ... + tempTable, pairsAll, pValsFrac, ... + {'respNeur','totalSomaticN'}, ... + fraction = true, showBothAndDiff = false, ... + yLegend = 'Responsive/total units', ... + diff = false, filled = false, Xjitter = 'none', ... + Alpha = 0.6, drawLines = true); + +set(gca, 'Clipping', 'off') + +% Count total unique responsive neurons across all (animal, insertion) pairs +totalResp = 0; +animals = unique(S.TableStimComp.animal); +for a = 1:numel(animals) + inser = unique(S.TableStimComp.insertion(S.TableStimComp.animal == animals(a))); + for in = 1:numel(inser) + totalResp = totalResp + length(unique( ... + S.TableStimComp.NeurID( ... + S.TableStimComp.insertion == inser(in) & ... + S.TableStimComp.animal == animals(a)))); + end +end + + +% --- Compute total somatic neurons across all unique insertions --- +totalSomatic = 0; % initialize accumulator +allInsertions = unique(S.TableRespNeurs.insertion); % unique insertion IDs across all animals +for in = 1:numel(allInsertions) % iterate over every insertion (BUG FIX: was "= numel(inser)", only hit last index) + insRows = S.TableRespNeurs.totalSomaticN( ... % extract totalSomaticN for all rows matching this insertion + S.TableRespNeurs.insertion == allInsertions(in)); + totalSomatic = totalSomatic + insRows(1); % take first value (identical across stimulus rows within an insertion) +end + +% Build annotation string with per-item responsive neuron counts +perItemN = arrayfun(@(s) sum(tempTable.respNeur(tempTable.stimulus == s)), ... + categorical(compLabels)); +annotParts = arrayfun(@(i) sprintf('%s = %d', compLabels{i}, perItemN(i)), ... + 1:numel(compLabels), 'UniformOutput', false); +annotStr = ['TS=' num2str(totalSomatic) '-' 'TR=' num2str(totalResp) '-' strjoin(annotParts, '-')]; + +% Apply consistent font formatting +ax = findobj(fig, 'Type', 'axes'); +for i = 1:numel(ax) + formatAxes(ax(i), 8, 'helvetica'); +end + +% Set figure size and labels +figure(fig); +set(fig, 'Units', 'centimeters', 'Position', [20 20 5 6]); +ylabel('Responsive / Total responsive'); +title(''); + +% % Shift axes up slightly to make room for the annotation +% pos = get(gca, 'Position'); +% pos(2) = pos(2) + 2; +% set(gca, 'Position', pos); + +% Add bottom annotation with neuron counts +annotation('textbox', [0.1, 0.01, 0.8, 0.04], ... + 'String', annotStr, 'EdgeColor', 'none', ... + 'FontSize', 5, 'FontWeight', 'bold', ... + 'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle', ... + 'FitBoxToText', false); + +% Save figure if PaperFig mode is active +if params.PaperFig + vs.printFig(fig, sprintf('ResponsiveUnits-%s', figTag), ... + PaperFig = params.PaperFig); +end + +if ~isempty(figAllDiffs) + figure(figAllDiffs); + % Save figure if PaperFig mode is active + if params.PaperFig + vs.printFig(figAllDiffs, sprintf('AllDiff-RespUnits-%s', figTag), ... + PaperFig = params.PaperFig); + end +end + +% ========================================================================= +% SECTION 11 — SAVE ANALYSIS STRUCT TO FIGURE DIRECTORY +% ========================================================================= +% When PaperFig is true, save a companion .mat alongside the figures so +% that every figure folder is self-contained: figures + the exact analysis +% configuration that produced them. + +if params.PaperFig + % Retrieve the figure save directory from the analysis object + % NOTE: adjust this path if vs.printFig uses a different convention + rootPath = extractBefore(vs0.dataObj.recordingDir, 'lizards'); % 'W:\Large_scale_mapping_NP\' + + figSaveDir = [rootPath 'Paper_figs']; + % Use the same base name as the analysis cache, with a suffix + [~, cacheName, ~] = fileparts(nameOfFile); + figStructPath = fullfile(figSaveDir, [cacheName '_analysisStruct.mat']); + + % Save the full struct (duplicates ~tens of KB; guarantees self-containment) + save(figStructPath, '-struct', 'S'); + fprintf('Saved analysis struct to figure directory: %s\n', figStructPath); +end + +end % end function AllExpAnalysis + + +% ######################################################################### +% LOCAL HELPER FUNCTIONS +% ######################################################################### + + +function gt = detectGratingType(stimName) +% detectGratingType Auto-detect the GratingType parameter from stimulus +% abbreviation. Returns 'moving' for SDGm, 'static' for SDGs, or '' +% for non-grating stimuli (in which case GratingType is not passed). + switch stimName + case 'SDGm', gt = 'moving'; % moving grating + case 'SDGs', gt = 'static'; % static grating + otherwise, gt = ''; % not a grating stimulus + end +end + + +function [labels, items] = buildMode3Items(stimsNeeded, catList, levelsCell) +% buildMode3Items Build canonical comparison labels and (stim, cat, lvl) tuples. +% labels{k} = 'MB_dir_0' etc. +% items(k) = struct('stim','MB', 'cat','direction', 'lv',0). + +labels = {}; +items = struct('stim', {}, 'cat', {}, 'lv', {}); + +for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + cat = catList{si}; + lvls = levelsCell{si}; + + if isempty(cat) + % No category → single item labeled with plain stimulus name + labels{end+1} = sn; %#ok + items(end+1) = struct('stim', sn, 'cat', '', 'lv', NaN); %#ok + continue + end + + for lvi = 1:numel(lvls) + lv = lvls(lvi); % single level value + labels{end+1} = makeCompLabel(sn, cat, lv); %#ok + items(end+1) = struct('stim', sn, 'cat', cat, 'lv', lv); %#ok + end +end +end + + +function lbl = makeCompLabel(stimName, catName, levelValue) +% makeCompLabel Short composite label: '__'. +% Category truncated to 3 chars; decimals -> 'p'; negative -> 'neg'. + + catAbbr = lower(catName); % lowercase category + if strlength(catAbbr) > 3 + catAbbr = extractBetween(catAbbr, 1, 3); % truncate to 3 characters + catAbbr = char(catAbbr); + end + lbl = sprintf('%s_%s_%g', stimName, catAbbr, levelValue); % e.g. 'MB_dir_0' + lbl = strrep(lbl, '.', 'p'); % 0.3 -> 0p3 + lbl = strrep(lbl, '-', 'neg'); % -1 -> neg1 +end + + +function [vsObj, allFound] = findSessionWithLevels(NP, stimName, catName, requestedLevels, params) +% findSessionWithLevels Find a session of stimName whose category column +% contains ALL requested levels. Tries Session=1 then Session=2. +% +% ResponseWindow is recomputed with params.overwriteResponse before +% reading colNames/C, to ensure stale cached column names are refreshed. + + vsObj = []; + allFound = false; + + for session = [1, 2] + candidate = createStimulusObject(NP, stimName, session); % try this session + if isempty(candidate) || isempty(candidate.VST) + continue % session not available + end + + % Recompute ResponseWindow (fixes stale column names if overwrite on) + candidate.ResponseWindow( ... + 'overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + rw = candidate.ResponseWindow; + + % Extract the condition matrix and its column names + [C, colNames] = getCmatrix(rw, stimName); + if isempty(C) || isempty(colNames) + continue + end + + % Find the requested category column (case-insensitive) + catIdx = find(strcmpi(colNames, catName)); + if isempty(catIdx) + continue % category not in this stimulus + end + + catColIdx = catIdx + 1; % +1 because colNames excludes first 4 cols + availLevels = uniquetol(C(~isnan(C(:, catColIdx)), catColIdx), 1e-6); + + % Check that every requested level is present + ok = true; + for lv = requestedLevels(:)' + if ~any(abs(availLevels - lv) < 1e-2) + ok = false; break + end + end + + if ok + vsObj = candidate; + allFound = true; + return + end + end +end + + +function [levels, catColIdx, vsObj] = findCategoryLevels(vsObj, NP, stimName, catName, params) +% findCategoryLevels Find unique category levels in a recording (mode 2). +% Tries Session=1, then Session=2. + + levels = []; + catColIdx = 0; + + for session = [1, 2] + fprintf(' Trying Session=%d...\n', session); + vsObj = createStimulusObject(NP, stimName, session); + if isempty(vsObj) || isempty(vsObj.VST) + continue + end + + % Recompute ResponseWindow + vsObj.ResponseWindow( ... + 'overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + rw = vsObj.ResponseWindow; + + % Extract condition matrix + [C, colNames] = getCmatrix(rw, stimName); + if isempty(C) || isempty(colNames) + continue + end + + % Look for the category column (case-insensitive) + catIdx = find(strcmpi(colNames, catName)); + if isempty(catIdx) + fprintf(' Category "%s" not found. Available: %s\n', ... + catName, strjoin(colNames, ', ')); + return + end + + catColIdx = catIdx + 1; % offset for first 4 metadata cols + rawCol = C(:, catColIdx); % raw values + rawCol = rawCol(~isnan(rawCol)); % remove NaNs + levels = uniquetol(rawCol, 1e-6); % unique levels with tolerance + + if numel(levels) >= 2 + fprintf(' Found %d levels of "%s" (session %d): [%s]\n', ... + numel(levels), catName, session, num2str(levels', '%.4g ')); + return % success — exit early + else + fprintf(' Only %d level of "%s" in session %d.\n', ... + numel(levels), catName, session); + end + end +end + + +function [C, colNames] = getCmatrix(rw, stimName) +% getCmatrix Extract the condition matrix C and column names from a +% ResponseWindow struct. Returns empty if not available. + + C = []; + colNames = {}; + + switch stimName + case {'MB', 'MBR'} + % MB/MBR store per-speed sub-structs; use the last (fastest) speed + speedFields = fieldnames(rw); + speedFields = speedFields(startsWith(speedFields, 'Speed')); + if isempty(speedFields), return; end + maxField = speedFields{end}; + C = rw.(maxField).C; + colNames = rw.colNames{1}(5:end); % skip first 4 metadata columns + + case 'SDGm' + if isfield(rw, 'C') + C = rw.C; + colNames = rw.colNames{1}(5:end); + end + + case 'SDGs' + if isfield(rw, 'C') + C = rw.C; + colNames = rw.colNames{1}(5:end); + end + + otherwise + % Generic fallback + if isfield(rw, 'C') + C = rw.C; + colNames = rw.colNames{1}(5:end); + end + end +end + + +function vsObj = createStimulusObject(NP, stimName, session) +% createStimulusObject Create an analysis object, optionally with Session. +% Returns [] on failure. + + vsObj = []; + try + key = getObjKey(stimName); % shared key (e.g. 'SDG' for both SDGm/SDGs) + if session == 0 + % No session specified — use default + switch key + case 'MB', vsObj = linearlyMovingBallAnalysis(NP); + case 'RG', vsObj = rectGridAnalysis(NP); + case 'MBR', vsObj = linearlyMovingBarAnalysis(NP); + case 'SDG', vsObj = StaticDriftingGratingAnalysis(NP); + case 'NI', vsObj = imageAnalysis(NP); + case 'NV', vsObj = movieAnalysis(NP); + case 'FFF', vsObj = fullFieldFlashAnalysis(NP); + end + else + % Explicit session number + switch key + case 'MB', vsObj = linearlyMovingBallAnalysis(NP, 'Session', session); + case 'RG', vsObj = rectGridAnalysis(NP, 'Session', session); + case 'MBR', vsObj = linearlyMovingBarAnalysis(NP, 'Session', session); + case 'SDG', vsObj = StaticDriftingGratingAnalysis(NP, 'Session', session); + case 'NI', vsObj = imageAnalysis(NP, 'Session', session); + case 'NV', vsObj = movieAnalysis(NP, 'Session', session); + case 'FFF', vsObj = fullFieldFlashAnalysis(NP, 'Session', session); + end + end + catch ME + fprintf(' Could not create %s Session=%d: %s\n', stimName, session, ME.message); + vsObj = []; + end +end + + +function [vsObjs, present] = loadStimulusObjects(NP, stimsNeeded) +% loadStimulusObjects Load one analysis object per unique stimulus class. +% Returns a containers.Map of objects and a Map of presence flags. + + vsObjs = containers.Map(); % key -> analysis object + present = containers.Map(); % stimName -> true/false + + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; % stimulus name + key = getObjKey(sn); % shared object key + + if ~vsObjs.isKey(key) + % First encounter of this key — create the object + obj = createStimulusObject(NP, sn, 0); + + if isempty(obj) || isempty(obj.VST) + fprintf(' %s: stimulus not found.\n', key); + present(sn) = false; + else + present(sn) = true; + end + + if ~isempty(obj) + vsObjs(key) = obj; + end + else + % Object already loaded for this key — check presence + if ~present.isKey(sn) + present(sn) = vsObjs.isKey(key) && ~isempty(vsObjs(key).VST); + end + end + end +end + + +function key = getObjKey(stimName) +% getObjKey Map stimulus abbreviation to shared analysis-object key. +% SDGm and SDGs share a single StaticDriftingGratingAnalysis object. + switch stimName + case {'SDGm','SDGs'}, key = 'SDG'; + otherwise, key = stimName; + end +end + + +function fName = levelToFieldName(catName, value) +% levelToFieldName Build a valid MATLAB field name matching +% StatisticsPerNeuronPerCategory's convention. +% e.g. ('size', 5) -> 'size_5', ('speed', 0.3) -> 'speed_0p3'. + + fName = sprintf('%s_%g', lower(strtrim(catName)), value); + fName = strrep(fName, '.', 'p'); % decimal point -> 'p' + fName = strrep(fName, '-', 'neg'); % minus sign -> 'neg' +end + + +function runStimStats(vsObj, params) +% runStimStats Run ResponseWindow + StatisticsPerNeuron. +% NOTE: This function assumes vsObj is a handle class. If it is a value +% class, the caller must capture the return value (which this function +% does not currently provide). Assert handle-class identity here to +% catch this early. + + assert(isa(vsObj, 'handle'), ... + 'runStimStats:notHandle', ... + 'Analysis object must be a handle class for in-place mutation.'); + + % Step 1: Compute ResponseWindow (response traces, condition matrix) + vsObj.ResponseWindow( ... + 'overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + + % Step 2: Run StatisticsPerNeuron (max-permutation test) + vsObj.StatisticsPerNeuron( ... + 'overwrite', params.overwriteStats, ... + 'BaseRespWindow', params.BaseRespWindow, ... + 'SpatialGridMode', params.SpatialGridMode, ... + 'maxCategory', params.maxCategory, ... + 'applyFDR', params.applyFDR); +end + + +function [z, p, spkR, spkDiff] = extractStimData(vsObj, stimName, useZmean) +% extractStimData Pull z-scores, p-values, and spike rate from the +% StatisticsPerNeuron results. +% +% INPUTS +% vsObj Analysis object (with computed StatisticsPerNeuron) +% stimName char Stimulus abbreviation ('MB','SDGm','SDGs', etc.) +% useZmean logical true = use z_mean; false = use peak spike rate +% +% OUTPUTS +% z (N,1) double Z-scores per neuron +% p (N,1) double P-values per neuron +% spkR (N,1) double Spike rate (or z_mean) per neuron +% spkDiff (N,1) double Response minus baseline difference + + % Always read from StatisticsPerNeuron (only stat method retained) + stats = vsObj.StatisticsPerNeuron; + rw = vsObj.ResponseWindow; + + switch stimName + + case 'MB' + % MB has multiple speeds — find best speed per neuron (lowest p) + speedFields = fieldnames(stats); + speedFields = speedFields(contains(speedFields, 'Speed')); + nSpeeds = numel(speedFields); + + allP = []; % (nNeurons x nSpeeds) p-value matrix + allZ = []; % (nNeurons x nSpeeds) z-score matrix + allR = []; % (nNeurons x nSpeeds) spike rate matrix + allDf = []; % (nNeurons x nSpeeds) difference matrix + + for iS = 1:nSpeeds + sName = speedFields{iS}; % e.g. 'Speed1', 'Speed2' + subTmp = stats.(sName); % statistics sub-struct + rwTmp = rw.(sName); % response window sub-struct + + allP(:,iS) = subTmp.pvalsResponse(:); %#ok + allZ(:,iS) = subTmp.ZScoreU(:); %#ok + + if useZmean && isfield(subTmp, 'z_mean') + allR(:,iS) = subTmp.z_mean(:); %#ok + else + allR(:,iS) = max(rwTmp.NeuronVals(:,:,4), [], 2); %#ok + end + allDf(:,iS) = max(rwTmp.NeuronVals(:,:,5), [], 2); %#ok + end + + % Select the speed with the lowest p-value for each neuron + [p, bestIdx] = min(allP, [], 2); + nNeurons = size(allP, 1); + linIdx = sub2ind(size(allP), (1:nNeurons)', bestIdx); + z = allZ(linIdx); + spkR = allR(linIdx); + spkDiff = allDf(linIdx); + + return + + case 'MBR' + sub = stats.Speed1; rwSub = rw.Speed1; % bar: single speed + case 'SDGm' + sub = stats.Moving; rwSub = rw.Moving; % moving grating sub-struct + case 'SDGs' + sub = stats.Static; rwSub = rw.Static; % static grating sub-struct + otherwise + sub = stats; rwSub = rw; % generic: top-level struct + end + + % Extract per-neuron values from the selected sub-struct + z = sub.ZScoreU(:); % z-score + p = sub.pvalsResponse(:); % p-value + + + if useZmean && isfield(sub, 'z_mean') + spkR = sub.z_mean(:); % z-scored mean response + else + spkR = max(rwSub.NeuronVals(:,:,4), [], 2); % peak spike rate + end + + spkDiff = max(rwSub.NeuronVals(:,:,5), [], 2); % peak response - baseline +end + + +function pVal = bootstrapPairDifference(tbl, pair, nBoot, metric) +% bootstrapPairDifference Hierarchical bootstrap test for a single pair. +% +% BUG FIX (was Bug #2): Pairs neurons explicitly by NeurID within each +% insertion using innerjoin, instead of relying on row order. The +% previous row-order approach silently produced wrong differences if the +% table was ever sorted or filtered asymmetrically. +% +% INPUTS +% tbl table Long-format with columns: insertion, stimulus, NeurID, +% animal, and the metric column. +% pair {1x2} Cell pair of stimulus labels. +% nBoot double Number of bootstrap iterations. +% metric char Column name to test ('Z-score' or 'SpkR'). +% +% OUTPUT +% pVal double Two-tailed p-value from hierarchical bootstrap. + + diffs = []; % per-neuron paired differences + insers = []; % insertion label for each difference (hierBoot level 1) + animals = []; % animal label for each difference (hierBoot level 2) + + for ins = unique(tbl.insertion)' + + % Logical masks: rows for this insertion x each stimulus + mask1 = tbl.insertion == ins & tbl.stimulus == pair{1}; + mask2 = tbl.insertion == ins & tbl.stimulus == pair{2}; + + if ~any(mask1) || ~any(mask2), continue; end % skip if either is absent + + % Extract sub-tables with the metric, NeurID, and animal columns + sub1 = tbl(mask1, {metric, 'NeurID', 'animal'}); + sub2 = tbl(mask2, {metric, 'NeurID'}); + + % Inner-join on NeurID ensures correct neuron-to-neuron pairing + merged = innerjoin(sub1, sub2, 'Keys', 'NeurID', ... + 'LeftVariables', {metric, 'NeurID', 'animal'}, ... + 'RightVariables', {metric}); + + % MATLAB auto-suffixes duplicated variable names after innerjoin; + % find both metric columns by prefix matching + mergedVarNames = merged.Properties.VariableNames; + metricCols = mergedVarNames(startsWith(mergedVarNames, metric)); + + % Paired difference: stimulus 1 minus stimulus 2 + d = merged.(metricCols{1}) - merged.(metricCols{2}); + + % Animal identity (constant within an insertion) + animal = merged.animal(1); + + % Append to accumulators + nPaired = numel(d); + diffs = [diffs; d]; %#ok + insers = [insers; double(repmat(ins, nPaired, 1))]; %#ok + animals = [animals; double(repmat(animal, nPaired, 1))]; %#ok + end + + % Hierarchical bootstrap: resample animals -> insertions -> neurons + %bootMeans = hierBoot(diffs, nBoot, insers, animals); + bootMeans = hierBootMatchFreq(diffs, nBoot, animals ,insers); + + + % Two-tailed p-value: probability that |bootstrap mean| >= |observed| + pLeft = mean(bootMeans <= 0); % fraction of bootstrap means <= 0 + pRight = mean(bootMeans >= 0); % fraction of bootstrap means >= 0 + pVal = 2 * min(pLeft, pRight); % double the smaller tail + pVal = min(pVal, 1); % cap at 1 +end + + +function fig = plotPairScatter(tbl, compLabels, metric, cmap, animalIdx, labelMap) +% plotPairScatter Scatter plot: first comparison item (x) vs second (y). +% Points coloured by animal identity. + + fig = figure; + + % Separate data for each stimulus + mask1 = tbl.stimulus == compLabels{1}; + mask2 = tbl.stimulus == compLabels{2}; + v1 = tbl.(metric)(mask1); % x-axis values + v2 = tbl.(metric)(mask2); % y-axis values + cIdx = animalIdx(mask1); % colour index per point + + % Scatter with animal-coloured markers + scatter(v1, v2, 7, cmap(cIdx,:), 'filled', 'MarkerFaceAlpha', 0.3); + hold on; axis equal; + + % Unity line (equal-response reference) + lims = [min(tbl.(metric)), max(tbl.(metric))]; + plot(lims, lims, 'k--', 'LineWidth', 1.5); + xlim(lims); ylim(lims); + + % Apply display-name substitutions for axis labels + xLab = compLabels{1}; yLab = compLabels{2}; + for li = 1:size(labelMap, 1) + xLab = strrep(xLab, labelMap{li,1}, labelMap{li,2}); + yLab = strrep(yLab, labelMap{li,1}, labelMap{li,2}); + end + xlabel(xLab); ylabel(yLab); + colormap(fig, cmap); + + % Consistent font formatting and figure size + formatAxes(gca, 8, 'helvetica'); + set(fig, 'Units', 'centimeters', 'Position', [20 20 5 5]); +end + + +function formatAxes(ax, fontSize, fontName) +% formatAxes Apply consistent font styling to an axes object. + ax.YAxis.FontSize = fontSize; ax.YAxis.FontName = fontName; + ax.XAxis.FontSize = fontSize; ax.XAxis.FontName = fontName; +end + + +function pAdj = bhFDR(pVals) +% bhFDR Benjamini-Hochberg FDR correction. +% Adjusts a vector of p-values to control the false discovery rate. + + n = numel(pVals); % number of tests + [pSorted, sortIdx] = sort(pVals(:)); % sort ascending + ranks = (1:n)'; % rank of each sorted p-value + + pAdj = pSorted .* n ./ ranks; % BH adjustment: p * n / rank + pAdj = min(pAdj, 1); % cap at 1 + + % Enforce monotonicity: each adjusted p must be <= the one above it + for k = n-1:-1:1 + pAdj(k) = min(pAdj(k), pAdj(k+1)); + end + + pAdj(sortIdx) = pAdj; % restore original order +end \ No newline at end of file diff --git a/visualStimulationAnalysis/AllExpDirectionTuning.m b/visualStimulationAnalysis/AllExpDirectionTuning.m new file mode 100644 index 0000000..158f800 --- /dev/null +++ b/visualStimulationAnalysis/AllExpDirectionTuning.m @@ -0,0 +1,656 @@ +function [tblOut, figs] = AllExpDirectionTuning(expList, params) +% AllExpDirectionTuning Pool OSI and DSI across multiple Neuropixels +% recordings, run hierarchical bootstrap statistics for pairwise +% stimulus comparisons, and generate publication-ready swarm plots. +% +% This function follows the same design pattern as AllExpAnalysis: +% 1. Loop over experiments, load each stimulus object +% 2. Call DirectionTuning to compute per-neuron OSI/DSI +% 3. Pool results into a long table (one row per neuron × stimulus × metric) +% 4. Run hierBoot (Saravanan et al. 2020) for pairwise comparisons +% 5. Plot with plotSwarmBootstrapWithComparisons +% +% INPUTS +% expList (1,:) double Row vector of experiment IDs from master Excel. +% params Name-value See arguments block below. +% +% OUTPUTS +% tblOut table Long table with columns: +% animal, insertion, stimulus, NeurID, metric, value +% figs struct Figure handles (.OSI, .DSI, .prefDir) +% +% EXAMPLES +% % Compare OSI between moving gratings and moving ball: +% [tbl, figs] = AllExpDirectionTuning([49:54 64:66], ... +% stimuli = {'SDGm', 'MB'}, plot = true, PaperFig = true); +% +% % Single stimulus — just pool and plot, no comparison: +% [tbl, figs] = AllExpDirectionTuning([49:54], ... +% stimuli = {'SDGm'}, plot = true); +% +% See also: DirectionTuning, hierBoot, plotSwarmBootstrapWithComparisons, +% AllExpAnalysis + +% ========================================================================= +% ARGUMENTS BLOCK +% ========================================================================= +arguments + expList (1,:) double % row vector of experiment IDs + + % --- Stimuli to compare --- + params.stimuli cell = {'SDGm'} % cell of stimulus abbreviations to pool + % (e.g. {'SDGm','MB'}). Each must have + % a 'direction' category in its conditions. + + % --- Responsiveness filter --- + params.threshold double = 0.05 % p-value cutoff for responsiveness mask + + % --- ResponseWindow / Statistics recomputation --- + params.overwriteRW logical = false % force recompute ResponseWindow + params.overwriteStats logical = false % force recompute StatisticsPerNeuron + + % --- Hierarchical bootstrap --- + params.nBoot double = 10000 % number of bootstrap resamples + params.rngSeed double = 42 % fixed RNG seed for reproducibility + + % --- Plotting --- + params.plot logical = true % generate swarm plots + params.PaperFig logical = false % save publication-quality figures via printFig + params.Alpha double = 0.4 % dot transparency in swarm plot + params.yMax double = 1 % y-axis upper limit for both OSI and DSI + + % --- Saving --- + params.overwrite logical = false % overwrite previously saved pooled results +end + +% ========================================================================= +% 0. INITIALISE +% ========================================================================= + +nStim = numel(params.stimuli); % number of stimuli to pool +figs = struct(); % output figure handles + +% Fix RNG seed for reproducible bootstrap confidence intervals across runs +rng(params.rngSeed, 'twister'); % Mersenne Twister, fixed seed + +% ------------------------------------------------------------------------- +% Build save path using the first experiment (same convention as AllExpAnalysis) +% ------------------------------------------------------------------------- +NP_first = loadNPclassFromTable(expList(1)); % load first experiment for path extraction +firstObj = createStimulusObject(NP_first, params.stimuli{1}, 0); + +% Extract the shared analysis directory from the first experiment +pathStr = extractBefore(firstObj.getAnalysisFileName, 'lizards'); +saveDir = fullfile([pathStr 'lizards'], 'Combined_lizard_analysis'); + +% Create directory if it does not exist +if ~exist(saveDir, 'dir') + mkdir(saveDir); +end + +% Construct save file name incorporating experiment range and stimuli +stimLabel = strjoin(params.stimuli, '-'); % e.g. 'SDGm-MB' +nameOfFile = sprintf('Ex_%d-%d_DirectionTuning_%s.mat', ... + expList(1), expList(end), stimLabel); +savePath = fullfile(saveDir, nameOfFile); + +% ------------------------------------------------------------------------- +% Check for existing results (load if available and not overwriting) +% ------------------------------------------------------------------------- +if exist(savePath, 'file') && ~params.overwrite + fprintf('Loading existing results: %s\n', savePath); + S = load(savePath, 'tblOut'); % load the saved long table + tblOut = S.tblOut; +else + % ===================================================================== + % 1. BUILD LONG TABLE — LOOP OVER EXPERIMENTS AND STIMULI + % ===================================================================== + + % Pre-allocate the long table with typed empty columns. + % Columns: animal, insertion, stimulus, NeurID, metric, value + tblOut = table( ... + categorical.empty(0,1), ... % animal — e.g. PV123 + categorical.empty(0,1), ... % insertion — stable numeric ID + categorical.empty(0,1), ... % stimulus — e.g. SDGm, MB + categorical.empty(0,1), ... % NeurID — unique neuron identifier + categorical.empty(0,1), ... % metric — 'OSI','DSI','prefDir','prefOri' + double.empty(0,1), ... % value — the index or angle value + 'VariableNames', {'animal','insertion','stimulus','NeurID','metric','value'}); + + % Tuning curve accumulator: one struct field per stimulus name. + % tcAll.(stimName).curves : [nNeurons_pooled × nDir] spike rates (spks/s) + % tcAll.(stimName).uDirRad : [1 × nDir] direction axis in radians (locked to first experiment) + tcAll = struct(); + + % Maps for stable numeric IDs across experiments (same as AllExpAnalysis) + animalMap = containers.Map(); % animalKey → unique integer + insertionMap = containers.Map(); % insKey → unique integer + nextAnimalIdx = 0; % counter for new animals + nextInsertionIdx = 0; % counter for new insertions + + for ei = 1:numel(expList) + ex = expList(ei); % current experiment ID + fprintf('\n======= Experiment %d (%d/%d) =======\n', ex, ei, numel(expList)); + + % ----------------------------------------------------------------- + % 1a. Load NP class for this experiment + % ----------------------------------------------------------------- + try + NP = loadNPclassFromTable(ex); % load Neuropixels data object + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + continue % skip to next experiment + end + + % Extract animal ID from recording name (supports PV### and SA### patterns) + animalID = string(regexp(NP.recordingName, 'PV\d+', 'match', 'once')); + if animalID == "" + animalID = string(regexp(NP.recordingName, 'SA\d+', 'match', 'once')); + end + if animalID == "" + warning('Cannot extract animal ID from "%s". Skipping.', NP.recordingName); + continue + end + + % Extract insertion number from the directory path + insStr = regexp(NP.recordingDir, 'Insertion\d+', 'match', 'once'); + insNum = str2double(regexp(insStr, '\d+', 'match')); + if isempty(insNum) || isnan(insNum) + insNum = ei; % fallback: use loop index + end + + % Register animal and insertion in maps for stable unique IDs + animalKey = char(animalID); + if ~animalMap.isKey(animalKey) + nextAnimalIdx = nextAnimalIdx + 1; % assign new index + animalMap(animalKey) = nextAnimalIdx; + end + insKey = sprintf('%s__Ins%d', animalKey, insNum); + if ~insertionMap.isKey(insKey) + nextInsertionIdx = nextInsertionIdx + 1; % assign new index + insertionMap(insKey) = nextInsertionIdx; + end + insertionIdx = insertionMap(insKey); % stable numeric ID for this insertion + + % ----------------------------------------------------------------- + % 1b. Get phy IDs for good somatic units (shared across stimuli) + % ----------------------------------------------------------------- + firstStimObj = createStimulusObject(NP, params.stimuli{1}, 0); + if isempty(firstStimObj) || isempty(firstStimObj.VST) + fprintf(' Stimulus %s not present in experiment %d. Skipping.\n', ... + params.stimuli{1}, ex); + continue % skip experiment — no direction stimulus found + end + p_sort = NP.convertPhySorting2tIc(firstStimObj.spikeSortingFolder, 0, 1, 1); + phy_IDg = p_sort.phy_ID(string(p_sort.label') == 'good'); + + % ----------------------------------------------------------------- + % 1c. Loop over stimulus types + % ----------------------------------------------------------------- + for s = 1:nStim + stimName = params.stimuli{s}; % e.g. 'SDGm', 'MB' + fprintf(' — Stimulus: %s\n', stimName); + + % Create the analysis object for this stimulus + vsObj = createStimulusObject(NP, stimName, 0); + if isempty(vsObj) || isempty(vsObj.VST) + fprintf(' Stimulus %s not found. Skipping.\n', stimName); + continue % stimulus not present in this session + end + + % Run DirectionTuning for this stimulus and experiment + try + res = DirectionTuning(vsObj, ... + 'threshold', params.threshold, ... + 'overwriteRW', params.overwriteRW, ... + 'overwriteStats', params.overwriteStats, ... + 'save', true, ... + 'overwrite', params.overwrite); + catch ME + warning('DirectionTuning failed for %s in exp %d: %s', ... + stimName, ex, ME.message); + continue + end + + % Apply responsiveness filter: only include neurons with p < threshold + respIdx = find(res.respMask); % indices of responsive neurons + nResp = numel(respIdx); % number of responsive neurons + fprintf(' Responsive: %d / %d\n', nResp, numel(res.respMask)); + + if nResp == 0 + continue % no responsive neurons — skip + end + + % Build unique NeurID strings for NeurID-based pairing across stimuli. + % Format: "ins_phy" where X = stable insertion ID, Y = phy cluster ID. + % This ensures the SAME neuron is matched across stimulus types. + neurIDs = arrayfun(@(pid) sprintf('ins%d_phy%d', insertionIdx, pid), ... + res.phyID(respIdx), 'UniformOutput', false); + + % Append OSI rows to the long table + nRows = nResp; % one row per responsive neuron per metric + osiRows = table( ... + repmat(categorical(animalID), nRows, 1), ... % animal + repmat(categorical(insertionIdx), nRows, 1), ...% insertion (numeric ID) + repmat(categorical(string(stimName)), nRows, 1), ...% stimulus + categorical(neurIDs(:)), ... % NeurID + repmat(categorical("OSI"), nRows, 1), ... % metric + res.OSI(respIdx), ... % value + 'VariableNames', tblOut.Properties.VariableNames); + + % Append DSI rows to the long table + dsiRows = table( ... + repmat(categorical(animalID), nRows, 1), ... + repmat(categorical(insertionIdx), nRows, 1), ... + repmat(categorical(string(stimName)), nRows, 1), ... + categorical(neurIDs(:)), ... + repmat(categorical("DSI"), nRows, 1), ... + res.DSI(respIdx), ... + 'VariableNames', tblOut.Properties.VariableNames); + + % Append preferred direction rows (degrees, [0, 360)) + prefDirRows = table( ... + repmat(categorical(animalID), nRows, 1), ... + repmat(categorical(insertionIdx), nRows, 1), ... + repmat(categorical(string(stimName)), nRows, 1), ... + categorical(neurIDs(:)), ... + repmat(categorical("prefDir"), nRows, 1), ... + res.prefDirDeg(respIdx), ... + 'VariableNames', tblOut.Properties.VariableNames); + + % Append preferred orientation rows (degrees, [0, 180)) + prefOriRows = table( ... + repmat(categorical(animalID), nRows, 1), ... + repmat(categorical(insertionIdx), nRows, 1), ... + repmat(categorical(string(stimName)), nRows, 1), ... + categorical(neurIDs(:)), ... + repmat(categorical("prefOri"), nRows, 1), ... + res.prefOriDeg(respIdx), ... + 'VariableNames', tblOut.Properties.VariableNames); + + % Concatenate all metrics to master table + tblOut = [tblOut; osiRows; dsiRows; prefDirRows; prefOriRows]; %#ok + + % ---------------------------------------------------------- + % Accumulate tuning curves for mean-curve plot. + % Directions must match across experiments for the mean to be + % valid — lock to first experiment and skip if they diverge. + % ---------------------------------------------------------- + fn = matlab.lang.makeValidName(stimName); % valid struct field name + if ~isfield(tcAll, fn) + % First encounter: initialise with this experiment's directions + tcAll.(fn).uDirRad = res.uDirRad; % [1 × nDir] direction axis (radians) + tcAll.(fn).curves = res.tuningCurve(respIdx, :); % [nResp × nDir] + else + % Subsequent experiments: stack only if direction axes match + if numel(res.uDirRad) == numel(tcAll.(fn).uDirRad) && ... + all(abs(res.uDirRad - tcAll.(fn).uDirRad) < 1e-4) + tcAll.(fn).curves = [tcAll.(fn).curves; ... + res.tuningCurve(respIdx, :)]; %#ok + else + warning('AllExpDirectionTuning:dirMismatch', ... + 'Direction axis differs in exp %d for %s — skipping tuning curve.', ... + ex, stimName); + end + end + + end % stimulus loop + end % experiment loop + + % Remove unused categories from categorical columns + tblOut.animal = removecats(tblOut.animal); + tblOut.insertion = removecats(tblOut.insertion); + tblOut.stimulus = removecats(tblOut.stimulus); + tblOut.NeurID = removecats(tblOut.NeurID); + tblOut.metric = removecats(tblOut.metric); + + % Save the pooled long table and tuning curve accumulator + save(savePath, 'tblOut', 'tcAll', 'params'); + fprintf('\nSaved pooled results: %s\n', savePath); +end + +% Load tcAll if coming from a cached file (may be absent in older saves) +if ~exist('tcAll', 'var') + S2 = load(savePath, '-mat'); + if isfield(S2, 'tcAll') + tcAll = S2.tcAll; + else + tcAll = struct(); % graceful fallback for old cache files + warning('AllExpDirectionTuning:noTcAll', ... + 'Cached file has no tuning curve data. Re-run with overwrite=true to regenerate.'); + end +end + +% ========================================================================= +% 2. BUILD COMBINED CONDITION TABLE AND GENERATE ALL COMPARISON PAIRS +% ========================================================================= + +% Merge OSI and DSI into one table for a single combined plot. +% The 'stimulus' column is repurposed as a condition label that encodes +% both the metric and the stimulus, e.g. "OSI SDGm", "DSI MB". +% NeurID-based pairing in hierBoot still works correctly: +% • OSI vs DSI of the same stimulus → paired within neuron (same NeurID) +% • OSI/DSI of stim1 vs stim2 → paired across stimuli (same NeurID) +tblPlot = tblOut; % copy to avoid modifying tblOut +condLabels = string(tblOut.metric) + " " + string(tblOut.stimulus); +tblPlot.stimulus = categorical(condLabels); % overwrite with combined label + +% Build ordered condition list: for each stimulus, OSI first then DSI. +% This gives a natural grouping on the x-axis: +% [OSI SDGm | DSI SDGm | OSI MB | DSI MB | ...] +orderedConds = {}; % cell of condition label strings +for s = 1:nStim + orderedConds{end+1} = "OSI " + params.stimuli{s}; %#ok + orderedConds{end+1} = "DSI " + params.stimuli{s}; %#ok +end + +% All pairwise comparisons from the ordered condition list. +% For 1 stimulus → 1 pair: OSI vs DSI +% For 2 stimuli → 6 pairs: OSI_s1/DSI_s1, OSI_s2/DSI_s2, +% OSI_s1/OSI_s2, DSI_s1/DSI_s2, +% OSI_s1/DSI_s2, DSI_s1/OSI_s2 +pairs = nchoosek(orderedConds, 2); % [nPairs × 2] cell array + +% ========================================================================= +% 3. PLOT COMBINED OSI + DSI WITH HIERARCHICAL BOOTSTRAP +% ========================================================================= + +if params.plot && height(tblPlot) > 0 + + % Compute hierBoot p-values for every pair + psAll = computeHierBootPvals(tblPlot, pairs, params.nBoot); + + % Single combined plot: one swarm per condition group + [figAll, ~] = plotSwarmBootstrapWithComparisons(tblPlot, pairs, psAll, {'value'}, ... + yLegend = 'OSI / DSI', ... + yMaxVis = params.yMax, ... + diff = false, ... % all conditions on same axis, no diff panel + Alpha = params.Alpha, ... + plotMeanSem = true); + + title(sprintf('Direction & Orientation Selectivity — %s', stimLabel), 'FontSize', 10); + + if params.PaperFig + firstObj.printFig(figAll, sprintf('OSI-DSI-%s', stimLabel), ... + PaperFig = params.PaperFig); + end + + figs.OSIDSI = figAll; +end + +% ========================================================================= +% 4. ADDITIONAL PLOTS: MEAN TUNING CURVE, PREFERRED DIRECTION/ORIENTATION +% ========================================================================= + +if params.plot + + % ----------------------------------------------------------------- + % 4a. Mean tuning curve — polar plot, one line per stimulus + % ----------------------------------------------------------------- + figTC = figure; + ax = polaraxes; % polar axes for direction tuning + hold(ax, 'on'); + colors = lines(nStim); % one colour per stimulus + + for s = 1:nStim + fn = matlab.lang.makeValidName(params.stimuli{s}); % struct field name + if ~isfield(tcAll, fn) || isempty(tcAll.(fn).curves) + fprintf(' No tuning curve data for %s.\n', params.stimuli{s}); + continue + end + + curves = tcAll.(fn).curves; % [nNeurons × nDir] spike rates + uDirs = tcAll.(fn).uDirRad; % [1 × nDir] direction axis + + meanTC = mean(curves, 1); % [1 × nDir] mean across neurons + semTC = std(curves, 0, 1) / sqrt(size(curves,1));% [1 × nDir] SEM across neurons + + % Close the polar curve by repeating the first direction + theta = [uDirs, uDirs(1)]; % [1 × nDir+1] + rhoMean = [meanTC, meanTC(1)]; % [1 × nDir+1] + rhoUp = [meanTC + semTC, meanTC(1) + semTC(1)]; + rhoDown = [max(meanTC - semTC, 0), max(meanTC(1) - semTC(1), 0)]; + + % SEM shading as a filled polygon + thetaShade = [theta, fliplr(theta)]; + rhoShade = [rhoUp, fliplr(rhoDown)]; + fill(ax, thetaShade, rhoShade, colors(s,:), ... + 'FaceAlpha', 0.15, 'EdgeColor', 'none'); % shaded SEM band + + % Mean line + polarplot(ax, theta, rhoMean, ... + 'Color', colors(s,:), 'LineWidth', 1.5, ... + 'DisplayName', params.stimuli{s}); + end + + legend(ax, 'Location', 'best'); + title(ax, sprintf('Mean Tuning Curve (n neurons pooled) — %s', stimLabel), 'FontSize', 10); + + if params.PaperFig + firstObj.printFig(figTC, sprintf('MeanTuningCurve-%s', stimLabel), ... + PaperFig = params.PaperFig); + end + figs.tuningCurve = figTC; + + % ----------------------------------------------------------------- + % 4b. Preferred direction — rose (polar histogram), one per stimulus + % ----------------------------------------------------------------- + figPD = figure; + nBinsDir = 16; % 16 bins = 22.5° each, covers 0–360° + + for s = 1:nStim + sName = params.stimuli{s}; + subplot(1, nStim, s); + ax2 = gca; + + % Filter table for this stimulus's preferred directions + tblPD = tblOut(tblOut.metric == 'prefDir' & ... + tblOut.stimulus == categorical({sName}), :); + + if isempty(tblPD) + title(ax2, sprintf('Pref Dir — %s\n(no data)', sName), 'FontSize', 9); + continue + end + + % Plot as a polar histogram (rose plot) + anglesRad = deg2rad(tblPD.value); % convert to radians for polarhistogram + polarhistogram(anglesRad, nBinsDir, ... + 'Normalization', 'probability', ... % normalise so bars sum to 1 + 'FaceColor', colors(s,:), ... + 'EdgeColor', 'w', 'FaceAlpha', 0.7); + + title(ax2, sprintf('Pref Direction — %s (n=%d)', sName, height(tblPD)), 'FontSize', 9); + end + + if params.PaperFig + firstObj.printFig(figPD, sprintf('PrefDirection-%s', stimLabel), ... + PaperFig = params.PaperFig); + end + figs.prefDir = figPD; + + % ----------------------------------------------------------------- + % 4c. Preferred orientation — linear histogram 0–180°, one per stimulus + % Orientation wraps at 180°, so a linear histogram is clearer than + % a polar plot for this quantity. + % ----------------------------------------------------------------- + figPO = figure; + binEdgesOri = 0 : 10 : 180; % 10° bins + + for s = 1:nStim + sName = params.stimuli{s}; + subplot(1, nStim, s); + + tblPO = tblOut(tblOut.metric == 'prefOri' & ... + tblOut.stimulus == categorical({sName}), :); + + if isempty(tblPO) + title(sprintf('Pref Ori — %s\n(no data)', sName), 'FontSize', 9); + continue + end + + histogram(tblPO.value, binEdgesOri, ... + 'FaceColor', colors(s,:), ... + 'EdgeColor', 'w', 'FaceAlpha', 0.7); + + xlabel('Preferred orientation (°)'); + ylabel('Neuron count'); + xlim([0 180]); + xticks(0:45:180); + title(sprintf('Pref Orientation — %s (n=%d)', sName, height(tblPO)), 'FontSize', 9); + box off; + end + + if params.PaperFig + firstObj.printFig(figPO, sprintf('PrefOrientation-%s', stimLabel), ... + PaperFig = params.PaperFig); + end + figs.prefOri = figPO; + +end % if params.plot + height(tblOut, numel(expList), nStim); + +end % ===== END OF MAIN FUNCTION ===== + + +% ========================================================================= +% LOCAL HELPER FUNCTIONS +% ========================================================================= + + +function ps = computeHierBootPvals(tbl, pairs, nBoot) +% computeHierBootPvals Compute p-values for stimulus pair comparisons +% using hierarchical bootstrap on paired differences. +% +% Pairing is NeurID-based: only neurons present in BOTH stimuli of a pair +% contribute. The hierarchy is: neurons nested within insertions nested +% within animals, matching the mixed-model random-effects structure. +% +% INPUTS +% tbl — long table with columns: animal, insertion, stimulus, NeurID, value +% pairs — [nPairs × 2] cell of stimulus name pairs +% nBoot — number of bootstrap resamples +% +% OUTPUTS +% ps — [nPairs × 1] two-sided p-values + + nPairs = size(pairs, 1); % number of comparisons + ps = nan(nPairs, 1); % pre-allocate + + for i = 1:nPairs + stim1 = pairs{i, 1}; % first stimulus in pair + stim2 = pairs{i, 2}; % second stimulus in pair + + % Accumulate paired differences, insertion IDs, and animal IDs + diffs = []; % paired differences (stim1 − stim2) + insers = []; % insertion grouping variable + animals = []; % animal grouping variable + + % Loop over unique insertions to enforce NeurID-based pairing + for ins = unique(tbl.insertion)' + % Find rows matching this insertion and each stimulus + idx1 = tbl.insertion == ins & tbl.stimulus == stim1; + idx2 = tbl.insertion == ins & tbl.stimulus == stim2; + + % Extract NeurIDs present in both stimuli for this insertion + nIDs1 = tbl.NeurID(idx1); % NeurIDs for stim1 + nIDs2 = tbl.NeurID(idx2); % NeurIDs for stim2 + shared = intersect(nIDs1, nIDs2); % neurons in both + + if isempty(shared) + continue % no paired data for this insertion + end + + % Extract paired values, matched by NeurID (NOT by row order) + [~, loc1] = ismember(shared, nIDs1); % indices into stim1 rows + [~, loc2] = ismember(shared, nIDs2); % indices into stim2 rows + vals1_all = tbl.value(idx1); % all values for stim1 at this insertion + vals2_all = tbl.value(idx2); % all values for stim2 at this insertion + V1 = vals1_all(loc1); % paired values stim1 + V2 = vals2_all(loc2); % paired values stim2 + + % Extract the animal ID for this insertion + animal = unique(tbl.animal(idx1)); % should be a single animal + + % Append paired differences and grouping variables + nShared = numel(shared); % number of paired neurons + diffs = [diffs; V1 - V2]; %#ok + insers = [insers; double(repmat(ins, nShared, 1))]; %#ok + animals = [animals; double(repmat(animal, nShared, 1))]; %#ok + end + + if isempty(diffs) + % No paired neurons found — cannot test + ps(i) = NaN; + fprintf(' Pair %s vs %s: no paired neurons. p = NaN.\n', stim1, stim2); + else + % Run hierarchical bootstrap on paired differences + % hierBoot resamples neurons within insertions within animals + bootDiff = hierBoot(diffs, nBoot, insers, animals); + + % Two-sided p-value: proportion of bootstrap means ≤ 0 + % (tests H0: no difference between stimuli) + ps(i) = min(mean(bootDiff <= 0), mean(bootDiff >= 0)) * 2; + + fprintf(' Pair %s vs %s: n=%d neurons, p = %.4f\n', ... + stim1, stim2, numel(diffs), ps(i)); + end + end +end + + +function vsObj = createStimulusObject(NP, stimName, session) +% createStimulusObject Create an analysis object for a given stimulus. +% +% Maps stimulus abbreviations to their analysis class constructors. +% Returns [] if the constructor throws an error. +% +% INPUTS +% NP – Neuropixels data object (from loadNPclassFromTable) +% stimName – stimulus abbreviation (e.g. 'MB', 'SDGm', 'RG') +% session – session number (0 = default, 1+ = specific session) + + vsObj = []; % default: empty on failure + try + key = getObjKey(stimName); % map abbreviation to shared key + if session == 0 + % Default session — no Session argument passed to constructor + switch key + case 'MB', vsObj = linearlyMovingBallAnalysis(NP); + case 'RG', vsObj = rectGridAnalysis(NP); + case 'MBR', vsObj = linearlyMovingBarAnalysis(NP); + case 'SDG', vsObj = StaticDriftingGratingAnalysis(NP); + case 'NI', vsObj = imageAnalysis(NP); + case 'NV', vsObj = movieAnalysis(NP); + case 'FFF', vsObj = fullFieldFlashAnalysis(NP); + otherwise + error('Unknown stimName: %s (key: %s)', stimName, key); + end + else + % Explicit session number passed to constructor + switch key + case 'MB', vsObj = linearlyMovingBallAnalysis(NP, 'Session', session); + case 'RG', vsObj = rectGridAnalysis(NP, 'Session', session); + case 'MBR', vsObj = linearlyMovingBarAnalysis(NP, 'Session', session); + case 'SDG', vsObj = StaticDriftingGratingAnalysis(NP, 'Session', session); + case 'NI', vsObj = imageAnalysis(NP, 'Session', session); + case 'NV', vsObj = movieAnalysis(NP, 'Session', session); + case 'FFF', vsObj = fullFieldFlashAnalysis(NP, 'Session', session); + otherwise + error('Unknown stimName: %s (key: %s)', stimName, key); + end + end + catch ME + fprintf(' Could not create %s Session=%d: %s\n', stimName, session, ME.message); + vsObj = []; % return empty on failure + end +end + + +function key = getObjKey(stimName) +% getObjKey Map stimulus abbreviation to shared analysis-object key. +% SDGm and SDGs share a single StaticDriftingGratingAnalysis object. + switch stimName + case {'SDGm', 'SDGs'}, key = 'SDG'; % both use the same constructor + otherwise, key = stimName; % all others map to themselves + end +end \ No newline at end of file diff --git a/visualStimulationAnalysis/OldVersions/AllExpAnalysisV1.m b/visualStimulationAnalysis/OldVersions/AllExpAnalysisV1.m new file mode 100644 index 0000000..d51b990 --- /dev/null +++ b/visualStimulationAnalysis/OldVersions/AllExpAnalysisV1.m @@ -0,0 +1,1735 @@ +function [tempTable] = AllExpAnalysis(expList, Stims2Comp, params) +% PlotZScoreComparison - Compare z-scores and spike rates across visual stimuli +% across multiple Neuropixels recordings. +% +% Loads pre-computed statistical results (z-scores, p-values, spike rates) +% for each experiment in expList, filters neurons by responsiveness, pools +% data across recordings, runs hierarchical bootstrapping for group-level +% inference, and generates swarm + scatter plots for publication. +% +% INPUTS: +% expList - (1,:) double Row vector of experiment indices from the +% Excel master list. +% Stims2Comp - cell Cell array of stimulus abbreviations defining +% the comparison order. The FIRST element is the +% "anchor" stimulus used to select responsive +% neurons (unless EachStimSignif=true). +% E.g. {'MB','RG','MBR'}. +% params - name-value Optional parameters (see arguments block). +% +% OUTPUT: +% fig - figure handle of the last figure created. +% +% ------------------------------------------------------------------------- +% KNOWN BUGS / ISSUES (see inline BUG comments for exact locations): +% BUG-1 [CRASH] splitapply fails on empty TableStimComp when no units +% pass significance threshold. → Guard added below. +% BUG-2 [LOGIC] fprintf prints recording name BEFORE NP is loaded for +% the current experiment, so iteration 1 always prints the +% name from the pre-loop load (expList(1)). +% BUG-3 [LOGIC] Insertion counter: AnimalI is updated inside the first +% `if Animal~=AnimalI` block, so the second block +% (which also checks Animal~=AnimalI) always sees them as +% equal, and a new animal's first insertion is never counted +% as new unless the insertion number also differs. +% BUG-4 [LOGIC] When SDG is absent, `sumNeurSDG=0` is set (new var) but +% `sumNeurSDGm` and `sumNeurSDGs` keep their last stale +% values, so sumNeurSDGmt{j} / sumNeurSDGst{j} are wrong. +% BUG-5 [DEBUG] `2+2` is a leftover breakpoint stub — does nothing but +% is confusing in published code. +% BUG-6 [STRUCT] S.groupStatsP_ZscoreCompare should be +% S.groupStats.P_ZscoreCompare (inconsistent nesting vs +% the spike-rate equivalent). +% BUG-7 [PREALLOC] totalU, pvalsRG, pvalsMB, pvalsNI, pvalsNV etc. are +% not pre-allocated before the for-loop (unlike zScoresMB +% etc.), causing dynamic growth inside the loop. +% +% SUGGESTIONS: +% SUGG-1 Refactor the 7-stimulus × 3-method conditional blocks into a +% helper function (e.g. runStimAnalysis(vs, method, params)) to +% drastically reduce code length and risk of copy-paste bugs. +% SUGG-2 Replace the -inf sentinel for absent stimuli with NaN. NaN +% propagates safely through most MATLAB statistics functions; +% -inf does not, and requires scattered special-case filtering. +% SUGG-3 For a publication, consider applying FDR correction +% (Benjamini-Hochberg) across neurons before applying the +% significance threshold, rather than using raw p < threshold. +% SUGG-4 For scatter plots, if spike rates span >1 order of magnitude, +% log-scaled axes improve readability (set(gca,'XScale','log',...)). +% SUGG-5 randiColors (subsampling index from plotSwarmBootstrapWithComparisons) +% is reused in scatter plots. If the swarm function subsamples +% non-uniformly, the scatter could misrepresent the distribution. +% Either plot all points or make subsampling explicit and documented. +% SUGG-6 The `eval(zscoresC1{1})` pattern is fragile. Prefer a struct +% or containers.Map to look up variables by name. + +% ------------------------------------------------------------------------- +arguments + expList (1,:) double % Row vector of experiment IDs from master Excel table + Stims2Comp cell % Cell array: comparison order, e.g. {'MB','RG','MBR'}. + % First element selects the anchor stimulus for + % filtering responsive neurons. + params.threshold = 0.05 % p-value significance threshold for responsiveness + params.diffResp = false % If true, use spike-rate difference (resp-baseline) + % instead of absolute response rate + params.overwrite = false % If true, recompute and overwrite saved combined file + params.StimsPresent = {'MB','RG'} % Stimuli present in ALL recordings (minimum set) + params.StimsNotPresent = {} % Stimuli known to be absent (currently unused) + params.StimsToCompare = {} % Two-element cell: which stimuli to use in the scatter + % sub-panel (default: 1st and 2nd of Stims2Comp) + params.overwriteResponse = false % Force re-run of ResponseWindow analysis + params.overwriteStats = false % Force re-run of per-neuron statistics + params.overwriteGroupStats = false % Force re-run of group-level bootstrapping + params.RespDurationWin = 100 % Duration (ms) of the response window (passed down) + params.shuffles = 2000 % Number of shuffles / bootstrap iterations for + % per-neuron statistics + params.StatMethod = 'ObsWindow' % Statistical method: + % 'ObsWindow' – shuffling analysis + % 'bootsrapRespBase' – per-neuron bootstrap + % 'maxPermuteTest' – permutation test + params.ignoreNonSignif = false % When true, zero out z-scores for neurons that are + % not significant for the non-anchor stimuli + params.EachStimSignif = false % If true, use each stimulus's own responsive neurons + % (default: use anchor stimulus's responsive neurons) + params.ComparePairs = {} % Cell of stimulus pairs for pairwise comparison. + % Recommended over the multi-stimulus mode. + % E.g. {'MB','RG'} or {'MB','RG';'MB','MBR'} + params.PaperFig logical = false % If true, save figures via vs.printFig + params.useZmean logical = true % Instead of the spikerate from pvals response, use the max response-baseline - null distribution +end + +% ========================================================================= +% SECTION 1 – INITIALISE BOOKKEEPING VARIABLES +% ========================================================================= + +% Running counters for unique animals and probe insertions encountered +animal = 0; +insertion = 0; + +% Pre-allocate per-experiment cell arrays (one cell per experiment in expList) +n = numel(expList); % total number of experiments to process + +% Animal/insertion labels for each neuron (repeated per neuron count) +animalVector = cell(1, n); +insertionVector = cell(1, n); + +% Z-scores filtered to neurons responsive to the anchor stimulus +zScoresMB = cell(1, n); +zScoresRG = cell(1, n); +zScoresMBR = cell(1, n); +zScoresFFF = cell(1, n); +zScoresSDGm = cell(1, n); % drifting gratings – moving condition +zScoresNI = cell(1, n); + +% Spike rates (peak across directions/speeds) for anchor-responsive neurons +spKrMB = cell(1, n); +spKrRG = cell(1, n); +spKrMBR = cell(1, n); +spKrFFF = cell(1, n); +spKrSDGm = cell(1, n); + +% Spike-rate difference (response – baseline) for anchor-responsive neurons +diffSpkMB = cell(1, n); +diffSpkRG = cell(1, n); +diffSpkMBR = cell(1, n); +diffSpkFFF = cell(1, n); +diffSpkSDGm = cell(1, n); + +% Natural image / video variables (declared but not pre-sized above) +spKrNI = cell(1, n); +spKrNV = cell(1, n); +diffSpkNI = cell(1, n); +diffSpkNV = cell(1, n); + +% BUG-7: The following accumulator cell arrays are NOT pre-allocated here. +% They grow dynamically inside the loop. Add pre-allocation if +% performance matters (e.g. pvalsRG = cell(1,n); etc.). + +% Tracker strings for detecting animal/insertion changes between experiments +j = 1; % experiment counter (1-based index into cell arrays) +AnimalI = ""; % animal ID seen in the previous iteration +InsertionI = 0; % insertion number seen in the previous iteration + +% ========================================================================= +% SECTION 2 – DETERMINE OUTPUT FILE PATH AND WHETHER THE LOOP IS NEEDED +% ========================================================================= + +% Load the first experiment to extract file-path information and response window +NP = loadNPclassFromTable(expList(1)); % load Neuropixels recording object +vs = linearlyMovingBallAnalysis(NP); % run moving-ball analysis (for path info) + +% Read response window used in moving-ball analysis (assumed identical across +% experiments — this assumption is NOT verified across experiments) +MBvs = vs.ResponseWindow; % cache the response-window struct + +% Build the filename for the pooled/combined output .mat file +nameOfFile = sprintf('\\Ex_%d-%d_Combined_Neural_responses_%s_filtered.mat', ... + expList(1), expList(end), Stims2Comp{1}); + +% Extract base path up to (and including) the 'lizards' folder +p = extractBefore(vs.getAnalysisFileName, 'lizards'); +p = [p 'lizards']; + +% Create the 'Combined_lizard_analysis' subdirectory if it does not exist +if ~exist([p '\Combined_lizard_analysis'], 'dir') + cd(p) + mkdir Combined_lizard_analysis +end +saveDir = [p '\Combined_lizard_analysis']; % full path to output folder + +% Decide whether to run the per-experiment for-loop: +% • Skip if a saved file exists with the same experiment list AND overwrite=false +% • Otherwise run the loop to build and save pooled data +if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite + S = load([saveDir nameOfFile]); % load previously saved pooled data + expList2 = S.expList; % experiment list stored inside the file + + if isequal(expList2, expList) + forloop = false; % saved data matches → skip re-processing + else + forloop = true; % experiment list changed → must re-process + end +else + forloop = true; % file does not exist or overwrite requested +end + +% ========================================================================= +% SECTION 3 – INITIALISE LONG-FORMAT TABLES +% ========================================================================= + +% longTablePairComp: one row per neuron × stimulus for the pairwise comparison. +% Columns: animal ID, insertion ID, stimulus name, neuron ID, +% z-score, and spike rate. +longTablePairComp = table( ... + categorical.empty(0,1), ... % animal + categorical.empty(0,1), ... % insertion + categorical.empty(0,1), ... % stimulus + categorical.empty(0,1), ... % NeurID + double.empty(0,1), ... % Z-score + double.empty(0,1), ... % SpkR + 'VariableNames', {'animal','insertion','stimulus','NeurID','Z-score','SpkR'}); + +% longTable: one row per insertion × stimulus; stores counts of responsive +% and total somatic neurons for fraction-responsive analysis. +longTable = table( ... + categorical.empty(0,1), ... % animal + categorical.empty(0,1), ... % insertion + categorical.empty(0,1), ... % stimulus + double.empty(0,1), ... % respNeur – number of responsive neurons + double.empty(0,1), ... % totalSomaticN – total neurons in recording + 'VariableNames', {'animal','insertion','stimulus','respNeur','totalSomaticN'}); + +% ========================================================================= +% SECTION 4 – PER-EXPERIMENT FOR-LOOP +% ========================================================================= + +if forloop + for ex = expList % iterate over each experiment ID + + % BUG-2: fprintf is called BEFORE NP is loaded for the current + % experiment. On the first iteration this prints the name + % from expList(1) (loaded before the loop), not from `ex`. + % FIX: move this fprintf to AFTER the loadNPclassFromTable call. + fprintf('Processing recording: %s .\n', NP.recordingName) + + % Load the Neuropixels recording object for this experiment + NP = loadNPclassFromTable(ex); + + % Instantiate analysis objects for the two stimuli present in all sessions + vs = linearlyMovingBallAnalysis(NP); % moving ball (MB) + vsR = rectGridAnalysis(NP); % rectangular grid (RG) + + % Extract animal ID using regex (expects pattern 'PV##' in filename) + Animal = string(regexp(vs.getAnalysisFileName, 'PV\d+', 'match', 'once')); + + % Add placeholder rows to longTable for MB and RG (always present) + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("MB"), 0, 0}; + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("RG"), 0, 0}; + + % ------------------------------------------------------------------ + % 4a – Try to load optional stimuli; fall back to a dummy analysis + % object (vsR / vs) when the stimulus was not shown, to keep + % all downstream variable names defined. + % ------------------------------------------------------------------ + + % Moving Bar (MBR) + try + vsBr = linearlyMovingBarAnalysis(NP); + params.StimsPresent{3} = 'MBR'; + if isempty(vsBr.VST) + error('Moving Bar stimulus not found.\n') + else + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("MBR"), 0, 0}; + end + catch + params.StimsPresent{3} = ''; % mark as absent + fprintf('Moving Bar stimulus not found.\n') + vsBr = linearlyMovingBallAnalysis(NP); % dummy placeholder (same class) + end + + % Static / Drifting Gratings (SDG) + try + vsG = StaticDriftingGratingAnalysis(NP); + params.StimsPresent{4} = 'SDGm'; + if isempty(vsG.VST) + error('Gratings stimulus not found.\n') + else + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("SDGm"), 0, 0}; + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("SDGs"), 0, 0}; + end + catch + params.StimsPresent{4} = ''; + fprintf('Gratings stimulus not found.\n') + vsG = rectGridAnalysis(NP); % dummy placeholder + end + + % Natural Images (NI) + try + vsNI = imageAnalysis(NP); + params.StimsPresent{5} = 'NI'; + if isempty(vsNI.VST) + error('Natural images stimulus not found.\n') + else + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("NI"), 0, 0}; + end + catch + params.StimsPresent{5} = ''; + fprintf('Natural images stimulus not found.\n') + vsNI = rectGridAnalysis(NP); % dummy placeholder + end + + % Natural Video (NV) + try + vsNV = movieAnalysis(NP); + params.StimsPresent{6} = 'NV'; + if isempty(vsNV.VST) + error('Natural video stimulus not found.\n') + else + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("NV"), 0, 0}; + end + catch + params.StimsPresent{6} = ''; + fprintf('Natural video stimulus not found.\n') + vsNV = rectGridAnalysis(NP); % dummy placeholder + end + + % Full-Field Flash (FFF) + try + vsFFF = fullFieldFlashAnalysis(NP); + params.StimsPresent{7} = 'FFF'; + if isempty(vsFFF.VST) + error('FFF stimulus not found.\n') + else + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("FFF"), 0, 0}; + end + catch + params.StimsPresent{7} = ''; + fprintf('FFF stimulus not found.\n') + vsFFF = rectGridAnalysis(NP); % dummy placeholder + end + + % ------------------------------------------------------------------ + % 4b – Run response-window and statistical analyses for each stimulus. + % Only compute stats for stimuli that are (a) present AND + % (b) included in Stims2Comp. For absent/excluded stimuli the + % analysis object already holds dummy data, so just call + % ResponseWindow without arguments to load any cached result. + % + % SUGG-1: This block repeats ~7 times with identical structure. + % Wrap in a helper: runStimAnalysis(vsObj, method, params). + % ------------------------------------------------------------------ + + % Moving Ball + if isequal(params.StimsPresent{1},'') || ~ismember(params.StimsPresent{1}, Stims2Comp) + vs.ResponseWindow; % load cached window only (no recompute) + else + vs.ResponseWindow('overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + if isequal(params.StatMethod,'ObsWindow') + vs.ShufflingAnalysis('overwrite', params.overwriteStats, ... + "N_bootstrap", params.shuffles); + elseif isequal(params.StatMethod,'bootsrapRespBase') + vs.BootstrapPerNeuron('overwrite', params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vs.StatisticsPerNeuron('overwrite', params.overwriteStats); + end + end + + % Rect Grid + if isequal(params.StimsPresent{2},'') || ~ismember(params.StimsPresent{2}, Stims2Comp) + vsR.ResponseWindow; + else + vsR.ResponseWindow('overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + if isequal(params.StatMethod,'ObsWindow') + vsR.ShufflingAnalysis('overwrite', params.overwriteStats, ... + "N_bootstrap", params.shuffles); + elseif isequal(params.StatMethod,'bootsrapRespBase') + vsR.BootstrapPerNeuron('overwrite', params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vsR.StatisticsPerNeuron('overwrite', params.overwriteStats); + end + end + + % Moving Bar + if isequal(params.StimsPresent{3},'') || ~ismember(params.StimsPresent{3}, Stims2Comp) + vsBr.ResponseWindow; + else + vsBr.ResponseWindow('overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + if isequal(params.StatMethod,'ObsWindow') + vsBr.ShufflingAnalysis('overwrite', params.overwriteStats, ... + "N_bootstrap", params.shuffles); + elseif isequal(params.StatMethod,'bootsrapRespBase') + vsBr.BootstrapPerNeuron('overwrite', params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vsBr.StatisticsPerNeuron('overwrite', params.overwriteStats); + end + end + + % Gratings + if isequal(params.StimsPresent{4},'') || ~ismember(params.StimsPresent{4}, Stims2Comp) + vsG.ResponseWindow; + else + vsG.ResponseWindow('overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + if isequal(params.StatMethod,'ObsWindow') + vsG.ShufflingAnalysis('overwrite', params.overwriteStats, ... + "N_bootstrap", params.shuffles); + elseif isequal(params.StatMethod,'bootsrapRespBase') + vsG.BootstrapPerNeuron('overwrite', params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vsG.StatisticsPerNeuron('overwrite', params.overwriteStats); + end + end + + % Natural Images + if isequal(params.StimsPresent{5},'') || ~ismember(params.StimsPresent{5}, Stims2Comp) + vsNI.ResponseWindow; + else + vsNI.ResponseWindow('overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + if isequal(params.StatMethod,'ObsWindow') + vsNI.ShufflingAnalysis('overwrite', params.overwriteStats, ... + "N_bootstrap", params.shuffles); + elseif isequal(params.StatMethod,'bootsrapRespBase') + vsNI.BootstrapPerNeuron('overwrite', params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vsNI.StatisticsPerNeuron('overwrite', params.overwriteStats); + end + end + + % Natural Video + if isequal(params.StimsPresent{6},'') || ~ismember(params.StimsPresent{6}, Stims2Comp) + vsNV.ResponseWindow; + else + vsNV.ResponseWindow('overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + if isequal(params.StatMethod,'ObsWindow') + vsNV.ShufflingAnalysis('overwrite', params.overwriteStats, ... + "N_bootstrap", params.shuffles); + elseif isequal(params.StatMethod,'bootsrapRespBase') + vsNV.BootstrapPerNeuron('overwrite', params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vsNV.StatisticsPerNeuron('overwrite', params.overwriteStats); + end + end + + % Full-Field Flash + if isequal(params.StimsPresent{7},'') || ~ismember(params.StimsPresent{7}, Stims2Comp) + vsFFF.ResponseWindow; + else + vsFFF.ResponseWindow('overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + if isequal(params.StatMethod,'ObsWindow') + vsFFF.ShufflingAnalysis('overwrite', params.overwriteStats, ... + "N_bootstrap", params.shuffles); + elseif isequal(params.StatMethod,'bootsrapRespBase') + vsFFF.BootstrapPerNeuron('overwrite', params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vsFFF.StatisticsPerNeuron('overwrite', params.overwriteStats); + end + end + + % ------------------------------------------------------------------ + % 4c – Retrieve statistics structs (dispatch on chosen method) + % ------------------------------------------------------------------ + + if isequal(params.StatMethod,'ObsWindow') + statsMB = vs.ShufflingAnalysis; + statsRG = vsR.ShufflingAnalysis; + statsMBR = vsBr.ShufflingAnalysis; + statsSDG = vsG.ShufflingAnalysis; + statsFFF = vsFFF.ShufflingAnalysis; + statsNI = vsNI.ShufflingAnalysis; + statsNV = vsNV.ShufflingAnalysis; + elseif isequal(params.StatMethod,'bootsrapRespBase') + statsMB = vs.BootstrapPerNeuron; + statsRG = vsR.BootstrapPerNeuron; + statsMBR = vsBr.BootstrapPerNeuron; + statsSDG = vsG.BootstrapPerNeuron; + statsFFF = vsFFF.BootstrapPerNeuron; + statsNI = vsNI.BootstrapPerNeuron; + statsNV = vsNV.BootstrapPerNeuron; + else % maxPermuteTest + statsMB = vs.StatisticsPerNeuron; + statsRG = vsR.StatisticsPerNeuron; + statsMBR = vsBr.StatisticsPerNeuron; + statsSDG = vsG.StatisticsPerNeuron; + statsFFF = vsFFF.StatisticsPerNeuron; + statsNI = vsNI.StatisticsPerNeuron; + statsNV = vsNV.StatisticsPerNeuron; + end + + % Retrieve response-window structs (used for spike-rate / diff columns) + rwRG = vsR.ResponseWindow; + rwMB = vs.ResponseWindow; + rwMBR = vsBr.ResponseWindow; + rwFFF = vsFFF.ResponseWindow; + rwSDG = vsG.ResponseWindow; + rwNI = vsNI.ResponseWindow; + rwNV = vsNV.ResponseWindow; + + % ------------------------------------------------------------------ + % 4d – Extract z-scores, p-values, and spike rates per stimulus + % ------------------------------------------------------------------ + + % --- Moving Ball --- + % Use Speed1 by default; overwrite with Speed2 if it exists + % (Speed2 is faster; the convention is to use the most salient speed) + zScores_MB = statsMB.Speed1.ZScoreU; + pValuesMB = statsMB.Speed1.pvalsResponse; + if params.useZmean + spkR_MB = statsMB.Speed1.z_mean; + else + spkR_MB = max(rwMB.Speed1.NeuronVals(:,:,4), [], 2); % max across directions, col 4 = resp. rate + end + + spkDiff_MB = max(rwMB.Speed1.NeuronVals(:,:,5), [], 2); % col 5 = response – baseline + + if isfield(statsMB, 'Speed2') % if a second (faster) speed was presented + zScores_MB = statsMB.Speed2.ZScoreU; + pValuesMB = statsMB.Speed2.pvalsResponse; + if params.useZmean + spkR_MB = statsMB.Speed1.z_mean'; + else + spkR_MB = max(rwMB.Speed1.NeuronVals(:,:,4), [], 2); % max across directions, col 4 = resp. rate + end + spkDiff_MB = max(rwMB.Speed2.NeuronVals(:,:,5), [], 2); + end + + % Store total unit count for this recording + % BUG-7: totalU not pre-allocated; grows dynamically + totalU{j} = numel(zScores_MB); + + % --- Rect Grid --- + zScores_RG = statsRG.ZScoreU; + pValuesRG = statsRG.pvalsResponse; + if params.useZmean + spkR_RG = statsRG.z_mean'; + else + spkR_RG = max(rwRG.NeuronVals(:,:,4), [], 2); % max across directions, col 4 = resp. rate + end + spkDiff_RG = max(rwRG.NeuronVals(:,:,5), [], 2); + + % --- Moving Bar --- + zScores_MBR = statsMBR.Speed1.ZScoreU; + pValuesMBR = statsMBR.Speed1.pvalsResponse; + spkR_MBR = max(rwMBR.Speed1.NeuronVals(:,:,4), [], 2); + spkDiff_MBR = max(rwMBR.Speed1.NeuronVals(:,:,5), [], 2); + + % --- Full-Field Flash --- + zScores_FFF = statsFFF.ZScoreU; + pValuesFFF = statsFFF.pvalsResponse; + spkR_FFF = max(rwFFF.NeuronVals(:,:,4), [], 2); + spkDiff_FFF = max(rwFFF.NeuronVals(:,:,5), [], 2); + + % --- Drifting / Static Gratings --- + % When SDG is absent, statsSDG holds dummy RG data (placeholder object). + % When present the struct has a .Moving and .Static subfield. + if isequal(params.StimsPresent{4},'') + % SDG not recorded: use dummy data (will be set to -inf below) + zScores_SDGm = statsSDG.ZScoreU; + pValuesSDGm = statsSDG.pvalsResponse; + spkR_SDGm = max(rwSDG.NeuronVals(:,:,4), [], 2); + spkDiff_SDGm = max(rwSDG.NeuronVals(:,:,5), [], 2); + + zScores_SDGs = statsSDG.ZScoreU; % same dummy for static + pValuesSDGs = statsSDG.pvalsResponse; + spkR_SDGs = max(rwSDG.NeuronVals(:,:,4), [], 2); + spkDiff_SDGs = max(rwSDG.NeuronVals(:,:,5), [], 2); + else + % SDG recorded: separate moving and static conditions + zScores_SDGm = statsSDG.Moving.ZScoreU; + pValuesSDGm = statsSDG.Moving.pvalsResponse; + spkR_SDGm = max(rwSDG.Moving.NeuronVals(:,:,4), [], 2); + spkDiff_SDGm = max(rwSDG.Moving.NeuronVals(:,:,5), [], 2); + + zScores_SDGs = statsSDG.Static.ZScoreU; + pValuesSDGs = statsSDG.Static.pvalsResponse; + spkR_SDGs = max(rwSDG.Static.NeuronVals(:,:,4), [], 2); + spkDiff_SDGs = max(rwSDG.Static.NeuronVals(:,:,5), [], 2); + end + + % --- Natural Images --- + zScores_NI = statsNI.ZScoreU; + pValuesNI = statsNI.pvalsResponse; + spkR_NI = max(rwNI.NeuronVals(:,:,4), [], 2); + spkDiff_NI = max(rwNI.NeuronVals(:,:,5), [], 2); + + % --- Natural Video --- + zScores_NV = statsNV.ZScoreU; + pValuesNV = statsNV.pvalsResponse; + spkR_NV = max(rwNV.NeuronVals(:,:,4), [], 2); + spkDiff_NV = max(rwNV.NeuronVals(:,:,5), [], 2); + + % ------------------------------------------------------------------ + % 4e – For non-ObsWindow methods, overwrite spike rates with the + % mean observed response stored in the stats struct + % (ObsWindow stores rates in rwXX; others store in stats struct) + % ------------------------------------------------------------------ + + if isequal(params.StatMethod,'bootsrapRespBase') %Take mean across all responses + spkR_NV = mean(statsNV.ObsResponse, 1)'; + spkR_NI = mean(statsNI.ObsResponse, 1)'; + + try + spkR_SDGs = mean(statsSDG.Static.ObsResponse, 1)'; + spkR_SDGm = mean(statsSDG.Moving.ObsResponse, 1)'; + catch + % Fallback: single-condition SDG struct (older data format) + spkR_SDGs = mean(statsSDG.ObsResponse, 1)'; + spkR_SDGm = mean(statsSDG.ObsResponse, 1)'; + end + + spkR_FFF = mean(statsFFF.ObsResponse, 1)'; + + try + spkR_MBR = mean(statsMBR.Speed1.ObsResponse, 1)'; + catch + spkR_MBR = mean(statsMBR.ObsResponse, 1)'; + end + + spkR_RG = mean(statsRG.ObsResponse, 1)'; + + if isfield(statsMB, 'Speed2') + spkR_MB = mean(statsMB.Speed2.ObsResponse)'; + else + spkR_MB = mean(statsMB.Speed1.ObsResponse)'; + end + end + + % ------------------------------------------------------------------ + % 4f – Optional: suppress z-scores for neurons non-significant in + % stimuli OTHER than the anchor by setting them to -1000 + % (acts as a hard "must respond to everything" filter) + % ------------------------------------------------------------------ + + if params.ignoreNonSignif + zScores_NV(pValuesNV > params.threshold) = -1000; + zScores_NI(pValuesNI > params.threshold) = -1000; + zScores_SDGs(pValuesSDGs > params.threshold) = -1000; + zScores_SDGm(pValuesSDGm > params.threshold) = -1000; + zScores_FFF(pValuesFFF > params.threshold) = -1000; + zScores_MBR(pValuesMBR > params.threshold) = -1000; + zScores_RG(pValuesRG > params.threshold) = -1000; + zScores_MB(pValuesMB > params.threshold) = -1000; + end + + % ------------------------------------------------------------------ + % 4g – Identify the anchor p-value vector using the first element of + % Stims2Comp (or the ComparePairs cell) via name matching + % ------------------------------------------------------------------ + + % Build a 2-row lookup: row 1 = variable names, row 2 = actual vectors + pvals = {'pValuesMB','pValuesRG','pValuesMBR','pValuesFFF', ... + 'pValuesSDGm','pValuesSDGs','pValuesNI','pValuesNV'; ... + pValuesMB, pValuesRG, pValuesMBR, pValuesFFF, ... + pValuesSDGm, pValuesSDGs, pValuesNI, pValuesNV}; + + % Find column whose name ends with the anchor stimulus label + [~, col] = find(cellfun(@(x) ischar(x) && endsWith(x, Stims2Comp{1}), pvals)); + % `row` is unused here — [~,col] is sufficient + + % ------------------------------------------------------------------ + % 4h – Build pairwise comparison table entries (ComparePairs mode) + % ------------------------------------------------------------------ + + for i = 1:numel(params.ComparePairs) + % Find the column in pvals whose name ends with the i-th pair member + [~, colPair] = find(cellfun(@(x) ischar(x) && endsWith(x, params.ComparePairs{i}), pvals)); + pvalsC{i} = pvals{2, colPair}; % store the actual p-value vector + end + + % Use `who` + eval to look up z-score and spike-rate variables by name + % SUGG-6: Replace eval with a struct lookup for robustness + vars = who; + + % Get z-scores for the first stimulus in the pair + zscoresC1 = vars(contains(vars, sprintf('zScores_%s', params.ComparePairs{1}))); + zscoresC1 = eval(zscoresC1{1}); + unitIDs = 1:numel(zscoresC1); + + % Filter to neurons significant for EITHER stimulus in the pair + sigMask = pvalsC{1} < params.threshold | pvalsC{2} < params.threshold; + zscoresC1 = zscoresC1(sigMask); + + spkRC1 = vars(contains(vars, sprintf('spkR_%s', params.ComparePairs{1}))); + spkRC1 = eval(spkRC1{1}); + spkRC1 = spkRC1(sigMask); + unitIDs = unitIDs(sigMask); % keep only IDs for significant neurons + + % Get z-scores for the second stimulus in the pair (same mask) + zscoresC2 = vars(contains(vars, sprintf('zScores_%s', params.ComparePairs{2}))); + zscoresC2 = eval(zscoresC2{1}); + zscoresC2 = zscoresC2(sigMask); + + spkRC2 = vars(contains(vars, sprintf('spkR_%s', params.ComparePairs{2}))); + spkRC2 = eval(spkRC2{1}); + spkRC2 = spkRC2(sigMask); + + % Append rows to longTablePairComp for this recording if any units found + if ~isempty(unitIDs) + try + TableC1 = table( ... + categorical(cellstr(repmat(Animal, numel(unitIDs), 1))), ... + categorical(repmat(j, numel(unitIDs), 1)), ... + categorical(cellstr(repmat(params.ComparePairs{1}, numel(unitIDs), 1))), ... + categorical(unitIDs)', zscoresC1', spkRC1, ... + 'VariableNames', {'animal','insertion','stimulus','NeurID','Z-score','SpkR'}); + + catch + TableC1 = table( ... + categorical(cellstr(repmat(Animal, numel(unitIDs), 1))), ... + categorical(repmat(j, numel(unitIDs), 1)), ... + categorical(cellstr(repmat(params.ComparePairs{1}, numel(unitIDs), 1))), ... + categorical(unitIDs)', zscoresC1', spkRC1', ... + 'VariableNames', {'animal','insertion','stimulus','NeurID','Z-score','SpkR'}); + end + + TableC2 = table( ... + categorical(cellstr(repmat(Animal, numel(unitIDs), 1))), ... + categorical(repmat(j, numel(unitIDs), 1)), ... + categorical(cellstr(repmat(params.ComparePairs{2}, numel(unitIDs), 1))), ... + categorical(unitIDs)', zscoresC2', spkRC2, ... + 'VariableNames', {'animal','insertion','stimulus','NeurID','Z-score','SpkR'}); + + longTablePairComp = [longTablePairComp; TableC1; TableC2]; + end + + % The anchor p-value vector (for filtering neurons in all stimuli below) + pvalsStimSelected = pvals{2, col}; + + % ------------------------------------------------------------------ + % 4i – Filter each stimulus's data to anchor-responsive neurons + % and compute "general" (self-responsive) neuron counts + % ------------------------------------------------------------------ + % Convention: suffix 's' = filtered to anchor-responsive neurons + % suffix 'g' = filtered to self-responsive neurons + % respIndexes accumulates union of responsive neuron indices across stims + + respIndexes = []; % will hold all neuron indices responsive to any stim + + % ---- Moving Ball ---- + % Anchor-responsive subset + zScores_MBs = zScores_MB( pvalsStimSelected <= params.threshold); + spkR_MBs = spkR_MB( pvalsStimSelected <= params.threshold); + spkDiff_MBs = spkDiff_MB( pvalsStimSelected <= params.threshold); + pvals_MB = pValuesMB( pvalsStimSelected <= params.threshold); + + % Self-responsive subset (significant for MB regardless of anchor) + zScores_MBg = zScores_MB( pValuesMB <= params.threshold); + sumNeurMB = numel(zScores_MBg); % count of MB-responsive neurons + spkR_MBg = spkR_MB( pValuesMB <= params.threshold); + spkDiff_MBg = spkDiff_MB( pValuesMB <= params.threshold); + respIndexes = [respIndexes, find(pValuesMB <= params.threshold)]; + + % Update longTable with responsive / total counts for this insertion × MB + try + idx = (longTable.insertion == categorical(j)) & ... + (longTable.stimulus == categorical("MB")); + longTable.respNeur(idx) = sumNeurMB; + longTable.totalSomaticN(idx) = numel(pValuesMB); + end + + % ---- Rect Grid ---- + zScores_RGs = zScores_RG( pvalsStimSelected <= params.threshold); + spkR_RGs = spkR_RG( pvalsStimSelected <= params.threshold); + spkDiff_RGs = spkDiff_RG(pvalsStimSelected <= params.threshold); + pvals_RG = pValuesRG( pvalsStimSelected <= params.threshold); + + zScores_RGg = zScores_RG( pValuesRG <= params.threshold); + sumNeurRG = numel(zScores_RGg); + spkR_RGg = spkR_RG( pValuesRG <= params.threshold); + spkDiff_RGg = spkDiff_RG( pValuesRG <= params.threshold); + respIndexes = [respIndexes, find(pValuesRG <= params.threshold)]; + + try + idx = (longTable.insertion == categorical(j)) & ... + (longTable.stimulus == categorical("RG")); + longTable.respNeur(idx) = sumNeurRG; + longTable.totalSomaticN(idx) = numel(pValuesMB); % total = same for all rows + end + + % If RG was not recorded, overwrite with -inf sentinel + % SUGG-2: NaN is safer than -inf for absent data + if isequal(params.StimsPresent{2},'') + zScores_RGs = zScores_RG - inf; + spkR_RGs = zScores_RG - inf; + spkDiff_RGs = zScores_RG - inf; + pvals_RG = zScores_RG - inf; + sumNeurRG = 0; + zScores_RGg = zScores_RGg - inf; + spkR_RGg = spkR_RGg - inf; + spkDiff_RGg = spkDiff_RGg - inf; + end + + % ---- Moving Bar ---- + zScores_MBRs = zScores_MBR( pvalsStimSelected <= params.threshold); + spkR_MBRs = spkR_MBR( pvalsStimSelected <= params.threshold); + spkDiff_MBRs = spkDiff_MBR(pvalsStimSelected <= params.threshold); + pvals_MBR = pValuesMBR( pvalsStimSelected <= params.threshold); + + zScores_MBRg = zScores_MBR( pValuesMBR <= params.threshold); + sumNeurMBR = numel(zScores_MBRg); + spkR_MBRg = spkR_MBR( pValuesMBR <= params.threshold); + spkDiff_MBRg = spkDiff_MBR( pValuesMBR <= params.threshold); + respIndexes = [respIndexes, find(pValuesMBR <= params.threshold)]; + + try + idx = (longTable.insertion == categorical(j)) & ... + (longTable.stimulus == categorical("MBR")); + longTable.respNeur(idx) = sumNeurMBR; + longTable.totalSomaticN(idx) = numel(pValuesMB); + end + + if isequal(params.StimsPresent{3},'') + zScores_MBRs = zScores_MBRs - inf; + spkR_MBRs = zScores_MBRs - inf; % NOTE: uses already -inf'd zscores + spkDiff_MBRs = zScores_MBRs - inf; + pvals_MBR = zScores_MBRs - inf; + sumNeurMBR = 0; + zScores_MBRg = zScores_MBRg - inf; + spkR_MBRg = zScores_MBRg - inf; + spkDiff_MBRg = zScores_MBRg - inf; + end + + % ---- Gratings (moving and static) ---- + zScores_SDGms = zScores_SDGm( pvalsStimSelected <= params.threshold); + spkR_SDGms = spkR_SDGm( pvalsStimSelected <= params.threshold); + spkDiff_SDGms = spkDiff_SDGm(pvalsStimSelected <= params.threshold); + pvals_SDGm = pValuesSDGm( pvalsStimSelected <= params.threshold); + + zScores_SDGss = zScores_SDGs( pvalsStimSelected <= params.threshold); + spkR_SDGss = spkR_SDGs( pvalsStimSelected <= params.threshold); + spkDiff_SDGss = spkDiff_SDGs(pvalsStimSelected <= params.threshold); + pvals_SDGs = pValuesSDGs( pvalsStimSelected <= params.threshold); + + zScores_SDGmg = zScores_SDGm( pValuesSDGm <= params.threshold); + sumNeurSDGm = numel(zScores_SDGmg); + spkR_SDGmg = spkR_SDGm( pValuesSDGm <= params.threshold); + spkDiff_SDGmg = spkDiff_SDGm( pValuesSDGm <= params.threshold); + respIndexes = [respIndexes, find(pValuesSDGm <= params.threshold)]; + + zScores_SDGsg = zScores_SDGs( pValuesSDGs <= params.threshold); + sumNeurSDGs = numel(zScores_SDGsg); + spkR_SDGsg = spkR_SDGs( pValuesSDGs <= params.threshold); + spkDiff_SDGsg = spkDiff_SDGs( pValuesSDGs <= params.threshold); + respIndexes = [respIndexes, find(pValuesSDGs <= params.threshold)]; + + try + idx = (longTable.insertion == categorical(j)) & ... + (longTable.stimulus == categorical("SDGm")); + longTable.respNeur(idx) = sumNeurSDGm; + longTable.totalSomaticN(idx) = numel(pValuesMB); + end + try + idx = (longTable.insertion == categorical(j)) & ... + (longTable.stimulus == categorical("SDGs")); + longTable.respNeur(idx) = sumNeurSDGs; + longTable.totalSomaticN(idx) = numel(pValuesMB); + end + + if isequal(params.StimsPresent{4},'') + zScores_SDGss = zScores_SDGss - inf; + spkR_SDGss = spkR_SDGss - inf; + spkDiff_SDGss = spkDiff_SDGss - inf; + pvals_SDGs = pvals_SDGs - inf; + + zScores_SDGms = zScores_SDGms - inf; + spkR_SDGms = spkR_SDGms - inf; + spkDiff_SDGms = spkDiff_SDGms - inf; + pvals_SDGm = pvals_SDGm - inf; + + % BUG-4: sumNeurSDG (new var) is set to 0 here, but + % sumNeurSDGm and sumNeurSDGs are NOT reset to 0. + % sumNeurSDGmt{j} and sumNeurSDGst{j} below will then + % store stale values from the previous iteration. + % FIX: replace the line below with: + % sumNeurSDGm = 0; sumNeurSDGs = 0; + sumNeurSDGm = 0; % FIX applied (was: sumNeurSDG = 0) + sumNeurSDGs = 0; % FIX applied + + zScores_SDGmg = zScores_SDGmg - inf; + spkR_SDGmg = zScores_SDGmg - inf; + spkDiff_SDGmg = zScores_SDGmg - inf; + + zScores_SDGsg = zScores_SDGsg - inf; + spkR_SDGsg = zScores_SDGsg - inf; + spkDiff_SDGsg = zScores_SDGsg - inf; + end + + % ---- Full-Field Flash ---- + zScores_FFFs = zScores_FFF( pvalsStimSelected <= params.threshold); + spkR_FFFs = spkR_FFF( pvalsStimSelected <= params.threshold); + spkDiff_FFFs = spkDiff_FFF(pvalsStimSelected <= params.threshold); + pvals_FFF = pValuesFFF( pvalsStimSelected <= params.threshold); + + zScores_FFFg = zScores_FFF( pValuesFFF <= params.threshold); + sumNeurFFF = numel(zScores_FFFg); + spkR_FFFg = spkR_FFF( pValuesFFF <= params.threshold); + spkDiff_FFFg = spkDiff_FFF( pValuesFFF <= params.threshold); + respIndexes = [respIndexes, find(pValuesFFF <= params.threshold)]; + + try + idx = (longTable.insertion == categorical(j)) & ... + (longTable.stimulus == categorical("FFF")); + longTable.respNeur(idx) = sumNeurFFF; + longTable.totalSomaticN(idx) = numel(pValuesMB); + end + + if isequal(params.StimsPresent{7},'') + zScores_FFFs = zScores_FFFs - inf; + spkR_FFFs = spkR_FFFs - inf; + spkDiff_FFFs = spkDiff_FFFs - inf; + pvals_FFF = pvals_FFF - inf; + sumNeurFFF = 0; + zScores_FFFg = zScores_FFFg - inf; + spkR_FFFg = zScores_FFFg - inf; + spkDiff_FFFg = zScores_FFFg - inf; + end + + % ---- Natural Images ---- + zScores_NIs = zScores_NI( pvalsStimSelected <= params.threshold); + spkR_NIs = spkR_NI( pvalsStimSelected <= params.threshold); + spkDiff_NIs = spkDiff_NI(pvalsStimSelected <= params.threshold); + pvals_NI = pValuesNI( pvalsStimSelected <= params.threshold); + + zScores_NIg = zScores_NI( pValuesNI <= params.threshold); + sumNeurNI = numel(zScores_NIg); + spkR_NIg = spkR_NI( pValuesNI <= params.threshold); + spkDiff_NIg = spkDiff_NI( pValuesNI <= params.threshold); + respIndexes = [respIndexes, find(pValuesNI <= params.threshold)]; + + try + idx = (longTable.insertion == categorical(j)) & ... + (longTable.stimulus == categorical("NI")); + longTable.respNeur(idx) = sumNeurNI; + longTable.totalSomaticN(idx) = numel(pValuesMB); + end + + if isequal(params.StimsPresent{5},'') + zScores_NIs = zScores_NIs - inf; + spkR_NIs = spkR_NIs - inf; + spkDiff_NIs = spkDiff_NIs - inf; + pvals_NI = pvals_NI - inf; + sumNeurNI = 0; + zScores_NIg = zScores_NIg - inf; + spkR_NIg = zScores_NIg - inf; + spkDiff_NIg = zScores_NIg - inf; + end + + % ---- Natural Video ---- + zScores_NVs = zScores_NV( pvalsStimSelected <= params.threshold); + spkR_NVs = spkR_NV( pvalsStimSelected <= params.threshold); + spkDiff_NVs = spkDiff_NV(pvalsStimSelected <= params.threshold); + pvals_NV = pValuesNV( pvalsStimSelected <= params.threshold); + + zScores_NVg = zScores_NV( pValuesNV <= params.threshold); + sumNeurNV = numel(zScores_NVg); + spkR_NVg = spkR_NV( pValuesNV <= params.threshold); + spkDiff_NVg = spkDiff_NV( pValuesNV <= params.threshold); + respIndexes = [respIndexes, find(pValuesNV <= params.threshold)]; + + try + idx = (longTable.insertion == categorical(j)) & ... + (longTable.stimulus == categorical("NV")); + longTable.respNeur(idx) = sumNeurNV; + longTable.totalSomaticN(idx) = numel(pValuesMB); + end + + if isequal(params.StimsPresent{6},'') + zScores_NVs = zScores_NVs - inf; + spkR_NVs = spkR_NVs - inf; + spkDiff_NVs = spkDiff_NVs - inf; + pvals_NV = pvals_NV - inf; + sumNeurNV = 0; + zScores_NVg = zScores_NVg - inf; + spkR_NVg = zScores_NVg - inf; + spkDiff_NVg = zScores_NVg - inf; + end + + % Union of all neuron indices responsive to at least one stimulus + responsiveNeuronsj = unique(respIndexes); + + % BUG-5: `2+2` is a debug breakpoint stub — removed here. + % Replace with a proper warning: + if numel(zScores_NVs) ~= numel(zScores_NIs) + warning('PlotZScoreComparison: NV and NI filtered vectors have different lengths in experiment %d.', ex); + end + + % ------------------------------------------------------------------ + % 4j – Re-extract animal and insertion labels (fresh regex in case + % the object was re-created above) + % ------------------------------------------------------------------ + + Animal = string(regexp(vs.getAnalysisFileName, 'PV\d+', 'match', 'once')); + Insertion = regexp(vs.getAnalysisFileName, 'Insertion\d+', 'match', 'once'); + Insertion = str2double(regexp(Insertion, '\d+', 'match')); + + % Fallback: some animals use 'SA##' naming convention + if isequal(Animal, "") + Animal = string(regexp(vs.getAnalysisFileName, 'SA\d+', 'match', 'once')); + end + + % BUG-3: AnimalI is updated inside the first if-block, so the second + % if-block (checking Animal~=AnimalI for insertion counting) + % always sees them as equal after the first block runs. + % FIX: capture the old value before updating. + AnimalChanged = (Animal ~= AnimalI); % evaluate BEFORE updating AnimalI + + if AnimalChanged + animal = animal + 1; % new animal encountered + AnimalNames{animal} = Animal; % store its name + AnimalI = Animal; % update tracker + end + + % Count a new insertion if the insertion number changed OR a new animal + if Insertion ~= InsertionI || AnimalChanged % FIX: use pre-evaluated flag + InsertionI = Insertion; + insertion = insertion + 1; + end + + % ------------------------------------------------------------------ + % 4k – Store this experiment's data into per-experiment cell arrays + % ------------------------------------------------------------------ + + % Replicate animal/insertion IDs to match number of anchor-filtered neurons + animalVector{j} = repmat(animal, [1, numel(zScores_MBs)]); + insertionVector{j} = repmat(insertion, [1, numel(zScores_MBs)]); + + % Anchor-filtered data (neurons significant for the anchor stimulus) + zScoresMB{j} = zScores_MBs; + zScoresRG{j} = zScores_RGs; + pvalsRG{j} = pvals_RG; + sumNeurRGt{j} = sumNeurRG; + pvalsMB{j} = pvals_MB; + sumNeurMBt{j} = sumNeurMB; + spKrMB{j} = spkR_MBs'; + spKrRG{j} = spkR_RGs'; + diffSpkMB{j} = spkDiff_MBs; + diffSpkRG{j} = spkDiff_RGs; + + zScoresFFF{j} = zScores_FFFs; + spKrFFF{j} = spkR_FFFs'; + diffSpkFFF{j} = spkDiff_FFFs; + pvalsFFF{j} = pvals_FFF; + sumNeurFFFt{j} = sumNeurFFF; + + zScoresMBR{j} = zScores_MBRs; + spKrMBR{j} = spkR_MBRs'; + diffSpkMBR{j} = spkDiff_MBRs; + pvalsMBR{j} = pvals_MBR; + sumNeurMBRt{j} = sumNeurMBR; + + zScoresSDGm{j} = zScores_SDGms; + spKrSDGm{j} = spkR_SDGms'; + diffSpkSDGm{j} = spkDiff_SDGms; + pvalsSDGm{j} = pvals_SDGm; + sumNeurSDGmt{j} = sumNeurSDGm; + + zScoresSDGs{j} = zScores_SDGss; + spKrSDGs{j} = spkR_SDGss'; + diffSpkSDGs{j} = spkDiff_SDGss; + pvalsSDGs{j} = pvals_SDGs; + sumNeurSDGst{j} = sumNeurSDGs; + + zScoresNI{j} = zScores_NIs; + spKrNI{j} = spkR_NIs'; + diffSpkNI{j} = spkDiff_NIs; + pvalsNI{j} = pvals_NI; + sumNeurNIt{j} = sumNeurNI; + + zScoresNV{j} = zScores_NVs; + spKrNV{j} = spkR_NVs'; + diffSpkNV{j} = spkDiff_NVs; + pvalsNV{j} = pvals_NV; + sumNeurNVt{j} = sumNeurNV; + + % Self-responsive data (neurons significant for EACH respective stimulus) + zScoresMBg{j} = zScores_MBg; spkRMBg{j} = spkR_MBg; spkDiffMBg{j} = spkDiff_MBg; + zScoresRGg{j} = zScores_RGg; spkRRGg{j} = spkR_RGg; spkDiffRGg{j} = spkDiff_RGg; + zScoresMBRg{j} = zScores_MBRg; spkRMBRg{j} = spkR_MBRg; spkDiffMBRg{j} = spkDiff_MBRg; + zScoresSDGmg{j} = zScores_SDGmg; spkRSDGmg{j} = spkR_SDGmg; spkDiffSDGmg{j} = spkDiff_SDGmg; + zScoresSDGsg{j} = zScores_SDGsg; spkRSDGsg{j} = spkR_SDGsg; spkDiffSDGsg{j} = spkDiff_SDGsg; + zScoresFFFg{j} = zScores_FFFg; spkRFFFg{j} = spkR_FFFg; spkDiffFFFg{j} = spkDiff_FFFg; + zScoresNIg{j} = zScores_NIg; spkRNIg{j} = spkR_NIg; spkDiffNIg{j} = spkDiff_NIg; + zScoresNVg{j} = zScores_NVg; spkRNVg{j} = spkR_NVg; spkDiffNVg{j} = spkDiff_NVg; + + % Set of neuron indices responsive to at least one stimulus in this recording + responsiveNeurons{j} = responsiveNeuronsj; + + j = j + 1; % advance experiment counter + + fprintf('Finished recording: %s .\n', NP.recordingName) + + end % end for ex = expList + + % ========================================================================= + % SECTION 5 – PACK ALL DATA INTO STRUCT S AND SAVE + % ========================================================================= + + % Anchor-filtered values (neurons responsive to the first Stims2Comp element) + S.stimValsSignif2oneStim.spKrMB = spKrMB; + S.stimValsSignif2oneStim.spKrRG = spKrRG; + S.stimValsSignif2oneStim.diffSpkMB = diffSpkMB; + S.stimValsSignif2oneStim.diffSpkRG = diffSpkRG; + S.stimValsSignif2oneStim.zScoresMB = zScoresMB; + S.stimValsSignif2oneStim.zScoresRG = zScoresRG; + S.pvals.pvalsMB = pvalsMB; + S.pvals.pvalsRG = pvalsRG; + + S.stimValsSignif2oneStim.spKrMBR = spKrMBR; + S.stimValsSignif2oneStim.spKrFFF = spKrFFF; + S.stimValsSignif2oneStim.diffSpkMBR = diffSpkMBR; + S.stimValsSignif2oneStim.diffSpkFFF = diffSpkFFF; + S.stimValsSignif2oneStim.zScoresMBR = zScoresMBR; + S.stimValsSignif2oneStim.zScoresFFF = zScoresFFF; + S.pvals.pvalsFFF = pvalsFFF; + S.pvals.pvalsMBR = pvalsMBR; + + S.stimValsSignif2oneStim.spKrSDGm = spKrSDGm; + S.stimValsSignif2oneStim.spKrSDGs = spKrSDGs; + S.stimValsSignif2oneStim.diffSpkSDGm = diffSpkSDGm; + S.stimValsSignif2oneStim.diffSpkSDGs = diffSpkSDGs; + S.stimValsSignif2oneStim.zScoresSDGm = zScoresSDGm; + S.stimValsSignif2oneStim.zScoresSDGs = zScoresSDGs; + S.pvals.pvalsSDGm = pvalsSDGm; + S.pvals.pvalsSDGs = pvalsSDGs; + + S.stimValsSignif2oneStim.spKrNI = spKrNI; + S.stimValsSignif2oneStim.spKrNV = spKrNV; + S.stimValsSignif2oneStim.diffSpkNI = diffSpkNI; + S.stimValsSignif2oneStim.diffSpkNV = diffSpkNV; + S.stimValsSignif2oneStim.zScoresNI = zScoresNI; + S.stimValsSignif2oneStim.zScoresNV = zScoresNV; + S.pvals.pvalsNI = pvalsNI; + S.pvals.pvalsNV = pvalsNV; + + % Self-responsive values (each neuron counted only for its own stimulus) + S.stimValsSignif.zScoresMBg = zScoresMBg; S.stimValsSignif.spkRMBg = spkRMBg; S.stimValsSignif.spkDiffMBg = spkDiffMBg; + S.stimValsSignif.zScoresRGg = zScoresRGg; S.stimValsSignif.spkRRGg = spkRRGg; S.stimValsSignif.spkDiffRGg = spkDiffRGg; + S.stimValsSignif.zScoresMBRg = zScoresMBRg; S.stimValsSignif.spkRMBRg = spkRMBRg; S.stimValsSignif.spkDiffMBRg = spkDiffMBRg; + S.stimValsSignif.zScoresSDGmg = zScoresSDGmg; S.stimValsSignif.spkRSDGmg = spkRSDGmg; S.stimValsSignif.spkDiffSDGmg = spkDiffSDGmg; + S.stimValsSignif.zScoresSDGsg = zScoresSDGsg; S.stimValsSignif.spkRSDGsg = spkRSDGsg; S.stimValsSignif.spkDiffSDGsg = spkDiffSDGsg; + S.stimValsSignif.zScoresFFFg = zScoresFFFg; S.stimValsSignif.spkRFFFg = spkRFFFg; S.stimValsSignif.spkDiffFFFg = spkDiffFFFg; + S.stimValsSignif.zScoresNIg = zScoresNIg; S.stimValsSignif.spkRNIg = spkRNIg; S.stimValsSignif.spkDiffNIg = spkDiffNIg; + S.stimValsSignif.zScoresNVg = zScoresNVg; S.stimValsSignif.spkRNVg = spkRNVg; S.stimValsSignif.spkDiffNVg = spkDiffNVg; + + % Responsive neuron counts per insertion per stimulus + S.stimValsSignif.sumNeurMB = sumNeurMBt; + S.stimValsSignif.sumNeurRG = sumNeurRGt; + S.stimValsSignif.sumNeurMBR = sumNeurMBRt; + S.stimValsSignif.sumNeurSDGm = sumNeurSDGmt; + S.stimValsSignif.sumNeurSDGs = sumNeurSDGst; + S.stimValsSignif.sumNeurFFF = sumNeurFFFt; + S.stimValsSignif.sumNeurNI = sumNeurNIt; + S.stimValsSignif.sumNeurNV = sumNeurNVt; + + % Metadata and indexing + S.expList = expList; % experiment IDs processed + S.animalVector = animalVector; % per-neuron animal index + S.insertionVector = insertionVector; % per-neuron insertion index + S.totalUnits = totalU; % total unit count per experiment + S.params = params; % parameter snapshot + S.responsiveNeurons = responsiveNeurons; % union-responsive neuron indices + S.TableRespNeurs = longTable; % fraction-responsive table + S.TableStimComp = longTablePairComp; % pairwise z-score/SpkR table + + save([saveDir nameOfFile], '-struct', 'S'); % save struct fields as top-level variables + +end % end if forloop + +% ========================================================================= +% SECTION 6 – PAIRWISE COMPARISON (ComparePairs mode) +% ========================================================================= + +if ~isempty(params.ComparePairs) + + pairs = params.ComparePairs; % cell of stimulus name(s) to compare + + % ----------------------------------------------------------------------- + % BUG-1 FIX: Guard against empty pairwise table (no significant units + % found in any experiment). splitapply on an empty grouping + % vector throws an error. + % ----------------------------------------------------------------------- + if isempty(S.TableStimComp) || height(S.TableStimComp) == 0 + warning('PlotZScoreComparison:noUnits', ... + ['No significant units found for pairwise comparison of %s vs %s.\n' ... + 'Returning empty figure.'], pairs{1}, pairs{2}); + fig = figure; % return empty figure handle to satisfy output contract + return + end + + % Replace NaN z-scores / spike rates with 0 (conservative: treat as no response) + S.TableStimComp.('Z-score')(isnan(S.TableStimComp.('Z-score'))) = 0; + S.TableStimComp.SpkR(isnan(S.TableStimComp.SpkR)) = 0; + + % Find insertions that contain both stimuli in the pair + [G, ~] = findgroups(S.TableStimComp.insertion); + hasAll = splitapply(@(s) all(ismember(unique(categorical(pairs)), s)), ... + S.TableStimComp.stimulus, G); + + % Restrict table to complete insertions (have both stimuli) and relevant rows + tempTable = S.TableStimComp( ... + hasAll(G) & ismember(S.TableStimComp.stimulus, unique(categorical(pairs))), :); + + nBoot = 10000; % number of hierarchical bootstrap iterations + + % SHARED COLORMAP: built once, reused in every swarm and scatter panel. + % double() on a categorical returns the rank within categories(), which is + % the same ordering used to index into the colormap — guaranteeing that + % animal X gets identical RGB in the swarm and in both scatter plots. + animalOrder = categories(S.TableStimComp.animal); % canonical ordering + nAnimals = numel(animalOrder); + sharedCmap = lines(nAnimals); % nAnimals × 3 RGB matrix + animalIdxAll = double(S.TableStimComp.animal); + + % Pre-compute the row masks for pairs{1} and pairs{2} — used in both + % the Z-score and spike-rate scatter panels below. + mask1 = S.TableStimComp.stimulus == pairs{1}; + mask2 = S.TableStimComp.stimulus == pairs{2}; + cIdx = animalIdxAll(mask1); % colour index aligned with pair{1} / pair{2} rows + + % ----------------------------------------------------------------------- + % 6a – Z-score comparison via hierarchical bootstrapping + % ----------------------------------------------------------------------- + + j = 1; + ps = zeros(1, size(pairs, 1)); % one p-value per stimulus pair + + for i = 1:size(pairs, 1) + + diffs = []; % per-neuron differences (stim1 – stim2) pooled across insertions + insers = []; % insertion label for each difference + animals = []; % animal label for each difference + + for ins = unique(S.TableStimComp.insertion)' + + % Select rows for this insertion × each stimulus + idx1 = S.TableStimComp.insertion == categorical(ins) & ... + S.TableStimComp.stimulus == pairs{j,1}; + idx2 = S.TableStimComp.insertion == categorical(ins) & ... + S.TableStimComp.stimulus == pairs{j,2}; + + V1 = S.TableStimComp.('Z-score')(idx1); + V2 = S.TableStimComp.('Z-score')(idx2); + + % Unique animal for this insertion (should be exactly one) + animal = unique(S.TableStimComp.animal(idx1)); + + % Append per-neuron differences and labels + diffs = [diffs; V1 - V2]; + insers = [insers; double(repmat(ins, size(V1,1), 1))]; + animals = [animals; double(repmat(animal, size(V1,1), 1))]; + end + + % Hierarchical bootstrap: resample at animal level, then insertion level + bootDiff = hierBoot(diffs, nBoot, insers, animals); + ps(j) = mean(bootDiff <= 0); % p-value: proportion of bootstrap samples ≤ 0 + j = j + 1; + end + + ZscoreYlimUp = ceil(max(S.TableStimComp.("Z-score")))+4; + + % Swarm plot with bootstrap-derived significance (returns subsampling index) + [fig, randiColors] = plotSwarmBootstrapWithComparisons(S.TableStimComp, pairs, ps, ... + {'Z-score'}, yLegend='Z-score', yMaxVis=ZscoreYlimUp, diff=true, plotMeanSem=true, Alpha=0.7); + + ax = gca; + ax.YAxis.FontSize = 8; ax.YAxis.FontName = 'helvetica'; + ax.XAxis.FontSize = 8; ax.XAxis.FontName = 'helvetica'; + + set(fig, 'Units', 'centimeters', 'Position', [20 20 10 6]); + colormap(fig, sharedCmap); % enforce shared colormap so swarm colours match scatter + + % Reload analysis object for figure saving (path extraction) + NP = loadNPclassFromTable(expList(1)); + vs = linearlyMovingBallAnalysis(NP); + + ylims = ylim; + + if params.PaperFig + vs.printFig(fig, sprintf('Zcore-comparison-Swarm-%s-%s', ... + params.ComparePairs{1}, params.ComparePairs{2}), PaperFig=params.PaperFig); + end + + % ----------------------------------------------------------------------- + % 6b – Scatter plot: first vs second stimulus in pairs (Z-score) + % SUGG-5: randiColors is a subsampling index from the swarm function. + % If it subsamples non-uniformly, the scatter may misrepresent + % the data density. Consider plotting all points for publication. + % ----------------------------------------------------------------------- + + fig = figure; + + pair1 = S.TableStimComp.("Z-score")(mask1); + pair2 = S.TableStimComp.("Z-score")(mask2); + % cIdx already computed above — direct RGB lookup, no implicit categorical conversion + + % Scatter with animal-coded colour, using subsampled indices + scatter(pair1, pair2, 7, sharedCmap(cIdx,:), ... + "filled", "MarkerFaceAlpha", 0.3) + hold on + axis equal + + lims = [min(S.TableStimComp.("Z-score")), max(S.TableStimComp.("Z-score"))]; + plot(lims, lims, 'k--', 'LineWidth', 1.5) % identity line + ylim(lims); xlim(lims) + + % Convert internal stimulus abbreviations to display labels + s = string(pairs); + s = replace(s, "RG", "SB"); % Rect Grid → Square Ball + s = replace(s, "SDGs", "SG"); % static gratings label + s = replace(s, "SDGm", "MG"); % moving gratings label + + xlabel(s{1}); ylabel(s{2}) + colormap(fig, sharedCmap) + + ax = gca; + ax.YAxis.FontSize = 8; ax.YAxis.FontName = 'helvetica'; + ax.XAxis.FontSize = 8; ax.XAxis.FontName = 'helvetica'; + set(fig, 'Units', 'centimeters', 'Position', [20 20 5 5]); + title('Z-score') + + if params.PaperFig + vs.printFig(fig, sprintf('Zcore-comparison-Scatter-%s-%s', ... + params.ComparePairs{1}, params.ComparePairs{2}), PaperFig=params.PaperFig); + end + + % ----------------------------------------------------------------------- + % 6c – Spike-rate comparison via hierarchical bootstrapping + % ----------------------------------------------------------------------- + + j = 1; + ps = zeros(1, size(pairs, 1)); + + for i = 1:size(pairs, 1) + + diffs = []; + insers = []; + animals = []; + + for ins = unique(S.TableStimComp.insertion)' + + idx1 = S.TableStimComp.insertion == categorical(ins) & ... + S.TableStimComp.stimulus == pairs{j,1}; + idx2 = S.TableStimComp.insertion == categorical(ins) & ... + S.TableStimComp.stimulus == pairs{j,2}; + + V1 = S.TableStimComp.SpkR(idx1); + V2 = S.TableStimComp.SpkR(idx2); + + animal = unique(S.TableStimComp.animal(idx1)); + diffs = [diffs; V1 - V2]; + insers = [insers; double(repmat(ins, size(V1,1), 1))]; + animals = [animals; double(repmat(animal, size(V1,1), 1))]; + end + + bootDiff = hierBoot(diffs, nBoot, insers, animals); + ps(j) = mean(bootDiff <= 0); + j = j + 1; + end + + V1max = max(diffs); % use max observed difference to set y-axis ceiling + + [fig, randiColors] = plotSwarmBootstrapWithComparisons(S.TableStimComp, pairs, ps, ... + {'SpkR'}, yLegend='SpkR', yMaxVis=V1max, diff=true, plotMeanSem=true, Alpha=0.7); + + ax = gca; + ax.YAxis.FontSize = 8; ax.YAxis.FontName = 'helvetica'; + ax.XAxis.FontSize = 8; ax.XAxis.FontName = 'helvetica'; + colormap(fig, sharedCmap); + set(fig, 'Units', 'centimeters', 'Position', [20 20 10 6]); + + if params.PaperFig + vs.printFig(fig, sprintf('spkRate-comparison-Swarm-%s-%s', ... + params.ComparePairs{1}, params.ComparePairs{2}), PaperFig=params.PaperFig); + end + + % ----------------------------------------------------------------------- + % 6d – Scatter plot: first vs second stimulus (Spike Rate) + % ----------------------------------------------------------------------- + + fig = figure; + pair1 = S.TableStimComp.SpkR(mask1); % mask1 pre-computed above + pair2 = S.TableStimComp.SpkR(mask2); + scatter(pair1, pair2, 7, sharedCmap(cIdx,:), ... + "filled", "MarkerFaceAlpha", 0.3) + hold on + axis equal + + lims = [min(S.TableStimComp.SpkR), max(S.TableStimComp.SpkR)]; + plot(lims, lims, 'k--', 'LineWidth', 1.5) + ylim(lims); xlim(lims) + + xlabel(s{1}); ylabel(s{2}) + colormap(fig, sharedCmap) + + ax = gca; + ax.YAxis.FontSize = 8; ax.YAxis.FontName = 'helvetica'; + ax.XAxis.FontSize = 8; ax.XAxis.FontName = 'helvetica'; + set(fig, 'Units', 'centimeters', 'Position', [20 20 5 5]); + title('Spk. rate') + + if params.PaperFig + vs.printFig(fig, sprintf('spkRate-comparison-Scatter-%s-%s', ... + params.ComparePairs{1}, params.ComparePairs{2}), PaperFig=params.PaperFig); + end + +else + % ========================================================================= + % SECTION 7 – MULTI-STIMULUS OVERVIEW (non-pairwise mode) + % Compares ALL stimuli in Stims2Comp using swarm + scatter. + % ========================================================================= + + fig = figure; + tiledlayout(2, 2, "TileSpacing", "compact"); + + % Choose field-name set based on whether each-stim or anchor-filtered + if ~params.EachStimSignif + fn = fieldnames(S.stimValsSignif2oneStim); % anchor-filtered fields + else + fn = fieldnames(S.stimValsSignif); % self-responsive fields + end + fnp = fieldnames(S.pvals); + + % Expand 'SDG' shorthand into two separate entries (moving + static) + Stims2Comp2 = {}; + for i = 1:numel(Stims2Comp) + if strcmp(Stims2Comp{i}, 'SDG') + Stims2Comp2 = [Stims2Comp2, {'SDGs','SDGm'}]; + else + Stims2Comp2 = [Stims2Comp2, Stims2Comp(i)]; + end + end + + % Select suffix used in field-name lookup + endingOpts = {'','g'}; % '' = anchor-filtered suffix, 'g' = self-responsive + ending2 = endingOpts{1 + params.EachStimSignif}; + + % Pre-allocate arrays that will hold concatenated data for each stimulus + StimZS = cell(numel(Stims2Comp2), 1); % z-scores per stimulus + stimRSP = cell(numel(Stims2Comp2), 1); % spike rates per stimulus + stimPvals = cell(numel(Stims2Comp2), 1); % p-values per stimulus + x = []; % stimulus-index label for each neuron (for swarmchart x-axis) + + for i = 1:numel(Stims2Comp2) + + ending = Stims2Comp2{i}; % e.g. 'MB', 'RGg', … + % Regex: field names starting with 'zS' and ending with the stimulus tag + pattern = ['^zS.*' ending ending2 '$']; + matches = fn(~cellfun('isempty', regexp(fn, pattern))); + + % Concatenate z-scores across experiments + if ~params.EachStimSignif + StimZS{i} = cell2mat(S.stimValsSignif2oneStim.(matches{1}))'; + else + StimZS{i} = cell2mat(S.stimValsSignif.(matches{1}))'; + end + + % Build pattern for spike rate OR spike difference (diffResp flag) + if ~params.diffResp + pattern = ['^spKr.*' ending ending2 '$']; + else + pattern = ['^diffSpk.*' ending ending2 '$']; + end + + matches = fn(~cellfun('isempty', regexp(fn, pattern))); + + if params.EachStimSignif + matches = fn(~cellfun('isempty', regexp(fn, pattern, 'ignorecase'))); + C = S.stimValsSignif.(matches{1}); + C = cellfun(@(x) x', C, 'UniformOutput', false); + stimRSP{i} = cell2mat(C'); + else + % Try several concatenation strategies to handle shape inconsistencies + try + stimRSP{i} = cell2mat(S.stimValsSignif2oneStim.(matches{1})'); + catch + try + stimRSP{i} = cell2mat(S.stimValsSignif2oneStim.(matches{1})); + catch + % Last resort: force column, then vertcat + Ccol = cellfun(@(x) x(:), S.stimValsSignif2oneStim.(matches{1}), ... + 'UniformOutput', false); + stimRSP{i} = vertcat(Ccol{:})'; + end + end + end + + % Retrieve p-values for this stimulus + pattern = ['^pvals.*' ending '$']; + matches = fnp(~cellfun('isempty', regexp(fnp, pattern))); + stimPvals{i} = cell2mat(S.pvals.(matches{1}))'; + + % Build x-axis labels: all neurons for stimulus i get label i + x = [x; ones(size(StimZS{i})) * i]; + + end + + % Per-neuron animal and insertion index vectors (from anchor-filtered pool) + AnIndex = cell2mat(S.animalVector)'; + InsIndex = cell2mat(S.insertionVector)'; + colormapUsed = parula(max(AnIndex)) .* 0.6; % muted parula for animal colouring + + % ----------------------------------------------------------------------- + % 7a – Z-score swarm chart + % ----------------------------------------------------------------------- + + y = cell2mat(StimZS); % all z-scores concatenated (length = total neurons × stims) + + allColorIndices = repmat(AnIndex, numel(Stims2Comp2), 1); % replicate animal index + + nexttile + if ~params.EachStimSignif + swarmchart(x, y, 5, colormapUsed(allColorIndices,:), 'filled', 'MarkerFaceAlpha', 0.7); + else + swarmchart(x, y, 5, 'filled', 'MarkerFaceAlpha', 0.7); + end + xticks(1:8); + xticklabels(Stims2Comp2); + ylabel('Z-score'); + set(fig, 'Color', 'w') + yline(0, 'LineWidth', 2) % reference line at zero + ylim([-5 40]) + + % ----------------------------------------------------------------------- + % 7b – Hierarchical bootstrapping for Z-score group comparison + % (computed fresh or loaded from saved S.groupStats) + % ----------------------------------------------------------------------- + + if params.overwriteGroupStats || ~isfield(S, 'groupStats') + + % Bootstrap the first (anchor) stimulus + FirstStim = y(x == 1); + BootFirst = hierBoot(FirstStim(~isnan(FirstStim)), 10000, ... + InsIndex(~isnan(FirstStim)), AnIndex(~isnan(FirstStim))); + + j = 1; + for i = 2:numel(Stims2Comp2) + secondaryStim = y(x == i); + secondaryStim(isnan(secondaryStim)) = 0; % treat NaN as no response + validMask = secondaryStim ~= -inf; + secondaryStim = secondaryStim(validMask); + + BootSec = hierBoot(secondaryStim, 10000, InsIndex(validMask), AnIndex(validMask)); + probs{j} = get_direct_prob(BootFirst, BootSec); % Bayesian-style overlap probability + ps{j} = mean(BootSec >= BootFirst); % frequentist p-value + j = j + 1; + end + + S.groupStats.Bayes_ZscoreCompare = probs; + % BUG-6 FIX: was S.groupStatsP_ZscoreCompare (top-level field), + % now correctly nested under S.groupStats + S.groupStats.P_ZscoreCompare = ps; + + save([saveDir nameOfFile], '-struct', 'S'); + end + + % ----------------------------------------------------------------------- + % 7c – Z-score scatter (two selected stimuli) + % ----------------------------------------------------------------------- + + nexttile + + % Default: compare 1st and 2nd stimulus; override with StimsToCompare if set + if isempty(params.StimsToCompare) + ind1 = 1; ind2 = 2; + else + ind1 = find(strcmp(Stims2Comp2, params.StimsToCompare{1})); + ind2 = find(strcmp(Stims2Comp2, params.StimsToCompare{2})); + end + + ValsToCompare = {StimZS{ind1}, StimZS{ind2}}; + + % Only plot if the two vectors are the same length (same neuron set) + if numel(ValsToCompare{1}) == numel(ValsToCompare{2}) + scatter(ValsToCompare{1}, ValsToCompare{2}, 10, AnIndex, "filled", "MarkerFaceAlpha", 0.5) + colormap(colormapUsed) + hold on + axis equal + lims = [min(y(y > -inf)), max(y)]; + plot(lims, lims, 'k--', 'LineWidth', 1.5) + lims = [-5 40]; + ylim(lims); xlim(lims) + xlabel(Stims2Comp(ind1)); ylabel(Stims2Comp(ind2)) + end + + % ----------------------------------------------------------------------- + % 7d – Spike-rate swarm chart + % ----------------------------------------------------------------------- + + y = cell2mat(stimRSP); % all spike rates concatenated + + nexttile + if ~params.EachStimSignif + swarmchart(x, y, 5, colormapUsed(allColorIndices,:), 'filled', 'MarkerFaceAlpha', 0.7); + else + swarmchart(x, y, 5, 'filled', 'MarkerFaceAlpha', 0.7); + end + xticks(1:8); + xticklabels(Stims2Comp2); + ylabel('Spike Rate'); + set(fig, 'Color', 'w') + + % ----------------------------------------------------------------------- + % 7e – Hierarchical bootstrapping for spike-rate group comparison + % ----------------------------------------------------------------------- + + if params.overwriteGroupStats || ~isfield(S, 'groupStats') + FirstStim = y(x == 1); + BootFirst = hierBoot(FirstStim(~isnan(FirstStim)), 10000, ... + InsIndex(~isnan(FirstStim)), AnIndex(~isnan(FirstStim))); + j = 1; + for i = 2:numel(Stims2Comp2) + secondaryStim = y(x == i); + secondaryStim(isnan(secondaryStim)) = 0; + validMask = secondaryStim ~= -inf; + secondaryStim = secondaryStim(validMask); + BootSec = hierBoot(secondaryStim, 10000, InsIndex(validMask), AnIndex(validMask)); + probs{j} = get_direct_prob(BootFirst, BootSec); + ps{j} = mean(BootSec >= BootFirst); + j = j + 1; + end + S.groupStats.Bayes_SpikeRateCompare = probs; + S.groupStats.P_SpikeRateCompare = ps; + end + + % ----------------------------------------------------------------------- + % 7f – Spike-rate scatter (same two stimuli as Z-score scatter) + % ----------------------------------------------------------------------- + + nexttile + ValsToCompare = {stimRSP{ind1}, stimRSP{ind2}}; + + if numel(ValsToCompare{1}) == numel(ValsToCompare{2}) + scatter(ValsToCompare{1}, ValsToCompare{2}, 10, AnIndex, "filled", "MarkerFaceAlpha", 0.5) + colormap(colormapUsed) + hold on + axis equal + lims = [0, max(xlim)]; + plot(lims, lims, 'k--', 'LineWidth', 1.5) + ylim(lims); xlim(lims) + xlabel(Stims2Comp(ind1)); ylabel(Stims2Comp(ind2)) + end + +end % end if/else ComparePairs + +% ========================================================================= +% SECTION 8 – FRACTION-RESPONSIVE ANALYSIS +% Compares the proportion of neurons responding to each stimulus +% using simple bootstrapping at the insertion level. +% ========================================================================= + +% Set default pair for fraction-responsive comparison +if isempty(params.ComparePairs) + pairs = {Stims2Comp{1}, Stims2Comp{2}}; +else + pairs = params.ComparePairs; +end + +% Find insertions with data for both stimuli in the pair +[G, ~] = findgroups(S.TableRespNeurs.insertion); +hasAll = splitapply(@(s) all(ismember(unique(categorical(pairs)), s)), ... + S.TableRespNeurs.stimulus, G); +tempTable = S.TableRespNeurs( ... + hasAll(G) & ismember(S.TableRespNeurs.stimulus, unique(categorical(pairs))), :); + +nBoot = 10000; +j = 1; +ps = zeros(1, size(pairs, 1)); + +% Bootstrap the difference in responsive fraction between the two stimuli +for i = 1:size(pairs, 1) + + diffs = []; + + for ins = unique(S.TableRespNeurs.insertion)' + + idx1 = S.TableRespNeurs.insertion == categorical(ins) & ... + S.TableRespNeurs.stimulus == pairs{j,1}; + idx2 = S.TableRespNeurs.insertion == categorical(ins) & ... + S.TableRespNeurs.stimulus == pairs{j,2}; + + if any(idx1) && any(idx2) + % Compute difference of fractions (responsive / total) + % Note: totalSomaticN from idx1 is used as the shared denominator + f1 = S.TableRespNeurs.respNeur(idx1) / S.TableRespNeurs.totalSomaticN(idx1); + f2 = S.TableRespNeurs.respNeur(idx2) / S.TableRespNeurs.totalSomaticN(idx1); + diffs(end+1, 1) = f1 - f2; + end + end + + % Simple bootstrap of mean difference (one value per insertion → no hierarchy needed) + bootDiff = bootstrp(nBoot, @mean, diffs); + ps(j) = mean(bootDiff <= 0); % p-value + j = j + 1; +end + +% Add column: total responsive neurons per insertion (summed across both stimuli) +[G, ~] = findgroups(tempTable.insertion); +totals = splitapply(@sum, tempTable.respNeur, G); +tempTable.TotalRespNeur = totals(G); + +% Plot fractions with significance annotation +fig = plotSwarmBootstrapWithComparisons(tempTable, pairs, ps, ... + {'respNeur','totalSomaticN'}, fraction=true, showBothAndDiff=false,yLegend='Responsive/total units', ... + diff=false, filled=false, Xjitter='none', Alpha=0.6, drawLines=true); + +TotalRespUnits = sum(tempTable.respNeur); + +TotalRespUnitsPair1 = sum(tempTable.respNeur(tempTable.stimulus == string(pairs{1}))); + +TotalRespUnitsPair2 = sum(tempTable.respNeur(tempTable.stimulus == string(pairs{2}))); + + +ax = gca; +ax.YAxis.FontSize = 8; ax.YAxis.FontName = 'helvetica'; +ax.XAxis.FontSize = 8; ax.XAxis.FontName = 'helvetica'; +set(fig, 'Units', 'centimeters', 'Position', [20 20 5 6]); +ylabel('Responsive/Total responsive') +title('') + +% Push axes up slightly to make room for bottom title +pos = get(gca, 'Position'); % [left bottom width height] +pos(2) = pos(2) + 0.05; % shift bottom edge up +set(gca, 'Position', pos); + +% Horizontal title at the bottom +annotation('textbox', [0.1, 0.01, 0.8, 0.04], ... + 'String', sprintf('TR = %d - %s = %d - %s = %d',TotalRespUnits,pairs{1},TotalRespUnitsPair1,pairs{2},TotalRespUnitsPair2), ... + 'Rotation', 0, ... + 'EdgeColor', 'none', ... + 'FontSize', 9, ... + 'FontWeight', 'bold', ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'middle', ... + 'FitBoxToText', false); + +if params.PaperFig + vs.printFig(fig, sprintf('ResponsiveUnits-comparison-%s-%s', ... + params.ComparePairs{1}, params.ComparePairs{2}), PaperFig=params.PaperFig); +end + +end % end function PlotZScoreComparison \ No newline at end of file diff --git a/visualStimulationAnalysis/OldVersions/AllExpAnalysisV2.m b/visualStimulationAnalysis/OldVersions/AllExpAnalysisV2.m new file mode 100644 index 0000000..3ae10d6 --- /dev/null +++ b/visualStimulationAnalysis/OldVersions/AllExpAnalysisV2.m @@ -0,0 +1,1037 @@ +function fig = AllExpAnalysisV2(expList, Stims2Comp, params) +% PlotZScoreComparison Pool z-scores and spike rates across recordings, +% run hierarchical bootstrapping, and produce swarm + scatter figures. +% +% KEY CHANGES FROM PREVIOUS VERSION +% SUGG-1 The 7×3 analysis dispatch and 7×4 extraction blocks are replaced +% by loops over a stimulus registry plus two local helpers: +% runStimAnalysis(vsObj,presentKey,stimKey,params,Stims2Comp) +% extractStimVals(stats,rw,stimKey,StatMethod) +% SUGG-2 Absent-stimulus data is filled with NaN instead of -Inf, so +% standard MATLAB functions (mean, max, isnan) work without extra +% guard code. +% SUGG-3 Optional BH-FDR correction via params.useFDR / params.FDRmethod. +% Applied per-recording to the raw p-value vectors before thresholding. +% SUGG-4 Spike-rate scatter axes can be log-scaled via params.logScaleSpkR. +% SUGG-5 Scatter plots show ALL neurons (no randiColors subsampling). +% Alpha is reduced to 0.25 to handle overplotting. +% SUGG-6 ComparePairs table building now uses a stimLookup struct instead +% of `who` + `eval`, making variable access explicit and debuggable. +% SUGG-7 All accumulator cell arrays are pre-allocated before the loop. +% +% BUG FIXES (retained from documented review) +% BUG-1 Guard against empty TableStimComp (no responsive units crash). +% BUG-2 fprintf moved after NP = loadNPclassFromTable(ex). +% BUG-3 AnimalChanged flag evaluated before AnimalI is updated, so the +% insertion counter uses the correct pre-update animal state. +% BUG-4 sumNeurSDGm and sumNeurSDGst reset to 0 when SDG is absent. +% BUG-5 2+2 debug stub replaced with warning(). +% BUG-6 S.groupStats.P_ZscoreCompare consistently nested (was top-level). + +% ========================================================================= +% ARGUMENT BLOCK +% ========================================================================= +arguments + expList (1,:) double % Experiment IDs from the master Excel table + Stims2Comp cell % Stimulus comparison order, e.g. {'MB','RG','MBR'}. + % First element is the anchor for neuron selection. + params.threshold = 0.05 % p-value cut-off for responsiveness + params.diffResp = false % Use spike-diff (resp-base) instead of rate + params.overwrite = false % Force recompute of combined .mat file + params.StimsPresent = {'MB','RG'} % Stimuli present in all sessions + params.StimsNotPresent = {} + params.StimsToCompare = {} % Override scatter pair (default: 1st & 2nd) + params.overwriteResponse = false % Force ResponseWindow recompute + params.overwriteStats = false % Force per-neuron stats recompute + params.overwriteGroupStats = false % Force group bootstrap recompute + params.RespDurationWin = 100 % Response-window duration (ms) + params.shuffles = 2000 % Bootstrap / shuffle iterations (per-neuron) + params.StatMethod = 'ObsWindow' % 'ObsWindow' | 'bootsrapRespBase' | 'maxPermuteTest' + params.ignoreNonSignif = false % Zero z-scores of non-sig secondary stimuli + params.EachStimSignif = false % Use per-stimulus responsive sets (not anchor) + params.ComparePairs = {} % Pair(s) for focused pairwise comparison + params.PaperFig logical = false % Save figures via vs.printFig + % [SUGG-3] FDR options + params.useFDR logical = false % Apply FDR correction to p-values per recording + params.FDRmethod char = 'BH' % FDR method: 'BH' (Benjamini-Hochberg) + % [SUGG-4] Spike-rate axis scaling + params.logScaleSpkR logical = false % Log-scale spike-rate scatter axes +end + +% ========================================================================= +% SECTION 1 – INITIALISE BOOKKEEPING [SUGG-7: full pre-allocation] +% ========================================================================= +n = numel(expList); + +animalVector = cell(1, n); % per-neuron animal index (anchor-filtered) +insertionVector = cell(1, n); % per-neuron insertion index +totalU = cell(1, n); % total unit count per recording +responsiveNeurons = cell(1, n); % union of neuron indices responsive to any stim + +animal = 0; % unique-animal counter +insertion = 0; % unique-insertion counter +AnimalI = ""; % animal ID from previous iteration (for change detection) +InsertionI = 0; % insertion number from previous iteration +j = 1; % experiment counter (1-based index into pre-allocated cell arrays) + +% [SUGG-1] Single organised store replaces 60+ individual cell arrays. +% Anchor-filtered fields: zScores, spKr, diffSpk, pvals +% Self-responsive fields: zScoresg, spKrg, diffSpkGCells, sumNeur +stimNames_all = {'MB','RG','MBR','FFF','SDGm','SDGs','NI','NV'}; +for sni = stimNames_all + sn = sni{1}; + zScoresCells.(sn) = cell(1, n); spKrCells.(sn) = cell(1, n); + diffSpkCells.(sn) = cell(1, n); pvalsCells.(sn) = cell(1, n); + zScoresgCells.(sn) = cell(1, n); spKrGCells.(sn) = cell(1, n); + diffSpkGCells.(sn) = cell(1, n); sumNeurCells.(sn) = cell(1, n); +end + +% ========================================================================= +% SECTION 2 – OUTPUT PATH AND SAVE-FILE CHECK +% ========================================================================= +NP = loadNPclassFromTable(expList(1)); % load first recording for path extraction +vs = linearlyMovingBallAnalysis(NP); % need analysis object for getAnalysisFileName + +nameOfFile = sprintf('\\Ex_%d-%d_Combined_Neural_responses_%s_filtered.mat', ... + expList(1), expList(end), Stims2Comp{1}); + +p = [extractBefore(vs.getAnalysisFileName, 'lizards'), 'lizards']; + +if ~exist([p '\Combined_lizard_analysis'], 'dir') + cd(p); mkdir Combined_lizard_analysis +end +saveDir = [p '\Combined_lizard_analysis']; + +% Decide whether the for-loop is needed +if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite + S = load([saveDir nameOfFile]); + forloop = ~isequal(S.expList, expList); % reprocess if experiment list changed +else + forloop = true; +end + +% ========================================================================= +% SECTION 3 – INITIALISE LONG-FORMAT TABLES +% ========================================================================= + +% Per-neuron table for pairwise z-score / spike-rate comparison +longTablePairComp = table( ... + categorical.empty(0,1), categorical.empty(0,1), ... + categorical.empty(0,1), categorical.empty(0,1), ... + double.empty(0,1), double.empty(0,1), ... + 'VariableNames', {'animal','insertion','stimulus','NeurID','Z-score','SpkR'}); + +% Per-insertion table for fraction-responsive analysis +longTable = table( ... + categorical.empty(0,1), categorical.empty(0,1), categorical.empty(0,1), ... + double.empty(0,1), double.empty(0,1), ... + 'VariableNames', {'animal','insertion','stimulus','respNeur','totalSomaticN'}); + +% ========================================================================= +% SECTION 4 – PER-EXPERIMENT FOR-LOOP +% ========================================================================= +if forloop + for ex = expList + + % [BUG-2 fix] Load recording BEFORE printing its name + NP = loadNPclassFromTable(ex); + fprintf('Processing recording: %s .\n', NP.recordingName) + + % Create analysis objects for the two always-present stimuli + vs = linearlyMovingBallAnalysis(NP); % Moving Ball (MB) + vsR = rectGridAnalysis(NP); % Rect Grid (RG) + + % Extract animal ID (try 'PV##' then 'SA##' as fallback) + Animal = string(regexp(vs.getAnalysisFileName, 'PV\d+', 'match', 'once')); + if isequal(Animal, "") + Animal = string(regexp(vs.getAnalysisFileName, 'SA\d+', 'match', 'once')); + end + + % Add rows to fraction-responsive table for always-present stimuli + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("MB"), 0, 0}; + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("RG"), 0, 0}; + + % ------------------------------------------------------------------ + % 4a – Load optional stimuli; fall back to a dummy when absent. + % Dummy objects have the same unit count so NaN replacement + % [SUGG-2] works correctly later. + % ------------------------------------------------------------------ + try + vsBr = linearlyMovingBarAnalysis(NP); + params.StimsPresent{3} = 'MBR'; + if isempty(vsBr.VST), error('absent'); end + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("MBR"), 0, 0}; + catch + params.StimsPresent{3} = ''; + fprintf('Moving Bar stimulus not found.\n') + vsBr = linearlyMovingBallAnalysis(NP); % dummy with correct N + end + + try + vsG = StaticDriftingGratingAnalysis(NP); + params.StimsPresent{4} = 'SDG'; + if isempty(vsG.VST), error('absent'); end + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("SDGm"), 0, 0}; + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("SDGs"), 0, 0}; + catch + params.StimsPresent{4} = ''; + fprintf('Gratings stimulus not found.\n') + vsG = rectGridAnalysis(NP); + end + + try + vsNI = imageAnalysis(NP); + params.StimsPresent{5} = 'NI'; + if isempty(vsNI.VST), error('absent'); end + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("NI"), 0, 0}; + catch + params.StimsPresent{5} = ''; + fprintf('Natural images not found.\n') + vsNI = rectGridAnalysis(NP); + end + + try + vsNV = movieAnalysis(NP); + params.StimsPresent{6} = 'NV'; + if isempty(vsNV.VST), error('absent'); end + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("NV"), 0, 0}; + catch + params.StimsPresent{6} = ''; + fprintf('Natural video not found.\n') + vsNV = rectGridAnalysis(NP); + end + + try + vsFFF = fullFieldFlashAnalysis(NP); + params.StimsPresent{7} = 'FFF'; + if isempty(vsFFF.VST), error('absent'); end + longTable(end+1,:) = {categorical(Animal), categorical(j), categorical("FFF"), 0, 0}; + catch + params.StimsPresent{7} = ''; + fprintf('Full-field flash not found.\n') + vsFFF = rectGridAnalysis(NP); + end + + % ------------------------------------------------------------------ + % 4b – Run analyses. [SUGG-1] replaces 7 × ~8-line conditional + % blocks with a single loop and one local helper. + % ------------------------------------------------------------------ + vsObjs = { vs, vsR, vsBr, vsG, vsNI, vsNV, vsFFF }; + presKeys = params.StimsPresent(1:7); + % 'SDG' key covers vsG for both SDGm and SDGs extraction below + stimKeys = {'MB', 'RG', 'MBR', 'SDG', 'NI', 'NV', 'FFF' }; + + statsAll = cell(1, 7); + rwAll = cell(1, 7); + for k = 1:7 + % runStimAnalysis handles ResponseWindow + statistics dispatch, + % computes or loads from cache based on presence and overwrite flags. + [statsAll{k}, rwAll{k}] = runStimAnalysis( ... + vsObjs{k}, presKeys{k}, stimKeys{k}, params, Stims2Comp); + end + + % ------------------------------------------------------------------ + % 4c – Extract z-scores, p-values, spike rate, spike diff. + % [SUGG-1] extractStimVals handles Speed/Moving/Static subfields. + % All outputs are column vectors of length N (unit count). + % ------------------------------------------------------------------ + [zS.MB, pV.MB, spkR.MB, spkDiff.MB ] = extractStimVals(statsAll{1}, rwAll{1}, 'MB', params.StatMethod); + [zS.RG, pV.RG, spkR.RG, spkDiff.RG ] = extractStimVals(statsAll{2}, rwAll{2}, 'RG', params.StatMethod); + [zS.MBR, pV.MBR, spkR.MBR, spkDiff.MBR ] = extractStimVals(statsAll{3}, rwAll{3}, 'MBR', params.StatMethod); + [zS.SDGm, pV.SDGm, spkR.SDGm, spkDiff.SDGm] = extractStimVals(statsAll{4}, rwAll{4}, 'SDGm', params.StatMethod); + [zS.SDGs, pV.SDGs, spkR.SDGs, spkDiff.SDGs] = extractStimVals(statsAll{4}, rwAll{4}, 'SDGs', params.StatMethod); + [zS.NI, pV.NI, spkR.NI, spkDiff.NI ] = extractStimVals(statsAll{5}, rwAll{5}, 'NI', params.StatMethod); + [zS.NV, pV.NV, spkR.NV, spkDiff.NV ] = extractStimVals(statsAll{6}, rwAll{6}, 'NV', params.StatMethod); + [zS.FFF, pV.FFF, spkR.FFF, spkDiff.FFF ] = extractStimVals(statsAll{7}, rwAll{7}, 'FFF', params.StatMethod); + + % Total units in this recording (before any filtering) + totalU{j} = numel(zS.MB); + + % ------------------------------------------------------------------ + % 4d – [SUGG-3] Optional FDR correction on raw p-values. + % Applied per-recording before the significance threshold is used. + % Corrects for the number of neurons tested simultaneously. + % ------------------------------------------------------------------ + if params.useFDR + for sni = stimNames_all + pV.(sni{1}) = bhFDR(pV.(sni{1})); + end + end + + % ------------------------------------------------------------------ + % 4e – [SUGG-2] Set absent-stimulus data to NaN. + % NaN propagates cleanly through isnan/nanmean/nanmax and + % integrates with the significance masks below without extra + % -Inf guard code throughout. + % ------------------------------------------------------------------ + % Mapping: {StimsPresent index, stimulus field name(s)} + absentMap = { 2, {'RG'}; 3, {'MBR'}; 4, {'SDGm','SDGs'}; ... + 5, {'NI'}; 6, {'NV'}; 7, {'FFF'} }; + N = numel(zS.MB); % total units (all same length by construction) + for ai = 1:size(absentMap, 1) + if isequal(params.StimsPresent{absentMap{ai,1}}, '') + for sni = absentMap{ai,2} + sn = sni{1}; + zS.(sn) = nan(N, 1); + pV.(sn) = nan(N, 1); + spkR.(sn) = nan(N, 1); + spkDiff.(sn) = nan(N, 1); + end + end + end + + % ------------------------------------------------------------------ + % 4f – Optional: suppress z-scores of non-significant secondary neurons + % (only meaningful when comparing across stimuli with a shared anchor) + % ------------------------------------------------------------------ + if params.ignoreNonSignif + for sni = stimNames_all + zS.(sni{1})(pV.(sni{1}) > params.threshold) = NaN; + end + end + + % ------------------------------------------------------------------ + % 4g – Determine the anchor p-value vector. + % [SUGG-6] Direct struct field access replaces the eval-based + % variable-name lookup used in the original code. + % ------------------------------------------------------------------ + anchorField = Stims2Comp{1}; + if strcmp(anchorField, 'SDG'), anchorField = 'SDGm'; end % SDG → moving + pvalsAnchor = pV.(anchorField); + anchorMask = ~isnan(pvalsAnchor) & (pvalsAnchor <= params.threshold); + + % ------------------------------------------------------------------ + % 4h – Populate pairwise comparison table. + % [SUGG-6] Uses stimLookup struct; no eval/who required. + % ------------------------------------------------------------------ + if ~isempty(params.ComparePairs) + cp1 = params.ComparePairs{1}; + cp2 = params.ComparePairs{2}; + + % A neuron enters the table if it is significant for EITHER stimulus + sigMask = (~isnan(pV.(cp1)) & pV.(cp1) < params.threshold) | ... + (~isnan(pV.(cp2)) & pV.(cp2) < params.threshold); + unitIDs = find(sigMask); + + if ~isempty(unitIDs) + nu = numel(unitIDs); + zC1 = zS.(cp1)(sigMask); rC1 = spkR.(cp1)(sigMask); + zC2 = zS.(cp2)(sigMask); rC2 = spkR.(cp2)(sigMask); + repAnimal = categorical(cellstr(repmat(Animal, nu, 1))); + repInser = categorical(repmat(j, nu, 1)); + + T1 = table(repAnimal, repInser, ... + categorical(cellstr(repmat(cp1, nu, 1))), categorical(unitIDs)', ... + zC1', rC1', ... + 'VariableNames', {'animal','insertion','stimulus','NeurID','Z-score','SpkR'}); + T2 = table(repAnimal, repInser, ... + categorical(cellstr(repmat(cp2, nu, 1))), categorical(unitIDs)', ... + zC2', rC2', ... + 'VariableNames', {'animal','insertion','stimulus','NeurID','Z-score','SpkR'}); + + longTablePairComp = [longTablePairComp; T1; T2]; + end + end + + % ------------------------------------------------------------------ + % 4i – Filter data and count responsive neurons per stimulus. + % Two subsets per stimulus: + % 'anchor-filtered' : neurons sig. for the anchor stimulus + % 'self-responsive' : neurons sig. for this stimulus itself + % ------------------------------------------------------------------ + respIndexes = []; % accumulates union of responsive indices + + for sni = stimNames_all + sn = sni{1}; + + % Anchor-filtered subset (all stimuli indexed by the same mask) + zScoresCells.(sn){j} = zS.(sn)(anchorMask); + spKrCells.(sn){j} = spkR.(sn)(anchorMask)'; % row vector (matches original packing) + diffSpkCells.(sn){j} = spkDiff.(sn)(anchorMask); + pvalsCells.(sn){j} = pV.(sn)(anchorMask); + + % Self-responsive subset + selfMask = ~isnan(pV.(sn)) & (pV.(sn) <= params.threshold); + zScoresgCells.(sn){j} = zS.(sn)(selfMask); + spKrGCells.(sn){j} = spkR.(sn)(selfMask); % column vector + diffSpkGCells.(sn){j} = spkDiff.(sn)(selfMask); + sumNeurCells.(sn){j} = sum(selfMask); + respIndexes = [respIndexes, find(selfMask)]; + + % Update fraction-responsive table if this insertion/stimulus row exists + try + idx = (longTable.insertion == categorical(j)) & ... + (longTable.stimulus == categorical(sn)); + if any(idx) + longTable.respNeur(idx) = sum(selfMask); + longTable.totalSomaticN(idx) = N; % same denominator for all rows + end + end + end + + responsiveNeurons{j} = unique(respIndexes); + + % [BUG-5 fix] Sanity check: NI and NV filtered lengths must match + nNI = sum(~isnan(pvalsCells.NI{j})); + nNV = sum(~isnan(pvalsCells.NV{j})); + if nNI ~= nNV + warning('PlotZScoreComparison:sizeMismatch', ... + 'NI and NV anchor-filtered lengths differ (%d vs %d) in experiment %d.', ... + nNI, nNV, ex); + end + + % ------------------------------------------------------------------ + % 4j – Animal / insertion change detection. + % [BUG-3 fix] AnimalChanged is evaluated BEFORE AnimalI is + % updated, so the insertion counter can use the correct + % pre-update state. + % ------------------------------------------------------------------ + Insertion = str2double(regexp( ... + regexp(vs.getAnalysisFileName, 'Insertion\d+', 'match', 'once'), '\d+', 'match')); + if isempty(Insertion), Insertion = 0; end % safety for missing tag + + AnimalChanged = (Animal ~= AnimalI); % evaluate BEFORE modifying AnimalI + if AnimalChanged + animal = animal + 1; + AnimalNames{animal} = Animal; %#ok + AnimalI = Animal; + end + if Insertion ~= InsertionI || AnimalChanged % [BUG-3 fix] use pre-evaluated flag + InsertionI = Insertion; + insertion = insertion + 1; + end + + % Replicate animal / insertion IDs once per anchor-filtered neuron + nAnchor = sum(anchorMask); + animalVector{j} = repmat(animal, [1, nAnchor]); + insertionVector{j} = repmat(insertion, [1, nAnchor]); + + j = j + 1; + fprintf('Finished recording: %s .\n', NP.recordingName) + end + + % ====================================================================== + % SECTION 4-END: PACK STRUCT S AND SAVE + % Maintain original field-name conventions (prefixes: zScores, spKr, + % diffSpk, spkR, spkDiff, sumNeur) for backward compatibility with any + % code that loads the saved .mat file. + % ====================================================================== + for sni = stimNames_all + sn = sni{1}; + % Anchor-filtered (one cell per recording) + S.stimValsSignif2oneStim.(['zScores' sn]) = zScoresCells.(sn); + S.stimValsSignif2oneStim.(['spKr' sn]) = spKrCells.(sn); + S.stimValsSignif2oneStim.(['diffSpk' sn]) = diffSpkCells.(sn); + S.pvals.(['pvals' sn]) = pvalsCells.(sn); + % Self-responsive (note different capitalisation to match original patterns) + S.stimValsSignif.(['zScores' sn 'g']) = zScoresgCells.(sn); + S.stimValsSignif.(['spkR' sn 'g']) = spKrGCells.(sn); + S.stimValsSignif.(['spkDiff' sn 'g']) = diffSpkGCells.(sn); + S.stimValsSignif.(['sumNeur' sn]) = sumNeurCells.(sn); + end + + S.expList = expList; + S.animalVector = animalVector; + S.insertionVector = insertionVector; + S.totalUnits = totalU; + S.params = params; + S.responsiveNeurons = responsiveNeurons; + S.TableRespNeurs = longTable; + S.TableStimComp = longTablePairComp; + + save([saveDir nameOfFile], '-struct', 'S'); +end % if forloop + +% ========================================================================= +% SECTION 5 – PAIRWISE COMPARISON MODE (ComparePairs is non-empty) +% ========================================================================= +if ~isempty(params.ComparePairs) + + pairs = params.ComparePairs; % cell of stimulus names; rows = pairs + + % [BUG-1 fix] Guard: splitapply crashes on an empty grouping vector + if isempty(S.TableStimComp) || height(S.TableStimComp) == 0 + warning('PlotZScoreComparison:noUnits', ... + 'No significant units found for %s vs %s. Returning empty figure.', ... + pairs{1}, pairs{2}); + fig = figure; return + end + + % Treat residual NaN values as zero (conservative: no response) + S.TableStimComp.('Z-score')(isnan(S.TableStimComp.('Z-score'))) = 0; + S.TableStimComp.SpkR(isnan(S.TableStimComp.SpkR)) = 0; + + % Keep only insertions that contain both stimuli in every pair + [G, ~] = findgroups(S.TableStimComp.insertion); + hasAll = splitapply( ... + @(s) all(ismember(unique(categorical(pairs)), s)), ... + S.TableStimComp.stimulus, G); + + nBoot = 10000; % bootstrap iterations for hierarchical resampling + + % Reload vs for figure saving (path may be stale if forloop did not run) + NP = loadNPclassFromTable(expList(1)); + vs = linearlyMovingBallAnalysis(NP); + + % Build display labels once (RG→SB, SDGs→SG, SDGm→MG) + s = replace(replace(replace(string(pairs), "RG","SB"), "SDGs","SG"), "SDGm","MG"); + + % ------------------------------------------------------------------ + % 5a – Z-score: hierarchical bootstrap + swarm + % ------------------------------------------------------------------ + j = 1; + ps = zeros(1, size(pairs, 1)); + + for i = 1:size(pairs, 1) + [diffs, insers, animals] = collectPairDiffs( ... + S.TableStimComp, pairs, j, 'Z-score'); + bootDiff = hierBoot(diffs, nBoot, insers, animals); + ps(j) = mean(bootDiff <= 0); + j = j + 1; + end + + % Swarm; discard randiColors — scatter will use all points [SUGG-5] + [fig, ~] = plotSwarmBootstrapWithComparisons(S.TableStimComp, pairs, ps, {'Z-score'}, ... + yLegend='Z-score', yMaxVis=40, diff=true, plotMeanSem=false, Alpha=0.7); + applyPaperAxes(gca); + set(fig, 'Units','centimeters', 'Position',[20 20 4 6]); + if params.PaperFig + vs.printFig(fig, sprintf('Zscore-comparison-Swarm-%s-%s', pairs{1}, pairs{2}), ... + PaperFig=params.PaperFig); + end + + % ------------------------------------------------------------------ + % 5b – Z-score scatter [SUGG-5: all points; alpha=0.25 for overlap] + % ------------------------------------------------------------------ + fig = figure; + [p1z, p2z, cA] = getPairScatterData(S.TableStimComp, pairs, 'Z-score'); + scatter(p1z, p2z, 7, cA, 'filled', 'MarkerFaceAlpha', 0.25) + hold on; axis equal + lims = [-5 40]; ylim(lims); xlim(lims) + plot(lims, lims, 'k--', 'LineWidth', 1.5) + xlabel(s{1}); ylabel(s{2}) + colormap(lines(numel(categories(S.TableStimComp.animal)))) + applyPaperAxes(gca); title('Z-score') + set(fig, 'Units','centimeters', 'Position',[20 20 5 5]); + if params.PaperFig + vs.printFig(fig, sprintf('Zscore-comparison-Scatter-%s-%s', pairs{1}, pairs{2}), ... + PaperFig=params.PaperFig); + end + + % ------------------------------------------------------------------ + % 5c – Spike rate: hierarchical bootstrap + swarm + % ------------------------------------------------------------------ + j = 1; + ps = zeros(1, size(pairs, 1)); + + for i = 1:size(pairs, 1) + [diffs, insers, animals] = collectPairDiffs( ... + S.TableStimComp, pairs, j, 'SpkR'); + bootDiff = hierBoot(diffs, nBoot, insers, animals); + ps(j) = mean(bootDiff <= 0); + j = j + 1; + end + + V1max = max(abs(diffs)); % set y-ceiling from actual data range + [fig, ~] = plotSwarmBootstrapWithComparisons(S.TableStimComp, pairs, ps, {'SpkR'}, ... + yLegend='SpkR', yMaxVis=V1max, diff=true, plotMeanSem=false, Alpha=0.7); + applyPaperAxes(gca); + set(fig, 'Units','centimeters', 'Position',[20 20 4 6]); + if params.PaperFig + vs.printFig(fig, sprintf('spkRate-comparison-Swarm-%s-%s', pairs{1}, pairs{2}), ... + PaperFig=params.PaperFig); + end + + % ------------------------------------------------------------------ + % 5d – Spike-rate scatter [SUGG-4: optional log scale; SUGG-5: all pts] + % ------------------------------------------------------------------ + fig = figure; + [p1r, p2r, cA] = getPairScatterData(S.TableStimComp, pairs, 'SpkR'); + scatter(p1r, p2r, 7, cA, 'filled', 'MarkerFaceAlpha', 0.25) % all points + hold on; axis equal + posVals = S.TableStimComp.SpkR(S.TableStimComp.SpkR > 0); + if ~isempty(posVals) + lims = [min(posVals), max(posVals)]; + else + lims = [0, 1]; + end + if params.logScaleSpkR && all(lims > 0) % [SUGG-4] + set(gca, 'XScale','log', 'YScale','log') + end + plot(lims, lims, 'k--', 'LineWidth', 1.5) + ylim(lims); xlim(lims) + xlabel(s{1}); ylabel(s{2}) + colormap(lines(numel(categories(S.TableStimComp.animal)))) + applyPaperAxes(gca); title('Spk. rate') + set(fig, 'Units','centimeters', 'Position',[20 20 5 5]); + if params.PaperFig + vs.printFig(fig, sprintf('spkRate-comparison-Scatter-%s-%s', pairs{1}, pairs{2}), ... + PaperFig=params.PaperFig); + end + +else + % ====================================================================== + % SECTION 5-ALT – MULTI-STIMULUS OVERVIEW (no ComparePairs) + % Four-panel figure: Z-score swarm, Z-score scatter, + % spike-rate swarm, spike-rate scatter. + % ====================================================================== + fig = figure; + tiledlayout(2, 2, 'TileSpacing', 'compact'); + + % Choose field set based on filtering mode + if ~params.EachStimSignif + fn = fieldnames(S.stimValsSignif2oneStim); + else + fn = fieldnames(S.stimValsSignif); + end + fnp = fieldnames(S.pvals); + + % Expand 'SDG' into moving + static sub-conditions + Stims2Comp2 = {}; + for i = 1:numel(Stims2Comp) + if strcmp(Stims2Comp{i}, 'SDG') + Stims2Comp2 = [Stims2Comp2, {'SDGs','SDGm'}]; %#ok + else + Stims2Comp2 = [Stims2Comp2, Stims2Comp(i)]; %#ok + end + end + + % Field suffix: '' for anchor-filtered, 'g' for self-responsive + ending2 = repmat({'','g'}, 1, 2); + ending2 = ending2{1 + params.EachStimSignif}; + + % Assemble concatenated data arrays for swarm and scatter + StimZS = cell(numel(Stims2Comp2), 1); + stimRSP = cell(numel(Stims2Comp2), 1); + stimPvals = cell(numel(Stims2Comp2), 1); + x = []; % stimulus index label per neuron + + for i = 1:numel(Stims2Comp2) + ending = Stims2Comp2{i}; + + % Z-scores + pattern = ['^zS.*' ending ending2 '$']; + matches = fn(~cellfun('isempty', regexp(fn, pattern))); + if ~params.EachStimSignif + StimZS{i} = cell2mat(S.stimValsSignif2oneStim.(matches{1}))'; + else + StimZS{i} = cell2mat(S.stimValsSignif.(matches{1}))'; + end + + % Spike rates (or diff-response if diffResp flag is set) + if ~params.diffResp + pattern = ['^spKr.*' ending ending2 '$']; + else + pattern = ['^diffSpk.*' ending ending2 '$']; + end + matches = fn(~cellfun('isempty', regexp(fn, pattern))); + if params.EachStimSignif + matches = fn(~cellfun('isempty', regexp(fn, pattern, 'ignorecase'))); + C = S.stimValsSignif.(matches{1}); + C = cellfun(@(x) x', C, 'UniformOutput', false); + stimRSP{i} = cell2mat(C'); + else + try + stimRSP{i} = cell2mat(S.stimValsSignif2oneStim.(matches{1})'); + catch + try + stimRSP{i} = cell2mat(S.stimValsSignif2oneStim.(matches{1})); + catch + Ccol = cellfun(@(x) x(:), S.stimValsSignif2oneStim.(matches{1}), ... + 'UniformOutput', false); + stimRSP{i} = vertcat(Ccol{:})'; + end + end + end + + % p-values (for completeness; not plotted directly here) + pattern = ['^pvals.*' ending '$']; + matches = fnp(~cellfun('isempty', regexp(fnp, pattern))); + stimPvals{i} = cell2mat(S.pvals.(matches{1}))'; + + x = [x; ones(size(StimZS{i})) * i]; %#ok + end + + % Per-neuron animal / insertion labels for colouring + AnIndex = cell2mat(S.animalVector)'; + InsIndex = cell2mat(S.insertionVector)'; + colormapUsed = parula(max(AnIndex)) .* 0.6; + allColorIdx = repmat(AnIndex, numel(Stims2Comp2), 1); + + % ------------------------------------------------------------------ + % Panel 1: Z-score swarm + % ------------------------------------------------------------------ + y = cell2mat(StimZS); + nexttile + if ~params.EachStimSignif + swarmchart(x, y, 5, colormapUsed(allColorIdx,:), 'filled', 'MarkerFaceAlpha', 0.7); + else + swarmchart(x, y, 5, 'filled', 'MarkerFaceAlpha', 0.7); + end + xticks(1:8); xticklabels(Stims2Comp2); ylabel('Z-score'); + set(fig, 'Color','w'); yline(0, 'LineWidth', 2); ylim([-5 40]) + + % Z-score group-level hierarchical bootstrap + if params.overwriteGroupStats || ~isfield(S, 'groupStats') + [probs, ps_boot] = runGroupBoot(y, x, InsIndex, AnIndex, numel(Stims2Comp2)); + S.groupStats.Bayes_ZscoreCompare = probs; + S.groupStats.P_ZscoreCompare = ps_boot; % [BUG-6 fix: nested correctly] + save([saveDir nameOfFile], '-struct', 'S'); + end + + % ------------------------------------------------------------------ + % Panel 2: Z-score scatter [SUGG-5: all points, alpha=0.25] + % ------------------------------------------------------------------ + nexttile + if isempty(params.StimsToCompare) + ind1 = 1; ind2 = 2; + else + ind1 = find(strcmp(Stims2Comp2, params.StimsToCompare{1})); + ind2 = find(strcmp(Stims2Comp2, params.StimsToCompare{2})); + end + VTC = {StimZS{ind1}, StimZS{ind2}}; + if numel(VTC{1}) == numel(VTC{2}) + scatter(VTC{1}, VTC{2}, 10, AnIndex, 'filled', 'MarkerFaceAlpha', 0.25) + colormap(colormapUsed); hold on; axis equal + validY = y(~isnan(y) & ~isinf(y)); + lims = [min(validY), max(validY)]; + plot(lims, lims, 'k--', 'LineWidth', 1.5) + lims = [-5 40]; ylim(lims); xlim(lims) + xlabel(Stims2Comp(ind1)); ylabel(Stims2Comp(ind2)) + end + + % ------------------------------------------------------------------ + % Panel 3: Spike-rate swarm + % ------------------------------------------------------------------ + y = cell2mat(stimRSP); + nexttile + if ~params.EachStimSignif + swarmchart(x, y, 5, colormapUsed(allColorIdx,:), 'filled', 'MarkerFaceAlpha', 0.7); + else + swarmchart(x, y, 5, 'filled', 'MarkerFaceAlpha', 0.7); + end + xticks(1:8); xticklabels(Stims2Comp2); ylabel('Spike Rate'); set(fig,'Color','w') + + % Spike-rate group-level hierarchical bootstrap + if params.overwriteGroupStats || ~isfield(S, 'groupStats') + [probs, ps_boot] = runGroupBoot(y, x, InsIndex, AnIndex, numel(Stims2Comp2)); + S.groupStats.Bayes_SpikeRateCompare = probs; + S.groupStats.P_SpikeRateCompare = ps_boot; + end + + % ------------------------------------------------------------------ + % Panel 4: Spike-rate scatter [SUGG-4: log scale; SUGG-5: all pts] + % ------------------------------------------------------------------ + nexttile + VTC = {stimRSP{ind1}, stimRSP{ind2}}; + if numel(VTC{1}) == numel(VTC{2}) + scatter(VTC{1}, VTC{2}, 10, AnIndex, 'filled', 'MarkerFaceAlpha', 0.25) + colormap(colormapUsed); hold on; axis equal + posY = y(y > 0 & ~isinf(y) & ~isnan(y)); + lims = [min(posY), max(posY)]; + if params.logScaleSpkR && all(lims > 0) % [SUGG-4] + set(gca, 'XScale','log', 'YScale','log') + end + plot(lims, lims, 'k--', 'LineWidth', 1.5) + ylim(lims); xlim(lims) + xlabel(Stims2Comp(ind1)); ylabel(Stims2Comp(ind2)) + end + +end % if/else ComparePairs + +% ========================================================================= +% SECTION 6 – FRACTION-RESPONSIVE ANALYSIS +% ========================================================================= +if isempty(params.ComparePairs) + pairs = {Stims2Comp{1}, Stims2Comp{2}}; +else + pairs = params.ComparePairs; +end + +% Insertions with both stimuli present +[G, ~] = findgroups(S.TableRespNeurs.insertion); +hasAll = splitapply( ... + @(s) all(ismember(unique(categorical(pairs)), s)), ... + S.TableRespNeurs.stimulus, G); +tempTable = S.TableRespNeurs( ... + hasAll(G) & ismember(S.TableRespNeurs.stimulus, unique(categorical(pairs))), :); + +nBoot = 10000; +j = 1; +ps = zeros(1, size(pairs, 1)); + +for i = 1:size(pairs, 1) + diffs = []; + for ins = unique(S.TableRespNeurs.insertion)' + idx1 = S.TableRespNeurs.insertion==categorical(ins) & S.TableRespNeurs.stimulus==pairs{j,1}; + idx2 = S.TableRespNeurs.insertion==categorical(ins) & S.TableRespNeurs.stimulus==pairs{j,2}; + if any(idx1) && any(idx2) + tot = S.TableRespNeurs.totalSomaticN(idx1); % shared denominator + diffs(end+1, 1) = S.TableRespNeurs.respNeur(idx1)/tot - ... + S.TableRespNeurs.respNeur(idx2)/tot; %#ok + end + end + bootDiff = bootstrp(nBoot, @mean, diffs); + ps(j) = mean(bootDiff <= 0); + j = j + 1; +end + +% Add total-responsive column for the plotting helper +[G, ~] = findgroups(tempTable.insertion); +totals = splitapply(@sum, tempTable.respNeur, G); +tempTable.TotalRespNeur = totals(G); + +fig = plotSwarmBootstrapWithComparisons(tempTable, pairs, ps, ... + {'respNeur','totalSomaticN'}, fraction=true, ... + yLegend='Responsive/total units', diff=false, filled=false, ... + Xjitter='none', Alpha=0.6); + +applyPaperAxes(gca); +set(fig, 'Units','centimeters', 'Position',[20 20 5 6]); + +if params.PaperFig && ~isempty(params.ComparePairs) + vs.printFig(fig, sprintf('ResponsiveUnits-comparison-%s-%s', ... + params.ComparePairs{1}, params.ComparePairs{2}), PaperFig=params.PaperFig); +end + +end % ===== END OF MAIN FUNCTION ===== + + +% ========================================================================= +% LOCAL HELPER FUNCTIONS +% ========================================================================= + +% ------------------------------------------------------------------------- +function [stats, rw] = runStimAnalysis(vsObj, presentKey, stimKey, params, Stims2Comp) +% runStimAnalysis Compute or load statistics for one stimulus. +% +% If presentKey is non-empty AND the stimulus appears in Stims2Comp the +% ResponseWindow and the selected statistics method are (re-)computed. +% Otherwise only the cached result is loaded. Both branches return the +% same output types, so callers need no conditional logic. +% +% INPUTS +% vsObj – analysis object (e.g. linearlyMovingBallAnalysis) +% presentKey – string key in params.StimsPresent, or '' if absent +% stimKey – canonical stimulus name ('MB','RG',…) for display only +% params – parameter struct from the main function arguments block +% Stims2Comp – cell of stimulus names that should be analysed +% +% OUTPUTS +% stats – statistics struct (ShufflingAnalysis / Bootstrap / Statistics) +% rw – ResponseWindow struct + + shouldCompute = ~isequal(presentKey, '') && ismember(presentKey, Stims2Comp); + + if shouldCompute + vsObj.ResponseWindow('overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + switch params.StatMethod + case 'ObsWindow' + vsObj.ShufflingAnalysis('overwrite', params.overwriteStats, ... + 'N_bootstrap', params.shuffles); + case 'bootsrapRespBase' + vsObj.BootstrapPerNeuron('overwrite', params.overwriteStats); + case 'maxPermuteTest' + vsObj.StatisticsPerNeuron('overwrite', params.overwriteStats); + end + else + vsObj.ResponseWindow; % load cached result only; no recompute + end + + % Retrieve whatever is cached (identical API regardless of compute path) + switch params.StatMethod + case 'ObsWindow'; stats = vsObj.ShufflingAnalysis; + case 'bootsrapRespBase'; stats = vsObj.BootstrapPerNeuron; + case 'maxPermuteTest'; stats = vsObj.StatisticsPerNeuron; + end + rw = vsObj.ResponseWindow; +end + + +% ------------------------------------------------------------------------- +function [zS, pV, spkR, spkDiff] = extractStimVals(stats, rw, stimKey, StatMethod) +% extractStimVals Pull z-scores, p-values, and spike metrics from a +% stats/rw struct pair. All outputs are N×1 column vectors. +% +% Handles three structural cases: +% • Speed-based subfields (MB, MBR) – uses Speed1; prefers Speed2 +% • Moving/Static subfields (SDGm, SDGs) +% • Flat structure (RG, NI, NV, FFF) +% +% Falls back to the flat-structure extractor when SDG subfields are absent +% (e.g. when vsG is a dummy rectGridAnalysis object). + + switch stimKey + + case {'MB', 'MBR'} + % Use the fastest available speed (Speed2 preferred over Speed1) + sp = 'Speed1'; + if isfield(stats, 'Speed2'), sp = 'Speed2'; end + + zS = stats.(sp).ZScoreU; + pV = stats.(sp).pvalsResponse; + spkR = max(rw.(sp).NeuronVals(:,:,4), [], 2); % max across directions + spkDiff = max(rw.(sp).NeuronVals(:,:,5), [], 2); % response – baseline + if ~isequal(StatMethod, 'ObsWindow') + try; spkR = mean(stats.(sp).ObsResponse)'; catch; end + end + + case 'SDGm' + % Drifting grating – moving condition + try + zS = stats.Moving.ZScoreU; + pV = stats.Moving.pvalsResponse; + spkR = max(rw.Moving.NeuronVals(:,:,4), [], 2); + spkDiff = max(rw.Moving.NeuronVals(:,:,5), [], 2); + if ~isequal(StatMethod, 'ObsWindow') + spkR = mean(stats.Moving.ObsResponse, 1)'; + end + catch + % Dummy vsG object (absent SDG): extract from flat struct + [zS, pV, spkR, spkDiff] = extractFlat(stats, rw, StatMethod); + end + + case 'SDGs' + % Drifting grating – static condition + try + zS = stats.Static.ZScoreU; + pV = stats.Static.pvalsResponse; + spkR = max(rw.Static.NeuronVals(:,:,4), [], 2); + spkDiff = max(rw.Static.NeuronVals(:,:,5), [], 2); + if ~isequal(StatMethod, 'ObsWindow') + spkR = mean(stats.Static.ObsResponse, 1)'; + end + catch + [zS, pV, spkR, spkDiff] = extractFlat(stats, rw, StatMethod); + end + + otherwise % RG, NI, NV, FFF – flat (no subfields) + [zS, pV, spkR, spkDiff] = extractFlat(stats, rw, StatMethod); + end + + % Guarantee column vectors regardless of how the analysis object returns data + zS = zS(:); pV = pV(:); spkR = spkR(:); spkDiff = spkDiff(:); +end + + +% ------------------------------------------------------------------------- +function [zS, pV, spkR, spkDiff] = extractFlat(stats, rw, StatMethod) +% extractFlat Extract from a stats struct with no Speed/Moving/Static nesting. + zS = stats.ZScoreU; + pV = stats.pvalsResponse; + spkR = max(rw.NeuronVals(:,:,4), [], 2); + spkDiff = max(rw.NeuronVals(:,:,5), [], 2); + if ~isequal(StatMethod, 'ObsWindow') + try; spkR = mean(stats.ObsResponse, 1)'; catch; end + end +end + + +% ------------------------------------------------------------------------- +function pAdj = bhFDR(pVals) +% bhFDR Benjamini-Hochberg false-discovery rate correction. +% Operates only on non-NaN entries; NaN values are preserved unchanged. +% +% INPUT pVals – N×1 (or 1×N) vector of raw p-values (may contain NaN) +% OUTPUT pAdj – BH-adjusted p-values, same shape as input +% +% Algorithm: +% 1. Sort non-NaN p-values in ascending order. +% 2. Multiply each by n/rank (BH formula). +% 3. Enforce monotonicity by a reverse cumulative minimum pass. +% 4. Cap at 1. + + pAdj = pVals; + validMask = ~isnan(pVals); + p = pVals(validMask); + n = numel(p); + if n == 0, return; end + + [sortedP, sortIdx] = sort(p); + adjP = sortedP .* n ./ (1:n)'; % BH: p_i * n / rank_i + adjP = min(flipud(cummin(flipud(adjP))), 1); % monotone & ≤ 1 + + result = zeros(n, 1); + result(sortIdx) = adjP; % restore original order + pAdj(validMask) = result; +end + + +% ------------------------------------------------------------------------- +function [diffs, insers, animals] = collectPairDiffs(T, pairs, pairIdx, colName) +% collectPairDiffs Pool per-neuron differences (stim1 – stim2) across +% insertions for one row of the pairs matrix. +% +% Returns column vectors diffs, insers, animals of equal length, +% suitable for direct input to hierBoot(). + + diffs = []; insers = []; animals = []; + for ins = unique(T.insertion)' + idx1 = T.insertion == categorical(ins) & T.stimulus == pairs{pairIdx, 1}; + idx2 = T.insertion == categorical(ins) & T.stimulus == pairs{pairIdx, 2}; + V1 = T.(colName)(idx1); + V2 = T.(colName)(idx2); + an = unique(T.animal(idx1)); + diffs = [diffs; V1 - V2]; %#ok + insers = [insers; double(repmat(ins, size(V1,1), 1))]; %#ok + animals = [animals; double(repmat(an, size(V1,1), 1))]; %#ok + end +end + + +% ------------------------------------------------------------------------- +function [p1, p2, colorAnimal] = getPairScatterData(T, pairs, colName) +% getPairScatterData Extract aligned vectors for a scatter plot. +% p1 and p2 are the column values for the first and second stimuli; +% colorAnimal is the animal index for colouring markers. + + p1 = T.(colName)(T.stimulus == pairs{1}); + p2 = T.(colName)(T.stimulus == pairs{2}); + colorAnimal = T.animal(T.stimulus == pairs{1}); +end + + +% ------------------------------------------------------------------------- +function [probs, ps_out] = runGroupBoot(y, x, InsIndex, AnIndex, nStims) +% runGroupBoot Hierarchical bootstrap comparing stimulus 1 against all +% others in the swarm data. +% +% INPUTS +% y – concatenated response values (all stimuli stacked) +% x – stimulus index label for each element of y (1…nStims) +% InsIndex – insertion index per neuron (same length as y for stim 1) +% AnIndex – animal index per neuron +% nStims – total number of stimuli +% +% OUTPUTS +% probs – cell of Bayesian overlap probabilities (stim2…stimN vs stim1) +% ps_out – cell of frequentist p-values (mean(BootSec >= BootFirst)) + + probs = cell(1, nStims - 1); + ps_out = cell(1, nStims - 1); + + FirstStim = y(x == 1); + validF = ~isnan(FirstStim); + BootFirst = hierBoot(FirstStim(validF), 10000, InsIndex(validF), AnIndex(validF)); + + for i = 2:nStims + sec = y(x == i); + sec(isnan(sec)) = 0; % NaN → 0 (absent = no response) + validMask = ~isinf(sec); % [SUGG-2] isinf replaces ==-inf + sec = sec(validMask); + BootSec = hierBoot(sec, 10000, InsIndex(validMask), AnIndex(validMask)); + probs{i-1} = get_direct_prob(BootFirst, BootSec); + ps_out{i-1} = mean(BootSec >= BootFirst); + end +end + + +% ------------------------------------------------------------------------- +function applyPaperAxes(ax) +% applyPaperAxes Apply standard axis formatting for publication. +% Centralising this in one function means font size / family changes +% propagate everywhere with a single edit. + ax.YAxis.FontSize = 8; ax.YAxis.FontName = 'helvetica'; + ax.XAxis.FontSize = 8; ax.XAxis.FontName = 'helvetica'; +end \ No newline at end of file diff --git a/visualStimulationAnalysis/OldVersions/AllExpAnalysisV3.m b/visualStimulationAnalysis/OldVersions/AllExpAnalysisV3.m new file mode 100644 index 0000000..29f928a --- /dev/null +++ b/visualStimulationAnalysis/OldVersions/AllExpAnalysisV3.m @@ -0,0 +1,837 @@ +function [tempTable] = AllExpAnalysis(expList, params) +% AllExpAnalysis Pool neural responses across Neuropixels recordings, +% run pairwise statistical comparisons via hierarchical bootstrapping, +% and generate publication-ready swarm and scatter plots. +% +% This function: +% 1. Iterates over a list of experiments, loading pre-computed per-neuron +% statistics (z-scores, p-values, spike rates) for each stimulus. +% 2. For each recording, identifies neurons responsive to ANY stimulus +% in ComparePairs (OR union) and adds them to a long-format table. +% 3. Computes pairwise hierarchical bootstrap tests between stimuli. +% 4. Plots swarm charts and scatter plots for z-scores and spike rates. +% 5. Computes a fraction-responsive comparison across insertions. +% +% INPUTS +% expList (1,:) double Row vector of experiment IDs from master Excel. +% params Name-value See arguments block below. +% +% OUTPUTS +% tempTable table Fraction-responsive table (one row per insertion × +% stimulus), filtered to insertions containing all +% compared stimuli. +% +% EXAMPLE +% tempTable = AllExpAnalysis([49:54 64:66], ... +% ComparePairs = {'SDGm','SDGs'}, ... +% StatMethod = 'maxPermuteTest', ... +% PaperFig = true); +% +% See also: hierBoot, plotSwarmBootstrapWithComparisons + +% ========================================================================= +% ARGUMENTS BLOCK +% ========================================================================= +arguments + expList (1,:) double % Experiment IDs from master Excel + params.ComparePairs cell % Stimuli to compare, e.g. {'SDGm','SDGs'} + % Neurons significant for ANY of these + % are included. Statistics are pairwise. + params.threshold double = 0.05 % p-value cutoff for responsiveness + params.StatMethod string = 'ObsWindow' % 'ObsWindow' | 'bootsrapRespBase' | 'maxPermuteTest' + params.overwrite logical = false % Recompute and overwrite saved pooled file + params.overwriteResponse logical = false % Force re-run of ResponseWindow + params.overwriteStats logical = false % Force re-run of per-neuron statistics + params.RespDurationWin double = 100 % Response window duration (ms) + params.shuffles double = 2000 % Shuffles / bootstrap iterations for per-neuron stats + params.useZmean logical = true % Use z_mean (response−baseline normalised by null) + % instead of raw peak spike rate + params.useFDR logical = false % Apply Benjamini-Hochberg FDR correction per recording + params.PaperFig logical = false % Save figures via vs.printFig + params.nBoot double = 10000 % Bootstrap iterations for group-level tests +end + +% ========================================================================= +% SECTION 1 — SETUP: DETERMINE STIMULI, PATHS, AND CACHING +% ========================================================================= + +% Unique stimulus names that need to be loaded and analysed +stimsNeeded = unique(params.ComparePairs, 'stable'); % e.g. {'SDGm','SDGs'} + +% Number of experiments to process +nExp = numel(expList); + +% Load the first experiment to extract directory paths +NP0 = loadNPclassFromTable(expList(1)); % Neuropixels recording object +vs0 = linearlyMovingBallAnalysis(NP0); % analysis object (used for path only) + +% Build the output directory: /lizards/Combined_lizard_analysis/ +rootPath = extractBefore(vs0.getAnalysisFileName, 'lizards'); % path up to 'lizards' +rootPath = [rootPath 'lizards']; % append 'lizards' +saveDir = fullfile(rootPath, 'Combined_lizard_analysis'); % subdirectory for pooled results +if ~exist(saveDir, 'dir') % create if absent + mkdir(saveDir); +end + +% Construct a descriptive filename for the cached pooled data +nameOfFile = sprintf('Ex_%d-%d_Combined_%s.mat', ... + expList(1), expList(end), strjoin(stimsNeeded, '-')); + +savePath = fullfile(saveDir, nameOfFile); % full path to cached .mat file + +% Decide whether the per-experiment loop needs to run: +% Skip if a cached file exists with the same experiment list AND overwrite=false +runLoop = true; % default: run the loop +if exist(savePath, 'file') == 2 && ~params.overwrite % cached file found + S = load(savePath); % load cached struct + if isfield(S, 'expList') && isequal(S.expList, expList) + runLoop = false; % cache is valid → skip loop + end +end + +% ========================================================================= +% SECTION 2 — INITIALISE LONG-FORMAT TABLES +% ========================================================================= + +% TableStimComp: one row per neuron × stimulus. +% Contains z-scores and spike rates for neurons responsive to ANY stimulus +% in ComparePairs (OR union across all stimuli). +TableStimComp = table( ... + categorical.empty(0,1), ... % animal — animal ID (e.g. 'PV97') + categorical.empty(0,1), ... % insertion — insertion counter (unique per probe track) + categorical.empty(0,1), ... % stimulus — stimulus abbreviation (e.g. 'SDGm') + categorical.empty(0,1), ... % NeurID — unit index within the recording + double.empty(0,1), ... % Z-score — z-score for this neuron × stimulus + double.empty(0,1), ... % SpkR — spike rate (or z_mean) for this neuron × stimulus + 'VariableNames', {'animal','insertion','stimulus','NeurID','Z-score','SpkR'}); + +% TableRespNeurs: one row per insertion × stimulus. +% Counts how many neurons are responsive to each stimulus (self-significant), +% and the total number of somatic units in that recording. +TableRespNeurs = table( ... + categorical.empty(0,1), ... % animal + categorical.empty(0,1), ... % insertion + categorical.empty(0,1), ... % stimulus + double.empty(0,1), ... % respNeur — count of responsive neurons + double.empty(0,1), ... % totalSomaticN — total sorted units in recording + 'VariableNames', {'animal','insertion','stimulus','respNeur','totalSomaticN'}); + +% ========================================================================= +% SECTION 3 — PER-EXPERIMENT LOOP +% ========================================================================= + +if runLoop + + % Counters for unique animals and insertions across the experiment list + animalCount = 0; % running count of distinct animals + insertionCount = 0; % running count of distinct probe insertions + prevAnimal = ""; % animal ID from the previous iteration + prevInsertion = 0; % insertion number from the previous iteration + + j = 1; % 1-based experiment counter (indexes cell arrays if needed later) + + for ex = expList % ---- iterate over each experiment ID ---- + + % ------------------------------------------------------------------ + % 3a — Load the recording and check stimulus availability + % ------------------------------------------------------------------ + + NP = loadNPclassFromTable(ex); % load Neuropixels recording object + fprintf('Processing recording: %s\n', NP.recordingName); % status message + + % Load analysis objects and check which stimuli are present + % vsObjs — containers.Map: objKey → analysis object + % present — containers.Map: stimName → logical + [vsObjs, present] = loadStimulusObjects(NP, stimsNeeded); + + % Skip this experiment if ANY needed stimulus is absent + allPresent = true; % assume all present + for si = 1:numel(stimsNeeded) + if ~present(stimsNeeded{si}) + allPresent = false; % at least one missing + break + end + end + if ~allPresent + fprintf(' → Skipping: not all stimuli present.\n'); + continue % skip to next experiment + end + + % ------------------------------------------------------------------ + % 3b — Parse metadata and update animal / insertion counters + % (only reached if all stimuli are present) + % ------------------------------------------------------------------ + + % Extract animal ID from the recording name (expects 'PV##' or 'SA##') + animalID = string(regexp(NP.recordingName, 'PV\d+', 'match', 'once')); + if animalID == "" % fallback naming convention + animalID = string(regexp(NP.recordingName, 'SA\d+', 'match', 'once')); + end + + % Extract insertion number from filename (e.g. 'Insertion2' → 2) + insStr = regexp( ... + linearlyMovingBallAnalysis(NP).getAnalysisFileName, ... + 'Insertion\d+', 'match', 'once'); % match 'Insertion#' + insNum = str2double(regexp(insStr, '\d+', 'match'));% parse the digit(s) + + % Update animal and insertion counters + animalChanged = (animalID ~= prevAnimal); % new animal? + if animalChanged + animalCount = animalCount + 1; % increment animal counter + prevAnimal = animalID; % update tracker + end + if insNum ~= prevInsertion || animalChanged % new insertion? + insertionCount = insertionCount + 1; % increment insertion counter + prevInsertion = insNum; % update tracker + end + + % ------------------------------------------------------------------ + % 3c — Run ResponseWindow + statistics for each stimulus + % ------------------------------------------------------------------ + + objKeys = keys(vsObjs); % unique analysis-object keys + for k = 1:numel(objKeys) + key = objKeys{k}; % e.g. 'SDG', 'MB', 'RG' + vsObj = vsObjs(key); % the analysis object + runStimStats(vsObj, params); % ResponseWindow + chosen stat method + vsObjs(key) = vsObj; % store back (handle class, but explicit) + end + + % ------------------------------------------------------------------ + % 3d — Extract z-scores, p-values, spike rates for each stimulus + % All stimuli are guaranteed present at this point. + % ------------------------------------------------------------------ + + stimData = struct(); % one sub-struct per stimulus + nUnits = []; % total unit count (set from first stim) + + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; % stimulus name (e.g. 'SDGm') + key = getObjKey(sn); % shared-object key (e.g. 'SDG') + + [z, p, spkR, spkDiff] = extractStimData( ... + vsObjs(key), sn, params.StatMethod, params.useZmean); + stimData.(sn).z = z(:); % force column vector + stimData.(sn).p = p(:); + stimData.(sn).spkR = spkR(:); + stimData.(sn).spkDiff = spkDiff(:); + + if isempty(nUnits) + nUnits = numel(z); % set unit count from first stimulus + end + end + + % ------------------------------------------------------------------ + % 3e — Optional: Benjamini-Hochberg FDR correction per recording + % ------------------------------------------------------------------ + + if params.useFDR + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + stimData.(sn).p = bhFDR(stimData.(sn).p); % adjust p-values for FDR + end + end + + % ------------------------------------------------------------------ + % 3f — Build OR significance mask across all compared stimuli + % A neuron is included if it is significant for ANY stimulus + % in ComparePairs. + % ------------------------------------------------------------------ + + orMask = false(nUnits, 1); % initialise all-false mask + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + orMask = orMask | (stimData.(sn).p < params.threshold); % OR with each stimulus + end + + unitIDs = find(orMask); % indices of neurons passing the OR filter + nSig = numel(unitIDs); % count of significant neurons + + % ------------------------------------------------------------------ + % 3g — Append rows to TableStimComp (neuron-level pairwise table) + % Each significant neuron gets one row PER stimulus. + % ------------------------------------------------------------------ + + if nSig > 0 + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + + % Build a mini-table for this stimulus × this recording + newRows = table( ... + repmat(categorical(cellstr(animalID)), nSig, 1), ... % animal column + repmat(categorical(insertionCount), nSig, 1), ... % insertion column + repmat(categorical(cellstr(sn)), nSig, 1), ... % stimulus column + categorical(unitIDs), ... % neuron ID column + stimData.(sn).z(orMask), ... % z-score column + stimData.(sn).spkR(orMask), ... % spike rate column + 'VariableNames', {'animal','insertion','stimulus','NeurID','Z-score','SpkR'}); + + TableStimComp = [TableStimComp; newRows]; % append to pooled table + end + end + + % ------------------------------------------------------------------ + % 3h — Append rows to TableRespNeurs (insertion-level counts) + % One row per stimulus: how many neurons respond to THIS stimulus + % (self-significant, not OR union), and total unit count. + % ------------------------------------------------------------------ + + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + nResp = sum(stimData.(sn).p < params.threshold); % self-responsive count + newRow = table( ... + categorical(cellstr(animalID)), ... % animal + categorical(insertionCount), ... % insertion + categorical(cellstr(sn)), ... % stimulus + nResp, ... % respNeur + nUnits, ... % totalSomaticN + 'VariableNames', TableRespNeurs.Properties.VariableNames); + TableRespNeurs = [TableRespNeurs; newRow]; % append row + end + + fprintf(' → %d / %d units pass OR filter.\n', nSig, nUnits); + j = j + 1; % advance experiment counter + + end % ---- end for ex = expList ---- + + % ===================================================================== + % SECTION 4 — SAVE POOLED DATA + % ===================================================================== + + S.expList = expList; % experiment IDs that were processed + S.TableStimComp = TableStimComp; % neuron-level pairwise table + S.TableRespNeurs = TableRespNeurs; % insertion-level responsive counts + S.params = params; % parameter snapshot for reproducibility + + save(savePath, '-struct', 'S'); % save struct fields as top-level vars + fprintf('Saved pooled data to %s\n', savePath); + +end % end if runLoop + +% ========================================================================= +% SECTION 5 — GUARD: ABORT EARLY IF NO SIGNIFICANT NEURONS WERE FOUND +% ========================================================================= + +if isempty(S.TableStimComp) || height(S.TableStimComp) == 0 + warning('AllExpAnalysis:noUnits', ... + 'No significant units found for comparison of %s. Returning empty.', ... + strjoin(stimsNeeded, ' vs ')); + tempTable = table(); % return empty table + return +end + +% Replace any residual NaN z-scores or spike rates with 0 +% (conservative: treat NaN as "no response" for bootstrap) +S.TableStimComp.('Z-score')(isnan(S.TableStimComp.('Z-score'))) = 0; +S.TableStimComp.SpkR(isnan(S.TableStimComp.SpkR)) = 0; + +% ========================================================================= +% SECTION 6 — SHARED PLOTTING SETUP +% ========================================================================= + +% Reload an analysis object for figure-saving paths +NP = loadNPclassFromTable(expList(1)); +vs = linearlyMovingBallAnalysis(NP); + +% Build a shared colormap so every animal gets the same colour across all panels +animalOrder = categories(S.TableStimComp.animal); % canonical alphabetical ordering +nAnimals = numel(animalOrder); % number of distinct animals +sharedCmap = lines(nAnimals); % nAnimals × 3 RGB matrix + +% Numeric animal index for each row (used for colour lookup) +animalIdxAll = double(S.TableStimComp.animal); + +% Generate all pairwise combinations of stimuli for statistical testing +% e.g. {'SDGm','SDGs'} → one pair; {'MB','RG','MBR'} → three pairs +pairsAll = nchoosek(stimsNeeded, 2); % nPairs × 2 cell array of pairs + +% Label-replacement map for display (internal abbreviation → paper label) +labelMap = {'RG','SB'; 'SDGs','SG'; 'SDGm','MG'}; + +% ========================================================================= +% SECTION 7 — Z-SCORE PAIRWISE COMPARISON +% ========================================================================= + +% --- 7a: Hierarchical bootstrap for each pair --- + +pValsZ = zeros(1, size(pairsAll, 1)); % one p-value per pair + +for pi = 1:size(pairsAll, 1) % iterate over stimulus pairs + % Compute per-neuron Z-score differences and run hierarchical bootstrap + pValsZ(pi) = bootstrapPairDifference( ... + S.TableStimComp, pairsAll(pi,:), params.nBoot, 'Z-score'); +end + +% --- 7b: Swarm plot of Z-scores --- + +ZscoreYlim = ceil(max(S.TableStimComp.('Z-score'))) + 4; % y-axis ceiling + +fig = plotSwarmBootstrapWithComparisons( ... + S.TableStimComp, pairsAll, pValsZ, {'Z-score'}, ... + yLegend = 'Z-score', ... + yMaxVis = ZscoreYlim, ... + diff = true, ... + plotMeanSem = true, ... + Alpha = 0.7); + +formatAxes(gca, 8, 'helvetica'); % consistent font styling +set(fig, 'Units', 'centimeters', 'Position', [20 20 10 6]); +colormap(fig, sharedCmap); % enforce shared colour scheme + +if params.PaperFig + vs.printFig(fig, sprintf('Zscore-Swarm-%s', strjoin(stimsNeeded,'-')), ... + PaperFig = params.PaperFig); +end + +% --- 7c: Scatter plot — first stimulus vs second stimulus (Z-score) --- + +if numel(stimsNeeded) == 2 + fig = plotPairScatter(S.TableStimComp, stimsNeeded, ... + 'Z-score', sharedCmap, animalIdxAll, labelMap); + title('Z-score'); + + if params.PaperFig + vs.printFig(fig, sprintf('Zscore-Scatter-%s', strjoin(stimsNeeded,'-')), ... + PaperFig = params.PaperFig); + end +end + +% ========================================================================= +% SECTION 8 — SPIKE-RATE PAIRWISE COMPARISON +% ========================================================================= + +% --- 8a: Hierarchical bootstrap for each pair --- + +pValsSpk = zeros(1, size(pairsAll, 1)); + +for pi = 1:size(pairsAll, 1) + pValsSpk(pi) = bootstrapPairDifference( ... + S.TableStimComp, pairsAll(pi,:), params.nBoot, 'SpkR'); +end + +% --- 8b: Swarm plot of spike rates --- + +spkMax = max(S.TableStimComp.SpkR); % y-axis ceiling + +fig = plotSwarmBootstrapWithComparisons( ... + S.TableStimComp, pairsAll, pValsSpk, {'SpkR'}, ... + yLegend = 'SpkR', ... + yMaxVis = spkMax, ... + diff = true, ... + plotMeanSem = true, ... + Alpha = 0.7); + +formatAxes(gca, 8, 'helvetica'); +colormap(fig, sharedCmap); +set(fig, 'Units', 'centimeters', 'Position', [20 20 10 6]); + +if params.PaperFig + vs.printFig(fig, sprintf('SpkRate-Swarm-%s', strjoin(stimsNeeded,'-')), ... + PaperFig = params.PaperFig); +end + +% --- 8c: Scatter plot — first stimulus vs second stimulus (spike rate) --- + +if numel(stimsNeeded) == 2 + fig = plotPairScatter(S.TableStimComp, stimsNeeded, ... + 'SpkR', sharedCmap, animalIdxAll, labelMap); + title('Spk. rate'); + + if params.PaperFig + vs.printFig(fig, sprintf('SpkRate-Scatter-%s', strjoin(stimsNeeded,'-')), ... + PaperFig = params.PaperFig); + end +end + +% ========================================================================= +% SECTION 9 — FRACTION-RESPONSIVE ANALYSIS +% Compares the proportion of responsive neurons between stimuli, +% bootstrapping at the insertion level (no hierarchy needed because +% there is one data point per insertion). +% ========================================================================= + +% Find insertions that contain ALL compared stimuli +[G, ~] = findgroups(S.TableRespNeurs.insertion); % group by insertion +hasAll = splitapply( ... % check each group + @(s) all(ismember(categorical(stimsNeeded), s)), ...% does it contain every stimulus? + S.TableRespNeurs.stimulus, G); + +% Restrict to complete insertions and relevant stimuli only +tempTable = S.TableRespNeurs( ... + hasAll(G) & ismember(S.TableRespNeurs.stimulus, categorical(stimsNeeded)), :); + +% Bootstrap the difference in responsive fraction for each pair +pValsFrac = zeros(1, size(pairsAll, 1)); + +for pi = 1:size(pairsAll, 1) + + diffs = []; % will hold one fraction-difference per insertion + + for ins = unique(S.TableRespNeurs.insertion)' % iterate over insertions + + % Find rows for this insertion × each stimulus in the pair + idx1 = S.TableRespNeurs.insertion == categorical(ins) & ... + S.TableRespNeurs.stimulus == pairsAll{pi,1}; + idx2 = S.TableRespNeurs.insertion == categorical(ins) & ... + S.TableRespNeurs.stimulus == pairsAll{pi,2}; + + if any(idx1) && any(idx2) + total = S.TableRespNeurs.totalSomaticN(idx1); % shared denominator + f1 = S.TableRespNeurs.respNeur(idx1) / total; % fraction responsive stim1 + f2 = S.TableRespNeurs.respNeur(idx2) / total; % fraction responsive stim2 + diffs(end+1, 1) = f1 - f2; % per-insertion difference + end + end + + % Simple bootstrap of the mean difference (one value per insertion → flat) + bootDiff = bootstrp(params.nBoot, @mean, diffs); % nBoot × 1 bootstrap means + pValsFrac(pi) = mean(bootDiff <= 0); % p-value: prop ≤ 0 +end + +% Add a total-responsive column (sum across stimuli within each insertion) +[G, ~] = findgroups(tempTable.insertion); +totals = splitapply(@sum, tempTable.respNeur, G); +tempTable.TotalRespNeur = totals(G); + +% Plot fraction-responsive with significance annotation +fig = plotSwarmBootstrapWithComparisons( ... + tempTable, pairsAll, pValsFrac, ... + {'respNeur','totalSomaticN'}, ... + fraction = true, ... + showBothAndDiff = false, ... + yLegend = 'Responsive/total units', ... + diff = false, ... + filled = false, ... + Xjitter = 'none', ... + Alpha = 0.6, ... + drawLines = true); + +% Compute summary counts for the annotation +totalResp = sum(tempTable.respNeur); % all stims combined +perStimN = arrayfun(@(s) sum(tempTable.respNeur(tempTable.stimulus == s)), ... + categorical(stimsNeeded)); % per-stimulus counts + +% Build annotation string: 'TR = 45 - SDGm = 28 - SDGs = 17' +annotParts = arrayfun(@(i) sprintf('%s = %d', stimsNeeded{i}, perStimN(i)), ... + 1:numel(stimsNeeded), 'UniformOutput', false); +annotStr = ['TR = ' num2str(totalResp) ' - ' strjoin(annotParts, ' - ')]; + +formatAxes(gca, 8, 'helvetica'); +set(fig, 'Units', 'centimeters', 'Position', [20 20 5 6]); +ylabel('Responsive / Total responsive'); +title(''); + +% Shift axes up slightly to make room for bottom annotation +pos = get(gca, 'Position'); % [left bottom width height] +pos(2) = pos(2) + 0.05; % push bottom edge up +set(gca, 'Position', pos); + +% Place annotation at the bottom of the figure +annotation('textbox', [0.1, 0.01, 0.8, 0.04], ... + 'String', annotStr, ... + 'EdgeColor', 'none', ... + 'FontSize', 9, ... + 'FontWeight', 'bold', ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'middle', ... + 'FitBoxToText', false); + +if params.PaperFig + vs.printFig(fig, sprintf('ResponsiveUnits-%s', strjoin(stimsNeeded,'-')), ... + PaperFig = params.PaperFig); +end + +end % end function AllExpAnalysis + + +% ######################################################################### +% LOCAL HELPER FUNCTIONS +% ######################################################################### + +function [vsObjs, present] = loadStimulusObjects(NP, stimsNeeded) +% loadStimulusObjects Load one analysis object per unique stimulus class. +% +% Several stimuli (e.g. SDGm and SDGs) share the same analysis object. +% This function loads each object at most once, and checks whether each +% stimulus was actually recorded by inspecting the VST property. +% +% INPUTS +% NP Neuropixels recording object +% stimsNeeded cell array of stimulus abbreviations +% +% OUTPUTS +% vsObjs containers.Map objKey → analysis object +% present containers.Map stimName → logical (true if recorded) + + vsObjs = containers.Map(); % cache of loaded analysis objects + present = containers.Map(); % presence flag per stimulus + + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; % stimulus name + key = getObjKey(sn); % shared object key + + % Load the analysis object if not already cached + if ~vsObjs.isKey(key) + try + switch key + case 'MB', obj = linearlyMovingBallAnalysis(NP); + case 'RG', obj = rectGridAnalysis(NP); + case 'MBR', obj = linearlyMovingBarAnalysis(NP); + case 'SDG', obj = StaticDriftingGratingAnalysis(NP); + case 'NI', obj = imageAnalysis(NP); + case 'NV', obj = movieAnalysis(NP); + case 'FFF', obj = fullFieldFlashAnalysis(NP); + end + + % Check if the stimulus was actually presented + if isempty(obj.VST) + fprintf(' %s: stimulus not found in recording.\n', key); + present(sn) = false; % VST empty → not recorded + else + present(sn) = true; % VST populated → was recorded + end + + vsObjs(key) = obj; % cache the object + + catch ME + fprintf(' %s: could not load (%s).\n', key, ME.message); + present(sn) = false; % constructor failed → not present + end + else + % Object already loaded; still need to set presence for this stimulus name + % (e.g. SDGm present ≠ SDGs present → both use the same object, + % but both are present if the object loaded successfully) + if ~present.isKey(sn) + % If the shared object loaded with non-empty VST, mark present + if isKey(vsObjs, key) && ~isempty(vsObjs(key).VST) + present(sn) = true; + else + present(sn) = false; + end + end + end + end +end + + +function key = getObjKey(stimName) +% getObjKey Map a stimulus abbreviation to its analysis-object key. +% SDGm and SDGs both map to 'SDG' because they share one object. + + switch stimName + case {'SDGm','SDGs'}, key = 'SDG'; + otherwise, key = stimName; % MB, RG, MBR, NI, NV, FFF + end +end + + +function runStimStats(vsObj, params) +% runStimStats Run ResponseWindow and the chosen statistical method. +% +% Dispatches to ShufflingAnalysis, BootstrapPerNeuron, or +% StatisticsPerNeuron depending on params.StatMethod. + + % Compute or load the response window + vsObj.ResponseWindow( ... + 'overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + + % Run the chosen statistical method + switch params.StatMethod + case 'ObsWindow' + vsObj.ShufflingAnalysis( ... + 'overwrite', params.overwriteStats, ... + 'N_bootstrap', params.shuffles); + + case 'bootsrapRespBase' + vsObj.BootstrapPerNeuron('overwrite', params.overwriteStats); + + case 'maxPermuteTest' + vsObj.StatisticsPerNeuron('overwrite', params.overwriteStats); + + otherwise + error('AllExpAnalysis:badMethod', ... + 'Unknown StatMethod "%s".', params.StatMethod); + end +end + + +function [z, p, spkR, spkDiff] = extractStimData(vsObj, stimName, statMethod, useZmean) +% extractStimData Pull z-scores, p-values, spike rate, and spike-rate +% difference from a stats struct, navigating the stimulus-specific nesting. +% +% The struct layout varies by stimulus type: +% Flat: stats.ZScoreU (RG, FFF, NI, NV) +% Speed: stats.Speed1.ZScoreU (MB prefers Speed2; MBR uses Speed1) +% Moving/Static: stats.Moving.* (SDGm) or stats.Static.* (SDGs) + + % --- Retrieve the stats struct (dispatch on statistical method) --- + switch statMethod + case 'ObsWindow', stats = vsObj.ShufflingAnalysis; + case 'bootsrapRespBase', stats = vsObj.BootstrapPerNeuron; + case 'maxPermuteTest', stats = vsObj.StatisticsPerNeuron; + end + + rw = vsObj.ResponseWindow; % response-window struct (spike rates stored here) + + % --- Navigate to the correct sub-struct for this stimulus --- + switch stimName + case 'MB' + % MB has Speed1 and optionally Speed2 (faster, more salient). + % Prefer Speed2 for z-scores/p-values; spike rate from Speed1. + sub = stats.Speed1; + rwSub = rw.Speed1; + if isfield(stats, 'Speed2') + sub = stats.Speed2; % z-scores/p from faster speed + % NOTE: spike rate intentionally comes from Speed1 (original convention) + rwSub = rw.Speed1; + end + + case 'MBR' + sub = stats.Speed1; % moving bar: Speed1 only + rwSub = rw.Speed1; + + case 'SDGm' + sub = stats.Moving; % drifting gratings: moving condition + rwSub = rw.Moving; + + case 'SDGs' + sub = stats.Static; % drifting gratings: static condition + rwSub = rw.Static; + + otherwise % RG, FFF, NI, NV — flat struct + sub = stats; + rwSub = rw; + end + + % --- Extract z-scores and p-values --- + z = sub.ZScoreU(:); % force column vector + p = sub.pvalsResponse(:); + + % --- Extract spike rate --- + if useZmean && isfield(sub, 'z_mean') + spkR = sub.z_mean(:); % normalised response (z_mean) + else + spkR = max(rwSub.NeuronVals(:,:,4), [], 2); % peak rate across directions + end + + % --- Extract spike-rate difference (response – baseline) --- + spkDiff = max(rwSub.NeuronVals(:,:,5), [], 2); + + % --- Override spike rate for bootstrap method (uses observed responses) --- + if strcmp(statMethod, 'bootsrapRespBase') && isfield(sub, 'ObsResponse') + spkR = mean(sub.ObsResponse, 1)'; % mean across repeats + end +end + + +function pVal = bootstrapPairDifference(tbl, pair, nBoot, metric) +% bootstrapPairDifference Hierarchical bootstrap test for a single pair. +% +% Computes per-neuron differences (stim1 − stim2), then resamples at the +% animal → insertion → neuron hierarchy. +% +% INPUTS +% tbl table Long-format table with columns: insertion, stimulus, +% animal, and the metric column. +% pair {1×2} cell Stimulus pair, e.g. {'SDGm','SDGs'}. +% nBoot double Number of bootstrap iterations. +% metric char Column name to compare ('Z-score' or 'SpkR'). +% +% OUTPUT +% pVal double Proportion of bootstrap means ≤ 0. + + diffs = []; % per-neuron differences pooled across insertions + insers = []; % insertion label for each difference + animals = []; % animal label for each difference + + for ins = unique(tbl.insertion)' % iterate over insertions + + % Select rows: this insertion × each stimulus in the pair + idx1 = tbl.insertion == categorical(ins) & tbl.stimulus == pair{1}; + idx2 = tbl.insertion == categorical(ins) & tbl.stimulus == pair{2}; + + V1 = tbl.(metric)(idx1); % metric values for stim 1 + V2 = tbl.(metric)(idx2); % metric values for stim 2 + + if isempty(V1) || isempty(V2) + continue % skip incomplete insertions + end + + animal = unique(tbl.animal(idx1)); % animal for this insertion + + diffs = [diffs; V1 - V2]; %#ok append differences + insers = [insers; double(repmat(ins, numel(V1), 1))]; %#ok + animals = [animals; double(repmat(animal, numel(V1), 1))]; %#ok + end + + % Run hierarchical bootstrap (resample animals → insertions → neurons) + bootMeans = hierBoot(diffs, nBoot, insers, animals); + pVal = mean(bootMeans <= 0); % one-sided p-value +end + + +function fig = plotPairScatter(tbl, stimsNeeded, metric, cmap, animalIdx, labelMap) +% plotPairScatter Scatter the first vs second stimulus for a given metric. +% +% Each dot is one neuron; colour = animal identity. + + fig = figure; + + % Extract data for each stimulus + mask1 = tbl.stimulus == stimsNeeded{1}; % rows for stimulus 1 + mask2 = tbl.stimulus == stimsNeeded{2}; % rows for stimulus 2 + v1 = tbl.(metric)(mask1); % metric values for stim 1 + v2 = tbl.(metric)(mask2); % metric values for stim 2 + cIdx = animalIdx(mask1); % animal colour index (aligned with mask1) + + % Scatter with animal-coded colour + scatter(v1, v2, 7, cmap(cIdx,:), 'filled', 'MarkerFaceAlpha', 0.3); + hold on; + axis equal; + + % Identity line (y = x) + lims = [min(tbl.(metric)), max(tbl.(metric))]; % data range + plot(lims, lims, 'k--', 'LineWidth', 1.5); + xlim(lims); ylim(lims); + + % Axis labels — apply display-name substitutions + xLab = stimsNeeded{1}; + yLab = stimsNeeded{2}; + for li = 1:size(labelMap, 1) + xLab = strrep(xLab, labelMap{li,1}, labelMap{li,2}); + yLab = strrep(yLab, labelMap{li,1}, labelMap{li,2}); + end + xlabel(xLab); ylabel(yLab); + colormap(fig, cmap); + + formatAxes(gca, 8, 'helvetica'); + set(fig, 'Units', 'centimeters', 'Position', [20 20 5 5]); +end + + +function formatAxes(ax, fontSize, fontName) +% formatAxes Apply consistent font styling to an axes object. + ax.YAxis.FontSize = fontSize; ax.YAxis.FontName = fontName; + ax.XAxis.FontSize = fontSize; ax.XAxis.FontName = fontName; +end + + +function pAdj = bhFDR(pVals) +% bhFDR Benjamini-Hochberg FDR correction. +% Adjusts a vector of p-values to control the false discovery rate. + + n = numel(pVals); % number of tests + [pSorted, sortIdx] = sort(pVals(:)); % sort ascending + ranks = (1:n)'; % integer ranks + + % BH adjustment: p_adj(k) = min( p(k)*n/k , 1 ), enforced monotone + pAdj = pSorted .* n ./ ranks; % raw BH adjustment + pAdj = min(pAdj, 1); % cap at 1 + for k = n-1:-1:1 % enforce monotonicity from bottom up + pAdj(k) = min(pAdj(k), pAdj(k+1)); + end + + % Unsort back to original order + pAdj(sortIdx) = pAdj; +end \ No newline at end of file diff --git a/visualStimulationAnalysis/CalculateSpatialTuningIndex.m b/visualStimulationAnalysis/OldVersions/CalculateSpatialTuningIndex.m similarity index 100% rename from visualStimulationAnalysis/CalculateSpatialTuningIndex.m rename to visualStimulationAnalysis/OldVersions/CalculateSpatialTuningIndex.m diff --git a/visualStimulationAnalysis/TestAndPlotReceptiveFieldsm.m b/visualStimulationAnalysis/OldVersions/TestAndPlotReceptiveFieldsm.m similarity index 100% rename from visualStimulationAnalysis/TestAndPlotReceptiveFieldsm.m rename to visualStimulationAnalysis/OldVersions/TestAndPlotReceptiveFieldsm.m diff --git a/visualStimulationAnalysis/OldVersions/plotSpatialTuningIndex.m b/visualStimulationAnalysis/OldVersions/plotSpatialTuningIndex.m new file mode 100644 index 0000000..b163552 --- /dev/null +++ b/visualStimulationAnalysis/OldVersions/plotSpatialTuningIndex.m @@ -0,0 +1,189 @@ +function [fig, tbl] = plotSpatialTuningIndex(exList, pairs, params) + +arguments + exList double + pairs cell = {} + params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] + params.indexType string = "L_combined" % L_amplitude, L_geometric, L_combined + params.onOff double = 1 % 1=on, 2=off (rectGrid only) + params.sizeIdx double = 1 + params.lumIdx double = 1 + params.nBoot double = 10000 + params.overwrite logical = false + params.yLegend char = 'Spatial Tuning Index' + params.yMaxVis double = 1 + params.Alpha double = 0.4 + params.PaperFig logical = false +end + +% ------------------------------------------------------------------------- +% Build save path +% ------------------------------------------------------------------------- +NP_first = loadNPclassFromTable(exList(1)); + +switch params.stimTypes(1) + case "rectGrid" + vs_first = rectGridAnalysis(NP_first); + case "linearlyMovingBall" + vs_first = linearlyMovingBallAnalysis(NP_first); +end + +p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); +p = [p 'lizards']; +saveDir = [p '\Combined_lizard_analysis']; + +stimLabel = strjoin(params.stimTypes, '-'); +nameOfFile = sprintf('\\Ex_%d-%d_SpatialTuningIndex_%s.mat', ... + exList(1), exList(end), stimLabel); + +% ------------------------------------------------------------------------- +% Load SpatialTuningIndex results +% ------------------------------------------------------------------------- +if ~exist([saveDir nameOfFile], 'file') + error('SpatialTuningIndex results not found. Run SpatialTuningIndex first.'); +end + +S = load([saveDir nameOfFile]); + +% ------------------------------------------------------------------------- +% Build long table +% ------------------------------------------------------------------------- +tbl = table(); + +nExp = numel(exList); +nStim = numel(params.stimTypes); + +for ei = 1:nExp + ex = exList(ei); + + % Get animal/insertion info + try + NP = loadNPclassFromTable(ex); + catch + warning('Could not load experiment %d — skipping.', ex); + continue + end + + for s = 1:nStim + + stimType = params.stimTypes(s); + + % Get the right index matrix for this stim/exp + switch params.indexType + case "L_amplitude" + idxMat = S.L_amplitude_all{s, ei}; + case "L_geometric" + idxMat = S.L_geometric_all{s, ei}; + case "L_combined" + idxMat = S.L_combined_all{s, ei}; + end + + if isempty(idxMat) + continue + end + + % idxMat is [nN x nOnOff x nSize x nLum] + % for linearlyMovingBall there is no onOff dimension — handle both + if ndims(idxMat) == 3 + % [nN x nSize x nLum] — no onOff + vals = idxMat(:, params.sizeIdx, params.lumIdx); + oi = 1; + else + vals = idxMat(:, params.onOff, params.sizeIdx, params.lumIdx); + oi = params.onOff; + end + + nN = numel(vals); + + % Build rows for this experiment/stim + rows = table(); + rows.value = vals; + rows.stimulus = categorical(repmat({char(stimType)}, nN, 1)); + rows.insertion = categorical(repmat(ex, nN, 1)); + rows.animal = categorical(repmat({NP.animalName}, nN, 1)); + rows.NeurID = (1:nN)'; + rows.onOff = repmat(oi, nN, 1); + rows.sizeIdx = repmat(params.sizeIdx, nN, 1); + rows.lumIdx = repmat(params.lumIdx, nN, 1); + rows.indexType = categorical(repmat({params.indexType}, nN, 1)); + + tbl = [tbl; rows]; + end +end + +if isempty(tbl) + warning('No data found — table is empty.'); + fig = []; + return +end + +% Clean up categories +tbl.stimulus = removecats(tbl.stimulus); +tbl.animal = removecats(tbl.animal); +tbl.insertion = removecats(tbl.insertion); + +% ------------------------------------------------------------------------- +% Compute p-values using hierBoot +% ------------------------------------------------------------------------- +ps = []; + +if ~isempty(pairs) + ps = zeros(size(pairs, 1), 1); + j = 1; + + for i = 1:size(pairs, 1) + diffs = []; + insers = []; + animals = []; + + for ins = unique(tbl.insertion)' + idx1 = tbl.insertion == categorical(ins) & tbl.stimulus == pairs{i,1}; + idx2 = tbl.insertion == categorical(ins) & tbl.stimulus == pairs{i,2}; + + V1 = tbl.value(idx1); + V2 = tbl.value(idx2); + + if isempty(V1) || isempty(V2) + continue + end + + animal = unique(tbl.animal(idx1)); + diffs = [diffs; V1 - V2]; + insers = [insers; double(repmat(ins, size(V1,1), 1))]; + animals = [animals; double(repmat(animal, size(V1,1), 1))]; + end + + if isempty(diffs) + ps(j) = NaN; + else + bootDiff = hierBoot(diffs, params.nBoot, insers, animals); + ps(j) = mean(bootDiff <= 0); + end + j = j + 1; + end +end + +% ------------------------------------------------------------------------- +% Plot +% ------------------------------------------------------------------------- +V1max = max(tbl.value, [], 'omitnan'); + +[fig, ~] = plotSwarmBootstrapWithComparisons(tbl, pairs, ps, {'value'}, ... + yLegend = params.yLegend, ... + yMaxVis = max(params.yMaxVis, V1max), ... + diff = false, ... + Alpha = params.Alpha, ... + plotMeanSem = true); + +title(sprintf('%s — %s (size=%d, lum=%d)', ... + params.indexType, strjoin(params.stimTypes,'/'), ... + params.sizeIdx, params.lumIdx), ... + 'FontSize', 9); + +if params.PaperFig + vs_first.printFig(fig, sprintf('SpatialTuningIndex-%s-%s', ... + params.indexType, strjoin(params.stimTypes, '-')), ... + PaperFig = params.PaperFig); +end + +end \ No newline at end of file diff --git a/visualStimulationAnalysis/RunAnalysisClass.asv b/visualStimulationAnalysis/RunAnalysisClass.asv new file mode 100644 index 0000000..395afdd --- /dev/null +++ b/visualStimulationAnalysis/RunAnalysisClass.asv @@ -0,0 +1,416 @@ +cd('\\sil3\data\Large_scale_mapping_NP') +excelFile = 'Experiment_Excel.xlsx'; + +data = readtable(excelFile); + + + +%% AllExpAnalysis +%[49:54 57:81] MBR all experiments 'NV','NI' +%[44:56,64:88] All experiments +%[28:32,44,45,47,48,56,98] All SA experiments +%Check triggers 45, SA82 44,45,47:54,56,64:88 +% All stim: 'FFF','SDG','MBR','MB','RG','NI','NV' +%[49:54,64:97] %All PV good experiments [49:54,64:85 87:97] +% %%[89,90,92,93,95,96,97] %Al NV and NI experiments +%[49:54,84:90,92:96] %All SDG experiments +%solve MBR +%bootsrapRespBase + +%% FIGURE 1 MOVING BALL VS STATIC BALL +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +%% Compare MB vs RG, use gridmode true, selects maximum spatial category across directions +markUnits = { 82, 'MB', 'PV140', 2 ; % -> X in the MB column [1, 8, 40:43,49:54,64:66,68:85 87:97 + 21, 'RG', 'PV132', 6 }; + +[tempTableMW] = AllExpAnalysis([1, 8, 40:43,49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... + overwriteResponse=false,overwriteStats=false,useFDR=false,SpatialGridMode=true,maxCategory=true,RespDurationWin=300, markUnits = markUnits); + +%% Calculate spatial tuning +results = SpatialTuningIndex([40:43,49:54,64:66, 68:85 87:97], indexType = "L_amplitude_diff" ,overwrite=true... + , topPercent = 30,useRF=true,onOff=1,unionResponsive = true,allResponsive=true, PaperFig=true, plotRFs=false, plotRFunion=false, detectStripe = false, testStripeDetection= false); +%% +results = SpatialTuningIndex([40:43,49:54,64:66, 68:85 87:97], indexType = "L_amplitude_diff" ,overwrite=false... + , topPercent = 50,useRF=true,onOff=1,unionResponsive = true,allResponsive=false, PaperFig=true, plotRFs=false, plotRFunion=false, detectStripe = true, testStripeDetection= false); +%% +results = analyzeStripeNeurons([40:43,49:54,64:66, 68:85 87:97], ... + indexType = "L_amplitude_diff", ... + onOff = 1, ... + sizeIdx = 1, ... + lumIdx = 1, ... + useRF = true, ... + prefDir = true, ... + unionResponsive = true,... + allResponsive=false, PaperFig=true); % match what you used in SpatialTuningIndex + +%% +plotPSTH_MultiExp([40:43,49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false,binWidth=50, postStim= 500, stimTypes={"MB","RG"},unionResponsive=true, requireAllStims=true); + +%% +plotRaster_MultiExp([40:43,49:54,64:66,68:85 87:97],overwrite=true,TakeTopPercentTrials=[],PaperFig=true,zScore = true, postStim=500,stimTypes=["MB","RG"],unionUnits=true, prefCatPSTH = true,splitCategory=["Directions","Position"]) + + +%% FIGURE 2 MOVING VS STATIC COMPARISON +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +%% %% Compare SDGm vs SDGs +[tempTableMW] = AllExpAnalysis([40:43,49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'SDGm','SDGs'},PaperFig=true,... + overwriteResponse=true,overwriteStats=true,useFDR=false,maxCategory=true,BaseRespWindow=1000); + +%% %% Compare SDGm vs SDGs, across directions +[tempTableMW] = AllExpAnalysis([40:43 49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'SDGm','SDGs'},CompareCategory={"angles","angles"},CompareLevels={[0,90,180,270],[0,90,180,270]},PaperFig=true,... + overwriteResponse=false,overwriteStats=true,useFDR=false,maxCategory=false,BaseRespWindow=1000); + +%% Plot PSTH of MG and SG +plotPSTH_MultiExp([40:43,49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, binWidth=100, postStim= 1000, stimTypes={"SDGm","SDGs"},unionResponsive=true); + +%% Raster for MG and SG +plotRaster_MultiExp([40:43,49:54,64:66,68:85 87:97],overwrite=true,TakeTopPercentTrials=[],PaperFig=true,postStim=500,stimTypes=["SDG"],unionUnits=true,preBase=500) + +%% SDGm spatial frequency +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'SDGm'},CompareCategory={"tempFrequency"},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 1000); + + +%% %% Compare NI vs NV, +[tempTableMW] = AllExpAnalysis([40:43 49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'NV','NI'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true,useFDR=false,maxCategory=false,BaseRespWindow=1000); +%% %% Compare NI vs NV, +[tempTableMW] = AllExpAnalysis([92:97], overwrite=false,ComparePairs={'NI','NV'},CompareCategory={"imgeOrder",""},CompareLevels={[1,9],[]},PaperFig=true,... + overwriteResponse=true,overwriteStats=true,useFDR=false,maxCategory=false,BaseRespWindow=1000); + +%% Raster for NI and NV +plotRaster_MultiExp([49:54,64:66,68:85 87:97],overwrite=true,TakeTopPercentTrials=[],PaperFig=true,postStim=1000,stimTypes=["NI","NV"],unionUnits=false ) + +%% FIGURE 3 SIZES AND LOCALITY COMPARISON +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +% %% Compares MB across different sizes +% [tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'MB'},CompareCategory="sizes",PaperFig=true,... +% overwriteResponse=false,overwriteStats=true, BaseRespWindow = 500,CategoryMaximized = "offsets"); +% +% +% %% Compares MB and SDGm across different categories, z-scores are computed with a moving window and responsive units are selected on the per category p value. +% [tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'MB','SDGm'},CompareCategory={"directions","angles"},CompareLevels={[0,1.57,3.14, 4.71],[0,90,180,270]},PaperFig=true,... +% overwriteResponse=false,overwriteStats=true, BaseRespWindow = 1000,useGeneralFilter=true,SpatialGridMode = false,maxCategory = true); +%% Compares MB and MG, across all categories +[tempTableMW] = AllExpAnalysis([40:43,49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'MB','SDGm'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 500, maxCategory = true, SpatialGridMode = true); + +%% %% Compares MB and MG, across all categories +[tempTableMW] = AllExpAnalysis([40:43,49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'MB','MBR'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 500, maxCategory = true, SpatialGridMode = true); + +%% %% PSTH for MB v sMG +plotPSTH_MultiExp([40:43,49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, binWidth=100, postStim= 1000,stimTypes={"MB","SDGm"},requireAllStims=true, unionResponsive=true); %stimTypes=["linearlyMovingBall"] + +%% %% PSTH for MB vs MBR +plotPSTH_MultiExp([40:43,49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=250,binWidth =50, postStim= 1000, stimTypes={"MB","MBR"},unionResponsive=true,requireAllStims = true); + + +%% Raster for MB vs MG + +plotRaster_MultiExp([40:43,49:54,64:66,68:85 87:97],overwrite=true,TakeTopPercentTrials=[],PaperFig=true,postStim=1000,stimTypes=["MB","SDGm"],unionUnits=true ) + +%% Raster for MB vs MBR +plotRaster_MultiExp([40:43,49:54,64:66,68:85 87:97],overwrite=true,TakeTopPercentTrials=[],PaperFig=true,postStim=1000,stimTypes=["MB","MBR"],unionUnits=true ) + +%% %% Compares SB, SG across all categories +[tempTableMW] = AllExpAnalysis([40:43, 49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'RG','SDGs'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 300, maxCategory = true, SpatialGridMode = false); + +%% %% Compares SB, FFF, across all categories +[tempTableMW] = AllExpAnalysis([40:43,49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'RG','FFF'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 300, maxCategory = true, SpatialGridMode = false); + +%% PSTH for all static experiments +plotPSTH_MultiExp([40:43,49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, binWidth=50, smooth=250, postStim= 500, stimTypes={"RG","SDGs"}); %stimTypes=["linearlyMovingBall"] + +%% Plot changes in size for MB +plotPSTH_MultiExp([40:43,20,49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=250,binWidth=50, postStim= 500, stimTypes={"RG","FFF"}); %stimTypes=["linearlyMovingBall"] + +%% SDGm spatial frequency +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'SDGs'},CompareCategory="spatFrequency",PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 1000); + +%% Plot different spatial freuqncies +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50, postStim= 2000, stimTypes={"SDGm"},splitBy="spatFrequency",useCategoryPvals = true); %stimTypes=["linearlyMovingBall"] + + +%% Plot MB raster sorted per size +plotRaster_MultiExp([49:54,64:66,68:85 87:97], overwrite=false, sortBy="preferredCategory", ... + splitCategory="Sizes", stimTypes="MB",zScore=true,PaperFig=true) + +%% Plot SDGm raster sorted per size +plotRaster_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, sortBy="preferredCategory", ... + splitCategory="spatFrequency", stimTypes="SDGm",zScore=true,PaperFig=true) + +%% %% FIGURE 4 DIRECTION TUNING COMPARISON +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +%% Compares MB across differen directions +[tempTableMW] = AllExpAnalysis([40:43, 49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'MB'},CompareCategory="directions",PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 1500); +%% +plotPSTH_MultiExp([40:43, 49:54,64:66,68:85 87:97], overwrite=false, zScore=true,TakeTopPercentTrials=[], PaperFig=true,postStim =2000, byDepth=false, smooth=50, stimTypes={"MB"},splitBy="directions"); %stimTypes=["linearlyMovingBall"] + +%% Compares MB across differen directions +[tempTableMW] = AllExpAnalysis([40:43, 49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'SDGm'},CompareCategory="angles",CompareLevels = {[0,90,180,270]},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 1000); +%% +plotPSTH_MultiExp([40:43, 49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true,postStim =1000, byDepth=false, smooth=50, stimTypes={"SDGm"},splitBy="angles",splitLevels=[0,90,180,270]); %stimTypes=["linearlyMovingBall"] + +%% PLot OSI and DSI + +AllExpDirectionTuning([40:43, 49:54,64:66,68:85 87:97], overwrite=false, stimuli = {'MB'},PaperFig=true) + + +%% Plot SDGm raster sorted per direction +plotRaster_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, sortBy="preferredCategory", ... + splitCategory="angles", splitLevels ={[0,90,180,270]}, stimTypes="SDGm",zScore=true,PaperFig=true) + +%% Plot SDGm raster sorted per direction +plotRaster_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, sortBy="preferredCategory", ... + splitCategory="directions", stimTypes="MB",zScore=true,PaperFig=true) + +%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +%% +%% Rect Grid +for ex = [40:42] %84:91 + NP = loadNPclassFromTable(ex); %73 81 + vsRe = rectGridAnalysis(NP); + % vsRe.getSessionTime("overwrite",true); + % %vsRe.getDiodeTriggers('extractionMethod','digitalTriggerDiode','overwrite',true); + % vsRe.getDiodeTriggers('overwrite',true); + % vsRe.getSyncedDiodeTriggers("overwrite",true); + % % vsRe.plotSpatialTuningSpikes; + % % vsRe.plotSpatialTuningLFP; + %vsRe.ResponseWindow('overwrite',true) + % results = vsRe.ShufflingAnalysis('overwrite',true); + %vsRe.plotRaster(MergeNtrials=1,overwrite=true,AllResponsiveNeurons = true, selectedLum=[],oneTrial = true,PaperFig = true) %43 + % vsRe.plotRaster(MergeNtrials=1,overwrite=true,AllResponsiveNeurons=false,exNeuronsPhyID = 145, selectedLum=255,oneTrial = true,PaperFig = true) %43 + vsRe.CalculateReceptiveFields('overwrite',true,AllResponsiveNeurons=false) + % [colorbarLimsRG] = vsRe.PlotReceptiveFields(exNeurons=13,allStimParamsCombined=false,PaperFig=true,overwrite=true); + %result = vsRe.BootstrapPerNeuron('overwrite',true); + %result = vsRe.StatisticsPerNeuron('overwrite',true); + +end +% vsRe.CalculateReceptiveFields +%vsRe.PlotReceptiveFields("exNeurons",18) + +% Moving ball + +for ex =[40:42]%97 74:84 (Neurons, 96_74, ) + NP = loadNPclassFromTable(ex); %73 81 + vs = linearlyMovingBallAnalysis(NP); + % vs.getSessionTime("overwrite",true); + % vs.getDiodeTriggers('extractionMethod','digitalTriggerDiode','overwrite',true); + % % % %vs.plotDiodeTriggers + % vs.getSyncedDiodeTriggers("overwrite",true); + % % % % %vs.plotSpatialTuningSpikes; + % r = vs.ResponseWindow('overwrite',true); + % % % results = vs.ShufflingAnalysis('overwrite',true); + % % % % vs.plotRaster('AllSomaticNeurons',true,'overwrite',true,'MergeNtrials',3) + % vs.plotRaster('AllResponsiveNeurons',true,'overwrite',true,'MergeNtrials',1,'bin',50,'GaussianLength',30,'MaxVal_1', false, oneLuminosity = "white", OneDirection="left", ... + % sortingOrder=["size","direction","luminosity","offset","speed"]) + %vs.plotRaster('AllSomaticNeurons',true,'overwrite',true,'MergeNtrials',3,PaperFig=true) + % vs.plotRaster('exNeuronsPhyID',145,'overwrite',true,'MergeNtrials',3,'PaperFig',false,'OneDirection','down',OneLuminosity= 'white') + % % % % %vs.plotCorrSpikePattern + % vs.plotRaster('exNeurons',82,'overwrite',true,'OneDirection','up','OneLuminosity','white','MergeNtrials',1,'PaperFig',false) + % %vs.plotRaster('AllSomaticNeurons',true,'overwrite',true,'MergeNtrials',3,MaxVal_1=false,PaperFig=false) + vs.CalculateReceptiveFields('overwrite',true,testConvolution=false,AllResponsiveNeurons=false); + % colorbarLims=vs.PlotReceptiveFields('exNeurons',13,'overwrite',true,'OneDirection','down','OneLuminosity','white','PaperFig',true); + % %result = vs.BootstrapPerNeuron('overwrite',true);%('overwrite',true); + % % pvals0_6Filter =result.Speed2.pvalsResponse'; + % % compare = [pvals,pvalsNoFilt,pvals0_6Filter]; + % result = vs.StatisticsPerNeuron('overwrite',true); + % result = vs.StatisticsPerNeuronPerCategory('compareCategory','sizes','overwrite',true); +end + + +%% Raster for all experiment +plotRaster_MultiExp([49:54,64:66,68:85 87:97], sortBy = "spatialTuning",overwrite=true,TakeTopPercentTrials=[],PaperFig=true) + +%% Calculate spatial tuning +results= SpatialTuningIndex([49:54,64:66, 68:85 87:97], indexType = "L_amplitude_diff" ,overwrite=true... + , topPercent = 30,useRF=true,onOff=1,unionResponsive = false,allResponsive=true, PaperFig=true, plotRFs=false, plotRFunion=false); + +%% Get neuron depths +getNeuronDepths([49:54,64:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates +%% Gratings + +for ex = [40] + NP = loadNPclassFromTable(ex); %73 81 + vs = StaticDriftingGratingAnalysis(NP); + vs.getSessionTime("overwrite",true); + vs.getDiodeTriggers('extractionMethod','digitalTriggerDiode','overwrite',true); + dT = vs.getDiodeTriggers; + % vs.plotDiodeTriggers + vs.getSyncedDiodeTriggers("overwrite",true); + %r = vs.ResponseWindow('overwrite',true); + % results = vs.ShufflingAnalysis('overwrite',true); + % result = vs.BootstrapPerNeuron('overwrite',true); + % vs.StatisticsPerNeuron(overwrite=true) + vs.plotRaster(MaxVal_1=true,OneAngle=270,exNeurons=28,AllResponsiveNeurons=false,PaperFig=false) %0.5208 %2.0833 + vs.plotRaster(MaxVal_1=false) + close all +end + +%% movie + +for ex = [92:97] + NP = loadNPclassFromTable(ex); %73 81 + vs = movieAnalysis(NP); + % vs.getSessionTime("overwrite",true); + % vs.getDiodeTriggers('extractionMethod','digitalTriggerDiode','overwrite',true); + % dT = vs.getDiodeTriggers; + % vs.plotDiodeTriggers + %vs.getSyncedDiodeTriggers("overwrite",true); + r = vs.ResponseWindow('overwrite',true); + %results = vs.ShufflingAnalysis('overwrite',true); + result = vs.StatisticsPerNeuron('overwrite',true); + vs.plotRaster(AllResponsiveNeurons=true) + close all +end + + +%% image + +for ex = [97] + NP = loadNPclassFromTable(ex); %73 81 + vs = imageAnalysis(NP); + %vs.getSessionTime("overwrite",true); + %vs.getDiodeTriggers('extractionMethod','digitalTriggerDiode','overwrite',true); + %dT = vs.getDiodeTriggers; + % vs.plotDiodeTriggers + %vs.getSyncedDiodeTriggers("overwrite",true); + r = vs.ResponseWindow('overwrite',true); + %results = vs.ShufflingAnalysis('overwrite',true); + vs.plotRaster('exNeurons',13,MergeNtrials=1,overwrite=true, selectCats =[], PaperFig=true) + close all + %result = vs.StatisticsPerNeuron('overwrite',true); + +end + + +%% Moving bar +for ex = 81 + NP = loadNPclassFromTable(ex); %73 81 + vs = linearlyMovingBarAnalysis(NP); + vs.getSessionTime("overwrite",true); + vs.getDiodeTriggers('extractionMethod','digitalTriggerDiode','overwrite',true); + %vs.plotDiodeTriggers + vs.getSyncedDiodeTriggers("overwrite",true); + r = vs.ResponseWindow('overwrite',true); + results = vs.ShufflingAnalysis('overwrite',true); + if ~any(results.Speed1.pvalsResponse<0.05) + fprintf('%d-No responsive neurons.\n',ex) + continue + end + vs.CalculateReceptiveFields('overwrite',true,'nShuffle',20); + vs.PlotReceptiveFields('overwrite',true,'RFsDivision',{'Directions','','Luminosities'},meanAllNeurons=true) +end + +%% FFF +for ex = 56 + NP = loadNPclassFromTable(ex); %73 81 + vs = fullFieldFlashAnalysis(NP); + vs.getSessionTime("overwrite",true); + vs.getDiodeTriggers('extractionMethod','digitalTriggerDiode','overwrite',true); + %vs.plotDiodeTriggers + vs.getSyncedDiodeTriggers("overwrite",true); + r = vs.ResponseWindow('overwrite',true); + results = vs.ShufflingAnalysis('overwrite',true); + if ~any(results.Speed1.pvalsResponse<0.05) + fprintf('%d-No responsive neurons.\n',ex) + continue + end + vs.CalculateReceptiveFields('overwrite',true,'nShuffle',20); + vs.PlotReceptiveFields('overwrite',true,'RFsDivision',{'Directions','','Luminosities'},meanAllNeurons=true) +end + + +%% Run for all +for ex = 85:88 + NP = loadNPclassFromTable(ex); %73 81 + vs = linearlyMovingBallAnalysis(NP); + vs.getSessionTime("overwrite",true); + vs.getDiodeTriggers('extractionMethod','digitalTriggerDiode','overwrite',true); + %vs.plotDiodeTriggers + vs.getSyncedDiodeTriggers("overwrite",true); + r = vs.ResponseWindow('overwrite',true); + results = vs.ShufflingAnalysis('overwrite',true); + if ~any(results.Speed1.pvalsResponse<0.05) + fprintf('%d-No responsive neurons.\n',ex) + continue + end + vs.CalculateReceptiveFields('overwrite',true,'nShuffle',20); + vs.PlotReceptiveFields('overwrite',true,'RFsDivision',{'Directions','','Luminosities'},meanAllNeurons=true) +end + +%% Check experiments in timseseries viewer +timeSeriesViewer(NP) +t=NP.getTrigger; +data.VS_ordered(ex) + +stimOn = t{3}; +stimOff = t{4}; + +MBRtOn = stimOn(stimOn > t{1}(1) & stimOn < t{2}(1)); +MBRtOff = stimOff(stimOff > t{1}(1) & stimOff < t{2}(1)); + +MBtOn = stimOn(stimOn > t{1}(2) & stimOn < t{2}(2)); +MBtOff = stimOff(stimOff > t{1}(2) & stimOff < t{2}(2)); + +RGtOn = stimOn(stimOn > t{1}(3) & stimOn < t{2}(3)); +RGtOff = stimOff(stimOff > t{1}(3) & stimOff < t{2}(3)); + +NGtOn = stimOn(stimOn > t{1}(4) & stimOn < t{2}(4)); +NGtOff = stimOff(stimOff > t{1}(4) & stimOff < t{2}(4)); + +DtOn = stimOn(stimOn > t{1}(5) & stimOn < t{2}(5)); +DtOff = stimOff(stimOff > t{1}(5) & stimOff < t{2}(5)); + +MovingBallTriggersDiode = d3.stimOnFlipTimes; + + + +%% %% check neural data sync and analog data sync + +allTimes = [stimOn(:); stimOff(:); onSync(:); offSync(:)]; % concatenate as column + +% Sort from earliest to latest +sortedTimesDiodeOldMethod = sort(allTimes); diff --git a/visualStimulationAnalysis/RunAnalysisClass.m b/visualStimulationAnalysis/RunAnalysisClass.m index 78d5485..d5676a7 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -3,9 +3,220 @@ data = readtable(excelFile); + + +%% AllExpAnalysis +%[49:54 57:81] MBR all experiments 'NV','NI' +%[44:56,64:88] All experiments +%[28:32,44,45,47,48,56,98] All SA experiments +%Check triggers 45, SA82 44,45,47:54,56,64:88 +% All stim: 'FFF','SDG','MBR','MB','RG','NI','NV' +%[49:54,64:97] %All PV good experiments [49:54,64:85 87:97] +% %%[89,90,92,93,95,96,97] %Al NV and NI experiments +%[49:54,84:90,92:96] %All SDG experiments +%solve MBR +%bootsrapRespBase + +%% FIGURE 1 MOVING BALL VS STATIC BALL +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +%% Compare MB vs RG, use gridmode true, selects maximum spatial category across directions +markUnits = { 82, 'MB', 'PV140', 2 ; % -> X in the MB column [1, 8, 40:43,49:54,64:66,68:85 87:97 + 21, 'RG', 'PV132', 6 }; + +[tempTableMW] = AllExpAnalysis([1, 8, 40:43,49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... + overwriteResponse=false,overwriteStats=false,useFDR=false,SpatialGridMode=true,maxCategory=true,RespDurationWin=300, markUnits = markUnits); + +%% Analysis of just entry to screen +markUnits = { 82, 'MB', 'PV140', 2 ; % -> X in the MB column [1, 8, 40:43,49:54,64:66,68:85 87:97 + 21, 'RG', 'PV132', 6 }; + +[tempTableMW] = AllExpAnalysis([1, 8, 40:43,49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true,useFDR=false,SpatialGridMode=false,maxCategory=true,BaseRespWindow=300, markUnits = markUnits); + +%% Calculate spatial tuning +results = SpatialTuningIndex([40:43,49:54,64:66, 68:85 87:97], indexType = "L_amplitude_diff" ,overwrite=true... + , topPercent = 30,useRF=true,onOff=1,unionResponsive = false,allResponsive=true, PaperFig=true, plotRFs=false, plotRFunion=false, detectStripe = false, testStripeDetection= false); +%% +results = SpatialTuningIndex([40:43,49:54,64:66, 68:85 87:97], indexType = "L_amplitude_diff" ,overwrite=false... + , topPercent = 50,useRF=true,onOff=1,unionResponsive = true,allResponsive=false, PaperFig=true, plotRFs=false, plotRFunion=false, detectStripe = true, testStripeDetection= false); +%% +results = analyzeStripeNeurons([40:43,49:54,64:66, 68:85 87:97], ... + indexType = "L_amplitude_diff", ... + onOff = 1, ... + sizeIdx = 1, ... + lumIdx = 1, ... + useRF = true, ... + prefDir = true, ... + unionResponsive = true,... + allResponsive=false, PaperFig=true); % match what you used in SpatialTuningIndex + +%% +plotPSTH_MultiExp([40:43,49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false,binWidth=50, postStim= 500, stimTypes={"MB","RG"},unionResponsive=true, requireAllStims=true); + +%% +plotRaster_MultiExp([40:43,49:54,64:66,68:85 87:97],overwrite=true,TakeTopPercentTrials=[],PaperFig=true,zScore = true, postStim=500,stimTypes=["MB","RG"],unionUnits=true, prefCatPSTH = true,splitCategory=["Directions","Position"]) + + +%% FIGURE 2 MOVING VS STATIC COMPARISON +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +%% %% Compare SDGm vs SDGs +[tempTableMW] = AllExpAnalysis([40:43,49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'SDGm','SDGs'},PaperFig=true,... + overwriteResponse=true,overwriteStats=true,useFDR=false,maxCategory=true,BaseRespWindow=1000); + +%% %% Compare SDGm vs SDGs, across directions +[tempTableMW] = AllExpAnalysis([40:43 49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'SDGm','SDGs'},CompareCategory={"angles","angles"},CompareLevels={[0,90,180,270],[0,90,180,270]},PaperFig=true,... + overwriteResponse=false,overwriteStats=true,useFDR=false,maxCategory=false,BaseRespWindow=1000); + +%% Plot PSTH of MG and SG +plotPSTH_MultiExp([40:43,49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, binWidth=100, postStim= 1000, stimTypes={"SDGm","SDGs"},unionResponsive=true); + +%% Raster for MG and SG +plotRaster_MultiExp([40:43,49:54,64:66,68:85 87:97],overwrite=true,TakeTopPercentTrials=[],PaperFig=true,postStim=500,stimTypes=["SDG"],unionUnits=true,preBase=500) + +%% SDGm spatial frequency +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'SDGm'},CompareCategory={"tempFrequency"},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 1000); + + +%% %% Compare NI vs NV, +[tempTableMW] = AllExpAnalysis([40:43 49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'NV','NI'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true,useFDR=false,maxCategory=false,BaseRespWindow=1000); +%% %% Compare NI vs NV, +[tempTableMW] = AllExpAnalysis([92:97], overwrite=false,ComparePairs={'NI','NV'},CompareCategory={"imgeOrder",""},CompareLevels={[1,9],[]},PaperFig=true,... + overwriteResponse=true,overwriteStats=true,useFDR=false,maxCategory=false,BaseRespWindow=1000); + +%% Raster for NI and NV +plotRaster_MultiExp([49:54,64:66,68:85 87:97],overwrite=true,TakeTopPercentTrials=[],PaperFig=true,postStim=1000,stimTypes=["NI","NV"],unionUnits=false ) + +%% FIGURE 3 SIZES AND LOCALITY COMPARISON +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +% %% Compares MB across different sizes +% [tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'MB'},CompareCategory="sizes",PaperFig=true,... +% overwriteResponse=false,overwriteStats=true, BaseRespWindow = 500,CategoryMaximized = "offsets"); +% +% +% %% Compares MB and SDGm across different categories, z-scores are computed with a moving window and responsive units are selected on the per category p value. +% [tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'MB','SDGm'},CompareCategory={"directions","angles"},CompareLevels={[0,1.57,3.14, 4.71],[0,90,180,270]},PaperFig=true,... +% overwriteResponse=false,overwriteStats=true, BaseRespWindow = 1000,useGeneralFilter=true,SpatialGridMode = false,maxCategory = true); +%% Compares MB and MG, across all categories +[tempTableMW] = AllExpAnalysis([40:43,49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'MB','SDGm'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 500, maxCategory = true, SpatialGridMode = true); + +%% %% Compares MB and MG, across all categories +[tempTableMW] = AllExpAnalysis([40:43,49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'MB','MBR'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 500, maxCategory = true, SpatialGridMode = false); + +%% %% PSTH for MB v sMG +plotPSTH_MultiExp([40:43,49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, binWidth=100, postStim= 1000,stimTypes={"MB","SDGm"},requireAllStims=true, unionResponsive=true); %stimTypes=["linearlyMovingBall"] + +%% %% PSTH for MB vs MBR +plotPSTH_MultiExp([40:43,49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=250,binWidth =50, postStim= 1000, stimTypes={"MB","MBR"},unionResponsive=true,requireAllStims = true); + + +%% Raster for MB vs MG + +plotRaster_MultiExp([40:43,49:54,64:66,68:85 87:97],overwrite=true,TakeTopPercentTrials=[],PaperFig=true,postStim=1000,stimTypes=["MB","SDGm"],unionUnits=true ) + +%% Raster for MB vs MBR +plotRaster_MultiExp([40:43,49:54,64:66,68:85 87:97],overwrite=true,TakeTopPercentTrials=[],PaperFig=true,postStim=1000,stimTypes=["MB","MBR"],unionUnits=true ) + +%% %% Compares SB, SG across all categories +[tempTableMW] = AllExpAnalysis([40:43, 49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'RG','SDGs'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 300, maxCategory = true, SpatialGridMode = false); + +%% %% Compares SB, FFF, across all categories +[tempTableMW] = AllExpAnalysis([40:43,49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'RG','FFF'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 300, maxCategory = true, SpatialGridMode = false); + +%% PSTH for all static experiments +plotPSTH_MultiExp([40:43,49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, binWidth=50, smooth=250, postStim= 500, stimTypes={"RG","SDGs"}); %stimTypes=["linearlyMovingBall"] + +%% Plot changes in size for MB +plotPSTH_MultiExp([40:43,20,49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=250,binWidth=50, postStim= 500, stimTypes={"RG","FFF"}); %stimTypes=["linearlyMovingBall"] + +%% SDGm spatial frequency +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'SDGs'},CompareCategory="spatFrequency",PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 1000); + +%% Plot different spatial freuqncies +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50, postStim= 2000, stimTypes={"SDGm"},splitBy="spatFrequency",useCategoryPvals = true); %stimTypes=["linearlyMovingBall"] + + +%% Plot MB raster sorted per size +plotRaster_MultiExp([49:54,64:66,68:85 87:97], overwrite=false, sortBy="preferredCategory", ... + splitCategory="Sizes", stimTypes="MB",zScore=true,PaperFig=true) + +%% Plot SDGm raster sorted per size +plotRaster_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, sortBy="preferredCategory", ... + splitCategory="spatFrequency", stimTypes="SDGm",zScore=true,PaperFig=true) + +%% %% FIGURE 4 DIRECTION TUNING COMPARISON +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +%% Compares MB across differen directions +[tempTableMW] = AllExpAnalysis([40:43, 49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'MB'},CompareCategory="directions",PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 1500); +%% +plotPSTH_MultiExp([40:43, 49:54,64:66,68:85 87:97], overwrite=false, zScore=true,TakeTopPercentTrials=[], PaperFig=true,postStim =2000, byDepth=false, smooth=50, stimTypes={"MB"},splitBy="directions"); %stimTypes=["linearlyMovingBall"] + +%% Compares MB across differen directions +[tempTableMW] = AllExpAnalysis([40:43, 49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'SDGm'},CompareCategory="angles",CompareLevels = {[0,90,180,270]},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 1000); +%% +plotPSTH_MultiExp([40:43, 49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true,postStim =1000, byDepth=false, smooth=50, stimTypes={"SDGm"},splitBy="angles",splitLevels=[0,90,180,270]); %stimTypes=["linearlyMovingBall"] + +%% PLot OSI and DSI + +AllExpDirectionTuning([40:43, 49:54,64:66,68:85 87:97], overwrite=false, stimuli = {'MB'},PaperFig=true) + + +%% Plot SDGm raster sorted per direction +plotRaster_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, sortBy="preferredCategory", ... + splitCategory="angles", splitLevels ={[0,90,180,270]}, stimTypes="SDGm",zScore=true,PaperFig=true) + +%% Plot SDGm raster sorted per direction +plotRaster_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, sortBy="preferredCategory", ... + splitCategory="directions", stimTypes="MB",zScore=true,PaperFig=true) + +%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + %% %% Rect Grid -for ex = [97] %84:91 +for ex = [1, 8] %84:91 NP = loadNPclassFromTable(ex); %73 81 vsRe = rectGridAnalysis(NP); % vsRe.getSessionTime("overwrite",true); @@ -14,59 +225,61 @@ % vsRe.getSyncedDiodeTriggers("overwrite",true); % % vsRe.plotSpatialTuningSpikes; % % vsRe.plotSpatialTuningLFP; - % vsRe.ResponseWindow('overwrite',true) + %vsRe.ResponseWindow('overwrite',true) % results = vsRe.ShufflingAnalysis('overwrite',true); - close all;vsRe.plotRaster(MergeNtrials=1,overwrite=true,exNeurons = 43, selectedLum=255,oneTrial = true,PaperFig = true) - vsRe.CalculateReceptiveFields('overwrite',true) - [colorbarLims] = vsRe.PlotReceptiveFields(exNeurons=43,allStimParamsCombined=true,PaperFig=true,overwrite=true); - result = vsRe.BootstrapPerNeuron('overwrite',true); + %vsRe.plotRaster(MergeNtrials=1,overwrite=true,AllResponsiveNeurons = true, selectedLum=[],oneTrial = true,PaperFig = true) %43 + % vsRe.plotRaster(MergeNtrials=1,overwrite=true,AllResponsiveNeurons=false,exNeuronsPhyID = 145, selectedLum=255,oneTrial = true,PaperFig = true) %43 + vsRe.CalculateReceptiveFields('overwrite',true,AllResponsiveNeurons=false) + % [colorbarLimsRG] = vsRe.PlotReceptiveFields(exNeurons=13,allStimParamsCombined=false,PaperFig=true,overwrite=true); + %result = vsRe.BootstrapPerNeuron('overwrite',true); + %result = vsRe.StatisticsPerNeuron('overwrite',true); end % vsRe.CalculateReceptiveFields -% vsRe.PlotReceptiveFields("meanAllNeurons",true) +%vsRe.PlotReceptiveFields("exNeurons",18) -%% Moving ball +% Moving ball -for ex = [69,81,95,97] %97 +for ex =[40:42]%97 74:84 (Neurons, 96_74, ) NP = loadNPclassFromTable(ex); %73 81 - vs = linearlyMovingBallAnalysis(NP,Session=1); + vs = linearlyMovingBallAnalysis(NP); % vs.getSessionTime("overwrite",true); % vs.getDiodeTriggers('extractionMethod','digitalTriggerDiode','overwrite',true); - % % %vs.plotDiodeTriggers + % % % %vs.plotDiodeTriggers % vs.getSyncedDiodeTriggers("overwrite",true); - % % %vs.plotSpatialTuningSpikes; - r = vs.ResponseWindow('overwrite',true); - results = vs.ShufflingAnalysis('overwrite',true); - % % vs.plotRaster('AllSomaticNeurons',true,'overwrite',true,'MergeNtrials',3) - % %vs.plotRaster('AllResponsiveNeurons',true,'overwrite',true,'MergeNtrials',2,'bin',5,'GaussianLength',30,'MaxVal_1', false) - % vs.plotRaster('AllSomaticNeurons',true,'overwrite',true,'speed',2,'MergeNtrials',3) - %vs.plotRaster('exNeurons',73,'overwrite',true,'MergeNtrials',1,'OneDirection','up','OneLuminosity','white','PaperFig',true) - % %vs.plotCorrSpikePattern - % %vs.plotRaster('AllSomaticNeurons',true,'overwrite',true,'speed',2) - %vs.CalculateReceptiveFields('overwrite',true); - %vs.PlotReceptiveFields('exNeurons',73,'overwrite',true,'OneDirection','up','OneLuminosity','white','PaperFig',true) - result = vs.BootstrapPerNeuron('overwrite',true);%('overwrite',true); - % pvals0_6Filter =result.Speed2.pvalsResponse'; - % compare = [pvals,pvalsNoFilt,pvals0_6Filter]; + % % % % %vs.plotSpatialTuningSpikes; + % r = vs.ResponseWindow('overwrite',true); + % % % results = vs.ShufflingAnalysis('overwrite',true); + % % % % vs.plotRaster('AllSomaticNeurons',true,'overwrite',true,'MergeNtrials',3) + % vs.plotRaster('AllResponsiveNeurons',true,'overwrite',true,'MergeNtrials',1,'bin',50,'GaussianLength',30,'MaxVal_1', false, oneLuminosity = "white", OneDirection="left", ... + % sortingOrder=["size","direction","luminosity","offset","speed"]) + %vs.plotRaster('AllSomaticNeurons',true,'overwrite',true,'MergeNtrials',3,PaperFig=true) + % vs.plotRaster('exNeuronsPhyID',145,'overwrite',true,'MergeNtrials',3,'PaperFig',false,'OneDirection','down',OneLuminosity= 'white') + % % % % %vs.plotCorrSpikePattern + % vs.plotRaster('exNeurons',82,'overwrite',true,'OneDirection','up','OneLuminosity','white','MergeNtrials',1,'PaperFig',false) + % %vs.plotRaster('AllSomaticNeurons',true,'overwrite',true,'MergeNtrials',3,MaxVal_1=false,PaperFig=false) + vs.CalculateReceptiveFields('overwrite',true,testConvolution=false,AllResponsiveNeurons=false); + % colorbarLims=vs.PlotReceptiveFields('exNeurons',13,'overwrite',true,'OneDirection','down','OneLuminosity','white','PaperFig',true); + % %result = vs.BootstrapPerNeuron('overwrite',true);%('overwrite',true); + % % pvals0_6Filter =result.Speed2.pvalsResponse'; + % % compare = [pvals,pvalsNoFilt,pvals0_6Filter]; + % result = vs.StatisticsPerNeuron('overwrite',true); + % result = vs.StatisticsPerNeuronPerCategory('compareCategory','sizes','overwrite',true); end -%% PlotZScoreComparison -%[49:54 57:81] MBR all experiments 'NV','NI' -%[44:56,64:88] All experiments -%[28:32,44,45,47,48,56,98] All SA experiments -%Check triggers 45, SA82 44,45,47:54,56,64:88 -% All stim: 'FFF','SDG','MBR','MB','RG','NI','NV' -%[49:54,64:97] %All PV good experiments -% %%[89,90,92,93,95,96,97] %Al NV and NI experiments -%[49:54,84:90,92:96] %All SDG experiments -%solve MBR -%bootsrapRespBase -VStimAnalysis.PlotZScoreComparison([49:54,64:97],{'MB','RG'},StatMethod='bootsrapRespBase', overwrite=false,ComparePairs={'MB','RG'},PaperFig=false,... - overwriteResponse=false,overwriteStats=false)%[49:54,57:91] %%Check why I have different array dimensions in MBR +%% Raster for all experiment +plotRaster_MultiExp([49:54,64:66,68:85 87:97], sortBy = "spatialTuning",overwrite=true,TakeTopPercentTrials=[],PaperFig=true) + +%% Calculate spatial tuning +results= SpatialTuningIndex([49:54,64:66, 68:85 87:97], indexType = "L_amplitude_diff" ,overwrite=true... + , topPercent = 30,useRF=true,onOff=1,unionResponsive = false,allResponsive=true, PaperFig=true, plotRFs=false, plotRFunion=false); + +%% Get neuron depths +getNeuronDepths([49:54,64:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates %% Gratings -for ex = [54 84:90] +for ex = [40] NP = loadNPclassFromTable(ex); %73 81 vs = StaticDriftingGratingAnalysis(NP); vs.getSessionTime("overwrite",true); @@ -74,14 +287,18 @@ dT = vs.getDiodeTriggers; % vs.plotDiodeTriggers vs.getSyncedDiodeTriggers("overwrite",true); - r = vs.ResponseWindow('overwrite',true); - results = vs.ShufflingAnalysis('overwrite',true); - result = vs.BootstrapPerNeuron('overwrite',true); + %r = vs.ResponseWindow('overwrite',true); + % results = vs.ShufflingAnalysis('overwrite',true); + % result = vs.BootstrapPerNeuron('overwrite',true); + % vs.StatisticsPerNeuron(overwrite=true) + vs.plotRaster(MaxVal_1=true,OneAngle=270,exNeurons=28,AllResponsiveNeurons=false,PaperFig=false) %0.5208 %2.0833 + vs.plotRaster(MaxVal_1=false) + close all end %% movie -for ex = [89,90,92,93,95:97] +for ex = [92:97] NP = loadNPclassFromTable(ex); %73 81 vs = movieAnalysis(NP); % vs.getSessionTime("overwrite",true); @@ -89,15 +306,17 @@ % dT = vs.getDiodeTriggers; % vs.plotDiodeTriggers %vs.getSyncedDiodeTriggers("overwrite",true); - %r = vs.ResponseWindow('overwrite',true); + r = vs.ResponseWindow('overwrite',true); %results = vs.ShufflingAnalysis('overwrite',true); - vs.plotRaster('AllResponsiveNeurons',true,MergeNtrials=1,overwrite=true) + result = vs.StatisticsPerNeuron('overwrite',true); + vs.plotRaster(AllResponsiveNeurons=true) + close all end %% image -for ex = [89,90,92,93,95:97] +for ex = [97] NP = loadNPclassFromTable(ex); %73 81 vs = imageAnalysis(NP); %vs.getSessionTime("overwrite",true); @@ -107,7 +326,9 @@ %vs.getSyncedDiodeTriggers("overwrite",true); r = vs.ResponseWindow('overwrite',true); %results = vs.ShufflingAnalysis('overwrite',true); - vs.plotRaster('AllResponsiveNeurons',true,MergeNtrials=1,overwrite=true) + vs.plotRaster('exNeurons',13,MergeNtrials=1,overwrite=true, selectCats =[], PaperFig=true) + close all + %result = vs.StatisticsPerNeuron('overwrite',true); end diff --git a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m index 7abbcf3..516a8e7 100644 --- a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m +++ b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m @@ -2,7 +2,7 @@ %% Run/load bombcell and confusion matrices % -exp = [49:54,64:97];% +exp = [19];% %tiledlayout(numel(exp),1) for ex = exp%GoodRecordingsPV%allGoodRec %GoodRecordings%GoodRecordingsPV%GoodRecordingsPV%selecN{1}(1,:) %1:size(data,1) %%%%%%%%%%%% Load data and data paremeters @@ -11,10 +11,12 @@ vs = linearlyMovingBallAnalysis(NP,Session=1); KSversion =4; - [qMetric,unitType]=NP.getBombCell(NP.recordingDir+"\kilosort4",0,KSversion); + [qMetric,unitType]=NP.getBombCell(NP.recordingDir+"\kilosort4",0,KSversion,1); %convertPhySorting2tIc(obj,pathToPhyResults,tStart,BombCelled) - +end +%% +for ex = exp % % goodUnits = unitType == 1; % muaUnits = unitType == 2; @@ -124,8 +126,70 @@ % sum(mismatch_ks_man), numel(mismatch_ks_man), ... % 100*mean(mismatch_ks_man)); + imec = Neuropixel.ImecDataset(NP.recordingDir); + ks = Neuropixel.KilosortDataset(vs.spikeSortingFolder,'imecDataset', imec); + ks.load(); + end %I want to compare bombcell unit classification with manual classification in phy. +%% Plot raw waveforms of specific units: + +% 1. Add to path: https://github.com/cortex-lab/spikes +% https://github.com/kwikteam/npy-matlab (dependency) + + +ksDir = vs.spikeSortingFolder; +sp = loadKSdir(ksDir); % loads all KS output into a struct + +% Get waveforms +gwfparams.dataDir = ksDir; +gwfparams.fileName = NP.recordingDir; +gwfparams.dataType = 'int16'; +gwfparams.nCh = 385; +gwfparams.wfWin = [-40 41]; % samples around spike +gwfparams.nWf = 100; % waveforms per unit +gwfparams.spikeTimes = sp.st; % spike times +gwfparams.spikeClusters = sp.clu; % cluster IDs + +wf = getWaveForms(gwfparams); % wf.waveForms: [units x waveforms x channels x samples] + +% Plot mean waveform for unit 1, best channel +figure; +plot(squeeze(mean(wf.waveFormsMean(1,:,:), 2))); + +%% Check low amp waveforms 10 neurons per experiment + +PVexps = [49:54,64:97]; +idx = randi(length(PVexps), 1, 4); +selected = PVexps(idx); + + +%% +selected =69; +for i = selected(1:end) + NP = loadNPclassFromTable(i); + vs = linearlyMovingBallAnalysis(NP,Session=1); + + p = vs.dataObj.convertPhySorting2tIc(vs.spikeSortingFolder); + phy_IDg = p.phy_ID(string(p.label') == 'good'); + + [param, qMetric, fractionRPVs_allTauR] = bc.load.loadSavedMetrics([NP.recordingDir filesep 'qMetrics']); + + [~ ,idx] = sort(qMetric.rawAmplitude(ismember(qMetric.phy_clusterID,phy_IDg))); + + %Select units with lowest amplitude + selecUnits = qMetric.phy_clusterID(ismember(qMetric.phy_clusterID,phy_IDg)); + selecUnits = selecUnits(idx(1:min([10 numel(selecUnits)]))); + + selecUnits = 104; + + plotRawWaveforms(vs, selecUnits, showCorr=true, corrWin=50, corrBin=0.5,nChanAround=6) + + qMetric.signalToNoiseRatio(qMetric.phy_clusterID == 630,:) + % q = qMetric(ismember(qMetric.phy_clusterID,selecUnits),:); +end + +[qMetric,unitType]=NP.getBombCell(NP.recordingDir+"\kilosort4",1,KSversion,0); \ No newline at end of file diff --git a/visualStimulationAnalysis/SpatialTuningIndex.m b/visualStimulationAnalysis/SpatialTuningIndex.m new file mode 100644 index 0000000..c3ac98f --- /dev/null +++ b/visualStimulationAnalysis/SpatialTuningIndex.m @@ -0,0 +1,2163 @@ +function results = SpatialTuningIndex(exList, params) + +arguments + exList double + params.stimTypes (1,:) string = ["linearlyMovingBall", "rectGrid"] + params.topPercent double = 10 + params.overwrite logical = false + params.statType string = "maxPermuteTest" + params.speed double = 1 + params.plot logical = true + params.indexType string = "L_amplitude_diff" % L_amplitude_diff, L_amplitude_ratio, L_geometric, L_combined + params.onOff double = 1 % 1=on, 2=off (rectGrid only; ignored for linearlyMovingBall) + params.sizeIdx double = 1 + params.lumIdx double = 1 + params.nBoot double = 10000 + params.yLegend char = 'Spatial Tuning Index' + params.yMaxVis double = 1 + params.Alpha double = 0.4 + params.PaperFig logical = false + params.useRF logical = true % If true: use RF (convolution-based) maps for both stim types. + % If false: use gridSpikeRate (trial-binned) maps. + % Recommended: true for linearlyMovingBall (avoids Y-offset bug), + % and true for rectGrid for cross-stimulus comparability. + params.prefDir logical = true % If true (requires useRF=true): use each neuron's preferred + % direction RF for linearlyMovingBall instead of averaging + % across all directions. Preferred direction is defined as the + % direction with the highest spike rate in NeuronVals. + % Avoids deflating the spatial tuning index by averaging over + % non-preferred directions. + params.allResponsive logical = false % If true: compute index for ALL neurons responsive to each + % stim type independently — no intersection across stim types. + % Each swarm column will have a different number of neurons. + % Difference plot is not available in this mode. + % P-value is computed via two-sample hierarchical bootstrap. + params.unionResponsive logical = false % If true: compute index for all neurons responsive + % to EITHER stim type (union). Same neuron set used + % for both stim types, so paired diff is still valid. + % P-value uses paired hierBoot on differences. + params.plotRFs logical = false % If true: generate multi-page PDF + % showing each neuron's full-resolution + % receptive field, sorted by tuning index + % (descending). Requires prefDir=true for + % linearlyMovingBall. + params.subtractShuffle logical = true % If true: subtract the shuffle-mean baseline + % from each RF before plotting, so the colour + % scale reflects signal above noise. + % If false: plot the raw (unsubtracted) RF. + params.plotRFunion logical = false % If true: RF pages show all neurons responsive + % to EITHER stim type (union). Two PDFs are + % generated with the same neuron set, one per + % stim type. Each PDF is sorted by that stim + % type's tuning index; neurons not responsive + % to the plotted stim type are annotated "n/r" + % and sink to the bottom. + params.detectStripe logical = false % If true: run Radon-based stripe + % detection on each neuron's full- + % resolution RF. Adds stripe + % classification columns to the + % output table. + params.stripeAlpha double = 0.05 % Significance level for the stripe + % permutation test. + params.stripeNSurr double = 1000 % Number of pixel-permutation + % surrogates per RF image. + params.stripeAngleStep double = 5 % Degrees between tested projection + % angles (0 to 175). + params.testStripeDetection logical = false % If true: after detection, + % generate a diagnostic figure + % showing a subsample of neurons + % that pass and fail, with their + % RFs and Radon variance profiles. + params.testStripeN double = 10 % Number of neurons to include in + % the test visualisation (half pass, + % half fail, if available). + params.stripeMinAngle double = 20 % Lower bound of accepted stripe angle (deg), BL→TR + params.stripeMaxAngle double = 60 % Upper bound of accepted stripe angle (deg), BL→TR +end + +% ------------------------------------------------------------------------- +% Parameter guards +% ------------------------------------------------------------------------- +% prefDir requires the RF path +if params.prefDir && ~params.useRF + error('prefDir=true requires useRF=true. The preferred direction RF is only available in the RF path.'); +end + +% allResponsive is incompatible with diff plotting — neurons are unpaired +if params.allResponsive + fprintf('allResponsive=true: each stim type will include all its own responsive neurons.\n'); + fprintf(' Difference plot is not available in this mode.\n'); + fprintf(' P-value computed via two-sample hierarchical bootstrap.\n'); +end + +% unionResponsive and allResponsive are mutually exclusive +if params.unionResponsive && params.allResponsive + error('unionResponsive and allResponsive cannot both be true — choose one neuron selection mode.'); +end + +if params.unionResponsive + fprintf('unionResponsive=true: using all neurons responsive to either stim type (union).\n'); + fprintf(' Paired difference plot is available since the same neuron set is used for both stim types.\n'); +end + +% ------------------------------------------------------------------------- +% Build save path +% ------------------------------------------------------------------------- +NP_first = loadNPclassFromTable(exList(1)); % Load first experiment to extract file path + +% Build path to combined analysis directory based on first stim type +switch params.stimTypes(1) + case "rectGrid" + vs_first = rectGridAnalysis(NP_first); + case "linearlyMovingBall" + vs_first = linearlyMovingBallAnalysis(NP_first); +end + +% Extract base path up to 'lizards' folder +p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); +p = [p 'lizards']; + +% Create combined analysis directory if it does not exist +if ~exist([p '\Combined_lizard_analysis'], 'dir') + cd(p) + mkdir Combined_lizard_analysis +end +saveDir = [p '\Combined_lizard_analysis']; + +% Build filename encoding all parameter modes that affect computation +% so different parameter combinations never collide on disk +stimLabel = strjoin(params.stimTypes, '-'); +rfLabel = ''; if params.useRF, rfLabel = '_RF'; end +prefLabel = ''; if params.prefDir, prefLabel = '_prefDir'; end +allRespLabel = ''; if params.allResponsive, allRespLabel = '_allResp'; end +unionLabel = ''; if params.unionResponsive, unionLabel = '_union'; end +nameOfFile = sprintf('\\Ex_%d-%d_SpatialTuningIndex_%s%s%s%s%s.mat', ... + exList(1), exList(end), stimLabel, rfLabel, prefLabel, allRespLabel, unionLabel); + +% ------------------------------------------------------------------------- +% Decide whether to compute or load from cache +% ------------------------------------------------------------------------- +if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite + S = load([saveDir nameOfFile]); + if isequal(S.expList, exList) + fprintf('Loading saved SpatialTuningIndex from:\n %s\n', [saveDir nameOfFile]); + tbl = S.tbl; + goto_plot = true; + else + fprintf('Experiment list mismatch — recomputing.\n'); + goto_plot = false; + end +else + goto_plot = false; +end + +% ========================================================================= +% COMPUTE +% ========================================================================= +if ~goto_plot + + nExp = numel(exList); + nStim = numel(params.stimTypes); + + % Guard: useRF must apply to all stim types — mixed inputs are not allowed + if params.useRF + supportedRF = ["rectGrid", "linearlyMovingBall"]; + unsupported = params.stimTypes(~ismember(params.stimTypes, supportedRF)); + if ~isempty(unsupported) + error(['useRF=true is not supported for stim type(s): %s.\n' ... + 'Either add RF support for these types or set useRF=false.'], ... + strjoin(unsupported, ', ')); + end + if params.prefDir + fprintf('RF mode (preferred direction): using preferred-direction RF for linearlyMovingBall.\n'); + else + fprintf('RF mode: using convolution-based RF maps for all stim types.\n'); + end + else + fprintf('Grid mode: using trial-binned gridSpikeRate for all stim types.\n'); + end + + tbl = table(); % Accumulates one row per neuron x condition x stim type x experiment + + for ei = 1:nExp + + ex = exList(ei); + fprintf('\n=== Experiment %d ===\n', ex); + + % Load NeuropixelsClass object for this experiment + try + NP = loadNPclassFromTable(ex); + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + continue + end + + % Use linearlyMovingBall to extract spike sorting info (shared across stim types) + obj_s = linearlyMovingBallAnalysis(NP); + + % Extract animal name from recording name (first underscore-delimited token) + nameParts = split(NP.recordingName, '_'); + animalName = nameParts{1}; + + % ---------------------------------------------------------- + % Get phy IDs for all good units (same spike sorting for all stim types) + % ---------------------------------------------------------- + p_s = NP.convertPhySorting2tIc(obj_s.spikeSortingFolder); + phy_IDg = p_s.phy_ID(string(p_s.label') == 'good'); % phy IDs of good units + + % Stores responsive unit indices and phy IDs per stim type + respPhyIDs_all = cell(1, nStim); + respU_all = cell(1, nStim); + + % ---------------------------------------------------------- + % Find responsive neurons for each stim type + % ---------------------------------------------------------- + for s = 1:nStim + stimType = params.stimTypes(s); + try + switch stimType + case "rectGrid" + obj_s = rectGridAnalysis(NP); + case "linearlyMovingBall" + obj_s = linearlyMovingBallAnalysis(NP); + end + + % Select statistical test output + if params.statType == "BootstrapPerNeuron" + Stats = obj_s.BootstrapPerNeuron; + elseif params.statType == "maxPermuteTest" + Stats = obj_s.StatisticsPerNeuron; + else + Stats = obj_s.ShufflingAnalysis; + end + + % Extract p-values (linearlyMovingBall has per-speed fields) + try + switch stimType + case "linearlyMovingBall" + fieldName = sprintf('Speed%d', params.speed); + pvals = Stats.(fieldName).pvalsResponse; + otherwise + pvals = Stats.pvalsResponse; + end + catch + pvals = Stats.pvalsResponse; + end + + % Indices of significantly responsive neurons (into phy_IDg) + respU = find(pvals < 0.05); + respU_all{s} = respU; + respPhyIDs_all{s} = phy_IDg(respU); + fprintf(' [%s] %d responsive neuron(s).\n', stimType, numel(respU)); + + catch ME + warning('Could not get pvals for %s exp %d: %s', stimType, ex, ME.message); + respU_all{s} = []; + respPhyIDs_all{s} = []; + end + end + + % ---------------------------------------------------------- + % Determine which neurons to include per stim type: + % allResponsive=false: intersection across all stim types (paired) + % allResponsive=true: each stim type uses its own responsive set + % ---------------------------------------------------------- + if ~params.allResponsive && ~params.unionResponsive + + % Paired mode: intersection of responsive neurons across stim types + sharedPhyIDs = respPhyIDs_all{1}; + for s = 2:nStim + sharedPhyIDs = intersect(sharedPhyIDs, respPhyIDs_all{s}); + end + + if isempty(sharedPhyIDs) + fprintf(' No neurons responsive to all stim types in exp %d — skipping.\n', ex); + continue + end + fprintf(' %d neuron(s) responsive to all stim types in exp %d.\n', numel(sharedPhyIDs), ex); + + % Same set for all stim types + sharedPhyIDs_perStim = repmat({sharedPhyIDs}, 1, nStim); + + elseif params.unionResponsive + + % Union mode: neurons responsive to ANY stim type + % Same union set applied to all stim types — neurons not + % responsive to a given stim will still have their RF/grid + % map computed, which may be weak but is not excluded. + % Paired diff is still valid since every neuron has an index + % for both stim types. + unionPhyIDs = respPhyIDs_all{1}; + for s = 2:nStim + unionPhyIDs = union(unionPhyIDs, respPhyIDs_all{s}); + end + + if isempty(unionPhyIDs) + fprintf(' No responsive neurons for any stim type in exp %d — skipping.\n', ex); + continue + end + fprintf(' %d neuron(s) in union (responsive to at least one stim type) in exp %d.\n', ... + numel(unionPhyIDs), ex); + + % Same union set for all stim types + sharedPhyIDs_perStim = repmat({unionPhyIDs}, 1, nStim); + + else + + % Unpaired mode: each stim type uses its own full responsive set + anyStimsHaveNeurons = any(cellfun(@(x) ~isempty(x), respPhyIDs_all)); + if ~anyStimsHaveNeurons + fprintf(' No responsive neurons for any stim type in exp %d — skipping.\n', ex); + continue + end + sharedPhyIDs_perStim = respPhyIDs_all; + for s = 1:nStim + fprintf(' [%s] %d neuron(s) (all responsive, unpaired).\n', ... + params.stimTypes(s), numel(sharedPhyIDs_perStim{s})); + end + + end + + % ---------------------------------------------------------- + % Loop over stim types and compute spatial tuning index + % ---------------------------------------------------------- + for s = 1:nStim + + stimType = params.stimTypes(s); + sharedPhyIDs = sharedPhyIDs_perStim{s}; % neurons for THIS stim type + + if isempty(sharedPhyIDs) + fprintf(' [%s] No neurons — skipping.\n', char(stimType)); + continue + end + + % Flag to track whether neuronIdx was already applied inside + % the RF block (prefDir path) to avoid double-indexing + alreadyIndexed = false; + + % Build stimulus-specific analysis object + try + switch stimType + case "rectGrid" + obj = rectGridAnalysis(NP); + case "linearlyMovingBall" + obj = linearlyMovingBallAnalysis(NP); + otherwise + error('Unknown stimType: %s', stimType); + end + catch ME + warning('Could not build %s for exp %d: %s', stimType, ex, ME.message); + continue + end + + % Load pre-computed receptive field results from file + S_rf = obj.CalculateReceptiveFields; + + % ---------------------------------------------------------- + % Build gridSpikeRateSelected and gridShuffMean + % Two paths: RF-based (convolution) or grid-binned (gridSpikeRate) + % ---------------------------------------------------------- + + if params.useRF + + switch stimType + + case "linearlyMovingBall" + % ------------------------------------------------- + % Moving ball RF path + % RFuSTDirSizeLum: [nDir, nSize, nLum, rfY, rfX, nN] + % RFuShuffST: [rfY, rfX, nN] — already shuffle-averaged + % ------------------------------------------------- + + % Read RF dimensions upfront — used by both prefDir and default paths + nDir_rf = size(S_rf.RFuSTDirSizeLum, 1); %#ok + nSize_rf = size(S_rf.RFuSTDirSizeLum, 2); + nLum_rf = size(S_rf.RFuSTDirSizeLum, 3); + rfY = size(S_rf.RFuSTDirSizeLum, 4); % typically 54 + nN_rf = size(S_rf.RFuSTDirSizeLum, 6); + nGrid = 9; + blockSize = rfY / nGrid; % typically 6 + + if params.prefDir + % ------------------------------------------------- + % Preferred direction path: + % Select per-neuron RF slice at preferred direction, + % defined as the direction with the highest spike rate + % in NeuronVals — chosen independently of RFuSTDirSizeLum + % to avoid circularity. + % + % NeuronVals: [nGoodUnits, nConditions, nFeatures] + % dim3=1: spike rate + % dim3=6: direction in radians + % dim 1 of NeuronVals indexes ALL good units. + % + % Speed used during RF computation may differ from + % params.speed if only one speed was recorded — + % use S_rf.params.speed as ground truth. + % ------------------------------------------------- + rfSpeed = S_rf.params.speed; + rfField = sprintf('Speed%d', rfSpeed); + NeuronResp = obj.ResponseWindow; + NeuronVals = NeuronResp.(rfField).NeuronVals; + % NeuronVals: [nGoodUnits, nConditions, nFeatures] + + dirLabels = NeuronVals(:,:,6); % direction in radians per condition + spikeRates = NeuronVals(:,:,1); % spike rate per condition + + % Unique directions — same across all neurons, read from row 1 + uDirs = unique(dirLabels(1,:)); % [1, nDir] + + % Max spike rate per direction per good unit. + % Max (not mean) across conditions sharing a direction avoids + % dilution from other conditions (size, lum) at that direction + nGoodUnits = size(NeuronVals, 1); + maxRespPerDir = zeros(nGoodUnits, numel(uDirs)); + for d = 1:numel(uDirs) + dirMask = dirLabels(1,:) == uDirs(d); + maxRespPerDir(:,d) = max(spikeRates(:, dirMask), [], 2); + end + + % Preferred direction index (1:nDir) for each good unit + [~, prefDirIdxAllGood] = max(maxRespPerDir, [], 2); % [nGoodUnits, 1] + + + + % Map sharedPhyIDs (which are a subset of respU_all{s}'s phy IDs) + % onto indices within the responsive set + [~, neuronIdx] = ismember(sharedPhyIDs, phy_IDg); + prefDirIdxShared = prefDirIdxAllGood(neuronIdx); % [nShared, 1] + nShared = numel(neuronIdx); + + fprintf(' [prefDir] Preferred directions for shared neurons: %s\n', ... + num2str(prefDirIdxShared')); + + % Build rfRaw by selecting each neuron's preferred direction slice + % Result: [nSize, nLum, rfY, rfX, nShared] — no direction dim + + assert(nN_rf == numel(phy_IDg), ... + 'RFuSTDirSizeLum dim 6 (%d) ≠ number of good units (%d) in exp %d. RF may need recomputation.', ... + nN_rf, numel(phy_IDg), ex); + rfRaw = zeros(nSize_rf, nLum_rf, rfY, rfY, nShared); + for u = 1:nShared + % Slice preferred direction for this neuron and squeeze + % direction singleton: [1,nSize,nLum,rfY,rfX,1] -> [nSize,nLum,rfY,rfX] + rfRaw(:,:,:,:,u) = squeeze( ... + S_rf.RFuSTDirSizeLum(prefDirIdxShared(u),:,:,:,:,neuronIdx(u))); + end + % rfRaw: [nSize, nLum, rfY, rfX, nShared] + + % Shuffle: RFuShuffST is already responsive-only + % just select shared neurons + rfShuff = S_rf.RFuShuffST; % [rfY, rfX, nRespUnits] + rfShuff = rfShuff(:,:, neuronIdx); % [rfY, rfX, nShared] + + % neuronIdx already applied above — skip post-switch indexing + alreadyIndexed = true; + + else + % ------------------------------------------------- + % Default path: average RF across all directions + % rfRaw: [nSize, nLum, rfY, rfX, nN_rf] + % ------------------------------------------------- + rfRaw = mean(S_rf.RFuSTDirSizeLum, 1); % [1, nSize, nLum, rfY, rfX, nN] + rfRaw = reshape(rfRaw, [nSize_rf, nLum_rf, rfY, rfY, nN_rf]); + + rfShuff = S_rf.RFuShuffST; % [rfY, rfX, nN_rf] + nShared = nN_rf; % neuronIdx applied after switch + + end + + % ------------------------------------------------- + % Downsample rfY x rfY -> nGrid x nGrid + % rfRaw is now always [nSize, nLum, rfY, rfX, nShared] + % rfShuff is [rfY, rfX, nShared] + % ------------------------------------------------- + rfDown = zeros(nGrid, nGrid, nShared, nSize_rf, nLum_rf); + rfShuffDown = zeros(nGrid, nGrid, nShared); + + for bi = 1:nGrid + for bj = 1:nGrid + % Row and column pixel indices for this 6x6 block + rr = (bi-1)*blockSize + (1:blockSize); + cc = (bj-1)*blockSize + (1:blockSize); + + % Average over spatial block dims 3 and 4 of rfRaw + % [nSize, nLum, blockSize, blockSize, nShared] -> [nSize, nLum, 1, 1, nShared] + block = mean(mean(rfRaw(:,:,rr,cc,:), 3), 4); + block = reshape(block, [nSize_rf, nLum_rf, nShared]); % [nSize, nLum, nShared] + block = permute(block, [3, 1, 2]); % [nShared, nSize, nLum] + rfDown(bi,bj,:,:,:) = reshape(block, [1, 1, nShared, nSize_rf, nLum_rf]); + + % Shuffle has no size/lum dim — average block directly + rfShuffDown(bi,bj,:) = mean(mean(rfShuff(rr,cc,:), 1), 2); + end + end + + % Tile shuffle baseline across nSize and nLum to match rfDown + rfShuffDown = repmat( ... + reshape(rfShuffDown, [nGrid, nGrid, nShared, 1, 1]), ... + [1, 1, 1, nSize_rf, nLum_rf]); % [nGrid, nGrid, nShared, nSize, nLum] + + gridSpikeRateSelected = rfDown; % [nGrid, nGrid, nShared, nSize, nLum] + gridShuffMean = rfShuffDown; % [nGrid, nGrid, nShared, nSize, nLum] + + case "rectGrid" + % ------------------------------------------------- + % rectGrid RF path + % RFu: [2(onOff), nLums, nSize, screenRed, screenRed, nN] + % RFuShuffMean: same dimensions + % IMPORTANT: dim2=nLums, dim3=nSize (not the other way around) + % ------------------------------------------------- + + % Select on or off response (dim 1); keep remaining dims explicit + rfFull = S_rf.RFu( params.onOff, :, :, :, :, :); + rfShuffFull = S_rf.RFuShuffMean(params.onOff, :, :, :, :, :); + + nLums_rf = size(rfFull, 2); + nSize_rf = size(rfFull, 3); + screenRed = size(rfFull, 4); % reduced screen resolution + nN_rf = size(rfFull, 6); + + % Collapse the leading singleton onOff dim + rfRaw = reshape(rfFull, [nLums_rf, nSize_rf, screenRed, screenRed, nN_rf]); + rfShuffRaw = reshape(rfShuffFull, [nLums_rf, nSize_rf, screenRed, screenRed, nN_rf]); + % rfRaw: [nLums, nSize, screenRed, screenRed, nN] + + nGrid = 9; + blockSize = screenRed / nGrid; + + rfDown = zeros(nGrid, nGrid, nN_rf, nSize_rf, nLums_rf); + rfShuffDown = zeros(nGrid, nGrid, nN_rf, nSize_rf, nLums_rf); + + for bi = 1:nGrid + for bj = 1:nGrid + rr = (bi-1)*blockSize + (1:blockSize); + cc = (bj-1)*blockSize + (1:blockSize); + + % [nLums, nSize, blockSize, blockSize, nN] -> [nLums, nSize, 1, 1, nN] + block = mean(mean(rfRaw( :,:,rr,cc,:), 3), 4); + blockShuff = mean(mean(rfShuffRaw(:,:,rr,cc,:), 3), 4); + + % Note: after reshape input order is [nLums, nSize, nN] + block = reshape(block, [nLums_rf, nSize_rf, nN_rf]); + blockShuff = reshape(blockShuff, [nLums_rf, nSize_rf, nN_rf]); + block = permute(block, [3, 2, 1]); % [nN, nSize, nLums] + blockShuff = permute(blockShuff, [3, 2, 1]); + + rfDown(bi,bj,:,:,:) = reshape(block, [1, 1, nN_rf, nSize_rf, nLums_rf]); + rfShuffDown(bi,bj,:,:,:) = reshape(blockShuff, [1, 1, nN_rf, nSize_rf, nLums_rf]); + end + end + + gridSpikeRateSelected = rfDown; % [nGrid, nGrid, nN, nSize, nLum] + gridShuffMean = rfShuffDown; % [nGrid, nGrid, nN, nSize, nLum] + + end % switch stimType (RF path) + + % Apply shared neuron indexing after the switch block. + % Skipped for linearlyMovingBall+prefDir since neuronIdx + % was already applied per-neuron inside that branch. + if ~alreadyIndexed + [~, neuronIdx] = ismember(sharedPhyIDs, phy_IDg); + gridSpikeRateSelected = gridSpikeRateSelected(:,:,neuronIdx,:,:); + gridShuffMean = gridShuffMean(:,:,neuronIdx,:,:); + end + + else + % ---------------------------------------------------------- + % Standard path: use gridSpikeRate / gridSpikeRateShuff + % ---------------------------------------------------------- + if stimType == "linearlyMovingBall" + warning(['gridSpikeRate for linearlyMovingBall may contain structural zeros ' ... + 'due to Y-offset bug in CalculateReceptiveFields. ' ... + 'Consider using useRF=true.']); + end + + gridSpikeRate = S_rf.gridSpikeRate; + gridSpikeRateShuff = S_rf.gridSpikeRateShuff; + + switch stimType + case "rectGrid" + gridSpikeRateSelected = gridSpikeRate(:,:,:,params.onOff,:,:); + gridShuffSelected = gridSpikeRateShuff(:,:,:,:,params.onOff,:,:); + + gridSpikeRateSelected = reshape(gridSpikeRateSelected, ... + [size(gridSpikeRateSelected,1), size(gridSpikeRateSelected,2), ... + size(gridSpikeRateSelected,3), size(gridSpikeRateSelected,5), ... + size(gridSpikeRateSelected,6)]); + + gridShuffSelected = reshape(gridShuffSelected, ... + [size(gridShuffSelected,1), size(gridShuffSelected,2), ... + size(gridShuffSelected,3), size(gridShuffSelected,4), ... + size(gridShuffSelected,6), size(gridShuffSelected,7)]); + + case "linearlyMovingBall" + gridSpikeRateSelected = gridSpikeRate; + gridShuffSelected = gridSpikeRateShuff; + end + + [~, neuronIdx] = ismember(sharedPhyIDs, phy_IDg(respU_all{s})); + + gridSpikeRateSelected = gridSpikeRateSelected(:,:,neuronIdx,:,:); + gridShuffSelected = gridShuffSelected(:,:,neuronIdx,:,:,:); + + % Average shuffle dimension (dim 4) to get baseline map + gridShuffMean = mean(gridShuffSelected, 4); + + end % useRF / standard path + + % ---------------------------------------------------------- + % Get dimensions and reshape to canonical [nGrid,nGrid,nN,nSize,nLum] + % ---------------------------------------------------------- + nN = size(gridSpikeRateSelected, 3); + nSize = size(gridSpikeRateSelected, 4); + nLum = size(gridSpikeRateSelected, 5); + nGrid = size(gridSpikeRateSelected, 1); + + fprintf('gridSpikeRateSelected size: %s\n', num2str(size(gridSpikeRateSelected))); + + gridSpikeRateSelected = reshape(gridSpikeRateSelected, [nGrid, nGrid, nN, nSize, nLum]); + gridShuffMean = reshape(gridShuffMean, [nGrid, nGrid, nN, nSize, nLum]); + + nCells = nGrid * nGrid; + maxDist = sqrt(2) * (nGrid - 1); % maximum possible distance between two grid cells + + %Capture preferred-direction info for the current stim×neuron set. + % These vectors will be written into the table inside the si/li loop. + if stimType == "linearlyMovingBall" && params.prefDir && params.useRF + % prefDirIdxShared: index (1:nDir) of each neuron's preferred direction + storePrefDirIdx = prefDirIdxShared(:); % [nShared, 1] + % Convert preferred-direction index to degrees via the sorted unique directions + storePrefDirDeg = rad2deg(uDirs(prefDirIdxShared(:)))'; % [nShared, 1] + else + % Non-MB or non-prefDir: fill with NaN so the table column exists + storePrefDirIdx = nan(size(gridSpikeRateSelected, 3), 1); + storePrefDirDeg = nan(size(gridSpikeRateSelected, 3), 1); + end + + % Pre-allocate stripe result vectors (one value per neuron). + % These will be appended to each table row in the si/li loop. + stripeIsAny = false(nN, 1); % does any direction pass? + stripeNPassDirs = zeros(nN, 1); % count of passing directions + stripePassDirStr = repmat({''}, nN, 1); % comma-separated passing dir indices + stripeBestScore = zeros(nN, 1); % highest stripe score across dirs + stripeBestPval = ones(nN, 1); % p-value at the best direction + stripeBestAngle = nan(nN, 1); % stripe orientation at best dir (degrees) + stripeBestDir = nan(nN, 1); % direction index with strongest stripe (MB only) + + if params.detectStripe + + fprintf(' [%s] Running stripe detection...\n', stimType); + + switch stimType + + case "linearlyMovingBall" + % ------------------------------------------------- + % MB: test each direction independently. + % RFuSTDirSizeLum: [nDir, nSize, nLum, rfY, rfX, nN_rf] + % RFuShuffST: [rfY, rfX, nN_rf] + % ------------------------------------------------- + + % Number of directions available in the RF data + nDirRF = size(S_rf.RFuSTDirSizeLum, 1); + + for u = 1:nN + + % Index into the RF data's neuron dimension. + % Use rfIdx if the indexing fix from the earlier + % bug discussion has been applied; otherwise use + % neuronIdx. Adjust the variable name to match + % your current code. + uRF = neuronIdx(u); + + % Extract the shuffle-mean baseline for this neuron + % RFuShuffST: [rfY, rfX, nN_rf] — already averaged over shuffles + rfShuff = S_rf.RFuShuffST(:, :, uRF); + + % Per-direction results accumulator + dirScores = zeros(nDirRF, 1); % stripe score per direction + dirPvals = ones(nDirRF, 1); % p-value per direction + dirAngles = nan(nDirRF, 1); % stripe angle per direction + dirIsPass = false(nDirRF, 1); % pass/fail per direction + + for d = 1:nDirRF + + % Extract the full-resolution RF for this direction, + % at the requested size and luminance condition. + % Squeeze removes the singleton dir/size/lum dims. + rfSlice = squeeze( ... + S_rf.RFuSTDirSizeLum(d, params.sizeIdx, params.lumIdx, :, :, uRF)); + + % Subtract the shuffle-mean to isolate signal above noise + rfSub = rfSlice - rfShuff; + + % Run the stripe detector. + % Each direction gets its own RNG seed offset so that + % the surrogate sequences are independent but reproducible. + res = detectStripeRF(rfSub, ... + 'angleStep', params.stripeAngleStep, ... + 'nSurrogates', params.stripeNSurr, ... + 'alpha', params.stripeAlpha, ... + 'minStripeAngle', params.stripeMinAngle, ... + 'maxStripeAngle', params.stripeMaxAngle, ... + 'rngSeed', 42 + (u-1)*nDirRF + d); + + % Store per-direction results + dirScores(d) = res.stripeScore; + dirPvals(d) = res.stripePval; + dirAngles(d) = res.stripeAngle; % NaN if not significant + dirIsPass(d) = res.isStripe; + + if res.isStripe == 1 && (res.stripeAngle < 10 || res.stripeAngle > 60) + + 2+2 + + end + + end % direction loop + + % Aggregate across directions for this neuron + stripeIsAny(u) = any(dirIsPass); % any direction passes? + stripeNPassDirs(u) = sum(dirIsPass); % how many pass? + passingDirs = find(dirIsPass); % indices of passing dirs + stripePassDirStr{u} = strjoin(string(passingDirs), ','); % e.g. "1,3,5" + + % Best direction: among directions that PASSED all filters, pick the one + % with the highest score. This guarantees stripeBestAngle is the angle of + % an accepted stripe — not a higher-scoring direction that the filters + % rejected (e.g. horizontal/vertical/wrong-diagonal). + % If no direction passed, fall back to the global best (for diagnostics). + if stripeIsAny(u) + scoresMasked = dirScores; + scoresMasked(~dirIsPass) = -Inf; % exclude non-passing dirs + [stripeBestScore(u), bestDirIdx] = max(scoresMasked); + else + [stripeBestScore(u), bestDirIdx] = max(dirScores); + end + stripeBestPval(u) = dirPvals(bestDirIdx); + stripeBestAngle(u) = dirAngles(bestDirIdx); + stripeBestDir(u) = bestDirIdx; + + if mod(u, 10) == 0 + fprintf(' stripe detection: %d/%d neurons done.\n', u, nN); + end + + end % neuron loop + + case "rectGrid" + % ------------------------------------------------- + % RG: single RF per neuron (9×9 resolution). + % Use the downsampled gridSpikeRateSelected and + % gridShuffMean already computed above. + % Both are [nGrid, nGrid, nN, nSize, nLum]. + % ------------------------------------------------- + + for u = 1:nN + + % Extract the 9×9 RF at the requested condition + rfGrid = gridSpikeRateSelected(:, :, u, params.sizeIdx, params.lumIdx); + % Extract the 9×9 shuffle-mean at the same condition + rfGridShuff = gridShuffMean(:, :, u, params.sizeIdx, params.lumIdx); + % Subtract shuffle to isolate signal + rfSub = rfGrid - rfGridShuff; + + % Run the stripe detector on the 9×9 image + res = detectStripeRF(rfSub, ... + 'angleStep', params.stripeAngleStep, ... + 'nSurrogates', params.stripeNSurr, ... + 'alpha', params.stripeAlpha, ... + 'minStripeAngle', params.stripeMinAngle, ... + 'maxStripeAngle', params.stripeMaxAngle, ... + 'rngSeed', 42 + u); + + % Store results (RG has no direction dimension) + stripeIsAny(u) = res.isStripe; + stripeNPassDirs(u) = double(res.isStripe); % 0 or 1 + if res.isStripe + stripePassDirStr{u} = '1'; % single "direction" + end + stripeBestScore(u) = res.stripeScore; + stripeBestPval(u) = res.stripePval; + stripeBestAngle(u) = res.stripeAngle; + stripeBestDir(u) = NaN; % no direction dim for RG + + end % neuron loop + + end % switch stimType (stripe detection) + + % Summary statistics + nStripe = sum(stripeIsAny); + fprintf(' [%s] Stripe detection: %d/%d neurons classified as stripe RFs.\n', ... + stimType, nStripe, nN); + + end % if detectStripe + + % ---------------------------------------------------------- + % Compute spatial tuning indices per neuron, size, and lum + % ---------------------------------------------------------- + for si = 1:nSize + for li = 1:nLum + + % Flatten spatial dims: [nCells, nN] + rateFlat = reshape(gridSpikeRateSelected(:,:,:,si,li), [nCells, nN]); + rateFlatShuff = reshape(gridShuffMean(:,:,:,si,li), [nCells, nN]); + + L_amplitude_diff = zeros(nN, 1); + L_amplitude_ratio = zeros(nN, 1); + L_geometric = zeros(nN, 1); + L_combined = zeros(nN, 1); + + for u = 1:nN + + rateVec = rateFlat(:, u); + rateVecShuff = rateFlatShuff(:, u); + + % Threshold for top-percent most active grid cells + threshold = prctile(rateVec, 100 - params.topPercent); + thresholdShuff = prctile(rateVecShuff, 100 - params.topPercent); + + topIdx = find(rateVec >= threshold); + topIdxShuff = find(rateVecShuff >= thresholdShuff); + restIdx = setdiff(1:nCells, topIdx); + restIdxShuff = setdiff(1:nCells, topIdxShuff); + + % Mean rates in top and rest regions. + % Guard against empty restIdx (all cells above threshold + % when topPercent is large) — set meanRest=0 in that case + meanTop = mean(rateVec(topIdx)); + meanAll = mean(rateVec); + if isempty(restIdx) + meanRest = 0; + else + meanRest = mean(rateVec(restIdx)); + end + + meanTopShuff = mean(rateVecShuff(topIdxShuff)); + meanAllShuff = mean(rateVecShuff); + if isempty(restIdxShuff) + meanRestShuff = 0; + else + meanRestShuff = mean(rateVecShuff(restIdxShuff)); + end + + % Guard against division by zero in normalisation + if meanAll == 0, meanAll = eps; end + if meanAllShuff == 0, meanAllShuff = eps; end + + % L_amplitude_diff: normalised contrast, shuffle-subtracted + L_amplitude_diff(u) = ... + (meanTop - meanRest) / meanAll - ... + (meanTopShuff - meanRestShuff) / meanAllShuff; + + % L_amplitude_ratio: real contrast divided by shuffle contrast + shuffleNorm = (meanTopShuff - meanRestShuff) / meanAllShuff; + if shuffleNorm == 0, shuffleNorm = eps; end + L_amplitude_ratio(u) = ((meanTop - meanRest) / meanAll) / shuffleNorm; + + % L_geometric: clustering of top cells (low spread = high tuning) + [rowIdx, colIdx] = ind2sub([nGrid nGrid], topIdx); + [rowIdxShuff, colIdxShuff] = ind2sub([nGrid nGrid], topIdxShuff); %#ok + + if size(rowIdx, 1) > 1 + D = mean(pdist([rowIdx, colIdx], 'euclidean')) / maxDist; + else + D = 0; + end + if size(rowIdxShuff, 1) > 1 + DShuff = mean(pdist([rowIdxShuff, colIdxShuff], 'euclidean')) / maxDist; + else + DShuff = 0; + end + + % Shuffle-corrected geometric index + L_geometric(u) = (1 - D) - (1 - DShuff); + + % L_combined: product of amplitude and geometric indices + L_combined(u) = L_amplitude_diff(u) * L_geometric(u); + + end % neuron loop + + % Check for NaN indices and report for debugging + nanMask = isnan(L_amplitude_diff) | isnan(L_amplitude_ratio) | ... + isnan(L_geometric) | isnan(L_combined); + if any(nanMask) + fprintf(' WARNING: %d/%d neurons have NaN index values [stim=%s, si=%d, li=%d]\n', ... + sum(nanMask), nN, char(stimType), si, li); + end + + % Build one table row per neuron for this condition + rows = table(); + rows.L_amplitude_diff = L_amplitude_diff; + rows.L_amplitude_ratio = L_amplitude_ratio; + rows.L_geometric = L_geometric; + rows.L_combined = L_combined; + rows.stimulus = categorical(repmat({char(stimType)}, nN, 1)); + rows.experimentNum = categorical(repmat(ex, nN, 1)); + rows.animal = categorical(repmat({animalName}, nN, 1)); + rows.NeurID = (1:nN)'; + % phyID: sharedPhyIDs is stim-specific when allResponsive=true + rows.phyID = sharedPhyIDs(:); + rows.onOff = repmat(params.onOff, nN, 1); + rows.sizeIdx = repmat(si, nN, 1); + rows.lumIdx = repmat(li, nN, 1); + + % Preferred-direction index into the direction dimension of + % RFuSTDirSizeLum. NaN for rectGrid or when prefDir=false. + rows.prefDirIdx = storePrefDirIdx; + % Preferred direction in degrees (0–360). NaN when not applicable. + rows.prefDirDeg = storePrefDirDeg; + + % Stripe detection columns. + % If detectStripe=false these vectors are pre-filled + % with their default values (false / 0 / '' / NaN), + % so the columns exist even when detection is skipped. + rows.isStripe = stripeIsAny; % any direction passes? + rows.stripeNPassDirs = stripeNPassDirs; % count of passing directions + rows.stripePassDirs = stripePassDirStr; % comma-sep dir indices (MB only) + rows.stripeBestScore = stripeBestScore; % max stripe score across dirs + rows.stripeBestPval = stripeBestPval; % p-value at best direction + rows.stripeBestAngle = stripeBestAngle; % stripe orientation (degrees) + rows.stripeBestDir = stripeBestDir; % best direction index (MB only) + + tbl = [tbl; rows]; %#ok + + end % lum loop + end % size loop + + fprintf(' [%s] Indices computed. %d neurons.\n', stimType, nN); + + end % stim loop + end % exp loop + + % Remove unused categorical levels introduced by partial data + tbl.stimulus = removecats(tbl.stimulus); + tbl.animal = removecats(tbl.animal); + tbl.experimentNum = removecats(tbl.experimentNum); + + % Cache results to disk + S.expList = exList; + S.tbl = tbl; + S.params = params; + save([saveDir nameOfFile], '-struct', 'S'); + fprintf('\nSaved to:\n %s\n', [saveDir nameOfFile]); + +end % compute block + +% ========================================================================= +% STRIPE DETECTION TEST VISUALISATION (larger fonts) +% +% Drop-in replacement for the block in SpatialTuningIndex.m that starts: +% if params.detectStripe && params.testStripeDetection +% ...and ends: +% end % testStripeDetection +% +% Changes from the previous version: every label, axis tick, and title is +% larger; row height bumped from 3.5 cm to 5 cm so the bigger text fits. +% ========================================================================= + + +if params.detectStripe && params.testStripeDetection + + fprintf('\n=== Stripe Detection Test Visualisation ===\n'); + + % ---- Filter table to the requested condition -------------------- + idxCondTest = tbl.onOff == params.onOff & ... + tbl.sizeIdx == params.sizeIdx & ... + tbl.lumIdx == params.lumIdx; + tblTest = tbl(idxCondTest, :); + tblTest.value = tblTest.(params.indexType); + + % ---- Split into stripe and non-stripe --------------------------- + stripeRows = tblTest(tblTest.isStripe == true, :); + nonStripeRows = tblTest(tblTest.isStripe == false, :); + + fprintf(' Total stripe: %d, non-stripe: %d\n', ... + height(stripeRows), height(nonStripeRows)); + + % ---- Subsample: half pass, half fail ---------------------------- + nHalf = floor(params.testStripeN / 2); + nPass = min(nHalf, height(stripeRows)); + nFail = min(nHalf, height(nonStripeRows)); + nSample = nPass + nFail; + + rng(99, 'twister'); + if nPass > 0 + passIdx = randperm(height(stripeRows), nPass); + samplePass = stripeRows(passIdx, :); + else + samplePass = stripeRows([], :); + end + if nFail > 0 + failIdx = randperm(height(nonStripeRows), nFail); + sampleFail = nonStripeRows(failIdx, :); + else + sampleFail = nonStripeRows([], :); + end + + tblSample = [samplePass; sampleFail]; + + if nSample == 0 + fprintf(' No neurons to visualise — skipping test plot.\n'); + else + + fprintf(' Visualising %d pass + %d fail = %d neurons.\n', ... + nPass, nFail, nSample); + + % ---- Figure layout ------------------------------------------ + % 4 tiles per row = 2 neurons (RF + profile each). Row height + % bumped to 5 cm so the bigger fonts have room to breathe. + nTestCols = 4; + nTestRows = ceil(nSample / 2); + figTest = figure('Visible', 'on', ... + 'Units', 'centimeters', ... + 'Position', [2 2 28 nTestRows * 5]); + + tlTest = tiledlayout(nTestRows, nTestCols, ... + 'TileSpacing', 'compact', ... + 'Padding', 'compact'); + + % Main figure title — large and bold + title(tlTest, 'Stripe Detection Test', ... + 'FontSize', 14, 'FontName', 'Helvetica', 'FontWeight', 'bold'); + + % Experiment cache for loading RF data (reuse if available) + if ~exist('cachedExp', 'var') || ~isa(cachedExp, 'containers.Map') + cachedExp = containers.Map('KeyType', 'double', 'ValueType', 'any'); + end + + for ti = 1:nSample + + % --- Metadata for this neuron --- + phyID_t = tblSample.phyID(ti); + ex_t = str2double(string(tblSample.experimentNum(ti))); + stimType_t = char(string(tblSample.stimulus(ti))); + isStr_t = tblSample.isStripe(ti); + score_t = tblSample.stripeBestScore(ti); + pval_t = tblSample.stripeBestPval(ti); + angle_t = tblSample.stripeBestAngle(ti); + + % --- Load experiment data (with caching) ----------------- + if ~cachedExp.isKey(ex_t) + NP_t = loadNPclassFromTable(ex_t); + obj_lmb = linearlyMovingBallAnalysis(NP_t); + p_s_t = NP_t.convertPhySorting2tIc(obj_lmb.spikeSortingFolder); + phy_IDg_t = p_s_t.phy_ID(string(p_s_t.label') == 'good'); + + S_rf_lmb_t = obj_lmb.CalculateReceptiveFields; + try + obj_rg_t = rectGridAnalysis(NP_t); + S_rf_rg_t = obj_rg_t.CalculateReceptiveFields; + catch + S_rf_rg_t = []; + end + + rfSpeed_t = S_rf_lmb_t.params.speed; + rfField_t = sprintf('Speed%d', rfSpeed_t); + NR_t = obj_lmb.ResponseWindow; + NV_t = NR_t.(rfField_t).NeuronVals; + dL_t = NV_t(:,:,6); + sR_t = NV_t(:,:,1); + uD_t = unique(dL_t(1,:)); + nGU_t = size(NV_t, 1); + mRPD_t = zeros(nGU_t, numel(uD_t)); + for dd = 1:numel(uD_t) + mRPD_t(:,dd) = max(sR_t(:, dL_t(1,:)==uD_t(dd)), [], 2); + end + [~, pDI_t] = max(mRPD_t, [], 2); + + cachedExp(ex_t) = struct( ... + 'S_rf_lmb', S_rf_lmb_t, ... + 'S_rf_rg', {S_rf_rg_t}, ... + 'phy_IDg', phy_IDg_t, ... + 'prefDirIdxAll', pDI_t); + end + cached_t = cachedExp(ex_t); + + [~, nIdx_t] = ismember(phyID_t, cached_t.phy_IDg); + if nIdx_t == 0, continue; end + + % --- Extract the RF slice for visualisation -------------- + switch stimType_t + case 'linearlyMovingBall' + bestDir_t = tblSample.stripeBestDir(ti); + if isnan(bestDir_t) + bestDir_t = cached_t.prefDirIdxAll(nIdx_t); + end + rfSlice_t = squeeze( ... + cached_t.S_rf_lmb.RFuSTDirSizeLum( ... + bestDir_t, params.sizeIdx, params.lumIdx, :, :, nIdx_t)); + rfShuff_t = cached_t.S_rf_lmb.RFuShuffST(:, :, nIdx_t); + rfSub_t = rfSlice_t - rfShuff_t; + + case 'rectGrid' + if ~isempty(cached_t.S_rf_rg) + rfFull_t = squeeze(cached_t.S_rf_rg.RFu( ... + params.onOff, params.lumIdx, params.sizeIdx, :, :, nIdx_t)); + rfSFull_t = squeeze(cached_t.S_rf_rg.RFuShuffMean( ... + params.onOff, params.lumIdx, params.sizeIdx, :, :, nIdx_t)); + rfSub_t = rfFull_t - rfSFull_t; + else + rfSub_t = zeros(9); + end + + otherwise + rfSub_t = zeros(9); + end + + % --- Run stripe detection to get the Radon profile ------- + resTest = detectStripeRF(rfSub_t, ... + 'angleStep', params.stripeAngleStep, ... + 'nSurrogates', 200, ... + 'alpha', params.stripeAlpha, ... + 'rngSeed', 42 + ti); + + % ========================================================= + % LEFT TILE: RF image + % ========================================================= + nexttile; + imagesc(rfSub_t); + axis equal tight off; + maxAbs_t = max(abs(rfSub_t(:))); + if maxAbs_t > 0, clim([-maxAbs_t maxAbs_t]); end + + % Title: colour-coded by classification — BIG FONT + if isStr_t + titleColor = [0.8 0 0]; + passLabel = 'STRIPE'; + else + titleColor = [0 0.5 0]; + passLabel = 'clean'; + end + title(sprintf('phy%d %s S=%.1f p=%.3f', ... + phyID_t, passLabel, score_t, pval_t), ... + 'FontSize', 11, ... % bumped from 10 + 'FontName', 'Helvetica', ... + 'FontWeight', 'bold', ... % new — emphasise classification + 'Color', titleColor); + + % Overlay stripe angle line if detected + if isStr_t && ~isnan(angle_t) + hold on; + cx = size(rfSub_t, 2) / 2; + cy = size(rfSub_t, 1) / 2; + lineLen = max(size(rfSub_t)) * 0.4; + dx = lineLen * cosd(angle_t); + dy = -lineLen * sind(angle_t); + plot([cx-dx, cx+dx], [cy-dy, cy+dy], ... + 'r-', 'LineWidth', 2); % thicker line + hold off; + end + + % Colourbar with readable tick labels + cb = colorbar; + cb.FontSize = 8; % readable + cb.TickDirection = 'out'; + cb.Ticks = linspace(cb.Limits(1), cb.Limits(2), 3); + + % ========================================================= + % RIGHT TILE: Radon variance profile + % ========================================================= + nexttile; + bar(resTest.angles, resTest.varProfile, 1, ... + 'FaceColor', [0.4 0.6 0.8], ... + 'EdgeColor', 'none'); + hold on; + + % Reference line at the mean projection variance + meanVarAll = mean(resTest.varProfile); + yline(meanVarAll, '--', ... + 'Color', [0.5 0.5 0.5], ... + 'LineWidth', 1, ... % bumped from 0.5 + 'Label', 'mean', ... + 'FontSize', 9, ... % new — readable label + 'LabelHorizontalAlignment', 'left'); + + % Shade the diagonal-acceptance windows (45° ± 30°, 135° ± 30°) + % so it's visually obvious why some peaks pass and others fail. + yl = ylim; + + % Shade the accepted projection-angle window for BL→TR + % stripes with 3° margin from horizontal and vertical. + + projLo = mod(params.stripeMinAngle - 90, 180); % 20° -> 110° + projHi = mod(params.stripeMaxAngle - 90, 180); % 60° -> 150° + patch([projLo projHi projHi projLo], [yl(1) yl(1) yl(2) yl(2)], ... + [0.9 0.95 0.7], 'EdgeColor', 'none', 'FaceAlpha', 0.3); + + % Re-draw bars on top of the patches + bar(resTest.angles, resTest.varProfile, 1, ... + 'FaceColor', [0.4 0.6 0.8], 'EdgeColor', 'none'); + + % Axis labels — BIG FONT + xlabel('Radon projection angle (°)', 'FontSize', 10); + ylabel('projection variance', 'FontSize', 10); + + % Title: stim type, best direction, stripe orientation + title(sprintf('%s dir=%s ang=%.0f°', ... + stimType_t(1:min(2,end)), ... + num2str(tblSample.stripeBestDir(ti)), ... + angle_t), ... + 'FontSize', 11, ... % bumped from 5 + 'FontName', 'Helvetica'); + + % Axis tick labels — BIG FONT + set(gca, 'FontSize', 9); % bumped from 4 + + xlim([0 180]); + hold off; + + end % test neuron loop + + % Save test figure + testPdfPath = fullfile(saveDir, sprintf('StripeTest_%s.pdf', params.indexType)); + exportgraphics(figTest, testPdfPath, 'ContentType', 'vector'); + fprintf(' Stripe test figure saved to:\n %s\n', testPdfPath); + + end % nSample > 0 + +end %closes: if params.detectStripe && params.testStripeDetection + + + +results.tbl = tbl; + +% ========================================================================= +% TOP UNIT TABLES +% Top 20% of neurons globally by params.indexType, for each stim type +% separately. Uses the same condition filter as the plot (onOff/sizeIdx/lumIdx). +% ========================================================================= + +idxCond = tbl.onOff == params.onOff & ... + tbl.sizeIdx == params.sizeIdx & ... + tbl.lumIdx == params.lumIdx; +tblCond = tbl(idxCond, :); +tblCond.value = tblCond.(params.indexType); + +sbLabel = 'rectGrid'; +mbLabel = 'linearlyMovingBall'; + +for tt = 1:2 + if tt == 1 + stimLabel = sbLabel; outField = 'topUnitsSB'; + else + stimLabel = mbLabel; outField = 'topUnitsMB'; + end + + if ~any(tblCond.stimulus == stimLabel) + fprintf(' No data for %s — skipping top unit table.\n', stimLabel); + results.(outField) = table(); + continue + end + + tblStim = tblCond(tblCond.stimulus == stimLabel, :); + globalThreshold = prctile(tblStim.value, 80); + topMask = tblStim.value >= globalThreshold; + tblTop = sortrows(tblStim(topMask, :), 'value', 'descend'); + + outTbl = table(); + outTbl.animal = tblTop.animal; + outTbl.experimentNum = tblTop.experimentNum; + outTbl.phyID = tblTop.phyID; + outTbl.indexValue = tblTop.value; + + fprintf(' [%s] %d top units (top 20%%, threshold=%.4f).\n', ... + stimLabel, height(outTbl), globalThreshold); + results.(outField) = outTbl; +end + +% ========================================================================= +% PLOT +% ========================================================================= +if params.plot + + % Filter table to the requested on/off, size, and lum condition + idx = tbl.onOff == params.onOff & ... + tbl.sizeIdx == params.sizeIdx & ... + tbl.lumIdx == params.lumIdx; + tblPlot = tbl(idx, :); + tblPlot.value = tblPlot.(params.indexType); + tblPlot.insertion = tblPlot.experimentNum; % rename for plotting compatibility + + pairs = {char(params.stimTypes(1)), char(params.stimTypes(2))}; + + % ---------------------------------------------------------- + % Compute p-values: + % allResponsive=false: paired hierBoot on per-neuron differences + % allResponsive=true: two-sample hierBoot on each group separately + % ---------------------------------------------------------- + ps = zeros(size(pairs, 1), 1); + j = 1; + + for i = 1:size(pairs, 1) + + if ~params.allResponsive + % --------------------------------------------------------- + % Paired mode: per-neuron differences within each insertion, + % then single hierBoot on the difference vector + % --------------------------------------------------------- + diffs = []; + insers = []; + animals = []; + + for ins = unique(tblPlot.insertion)' + idx1 = tblPlot.insertion == categorical(ins) & tblPlot.stimulus == pairs{i,1}; + idx2 = tblPlot.insertion == categorical(ins) & tblPlot.stimulus == pairs{i,2}; + + V1 = tblPlot.value(idx1); + V2 = tblPlot.value(idx2); + if isempty(V1) || isempty(V2), continue; end + + animal = unique(tblPlot.animal(idx1)); + diffs = [diffs; V1 - V2]; %#ok + insers = [insers; double(repmat(ins, size(V1,1), 1))]; %#ok + animals = [animals; double(repmat(animal, size(V1,1), 1))]; %#ok + end + + if isempty(diffs) + ps(j) = NaN; + else + % hierBoot on the paired difference, respecting nesting + bootDiff = hierBootMatchFreq(diffs, params.nBoot, animals,insers); + ps(j) = mean(bootDiff <= 0); % P(stim1 <= stim2) + end + + else + % --------------------------------------------------------- + % Unpaired (two-sample) mode: + % Bootstrap each group separately, then compare distributions. + % Nesting: neurons within insertions within animals. + % --------------------------------------------------------- + mask1 = tblPlot.stimulus == pairs{i,1}; + mask2 = tblPlot.stimulus == pairs{i,2}; + + V1 = tblPlot.value(mask1); + insers1 = double(tblPlot.insertion(mask1)); + anim1 = double(tblPlot.animal(mask1)); + + V2 = tblPlot.value(mask2); + insers2 = double(tblPlot.insertion(mask2)); + anim2 = double(tblPlot.animal(mask2)); + + % Remove NaNs from each group independently + valid1 = ~isnan(V1); + valid2 = ~isnan(V2); + + if sum(valid1) < 3 || sum(valid2) < 3 + ps(j) = NaN; + fprintf(' Not enough valid values for two-sample bootstrap (pair %d).\n', i); + else + % hierBoot on each group separately — same nesting structure + % as the paired case but applied independently per group + BootFirst = hierBootMatchFreq(V1(valid1), params.nBoot, anim1(valid1),insers1(valid1)); + BootSec = hierBootMatchFreq(V2(valid2), params.nBoot, anim2(valid2), insers2(valid2)); + + % One-tailed p: P(group2 >= group1), i.e. P(stim2 >= stim1) + ps(j) = mean(BootSec >= BootFirst); + end + end + + j = j + 1; + end + + % ---------------------------------------------------------- + % Plot swarm with bootstrap confidence intervals + % ---------------------------------------------------------- + V1max = max(tblPlot.value, [], 'omitnan'); + + fprintf('Length of ps: %d\n', numel(ps)); + fprintf('Size of pairs: %s\n', num2str(size(pairs))); + + % In allResponsive mode: suppress diff plot and inter-neuron lines + % (neurons are unpaired so neither makes biological sense) + useDiff = false; % diff is never shown directly here — handled inside plotSwarm + useBoth = ~params.allResponsive; + + if isequal(pairs{1},'linearlyMovingBall'), pairs{1} = 'MB'; end + + if isequal(pairs{2},'rectGrid'), pairs{2} = 'SB'; end + + tblPlot.stimulus(tblPlot.stimulus == "linearlyMovingBall") = "MB"; + tblPlot.stimulus(tblPlot.stimulus == "rectGrid") = "SB"; + + [fig, ~] = plotSwarmBootstrapWithComparisons(tblPlot, pairs, ps, {'value'}, ... + yLegend = params.yLegend, ... + yMaxVis = max(params.yMaxVis, V1max), ... + diff = useDiff, ... + Alpha = params.Alpha, ... + plotMeanSem = true, ... + drawLines = false, ... + showBothAndDiff = useBoth); + + % title(sprintf('%s — %s (onOff=%d, size=%d, lum=%d, RF=%d, prefDir=%d, allResp=%d, union=%d)', ... + % params.indexType, strjoin(params.stimTypes, '/'), ... + % params.onOff, params.sizeIdx, params.lumIdx, ... + % params.useRF, params.prefDir, params.allResponsive, params.unionResponsive), ... + % 'FontSize', 9); + + ax = gca; + ax.YAxis.FontSize = 8; ax.YAxis.FontName = 'helvetica'; + ax.XAxis.FontSize = 8; ax.XAxis.FontName = 'helvetica'; + set(fig, 'Units', 'centimeters', 'Position', [20 20 5 4]); + + if params.PaperFig + vs_first.printFig(fig, sprintf('SpatialTuningIndex-%s-%s-RF%d-prefDir%d-allResp%d', ... + params.indexType, strjoin(params.stimTypes, '-'), ... + params.useRF, params.prefDir, params.allResponsive), ... + PaperFig = params.PaperFig); + end + + results.fig = fig; + results.ps = ps; + +end + +% ========================================================================= +% RECEPTIVE FIELD PAGES (multi-page PDF) +% +% Layout: One page per animal. Within each animal, neurons are grouped +% by insertion (experimentNum). Each insertion group begins with +% a labelled header tile so the reader knows which recording the +% following RFs come from. Neurons within an insertion are sorted +% by descending tuning index. +% +% Standard mode (plotRFunion=false): +% One PDF per stim type. Each tile shows one neuron's RF. +% Grid: nCols_rf=5 × nRows_rf=8 (40 tiles/page). +% +% Union mode (plotRFunion=true): +% Single PDF. Each neuron occupies TWO adjacent tiles: MB on the left, +% SB (rectGrid) on the right. Tiles are arranged in "pair columns". +% Grid: nPairCols=3 → nCols_rf=6 × nRows_rf=7 (21 neuron-pairs/page). +% Neurons not responsive to one stim type show "n/r" in that tile. +% ========================================================================= +if params.plotRFs + + % prefDir is required for linearlyMovingBall because without it + % the RF slice to display is ambiguous (which direction to show?) + if ~params.prefDir + fprintf(['plotRFs requires prefDir=true for linearlyMovingBall.\n' ... + 'Skipping RF page generation.\n']); + else + + % ----------------------------------------------------------------- + % Tile layout constants + % ----------------------------------------------------------------- + if params.plotRFunion + nPairCols = 3; % neuron-pair columns per row (each pair = MB + SB) + nCols_rf = nPairCols * 2; % total tile columns (6) + nRows_rf = 7; % tile rows per page + else + nCols_rf = 5; % tile columns per page (standard mode) + nRows_rf = 8; % tile rows per page (standard mode) + end + tilesPerPage = nCols_rf * nRows_rf; % total tiles that fit on one A4 page + + % Width of one "neuron item" in tile units: + % union → 2 tiles (MB + SB side by side); standard → 1 tile + neuronWidth = 1 + params.plotRFunion; + + % ----------------------------------------------------------------- + % Inline ternary helper for compact annotation formatting: + % returns trueStr when cond is true, falseStr otherwise. + % Used for "n/r" vs numeric value in tile titles. + % ----------------------------------------------------------------- + ternaryStr = @(cond, trueStr, falseStr) ... + subsref({falseStr; trueStr}, substruct('{}', {cond + 1})); + + % ----------------------------------------------------------------- + % Filter the full results table to the requested condition + % (onOff, sizeIdx, lumIdx) — same filter used for the swarm plot. + % ----------------------------------------------------------------- + idxCond_rf = tbl.onOff == params.onOff & ... + tbl.sizeIdx == params.sizeIdx & ... + tbl.lumIdx == params.lumIdx; + tblCondRF = tbl(idxCond_rf, :); % rows matching condition + tblCondRF.value = tblCondRF.(params.indexType); % copy chosen index into 'value' + + % ----------------------------------------------------------------- + % Build neuron list for union mode + % ----------------------------------------------------------------- + if params.plotRFunion + + % Separate rows by stim type for per-stim lookup + mbRows = tblCondRF(tblCondRF.stimulus == "linearlyMovingBall", :); + sbRows = tblCondRF(tblCondRF.stimulus == "rectGrid", :); + + % Extract unique (animal, experimentNum, phyID) keys from each + % stim type. Including 'animal' in the key prevents accidental + % merging if two animals ever share an experimentNum. + keysMB = mbRows(:, {'animal', 'experimentNum', 'phyID'}); + keysSB = sbRows(:, {'animal', 'experimentNum', 'phyID'}); + allKeys = unique([keysMB; keysSB], 'rows'); % union of both key sets + nUnion = height(allKeys); % total unique neurons + + % Pre-allocate per-stim-type value columns and pref-dir metadata + allKeys.valueMB = nan(nUnion, 1); % MB tuning index (NaN = not responsive) + allKeys.valueSB = nan(nUnion, 1); % SB tuning index (NaN = not responsive) + allKeys.prefDirIdx = nan(nUnion, 1); % preferred direction index (MB only) + allKeys.prefDirDeg = nan(nUnion, 1); % preferred direction in degrees (MB only) + allKeys.isStripeMB = false(nUnion, 1); + allKeys.isStripeSB = false(nUnion, 1); + allKeys.stripeScoreMB = nan(nUnion, 1); + allKeys.stripeScoreSB = nan(nUnion, 1); + allKeys.stripeAngleMB = nan(nUnion, 1); + allKeys.stripeAngleSB = nan(nUnion, 1); + + + % Fill each neuron's stim-specific values by matching on + % (experimentNum, phyID). A neuron may appear in one or both + % stim types; missing entries stay NaN. + for ri = 1:nUnion + % Look up this neuron in the MB rows + mMatch = mbRows.experimentNum == allKeys.experimentNum(ri) & ... + mbRows.phyID == allKeys.phyID(ri); + if any(mMatch) + mIdx = find(mMatch, 1); % first (only) match + allKeys.valueMB(ri) = mbRows.value(mIdx); % MB tuning index + allKeys.prefDirIdx(ri) = mbRows.prefDirIdx(mIdx); + allKeys.prefDirDeg(ri) = mbRows.prefDirDeg(mIdx); + allKeys.isStripeMB(ri) = mbRows.isStripe(mIdx); + allKeys.stripeScoreMB(ri) = mbRows.stripeBestScore(mIdx); + allKeys.stripeAngleMB(ri) = mbRows.stripeBestAngle(mIdx); + end + + % Look up this neuron in the SB rows + sMatch = sbRows.experimentNum == allKeys.experimentNum(ri) & ... + sbRows.phyID == allKeys.phyID(ri); + if any(sMatch) + allKeys.valueSB(ri) = sbRows.value(find(sMatch, 1)); + allKeys.isStripeSB(ri) = sbRows.isStripe(find(sMatch,1)); + allKeys.stripeScoreSB(ri) = sbRows.stripeBestScore(find(sMatch,1)); + allKeys.stripeAngleSB(ri) = sbRows.stripeBestAngle(find(sMatch,1)); + end + end + + % Use MB tuning index as the primary sort key so neuron order + % is consistent between the MB and SB tiles. + allKeys.value = allKeys.valueMB; + + fprintf(' [plotRFunion] %d unique neurons in union set.\n', nUnion); + end + + % ----------------------------------------------------------------- + % Determine how many stim-type iterations the PDF loop needs: + % union → 1 pass (single PDF with paired tiles) + % standard → one pass per stim type (one PDF each) + % ----------------------------------------------------------------- + if params.plotRFunion + nStimLoop = 1; % single combined PDF + else + nStimLoop = numel(params.stimTypes); % one PDF per stim type + end + + % ----------------------------------------------------------------- + % Build filename tags that reflect the current mode + % ----------------------------------------------------------------- + if params.subtractShuffle + shuffTag = '_shuffSub'; % shuffle baseline subtracted + else + shuffTag = '_raw'; % raw RF plotted + end + if params.plotRFunion + unionTag = '_union'; % union neuron set + else + unionTag = ''; % per-stim neuron set + end + + % Experiment data cache: avoids reloading heavy RF data for each + % neuron. Key = experiment number (double), value = struct with + % RF data for both stim types plus preferred-direction metadata. + cachedExp = containers.Map('KeyType', 'double', 'ValueType', 'any'); + + % ================================================================= + % STIM-TYPE (OR UNION) LOOP — one iteration per output PDF + % ================================================================= + for ssLoop = 1:nStimLoop + + % Select the neuron table for this PDF + if params.plotRFunion + tblStim = allKeys; % pre-built union table + stimLabel_file = 'union'; % filename label + stimType_loop = "union"; % sentinel — not a real stim type + else + stimType_loop = params.stimTypes(ssLoop); % current stim type string + stimMask = tblCondRF.stimulus == char(stimType_loop); + tblStim = tblCondRF(stimMask, :); % rows for this stim type + stimLabel_file = char(stimType_loop); % filename label + end + + % Skip if no neurons for this stim type / union + if isempty(tblStim) + fprintf(' [plotRFs] No neurons for %s — skipping.\n', stimLabel_file); + continue + end + + % Build the output PDF path inside the combined-analysis directory + pdfName = sprintf('RFpages_%s_%s%s%s.pdf', ... + stimLabel_file, params.indexType, shuffTag, unionTag); + pdfPath = fullfile(saveDir, pdfName); % full path for the PDF file + + % Flag: true until the first page is exported (create vs append) + firstPdfPage = true; + + % Get unique animal names from this neuron set. + % removecats drops levels with zero rows; categories returns + % the remaining level names as a cell array of char vectors. + animals_rf = categories(removecats(tblStim.animal)); + + % ============================================================= + % ANIMAL LOOP — one or more pages per animal + % ============================================================= + for ai = 1:numel(animals_rf) + + animalCat = animals_rf{ai}; % current animal name (char) + + % Select rows belonging to this animal + animalRows = tblStim(tblStim.animal == animalCat, :); + + % Get unique insertions (experiments) for this animal, + % preserving their natural categorical order. + insertions_rf = categories(removecats(animalRows.experimentNum)); + + % Sort neurons within each insertion by descending tuning + % index, but keep insertion groups in their original order. + sortedAnimalRows = table(); % accumulator + for ii = 1:numel(insertions_rf) + % Extract rows for this insertion + insRows = animalRows(animalRows.experimentNum == insertions_rf{ii}, :); + % Sort descending by tuning index; NaN values go last + insRows = sortrows(insRows, 'value', 'descend', ... + 'MissingPlacement', 'last'); + % Append to the sorted accumulator + sortedAnimalRows = [sortedAnimalRows; insRows]; %#ok + end + animalRows = sortedAnimalRows; % overwrite with sorted version + + nNeuronsAnimal = height(animalRows); % neurons for this animal + if nNeuronsAnimal == 0, continue; end % skip empty animals + + % --------------------------------------------------------- + % Build a flat "slot" list for this animal. + % Each slot is either: + % 'header' — marks the start of a new insertion group + % 'neuron' — one RF tile (or pair of tiles in union mode) + % Headers always begin at column 1 of a fresh row. + % --------------------------------------------------------- + slots = struct('type', {}, 'expNum', {}, 'rowIdx', {}); + currentIns = ''; % track current insertion to detect boundaries + for ri = 1:nNeuronsAnimal + % Convert categorical experimentNum to char for comparison + thisIns = char(string(animalRows.experimentNum(ri))); + if ~strcmp(thisIns, currentIns) + % New insertion detected — insert a header slot + currentIns = thisIns; + slots(end+1) = struct('type', 'header', ... %#ok + 'expNum', thisIns, ... % insertion label + 'rowIdx', NaN); % not a neuron — no row index + end + % Append a neuron slot referencing this row of animalRows + slots(end+1) = struct('type', 'neuron', ... %#ok + 'expNum', thisIns, ... % for reference (not used in layout) + 'rowIdx', ri); % index into animalRows + end + + % --------------------------------------------------------- + % Assign tile positions and paginate. + % Walk through the slot list, tracking the current tile + % position (1-based linear index within a page) and the + % current column (1-based). When a header is encountered, + % jump to column 1 of the next row. When a page fills up, + % start a new page. + % --------------------------------------------------------- + assignments = struct('page',{}, 'tile',{}, 'type',{}, ... + 'expNum',{}, 'rowIdx',{}); % output: per-tile render plan + pg = 1; % current page number (1-based) + tilePos = 1; % current tile position within the page (1-based, linear) + col = 1; % current column within the page (1-based) + + for si = 1:numel(slots) + slot = slots(si); % current slot to place + + if strcmp(slot.type, 'header') + % --- Insertion header: must start at column 1 ---- + + % If not already at column 1, skip remaining tiles + % in the current row to reach the start of the next row + if col > 1 + tilePos = tilePos + (nCols_rf - col + 1); % advance to next row + col = 1; % reset column counter + end + + % Check whether we've exceeded the current page + if tilePos > tilesPerPage + pg = pg + 1; % move to a new page + tilePos = 1; % reset tile counter + col = 1; % reset column counter + end + + % Record this header's position in the render plan. + % In union mode the header will span 2 tile columns + % (one pair width) via nexttile([1,2]); in standard + % mode it occupies a single tile. + assignments(end+1) = struct( ... + 'page', pg, ... % page this header appears on + 'tile', tilePos, ... % tile position within the page + 'type', 'header', ... % slot type + 'expNum', slot.expNum, ... % insertion label to display + 'rowIdx', NaN); %#ok + + % Advance the tile counter past the header. + % neuronWidth is 2 for union (header spans a pair) or 1 for standard. + tilePos = tilePos + neuronWidth; + col = col + neuronWidth; + + else + % --- Neuron slot --------------------------------- + + % Check whether the neuron fits in the remaining + % columns of the current row. If not, jump to the + % next row. + if col + neuronWidth - 1 > nCols_rf + tilePos = tilePos + (nCols_rf - col + 1); % skip to next row + col = 1; % reset column + end + + % Check page overflow after the potential row skip + if tilePos + neuronWidth - 1 > tilesPerPage + pg = pg + 1; % new page + tilePos = 1; % reset tile counter + col = 1; % reset column counter + end + + % Record the neuron's position + assignments(end+1) = struct( ... + 'page', pg, ... % page number + 'tile', tilePos, ... % starting tile position + 'type', 'neuron', ... % slot type + 'expNum', slot.expNum, ... % insertion (for reference) + 'rowIdx', slot.rowIdx); %#ok index into animalRows + + % Advance the tile counter + tilePos = tilePos + neuronWidth; + col = col + neuronWidth; + end + + % Wrap column counter when a full row is consumed + if col > nCols_rf + col = 1; + end + end % slot loop + + totalPages = pg; % total pages needed for this animal + + % ========================================================== + % PAGE RENDERING LOOP + % ========================================================== + for pg = 1:totalPages + + % Extract the assignments that belong to this page + pgAssign = assignments([assignments.page] == pg); + + % Create an invisible figure sized to A4 portrait (21 × 29.7 cm) + fig_rf = figure('Visible', 'off', ... + 'Units', 'centimeters', ... + 'Position', [0 0 21 29.7]); + + % Tiled layout with compact spacing to maximise tile area + tl = tiledlayout(nRows_rf, nCols_rf, ... + 'TileSpacing', 'compact', ... + 'Padding', 'compact'); + + % Build a descriptive page-level title + if params.plotRFunion + pageLabel = sprintf('%s — MB vs SB (union)', animalCat); + else + pageLabel = sprintf('%s — %s', animalCat, char(stimType_loop)); + end + title(tl, sprintf('%s [%s, page %d/%d]', ... + pageLabel, params.indexType, pg, totalPages), ... + 'FontSize', 9, 'FontName', 'Helvetica'); + + % ---- Render each assigned tile on this page --------- + for ti = 1:numel(pgAssign) + + ta = pgAssign(ti); % current tile assignment + + % ===================================================== + % HEADER TILE + % ===================================================== + if strcmp(ta.type, 'header') + + % In union mode: header spans 2 columns (one pair width) + % so that it visually matches the neuron-pair width. + % In standard mode: header occupies one tile. + if params.plotRFunion + nexttile(ta.tile, [1, 2]); % span 1 row × 2 cols + else + nexttile(ta.tile); % single tile + end + + % Draw the insertion label centred in the tile + text(0.5, 0.5, ... + sprintf('Insertion %s', ta.expNum), ... + 'Units', 'normalized', ... % normalised so 0.5 = centre + 'HorizontalAlignment', 'center', ... % centre horizontally + 'VerticalAlignment', 'middle', ... % centre vertically + 'FontSize', 7, ... % legible but compact + 'FontWeight', 'bold', ... % bold for emphasis + 'FontName', 'Helvetica'); + axis off; % hide axes for a clean look + + % ===================================================== + % NEURON TILE(S) + % ===================================================== + else + + ri = ta.rowIdx; % row index into animalRows + phyID = animalRows.phyID(ri); % Phy cluster ID + ex = str2double(string(animalRows.experimentNum(ri))); % experiment number as double + + % ------------------------------------------------- + % Load and cache experiment data + % The cache stores heavy RF data structures so each + % experiment is read from disk only once. Both MB + % and RG RF data are loaded unconditionally because + % union mode needs both, and caching both is cheap + % compared to the disk read. + % ------------------------------------------------- + if ~cachedExp.isKey(ex) + + % Load the NeuropixelsClass object for this experiment + NP_tmp = loadNPclassFromTable(ex); + + % Build linearlyMovingBallAnalysis — always needed + % for preferred-direction computation and phy_IDg + obj_lmb = linearlyMovingBallAnalysis(NP_tmp); + % Convert phy sorting to get good-unit phy IDs + p_s_tmp = NP_tmp.convertPhySorting2tIc(obj_lmb.spikeSortingFolder); + % Extract phy IDs of units labelled 'good' + phy_IDg_tmp = p_s_tmp.phy_ID(string(p_s_tmp.label') == 'good'); + + % Load MB receptive field data (always needed for prefDir) + S_rf_lmb = obj_lmb.CalculateReceptiveFields; + + % Load RG receptive field data (needed for rectGrid + % tiles; wrapped in try-catch in case this experiment + % lacks a rectGrid recording) + try + obj_rg = rectGridAnalysis(NP_tmp); + S_rf_rg = obj_rg.CalculateReceptiveFields; + catch + S_rf_rg = []; % flag: no RG data for this experiment + end + + % ---- Compute preferred direction for ALL good units ---- + % Uses NeuronVals from the ResponseWindow at the speed + % used during RF computation. Identical logic to the + % main computation block above. + rfSpeed = S_rf_lmb.params.speed; % speed used in RF + rfField = sprintf('Speed%d', rfSpeed); % field name + NeuronResp = obj_lmb.ResponseWindow; % response data + NeuronVals = NeuronResp.(rfField).NeuronVals; % [nGU, nCond, nFeat] + + dirLabels = NeuronVals(:,:,6); % direction (radians) per condition + spikeRates = NeuronVals(:,:,1); % spike rate per condition + uDirsLocal = unique(dirLabels(1,:)); % sorted unique direction values + + % Max spike rate per direction per good unit (avoids + % dilution from other conditions sharing that direction) + nGU = size(NeuronVals, 1); % number of good units + maxRPD = zeros(nGU, numel(uDirsLocal)); % preallocate + for dd = 1:numel(uDirsLocal) + dMask = dirLabels(1,:) == uDirsLocal(dd); % conditions at this dir + maxRPD(:,dd) = max(spikeRates(:, dMask), [], 2); % max across conds + end + + % Preferred direction = direction with highest max spike rate + [~, prefDirIdxAll] = max(maxRPD, [], 2); % [nGU, 1] + prefDirDegAll = rad2deg(uDirsLocal(prefDirIdxAll))'; % [nGU, 1] degrees + + % Store everything in the cache + cachedExp(ex) = struct( ... + 'S_rf_lmb', S_rf_lmb, ... % MB RF data + 'S_rf_rg', {S_rf_rg}, ... % RG RF data (may be []) + 'phy_IDg', phy_IDg_tmp, ... % good-unit phy IDs + 'prefDirIdxAll', prefDirIdxAll, ... % pref dir index per good unit + 'prefDirDegAll', prefDirDegAll); % pref dir degrees per good unit + end + + % Retrieve cached data for this experiment + cached = cachedExp(ex); + + % Find this neuron's index within the good-unit phy ID array + [~, nIdx] = ismember(phyID, cached.phy_IDg); + + % Guard: if phyID is not found in this experiment, + % annotate the tile and skip to the next neuron + if nIdx == 0 + nexttile(ta.tile); % advance to the tile position + text(0.5, 0.5, sprintf('phy%d\nnot found', phyID), ... + 'Units', 'normalized', ... + 'HorizontalAlignment', 'center', ... + 'FontSize', 5); + axis off; + % In union mode, also blank the paired tile + if params.plotRFunion + nexttile(ta.tile + 1); + axis off; + end + continue % skip to next tile assignment + end + + % ---- Determine preferred direction for this neuron ---- + + % Read preferred-direction metadata from the neuron table + % (same column exists in both union and per-stim tables) + prefIdx = animalRows.prefDirIdx(ri); + prefDeg = animalRows.prefDirDeg(ri); + if isnan(prefIdx) + % Neuron was not MB-responsive — use cached pref dir + prefIdx = cached.prefDirIdxAll(nIdx); + prefDeg = cached.prefDirDegAll(nIdx); + end + + % ============================================= + % UNION MODE: paired MB + SB tiles + % ============================================= + if params.plotRFunion + + % Define the stim types and their labels for + % the left (MB) and right (SB) tiles + stimPair = ["linearlyMovingBall", "rectGrid"]; + tileLabel = ["MB", "SB"]; + + % Loop over the two paired tiles + for sp = 1:2 + nexttile(ta.tile + sp - 1); % left tile at ta.tile, right at ta.tile+1 + + rfSlice = []; % will hold the RF image + canPlot = true; % flag: set false if data unavailable + + % Extract the full-resolution RF slice for this stim type + switch stimPair(sp) + case "linearlyMovingBall" + try + % Slice at preferred direction, requested size & lum + % RFuSTDirSizeLum: [nDir, nSize, nLum, rfY, rfX, nN] + rfSlice = squeeze( ... + cached.S_rf_lmb.RFuSTDirSizeLum( ... + prefIdx, params.sizeIdx, params.lumIdx, :, :, nIdx)); + if params.subtractShuffle + % Subtract the per-neuron shuffle mean + rfShuff = cached.S_rf_lmb.RFuShuffST(:, :, nIdx); + rfSlice = rfSlice - rfShuff; + end + catch + canPlot = false; % data missing or dimension mismatch + end + + case "rectGrid" + if isempty(cached.S_rf_rg) + canPlot = false; % no rectGrid recording for this experiment + else + try + % Slice at requested onOff, lum, size + % RFu: [2(onOff), nLums, nSize, sR, sR, nN] + rfSlice = squeeze( ... + cached.S_rf_rg.RFu( ... + params.onOff, params.lumIdx, params.sizeIdx, :, :, nIdx)); + if params.subtractShuffle + % Subtract the per-neuron shuffle mean + rfShuff = squeeze( ... + cached.S_rf_rg.RFuShuffMean( ... + params.onOff, params.lumIdx, params.sizeIdx, :, :, nIdx)); + rfSlice = rfSlice - rfShuff; + end + catch + canPlot = false; % data missing or dimension mismatch + end + end + end + + % Render the RF or a placeholder + if canPlot && ~isempty(rfSlice) + imagesc(rfSlice); % display as colour image + axis equal tight off; % square pixels, no padding, no axes + if params.subtractShuffle + % Symmetric colour scale centred at zero so that + % positive (excitatory) and negative (suppressive) + % signals are equally visible + maxAbs = max(abs(rfSlice(:))); + if maxAbs > 0 + clim([-maxAbs maxAbs]); + end + end + % Compact colourbar showing min/zero/max + cb = colorbar; + cb.FontSize = 3; + cb.TickDirection = 'out'; + cb.Ticks = linspace(cb.Limits(1), cb.Limits(2), 3); + else + % No data available — show placeholder text + text(0.5, 0.5, 'n/a', ... + 'Units', 'normalized', ... + 'HorizontalAlignment', 'center', ... + 'FontSize', 6, 'Color', [0.5 0.5 0.5]); + axis off; + end + + % Tile title annotation + if sp == 1 + % Left tile (MB): show phyID, MB index, pref direction + mbVal = animalRows.valueMB(ri); % MB tuning index + valStr = ternaryStr(isnan(mbVal), 'n/r', sprintf('%.2f', mbVal)); + title(sprintf('phy%d MB:%s %d°', ... + phyID, valStr, round(prefDeg)), ... + 'FontSize', 4.5, 'FontName', 'Helvetica'); + else + % Right tile (SB): show SB index only (phyID already on left) + sbVal = animalRows.valueSB(ri); % SB tuning index + valStr = ternaryStr(isnan(sbVal), 'n/r', sprintf('%.2f', sbVal)); + title(sprintf('SB:%s', valStr), ... + 'FontSize', 4.5, 'FontName', 'Helvetica'); + end + + + % --- Stripe annotation (union mode) --- + % Check the stim-specific stripe flag + if sp == 1 && ismember('isStripeMB', animalRows.Properties.VariableNames) + isStr = animalRows.isStripeMB(ri); + sAng = animalRows.stripeAngleMB(ri); + elseif sp == 2 && ismember('isStripeSB', animalRows.Properties.VariableNames) + isStr = animalRows.isStripeSB(ri); + sAng = animalRows.stripeAngleSB(ri); + else + isStr = false; + sAng = NaN; + end + if isStr + hold on; + text(2, 3, 'S', 'Color', [1 0.2 0.2], ... + 'FontSize', 7, 'FontWeight', 'bold'); + if canPlot && ~isnan(sAng) + cx = size(rfSlice,2)/2; cy = size(rfSlice,1)/2; + ll = max(size(rfSlice))*0.35; + plot([cx-ll*cosd(sAng), cx+ll*cosd(sAng)], ... + [cy+ll*sind(sAng), cy-ll*sind(sAng)], ... + 'r-', 'LineWidth', 1.2); + end + hold off; + end + end % sp loop (MB / SB pair) + + % ============================================= + % STANDARD MODE: single RF tile + % ============================================= + else + + nexttile(ta.tile); % advance to the assigned tile + + % Extract the full-resolution RF slice for this stim type + switch stimType_loop + case "linearlyMovingBall" + % Slice at preferred direction, requested size & lum + rfSlice = squeeze( ... + cached.S_rf_lmb.RFuSTDirSizeLum( ... + prefIdx, params.sizeIdx, params.lumIdx, :, :, nIdx)); + if params.subtractShuffle + rfShuff = cached.S_rf_lmb.RFuShuffST(:, :, nIdx); + rfSlice = rfSlice - rfShuff; + end + + case "rectGrid" + rfSlice = squeeze( ... + cached.S_rf_rg.RFu( ... + params.onOff, params.lumIdx, params.sizeIdx, :, :, nIdx)); + if params.subtractShuffle + rfShuff = squeeze( ... + cached.S_rf_rg.RFuShuffMean( ... + params.onOff, params.lumIdx, params.sizeIdx, :, :, nIdx)); + rfSlice = rfSlice - rfShuff; + end + end + + imagesc(rfSlice); % display as colour image + axis equal tight off; % square pixels, no padding, no axes + + if params.subtractShuffle + maxAbs = max(abs(rfSlice(:))); + if maxAbs > 0 + clim([-maxAbs maxAbs]); % symmetric colour scale + end + end + + % Compact colourbar + cb = colorbar; + cb.FontSize = 3.5; + cb.TickDirection = 'out'; + cb.Ticks = linspace(cb.Limits(1), cb.Limits(2), 3); + + % Tile title: phyID, tuning index, and (for MB) pref direction + switch stimType_loop + case "linearlyMovingBall" + title(sprintf('phy%d | %.2f | %d°', ... + phyID, animalRows.value(ri), round(prefDeg)), ... + 'FontSize', 5, 'FontName', 'Helvetica'); + case "rectGrid" + title(sprintf('phy%d | %.2f', ... + phyID, animalRows.value(ri)), ... + 'FontSize', 5, 'FontName', 'Helvetica'); + end + + % --- Stripe annotation (standard mode) --- + if ismember('isStripe', animalRows.Properties.VariableNames) ... + && animalRows.isStripe(ri) + hold on; + text(2, 3, 'S', ... + 'Color', [1 0.2 0.2], ... + 'FontSize', 7, 'FontWeight', 'bold'); + sAng = animalRows.stripeBestAngle(ri); + if ~isnan(sAng) + cx = size(rfSlice,2)/2; cy = size(rfSlice,1)/2; + ll = max(size(rfSlice))*0.35; + plot([cx-ll*cosd(sAng), cx+ll*cosd(sAng)], ... + [cy+ll*sind(sAng), cy-ll*sind(sAng)], ... + 'r-', 'LineWidth', 1.2); + end + hold off; + end + + end % union vs standard rendering + + end % header vs neuron + + end % tile assignment loop (ti) + + % ----- Export this page to the multi-page PDF --------- + if firstPdfPage + % First page across all animals: create the PDF + exportgraphics(fig_rf, pdfPath, 'ContentType', 'vector'); + firstPdfPage = false; % subsequent pages will append + else + % Append to the existing PDF + exportgraphics(fig_rf, pdfPath, ... + 'ContentType', 'vector', 'Append', true); + end + + close(fig_rf); % free memory before the next page + + fprintf(' [plotRFs] %s — %s page %d/%d exported.\n', ... + animalCat, stimLabel_file, pg, totalPages); + + end % page loop (pg) + + end % animal loop (ai) + + fprintf(' [plotRFs] Saved %s to:\n %s\n', stimLabel_file, pdfPath); + + end % stim-type loop (ssLoop) + + end % prefDir guard + +end % plotRFs block + diff --git a/visualStimulationAnalysis/analyzeStripeNeurons.m b/visualStimulationAnalysis/analyzeStripeNeurons.m new file mode 100644 index 0000000..a92b8e2 --- /dev/null +++ b/visualStimulationAnalysis/analyzeStripeNeurons.m @@ -0,0 +1,974 @@ +function results = analyzeStripeNeurons(exList, params) +% analyzeStripeNeurons Compare stripe-classified neurons (MB vs SB) and +% look at their cortical depths. +% +% Loads the cached SpatialTuningIndex table for the given exList / +% parameter combination, joins it with cortical depths obtained via +% getNeuronDepths, and produces two figures: +% +% (1) Stripeness swarm plot: +% MB stripeBestScore vs SB stripeBestScore for the union of +% neurons classified as stripe in EITHER stim type. Paired +% difference column shown; p-value via hierBoot on the per-neuron +% differences (two-tailed). +% +% (2) Depth swarm plot: +% Four groups, side by side: +% MB stripe | SB stripe | MB resp (non-stripe) | SB resp (non-stripe) +% Bootstrap means and 95% CIs overlaid. Pairwise two-tailed +% hierBoot p-values reported for: +% MB stripe vs MB resp +% SB stripe vs SB resp +% MB stripe vs SB stripe +% +% PARAMETERS (must match the SpatialTuningIndex run that produced the cache) +% indexType, onOff, sizeIdx, lumIdx condition filter +% useRF, prefDir, allResponsive, unionResponsive filename-tag flags +% nBoot number of bootstrap resamples for hierBoot (10000) +% Alpha transparency for swarm dots (0.4) +% plot generate and save figures (true) +% saveDepthMap cache the (exp,phyID) → depth lookup in tbl (true) +% +% OUTPUTS (struct) +% stripeData long-format table fed to the swarm plot +% depthData long-format table for the depth plot +% pStripe paired-difference p-value (MB vs SB stripeness) +% pDepth struct of pairwise depth comparison p-values +% figSwarm handle to the stripeness swarm figure +% figDepth handle to the depth swarm figure +% joinedTbl full tbl with depth_um column appended (saved to disk) + +arguments + exList double + params.indexType string = "L_amplitude_diff" + params.onOff double = 1 + params.sizeIdx double = 1 + params.lumIdx double = 1 + params.useRF logical = true + params.prefDir logical = true + params.allResponsive logical = false + params.unionResponsive logical = false + params.nBoot double = 10000 + params.Alpha double = 0.4 + params.plot logical = true + params.plotLegend logical = false + params.PaperFig logical = false + params.zStim string = "linearlyMovingBall" % "linearlyMovingBall" | "rectGrid" | "both" + params.idxStim string = "linearlyMovingBall" % "linearlyMovingBall" | "rectGrid" | "both" +end + +% ========================================================================= +% 1. Locate and load the cached SpatialTuningIndex results +% ========================================================================= +NP_first = loadNPclassFromTable(exList(1)); +vs_first = linearlyMovingBallAnalysis(NP_first); +pBase = extractBefore(vs_first.getAnalysisFileName, 'lizards'); +saveDir = [pBase 'lizards\Combined_lizard_analysis']; + +stimTypes = ["linearlyMovingBall", "rectGrid"]; +stimLabel = strjoin(stimTypes, '-'); +rfLabel = ''; if params.useRF, rfLabel = '_RF'; end +prefLabel = ''; if params.prefDir, prefLabel = '_prefDir'; end +allRespLabel = ''; if params.allResponsive, allRespLabel = '_allResp'; end +unionLabel = ''; if params.unionResponsive, unionLabel = '_union'; end +nameOfFile = sprintf('\\Ex_%d-%d_SpatialTuningIndex_%s%s%s%s%s.mat', ... + exList(1), exList(end), stimLabel, rfLabel, prefLabel, allRespLabel, unionLabel); +fullPath = [saveDir nameOfFile]; + +if ~exist(fullPath, 'file') + error(['SpatialTuningIndex cache not found:\n %s\n' ... + 'Run SpatialTuningIndex with detectStripe=true and matching params first.'], ... + fullPath); +end +S = load(fullPath); +tbl = S.tbl; +fprintf('Loaded %s\n', fullPath); + +if ~ismember('isStripe', tbl.Properties.VariableNames) + error('Cached table lacks stripe columns. Re-run SpatialTuningIndex with detectStripe=true.'); +end + +% Apply the same condition filter that SpatialTuningIndex uses for plotting +idxCond = tbl.onOff == params.onOff & tbl.sizeIdx == params.sizeIdx & tbl.lumIdx == params.lumIdx; +tbl = tbl(idxCond, :); + +% ========================================================================= +% 2. Load (or compute) cortical depths and join into tbl +% ========================================================================= +depthFile = fullfile(saveDir, 'NeuronDepths.mat'); +if exist(depthFile, 'file') + depthRes = load(depthFile); + fprintf('Loaded cached depths from %s\n', depthFile); +else + fprintf('Cached depths not found — running getNeuronDepths...\n'); + depthRes = getNeuronDepths(exList); +end + +% Build (experiment, phyID) → depth lookup. +% depthRes.depthTable has Experiment + Unit (1:nGood index) + Depth_um. +% Unit index needs to be mapped to phyID via perExp.p_sort, since the +% SpatialTuningIndex table uses phyID as the neuron identifier. +depthMap = containers.Map('KeyType', 'char', 'ValueType', 'double'); +for ei = 1:numel(depthRes.perExp) + pe = depthRes.perExp(ei); + if isempty(pe.p_sort), continue; end + + lbl = string(pe.p_sort.label'); + phy_IDg = pe.p_sort.phy_ID(lbl == 'good'); + + expRows = depthRes.depthTable.Experiment == pe.exNum; + expDepths = depthRes.depthTable.Depth_um(expRows); + expUnits = depthRes.depthTable.Unit(expRows); + + for ui = 1:numel(expUnits) + if expUnits(ui) <= numel(phy_IDg) + depthMap(sprintf('%d_%d', pe.exNum, phy_IDg(expUnits(ui)))) = expDepths(ui); + end + end +end + +% Append depth_um column +tbl.depth_um = nan(height(tbl), 1); +for ri = 1:height(tbl) + ex_ri = str2double(string(tbl.experimentNum(ri))); + key = sprintf('%d_%d', ex_ri, tbl.phyID(ri)); + if depthMap.isKey(key) + tbl.depth_um(ri) = depthMap(key); + end +end +nDepthOK = sum(~isnan(tbl.depth_um)); +fprintf(' Depth assigned to %d/%d rows (%d missing).\n', ... + nDepthOK, height(tbl), height(tbl)-nDepthOK); + +% ========================================================================= +% 2b. Responsiveness z-score (pooled over all directions/conditions) +% Recomputed per experiment×stimulus, then joined by phyID. Must run +% before section 3 so the four group tables inherit the zResp column. +% ========================================================================= +zp.nBoot = params.nBoot; +zp.rngSeed = 42; +zp.baseRespWindow = 1000; % ms response window from onset (RG path) +zp.baselineBuffer = 200; % ms gap before onset for baseline +zp.movingWindow = 200; % ms sliding window for MB peak + +tbl.zResp = nan(height(tbl), 1); +zCache = containers.Map('KeyType','char','ValueType','any'); % key 'exp_stim' + +for ri = 1:height(tbl) + ex_ri = str2double(string(tbl.experimentNum(ri))); + stim_ri = string(tbl.stimulus(ri)); + key = sprintf('%d_%s', ex_ri, stim_ri); + + if ~zCache.isKey(key) + try + NP_ri = loadNPclassFromTable(ex_ri); + switch stim_ri + case "linearlyMovingBall", obj_ri = linearlyMovingBallAnalysis(NP_ri); + case "rectGrid", obj_ri = rectGridAnalysis(NP_ri); + otherwise, obj_ri = []; + end + if isempty(obj_ri) + zCache(key) = struct('z', [], 'phy', []); + else + [z_ri, phy_ri] = computeResponsivenessZ(obj_ri, zp); + zCache(key) = struct('z', z_ri, 'phy', phy_ri); + end + catch ME + warning('z-score failed for exp %d %s: %s', ex_ri, stim_ri, ME.message); + zCache(key) = struct('z', [], 'phy', []); + end + end + + zc = zCache(key); + if ~isempty(zc.phy) + idx = find(zc.phy == tbl.phyID(ri), 1); + if ~isempty(idx), tbl.zResp(ri) = zc.z(idx); end + end +end +fprintf(' z-score assigned to %d/%d rows.\n', sum(~isnan(tbl.zResp)), height(tbl)); + +% ========================================================================= +% 2c. Direction/orientation tuning (OSI, DSI) for MB neurons. +% From DirectionTuning, joined by phyID. rectGrid has no direction +% category, so SB rows stay NaN (OSI/DSI undefined there). +% ========================================================================= +tbl.OSI = nan(height(tbl), 1); +tbl.DSI = nan(height(tbl), 1); + +dtCache = containers.Map('KeyType','double','ValueType','any'); % key: experiment +for ri = 1:height(tbl) + if tbl.stimulus(ri) ~= "linearlyMovingBall", continue; end % MB only + ex_ri = str2double(string(tbl.experimentNum(ri))); + + if ~dtCache.isKey(ex_ri) + try + NP_ri = loadNPclassFromTable(ex_ri); + obj_ri = linearlyMovingBallAnalysis(NP_ri); + dt = DirectionTuning(obj_ri, 'save', true, 'overwrite', false); + dtCache(ex_ri) = struct('OSI', dt.OSI, 'DSI', dt.DSI, 'phyID', dt.phyID); + catch ME + warning('DirectionTuning failed for exp %d: %s', ex_ri, ME.message); + dtCache(ex_ri) = struct('OSI', [], 'DSI', [], 'phyID', []); + end + end + + dc = dtCache(ex_ri); + if ~isempty(dc.phyID) + idx = find(dc.phyID == tbl.phyID(ri), 1); + if ~isempty(idx) + tbl.OSI(ri) = dc.OSI(idx); + tbl.DSI(ri) = dc.DSI(idx); + end + end +end +fprintf(' OSI/DSI assigned to %d MB rows.\n', sum(~isnan(tbl.OSI))); + +% ========================================================================= +% 3. Split rows into the four groups of interest +% ========================================================================= +mbAll = tbl(tbl.stimulus == "linearlyMovingBall", :); +sbAll = tbl(tbl.stimulus == "rectGrid", :); +mbStripe = mbAll(mbAll.isStripe, :); +sbStripe = sbAll(sbAll.isStripe, :); +mbNonStr = mbAll(~mbAll.isStripe, :); +sbNonStr = sbAll(~sbAll.isStripe, :); + +fprintf('\nStripe summary (condition onOff=%d, size=%d, lum=%d):\n', ... + params.onOff, params.sizeIdx, params.lumIdx); +fprintf(' MB stripe: %4d / %4d responsive (%.1f%%)\n', ... + height(mbStripe), height(mbAll), 100*height(mbStripe)/max(1,height(mbAll))); +fprintf(' SB stripe: %4d / %4d responsive (%.1f%%)\n', ... + height(sbStripe), height(sbAll), 100*height(sbStripe)/max(1,height(sbAll))); + +% ========================================================================= +% 4. STRIPENESS SWARM — MB stripe vs SB stripe (two independent groups) +% Uses stripeBestScore of neurons that PASSED the stripe test only. +% With allResponsive=true the two pools are independent, so we use a +% two-sample hierBoot rather than a paired difference. +% ========================================================================= + +% Add value column pointing at stripeBestScore +mbStripe.value = mbStripe.stripeBestScore; +mbStripe.insertion = mbStripe.experimentNum; +sbStripe.value = sbStripe.stripeBestScore; +sbStripe.insertion = sbStripe.experimentNum; + +% Rename stimulus labels to short forms +mbStripe.stimulus(mbStripe.stimulus == "linearlyMovingBall") = "MB"; +sbStripe.stimulus(sbStripe.stimulus == "rectGrid") = "SB"; + +% Long-format table — two independent groups, no NaN rows +tblStripe = [mbStripe(:, {'value','stimulus','insertion','animal'}); ... + sbStripe(:, {'value','stimulus','insertion','animal'})]; +tblStripe.NeurID = (1:height(tblStripe))'; + + +% Two-sample hierBoot p-value +if height(mbStripe) >= 3 && height(sbStripe) >= 3 + bMB = hierBootMatchFreq(mbStripe.value, params.nBoot, ... + double(mbStripe.insertion), double(mbStripe.animal)); + bSB = hierBootMatchFreq(sbStripe.value, params.nBoot, ... + double(sbStripe.insertion), double(sbStripe.animal)); + pStripe = 2 * min(mean(bMB >= bSB), mean(bMB < bSB)); % two-tailed +else + pStripe = NaN; +end + +fprintf('\n Stripeness MB (n=%d) vs SB (n=%d): two-sample two-tailed p = %.4f\n', ... + height(mbStripe), height(sbStripe), pStripe); + +results.stripeData = tblStripe; +results.pStripe = pStripe; + +% --- Section 4 plot: stripeness swarm --- +if params.plot + yMaxVis = max(tblStripe.value, [], 'omitnan') * 1.15; % ensure all dots visible + pairs = {'MB','SB'}; + figSwarm = plotSwarmBootstrapWithComparisons(tblStripe, pairs, pStripe, {'value'}, ... + yLegend = 'Stripeness (S = max/min var)', ... + yMaxVis = yMaxVis, ... + diff = false, ... + showBothAndDiff = false, ... + Alpha = params.Alpha, ... + plotMeanSem = true, ... + drawLines = false); + + ax = gca; + ax.YAxis.FontSize = 8; ax.YAxis.FontName = 'helvetica'; + ax.XAxis.FontSize = 8; ax.XAxis.FontName = 'helvetica'; + set(figSwarm, 'Units', 'centimeters', 'Position', [10 10 5 5]); + + if params.PaperFig + vs_first.printFig(figSwarm, sprintf('StripeNeurons-swarm-%s', params.indexType), ... + PaperFig = params.PaperFig); + end + + results.figSwarm = figSwarm; +end + +% ========================================================================= +% 5. DEPTH PLOT (4 groups: stripe vs responsive non-stripe for MB and SB) +% ========================================================================= +groupOrder = {'MB stripe', 'SB stripe', 'MB resp', 'SB resp'}; +groupRows = {mbStripe, sbStripe, mbNonStr, sbNonStr}; + +% Build long-format depth table — one row per neuron per group +depthData = table([], categorical([]), categorical([]), categorical([]), [], ... + 'VariableNames', {'depth','group','insertion','animal','NeurID'}); +for gi = 1:numel(groupOrder) + rows = groupRows{gi}; + if isempty(rows), continue; end + keep = ~isnan(rows.depth_um); + if ~any(keep), continue; end + addTbl = table(rows.depth_um(keep), ... + categorical(repmat(groupOrder(gi), sum(keep), 1)), ... + rows.experimentNum(keep), ... + rows.animal(keep), ... + rows.phyID(keep), ... + 'VariableNames', {'depth','group','insertion','animal','NeurID'}); + depthData = [depthData; addTbl]; %#ok +end + +fprintf('\nDepth groups (rows with depth):\n'); +for gi = 1:numel(groupOrder) + fprintf(' %-10s n=%d\n', groupOrder{gi}, sum(depthData.group == groupOrder{gi})); +end + +results.depthData = depthData; + +% Pairwise hierBoot (two-tailed) — three comparisons of interest +results.pDepth.MBstripe_vs_MBresp = compareGroupsHB(depthData, 'depth', 'MB stripe', 'MB resp', params.nBoot); +results.pDepth.SBstripe_vs_SBresp = compareGroupsHB(depthData, 'depth', 'SB stripe', 'SB resp', params.nBoot); +results.pDepth.MBstripe_vs_SBstripe = compareGroupsHB(depthData, 'depth', 'MB stripe', 'SB stripe', params.nBoot); + +fprintf('\nDepth comparisons (two-tailed hierBoot p):\n'); +fprintf(' MB stripe vs MB resp : p = %.4f\n', results.pDepth.MBstripe_vs_MBresp); +fprintf(' SB stripe vs SB resp : p = %.4f\n', results.pDepth.SBstripe_vs_SBresp); +fprintf(' MB stripe vs SB stripe : p = %.4f\n', results.pDepth.MBstripe_vs_SBstripe); + +% --- Section 5 plot: depth swarm with significance brackets --- +if params.plot + figDepth = figure('Units','centimeters','Position',[5 5 14 8]); + hold on; + nG = numel(groupOrder); + + % Global animal → color mapping (consistent across all four groups) + allAnimalNames = categories(removecats(depthData.animal)); + nAnimals = numel(allAnimalNames); + animalCmap = lines(max(nAnimals, 1)); + + for gi = 1:nG + rows = depthData(depthData.group == groupOrder{gi}, :); + if isempty(rows), continue; end + + [~, animalIdx] = ismember(string(rows.animal), allAnimalNames); + dotColors = animalCmap(animalIdx, :); + + swarmchart(gi * ones(height(rows), 1), rows.depth, ... + 16, dotColors, 'filled', ... + 'XJitter', 'density', ... + 'XJitterWidth', 0.35, ... + 'MarkerFaceAlpha', 0.65, ... + 'MarkerEdgeColor', 'none'); + + try + bM = hierBootMatchFreq(rows.depth, params.nBoot, ... + double(rows.insertion), double(rows.animal)); + m = mean(bM); + ci = prctile(bM, [2.5 97.5]); + errorbar(gi, m, m-ci(1), ci(2)-m, 'k', ... + 'LineWidth', 1.5, 'CapSize', 8); + plot(gi, m, 'ko', 'MarkerFaceColor','w', ... + 'MarkerSize', 7, 'LineWidth', 1.5); + catch ME + warning('hierBoot failed for %s: %s', groupOrder{gi}, ME.message); + end + end + + % Significance brackets (reversed Y-axis: smaller depth = top of plot) + allDepths = depthData.depth(~isnan(depthData.depth)); + yMin = min(allDepths); + yMax = max(allDepths); + yRange = yMax - yMin; + bracketStep = yRange * 0.06; + tickH = yRange * 0.015; + + comps = { + 1, 2, results.pDepth.MBstripe_vs_SBstripe, 1; + 1, 3, results.pDepth.MBstripe_vs_MBresp, 2; + 2, 4, results.pDepth.SBstripe_vs_SBresp, 3; + }; + + % Significance brackets — only drawn when p < 0.05 + for ci = 1:size(comps, 1) + g1 = comps{ci,1}; g2 = comps{ci,2}; + pV = comps{ci,3}; lv = comps{ci,4}; + + if isnan(pV) || pV >= 0.05 + continue; % skip non-significant comparisons — no clutter + end + + yB = yMin - lv * bracketStep; + plot([g1 g2], [yB yB], 'k-', 'LineWidth', 1); + plot([g1 g1], [yB, yB+tickH], 'k-', 'LineWidth', 1); + plot([g2 g2], [yB, yB+tickH], 'k-', 'LineWidth', 1); + text((g1+g2)/2, yB+tickH*2.5, sigStars(pV), ... + 'HorizontalAlignment', 'center', ... + 'VerticalAlignment', 'bottom', ... + 'FontSize', 11, 'FontWeight', 'bold'); + end + + % Reserve vertical space only for the highest significant bracket level, + % plus one extra step of padding so the label isn't clipped at the edge. + maxSigLevel = 0; + for ci = 1:size(comps, 1) + if ~isnan(comps{ci,3}) && comps{ci,3} < 0.05 + maxSigLevel = max(maxSigLevel, comps{ci,4}); + end + end + % Extra 2 bracket-steps above the highest significant bracket so the + % bracket line itself and any tick are never at the very edge. + ylim([yMin - (maxSigLevel + 2) * bracketStep, yMax + bracketStep]); + + + % Animal legend + lgdH = gobjects(nAnimals, 1); + for ai = 1:nAnimals + lgdH(ai) = plot(nan, nan, 'o', ... + 'Color', animalCmap(ai,:), 'MarkerFaceColor', animalCmap(ai,:), ... + 'MarkerSize', 6, 'DisplayName', allAnimalNames{ai}); + end + + if params.plotLegend + legend(lgdH, 'Location','best', 'FontSize',7, 'Box','off'); + end + + set(gca, 'XTick',1:nG, 'XTickLabel',groupOrder, ... + 'YDir','reverse', 'XLim',[0.5 nG+0.5], ... + 'FontSize',8, 'FontName','helvetica'); + ylabel('Depth (\mum)', 'FontSize', 9); + xtickangle(20); + title('Depth: stripe vs responsive non-stripe', 'FontSize', 9); + box on; hold off; + + + if params.PaperFig + vs_first.printFig(figDepth, sprintf('StripeNeurons_depth_%s', params.indexType), ... + PaperFig = params.PaperFig); + end + + + results.figDepth = figDepth; +end + +% ========================================================================= +% 7. STRIPE DIRECTION DISTRIBUTION (MB only) — flat histogram +% stripeBestDir = index of the highest-SCORE passing direction. +% Converted to radians, then matched to the 4 cardinal ball directions. +% Convention: 0 = up, pi/2 = right, pi = down, 3*pi/2 = left. +% ========================================================================= +if ~isempty(mbStripe) && ismember('stripeBestDir', mbStripe.Properties.VariableNames) + + dirCache = containers.Map('KeyType','double','ValueType','any'); + stripeDirRad = nan(height(mbStripe), 1); + + for ri = 1:height(mbStripe) + ex_ri = str2double(string(mbStripe.experimentNum(ri))); + dirIdx = mbStripe.stripeBestDir(ri); + if isnan(dirIdx), continue; end + + if ~dirCache.isKey(ex_ri) + try + NP_ri = loadNPclassFromTable(ex_ri); + obj_ri = linearlyMovingBallAnalysis(NP_ri); + S_rf_ri = obj_ri.CalculateReceptiveFields; + rfSpeed = S_rf_ri.params.speed; + NR_ri = obj_ri.ResponseWindow; + NV_ri = NR_ri.(sprintf('Speed%d', rfSpeed)).NeuronVals; + uDirs_ri = unique(NV_ri(1,:,6)); % sorted unique directions (rad) + dirCache(ex_ri) = uDirs_ri; + catch ME + warning('Could not load directions for exp %d: %s', ex_ri, ME.message); + dirCache(ex_ri) = []; + end + end + + uDirsRad = dirCache(ex_ri); + if ~isempty(uDirsRad) && dirIdx >= 1 && dirIdx <= numel(uDirsRad) + stripeDirRad(ri) = uDirsRad(dirIdx); + end + end + + results.stripeDirRad = stripeDirRad; + + if params.plot + validDir = stripeDirRad(~isnan(stripeDirRad)); + if ~isempty(validDir) + % Reference directions and their cardinal labels + dirRadRef = [0, pi/2, pi, 3*pi/2]; + dirNames = {'up', 'right', 'down', 'left'}; + + % Count neurons per direction (tolerance match, wrapped to [0,2pi)) + counts = zeros(1, numel(dirRadRef)); + for k = 1:numel(dirRadRef) + counts(k) = sum(abs(mod(validDir, 2*pi) - dirRadRef(k)) < 1e-3); + end + + figDir = figure('Units','centimeters','Position',[5 5 8 6]); + bar(counts, 0.6, 'FaceColor', [0.85 0.2 0.2], 'EdgeColor', 'none'); + set(gca, 'XTick', 1:numel(dirNames), 'XTickLabel', dirNames, ... + 'FontSize', 8, 'FontName', 'helvetica'); + ylabel('Number of stripe neurons', 'FontSize', 9); + xlabel('Ball direction', 'FontSize', 9); + title(sprintf('MB stripe best direction (n=%d)', numel(validDir)), ... + 'FontSize', 9); + box off; + + + if params.PaperFig + vs_first.printFig(figDir, sprintf('StripeNeurons_dirDist_%s', params.indexType), ... + PaperFig = params.PaperFig); + end + + results.figDir = figDir; + end + end +end + + +% ========================================================================= +% 8. RESPONSIVENESS Z-SCORE: stripe vs responsive non-stripe +% ========================================================================= +% Choose which stimulus/stimuli to show, since RG has few stripe neurons +switch params.zStim + case "linearlyMovingBall" + groupOrder = {'MB stripe', 'MB resp'}; + groupRows = {mbStripe, mbNonStr}; + zComps = { 1, 2, [], 1 }; % MB stripe vs MB resp + case "rectGrid" + groupOrder = {'SB stripe', 'SB resp'}; + groupRows = {sbStripe, sbNonStr}; + zComps = { 1, 2, [], 1 }; % SB stripe vs SB resp + case "both" + groupOrder = {'MB stripe', 'SB stripe', 'MB resp', 'SB resp'}; + groupRows = {mbStripe, sbStripe, mbNonStr, sbNonStr}; + zComps = { 1, 3, [], 1; 2, 4, [], 2 }; % MB & SB stripe-vs-resp +end + +zData = table([], categorical([]), categorical([]), categorical([]), [], ... + 'VariableNames', {'z','group','insertion','animal','NeurID'}); +for gi = 1:numel(groupOrder) + rows = groupRows{gi}; + if isempty(rows) || ~ismember('zResp', rows.Properties.VariableNames), continue; end + keep = ~isnan(rows.zResp); + if ~any(keep), continue; end + zData = [zData; table(rows.zResp(keep), ... + categorical(repmat(groupOrder(gi), sum(keep), 1)), ... + rows.experimentNum(keep), rows.animal(keep), rows.phyID(keep), ... + 'VariableNames', {'z','group','insertion','animal','NeurID'})]; %#ok +end +results.zData = zData; + +results.pZ = struct(); +for ci = 1:size(zComps,1) + gA = groupOrder{zComps{ci,1}}; + gB = groupOrder{zComps{ci,2}}; + pV = compareGroupsHB(zData, 'z', gA, gB, params.nBoot); + zComps{ci,3} = pV; + results.pZ.(matlab.lang.makeValidName([gA '_vs_' gB])) = pV; + fprintf(' z: %s vs %s : p = %.4f\n', gA, gB, pV); +end + +if params.plot && ~isempty(zData) + figZ = figure('Units','centimeters','Position',[5 5 14 8]); + hold on; + nG = numel(groupOrder); + allAnimalNames = categories(removecats(zData.animal)); + nAnimals = numel(allAnimalNames); + animalCmap = lines(max(nAnimals,1)); + + for gi = 1:nG + rows = zData(zData.group == groupOrder{gi}, :); + if isempty(rows), continue; end + [~, aIdx] = ismember(string(rows.animal), allAnimalNames); + swarmchart(gi*ones(height(rows),1), rows.z, 16, animalCmap(aIdx,:), 'filled', ... + 'XJitter','density', 'XJitterWidth',0.35, ... + 'MarkerFaceAlpha',0.65, 'MarkerEdgeColor','none'); + try + bM = hierBootMatchFreq(rows.z, params.nBoot, double(rows.insertion), double(rows.animal)); + m = mean(bM); ci = prctile(bM,[2.5 97.5]); + errorbar(gi, m, m-ci(1), ci(2)-m, 'k', 'LineWidth',1.5, 'CapSize',8); + plot(gi, m, 'ko', 'MarkerFaceColor','w', 'MarkerSize',7, 'LineWidth',1.5); + catch ME + warning('hierBoot failed for %s: %s', groupOrder{gi}, ME.message); + end + end + + % Significance brackets — normal Y-axis: brackets ABOVE the data + yMax = max(zData.z); yMin = min(zData.z); zR = yMax - yMin; + bStep = zR*0.10; tickH = zR*0.025; + % comps = { + % 1, 2, results.pZ.MBstripe_vs_SBstripe, 1; + % 1, 3, results.pZ.MBstripe_vs_MBresp, 2; + % 2, 4, results.pZ.SBstripe_vs_SBresp, 3; + % }; + maxSig = 0; + for ci = 1:size(zComps,1) + g1=zComps{ci,1}; g2=zComps{ci,2}; pV=zComps{ci,3}; lv=zComps{ci,4}; + if isnan(pV) || pV >= 0.05, continue; end + yB = yMax + lv*bStep; % above data + plot([g1 g2],[yB yB], 'k-','LineWidth',1); + plot([g1 g1],[yB, yB-tickH], 'k-','LineWidth',1); % ticks point DOWN to data + plot([g2 g2],[yB, yB-tickH], 'k-','LineWidth',1); + text((g1+g2)/2, yB, sigStars(pV), ... + 'HorizontalAlignment','center','VerticalAlignment','bottom', ... + 'FontSize',11,'FontWeight','bold'); + maxSig = max(maxSig, lv); + end + ylim([yMin - bStep, yMax + (maxSig+1.5)*bStep]); + + if params.plotLegend + lgdH = gobjects(nAnimals,1); + for ai = 1:nAnimals + lgdH(ai) = plot(nan,nan,'o','Color',animalCmap(ai,:), ... + 'MarkerFaceColor',animalCmap(ai,:),'MarkerSize',6, ... + 'DisplayName',allAnimalNames{ai}); + end + legend(lgdH,'Location','best','FontSize',7,'Box','off'); + end + + set(gca,'XTick',1:nG,'XTickLabel',groupOrder, ... + 'XLim',[0.5 nG+0.5],'FontSize',8,'FontName','helvetica'); + ylabel('Responsiveness (z-score)','FontSize',9); + xtickangle(20); + title('Responsiveness: stripe vs non-stripe','FontSize',9); + box on; hold off; + + if params.PaperFig + vs_first.printFig(figZ, sprintf('StripeNeurons-zscore-%s', params.indexType), ... + PaperFig = params.PaperFig); + end + results.figZ = figZ; +end + +% ========================================================================= +% 9. TUNING INDEX: MB stripe vs MB responsive non-stripe (OSI and DSI) +% MB-only — rectGrid has no direction tuning. +% Columns: [OSI stripe | OSI resp | DSI stripe | DSI resp] +% ========================================================================= +tuneOrder = {'OSI stripe', 'OSI resp', 'DSI stripe', 'DSI resp'}; +tuneSrc = {mbStripe, mbNonStr, mbStripe, mbNonStr}; +tuneMetric = {'OSI', 'OSI', 'DSI', 'DSI'}; + +tuneData = table([], categorical([]), categorical([]), categorical([]), [], ... + 'VariableNames', {'val','group','insertion','animal','NeurID'}); +for gi = 1:numel(tuneOrder) + rows = tuneSrc{gi}; + mcol = tuneMetric{gi}; + if isempty(rows) || ~ismember(mcol, rows.Properties.VariableNames), continue; end + vals = rows.(mcol); + keep = ~isnan(vals); + if ~any(keep), continue; end + tuneData = [tuneData; table(vals(keep), ... + categorical(repmat(tuneOrder(gi), sum(keep), 1)), ... + rows.experimentNum(keep), rows.animal(keep), rows.phyID(keep), ... + 'VariableNames', {'val','group','insertion','animal','NeurID'})]; %#ok +end +results.tuneData = tuneData; + +results.pTune.OSI = compareGroupsHB(tuneData, 'val', 'OSI stripe', 'OSI resp', params.nBoot); +results.pTune.DSI = compareGroupsHB(tuneData, 'val', 'DSI stripe', 'DSI resp', params.nBoot); + +fprintf('\nTuning index (two-tailed hierBoot p):\n'); +fprintf(' OSI stripe vs resp : p = %.4f\n', results.pTune.OSI); +fprintf(' DSI stripe vs resp : p = %.4f\n', results.pTune.DSI); + +if params.plot && ~isempty(tuneData) + figTune = figure('Units','centimeters','Position',[5 5 12 8]); + hold on; + nG = numel(tuneOrder); + allAnimalNames = categories(removecats(tuneData.animal)); + nAnimals = numel(allAnimalNames); + animalCmap = lines(max(nAnimals,1)); + + for gi = 1:nG + rows = tuneData(tuneData.group == tuneOrder{gi}, :); + if isempty(rows), continue; end + [~, aIdx] = ismember(string(rows.animal), allAnimalNames); + swarmchart(gi*ones(height(rows),1), rows.val, 16, animalCmap(aIdx,:), 'filled', ... + 'XJitter','density', 'XJitterWidth',0.35, ... + 'MarkerFaceAlpha',0.65, 'MarkerEdgeColor','none'); + try + bM = hierBootMatchFreq(rows.val, params.nBoot, double(rows.insertion), double(rows.animal)); + m = mean(bM); ci = prctile(bM,[2.5 97.5]); + errorbar(gi, m, m-ci(1), ci(2)-m, 'k', 'LineWidth',1.5, 'CapSize',8); + plot(gi, m, 'ko', 'MarkerFaceColor','w', 'MarkerSize',7, 'LineWidth',1.5); + catch ME + warning('hierBoot failed for %s: %s', tuneOrder{gi}, ME.message); + end + end + + % Brackets: 1 vs 2 (OSI), 3 vs 4 (DSI) — disjoint spans, both at level 1 + yMax = max(tuneData.val); yMin = min([0; tuneData.val]); vR = yMax - yMin; + bStep = vR*0.10; tickH = vR*0.025; + comps = { 1,2,results.pTune.OSI,1; 3,4,results.pTune.DSI,1 }; + maxSig = 0; + for ci = 1:size(comps,1) + g1=comps{ci,1}; g2=comps{ci,2}; pV=comps{ci,3}; lv=comps{ci,4}; + if isnan(pV) || pV >= 0.05, continue; end + yB = yMax + lv*bStep; + plot([g1 g2],[yB yB], 'k-','LineWidth',1); + plot([g1 g1],[yB, yB-tickH], 'k-','LineWidth',1); + plot([g2 g2],[yB, yB-tickH], 'k-','LineWidth',1); + text((g1+g2)/2, yB, sigStars(pV), ... + 'HorizontalAlignment','center','VerticalAlignment','bottom', ... + 'FontSize',11,'FontWeight','bold'); + maxSig = max(maxSig, lv); + end + ylim([yMin, yMax + (maxSig+1.5)*bStep]); + + if params.plotLegend + lgdH = gobjects(nAnimals,1); + for ai = 1:nAnimals + lgdH(ai) = plot(nan,nan,'o','Color',animalCmap(ai,:), ... + 'MarkerFaceColor',animalCmap(ai,:),'MarkerSize',6, ... + 'DisplayName',allAnimalNames{ai}); + end + legend(lgdH,'Location','best','FontSize',7,'Box','off'); + end + + set(gca,'XTick',1:nG,'XTickLabel',tuneOrder, ... + 'XLim',[0.5 nG+0.5],'FontSize',8,'FontName','helvetica'); + ylabel('Selectivity index','FontSize',9); + xtickangle(20); + title('Direction/orientation tuning: MB stripe vs non-stripe','FontSize',9); + box on; hold off; + + if params.PaperFig + vs_first.printFig(figTune, sprintf('StripeNeurons-tuning-%s', params.indexType), ... + PaperFig = params.PaperFig); + end + results.figTune = figTune; +end + +% ========================================================================= +% 10. SPATIAL TUNING INDEX: stripe vs responsive non-stripe +% Uses params.indexType at each neuron's preferred direction (same for +% both groups — symmetric). NOT stripeBestDir, to keep the comparison +% apples-to-apples (non-stripe neurons have no stripe direction). +% ========================================================================= +switch params.idxStim + case "linearlyMovingBall" + idxOrder = {'MB stripe', 'MB resp'}; + idxRows = {mbStripe, mbNonStr}; + idxComps = { 1, 2, [], 1 }; + case "rectGrid" + idxOrder = {'SB stripe', 'SB resp'}; + idxRows = {sbStripe, sbNonStr}; + idxComps = { 1, 2, [], 1 }; + case "both" + idxOrder = {'MB stripe', 'SB stripe', 'MB resp', 'SB resp'}; + idxRows = {mbStripe, sbStripe, mbNonStr, sbNonStr}; + idxComps = { 1, 3, [], 1; 2, 4, [], 2 }; +end + +idxData = table([], categorical([]), categorical([]), categorical([]), [], ... + 'VariableNames', {'val','group','insertion','animal','NeurID'}); +for gi = 1:numel(idxOrder) + rows = idxRows{gi}; + if isempty(rows), continue; end + vals = rows.(params.indexType); % the tuning index, already at prefDir + keep = ~isnan(vals); + if ~any(keep), continue; end + idxData = [idxData; table(vals(keep), ... + categorical(repmat(idxOrder(gi), sum(keep), 1)), ... + rows.experimentNum(keep), rows.animal(keep), rows.phyID(keep), ... + 'VariableNames', {'val','group','insertion','animal','NeurID'})]; %#ok +end +results.idxData = idxData; + +results.pIdx = struct(); +for ci = 1:size(idxComps,1) + gA = idxOrder{idxComps{ci,1}}; + gB = idxOrder{idxComps{ci,2}}; + pV = compareGroupsHB(idxData, 'val', gA, gB, params.nBoot); + idxComps{ci,3} = pV; + results.pIdx.(matlab.lang.makeValidName([gA '_vs_' gB])) = pV; + fprintf(' idx: %s vs %s : p = %.4f\n', gA, gB, pV); +end + +if params.plot && ~isempty(idxData) && any(~isnan(idxData.val)) + figIdx = figure('Units','centimeters','Position',[5 5 12 8]); + hold on; + nG = numel(idxOrder); + allAnimalNames = categories(removecats(idxData.animal)); + nAnimals = numel(allAnimalNames); + animalCmap = lines(max(nAnimals,1)); + + for gi = 1:nG + rows = idxData(idxData.group == idxOrder{gi}, :); + if isempty(rows), continue; end + [~, aIdx] = ismember(string(rows.animal), allAnimalNames); + swarmchart(gi*ones(height(rows),1), rows.val, 16, animalCmap(aIdx,:), 'filled', ... + 'XJitter','density', 'XJitterWidth',0.35, ... + 'MarkerFaceAlpha',0.65, 'MarkerEdgeColor','none'); + vv = rows.val(~isnan(rows.val)); + if numel(vv) < 3, continue; end + try + bM = hierBootMatchFreq(vv, params.nBoot, ... + double(rows.insertion(~isnan(rows.val))), ... + double(rows.animal(~isnan(rows.val)))); + m = mean(bM); ci2 = prctile(bM,[2.5 97.5]); + errorbar(gi, m, m-ci2(1), ci2(2)-m, 'k', 'LineWidth',1.5, 'CapSize',8); + plot(gi, m, 'ko', 'MarkerFaceColor','w', 'MarkerSize',7, 'LineWidth',1.5); + catch ME + warning('hierBoot failed for %s: %s', idxOrder{gi}, ME.message); + end + end + + allVals = idxData.val(~isnan(idxData.val)); + yMax = max(allVals); yMin = min([0; allVals]); vR = yMax - yMin; + bStep = vR*0.10; tickH = vR*0.025; maxSig = 0; + for ci = 1:size(idxComps,1) + g1 = idxComps{ci,1}; g2 = idxComps{ci,2}; + pV = idxComps{ci,3}; lv = idxComps{ci,4}; + if isnan(pV) || pV >= 0.05, continue; end + yB = yMax + lv*bStep; + plot([g1 g2],[yB yB], 'k-','LineWidth',1); + plot([g1 g1],[yB, yB-tickH], 'k-','LineWidth',1); + plot([g2 g2],[yB, yB-tickH], 'k-','LineWidth',1); + text((g1+g2)/2, yB, sigStars(pV), ... + 'HorizontalAlignment','center','VerticalAlignment','bottom', ... + 'FontSize',11,'FontWeight','bold'); + maxSig = max(maxSig, lv); + end + ylim([yMin, yMax + (maxSig+1.5)*bStep]); + + if params.plotLegend + lgdH = gobjects(nAnimals,1); + for ai = 1:nAnimals + lgdH(ai) = plot(nan,nan,'o','Color',animalCmap(ai,:), ... + 'MarkerFaceColor',animalCmap(ai,:),'MarkerSize',6, ... + 'DisplayName',allAnimalNames{ai}); + end + legend(lgdH,'Location','best','FontSize',7,'Box','off'); + end + + set(gca,'XTick',1:nG,'XTickLabel',idxOrder, ... + 'XLim',[0.5 nG+0.5],'FontSize',8,'FontName','helvetica'); + ylabel(params.indexType, 'FontSize',9, 'Interpreter','none'); + xtickangle(20); + title('Spatial tuning index: stripe vs non-stripe','FontSize',9); + box on; hold off; + + if params.PaperFig + vs_first.printFig(figIdx, sprintf('StripeNeurons-tuningIndex-%s', params.indexType), ... + PaperFig = params.PaperFig); + end + results.figIdx = figIdx; +end + +% ========================================================================= +% 6. Save the joined table to disk for downstream use +% ========================================================================= +results.joinedTbl = tbl; +joinedPath = fullfile(saveDir, sprintf('StripeNeurons_joinedTbl_%s.mat', params.indexType)); +save(joinedPath, '-struct', 'results'); +fprintf('\nJoined table + results saved to:\n %s\n', joinedPath); + +end + + +% ========================================================================= +% LOCAL FUNCTIONS +% ========================================================================= +function pVal = compareGroupsHB(dataTbl, valCol, grpA, grpB, nBoot) +rowsA = dataTbl(dataTbl.group == grpA, :); +rowsB = dataTbl(dataTbl.group == grpB, :); +rowsA = rowsA(~isnan(rowsA.(valCol)), :); % drop NaN +rowsB = rowsB(~isnan(rowsB.(valCol)), :); +if height(rowsA) < 3 || height(rowsB) < 3 + pVal = NaN; + return +end +try + bA = hierBootMatchFreq(rowsA.(valCol), nBoot, double(rowsA.insertion), double(rowsA.animal)); + bB = hierBootMatchFreq(rowsB.(valCol), nBoot, double(rowsB.insertion), double(rowsB.animal)); + pOne = mean(bA >= bB); + pVal = 2 * min(pOne, 1 - pOne); +catch + pVal = NaN; +end +end + +function s = sigStars(p) +% Convert a p-value to a significance star string for bracket annotation. +if isnan(p) || p >= 0.05, s = 'ns'; +elseif p < 0.001, s = '***'; +elseif p < 0.01, s = '**'; +else, s = '*'; +end +end + +function [zScore, phyIDg] = computeResponsivenessZ(vsObj, zp) +% Pooled responsiveness z-score per good unit (all trials, all directions). +% Adapted from the sign-flip permutation z in StatisticsPerNeuronPerCategory: +% z = (mean(response-baseline) - nullMean) / sd(baseline) +% computed identically for every neuron so stripe/non-stripe are comparable. + + p = vsObj.dataObj.convertPhySorting2tIc(vsObj.spikeSortingFolder); + label = string(p.label'); + goodU = p.ic(:, label == 'good'); + phyIDg = p.phy_ID(label == 'good'); + + rw = vsObj.ResponseWindow; + + % Baseline window: ends baselineBuffer ms before onset, <= baseRespWindow long + preStim = vsObj.VST.interTrialDelay*1000 - zp.baselineBuffer; + baseDur = min(preStim, zp.baseRespWindow); + baseStart = baseDur + zp.baselineBuffer; + if baseDur <= 0 + error('Non-positive baseline duration (interTrialDelay too short).'); + end + + isMB = isequal(vsObj.stimName, 'linearlyMovingBall'); + + if isMB + nSpeeds = numel(unique(vsObj.VST.speed)); + respList = cell(nSpeeds,1); + baseList = cell(nSpeeds,1); + for s = 1:nSpeeds + fName = sprintf('Speed%d', s); + tt = rw.(fName).C(:,1)'; + sd = rw.(fName).stimDur; + Mr = BuildBurstMatrix(goodU, round(p.t), round(tt), round(sd)); + mrMov = movmean(Mr, zp.movingWindow, 3, 'Endpoints','discard'); + respList{s} = max(mrMov, [], 3); % peak window per trial + Mb = BuildBurstMatrix(goodU, round(p.t), round(tt - baseStart), baseDur); + baseList{s} = mean(Mb, 3); + end + responsesFull = vertcat(respList{:}); + baselinesFull = vertcat(baseList{:}); + else + tt = rw.C(:,1)'; + Mr = BuildBurstMatrix(goodU, round(p.t), round(tt), zp.baseRespWindow); + responsesFull = mean(Mr, 3); + Mb = BuildBurstMatrix(goodU, round(p.t), round(tt - baseStart), baseDur); + baselinesFull = mean(Mb, 3); + end + + DiffFull = responsesFull - baselinesFull; % [nTrials x nNeurons] + sdBase = std(baselinesFull, 0, 1); % [1 x nNeurons] + nTrials = size(DiffFull, 1); + + ObsStat = mean(DiffFull, 1); % [1 x nNeurons] + + rng(zp.rngSeed, 'twister'); % reproducible sign-flip null + signs = 2*randi(2, nTrials, zp.nBoot) - 3; % {-1,+1} + nullDist = (signs' * DiffFull) / nTrials; % [nBoot x nNeurons] + nullMean = mean(nullDist, 1); + + zScore = (ObsStat - nullMean) ./ sdBase; + zScore(sdBase == 0) = 0; + zScore = zScore(:); % column, aligned with phyIDg +end \ No newline at end of file diff --git a/visualStimulationAnalysis/computeBallGridCrossings.m b/visualStimulationAnalysis/computeBallGridCrossings.m new file mode 100644 index 0000000..600cf8e --- /dev/null +++ b/visualStimulationAnalysis/computeBallGridCrossings.m @@ -0,0 +1,297 @@ +function [crossingFrame, dwellFrames, validGridPerDirection, nTrialsPerCellDir] = ... + computeBallGridCrossings(obj, speedIndx, params) +% computeBallGridCrossings - Detect when ball centre crosses each grid cell. +% +% For each trial and each grid cell, finds the frame at which the ball centre +% first enters the spatial bin corresponding to that grid cell. Grid cells are +% defined on the original screen coordinates (not the reduced coordinates used +% elsewhere for receptive field plotting). +% +% Inputs: +% obj - experiment object with VST metadata and stimulus category matrix C +% speedIndx - speed index into VST trajectory arrays (1 or 2) +% params - parameter struct containing GridSize (e.g. 9) +% +% Outputs: +% crossingFrame : [nTrials × nGridCells] frame at which ball centre +% first enters grid cell. NaN if ball never enters. +% dwellFrames : [nTrials × nGridCells] number of frames ball centre +% remains in cell. 0 if never entered. +% validGridPerDirection : [nGridCells × nDirections] logical. True if at least +% one trial in that direction crosses the cell. +% nTrialsPerCellDir : [nGridCells × nDirections] trial count per cell×dir. + +% ------------------------------------------------------------------------- +% ------------------------------------------------------------------------- +% Reconstruct trajectories from stimulus geometry rather than raw data +% Raw obj.VST.ballTrajectoriesX/Y has sampling glitches — using min/max +% offsets from obj.VST.parallelsOffset and screen centre gives clean +% constant-velocity trajectories independent of sampling artefacts. +% ------------------------------------------------------------------------- + +% Frame count per (offset, direction) for this speed condition +nFramesFull = obj.VST.nFrames; % [nSpeeds × nOffsets × nDirections] +if ndims(nFramesFull) == 3 + nFramesPerOffsetDir = squeeze(nFramesFull(speedIndx, :, :)); % [nOffsets × nDirs] +else + nFramesPerOffsetDir = nFramesFull; +end + +useOriginalCorrs = true; + +if useOriginalCorrs + + Xpos = obj.VST.ballTrajectoriesX; + Ypos = obj.VST.ballTrajectoriesY; + + if size(Xpos,1) > 1 + Xpos = Xpos(speedIndx,:,:,1:unique(obj.VST.nFrames(speedIndx,:,:))); + Ypos = Ypos(speedIndx,:,:,1:unique(obj.VST.nFrames(speedIndx,:,:))); + end + +else + + % Reconstruct from stimulus design parameters + [Xpos, Ypos] = reconstructBallTrajectoriesFromGeometry(obj, speedIndx, nFramesPerOffsetDir); + +end +% Xpos, Ypos are [1 × nOffsets × nDirs × nFramesMax] + +% Flatten trajectories to [nTrials × nFrames] matching trial ordering +% in C +sizeX = size(Xpos); % [nSpeeds × nOffsets × nDirs × nFrames] +nSizes = length(unique(obj.VST.ballSizes)); +C = obj.ResponseWindow.(sprintf('Speed%d', speedIndx)).C; % category matrix +trialDivVid = size(C,1) / numel(unique(C(:,2))) / numel(unique(C(:,3))) ... + / numel(unique(C(:,4))) / numel(unique(C(:,5))); % trials per unique video + +nFrames = sizeX(end); +nTrials = size(C,1); + +% Build [nTrials × nFrames] position arrays matching C order +% Loop structure MUST match the order used to build C (dir × offset × +% speed)member you can have more than one speed +ChangePosX = zeros(nTrials, nFrames); +ChangePosY = zeros(nTrials, nFrames); +j = 1; +for d = 1:sizeX(3) % directions + for of = 1:sizeX(2) % offsets + for sp = 1:sizeX(1) % speeds (one after speedIndx selection) + % Replicate trajectory across size categories and trial divisions + traj = squeeze(Xpos(sp, of, d, :))'; % [1 × nFrames] + ChangePosX(j:j+nSizes*trialDivVid-1, :) = repmat(traj, nSizes*trialDivVid, 1); + trajY = squeeze(Ypos(sp, of, d, :))'; + ChangePosY(j:j+nSizes*trialDivVid-1, :) = repmat(trajY, nSizes*trialDivVid, 1); + j = j + nSizes * trialDivVid; + end + end +end + +% ------------------------------------------------------------------------- +% Define grid cells on original screen coordinates +% Screen is [obj.VST.rect(3) × obj.VST.rect(4)] pixels +% Each grid cell is cellW × cellH pixels +% Cell (gx, gy) spans: x ∈ [(gx-1)*cellW, gx*cellW], y ∈ [(gy-1)*cellH, gy*cellH] +% ------------------------------------------------------------------------- +screenW = obj.VST.rect(3); % full screen width in pixels +screenH = obj.VST.rect(4); % full screen height in pixels +nGrid = params.GridSize; % e.g. 9 → 9×9 = 81 cells +cellW = screenH / nGrid; % width of each cell in pixels +cellH = screenH / nGrid; % height of each cell in pixels +nCells = nGrid * nGrid; % total number of grid cells + +cropOffsetX = (screenW - screenH)/2; + + +% ------------------------------------------------------------------------- +% For each trial and cell: find first frame of entry and dwell duration +% ------------------------------------------------------------------------- +crossingFrame = nan(nTrials, nCells); % initialise as NaN (never entered) +dwellFrames = nan(nTrials, nCells); % initialise dwell time as 0 + +for t = 1:nTrials + % For each frame, determine which grid cell the ball centre is in + gxPerFrame = floor((ChangePosX(t,:) - cropOffsetX )/ cellW) + 1; % [1 × nFrames] grid x index + gyPerFrame = floor(ChangePosY(t,:) / cellH) + 1; % [1 × nFrames] grid y index + + % % Clamp to valid grid range (ball may be off-screen during entry/exit) + % gxPerFrame = max(1, min(nGrid, gxPerFrame)); + % gyPerFrame = max(1, min(nGrid, gyPerFrame)); + % + % % Flatten (gx, gy) to linear cell index: cellIdx = (gy-1)*nGrid + gx + % cellIdxPerFrame = (gyPerFrame - 1) * nGrid + gxPerFrame; % [1 × nFrames] + + valid = gxPerFrame >= 1 & gxPerFrame <= nGrid & ... + gyPerFrame >= 1 & gyPerFrame <= nGrid; + + cellIdxPerFrame = nan(size(gxPerFrame)); + cellIdxPerFrame(valid) = (gyPerFrame(valid)-1)*nGrid + gxPerFrame(valid); + + visitedCells = unique(cellIdxPerFrame(~isnan(cellIdxPerFrame))); + + for c = visitedCells + inCell = cellIdxPerFrame == c; + frames = find(inCell); + + if isempty(frames) + continue + end + + % --- cell center --- + gx = mod(c-1, nGrid) + 1; + gy = floor((c-1)/nGrid) + 1; + + cx = cropOffsetX + (gx - 0.5) * cellW; % CORRECT + cy = (gy - 0.5) * cellH; + + % --- distance to center --- + dx = ChangePosX(t, frames) - cx; + dy = ChangePosY(t, frames) - cy; + dist = sqrt(dx.^2 + dy.^2); + + % --- center crossing --- + [~, minIdx] = min(dist); + centerFrame = frames(minIdx); + + % --- exit --- + exitFrame = frames(end); + + crossingFrame(t, c) = centerFrame; + %dwellFrames(t, c) = exitFrame - centerFrame + 1; + dwellFrames(t,c) = numel(frames); + end +end + +%figure;imagesc(reshape(mean(dwellFrames,1),[9,9])) +% +% figure;imagesc(reshape(mean(dwellFrames, 1, 'omitnan'), [nGrid nGrid])); +% colorbar; title('Original trajectories'); + +% test = Xpos(1,:,1,:); +% figure;hist(test(:)) + +% ------------------------------------------------------------------------- +% Identify valid grid cells per direction +% Cell is valid for direction d if at least one trial in that direction crosses it +% ------------------------------------------------------------------------- +directions = C(:,2); % direction label per trial +uDirs = unique(directions); % unique direction values +nDirs = numel(uDirs); +nOffsets = numel(obj.VST.parallelsOffset); +centerX = obj.VST.centerX; +centerY = obj.VST.centerY; + +validGridPerDirection = false(nCells, nDirs); +nTrialsPerCellDir = zeros(nCells, nDirs); + +for d = 1:nDirs + trialsThisDir = directions == uDirs(d); + % Cell is valid if any trial in this direction has a non-NaN crossing + validGridPerDirection(:,d) = any(~isnan(crossingFrame(trialsThisDir,:)), 1)'; + % Trial count per cell for this direction + nTrialsPerCellDir(:,d) = sum(~isnan(crossingFrame(trialsThisDir,:)), 1)'; +end + +% figure; +% for d = 1:nDirs +% subplot(1, nDirs, d); +% hold on; +% for o = 1:nOffsets +% x = squeeze(Xpos(1, o, d, ~isnan(Xpos(1,o,d,:)))); +% y = squeeze(Ypos(1, o, d, ~isnan(Ypos(1,o,d,:)))); +% plot(x, y, 'b-'); +% end +% plot(centerX, centerY, 'r+', 'MarkerSize', 12, 'LineWidth', 2); +% rectangle('Position', [0 0 screenW screenH], 'EdgeColor', 'k', 'LineWidth', 1.5); +% title(sprintf('Direction %d', d)); +% axis equal; +% xlim([-screenW screenW*2]); +% ylim([-screenH screenH*2]); +% end +end + +function [dx_dir, dy_dir] = inferDirectionVector(XposRaw, YposRaw, dirIdx) +% Infer motion direction by comparing trajectory endpoints +% Returns unit vector (dx_dir, dy_dir) for the motion direction + +% Average across offsets to get robust endpoints (immune to glitches in single trajectories) +xStart = mean(XposRaw(:, dirIdx, 1), 'omitnan'); % first frame across offsets +xEnd = mean(XposRaw(:, dirIdx, end), 'omitnan'); % last frame across offsets +yStart = mean(YposRaw(:, dirIdx, 1), 'omitnan'); +yEnd = mean(YposRaw(:, dirIdx, end), 'omitnan'); + +% Motion vector +dx = xEnd - xStart; +dy = yEnd - yStart; + +% Normalise to unit vector +mag = sqrt(dx^2 + dy^2); +dx_dir = dx / mag; +dy_dir = dy / mag; +end + +function [Xpos, Ypos] = reconstructBallTrajectoriesFromGeometry(obj, speedIndx, nFramesPerOffsetDir) +% reconstructBallTrajectoriesFromGeometry - Reconstruct ball trajectories from +% stimulus design parameters rather than (potentially glitched) raw trajectory data. +% +% Direction of motion is inferred from raw trajectory endpoints (first vs last +% frame across offsets), avoiding any assumption about angle conventions. +% Offset is applied perpendicular to motion direction. + +% Stimulus geometry +centerX = obj.VST.centerX; +centerY = obj.VST.centerY; +screenW = obj.VST.rect(3); +screenH = obj.VST.rect(4); +offsets = obj.VST.parallelsOffset; +directions = unique(obj.VST.directions); + +nOffsets = numel(offsets); +nDirs = numel(directions); +nFramesMax = max(nFramesPerOffsetDir(:)); + +Xpos = nan(1, nOffsets, nDirs, nFramesMax); +Ypos = nan(1, nOffsets, nDirs, nFramesMax); + +travelDist = sqrt(screenW^2 + screenH^2); + +% Load raw trajectories for direction inference +% Squeeze speed dim so we have [nOffsets × nDirs × nFrames] +XposRawFull = obj.VST.ballTrajectoriesX; +YposRawFull = obj.VST.ballTrajectoriesY; +if size(XposRawFull,1) > 1 + XposRaw = squeeze(XposRawFull(speedIndx, :, :, :)); + YposRaw = squeeze(YposRawFull(speedIndx, :, :, :)); +else + XposRaw = squeeze(XposRawFull); + YposRaw = squeeze(YposRawFull); +end + +for d = 1:nDirs + % Infer direction vector from raw data — robust to angle convention + [dx_dir, dy_dir] = inferDirectionVector(XposRaw, YposRaw, d); + + % Perpendicular vector (rotate 90° counterclockwise in screen coordinates) + dx_perp = -dy_dir; + dy_perp = dx_dir; + + for o = 1:nOffsets + offsetVal = offsets(o); + nFr = nFramesPerOffsetDir(o, d); + + % Trajectory midpoint = screen centre + offset perpendicular to motion + midX = centerX + offsetVal * dx_perp; + midY = centerY + offsetVal * dy_perp; + + % Start/end points along motion direction + xStart = midX - (travelDist/2) * dx_dir; + yStart = midY - (travelDist/2) * dy_dir; + xEnd = midX + (travelDist/2) * dx_dir; + yEnd = midY + (travelDist/2) * dy_dir; + + % Linear interpolation across frames — constant velocity + Xpos(1, o, d, 1:nFr) = linspace(xStart, xEnd, nFr); + Ypos(1, o, d, 1:nFr) = linspace(yStart, yEnd, nFr); + end +end +end \ No newline at end of file diff --git a/visualStimulationAnalysis/detectStripeRF.m b/visualStimulationAnalysis/detectStripeRF.m new file mode 100644 index 0000000..ad818d3 --- /dev/null +++ b/visualStimulationAnalysis/detectStripeRF.m @@ -0,0 +1,168 @@ +function result = detectStripeRF(rfImage, options) +% detectStripeRF Detect diagonal excitatory stripe patterns in a 2D RF. +% +% Four filters must all pass for isStripe = true: +% (i) anisotropy score significant vs pixel-permutation null +% AND above minStripeScore +% (ii) stripe orientation within [minStripeAngle, maxStripeAngle] +% in the requested diagonal direction +% (iii) stripe is excitatory (positive projection peak) +% +% Stripeness metric: S = max(var) / min(var) (anisotropy) +% +% Angle convention (stripeAngle ∈ [0, 180)): +% 0° = horizontal +% 45° = bottom-left → top-right (BL→TR) +% 90° = vertical +% 135° = bottom-right → top-left (BR→TL) +% +% NAME-VALUE OPTIONS +% angleStep projection angle step (default 5°) +% nSurrogates pixel-permutation surrogates (default 1000) +% alpha significance threshold (default 0.05) +% minStripeScore absolute floor on S (default 2) +% rngSeed RNG seed (default 42; NaN to skip) +% diagonalOnly apply orientation filter (default true) +% diagonalDirection "BLtoTR" | "BRtoTL" | "both" (default "BLtoTR") +% minStripeAngle lower bound of accepted stripe angle (default 5°) +% maxStripeAngle upper bound of accepted stripe angle (default 60°) +% For BLtoTR these apply directly. +% For BRtoTL they are mirrored: (180-max, 180-min). +% requirePositive require excitatory peak (default true) + +arguments + rfImage (:,:) double + options.angleStep (1,1) double = 5 + options.nSurrogates (1,1) double = 1000 + options.alpha (1,1) double = 0.05 + options.minStripeScore (1,1) double = 2 + options.rngSeed (1,1) double = 42 + options.diagonalOnly (1,1) logical = true + options.diagonalDirection (1,1) string = "BLtoTR" + options.minStripeAngle (1,1) double = 20 % degrees — lower bound + options.maxStripeAngle (1,1) double = 60 % degrees — upper bound + options.requirePositive (1,1) logical = true +end + +% ------------------------------------------------------------------------- +% 0. RNG +% ------------------------------------------------------------------------- +if ~isnan(options.rngSeed) + rng(options.rngSeed, 'twister'); +end + +% ------------------------------------------------------------------------- +% 1. Projection angles +% ------------------------------------------------------------------------- +angles = 0 : options.angleStep : (180 - options.angleStep); + +% ------------------------------------------------------------------------- +% 2. Stripeness on the real RF +% ------------------------------------------------------------------------- +[stripeScore, bestProjAngle, varProfile, bestProj] = ... + computeStripeness(rfImage, angles); + +% ------------------------------------------------------------------------- +% 3. Pixel-permutation null +% ------------------------------------------------------------------------- +nPix = numel(rfImage); +imgSize = size(rfImage); +nSurr = options.nSurrogates; +nullScores = zeros(nSurr, 1); + +for si = 1:nSurr + surrogate = rfImage(randperm(nPix)); + surrogate = reshape(surrogate, imgSize); + nullScores(si) = computeStripeness(surrogate, angles); +end + +% ------------------------------------------------------------------------- +% 4. Filters +% ------------------------------------------------------------------------- + +% (i) Significance + absolute floor +pval = mean(nullScores >= stripeScore); +passedScore = (pval < options.alpha) && (stripeScore >= options.minStripeScore); + +% Stripe orientation +stripeAngle = mod(bestProjAngle + 90, 180); + +% (ii) Angle within accepted range +if options.diagonalOnly + lo = options.minStripeAngle; % e.g. 5° + hi = options.maxStripeAngle; % e.g. 60° + + % BL→TR band: stripeAngle ∈ [lo, hi] + inBLtoTR = (stripeAngle >= lo) && (stripeAngle <= hi); + % BR→TL band: mirror → stripeAngle ∈ [180−hi, 180−lo] + inBRtoTL = (stripeAngle >= (180 - hi)) && (stripeAngle <= (180 - lo)); + + switch options.diagonalDirection + case "BLtoTR" + passedDiagonal = inBLtoTR; + case "BRtoTL" + passedDiagonal = inBRtoTL; + case "both" + passedDiagonal = inBLtoTR || inBRtoTL; + otherwise + error('Unknown diagonalDirection: %s', options.diagonalDirection); + end +else + passedDiagonal = true; +end + +% (iii) Excitatory peak +[~, peakIdx] = max(abs(bestProj)); +peakValue = bestProj(peakIdx); +peakSign = sign(peakValue); +if options.requirePositive + passedPositive = peakValue > 0; +else + passedPositive = true; +end + +% Final +isStripe = passedScore && passedDiagonal && passedPositive; + +% ------------------------------------------------------------------------- +% 5. Package +% ------------------------------------------------------------------------- +result.isStripe = isStripe; +result.passedScore = passedScore; +result.passedDiagonal = passedDiagonal; +result.passedPositive = passedPositive; +result.stripeScore = stripeScore; +result.stripePval = pval; +result.stripeAngle = stripeAngle; +result.projAngle = bestProjAngle; +result.peakSign = peakSign; +result.varProfile = varProfile; +result.angles = angles; +result.nullScores = nullScores; + +end + + +% ========================================================================= +function [score, bestAngle, varProfile, bestProj] = computeStripeness(img, angles) + +R = radon(img, angles); +varProfile = var(R, 0, 1); + +minVar = min(varProfile); +maxVar = max(varProfile); + +if minVar <= 0 || maxVar <= 0 + score = 1; + bestAngle = NaN; + bestProj = R(:, 1); + return +end + +score = maxVar / minVar; + +[~, maxIdx] = max(varProfile); +bestAngle = angles(maxIdx); +bestProj = R(:, maxIdx); + +end \ No newline at end of file diff --git a/visualStimulationAnalysis/getNeuronDepths.m b/visualStimulationAnalysis/getNeuronDepths.m new file mode 100644 index 0000000..b653851 --- /dev/null +++ b/visualStimulationAnalysis/getNeuronDepths.m @@ -0,0 +1,107 @@ +function [result] = getNeuronDepths(exList) +% getNeuronDepths Returns cortical depths of good units across all experiments, +% and computes 3 globally-defined equal depth bins. +% +% Inputs: +% exList - vector of experiment numbers (same as used in plotPSTH_MultiExp) +% +% Outputs: +% result - struct with fields: +% .depthTable - table with columns: Experiment, Unit, Depth_um +% .depthBinEdges - 1x4 vector [min, t1, t2, max] in um +% .perExp - struct array with per-experiment data: +% .exNum, .goodU, .p_sort + +% ------------------------------------------------------------------ +% Load Excel once +% ------------------------------------------------------------------ +excelPath = '\\sil3\data\Large_scale_mapping_NP\Experiment_Excel.xlsx'; +T = readtable(excelPath); + +% ------------------------------------------------------------------ +% Preallocate collections +% ------------------------------------------------------------------ +expCol = []; % experiment number per unit +unitCol = []; % unit index (1-based) per unit +depthCol = []; % depth in um per unit + +result.perExp(numel(exList)) = struct('exNum', [], 'goodU', [], 'p_sort', []); + +% ------------------------------------------------------------------ +% Loop over experiments +% ------------------------------------------------------------------ +for ei = 1:numel(exList) + + ex = exList(ei); + fprintf('Loading experiment %d ...\n', ex); + + try + NP = loadNPclassFromTable(ex); + obj = linearlyMovingBallAnalysis(NP); + p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + result.perExp(ei).exNum = ex; + result.perExp(ei).goodU = []; + result.perExp(ei).p_sort = []; + continue + end + + % coor_Z for this experiment + coor_Z = T.coor_Z(ex); + + % Good units + label = string(p_sort.label'); + goodU = p_sort.ic(:, label == 'good'); % nTimePoints x nGoodUnits + nGood = size(goodU, 2); + + % Channel IDs (0-based) → Y positions → real depths + channelIDs = goodU(1, :); % 1 x nGoodUnits, 0-based + yPos = NP.chLayoutPositions(2, channelIDs); % 1 x nGoodUnits + neuronDepths = coor_Z - yPos; % 1 x nGoodUnits, in um + + % Accumulate table columns + expCol = [expCol, repmat(ex, 1, nGood)]; + unitCol = [unitCol, 1:nGood ]; + depthCol = [depthCol, neuronDepths ]; + + % Store per-experiment data + result.perExp(ei).exNum = ex; + result.perExp(ei).goodU = goodU; + result.perExp(ei).p_sort = p_sort; + + fprintf(' coor_Z = %.0f um | Good units: %d | Depth range: %.0f - %.0f um\n', ... + coor_Z, nGood, min(neuronDepths), max(neuronDepths)); + +end + +% ------------------------------------------------------------------ +% Build table +% ------------------------------------------------------------------ +result.depthTable = table(expCol(:), unitCol(:), depthCol(:), ... + 'VariableNames', {'Experiment', 'Unit', 'Depth_um'}); + +% ------------------------------------------------------------------ +% Global depth bins +% ------------------------------------------------------------------ +dMin = min(depthCol); +dMax = max(depthCol); +step = (dMax - dMin) / 3; + +result.depthBinEdges = [dMin, dMin+step, dMin+2*step, dMax]; + +fprintf('\nGlobal depth range: %.0f - %.0f um\n', dMin, dMax); +fprintf('Depth bins:\n'); +fprintf(' Bin 1 (shallow) : %.0f - %.0f um\n', result.depthBinEdges(1), result.depthBinEdges(2)); +fprintf(' Bin 2 (middle) : %.0f - %.0f um\n', result.depthBinEdges(2), result.depthBinEdges(3)); +fprintf(' Bin 3 (deep) : %.0f - %.0f um\n', result.depthBinEdges(3), result.depthBinEdges(4)); + +% ------------------------------------------------------------------ +% Save to disk +% ------------------------------------------------------------------ + +n = extractBefore(obj.getAnalysisFileName,'lizards'); +saveName = [n 'lizards' filesep 'Combined_lizard_analysis' filesep 'NeuronDepths.mat']; +save(saveName, '-struct', 'result'); +fprintf('\nSaved to: %s\n', saveName); +end \ No newline at end of file diff --git a/visualStimulationAnalysis/plotPSTH_MultiExp.m b/visualStimulationAnalysis/plotPSTH_MultiExp.m new file mode 100644 index 0000000..4d9998c --- /dev/null +++ b/visualStimulationAnalysis/plotPSTH_MultiExp.m @@ -0,0 +1,1588 @@ +function plotPSTH_MultiExp(exList, params) +% plotPSTH_MultiExp Compute and plot population PSTHs across experiments. +% +% plotPSTH_MultiExp(exList) — default parameters +% plotPSTH_MultiExp(exList, Name=Value) — override any parameter +% +% Computes a peri-stimulus time histogram for each experiment, then plots +% the grand-average PSTH ± SEM across experiments. Supports multiple +% stimulus types, optional depth-bin stratification, and optional +% within-stimulus category splits (e.g. one PSTH per ball size). +% +% STIMULUS TYPE ABBREVIATIONS +% MB — linearlyMovingBall (linearlyMovingBallAnalysis) +% MBR — linearlyMovingBar (linearlyMovingBarAnalysis) +% RG — rectGrid (rectGridAnalysis) +% SDGm — StaticDriftingGrating, moving phase +% SDGs — StaticDriftingGrating, static phase +% NV — natural video (movieAnalysis) +% NI — natural images (imageAnalysis) +% FFF — fullFieldFlash (fullFieldFlashAnalysis) +% +% KEY PARAMETERS +% stimTypes — which stimulus analyses to include (abbreviations) +% requireAllStims — if true, skip experiments that lack ANY of the +% requested stimTypes; ensures a fully matched +% population across stim types (recommended for +% cross-stim comparisons in publication figures). +% NOTE: stimulus PRESENCE is now verified even when +% splitBy == "" (the no-split case), and the session +% containing each stimulus is discovered across +% sessions [0, 1, 2] rather than assuming session 0. +% splitBy — category variable to split within each stim type +% (e.g. "size", "direction"). "" = no split. +% Experiments with <2 levels are automatically skipped. +% splitLevels — numeric vector of specific levels to use (e.g. [5 10 20]). +% Empty = use all available levels. Experiments +% missing any of the requested levels are skipped. +% binWidth — PSTH bin width in ms +% smooth — Gaussian smoothing window in ms (0 = none) +% TakeTopPercentTrials — fraction of trials to keep (1 = all trials) +% byDepth — stratify neurons by cortical depth +% +% See the 'arguments' block below for the full parameter list and defaults. + +% ------------------------------------------------------------------------- +% Input validation via MATLAB arguments block +% ------------------------------------------------------------------------- +arguments + exList double % vector of experiment IDs to include + params.stimTypes (1,:) string = ["RG", "MB"] % stimulus types to include (abbreviations) + params.requireAllStims logical = false % if true, skip experiments that lack ANY of the requested stimTypes (recommended for cross-stim comparisons) + params.splitBy string = "" % category variable for within-stim split; "" = no split + params.splitLevels double = [] % specific category levels to use (e.g. [5 10 20]); empty = all available + params.binWidth double = 50 % PSTH bin width in ms + params.smooth double = 0 % Gaussian smoothing window in ms (0 = no smoothing) + params.statType string = "maxPermuteTest" % statistical test used for per-neuron p-values + params.speed string = "max" % speed condition selector for MB/MBR stimuli + params.alpha double = 0.05 % significance threshold for neuron responsiveness + params.shadeSTD logical = true % shade ±SEM around the mean PSTH line + params.postStim double = 500 % duration after stimulus onset to include (ms) + params.preBase double = 200 % pre-stimulus baseline duration (ms) + params.overwrite logical = false % if true, recompute PSTHs even when a saved file exists + params.overwriteResponse logical = false % if true, force recompute of ResponseWindow + params.overwriteStats logical = false % if true, force recompute of per-neuron statistics + params.useCategoryPvals logical = false % if true and splitBy is active, use per-category p-values (OR across levels) instead of general per-neuron p-values + params.nBootCategory double = 10000 % bootstrap iterations for StatisticsPerNeuronPerCategory + params.TakeTopPercentTrials double = 1 % fraction of trials to keep (1 = all; values <1 bias PSTH amplitudes — see note below) + params.zScore logical = false % z-score each neuron's PSTH to its own pre-stimulus baseline + params.PaperFig logical = false % export publication-quality figure via printFig + params.byDepth logical = false % stratify neurons into 3 cortical depth bins + params.unionResponsive logical = true % if true, include neurons responsive to ANY stimType (OR-union across stim types) +end + +% ------------------------------------------------------------------------- +% NOTE ON TakeTopPercentTrials (default = 1 = all trials) +% ------------------------------------------------------------------------- +% Selecting the top N% of trials by mean spike count inflates PSTH +% amplitudes and biases the response profile. For a publication figure +% this parameter should remain at 1 (all trials) unless there is a +% specific, pre-registered reason (e.g. attention gating in a behaving +% animal). +% ------------------------------------------------------------------------- + +% ------------------------------------------------------------------------- +% Guard: splitBy and byDepth together create too many lines to read +% ------------------------------------------------------------------------- +if params.splitBy ~= "" && params.byDepth + error(['splitBy and byDepth cannot both be active — the resulting ', ... + 'combinatorial line count is unreadable. Use one at a time.']); +end + +% ------------------------------------------------------------------------- +% Guard: unionResponsive requires ≥2 stim types to be meaningful +% ------------------------------------------------------------------------- +if params.unionResponsive && numel(params.stimTypes) < 2 + warning('unionResponsive has no effect with a single stimType — ignoring.'); + params.unionResponsive = false; % disable to avoid misleading output +end + +% ------------------------------------------------------------------------- +% Guard: unionResponsive + useCategoryPvals is logically ambiguous +% ------------------------------------------------------------------------- +if params.unionResponsive && params.useCategoryPvals + error(['unionResponsive and useCategoryPvals cannot both be true. ', ... + 'The union pre-pass uses general per-neuron p-values (statType). ', ... + 'Use one mode at a time.']); +end + +% ------------------------------------------------------------------------- +% Guard: requireAllStims only makes sense with ≥2 stim types +% ------------------------------------------------------------------------- +if params.requireAllStims && numel(params.stimTypes) < 2 + warning('requireAllStims has no effect with a single stimType — ignoring.'); + params.requireAllStims = false; % disable to avoid unnecessary experiment exclusions +end + +% ------------------------------------------------------------------------- +% Load depth-bin info if byDepth is requested +% ------------------------------------------------------------------------- +if params.byDepth + % Path to the precomputed depth table produced by getNeuronDepths() + depthFile = 'W:\Large_scale_mapping_NP\lizards\Combined_lizard_analysis\NeuronDepths.mat'; + if ~exist(depthFile, 'file') + error('NeuronDepths.mat not found. Run getNeuronDepths() first.'); + end + D = load(depthFile); % load the depth struct from disk + depthTable = D.depthTable; % table with columns Experiment, Unit, Depth_um + depthBinEdges = D.depthBinEdges; % 4-element vector of bin boundaries in µm + nDepthBins = 3; % three cortical depth bins: shallow / middle / deep + fprintf('Depth bins loaded:\n'); + fprintf(' Bin 1 (shallow): %.0f – %.0f µm\n', depthBinEdges(1), depthBinEdges(2)); + fprintf(' Bin 2 (middle) : %.0f – %.0f µm\n', depthBinEdges(2), depthBinEdges(3)); + fprintf(' Bin 3 (deep) : %.0f – %.0f µm\n', depthBinEdges(3), depthBinEdges(4)); +else + nDepthBins = 1; % no depth stratification: treat all neurons as one bin +end + +% ------------------------------------------------------------------------- +% Build save directory path using the first experiment as a reference +% ------------------------------------------------------------------------- +NP_first = loadNPclassFromTable(exList(1)); % load NP class for the first experiment to extract paths +vs_first = linearlyMovingBallAnalysis(NP_first); % construct a ball-analysis object just to access getAnalysisFileName + +basePath = extractBefore(vs_first.getAnalysisFileName, 'lizards'); % trim the path at 'lizards' to get the shared root +basePath = [basePath 'lizards']; % re-append 'lizards' to form the correct base +saveDir = fullfile(basePath, 'Combined_lizard_analysis'); % combined output directory +if ~exist(saveDir, 'dir') + mkdir(saveDir); % create directory if it doesn't already exist +end + +% ---- Construct the cache filename (encodes key parameter choices) ---- +stimLabel = strjoin(params.stimTypes, '-'); % e.g. 'RG-MB' +depthSuffix = ''; +if params.byDepth; depthSuffix = '_byDepth'; end % append depth tag if stratifying by depth + +splitSuffix = ''; +if params.splitBy ~= ""; splitSuffix = ['_by' char(params.splitBy)]; end % append split variable name to filename + +if ~isempty(params.splitLevels) + % Build a string of the requested levels, e.g. '_lvl5_10_20' + lvlStr = strjoin(arrayfun(@(v) sprintf('%g',v), params.splitLevels, ... + 'UniformOutput', false), '_'); + splitSuffix = [splitSuffix '_lvl' lvlStr]; +end + +allStimSuffix = ''; +if params.requireAllStims; allStimSuffix = '_allStims'; end % tag the cache file so it is distinct from the unfiltered version + +nameOfFile = sprintf('Ex_%d-%d_Combined_PSTHs_%s%s%s%s.mat', ... + exList(1), exList(end), stimLabel, splitSuffix, depthSuffix, allStimSuffix); +fullSavePath = fullfile(saveDir, nameOfFile); + +% ------------------------------------------------------------------------- +% Decide: recompute or load from disk +% ------------------------------------------------------------------------- +% The cache filename only encodes a few parameters (stim / split / depth / +% allStims). Many other parameters (binWidth, alpha, statType, preBase, +% postStim, zScore, TakeTopPercentTrials, speed, unionResponsive, +% useCategoryPvals) ALSO change the stored psthAll but are NOT in the +% filename. We therefore verify them explicitly via computationParamsMatch +% on load, so changing one of them and rerunning forces a recompute instead +% of silently loading a stale result. +% ------------------------------------------------------------------------- +forloop = true; % assume we need to recompute +if exist(fullSavePath, 'file') == 2 && ~params.overwrite + S = load(fullSavePath); % load the cached struct + if isequal(S.expList, exList) && isfield(S, 'params') && ... + computationParamsMatch(S.params, params) + fprintf('Loading saved PSTHs from:\n %s\n', fullSavePath); + forloop = false; % cached data matches — skip the computation loop + else + fprintf('Experiment list or computation params changed — recomputing.\n'); % stale cache; recompute + end +end + +% ========================================================================= +% MAIN COMPUTATION LOOP (skip if loaded from disk) +% ========================================================================= +if forloop + + nStim = numel(params.stimTypes); % number of stimulus types requested + nExp = numel(exList); % number of experiments in the list + + % ===================================================================== + % DISCOVERY PASS — find valid sessions and category levels + % ===================================================================== + % For each experiment × stim type: + % - If splitBy == "" : probe sessions [0, 1, 2] for stimulus PRESENCE + % (non-empty VST) and record the session it lives in (or -1). + % This is what lets requireAllStims work in the no-split case. + % - If splitBy ~= "" : additionally require the category column to + % have ≥2 usable levels (or all of splitLevels). + % + % Results are stored in sessionMap so the main loop can skip invalid + % experiments without re-searching. + % + % sessionMap(ei, s) = session to use (-1 = skip this exp×stim) + % catLabelsAll{s} = string array of category labels for stim s + % ===================================================================== + + sessionMap = zeros(nExp, nStim); % pre-allocate session map; 0 = default session, -1 = skip + catLabelsAll = cell(nStim, 1); % will store global category label strings per stim type + + if params.splitBy ~= "" + fprintf('\nDiscovering categories (splitBy = "%s") ...\n', params.splitBy); + else + fprintf('\nDiscovering stimulus presence per experiment ...\n'); % no split: presence/session discovery only + end + + for s = 1:nStim % iterate over each stimulus type + stimKey = params.stimTypes(s); % current stimulus abbreviation (e.g. "MB") + + if params.splitBy == "" + % ---- No split: still probe each experiment for stimulus + % PRESENCE and record the session it lives in (or -1). ---- + % FIX: previously this branch unconditionally set sessionMap to 0 + % for every experiment, so missing stimuli were never detected and + % requireAllStims had no effect. It also forced session 0 only. + catLabelsAll{s} = "all"; % single pseudo-category — no splitting + + for ei = 1:nExp + try + NP_tmp = loadNPclassFromTable(exList(ei)); % load NP class for this experiment + catch + sessionMap(ei, s) = -1; % experiment could not be loaded — mark skip + continue + end + + % Probe sessions [0, 1, 2]; return first session that has this stim + sess = findStimSession(NP_tmp, stimKey, params.overwriteResponse); + sessionMap(ei, s) = sess; % -1 if stim absent in all sessions + + if sess < 0 + fprintf(' Exp %d [%s]: stimulus not present in any session — will skip.\n', ... + exList(ei), stimKey); + else + fprintf(' Exp %d [%s]: stimulus present in session %d.\n', ... + exList(ei), stimKey, sess); + end + end + else + % ---- Split requested: scan each experiment for a valid session ---- + allLevelsFound = []; % accumulate all category levels seen across experiments + + for ei = 1:nExp + try + NP_tmp = loadNPclassFromTable(exList(ei)); % load NP class for this experiment + catch + sessionMap(ei, s) = -1; % experiment could not be loaded — mark as skip + continue + end + + % Try sessions [0, 1, 2]; return first with ≥2 valid levels + [~, sess, levels] = findValidSession( ... + NP_tmp, stimKey, params.speed, params.splitBy, ... + params.splitLevels, params.overwriteResponse); + + if sess < 0 + sessionMap(ei, s) = -1; % no usable session found for this stim — mark skip + fprintf(' Exp %d [%s]: no session with ≥2 levels of "%s" — will skip.\n', ... + exList(ei), stimKey, params.splitBy); + else + sessionMap(ei, s) = sess; % store the valid session number + allLevelsFound = [allLevelsFound; levels(:)]; %#ok accumulate levels seen in this experiment + fprintf(' Exp %d [%s]: session %d has levels [%s]\n', ... + exList(ei), stimKey, sess, num2str(levels(:)', '%g ')); + end + end + + % Determine the global set of category levels across all experiments + uniqueVals = unique(allLevelsFound); % sorted unique levels seen across all experiments + + % If user requested specific levels, restrict to the intersection + if ~isempty(params.splitLevels) + uniqueVals = intersect(uniqueVals, params.splitLevels(:)); + end + + if numel(uniqueVals) < 2 + % Not enough global levels to split — fall back to unsplit mode. + % NOTE: in this fall-back we set sessions to 0 (presence was + % NOT probed for the unsplit case here). If you intend to + % combine this fall-back with requireAllStims, prefer calling + % the function with splitBy == "" directly so presence is probed. + fprintf(' [%s] splitBy="%s" has only %d global level — falling back to unsplit.\n', ... + stimKey, params.splitBy, numel(uniqueVals)); + catLabelsAll{s} = "all"; % treat as unsplit + sessionMap(:, s) = 0; % reset all to default session + else + catLabelsAll{s} = string(uniqueVals(:)'); % store the final category labels as a string row vector + fprintf(' [%s] final category levels: %s\n', ... + stimKey, strjoin(catLabelsAll{s}, ', ')); + end + end + end + + % ===================================================================== + % EXPERIMENT-LEVEL FILTER: skip experiments missing any stim type + % (only applies when requireAllStims = true) + % ===================================================================== + % By default, an experiment missing stim A but having stim B will still + % contribute to stim B's PSTH. When requireAllStims = true, we exclude + % the entire experiment so that every line in the final plot reflects + % exactly the same population of experiments. This is the principled + % choice for cross-stim comparisons in publication figures. + % + % In the split case, an experiment is also excluded if it has the stim + % but fewer than 2 usable category levels (sessionMap was set to -1 + % above) — i.e. "missing stim" and "missing levels" both trigger + % exclusion. State this explicitly in Methods when reporting N. + % ===================================================================== + + if params.requireAllStims + skipExp = false(nExp, 1); % logical flag: should this experiment be skipped entirely? + + for ei = 1:nExp + if any(sessionMap(ei, :) < 0) % if any stim type has no valid session for this experiment... + skipExp(ei) = true; % ...mark the experiment for exclusion + fprintf(' requireAllStims: Exp %d excluded (missing ≥1 stim type).\n', exList(ei)); + end + end + + % Force session map to -1 for all stim types in excluded experiments + for ei = 1:nExp + if skipExp(ei) + sessionMap(ei, :) = -1; % mark all stim types as invalid for this experiment + end + end + + nKept = sum(~skipExp); % number of experiments that pass the filter + fprintf(' requireAllStims: %d / %d experiments kept (all stim types present).\n', ... + nKept, nExp); + + if nKept == 0 + error('requireAllStims: no experiments have all requested stim types [%s].', ... + strjoin(params.stimTypes, ', ')); % abort early — nothing to plot + end + end + + % ----- Find the maximum number of categories across stim types ------- + maxCats = max(cellfun(@numel, catLabelsAll)); % largest nCats across all stim types + + % ----- Pre-allocate psthAll: cell array of per-experiment mean traces ---- + % Dimensions: nStim × nDepthBins × maxCats + % Each cell accumulates one row per CONTRIBUTING experiment (mean firing + % rate vector). Skipped experiments simply do not append a row; because + % the plotting stage reduces each condition independently (it strips + % all-NaN rows and never relies on cross-stim row alignment), we no longer + % insert NaN placeholder rows for skipped experiments. + psthAll = cell(nStim, nDepthBins, maxCats); + + % ----- Time-axis parameters: locked on the first successful experiment ---- + lockedPreBase = []; % pre-stimulus baseline duration, locked once + lockedNBins = []; % number of time bins, locked once + lockedEdges = []; % bin edge vector in ms, locked once + + % ===================================================================== + % MAIN EXPERIMENT LOOP + % ===================================================================== + for ei = 1:nExp + + ex = exList(ei); % current experiment ID + fprintf('\n=== Experiment %d (%d/%d) ===\n', ex, ei, nExp); + + % ---- Check if this experiment was excluded by requireAllStims --- + if params.requireAllStims && all(sessionMap(ei, :) < 0) + fprintf(' Skipping exp %d (excluded by requireAllStims).\n', ex); + continue % advance to the next experiment (no row appended) + end + + % ---- Load NP class for this experiment -------------------------- + try + NP = loadNPclassFromTable(ex); % load the Neuropixels data class + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + continue + end + + % ================================================================= + % Union-responsive pre-pass + % + % For each stim type, find responsive neurons (p < alpha) using + % per-neuron p-values, then take the OR-union across stim types. + % The resulting index vector (unionENeurons) is applied to ALL + % stim types in the main loop, so every PSTH line reflects + % the same set of neurons within each experiment. + % + % Rationale: spike sorting is stimulus-agnostic — within ONE + % recording all stim objects share the same spikeSortingFolder, + % so goodU is identical across stim types and neuron indices are + % directly comparable. (If stim objects in a recording could ever + % point at different sorting folders, this index reuse would break; + % that assumption should be verified for the dataset.) + % + % Note: when requireAllStims = true, this pre-pass only runs on + % experiments that passed the completeness filter above, so the + % union is always built from ALL requested stim types (no stim + % will contribute an empty set due to a missing session). + % ================================================================= + if params.unionResponsive + eNeuronsPerStim = cell(1, nStim); % one responsive-neuron index vector per stim type + + for su = 1:nStim + stimTypeU = params.stimTypes(su); % stimulus abbreviation for this union pass + sessU = sessionMap(ei, su); % pre-validated session for this exp × stim + + if sessU < 0 % stim type missing or excluded — contributes no neurons + eNeuronsPerStim{su} = []; + continue + end + + % ---- Build analysis object for this stim type ----------- + try + objU = buildStimObject(NP, stimTypeU, sessU); % construct stim-specific analysis object + catch + eNeuronsPerStim{su} = []; + continue + end + if isempty(objU) + eNeuronsPerStim{su} = []; + continue + end + + % ---- Check stimulus was actually presented in this session ---- + try + stimMissingU = isempty(objU.VST); % VST = visual stimulus table; empty means stim not run + catch + stimMissingU = true; + end + if stimMissingU + eNeuronsPerStim{su} = []; + continue + end + + % ---- Ensure ResponseWindow is computed ------------------ + try + objU.ResponseWindow('overwrite', params.overwriteResponse); + catch + eNeuronsPerStim{su} = []; + continue + end + + % ---- Load statistics struct ----------------------------- + try + if params.statType == "BootstrapPerNeuron" + StatsU = objU.BootstrapPerNeuron; + elseif params.statType == "maxPermuteTest" + StatsU = objU.StatisticsPerNeuron; % permutation-test p-values per neuron + else + StatsU = objU.ShufflingAnalysis; + end + catch + eNeuronsPerStim{su} = []; + continue + end + + % ---- Extract p-values using the correct field name ------ + [fieldNameU, ~] = getFieldAndOffset(objU, stimTypeU, params.speed); + try + pvalsU = StatsU.(fieldNameU).pvalsResponse; % e.g. Stats.Speed1.pvalsResponse + catch + try + pvalsU = StatsU.pvalsResponse; % flat struct fallback (some older stim types) + catch + eNeuronsPerStim{su} = []; + continue + end + end + + eNeuronsPerStim{su} = find(pvalsU < params.alpha); % indices of neurons passing the significance threshold + end + + % ---- Take the OR-union across all stim types ---------------- + unionENeurons = []; % will hold sorted unique neuron indices + for su = 1:nStim + unionENeurons = union(unionENeurons, eNeuronsPerStim{su}); % union() returns sorted, unique indices + end + + fprintf(' Union-responsive: %d neuron(s) responsive to ≥1 of [%s] in exp %d.\n', ... + numel(unionENeurons), strjoin(params.stimTypes, ', '), ex); + + % ---- If the union is empty, skip this experiment entirely --- + if isempty(unionENeurons) + fprintf(' No neurons responsive to any stim type — skipping exp %d.\n', ex); + continue % advance to the next experiment (no row appended) + end + end + + % ================================================================= + % Per-experiment trace buffer + % ================================================================= + % Instead of appending each stim's mean trace to psthAll inside the + % stim loop, we first collect every stim's experiment-mean trace into + % expBuf and record which (stim, bin, cat) slots produced valid data + % in validBuf. The append to psthAll happens ONCE after the stim + % loop, governed by an inclusion rule (see below). This is what lets + % unionResponsive enforce matched experiments across stim lines: + % a kept experiment contributes to every stim line for a given + % (bin, cat), or to none — so the per-line N is necessarily equal. + expBuf = cell(nStim, nDepthBins, maxCats); % per-stim experiment-mean traces (or NaN row) + validBuf = false(nStim, nDepthBins, maxCats); % true where a real (non-all-NaN) trace was produced + + % ================================================================= + % Loop over stimulus types + % ================================================================= + for s = 1:nStim + + stimType = params.stimTypes(s); % current stimulus abbreviation + + % ---- Check session map: skip if no valid session was found -- + sess = sessionMap(ei, s); + if sess < 0 + fprintf(' [%s] Skipping exp %d (no valid session).\n', stimType, ex); + continue + end + + % ---- Construct the analysis object using the selected session ---- + try + obj = buildStimObject(NP, stimType, sess); % build stim-specific analysis object + catch ME + warning('Could not build %s (session %d) for exp %d: %s', ... + stimType, sess, ex, ME.message); + continue + end + + if isempty(obj) + continue + end + + % ---- Check that the stimulus was actually presented --------- + % The constructor may succeed even if this protocol was not run; + % VST being empty is the reliable indicator. + try + stimMissing = isempty(obj.VST); + catch + stimMissing = true; + end + if stimMissing + fprintf(' [%s] Stimulus not present in exp %d — skipping.\n', stimType, ex); + continue + end + + % ---- Ensure ResponseWindow is computed ---------------------- + try + obj.ResponseWindow('overwrite', params.overwriteResponse); + catch ME + warning(' [%s] ResponseWindow failed for exp %d: %s — skipping.', ... + stimType, ex, ME.message); + continue + end + + % ---- Load statistics (p-values per neuron) ------------------ + % IMPORTANT: only load per-stim Stats when we will actually use + % it for neuron selection (i.e. NOT unionResponsive). When + % unionResponsive is true the per-stim selection is discarded and + % replaced by the precomputed union, so loading Stats here is + % pointless — and worse, a Stats failure for ONE stim would + % `continue` and drop that stim's row for an experiment the union + % already kept, making the per-line N unequal (e.g. MB n=16 but + % SDGm n=14). Skipping the load keeps every kept experiment + % contributing to every stim line. + Stats = []; + if ~params.unionResponsive + try + if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; + elseif params.statType == "maxPermuteTest" + Stats = obj.StatisticsPerNeuron; % preferred: permutation-based p-value per neuron + else + Stats = obj.ShufflingAnalysis; + end + catch ME + warning(' [%s] Statistics failed for exp %d: %s — skipping.', ... + stimType, ex, ME.message); + continue + end + end + + % ---- Determine field name and stim-onset offset ------------- + [fieldName, startStim] = getFieldAndOffset(obj, stimType, params.speed); + + % ---- Get sorted good-unit spike data ------------------------ + p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); % load Phy-curated sorting results + label = string(p_sort.label'); % string array of unit labels ('good', 'mua', etc.) + goodU = p_sort.ic(:, label == "good"); % restrict to manually curated 'good' units only + + % ---- Extract condition matrix C and stimulus onset times ---- + C = getConditionMatrix(obj, stimType, params.speed); % each row = one trial; C(:,1) = onset time in ms + directimesSorted = C(:, 1)' + startStim; % shift onset times by the intra-stimulus offset (e.g. SDGm starts after static phase) + + % ---- Lock the time-axis on the first valid experiment ------- + % All experiments must share the same bin edges for the grand average to be valid. + preBase = params.preBase; % pre-stimulus baseline window in ms + windowTotal = preBase + params.postStim; % total window duration per trial in ms + + if isempty(lockedPreBase) + lockedPreBase = preBase; % store the baseline duration for this analysis run + lockedEdges = 0 : params.binWidth : windowTotal; % bin edges from 0 to windowTotal in steps of binWidth + lockedNBins = numel(lockedEdges) - 1; % number of time bins + fprintf(' Locked window: preBase=%d ms, postStim=%d ms, nBins=%d\n', ... + lockedPreBase, params.postStim, lockedNBins); + end + + % ---- Determine whether a category split is active ----------- + nCats = numel(catLabelsAll{s}); % number of category levels for this stim type + isSplitActive = params.splitBy ~= "" && ~isequal(catLabelsAll{s}, "all"); % true = split is in use + + % ---- Select responsive neurons ----------------------------- + % Priority order: + % (0) unionResponsive : use the precomputed OR-union across + % stim types. The experiment was already skipped above if + % this union was empty, so it is guaranteed non-empty here + % and IDENTICAL across all stim types → equal per-line N. + % This branch deliberately does not touch per-stim Stats. + % (a) useCategoryPvals + active split : OR across per-category + % p-values. + % (b) Default : overall per-neuron p-values (statType). + if params.unionResponsive + % ---- Mode (0): shared union set across stim types ------- + eNeurons = unionENeurons; % same set for every stim type in this experiment + fprintf(' [%s] Using union-responsive set: %d neuron(s) in exp %d.\n', ... + stimType, numel(eNeurons), ex); + elseif isSplitActive && params.useCategoryPvals + % ---- Mode (a): per-category p-values, OR across levels -- + try + catStats = obj.StatisticsPerNeuronPerCategory( ... + 'compareCategory', char(params.splitBy), ... + 'nBoot', params.nBootCategory, ... + 'overwrite', params.overwriteStats); + catch ME + warning(' [%s] StatisticsPerNeuronPerCategory failed for exp %d: %s — skipping.', ... + stimType, ex, ME.message); + continue + end + + % Start with a scalar false; first OR will broadcast to a logical vector + orMask = false; % will expand to [nNeurons × 1] on the first iteration + + for ci = 1:nCats + levelVal = str2double(catLabelsAll{s}(ci)); % numeric value of this category level + fName = levelToFieldName(params.splitBy, levelVal); % build field name, e.g. 'size_5' + if isfield(catStats, fName) + orMask = orMask | (catStats.(fName).pvalsResponse(:) < params.alpha); % OR in the mask for this level + end + end + eNeurons = find(orMask); % indices of neurons significant for at least one level + + fprintf(' [%s] Using per-category p-values: %d responsive neuron(s) in exp %d.\n', ... + stimType, numel(eNeurons), ex); + else + % ---- Mode (b): general per-neuron p-values -------------- + try + pvals = Stats.(fieldName).pvalsResponse; % p-values from the main statistics struct + catch + pvals = Stats.pvalsResponse; % flat struct fallback + end + eNeurons = find(pvals < params.alpha); % indices of neurons below the significance threshold + end + + if isempty(eNeurons) + fprintf(' [%s] No responsive neurons in exp %d.\n', stimType, ex); + continue + end + + % Per-stim count logging (the union branch already printed above) + if ~params.unionResponsive && ~(isSplitActive && params.useCategoryPvals) + fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', ... + stimType, numel(eNeurons), ex); + end + + % ---- Extract per-trial category column (only if splitting) -- + catValues = []; + if isSplitActive + catCol = getCategoryColumn(obj, stimType, params.speed, params.splitBy); % extract trial-by-trial category values + catValues = catCol(:)'; % row vector of category value per trial + end + + % ============================================================== + % Per-neuron PSTH computation + % ============================================================== + psthRateNeurons = NaN(numel(eNeurons), lockedNBins, nCats); % [nNeurons × nBins × nCats] — NaN-initialised + neuronBinIdx = zeros(numel(eNeurons), 1); % depth-bin assignment for each neuron (0 = no depth data) + baseMeanNeuron = nan(numel(eNeurons), 1); % per-neuron baseline mean (spk/s) for z-scoring + baseStdNeuron = nan(numel(eNeurons), 1); % per-neuron baseline SD (spk/s) for z-scoring + + for ni = 1:numel(eNeurons) + u = eNeurons(ni); % unit index into goodU + + % ---- Per-neuron baseline for z-scoring ------------------ + % Across-trial baseline statistics, pooled over ALL trials + % (the pre-stimulus period is stimulus-independent, so this is + % more stable than a per-category estimate and identical across + % the split levels). Matches sdBase in StatisticsPerNeuron and + % does NOT shrink with trial count, unlike the SD across PSTH bins. + if params.zScore + MRbase = BuildBurstMatrix( ... + goodU(:, u), ... % spike times for this unit + round(p_sort.t), ... % all sample times (rounded to ms) + round(directimesSorted - lockedPreBase), ... % ALL trial starts (onset - baseline) + round(windowTotal)); % window length in ms + MRbase = reshape(MRbase, numel(directimesSorted), []); % [nTrials × time]; reshape avoids singleton collapse + perTrialBase = mean(MRbase(:, 1:lockedPreBase), 2) * 1000; % [nTrials × 1] baseline rate (spk/s) + baseMeanNeuron(ni) = mean(perTrialBase); % across-trial baseline mean + baseStdNeuron(ni) = std(perTrialBase); % across-trial baseline SD + end + + % ---- Assign neuron to depth bin ------------------------- + if params.byDepth + depthRow = depthTable.Experiment == ex & depthTable.Unit == u; % find this unit's row in the depth table + if ~any(depthRow) + neuronBinIdx(ni) = 0; % unit not found in depth table — will be excluded from averaging + continue + end + unitDepth = depthTable.Depth_um(depthRow); % cortical depth of this unit in µm + unitDepth = unitDepth(1); % guard: take the first match if the table has duplicate (Experiment,Unit) rows + if unitDepth <= depthBinEdges(2) + neuronBinIdx(ni) = 1; % shallow bin + elseif unitDepth <= depthBinEdges(3) + neuronBinIdx(ni) = 2; % middle bin + else + neuronBinIdx(ni) = 3; % deep bin + end + else + neuronBinIdx(ni) = 1; % all neurons go to the single bin when byDepth is false + end + + % ---- Build PSTH for each category level ----------------- + for ci = 1:nCats + + % Build the trial selection mask for this category level + if ~isSplitActive + trialMask = true(size(directimesSorted)); % all trials belong to the single category + else + targetVal = str2double(catLabelsAll{s}(ci)); % numeric value of the current category level + trialMask = ismembertol(catValues, targetVal, 1e-3); % logical mask: trials matching this level + end + catOnsets = directimesSorted(trialMask); % onset times (ms) for the selected trials + + if isempty(catOnsets) + psthRateNeurons(ni, :, ci) = NaN(1, lockedNBins); % no trials for this level — leave as NaN + continue + end + + % Build binary spike matrix: rows = trials, columns = ms within window + MRhist = BuildBurstMatrix( ... + goodU(:, u), ... % spike times for this unit + round(p_sort.t), ... % all sample times (rounded to ms) + round(catOnsets - lockedPreBase), ... % trial start = onset minus baseline + round(windowTotal)); % window length in ms + MRhist = squeeze(MRhist); % remove the singleton neuron dimension + + % FIX: guard the single-trial case. squeeze() of a + % [1 × 1 × W] array yields a [W × 1] column vector, which + % would make the per-bin column indexing below address the + % wrong dimension. Force a [nTrials × W] orientation. + if numel(catOnsets) == 1 + MRhist = reshape(MRhist, 1, []); % single trial → guarantee a 1 × W row vector + end + + % ---- Optional: keep only top-N% of trials by mean spike count ---- + % WARNING: this inflates PSTH amplitudes and biases the response + % profile — see note above. Only activate with a principled reason. + if ~isempty(params.TakeTopPercentTrials) && params.TakeTopPercentTrials < 1 + MeanTrial = mean(MRhist, 2); % mean spike count per trial + [~, ind] = sort(MeanTrial, 'descend'); % sort trials from highest to lowest spike count + nKeep = max(1, round(numel(MeanTrial) * params.TakeTopPercentTrials)); % number of trials to retain + MRhist = MRhist(ind(1:nKeep), :); % keep only the top-N% trials + end + + nTrials = size(MRhist, 1); % number of trials (after optional trimming) + + % ---- Compute PSTH by direct bin summation ----------- + % Sum spikes within each bin, divide by nTrials and bin + % width (converted to seconds) to get spikes/second. + counts = zeros(1, lockedNBins); % per-bin spike counts, summed across trials + for bi = 1:lockedNBins + msStart = lockedEdges(bi) + 1; % first ms index of this bin (1-based) + msEnd = lockedEdges(bi + 1); % last ms index of this bin + counts(bi) = sum(MRhist(:, msStart:msEnd), 'all'); % total spikes in this bin across all trials + end + psthRateNeurons(ni, :, ci) = (counts / nTrials) / (params.binWidth / 1000); % convert to firing rate in spk/s + + end % category loop + end % neuron loop + + % ---- Report neurons dropped for lacking depth data ---------- + if params.byDepth + nNoDepth = sum(neuronBinIdx == 0); + if nNoDepth > 0 + fprintf(' [%s] %d neuron(s) excluded in exp %d (not in depth table).\n', ... + stimType, nNoDepth, ex); + end + end + + % ============================================================== + % Optional: z-score each neuron's PSTH to its own baseline + % Uses the across-trial baseline statistics computed per neuron + % in the loop above (one baseline per neuron, shared across all + % category levels) — comparable across stimuli with different + % trial counts, unlike the SD across PSTH bins. + % ============================================================== + if params.zScore + for ni = 1:size(psthRateNeurons, 1) + bMean = baseMeanNeuron(ni); % per-neuron baseline mean (spk/s) + bStd = baseStdNeuron(ni); % per-neuron baseline SD (spk/s) + for ci = 1:nCats + trace = psthRateNeurons(ni, :, ci); % firing-rate vector for this neuron × category + if all(isnan(trace)); continue; end % skip neuron×cat with no data + if bStd > 0 + psthRateNeurons(ni, :, ci) = (trace - bMean) / bStd; % z-score to across-trial baseline + else + psthRateNeurons(ni, :, ci) = NaN; % zero baseline variance — z-score undefined; exclude + end + end + end + end + + % ============================================================== + % Average across neurons per depth bin × category, then store + % the experiment-level mean trace in the per-experiment buffer. + % (The actual append to psthAll happens after the stim loop, so + % that inclusion can be made all-or-nothing across stim lines.) + % ============================================================== + for b = 1:nDepthBins + binNeurons = neuronBinIdx == b; % logical mask: neurons assigned to depth bin b + + if ~any(binNeurons) + % No neurons in this depth bin for this experiment — + % leave the buffer slot as a NaN row (validBuf stays false). + for ci = 1:nCats + expBuf{s, b, ci} = NaN(1, lockedNBins); + end + continue + end + + for ci = 1:nCats + catData = psthRateNeurons(binNeurons, :, ci); % [nBinNeurons × nBins] firing rates for this category + + if all(isnan(catData), 'all') + % All neurons NaN for this category — record a NaN row; + % validBuf stays false so this slot counts as "no data". + expBuf{s, b, ci} = NaN(1, lockedNBins); + else + psthExp = mean(catData, 1, 'omitnan'); % mean across neurons (grand-mean for this experiment) + expBuf{s, b, ci} = psthExp(:)'; % store as a row vector + validBuf(s, b, ci) = true; % mark this slot as having real data + end + end + + fprintf(' [%s] Depth bin %d: %d neuron(s) in exp %d.\n', ... + stimType, b, sum(binNeurons), ex); + end + + end % stim-type loop + + % ================================================================= + % Commit this experiment's traces to psthAll + % ================================================================= + % If the time axis was never locked, no stim produced any data for + % this experiment — nothing to commit. + if isempty(lockedNBins) + fprintf(' Exp %d produced no data for any stim — not committed.\n', ex); + continue + end + + if params.unionResponsive + % ---- Matched inclusion: per (bin, cat), commit a row to EVERY + % stim line only if ALL stims produced a valid trace for + % that slot. This guarantees equal per-line N: every line + % reflects exactly the same set of experiments. If any stim + % is missing data for a slot, the experiment is dropped from + % ALL stim lines for that slot (and a warning names it). ---- + for b = 1:nDepthBins + for ci = 1:maxCats + % Consider only stim types that actually define this cat index + stimsThisSlot = find(arrayfun(@(s) ci <= numel(catLabelsAll{s}), 1:nStim)); + if isempty(stimsThisSlot); continue; end + + allValid = all(validBuf(stimsThisSlot, b, ci)); % every relevant stim produced data? + + if allValid + for s = stimsThisSlot + psthAll{s, b, ci} = appendOrInit(psthAll{s, b, ci}, expBuf{s, b, ci}); + end + else + missing = stimsThisSlot(~validBuf(stimsThisSlot, b, ci)); + warning([' Exp %d (bin %d, cat %d): stim(s) [%s] produced no ', ... + 'usable trace; dropping this experiment from ALL stim ', ... + 'lines for this condition to keep N matched.'], ... + ex, b, ci, strjoin(cellstr(params.stimTypes(missing)), ', ')); + end + end + end + else + % ---- Independent inclusion: each stim line includes this + % experiment iff that stim produced a valid trace. Lines + % may therefore have different N (expected without union). ---- + for s = 1:nStim + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + if validBuf(s, b, ci) + psthAll{s, b, ci} = appendOrInit(psthAll{s, b, ci}, expBuf{s, b, ci}); + end + end + end + end + end + + end % experiment loop + + % ===================================================================== + % Save results to disk + % ===================================================================== + S.expList = exList; % store experiment list for cache-validity check on reload + S.lockedEdges = lockedEdges; % bin edge vector, needed to reconstruct tAxis on reload + S.lockedPreBase = lockedPreBase; % baseline duration, needed for tAxisPlot alignment + S.params = params; % full parameter struct for provenance / reproducibility and cache validation + + % Store catLabelsAll — needed to reconstruct category loop on reload + S.catLabelsAll = catLabelsAll; + + % Flatten psthAll cell array into named fields for safe .mat storage + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); % make the stim abbreviation a valid struct field name + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + fieldKey = sprintf('%s_bin%d_cat%d', stimField, b, ci); % unique field per stim × bin × category + S.(fieldKey) = psthAll{s, b, ci}; % [nExp × nBins] matrix for this condition + end + end + end + + save(fullSavePath, '-struct', 'S'); % save the struct fields as top-level variables in the .mat file + fprintf('\nSaved PSTHs to:\n %s\n', fullSavePath); + +else + % ================================================================= + % Reload psthAll from the saved struct S (cache hit) + % ================================================================= + lockedEdges = S.lockedEdges; % recover bin edges + lockedPreBase = S.lockedPreBase; % recover baseline duration + catLabelsAll = S.catLabelsAll; % recover category labels + + maxCats = max(cellfun(@numel, catLabelsAll)); % maximum number of categories across stim types + psthAll = cell(numel(params.stimTypes), nDepthBins, maxCats); % re-allocate cell array + + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); % re-derive the valid field name + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + fieldKey = sprintf('%s_bin%d_cat%d', stimField, b, ci); % re-construct the field key + if isfield(S, fieldKey) + psthAll{s, b, ci} = S.(fieldKey); % load the [nExp × nBins] matrix + else + warning('Field "%s" not found in saved file.', fieldKey); + psthAll{s, b, ci} = []; % leave empty if field is missing + end + end + end + end +end + +% ========================================================================= +% PLOTTING +% ========================================================================= + +tAxis = lockedEdges(1:end-1); % left bin edges within the analysis window (ms) +tAxisPlot = tAxis - lockedPreBase; % shift so time 0 = stimulus onset + +% ---- Colour palette and legend label maps -------------------------------- +nStim = numel(params.stimTypes); % number of stimulus types (re-read from params in case forloop was skipped) +baseColors = lines(nStim); % default MATLAB colour cycle, one colour per stim type + +% Map stimulus abbreviations to readable legend labels +stimLegendMap = containers.Map( ... + {'MB', 'MBR', 'RG', 'SDGm', 'SDGs', 'NV', 'NI', 'FFF'}, ... + {'MB', 'MBR', 'RG', 'SDGm', 'SDGs', 'NV', 'NI', 'FFF'}); + +depthShades = [0.05, 0.45, 0.78]; % brightness multipliers for depth bins (shallow = brightest) +binLabels = {'shallow', 'middle', 'deep'}; % human-readable depth-bin labels + +% ---- First pass: smooth traces, compute mean & SEM, find global ylim --- +yMax = -Inf; % running maximum across all plotted conditions +yMin = Inf; % running minimum across all plotted conditions + +maxCatsPlot = max(cellfun(@numel, catLabelsAll)); % re-derive for plotting (in case forloop was skipped) +meanStore = cell(nStim, nDepthBins, maxCatsPlot); % mean PSTH traces keyed by [stim × bin × cat] +semStore = cell(nStim, nDepthBins, maxCatsPlot); % SEM traces keyed by [stim × bin × cat] +nExpStore = zeros(nStim, nDepthBins, maxCatsPlot); % number of valid experiments per condition + +for s = 1:nStim + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + data = psthAll{s, b, ci}; % [nExp × nBins] matrix for this condition + if isempty(data); continue; end + + validRows = ~all(isnan(data), 2); % exclude experiments that contributed all-NaN rows + data = data(validRows, :); + if isempty(data); continue; end + + nValid = size(data, 1); % number of experiments with real data for this condition + nExpStore(s, b, ci) = nValid; + + % Smooth each experiment's trace individually BEFORE computing + % the mean and SEM. This preserves trial-to-trial variability + % in the SEM estimate while applying the same smoothing uniformly. + if params.smooth > 0 + smoothBins = max(1, round(params.smooth / params.binWidth)); % convert ms→bins; guard against rounding to 0 + for ri = 1:nValid + data(ri, :) = smoothdata(data(ri, :), 'gaussian', smoothBins); % Gaussian-weighted kernel smoothing + end + end + + meanTrace = mean(data, 1, 'omitnan'); % grand mean across experiments + semTrace = std(data, 0, 1, 'omitnan') / sqrt(nValid); % SEM across experiments + + meanStore{s, b, ci} = meanTrace; % store for plotting + semStore{s, b, ci} = semTrace; + + yMax = max(yMax, max(meanTrace + semTrace)); % update global y-axis maximum + yMin = min(yMin, min(meanTrace - semTrace)); % update global y-axis minimum + end + end +end + +% Guard: if no condition produced finite data, fall back to a default range +% so that ylim() does not error on [Inf, -Inf]. +if ~isfinite(yMax) || ~isfinite(yMin) + warning('No finite PSTH data to plot — using default y-limits.'); + yMin = 0; yMax = 1; +end + +% Add 10% padding to the y-axis range +yPad = (yMax - yMin) * 0.1; +if yPad == 0; yPad = max(abs(yMax), 1) * 0.1; end % guard against a zero-width range (single flat trace) +if params.zScore + yLims = [yMin - yPad, yMax + yPad]; % z-scored data can be negative — allow full range +else + yLims = [max(0, yMin - yPad), yMax + yPad]; % firing rates cannot be negative — clamp lower bound at 0 +end + +% ---- Create figure ------------------------------------------------------- +fig = figure; +set(fig, 'Units', 'centimeters', 'Position', [5 5 10 7]); % set figure size in cm for reproducible layout +ax = axes(fig); % create a single axes in the figure +hold(ax, 'on'); % allow multiple plot calls without clearing + +legendHandles = []; % accumulate plot handles for the legend +legendLabels = {}; % accumulate matching label strings + +% ---- Build per-stim colour maps for category splits -------------------- +catColorMaps = cell(nStim, 1); % one [nCats × 3] colour matrix per stim type + +% Perceptually distinct base colours (blue, orange, purple) +basePalette = [ + 0.0000 0.4470 0.7410 % blue + 0.8500 0.3250 0.0980 % orange + 0.4940 0.1840 0.5560 % purple + ]; + +for s = 1:nStim + + nc = numel(catLabelsAll{s}); % number of category levels for this stim type + cmap = zeros(nc, 3); % colour matrix for this stim type + + for ci = 1:nc + baseIdx = mod(ci-1, size(basePalette,1)) + 1; % cycle through base colours + shadeIdx = floor((ci-1) / size(basePalette,1)); % increment shade tier after one full cycle + baseC = basePalette(baseIdx,:); % selected base colour + + if shadeIdx == 0 + newC = baseC; % first cycle: full-saturation colour + else + newC = baseC + (1-baseC)*0.45; % subsequent cycles: lighter tint + end + + cmap(ci,:) = min(max(newC,0),1); % clamp to [0,1] to avoid out-of-range RGB values + end + + catColorMaps{s} = cmap; % store colour map for this stim type +end + +% ---- Plot each stim × depth bin × category condition -------------------- +for s = 1:nStim + + stimKey = char(params.stimTypes(s)); % current stimulus abbreviation as char for map lookup + if isKey(stimLegendMap, stimKey) + shortName = stimLegendMap(stimKey); % readable label from the map + else + shortName = stimKey; % fallback: use the abbreviation directly + end + + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + + meanPSTH = meanStore{s, b, ci}; % grand-mean trace for this condition + semPSTH = semStore{s, b, ci}; + if isempty(meanPSTH); continue; end % skip conditions with no valid data + + nValid = nExpStore(s, b, ci); % number of experiments contributing to this condition + + % ---- Choose line colour and legend text -------------------- + isSplitHere = params.splitBy ~= "" && ~isequal(catLabelsAll{s}, "all"); % true = this stim type is being split + + if params.byDepth + % Depth-stratified: darken the base colour by depth bin + lineColor = baseColors(s,:) * (1 - depthShades(b)); + legendLabel = sprintf('%s %s (%.0f–%.0f µm, n=%d)', ... + shortName, binLabels{b}, ... + depthBinEdges(b), depthBinEdges(b+1), nValid); + elseif isSplitHere + % Category split: each level gets a distinct colour from catColorMaps + lineColor = catColorMaps{s}(ci, :); + legendLabel = sprintf('%s (n=%d)', catLabelsAll{s}(ci), nValid); + else + % Default: one colour per stim type + lineColor = baseColors(s,:); + legendLabel = sprintf('%s (n=%d)', shortName, nValid); + end + + % ---- SEM shading ------------------------------------------- + if params.shadeSTD && nValid > 1 + upper = meanPSTH + semPSTH; % upper bound of the shaded region + lower = meanPSTH - semPSTH; % lower bound + xFill = [tAxisPlot(:)', fliplr(tAxisPlot(:)')]; % x coordinates: forward then backward for closed polygon + yFill = [upper(:)', fliplr(lower(:)')]; % y coordinates: upper then lower (reversed) + fill(ax, xFill, yFill, lineColor, ... + 'FaceAlpha', 0.08, 'EdgeColor', 'none'); % semi-transparent filled polygon, no border + end + + % ---- Mean PSTH line ---------------------------------------- + h = plot(ax, tAxisPlot(:)', meanPSTH(:)', ... + 'Color', lineColor, 'LineWidth', 1.5); % plot mean as a solid line + + legendHandles(end+1) = h; %#ok collect handle for legend + legendLabels{end+1} = legendLabel; %#ok collect label for legend + + end % category loop + end % depth-bin loop +end % stim-type loop + +% ---- Reference lines at stimulus onset and offset ----------------------- +xline(ax, 0, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); % vertical dashed line at t = 0 (stim onset) +xline(ax, params.postStim, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); % vertical dashed line at stim offset + +% ---- Axis labels and formatting ----------------------------------------- +if params.zScore + yLabel = 'Z-score'; % z-scored mode: dimensionless +else + yLabel = 'Firing rate [spk/s]'; % default: spikes per second +end + +xlabel(ax, 'Time re. stim onset [ms]', 'FontName', 'Helvetica', 'FontSize', 8); +ylabel(ax, yLabel, 'FontName', 'Helvetica', 'FontSize', 8); +xlim(ax, [tAxisPlot(1), tAxisPlot(end)]); % set x range to match the locked window +ylim(ax, yLims); % apply the globally computed y limits + +legend(legendHandles, legendLabels, ... + 'Location', 'best', ... + 'FontName', 'Helvetica', ... + 'FontSize', 7); + +ax.FontName = 'Helvetica'; % set all axis text to Helvetica +ax.FontSize = 8; +ax.YAxis.FontSize = 8; +ax.XAxis.FontSize = 8; +hold(ax, 'off'); + +% ---- Title: report the ACTUAL number of contributing experiments -------- +% nExpStore holds the true per-condition experiment counts; the maximum over +% conditions is the number of experiments that contributed at least one line. +% Reporting numel(exList) here would overstate N after skips / filtering. +nContrib = max(nExpStore(:)); +if params.requireAllStims + titleStr = sprintf('N = %d experiments (all stim types present)', nContrib); +else + titleStr = sprintf('N = %d experiments', nContrib); +end +title(ax, titleStr, 'FontName', 'Helvetica', 'FontSize', 10); + +% ---- Export publication figure if requested ----------------------------- +if params.PaperFig + stimStr = strjoin(params.stimTypes, '-'); % join stim abbreviations for the filename + vs_first.printFig(fig, sprintf('PSTH-%s%s%s%s', stimStr, splitSuffix, depthSuffix, allStimSuffix), ... + PaperFig = params.PaperFig); +end + +end % end of main function + + +% ######################################################################### +% LOCAL HELPER FUNCTIONS +% ######################################################################### + + +function validSession = findStimSession(NP, stimKey, overwriteRW) +% findStimSession Probe sessions [0, 1, 2] and return the first session in +% which the stimulus was actually presented (non-empty VST) and whose +% ResponseWindow is computable. Returns -1 if the stimulus is absent from +% all sessions. +% +% Used for the no-split case (splitBy == ""), where category levels are +% irrelevant but stimulus PRESENCE must still be verified — this is what +% allows requireAllStims to exclude experiments missing a stim type, and +% ensures the correct session is selected rather than always assuming 0. + +validSession = -1; % default: stimulus not found in any session + +for sess = [0, 1, 2] + candidate = buildStimObject(NP, stimKey, sess); % attempt to build analysis object for this session + if isempty(candidate) + continue % construction failed — try next session + end + + % Was the stimulus actually presented in this session? + try + if isempty(candidate.VST) + continue % VST empty → stim not run in this session + end + catch + continue + end + + % Must be computable downstream (the main loop relies on this) + try + candidate.ResponseWindow('overwrite', overwriteRW); + catch + continue % ResponseWindow failed — try next session + end + + validSession = sess; % first valid session found + return +end +end + + +function [obj, validSession, levels] = findValidSession(NP, stimKey, speedParam, splitBy, splitLevels, overwriteRW) +% findValidSession Try sessions [0, 1, 2] for a stimulus and return the +% first session whose category column (splitBy) has ≥2 usable levels. +% If splitLevels is non-empty, ALL requested levels must be present. +% +% INPUTS +% NP — loaded NP class for this experiment +% stimKey — stimulus abbreviation (e.g. "MB", "RG") +% speedParam — "max" or other speed selector +% splitBy — category column name (e.g. "size") +% splitLevels — specific levels required (numeric vector, or []) +% overwriteRW — logical: force recompute of ResponseWindow +% +% OUTPUTS +% obj — analysis object for the valid session (or []) +% validSession — session number (0, 1, or 2), or -1 if none found +% levels — unique category levels found in the chosen session + +obj = []; % default: no valid object found +validSession = -1; % default: no valid session found +levels = []; % default: no levels found + +for sess = [0, 1, 2] + candidate = buildStimObject(NP, stimKey, sess); % attempt to build analysis object for this session + if isempty(candidate) + continue % object construction failed — try next session + end + + % Check that the stimulus was actually presented in this session + try + if isempty(candidate.VST) + continue % VST empty → stim not run + end + catch + continue + end + + % Ensure ResponseWindow is computed (required to access C and colNames) + try + candidate.ResponseWindow('overwrite', overwriteRW); + catch + continue % ResponseWindow computation failed — try next session + end + + % Extract condition matrix and column names from ResponseWindow + rw = candidate.ResponseWindow; + [C, colNames] = getCmatrixLocal(rw, stimKey, speedParam); % C: trial × parameter matrix; colNames: parameter names only + if isempty(C) || isempty(colNames) + continue % condition matrix unavailable — try next session + end + + % Locate the requested category column by name + catIdx = find(strcmpi(colNames, splitBy), 1); % case-insensitive match against parameter names + if isempty(catIdx) + continue % splitBy column not present in this stim type + end + + % Column layout: colNames(k) → C(:, k+1) because C(:,1) = onset times + catColIdx = catIdx + 1; % offset by 1 to account for the onset column at C(:,1) + rawCol = C(:, catColIdx); % raw category values for all trials + rawCol = rawCol(~isnan(rawCol)); % remove NaN rows (incomplete trials) + availLevels = uniquetol(rawCol, 1e-6); % unique levels with floating-point tolerance + + % If specific levels were requested, verify that ALL are present + if ~isempty(splitLevels) + allPresent = true; + for lv = splitLevels(:)' + if ~any(abs(availLevels - lv) < 1e-6) + allPresent = false; % at least one requested level is absent + break + end + end + if ~allPresent + continue % required levels missing — try next session + end + useLevels = splitLevels(:); % restrict to the requested subset + else + useLevels = availLevels; % use all available levels + end + + % Need at least 2 levels to make a split meaningful + if numel(useLevels) < 2 + continue + end + + % Valid session found — store results and return + obj = candidate; + validSession = sess; + levels = useLevels; + return +end +end + + +function [C, colNames] = getCmatrixLocal(rw, stimKey, speedParam) +% getCmatrixLocal Extract condition matrix C and parameter column names +% from a ResponseWindow struct. +% +% colNames = stimulus-parameter names only (rw.colNames{1}(5:end)). +% C(:,1) = onset times (ms). +% C(:, k+1) = parameter column for colNames(k). + +C = []; +colNames = {}; + +% colNames{1}(1:4) are internal bookkeeping columns; parameters start at index 5 +try + allColNames = rw.colNames{1}; + colNames = allColNames(5:end); % strip the 4 fixed bookkeeping columns +catch + return % colNames unavailable — cannot proceed +end + +% Select C from the correct sub-field based on stimulus type +switch stimKey + case {"MB", "MBR"} + % Moving-ball/bar stimuli: choose speed field + if speedParam == "max" + fld = 'Speed1'; % 'Speed1' = fastest speed condition + else + fld = 'Speed2'; + end + if isfield(rw, fld) + C = rw.(fld).C; + else + % Fall back to the last speed field found + speedFields = fieldnames(rw); + speedFields = speedFields(startsWith(speedFields, 'Speed')); + if ~isempty(speedFields) + C = rw.(speedFields{end}).C; + end + end + + case "SDGm" + % Drifting-grating moving phase + if isfield(rw, 'C') + C = rw.C; + elseif isfield(rw, 'Moving') && isfield(rw.Moving, 'C') + C = rw.Moving.C; + end + + case "SDGs" + % Static grating phase + if isfield(rw, 'C') + C = rw.C; + elseif isfield(rw, 'Static') && isfield(rw.Static, 'C') + C = rw.Static.C; + end + + otherwise + % Generic fallback: use top-level C field + if isfield(rw, 'C') + C = rw.C; + end +end +end + + +function obj = buildStimObject(NP, stimKey, session) +% buildStimObject Construct the analysis object for a stimulus key, +% optionally with a specific session number. +% +% obj = buildStimObject(NP, "MB", 0) — default (no Session arg) +% obj = buildStimObject(NP, "MB", 1) — Session=1 +% obj = buildStimObject(NP, "MB", 2) — Session=2 +% +% Returns [] if construction fails or stimKey is unrecognised. + +if nargin < 3; session = 0; end % default to session 0 if not specified + +obj = []; +try + % SDGm and SDGs are both served by StaticDriftingGratingAnalysis + switch stimKey + case {"SDGm", "SDGs"}, ctorKey = "SDG"; % map both moving and static phases to the same constructor key + otherwise, ctorKey = stimKey; + end + + if session == 0 + % Default session: call constructor without a Session argument + switch ctorKey + case "MB", obj = linearlyMovingBallAnalysis(NP); + case "MBR", obj = linearlyMovingBarAnalysis(NP); + case "RG", obj = rectGridAnalysis(NP); + case "SDG", obj = StaticDriftingGratingAnalysis(NP); + case "NV", obj = movieAnalysis(NP); + case "NI", obj = imageAnalysis(NP); + case "FFF", obj = fullFieldFlashAnalysis(NP); + otherwise, error('Unknown stimulus key: "%s".', stimKey); + end + else + % Non-default session: pass Session as a name-value pair + switch ctorKey + case "MB", obj = linearlyMovingBallAnalysis(NP, 'Session', session); + case "MBR", obj = linearlyMovingBarAnalysis(NP, 'Session', session); + case "RG", obj = rectGridAnalysis(NP, 'Session', session); + case "SDG", obj = StaticDriftingGratingAnalysis(NP, 'Session', session); + case "NV", obj = movieAnalysis(NP, 'Session', session); + case "NI", obj = imageAnalysis(NP, 'Session', session); + case "FFF", obj = fullFieldFlashAnalysis(NP, 'Session', session); + otherwise, error('Unknown stimulus key: "%s".', stimKey); + end + end +catch ME + fprintf(' Could not create %s Session=%d: %s\n', stimKey, session, ME.message); + obj = []; % return empty on any constructor failure +end +end + + +function [fieldName, startStim] = getFieldAndOffset(obj, stimKey, speedParam) +% getFieldAndOffset Return the response-table field name and the +% intra-stimulus onset offset (ms) for the given stimulus abbreviation. +% +% For SDGm, startStim accounts for the static phase that precedes the +% moving phase; the offset shifts trial onsets to the moving-phase start. + +startStim = 0; % default: stimulus onset coincides with the trial onset + +switch stimKey + case "MB" + if speedParam == "max"; fieldName = 'Speed1'; else; fieldName = 'Speed2'; end + case "MBR" + if speedParam == "max"; fieldName = 'Speed1'; else; fieldName = 'Speed2'; end + case "SDGs" + fieldName = 'Static'; % static-grating phase sub-field + case "SDGm" + fieldName = 'Moving'; % moving-grating phase sub-field + startStim = obj.VST.static_time * 1000; % static phase duration (s→ms) as onset offset + otherwise + fieldName = 'Speed1'; % generic fallback — valid for RG, NV, NI, FFF +end +end + + +function C = getConditionMatrix(obj, stimType, speedParam) +% getConditionMatrix Extract the condition matrix C from ResponseWindow. +% +% C(:,1) = trial onset times (ms). +% C(:,2:end) = stimulus parameters. + +[fieldName, ~] = getFieldAndOffset(obj, stimType, speedParam); % get the correct sub-field name +NeuronResp = obj.ResponseWindow; % full ResponseWindow struct + +try + C = NeuronResp.(fieldName).C; % retrieve C from the speed/phase sub-field +catch + C = NeuronResp.C; % fallback: top-level C (some stim types) +end +end + + +function catCol = getCategoryColumn(obj, stimType, speedParam, splitBy) +% getCategoryColumn Extract the per-trial category column from C by +% matching splitBy against the ResponseWindow parameter column names. +% +% Column layout (ResponseWindow): +% colNames{1}(1:4) = internal bookkeeping (discarded) +% colNames{1}(5:end) = stimulus-parameter names +% C(:,1) = onset times +% C(:,2:) = parameter columns +% → paramNames(k) maps to C(:, k+1) + +responseParams = obj.ResponseWindow; % ResponseWindow struct + +allColNames = responseParams.colNames{1}; % full column name list including bookkeeping headers +paramNames = allColNames(5:end); % parameter names only (strip the first 4 bookkeeping columns) + +matchIdx = find(strcmpi(paramNames, splitBy), 1); % case-insensitive search for splitBy column +if isempty(matchIdx) + error(['splitBy = "%s" does not match any column in colNames.\n' ... + ' Available: %s'], splitBy, strjoin(string(paramNames), ', ')); +end + +colIdxInC = matchIdx + 1; % add 1 because C(:,1) = onset times (paramNames(1) → C(:,2)) + +C = getConditionMatrix(obj, stimType, speedParam); % retrieve the full condition matrix +catCol = C(:, colIdxInC); % extract the matching column +end + + +function arr = appendOrInit(arr, newRow) +% appendOrInit Append newRow to arr, or initialise arr as newRow if empty. +% +% Used to accumulate per-experiment PSTH rows without pre-allocating +% the exact number of experiments. +if isempty(arr) + arr = newRow; % first experiment: initialise the matrix +else + arr = [arr; newRow]; % subsequent experiments: vertical concatenation +end +end + + +function tf = computationParamsMatch(a, b) +% computationParamsMatch True only if all parameters that affect the STORED +% psthAll are identical between two parameter structs. This is the cache- +% validity check used on reload: changing any of these and rerunning the +% same exList must force a recompute rather than load a stale result. +% +% Excluded fields are plotting-only or IO-only and do NOT change psthAll: +% smooth (applied at plot time), shadeSTD, PaperFig, +% overwrite, overwriteResponse, overwriteStats. + +flds = {'stimTypes', 'requireAllStims', 'splitBy', 'splitLevels', ... + 'binWidth', 'statType', 'speed', 'alpha', 'postStim', 'preBase', ... + 'useCategoryPvals', 'nBootCategory', 'TakeTopPercentTrials', ... + 'zScore', 'byDepth', 'unionResponsive'}; + +tf = true; +for k = 1:numel(flds) + f = flds{k}; + if ~isfield(a, f) || ~isfield(b, f) || ~isequal(a.(f), b.(f)) + tf = false; % any mismatch (or missing field) → cache is stale + return + end +end +end + + +function fName = levelToFieldName(catName, value) +% levelToFieldName Build a valid struct field name matching the convention +% used by StatisticsPerNeuronPerCategory. +% +% Examples: +% levelToFieldName("size", 5) → 'size_5' +% levelToFieldName("speed", 0.3) → 'speed_0p3' +% levelToFieldName("dir", -90) → 'dir_neg90' + +fName = sprintf('%s_%g', lower(strtrim(char(catName))), value); % base name, e.g. 'size_5' or 'speed_0.3' +fName = strrep(fName, '.', 'p'); % replace decimal point with 'p' (invalid in field names) +fName = strrep(fName, '-', 'neg'); % replace minus sign with 'neg' (invalid in field names) +end \ No newline at end of file diff --git a/visualStimulationAnalysis/plotRaster_MultiExp.m b/visualStimulationAnalysis/plotRaster_MultiExp.m new file mode 100644 index 0000000..d504473 --- /dev/null +++ b/visualStimulationAnalysis/plotRaster_MultiExp.m @@ -0,0 +1,1770 @@ +function plotRaster_MultiExp(exList, params) +% plotRaster_MultiExp Build and display a multi-experiment raster plot. +% +% plotRaster_MultiExp(exList, params) +% +% For each experiment in exList the function: +% 1. Loads spike-sorted data for each stimulus type. +% 2. Identifies statistically responsive neurons. +% 3. Builds a per-neuron PSTH (optionally z-scored and smoothed). +% 4. Sorts neurons by peak-response time, recording depth, +% spatial-tuning index, preferred category level, or leaves +% them unsorted. +% 5. Displays an imagesc raster for each stimulus type side-by-side, +% with a shared x-label and a single colorbar. +% +% ------------------------------------------------------------------------- +% CHANGE LOG +% ------------------------------------------------------------------------- +% BUG 1 (fixed earlier) — Sort-by-peak double-smoothing. +% BUG 2 (fixed earlier) — Wrong colour limits for zScore + gray. +% BUG 3 (fixed earlier) — Stim-offset xline in wrong units. +% FIX 4 — Trial selection now uses post-onset window only, avoiding +% bias toward high-baseline trials. +% FIX 5 — Zero-SD neurons are counted and logged instead of silently +% dropped. +% FIX 6 — TakeTopPercentTrials = 0 is handled explicitly. +% FIX 7 — Baseline/binWidth alignment assertion added. +% FIX 8 — Diverging colormap warns when climNeg = 0. +% FIX 9 — Accumulator alignment assertion after experiment loop. +% FIX A — depthFile is now loaded outside the forloop guard, preventing +% a crash when reloading from cache with sortBy="depth". +% FIX B — Figure size override removed; single nStim-scaled sizing. +% FIX C — printFig filename now joins splitCategory safely. +% FIX D — Cache key includes numel(exList) to reduce collision risk. +% FIX E — smoothdata window parameter documented correctly. +% FIX F — Dead gt assignment removed from main-loop switch preamble. +% NEW — sortBy = "spatialTuning": sort neurons by a column from an +% external spatial-tuning table, matched via Phy cluster ID. +% NEW — phyAll accumulator tracks Phy cluster IDs for each neuron row. +% NEW — sortBy = "preferredCategory": group neurons by their preferred +% category level (e.g. direction, size), show only +% preferred-level trials in each PSTH row, secondary sort by +% mean post-onset firing rate within each group. +% NEW — unionUnits mode: neurons are matched across all stimulus +% panels so that row k is the same physical neuron everywhere. +% The union of per-stim responsive sets is used; all stim types +% must be present for each included experiment. +% NEW — "SDG" combined stimulus type: single-panel raster that shows +% the static grating phase as the main stimulus window, with the +% moving phase appearing as post-stimulus time. A second xline +% marks the static-to-moving transition. + +arguments + exList double % vector of experiment IDs to include + params.stimTypes (1,:) string = ["RG","MB"] % stimulus abbreviations — one tile each (RG|MB|MBR|SDGs|SDGm|SDG|NI|NV|FFF) + params.binWidth double = 10 % PSTH bin width in ms + params.smooth double = 0 % Gaussian smoothing SD in ms (0 = off) + params.statType string = "MaxPermuteTest" % which statistics field to use + params.speed string = "max" % ball-speed selector: "max" or other + params.alpha double = 0.05 % significance threshold + params.postStim double = 0 % post-stimulus window in ms + params.preBase double = 200 % pre-stimulus baseline in ms + params.overwrite logical = false % if true, recompute even if cache exists + params.TakeTopPercentTrials double = 0 % fraction (0,1] of trials to keep; [] or 0 = keep all + params.zScore logical = true % z-score each neuron using its baseline + params.sortBy string = "none" % "peak" | "depth" | "spatialTuning" | "preferredCategory" | "none" + params.PaperFig logical = false % if true, export figure via printFig + params.climPrctile double = 90 % upper percentile for colour scale + params.climNeg double = 0 % fixed negative z-score colour limit + params.colormap string = "gray" % "gray" -> flipud(gray); else -> diverging + params.GaussianLength = 5 % Gaussian kernel half-width (bins) for sort smoothing + params.useCompleteWindow logical = true % if true, use actual stim duration from data + % --- Spatial-tuning sort parameters --- + params.tuningIndexCol string = "L_amplitude_diff" % column in tuning table to sort by + params.tuningSortOrder string = "descend" % "descend" = most-tuned at top + params.tuningFile string = "" % full path to tuning .mat; "" = auto-construct + % --- Preferred-category sort parameters --- + params.splitCategory (1,:) string = "" % per-stim category: e.g. ["size","direction"]; scalar is broadcast + params.splitLevels cell = {} % per-stim levels: e.g. {[1 2],[0 90]}; {} = all levels for every stim + % --- Per-category statistics parameters (used when splitCategory is active) --- + params.nBootCategory double = 10000 % bootstrap iterations for StatisticsPerNeuronPerCategory + params.overwriteCatStats logical = false % force recomputation of per-category statistics + params.catBaseRespWindow double = 100 % base response window (ms) for per-category statistics + params.catApplyFDR logical = false % apply FDR inside StatisticsPerNeuronPerCategory + params.useGeneralFilter logical = false % true = use general StatisticsPerNeuron p-values even in category mode + % --- Union responsive units mode --- + params.unionUnits logical = false % if true, match neurons across stim panels (same row = same neuron) + % --- Preferred-category PSTH content (independent of sorting) --- + params.prefCatPSTH logical = false % if true: each panel shows each neuron's preferred-category PSTH + % (all trials of preferred level), via splitCategory/splitLevels; + % compatible with unionUnits (content only, does not reorder rows) +end + +% ------------------------------------------------------------------------- +% Sanity check: baseline must be an integer multiple of bin width +% ------------------------------------------------------------------------- +assert(mod(params.preBase, params.binWidth) == 0, ... % prevents misaligned baseline bin edges + 'preBase (%g ms) must be a multiple of binWidth (%g ms).', ... + params.preBase, params.binWidth); + +% ------------------------------------------------------------------------- +% Validate unionUnits constraints +% ------------------------------------------------------------------------- +if params.unionUnits + % preferredCategory sorts differently per stimulus, which would break + % the row-alignment guarantee. Block this combination up front. + assert(params.sortBy ~= "preferredCategory", ... + ['unionUnits = true is incompatible with sortBy = "preferredCategory" ' ... + '(per-stim sort breaks row alignment). For preferred-category content ' ... + 'with union sorting, use prefCatPSTH = true instead.']); + + % splitCategory under union is allowed ONLY for preferred-category PSTH + % content (prefCatPSTH=true), which changes what each panel displays but + % not the row order. It remains incompatible with preferredCategory + % *sorting* (blocked above), which would reorder rows per stim. + if ~params.prefCatPSTH + assert(all(strlength(params.splitCategory) == 0) || ... + (isscalar(params.splitCategory) && params.splitCategory == ""), ... + 'unionUnits = true requires empty splitCategory unless prefCatPSTH = true.'); + end + + % Union across stim types needs ≥2 types. Exception: a single "SDG" + % tile unions across the static and moving *phases* internally. + isSingleSDG = numel(params.stimTypes) == 1 && params.stimTypes == "SDG"; + assert(numel(params.stimTypes) >= 2 || isSingleSDG, ... + 'unionUnits = true requires at least 2 stimTypes, or a single "SDG" (phase-union).'); +end + +% ------------------------------------------------------------------------- +% Validate and broadcast preferredCategory parameters +% ------------------------------------------------------------------------- +if params.sortBy == "preferredCategory" || params.prefCatPSTH + + nStimTypes = numel(params.stimTypes); % number of stimulus types + + % --- Broadcast splitCategory --- + % A single string is applied to all stim types; a vector must match nStim. + % An empty string "" for a given slot means that stim uses all-trial mode. + if isscalar(params.splitCategory) + params.splitCategory = repmat(params.splitCategory, 1, nStimTypes); % broadcast scalar to all stim types + end + assert(numel(params.splitCategory) == nStimTypes, ... + 'splitCategory must be scalar or have one entry per stimType (%d).', nStimTypes); + + % --- Broadcast splitLevels --- + % An empty cell {} means "all levels" for every stim. + % A cell with one element is broadcast to all stim types. + % A cell with nStim elements maps one-to-one. + if isempty(params.splitLevels) + params.splitLevels = repmat({[]}, 1, nStimTypes); % all levels for every stim + elseif numel(params.splitLevels) == 1 + params.splitLevels = repmat(params.splitLevels, 1, nStimTypes); % broadcast single cell to all stim + end + assert(numel(params.splitLevels) == nStimTypes, ... + 'splitLevels must be empty, scalar cell, or have one entry per stimType (%d).', nStimTypes); + + % --- Ensure at least one stim has a non-empty category --- + assert(any(strlength(params.splitCategory) > 0), ... + 'sortBy="preferredCategory" requires at least one non-empty splitCategory entry.'); +end + +% ------------------------------------------------------------------------- +% Load depth table when sorting by cortical depth +% (FIX A: loaded unconditionally outside forloop to prevent scope crash +% when reloading from cache) +% ------------------------------------------------------------------------- +depthFile = ''; % initialise empty; only populated if needed +depthTable = []; % initialise empty +if params.sortBy == "depth" + depthFile = 'W:\Large_scale_mapping_NP\lizards\Combined_lizard_analysis\NeuronDepths.mat'; + if ~exist(depthFile, 'file') % abort if file is missing + error('NeuronDepths.mat not found. Run getNeuronDepths() first.'); + end + D = load(depthFile); % load depth struct + depthTable = D.depthTable; % table: Experiment, Unit, Depth_um +end + +% ------------------------------------------------------------------------- +% Derive save/load path from the first experiment +% ------------------------------------------------------------------------- +NP_first = loadNPclassFromTable(exList(1)); % load NP object for path info +vs_first = linearlyMovingBallAnalysis(NP_first); % analysis object for filesystem path + +p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); % root up to 'lizards' token +p = [p 'lizards']; % include 'lizards' folder + +if ~exist([p '\Combined_lizard_analysis'], 'dir') % create output dir if absent + cd(p) % change to parent dir + mkdir Combined_lizard_analysis % create sub-directory +end +saveDir = [p '\Combined_lizard_analysis']; % output directory + +stimLabel = strjoin(params.stimTypes, '-'); % e.g. "RG-MB" + +% --- Include splitCategory in cache filename to avoid collisions --- +% Join all per-stim categories into a compact suffix, e.g. "_cat-size-direction" +if params.sortBy == "preferredCategory" || params.prefCatPSTH + nonEmpty = params.splitCategory(strlength(params.splitCategory) > 0); % only non-empty categories + catSuffix = sprintf('_cat-%s', strjoin(nonEmpty, '-')); % e.g. "_cat-size-direction" +else + catSuffix = ''; % no suffix for other sort modes +end + +% --- Include union flag in cache filename --- +if params.unionUnits + unionSuffix = '_union'; % distinguish union cache from standard +else + unionSuffix = ''; % no suffix for standard mode +end + +% FIX D: include numel(exList) to reduce cache key collisions when +% experiment lists share the same first/last ID but differ in between. +nameOfFile = sprintf('\\Ex_%d-%d_n%d_Raster_%s%s%s.mat', ... + exList(1), exList(end), numel(exList), stimLabel, catSuffix, unionSuffix); % cache filename + +% ------------------------------------------------------------------------- +% Decide whether to recompute or reload from cache +% ------------------------------------------------------------------------- +if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite % cache exists and overwrite not forced + S = load([saveDir nameOfFile]); % load cached struct + if isequal(S.expList, exList) % cached list matches current request + fprintf('Loading saved raster data from:\n %s\n', [saveDir nameOfFile]); + forloop = false; % skip computation + else + fprintf('Experiment list mismatch — recomputing.\n'); + forloop = true; % mismatch: recompute + end +else + forloop = true; % no cache or overwrite requested +end + +% ========================================================================= +% EXPERIMENT LOOP — collect responsive neurons across all experiments +% ========================================================================= +if forloop + + nStim = numel(params.stimTypes); % number of stimulus conditions + nExp = numel(exList); % number of experiments + + % --- Accumulators: one cell per stimulus type, one row per neuron --- + rasterAll = cell(1, nStim); % nNeurons x nBins PSTH per stim + depthAll = cell(1, nStim); % recording depth (um) per neuron + expAll = cell(1, nStim); % experiment ID per neuron row + phyAll = cell(1, nStim); % Phy cluster ID per neuron row + prefLevelAll = cell(1, nStim); % preferred category level per neuron + respGroupAll = cell(1, nStim); % union group label per neuron ("both", "X only", etc.) + + for s = 1:nStim + rasterAll{s} = []; % initialise empty PSTH matrix + depthAll{s} = []; % initialise empty depth vector + expAll{s} = []; % initialise empty exp-ID vector + phyAll{s} = []; % initialise empty Phy-ID vector + prefLevelAll{s} = []; % initialise empty preferred-level vector + respGroupAll{s} = string.empty; % initialise empty string vector + end + + % Counter for neurons dropped due to zero-SD baseline + nDroppedZeroSD = zeros(1, nStim); % per-stim counter + + % Counter for experiments skipped due to insufficient category levels + nSkippedCat = zeros(1, nStim); % per-stim counter + + % Counter for experiments skipped in union mode (missing stim type) + nSkippedUnion = 0; % scalar counter + + % --- Shared time-axis variables --- + lockedPreBase = []; % baseline duration (ms) — locked on first exp + lockedEdges = cell(1, nStim); % bin edges (ms) per stimulus + lockedNBins = zeros(1, nStim); % bin count per stimulus + tAxis = cell(1, nStim); % left bin edge (ms) per stimulus + stimDurAll = zeros(1, nStim); % stim duration (ms) per stim for xline + movingOnsetAll = nan(1, nStim); % ms from stim onset to moving phase (SDG only; NaN elsewhere) + + % ----------------------------------------------------------------- + % Pre-scan: find shortest stimulus duration per type across all exps + % ----------------------------------------------------------------- + minStimDur = inf(1, nStim); % initialise with inf + minMovingOnset = inf(1, nStim); % SDG only: shortest static phase across exps (= transition point) + + for ei = 1:nExp + for s = 1:nStim + try + NPtmp = loadNPclassFromTable(exList(ei)); % load NP object + stimKey = params.stimTypes(s); % current stim abbreviation + + % --- Build analysis object from abbreviation --- + switch stimKey + case "RG"; objTmp = rectGridAnalysis(NPtmp); % receptive-field grid + case "MB"; objTmp = linearlyMovingBallAnalysis(NPtmp); % moving ball + case "MBR"; objTmp = linearlyMovingBarAnalysis(NPtmp); % moving bar + case {"SDGs","SDGm","SDG"} + objTmp = StaticDriftingGratingAnalysis(NPtmp); % grating (all variants) + case "NI"; objTmp = imageAnalysis(NPtmp); % natural image + case "NV"; objTmp = movieAnalysis(NPtmp); % natural movie + case "FFF"; objTmp = fullFieldFlashAnalysis(NPtmp); % full-field flash + otherwise; error('Unknown stimType abbreviation: %s', stimKey); + end + NRtmp = objTmp.ResponseWindow; % response-window struct + + % Resolve fieldName (same logic as main loop) + fn = resolveFieldName(stimKey, params.speed); % sub-field key for this stim type + + try + dur = NRtmp.(fn).stimDur; % sub-field duration + catch + dur = NRtmp.stimDur; % flat struct fallback + end + + % For combined SDG, the stimulus spans both phases: + % total duration = static phase + moving phase. + % Also track the static duration separately as the + % transition point for the xline. + if stimKey == "SDG" + staticDur_ms = objTmp.VST.static_time * 1000; % static phase duration in ms + movingDur_ms = NRtmp.Moving.stimDur; % moving phase duration in ms + dur = staticDur_ms + movingDur_ms; % total stimulus = both phases + minMovingOnset(s) = min(minMovingOnset(s), staticDur_ms); % track shortest static phase (= transition point) + end + + minStimDur(s) = min(minStimDur(s), dur); % keep shortest for this stim + catch + % skip quietly if experiment/stim cannot be loaded + end + end + end + + fprintf('Minimum stimulus durations per type (ms):'); + for s = 1:nStim + fprintf(' %s = %.0f ms', params.stimTypes(s), minStimDur(s)); % display per-stim duration + end + fprintf('\n'); + + % ----------------------------------------------------------------- + % Main loop: experiments x stimulus types + % ----------------------------------------------------------------- + for ei = 1:nExp + + ex = exList(ei); % current experiment ID + fprintf('\n=== Experiment %d ===\n', ex); + + try + NP = loadNPclassFromTable(ex); % load Neuropixels data object + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + continue % skip on load failure + end + + % ============================================================= + % UNION MODE: pre-scan all stim types for this experiment + % ============================================================= + % When unionUnits is true we need to: + % (a) verify every stim type can be loaded for this experiment, + % (b) find responsive units per stim, + % (c) compute the union. + % If any stim type fails to load, skip the entire experiment. + + % Pre-allocate per-stim containers for this experiment. + % These are populated in the pre-scan (union mode) or in the + % main per-stim loop (standard mode). + expObjs = cell(1, nStim); % analysis objects + expGoodU = cell(1, nStim); % good-unit identity matrices + expPsort = cell(1, nStim); % p_sort structs + expGoodPhyIDs = cell(1, nStim); % Phy cluster IDs for good units + expFieldNames = strings(1, nStim); % resolved sub-field names + expStartStim = zeros(1, nStim); % ms offset for stim onset + expStats = cell(1, nStim); % statistics structs + expPvals = cell(1, nStim); % p-value vectors + expC = cell(1, nStim); % condition matrices + expDirectimes = cell(1, nStim); % onset times (ms) + expResponsive = cell(1, nStim); % responsive-unit index vectors + + skipExperiment = false; % flag: skip if any stim fails (union mode) + + for s = 1:nStim + + stimType = params.stimTypes(s); % current stim abbreviation + + % --- Build stimulus-specific analysis object --- + try + switch stimType + case "RG" + obj = rectGridAnalysis(NP); % receptive-field grid stimulus + case "MB" + obj = linearlyMovingBallAnalysis(NP); % moving ball stimulus + case "MBR" + obj = linearlyMovingBarAnalysis(NP); % moving bar stimulus + case {"SDGs","SDGm","SDG"} + obj = StaticDriftingGratingAnalysis(NP); % grating stimulus (all variants) + case "NI" + obj = imageAnalysis(NP); % natural image stimulus + case "NV" + obj = movieAnalysis(NP); % natural movie stimulus + case "FFF" + obj = fullFieldFlashAnalysis(NP); % full-field flash stimulus + otherwise + error('Unknown stimType abbreviation: %s', stimType); + end + NeuronResp = obj.ResponseWindow; % response-window struct + catch ME + warning('Could not build %s for exp %d: %s', stimType, ex, ME.message); + if params.unionUnits + skipExperiment = true; % union mode: skip entire experiment + break % exit inner loop immediately + end + continue % standard mode: skip this stim/exp + end + + expObjs{s} = obj; % store analysis object + + % --- Select statistics struct --- + if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; % bootstrap p-values + else + Stats = obj.StatisticsPerNeuron; % default: permutation test + end + expStats{s} = Stats; % store for later use + + % --- Resolve sub-field name and stim-onset offset --- + fieldName = resolveFieldName(stimType, params.speed); % sub-field key for this stim type + expFieldNames(s) = fieldName; % store resolved name + + startStim = 0; % ms offset for stim onset + if stimType == "SDGm" + startStim = obj.VST.static_time * 1000; % moving phase onset (s -> ms) + end + % SDG combined: onset is start of static phase, so startStim = 0. + expStartStim(s) = startStim; % store onset offset + + % --- Convert Phy sorting to tIc format --- + p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); + label = string(p_sort.label'); % quality label per unit + goodU = p_sort.ic(:, label == 'good'); % keep only curated 'good' units + expGoodU{s} = goodU; % store good-unit matrix + expPsort{s} = p_sort; % store full p_sort struct + + % --- Extract Phy cluster IDs for good units --- + goodPhyIDs = p_sort.phy_ID(label == 'good'); % Phy cluster IDs matching goodU columns + expGoodPhyIDs{s} = goodPhyIDs; % store Phy IDs + + % --- General response p-values (StatisticsPerNeuron) --- + try + pvals = Stats.(fieldName).pvalsResponse; % stim-specific p-values + catch + pvals = Stats.pvalsResponse; % flat struct fallback + end + expPvals{s} = pvals; % store p-value vector + + % --- Stimulus onset times and condition matrix --- + try + C = NeuronResp.(fieldName).C; % condition matrix (sub-field) + catch + C = NeuronResp.C; % condition matrix (flat) + end + expC{s} = C; % store condition matrix + expDirectimes{s} = C(:, 1)' + startStim; % onset times in ms + + % --- Find responsive neurons (general filter) --- + expResponsive{s} = find(pvals < params.alpha); % indices of responsive units + + end % stim pre-scan loop + + % --- Union mode: skip experiment if any stim failed --- + if params.unionUnits && skipExperiment + nSkippedUnion = nSkippedUnion + 1; % count skipped experiment + fprintf(' Skipping experiment %d (missing stim type in union mode).\n', ex); + continue % skip to next experiment + end + + % --- Union mode: verify shared neuron pool and compute union --- + % Also classify each neuron into responsiveness groups: + % - For single SDG: "both", "static", "moving" + % - For multi-stim: "both", "", "" + expNeuronGroup = strings(1, 0); % per-unit group label (populated below if union mode) + + if params.unionUnits + + isSingleSDG = nStim == 1 && params.stimTypes == "SDG"; % special case: phase-union + + if ~isSingleSDG + % --- Multi-stim: verify shared neuron pool --- + refPhyIDs = sort(expGoodPhyIDs{1}); % reference: good Phy IDs from first stim + for s = 2:nStim + if ~isequal(sort(expGoodPhyIDs{s}), refPhyIDs) + warning(['Experiment %d: good-unit Phy IDs differ between %s and %s. ' ... + 'Spike sorting may differ. Skipping experiment.'], ... + ex, params.stimTypes(1), params.stimTypes(s)); + skipExperiment = true; % flag to skip this experiment + break + end + end + if skipExperiment + nSkippedUnion = nSkippedUnion + 1; % count skipped + continue % skip to next experiment + end + end + + % ============================================================= + % Compute union and classify neurons into groups + % ============================================================= + nGoodUnits = numel(expPvals{1}); % number of good units (shared across stim types) + + if isSingleSDG + % --- SDG phase-union: union across static and moving phases --- + % expPvals{1} holds Static p-values (from resolveFieldName). + % Also extract Moving p-values from the same analysis object. + staticPvals = expPvals{1}; % already stored by pre-scan + try + movingPvals = expStats{1}.Moving.pvalsResponse; % moving-phase p-values + catch + movingPvals = expStats{1}.pvalsResponse; % flat struct fallback + end + + staticResp = staticPvals(:) < params.alpha; % logical: responsive to static phase + movingResp = movingPvals(:) < params.alpha; % logical: responsive to moving phase + unionMask = staticResp | movingResp; % union: responsive to either phase + unionIdx = find(unionMask); % indices of union neurons + + % Classify each good unit (indexed by position in goodU) + expNeuronGroup = strings(1, nGoodUnits); % pre-allocate + expNeuronGroup(staticResp & movingResp) = "both"; % responsive to both phases + expNeuronGroup(staticResp & ~movingResp) = "static"; % static phase only + expNeuronGroup(~staticResp & movingResp) = "moving"; % moving phase only + % Units not in the union keep "" (will never be queried) + + fprintf(' SDG phase-union exp %d: %d static, %d moving, %d both, %d total union.\n', ... + ex, sum(staticResp & ~movingResp), sum(~staticResp & movingResp), ... + sum(staticResp & movingResp), numel(unionIdx)); + + else + % --- Multi-stim union: union across stimulus types --- + % Build a logical matrix: nGoodUnits x nStim + respMatrix = false(nGoodUnits, nStim); % pre-allocate + for s = 1:nStim + respMatrix(expResponsive{s}, s) = true; % mark responsive units per stim + end + + unionMask = any(respMatrix, 2); % responsive to at least one stim + unionIdx = find(unionMask); % indices of union neurons + + % Classify each unit + nRespStim = sum(respMatrix, 2); % how many stim types each unit responds to + expNeuronGroup = strings(1, nGoodUnits); % pre-allocate + + % Neurons responsive to ALL stim types → "both" (or "all" for 3+) + expNeuronGroup(nRespStim >= 2) = "both"; + + % Neurons responsive to exactly one stim type → " only" + for s = 1:nStim + onlyThisStim = respMatrix(:, s) & nRespStim == 1; % responsive to this stim only + expNeuronGroup(onlyThisStim) = params.stimTypes(s); % e.g. "MB" + end + + fprintf(' Union exp %d: %d both, ', ex, sum(nRespStim >= 2)); + for s = 1:nStim + fprintf('%d %s only, ', sum(respMatrix(:,s) & nRespStim==1), params.stimTypes(s)); + end + fprintf('%d total union.\n', numel(unionIdx)); + end + + if isempty(unionIdx) + fprintf(' No responsive neurons in any stim type/phase for exp %d.\n', ex); + continue % nothing to add + end + + % Override each stim's responsive set with the shared union + for s = 1:nStim + expResponsive{s} = unionIdx; % same neuron list for every panel + end + end + + % ============================================================= + % Per-stim PSTH building (shared by both modes) + % ============================================================= + for s = 1:nStim + + stimType = params.stimTypes(s); % current stim abbreviation + fieldName = expFieldNames(s); % resolved sub-field name + obj = expObjs{s}; % analysis object for this stim + + if isempty(obj) + continue % object failed to load (standard mode: already warned) + end + + % Retrieve pre-computed variables from the pre-scan + goodU = expGoodU{s}; % good-unit identity matrix + p_sort = expPsort{s}; % full p_sort struct + goodPhyIDs = expGoodPhyIDs{s}; % Phy cluster IDs + pvals = expPvals{s}; % p-value vector + directimesSorted = expDirectimes{s}; % onset times (ms) + C = expC{s}; % condition matrix + + % ============================================================= + % CATEGORY DETECTION (for preferredCategory mode) + % ============================================================= + % isCatMode is per-stimulus: true only if sortBy is + % preferredCategory AND this stim slot has a non-empty category. + isCatMode = (params.sortBy == "preferredCategory" || params.prefCatPSTH) && ... + strlength(params.splitCategory(s)) > 0; % per-stim flag + + if isCatMode + + thisCatName = params.splitCategory(s); % category for THIS stim (e.g. "direction") + thisCatLevels = params.splitLevels{s}; % requested levels for THIS stim ([] = all) + + % --- Extract column names from ResponseWindow --- + NeuronResp = obj.ResponseWindow; % re-fetch for column name access + try + allColNames = NeuronResp.(fieldName).colNames{1}; % sub-field column names + catch + try + allColNames = NeuronResp.colNames{1}; % flat struct column names + catch + warning('[%s] exp %d: colNames not found — skipping.', stimType, ex); + nSkippedCat(s) = nSkippedCat(s) + 1; % count skipped experiment + continue % skip this stim/exp + end + end + + % Column names: first 4 are metadata (onset, etc.), + % entries 5+ are stimulus-parameter names. + catColNames = string(allColNames(5:end)); % stimulus-parameter names only + + % --- Find column index for the requested category --- + catIdx = find(strcmpi(catColNames, thisCatName), 1); % case-insensitive match + + if isempty(catIdx) + warning('[%s] exp %d: category "%s" not found in colNames [%s] — skipping.', ... + stimType, ex, thisCatName, strjoin(catColNames, ', ')); + nSkippedCat(s) = nSkippedCat(s) + 1; % count skipped experiment + continue % skip: category absent + end + + % BUG 10 FIX: C column 1 = onset times; columns 2+ = stimulus + % parameters matching catColNames. catIdx is 1-based into + % catColNames. Offset by 1 for the time column. + catColInC = catIdx + 1; % +1 for onset-time column 1 + + % --- Extract category values per trial --- + trialCatValues = C(:, catColInC); % category value for each trial + + % --- Determine available levels --- + availableLevels = unique(trialCatValues); % unique levels in this experiment + + % --- Filter to requested levels if specified --- + if ~isempty(thisCatLevels) + availableLevels = intersect(availableLevels, thisCatLevels(:)); % keep only requested + end + + % --- Skip if fewer than 2 levels remain --- + if numel(availableLevels) < 2 + warning('[%s] exp %d: only %d level(s) of "%s" after filtering — skipping.', ... + stimType, ex, numel(availableLevels), thisCatName); + nSkippedCat(s) = nSkippedCat(s) + 1; % count skipped experiment + continue % skip: can't determine preference + end + + fprintf(' [%s] exp %d: %d levels of "%s": [%s]\n', ... + stimType, ex, numel(availableLevels), thisCatName, ... + strjoin(string(availableLevels'), ', ')); + + % ========================================================== + % PER-CATEGORY STATISTICS (StatisticsPerNeuronPerCategory) + % ========================================================== + if ~params.useGeneralFilter && ~params.unionUnits + gt = detectGratingType(stimType); % '' for non-grating, 'moving'/'static' for SDG + + % Build name-value args for StatisticsPerNeuronPerCategory + catStatsArgs = { ... + 'compareCategory', char(thisCatName), ... % category to decompose + 'nBoot', params.nBootCategory, ... % bootstrap iterations + 'overwrite', params.overwriteCatStats, ... % force recomputation flag + 'BaseRespWindow', params.catBaseRespWindow, ... % response window (ms) + 'applyFDR', params.catApplyFDR}; % FDR inside the stats function + if ~isempty(gt) + catStatsArgs = [catStatsArgs, {'GratingType', gt}]; % pass GratingType only for SDG stim + end + + % Run per-category statistics (results are cached by the object) + catStats = obj.StatisticsPerNeuronPerCategory(catStatsArgs{:}); + + % Build OR mask: neuron passes if significant for ANY available level + nUnits = numel(pvals); % total good units (same as general stats) + orMask = false(nUnits, 1); % initialise all-false + + for li = 1:numel(availableLevels) + fName = levelToFieldName(char(thisCatName), availableLevels(li)); % e.g. 'direction_0' + if isfield(catStats, fName) + levelP = catStats.(fName).pvalsResponse(:); % per-level p-values + orMask = orMask | (levelP < params.alpha); % OR into mask + else + warning('[%s] exp %d: catStats missing field "%s" — skipping level.', ... + stimType, ex, fName); + end + end + + fprintf(' [%s] exp %d: %d / %d neurons pass per-category OR filter.\n', ... + stimType, ex, sum(orMask), nUnits); + end % ~useGeneralFilter + + end % isCatMode detection block + + % --- Determine total trial window --- + preBase = params.preBase; % baseline in ms + + if params.useCompleteWindow + rawStimDur_ms = minStimDur(s); % truncate to shortest duration + windowTotal = preBase + rawStimDur_ms + params.postStim; % full window in ms + else + rawStimDur_ms = params.postStim; % fixed window + windowTotal = preBase + params.postStim; % full window in ms + end + + % Lock baseline on first experiment + if isempty(lockedPreBase) + lockedPreBase = preBase; % shared across all stimuli + end + + % Lock bin edges per stim on first encounter + if isempty(lockedEdges{s}) + lockedEdges{s} = 0 : params.binWidth : windowTotal; % bin edges from 0 to windowTotal + lockedNBins(s) = numel(lockedEdges{s}) - 1; % number of bins + tAxis{s} = lockedEdges{s}(1:end-1); % left edge per bin (ms) + stimDurAll(s) = rawStimDur_ms; % for xline in plot + + % Store moving-phase onset for SDG combined type. + % Use the pre-scanned minimum static duration across + % experiments so the transition xline is consistent + % with the truncated window. + if stimType == "SDG" + movingOnsetAll(s) = minMovingOnset(s); % static-to-moving transition (ms) + end + + fprintf(' [%s] Locked window: preBase=%d ms, stimDur=%.0f ms, nBins=%d\n', ... + stimType, lockedPreBase, rawStimDur_ms, lockedNBins(s)); + end + + % --- Find responsive neurons --- + % In union mode, expResponsive was already overwritten with + % the shared union set. In standard mode it holds the + % per-stim responsive indices. + if isCatMode && ~params.useGeneralFilter && ~params.unionUnits + eNeurons = find(orMask); % per-category OR filter + else + eNeurons = expResponsive{s}; % general filter or union set + end + + if isempty(eNeurons) + fprintf(' [%s] No responsive neurons in exp %d.\n', stimType, ex); + continue + end + + fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', ... + stimType, numel(eNeurons), ex); + + % ---------------------------------------------------------- + % Per-neuron PSTH + % ---------------------------------------------------------- + nAppended = 0; % count neurons actually added to raster + for ni = 1:numel(eNeurons) + + u = eNeurons(ni); % index into the good-unit list + + % ============================================================== + % ALL-TRIALS BASELINE (category mode only) + % ============================================================== + % In category mode, compute baseline stats from ALL trials + % before selecting the preferred level. The pre-stimulus + % period is stimulus-independent (the animal cannot predict + % the upcoming category), so pooling all trials gives the + % most stable baseline estimate. + bMean = []; % sentinel: compute later in standard mode + bStd = []; + + if isCatMode && params.zScore + + % All-trials matrix (baseline is stimulus-independent, so + % pooling all trials gives the most stable estimate). + nTrialsAll = numel(directimesSorted); % total number of trials + MRall = BuildBurstMatrix( ... + goodU(:, u), ... % spike identity for this unit + round(p_sort.t), ... % rounded sample timestamps + round(directimesSorted - lockedPreBase), ... % ALL trial starts + round(windowTotal)); % window length (ms) + MRall = reshape(MRall, nTrialsAll, []); % [nTrialsAll × time]; reshape avoids singleton collapse + + % Across-trial baseline statistics from per-trial baseline + % means. Matches sdBase in StatisticsPerNeuron and, unlike + % the SD across PSTH bins, does not shrink with trial count, + % so panels with different trial counts stay on a comparable + % z-scale (critical for the shared colorbar). + perTrialBaseAll = mean(MRall(:, 1:lockedPreBase), 2) * 1000; % [nTrialsAll × 1] spk/s + bMean = mean(perTrialBaseAll); % across-trial baseline mean + bStd = std(perTrialBaseAll); % across-trial baseline SD + + % Early exit if baseline SD is zero — z-score is + % undefined regardless of which level we pick. + if bStd == 0 + nDroppedZeroSD(s) = nDroppedZeroSD(s) + 1; + % In union mode, we MUST append a NaN row to + % preserve row alignment. + if params.unionUnits + rasterAll{s} = [rasterAll{s}; nan(1, lockedNBins(s))]; % NaN placeholder row + phyAll{s}(end+1) = goodPhyIDs(u); % Phy ID (for alignment tracking) + expAll{s}(end+1) = ex; % experiment ID + prefLevelAll{s}(end+1) = NaN; % no preferred level + depthAll{s}(end+1) = NaN; % no depth + respGroupAll{s}(end+1) = expNeuronGroup(u); % union group label + nAppended = nAppended + 1; % count as appended (placeholder) + end + continue % skip neuron + end + end + + % ========================================================== + % PREFERRED-CATEGORY MODE: determine preferred level and + % build PSTH from only that level's trials + % ========================================================== + if isCatMode + + % --- Compute mean post-onset rate per level --- + nLevels = numel(availableLevels); % number of category levels + meanRatePerLevel = nan(1, nLevels); % pre-allocate rate per level + + for li = 1:nLevels + levelMask = trialCatValues == availableLevels(li); % logical mask: trials of this level + levelOnsets = directimesSorted(levelMask); % onset times for this level's trials + + if isempty(levelOnsets) + continue % no trials for this level + end + + % Build binary spike matrix for this level's trials + MRtemp = BuildBurstMatrix( ... + goodU(:, u), ... % spike identity for this unit + round(p_sort.t), ... % rounded sample timestamps + round(levelOnsets - lockedPreBase), ... % trial start = onset - baseline + round(windowTotal)); % window length (ms) + MRtemp = squeeze(MRtemp); % remove singleton dims + + % Handle single-trial case: ensure matrix is (1 x T) + if isvector(MRtemp) && numel(levelOnsets) == 1 + MRtemp = MRtemp(:)'; % force row vector + end + + % Mean firing rate in the post-onset window (spk/ms -> spk/s) + postCols = (lockedPreBase + 1) : size(MRtemp, 2); % columns after stim onset + meanRatePerLevel(li) = mean(MRtemp(:, postCols), 'all') * 1000; % convert to spk/s + end + + % --- Determine preferred level by argmax --- + [bestRate, bestIdx] = max(meanRatePerLevel); % highest mean rate across levels + + if isnan(bestRate) + % In union mode, append NaN row to preserve alignment + if params.unionUnits + rasterAll{s} = [rasterAll{s}; nan(1, lockedNBins(s))]; % NaN placeholder row + phyAll{s}(end+1) = goodPhyIDs(u); + expAll{s}(end+1) = ex; + prefLevelAll{s}(end+1) = NaN; + depthAll{s}(end+1) = NaN; + respGroupAll{s}(end+1) = expNeuronGroup(u); % union group label + nAppended = nAppended + 1; + end + continue % all levels empty — skip neuron + end + + prefLevel = availableLevels(bestIdx); % the preferred category level + + % --- Build PSTH from preferred-level trials only --- + prefMask = trialCatValues == prefLevel; % logical mask for preferred level + prefOnsets = directimesSorted(prefMask); % onset times of preferred trials + + MRhist = BuildBurstMatrix( ... + goodU(:, u), ... % spike identity for this unit + round(p_sort.t), ... % rounded sample timestamps + round(prefOnsets - lockedPreBase), ... % trial start = onset - baseline + round(windowTotal)); % window length (ms) + MRhist = squeeze(MRhist); % remove singleton dims + + % Handle single-trial case + if isvector(MRhist) && numel(prefOnsets) == 1 + MRhist = MRhist(:)'; % force row vector + end + + else + % ================================================== + % STANDARD MODE: build PSTH from all trials + % ================================================== + prefLevel = NaN; % not applicable in standard mode + + % Binary spike matrix: trials x time at 1 ms resolution + MRhist = BuildBurstMatrix( ... + goodU(:, u), ... % spike identity for this unit + round(p_sort.t), ... % rounded sample timestamps + round(directimesSorted - lockedPreBase), ... % trial start = onset - baseline + round(windowTotal)); % window length (ms, integer) + MRhist = squeeze(MRhist); % remove singleton dims + end + + % --- Optionally keep only the highest-firing trials --- + if ~isempty(params.TakeTopPercentTrials) && ... + params.TakeTopPercentTrials > 0 && ... + params.TakeTopPercentTrials < 1 % FIX 6: guard against 0 and >=1 + + % FIX 4: rank trials by post-onset activity only, + % avoiding bias toward high-baseline trials. + postOnsetCols = (lockedPreBase + 1) : size(MRhist, 2); % columns after stim onset (1 ms res) + MeanTrial = mean(MRhist(:, postOnsetCols), 2); % mean post-onset spike count per trial + [~, ind] = sort(MeanTrial, 'descend'); % rank descending + nKeep = max(1, round(numel(MeanTrial) * params.TakeTopPercentTrials)); % at least 1 + takeTrials = ind(1:nKeep); % indices of top trials + MRhist = MRhist(takeTrials, :); % keep only top fraction + end + + nTrials = size(MRhist, 1); % number of trials used + spikeTimes = repmat((1:size(MRhist,2)), nTrials, 1); % column index for every position + spikeTimes = spikeTimes(logical(MRhist)); % keep only spike positions + counts = histcounts(spikeTimes, lockedEdges{s}); % spike count per bin + neuronPSTH = (counts / (params.binWidth * nTrials)) * 1000; % convert to spk/s + + % --- Z-score using pre-stimulus baseline --- + if params.zScore + if isempty(bMean) + % Standard mode: across-trial baseline statistics from + % per-trial baseline means (matches sdBase; independent + % of trial count, unlike the SD across PSTH bins used + % previously). + perTrialBase = mean(MRhist(:, 1:lockedPreBase), 2) * 1000; % [nTrials × 1] spk/s + bMean = mean(perTrialBase); % across-trial baseline mean + bStd = std(perTrialBase); % across-trial baseline SD + end + % Category mode: bMean/bStd already set from all-trials + % matrix above (more stable, stimulus-independent estimate) + + if bStd > 0 + neuronPSTH = (neuronPSTH - bMean) / bStd; % z-score normalisation + else + % FIX 5: count and log rather than silently skip + nDroppedZeroSD(s) = nDroppedZeroSD(s) + 1; + % In union mode, append NaN row to preserve alignment + if params.unionUnits + rasterAll{s} = [rasterAll{s}; nan(1, lockedNBins(s))]; % NaN placeholder row + phyAll{s}(end+1) = goodPhyIDs(u); + expAll{s}(end+1) = ex; + prefLevelAll{s}(end+1) = prefLevel; + depthAll{s}(end+1) = NaN; + respGroupAll{s}(end+1) = expNeuronGroup(u); % union group label + nAppended = nAppended + 1; + end + continue % skip: undefined z-score + end + end + + % --- Smooth PSTH if requested --- + if params.smooth > 0 + % FIX E: smoothdata('gaussian', N) treats N as window + % WIDTH, not SD. Convert: width ≈ 6*SD ensures ~99.7% + % of the kernel mass is captured. + smoothSD = params.smooth / params.binWidth; % smoothing SD in bin units + smoothWidth = max(3, round(6 * smoothSD)); % window width in bins (at least 3) + neuronPSTH = smoothdata(neuronPSTH, 'gaussian', smoothWidth); % smooth in-place + end + + % --- Append neuron row to accumulators --- + rasterAll{s} = [rasterAll{s}; neuronPSTH]; % PSTH row + phyAll{s}(end+1) = goodPhyIDs(u); % Phy cluster ID for this unit + expAll{s}(end+1) = ex; % source experiment + prefLevelAll{s}(end+1) = prefLevel; % preferred level (NaN if not catMode) + % Store union responsiveness group label + if params.unionUnits && numel(expNeuronGroup) >= u + respGroupAll{s}(end+1) = expNeuronGroup(u); % e.g. "both", "static", "MB" + else + respGroupAll{s}(end+1) = ""; % not in union mode or not classified + end + nAppended = nAppended + 1; % count actually-appended neurons + + % Store recording depth + if params.sortBy == "depth" + depthRow = depthTable.Experiment == ex & depthTable.Unit == u; + if any(depthRow) + depthAll{s}(end+1) = depthTable.Depth_um(depthRow); % matched depth + else + depthAll{s}(end+1) = NaN; % not found + end + else + depthAll{s}(end+1) = NaN; % unused; keeps vector aligned + end + + end % neuron loop + + % Report if any responsive neurons were dropped during PSTH building + nDropped = numel(eNeurons) - nAppended; % neurons lost to zero-SD or empty levels + if nDropped > 0 + fprintf(' [%s] exp %d: %d / %d responsive neurons dropped (zero-SD or empty preferred level).\n', ... + stimType, ex, nDropped, numel(eNeurons)); + end + + end % stimulus loop + end % experiment loop + + % FIX 5: report zero-SD dropped neurons + for s = 1:nStim + if nDroppedZeroSD(s) > 0 + fprintf(' [%s] Dropped %d neuron(s) with zero baseline SD.\n', ... + params.stimTypes(s), nDroppedZeroSD(s)); + end + end + + % Report experiments skipped due to insufficient category levels + if params.sortBy == "preferredCategory" + for s = 1:nStim + if nSkippedCat(s) > 0 + fprintf(' [%s] Skipped %d experiment(s) with <2 levels of "%s".\n', ... + params.stimTypes(s), nSkippedCat(s), params.splitCategory(s)); + end + end + end + + % Report experiments skipped in union mode + if params.unionUnits && nSkippedUnion > 0 + fprintf(' Union mode: skipped %d experiment(s) with missing stim types.\n', nSkippedUnion); + end + + % FIX 9: verify accumulator alignment after all experiments + for s = 1:nStim + nRows = size(rasterAll{s}, 1); % number of neuron rows + assert(numel(expAll{s}) == nRows, 'expAll{%d} length mismatch.', s); + assert(numel(phyAll{s}) == nRows, 'phyAll{%d} length mismatch.', s); + assert(numel(depthAll{s}) == nRows, 'depthAll{%d} length mismatch.', s); + assert(numel(prefLevelAll{s}) == nRows, 'prefLevelAll{%d} length mismatch.', s); + if params.unionUnits + assert(numel(respGroupAll{s}) == nRows, 'respGroupAll{%d} length mismatch.', s); + end + end + + % UNION MODE: verify row counts match across all stim types. + % This is the fundamental invariant: same row = same neuron. + if params.unionUnits + refRows = size(rasterAll{1}, 1); % row count for first stim + for s = 2:nStim + assert(size(rasterAll{s}, 1) == refRows, ... + 'Union mode row-count mismatch: stim %d has %d rows, stim 1 has %d.', ... + s, size(rasterAll{s}, 1), refRows); + end + + % Also verify Phy IDs match across panels (same neuron in each row) + for s = 2:nStim + if ~isequal(phyAll{s}, phyAll{1}) || ~isequal(expAll{s}, expAll{1}) + error(['Union mode: Phy IDs or experiment IDs differ between stim 1 and stim %d. ' ... + 'Row alignment is broken.'], s); + end + end + fprintf(' Union mode verified: %d neurons, row alignment intact.\n', refRows); + end + + % ------------------------------------------------------------------ + % Save processed data to disk + % ------------------------------------------------------------------ + S.expList = exList; % for validation on reload + S.lockedEdges = lockedEdges; % per-stim bin edges + S.lockedPreBase = lockedPreBase; % shared baseline + S.stimDurAll = stimDurAll; % per-stim duration for xline + S.movingOnsetAll = movingOnsetAll; % static-to-moving transition (SDG only) + S.params = params; % full parameter set + + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); % valid struct field name + S.(sprintf('%s_raster', stimField)) = rasterAll{s}; % PSTH matrix + S.(sprintf('%s_depth', stimField)) = depthAll{s}; % depth vector + S.(sprintf('%s_exp', stimField)) = expAll{s}; % experiment ID vector + S.(sprintf('%s_phy', stimField)) = phyAll{s}; % Phy cluster ID vector + S.(sprintf('%s_prefLevel', stimField)) = prefLevelAll{s}; % preferred level vector + S.(sprintf('%s_respGroup', stimField)) = respGroupAll{s}; % union responsiveness group label + end + + save([saveDir nameOfFile], '-struct', 'S'); % write cache to disk + fprintf('\nSaved raster data to:\n %s\n', [saveDir nameOfFile]); + +else + % ------------------------------------------------------------------ + % Reload cached data + % ------------------------------------------------------------------ + lockedEdges = S.lockedEdges; % restore bin edges + lockedPreBase = S.lockedPreBase; % restore baseline + stimDurAll = S.stimDurAll; % restore stim durations + + % Restore movingOnsetAll if present, otherwise NaN (old cache) + if isfield(S, 'movingOnsetAll') + movingOnsetAll = S.movingOnsetAll; % restore static-to-moving transition + else + movingOnsetAll = nan(1, numel(params.stimTypes)); % old cache: no SDG combined data + end + + rasterAll = cell(1, numel(params.stimTypes)); % pre-allocate + depthAll = cell(1, numel(params.stimTypes)); + expAll = cell(1, numel(params.stimTypes)); + phyAll = cell(1, numel(params.stimTypes)); + prefLevelAll = cell(1, numel(params.stimTypes)); + respGroupAll = cell(1, numel(params.stimTypes)); + + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); % valid struct field name + rasterAll{s} = S.(sprintf('%s_raster', stimField)); % restore PSTH matrix + depthAll{s} = S.(sprintf('%s_depth', stimField)); % restore depth vector + expAll{s} = S.(sprintf('%s_exp', stimField)); % restore experiment IDs + + % phyAll may be absent in old caches — require recompute if needed + phyField = sprintf('%s_phy', stimField); + if isfield(S, phyField) + phyAll{s} = S.(phyField); % restore Phy IDs + elseif params.sortBy == "spatialTuning" || params.sortBy == "preferredCategory" || params.unionUnits + error(['Cache file lacks Phy IDs (old format). ' ... + 'Re-run with params.overwrite = true.']); + else + phyAll{s} = nan(1, size(rasterAll{s}, 1)); % fill NaN if unused + end + + % prefLevelAll may be absent in old caches + plField = sprintf('%s_prefLevel', stimField); + if isfield(S, plField) + prefLevelAll{s} = S.(plField); % restore preferred levels + elseif params.sortBy == "preferredCategory" + error(['Cache file lacks prefLevel data (old format). ' ... + 'Re-run with params.overwrite = true.']); + else + prefLevelAll{s} = nan(1, size(rasterAll{s}, 1)); % fill NaN if unused + end + + % respGroupAll may be absent in old caches + rgField = sprintf('%s_respGroup', stimField); + if isfield(S, rgField) + respGroupAll{s} = S.(rgField); % restore group labels + elseif params.unionUnits + error(['Cache file lacks respGroup data (old format). ' ... + 'Re-run with params.overwrite = true.']); + else + respGroupAll{s} = repmat("", 1, size(rasterAll{s}, 1)); % fill empty if unused + end + end + + % Reconstruct per-stimulus tAxis from stored edges + tAxis = cell(1, numel(params.stimTypes)); + for s = 1:numel(params.stimTypes) + tAxis{s} = lockedEdges{s}(1:end-1); % left edge per bin (ms) + end + +end + +% ========================================================================= +% LOAD SPATIAL-TUNING TABLE (only when sorting by spatial tuning) +% ========================================================================= +tuningAll = cell(1, numel(params.stimTypes)); % one tuning vector per stim + +if params.sortBy == "spatialTuning" + + % --- Resolve tuning file path --- + if strlength(params.tuningFile) == 0 + cand1 = sprintf('%s\\Ex_%d-%d_SpatialTuningIndex_%s_RF_prefDir_allResp.mat', ... + saveDir, exList(1), exList(end), stimLabel); % primary: same stim order + cand2 = sprintf('%s\\Ex_%d-%d_SpatialTuningIndex_%s_RF_prefDir_allResp.mat', ... + saveDir, exList(1), exList(end), ... + strjoin(flip(params.stimTypes), '-')); % fallback: reversed order + if exist(cand1, 'file') + tuningFile = cand1; % prefer primary + elseif exist(cand2, 'file') + tuningFile = cand2; % accept reversed order + else + error('Spatial-tuning file not found at:\n %s\nor\n %s', cand1, cand2); + end + else + tuningFile = char(params.tuningFile); % explicit path overrides + end + + fprintf('Loading spatial-tuning table from:\n %s\n', tuningFile); + + % --- Load and validate the tuning table --- + Ttmp = load(tuningFile); % load .mat + tflds = fieldnames(Ttmp); % variable names in file + isTab = cellfun(@(f) istable(Ttmp.(f)), tflds); % find first table variable + if ~any(isTab) + error('No table variable found inside %s.', tuningFile); + end + tuningTable = Ttmp.(tflds{find(isTab, 1)}); % grab the tuning table + + % Convert categorical columns to native types so == comparisons work. + if iscategorical(tuningTable.experimentNum) + tuningTable.experimentNum = str2double(string(tuningTable.experimentNum)); + end + if iscategorical(tuningTable.phyID) + tuningTable.phyID = str2double(string(tuningTable.phyID)); + end + if iscategorical(tuningTable.stimulus) + tuningTable.stimulus = string(tuningTable.stimulus); + end + + % Check required columns exist + varNames = string(tuningTable.Properties.VariableNames); + assert(ismember("phyID", varNames), 'Tuning table missing "phyID" column.'); + assert(ismember("experimentNum", varNames), 'Tuning table missing "experimentNum" column.'); + assert(ismember("stimulus", varNames), 'Tuning table missing "stimulus" column.'); + assert(ismember(params.tuningIndexCol, varNames), ... + 'Tuning table missing requested column "%s".', params.tuningIndexCol); + + % --- Build per-stim tuning vectors aligned to rasterAll rows --- + for s = 1:numel(params.stimTypes) + nNeu = size(rasterAll{s}, 1); % neurons for this stim + tuningAll{s} = nan(1, nNeu); % default NaN = sorted to end + + if nNeu == 0; continue; end % nothing to do + + legacyNames = abbrevToLegacyNames(params.stimTypes(s)); % e.g. ["MB","linearlyMovingBall"] + stimMask = ismember(string(tuningTable.stimulus), legacyNames); % match any known name variant + subT = tuningTable(stimMask, :); % subtable for this stim + + for k = 1:nNeu + row = subT.experimentNum == expAll{s}(k) & ... + subT.phyID == phyAll{s}(k); + if any(row) + tuningAll{s}(k) = subT.(params.tuningIndexCol)(find(row, 1)); % tuning index value + end + end + + nMissing = sum(isnan(tuningAll{s})); % unmatched rows + if nMissing > 0 + warning('plotRaster:tuningMissing', ... + '[%s] %d / %d neurons missing from tuning table — sorted to end.', ... + params.stimTypes(s), nMissing, nNeu); + end + end +end + +% ========================================================================= +% SORT NEURONS +% ========================================================================= +% In union mode, sorting must be computed ONCE and applied identically +% to all stimulus panels to preserve row alignment. +% Strategy: compute sortIdx from the FIRST non-empty stim (or a combined +% metric), then apply it to all panels. + +levelGroupBounds = cell(1, numel(params.stimTypes)); % {s}: struct with edges and labels + +if params.unionUnits + % ------------------------------------------------------------------ + % UNION SORT: primary sort by responsiveness group, secondary sort + % by sortBy within each group. One sort order applied to all panels. + % ------------------------------------------------------------------ + % Group order: "both" first, then individual-stim/phase groups + % (alphabetical). Within each group, neurons are sorted by the + % selected sortBy criterion (peak, depth, spatialTuning, or identity). + + refStim = find(~cellfun(@isempty, rasterAll), 1); % first non-empty stim index + nNeurons = size(rasterAll{refStim}, 1); % shared neuron count + groups = respGroupAll{refStim}; % group label per neuron (shared across panels) + + % Determine unique groups, with "both" forced to position 1 + uniqueGroups = unique(groups); % alphabetical order + uniqueGroups = uniqueGroups(uniqueGroups ~= ""); % remove empty strings (shouldn't occur) + hasBoth = ismember("both", uniqueGroups); % check if "both" is present + if hasBoth + uniqueGroups(uniqueGroups == "both") = []; % remove "both" temporarily + uniqueGroups = ["both", sort(uniqueGroups)]; % prepend "both", sort the rest + else + uniqueGroups = sort(uniqueGroups); % just sort alphabetically + end + + % --- Compute secondary sort metric (within-group ordering) --- + % Pre-compute whatever the sortBy criterion needs, so we can apply + % it independently within each group. + if params.sortBy == "peak" + % Average PSTH across panels for peak detection + avgData = zeros(nNeurons, size(rasterAll{refStim}, 2)); % accumulator + nContrib = 0; % contributing panel count + for s = 1:numel(params.stimTypes) + if ~isempty(rasterAll{s}) + nBins = min(size(avgData, 2), size(rasterAll{s}, 2)); % common bin count + avgData(:, 1:nBins) = avgData(:, 1:nBins) + rasterAll{s}(:, 1:nBins); + nContrib = nContrib + 1; + end + end + avgData = avgData / max(nContrib, 1); % mean across panels + + postStimBins = tAxis{refStim} >= lockedPreBase; % post-onset bin mask + + if size(avgData, 2) > 100 + dataForSort = ConvBurstMatrix( ... + avgData, fspecial('gaussian', [1 params.GaussianLength+20], 2), 'same'); + else + dataForSort = ConvBurstMatrix( ... + avgData, fspecial('gaussian', [1 params.GaussianLength], 2), 'same'); + end + + [~, secondaryMetric] = max(dataForSort(:, postStimBins), [], 2); % peak bin per neuron + secondaryDirection = 'ascend'; % early-peaking first + + elseif params.sortBy == "depth" + secondaryMetric = depthAll{refStim}(:); % depth per neuron + secondaryDirection = 'ascend'; % shallowest first + + elseif params.sortBy == "spatialTuning" + secondaryMetric = tuningAll{refStim}(:); % tuning index per neuron + secondaryDirection = char(params.tuningSortOrder); % user-specified order + + else + secondaryMetric = (1:nNeurons)'; % identity (preserve original order) + secondaryDirection = 'ascend'; + end + + % --- Build sortIdx: iterate groups, sort within each --- + sortIdx = zeros(1, nNeurons); % pre-allocate + cursor = 0; % running position + groupInfo = struct('edges', [], 'labels', {{}}); % for plot annotations + + for gi = 1:numel(uniqueGroups) + groupMask = groups == uniqueGroups(gi); % neurons in this group + groupIndices = find(groupMask); % their row indices + nGroup = numel(groupIndices); % count + + if nGroup == 0; continue; end % skip empty groups + + % Secondary sort within group + groupMetric = secondaryMetric(groupIndices); % metric for this group's neurons + [~, withinOrder] = sort(groupMetric, secondaryDirection, 'MissingPlacement', 'last'); + + sortIdx(cursor+1 : cursor+nGroup) = groupIndices(withinOrder); % fill sorted indices + + % Record group boundary for plotting + groupInfo.edges(end+1) = cursor + nGroup; % last row of this group + groupInfo.labels{end+1} = char(uniqueGroups(gi)); % group label (e.g. "both", "static") + + cursor = cursor + nGroup; % advance cursor + end + + % Store group boundaries for all panels (identical since union mode) + for s = 1:numel(params.stimTypes) + levelGroupBounds{s} = groupInfo; % reuse same struct for plot annotation + end + + % Apply the SAME sort to ALL panels + for s = 1:numel(params.stimTypes) + if isempty(rasterAll{s}); continue; end + rasterAll{s} = rasterAll{s}(sortIdx, :); % reorder PSTH rows + depthAll{s} = depthAll{s}(sortIdx); % reorder depths + expAll{s} = expAll{s}(sortIdx); % reorder experiment IDs + phyAll{s} = phyAll{s}(sortIdx); % reorder Phy IDs + prefLevelAll{s} = prefLevelAll{s}(sortIdx); % reorder preferred levels + respGroupAll{s} = respGroupAll{s}(sortIdx); % reorder group labels + if params.sortBy == "spatialTuning" + tuningAll{s} = tuningAll{s}(sortIdx); % keep tuning vector aligned + end + end + +else + % ------------------------------------------------------------------ + % STANDARD SORT: independent per-stim sort order + % ------------------------------------------------------------------ + for s = 1:numel(params.stimTypes) + + data = rasterAll{s}; % nNeurons x nBins + if isempty(data); continue; end + + if params.sortBy == "peak" + postStimBins = tAxis{s} >= lockedPreBase; % post-onset mask + + % Local smoothed copy for peak detection only + if size(data, 2) > 100 + dataForSort = ConvBurstMatrix( ... + data, fspecial('gaussian', [1 params.GaussianLength+20], 2), 'same'); + else + dataForSort = ConvBurstMatrix( ... + data, fspecial('gaussian', [1 params.GaussianLength], 2), 'same'); + end + + [~, peakBin] = max(dataForSort(:, postStimBins), [], 2); % peak column per neuron + [~, sortIdx] = sort(peakBin); % early-peaking first + + elseif params.sortBy == "depth" + [~, sortIdx] = sort(depthAll{s}, 'ascend'); % shallowest first + + elseif params.sortBy == "spatialTuning" + [~, sortIdx] = sort(tuningAll{s}, params.tuningSortOrder, ... + 'MissingPlacement', 'last'); % most-tuned first + + elseif params.sortBy == "preferredCategory" + if strlength(params.splitCategory(s)) == 0 + sortIdx = 1:size(data, 1); % no category: no reorder + else + levels = prefLevelAll{s}; % preferred level per neuron + nNeurons = size(data, 1); % total neurons for this stim + + postStimBins = tAxis{s} >= lockedPreBase; % post-onset mask + meanPostResp = mean(data(:, postStimBins), 2)'; % mean post-onset value per neuron + + uniqueLevels = unique(levels(~isnan(levels))); % unique levels (ascending) + + sortIdx = zeros(1, nNeurons); % pre-allocate + cursor = 0; % running position + groupInfo = struct('edges', [], 'labels', {{}}); % for plot annotations + + for li = 1:numel(uniqueLevels) + groupMask = levels == uniqueLevels(li); % neurons preferring this level + groupIndices = find(groupMask); % their row indices + groupResp = meanPostResp(groupMask); % their mean responses + + [~, withinOrder] = sort(groupResp, 'descend'); % strongest first + nGroup = numel(groupIndices); % neurons in this group + + sortIdx(cursor+1 : cursor+nGroup) = groupIndices(withinOrder); + + groupInfo.edges(end+1) = cursor + nGroup; % group boundary + groupInfo.labels{end+1} = sprintf('%s=%g', ... + params.splitCategory(s), uniqueLevels(li)); + + cursor = cursor + nGroup; % advance cursor + end + + % Handle any NaN-level neurons defensively + nanMask = isnan(levels); + if any(nanMask) + nanIndices = find(nanMask); + nNan = numel(nanIndices); + sortIdx(cursor+1 : cursor+nNan) = nanIndices; + groupInfo.edges(end+1) = cursor + nNan; + groupInfo.labels{end+1} = 'unclassified'; + cursor = cursor + nNan; + end + + levelGroupBounds{s} = groupInfo; % store for plotting + end + + else + sortIdx = 1:size(data, 1); % identity permutation + end + + % Apply sort to all parallel vectors + rasterAll{s} = data(sortIdx, :); + depthAll{s} = depthAll{s}(sortIdx); + expAll{s} = expAll{s}(sortIdx); + phyAll{s} = phyAll{s}(sortIdx); + prefLevelAll{s} = prefLevelAll{s}(sortIdx); + respGroupAll{s} = respGroupAll{s}(sortIdx); % reorder group labels (empty in standard mode) + + if params.sortBy == "spatialTuning" + tuningAll{s} = tuningAll{s}(sortIdx); + end + + end +end + +% ========================================================================= +% PLOT +% ========================================================================= + +% Short display labels for each stimulus type +stimLegendMap = containers.Map( ... + {'RG', 'MB', 'MBR', 'SDGs', 'SDGm', 'SDG', 'NI', 'NV', 'FFF'}, ... + {'RG', 'MB', 'MBR', 'SDGs', 'SDGm', 'SDG', 'NI', 'NV', 'FFF'}); + +nStim = numel(params.stimTypes); + +% ------------------------------------------------------------------ +% Global colour limits — computed once, shared across all tiles +% ------------------------------------------------------------------ +allValues = []; +for s = 1:nStim + if ~isempty(rasterAll{s}) + % In union mode, NaN rows are placeholders — exclude from colour scaling + vals = rasterAll{s}(:)'; % flatten PSTH matrix + allValues = [allValues, vals(~isnan(vals))]; %#ok % pool non-NaN values + end +end + +if params.zScore + cLimPos = prctile(allValues, params.climPrctile); % data-driven upper limit + cLims = [-params.climNeg, cLimPos]; % fixed lower, data-driven upper +else + % Asymmetric percentile clipping: 2nd pctl as floor, climPrctile as ceiling + cLims = [prctile(allValues, 2), prctile(allValues, params.climPrctile)]; +end + +% ------------------------------------------------------------------ +% Build colormap once +% ------------------------------------------------------------------ +if params.zScore && params.colormap ~= "gray" + + % FIX 8: warn if climNeg=0 collapses the negative half + if params.climNeg == 0 + warning('plotRaster:noNegRange', ... + 'climNeg = 0 with a diverging colormap: negative half is empty. Set climNeg > 0 for a true diverging scale.'); + end + + nColors = 256; % total colour entries + nNeg = round(nColors * params.climNeg / (params.climNeg + cLimPos)); % entries for negative half + nPos = nColors - nNeg; % entries for positive half + blueHalf = [linspace(0.1,1,nNeg)', linspace(0.2,1,nNeg)', linspace(0.8,1,nNeg)']; % blue -> white + redHalf = [linspace(1,0.9,nPos)', linspace(1,0.2,nPos)', linspace(1,0.05,nPos)']; % white -> red + cmapToUse = [blueHalf; redHalf]; % diverging map +else + cmapToUse = flipud(gray); % white = low, black = high +end + +% ------------------------------------------------------------------ +% Figure and tiled layout +% ------------------------------------------------------------------ +fig = figure; +% FIX B: single figure sizing, width scales with panel count +set(fig, 'Units', 'centimeters', 'Position', [5 5 5*nStim + 2, 10]); + +tl = tiledlayout(fig, 1, nStim, 'TileSpacing', 'compact', 'Padding', 'compact'); +axAll = gobjects(1, nStim); % pre-allocate axes handles + +for s = 1:nStim + + data = rasterAll{s}; % PSTH matrix for this stim + stimKey = char(params.stimTypes(s)); % char key for Map lookup + if isKey(stimLegendMap, stimKey) + shortName = stimLegendMap(stimKey); % abbreviated label + else + shortName = stimKey; % fallback + end + + axAll(s) = nexttile(tl); % create tile + ax = axAll(s); + + if isempty(data) + title(ax, shortName, 'FontName', 'helvetica', 'FontSize', 8); + axis(ax, 'off'); % nothing to plot + continue + end + + % --- Per-stimulus time axis in seconds --- + tAxisPlot = tAxis{s} - lockedPreBase; % stim onset = 0 ms + tAxisSec = tAxisPlot / 1000; % convert to seconds + + imagesc(ax, tAxisSec, 1:size(data,1), data); % raster image + clim(ax, cLims); % shared colour limits + colormap(ax, cmapToUse); % shared colormap + + % In union mode, NaN rows render as the lowest colour by default. + % Set NaN to transparent (white) by using 'AlphaData'. + if params.unionUnits + nanMask = all(isnan(data), 2); % rows that are entirely NaN + if any(nanMask) + alphaMap = ones(size(data)); % fully opaque by default + alphaMap(nanMask, :) = 0; % make NaN rows transparent + set(findobj(ax, 'Type', 'Image'), 'AlphaData', alphaMap); % apply alpha mask + end + end + + % ------------------------------------------------------------------ + % Depth-bin boundary lines (depth sort only) + % ------------------------------------------------------------------ + if params.sortBy == "depth" && ~isempty(depthAll{s}) && ~isempty(depthFile) + + D2 = load(depthFile); % reload bin edges (FIX A: depthFile now always defined) + depthBinEdges = D2.depthBinEdges; % depth layer edges (um) + + binLabelsDepth = { ... + sprintf('%.0f-%.0f um', depthBinEdges(1), depthBinEdges(2)), ... + sprintf('%.0f-%.0f um', depthBinEdges(2), depthBinEdges(3)), ... + sprintf('%.0f-%.0f um', depthBinEdges(3), depthBinEdges(4))}; + + depthCombined = depthAll{s}(~isnan(depthAll{s})); % exclude NaN + labelX = tAxisSec(1) + 0.05 * range(tAxisSec); % label x pos: 5% from left + + for edge = 2:3 % internal boundaries only + lastInBin = find(depthCombined <= depthBinEdges(edge), 1, 'last'); + if ~isempty(lastInBin) && lastInBin < size(data,1) + yline(ax, lastInBin + 0.5, 'k-', 'LineWidth', 1.2); % horizontal separator + text(ax, labelX, lastInBin - size(data,1)*0.02, ... + binLabelsDepth{edge-1}, ... + 'Color', 'w', 'FontSize', 6, 'FontName', 'helvetica', ... + 'HorizontalAlignment', 'left', 'VerticalAlignment', 'top'); + end + end + text(ax, labelX, size(data,1), binLabelsDepth{3}, ... % deepest bin label + 'Color', 'w', 'FontSize', 6, 'FontName', 'helvetica', ... + 'HorizontalAlignment', 'left', 'VerticalAlignment', 'top'); + end + + % ------------------------------------------------------------------ + % Group boundary lines and labels (preferredCategory or union mode) + % ------------------------------------------------------------------ + showGroupBounds = ~isempty(levelGroupBounds{s}) && ... + (params.sortBy == "preferredCategory" || params.unionUnits); + + if showGroupBounds + + gInfo = levelGroupBounds{s}; + nGroups = numel(gInfo.edges); + + ytPositions = zeros(1, nGroups); + ytLabels = cell(1, nGroups); + + prevEdge = 0; + for gi = 1:nGroups + edgeRow = gInfo.edges(gi); + nInGroup = edgeRow - prevEdge; + + ytPositions(gi) = (prevEdge + edgeRow) / 2; % centre of group for tick label + rawLabel = gInfo.labels{gi}; % e.g. "size=129" or "both" + + % Format label: for preferredCategory labels contain "=", + % for union-mode labels are plain strings (e.g. "both"). + if contains(rawLabel, '=') + levelVal = extractAfter(rawLabel, '='); % e.g. "129" + else + levelVal = rawLabel; % plain label (e.g. "both", "static") + end + ytLabels{gi} = sprintf('%s (n=%d)', levelVal, nInGroup); % label with count + + if gi < nGroups + yline(ax, edgeRow + 0.5, 'k-', 'LineWidth', 1.5); % group boundary line + end + + prevEdge = edgeRow; + end + + set(ax, 'YTick', ytPositions, 'YTickLabel', ytLabels, ... + 'TickLength', [0 0], 'YTickLabelRotation', 90); % vertical tick labels + end + + % --- Stim onset / offset lines (seconds) --- + xline(ax, 0, 'k', 'LineWidth', 1.5); % onset at t = 0 s + xline(ax, stimDurAll(s)/1000, 'k', 'LineWidth', 1.5); % offset in seconds + + % --- SDG combined: additional line at static-to-moving transition --- + % For the combined SDG type, stimDurAll(s) = static + moving (full + % stimulus). The onset xline (t=0) and offset xline (stimDurAll/1000) + % already bracket the full stimulus. This third xline marks the + % boundary between the two phases within the stimulus window. + if params.stimTypes(s) == "SDG" && ~isnan(movingOnsetAll(s)) + xline(ax, movingOnsetAll(s)/1000, 'r--', 'LineWidth', 1.0 ... + , 'LabelHorizontalAlignment', 'left', ... + 'FontSize', 6); % red dashed line at static→moving transition + end + + % --- Axis formatting --- + xlim(ax, [tAxisSec(1), tAxisSec(end)]); % per-stim x range + ylim(ax, [0.5, size(data,1) + 0.5]); % half-row margin + + ticksSec = linspace(tAxisSec(1), tAxisSec(end), 5); % 5 equally spaced ticks + [~, iz] = min(abs(ticksSec)); % tick nearest to 0 + ticksSec(iz) = 0; % snap to exactly 0 + xticks(ax, ticksSec); % apply x tick positions + xticklabels(ax, arrayfun(@(v) sprintf('%.2g', v), ticksSec, 'UniformOutput', false)); % formatted labels + + if s == 1 + if params.sortBy == "preferredCategory" && strlength(params.splitCategory(s)) > 0 + ylabel(ax, params.splitCategory(s), ... + 'FontName', 'helvetica', 'FontSize', 8); % category name as ylabel + else + ylabel(ax, 'Neuron #', 'FontName', 'helvetica', 'FontSize', 8); + end + end + + title(ax, sprintf('%s (n=%d)', shortName, size(data,1)), ... + 'FontName', 'helvetica', 'FontSize', 8); % tile title with neuron count + + ax.FontName = 'helvetica'; % consistent font + ax.FontSize = 8; % readable size + ax.YDir = 'normal'; % neuron 1 at bottom + ax.TickDir = 'out'; % outward ticks + ax.Box = 'off'; % no top/right border + +end + +% ------------------------------------------------------------------ +% Shared x-label +% ------------------------------------------------------------------ +xlabel(tl, 'Time relative to stimulus onset (s)', ... + 'FontName', 'helvetica', 'FontSize', 8); + +% ------------------------------------------------------------------ +% Single colorbar on the rightmost tile +% ------------------------------------------------------------------ +cb = colorbar(axAll(end)); % attach to last axes +if params.zScore + cb.Label.String = 'Z-score'; % label for z-scored data +else + cb.Label.String = 'Firing rate (spk/s)'; % label for raw rates +end +cb.Label.FontName = 'helvetica'; % colorbar label font +cb.Label.FontSize = 8; +cb.FontName = 'helvetica'; % tick font +cb.FontSize = 8; + +sgtitle(sprintf('N = %d experiments', numel(exList)), ... + 'FontName', 'helvetica', 'FontSize', 10); % super-title + +% --- Export figure for publication if requested --- +if params.PaperFig + % FIX C: safely join splitCategory into a single string for filename + if any(strlength(params.splitCategory) > 0) + catStr = strjoin(params.splitCategory(strlength(params.splitCategory) > 0), '-'); + else + catStr = 'all'; % fallback when no category is set + end + if params.unionUnits + catStr = [catStr '_union']; % mark union mode in filename + end + vs_first.printFig(fig, sprintf('Raster-%s-%s', stimLabel, catStr), PaperFig=params.PaperFig); +end + +end + +% ========================================================================= +% LOCAL HELPER FUNCTIONS +% ========================================================================= + +function gt = detectGratingType(stimKey) +% detectGratingType Return the GratingType parameter from a stimulus +% abbreviation. 'moving' for SDGm, 'static' for SDGs/SDG, '' otherwise. + switch stimKey + case "SDGm"; gt = 'moving'; % moving (drifting) grating + case {"SDGs", "SDG"}; gt = 'static'; % static grating (or combined viewed from static onset) + otherwise; gt = ''; % not a grating stimulus + end +end + +function fn = resolveFieldName(stimKey, speedParam) +% resolveFieldName Map a stimulus abbreviation + speed selector to the +% sub-field name used in ResponseWindow / StatisticsPerNeuron structs. +% +% stimKey — stimulus abbreviation string (e.g. "MB", "SDGs", "SDG") +% speedParam — "max" or other speed selector +% fn — sub-field key (e.g. 'Speed1', 'Static', '') + switch stimKey + case {"MB","MBR"} + if speedParam == "max" + fn = 'Speed1'; % fastest speed condition + else + fn = 'Speed2'; % slower speed condition + end + case {"SDGs", "SDG"} + fn = 'Static'; % static grating sub-field (SDG combined uses static onset) + case "SDGm" + fn = 'Moving'; % moving grating sub-field + otherwise + fn = ''; % flat struct (RG, NI, NV, FFF) + end +end + +function names = abbrevToLegacyNames(stimKey) +% abbrevToLegacyNames Return the set of stimulus name strings that might +% appear in external tables (e.g. tuning tables) for a given abbreviation. + switch stimKey + case "RG"; names = ["RG", "rectGrid"]; + case "MB"; names = ["MB", "linearlyMovingBall"]; + case "MBR"; names = ["MBR", "linearlyMovingBar"]; + case "SDGs"; names = ["SDGs", "StaticGrating"]; + case "SDGm"; names = ["SDGm", "MovingGrating"]; + case "SDG"; names = ["SDG", "SDGs", "StaticGrating"]; % combined maps to static for tuning lookup + case "NI"; names = ["NI", "naturalImage"]; + case "NV"; names = ["NV", "naturalMovie"]; + case "FFF"; names = ["FFF", "fullFieldFlash"]; + otherwise; names = string(stimKey); % fallback: just the abbreviation + end +end + +function fName = levelToFieldName(catName, value) +% levelToFieldName Build a valid MATLAB field name matching +% StatisticsPerNeuronPerCategory's naming convention. +% e.g. ('direction', 0) -> 'direction_0' +% ('size', 5) -> 'size_5' +% ('speed', 0.3) -> 'speed_0p3' +% ('offset', -1) -> 'offset_neg1' + fName = sprintf('%s_%g', lower(strtrim(catName)), value); % base: 'cat_value' + fName = strrep(fName, '.', 'p'); % decimal point -> 'p' + fName = strrep(fName, '-', 'neg'); % minus sign -> 'neg' +end \ No newline at end of file