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..401d70e --- /dev/null +++ b/general functions/plotSwarmBootstrapWithComparisons.asv @@ -0,0 +1,807 @@ +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 = 7 % 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 +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 + +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) > 5; + plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... + yMaxVis, bracketPad, stackPad, textPad, adjacentOnly); +end + +% Cap visible y-range (brackets use Clipping=off so they remain visible) +ylim(ax, [ax.YLim(1) yMaxVis]); + +end % plotRawSwarm + + +% ========================================================================= +% LOCAL FUNCTION: plotDiffSwarm +% ========================================================================= +function randiColors = plotDiffSwarm(ax, tblDiff, pairs, pValues, params, ... + yMaxVis, bracketPad, textPad) + +hold(ax, 'on'); +set(ax, 'Clipping', 'off'); + +% Handle empty diff table (no overlapping data for this pair) +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 +randiColors = randperm(height(tblDiff)); + +% Color source +if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) + colorData = tblDiff.zScore(randiColors); +else + colorData = tblDiff.animal(randiColors); +end + +if params.filled + s = swarmchart(ax, tblDiff.stimulus(randiColors), tblDiff.value(randiColors), ... + params.dotSize, colorData, 'filled', ... + 'MarkerFaceAlpha', params.Alpha); +else + s = swarmchart(ax, tblDiff.stimulus(randiColors), tblDiff.value(randiColors), ... + params.dotSize, colorData, ... + 'MarkerEdgeAlpha', params.Alpha, 'LineWidth', 1, 'SizeData', 30); +end +s.XJitter = params.Xjitter; + +% Build readable tick label from pair names (e.g. 'MB 1.57 - MG 90') +pairLabels = buildTickLabels(string({pairs{1,1}, pairs{1,2}})); +xticklabels(ax, join(pairLabels, " - ")); + +% Colormap +if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) + colormap(ax, buildRdBuColormap(256)); + maxZ = max(abs(tblDiff.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(tblDiff.animal)))); +end + +% Zero reference line +yline(ax, 0, 'LineWidth', 1, 'Alpha', 0.7); + +ylabel(ax, params.yLegend); +ax.Box = 'off'; +ax.Layer = 'top'; + +% Bootstrap mean +/- SE +if params.plotMeanSem + stimuli = categories(tblDiff.stimulus); + plotMeanSemBars(ax, tblDiff, stimuli, params, 0.6); +end + +% ------------------------------------------------------------------------- +% Significance annotation (four-tier: ***, **, *, or nothing) +% ------------------------------------------------------------------------- +ylims = ylim(ax); +if ~isempty(pValues) && numel(pValues) >= 1 + pVal = pValues(1); % scalar for this diff panel + fprintf('Diff significance: p = %.4e\n', pVal); + + vals = tblDiff.value; + maxVisible = max(min(vals(:), yMaxVis(1))); + if isempty(maxVisible), maxVisible = yMaxVis; end + yText = maxVisible + bracketPad; + + % Only draw stars for significant results + if ~isnan(pVal) && pVal < 0.05 + if pVal < 0.001, txt = '***'; + elseif pVal < 0.01, txt = '**'; + else, txt = '*'; + end + text(ax, 1, yText, txt, ... + 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); + end + + ylim(ax, [ylims(1) yMaxVis]); +else + ylim(ax, [ylims(1) yMaxVis]); +end + +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) + +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 + 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); + 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); + 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 + 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); + +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 + + % Hierarchical or flat bootstrap + if isempty(groupVars) + bootMean = bootstrp(params.nBoot, @mean, vals); + else + bootMean = hierBoot(vals, params.nBoot, groupVals{:}); + 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) + +% Track y-positions of placed brackets to prevent overlap +usedHeights = zeros(size(pairs, 1), 1); + +for k = 1:size(pairs, 1) + + % Skip non-significant (no bracket, no text) + if isnan(pValues(k)) || pValues(k) >= 0.05 + 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'); + + % Star annotation + if pValues(k) < 0.001, txt = '***'; + elseif pValues(k) < 0.01, txt = '**'; + elseif pValues(k) < 0.05, txt = '*'; + 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) + +nPairs = size(pairs, 1); + +figAll = figure; +set(figAll, 'Color', 'w'); + +% Compute a reasonable grid for many tiles +nCols = min(nPairs, 7); % max 7 columns wide +nRows = ceil(nPairs / nCols); +tl = tiledlayout(figAll, nRows, nCols, ... + 'TileSpacing', 'compact', 'Padding', 'compact'); +title(tl, 'All pairwise differences'); + +for k = 1:nPairs + ax = nexttile(tl); + + pairK = pairs(k, :); % 1x2 cell for this pair + pValK = pValues(k); + + tblDiff = buildDiffTable(tbl, pairK, params, isInsertionLevel); + + % Empty diff table: show a minimal label and move on + if height(tblDiff) == 0 + pairLabels = buildTickLabels(string({pairK{1}, pairK{2}})); + text(ax, 0.5, 0.5, join(pairLabels, " - ") + " (no data)", ... + 'Units', 'normalized', 'HorizontalAlignment', 'center', ... + 'FontSize', 6, 'Color', [0.6 0.6 0.6]); + continue + end + + plotDiffSwarm(ax, tblDiff, pairK, pValK, params, ... + yMaxVis, bracketPad, textPad); +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) +out = strings(size(Str)); +for i = 1:numel(Str) + s = Str(i); + + % Extract uppercase prefix (MB, MG, etc.) + prefix = regexp(s, '^[A-Z]+', 'match', 'once'); + + % Extract number-like string (possibly negative or with 'p' for decimal) + numStr = regexp(s, '-?\d+(?:[p\.]\d+)?', 'match', 'once'); + + if isempty(numStr) + out(i) = s; % no number found — use original + continue + end + + % Decode 'p' -> '.' and convert to number + numStr = replace(numStr, 'p', '.'); + numVal = str2double(numStr); + + % Format with up to 2 decimals, strip trailing zeros + numFormatted = compose("%.2f", numVal); + numFormatted = regexprep(numFormatted, '\.?0+$', ''); + + if ~ismissing(prefix) + out(i) = prefix + " " + numFormatted; + else + out(i) = numFormatted; + 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 \ No newline at end of file diff --git a/general functions/plotSwarmBootstrapWithComparisons.m b/general functions/plotSwarmBootstrapWithComparisons.m index 6ae4703..daee790 100644 --- a/general functions/plotSwarmBootstrapWithComparisons.m +++ b/general functions/plotSwarmBootstrapWithComparisons.m @@ -1,385 +1,811 @@ -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 + 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 = 7 % 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 +end +% ------------------------------------------------------------------------- +% Up-front input validation +% ------------------------------------------------------------------------- - ins = categories(tbl.insertion); +% 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 - diffVals = []; - animals = []; - insers = []; +% 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 - for i = 1:numel(ins) - idxA = tbl.insertion == ins{i} & tbl.stimulus == stimA; - idxB = tbl.insertion == ins{i} & tbl.stimulus == 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 - if any(idxA) && any(idxB) +% Seed RNG once so bootstraps and dot-draw orders are deterministic +rng(params.rngSeed); - 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 +% ------------------------------------------------------------------------- +% 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 - diffVals = [diffVals; vA - vB]; - animals = [animals; repmat(tbl.animal(find(idxA,1)),length(vA),1)]; - insers = [insers; repmat(i,length(vA),1)]; - end - 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 + +if params.fraction + tbl.value = tbl.(valueField{1}) ./ tbl.(valueField{2}); % element-wise ratio +else + tbl.value = tbl.(valueField{1}); % raw value +end - valid = ~isnan(diffVals); +% 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 - stimName = sprintf('%s-%s', stimA, stimB); +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); - tblPlot = table(); - tblPlot.insertion = categorical(insers(valid)); - tblPlot.stimulus = categorical(repmat({stimName}, sum(valid), 1)); - tblPlot.animal = animals(valid); - tblPlot.value = diffVals(valid); + randiColors = plotRawSwarm(axRaw, tbl, pairs, pValues, params, ... + yMaxVis, bracketPad, stackPad, textPad, semAlpha, isInsertionLevel); - plotLinesBetween = false; + % 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 - %% ----------------- RAW MODE ----------------- - if params.fraction - assert(numel(valueField) == 2, ... - 'Fraction mode requires two valueField entries.'); - tbl.value = tbl.(valueField{1}) ./ tbl.(valueField{2}); + % 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 - tbl.value = tbl.(valueField{1}); + randiColors = plotRawSwarm(ax, tbl, pairs, pValues, params, ... + yMaxVis, bracketPad, stackPad, textPad, semAlpha, isInsertionLevel); end +end - tblPlot = tbl; - plotLinesBetween = true; +% ------------------------------------------------------------------------- +% 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 -%% ----------------- CHANGE STIM NAMES ----------------- -stimuli = unique(tblPlot.stimulus); +end % main function -% tbl.stimulus = removecats(tbl.stimulus); -% tbl.animal = removecats(tbl.animal); -% tbl.insertion = removecats(tbl.insertion); -%Replace 'RG' with 'SB' -% 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"); +% ========================================================================= +% 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); -% Convert back to categorical -tblPlot.stimulus = categorical(s); +% 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 -%% ----------------- 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'; + 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) > 5; + plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... + yMaxVis, bracketPad, stackPad, textPad, adjacentOnly); +end -%% ----------------- CLEAN CATEGORIES ----------------- -tblPlot.stimulus = removecats(tblPlot.stimulus); -tblPlot.animal = removecats(tblPlot.animal); -tblPlot.insertion = removecats(tblPlot.insertion); +% Cap visible y-range (brackets use Clipping=off so they remain visible) +ylim(ax, [ax.YLim(1) yMaxVis]); -stimuli = categories(tblPlot.stimulus); -insertions = categories(tblPlot.insertion); +end % plotRawSwarm -%% ----------------- RANDOMIZED COLORS ----------------- -randiColors = randperm(size(tblPlot,1)); -%% ----------------- FIGURE ----------------- -fig = figure; -ax = axes; +% ========================================================================= +% LOCAL FUNCTION: plotDiffSwarm +% ========================================================================= +function randiColors = plotDiffSwarm(ax, tblDiff, pairs, pValues, params, ... + yMaxVis, bracketPad, textPad) + hold(ax, 'on'); -set(ax, 'Clipping', 'off'); % <-- ADD THIS LINE +set(ax, 'Clipping', 'off'); + +% Handle empty diff table (no overlapping data for this pair) +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 +randiColors = randperm(height(tblDiff)); + +% Color source +if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) + colorData = tblDiff.zScore(randiColors); +else + colorData = tblDiff.animal(randiColors); +end -% 1) Swarm first if params.filled - s=swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... - params.dotSize, tblPlot.animal(randiColors), 'filled', ... + s = swarmchart(ax, tblDiff.stimulus(randiColors), tblDiff.value(randiColors), ... + params.dotSize, colorData, '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); + s = swarmchart(ax, tblDiff.stimulus(randiColors), tblDiff.value(randiColors), ... + params.dotSize, colorData, ... + 'MarkerEdgeAlpha', params.Alpha, 'LineWidth', 1, 'SizeData', 30); end - 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)); +% Build readable tick label from pair names (e.g. 'MB 1.57 - MG 90') +pairLabels = buildTickLabels(string({pairs{1,1}, pairs{1,2}})); +xticklabels(ax, join(pairLabels, " - ")); + +% Colormap +if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) + colormap(ax, buildRdBuColormap(256)); + maxZ = max(abs(tblDiff.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(tblDiff.animal)))); +end + +% Zero reference line +yline(ax, 0, 'LineWidth', 1, 'Alpha', 0.7); - xNum = arrayfun(@(i) xMap(char(tblPlot.stimulus(i))), ... - 1:height(tblPlot)); +ylabel(ax, params.yLegend); +ax.Box = 'off'; +ax.Layer = 'top'; +% Bootstrap mean +/- SE +if params.plotMeanSem + stimuli = categories(tblDiff.stimulus); + plotMeanSemBars(ax, tblDiff, stimuli, params, 0.6); +end - try - tblPlot.NeurID; - UI = 'NeurID'; - catch - UI = 'insertion'; - end +% ------------------------------------------------------------------------- +% Significance annotation (four-tier: ***, **, *, or nothing) +% ------------------------------------------------------------------------- +ylims = ylim(ax); +if ~isempty(pValues) && numel(pValues) >= 1 + pVal = pValues(1); % scalar for this diff panel + fprintf('Diff significance: p = %.4e\n', pVal); + + vals = tblDiff.value; + maxVisible = max(min(vals(:), yMaxVis(1))); + if isempty(maxVisible), maxVisible = yMaxVis; end + yText = maxVisible + bracketPad; - % 3) Plot lines AFTER swarm - for i = 1:numel(unique(tblPlot.(UI))) - idx = double(tblPlot.(UI)) == i; - if sum(idx) < 2 - continue + % Only draw stars for significant results + if ~isnan(pVal) && pVal < 0.05 + if pVal < 0.001, txt = '***'; + elseif pVal < 0.01, txt = '**'; + else, txt = '*'; end - - line(ax, ... - xNum(idx), tblPlot.value(idx), ... - 'Color', [0 0 0 0.1], ... - 'LineWidth', 0.1),'lin'; + text(ax, 1, yText, txt, ... + 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); end + + ylim(ax, [ylims(1) yMaxVis]); else - yline(0,LineWidth=1,Alpha=0.7) + ylim(ax, [ylims(1) yMaxVis]); end -coloranimal = {lines(numel(categories(tblPlot.animal))),categories(tblPlot.animal)}; -colormap(lines(numel(categories(tblPlot.animal)))) -ylabel(params.yLegend) +end % plotDiffSwarm -ax = gca; -ax.Box = 'off'; -ax.Layer = 'top'; -%% ----------------- BOOTSTRAP MEAN + SEM ----------------- +% ========================================================================= +% 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) -if params.plotMeanSem +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) - for i = 1:numel(stimuli) +useZ = params.colorByZScore && ismember('zScore', tbl.Properties.VariableNames); - idx = tblPlot.stimulus == stimuli{i}; +for i = 1:numel(ins) + idxA = tbl.insertion == ins{i} & tbl.stimulus == stimA; + idxB = tbl.insertion == ins{i} & tbl.stimulus == stimB; - 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); + % 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 + 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); + 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); + 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 + if useZ + zScores = [zScores; zPair]; %#ok + end +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); +% 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); - % mean - dx = 0.15; - plot([i-dx i+dx], [mu mu], 'k-', 'LineWidth', 1.2); - end +if useZ + tblDiff.zScore = zScores(valid); end +end % buildDiffTable -%% ----------------- 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); +% ========================================================================= +% 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 k = 1:size(pairs,1) - - fprintf('\n--- Pair %d: %s vs %s ---\n', k, pairs{k,1}, pairs{k,2}); +for i = 1:numel(stimuli) + idx = tblPlot.stimulus == stimuli{i}; + if ~any(idx), continue; end - 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 + % 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 + + % Hierarchical or flat bootstrap + if isempty(groupVars) + bootMean = bootstrp(params.nBoot, @mean, vals); + else + bootMean = hierBoot(vals, params.nBoot, groupVals{:}); + end - vals1 = tblPlot.value(tblPlot.stimulus == pairs{k,1}); - vals2 = tblPlot.value(tblPlot.stimulus == pairs{k,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 - fprintf('vals1 count: %d, vals2 count: %d\n', numel(vals1), numel(vals2)); + % 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 - maxVisible = max(min([vals1; vals2], yMaxVis)); - yBase = maxVisible + bracketPad; +end % plotMeanSemBars - 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 + +% ========================================================================= +% 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) + +% Track y-positions of placed brackets to prevent overlap +usedHeights = zeros(size(pairs, 1), 1); + +for k = 1:size(pairs, 1) + + % Skip non-significant (no bracket, no text) + if isnan(pValues(k)) || pValues(k) >= 0.05 + continue end - -end - -%% ----------------- SIGNIFICANCE FOR DIFF MODE ----------------- -ylims = ylim; - -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 + + % 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'); + + % Star annotation + if pValues(k) < 0.001, txt = '***'; + elseif pValues(k) < 0.01, txt = '**'; + elseif pValues(k) < 0.05, txt = '*'; + 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) + +nPairs = size(pairs, 1); + +figAll = figure; +set(figAll, 'Color', 'w'); + +% Compute a reasonable grid for many tiles +nCols = min(nPairs, 7); % max 7 columns wide +nRows = ceil(nPairs / nCols); +tl = tiledlayout(figAll, nRows, nCols, ... + 'TileSpacing', 'compact', 'Padding', 'compact'); +title(tl, 'All pairwise differences'); + +for k = 1:nPairs + ax = nexttile(tl); + + pairK = pairs(k, :); % 1x2 cell for this pair + pValK = pValues(k); + + tblDiff = buildDiffTable(tbl, pairK, params, isInsertionLevel); + + % Empty diff table: show a minimal label and move on + if height(tblDiff) == 0 + pairLabels = buildTickLabels(string({pairK{1}, pairK{2}})); + text(ax, 0.5, 0.5, join(pairLabels, " - ") + " (no data)", ... + 'Units', 'normalized', 'HorizontalAlignment', 'center', ... + 'FontSize', 6, 'Color', [0.6 0.6 0.6]); + continue + end + + plotDiffSwarm(ax, tblDiff, pairK, pValK, params, ... + yMaxVis, bracketPad, textPad); +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) +out = strings(size(Str)); +for i = 1:numel(Str) + s = Str(i); + + % Extract uppercase prefix (MB, MG, etc.) + prefix = regexp(s, '^[A-Z]+', 'match', 'once'); + + % Extract number-like string (possibly negative or with 'p' for decimal) + numStr = regexp(s, '-?\d+(?:[p\.]\d+)?', 'match', 'once'); + + if isempty(numStr) + out(i) = s; % no number found — use original + continue + end + + % Decode 'p' -> '.' and convert to number + numStr = replace(numStr, 'p', '.'); + numVal = str2double(numStr); + + % Format with up to 2 decimals, strip trailing zeros + if numVal < 0.01 + numFormatted = compose("%.2e", numVal); else - fprintf('p-value not significant enough (>= 1e-3)\n'); - ylim([ylims(1) yMaxVis]); + numFormatted = compose("%.2f", numVal); + end + numFormatted = regexprep(numFormatted, '\.?0+$', ''); + + if ~ismissing(prefix) + out(i) = prefix + " " + numFormatted; + else + out(i) = numFormatted; end - - fprintf('=== END DIFF MODE SIGNIFICANCE ===\n'); -else - ylim([ylims(1) yMaxVis]); 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 \ 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/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..dd9fe04 --- /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 = 200, ... + 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..e899799 --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.m @@ -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.MovingWindowDuration = 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/StatisticsPerNeuronSpatialGrid.m b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m new file mode 100644 index 0000000..1a319fd --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m @@ -0,0 +1,376 @@ +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); % 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] + nullStatsAll = zeros(params.nBoot * nDirs, nNeurons); % pooled null + + 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; + + % Store max across valid cells for this permutation and direction + nullStatsAll((d-1)*params.nBoot + b, :) = max(meanPerm, [], 1); + end + end + + % ------------------------------------------------------------------------- + % Overall observed stat and p-value + % obsStat: max across directions per neuron + % pVal: proportion of pooled null >= observed + % ------------------------------------------------------------------------- + [obsStat, prefDirection] = max(obsStatPerDir, [], 1); % [1 × nNeurons] + pVal = mean(nullStatsAll >= 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(nullStatsAll, 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..3d8af68 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/VStimAnalysis.m +++ b/visualStimulationAnalysis/@VStimAnalysis/VStimAnalysis.m @@ -40,6 +40,7 @@ else obj.dataObj=dataObj; end + end end diff --git a/visualStimulationAnalysis/@VStimAnalysis/plotZScoreReceptiveField.m b/visualStimulationAnalysis/@VStimAnalysis/plotZScoreReceptiveField.m new file mode 100644 index 0000000..f9b9d1f --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/plotZScoreReceptiveField.m @@ -0,0 +1,52 @@ +function plotZScoreReceptiveField(obj, neuronIdx, direction, speedIdx) +% plotZScoreReceptiveField - Visualise grid-based z-score receptive field. +% +% Inputs: +% neuronIdx : index of neuron to plot +% direction : 'best' (default) or a specific direction index +% speedIdx : 1 or 2 (default 1) + + if nargin < 3, direction = 'best'; end + if nargin < 4, speedIdx = 1; end + + % Load cached results + if ~isfile(obj.getAnalysisFileName) + error('Run StatisticsPerNeuronSpatialGrid first.'); + end + S = load(obj.getAnalysisFileName); + fieldName = sprintf('Speed%d', speedIdx); + if ~isfield(S, fieldName) + error('Speed%d not present in results.', speedIdx); + end + + data = S.(fieldName); + + % Pick direction + if strcmp(direction, 'best') + dirIdx = data.prefDirection(neuronIdx); + else + dirIdx = direction; + end + + % Extract mean Diff per cell for this neuron at this direction + % Use pooled across all factor levels — average the ZScorePerGrid across levels + factorFields = fieldnames(data.ZScorePerGrid); + fName = factorFields{1}; % use first factor's array (all have same dirs) + zArr = data.ZScorePerGrid.(fName); % [nCells × nDirs × nLevels × nNeurons] + + zCell = squeeze(mean(zArr(:, dirIdx, :, neuronIdx), 3, 'omitnan')); % [nCells × 1] + + % Reshape to grid + gridSize = data.gridSize; + zGrid = reshape(zCell, gridSize, gridSize)'; % transpose because (gy-1)*nGrid + gx + + % Plot + figure; + imagesc(zGrid); + axis image; + colorbar; + title(sprintf('Neuron %d — Direction %d — Speed %d', neuronIdx, dirIdx, speedIdx)); + xlabel('grid x'); + ylabel('grid y'); + colormap(parula); +end \ No newline at end of file diff --git a/visualStimulationAnalysis/@fullFieldFlashAnalysis/fullFieldFlashAnalysis.m b/visualStimulationAnalysis/@fullFieldFlashAnalysis/fullFieldFlashAnalysis.m index 3a4639a..226e267 100644 --- a/visualStimulationAnalysis/@fullFieldFlashAnalysis/fullFieldFlashAnalysis.m +++ b/visualStimulationAnalysis/@fullFieldFlashAnalysis/fullFieldFlashAnalysis.m @@ -203,10 +203,10 @@ %4% real spike rate of response posTr*trialDivision-trialDivision+1:posTr*trialDivision - NeuronRespProfile(k,4) = mean(Mr(max_position_Trial(k,1)*mergeTrials-mergeTrials+1:max_position_Trial(k,1)*mergeTrials-1,u,... + NeuronRespProfile(k,4) = mean(Mr(max_position_Trial(k,1)*mergeTrials-mergeTrials+1:max_position_Trial(k,1)*mergeTrials,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)*mergeTrials-mergeTrials+1:max_position_TrialB(k,1)*mergeTrials-1,u,... + BaseResp = mean(Mbd(max_position_TrialB(k,1)*mergeTrials-mergeTrials+1:max_position_TrialB(k,1)*mergeTrials,u,... max_position_TrialB(k,2):max_position_TrialB(k,2)+window_size(2)-1),'all'); %4%. Resp - Baseline @@ -223,7 +223,7 @@ NeuronVals(u,:,:) = NeuronRespProfile; end - colNames = {'Resp','MaxWinTrial','MaxWinBin','RespSubsBaseline','Position','Size','Luminosities'}; + colNames = {''}; S.params = params; S.colNames = {colNames}; diff --git a/visualStimulationAnalysis/@imageAnalysis/imageAnalysis.m b/visualStimulationAnalysis/@imageAnalysis/imageAnalysis.m index 86aa899..0a835d8 100644 --- a/visualStimulationAnalysis/@imageAnalysis/imageAnalysis.m +++ b/visualStimulationAnalysis/@imageAnalysis/imageAnalysis.m @@ -206,10 +206,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 @@ -229,7 +229,7 @@ NeuronVals(u,:,:) = NeuronRespProfile; end - colNames = {'Resp','MaxWinTrial','MaxWinBin','RespSubsBaseline','Position','Size','Luminosities'}; + colNames = {'Resp','MaxWinTrial','MaxWinBin','RespSubsBaseline','imgeOrder','shuffled'}; S.params = params; S.colNames = {colNames}; diff --git a/visualStimulationAnalysis/@imageAnalysis/plotRaster.m b/visualStimulationAnalysis/@imageAnalysis/plotRaster.m index 8cf2159..e09221b 100644 --- a/visualStimulationAnalysis/@imageAnalysis/plotRaster.m +++ b/visualStimulationAnalysis/@imageAnalysis/plotRaster.m @@ -1,202 +1,427 @@ -function plotRaster(obj,params) - -arguments (Input) +function plotRaster(obj, params) +% plotRaster Natural-image raster, PSTH, and raw trace figure. +% +% Layout (manually positioned axes — no subplot): +% +% +-----------------------------+ top = 1 - topMargin +% | | +% | RASTER | THUMB | +% | | +% +-----------------------------+ psthTop + midGap +% | midGap (empty) | +% +-----------------------------+ psthTop +% | PSTH | +% +-----------------------------+ psthBottom = bottomMargin +% | bottomMargin (xlabel) | +% +-----------------------------+ y = 0 +% +% ResponseWindow fields used: +% C columns = [stimOnTime, imageIndex, state] +% stimDur, stimInter, NeuronVals + +% -------------------------------------------------------------------------- +% BUG FIXES vs original: +% 1. Mr2 = Mr(:,u,:) left 3-D when MergeNtrials==1 -> 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..045e538 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..84952ca 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(uDir == 0); % 0 rad = upward motion case "left" - dirIDX = find(uDir==1.57); + dirIDX = find(uDir == 1.57); % π/2 rad ≈ leftward motion case "down" - dirIDX = find(uDir==3.14); + dirIDX = find(uDir == 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(uDir == 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..c13e103 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 diff --git a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.asv b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.asv new file mode 100644 index 0000000..a892950 --- /dev/null +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.asv @@ -0,0 +1,719 @@ +function plotRaster(obj,params) + +arguments (Input) + obj + params.overwrite logical = false + params.analysisTime = datetime('now') + params.inputParams = false + params.preBase = 200 + 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 + params.speed string = "max" %or "1", "2", etc + 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; + +if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; +else + Stats = obj.StatisticsPerNeuron; +end + + + +if params.speed ~= "max" + fieldName = sprintf('Speed%d', str2double(params.speed)); +else + fn = fieldnames(NeuronResp); + + % Extract numbers from field names + nums = nan(numel(fn),1); + for i = 1:numel(fn) + tok = regexp(fn{i}, '\d+', 'match'); + if ~isempty(tok) + nums(i) = str2double(tok{end}); % use last number if multiple + end + end + + % Find field with highest number + [~, idx] = max(nums); + fieldName = fn{idx}; + +end + +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; + +if params.OneDirection ~= "all" + switch params.OneDirection + case "up" + C = NeuronResp.(fieldName).C(round(C(:,2), 2)==0,:); + case "left" + C = NeuronResp.(fieldName).C(round(C(:,2), 2)==1.57,:); + case "down" + C = NeuronResp.(fieldName).C(round(C(:,2), 2)==3.14,:); + case "right" + C = NeuronResp.(fieldName).C(round(C(:,2), 2)==4.71,:); + otherwise + error("Unknown inputPa value: %s", params.OneDirection) + end +end + +if params.OneLuminosity ~= "all" + switch params.OneLuminosity + case "black" + C = C(round(C(:,6), 2)==1,:); + case "white" + C = C(round(C(:,6), 2)==255,:); + otherwise + error("Unknown inputPa value: %s", params.OneLuminosity) + end +end + + +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)'; + + + +% ========================================================== +% 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 + + + + +%Number of unique parameters per category +offsetN = length(uOffset); +direcN = length(uDir); +sizeN = length(uSize); +speedN = length(uSpeed); +nT = numel(C(:,1)); +lumsN = length(uLums); +trialDivision = nT/(offsetN*direcN*speedN*sizeN*lumsN); %Number of trials per unique conditions + +preBase = round(stimInter-stimInter/4); + +if params.AllSomaticNeurons + 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 + if isempty(eNeuron) + fprintf('No responsive neurons.\n') + return + end +else + eNeuron = params.exNeurons; + 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)); + +if params.Gaussian + [Mr]=ConvBurstMatrix(Mr,fspecial('gaussian',[1 params.GaussianLength],3),'same'); +end + +channels = goodU(1,eNeuron); + +[nT,nN,nB] = size(Mr); + +Mr2 = []; + +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 + + + fig = figure; + + + + %sizeN=1; + + j=1; + + mergeTrials = params.MergeNtrials; + + Mr2 = zeros(size(Mr,1),size(Mr,3)); + + if mergeTrials > 1 %Merge trials + + 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]); + + j = j+1; + + end + else + Mr2 = Mr(:,ur,:); + end + + [nT,nN,nB] = size(Mr2); + + if sum(Mr2,'all') ==0 + close + ur = ur+1; + continue + end + + + 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: + xline(stimDur/params.bin+preBase/params.bin,'k',LineWidth=1.5) + + + if params.MaxVal_1 + caxis([0 1]) + 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([]) + xlim([0 round(stimDur+preBase*2)/params.bin]) + xticks([0 preBase/params.bin:600/params.bin:(stimDur+preBase*2)/params.bin (round((stimDur+preBase*2)/100)*100)/params.bin]) + xticklabels([]); + + yt =[0]; + for d = 1:direcN + yt = [yt [1:trialDivision*2*sizeN:(nT/direcN)-1+trialDivision*sizeN]+max(yt)+trialDivision-1]; + + end + + yt = yt(2:end-1); + yticks(yt) + + yticklabels(repmat([trialDivision:trialDivision*2*sizeN:(nT/direcN)-1+trialDivision*sizeN],1,direcN)) + + ax = gca; % Get current axes + ax.YAxis.FontSize = 8; % Change font size of y-axis tick labels + ax.YAxis.FontName = 'helvetica'; + + ylabel('Trials','FontSize',10,'FontName','helvetica') + + if params.SelectedWindow %%Select highest window stim type + j =1; + meanMr = zeros(1,nT/trialDivision); + for i = 1:trialDivision:nT + meanMr(j) = mean(Mr2(i:i+trialDivision-1,:),'all'); + j = j+1; + end + + %Find max trial category + [maxTrialCat,maxRespIn]= max(meanMr); + 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); + + 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 = 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 + [maxResp,maxRespIn]= max(NeuronResp.(fieldName).NeuronVals(u,:,1)); + else + [maxResp,maxRespIn]= max(NeuronResp.(fieldName).NeuronVals(u,:,4)); + end + start = NeuronResp.(fieldName).NeuronVals(u,maxRespIn,3)*NeuronResp.params.binRaster-20; + window = 500; + maxRespIn = maxRespIn-1; + end + + trials = maxRespIn*trialDivision+1:maxRespIn*trialDivision + trialDivision; + y1 = maxRespIn*trialDivision + trialDivision+0.5; + y2 = maxRespIn*trialDivision+0.5; + + + %patch([(preBase+start)/params.bin (preBase+start+window)/params.bin (preBase+start+window)/params.bin (preBase+start)/params.bin],... + % [y2 y2 y1 y1],... + % 'r','FaceAlpha',0.2,'EdgeColor','none') + + patch([0 (preBase*2+stimDur)/params.bin (preBase*2+stimDur)/params.bin 0],... + [y2 y2 y1 y1],... + 'k','FaceAlpha',0.1,'EdgeColor','none') + + + % TrialM = squeeze(Mr2(trials,round((preBase+start)/params.bin):round((preBase+start+window)/params.bin)))'; + % + % [mxTrial TrialNumber] = max(sum(TrialM)); + + RasterTrials = trials(best_row); + + % 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([(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 + %%%%%% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + + subplot(18,1,[17 18]) + + MRhist = BuildBurstMatrix(goodU(:,u),round(p.t),round((directimesSorted-preBase)),round((stimDur+preBase*2))); + + MRhist = squeeze(MRhist(trials,:,:)); + + [nT2,nB2]= size(MRhist); + + spikeTimes = repmat([1:nB2],nT2,1); + + spikeTimes = spikeTimes(logical(MRhist)); + % Define bin edges (adjust for resolution) + binWidth = 125; % 10 ms bins + if nB>300 + binWidth = 250; + end + edges = [1:binWidth:round((stimDur+preBase*2))]; % Adjust time window as needed + + % Compute histogram + psthCounts = histcounts(spikeTimes, edges); + + % Convert to firing rate (normalize by bin width) + psthRate = (psthCounts / (binWidth * nT2))*1000; + + b=bar(edges(1:end-1), psthRate,'histc'); + b.FaceColor = 'k'; + b.FaceAlpha = 0.3; + b.MarkerEdgeColor = "none"; + xlim([0 round((stimDur+preBase*2)/100)*100]) + + try %zero spiking in selection + ylim([0 max(psthRate)+std(psthRate)]) + catch + ur = ur+1; + close + continue + end + + xticks([0 preBase:600:(stimDur+preBase*2) round((stimDur+preBase*2)/100)*100]) + + xline([preBase stimDur+preBase],'LineWidth',1.5) + + xticklabels([-(preBase) 0:600:round((stimDur/100))*100 round((stimDur/100))*100 + 2*preBase]./1000) + + ax = gca; % Get current axes + ax.XAxis.FontSize = 8; + ax.XAxis.FontName = 'helvetica'; + + ax.YAxis.FontSize = 8; + ax.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]) + + + %%%%PLot raw data several trials one + %%%%channel%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + + %Mark selected trial + + bin3 = 1; + trialM = BuildBurstMatrix(goodU(:,u),round(p.t/bin3),round((directimesSorted+start)/bin3),round((window)/bin3)); + TrialM = squeeze(trialM(trials,:,:))'; + + [mxTrial TrialNumber] = max(mean(TrialM)); + + %RasterTrials = trials(TrialNumber); + + RasterTrials = trials(best_row); + + chan = goodU(1,u); + + subplot(18,1,[1 3]) + + startTimes = directimesSorted(RasterTrials)+start-preBase; + + freq = "AP"; %or "LFP" + + typeData = "line"; %or heatmap + + spikes = squeeze(BuildBurstMatrix(goodU(:,u),round(p.t),round(startTimes),round((window)))); + + + [fig, mx, mn] = PlotRawDataNP(obj,fig = fig,chan = chan, startTimes = startTimes,... + window = window,spikeTimes = spikes); + + ax = gca; + ax.YAxis.FontSize = 8; + ax.YAxis.FontName = 'helvetica'; + + xlims= xlim; + xticks([0:(xlims(2)/5):xlims(2)]) + xticklabels([0:100:window]) + ax.XAxis.FontSize = 8; + ax.XAxis.FontName = 'helvetica'; + + xlabel(string(chan)) + xline(-start/1000,'LineWidth',1.5) + xlabel('Time [ms]','FontName','helvetica','FontSize',10) + + 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=%.4f',u,channels(ur),phy_IDg(u),pvals(2,ur)),... + sprintf('Ball-sizes-deg-%s',sizesString)}); + + %%%%%%%%%%% Plot raster of selected trials + %%%%%%%%%%% %%%%%%%%%%%%%%%%%%%%%%%%%%% + % subplot(20,1,[5 7]) + % + % RasterTrials = trials; + % + % bin3 = 4; + % trialM = BuildBurstMatrix(goodU(:,u),round(p.t/bin3),round((directimesSorted+start)/bin3),round((window)/bin3)); + % + % if numel(RasterTrials) == 1 + % TrialM = squeeze(trialM(RasterTrials,:,:))'; + % else + % TrialM = squeeze(trialM(trials,:,:)); + % end + % + % %TrialM(TrialM~=0) = 0.3; + % spikes1 = TrialM(TrialNumber,:); + % spikeLoc = find(spikes1 >0); + % if isempty(spikeLoc) + % close + % ur = ur+1; + % continue + % end + % %TrialM(TrialNumber,spikeLoc) = 1; + % + % %Select offset in which selected trial belongs (10 trials) + % imagesc(TrialM);colormap(flipud(gray(64))); + % caxis([0 1]) + % xline([preBase stimDur+preBase],'LineWidth',1.5) + % ylabel([sprintf('%d trials',numel(trials))]) + % + % xticks([1:50/bin3:window/bin3+1]) + % xticklabels([0:50:window]) + % + % ax = gca; + % ax.XRuler.TickDirection = 'out'; % ticks only outward (bottom) + % ax.XAxisLocation = 'bottom'; + % + % xlabel('Milliseconds') + % set(gca,'FontSize',7) + % + % xline(-start/bin3,'LineWidth',1.5) + % + % xline(spikeLoc,'LineWidth',1,'Color','r','Alpha',0.3) + % + % yline(TrialNumber,'LineWidth',3,'Color','r','Alpha',0.3) %Mark trial + + %fig.Position = [1 1 366 379]; + + set(fig, 'Units', 'centimeters'); + set(fig, 'Position', [20 20 9 12]); + + if params.PaperFig + obj.printFig(fig,sprintf('%s-%s-MovBall-SelectedTrials-eNeuron-%d',obj.dataObj.recordingName,fieldName,u),PaperFig = params.PaperFig) + elseif params.overwrite + obj.printFig(fig,sprintf('%s-%s-MovBall-SelectedTrials-eNeuron-%d',obj.dataObj.recordingName,fieldName,u)) + end + + if ur ~= length(eNeuron) + close + end + + ur = ur+1; + +end %end eNeuron for loop + +end %end plotRaster \ No newline at end of file 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..b4f9410 100644 --- a/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m +++ b/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m @@ -12,295 +12,385 @@ 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. +sizes = obj.VST.tilingRatios; +uSize = unique(sizes); +nSize = length(uSize); +uLums = unique(obj.VST.rectLuminosity(obj.VST.luminosities)); +nLums = length(uLums); -trialDiv = length(seqMatrix)/length(unique(seqMatrix))/nSize/nLums; -directimesSorted = C(:,1)'; +% 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; - - MRtotal(2,:,:,:) = Mro; + % 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); - %%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; + + % Binary circle mask: 1 inside stimulus, 0 outside + distances = sqrt((x - Xc).^2 + (y - Yc).^2); + xyScreen(distances <= r) = 1; - xyScreen = zeros(screenRed,screenRed)'; %%Make calculations if sizes>1 and if experiment is new and the shape is a circle. + VideoScreen(:,:,j) = xyScreen'; + pxyScreen = pxyScreen + xyScreen; + j = j + 1; +end - % string(obj.VST.shape) == "circle" %%%Asumes that shape is circle +% ------------------------------------------------------------------------- +% Spike rate grid map: bin trials into nGrid x nGrid spatial grid +% ------------------------------------------------------------------------- +nGrid = params.nGrid; - 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; +% Grid edges in reduced pixel coordinates +xEdges = linspace(0, screenSide(3) / params.reduceFactor, nGrid + 1); +yEdges = linspace(0, screenSide(4) / params.reduceFactor, nGrid + 1); - 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; +% [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); - 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; +jj = 1; +for i = 1:trialDiv:nT - % Calculate the distance of each point from the center - distances = sqrt((x - Xc).^2 + (y - Yc).^2); + % Bin stimulus centre into grid cell + xBin = discretize(XcStore(jj), xEdges); + yBin = discretize(YcStore(jj), yEdges); - % Set the values inside the circle to 1 (or any other value you prefer) - xyScreen(distances <= r) = 1; + if isnan(xBin) || isnan(yBin) + jj = jj + 1; + continue + end - % - % 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); + sizeIdx = find(uSize == C(i,3)); + lumIdx = find(uLums == C(i,4)); - %figure;imagesc(xyScreen') + trialCount(yBin, xBin, sizeIdx, lumIdx) = trialCount(yBin, xBin, sizeIdx, lumIdx) + 1; - VideoScreen(:,:,j) = 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]); - pxyScreen = pxyScreen+xyScreen; + 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; - j = j+1; + % 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 + jj = jj + 1; end -% M = MrMean(:,u)'./Nbase(u); - -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..b1fb254 100644 --- a/visualStimulationAnalysis/@rectGridAnalysis/rectGridAnalysis.m +++ b/visualStimulationAnalysis/@rectGridAnalysis/rectGridAnalysis.m @@ -210,10 +210,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/AllExpAnalysis.asv b/visualStimulationAnalysis/AllExpAnalysis.asv new file mode 100644 index 0000000..2069251 --- /dev/null +++ b/visualStimulationAnalysis/AllExpAnalysis.asv @@ -0,0 +1,1472 @@ +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, plotSwarmBootstrapWithComparisons, +% 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 + + % --- 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 + + % --- 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) + % 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.'); + 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(1)); +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) + lvStr = strjoin(arrayfun(@(v) sprintf('%g', v), ... + params.CompareLevels{si}, 'UniformOutput', false), '_'); + parts{si} = sprintf('%s-%s-%s', stimsNeeded{si}, catList{si}, lvStr); + 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), double.empty(0,1), double.empty(0,1), ... + 'VariableNames', {'animal','insertion','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 + % Build the canonical comparison labels and (stim, cat, level) tuples now + [fixedCompLabels, mode3Items] = buildMode3Items(stimsNeeded, catList, params.CompareLevels); +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 + % Mode 3: for each stimulus, find a session containing ALL requested levels + sessionFound = true; + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; % stimulus name + cat = catList{si}; % category for this stimulus + lvls = params.CompareLevels{si}; % required numeric levels + key = getObjKey(sn); + + % Try Session=1 then Session=2 for this stimulus + [vsObj, allFound] = findSessionWithLevels(NP, sn, cat, lvls, params); + if ~allFound + fprintf(' -> Skipping: %s session with all levels of "%s" [%s] not found.\n', ... + sn, cat, num2str(lvls(:)', '%g ')); + sessionFound = false; break + end + vsObjs(key) = vsObj; % store the valid session object + end + if ~sessionFound + continue % skip this experiment entirely + 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')); + + % 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}; + 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); + 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(allFields, 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 that we recovered all expected labels + if ~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(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','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(1)); +vs = linearlyMovingBallAnalysis(NP, 'MultipleOffsets', false, 'Multiplesizes', false); + +% 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) + +% 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'))); + +if (mode == 3 || mode == 2) && any(contains(string(S.TableStimComp.stimulus), "SDG")) + + +end + +% Generate swarm + bootstrap plot for z-scores +[fig,~,~] = plotSwarmBootstrapWithComparisons( ... + S.TableStimComp, pairsAll, pValsZ, {'Z-score'}, ... + yLegend = 'Z-score', yMaxVis = ZscoreYlim, ... + diff = true, plotMeanSem = true, Alpha = 0.7); + +% 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); + +% Save figure if PaperFig mode is active +if params.PaperFig + vs.printFig(fig, sprintf('Zscore-Swarm-%s', strjoin(compLabels,'-')), ... + PaperFig = params.PaperFig); +end + +% 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', strjoin(compLabels,'-')), ... + 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,~,~] = plotSwarmBootstrapWithComparisons( ... + S.TableStimComp, pairsAll, pValsSpk, {'SpkR'}, ... + yLegend = 'SpkR', yMaxVis = spkMax, ... + diff = true, plotMeanSem = true, Alpha = 0.7); + +% 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); + +% Save figure if PaperFig mode is active +if params.PaperFig + vs.printFig(fig, sprintf('SpkRate-Swarm-%s', strjoin(compLabels,'-')), ... + PaperFig = params.PaperFig); +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', strjoin(compLabels,'-')), ... + PaperFig = params.PaperFig); + end +end + +% ========================================================================= +% SECTION 10 — FRACTION-RESPONSIVE ANALYSIS +% ========================================================================= + +% 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. +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]; %#ok + insLabels = [insLabels; double(ins)]; %#ok + animLabels = [animLabels; double(animal)]; %#ok + end + end + + % Hierarchical bootstrap: resample animals -> insertions -> fractions + bootDiff = hierBoot(diffs, params.nBoot, insLabels, animLabels); + + % 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 = 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); + +% 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 + +% 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 = ['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) + 0.05; +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', strjoin(compLabels,'-')), ... + PaperFig = params.PaperFig); +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}; % stimulus name + cat = catList{si}; % category name + lvls = levelsCell{si}; % numeric level vector + + 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); + + % 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..1fcb856 --- /dev/null +++ b/visualStimulationAnalysis/AllExpAnalysis.m @@ -0,0 +1,1491 @@ +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, plotSwarmBootstrapWithComparisons, +% 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 + + % --- 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 + + % --- 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) + % 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.'); + 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(1)); +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) + lvStr = strjoin(arrayfun(@(v) sprintf('%g', v), ... + params.CompareLevels{si}, 'UniformOutput', false), '_'); + parts{si} = sprintf('%s-%s-%s', stimsNeeded{si}, catList{si}, lvStr); + 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), double.empty(0,1), double.empty(0,1), ... + 'VariableNames', {'animal','insertion','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 + % Build the canonical comparison labels and (stim, cat, level) tuples now + [fixedCompLabels, mode3Items] = buildMode3Items(stimsNeeded, catList, params.CompareLevels); +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 + % Mode 3: for each stimulus, find a session containing ALL requested levels + sessionFound = true; + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; % stimulus name + cat = catList{si}; % category for this stimulus + lvls = params.CompareLevels{si}; % required numeric levels + key = getObjKey(sn); + + % Try Session=1 then Session=2 for this stimulus + [vsObj, allFound] = findSessionWithLevels(NP, sn, cat, lvls, params); + if ~allFound + fprintf(' -> Skipping: %s session with all levels of "%s" [%s] not found.\n', ... + sn, cat, num2str(lvls(:)', '%g ')); + sessionFound = false; break + end + vsObjs(key) = vsObj; % store the valid session object + end + if ~sessionFound + continue % skip this experiment entirely + 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')); + + % 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}; + 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); + 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(allFields, 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 that we recovered all expected labels + if ~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(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','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(1)); +vs = linearlyMovingBallAnalysis(NP, 'MultipleOffsets', false, 'Multiplesizes', false); + +% 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,~,~] = plotSwarmBootstrapWithComparisons( ... + S.TableStimComp, pairsAll, pValsZ, {'Z-score'}, ... + yLegend = 'Z-score', yMaxVis = ZscoreYlim, ... + diff = true, plotMeanSem = true, Alpha = 0.7); + +% 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); + +% Save figure if PaperFig mode is active +if params.PaperFig + vs.printFig(fig, sprintf('Zscore-Swarm-%s', strjoin(compLabels,'-')), ... + PaperFig = params.PaperFig); +end + +% 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', strjoin(compLabels,'-')), ... + 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,~,~] = plotSwarmBootstrapWithComparisons( ... + S.TableStimComp, pairsAll, pValsSpk, {'SpkR'}, ... + yLegend = 'SpkR', yMaxVis = spkMax, ... + diff = true, plotMeanSem = true, Alpha = 0.7); + +% 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); + +% Save figure if PaperFig mode is active +if params.PaperFig + vs.printFig(fig, sprintf('SpkRate-Swarm-%s', strjoin(compLabels,'-')), ... + PaperFig = params.PaperFig); +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', strjoin(compLabels,'-')), ... + 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 = hierBoot(diffs, params.nBoot, insLabels, animLabels); + + % 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 = 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); + +% 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 + +% 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 = ['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) + 0.05; +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', strjoin(compLabels,'-')), ... + PaperFig = params.PaperFig); +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}; % stimulus name + cat = catList{si}; % category name + lvls = levelsCell{si}; % numeric level vector + + 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); + + % 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/AllExpAnalysisV1.m b/visualStimulationAnalysis/AllExpAnalysisV1.m new file mode 100644 index 0000000..d51b990 --- /dev/null +++ b/visualStimulationAnalysis/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/AllExpAnalysisV2.m b/visualStimulationAnalysis/AllExpAnalysisV2.m new file mode 100644 index 0000000..3ae10d6 --- /dev/null +++ b/visualStimulationAnalysis/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/AllExpAnalysisV3.m b/visualStimulationAnalysis/AllExpAnalysisV3.m new file mode 100644 index 0000000..29f928a --- /dev/null +++ b/visualStimulationAnalysis/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/RunAnalysisClass.asv b/visualStimulationAnalysis/RunAnalysisClass.asv new file mode 100644 index 0000000..f0aa5bf --- /dev/null +++ b/visualStimulationAnalysis/RunAnalysisClass.asv @@ -0,0 +1,318 @@ +cd('\\sil3\data\Large_scale_mapping_NP') +excelFile = 'Experiment_Excel.xlsx'; + +data = readtable(excelFile); + +%% +%% Rect Grid +for ex = [69] %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,exNeurons=21, selectedLum=255,oneTrial = true,PaperFig = true) %43 + vsRe.CalculateReceptiveFields('overwrite',true,AllResponsiveNeurons=false) + [colorbarLimsRG] = vsRe.PlotReceptiveFields(exNeurons=21,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 =[84,88]%97 74:84 (Neurons, 96_74, ) + NP = loadNPclassFromTable(ex); %73 81 + vs = linearlyMovingBallAnalysis(NP,Multiplesizes=true); + % 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',5,'bin',50,'GaussianLength',30,'MaxVal_1', false, ... + sortingOrder=["size","direction","luminosity","offset","speed"],ov) + %vs.plotRaster('AllSomaticNeurons',true,'overwrite',true,'MergeNtrials',3,PaperFig=true) + % % %vs.plotRaster('exNeuronsPhyID',288,'overwrite',true,'MergeNtrials',3,'PaperFig',true) + % % % % %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',21,'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]; + % result = vs.StatisticsPerNeuron('overwrite',true); + % result = vs.StatisticsPerNeuronPerCategory('compareCategory','sizes','overwrite',true); +end + + +%% 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 +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true,useFDR=false,SpatialGridMode=true,maxCategory=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); + +%% FIGURE 3 SIZES AND LOCALITY COMPARISON +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +%% Compares MB across different sizes +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'MB'},CompareCategory="sizes",PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 500); + + +%% 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 = false); +%% Compares MB and MG, across all categories +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'MB','SDGm'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 500, maxCategory = false, SpatialGridMode = false); + +%% %% Compares MB and MG, across all categories +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'MB','MBR'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 500, maxCategory = false, SpatialGridMode = false); + +%% %% PSTH for all moving experiments +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=false, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50, stimTypes={"MB","MBR","SDGm"}); %stimTypes=["linearlyMovingBall"] + + +%% %% Compares SB, SG and FFF, across all categories +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'RG','SDGs','FFF'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 300, maxCategory = false, SpatialGridMode = false); + +%% PSTH for all static experiments +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50, stimTypes={"RG","SDGs","FFF"}); %stimTypes=["linearlyMovingBall"] + +%% Plot changes in size for MB +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50, postStim= 2000, stimTypes={"MB"},splitBy="sizes",useCategoryPvals = true); %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([49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'MB'},CompareCategory="sizes",PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 500); + +%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +%% 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 = [97] + 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..b56d366 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -5,7 +5,7 @@ %% %% Rect Grid -for ex = [97] %84:91 +for ex = [69] %84:91 NP = loadNPclassFromTable(ex); %73 81 vsRe = rectGridAnalysis(NP); % vsRe.getSessionTime("overwrite",true); @@ -14,74 +14,200 @@ % 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,exNeurons=21, selectedLum=255,oneTrial = true,PaperFig = true) %43 + vsRe.CalculateReceptiveFields('overwrite',true,AllResponsiveNeurons=false) + [colorbarLimsRG] = vsRe.PlotReceptiveFields(exNeurons=21,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 -for ex = [69,81,95,97] %97 +for ex =[88]%97 74:84 (Neurons, 96_74, ) NP = loadNPclassFromTable(ex); %73 81 - vs = linearlyMovingBallAnalysis(NP,Session=1); + vs = linearlyMovingBallAnalysis(NP,Multiplesizes=true); % 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',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',288,'overwrite',true,'MergeNtrials',3,'PaperFig',true) + % % % % %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',21,'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]; + % result = vs.StatisticsPerNeuron('overwrite',true); + % result = vs.StatisticsPerNeuronPerCategory('compareCategory','sizes','overwrite',true); end -%% PlotZScoreComparison + +%% 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: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 -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 +%% FIGURE 1 MOVING BALL VS STATIC BALL +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +%% Compare MB vs RG, use gridmode true, selects maximum spatial category across directions +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true,useFDR=false,SpatialGridMode=true,maxCategory=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); +%% FIGURE 2 MOVING VS STATIC COMPARISON +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +%% %% Compare SDGm vs SDGs, use gridmode true, selects maximum spatial category across directions +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'SDGm','SDGs'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true,useFDR=false,maxCategory=false,BaseRespWindow=1000); + + + +%% FIGURE 3 SIZES AND LOCALITY COMPARISON +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +%% Compares MB across different sizes +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'MB'},CompareCategory="sizes",PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 500); + + +%% 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 = false); +%% Compares MB and MG, across all categories +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'MB','SDGm'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 500, maxCategory = false, SpatialGridMode = false); + +%% %% Compares MB and MG, across all categories +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'MB','MBR'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 500, maxCategory = false, SpatialGridMode = false); + +%% %% PSTH for all moving experiments +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=false, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50, stimTypes={"MB","MBR","SDGm"}); %stimTypes=["linearlyMovingBall"] + + +%% %% Compares SB, SG and FFF, across all categories +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97], overwrite=true,ComparePairs={'RG','SDGs','FFF'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 300, maxCategory = false, SpatialGridMode = false); + +%% PSTH for all static experiments +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50, stimTypes={"RG","SDGs","FFF"}); %stimTypes=["linearlyMovingBall"] + +%% Plot changes in size for MB +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50, postStim= 2000, stimTypes={"MB"},splitBy="sizes",useCategoryPvals = true); %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([49:54,64:66,68:85 87:97], overwrite=false,ComparePairs={'MB'},CompareCategory="sizes",PaperFig=true,... + overwriteResponse=false,overwriteStats=true, BaseRespWindow = 500); + +%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% +%%%%%%%% + +%% 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 = [97] 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.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 = [89,90,92,93,95:97] +for ex = [92:97] NP = loadNPclassFromTable(ex); %73 81 vs = movieAnalysis(NP); % vs.getSessionTime("overwrite",true); @@ -89,15 +215,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); @@ -105,9 +233,11 @@ %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) + 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..ebbf9cb 100644 --- a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m +++ b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m @@ -4,17 +4,19 @@ % exp = [49:54,64:97];% %tiledlayout(numel(exp),1) -for ex = exp%GoodRecordingsPV%allGoodRec %GoodRecordings%GoodRecordingsPV%GoodRecordingsPV%selecN{1}(1,:) %1:size(data,1) +for ex = exp(2:end)%GoodRecordingsPV%allGoodRec %GoodRecordings%GoodRecordingsPV%GoodRecordingsPV%selecN{1}(1,:) %1:size(data,1) %%%%%%%%%%%% Load data and data paremeters %1. Load NP class NP = loadNPclassFromTable(ex); 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..6551d7a --- /dev/null +++ b/visualStimulationAnalysis/SpatialTuningIndex.m @@ -0,0 +1,1307 @@ +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. +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 + 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 + + % ---------------------------------------------------------- + % 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; + + 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 + +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 = hierBoot(diffs, params.nBoot, insers, animals); + 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 = hierBoot(V1(valid1), params.nBoot, insers1(valid1), anim1(valid1)); + BootSec = hierBoot(V2(valid2), params.nBoot, insers2(valid2), anim2(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) +% Generates one PDF per stim type. Each page shows a tile-layout of +% full-resolution RFs, sorted by descending tuning index. Each tile is +% annotated with the neuron's phy ID, tuning-index value, and (for +% linearlyMovingBall) the preferred direction in degrees. +% ========================================================================= +if params.plotRFs + + % ---- Guard: prefDir must be true for MB RF pages -------------------- + if ~params.prefDir + % Without preferred-direction selection the RF slice to plot is + % ambiguous for linearlyMovingBall, so we skip entirely. + fprintf(['plotRFs requires prefDir=true for linearlyMovingBall.\n' ... + 'Skipping RF page generation.\n']); + else + + % ---- Page geometry ---------------------------------------------- + nCols = 5; % number of tile columns per page + nRows = 8; % number of tile rows per page + tilesPerPage = nCols * nRows; % total tiles that fit on one page + + % ---- Filter the results table to the requested condition -------- + 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' + + % --------------------------------------------------------- + % If plotRFunion: build a single neuron set from the union + % of neurons responsive to either stim type, BEFORE entering + % the stim-type loop. Both PDFs will show the same neurons. + % --------------------------------------------------------- + if params.plotRFunion + + % Inline ternary helper for compact annotation formatting: + % returns trueStr when cond is true, falseStr otherwise. + ternaryStr = @(cond, trueStr, falseStr) ... + subsref({falseStr; trueStr}, substruct('{}', {cond + 1})); + + % Separate table rows by stim type for per-stim-type lookup + mbRows = tblCondRF(tblCondRF.stimulus == "linearlyMovingBall", :); + sbRows = tblCondRF(tblCondRF.stimulus == "rectGrid", :); + + % Collect every unique (experimentNum, phyID) pair across both + % stim types — this is the union neuron set + keysMB = mbRows(:, {'experimentNum', 'phyID'}); + keysSB = sbRows(:, {'experimentNum', 'phyID'}); + allKeys = unique([keysMB; keysSB], 'rows'); + + % Pre-allocate columns for each stim type's tuning index and + % preferred-direction metadata (MB only) + nUnion = height(allKeys); + 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 degrees (MB only) + + % Fill per-neuron values by matching on experiment + phyID + 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) matching row + allKeys.valueMB(ri) = mbRows.value(mIdx); + allKeys.prefDirIdx(ri) = mbRows.prefDirIdx(mIdx); + allKeys.prefDirDeg(ri) = mbRows.prefDirDeg(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)); + end + end + + fprintf(' [plotRFunion] %d unique neurons in union set.\n', nUnion); + end + + % ---- Iterate over each stimulus type ---------------------------- + for ss = 1:numel(params.stimTypes) + + stimType = params.stimTypes(ss); % current stimulus type string + + % --------------------------------------------------------- + % Select neurons: union set or per-stim-type set + % --------------------------------------------------------- + if params.plotRFunion + + % Use the pre-built union table — same neurons for both PDFs + tblStim = allKeys; + + % Sort both PDFs by MB tuning index so neuron positions + % match across the two PDFs for direct visual comparison. + % Neurons not responsive to MB (NaN) sink to the bottom. + tblStim.value = tblStim.valueMB; + tblStim = sortrows(tblStim, 'value', 'descend', ... + 'MissingPlacement', 'last'); + else + % Default: only neurons responsive to this stim type + stimMask = tblCondRF.stimulus == char(stimType); + tblStim = tblCondRF(stimMask, :); + end + + % Skip if no data for this stim type + if isempty(tblStim) + fprintf(' [plotRFs] No neurons for %s — skipping.\n', char(stimType)); + continue + end + + % Sort by tuning index descending (only for the non-union path; + % the union path was already sorted above) + if ~params.plotRFunion + tblStim = sortrows(tblStim, 'value', 'descend'); + end + + nNeurons = height(tblStim); % total neurons to plot + nPages = ceil(nNeurons / tilesPerPage); % number of PDF pages + + % Build the output PDF path inside the combined-analysis directory + if params.subtractShuffle + shuffTag = '_shuffSub'; % tag indicating shuffle was subtracted + else + shuffTag = '_raw'; % tag indicating raw RF plotted + end + unionTag = ''; if params.plotRFunion, unionTag = '_union'; end + pdfName = sprintf('RFpages_%s_%s%s%s.pdf', ... + char(stimType), params.indexType, shuffTag, unionTag); + pdfPath = fullfile(saveDir, pdfName); % full path for the PDF + + % Cache loaded experiments so each experiment's heavy data is + % read from disk only once (key = experiment number) + cachedExp = containers.Map('KeyType', 'double', 'ValueType', 'any'); + + % ---- Loop over pages ---------------------------------------- + for pg = 1:nPages + + % 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, nCols, ... + 'TileSpacing', 'compact', ... + 'Padding', 'compact'); + + % Page-level title indicating stim type, mode, and page number + if stimType == "linearlyMovingBall" + stimLabel_pg = 'Moving Ball RFs'; + else + stimLabel_pg = 'Rect Grid RFs'; + end + if params.plotRFunion + stimLabel_pg = [stimLabel_pg ' (union)']; %#ok + end + pageTitleStr = sprintf('%s — %s (page %d/%d)', ... + stimLabel_pg, params.indexType, pg, nPages); + title(tl, pageTitleStr, 'FontSize', 9, 'FontName', 'Helvetica'); + + % Index range for the neurons that belong to this page + startNeuron = (pg - 1) * tilesPerPage + 1; + endNeuron = min(pg * tilesPerPage, nNeurons); + + % ---- Loop over neurons on this page --------------------- + for ni = startNeuron:endNeuron + + nexttile; % advance to the next tile in the layout + + % Read metadata for this neuron from the sorted table + phyID = tblStim.phyID(ni); + tVal = tblStim.value(ni); + ex = str2double(string(tblStim.experimentNum(ni))); + + % ----- Load experiment data (with caching) ----------- + if ~cachedExp.isKey(ex) + + NP_tmp = loadNPclassFromTable(ex); + + % Always derive phy_IDg from linearlyMovingBallAnalysis + % to match the phyIDs stored in the results table + obj_lmb = linearlyMovingBallAnalysis(NP_tmp); + p_s_tmp = NP_tmp.convertPhySorting2tIc(obj_lmb.spikeSortingFolder); + phy_IDg_tmp = p_s_tmp.phy_ID(string(p_s_tmp.label') == 'good'); + + % Load RF data from the stim-specific analysis object + switch stimType + case "linearlyMovingBall" + obj_stim = obj_lmb; + case "rectGrid" + obj_stim = rectGridAnalysis(NP_tmp); + end + S_rf_tmp = obj_stim.CalculateReceptiveFields; + + % ------------------------------------------------- + % Compute preferred direction for ALL good units + % (needed for union mode, where some neurons may not + % be MB-responsive and thus lack prefDirIdx in the + % table). Uses the same logic as the main computation. + % ------------------------------------------------- + S_rf_lmb = obj_lmb.CalculateReceptiveFields; + rfSpeed = S_rf_lmb.params.speed; + rfField = sprintf('Speed%d', rfSpeed); + NeuronResp = obj_lmb.ResponseWindow; + NeuronVals = NeuronResp.(rfField).NeuronVals; + % NeuronVals: [nGoodUnits, nConditions, nFeatures] + + dirLabels = NeuronVals(:,:,6); % direction (radians) + spikeRates = NeuronVals(:,:,1); % spike rate + uDirsLocal = unique(dirLabels(1,:)); % sorted unique directions + + % Max spike rate per direction per good unit + nGU = size(NeuronVals, 1); + maxRPD = zeros(nGU, numel(uDirsLocal)); + for dd = 1:numel(uDirsLocal) + dMask = dirLabels(1,:) == uDirsLocal(dd); + maxRPD(:,dd) = max(spikeRates(:, dMask), [], 2); + end + [~, prefDirIdxAll] = max(maxRPD, [], 2); % [nGU,1] + prefDirDegAll = rad2deg(uDirsLocal(prefDirIdxAll))'; % [nGU,1] + + % Store everything in the cache + cachedExp(ex) = struct( ... + 'S_rf', S_rf_tmp, ... + 'S_rf_lmb', S_rf_lmb, ... + 'phy_IDg', phy_IDg_tmp, ... + 'prefDirIdxAll', prefDirIdxAll, ... + 'prefDirDegAll', prefDirDegAll); + end + + % Retrieve cached data for this experiment + cached = cachedExp(ex); + + % Find this neuron's index within the good-unit array + [~, nIdx] = ismember(phyID, cached.phy_IDg); + + % Guard: if phyID is not found, skip this tile + if nIdx == 0 + text(0.5, 0.5, sprintf('phy%d\nnot found', phyID), ... + 'HorizontalAlignment', 'center', ... + 'FontSize', 5); + axis off; + continue + end + + % ----- Extract the full-resolution RF slice ----------- + switch stimType + + case "linearlyMovingBall" + % Preferred direction: use table value if available + % (MB-responsive neuron), otherwise use the cached + % value computed for all good units + prefIdx = tblStim.prefDirIdx(ni); + prefDeg = tblStim.prefDirDeg(ni); + + if isnan(prefIdx) + % Neuron was not MB-responsive — use cached + % preferred direction computed from NeuronVals + prefIdx = cached.prefDirIdxAll(nIdx); + prefDeg = cached.prefDirDegAll(nIdx); + end + + % Slice RFuSTDirSizeLum: [nDir,nSize,nLum,rfY,rfX,nN] + % Use the MB-specific RF cache (S_rf_lmb) since + % S_rf may be rectGrid when this is the SB iteration + 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" + % Slice RFu: [2(onOff), nLums, nSize, sR, sR, nN] + rfSlice = squeeze( ... + cached.S_rf.RFu( ... + params.onOff, params.lumIdx, params.sizeIdx, :, :, nIdx)); + + if params.subtractShuffle + rfShuff = squeeze( ... + cached.S_rf.RFuShuffMean( ... + params.onOff, params.lumIdx, params.sizeIdx, :, :, nIdx)); + rfSlice = rfSlice - rfShuff; + end + end + + % ----- Plot the RF as a colour image ----------------- + imagesc(rfSlice); + axis equal tight; + axis off; + + if params.subtractShuffle + maxAbs = max(abs(rfSlice(:))); + if maxAbs > 0 + clim([-maxAbs, maxAbs]); + end + end + + cb = colorbar; + cb.FontSize = 3.5; + cb.TickDirection = 'out'; + cb.Ticks = linspace(cb.Limits(1), cb.Limits(2), 3); + + % ----- Tile title annotation ------------------------- + if params.plotRFunion + % Union mode: show both stim type indices on every tile + mbVal = tblStim.valueMB(ni); + sbVal = tblStim.valueSB(ni); + mbStr = ternaryStr(isnan(mbVal), 'n/r', sprintf('%.2f', mbVal)); + sbStr = ternaryStr(isnan(sbVal), 'n/r', sprintf('%.2f', sbVal)); + + switch stimType + case "linearlyMovingBall" + % Also show preferred direction + tileTitle = sprintf('phy%d|MB %s|SB %s|%d°', ... + phyID, mbStr, sbStr, round(prefDeg)); + case "rectGrid" + tileTitle = sprintf('phy%d|MB %s|SB %s', ... + phyID, mbStr, sbStr); + end + else + % Standard mode: show only this stim type's index + switch stimType + case "linearlyMovingBall" + tileTitle = sprintf('phy%d | %.2f | %d°', ... + phyID, tVal, round(tblStim.prefDirDeg(ni))); + case "rectGrid" + tileTitle = sprintf('phy%d | %.2f', phyID, tVal); + end + end + title(tileTitle, 'FontSize', 5, 'FontName', 'Helvetica'); + + end % neuron loop (tiles on this page) + + % ----- Export this page to the PDF ----------------------- + if pg == 1 + % First page: create the PDF file + exportgraphics(fig_rf, pdfPath, 'ContentType', 'vector'); + else + % Subsequent pages: append to the existing PDF + exportgraphics(fig_rf, pdfPath, 'ContentType', 'vector', 'Append', true); + end + + close(fig_rf); % close figure to free memory before next page + + fprintf(' [plotRFs] %s — page %d/%d exported.\n', ... + char(stimType), pg, nPages); + + end % page loop + + fprintf(' [plotRFs] Saved %d pages to:\n %s\n', nPages, pdfPath); + + end % stim-type loop + + end % prefDir guard + +end % plotRFs block + +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/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.asv b/visualStimulationAnalysis/plotPSTH_MultiExp.asv new file mode 100644 index 0000000..5667b94 --- /dev/null +++ b/visualStimulationAnalysis/plotPSTH_MultiExp.asv @@ -0,0 +1,1090 @@ +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) +% 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 (abbreviations: MB, MBR, RG, SDGm, SDGs, NV, NI, FFF) + params.splitBy string = "" % category variable for within-stim split; "" = no split + params.splitLevels double = [] % specific levels to compare (e.g. [5 10 20]); empty = all available + params.binWidth double = 10 % PSTH time-bin width in ms + params.smooth double = 0 % Gaussian smoothing window in ms (0 = no smoothing) + params.statType string = "maxPermuteTest" % which statistical test for p-values + params.speed string = "max" % which speed condition for MB/MBR + 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 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 statistics + params.useCategoryPvals logical = false % if true and splitBy is active, use per-level p-values from StatisticsPerNeuronPerCategory (OR across levels) instead of general per-neuron p-values + params.nBootCategory double = 10000 % number of bootstrap iterations for StatisticsPerNeuronPerCategory + params.TakeTopPercentTrials double = 1 % fraction of trials to keep (1 = all; see note below) + params.zScore logical = false % z-score each neuron's PSTH to its own baseline + params.PaperFig logical = false % export publication-quality figure via printFig + params.byDepth logical = false % split neurons into 3 depth bins +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 is hard to justify unless there is a principled reason (e.g. +% attention gating in a behaving animal). Default is 1 (all trials). +% ------------------------------------------------------------------------- + +% ------------------------------------------------------------------------- +% Guard: splitBy and byDepth together create too many lines +% ------------------------------------------------------------------------- +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 + +% ------------------------------------------------------------------------- +% Load depth-bin info if byDepth is requested +% ------------------------------------------------------------------------- +if params.byDepth + 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); + depthTable = D.depthTable; + depthBinEdges = D.depthBinEdges; + nDepthBins = 3; + 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; +end + +% ------------------------------------------------------------------------- +% Build save directory path +% ------------------------------------------------------------------------- +NP_first = loadNPclassFromTable(exList(1)); +vs_first = linearlyMovingBallAnalysis(NP_first); + +basePath = extractBefore(vs_first.getAnalysisFileName, 'lizards'); +basePath = [basePath 'lizards']; +saveDir = fullfile(basePath, 'Combined_lizard_analysis'); +if ~exist(saveDir, 'dir') + mkdir(saveDir); +end + +% ---- Construct the filename ---- +stimLabel = strjoin(params.stimTypes, '-'); +depthSuffix = ''; +if params.byDepth; depthSuffix = '_byDepth'; end +splitSuffix = ''; +if params.splitBy ~= ""; splitSuffix = ['_by' char(params.splitBy)]; end +if ~isempty(params.splitLevels) + lvlStr = strjoin(arrayfun(@(v) sprintf('%g',v), params.splitLevels, ... + 'UniformOutput', false), '_'); + splitSuffix = [splitSuffix '_lvl' lvlStr]; +end +nameOfFile = sprintf('Ex_%d-%d_Combined_PSTHs_%s%s%s.mat', ... + exList(1), exList(end), stimLabel, splitSuffix, depthSuffix); +fullSavePath = fullfile(saveDir, nameOfFile); + +% ------------------------------------------------------------------------- +% Decide: recompute or load from disk +% ------------------------------------------------------------------------- +forloop = true; +if exist(fullSavePath, 'file') == 2 && ~params.overwrite + S = load(fullSavePath); + if isequal(S.expList, exList) + fprintf('Loading saved PSTHs from:\n %s\n', fullSavePath); + forloop = false; + else + fprintf('Experiment list mismatch — recomputing.\n'); + end +end + +% ========================================================================= +% MAIN COMPUTATION LOOP (skip if loaded from disk) +% ========================================================================= +if forloop + + nStim = numel(params.stimTypes); + nExp = numel(exList); + + % ===================================================================== + % DISCOVERY PASS — find valid sessions and category levels + % ===================================================================== + % For each experiment × stim type, we try sessions [0, 1, 2] to find + % one whose category column (splitBy) has ≥2 levels (or contains all + % of the user-specified 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); % will hold session numbers; -1 = skip + catLabelsAll = cell(nStim, 1); + + if params.splitBy ~= "" + fprintf('\nDiscovering categories (splitBy = "%s") ...\n', params.splitBy); + end + + for s = 1:nStim + stimKey = params.stimTypes(s); + + if params.splitBy == "" + % ---- No split: all experiments use default session (0) ------ + catLabelsAll{s} = "all"; + sessionMap(:, s) = 0; + else + % ---- Split requested: scan experiments for valid sessions --- + allLevelsFound = []; % accumulate levels across experiments + + for ei = 1:nExp + try + NP_tmp = loadNPclassFromTable(exList(ei)); + catch + sessionMap(ei, s) = -1; + continue + end + + % Try sessions [0, 1, 2]; return first with ≥2 levels + [~, sess, levels] = findValidSession( ... + NP_tmp, stimKey, params.speed, params.splitBy, ... + params.splitLevels, params.overwriteResponse); + + if sess < 0 + sessionMap(ei, s) = -1; + fprintf(' Exp %d [%s]: no session with ≥2 levels of "%s" — will skip.\n', ... + exList(ei), stimKey, params.splitBy); + else + sessionMap(ei, s) = sess; + allLevelsFound = [allLevelsFound; levels(:)]; %#ok + fprintf(' Exp %d [%s]: session %d has levels [%s]\n', ... + exList(ei), stimKey, sess, num2str(levels(:)', '%g ')); + end + end + + % Determine the final global set of category levels + uniqueVals = unique(allLevelsFound); + + % If the user requested specific levels, intersect + if ~isempty(params.splitLevels) + uniqueVals = intersect(uniqueVals, params.splitLevels(:)); + end + + if numel(uniqueVals) < 2 + fprintf(' [%s] splitBy="%s" has only %d global level — falling back to unsplit.\n', ... + stimKey, params.splitBy, numel(uniqueVals)); + catLabelsAll{s} = "all"; + sessionMap(:, s) = 0; % reset all to default session + else + catLabelsAll{s} = string(uniqueVals(:)'); + fprintf(' [%s] final category levels: %s\n', ... + stimKey, strjoin(catLabelsAll{s}, ', ')); + end + end + end + + % ----- Find the maximum number of categories across stim types ------- + maxCats = max(cellfun(@numel, catLabelsAll)); + + % ----- Pre-allocate psthAll ------------------------------------------ + psthAll = cell(nStim, nDepthBins, maxCats); + + % ----- Time-axis parameters (locked on first successful experiment) -- + lockedPreBase = []; + lockedNBins = []; + lockedEdges = []; + + % ===================================================================== + % MAIN EXPERIMENT LOOP + % ===================================================================== + for ei = 1:nExp + + ex = exList(ei); + fprintf('\n=== Experiment %d (%d/%d) ===\n', ex, ei, nExp); + + % ---- Load experiment -------------------------------------------- + try + NP = loadNPclassFromTable(ex); + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + appendNaNRow(psthAll, nStim, nDepthBins, catLabelsAll, lockedNBins); + continue + end + + % ================================================================= + % Loop over stimulus types + % ================================================================= + for s = 1:nStim + + stimType = params.stimTypes(s); + + % ---- Check session map: skip if no valid session found ------ + sess = sessionMap(ei, s); + if sess < 0 + fprintf(' [%s] Skipping exp %d (no valid session).\n', stimType, ex); + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue + end + + % ---- Construct the analysis object using the chosen session - + try + obj = buildStimObject(NP, stimType, sess); + catch ME + warning('Could not build %s (session %d) for exp %d: %s', ... + stimType, sess, ex, ME.message); + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue + end + + if isempty(obj) + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue + end + + % ---- Check that the stimulus was actually presented --------- + % The constructor may succeed but VST is empty when the + % stimulus protocol was not run in this experiment. + try + stimMissing = isempty(obj.VST); + catch + stimMissing = true; + end + if stimMissing + fprintf(' [%s] Stimulus not present in exp %d — skipping.\n', stimType, ex); + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + 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); + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue + end + + % ---- Load statistics (p-values per neuron) ------------------ + try + if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; + elseif params.statType == "maxPermuteTest" + Stats = obj.StatisticsPerNeuron; + else + Stats = obj.ShufflingAnalysis; + end + catch ME + warning(' [%s] Statistics failed for exp %d: %s — skipping.', ... + stimType, ex, ME.message); + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue + end + + % ---- Determine field name and stim-onset offset ------------- + [fieldName, startStim] = getFieldAndOffset(obj, stimType, params.speed); + + % ---- Get sorted good units ---------------------------------- + p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); + label = string(p_sort.label'); + goodU = p_sort.ic(:, label == "good"); + + % ---- Extract condition matrix C and stimulus onset times ---- + C = getConditionMatrix(obj, stimType, params.speed); + directimesSorted = C(:, 1)' + startStim; + + % ---- Lock the time-axis on the first valid experiment ------- + preBase = params.preBase; + windowTotal = preBase + params.postStim; + + if isempty(lockedPreBase) + lockedPreBase = preBase; + lockedEdges = 0 : params.binWidth : windowTotal; + lockedNBins = numel(lockedEdges) - 1; + fprintf(' Locked window: preBase=%d ms, postStim=%d ms, nBins=%d\n', ... + lockedPreBase, params.postStim, lockedNBins); + end + + % ---- Determine whether category split is active for this stim --- + nCats = numel(catLabelsAll{s}); + isSplitActive = params.splitBy ~= "" && ~isequal(catLabelsAll{s}, "all"); + + % ---- Select responsive neurons ------------------------------ + % Two modes: + % (a) useCategoryPvals + active split: call + % StatisticsPerNeuronPerCategory and OR the per-level + % p-values. A neuron is "responsive" if it passes the + % threshold for ANY of the compared levels. + % (b) Default: use the general per-neuron p-values from the + % overall statistics (statType). + + if isSplitActive && params.useCategoryPvals + % ---- Category-level p-values ---------------------------- + 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); + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue + end + + % OR across all category levels + orMask = false; % will broadcast to vector on first OR + for ci = 1:nCats + levelVal = str2double(catLabelsAll{s}(ci)); % numeric level value + fName = levelToFieldName(params.splitBy, levelVal); % e.g. 'size_5' + if isfield(catStats, fName) + orMask = orMask | (catStats.(fName).pvalsResponse(:) < params.alpha); + end + end + eNeurons = find(orMask); + + fprintf(' [%s] Using per-category p-values: %d responsive neuron(s) in exp %d.\n', ... + stimType, numel(eNeurons), ex); + else + % ---- General per-neuron p-values ------------------------ + try + pvals = Stats.(fieldName).pvalsResponse; + catch + pvals = Stats.pvalsResponse; + end + eNeurons = find(pvals < params.alpha); + end + + if isempty(eNeurons) + fprintf(' [%s] No responsive neurons in exp %d.\n', stimType, ex); + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue + end + + if ~(isSplitActive && params.useCategoryPvals) + fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', ... + stimType, numel(eNeurons), ex); + end + + % ---- Extract per-trial category values (if splitting) ------- + catValues = []; + if isSplitActive + catCol = getCategoryColumn(obj, stimType, params.speed, params.splitBy); + catValues = catCol(:)'; + end + + % ============================================================== + % Loop over responsive neurons + % ============================================================== + psthRateNeurons = NaN(numel(eNeurons), lockedNBins, nCats); + neuronBinIdx = zeros(numel(eNeurons), 1); + + for ni = 1:numel(eNeurons) + u = eNeurons(ni); + + % ---- Assign depth bin ----------------------------------- + if params.byDepth + depthRow = depthTable.Experiment == ex & depthTable.Unit == u; + if ~any(depthRow) + neuronBinIdx(ni) = 0; + continue + end + unitDepth = depthTable.Depth_um(depthRow); + if unitDepth <= depthBinEdges(2) + neuronBinIdx(ni) = 1; + elseif unitDepth <= depthBinEdges(3) + neuronBinIdx(ni) = 2; + else + neuronBinIdx(ni) = 3; + end + else + neuronBinIdx(ni) = 1; + end + + % ---- Build PSTH for each category ----------------------- + for ci = 1:nCats + + if ~isSplitActive + trialMask = true(size(directimesSorted)); + else + trialMask = abs(catValues - str2double(catLabelsAll{s}(ci))) < 1e-6; + end + catOnsets = directimesSorted(trialMask); + + if isempty(catOnsets) + psthRateNeurons(ni, :, ci) = NaN(1, lockedNBins); + continue + end + + % Build binary spike matrix: [nTrials × windowTotal_ms] + MRhist = BuildBurstMatrix( ... + goodU(:, u), ... + round(p_sort.t), ... + round(catOnsets - lockedPreBase), ... + round(windowTotal)); + MRhist = squeeze(MRhist); + + % ---- Optional: keep only top-N% of trials ----------- + if params.TakeTopPercentTrials < 1 + MeanTrial = mean(MRhist, 2); + [~, ind] = sort(MeanTrial, 'descend'); + nKeep = max(1, round(numel(MeanTrial) * params.TakeTopPercentTrials)); + MRhist = MRhist(ind(1:nKeep), :); + end + + nTrials = size(MRhist, 1); + + % ---- Compute PSTH by direct bin-summation ----------- + counts = zeros(1, lockedNBins); + for bi = 1:lockedNBins + msStart = lockedEdges(bi) + 1; + msEnd = lockedEdges(bi + 1); + counts(bi) = sum(MRhist(:, msStart:msEnd), 'all'); + end + psthRateNeurons(ni, :, ci) = (counts / nTrials) / (params.binWidth / 1000); + + end % category loop + end % neuron loop + + % ============================================================== + % z-score each neuron individually (if requested) + % ============================================================== + tAxis = lockedEdges(1:end-1); + if params.zScore + baselineBins = tAxis < lockedPreBase; + for ni = 1:size(psthRateNeurons, 1) + for ci = 1:nCats + trace = psthRateNeurons(ni, :, ci); + if all(isnan(trace)); continue; end + bMean = mean(trace(baselineBins), 'omitnan'); + bStd = std(trace(baselineBins), 0, 'omitnan'); + if bStd > 0 + psthRateNeurons(ni, :, ci) = (trace - bMean) / bStd; + else + psthRateNeurons(ni, :, ci) = NaN; + end + end + end + end + + % ============================================================== + % Average across neurons per depth bin × category, append + % ============================================================== + for b = 1:nDepthBins + binNeurons = neuronBinIdx == b; + + if ~any(binNeurons) + for ci = 1:nCats + psthAll{s, b, ci} = appendOrInit(psthAll{s, b, ci}, NaN(1, lockedNBins)); + end + continue + end + + for ci = 1:nCats + catData = psthRateNeurons(binNeurons, :, ci); + if all(isnan(catData), 'all') + psthAll{s, b, ci} = appendOrInit(psthAll{s, b, ci}, NaN(1, lockedNBins)); + else + psthExp = mean(catData, 1, 'omitnan'); + psthAll{s, b, ci} = appendOrInit(psthAll{s, b, ci}, psthExp(:)'); + end + end + + fprintf(' [%s] Depth bin %d: %d neuron(s) in exp %d.\n', ... + stimType, b, sum(binNeurons), ex); + end + + end % stim-type loop + end % experiment loop + + % ===================================================================== + % Save results to disk + % ===================================================================== + S.expList = exList; + S.lockedEdges = lockedEdges; + S.lockedPreBase = lockedPreBase; + S.params = params; + S.catLabelsAll = catLabelsAll; + + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + fieldKey = sprintf('%s_bin%d_cat%d', stimField, b, ci); + S.(fieldKey) = psthAll{s, b, ci}; + end + end + end + + save(fullSavePath, '-struct', 'S'); + fprintf('\nSaved PSTHs to:\n %s\n', fullSavePath); + +else + % ================================================================= + % Load psthAll from the saved struct S + % ================================================================= + lockedEdges = S.lockedEdges; + lockedPreBase = S.lockedPreBase; + catLabelsAll = S.catLabelsAll; + + maxCats = max(cellfun(@numel, catLabelsAll)); + psthAll = cell(numel(params.stimTypes), nDepthBins, maxCats); + + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + fieldKey = sprintf('%s_bin%d_cat%d', stimField, b, ci); + if isfield(S, fieldKey) + psthAll{s, b, ci} = S.(fieldKey); + else + warning('Field "%s" not found in saved file.', fieldKey); + psthAll{s, b, ci} = []; + end + end + end + end +end + +% ========================================================================= +% PLOTTING +% ========================================================================= + +tAxis = lockedEdges(1:end-1); +tAxisPlot = tAxis - lockedPreBase; + +% ---- Colour palette and label maps ------------------------------------- +nStim = numel(params.stimTypes); +baseColors = lines(nStim); + +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]; +binLabels = {'shallow', 'middle', 'deep'}; + +% ---- First pass: smooth traces, compute mean & SEM, find global ylim --- +yMax = -Inf; +yMin = Inf; + +meanStore = cell(nStim, nDepthBins, max(cellfun(@numel, catLabelsAll))); +semStore = cell(nStim, nDepthBins, max(cellfun(@numel, catLabelsAll))); +nExpStore = zeros(nStim, nDepthBins, max(cellfun(@numel, catLabelsAll))); + +for s = 1:nStim + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + data = psthAll{s, b, ci}; + if isempty(data); continue; end + validRows = ~all(isnan(data), 2); + data = data(validRows, :); + if isempty(data); continue; end + + nValid = size(data, 1); + nExpStore(s, b, ci) = nValid; + + % Smooth each experiment's trace FIRST, then compute stats + if params.smooth > 0 + smoothBins = round(params.smooth / params.binWidth); + for ri = 1:nValid + data(ri, :) = smoothdata(data(ri, :), 'gaussian', smoothBins); + end + end + + meanTrace = mean(data, 1, 'omitnan'); + semTrace = std(data, 0, 1, 'omitnan') / sqrt(nValid); + + meanStore{s, b, ci} = meanTrace; + semStore{s, b, ci} = semTrace; + + yMax = max(yMax, max(meanTrace + semTrace)); + yMin = min(yMin, min(meanTrace - semTrace)); + end + end +end + +yPad = (yMax - yMin) * 0.1; +if params.zScore + yLims = [yMin - yPad, yMax + yPad]; +else + yLims = [max(0, yMin - yPad), yMax + yPad]; +end + +% ---- Create figure ------------------------------------------------------ +fig = figure; +set(fig, 'Units', 'centimeters', 'Position', [5 5 7 10]); +ax = axes(fig); +hold(ax, 'on'); + +legendHandles = []; +legendLabels = {}; + +% ---- Category colour maps ----------------------------------------------- +catColorMaps = cell(nStim, 1); +for s = 1:nStim + nc = numel(catLabelsAll{s}); + if nc == 1 + catColorMaps{s} = baseColors(s, :); + else + cmap = zeros(nc, 3); + for ci = 1:nc + frac = (ci - 1) / max(nc - 1, 1); + cmap(ci,:) = baseColors(s,:) .* (1 - 0.5*frac) + [0.3 0.1 0]*frac; + cmap(ci,:) = min(max(cmap(ci,:), 0), 1); + end + catColorMaps{s} = cmap; + end +end + +% ---- Plot each condition ------------------------------------------------ +for s = 1:nStim + + stimKey = char(params.stimTypes(s)); + if isKey(stimLegendMap, stimKey) + shortName = stimLegendMap(stimKey); + else + shortName = stimKey; + end + + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + + meanPSTH = meanStore{s, b, ci}; + semPSTH = semStore{s, b, ci}; + if isempty(meanPSTH); continue; end + + nValid = nExpStore(s, b, ci); + + % ---- Determine line colour and legend label ----------------- + isSplitHere = params.splitBy ~= "" && ~isequal(catLabelsAll{s}, "all"); + if params.byDepth + 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 + lineColor = catColorMaps{s}(ci, :); + legendLabel = sprintf('%s %s=%s (n=%d)', ... + shortName, params.splitBy, catLabelsAll{s}(ci), nValid); + else + lineColor = baseColors(s,:); + legendLabel = sprintf('%s (n=%d)', shortName, nValid); + end + + % ---- SEM shading ------------------------------------------- + if params.shadeSTD && nValid > 1 + upper = meanPSTH + semPSTH; + lower = meanPSTH - semPSTH; + xFill = [tAxisPlot(:)', fliplr(tAxisPlot(:)')]; + yFill = [upper(:)', fliplr(lower(:)')]; + fill(ax, xFill, yFill, lineColor, ... + 'FaceAlpha', 0.08, 'EdgeColor', 'none'); + end + + % ---- Mean PSTH line ---------------------------------------- + h = plot(ax, tAxisPlot(:)', meanPSTH(:)', ... + 'Color', lineColor, 'LineWidth', 1.5); + + legendHandles(end+1) = h; %#ok + legendLabels{end+1} = legendLabel; %#ok + + end % category loop + end % depth-bin loop +end % stim-type loop + +% ---- Reference lines ---------------------------------------------------- +xline(ax, 0, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); +xline(ax, params.postStim, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); + +% ---- Axis labels and formatting ----------------------------------------- +if params.zScore; yLabel = 'Z-score'; else; yLabel = 'Firing rate [spk/s]'; 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)]); +ylim(ax, yLims); + +legend(legendHandles, legendLabels, 'Location', 'northwest', ... + 'FontName', 'Helvetica', 'FontSize', 7); + +ax.FontName = 'Helvetica'; +ax.FontSize = 8; +ax.YAxis.FontSize = 8; +ax.XAxis.FontSize = 8; +hold(ax, 'off'); + +title(ax, sprintf('N = %d experiments', numel(exList)), ... + 'FontName', 'Helvetica', 'FontSize', 10); + + + +% ---- Export publication figure if requested ----------------------------- +if params.PaperFig + stimStr = strjoin(params.stimTypes, '-'); + vs_first.printFig(fig, sprintf('PSTH-%s%s%s', stimStr, splitSuffix, depthSuffix), ... + PaperFig = params.PaperFig); +end + +end % end of main function + + +% ######################################################################### +% LOCAL HELPER FUNCTIONS +% ######################################################################### + + +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 = []; +validSession = -1; +levels = []; + +for sess = [0, 1, 2] + candidate = buildStimObject(NP, stimKey, sess); + if isempty(candidate) + continue + end + + % Check that the stimulus was actually presented + try + if isempty(candidate.VST) + continue + end + catch + continue + end + + % Ensure ResponseWindow is computed + try + candidate.ResponseWindow('overwrite', overwriteRW); + catch + continue + end + + % Extract condition matrix and column names + rw = candidate.ResponseWindow; + [C, colNames] = getCmatrixLocal(rw, stimKey, speedParam); + if isempty(C) || isempty(colNames) + continue + end + + % Find the category column by name + catIdx = find(strcmpi(colNames, splitBy), 1); + if isempty(catIdx) + continue + end + + % Column mapping: colNames(k) → C(:, k+1) [C(:,1) = onset times] + catColIdx = catIdx + 1; + rawCol = C(:, catColIdx); + rawCol = rawCol(~isnan(rawCol)); + availLevels = uniquetol(rawCol, 1e-6); + + % If specific levels requested, verify they are all present + if ~isempty(splitLevels) + allPresent = true; + for lv = splitLevels(:)' + if ~any(abs(availLevels - lv) < 1e-6) + allPresent = false; + break + end + end + if ~allPresent + continue + end + useLevels = splitLevels(:); + else + useLevels = availLevels; + end + + % Need ≥2 levels to be worth splitting + if numel(useLevels) < 2 + continue + end + + % Found a valid session + 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 = parameter names only (rw.colNames{1}(5:end)). +% C(:,1) = onset times. C(:, k+1) = colNames(k). + +C = []; +colNames = {}; + +% Try to read colNames (same regardless of speed field) +try + allColNames = rw.colNames{1}; + colNames = allColNames(5:end); +catch + return +end + +% Get C from the correct sub-field +switch stimKey + case {"MB", "MBR"} + if speedParam == "max" + fld = 'Speed1'; + else + fld = 'Speed2'; + end + if isfield(rw, fld) + C = rw.(fld).C; + else + speedFields = fieldnames(rw); + speedFields = speedFields(startsWith(speedFields, 'Speed')); + if ~isempty(speedFields) + C = rw.(speedFields{end}).C; + end + end + + case "SDGm" + if isfield(rw, 'C') + C = rw.C; + elseif isfield(rw, 'Moving') && isfield(rw.Moving, 'C') + C = rw.Moving.C; + end + + case "SDGs" + if isfield(rw, 'C') + C = rw.C; + elseif isfield(rw, 'Static') && isfield(rw.Static, 'C') + C = rw.Static.C; + end + + otherwise + 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. + +if nargin < 3; session = 0; end + +obj = []; +try + % SDGm and SDGs both use StaticDriftingGratingAnalysis + switch stimKey + case {"SDGm", "SDGs"}, ctorKey = "SDG"; + otherwise, ctorKey = stimKey; + end + + if session == 0 + 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 + 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 = []; +end +end + + +function [fieldName, startStim] = getFieldAndOffset(obj, stimKey, speedParam) +% getFieldAndOffset Return the response-table field name and the stimulus- +% onset offset (ms) for the given stimulus abbreviation key. + +startStim = 0; + +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'; + case "SDGm" + fieldName = 'Moving'; + startStim = obj.VST.static_time * 1000; + otherwise + fieldName = 'Speed1'; +end +end + + +function C = getConditionMatrix(obj, stimType, speedParam) +% getConditionMatrix Extract condition matrix C from ResponseWindow. + +[fieldName, ~] = getFieldAndOffset(obj, stimType, speedParam); +NeuronResp = obj.ResponseWindow; + +try + C = NeuronResp.(fieldName).C; +catch + C = NeuronResp.C; +end +end + + +function catCol = getCategoryColumn(obj, stimType, speedParam, splitBy) +% getCategoryColumn Extract the category column from C by matching +% splitBy against column names in ResponseWindow. +% +% Column layout: +% 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; + +allColNames = responseParams.colNames{1}; +paramNames = allColNames(5:end); + +matchIdx = find(strcmpi(paramNames, splitBy), 1); +if isempty(matchIdx) + error(['splitBy = "%s" does not match any column in colNames.\n' ... + ' Available: %s'], splitBy, strjoin(string(paramNames), ', ')); +end + +colIdxInC = matchIdx + 1; % paramNames(1) → C(:,2) + +C = getConditionMatrix(obj, stimType, speedParam); +catCol = C(:, colIdxInC); +end + + +function arr = appendOrInit(arr, newRow) +% appendOrInit Append a row, or initialize if empty. +if isempty(arr) + arr = newRow; +else + arr = [arr; newRow]; +end +end + + +function appendNaNRow(psthAll, nStim, nDepthBins, catLabelsAll, lockedNBins) +% appendNaNRow Insert NaN placeholder for ALL stim × depth × cat conditions. +if isempty(lockedNBins); return; end +for s = 1:nStim + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + psthAll{s, b, ci} = appendOrInit(psthAll{s, b, ci}, NaN(1, lockedNBins)); + end + end +end +end + + +function appendNaNRowForStim(psthAll, s, nDepthBins, catLabels, lockedNBins) +% appendNaNRowForStim Insert NaN placeholder for one stim type. +if isempty(lockedNBins); return; end +for b = 1:nDepthBins + for ci = 1:numel(catLabels) + psthAll{s, b, ci} = appendOrInit(psthAll{s, b, ci}, NaN(1, lockedNBins)); + end +end +end + + +function fName = levelToFieldName(catName, value) +% levelToFieldName Build a valid 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); % e.g. 'size_5' or 'speed_0.3' +fName = strrep(fName, '.', 'p'); % replace decimal point with 'p' +fName = strrep(fName, '-', 'neg'); % replace minus sign with 'neg' +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..1bc365e --- /dev/null +++ b/visualStimulationAnalysis/plotPSTH_MultiExp.m @@ -0,0 +1,1090 @@ +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) +% 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 (abbreviations: MB, MBR, RG, SDGm, SDGs, NV, NI, FFF) + params.splitBy string = "" % category variable for within-stim split; "" = no split + params.splitLevels double = [] % specific levels to compare (e.g. [5 10 20]); empty = all available + params.binWidth double = 10 % PSTH time-bin width in ms + params.smooth double = 0 % Gaussian smoothing window in ms (0 = no smoothing) + params.statType string = "maxPermuteTest" % which statistical test for p-values + params.speed string = "max" % which speed condition for MB/MBR + 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 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 statistics + params.useCategoryPvals logical = false % if true and splitBy is active, use per-level p-values from StatisticsPerNeuronPerCategory (OR across levels) instead of general per-neuron p-values + params.nBootCategory double = 10000 % number of bootstrap iterations for StatisticsPerNeuronPerCategory + params.TakeTopPercentTrials double = 1 % fraction of trials to keep (1 = all; see note below) + params.zScore logical = false % z-score each neuron's PSTH to its own baseline + params.PaperFig logical = false % export publication-quality figure via printFig + params.byDepth logical = false % split neurons into 3 depth bins +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 is hard to justify unless there is a principled reason (e.g. +% attention gating in a behaving animal). Default is 1 (all trials). +% ------------------------------------------------------------------------- + +% ------------------------------------------------------------------------- +% Guard: splitBy and byDepth together create too many lines +% ------------------------------------------------------------------------- +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 + +% ------------------------------------------------------------------------- +% Load depth-bin info if byDepth is requested +% ------------------------------------------------------------------------- +if params.byDepth + 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); + depthTable = D.depthTable; + depthBinEdges = D.depthBinEdges; + nDepthBins = 3; + 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; +end + +% ------------------------------------------------------------------------- +% Build save directory path +% ------------------------------------------------------------------------- +NP_first = loadNPclassFromTable(exList(1)); +vs_first = linearlyMovingBallAnalysis(NP_first); + +basePath = extractBefore(vs_first.getAnalysisFileName, 'lizards'); +basePath = [basePath 'lizards']; +saveDir = fullfile(basePath, 'Combined_lizard_analysis'); +if ~exist(saveDir, 'dir') + mkdir(saveDir); +end + +% ---- Construct the filename ---- +stimLabel = strjoin(params.stimTypes, '-'); +depthSuffix = ''; +if params.byDepth; depthSuffix = '_byDepth'; end +splitSuffix = ''; +if params.splitBy ~= ""; splitSuffix = ['_by' char(params.splitBy)]; end +if ~isempty(params.splitLevels) + lvlStr = strjoin(arrayfun(@(v) sprintf('%g',v), params.splitLevels, ... + 'UniformOutput', false), '_'); + splitSuffix = [splitSuffix '_lvl' lvlStr]; +end +nameOfFile = sprintf('Ex_%d-%d_Combined_PSTHs_%s%s%s.mat', ... + exList(1), exList(end), stimLabel, splitSuffix, depthSuffix); +fullSavePath = fullfile(saveDir, nameOfFile); + +% ------------------------------------------------------------------------- +% Decide: recompute or load from disk +% ------------------------------------------------------------------------- +forloop = true; +if exist(fullSavePath, 'file') == 2 && ~params.overwrite + S = load(fullSavePath); + if isequal(S.expList, exList) + fprintf('Loading saved PSTHs from:\n %s\n', fullSavePath); + forloop = false; + else + fprintf('Experiment list mismatch — recomputing.\n'); + end +end + +% ========================================================================= +% MAIN COMPUTATION LOOP (skip if loaded from disk) +% ========================================================================= +if forloop + + nStim = numel(params.stimTypes); + nExp = numel(exList); + + % ===================================================================== + % DISCOVERY PASS — find valid sessions and category levels + % ===================================================================== + % For each experiment × stim type, we try sessions [0, 1, 2] to find + % one whose category column (splitBy) has ≥2 levels (or contains all + % of the user-specified 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); % will hold session numbers; -1 = skip + catLabelsAll = cell(nStim, 1); + + if params.splitBy ~= "" + fprintf('\nDiscovering categories (splitBy = "%s") ...\n', params.splitBy); + end + + for s = 1:nStim + stimKey = params.stimTypes(s); + + if params.splitBy == "" + % ---- No split: all experiments use default session (0) ------ + catLabelsAll{s} = "all"; + sessionMap(:, s) = 0; + else + % ---- Split requested: scan experiments for valid sessions --- + allLevelsFound = []; % accumulate levels across experiments + + for ei = 1:nExp + try + NP_tmp = loadNPclassFromTable(exList(ei)); + catch + sessionMap(ei, s) = -1; + continue + end + + % Try sessions [0, 1, 2]; return first with ≥2 levels + [~, sess, levels] = findValidSession( ... + NP_tmp, stimKey, params.speed, params.splitBy, ... + params.splitLevels, params.overwriteResponse); + + if sess < 0 + sessionMap(ei, s) = -1; + fprintf(' Exp %d [%s]: no session with ≥2 levels of "%s" — will skip.\n', ... + exList(ei), stimKey, params.splitBy); + else + sessionMap(ei, s) = sess; + allLevelsFound = [allLevelsFound; levels(:)]; %#ok + fprintf(' Exp %d [%s]: session %d has levels [%s]\n', ... + exList(ei), stimKey, sess, num2str(levels(:)', '%g ')); + end + end + + % Determine the final global set of category levels + uniqueVals = unique(allLevelsFound); + + % If the user requested specific levels, intersect + if ~isempty(params.splitLevels) + uniqueVals = intersect(uniqueVals, params.splitLevels(:)); + end + + if numel(uniqueVals) < 2 + fprintf(' [%s] splitBy="%s" has only %d global level — falling back to unsplit.\n', ... + stimKey, params.splitBy, numel(uniqueVals)); + catLabelsAll{s} = "all"; + sessionMap(:, s) = 0; % reset all to default session + else + catLabelsAll{s} = string(uniqueVals(:)'); + fprintf(' [%s] final category levels: %s\n', ... + stimKey, strjoin(catLabelsAll{s}, ', ')); + end + end + end + + % ----- Find the maximum number of categories across stim types ------- + maxCats = max(cellfun(@numel, catLabelsAll)); + + % ----- Pre-allocate psthAll ------------------------------------------ + psthAll = cell(nStim, nDepthBins, maxCats); + + % ----- Time-axis parameters (locked on first successful experiment) -- + lockedPreBase = []; + lockedNBins = []; + lockedEdges = []; + + % ===================================================================== + % MAIN EXPERIMENT LOOP + % ===================================================================== + for ei = 1:nExp + + ex = exList(ei); + fprintf('\n=== Experiment %d (%d/%d) ===\n', ex, ei, nExp); + + % ---- Load experiment -------------------------------------------- + try + NP = loadNPclassFromTable(ex); + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + appendNaNRow(psthAll, nStim, nDepthBins, catLabelsAll, lockedNBins); + continue + end + + % ================================================================= + % Loop over stimulus types + % ================================================================= + for s = 1:nStim + + stimType = params.stimTypes(s); + + % ---- Check session map: skip if no valid session found ------ + sess = sessionMap(ei, s); + if sess < 0 + fprintf(' [%s] Skipping exp %d (no valid session).\n', stimType, ex); + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue + end + + % ---- Construct the analysis object using the chosen session - + try + obj = buildStimObject(NP, stimType, sess); + catch ME + warning('Could not build %s (session %d) for exp %d: %s', ... + stimType, sess, ex, ME.message); + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue + end + + if isempty(obj) + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue + end + + % ---- Check that the stimulus was actually presented --------- + % The constructor may succeed but VST is empty when the + % stimulus protocol was not run in this experiment. + try + stimMissing = isempty(obj.VST); + catch + stimMissing = true; + end + if stimMissing + fprintf(' [%s] Stimulus not present in exp %d — skipping.\n', stimType, ex); + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + 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); + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue + end + + % ---- Load statistics (p-values per neuron) ------------------ + try + if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; + elseif params.statType == "maxPermuteTest" + Stats = obj.StatisticsPerNeuron; + else + Stats = obj.ShufflingAnalysis; + end + catch ME + warning(' [%s] Statistics failed for exp %d: %s — skipping.', ... + stimType, ex, ME.message); + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue + end + + % ---- Determine field name and stim-onset offset ------------- + [fieldName, startStim] = getFieldAndOffset(obj, stimType, params.speed); + + % ---- Get sorted good units ---------------------------------- + p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); + label = string(p_sort.label'); + goodU = p_sort.ic(:, label == "good"); + + % ---- Extract condition matrix C and stimulus onset times ---- + C = getConditionMatrix(obj, stimType, params.speed); + directimesSorted = C(:, 1)' + startStim; + + % ---- Lock the time-axis on the first valid experiment ------- + preBase = params.preBase; + windowTotal = preBase + params.postStim; + + if isempty(lockedPreBase) + lockedPreBase = preBase; + lockedEdges = 0 : params.binWidth : windowTotal; + lockedNBins = numel(lockedEdges) - 1; + fprintf(' Locked window: preBase=%d ms, postStim=%d ms, nBins=%d\n', ... + lockedPreBase, params.postStim, lockedNBins); + end + + % ---- Determine whether category split is active for this stim --- + nCats = numel(catLabelsAll{s}); + isSplitActive = params.splitBy ~= "" && ~isequal(catLabelsAll{s}, "all"); + + % ---- Select responsive neurons ------------------------------ + % Two modes: + % (a) useCategoryPvals + active split: call + % StatisticsPerNeuronPerCategory and OR the per-level + % p-values. A neuron is "responsive" if it passes the + % threshold for ANY of the compared levels. + % (b) Default: use the general per-neuron p-values from the + % overall statistics (statType). + + if isSplitActive && params.useCategoryPvals + % ---- Category-level p-values ---------------------------- + 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); + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue + end + + % OR across all category levels + orMask = false; % will broadcast to vector on first OR + for ci = 1:nCats + levelVal = str2double(catLabelsAll{s}(ci)); % numeric level value + fName = levelToFieldName(params.splitBy, levelVal); % e.g. 'size_5' + if isfield(catStats, fName) + orMask = orMask | (catStats.(fName).pvalsResponse(:) < params.alpha); + end + end + eNeurons = find(orMask); + + fprintf(' [%s] Using per-category p-values: %d responsive neuron(s) in exp %d.\n', ... + stimType, numel(eNeurons), ex); + else + % ---- General per-neuron p-values ------------------------ + try + pvals = Stats.(fieldName).pvalsResponse; + catch + pvals = Stats.pvalsResponse; + end + eNeurons = find(pvals < params.alpha); + end + + if isempty(eNeurons) + fprintf(' [%s] No responsive neurons in exp %d.\n', stimType, ex); + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue + end + + if ~(isSplitActive && params.useCategoryPvals) + fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', ... + stimType, numel(eNeurons), ex); + end + + % ---- Extract per-trial category values (if splitting) ------- + catValues = []; + if isSplitActive + catCol = getCategoryColumn(obj, stimType, params.speed, params.splitBy); + catValues = catCol(:)'; + end + + % ============================================================== + % Loop over responsive neurons + % ============================================================== + psthRateNeurons = NaN(numel(eNeurons), lockedNBins, nCats); + neuronBinIdx = zeros(numel(eNeurons), 1); + + for ni = 1:numel(eNeurons) + u = eNeurons(ni); + + % ---- Assign depth bin ----------------------------------- + if params.byDepth + depthRow = depthTable.Experiment == ex & depthTable.Unit == u; + if ~any(depthRow) + neuronBinIdx(ni) = 0; + continue + end + unitDepth = depthTable.Depth_um(depthRow); + if unitDepth <= depthBinEdges(2) + neuronBinIdx(ni) = 1; + elseif unitDepth <= depthBinEdges(3) + neuronBinIdx(ni) = 2; + else + neuronBinIdx(ni) = 3; + end + else + neuronBinIdx(ni) = 1; + end + + % ---- Build PSTH for each category ----------------------- + for ci = 1:nCats + + if ~isSplitActive + trialMask = true(size(directimesSorted)); + else + trialMask = abs(catValues - str2double(catLabelsAll{s}(ci))) < 1e-6; + end + catOnsets = directimesSorted(trialMask); + + if isempty(catOnsets) + psthRateNeurons(ni, :, ci) = NaN(1, lockedNBins); + continue + end + + % Build binary spike matrix: [nTrials × windowTotal_ms] + MRhist = BuildBurstMatrix( ... + goodU(:, u), ... + round(p_sort.t), ... + round(catOnsets - lockedPreBase), ... + round(windowTotal)); + MRhist = squeeze(MRhist); + + % ---- Optional: keep only top-N% of trials ----------- + if params.TakeTopPercentTrials < 1 + MeanTrial = mean(MRhist, 2); + [~, ind] = sort(MeanTrial, 'descend'); + nKeep = max(1, round(numel(MeanTrial) * params.TakeTopPercentTrials)); + MRhist = MRhist(ind(1:nKeep), :); + end + + nTrials = size(MRhist, 1); + + % ---- Compute PSTH by direct bin-summation ----------- + counts = zeros(1, lockedNBins); + for bi = 1:lockedNBins + msStart = lockedEdges(bi) + 1; + msEnd = lockedEdges(bi + 1); + counts(bi) = sum(MRhist(:, msStart:msEnd), 'all'); + end + psthRateNeurons(ni, :, ci) = (counts / nTrials) / (params.binWidth / 1000); + + end % category loop + end % neuron loop + + % ============================================================== + % z-score each neuron individually (if requested) + % ============================================================== + tAxis = lockedEdges(1:end-1); + if params.zScore + baselineBins = tAxis < lockedPreBase; + for ni = 1:size(psthRateNeurons, 1) + for ci = 1:nCats + trace = psthRateNeurons(ni, :, ci); + if all(isnan(trace)); continue; end + bMean = mean(trace(baselineBins), 'omitnan'); + bStd = std(trace(baselineBins), 0, 'omitnan'); + if bStd > 0 + psthRateNeurons(ni, :, ci) = (trace - bMean) / bStd; + else + psthRateNeurons(ni, :, ci) = NaN; + end + end + end + end + + % ============================================================== + % Average across neurons per depth bin × category, append + % ============================================================== + for b = 1:nDepthBins + binNeurons = neuronBinIdx == b; + + if ~any(binNeurons) + for ci = 1:nCats + psthAll{s, b, ci} = appendOrInit(psthAll{s, b, ci}, NaN(1, lockedNBins)); + end + continue + end + + for ci = 1:nCats + catData = psthRateNeurons(binNeurons, :, ci); + if all(isnan(catData), 'all') + psthAll{s, b, ci} = appendOrInit(psthAll{s, b, ci}, NaN(1, lockedNBins)); + else + psthExp = mean(catData, 1, 'omitnan'); + psthAll{s, b, ci} = appendOrInit(psthAll{s, b, ci}, psthExp(:)'); + end + end + + fprintf(' [%s] Depth bin %d: %d neuron(s) in exp %d.\n', ... + stimType, b, sum(binNeurons), ex); + end + + end % stim-type loop + end % experiment loop + + % ===================================================================== + % Save results to disk + % ===================================================================== + S.expList = exList; + S.lockedEdges = lockedEdges; + S.lockedPreBase = lockedPreBase; + S.params = params; + S.catLabelsAll = catLabelsAll; + + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + fieldKey = sprintf('%s_bin%d_cat%d', stimField, b, ci); + S.(fieldKey) = psthAll{s, b, ci}; + end + end + end + + save(fullSavePath, '-struct', 'S'); + fprintf('\nSaved PSTHs to:\n %s\n', fullSavePath); + +else + % ================================================================= + % Load psthAll from the saved struct S + % ================================================================= + lockedEdges = S.lockedEdges; + lockedPreBase = S.lockedPreBase; + catLabelsAll = S.catLabelsAll; + + maxCats = max(cellfun(@numel, catLabelsAll)); + psthAll = cell(numel(params.stimTypes), nDepthBins, maxCats); + + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + fieldKey = sprintf('%s_bin%d_cat%d', stimField, b, ci); + if isfield(S, fieldKey) + psthAll{s, b, ci} = S.(fieldKey); + else + warning('Field "%s" not found in saved file.', fieldKey); + psthAll{s, b, ci} = []; + end + end + end + end +end + +% ========================================================================= +% PLOTTING +% ========================================================================= + +tAxis = lockedEdges(1:end-1); +tAxisPlot = tAxis - lockedPreBase; + +% ---- Colour palette and label maps ------------------------------------- +nStim = numel(params.stimTypes); +baseColors = lines(nStim); + +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]; +binLabels = {'shallow', 'middle', 'deep'}; + +% ---- First pass: smooth traces, compute mean & SEM, find global ylim --- +yMax = -Inf; +yMin = Inf; + +meanStore = cell(nStim, nDepthBins, max(cellfun(@numel, catLabelsAll))); +semStore = cell(nStim, nDepthBins, max(cellfun(@numel, catLabelsAll))); +nExpStore = zeros(nStim, nDepthBins, max(cellfun(@numel, catLabelsAll))); + +for s = 1:nStim + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + data = psthAll{s, b, ci}; + if isempty(data); continue; end + validRows = ~all(isnan(data), 2); + data = data(validRows, :); + if isempty(data); continue; end + + nValid = size(data, 1); + nExpStore(s, b, ci) = nValid; + + % Smooth each experiment's trace FIRST, then compute stats + if params.smooth > 0 + smoothBins = round(params.smooth / params.binWidth); + for ri = 1:nValid + data(ri, :) = smoothdata(data(ri, :), 'gaussian', smoothBins); + end + end + + meanTrace = mean(data, 1, 'omitnan'); + semTrace = std(data, 0, 1, 'omitnan') / sqrt(nValid); + + meanStore{s, b, ci} = meanTrace; + semStore{s, b, ci} = semTrace; + + yMax = max(yMax, max(meanTrace + semTrace)); + yMin = min(yMin, min(meanTrace - semTrace)); + end + end +end + +yPad = (yMax - yMin) * 0.1; +if params.zScore + yLims = [yMin - yPad, yMax + yPad]; +else + yLims = [max(0, yMin - yPad), yMax + yPad]; +end + +% ---- Create figure ------------------------------------------------------ +fig = figure; +set(fig, 'Units', 'centimeters', 'Position', [5 5 10 7]); +ax = axes(fig); +hold(ax, 'on'); + +legendHandles = []; +legendLabels = {}; + +% ---- Category colour maps ----------------------------------------------- +catColorMaps = cell(nStim, 1); +for s = 1:nStim + nc = numel(catLabelsAll{s}); + if nc == 1 + catColorMaps{s} = baseColors(s, :); + else + cmap = zeros(nc, 3); + for ci = 1:nc + frac = (ci - 1) / max(nc - 1, 1); + cmap(ci,:) = baseColors(s,:) .* (1 - 0.5*frac) + [0.3 0.1 0]*frac; + cmap(ci,:) = min(max(cmap(ci,:), 0), 1); + end + catColorMaps{s} = cmap; + end +end + +% ---- Plot each condition ------------------------------------------------ +for s = 1:nStim + + stimKey = char(params.stimTypes(s)); + if isKey(stimLegendMap, stimKey) + shortName = stimLegendMap(stimKey); + else + shortName = stimKey; + end + + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + + meanPSTH = meanStore{s, b, ci}; + semPSTH = semStore{s, b, ci}; + if isempty(meanPSTH); continue; end + + nValid = nExpStore(s, b, ci); + + % ---- Determine line colour and legend label ----------------- + isSplitHere = params.splitBy ~= "" && ~isequal(catLabelsAll{s}, "all"); + if params.byDepth + 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 + lineColor = catColorMaps{s}(ci, :); + legendLabel = sprintf('%s %s=%s (n=%d)', ... + shortName, params.splitBy, catLabelsAll{s}(ci), nValid); + else + lineColor = baseColors(s,:); + legendLabel = sprintf('%s (n=%d)', shortName, nValid); + end + + % ---- SEM shading ------------------------------------------- + if params.shadeSTD && nValid > 1 + upper = meanPSTH + semPSTH; + lower = meanPSTH - semPSTH; + xFill = [tAxisPlot(:)', fliplr(tAxisPlot(:)')]; + yFill = [upper(:)', fliplr(lower(:)')]; + fill(ax, xFill, yFill, lineColor, ... + 'FaceAlpha', 0.08, 'EdgeColor', 'none'); + end + + % ---- Mean PSTH line ---------------------------------------- + h = plot(ax, tAxisPlot(:)', meanPSTH(:)', ... + 'Color', lineColor, 'LineWidth', 1.5); + + legendHandles(end+1) = h; %#ok + legendLabels{end+1} = legendLabel; %#ok + + end % category loop + end % depth-bin loop +end % stim-type loop + +% ---- Reference lines ---------------------------------------------------- +xline(ax, 0, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); +xline(ax, params.postStim, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); + +% ---- Axis labels and formatting ----------------------------------------- +if params.zScore; yLabel = 'Z-score'; else; yLabel = 'Firing rate [spk/s]'; 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)]); +ylim(ax, yLims); + +legend(legendHandles, legendLabels, 'Location', 'northwest', ... + 'FontName', 'Helvetica', 'FontSize', 7); + +ax.FontName = 'Helvetica'; +ax.FontSize = 8; +ax.YAxis.FontSize = 8; +ax.XAxis.FontSize = 8; +hold(ax, 'off'); + +title(ax, sprintf('N = %d experiments', numel(exList)), ... + 'FontName', 'Helvetica', 'FontSize', 10); + + + +% ---- Export publication figure if requested ----------------------------- +if params.PaperFig + stimStr = strjoin(params.stimTypes, '-'); + vs_first.printFig(fig, sprintf('PSTH-%s%s%s', stimStr, splitSuffix, depthSuffix), ... + PaperFig = params.PaperFig); +end + +end % end of main function + + +% ######################################################################### +% LOCAL HELPER FUNCTIONS +% ######################################################################### + + +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 = []; +validSession = -1; +levels = []; + +for sess = [0, 1, 2] + candidate = buildStimObject(NP, stimKey, sess); + if isempty(candidate) + continue + end + + % Check that the stimulus was actually presented + try + if isempty(candidate.VST) + continue + end + catch + continue + end + + % Ensure ResponseWindow is computed + try + candidate.ResponseWindow('overwrite', overwriteRW); + catch + continue + end + + % Extract condition matrix and column names + rw = candidate.ResponseWindow; + [C, colNames] = getCmatrixLocal(rw, stimKey, speedParam); + if isempty(C) || isempty(colNames) + continue + end + + % Find the category column by name + catIdx = find(strcmpi(colNames, splitBy), 1); + if isempty(catIdx) + continue + end + + % Column mapping: colNames(k) → C(:, k+1) [C(:,1) = onset times] + catColIdx = catIdx + 1; + rawCol = C(:, catColIdx); + rawCol = rawCol(~isnan(rawCol)); + availLevels = uniquetol(rawCol, 1e-6); + + % If specific levels requested, verify they are all present + if ~isempty(splitLevels) + allPresent = true; + for lv = splitLevels(:)' + if ~any(abs(availLevels - lv) < 1e-6) + allPresent = false; + break + end + end + if ~allPresent + continue + end + useLevels = splitLevels(:); + else + useLevels = availLevels; + end + + % Need ≥2 levels to be worth splitting + if numel(useLevels) < 2 + continue + end + + % Found a valid session + 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 = parameter names only (rw.colNames{1}(5:end)). +% C(:,1) = onset times. C(:, k+1) = colNames(k). + +C = []; +colNames = {}; + +% Try to read colNames (same regardless of speed field) +try + allColNames = rw.colNames{1}; + colNames = allColNames(5:end); +catch + return +end + +% Get C from the correct sub-field +switch stimKey + case {"MB", "MBR"} + if speedParam == "max" + fld = 'Speed1'; + else + fld = 'Speed2'; + end + if isfield(rw, fld) + C = rw.(fld).C; + else + speedFields = fieldnames(rw); + speedFields = speedFields(startsWith(speedFields, 'Speed')); + if ~isempty(speedFields) + C = rw.(speedFields{end}).C; + end + end + + case "SDGm" + if isfield(rw, 'C') + C = rw.C; + elseif isfield(rw, 'Moving') && isfield(rw.Moving, 'C') + C = rw.Moving.C; + end + + case "SDGs" + if isfield(rw, 'C') + C = rw.C; + elseif isfield(rw, 'Static') && isfield(rw.Static, 'C') + C = rw.Static.C; + end + + otherwise + 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. + +if nargin < 3; session = 0; end + +obj = []; +try + % SDGm and SDGs both use StaticDriftingGratingAnalysis + switch stimKey + case {"SDGm", "SDGs"}, ctorKey = "SDG"; + otherwise, ctorKey = stimKey; + end + + if session == 0 + 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 + 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 = []; +end +end + + +function [fieldName, startStim] = getFieldAndOffset(obj, stimKey, speedParam) +% getFieldAndOffset Return the response-table field name and the stimulus- +% onset offset (ms) for the given stimulus abbreviation key. + +startStim = 0; + +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'; + case "SDGm" + fieldName = 'Moving'; + startStim = obj.VST.static_time * 1000; + otherwise + fieldName = 'Speed1'; +end +end + + +function C = getConditionMatrix(obj, stimType, speedParam) +% getConditionMatrix Extract condition matrix C from ResponseWindow. + +[fieldName, ~] = getFieldAndOffset(obj, stimType, speedParam); +NeuronResp = obj.ResponseWindow; + +try + C = NeuronResp.(fieldName).C; +catch + C = NeuronResp.C; +end +end + + +function catCol = getCategoryColumn(obj, stimType, speedParam, splitBy) +% getCategoryColumn Extract the category column from C by matching +% splitBy against column names in ResponseWindow. +% +% Column layout: +% 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; + +allColNames = responseParams.colNames{1}; +paramNames = allColNames(5:end); + +matchIdx = find(strcmpi(paramNames, splitBy), 1); +if isempty(matchIdx) + error(['splitBy = "%s" does not match any column in colNames.\n' ... + ' Available: %s'], splitBy, strjoin(string(paramNames), ', ')); +end + +colIdxInC = matchIdx + 1; % paramNames(1) → C(:,2) + +C = getConditionMatrix(obj, stimType, speedParam); +catCol = C(:, colIdxInC); +end + + +function arr = appendOrInit(arr, newRow) +% appendOrInit Append a row, or initialize if empty. +if isempty(arr) + arr = newRow; +else + arr = [arr; newRow]; +end +end + + +function appendNaNRow(psthAll, nStim, nDepthBins, catLabelsAll, lockedNBins) +% appendNaNRow Insert NaN placeholder for ALL stim × depth × cat conditions. +if isempty(lockedNBins); return; end +for s = 1:nStim + for b = 1:nDepthBins + for ci = 1:numel(catLabelsAll{s}) + psthAll{s, b, ci} = appendOrInit(psthAll{s, b, ci}, NaN(1, lockedNBins)); + end + end +end +end + + +function appendNaNRowForStim(psthAll, s, nDepthBins, catLabels, lockedNBins) +% appendNaNRowForStim Insert NaN placeholder for one stim type. +if isempty(lockedNBins); return; end +for b = 1:nDepthBins + for ci = 1:numel(catLabels) + psthAll{s, b, ci} = appendOrInit(psthAll{s, b, ci}, NaN(1, lockedNBins)); + end +end +end + + +function fName = levelToFieldName(catName, value) +% levelToFieldName Build a valid 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); % e.g. 'size_5' or 'speed_0.3' +fName = strrep(fName, '.', 'p'); % replace decimal point with 'p' +fName = strrep(fName, '-', 'neg'); % replace minus sign with 'neg' +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..f6eac26 --- /dev/null +++ b/visualStimulationAnalysis/plotRaster_MultiExp.m @@ -0,0 +1,1297 @@ +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. +% 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. + +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|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 = 1 % 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 = "spatialTuning" % "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 +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 and broadcast preferredCategory parameters +% ------------------------------------------------------------------------- +if params.sortBy == "preferredCategory" + + 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 +% ------------------------------------------------------------------------- +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" + 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 +nameOfFile = sprintf('\\Ex_%d-%d_Raster_%s%s.mat', ... + exList(1), exList(end), stimLabel, catSuffix); % 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 (NEW) + + 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 + 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 (NEW) + + % --- 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 + + % ----------------------------------------------------------------- + % Pre-scan: find shortest stimulus duration per type across all exps + % ----------------------------------------------------------------- + minStimDur = inf(1, nStim); % initialise with inf + + 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 --- + gt = detectGratingType(stimKey); % '' for non-grating, 'moving'/'static' for SDG + switch stimKey + case "RG"; objTmp = rectGridAnalysis(NPtmp); + case "MB"; objTmp = linearlyMovingBallAnalysis(NPtmp); + case "MBR"; objTmp = linearlyMovingBarAnalysis(NPtmp); + case {"SDGs"} + objTmp = StaticDriftingGratingAnalysis(NPtmp); + case {"SDGm"} + objTmp = StaticDriftingGratingAnalysis(NPtmp); + case "NI"; objTmp = imageAnalysis(NPtmp); + case "NV"; objTmp = movieAnalysis(NPtmp); + case "FFF"; objTmp = fullFieldFlashAnalysis(NPtmp); + 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 + + 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 + + for s = 1:nStim + + stimType = params.stimTypes(s); % current stim abbreviation + + % --- Build stimulus-specific analysis object --- + try + gt = detectGratingType(stimType); % '' for non-grating, 'moving'/'static' for SDG + 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"} + obj = StaticDriftingGratingAnalysis(NP); % grating stimulus + 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); + continue % skip this stim/exp + end + + + + % --- Select statistics struct --- + if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; % bootstrap p-values + else + Stats = obj.StatisticsPerNeuron; % default: permutation test + end + + % --- Resolve sub-field name and stim-onset offset --- + fieldName = resolveFieldName(stimType, params.speed); % sub-field key for this stim type + startStim = 0; % ms offset for stim onset + if stimType == "SDGm" + startStim = obj.VST.static_time * 1000; % moving phase onset (s -> ms) + end + + % --- 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 + + % --- Extract Phy cluster IDs for good units --- + goodPhyIDs = p_sort.phy_ID(label == 'good'); % Phy cluster IDs matching goodU columns + + % --- General response p-values (StatisticsPerNeuron) --- + % These are always extracted: used directly in standard mode, + % or as a fallback when useGeneralFilter = true in category mode. + try + pvals = Stats.(fieldName).pvalsResponse; % stim-specific p-values + catch + pvals = Stats.pvalsResponse; % flat struct fallback + end + + % --- Stimulus onset times and condition matrix --- + try + C = NeuronResp.(fieldName).C; % condition matrix (sub-field) + catch + C = NeuronResp.C; % condition matrix (flat) + end + directimesSorted = C(:, 1)' + startStim; % onset times in ms + + % ============================================================= + % 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" && ... + 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 --- + 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. Since catColNames is + % already colNames{1}(5:end), catIdx is a 1-based index + % into the parameter names. 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) + % ========================================================== + % When useGeneralFilter is false (default), we use per-level + % p-values from StatisticsPerNeuronPerCategory to filter + % neurons. A neuron passes if it is significant for at + % least one level (OR across levels). This mirrors the + % AllExpAnalysis Mode 2 convention and is methodologically + % correct: neurons should be responsive to the specific + % category being split, not just to the stimulus in general. + if ~params.useGeneralFilter + 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; + else + rawStimDur_ms = params.postStim; % fixed window + windowTotal = preBase + params.postStim; + 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 + fprintf(' [%s] Locked window: preBase=%d ms, stimDur=%.0f ms, nBins=%d\n', ... + stimType, lockedPreBase, rawStimDur_ms, lockedNBins(s)); + end + + % --- Find responsive neurons --- + % In category mode with per-level filtering (default), use + % the OR mask from StatisticsPerNeuronPerCategory. + % In all other cases, use general StatisticsPerNeuron p-values. + if isCatMode && ~params.useGeneralFilter + eNeurons = find(orMask); % per-category OR filter + else + eNeurons = find(pvals < params.alpha); % general filter + 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. In standard mode bMean/bStd + % are left empty and computed later from the (single) PSTH. + bMean = []; % sentinel: compute later in standard mode + bStd = []; + + if isCatMode && params.zScore + + % Build all-trials spike matrix (just for baseline) + 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 = squeeze(MRall); % remove singleton dims + + % Build all-trials PSTH (only baseline portion matters) + nTrialsAll = size(MRall, 1); % total number of trials + spikeTimesAll = repmat((1:size(MRall,2)), nTrialsAll, 1); % column indices + spikeTimesAll = spikeTimesAll(logical(MRall)); % keep spike positions only + countsAll = histcounts(spikeTimesAll, lockedEdges{s}); % spike count per bin + allTrialPSTH = (countsAll / (params.binWidth * nTrialsAll)) * 1000; % spk/s + + % Extract baseline stats from the all-trials PSTH + baselineBins = tAxis{s} < lockedPreBase; % pre-stimulus bin mask + bMean = mean(allTrialPSTH(baselineBins)); % all-trial baseline mean + bStd = std(allTrialPSTH(baselineBins)); % all-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; + 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 --- + % Use ALL trials (before TakeTopPercentTrials) for a + % stable preference estimate. + 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) + 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 + baselineBins = tAxis{s} < lockedPreBase; % mask for baseline bins + + if isempty(bMean) + % Standard mode: compute baseline from this PSTH + bMean = mean(neuronPSTH(baselineBins)); % baseline mean + bStd = std(neuronPSTH(baselineBins)); % baseline SD + end + % Category mode: bMean/bStd already set from all-trials + % PSTH 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; + continue % skip: undefined z-score + end + end + + % --- Smooth PSTH if requested --- + if params.smooth > 0 + smoothBins = round(params.smooth / params.binWidth); % smoothing SD in bin units + neuronPSTH = smoothdata(neuronPSTH, 'gaussian', smoothBins); % 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) + 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 + + % 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); + 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.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 (NEW) + 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 + + 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)); % NEW + + 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" + 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 + 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 --- + % Auto-construction tries both stim orderings because the on-disk + % filename may have been saved with stimTypes in a different order. + % The data inside is the same (rows are filtered by the stimulus column). + 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. + % double(categorical) returns category indices, not values — must go + % through string first to recover the original numeric values. + 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 + + % Pre-filter to this stimulus for speed. + % Use abbrevToLegacyNames so matching works whether the tuning + % table was built with abbreviations or legacy full names. + 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 + % Match by experiment AND Phy cluster ID + 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 +% ========================================================================= +% Also store level-group boundaries for plotting (preferredCategory mode) +levelGroupBounds = cell(1, numel(params.stimTypes)); % {s}: struct with edges and labels + +for s = 1:numel(params.stimTypes) + + data = rasterAll{s}; % nNeurons x nBins + if isempty(data); continue; end + + if params.sortBy == "peak" + % --- Sort by peak-response latency --- + postStimBins = tAxis{s} >= lockedPreBase; % post-onset mask + + % Local smoothed copy for peak detection only (avoids double-smoothing) + if size(data, 2) > 100 + dataForSort = ConvBurstMatrix( ... + data, fspecial('gaussian', [1 params.GaussianLength+20], 2), 'same'); % wider kernel for long windows + else + dataForSort = ConvBurstMatrix( ... + data, fspecial('gaussian', [1 params.GaussianLength], 2), 'same'); % narrow kernel for short windows + end + + [~, peakBin] = max(dataForSort(:, postStimBins), [], 2); % peak column per neuron + [~, sortIdx] = sort(peakBin); % early-peaking first + + elseif params.sortBy == "depth" + % --- Sort by recording depth --- + [~, sortIdx] = sort(depthAll{s}, 'ascend'); % shallowest first + + elseif params.sortBy == "spatialTuning" + % --- Sort by spatial-tuning index --- + % MissingPlacement='last' sends NaN (unmatched) neurons to the bottom + [~, sortIdx] = sort(tuningAll{s}, params.tuningSortOrder, ... + 'MissingPlacement', 'last'); % most-tuned first (default) + + elseif params.sortBy == "preferredCategory" + % --- Sort by preferred category level, then by response strength --- + % If this stim slot has no category assigned (splitCategory(s) == ""), + % fall through to unsorted (identity permutation). + if strlength(params.splitCategory(s)) == 0 + sortIdx = 1:size(data, 1); % no category for this stim: no reorder + + else + % Primary: group neurons by preferred level (ascending level value) + % Secondary: within each group, sort by mean post-onset response + % (descending, so strongest responder at top of group) + + levels = prefLevelAll{s}; % preferred level per neuron + nNeurons = size(data, 1); % total neurons for this stim + + % Compute mean post-onset response for secondary sort + postStimBins = tAxis{s} >= lockedPreBase; % post-onset mask (same as in peak sort) + meanPostResp = mean(data(:, postStimBins), 2)'; % 1 x nNeurons: mean post-onset value + + % Get unique levels present (excluding NaN, which shouldn't occur + % here but is handled defensively) + uniqueLevels = unique(levels(~isnan(levels))); % sorted ascending by default + + % Build sort index: iterate levels in order, within each group + % sort by response strength + sortIdx = zeros(1, nNeurons); % pre-allocate + cursor = 0; % running position counter + 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 within group + nGroup = numel(groupIndices); % neurons in this group + + sortIdx(cursor+1 : cursor+nGroup) = groupIndices(withinOrder); % fill sorted indices + + % Record group boundary for plotting + groupInfo.edges(end+1) = cursor + nGroup; % row index of last neuron in group + groupInfo.labels{end+1} = sprintf('%s=%g', ... + params.splitCategory(s), uniqueLevels(li)); % label: e.g. "direction=0" + + cursor = cursor + nGroup; % advance cursor + end + + % Handle any NaN-level neurons (shouldn't happen, but defensive) + nanMask = isnan(levels); + if any(nanMask) + nanIndices = find(nanMask); % indices of NaN neurons + nNan = numel(nanIndices); + sortIdx(cursor+1 : cursor+nNan) = nanIndices; % append at end + groupInfo.edges(end+1) = cursor + nNan; + groupInfo.labels{end+1} = 'unclassified'; + cursor = cursor + nNan; + end + + levelGroupBounds{s} = groupInfo; % store for plotting + end + + else + % --- No reordering --- + sortIdx = 1:size(data, 1); % identity permutation + end + + % Apply sort to all parallel vectors + rasterAll{s} = data(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 + + if params.sortBy == "spatialTuning" + tuningAll{s} = tuningAll{s}(sortIdx); % keep tuning vector aligned + end + +end + +% ========================================================================= +% PLOT +% ========================================================================= + +% Short display labels for each stimulus type (abbreviations are already +% concise, but the map allows custom labels if desired). +stimLegendMap = containers.Map( ... + {'RG', 'MB', 'MBR', 'SDGs', 'SDGm', 'NI', 'NV', 'FFF'}, ... + {'RG', 'MB', 'MBR', 'SDGs', 'SDGm', '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}) + allValues = [allValues, rasterAll{s}(:)']; %#ok % pool all 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; +set(fig, 'Units', 'centimeters', 'Position', [5 5 5*nStim + 2, 10]); % width scales with tile count + +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 + + % ------------------------------------------------------------------ + % Depth-bin boundary lines (depth sort only) + % ------------------------------------------------------------------ + if params.sortBy == "depth" && ~isempty(depthAll{s}) + + D2 = load(depthFile); % reload bin edges + 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 + + % ------------------------------------------------------------------ + % Preferred-category group boundary lines and labels (NEW) + % ------------------------------------------------------------------ + if params.sortBy == "preferredCategory" && ~isempty(levelGroupBounds{s}) + + 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; + % Level value + count only, e.g. "129 (n=15)" + rawLabel = gInfo.labels{gi}; % e.g. "size=129" + levelVal = extractAfter(rawLabel, '='); % e.g. "129" + ytLabels{gi} = sprintf('%s (n=%d)', levelVal, nInGroup); + + if gi < nGroups + yline(ax, edgeRow + 0.5, 'k-', 'LineWidth', 1.5); + end + + prevEdge = edgeRow; + end + + set(ax, 'YTick', ytPositions, 'YTickLabel', ytLabels, ... + 'TickLength', [0 0]); + end + + % --- Stim onset / offset lines (seconds) --- + xline(ax, 0, 'k--', 'LineWidth', 1.0); % onset at t = 0 + xline(ax, stimDurAll(s)/1000, 'k--', 'LineWidth', 1.0); % offset in seconds + + % --- 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; +set(fig, 'Units', 'centimeters', 'Position', [20 20 9 12]); % final figure position/size + +sgtitle(sprintf('N = %d experiments', numel(exList)), ... + 'FontName', 'helvetica', 'FontSize', 10); % super-title + +if params.PaperFig + vs_first.printFig(fig, sprintf('Raster-%s-%s', stimLabel,params.splitCategory), PaperFig=params.PaperFig); % export for publication +end + +end + +% ========================================================================= +% LOCAL HELPER FUNCTIONS +% ========================================================================= + +function gt = detectGratingType(stimKey) +% detectGratingType Return the GratingType parameter from a stimulus +% abbreviation. 'moving' for SDGm, 'static' for SDGs, '' otherwise. + switch stimKey + case "SDGm"; gt = 'moving'; % moving (drifting) grating + case "SDGs"; gt = 'static'; % static grating + 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") +% 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" + fn = 'Static'; % static grating sub-field + 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. +% Includes both the abbreviation itself and any legacy full names, so that +% matching works regardless of which convention the table was built with. + 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 "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 diff --git a/visualStimulationAnalysis/plotSpatialTuningIndex.m b/visualStimulationAnalysis/plotSpatialTuningIndex.m new file mode 100644 index 0000000..b163552 --- /dev/null +++ b/visualStimulationAnalysis/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